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
subagents.ts17.9 KB
// Subagent transcripts as the CLI stores them, rebuilt into task items (the
// Rust `subagents.rs`).
//
// The CLI writes each subagent to its own file under the session's folder:
// `<projects>/<workspace>/<session id>/subagents/**/agent-<agent id>.jsonl`,
// with a sibling `agent-<agent id>.meta.json` holding `agentType`,
// `description`, `toolUseId`, `parentAgentId`, `spawnDepth`,
// `requestShape` and `model`. The reading rule is the Agent SDK's: keep
// `user`, `assistant` and `attachment` records with a uuid, start from the
// last `user` or `assistant` record, follow `parentUuid` back to the root,
// and drop the attachments.
//
// What the files do not say (the prompt, how the subagent ended, its
// result, its spend) comes from the conversation that started it: the
// spawning `Agent` call and its result (`toolUseResult`, which names the
// `agentId`), and, for a background agent, the `<task-notification>` the
// CLI queued when it ended. A nested agent was started by another
// subagent, so those records are read from every file, not only the main
// one.
import * as wire from "./wire.ts";
import * as Effect from "effect/Effect";
import { parse } from "convergence/effect";
import type { Files } from "./files.ts";
import type { Task, TaskStatus, TranscriptItem } from "./types.ts";
import { join, mapLimit, readJson, readLines } from "./files.ts";
import { firstText, recordsToItems, taskItem, timestamp } from "./records.ts";
import { cleanTask } from "./task.ts";
import { firstLine, resultText } from "./tools.ts";
export interface Notified {
status?: TaskStatus | null;
summary?: string | null;
tokens?: number | null;
toolUses?: number | null;
background?: boolean;
}
interface Facts {
calls: Map<string, wire.Input | null>;
callOwner: Map<string, string>;
spawnedBy: Map<string, string>;
outcomes: Map<string, Notified>;
}
interface Stored {
parentAgent: string | null;
info: Task;
items: TranscriptItem[];
}
function text(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
}
const blocksOf = (record: wire.Record) => (Array.isArray(record?.message?.content) ? record.message.content : []);
/// Reads the subagents of the session whose folder is `sessionDir` and
/// puts each one, as a task item, right after the tool call that started
/// it: in the main conversation, or inside the subagent that started it.
export const attachSubagents = Effect.fn("Claude.attachSubagents")(function* (
files: Files,
items: TranscriptItem[],
main: wire.Record[],
sessionDir: string,
) {
const found = yield* agentFiles(files, join(sessionDir, "subagents"));
if (!found.length) return;
const read = yield* mapLimit(
found,
8,
Effect.fn(function* ([agentId, path]) {
const folder = path.slice(0, path.lastIndexOf("/"));
const meta = yield* parse(
"Claude subagent metadata",
wire.metadata,
(yield* readJson(files, join(folder, `agent-${agentId}.meta.json`))) ?? {},
);
const entry: [string, wire.Metadata, wire.Record[]] = [agentId, meta, yield* readLines(files, path)];
return entry;
}),
);
const facts = readFacts(main, read);
const agents = new Map<string, Stored>();
for (const [agentId, meta, records] of read) {
// The spawning tool result names the agent too, for a file with no
// meta beside it.
const toolUseId = text(meta.toolUseId) ?? facts.spawnedBy.get(agentId) ?? null;
if (toolUseId === null) continue;
const stored = storedAgent(toolUseId, meta, chain(records), facts, agentId);
if (stored.parentAgent === null) stored.parentAgent = facts.callOwner.get(toolUseId) ?? null;
agents.set(agentId, stored);
}
const toolOf = new Map([...agents].map(([agent, stored]) => [agent, stored.info.id]));
const children = new Map<string, string[]>();
const roots: string[] = [];
for (const [agent, stored] of agents) {
const parent = stored.parentAgent;
if (parent !== null && toolOf.has(parent) && parent !== agent) {
stored.info.parentTaskId = toolOf.get(parent);
if (!children.has(parent)) children.set(parent, []);
children.get(parent)!.push(agent); // Created just above.
} else {
roots.push(agent);
}
}
const order = (a: string, b: string) => {
const sa = agents.get(a)?.info.startedAt ?? "";
const sb = agents.get(b)?.info.startedAt ?? "";
if (sa !== sb) return sa < sb ? -1 : 1;
return a < b ? -1 : a > b ? 1 : 0;
};
roots.sort(order);
for (const list of children.values()) list.sort(order);
const visiting = new Set<string>();
for (const root of roots) {
const built = build(root, agents, children, visiting);
if (built) place(items, built[0], built[1]);
}
});
/// The task item of one agent, with its own subagents placed inside.
function build(agent: string, agents: Map<string, Stored>, children: Map<string, string[]>, visiting: Set<string>) {
if (visiting.has(agent)) return null;
visiting.add(agent);
const stored = agents.get(agent);
if (!stored) return null;
agents.delete(agent);
const items = stored.items;
for (const child of children.get(agent) ?? []) {
const built = build(child, agents, children, visiting);
if (built) place(items, built[0], built[1]);
}
return [stored.info.id, taskItem(cleanTask(stored.info), items)] as const;
}
/// Inserts `item` right after the tool call `tool`, or at the end when the
/// call is not in `items`.
function place(items: TranscriptItem[], tool: string, item: TranscriptItem) {
const at = items.findIndex((entry) => entry.role === "tool" && entry.call?.id === tool);
if (at >= 0) items.splice(at + 1, 0, item);
else items.push(item);
}
/// Every `agent-<id>.jsonl` below `dir`, at any depth, as `[agentId,
/// path]`. A workflow run's folder (`workflows/wf_<id>`) holds agents with
/// no spawning call of their own; `workflow.ts` places them.
const agentFiles = Effect.fn("Claude.agentFiles")(function* (files: Files, dir: string) {
const found: [string, string][] = [];
const pending = [dir];
while (pending.length) {
const folder = pending.pop()!; // The loop requires a nonempty stack.
const entries = yield* files.list(folder);
if (!entries) continue;
for (const entry of entries) {
const path = join(folder, entry.name);
if (entry.kind === "dir") {
if (!entry.name.startsWith("wf_")) pending.push(path);
continue;
}
const match = /^agent-(.+)\.jsonl$/.exec(entry.name);
if (match?.[1]) found.push([match[1], path]);
}
}
found.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : 1));
return found;
});
/// The conversation a subagent's file holds, by the SDK's rule.
export function chain(records: wire.Record[]) {
const kept = records.filter(
(record) => ["user", "assistant", "attachment"].includes(record.type ?? "") && typeof record.uuid === "string",
);
const index = new Map(kept.map((record, at) => [record.uuid, at]));
let last = -1;
// Indices below come only from kept's bounds or its own index map.
for (let at = kept.length - 1; at >= 0; at -= 1) {
if (kept[at]!.type === "user" || kept[at]!.type === "assistant") {
last = at;
break;
}
}
if (last < 0) return [];
const order = [];
const seen = new Set();
let at: number | undefined = last;
while (at !== undefined && at !== null) {
if (seen.has(at)) break;
seen.add(at);
order.push(at);
const parent: string | null | undefined = kept[at]!.parentUuid;
at = typeof parent === "string" ? index.get(parent) : undefined;
}
return order
.reverse()
.map((at) => kept[at]!)
.filter((record) => record.type !== "attachment");
}
function storedAgent(toolUseId: string, meta: wire.Metadata, records: wire.Record[], facts: Facts, agentId: string) {
const call = facts.calls.get(toolUseId) ?? null;
const outcome = facts.outcomes.get(agentId) ?? {};
// The first record is the prompt the subagent was given: the task's
// `prompt`, not a message the user wrote.
const first = records[0];
const opening =
first?.type === "user" &&
!(Array.isArray(first.message?.content) && first.message.content.some((block) => block?.type === "tool_result"));
const prompt = text(call?.prompt) ?? (opening ? firstText(first.message?.content) : null);
const startedAt = records.length ? timestamp(records[0]) : null;
const endedAt = records.length ? timestamp(records[records.length - 1]) : null;
let model = null;
for (const record of records) {
if (record.type !== "assistant") continue;
const name = text(record.message?.model);
if (name && !name.startsWith("<")) {
model = name;
break;
}
}
model ??= text(meta.model);
// The result is what the agent said last, unless its caller recorded it.
let lastText = null;
for (let at = records.length - 1; at >= 0; at -= 1) {
const record = records[at]!; // The indexed loop stays within records.
if (record.type !== "assistant" || !Array.isArray(record.message?.content)) continue;
const joined = record.message.content
.filter((block) => block?.type === "text" && typeof block.text === "string")
.map((block) => block.text)
.join("\n");
if (joined.trim()) {
lastText = joined;
break;
}
}
const body = opening ? records.slice(1) : records;
const title =
text(meta.description) ?? text(call?.description) ?? (prompt !== null ? firstLine(prompt) : null) ?? "Subagent";
const info: Task = {
id: toolUseId,
title,
status: outcome.status ?? "completed",
toolCallId: toolUseId,
name: text(meta.agentType) ?? text(call?.subagent_type),
model,
prompt,
background: outcome.background === true || meta.requestShape === "background" || call?.run_in_background === true,
summary: outcome.summary ?? (lastText !== null ? lastText.trim() : null),
startedAt,
endedAt,
usage: typeof outcome.tokens === "number" ? { usedTokens: outcome.tokens } : null,
toolUses: outcome.toolUses ?? null,
};
return { parentAgent: text(meta.parentAgentId), info, items: recordsToItems(body) };
}
/// What the whole session (main conversation and every subagent file)
/// says about its subagents.
function readFacts(main: wire.Record[], files: [string, wire.Metadata, wire.Record[]][]) {
const facts: Facts = { calls: new Map(), callOwner: new Map(), spawnedBy: new Map(), outcomes: new Map() };
const all: [string | null, wire.Record][] = [
...main.map((record): [null, wire.Record] => [null, record]),
...files.flatMap(([agent, , records]) => records.map((record): [string, wire.Record] => [agent, record])),
];
// The spawning calls first: a result only counts when it answers one, so
// a `SendMessage` to an agent does not pass for its start.
for (const [agent, record] of all) {
if (record?.type !== "assistant") continue;
for (const block of blocksOf(record)) {
if (block?.type === "tool_use" && (block.name === "Agent" || block.name === "Task")) {
const id = text(block.id);
if (id === null) continue;
if (agent !== null) facts.callOwner.set(id, agent);
facts.calls.set(id, block.input ?? null);
}
}
}
const outcome = (agent: string): Notified => {
if (!facts.outcomes.has(agent)) facts.outcomes.set(agent, {});
return facts.outcomes.get(agent)!; // Created immediately above.
};
for (const [, record] of all) {
spawnResult(facts, record, outcome);
// A background agent's end, queued for the agent that started it. The
// same text is recorded more than once (queued, then delivered), and
// an agent given more work notifies again: the last one wins.
for (const body of notificationTexts(record)) {
for (const notification of notifications(body)) {
// One notification can name several agents (the ones a previous
// process left unfinished). Its `summary` is a status line, never
// the answer: the answer is `result`, else what the agent said
// last.
const agents = tags(notification, "task-id");
const single = agents.length === 1;
const status = statusOf(tag(notification, "status"));
for (const agent of agents) {
const found = outcome(agent);
found.status = status ?? found.status;
if (!single) continue;
const result = tag(notification, "result") ?? (status === "failed" ? tag(notification, "summary") : null);
found.summary = result !== null ? unescape(result) : null;
const tokens = Number.parseInt(tag(notification, "subagent_tokens") ?? "", 10);
if (Number.isFinite(tokens)) found.tokens = tokens;
const tools = Number.parseInt(tag(notification, "tool_uses") ?? "", 10);
if (Number.isFinite(tools)) found.toolUses = tools;
}
}
}
}
return facts;
}
/// The spawning tool's own result: the whole answer of a foreground agent,
/// or the note that a background one was launched.
function spawnResult(facts: Facts, record: wire.Record, outcomeOf: (id: string) => Notified) {
const result = record?.toolUseResult;
if (!result || typeof result !== "object" || result.agentId === undefined) return;
const block = blocksOf(record).find(
(block) =>
block?.type === "tool_result" && typeof block.tool_use_id === "string" && facts.calls.has(block.tool_use_id),
);
if (!block) return;
const tool = text(block.tool_use_id);
const agent = text(result.agentId);
if (tool === null || agent === null) return;
facts.spawnedBy.set(agent, tool);
const outcome = outcomeOf(agent);
if (result.isAsync === true || result.status === "async_launched") {
outcome.background = true;
return;
}
outcome.status = block.is_error === true ? "failed" : (statusOf(result.status) ?? "completed");
outcome.summary = spawnAnswer(result, block.content ?? null);
outcome.tokens =
typeof result.totalTokens === "number" && Number.isInteger(result.totalTokens) ? result.totalTokens : null;
outcome.toolUses =
typeof result.totalToolUseCount === "number" && Number.isInteger(result.totalToolUseCount)
? result.totalToolUseCount
: null;
}
/// The final answer of a foreground agent: the structured `content` of its
/// tool result, which holds the answer alone, else the result text (which
/// also carries the CLI's `agentId` trailer).
export function spawnAnswer(structured: wire.Structured | null, content: unknown) {
let answer = structured && structured.content !== undefined ? resultText(structured.content) : "";
if (!answer.trim()) answer = resultText(content);
answer = answer.trim();
return answer || null;
}
/// Every task the main conversation was notified about, by the CLI's task
/// id: `{ status, summary, tokens, toolUses }`. The last one wins.
export function notifiedOutcomes(main: wire.Record[]) {
const found = new Map<string, Notified>();
for (const record of main) {
for (const body of notificationTexts(record)) {
for (const notification of notifications(body)) {
const ids = tags(notification, "task-id");
if (ids.length !== 1) continue;
const status = statusOf(tag(notification, "status"));
const result = tag(notification, "result") ?? (status === "failed" ? tag(notification, "summary") : null);
const tokens = Number.parseInt(tag(notification, "subagent_tokens") ?? "", 10);
const tools = Number.parseInt(tag(notification, "tool_uses") ?? "", 10);
found.set(ids[0]!, {
// The length check above proves one id exists.
status,
summary: result !== null ? unescape(result) : null,
tokens: Number.isFinite(tokens) ? tokens : null,
toolUses: Number.isFinite(tools) ? tools : null,
});
}
}
}
return found;
}
function statusOf(status: unknown) {
switch (status) {
case "completed":
case "success":
return "completed";
case "failed":
case "error":
return "failed";
case "stopped":
case "killed":
case "cancelled":
return "cancelled";
default:
return null;
}
}
/// The places a record can carry a queued `<task-notification>`.
function notificationTexts(record: wire.Record) {
const texts = [];
if (record?.type === "queue-operation" && record.operation === "enqueue" && typeof record.content === "string")
texts.push(record.content);
if (typeof record?.attachment?.prompt === "string") texts.push(record.attachment.prompt);
if (record?.type === "user") {
const content = record.message?.content;
if (typeof content === "string") texts.push(content);
else if (Array.isArray(content))
for (const block of content) if (typeof block?.text === "string") texts.push(block.text);
}
return texts.filter((body) => body.includes("<task-notification>"));
}
function notifications(body: string) {
const found = [];
let rest = body;
for (;;) {
const start = rest.indexOf("<task-notification>");
if (start < 0) break;
const inner = rest.slice(start + "<task-notification>".length);
const end = inner.indexOf("</task-notification>");
found.push(end >= 0 ? inner.slice(0, end) : inner);
rest = end >= 0 ? inner.slice(end) : "";
}
return found;
}
/// A notification escapes the text it carries as XML does.
function unescape(body: string) {
return body
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll(""", '"')
.replaceAll("'", "'")
.replaceAll("&", "&");
}
function tag(body: string, name: string) {
return tags(body, name)[0] ?? null;
}
/// The text of every `<name>…</name>` in a notification.
function tags(body: string, name: string) {
const open = `<${name}>`;
const close = `</${name}>`;
const found = [];
let rest = body;
for (;;) {
const start = rest.indexOf(open);
if (start < 0) break;
const inner = rest.slice(start + open.length);
const end = inner.indexOf(close);
if (end < 0) break;
const value = inner.slice(0, end).trim();
if (value) found.push(value);
rest = inner.slice(end + close.length);
}
return found;
}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.