Skip to content

M2-34 Claude Code runner core

No Date Ruling Reason
D-M2-34-0 2026-10-23 Built first on an integration branch with the dependencies stacked, then ported onto main after M2-03, M2-12 and M2-29 landed: the ticket's own commits are cherry-picked; the spec text of 18.1 to 18.4, 18.7 and 21 is written into the English chapter 09-agents.md, the tool class table of 18.2, R-AG-23 and D-W-16 were on main already; the rulings and changelog entries are fragments (PL/24). The dependencies reached main before this ticket; the rules of main replace those of the integration branch.
D-M2-34-1 2026-10-21 The runner is Python on the SDK, asyncio, POSIX only (Linux, macOS; run refuses other platforms); distribution claude-code-runner, package claude_code_runner, heartbeat names claude-code-runner and claude-code; no mkdocstrings page: the public symbols carry google docstrings checked by ruff D, the operator interface is the README and 09 21. Process groups, flock and the signals of 18.6 have no Windows equivalent the spec names; 02 6 lists no place of the product name for the runner; 23 names generated API docs only for the SDKs.
D-M2-34-2 2026-10-21 One TOML file (--config, tomllib), keys nested by their dots ([runner], [adapter], [agent], [sdk]); unknown keys, wrong types, missing required keys and invalid durations (ms, s, m, h, d) stop the start with key and file, never a value. Thirteen new keys in 09 21 (runner.server_url, runner.instance_id, runner.agent_token_ref, runner.dpop_key_ref, runner.consumer, runner.tool_class, runner.scoped_allowed_tools, runner.default_model, runner.anthropic_api_key_ref, runner.allow_personal, runner.forward_ssh_agent, runner.cli_probe_timeout, runner.poll_interval); the existing runner keys runner.followup_grace and runner.attach_* are read and unused until M2-35 and M3-12. secret_ref values (file, env) are read fresh for each use into the SDK's redacted Secret. runner.server_url is the server origin; the runner appends /api/v1. A DPoP key must be a file: reference. configuration.md 2 and 3; a value that can be printed must never hold a secret.
D-M2-34-3 2026-10-21 Acceptance: the actual tool class is runner.tool_class (above the claim the SDK refuses with tool_class_mismatch); the actual mode is the claim's when the runner may run it (organization always, personal only with runner.allow_personal), else unknown (billing_mode_mismatch); after prepare the mode of the pre-check evidence. No allow list of point 9. A trigger_kind command has no counterpart in the SDK verifier yet and ends invalid_invocation_token there (M2-29); interaction reaches prepare and ends adapter_error. I1 and I2 are decided by the server and carried by the token; the binding evidence must describe the environment of the turn (10 7).
D-M2-34-4 2026-10-21 Prepare order: trigger kind, binding (reload when settings_version is newer), runtime degraded, project, session and model choice, thread lock then session lock then a slot, context (404 or run_inactive end the run without finishRun), attachments, environment and pre-check, start options. The SDK got RunRefused(reason), which ends a run from prepare with exactly that reason (or without finishRun), and AgentHandler.on_not_started, which frees slot and locks of every prepare that does not reach execute. A lost finishRun answer (5xx, 429, transport) is sent again with the same body. 09 4.9 and 18.11; the SDK mapped every prepare exception to adapter_error; a run must never stay running because one answer was lost, and only the call is repeated, never the turn (R-AG-17).
D-M2-34-5 2026-10-21 Workspace: refuse an empty, absolute, ~ or - led reference, one with \, a drive letter or a .. segment; join with the real root, realpath, require a strict descendant; the real path is the working folder. Ready: a folder with .git (folder or file) and read, write and search rights; log codes outside_root, workspace_missing, not_a_git_worktree, not_writable; computed at start, after channel cache changes and before every heartbeat. Defence in depth after M2-03 note 4; linked work trees count.
D-M2-34-6 2026-10-21 The run environment is built from scratch: HOME, USER, LOGNAME, SHELL, LANG, TERM, TMPDIR, LC_*, a cleaned PATH (absolute, existing, not group or world writable, no duplicates), SSH_AUTH_SOCK only with runner.forward_ssh_agent; organization adds ANTHROPIC_API_KEY and <runner.state_dir>/claude-config/organization; personal adds nothing. 09 18.2, 18.3; a writable path folder or an agent socket would hand the turn more than its class (T-DEV-01).
D-M2-34-7 2026-10-21 Pre-check table: organization with api_key and personal with claude.ai or oauth_token start; none is runtime_not_authenticated; every other value and unknown is billing_mode_mismatch; key_type from authMethod alone; the exit code is ignored; organization without a key calls no CLI. 09 5.2 and 18.3, BR-12: the runner never falls back to a subscription.
D-M2-34-8 2026-10-21 Command line in the order of 09 18.2; class flags exactly the cells of the table (scoped one argument per tool, --allowedTools left out without tools); allowed_tools narrows only scoped; --add-dir with the thread's incoming folder only for scoped and host with stored files; model from the binding, else runner.default_model; session by agent_session.started only; values that start with - or hold controls are refused. dontAsk reads only in the working folder; bypassPermissions honours no allow list; arguments must never turn into flags.
D-M2-34-9 2026-10-21 Prompt split: the system prompt file holds the fixed runner instructions (TITLE request only for a new or unstarted session) and the channel prompt in its own section; the first stdin line holds the rendered context with the attachment paths as the first content item and the trigger's words and transcripts as the second; attachments are files under files/<thread_id>/, named only as local file: <path> inside their data block; behavior.skills is unused. ADR 0054 and SEC-AI-1; the context stays in the session history for --resume; Claude Code loads skills from the working folder.
D-M2-34-10 2026-10-21 Stream and stderr go to files, read every runner.poll_interval; lock descriptors are inherited by the turn; transcripts, prompt files and downloaded attachments are swept after runner.stream_log_retention at start and after every turn; terminate is SIGINT, SIGTERM after runner.stop_grace, SIGKILL after runner.kill_grace to the group, and a cancelled execute terminates the group before the cancellation propagates. 18.2 "Detachment" and "Locks", 18.6; a runtime never outlives its run.
D-M2-34-11 2026-10-21 The stream field table of 18.4 [derived], step titles from the first string member in a fixed order, the reply without the TITLE: line, the title once after the first completed run of a new session, cost as the per-model difference of the cumulative values with a reset on a negative difference, half-even micros and a pending record in usage.json. Logs are JSON lines with codes and IDs only; the WebSocket library's frame dumps stay at level warning because a frame can hold the API token. 09 12, 18.4, 18.7; SEC-LOG-2.
D-M2-34-12 2026-10-21 MIN_CLAUDE_CODE_VERSION and PINNED_CLAUDE_CODE_VERSION are both 2.1.259; the version probe runs at start and again in the background whenever the SDK builds a heartbeat (so a heartbeat carries the outcome of the probe before it); the real-CLI suite has level one (CLAUDE_CODE_RUNNER_REAL_CLI=1) and level two (also CLAUDE_CODE_RUNNER_REAL_TURN=1); the FakeServer got the routes and world parts the scenarios need (forums with bindings, agent.config_changed, getMe, downloadFile, getThread, setThreadTitle, reportAgentSession, files, contexts per run, Fault.problem) and accepts costs only with FakeServer(costs=True), because the server books costs only from M2-22 on. The agent under test of the FakeServer stays Lotse, and Texter is the other agent of the scenarios. RK-14; 09 16.1 names the FakeServer the shared adapter test double; the heartbeat hook must not block on a process.
D-M2-34-13 2026-10-23 Fake-Claude never waits a fixed real time: a scenario holds auth status or a turn with a gate file the test creates (auth_gate, turn_gate), and helper.install and helper.update refuse the former keys auth_sleep, version_sleep and delay; Fake-Claude holds SIGINT back while it writes, so its handler never runs inside a blocked flush of the same buffer. Every wait of the runner tests is bounded only by quality.test.patience (bothy_sdk.testing._patience.PATIENCE), including the scenario world's session_lock_wait and cli_probe_timeout unless a test of that limit sets its own. The red team reproduced a failure of the AZ-P6 token scenario on a throttled machine: the clock steps outlasted a two second auth_sleep, so the token never expired before the pre-check answered. The delta red team reproduced the counter-check scenario failing when SIGINT arrived inside Fake-Claude's flush (a reentrant write killed it before it recorded its end).
D-M2-34-14 2026-10-24 Two SonarCloud findings are marked NOSONAR with a reason: the synchronous open in locks.acquire and the read of the --config path in config.load_config. Opening a local lock file does not block, and a thread executor would leak the descriptor when the waiting task is cancelled; the --config path comes from the operator's command line, not from a model.