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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 128.7 KB | 4 permissions | Listed |
No comments yet.