feat(claude-code): Claude Code as an iii worker - #243
Conversation
New Node worker that drives headless Claude Code turns over the iii bus. - claude::run / claude::start / claude::stop / claude::status / claude::sessions::list, plus run::start_and_wait so the canonical brain contract (console, acp) drives Claude Code unchanged - AgentEvent subset (message_complete, function_execution_start/end, turn_end, agent_end) streamed onto agent::events via stream::set - in-process MCP bridge exposes engine functions_list / functions_info / trigger to the running turn - session resume via engine state scope claude_sessions (iii session_id to Claude session id mapping) - optional approval_gate config routes tool permissions through policy::check_permissions, fail-closed - deploy: bundle with esbuild single-file build; claude_executable config plus PATH fallback covers the bundled SDK losing its native CLI - vitest unit tests for payload parsing, event mapping, config, executable resolution
…opt-in bus bridge - options field on claude::run forwards any Agent SDK Options key verbatim (forkSession, includePartialMessages, fallbackModel, addDirs, mcpServers, ...) - every SDK message mirrors verbatim onto the claude::events stream (system/init, assistant, user, result, stream_event partials), alongside the translated AgentEvent view on agent::events - expose_iii_bridge now defaults to false: the worker is a plain Claude Code API surface unless the operator opts into the mcp__iii__* bus tools; user MCP servers pass through options - zero-to-turn quickstart in README (install engine, worker add, iii)
The worker exposes the Claude Code API and nothing else. The in-process MCP server that handed Claude mcp__iii__* bus tools was an extra feature on top of that surface; users who want MCP servers in a turn pass them through options.mcpServers exactly as in the Agent SDK. Removes src/bridge.ts, the expose_iii_bridge config option, and all related wiring and docs.
Capabilities beyond Claude Code itself come from other iii workers on the bus, not from MCP wiring.
- SKILL.md leads with what the worker exposes and how to call it: payload shapes, both streams, session resume, options pass-through - removes 'brain' / 'canonical brain contract' wording everywhere (skill, README, function descriptions, source comments, test names); run::start_and_wait described as the entrypoint the console and acp worker drive - fixes two stale claims: concurrent runs on one session race (no queueing), and token deltas exist on claude::events when includePartialMessages is set
Brings the suite to the level of the other workers' tests: handlers exercised at the engine's unknown boundary with an in-memory bus fake and a scripted Agent SDK query mock. - tests/_helpers: fake ISdk (state::get/set/list semantics, stream::set capture, registerFunction capture) and scripted query() fixtures - run.test.ts: full executeRun flow — result/usage/cost mapping, working->done record lifecycle, verbatim claude::events mirror, AgentEvent sequence with function_execution pairs, named-field and raw-options forwarding, resume for known sessions, SDK throw path, non-success stop reasons; approval gate allow/deny/fail-closed/off - register.test.ts: all six function ids, boundary parse rejection, claude::start background completion, claude::stop on live and ghost runs, status and sessions::list - events.test.ts: frame shape, per-session monotonic item_ids, stream::set failures swallowed - state.test.ts: scope round-trip, direct-value state::get reply (regression for the live resume bug), non-array list tolerance 57 tests, up from 26.
iii trigger <fn> --help previously showed '(no request schema published)'. Each registerFunction call now carries request_format derived from the zod payload schema via zod 4's native z.toJSONSchema, with per-field descriptions, so the CLI and engine catalog print the full parameter table.
skill-check — worker0 verified, 19 skipped (no docs/).
Four for four. Nicely done. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a Node-based claude-code worker that shells out to the host Claude CLI, exposes Changesclaude-code Worker Implementation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 16
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@claude-code/biome.json`:
- Line 2: The Biome schema version in claude-code/biome.json ("$schema":
"https://biomejs.dev/schemas/2.4.16/schema.json") is newer than the pinned CLI
version in claude-code/package.json (`@biomejs/biome`@2.4.10); either downgrade
the schema reference to 2.4.10 or upgrade the package pin so both match—update
the "$schema" value to "https://biomejs.dev/schemas/2.4.10/schema.json" if you
want to keep `@biomejs/biome`@2.4.10, or bump the `@biomejs/biome` version in
package.json to 2.4.16 to match the current schema.
In `@claude-code/iii.worker.yaml`:
- Line 6: Update the manifest description line in iii.worker.yaml to remove the
reference to "in-process MCP bridge" and instead state that MCP servers are
provided via options (e.g., "expects MCP servers to be passed via options");
also update the package.json "description" field to match this new wording so
both descriptions remain consistent. Locate and edit the description value in
iii.worker.yaml and the description property in package.json to reflect the
bridge removal and the new behavior.
In `@claude-code/scripts/build-bundle.mjs`:
- Around line 32-36: The plugin currently calls readFile(join(root,
'node_modules/iii-sdk/package.json')) without verifying the file exists; update
the b.onLoad handler (the async callback that uses args, root, readFile, and
join) to first check for the package.json presence (e.g., using fs.existsSync or
try/catch around readFile) and handle the missing file by either returning a
clear processLogger/error message or providing a safe fallback value so the
build fails with a descriptive error instead of an unhandled exception. Ensure
the updated logic still reads the original source (readFile(args.path, 'utf8'))
and only attempts to parse package.json when present, referencing the same
join(root, 'node_modules/iii-sdk/package.json') path used now.
In `@claude-code/src/config.ts`:
- Around line 30-34: The try/catch in config.ts around the parse(await
readFile(path, 'utf8')) call is currently swallowing all errors; change the
catch to accept an error parameter and only ignore missing-file errors
(error.code === 'ENOENT' or equivalent) and rethrow every other error (including
YAML parse errors and permission errors) so the worker fails fast; update the
block that assigns raw to ensure non-ENOENT exceptions are rethrown while
preserving the existing fallback-to-defaults behavior for a missing file.
In `@claude-code/src/events.ts`:
- Around line 10-15: seqBySession is a global Map that accumulates per-session
counters and is never pruned; update makeEmitter/emit to remove the session
entry when the session reaches a terminal event (e.g., detect event type or
property indicating "agent_end" or similar) by calling
seqBySession.delete(session_id) after handling that terminal event; if terminal
detection requires different shapes, add a small helper inside makeEmitter (or
use an exported function) to determine terminal state from the passed event and
ensure deletion is performed exactly once when terminal is seen to prevent
memory leak for long‑lived workers.
In `@claude-code/src/index.ts`:
- Around line 34-37: The shutdown handler currently awaits iii.shutdown and may
never call process.exit(0) if iii.shutdown rejects; modify the shutdown function
(named shutdown) to call iii.shutdown inside a try/catch or try block and use a
finally block to always call process.exit(0) (and in the catch optionally log
the error via your logger) so the process exits deterministically even if
iii.shutdown throws.
In `@claude-code/src/map.ts`:
- Around line 114-119: The function lastAssistant currently can return undefined
for an empty messages array; update lastAssistant to guard the empty-input case
at the top (e.g., if messages.length === 0) and return a safe default
AgentMessage instead of undefined. Construct the default minimal AgentMessage
with role: 'assistant' and empty/neutral fields required by your AgentMessage
type (e.g., content: '' and any required ids/metadata), then keep the existing
reverse-search logic for non-empty arrays so callers always receive a valid
AgentMessage.
- Around line 57-64: In the Array.isArray(raw) branch, don't filter out scalar
entries; instead iterate over raw and for each item produce an SdkContentBlock:
if the item is an object (and not null) treat it as before (type:'text', text =
item.type === 'text' ? item.text ?? '' : JSON.stringify(item)), otherwise
convert the scalar (including null/undefined) to a string via
JSON.stringify(item) (or String(item) if preferred) and return a text block for
it so scalar array elements are preserved rather than dropped.
In `@claude-code/src/run.ts`:
- Around line 128-133: The current logic reuses a prior SessionRecord (symbol:
prior) into record and then later sources model/cwd from record, which ignores
per-turn overrides in payload; fix by merging payload overrides into the resumed
record so payload.cwd and payload.model take precedence when present (i.e., when
constructing record from prior, set record.cwd = payload.cwd ?? prior.cwd and
record.model = payload.model ?? prior.model) or alternatively adjust the later
uses (where model/cwd are read) to use payload.model ?? record.model and
payload.cwd ?? record.cwd; update the code paths that reference record, prior,
payload, session_id, claude_session_id, cwd, model and status to reflect this
merge so resumed sessions honor per-turn overrides.
- Around line 162-163: The live map currently stores a single handler per
session_id (live.set(session_id, { interrupt: () => q.interrupt() })), causing a
race when multiple runs share the same session: later runs overwrite earlier
handlers and an earlier run's finally block can delete the key and detach the
later run. Fix by making the value for each session_id track handlers per-run
(e.g., a Set/Map of handlers) or by associating a unique run token with the
stored handler and only removing the handler you created in your finally block;
specifically, when creating the handler for q.interrupt() generate a unique
runId/token, add the handler under live.get(session_id) (append to a collection
or store {runId, interrupt}), and in the finally block remove only that run's
handler (or delete the session entry only if the collection becomes empty)
rather than unconditionally calling live.delete(session_id).
- Around line 88-92: The code treats an empty-string prompt as missing because
it uses a truthy check on payload.prompt; change the check to explicitly accept
an explicit string (including empty string) by returning payload.prompt when
it's present (e.g. if (payload.prompt !== undefined && payload.prompt !== null)
return payload.prompt; or better if (typeof payload.prompt === 'string') return
payload.prompt;), so that payload.prompt === "" is returned instead of falling
through to messages; keep the subsequent logic that falls back to
payload.messages and the typeof last.content check unchanged.
- Around line 157-159: The build of the options object currently spreads
payload.options after adding approval-gate-derived fields, allowing callers to
override enforcement; fix by preventing permission-related keys in
payload.options from shadowing approval logic—either move the spread of
payload.options before applying the approval-gate override so canUseTool and
related fields always win, or explicitly filter out enforcement keys (e.g.,
permissionMode, allowedTools, disallowedTools, canUseTool) from payload.options
before merging; update the code paths referencing gatedCanUseTool,
approval_gate, payload.options, iii, and session_id accordingly.
In `@claude-code/src/state.ts`:
- Around line 12-33: The state trigger calls in loadSession, saveSession, and
listSessions are currently unprotected and will throw on transient failures;
wrap each iii.trigger invocation in a try/catch: in loadSession (function name
loadSession) catch errors, log the error with context (including session_id) and
return null; in saveSession (function name saveSession) catch errors, log the
error with context (including record.session_id) and return without rethrowing
(best-effort save); in listSessions (function name listSessions) catch errors,
log the error and return an empty array; ensure logs include the caught error
object/details for debugging.
In `@claude-code/tests/config.test.ts`:
- Line 9: The test currently uses a hard-coded platform-dependent path
'/nonexistent/config.yaml' when calling loadConfig; replace that with a
guaranteed-missing temporary path by generating a random filename in the system
temp directory (e.g., via os.tmpdir()/path.join or a tmp utility) and ensure the
file is not created before calling loadConfig so the missing-file behavior is
deterministic; update the test that invokes loadConfig(...) in
tests/config.test.ts to compute this temp path instead of using the hard-coded
literal.
In `@claude-code/tests/events.test.ts`:
- Around line 39-44: The console.warn spy (warn) created before calling
makeEmitter/emit can leak if an assertion throws; wrap the spy lifecycle in a
try/finally so warn.mockRestore() always runs (or use suite-level auto-restore
with vi.restoreAllMocks()), e.g., create the spy (const warn = vi.spyOn(console,
'warn')...), run the test logic that calls makeEmitter/emit and assertions
inside the try block, and call warn.mockRestore() in the finally block to ensure
the spy is always restored.
In `@README.md`:
- Line 43: Update the README table entry for `claude-code` to remove the stale
claim about an "in-process MCP" bridge and any wording that it "bridges the
engine catalog back to Claude" or streams frames onto `agent::events`; instead
state that `claude-code` exposes a pure Claude Code API surface (e.g., keep
reference to `claude::*` running headless Claude Code turns but remove
MCP/bridge/agent::events mentions) so the description matches the current
implementation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4d798685-490e-4fa3-854a-735093d93d08
⛔ Files ignored due to path filters (5)
claude-code/assets/cli-help.pngis excluded by!**/*.pngclaude-code/assets/cli-run.pngis excluded by!**/*.pngclaude-code/assets/cli-status.pngis excluded by!**/*.pngclaude-code/assets/console-traces.pngis excluded by!**/*.pngclaude-code/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (29)
README.mdclaude-code/.gitignoreclaude-code/README.mdclaude-code/biome.jsonclaude-code/config.yamlclaude-code/iii.worker.yamlclaude-code/package.jsonclaude-code/scripts/build-bundle.mjsclaude-code/skills/SKILL.mdclaude-code/src/config.tsclaude-code/src/events.tsclaude-code/src/executable.tsclaude-code/src/index.tsclaude-code/src/map.tsclaude-code/src/run.tsclaude-code/src/state.tsclaude-code/src/types.tsclaude-code/tests/_helpers/fake-iii.tsclaude-code/tests/_helpers/fake-query.tsclaude-code/tests/config.test.tsclaude-code/tests/events.test.tsclaude-code/tests/executable.test.tsclaude-code/tests/map.test.tsclaude-code/tests/register.test.tsclaude-code/tests/run-payload.test.tsclaude-code/tests/run.test.tsclaude-code/tests/state.test.tsclaude-code/tsconfig.jsonclaude-code/vitest.config.ts
- biome.json schema version matches the pinned CLI (2.4.10) - iii.worker.yaml, package.json, and root README descriptions drop the removed MCP-bridge wording - loadConfig only swallows ENOENT; YAML parse and permission errors fail the worker fast - shutdown always exits even when iii.shutdown rejects - lastAssistant returns a synthetic assistant message for an empty transcript instead of undefined - mapToolResultContent preserves scalar array entries - resumed sessions honor per-turn cwd/model overrides - live-run registry deletes only its own handle, so a stale run cannot detach a newer run for the same session - explicit empty-string prompt is forwarded instead of falling through to messages - approval gate wins over caller options: canUseTool/permissionMode in the options pass-through cannot shadow enforcement 62 tests (5 new regression tests).
Every turn's system prompt now carries the iii runtime block, the same engine-grounded discovery rules as the harness identity prompts retargeted to the iii CLI the agent reaches through its shell: discover ids via engine::functions::list, fetch the contract with iii trigger <fn> --help before the first call, install from the public registry when nothing registered fits, never invent ids or fields from memory. The matching Bash(iii *) allow rule is added automatically so those calls run headless. Caller-supplied system_prompt still wins verbatim; iii_context: false disables per turn or in config.yaml. 65 tests.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@claude-code/src/iii-prompt.ts`:
- Around line 43-50: Update the prompt text and runtime guards so registry
installs cannot be executed headlessly: replace the direct "iii trigger
worker::add --json ..." recommendation with an explicit approval step and
allowlist check (e.g., require a user confirmation prompt and validate against a
TRUSTED_REGISTRIES/allowlist) and ensure the headless runner (Bash(iii *)) and
any auto-appended "iii-prompt" content will not call "iii trigger worker::add"
unless confirmation succeeds; keep the guidance to verify installation with
"engine::functions::list" and "iii trigger directory::registry::workers::info",
but stop auto-execution by adding a confirmation/allowlist gate before invoking
"iii trigger worker::add".
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a355df47-a4db-49f4-adea-655172add6a0
📒 Files selected for processing (8)
claude-code/README.mdclaude-code/config.yamlclaude-code/skills/SKILL.mdclaude-code/src/config.tsclaude-code/src/iii-prompt.tsclaude-code/src/run.tsclaude-code/tests/config.test.tsclaude-code/tests/run.test.ts
✅ Files skipped from review due to trivial changes (1)
- claude-code/README.md
🚧 Files skipped from review as they are similar to previous changes (6)
- claude-code/config.yaml
- claude-code/src/config.ts
- claude-code/tests/config.test.ts
- claude-code/tests/run.test.ts
- claude-code/src/run.ts
- claude-code/skills/SKILL.md
| Need a backend capability? Check what is already registered FIRST — it is usually one call | ||
| away. When nothing fits, search the public registry before building anything: | ||
| \`iii trigger directory::registry::workers::list --json '{"search":"<capability>"}'\` pages the | ||
| published catalogue and \`iii trigger directory::registry::workers::info name=<name>\` returns | ||
| one worker's full detail. Say what you are about to install and why, install with | ||
| \`iii trigger worker::add --json '{"source":{"kind":"registry","name":"<name>"}}'\`, then | ||
| confirm the new ids appear via \`engine::functions::list\` with that prefix and fetch each | ||
| contract with \`--help\` as usual. |
There was a problem hiding this comment.
Require explicit approval before registry installs.
This section instructs autonomous installation from the public registry (worker::add) without requiring explicit user approval or a trust allowlist. Given this prompt is auto-appended in the run path and paired with headless Bash(iii *), this materially increases supply-chain and prompt-injection blast radius.
Suggested prompt hardening
-Need a backend capability? Check what is already registered FIRST — it is usually one call
-away. When nothing fits, search the public registry before building anything:
+Need a backend capability? Check what is already registered FIRST — it is usually one call
+away. When nothing fits, search the public registry before building anything, but DO NOT
+install anything until the caller explicitly approves the exact worker name and source:
...
-`iii trigger worker::add --json '{"source":{"kind":"registry","name":"<name>"}}'`, then
+`iii trigger worker::add --json '{"source":{"kind":"registry","name":"<name>"}}'` only after
+explicit caller approval (or preconfigured allowlist), then🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@claude-code/src/iii-prompt.ts` around lines 43 - 50, Update the prompt text
and runtime guards so registry installs cannot be executed headlessly: replace
the direct "iii trigger worker::add --json ..." recommendation with an explicit
approval step and allowlist check (e.g., require a user confirmation prompt and
validate against a TRUSTED_REGISTRIES/allowlist) and ensure the headless runner
(Bash(iii *)) and any auto-appended "iii-prompt" content will not call "iii
trigger worker::add" unless confirmation succeeds; keep the guidance to verify
installation with "engine::functions::list" and "iii trigger
directory::registry::workers::info", but stop auto-execution by adding a
confirmation/allowlist gate before invoking "iii trigger worker::add".
…e defaults - approval gate now forces permissionMode to 'default' so the named permission_mode field can't smuggle bypassPermissions past the gate; canUseTool stays authoritative for every tool call - claude::start marks the session 'error' best-effort when a background run throws, so a failed terminal save never leaves it stuck 'working' - mapUsage defaults absent cache token fields to 0 instead of undefined From an /improve audit. 70 tests (3 new).
# Conflicts: # README.md
- reject a run when one is already live for the session (live.has guard): the in-process handle is what claude::stop targets, so a second run would clobber it and race the shared record - persist 'working' only after the query + live handle exist, so a throw during setup never leaves the record stuck in 'working' 71 tests (1 new).
Read-only status / sessions::list allow-listed; run/start/stop (and the run::start_and_wait alias) stay at needs_approval since they spawn a full Claude Code agent with host fs+shell.
Migrate off file-only config.yaml to the configuration-worker pattern: config.yaml is the seed (initial_value); the live value is authoritative and hot-reloads on configuration:updated. - src/configuration.ts: registerClaudeConfig / fetchRuntime / bindConfigTrigger (reconcile now + on every update) using the configuration:: builtins. - config.ts: RuntimeConfigSchema = ConfigSchema without engine_url (bootstrap, stays on --url/seed); runtimeJsonSchema + toRuntime helpers. - index.ts: load seed -> registerClaudeConfig -> bindConfigTrigger (fetch + reconcile into a live holder, re-resolving claude_executable) -> register with a () => Config getter so handlers read the live snapshot. - stream names are read once at boot for the emitters (a change needs a restart); every other field hot-reloads. 74 tests (3 new). Verified live: schema registered, configuration::get returns the value, claude::run runs, configuration::set accepted.
- add claude-code to create-tag.yml options and release.yml tag patterns (SOP § 6 — without it a release tag triggers nothing) - registerClaudeConfig is now best-effort: a configuration-worker hiccup at boot logs + continues with the seed instead of crashing the process - deny the internal claude::on-config-change from agent calls Merged latest main. 74 tests, biome clean, bundle builds.
From an adversarial review. Two real races in executeRun: - TOCTOU: live.has() and live.set() were split by the loadSession await, so two concurrent same-session calls both passed the guard and the second clobbered the first's interrupt handle. Now the check + set are synchronous with no await between them (atomic on JS's single thread). - handle leak: live.set() ran before the try, so a rejected working-save left the session stuck reporting busy forever. The body moved into runReserved(), wrapped by a try/finally that always releases the slot, setup throws included. 75 tests (1 new: slot released when the working-save rejects).
New Node worker that drives headless Claude Code turns over the iii bus.
Summary by CodeRabbit
New Features
Documentation
Configuration
Tests
Chores