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

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("&lt;", "<")
    .replaceAll("&gt;", ">")
    .replaceAll("&quot;", '"')
    .replaceAll("&apos;", "'")
    .replaceAll("&amp;", "&");
}

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.