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

NOTES.md41.1 KB
# Claude Code provider plugin — notes

Agent id `claude` (and `claude:<id>` per extra account), plugin
`convergence/claude`.

The plugin is sandboxed TypeScript (`"main": "main.ts"`), with Effect v4
for broker calls, listeners, timers and scoped process cleanup. It speaks the
Claude Code CLI's headless `stream-json` protocol directly: it does not use
Node or the Agent SDK. It starts `claude` through the broker
(`api.process`), reads the CLI's own records through `api.fs`, and emits
agent events (`plugins/docs/agent-protocol.md`). The wire format was read
out of the CLI's own embedded schemas and verified against the installed
CLI (2.1.271 to 2.1.282). It replaced a Rust program, deleted with the
other native providers; what that program kept is read once from
`legacy/` (`legacy.ts`).

## Files

| File | Content |
| --- | --- |
| `main.ts` | `activate`: one agent per configured account |
| `instances.ts` | the accounts (plugin storage key `instances`) |
| `legacy.ts` | the Rust plugin's `instances.json` and `sessions/*.json`, read once into storage |
| `agent.ts` | `ClaudeAgent`: every agent method, the catalog probe, restarts for rewind and fork |
| `session.ts` | one `claude` process per session: launch arguments, the control protocol, approvals, questions, plugin tools, cancel, workflows followed live |
| `map.ts` | `stream-json` output → agent events, including subagents and workflows |
| `options.ts` | model / effort / approval / toggle options |
| `models.ts`, `models.json` | the model list: versioned names, effort levels, 1M context (`models.json` is imported with `with { type: "json" }`) |
| `config.ts` | configuration directory, the child's environment, the launch knobs |
| `account.ts` | `claude auth status`, installer ownership, `claude update` |
| `skills.ts` | `SKILL.md` discovery and the `skillOverrides` setting |
| `limits.ts` | rate limits → `UsageLimits` |
| `tools.ts` | tool classification, diffs (the SDK's `unifiedDiff`), terminal output |
| `records.ts` | stored transcript records → transcript items |
| `history.ts` | `<config dir>/projects/**.jsonl`: session list, transcripts, rewind points |
| `subagents.ts` | stored subagent transcripts (`<session>/subagents/**`) rebuilt into task items |
| `workflow.ts` | dynamic workflows: agent snapshots, the agents' transcript files (`Tail`), stored runs |
| `task.ts` | subagent snapshots: cleaning, comparing, copying |
| `state.ts` | the per-session state in plugin storage (CLI id alias, rewind map) |
| `files.ts` | reads through `api.fs`, `null` for a missing or ungranted file |
| `wire.ts` | Zod schemas for CLI streams, records and workflow snapshots |
| `types.ts`, `errors.ts` | protocol outputs, mutable task state and tagged provider errors |
| `transport.ts` | SDK line transport acquired and stopped in the provider scope |
| `icon.ts` | `icon.svg` as a string |
| `testing.ts`, `*.test.ts`, `testdata/` | `node --test`, with the recorded workflow run |

## Permissions

| Grant | Why |
| --- | --- |
| `agents.provide` | serve the agents and pass their tool calls to plugin tools |
| `process`: `claude`, `npm` | run the CLI (sessions, `--version`, `auth status/login/logout`, `update`); ask the npm installation that owns it for a newer version |
| `fs.read`: `~/.claude/**` | the stored sessions, subagent and workflow transcripts (read as they grow), `settings.json` (workflow switches, `skillOverrides`) and the user's skills |
| `fs.read`: `~/.agents/skills/**` | the shared skills a skill folder in `~/.claude/skills` links to (the skill installers keep them there): the broker follows a link and checks where it lands, so a link out of `~/.claude` needs a grant for its target |
| `fs.read`: `workspace` | the project's `.claude/skills` and `.claude/settings*.json` |
| `fs.read`: `data` | once, the Rust plugin's accounts and session states the host moved into the plugin's data folder (`legacy.ts`) |
| `fs.read`: the two `managed-settings.json` paths | the administrator's `skillOverrides` (macOS and Linux) |
| `env`: `HOME`, `CLAUDE_CONFIG_DIR`, `CLAUDE_*` knobs | find the configuration directory; the launch knobs below |

The plugin writes no files. Its only state is plugin storage.

## CLI invocation

One process per session, started at `create_session` (and again on demand
if it exited, then always on the conversation it left: `--resume`, never a
second `--session-id` for an id the CLI already has a file for), with the
workspace as the working directory:

```
claude --output-format stream-json --input-format stream-json --verbose \
       --include-partial-messages --permission-prompt-tool stdio \
       (--session-id <uuid> | --resume <id> [--resume-session-at <uuid>]
        | --resume <origin> --fork-session --session-id <uuid>) \
       --forward-subagent-text \
       [--model <value>] [--effort <level>] \
       --permission-mode <mode> --allow-dangerously-skip-permissions \
       (--thinking-display summarized | --thinking disabled) [--settings <json>] \
       [--mcp-config <path>]… [--strict-mcp-config] \
       [--setting-sources <list>] \
       --add-dir <workspace> [--add-dir <path>]… [user arguments…]
```

`--permission-prompt-tool stdio` routes the CLI's permission prompts to the
stdio control channel as `can_use_tool` control requests. The child runs
with `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1`. The broker finds `claude`
on the login `PATH` (`process: ["claude"]`); `CLAUDE_PATH` is no longer
honoured, since a path needs `process.any` (as Codex dropped `CODEX_PATH`).

## Environment variables

There is no settings UI for this plugin yet, so the launch knobs are
variables of the login environment (`api.env`, the `env` grant). They are
read again at every launch, so a change applies to the next session without
restarting the app.

| Variable | Effect |
| --- | --- |
| `CLAUDE_CONFIG_DIR` | The CLI's own variable. It picks the account and the transcript store. It reaches the child as the login environment has it, and becomes `AgentInfo.continuationKey`, so two instances sharing a directory can continue each other's chats. The plugin never sets it for the default account: setting it at all, even to `~/.claude`, makes the CLI read a credentials file instead of the keychain and report `loggedIn: false`. Only an account with its own `configDir` exports it. |
| `CLAUDE_EXTRA_ARGS` | Extra launch arguments, split like a shell command line (quotes and backslashes are honoured). |
| `CLAUDE_MCP_CONFIG` | One or more MCP configuration files or JSON strings, split the same way. Each gets its own `--mcp-config`. |
| `CLAUDE_STRICT_MCP_CONFIG` | `1`/`true`/`yes`/`on` adds `--strict-mcp-config`, so only the servers above are used. |
| `CLAUDE_SETTING_SOURCES` | Comma separated `user,project,local` for `--setting-sources`. |
| `CLAUDE_APPEND_SYSTEM_PROMPT` | Text appended to the CLI's own system prompt. It is sent as `appendSystemPrompt` in `initialize`, before the host's instructions (see below), not as `--append-system-prompt`. |
| `CLAUDE_ADD_DIRS` | Directories to grant beyond the workspace. |

`HOME` is read (to name `~/.claude`) and never overridden: on macOS it also
moves the login keychain, and the CLI would lose the credentials it keeps
there. An account's `env` may not set `HOME` or `CLAUDE_CONFIG_DIR`.

## Accounts

One Claude Code install can hold several logins, kept apart by
`CLAUDE_CONFIG_DIR`. The accounts are the plugin's stored value
`instances`. The Rust plugin read `<data dir>/agents/claude/instances.json`;
the first time this plugin loads in its place the host moves that folder
into the plugin's data folder (`legacy/`), and `legacy.ts` adds its entries
to the stored ones once (`fs.read` of the `data` scope):

```json
[{ "id": "personal", "name": "Claude Personal", "configDir": "~/.claude_personal" },
 { "id": "openrouter", "configDir": "~/.claude_openrouter",
   "env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" } }]
```

The default account registers in `activate`; the stored ones register once
the storage answers (the runtime tells the host). Each
account gets its own processes, catalog and session map. The `fs.read`
grant covers `~/.claude/**` only, so an account whose directory lies
elsewhere runs and prompts normally but has no history, no stored-session
list, no skills from its directory and default workflow switches (see the
gaps below).

## Plugin tools and instructions

`tools` and `instructions` from `create_session`, `resume_session` and
`fork_session` go in the `initialize` control request of every launch (new,
resume, rewind, fork), never on the command line:

```json
{"subtype":"initialize","sdkMcpServers":["convergence"],
 "sdkMcpServerConfigs":{"convergence":{"timeout":3600000}},
 "appendSystemPrompt":"<CLAUDE_APPEND_SYSTEM_PROMPT>\n\n<instructions>"}
```

Each field is sent only when it has content; the catalog probe sends
neither. Verified against 2.1.280 and, through the JavaScript plugin, 2.1.282:

* The CLI then talks to the server with `mcp_message` control requests
  (`server_name`, `message`) and expects `{"mcp_response": <JSON-RPC>}`
  in the success response; a notification is answered with
  `{"jsonrpc":"2.0","result":{},"id":0}`. The SDK's `handleMcp` answers
  them, and a `tools/call` runs by itself so the reader never waits for a
  tool; the host runs it (`host/tools.call`).
* The MCP `initialize` for the server arrives **before** the CLI answers
  our `initialize`, so the reader must be running first.
* `appendSystemPrompt` in `initialize` wins over `--append-system-prompt`:
  with both, the flag's text is dropped. So the user's text joins ours in
  the request.
* A resumed conversation keeps neither the server nor the appended text,
  so every launch declares them again.
* The model sees the tools as `mcp__convergence__<tool>`; the transcript
  titles them `<tool>` and keeps the input (code mode's `code`) as is.
  They go through `can_use_tool` like any tool, so the session's own
  permission mode decides. (Live, Haiku first calls `ToolSearch` to load
  the deferred MCP tool, then the tool.)

## Session state

What the plugin remembers about a host session beyond the CLI's transcript
lives in plugin storage under `session:<host session id>`:
`{ cli?, sent: { <host item id>: <user record index> } }`. `cli` is the id
the CLI files the conversation under when it is not the host's id (after a
rewind to nothing: the CLI refuses a new conversation under an id it has a
file for); `sent` maps each host prompt to the user record it became, which
is how `rollback` finds the record to resume before. The Rust plugin kept
the same state in `<data dir>/agents/claude/sessions/<id>.json`; those files
are outside every grant a plugin can hold and are not read (see the gaps).

## Protocol facts verified against the live CLI

* The `initialize` **control response** carries `models`, `commands`,
  `agents`, `account`, `current_permission_mode`, `fast_mode_state` and
  `fast_mode_disabled_reason`. Models arrive with `value`, `displayName`,
  `description`, `resolvedModel`, `supportedEffortLevels`,
  `supportsAdaptiveThinking` and `supportsFastMode`.
* That model list is only what the CLI's picker recommends (Default, Opus,
  Fable, Sonnet, Haiku), with names that carry no version. With `"model":
  "opus"` in the user's settings it also lists Opus twice (`opus` and
  `opus[1m]`). Like T3 Code (`model-manifest.json`), the plugin offers its
  own list from `models.json` instead: every model by version, and the
  `[1m]` variant where the model has one. The CLI's values become
  `aliases` of the model their `resolvedModel` names, so stored `opus`,
  `opus[1m]` and `default` select Opus 5.5. There is no Default entry.
  Verified 2026-09-24 on a Max account (CLI 2.1.281): every value in the
  list runs; `claude-sonnet-4-6[1m]` needs usage credits, so Sonnet 4.6 is
  offered without `[1m]`.
