Files
tutti/docs/architecture/claude-code-sdk-runtime.md
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

346 lines
21 KiB
Markdown

# Claude Code SDK Runtime
Claude Code has one supported runtime path: the daemon starts the
`@tutti-os/claude-sdk-sidecar` package, and the sidecar talks to the Claude Agent
SDK.
The product-fidelity and acceptance contract is maintained in
[Claude Code SDK Refactor Fidelity Requirements](./claude-code-sdk-requirements.md).
## Boundary
```mermaid
flowchart LR
Service["tuttid agent services"] --> Adapter["Go Claude SDK adapter"]
Adapter <-->|"versioned NDJSON over stdio"| Sidecar["TypeScript SDK sidecar"]
Sidecar --> SDK["Claude Agent SDK"]
Adapter --> Activity["normalized agent activity"]
Activity --> Consumers["Agent GUI and other consumers"]
```
The public boundary is normalized agent activity and capability data. The GUI
must branch on capabilities or event semantics, not on the Claude Code provider
name. Claude-specific SDK messages, tool shapes, session cursors, and transport
details stay behind the daemon adapter and sidecar.
## Daemon ownership
The Go runtime under `packages/agent/daemon/runtime` is split by responsibility:
| Module | Ownership |
| ------------------------------ | -------------------------------------------------------- |
| `claude_sdk_adapter.go` | Adapter construction, shared state, and sink wiring |
| `claude_sdk_lifecycle.go` | Session start, resume, close, and live-session release |
| `claude_sdk_execution.go` | Prompt validation, execution, guidance, and cancellation |
| `claude_sdk_settings.go` | Live settings and permission-mode application |
| `claude_sdk_transport.go` | Sidecar process and NDJSON transport |
| `claude_sdk_protocol.go` | Protocol version and envelope validation |
| `claude_sdk_session.go` | Session storage, command/env helpers, and state payloads |
| `claude_sdk_events.go` | Sidecar event dispatch and lifecycle routing |
| `claude_sdk_turn.go` | Per-turn event lifecycle via shared `acpTurnNormalizer` |
| `claude_sdk_activity.go` | Conversion into normalized activity events |
| `claude_sdk_live_state.go` | Live message, task, and usage state reconciliation |
| `claude_sdk_interactive.go` | Approval and interactive prompt handling |
| `claude_sdk_goal.go` | Goal and plan lifecycle projection |
| `normalized_session_events.go` | Protocol-neutral session activity projection |
The descriptor selects the SDK runtime kind; these adapter modules own the
process/session lifecycle and normalization behind that selection. They must not expose
raw SDK envelopes to services or GUI packages. Service-layer provider catalogs,
composer profiles, targets, status probes, and identity projections consume the
same `ProviderDescriptor` instead of re-registering Claude Code locally.
Provider preparation may inject system-prompt and plugin paths that exist only
in the sidecar runtime filesystem. The Go adapter treats those environment
values as opaque and forwards them unchanged; `options.ts` reads and validates
the referenced files after the sidecar starts. Daemon code must not inspect
those paths because remote transports do not share its filesystem.
Claude modules do not call ACP _protocol_ helpers (session-update decoding,
standard ACP tool-call envelopes, and similar). Open tool-call bookkeeping and
turn `Finish*` settlement reuse the shared adapter turn lifecycle
(`acpTurnNormalizer`), the same entity Codex and standard ACP already use, so
cancel/fail/complete close dangling tool cards instead of leaving them
in progress. Protocol-neutral session and interactive activity projection have
their own modules, while Claude goal, command, usage, and interaction decoding
stay inside the Claude SDK boundary.
Claude Goal state comes only from provider-owned Goal observations. The
sidecar normalizes both SDK `active_goal` messages and native `/goal`
`goal_status` transcript attachments into one `goal_observed` event. Local
Claude Agent SDK `0.3.220` streams omit those attachment records from the
public `SDKMessage` iterator. The root `system/init` message supplies the
provider Session ID and cwd; `goalTranscript.ts` mirrors the pinned SDK's local
project-key algorithm to locate its JSONL. Restore starts from the already
known provider Session ID, while a generation-scoped `SessionStart` callback
can still supply the SDK-provided exact `transcript_path`. The reader consumes
only rows appended after observation began, drains once before the root result
settles, and continues following the file because Claude's native evaluator
may flush the final `goal_status` just after the SDK result. Starting at the
existing end of file prevents a restored session from replaying an old active
or completed Goal. A
non-null `active_goal` or `goal_status.met=false` keeps the condition active;
`goal_status.met=true` completes it. A null `active_goal` is interpreted as
explicit clear or completion using the exact command action and previous Goal
observation. Ordinary Turn completion has no Goal semantics.
Command-consumption evidence travels as the internal `goal.control_applied`
event to the Host Goal lane and must never be embedded in session runtime
context.
New normalized session updates use `sessionUpdateKind`; the former ACP-named
metadata key is accepted only while reading imported or durable historical
events.
Context usage is published only when the provider reports a context-window
limit or the live Session already has a provider-reported limit for the same
model. Token deltas from a new Session do not synthesize a 200k or 1M
denominator from the model name; AgentGUI keeps usage hidden until the first
authoritative window arrives. Restore/resume must not await that snapshot
before `session_started`: Host Resume/Send completes on the start handshake,
and usage may refresh asynchronously afterward.
Model selection preserves requested and resolved state separately. The model
config option's `currentValue` remains the user's selection, including
`default`, while `effectiveValue` is the concrete model reported by the
per-user SDK runtime. The sidecar begins consuming the Query after
`session_started` so the SDK's `system/init.model` can publish that fact before
the first prompt; root assistant messages continuously reconcile it. A live
switch back to `default` clears the previous effective value until the SDK
reports the newly resolved model. Hosts and renderers must not derive the
effective model from the advertised Default option's label or description.
Claude credential-sensitive operations share the process-wide gate owned by
`services/tuttid/service/claudecode`. Real session startup, hidden model
discovery, and `claude auth status` acquire this same gate. The AgentGUI's
initial provider demand only checks local availability; Anthropic usage is
queried after the user opens a usage surface or explicitly refreshes it.
Composer model discovery uses an account-level scope made from provider,
agent-target identity, and a non-secret auth fingerprint. It intentionally does
not include workspace or caller cwd for Claude Code, so switching workspaces
cannot create duplicate discovery processes. The process runs from the
daemon-owned discovery directory. A composer request waits at most 20 seconds,
but that wait does not cancel the SDK initialization: the background lifecycle
may continue for up to ten minutes so OAuth refresh state can be persisted.
Transient failures before a session starts remain retryable. Auth invalidation
marks in-flight results superseded and clears caches, but never closes a hidden
discovery session early; the original ten-minute lifecycle remains responsible
for teardown so credential refresh has time to finish persisting.
The descriptor's `CLAUDE_CONFIG_DIR` root override is honored consistently by
runtime endpoint discovery, status/custom-config inspection, and auth watching.
## Sidecar ownership
`packages/agent/claude-sdk-sidecar/src/main.ts` is only the stdio server and
request router. `sessionRuntime.ts` coordinates a session through focused
collaborators:
- `protocol.ts` and `eventSink.ts`: versioned wire envelopes and event emission.
- `sessionConfiguration.ts`, `sessionSettings.ts`, and `options.ts`: SDK query
configuration and mutable session settings.
- `promptQueue.ts` and `turnLifecycle.ts`: prompt ordering and turn ownership.
- `goalExecQueue.ts`: queued Goal command ordering and supersession.
- `goalProjection.ts` and `goalTranscript.ts`: provider Goal normalization and
narrow recovery of SDK-omitted transcript evidence.
- `queryGeneration.ts` and `queryHooks.ts`: one SDK Query execution generation,
its prompt/abort resources, and generation-scoped SDK hooks.
- `messageRouter.ts`, `messageProjection.ts`, `assistantStream.ts`, and
`sdkMessages.ts`: SDK message routing and assistant output projection.
- `toolActivity.ts`, `toolEvents.ts`, `toolActivityTypes.ts`, `taskPlan.ts`, and
`taskNotification.ts`: normalized tool, delegated-task, and plan activity.
- `interactive.ts` and `interactiveTurnResolver.ts`: approvals, interactive
questions, and canonical Turn ownership resolution.
- `compaction.ts` and `usage.ts`: context compaction and usage reporting.
Restore/resume publishes `session_started` before refreshing context usage;
`getContextUsage()` runs in the background and must not gate Host Resume.
- `authDiagnostics.ts`, `errors.ts`, and `runtimeValues.ts`: diagnostics and
small runtime value helpers.
- `testDriver.ts`: isolated deterministic sidecar-driver behavior used by
runtime integration tests.
The dependency direction is from `main.ts` to `sessionRuntime.ts`, then to these
collaborators. Collaborators must not import `main.ts` or own the stdio loop.
Projection modules emit typed sidecar events; they do not call daemon or GUI
code.
The outbound root user UUID is correlation evidence, not provider identity.
Normally the live SDK iterator returns the persisted root user message and the
sidecar binds its UUID before projecting the provider Turn. A successful query
may omit that echo and begin with assistant output or an interactive tool.
Every root assistant, stream, tool, approval, user-input, and result path enters
one shared single-flight identity barrier before projection. The barrier reads
the official provider transcript, resolves exactly one root user message by the
opaque correlation UUID, and emits `provider_turn_identity_resolved` with the
latest persisted checkpoint. A bounded cancellable retry handles short
transcript write delay; absence at the deadline and ambiguity fail explicitly.
The Go adapter converts the resolved identity into a durable acceptance
receipt. Its event reader blocks until the Host atomically persists the
canonical Turn, provider Session, and provider Turn mapping. The canonical
`root_provider_turn.started` is published exactly once before streaming or
interactive activity is released. Provider start, checkpoint, and completion
never derive identity from the outbound UUID alone.
Raw sidecar stderr is never copied into activity, logs, or user-visible errors.
The Go transport retains only a bounded failure classification; explicitly
prefixed auth diagnostics are separately sanitized before structured logging.
SDK Query lifetime is narrower than durable provider-session lifetime. The
sidecar owns Query termination; an RPC caller deadline alone is never treated
as cancellation because it cannot stop an already-dispatched SDK control
request. A user cancel carries the exact canonical Turn, a cooperative
interrupt budget, and a consumer-drain budget, and returns one structured disposition:
`pre_accept`, `provider_active`, `absent`, or `mismatch`. An undispatched queue
entry can be removed locally. Once the prompt has been dispatched, cancel first
revokes the current Query generation and requests the SDK interrupt. If Claude
Code acknowledges within the cooperative budget, shutdown remains graceful. If
the acknowledgment is missing or fails, the sidecar closes the owned SDK Query
transport; the pinned SDK then ends stdin and escalates its Claude Code child
from `SIGTERM` to `SIGKILL`. The sidecar waits only for the separate drain budget
for its consumer to settle. A drain timeout is an explicit bounded failure and
never leaves the request pending. Revocation fences message routing, hooks, and
`canUseTool` before permission-mode handling, including `bypassPermissions`;
background-task notifications from the canceled generation therefore cannot
start a synthetic continuation or execute another tool. Every Turn already
handed to the retired Query's prompt queue settles after that same authoritative
shutdown boundary, including a drained Goal command that can no longer execute;
a Goal command still in the sidecar's deferred queue is independent and remains
eligible for exact local removal. For a
`provider_active` response, the Go adapter also waits for the exact Turn's
durable provider-acceptance outcome before confirming cancellation. `absent`
is the only authoritative not-found result; mismatches, unknown dispositions,
drain failures, and acceptance failures remain fail-closed. The next real
user prompt creates a fresh Query with
`resume: providerSessionId` and generation-local prompt/abort resources. If the
SDK replays the canceled generation's terminal task notification and paired
result during resume, the new generation consumes that tail without attaching
it to the new canonical Turn. Session close remains permanent and closes the
current generation without creating a resumable successor.
The Go adapter also tracks each accepted-but-not-started typed Goal command by
its operation, revision, repair epoch, action, and previous optimistic Goal
mirror. A Goal-generation fence targets both those pending commands and Turns
that already have provider bindings. When the sidecar confirms an exact pending
Goal cancellation, the adapter removes its bookkeeping and restores the
previous mirror only while that same identity is still current, so a stale
cancellation cannot overwrite a newer Goal. `goal_command_started` records
provider progress but does not commit the optimistic mirror; an authoritative
Goal observation or successful command terminal commits it, while cancellation,
failure, or supersession closes the exact pending transaction.
Guidance preempts a provider response without ending its canonical Turn. The
sidecar captures the already-active Query and calls its SDK `interrupt()` before
the guidance handler first yields; it never waits for query creation or pending
configuration while the old response continues. As soon as that interrupt
succeeds, the sidecar emits `guidance_interrupted`, and only then queues the
guidance prompt. The daemon uses that response boundary to settle open thinking,
assistant, and tool projections without emitting a Turn terminal. Claude's
later matching `error_during_execution` is bookkeeping only. Output produced
for the guidance then starts a fresh response projection on the same canonical
Turn.
Background-task lifecycle uses the SDK's `background_tasks_changed` system
message as a level signal. Its `tasks` array fully replaces the previous live
set. An empty set means the background children have quiesced; it does not mean
the root Turn is complete. When a successful ordinary root result arrives while
a background continuation is already pending, the sidecar retains the original
root Turn until session idle instead of emitting a terminal/start pair. If the
root result already settled before the pending signal, the sidecar reserves a
synthetic continuation. It remains in the existing running phase,
and results with `origin.kind = task-notification` confirm background
follow-up output without settling it by notification/result count. The SDK's
`session_state_changed: idle` event is the authoritative turn-over edge after
the held-back result and background-agent loop drain. The sidecar does not
start a post-result settlement timer: queued follow-ups may legitimately begin
several seconds apart, and interrupting that queue fabricates provider errors.
If root output does not begin within 30 seconds, the sidecar emits the
`continuation_delayed` warning, completes the synthetic reservation with
`background_agent_continuation_timeout`, interrupts the pending SDK query, and
rejects that continuation's late output so it cannot attach to a later Turn.
Exact task terminal events and the level signal are not ordered by contract.
The sidecar therefore waits for a short quiescence grace after the live set
becomes empty. Exact terminal edges received during that window win; after the
window, the daemon marks only still-unresolved asynchronous child Turns as
interrupted. Background-level diagnostics record aggregate counts for tasks
observed by the SDK level signal and exact delegated-task states known to Tutti.
This makes missing terminal edges visible in logs while leaving the GUI's
existing running/processing presentation unchanged.
The background-task level tracker owns its quiescence timer and continuation
reservation state. Query cancellation clears both before interrupting the SDK.
A failed or canceled root result fences later empty-level and task-notification
signals from opening a synthetic continuation, while those signals may still
report diagnostics or settle their exact child lifecycle.
The daemon resolves one owner for each Claude tool event before both closed-Turn
admission and activity projection. A delegation call belongs to the Session
that launched the child; an ordinary tool executed by a child belongs to that
child. Settling the child therefore cannot suppress a later completion of its
still-open parent Agent call, and repeated parent updates cannot move a terminal
child back to running.
The SDK may also report one completed child twice: first as `task_updated` with
only its task description, then as `task_notification` with the actual result.
The first edge settles the child lifecycle; the later notification updates the
same child assistant-message snapshot and marks its root continuation pending.
Several queued notifications may be coalesced into fewer follow-up results, so
they share the currently active root provider Turn or one reserved synthetic
Turn. Result origin identifies those follow-ups; session idle, rather than a
cardinality comparison, terminates the shared continuation.
## Protocol and compatibility
The daemon and sidecar exchange newline-delimited JSON over standard input and
output. Every request and event carries the current protocol version. Missing
or unsupported versions fail explicitly. Change both
`claude_sdk_protocol.go` and `src/protocol.ts` together and cover the change on
both sides. Version 10 makes the cooperative-interrupt and consumer-drain
budgets required cancel-request fields. Version 9 makes the cancel disposition, exact canonical Turn ID,
provider Turn ID, and dispatch phase correctness-required response fields.
Capability and composer contracts are intentionally stable across this runtime
split. Imported historical metadata may still be read for display compatibility
without affecting runtime selection.
Desktop packaging runs the deterministic sidecar protocol smoke twice: once
after production dependencies are vendored, and again from the final Electron
Resources directory after symlinks have been replaced. Both checks must complete
`start`, `exec`, and `close` without reading repository sources.
The vendored bundle excludes the native `claude` executable (it still carries
the SDK's JS, type metadata, and `manifest.json`). The binary the SDK spawns
is provisioned at runtime by tuttid from the CDN (npm mirrors as fallback),
pinned and verified against the vendored SDK's `manifest.json` (see
`services/tuttid/service/agentstatus/claude_binary.go`). The sidecar picks the
executable in `src/executablePath.ts`: an explicit `CLAUDE_CODE_EXECUTABLE`
always wins, a native package next to the SDK (dev tree) comes next, and
`TUTTI_CLAUDE_CODE_FALLBACK_EXECUTABLE` — the provisioned binary or a
PATH-installed claude, chosen by `runtimeprep.ClaudeCodePreparer` — covers the
packaged app.
`close` is a request/ack boundary, not a fire-and-forget signal. The sidecar
awaits SDK query shutdown before replying `ok`; the daemon only then closes
stdin and the process. Transport reads are context-aware so a request timeout
can stop waiting without implicitly killing a provider process.
## Code health and validation
Production business files follow the repository limit of at most 800 lines.
Before adding another responsibility to a file near that limit, extract a
named collaborator with one owner and a directed dependency. Tests are grouped
by session, assistant streaming, delegated tasks, nested tasks, interaction,
and lifecycle behavior rather than collected in one fixture file.
Run these focused checks after changing the runtime:
```sh
cd packages/agent/claude-sdk-sidecar
pnpm typecheck
pnpm test
pnpm exec oxlint src
cd ../daemon
go test ./runtime
golangci-lint run ./runtime/...
```
Also run `pnpm check:changed` from the repository root for cross-package
contracts and packaging checks.