Official

claude

Claude Code agent provider: runs the Claude Code CLI headless for each account.

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/claude@0.2.0

Permissions in 0.2.0

  • Provide agents agents.provideMediumAdds agents to the app.Provide the Claude Code agent and pass its tool calls to plugin tools
  • Run named programs processMediumStarts the listed programs.Run the Claude Code CLI (sessions, sign-in, `claude update`), and ask the npm installation that owns it about a newer versionPrograms: claudenpm
  • Read files fs.readMediumReads files in the listed places.Read Claude Code's sessions, settings and skills (in ~/.claude, other accounts' ~/.claude-* folders, the shared skills and the folder you choose in Settings), the project's .claude folder, the administrator's skill policy, and once, what the previous provider keptPlaces: ~/.claude/**~/.claude*/**~/.agents/skills/**the open workspaceits own data folder/Library/Application Support/ClaudeCode/managed-settings.json/etc/claude-code/managed-settings.json${settings.configDir}
  • Environment variables envMediumReads the listed environment variables.Find Claude Code's configuration directory, and the launch settings you set in CLAUDE_* variables (extra arguments, MCP servers, setting sources, appended system prompt, extra folders)Variables: HOMECLAUDE_CONFIG_DIRCLAUDE_EXTRA_ARGSCLAUDE_MCP_CONFIGCLAUDE_STRICT_MCP_CONFIGCLAUDE_SETTING_SOURCESCLAUDE_APPEND_SYSTEM_PROMPTCLAUDE_ADD_DIRS

Files

workflow.test.ts11.4 KB
// Ported from the Rust plugin's workflow.rs tests, over the recorded run in
// `testdata/` (CLI 2.1.281: two phases, three agents).
import assert from "node:assert/strict";
import {
  eventAs,
  required,
  itemAs,
  test,
  run as runEffect,
  WF,
  RUN,
  fixture,
  streamLines,
  tempDir,
  files,
} from "./testing.ts";
import type { Emission, TranscriptItem } from "./types.ts";
import type { TaskInfo } from "convergence/protocol";
import { writeFileSync, mkdirSync, appendFileSync } from "node:fs";
import { join } from "node:path";
import { Mapper } from "./map.ts";
import { Tail, agentStatus, attachWorkflows, unframe } from "./workflow.ts";
import { recordsToItems } from "./records.ts";

const RESULT = { a: "hello", b: "world", combined: "hello world" };
const agentRecords = () =>
  fixture("workflow_agent.jsonl")
    .split("\n")
    .filter((line) => line.trim())
    .map((line) => JSON.parse(line));
const tasks = (events: Emission[]) => events.map((event) => event.kind).filter((kind) => kind.event === "task");
function last(list: TaskInfo[], id: string) {
  for (let at = list.length - 1; at >= 0; at -= 1) if (list[at].id === id) return list[at];
  assert.fail(`no task ${id}`);
}
const child = (index: number) => `${WF}/${index}`;

function mapped(dir: string, output: string) {
  const mapper = new Mapper();
  const events: Emission[] = [];
  for (const line of streamLines(dir, output)) events.push(...mapper.handle(JSON.parse(line)));
  return { mapper, events };
}

test("a workflow is a task whose agents are its children", () => {
  const dir = tempDir();
  const output = join(dir, "wy6a0h7qg.output");
  const { mapper, events } = mapped(dir, output);
  const all = tasks(events);
  let run = last(all, WF);
  assert.equal(run.name, "probe");
  assert.equal(run.title, "Read a.txt and b.txt in parallel, then join the two words");
  assert.equal(run.background, true, "a workflow always runs in the background");
  assert.equal(run.status, "completed");
  assert.ok(required(run.prompt).startsWith("export const meta"), "the script");
  assert.deepEqual(
    required(required(run.workflow).phases).map((phase) => phase.title),
    ["Read", "Combine"],
  );
  assert.equal(run.summary, undefined, "the notification's status line is not the result");
  assert.equal(required(run.usage).usedTokens, 37969);

  const readA = last(all, child(1));
  assert.equal(readA.title, "read:a");
  assert.equal(readA.phase, "Read");
  assert.equal(readA.parentTaskId, WF);
  assert.equal(readA.status, "completed");
  assert.equal(readA.summary, "hello");
  assert.equal(readA.model, "claude-haiku-4-5-20251001");
  assert.equal(required(readA.usage).usedTokens, 13062);
  assert.ok(required(readA.endedAt) > required(readA.startedAt));
  const combine = last(all, child(3));
  assert.deepEqual([combine.phase, combine.summary], ["Combine", "hello world"]);

  // The second agent waited for a slot, then ran.
  const states = all.filter((task) => task.id === child(2)).map((task) => task.status);
  assert.equal(states[0], "queued");
  assert.ok(states.includes("running"));
  // Each agent's first event comes after the workflow's.
  assert.ok(all.findIndex((task) => task.parentTaskId === WF) > all.findIndex((task) => task.id === WF));
  // The task stands in for the call, which the UI then hides.
  assert.ok(events.some((event) => event.kind.event === "tool_call_started" && event.kind.id === WF));
  assert.equal(run.toolCallId, WF);

  const follows = mapper.workflowTranscripts();
  assert.equal(follows.length, 3);
  assert.ok(
    follows.some((follow) => follow.task === child(1) && follow.path === join(dir, "agent-a732ac4a9df356033.jsonl")),
  );
  assert.ok(mapper.isWorkflowAgent(child(1)));
  assert.ok(!mapper.isWorkflowAgent(WF));

  assert.deepEqual(mapper.takeWorkflowOutputs(), [[WF, output, RUN]]);
  assert.deepEqual(mapper.takeWorkflowOutputs(), [], "each record is read once");
  run = last(tasks(mapper.workflowOutput(WF, JSON.parse(fixture("workflow_record.json")))), WF);
  assert.deepEqual(required(run.workflow).result, RESULT);
  assert.equal(run.summary, JSON.stringify(RESULT));
  mapper.drained(WF);
  assert.deepEqual(mapper.workflowTranscripts(), [], "a drained run is read no more");
});

/// The agents' messages never reach the stream; their files do, and they
/// read as if the CLI had forwarded them.
test("an agent's transcript file becomes its task's items", () => {
  const dir = tempDir();
  // A long prompt reaches the snapshot cut short.
  const full = "Read /private/tmp/wf-probe/a.txt with the Read tool. Return only its content.";
  const mapper = new Mapper();
  for (const line of streamLines(dir, join(dir, "out"))) {
    mapper.handle(JSON.parse(line.replaceAll(`"promptPreview":"${full}"`, '"promptPreview":"Read /private/tmp…"')));
  }
  const events = mapper.workflowRecords(child(1), agentRecords());
  assert.equal(last(tasks(events), child(1)).prompt, full);
  const read = events.find((event) => event.kind.event === "tool_call_started" && event.kind.name === "Read");
  assert.equal(required(read).task, child(1));
  assert.ok(
    events.some(
      (event) =>
        event.kind.event === "tool_call_updated" &&
        event.kind.id === eventAs(required(read).kind, "tool_call_started").id &&
        event.kind.status === "completed",
    ),
  );
  assert.ok(
    events.some(
      (event) => event.task === child(1) && event.kind.event === "text_delta" && event.kind.text.trim() === "hello",
    ),
  );
  assert.ok(
    events.every((event) => event.task !== null || event.kind.event === "task"),
    "nothing of the agent lands in the main conversation",
  );
});

test("a tail reads whole lines as they arrive", async () => {
  const dir = tempDir();
  const path = join(dir, "agent-x.jsonl");
  const tail = new Tail(files(), path);
  assert.deepEqual(await runEffect(tail.read()), [], "no file yet");
  writeFileSync(path, '{"a":1}\n{"b":');
  assert.deepEqual(await runEffect(tail.read()), [{ a: 1 }], "the half-written line waits");
  appendFileSync(path, "2}\n");
  assert.deepEqual(await runEffect(tail.read()), [{ b: 2 }]);
  assert.deepEqual(await runEffect(tail.read()), []);
});

test("agent states and prompts read as the CLI means them", () => {
  assert.equal(agentStatus({ state: "start" }), "queued");
  assert.equal(agentStatus({ state: "start", startedAt: 1 }), "running");
  assert.equal(agentStatus({ state: "progress", startedAt: 1 }), "running");
  assert.equal(agentStatus({ state: "done" }), "completed");
  assert.equal(agentStatus({ state: "error", blocked: true }), "failed");
  const framed =
    "[Workflow harness — computed task] … The computed task text follows:\n  line one\n    indented\n  line three";
  assert.equal(unframe(framed), "line one\n  indented\nline three");
  assert.equal(unframe("plain"), "plain");
});

/// The main conversation as the CLI stores it around a `Workflow` call.
function mainRecords() {
  return [
    {
      type: "assistant",
      uuid: "u1",
      message: {
        id: "m1",
        role: "assistant",
        content: [{ type: "tool_use", id: WF, name: "Workflow", input: { script: "export const meta = {}" } }],
      },
    },
    {
      type: "user",
      uuid: "u2",
      message: {
        role: "user",
        content: [{ type: "tool_result", tool_use_id: WF, content: "Workflow launched in background." }],
      },
      toolUseResult: {
        status: "async_launched",
        taskId: "wy6a0h7qg",
        taskType: "local_workflow",
        workflowName: "probe",
        runId: RUN,
        summary: "Read a.txt and b.txt in parallel, then join the two words",
      },
    },
    {
      type: "assistant",
      uuid: "u3",
      message: { id: "m2", role: "assistant", content: [{ type: "text", text: "It runs in the background." }] },
    },
  ];
}

function workflowAfterCall(items: TranscriptItem[]) {
  const at = items.findIndex((item) => item.role === "tool" && item.call.id === WF);
  assert.ok(at >= 0, "the Workflow call");
  assert.equal(items[at + 1].role, "task", "the workflow right after its call");
  return itemAs(items[at + 1], "task");
}

test("a stored workflow reads back with its agents and their transcripts", async () => {
  const dir = tempDir();
  const session = join(dir, "session");
  mkdirSync(join(session, "workflows"), { recursive: true });
  writeFileSync(join(session, "workflows", `${RUN}.json`), fixture("workflow_record.json"));
  const agents = join(session, "subagents", "workflows", RUN);
  mkdirSync(agents, { recursive: true });
  writeFileSync(join(agents, "agent-a732ac4a9df356033.jsonl"), fixture("workflow_agent.jsonl"));

  const main = mainRecords();
  const items = recordsToItems(main);
  await runEffect(attachWorkflows(files(), items, main, session));
  const { task: run, items: children } = workflowAfterCall(items);
  assert.equal(run.name, "probe");
  assert.equal(run.status, "completed");
  assert.equal(run.background, true);
  assert.equal(required(required(run.workflow).phases).length, 2);
  assert.deepEqual(required(run.workflow).result, RESULT);
  assert.equal(run.summary, JSON.stringify(RESULT));
  assert.equal(required(run.usage).usedTokens, 37969);
  assert.equal(run.toolUses, 2);
  assert.ok(required(run.endedAt) > required(run.startedAt));

  assert.equal(required(children).length, 3);
  const readA = required(children)[0];
  assert.deepEqual([itemAs(readA, "task").task.id, itemAs(readA, "task").task.title], [child(1), "read:a"]);
  assert.equal(itemAs(readA, "task").task.phase, "Read");
  assert.equal(
    itemAs(readA, "task").task.prompt,
    "Read /private/tmp/wf-probe/a.txt with the Read tool. Return only its content.",
  );
  assert.ok(required(itemAs(readA, "task").items).some((item) => item.role === "tool" && item.call.name === "Read"));
  assert.ok(
    required(itemAs(readA, "task").items).some((item) => item.role === "assistant" && item.text.trim() === "hello"),
  );
  const readB = required(children)[1];
  assert.equal(itemAs(readB, "task").task.summary, "world");
  assert.deepEqual(itemAs(readB, "task").items, [], "no file, no transcript, but the agent is still listed");
});

/// The process ended while the run went on: there is no record, only the
/// journal and the agents' meta files.
test("a workflow the process left unfinished reads back from its journal", async () => {
  const dir = tempDir();
  const session = join(dir, "session");
  const agents = join(session, "subagents", "workflows", RUN);
  mkdirSync(agents, { recursive: true });
  const journal = [
    { type: "launched" },
    { type: "started", agentId: "a1", label: "read:a", phase: "Read" },
    { type: "started", agentId: "a2", label: "read:b", phase: "Read" },
    { type: "result", agentId: "a1", result: "hello" },
  ];
  writeFileSync(join(agents, "journal.jsonl"), journal.map((line) => `${JSON.stringify(line)}\n`).join(""));
  writeFileSync(join(agents, "agent-a1.meta.json"), JSON.stringify({ description: "read:a", workflowPhase: "Read" }));
  writeFileSync(join(agents, "agent-a2.meta.json"), JSON.stringify({ description: "read:b", workflowPhase: "Read" }));

  const main = mainRecords();
  const items = recordsToItems(main);
  await runEffect(attachWorkflows(files(), items, main, session));
  const { task: run, items: children } = workflowAfterCall(items);
  assert.equal(run.status, "cancelled");
  assert.equal(required(required(run.workflow).phases).length, 1);
  assert.deepEqual(
    [itemAs(required(children)[0], "task").task.status, itemAs(required(children)[0], "task").task.summary],
    ["completed", "hello"],
  );
  assert.equal(itemAs(required(children)[1], "task").task.status, "cancelled", "it never returned");
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <3128.7 KB4 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.