* Model effort is the `--effort` launch flag: `low`, `medium`, `high`,
  `xhigh`, `max`, per model. A running session takes a new level through
  `apply_flag_settings {"effortLevel": <level>}` and `get_settings` then
  reports it in `applied.effort` (verified 2.1.281). `null` drops the
  layer's level; the CLI then uses the user's top-level `effortLevel`, not
  a per-model one from the settings file.
* **Ultracode** is `xhigh` plus standing dynamic-workflow orchestration,
  for the session only (verified 2.1.281): `--effort ultracode` at launch,
  or `apply_flag_settings {"ultracode": true}` live, and `get_settings`
  answers `applied.ultracode`. `--settings {"ultracode": true}` next to
  `--effort high` stays off. On a model without `xhigh` (Haiku, Opus 4.6)
  the request succeeds and ultracode stays off. The word `ultracode` in a
  prompt turns that one turn into a workflow (setting
  `workflowKeywordTriggerEnabled`, default on); `enableWorkflows: false`
  turns workflows off altogether. The CLI reports neither setting.
* `--permission-mode` accepts `acceptEdits | auto | bypassPermissions |
  manual | dontAsk | plan` and the undocumented `default`, which is the one
  that means "ask" (verified: `--permission-mode default` is accepted,
  `Default` is rejected).
