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