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
history.test.ts14.9 KB
// Ported from the Rust plugin's history.rs and subagents.rs tests.
import * as wire from "./wire.ts";
import assert from "node:assert/strict";
import { itemAs, required, test, run as runEffect, files, tempDir } from "./testing.ts";
import type { TranscriptItem } from "./types.ts";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import {
encodeWorkspace,
listSessions,
readFile,
readSession,
rewindBefore,
rewindTarget,
userRecords,
} from "./history.ts";
function write(path: string, records: unknown[]) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
records.map((record) => (typeof record === "string" ? record : JSON.stringify(record))).join("\n"),
);
}
/// The CLI's own records of a background task's end and of a stop are not
/// the user's words: no prompt bubble for either.
test("the CLI's own records are not prompts", async () => {
const dir = tempDir();
const path = join(dir, "s.jsonl");
write(path, [
{ type: "user", uuid: "u1", message: { role: "user", content: [{ type: "text", text: "hello" }] } },
{
type: "user",
uuid: "u2",
origin: { kind: "task-notification" },
message: { role: "user", content: "<task-notification>\n<task-id>a1</task-id>\n</task-notification>" },
},
{
type: "user",
uuid: "u3",
message: { role: "user", content: [{ type: "text", text: "[Request interrupted by user for tool use]" }] },
},
]);
const items = await runEffect(readFile(files(), path));
assert.equal(items.length, 2);
assert.equal(items[0].role, "user");
assert.deepEqual(items[1], { id: "u3", role: "notice", text: "Stopped." });
});
test("the workspace encoding matches the CLI", () => {
assert.equal(encodeWorkspace("/private/tmp"), "-private-tmp");
assert.equal(encodeWorkspace("/private/tmp/pr.obe_dir/x"), "-private-tmp-pr-obe-dir-x");
});
test("a transcript becomes items, and the sessions are listed", async () => {
const home = tempDir();
const workspace = "/w/project";
write(join(home, "projects", encodeWorkspace(workspace), "s1.jsonl"), [
'{"type":"ai-title","aiTitle":"Rename the widget","sessionId":"s1"}',
'{"type":"user","uuid":"u1","timestamp":"2026-09-14T12:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"rename it"}]}}',
'{"type":"assistant","uuid":"a1","timestamp":"2026-09-14T12:00:01.000Z","message":{"content":[{"type":"thinking","thinking":"plan"},{"type":"text","text":"on it"},{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}',
'{"type":"user","uuid":"u2","timestamp":"2026-09-14T12:00:02.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"a.txt\\n"}]}}',
]);
const sessions = await runEffect(listSessions(files(), home, workspace));
assert.deepEqual(sessions, [
{
id: "s1",
title: "Rename the widget",
createdAt: "2026-09-14T12:00:00.000Z",
updatedAt: "2026-09-14T12:00:02.000Z",
},
]);
const items = await runEffect(readSession(files(), home, workspace, "s1"));
assert.equal(items[0].role, "user");
assert.equal(items[0].blocks.length, 1);
assert.deepEqual([items[1].role, itemAs(items[1], "reasoning").text], ["reasoning", "plan"]);
assert.deepEqual([items[2].role, itemAs(items[2], "assistant").text], ["assistant", "on it"]);
assert.equal(itemAs(items[3], "tool").call.status, "completed");
assert.equal(itemAs(items[3], "tool").call.kind, "execute");
assert.deepEqual(itemAs(items[3], "tool").call.content, [{ type: "terminal", command: "ls", output: "a.txt\n" }]);
assert.equal(items.length, 4, "the tool result updates the call instead of adding an item");
assert.deepEqual(await runEffect(listSessions(files(), home, "/w/none")), []);
});
/// The host names its own message ids, which never appear in the CLI's
/// file; the prompt is matched to its user record by position instead.
test("a prompt is found again by its position among user records", () => {
const records = [
{ type: "user", uuid: "u1", parentUuid: null, message: { role: "user", content: [{ type: "text", text: "one" }] } },
{
type: "assistant",
uuid: "a1",
parentUuid: "u1",
message: { role: "assistant", content: [{ type: "text", text: "ok" }] },
},
{
type: "user",
uuid: "side",
isSidechain: true,
parentUuid: "a1",
message: { role: "user", content: [{ type: "text", text: "sub" }] },
},
{ type: "user", uuid: "u2", parentUuid: "a1", message: { role: "user", content: [{ type: "text", text: "two" }] } },
];
const users = userRecords(records);
assert.equal(users.length, 2, "sidechain records are not the user's own messages");
assert.deepEqual(rewindBefore(users[1]), { at: "a1" });
// The first message has nothing before it: forgetting it means an empty
// conversation, not an error.
assert.deepEqual(rewindBefore(users[0]), { fresh: true });
});
/// `--resume-session-at` keeps the message it names, so rewinding to an
/// item means resuming at the item's parent.
test("rewinding resumes at the record before the item", async () => {
const home = tempDir();
const workspace = "/w/rewind";
write(join(home, "projects", encodeWorkspace(workspace), "s3.jsonl"), [
{ type: "user", uuid: "u1", parentUuid: null, message: { role: "user", content: [{ type: "text", text: "one" }] } },
{ type: "assistant", uuid: "a1", parentUuid: "u1", message: { content: [{ type: "text", text: "done" }] } },
{ type: "user", uuid: "u2", parentUuid: "a1", message: { role: "user", content: [{ type: "text", text: "two" }] } },
]);
assert.equal(await runEffect(rewindTarget(files(), home, workspace, "s3", "u2")), "a1");
// An assistant item's id carries its block index.
assert.equal(await runEffect(rewindTarget(files(), home, workspace, "s3", "a1:0")), "u1");
// Nothing comes before the first message: refused, not half done.
assert.equal(await runEffect(rewindTarget(files(), home, workspace, "s3", "u1")), null);
assert.equal(await runEffect(rewindTarget(files(), home, workspace, "s3", "missing")), null);
});
test("the title falls back to the first prompt", async () => {
const home = tempDir();
const workspace = "/w/other";
write(join(home, "projects", encodeWorkspace(workspace), "s2.jsonl"), [
'{"type":"user","uuid":"u1","timestamp":"2026-09-14T12:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"fix the build\\nplease"}]}}',
]);
assert.equal((await runEffect(listSessions(files(), home, workspace)))[0].title, "fix the build");
});
// --- stored subagents (subagents.rs) --------------------------------------------
const user = (uuid: string, parent: string | null, time: string, content: unknown) =>
wire.record.parse({ type: "user", uuid, parentUuid: parent, timestamp: time, message: { role: "user", content } });
const assistant = (uuid: string, parent: string | null, time: string, content: unknown) =>
wire.record.parse({
type: "assistant",
uuid,
parentUuid: parent,
timestamp: time,
message: { model: "claude-haiku-4-5", content },
});
const sidechain = (record: wire.Record, agent: string) => ({ ...record, isSidechain: true, agentId: agent });
const toolId = (item: TranscriptItem) => (item.role === "tool" ? item.call.id : null);
/// A redacted copy of the layout claude 2.1.280 writes: a foreground agent
/// that starts a nested one, and a background agent that reports through a
/// queued notification.
test("stored subagents come back as nested tasks", async () => {
const home = tempDir();
const main = join(home, "s1.jsonl");
const dir = join(home, "s1", "subagents");
write(main, [
user("m1", null, "2026-09-22T10:00:00Z", "look around"),
assistant("m2", "m1", "2026-09-22T10:00:01Z", [
{
type: "tool_use",
id: "toolu_A",
name: "Agent",
input: { description: "Survey the parser", prompt: "Read the parser.", subagent_type: "Explore" },
},
{
type: "tool_use",
id: "toolu_C",
name: "Agent",
input: { prompt: "\nWatch the build\nand report", run_in_background: true },
},
]),
{
type: "user",
uuid: "m3",
parentUuid: "m2",
timestamp: "2026-09-22T10:00:02Z",
toolUseResult: { isAsync: true, status: "async_launched", agentId: "ccc" },
message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_C", content: "Launched" }] },
},
{
type: "user",
uuid: "m4",
parentUuid: "m3",
timestamp: "2026-09-22T10:01:00Z",
toolUseResult: {
status: "completed",
agentId: "aaa",
totalTokens: 900,
totalToolUseCount: 2,
content: [{ type: "text", text: "The parser is fine." }],
},
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_A",
content: [
{ type: "text", text: "The parser is fine." },
{ type: "text", text: "agentId: aaa" },
],
},
],
},
},
{
type: "queue-operation",
operation: "enqueue",
timestamp: "2026-09-22T10:02:00Z",
content:
'<task-notification>\n<task-id>ccc</task-id>\n<status>completed</status>\n<summary>Agent "Watch" finished</summary>\n<result>Build is green & fast.</result>\n</task-notification>',
},
]);
write(join(dir, "agent-aaa.jsonl"), [
sidechain(user("a1", null, "2026-09-22T10:00:03Z", "Read the parser."), "aaa"),
sidechain({ type: "attachment", uuid: "a1x", parentUuid: "a1", attachment: {} }, "aaa"),
sidechain(
assistant("a2", "a1x", "2026-09-22T10:00:04Z", [
{ type: "text", text: "Starting." },
{ type: "tool_use", id: "toolu_B", name: "Agent", input: { description: "Check tokens", prompt: "Check." } },
]),
"aaa",
),
// An abandoned branch the chain must skip.
sidechain(assistant("a2dead", "a1", "2026-09-22T10:00:04Z", [{ type: "text", text: "dead" }]), "aaa"),
sidechain(
{
type: "user",
uuid: "a3",
parentUuid: "a2",
timestamp: "2026-09-22T10:00:30Z",
toolUseResult: { status: "completed", agentId: "bbb", content: [{ type: "text", text: "Tokens ok." }] },
message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_B", content: "Tokens ok." }] },
},
"aaa",
),
sidechain(assistant("a4", "a3", "2026-09-22T10:00:59Z", [{ type: "text", text: "The parser is fine." }]), "aaa"),
]);
write(join(dir, "agent-aaa.meta.json"), [
{
agentType: "Explore",
description: "Survey the parser",
toolUseId: "toolu_A",
spawnDepth: 1,
requestShape: "foreground",
},
]);
const nested = join(dir, "workflows", "agent-bbb.jsonl");
write(nested, [
sidechain(user("b1", null, "2026-09-22T10:00:05Z", "Check."), "bbb"),
sidechain(assistant("b2", "b1", "2026-09-22T10:00:06Z", [{ type: "text", text: "Tokens ok." }]), "bbb"),
]);
write(join(dir, "workflows", "agent-bbb.meta.json"), [
{
agentType: "general-purpose",
description: "Check tokens",
toolUseId: "toolu_B",
parentAgentId: "aaa",
spawnDepth: 2,
model: "claude-sonnet-5",
},
]);
write(join(dir, "agent-ccc.jsonl"), [
sidechain(user("c1", null, "2026-09-22T10:00:03Z", "Watch the build"), "ccc"),
sidechain(assistant("c2", "c1", "2026-09-22T10:01:50Z", [{ type: "text", text: "Green." }]), "ccc"),
]);
write(join(dir, "agent-ccc.meta.json"), [
{ agentType: "general-purpose", toolUseId: "toolu_C", spawnDepth: 1, requestShape: "background" },
]);
const items = await runEffect(readFile(files(), main));
const atA = items.findIndex((item) => toolId(item) === "toolu_A");
const atC = items.findIndex((item) => toolId(item) === "toolu_C");
assert.equal(items[atA + 1].id, "task-toolu_A");
assert.equal(items[atC + 1].id, "task-toolu_C");
const { task: a, items: aItems } = itemAs(items[atA + 1], "task");
assert.equal(a.id, "toolu_A");
assert.equal(a.toolCallId, "toolu_A");
assert.equal(a.title, "Survey the parser");
assert.equal(a.name, "Explore");
assert.equal(a.prompt, "Read the parser.");
assert.equal(a.model, "claude-haiku-4-5");
assert.equal(a.status, "completed");
assert.equal(a.background, undefined);
assert.equal(a.summary, "The parser is fine.");
assert.equal(required(a.usage).usedTokens, 900);
assert.equal(a.toolUses, 2);
assert.ok(a.startedAt && a.endedAt);
// Its own transcript, without the prompt record or the dead branch, and
// the nested agent right after the call that started it.
assert.deepEqual(
required(aItems)
.filter((item) => item.role === "assistant")
.map((item) => item.text),
["Starting.", "The parser is fine."],
);
assert.ok(required(aItems).every((item) => item.role !== "user"));
const atB = required(aItems).findIndex((item) => toolId(item) === "toolu_B");
const { task: b, items: bItems } = itemAs(required(aItems)[atB + 1], "task");
assert.equal(b.title, "Check tokens");
assert.equal(b.parentTaskId, "toolu_A");
assert.equal(b.summary, "Tokens ok.");
assert.equal(required(bItems).length, 1);
assert.ok(!items.some((item) => item.id === "task-toolu_B"), "a nested agent is not at the top level");
const { task: c, items: cItems } = itemAs(items[atC + 1], "task");
assert.equal(c.title, "Watch the build", "the prompt's first line when there is no description");
assert.equal(c.background, true);
assert.equal(c.status, "completed");
assert.equal(c.summary, "Build is green & fast.", "the notification's result, not its status line");
assert.equal(required(cItems).length, 1);
});
/// A notification that names several agents is a status line about all of
/// them; it sets their status and never their result.
test("a shared notification is not a result", async () => {
const home = tempDir();
const main = join(home, "s2.jsonl");
write(main, [
assistant("m1", null, "2026-09-22T10:00:00Z", [
{ type: "tool_use", id: "toolu_X", name: "Agent", input: { description: "X", run_in_background: true } },
]),
user(
"m2",
"m1",
"2026-09-22T10:05:00Z",
"<task-notification>\n<task-id>xxx</task-id>\n<task-id>yyy</task-id>\n<status>stopped</status>\n<summary>2 background agents didn't finish</summary>\n</task-notification>",
),
]);
const dir = join(home, "s2", "subagents");
write(join(dir, "agent-xxx.jsonl"), [
sidechain(user("x1", null, "2026-09-22T10:00:01Z", "X"), "xxx"),
sidechain(assistant("x2", "x1", "2026-09-22T10:00:02Z", [{ type: "text", text: "Half done." }]), "xxx"),
]);
write(join(dir, "agent-xxx.meta.json"), [{ agentType: "general-purpose", description: "X", toolUseId: "toolu_X" }]);
const items = await runEffect(readFile(files(), main));
const x = itemAs(required(items.find((item) => item.id === "task-toolu_X")), "task").task;
assert.equal(x.status, "cancelled");
assert.equal(x.summary, "Half done.");
});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.