* `--thinking` accepts `enabled | adaptive | disabled`. It is the current
  spelling of the `alwaysThinkingEnabled` setting;
  `--max-thinking-tokens` is marked deprecated in favour of it.
* `--session-id` may accompany `--resume` only with `--fork-session`
  ("Error: --session-id can only be used with --continue or --resume if
  --fork-session is also specified"), which is what lets the plugin choose
  the forked id instead of discovering it.
* `--resume-session-at <uuid>` truncates a resumed conversation. It **keeps**
  the message it names (`messages.slice(0, index + 1)`) and drops everything
  after, and it errors with "No message found with message.uuid of: …" for an
  unknown id. It requires `--resume`.
* List flags (`--add-dir`, `--mcp-config`) keep consuming words until the
  next flag, so a single flag with several values swallows whatever follows
  it (verified: `--add-dir /tmp "prompt"` ate the prompt). Every value
  therefore gets its own flag.
* `claude auth status --json` prints `loggedIn`, `authMethod`, `apiProvider`,
  `email`, `subscriptionType`, `configDirectory` and `orgName`.
* `rate_limit_event` frames carry `rate_limit_info` with `status`,
  `rateLimitType`, `utilization` (a **0-1 fraction**), `resetsAt` (**unix
  seconds**) and `unifiedWindows` (`five_hour`, `seven_day`,
  `seven_day_overage_included`, each with their own utilization and reset).
* The `get_usage` control request answers with `rate_limits`, whose
  `utilization` is a **0-100 percentage** and whose `resets_at` is an **ISO
  string** — the opposite units of the stream frame. They have separate
  decoders for that reason.
* `result` carries `usage.output_tokens_details.thinking_tokens` and
  `modelUsage[<model>]` with `contextWindow`, `maxOutputTokens` and
  `thinkingTokens`.
* `system/informational` carries its text in **`content`**, not `message`,
  and an optional `tool_use_id` that marks it as progress for that tool call.
* `system/compact_boundary` carries `compact_metadata` but no summary; the
  summary is the `user` message stamped `isCompactSummary` that precedes it.
* Subagents (2.1.280): `system/task_started` (`task_id`, which is the
  agent's `agentId`, `tool_use_id`, `description`, `subagent_type`,
  `task_type`, `is_backgrounded`, `spawn_depth`, `prompt`,
  `skip_transcript`, `ambient`), `task_progress` (`summary`,
  `usage{total_tokens,tool_uses,duration_ms}`, `last_tool_name`, and a
  `description` that follows the current activity), `task_updated`
  (`patch.status` of `pending|running|completed|failed|killed|paused`,
  `patch.is_backgrounded`, `patch.error`) and `task_notification` (`status`
  of `completed|failed|stopped`, `summary`, which is a status line such as
  `Agent "…" finished`, `usage`). Their messages are forwarded as
  `assistant` / `user` frames with `parent_tool_use_id` = the `Agent` call,
  but only with `--forward-subagent-text`. `can_use_tool` and
  `system/permission_denied` name the asking subagent in `agent_id`.
* **A subagent's message arrives one block at a time**: each block is an
  `assistant` frame of its own with the same `message.id` and the block at
  array position 0 (thinking, then tool_use; thinking, then text). There is
  no `stream_event` for a subagent. The mapper counts the blocks of each
  message to find a block's real index; keying on the array position
  dropped every block after the first, so subagent transcripts were empty.
  A background subagent's frames keep coming after the parent's `result`.
* **The CLI's own user records**: the end of a background task is written
  into the main conversation as a user message with `origin.kind:
  "task-notification"`, and a stop as the text `[Request interrupted by
  user…]`. History skips the first and shows the second as "Stopped.".
* **Dynamic workflows** (verified 2.1.281 with a two-phase, three-agent
  run): the `Workflow` tool starts a `local_workflow` task. `task_started`
  carries `workflow_name` (`meta.name`), `description` (`meta.description`)
  and the script as `prompt`, and the tool result says `async_launched`
  with `tool_use_result {taskId, taskType: "local_workflow", workflowName,
  runId, summary, transcriptDir, scriptPath}`. `task_progress` carries
  `workflow_progress`, a **full** snapshot of `workflow_phase {index,
  title}` and `workflow_agent {index, label, phaseIndex, phaseTitle,
  agentId, model, state: start|progress|done|error, startedAt, queuedAt,
  attempt, lastToolName, lastToolSummary, promptPreview, tokens,
  toolCalls, durationMs, resultPreview, error, blocked, cached,
  agentType}` entries keyed by `index`; `start` without `startedAt` is an
  agent waiting for a slot. Frames that only move the token count leave
  the snapshot out, and snapshots of pure progress are throttled. The
  frame's `description` is "<phase>: <label>" of the agent that moved last,
  its `summary` the script's description. The run ends with `task_updated`
  and `task_notification` (`summary` is a status line; `output_file` is a
  JSON record `{summary, agentCount, logs, result, workflowProgress,
  totalTokens, totalToolCalls}`). **The agents' messages never reach the
  stream**, even with `--forward-subagent-text`: each agent writes
  `<transcriptDir>/agent-<agentId>.jsonl` (records shaped like forwarded
  frames, one block per record; the first is the prompt, framed by the
  harness and indented) and `.meta.json` (`agentType:
  "workflow-subagent"`, `description` = label, `workflowPhase`, no
  `toolUseId`), plus `journal.jsonl` (`started` / `result` / `failed` per
  agent). A finished run's record is also stored at `<session
  dir>/workflows/<runId>.json` with `status`, `startTime`, `durationMs`,
  `phases` and `script`. `stop_task` with the workflow's task id stops it;
  the agents are not tasks the CLI can stop one by one.
* **Every foreground Bash call is a task too**: `task_started
  {task_type: "local_bash", tool_use_id: <the Bash call>, description: <the
  command>, is_backgrounded: false}` then `task_notification completed`.
  Only `task_type: "local_agent"` is a subagent.
* `control_request {subtype: "stop_task", task_id: <agentId>}` stops one
  subagent; it ends with `task_notification` `stopped`.
* Stored subagents: `<projects>/<encoded cwd>/<session id>/subagents/`
  (any depth) holds `agent-<agentId>.jsonl` (every record `isSidechain`,
  with `agentId`, `uuid`, `parentUuid`) and `agent-<agentId>.meta.json`
  (`agentType`, `description`, `toolUseId`, `parentAgentId` from depth 2,
  `spawnDepth`, `requestShape` `foreground|background`, `model`). The main
  file's `Agent` tool result carries `toolUseResult {agentId, status,
  content, totalTokens, totalToolUseCount}` (`status: "async_launched"`,
  `isAsync` for a background agent). A background agent's end is a queued
  `<task-notification>` with `<task-id>`, `<status>`, `<summary>` (a status
  line) and `<result>` (its answer); one notification can list several
  `<task-id>`s (agents a previous process left unfinished).
* The CLI records the **resolved** working directory in
  `<config dir>/projects/<encoded cwd>/`, so the path is canonicalized
  before encoding. The encoding replaces every character that is not a
  letter, a digit or a hyphen with a hyphen.
* Stored transcripts hold `ai-title` and `custom-title` records; the session
  title prefers a custom title, then the AI title, then the first line of the
  first user message. Every record names its `parentUuid`.
* `AskUserQuestion` answers travel back through `can_use_tool`:
  `{"behavior":"allow","updatedInput":{…input…,"answers":{"<question text>":"<label>"}}}`.
  This was established by experiment, because it is documented nowhere:
  - `updatedInput` is validated against the tool's JSON schema, so the
    `questions` array must survive untouched;
  - an `answers` map keyed by the question **header** is ignored; keyed by
    the question **text** it works. A multi select answer is an array of
    labels; free text goes in `response`.

## Decisions

* **Text and reasoning** stream from `stream_event` raw Anthropic deltas,
  keyed by `<message id>:<block index>`. The complete `assistant` message
  that follows is used only for blocks the stream never delivered.
* **Subagents** are published with the spawning `Agent` / `Task` tool
  call's id as the task id, because that is the id every forwarded child
  message already carries: every child event is tagged with it, so the host
  keeps it inside the task. The CLI's own id (`agentId`) is kept in a side
  index for the `task_*` frames, approvals (`agent_id`) and `stop_task`.
  Only `local_agent` tasks are published; a `local_bash` task (every Bash
  call) or any other kind produces nothing, so the Bash row is untouched.
  A child message can reach the stream before its `task_started`, so a
  forwarded message whose parent is an agent call **declares** the task from
  that call. A finished task is kept, marked done: a late message still
  lands in it, never in the main conversation.
  - Title: `description`, else the prompt's first line, set once. The
    `description` of `task_progress` follows the current activity and is
    ignored; the last tool goes to `activity`.
  - `parent_task_id`: the task the spawning call ran in. `name` =
    `subagent_type`; `model` from the first child `assistant.message.model`;
    `usage.used_tokens` = `total_tokens`; `tool_uses`; `background` from
    `is_backgrounded` / `run_in_background` / an `async_launched` result.
  - Status: `paused|pending` → Waiting (also while one of its approvals or
    questions is open), `killed` / `stopped` → Cancelled.
  - Summary: progress line while running; at the end the spawning call's
    result (a foreground agent's whole answer, which nothing later
    replaces), else the agent's last text (the notification `summary` is a
    status line), else for a failure the reported error.
  - A `SendMessage` that gives an agent more work routes its messages to
    that agent's task (by `to`, or by the re-sent `task_started`).
  - `cancel_task` sends `stop_task` with the agent id. `initialize` does not
    declare `perTaskStopAffordance`: the parent's Stop stops every agent.
  - Tasks marked `skip_transcript` or `ambient` are housekeeping and are not
    announced.
* **Stored subagents** (`read_session`): each `agent-*.jsonl` is read by the
  SDK's rule (user / assistant / attachment records with a uuid; from the
  last user or assistant record follow `parentUuid` to the root; drop
  attachments), its opening prompt record removed, and mapped with the same
  record mapper as the main chain. It becomes a `Task` item right after its
  spawning call, inside its parent agent's items when `parentAgentId` (or
  the file the call was made in) says so. `TaskInfo` comes from the meta and
  the spawning call: id = `toolUseId`, name = `agentType`, title =
  description or the prompt's first line, prompt = the call's `prompt`,
  status from the tool result (`is_error` → Failed) or the notification
  (default Completed), summary = the tool result's answer, else the
  notification's `<result>`, else the last assistant text. Inline sidechain
  records of old CLIs stay skipped.
* **Approvals** use the shared four modes, mapped to `default`,
  `acceptEdits`, `auto` and `bypassPermissions`; `full` also passes
  `--allow-dangerously-skip-permissions`, which the CLI requires before it
  accepts `bypassPermissions`. Unlike the reference client, the strictest
  mode is sent rather than left to the CLI's own configuration: the host
  reads an unset option as Supervised, so adopting a looser configured
  default would run the session looser than the composer says. `plan` is not
  one of the four modes — it stops the agent changing anything at all, which
  is a different axis from who approves what.
* **Streaming tool output.** No frame in this CLI carries partial tool
  output. The real progress channel is `system/informational` with a
  `tool_use_id`; it becomes `output_delta` on the tool call (tagged with the
  subagent the call ran in), and a repeated line is dropped so a panel does
  not fill with one sentence. A subagent's `task_progress` goes to its task
  (`summary`, `activity`), not to the hidden spawning call.
* **Compaction** is the `/compact` slash command, so it is an ordinary turn.
  `Compacted` is emitted at the `compact_boundary`, carrying the summary the
  CLI wrote just before it. A `compact_result: "failed"` status becomes an
  error notice, because the turn otherwise ends normally with the context
  still full.
* **Rewind** restarts the session process with `--resume-session-at` set to
  the target item's `parentUuid`. The flag keeps the message it names, so
  naming the item itself would leave the turn the user wants undone in place.
  An item with no parent cannot be rewound to and the call fails rather than
  half succeeding.
* **Fork** starts `--resume <origin> --fork-session --session-id <new>` and
  keeps the origin's option values, because a fork is the same conversation
  taken in another direction.
* **Skills** are read from `<config dir>/skills` and `<workspace>/.claude/skills`,
  the config directory winning a name clash. `disable-model-invocation` and a
  `user-invocable-only` override become `manual_only`; an `off` override hides
  the skill; `user-invocable: false` hides it from a picker the user drives,
  because only the model may start it. The `skillOverrides` map is validated
  whole: one unknown value drops every override in that file, as the CLI
  does, so a half-read policy cannot enable a skill an administrator switched
  off. The list is re-read when a session starts and after every run, and a
  `Skills` event follows only when it changed.
* **A skill in a prompt** becomes the closing text block, with images before
  it, because the CLI expands a command only from the last block and only the
  first one. An earlier skill stays inline as `/name` for the model to start
  through its own tool.
* **Context window** comes from `modelUsage[<the session's model>]`, tracked
  from the last assistant message. The largest window across all models would
  over-report as soon as a subagent ran on a roomier model.
* **Maintenance.** `manager` is `native` only when the resolved real path
  ends with `/.local/bin/claude` or lies under `/.local/share/claude/`, and
  `npm` only when it lies under `<prefix>/lib/node_modules/@anthropic-ai/claude-code/`
  with no `node_modules` above that (a project checkout is not a global
  install). Anything unproven reports no manager and `can_update: false`, so
  Divergence never runs a package manager against an install it cannot show
  it created. `update` runs `claude update` or
  `npm install -g --prefix <prefix> @anthropic-ai/claude-code@latest`.
* **Account.** `claude auth status --json` decides the status: signed out is
  `AuthRequired` with a message naming the configuration directory to log in
  against, and a probe that cannot run at all is `Unavailable`, because the
  plugin drives that same binary. The signed in address and plan go into
  `AgentInfo.description`, the only field the protocol has for them.
* **Diffs.** `Edit` / `MultiEdit` / `NotebookEdit` only give the replaced
  fragment, so they produce a unified `diff`; `Write` carries the whole
  file, so it produces `new_text`. Diffs are attached at `ToolCallStarted`.
* **Bash exit codes** come from the CLI's `tool_use_result`, which is now
  also forwarded whole as `ToolCall.output`.
* **`TodoWrite`** emits its tool call *and* a `Plan` event.
* **`set_option`** sends `set_model`, `set_permission_mode` and
  `apply_flag_settings` (fast mode, effort) to the running process.
  Thinking has no such request, so a new value applies at the next start.
* **Workflows** are published like subagents, under the `Workflow` call's
  id, with `TaskInfo.workflow` set (phases, then the result and log from the
  `output_file` record, read with retries because the CLI writes it without
  waiting). Each snapshot entry becomes a child task `<workflow id>/<index>`
  with `phase` set; its `agentId` goes into the side index, so an approval
  from that agent lands on its task. The session follows each agent's
  transcript file from the `transcriptDir` of the tool result: after every
  `system` and `user` frame it reads what the files gained (whole lines
  only) and maps the records as frames forwarded for the agent's task.
  When the run ends, it reads the record and then every file one last time.
  The record is read from `output_file`, else from the copy the CLI keeps
  beside the session (`<session dir>/workflows/<runId>.json`, found from
  the run's `transcriptDir`): `output_file` lives in the CLI's temporary
  folder (`$TMPDIR/claude-<uid>/…/tasks/<id>.output`), which no grant of
  this plugin covers.
  `cancel_task` on a workflow agent fails with a message; on the workflow it
  sends `stop_task`. `read_session` rebuilds each run from
  `workflows/<runId>.json` and the agents' files, or, for a run the process
  left unfinished, from `journal.jsonl` and the meta files; the plain
  subagent reader skips the `wf_*` folders.
* **Ultracode** is an effort level (`ultracode`) after `max`, offered for a
  model with `xhigh` unless the settings file turns workflows off. Its
  choice is `transient` (the host does not carry it into new chats) and
  has the `workflow` icon as its mark. Choosing it sends `{effortLevel:
  "xhigh", ultracode: true}`; any other level sends `ultracode: false`. As
  the CLI accepts it silently where it cannot apply, the session asks
  `get_settings` after choosing it, after a model change and at launch, and
  warns when `applied.ultracode` is false. `AgentInfo.promptKeywords`
  declares the `ultracode` word unless the keyword trigger is off.
* **Cancel** sends the `interrupt` control request, rejects every pending
  permission request, and ends the run as `Cancelled` when the `result` line
  arrives (a 10 s watchdog ends it anyway if the CLI stays silent).
* **Turns the CLI begins by itself.** When a background task (a
  background Bash command or subagent) ends after the turn's `result`, the
  CLI answers it in a new turn that no prompt started. The child runs with
  `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1`, so every turn begins with
  `system/session_state_changed` `running`. With no run open, that line opens
  one and emits `RunStarted`; the next `result` ends it as usual. The state
  line alone is not enough: while a background subagent still runs, the CLI
  stays `running` after a turn's `result`, and the turn that answers the
  subagent begins with only `system/init`. So `init` opens a run too, and so
  does a `can_use_tool` request (a background subagent can ask for approval
  after the turn ended). Without that run, a question in such a turn left the
  chat waiting with a Stop button that reached nothing.
* **Native steering** sends `priority: "next"`, never `interrupt` or native
  `later` queueing. Prompt admission is serialized through startup and keeps
  the active run id. Each input has a native UUID, including legacy Alpha
  prompts without host delivery metadata. Correlated first replies, native
  `system/thinking_tokens`, and results with model-work evidence establish
  pickup; a buffered stdin write, replayed user line, ping or child frame does
  not. Merged UUID lists attribute every reported contribution. Finished
  aliases remain fenced to their old run; an unpicked input may instead be
  consumed in a native continuation that starts and finishes exactly once.
* **Quota recovery facts** are separate from meters and lifecycle. Native
  allowed/warning and permitted paid overage do not block or cancel work;
  `rateLimitGraceActive` alone does not override a native rejection. Rejection
  emits run-scoped `usage_blocked`, but only the actual result stops the run.
  `credits_required` never promises a timed reset. Report the responsible
  window's deadline (including its unified entry); do not substitute unrelated
  exhausted percentages or hide a missing rejected-overage deadline.
* **Recovery identity** pairs the session's native initialization account with
  `claude auth status --json` run in its exact cwd/environment. Only agreeing
  first-party claude.ai profile evidence yields an identity: native email and
  organization, auth organization id and effective configuration directory.
  API-key/external token sources, missing or conflicting evidence, custom CLI
  flags or setting sources remain unidentified. A refresh starts a new
  prompt-free, hook/MCP-free probe with the current environment instead of
  relabelling an old process's facts after a login/config change. `get_usage`
  reports meters only: its availability remains `unknown` (or `unsupported`),
  even at 0% or 100%. Refresh failures propagate rather than returning cached
  permission. No new host methods or runtime features are required.

### Steering verification (2026-10-05, installed CLI 2.1.289)

A live isolated `/tmp` Haiku stream called Bash `sleep 6; printf TOOL_DONE`.
While it ran, a second UUID-tagged `next` message requested another Bash call
`printf STEER_TOOL` and the answer `STEERED`. The first tool completed normally,
the second tool ran **before the sole result**, and that result was `STEERED`
with `num_turns: 3` and both input UUIDs in `user_message_uuids`. The single
`user_message_uuid` still named the original input; using only it would miss
the steer. Thinking frames were `system`/`thinking_tokens`, not a top-level
`thinking_tokens` type. No live quota exhaustion or billing mutation was used.

A prompt-free live initialize/get_usage check confirmed native first-party
account fields plus matching auth `orgId`/`configDirectory`; the usage response
carried percentages and reset timestamps but no affirmative availability.
SDK `accountInfo()` reads the initialization snapshot: the installed CLI
explicitly rejected `get_account_info` as unsupported, so it is not a refresh.

## Protocol gaps and deliberate omissions

* **Delivery reconciliation after restart is incomplete.** UUID aliases are
  process-local; saved history can prove a user record was written but cannot
  establish model consumption. No native admission/write receipt or withdrawal
  API is advertised. Missing echoes (older producers or the 64-UUID list cap),
  transport loss, and zero-work results remain uncertain, not replayable proof.
  Restored pending inputs need the host's unknown-delivery/manual policy; this
  provider does not pretend to restore consumption from its rewind-index map.
* **Identity/availability limitations.** Native account metadata is not a
  transactional credential fingerprint. A credential switch during an
  initialize/auth/usage exchange can only be rejected when reported evidence
  disagrees; unidentified configurations cannot enable restart-safe automatic
  recovery. Identity is conservative per effective config/account, not proof
  that two configurations share a billing pool. Fresh usage may itself be
  endpoint-cached and cannot override a matching rejection as `allowed`.
* **Reset credits are not reported.** Claude has no credit that reopens a
  rate-limit window early; its `extra_usage` is a spend allowance, not a
  reset, so `UsageLimits.reset_credits` stays empty.
* **The published version** comes from the npm registry, asked through the
  npm installation that owns the CLI (a native install is compared with
  the registry's `@anthropic-ai/claude-code` too, as the Rust plugin did);
  an install nobody can be proven to own asks nothing and offers no update.
* **`AgentInfo.usageLimits` is empty at the first start.** `initialize` has
  no workspace, so there is no process to ask. The catalog probe fills the
  cache a moment later, a later `initialize` reports it, and every
  `rate_limit_event` refreshes the host's copy.
* **`--rewind-files` is not used.** The CLI can restore files to a message,
  but the host owns file checkpoints; `rollback` only rewinds the
  conversation, which is exactly what the capability promises.
* **Unfinished stored agents.** A foreground agent whose spawning call has
  no result in the file (the process ended mid-run) reads back as
  Completed, with its last text as the summary.
* **npm 12.** `npm install -g` there blocks install scripts and still exits
  zero, which can leave the native binary as a placeholder. The reference
  client passes `--allow-scripts`; that flag is not passed here because older
  npm rejects it.
* `get_settings` exposes the effort in force (`applied.effort`), but only
  ultracode is checked against it.
* `AskUserQuestion` has no documented answer channel; the `answers` map above
  is experimentally derived and could change with the CLI.
* `request_user_dialog` (kinds `resume_return`, `refusal_fallback_prompt`) is
  answered with an error, since this plugin renders no such dialog.
* `tool_progress` frames carry elapsed time only, never output bytes.
* Live session titles are not reported on the wire. Titles come from the
  stored transcript, so they appear in `list_sessions`, not as a live
  `SessionInfo` event.

## Deliberate gaps and changes from the Rust plugin

* **The old session state files are read once.** The host moves
  `agents/claude/sessions/*.json` with the rest of the old folder into the
  plugin's data folder (`legacy/sessions/`), and `legacy.ts` copies each into
  the storage as `session:<id>` (unless it has one) before any session state
  is read.
* **Accounts outside `~/.claude`** (`configDir`, or `CLAUDE_CONFIG_DIR`
  elsewhere) have no readable history, skills or settings: a static grant
  cannot name a folder that comes from a setting (setting templates in
  scopes are reserved for P11).
* **`output_file` of a workflow** is in the CLI's temporary folder; the
  record is read from the copy beside the session instead.
* **A withdrawn request** (`control_cancel_request`) now also sends
  `approval_resolved` / `question_resolved`, so the card goes; the Rust
  plugin only dropped it on its side.
* **A process that exits** is restarted on the conversation it left
  (`--resume`); the Rust plugin restarted it with its first launch's
  arguments. The exit's notice carries the last lines of the CLI's stderr.
* **The repository root's `.claude/settings.local.json`** above a workspace
  that is a sub-folder is not read (outside the grants).
* **`CLAUDE_PATH`** is not honoured.
* Session ids are version 4 UUIDs from `crypto.randomUUID()`.
## Tests

`node --import ./plugins/testing/register.mjs --test plugins/claude/*.test.ts`
(with `plugins/sdk/*.test.ts`), no
network and no CLI. Every Rust unit test is ported: text accumulation,
reasoning, a command call with output and exit code, a file edit with a
diff, a failed tool result, todo plans, usage and failure results,
subagents (a foreground `local_bash` task that must stay a command,
lifecycle, nesting, stable titles, tagged child messages, the answer from
the spawning call, late messages, approval attribution by `agent_id`,
`stop_task`), stored subagents rebuilt with nesting from a temp folder,
workflows (the recorded two-phase run in `testdata/`: the workflow task and
its agents, a queued agent, the agents' transcript files, the record, stop
rules, a stored run and one the process left unfinished), ultracode and
live effort, tool progress, compaction, rate limits in both unit systems,
the approval and question round trips, the argument list for every start
mode, skill discovery and overrides, the rewind target, installer
ownership, the account probe, the accounts and the tool server over the
control channel. Added: the agent against a scripted `claude` (the SDK's
`FakePeer`): initialize, the catalog probe, create and prompt, approvals
routed by id, rollback by host id and to nothing, fork, history under the
alias, sign-in and sign-out environments, option changes on a running
process; a withdrawn request, a refused control request, cancel, the
process exiting, the workflow record read from beside the session, and a
skill that only the CLI's command list names. Effect lifecycle coverage
checks concurrent catalog probes, control-request timeout and interruption,
and scope closure with final output still queued. Cleanup waits for the SDK
reader to finish before releasing the session queue. Auth parsing also
preserves the distinction between non-object output and a signed-out account.

`cargo test -p convergence-host --test e2e -- --ignored` runs the plugin in
the real plugin host against the installed CLI:
`claude_initializes_and_lists_its_options` (no prompt: loading, `--version`,
`auth status`, maintenance, the catalog probe) and
`claude_calls_a_plugin_tool` (one Haiku turn that calls a plugin tool
through the MCP control channel, with its approval, then the chat imported
again from the CLI's stored transcript).

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.