@gajae-code/coding-agent 0.19.2-nightly.20261028085024.40673953840.g86cbbabff393 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.19.2] - 2026-10-29
6
+
5
7
  ## [0.19.1] - 2026-10-28
6
8
 
7
9
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/coding-agent",
4
- "version": "0.19.2-nightly.20261028085024.40673953840.g86cbbabff393",
4
+ "version": "0.19.2",
5
5
  "description": "Gajae Code CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -57,12 +57,12 @@
57
57
  "@agentclientprotocol/sdk": "1.3.0",
58
58
  "@babel/parser": "^7.29.3",
59
59
  "@mozilla/readability": "^0.6.0",
60
- "@gajae-code/stats": "0.19.2-nightly.20261028085024.40673953840.g86cbbabff393",
61
- "@gajae-code/agent-core": "0.19.2-nightly.20261028085024.40673953840.g86cbbabff393",
62
- "@gajae-code/ai": "0.19.2-nightly.20261028085024.40673953840.g86cbbabff393",
63
- "@gajae-code/natives": "0.19.2-nightly.20261028085024.40673953840.g86cbbabff393",
64
- "@gajae-code/tui": "0.19.2-nightly.20261028085024.40673953840.g86cbbabff393",
65
- "@gajae-code/utils": "0.19.2-nightly.20261028085024.40673953840.g86cbbabff393",
60
+ "@gajae-code/stats": "0.19.2",
61
+ "@gajae-code/agent-core": "0.19.2",
62
+ "@gajae-code/ai": "0.19.2",
63
+ "@gajae-code/natives": "0.19.2",
64
+ "@gajae-code/tui": "0.19.2",
65
+ "@gajae-code/utils": "0.19.2",
66
66
  "@puppeteer/browsers": "^2.13.0",
67
67
  "@types/turndown": "5.0.6",
68
68
  "@xterm/headless": "^6.0.0",
@@ -101,7 +101,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
101
101
  "sdk-embedding.md": "# SDK\n\nFor managed control and notification attachment policy, see [the Gajae-Code SDK](./sdk.md).\n\nThe SDK is the in-process integration surface for `@gajae-code/coding-agent`.\nUse it when you want direct access to agent state, event streaming, tool wiring, and session control from your own Bun/Node process.\n\nFor process-isolated control, use a managed SDK-core adapter backed by `SessionRouter`; raw endpoint discovery and direct WebSocket clients are not public integration surfaces.\n\n## Installation\n\n```bash\nbun add @gajae-code/coding-agent\n```\n\n\n## Entry points\n\n`@gajae-code/coding-agent/sdk` is the canonical entry point for embedders. The package root exports the same SDK APIs for convenience.\n\nCore exports for embedders:\n\n- `createAgentSession`\n- `SessionManager`\n- `Settings`\n- `AuthStorage`\n- `ModelRegistry`\n- `discoverAuthStorage`\n- Discovery helpers for retained context/prompt surfaces (`discoverContextFiles`, `discoverPromptTemplates`)\n- Tool factory surface (`createTools`, `BUILTIN_TOOLS`, tool classes)\n\n## Quick start (auto-discovery defaults)\n\n```ts\nimport { createAgentSession } from \"@gajae-code/coding-agent\";\n\nconst { session, modelFallbackMessage } = await createAgentSession();\n\nif (modelFallbackMessage) {\n process.stderr.write(`${modelFallbackMessage}\\n`);\n}\n\nconst unsubscribe = session.subscribe((event) => {\n if (\n event.type === \"message_update\" &&\n event.assistantMessageEvent.type === \"text_delta\"\n ) {\n process.stdout.write(event.assistantMessageEvent.delta);\n }\n});\n\nawait session.prompt(\"Summarize this repository in 3 bullets.\");\nunsubscribe();\nawait session.dispose();\n```\n\n## What `createAgentSession()` discovers by default\n\n`createAgentSession()` follows “provide to override, omit to discover”.\n\nIf omitted, it resolves:\n\n- `cwd`: `getProjectDir()`\n- `agentDir`: `~/.gjc/agent` (via `getAgentDir()`)\n- `authStorage`: `discoverAuthStorage(agentDir)`\n- `modelRegistry`: `new ModelRegistry(authStorage)` + background `refreshInBackground()` when the registry is not provided\n- `settings`: `await Settings.init({ cwd, agentDir })`\n- `sessionManager`: `SessionManager.create(cwd)` (file-backed)\n- context files and prompt templates\n- built-in tools via `createTools(...)`\n- LSP integration (enabled by default)\n- `eventBus`: new `EventBus()` unless supplied\n\n### Required vs optional inputs\n\nTypically you must provide only what you want to control:\n\n- **Must provide**: nothing for a minimal session\n- **Usually provide explicitly** in embedders:\n - `sessionManager` (if you need in-memory or custom location)\n - `authStorage` + `modelRegistry` (if you own credential/model lifecycle)\n - `model` or `modelPattern` (if deterministic model selection matters)\n - `settings` (if you need isolated/test config)\n\n## Session manager behavior (persistent vs in-memory)\n\n`AgentSession` always uses a `SessionManager`; behavior depends on which factory you use.\n\n### File-backed (default)\n\n```ts\nimport { createAgentSession, SessionManager } from \"@gajae-code/coding-agent\";\n\nconst { session } = await createAgentSession({\n sessionManager: SessionManager.create(process.cwd()),\n});\n\nconsole.log(session.sessionFile); // absolute .jsonl path\n```\n\n- Persists conversation/messages/state deltas to session files.\n- Supports resume/open/list/fork workflows.\n- `session.sessionFile` is defined.\n\n### In-memory\n\n```ts\nimport { createAgentSession, SessionManager } from \"@gajae-code/coding-agent\";\n\nconst { session } = await createAgentSession({\n sessionManager: SessionManager.inMemory(),\n});\n\nconsole.log(session.sessionFile); // undefined\n```\n\n- No filesystem persistence.\n- Useful for tests, ephemeral workers, request-scoped agents.\n- Session methods still work, but persistence-specific behaviors (file resume/fork paths) are naturally limited.\n\n### Resume/open/list helpers\n\n```ts\nimport { SessionManager } from \"@gajae-code/coding-agent\";\n\nconst recent = await SessionManager.continueRecent(process.cwd());\nconst listed = await SessionManager.list(process.cwd());\nconst opened = listed[0] ? await SessionManager.open(listed[0].path) : null;\n```\n\n## Model and auth wiring\n\n`createAgentSession()` uses `ModelRegistry` + `AuthStorage` for model selection and API key resolution.\n\n### Explicit wiring\n\n```ts\nimport {\n createAgentSession,\n discoverAuthStorage,\n ModelRegistry,\n SessionManager,\n} from \"@gajae-code/coding-agent\";\n\nconst authStorage = await discoverAuthStorage();\nconst modelRegistry = new ModelRegistry(authStorage);\nawait modelRegistry.refresh();\n\nconst available = modelRegistry.getAvailable();\nif (available.length === 0)\n throw new Error(\"No authenticated models available\");\n\nconst { session } = await createAgentSession({\n authStorage,\n modelRegistry,\n model: available[0],\n thinkingLevel: \"medium\",\n sessionManager: SessionManager.inMemory(),\n});\n```\n\n### Selection order when `model` is omitted\n\nWhen no explicit `model`/`modelPattern` is provided:\n\n1. restore model from existing session (if restorable + key available)\n2. settings default model role (`default`)\n3. first available model with valid auth\n\nIf restore fails, `modelFallbackMessage` explains fallback.\n\n### Auth priority\n\n`AuthStorage.getApiKey(...)` resolves in this order:\n\n1. runtime override (`setRuntimeApiKey`)\n2. stored credentials in `agent.db`\n3. provider environment variables\n4. custom-provider resolver fallback (if configured)\n\n## Event subscription model\n\nSubscribe with `session.subscribe(listener)`; it returns an unsubscribe function.\n\n```ts\nconst unsubscribe = session.subscribe((event) => {\n switch (event.type) {\n case \"agent_start\":\n case \"turn_start\":\n case \"tool_execution_start\":\n break;\n case \"message_update\":\n if (event.assistantMessageEvent.type === \"text_delta\") {\n process.stdout.write(event.assistantMessageEvent.delta);\n }\n break;\n }\n});\n```\n\n`AgentSessionEvent` includes core `AgentEvent` plus session-level events:\n\n- `auto_compaction_start` / `auto_compaction_end`\n- `auto_retry_start` / `auto_retry_end`\n- `agent_failed` (diagnostic failure for the current attempt; the correlated\n terminal `agent_end` still follows and remains the lifecycle boundary)\n- `retry_fallback_applied` / `retry_fallback_succeeded`\n- `ttsr_triggered`\n- `todo_reminder` / `todo_auto_clear`\n- `irc_message`\n\n## Prompt lifecycle\n\n`session.prompt(text, options?)` is the primary entry point.\n\nFailure consumers should handle `agent_failed` as an additive diagnostic event,\nnot as terminal completion. It carries only the sanitized `{ code, message }`\ndiagnostic for the correlated attempt — never the raw provider/local error —\nso consumers must not depend on provider-specific detail or raw error objects;\ncontinue waiting for `agent_end` before releasing prompt ownership, deadlines,\nor transport resources. This ordering also applies when the run is canceled or\nenters a maintenance continuation.\n\nBehavior:\n\n1. optional command/template expansion (`/` commands, custom commands, file slash commands, prompt templates)\n2. if currently streaming:\n - requires `streamingBehavior: \"steer\" | \"followUp\"`\n - queues instead of throwing work away\n3. if idle:\n - validates model + API key\n - appends user message\n - starts agent turn\n\n### Queued input lifecycle\n\nKeep admission, consumption, and completion separate when embedding the SDK:\n\n| Session state | Admission | Consumption | Completion |\n| --- | --- | --- | --- |\n| Fresh or settled idle with an assistant tail | `prompt()` starts a turn; an explicit `followUp` is accepted into the executable queue | The next turn consumes the queued message and normally starts its own run | That run publishes its own terminal `agent_end` |\n| Live model/tool loop | `steer` may be consumed by the current run; `followUp` waits for the next turn | The live loop can consume the message before its terminal boundary | The message may share the current run and terminal; it is not necessarily a separate model call |\n| Prompt unwind after the Agent loop emitted `agent_end` | An explicit `followUp` is still accepted | The SDK schedules a queued-only successor after the unwind finishes; it is not left waiting for an unrelated prompt | The successor owns a new run and terminal boundary |\n| Paused, cancelled, or a non-resumable non-assistant tail | The message can remain queued | No automatic successor is promised until a supported resume/abort path makes the queue deliverable | The admission is not completion; clear/remove the queue or resume it explicitly |\n| Existing queued input ahead of a new submission | The new message is ordered behind the existing queue | Queue order is preserved; later plain prompts do not overtake an earlier follow-up | Each consumed message is correlated at its actual dequeue boundary |\n\n`session.waitForIdle()` waits for active Agent work, session settlement, and\ncontinuations that the SDK has scheduled. It is **not** a receipt that every\nsteering/follow-up queue is empty. Inspect `pendingMessageCounts` or use the\nqueue APIs when queue state matters. A queued follow-up can also share the\npredecessor's run and terminal when the live loop consumes it; do not equate\none accepted submission with one model call or one `agent_end` event.\n\nFor generic embedders, prefer an application-owned queue of bounded full turns\nsubmitted through `session.prompt()`, and use `steer`/`followUp` only for live\nconversational controls. When a queued control must remain pending until its\nexact ownership is known, use `submitUserMessage()`:\n\n```ts\nconst submission = await session.submitUserMessage(\"refresh the status\", {\n deliverAs: \"followUp\",\n queuePolicy: \"sequential\",\n trackSubmission: true,\n});\n\nawait submission.admitted;\nconst execution = await submission.execution;\n// execution.disposition is \"joined-current-run\", \"promoted-to-run\", or \"removed\".\n// For execution, attemptScope identifies the run that owns the terminal boundary.\nawait submission.terminal;\n```\n\n`submissionId` is generated per call, so identical message text remains\nindependently correlatable. `admitted` resolves only after the exact queue\nentry exists. `execution` resolves at same-run consumption, successor-run\npromotion, or exact removal. `terminal` resolves at the owning run's\n`agent_end` boundary, or immediately for removal. `submission.cancel()` removes\nthe entry when it is still queued; it returns `false` after execution or\nremoval. `queuePolicy: \"sequential\"` preserves FIFO delivery one queued input\nat a time, including when the session's configured follow-up/steering mode is\n`\"all\"`.\n\nThe ordinary `sendUserMessage` promise retains its delivery-mode-dependent\ncompletion semantics:\n\n- An ordinary idle submission with no `deliverAs` queues nothing and awaits the\n prompt turn, including its terminal completion.\n- An explicit queued `steer`/`followUp`, or a submission diverted into a queue\n because a live turn is active, resolves when the submission is admitted to\n that delivery path. Use `submitUserMessage` when later consumption,\n completion, and cancellation must be correlated to one exact submission.\n\nNeither promise is a generic queue-drained receipt. Do not build a generic\nembedder contract around internal dispatch or promotion-correlation hooks.\n\nRelated APIs:\n\n- `sendUserMessage(content, { deliverAs? })`\n- `steer(text, images?)`\n- `followUp(text, images?)`\n- `sendCustomMessage({ customType, content, ... }, { deliverAs?, triggerTurn? })`\n- `abort()`\n\n## Tools integration\n\n### Built-ins and filtering\n\n- Built-ins come from `createTools(...)` and `BUILTIN_TOOLS`.\n- `toolNames` acts as an allowlist for built-ins.\n- Hidden tools (for example `yield`) are opt-in unless required by options.\n\n```ts\nconst { session } = await createAgentSession({\n toolNames: [\"read\", \"search\", \"find\", \"write\"],\n requireYieldTool: true,\n});\n```\n\n### Runtime tool set changes\n\n`AgentSession` supports runtime activation updates:\n\n- `getActiveToolNames()`\n- `getAllToolNames()`\n- `setActiveToolsByName(names)`\n\nSystem prompt is rebuilt to reflect active tool changes.\n\n## Discovery helpers\n\nUse these when you want partial control without recreating internal discovery logic:\n\n- `discoverAuthStorage(agentDir?)`\n- `discoverContextFiles(cwd?, _agentDir?)`\n- `discoverPromptTemplates(cwd?, agentDir?)`\n- `buildSystemPrompt(options?)`\n\n## Subagent-oriented options\n\nFor SDK consumers building orchestrators (similar to task executor flow):\n\n- `outputSchema`: passes structured output expectation into tool context\n- `requireYieldTool`: forces `yield` tool inclusion\n- `taskDepth`: recursion-depth context for nested task sessions\n- `parentTaskPrefix`: artifact naming prefix for nested task outputs\n\nThese are optional for normal single-agent embedding.\n\n## `createAgentSession()` return value\n\n```ts\ntype CreateAgentSessionResult = {\n session: AgentSession;\n setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void;\n modelFallbackMessage?: string;\n lspServers?: Array<{\n name: string;\n status: \"ready\" | \"error\";\n fileTypes: string[];\n error?: string;\n }>;\n eventBus: EventBus;\n};\n```\n\nUse `setToolUIContext(...)` only if your embedder provides UI capabilities that tools should call into.\n\n## Startup performance\n\n`createAgentSession()` runs two background optimizations to overlap I/O with the rest of session setup:\n\n- **Model-host preconnect.** As soon as the model is resolved, the SDK fires a best-effort `fetch.preconnect(model.baseUrl)` so DNS + TCP + TLS + HTTP/2 to the provider's host happens in parallel with tool registry build, and system-prompt assembly. The first real `fetch(...)` then reuses the warm connection, saving 100–300 ms on transcontinental hops (e.g. residential IP → `api.anthropic.com`). `packages/coding-agent/src/sdk/session.ts` triggers it through the `startup.networkPrewarm` runtime service (`packages/coding-agent/src/runtime/network-prewarm-service.ts`); setting `startup.networkPrewarm: false` skips it. If `fetch.preconnect` is unavailable (non-Bun runtime) or the call throws, the optimization is skipped (failures are logged at debug level) — never a hard dependency. Applies to interactive, print, and ACP modes.\n- **Conditional LSP warmup.** Startup LSP servers (those returned by `discoverStartupLspServers(cwd)`) are only warmed when **all** of these hold:\n - `enableLsp !== false` on the session options, **and**\n - `options.hasUI === true` (interactive TUI), **and**\n - the `lsp.diagnosticsOnWrite` setting is enabled.\n\n Print, script, and ACP invocations (`hasUI=false`) skip the warmup entirely: they don't render the warmup status indicator and typically finish before the language servers would stabilize, so warming them just spends CPU parsing big `initialize` responses concurrently with the LLM stream consumer and jitters perceived latency. Tools that actually need an LSP server still spin one up on demand through `getOrCreateClient()` — only the *startup* warmup is skipped. The returned `lspServers` field in `CreateAgentSessionResult` is therefore `undefined` (not an empty array) whenever the warmup branch was bypassed.\n\n## Minimal controlled embed example\n\n```ts\nimport {\n createAgentSession,\n discoverAuthStorage,\n ModelRegistry,\n SessionManager,\n Settings,\n} from \"@gajae-code/coding-agent\";\n\nconst authStorage = await discoverAuthStorage();\nconst modelRegistry = new ModelRegistry(authStorage);\nawait modelRegistry.refresh();\n\nconst settings = Settings.isolated({\n \"compaction.enabled\": true,\n \"retry.enabled\": true,\n});\n\nconst { session } = await createAgentSession({\n authStorage,\n modelRegistry,\n settings,\n sessionManager: SessionManager.inMemory(),\n toolNames: [\"read\", \"search\", \"find\", \"edit\", \"write\"],\n enableLsp: true,\n});\n\nsession.subscribe((event) => {\n if (\n event.type === \"message_update\" &&\n event.assistantMessageEvent.type === \"text_delta\"\n ) {\n process.stdout.write(event.assistantMessageEvent.delta);\n }\n});\n\nawait session.prompt(\"Find all TODO comments in this repo and propose fixes.\");\nawait session.dispose();\n```\n",
102
102
  "sdk-owned-session-lifecycle-handoff.md": "# SDK-owned session lifecycle handoff\n\n## Status\n\n- Branch: `refactor/sdk-owned-session-lifecycle`\n- Worktree: `../gajae-code.gajae-code-worktrees/refactor-sdk-owned-session-lifecycle`\n- Base: `origin/dev` at `31c00c2d5bb462464046208a94a4597c27260a4c`\n- Review target: `dev`\n- Verdict: sound direction, with a required ownership qualification\n\n## Decision\n\nGJC SDK core must be the sole authority for coding-agent session lifecycle and external session attachments. Provider daemons must not create, resume, close, delete, signal, recover, or allocate identities for GJC sessions.\n\n> SDK core owns session lifecycle and attachment authority. Provider supervisors own provider transport authority.\n\nTelegram `getUpdates`, Slack Socket Mode, Discord Gateway, provider cursors, rate limits, and provider topic/thread/message state remain provider-owned because they are external transport and presentation resources, not AgentSession lifecycle resources.\n\n## Problem\n\nThe Broker and per-session SDK runtime already provide lifecycle idempotency, target serialization, a durable lifecycle ledger, startup admission, process-incarnation fencing, effect markers, readiness proof, endpoint-generation checks, exact cleanup evidence, and honest `terminal_uncertain` outcomes.\n\nThe chat-daemon path nevertheless has a second set of session authorities:\n\n- daemon-owned lifecycle control server, credential, pending table, and sequence;\n- chat lifecycle ledger and audit path;\n- tmux/session spawn, close, and resume effects;\n- intended session-ID allocation before Broker creation;\n- notification-root registration as the provider discovery universe;\n- direct endpoint discovery, credentials, reconnect, and action routing.\n\nA provider crash can therefore occur between provider-ledger persistence, Broker-ledger persistence, process effects, endpoint publication, and topic binding. Authority is divided by ingress process instead of by the mutated resource.\n\n## Non-goals\n\n- Do not move provider transport ownership into the Broker.\n- Do not weaken incarnation, generation, effect-marker, readiness, or exact-cleanup checks.\n- Do not add a compatibility lifecycle executor or dual writer.\n- Do not infer completion from terminal output, tmux, or provider delivery state.\n- Do not make topic/thread cleanup part of session lifecycle terminality.\n\n## Current ownership\n\n### Broker and lifecycle executor\n\nPrimary files:\n\n- `packages/coding-agent/src/sdk/broker/broker.ts`\n- `packages/coding-agent/src/sdk/broker/lifecycle.ts`\n\nRetain and strengthen:\n\n- Broker root lock/discovery/heartbeat;\n- `SessionIndex` and `LifecycleLedger`;\n- lifecycle idempotency and target serialization;\n- create/fork/resume/close/delete execution;\n- worktree, transcript, and managed-session validation;\n- effect marker and process-incarnation evidence;\n- readiness and endpoint-generation proof;\n- close escalation and exact cleanup reconciliation.\n\nThe Broker lifecycle executor becomes the only executor and durable terminal authority for session lifecycle mutations.\n\n### Per-session SDK runtime\n\nPrimary file: `packages/coding-agent/src/sdk/host/session-runtime.ts`.\n\nRetain:\n\n- AgentSession-local host/transport startup and shutdown;\n- endpoint URL, token, and generation publication;\n- control, query, event, replay, and reverse-provider semantics;\n- readiness and activation;\n- exact generation registration and unregistration with Broker.\n\nThe runtime owns live local resources; Broker owns the global transaction and permission to create or retire the runtime.\n\n### Provider-owned transport and presentation\n\nRetain outside SDK session lifecycle:\n\n- provider credential/account fingerprint;\n- one active provider poller or connection;\n- cursor, conflict handling, rate limit, retry, and backoff;\n- topic/thread/message/callback/attachment state;\n- provider delivery receipts and presentation reconciliation;\n- provider process lease, heartbeat, restart, and shutdown.\n\n### Authority to remove from Telegram/chat daemon\n\nPrimary files:\n\n- `packages/coding-agent/src/sdk/bus/telegram-daemon.ts`\n- `packages/coding-agent/src/sdk/bus/lifecycle-orchestrator.ts` (planned name, never created; the daemon-side grammar landed as `packages/coding-agent/src/sdk/bus/lifecycle-commands.ts`)\n\nRemove:\n\n- lifecycle control server ownership;\n- lifecycle endpoint credential and pending request ownership;\n- chat-owned lifecycle outcome ledger;\n- tmux/process lifecycle effects;\n- logical SessionId allocation or preallocation;\n- direct endpoint credentials and endpoint discovery;\n- provider-owned session discovery roots;\n- provider-specific endpoint-generation and reconnect authority.\n\nProvider update IDs may remain idempotency inputs, but not a second lifecycle result ledger.\n\n## Target architecture\n\n```text\nProvider boundary\n┌─────────────────────────────────────────────────────────────┐\n│ ChatDaemonSupervisor │\n│ provider lease, poller/connection, cursor, retry/rate limit│\n│ │\n│ ProviderAdapter │\n│ provider update → ChatIntent │\n│ PresentationEvent → provider payload │\n│ topic/thread/message/callback mappings │\n└────────────────────────┬────────────────────────────────────┘\n │ opaque intents/events\n ▼\nSDK core\n┌─────────────────────────────────────────────────────────────┐\n│ SessionRouter │\n│ Broker-index discovery, endpoint credential custody, │\n│ SDK connections, replay/reconnect, attachment leases, │\n│ prompt and ask-answer routing │\n│ │\n│ SessionLifecycleService │\n│ actor/capability authorization, canonical requests, │\n│ idempotency namespace, lifecycle certainty │\n│ │\n│ Broker │\n│ one ledger, one executor, index, process/effect recovery │\n│ │\n│ SessionSdkSessionRuntime │\n│ AgentSession, endpoint generation, controls/events/teardown│\n└─────────────────────────────────────────────────────────────┘\n```\n\nDependency direction:\n\n```text\nProviderAdapter\n → SessionRouter\n → SessionLifecycleService\n → Broker\n → SessionSdkSessionRuntime\n```\n\nForbidden paths:\n\n- Broker/runtime depending on provider concepts;\n- provider adapter calling Broker lifecycle internals;\n- provider adapter invoking tmux or process signals for session lifecycle;\n- provider adapter reading endpoint files or tokens;\n- provider adapter generating SessionIds;\n- provider state participating in Broker recovery proof.\n\n## Target components\n\n### SessionLifecycleService\n\nRecommended under `packages/coding-agent/src/sdk/lifecycle/`. It is a provider-neutral facade over existing Broker operations, not another state machine or ledger.\n\nResponsibilities:\n\n- authenticated actor and explicit capability input;\n- operation-specific authorization;\n- canonical lifecycle request normalization;\n- idempotency derived from actor namespace, caller request key, operation, and target;\n- invocation of the existing Broker path;\n- bounded lifecycle result and certainty;\n- no provider, topic, thread, tmux, or shell concepts.\n\n### SessionRouter\n\nRecommended under `packages/coding-agent/src/sdk/router/`.\n\nResponsibilities:\n\n- consume Broker `SessionIndex` snapshots and index sequence;\n- resolve exact endpoint authority through Broker;\n- remain the sole non-runtime holder of endpoint credentials;\n- own SDK connections, event replay, and reconnect;\n- revoke attachment leases when endpoint/index/router generation changes;\n- route provider-neutral prompts and answers through standard SDK operations;\n- reconcile prompt acceptance and terminal outcomes;\n- emit provider-neutral presentation events;\n- never perform process lifecycle effects.\n\nAttachment authority must bind session identity, endpoint generation/incarnation, and router epoch. Its provider representation should be opaque.\n\n### ChatDaemonSupervisor\n\nOwns only the provider-account process lease, poller/connection, cursor, API retry/backoff/rate limits, and independent provider restart/shutdown. It never creates, resumes, closes, or signals a GJC session.\n\n### ProviderAdapter\n\nValidates inbound provider identity, normalizes `ChatIntent`, renders `PresentationEvent`, owns provider-created presentation bindings and delivery receipts, and stores returned SessionIds only as opaque mapping keys.\n\n## Ownership matrix\n\n| Authority | Creator | Mutator/retirer | Persistence | Provider role |\n|---|---|---|---|---|\n| Logical SessionId | Broker lifecycle | Broker lifecycle | Broker/managed session | Store returned opaque ID |\n| Lifecycle idempotency | Service derives, Broker records | Broker ledger | `LifecycleLedger` | Supply stable request identity |\n| Broker root lease | Broker | Exact owner | lock/discovery/heartbeat | None |\n| Effect marker | Broker | Broker | ledger/lifecycle artifacts | None |\n| Process incarnation | OS, observed by core | Immutable | effect evidence | None |\n| Endpoint URL/token/generation | Session runtime | Session runtime | endpoint/index | Must not receive |\n| Attachment lease | SessionRouter | SessionRouter | Prefer ephemeral | Use opaque handle |\n| Provider transport lease | Supervisor | Supervisor | provider state | Full owner |\n| Provider cursor/rate state | Supervisor | Supervisor | provider state | Full owner |\n| Topic/thread/message binding | Adapter | Adapter CAS | provider mapping | Full owner |\n| Session lifecycle result | Broker | Broker ledger | `LifecycleLedger` | Read only |\n| Provider delivery result | Adapter | Adapter | provider delivery state | Cannot alter lifecycle |\n\n## Lifecycle flows\n\n### Create\n\n1. Adapter validates provider identity and policy.\n2. Existing topic/thread adoption creates a provider-local reservation keyed by `providerRequestKey`, not a SessionId.\n3. Adapter sends a create intent to Router.\n4. Router calls lifecycle service with actor, capability, target, and stable idempotency.\n5. Broker begins the sole ledger entry, allocates SessionId, records effect identity, spawns, and proves readiness.\n6. Router attaches to the exact endpoint authority.\n7. Adapter CAS-binds its reservation to the returned SessionId.\n8. Retry of the same provider request returns the same Broker result and cannot spawn another session.\n\n### Attach\n\nRouter reads Broker index, resolves exact generation/incarnation, opens the authenticated SDK connection, replays from its cursor, and issues an attachment lease. Provider stores only the opaque attachment and presentation mapping.\n\n### Prompt and ask answer\n\nAdapter sends an authenticated intent with provider request identity and attachment lease. Router validates authority and invokes standard SDK controls. Runtime reconciliation remains authoritative for accepted, in-flight, terminal, unknown, and one-shot answer outcomes. Provider retries query/replay rather than duplicate effects.\n\n### Close\n\nAdapter submits intent only. Lifecycle service authorizes and Broker persists the effect, uses the exact endpoint first, escalates only with incarnation/effect fencing, and requires unregister, endpoint removal, and process-exit proof. Router revokes attachments. Provider presentation cleanup happens afterward and cannot make a confirmed close uncertain.\n\n### Resume and endpoint rotation\n\nBroker validates saved-session ownership and returns an exact live endpoint or performs canonical spawn/readiness. Runtime owns token/generation rotation and Broker registration. Router revokes old leases, resolves the new endpoint, reconnects, and replays. Provider never receives old or new credentials.\n\n### Provider restart\n\nSupervisor reacquires only provider transport authority and reloads cursor/presentation state. Router reconstructs attachments from Broker index. Provider restart alone never creates, resumes, closes, or mutates a session. Submitted operations replay through the same Broker idempotency identity.\n\n### Broker/runtime recovery\n\nBroker reconciles only from its ledger, effect markers, process incarnation, endpoint/index registration, readiness/failure records, and exact cleanup evidence. Provider records are never lifecycle proof. Unprovable effects remain `terminal_uncertain`.\n\n## Migration plan\n\nEach stage cuts over one authority and deletes the obsolete path in the same logical change. No dual writers or fallback executors.\n\n### Stage 1: canonical lifecycle service\n\n- Add provider-neutral lifecycle service over existing Broker operations.\n- Define actor/capability, canonical request, idempotency, and certainty contracts.\n- Reuse `Broker.handleRequest`, `executeLifecycle`, and `LifecycleLedger`.\n- Add focused authorization, replay, conflict, and uncertainty tests.\n\n### Stage 2: chat lifecycle cutover\n\n- Route Telegram create/close/resume through Router and lifecycle service.\n- Preserve provider parsing, authentication, policy, and rendering.\n- Remove daemon lifecycle server, token, client, pending table, request sequence, chat lifecycle ledger/audit, and tmux/process effects.\n- Verify one chat operation creates one Broker ledger identity.\n\n### Stage 3: remove provider SessionId allocation\n\n- Replace `intendedSessionId` with a provider-local reservation keyed by provider request identity.\n- CAS-bind the provider resource to the Broker-returned SessionId.\n- Test crashes before Broker call, after Broker intent, after readiness, and before binding.\n\n### Stage 4: SDK-owned SessionRouter\n\n- Route discovery through Broker index and exact endpoint resolution.\n- Move credentials, connections, replay, reconnect, and attachment generation into Router.\n- Introduce opaque attachment leases.\n- Remove notification-root registration, provider root scanning, and direct endpoint access.\n\n### Stage 5: provider supervisor/adapter boundary\n\n- Retain provider lease, cursor, connection, retry/backoff, rate limits, delivery, mappings, callbacks, and attachments.\n- Apply the Router contract to Telegram, Discord, and Slack.\n- Remove provider edges to lifecycle execution, tmux, process signaling, Broker cleanup, and endpoint credentials.\n\n### Stage 6: singular recovery and deletion\n\n- Broker owns lifecycle recovery.\n- Router reconstructs attachments.\n- Provider supervisor reconstructs only transport and presentation.\n- Delete obsolete lifecycle orchestrator/control-runtime modules.\n- Leave no feature flag, dual writer, silent fallback, or compatibility shim.\n\n## Required invariants\n\n1. Only Broker lifecycle may spawn, signal, close, resume, delete, or persist lifecycle terminality.\n2. Provider retry cannot create two sessions or effects.\n3. Stale endpoint/router/attachment authority cannot submit prompt or answer.\n4. Provider transport failure or restart cannot mutate session lifecycle.\n5. Provider presentation state is recoverable but non-authoritative.\n6. Endpoint credentials remain inside runtime, Broker resolution, and Router.\n7. Provider cleanup failure cannot rewrite a lifecycle result.\n8. Recovery cannot signal or delete a successor process, endpoint, or transcript.\n9. Prompt acceptance remains distinct from completion.\n10. Timeout never invents success or failure.\n\n## Verification\n\n### Static architecture gates\n\nFail provider modules that import lifecycle execution internals, tmux/session process lifecycle, process signals for session lifecycle, direct endpoint discovery/credentials, or logical SessionId generation.\n\n### Crash matrix\n\nInject provider-triggered create failures after reservation, lifecycle admission, Broker ledger begin, effect persistence, child spawn, endpoint publication, readiness/index registration, and provider binding. Retry must produce one SessionId, one effect identity, at most one live owned process, and one Broker result.\n\n### Endpoint rotation\n\nRender work under generation N, rotate to N+1, reject the N lease, reconnect through Broker exact resolution, and replay without duplicate prompt or answer effects.\n\n### Provider restart\n\nRestart the supervisor during active prompt, unanswered ask, create, close, and endpoint rotation. Broker/process/runtime evidence changes only for operations already submitted; provider delivery alone is reconciled.\n\n### Close and cleanup\n\nConfirm close independently of topic/thread deletion. Exercise PID reuse, endpoint replacement, stale index generation, and exact cleanup races. Preserve `terminal_uncertain` when proof is unavailable.\n\n### Credential boundary\n\nRouter/adapter payloads and provider state must not contain endpoint URL/token, Broker token, or lifecycle effect credentials.\n\nStart focused verification with `packages/coding-agent/test/sdk-broker-lifecycle-e2e.test.ts`, Telegram ownership/lifecycle/restart/reconciliation suites, SDK host/runtime lifecycle tests, Coordinator lifecycle/idempotency tests, and ACP create/load/resume/cancel tests when shared paths change.\n\n## Implementation order\n\nPrefer atomic commits:\n\n1. lifecycle-service contract and tests;\n2. Telegram lifecycle cutover and old server/ledger deletion;\n3. provider reservation and Broker-owned SessionId;\n4. Router contract and direct endpoint-access removal;\n5. Telegram supervisor/adapter split;\n6. Discord and Slack Router adoption;\n7. obsolete-path deletion, architecture gates, and docs.\n\nDo not mix provider rendering, topic UX, or unrelated notification features into this migration.\n\n## Primary files\n\nExpected existing files:\n\n- `packages/coding-agent/src/sdk/broker/broker.ts`\n- `packages/coding-agent/src/sdk/broker/lifecycle.ts`\n- `packages/coding-agent/src/sdk/host/session-runtime.ts`\n- `packages/coding-agent/src/sdk/bus/telegram-daemon.ts`\n- `packages/coding-agent/src/sdk/bus/lifecycle-orchestrator.ts` (planned name, never created; landed as `packages/coding-agent/src/sdk/bus/lifecycle-commands.ts` plus `packages/coding-agent/src/sdk/lifecycle/`)\n- shared chat-daemon/session-control modules under `packages/coding-agent/src/sdk/bus/`\n- Coordinator and ACP adapters where they consume the same core contracts\n\nExpected new core areas:\n\n- `packages/coding-agent/src/sdk/lifecycle/`\n- `packages/coding-agent/src/sdk/router/`\n\nMove or rename existing services where possible instead of introducing parallel abstractions.\n\n## Risks\n\n- **High:** temporary dual authority during cutover.\n- **High:** provider, Router, and Broker idempotency namespace drift.\n- **High:** endpoint credential duplication or leakage.\n- **High:** successor destruction if existing fences are simplified.\n- **Medium:** crash-safe reservation-to-session binding.\n- **Medium:** event replay duplication versus effect duplication.\n- **Medium:** Telegram-specific requirements leaking into SDK core.\n\n## Definition of done\n\n- TUI, CLI, ACP, Coordinator, Telegram, Discord, and Slack lifecycle mutations enter one Broker path.\n- One durable ledger determines lifecycle terminality.\n- Provider daemons cannot allocate SessionIds, signal session processes, or read endpoint credentials.\n- Providers retain only transport and presentation ownership.\n- Router is the sole external attachment authority and credential-bearing SDK client manager.\n- Provider restart cannot create, resume, close, or mutate a session by itself.\n- Stale attachments fail closed.\n- Crash, retry, rotation, and cleanup tests pass.\n- Obsolete lifecycle server, chat lifecycle ledger, root scanning, and compatibility paths are removed.\n- SDK, bot integration, notification, ACP, and Coordinator docs describe the final ownership model consistently.\n",
103
103
  "sdk-rpc-parity-audit.md": "# SDK v3 RPC parity audit\n\n**Status:** internal, closed-inventory audit. This is a comparison of the retired\nRPC contract at `6e147d58~1:docs/rpc.md` with SDK v3; it is not an event-plane\nparity claim. The CLI rejects the retired `--mode rpc`, `rpc-ui`, and `bridge`\nmodes and directs external control to the SDK (`packages/coding-agent/src/cli/args.ts:117-127`).\n\nThe historical issue files (01–08, 11–21) are archived under `issues/archive/` as provenance only. Their current disposition is recorded in `issues/README.md`: implementation findings are resolved, retired RPC documentation findings are obsolete, and persistent-session/registry items (09/10) remain deferred architectural follow-ups. Do not treat this closed audit as an active implementation backlog.\n\n## Method and classifications\n\nThe inventory below is **closed**. Command, frame, and sub-protocol rows were\nrecovered from `git show 6e147d58~1:docs/rpc.md`; the supplemental\n`rpc-sessions` registry and `--listen` Unix-socket rows were recovered from\nparent-commit source because they do not appear in that document:\n`6e147d58~1:packages/coding-agent/src/cli/args.ts:157-158`,\n`6e147d58~1:packages/coding-agent/src/modes/rpc/rpc-mode.ts:892-907,984-992`,\nand\n`6e147d58~1:packages/coding-agent/src/modes/shared/agent-wire/session-registry.ts:1-53`.\n`SDK equivalent` means a current operation or documented SDK protocol covers the\ncontrol/query intent, not that its transport or event semantics are identical.\n`transport-gap — retired` means the direct `gjc sdk serve` relay and standalone transport clients were removed when `SessionRouter` became the sole external attachment authority.\n`phase-2-gap` means no equivalent has been implemented by this audit.\n\nOperation names and their stated roles are from\n`packages/coding-agent/src/sdk/protocol/operation-registry.ts:66-166`; dispatch\ncoverage is from `packages/coding-agent/src/sdk/host/control/dispatch.ts:138-253`.\nSDK protocol and lifecycle references use stable heading references in\n`docs/sdk.md`. Command, frame, and sub-protocol rows cite\n`6e147d58~1:docs/rpc.md`; the two supplemental rows cite the parent-commit\nsources above.\n\n## Closed command inventory\n\n| Retired family | Retired command | SDK v3 equivalent or classification | Evidence |\n| --- | --- | --- | --- |\n| Prompting | `prompt` | `turn.prompt` | retired doc; registry:67; dispatch:139-140 |\n| Prompting | `steer` | Partial SDK equivalent: `turn.steer` is text-only and loses retired `images` | `6e147d58~1:docs/rpc.md:77`; registry:68; dispatch:141-142 |\n| Prompting | `follow_up` | Partial SDK equivalent: `turn.follow_up` is text-only and loses retired `images` | `6e147d58~1:docs/rpc.md:78`; registry:69; dispatch:143-144 |\n| Prompting | `abort` | `turn.abort` | retired doc; registry:70; dispatch:145-146 |\n| Prompting | `abort_and_prompt` | `turn.abort_and_prompt` | retired doc; registry:71; dispatch:147-148 |\n| Prompting | `new_session` | Partial SDK equivalent: `session.new` takes no input and loses retired `parentSession` | `6e147d58~1:docs/rpc.md:81`; registry:93; dispatch:196-197 |\n| State | `get_state` | Partial SDK equivalent: query bundle `context.get` (includes `systemPrompt`), `tools.list` (Q20), `models.list/current`, `todo.list`, `queue.messages.list`, `session.metadata`, and `session.stats`; no one-shot legacy-shaped snapshot, no retired `dumpTools` include-toggle/exact dump schema, and some legacy snapshot fields remain absent | `6e147d58~1:docs/rpc.md:85,169-222`; registry:132-152; sdk/bus/index.ts:1804-1808,1852-1855; host/query/handlers.ts:91,116; docs/sdk.md “Protocol” and “Model catalog query (Q10)” |\n| State | `set_todos` | `todo.replace` | retired doc; registry:78; dispatch:166-167 |\n| State | `set_host_tools` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: `host_tools.register` | `6e147d58~1:docs/rpc.md:87,255-291`; registry:105,164; dispatch:220-221; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| State | `set_host_uri_schemes` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: `host_uri.register` | `6e147d58~1:docs/rpc.md:88,293-323`; registry:106,165; dispatch:222-223; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| State | `workflow_gate_response` | `workflow.gate_answer` (durable Q12 gate ID) | retired doc; registry:73; dispatch:151-157; docs/sdk.md “Durable workflow controls and Q12” |\n| Model | `set_model` | `model.set` | retired doc; registry:79; dispatch:168-169 |\n| Model | `set_default_model_selection` | `model.set` with `thinkingLevel`; equivalent active-model/default-selection intent, not the retired durable-selector response envelope | retired doc; registry:79; dispatch:168-169; docs/sdk.md “Model catalog query (Q10)” |\n| Model | `cycle_model` | `model.cycle` | retired doc; registry:80; dispatch:170-171 |\n| Model | `get_available_models` | `models.list/current` / Q10 | retired doc; registry:141; docs/sdk.md “Model catalog query (Q10)” |\n| Thinking | `set_thinking_level` | `thinking.set` | retired doc; registry:81; dispatch:172-173 |\n| Thinking | `cycle_thinking_level` | `thinking.cycle` | retired doc; registry:82; dispatch:174-175 |\n| Queue modes | `set_steering_mode` | `queue.steering_mode.set` | retired doc; registry:84; dispatch:178-179 |\n| Queue modes | `set_follow_up_mode` | `queue.follow_up_mode.set` | retired doc; registry:85; dispatch:180-181 |\n| Queue modes | `set_interrupt_mode` | `queue.tool_interrupt_policy.set` | retired doc; registry:86; dispatch:182-183; renamed from `queue.interrupt_mode.set` (values `abort_tools` / `finish_tools`) |\n| Compaction | `compact` | Partial SDK equivalent: `compaction.run` takes no input and loses retired `customInstructions` | `6e147d58~1:docs/rpc.md:111`; registry:87; dispatch:184-185 |\n| Compaction | `set_auto_compaction` | `compaction.auto.set` | retired doc; registry:88; dispatch:186-187 |\n| Retry | `set_auto_retry` | `retry.auto.set` | retired doc; registry:89; dispatch:188-189 |\n| Retry | `abort_retry` | `retry.abort` | retired doc; registry:90; dispatch:190-191 |\n| Bash | `bash` | `bash.execute` | retired doc; registry:91; dispatch:192-193 |\n| Bash | `abort_bash` | `bash.abort` | retired doc; registry:92; dispatch:194-195 |\n| Session | `get_session_stats` | `session.stats` | retired doc; registry:146; docs/sdk.md “Protocol” |\n| Session | `export_html` | Partial SDK equivalent: `session.export_html` takes no input and loses retired `outputPath` | `6e147d58~1:docs/rpc.md:127`; registry:101; dispatch:212-213 |\n| Session | `switch_session` | Partial SDK equivalent: retired `switch_session` was path-addressed (`sessionPath`), while `session.switch` is ID-addressed | `6e147d58~1:docs/rpc.md:128`; registry:97; dispatch:204-205 |\n| Session | `branch` | `session.branch` | retired doc; registry:98; dispatch:206-207 |\n| Session | `get_branch_messages` | `session.branch_candidates` plus `transcript.list`/`transcript.body`; no identical combined payload | retired doc; registry:132-133,147; docs/sdk.md “Protocol” |\n| Session | `get_last_assistant_text` | `session.last_assistant` | retired doc; registry:148; docs/sdk.md “Protocol” |\n| Session | `set_session_name` | `session.rename` | retired doc; registry:99; dispatch:208-209 |\n| Messages | `get_messages` | `transcript.list` and `transcript.body`; no identical monolithic payload | retired doc; registry:132-133; docs/sdk.md “Protocol” |\n\n## Closed framing, sub-protocol, registry, and transport inventory\n\n| Retired family | Retired frame, protocol, or transport | SDK v3 equivalent or classification | Evidence |\n| --- | --- | --- | --- |\n| Outbound frame | `ready` | transport-gap — closed by Phase 1; WebSocket connection/authentication replaces JSONL readiness | retired doc; docs/sdk.md §Endpoint discovery |\n| Outbound frame | `response` | transport-gap — closed by Phase 1; SDK control request/response replaces JSONL `RpcResponse` | retired doc; registry:66-119; dispatch:138-253 |\n| Outbound frame | canonical `event` | phase-2-gap; no renderer-grade canonical `AgentSessionEvent` stream | retired doc; docs/sdk.md §Protocol |\n| Outbound frame | `workflow_gate` | Partial SDK equivalent: `action_needed` with `workflowGateId`, plus Q12; not the retired frame/schema | retired doc; docs/sdk.md §Server → client, §Durable workflow controls and Q12 |\n| Outbound frame | `extension_ui_request` | phase-2-gap for extension UI methods; `action_needed` covers only generic asks | retired doc; docs/sdk.md §Server → client |\n| Outbound frame | `host_tool_call`, `host_tool_cancel` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: reverse `host_tool.invoke/cancel/update/result` with `host_tools.register` | `6e147d58~1:docs/rpc.md:45-46,357`; registry:105,164; dispatch:220-221; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Outbound frame | `host_uri_request`, `host_uri_cancel` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: reverse `host_uri.read/write/cancel/result` with `host_uri.register` | `6e147d58~1:docs/rpc.md:46,357`; registry:106,165; dispatch:222-223; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Outbound frame | `extension_error` | phase-2-gap; no SDK extension-error frame contract | retired doc; docs/sdk.md §Protocol |\n| Inbound frame | `RpcCommand` | SDK control and query operations | retired doc; registry:66-157; dispatch:138-253 |\n| Inbound frame | `workflow_gate_response` | `workflow.gate_answer` | retired doc; registry:73; docs/sdk.md “Durable workflow controls and Q12” |\n| Inbound frame | `extension_ui_response` | phase-2-gap except generic `reply` for an `action_needed` ask | retired doc; docs/sdk.md §Client → server |\n| Inbound frame | `host_tool_update`, `host_tool_result` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: reverse `host_tool.invoke/cancel/update/result` | `6e147d58~1:docs/rpc.md:54`; registry:164; dispatch:220-221; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Inbound frame | `host_uri_result` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: reverse `host_uri.read/write/cancel/result` | `6e147d58~1:docs/rpc.md:55`; registry:165; dispatch:222-223; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Workflow gate sub-protocol | `workflow_gate` / `workflow_gate_response` with schema and durable broker semantics | Partial SDK equivalent: `action_needed`, `reply`, Q12 `workflow.gates.list`, and `workflow.gate_answer`; IDs and authority rules differ | retired doc; registry:73,143; docs/sdk.md “Answer semantics” and “Durable workflow controls and Q12” |\n| Extension UI sub-protocol | select/confirm/input/editor/cancel/notify/status/widget/title/editor-text | phase-2-gap; generic action presentation is not extension UI parity | retired doc; docs/sdk.md §Server → client |\n| Host tool sub-protocol | registration, call/cancel, update/result | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: `host_tools.register` plus reverse callback operations | `6e147d58~1:docs/rpc.md:45,54,255-291,357`; registry:105,164; dispatch:220-221; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Host URI sub-protocol | scheme registration, read/write/cancel/result | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: `host_uri.register` plus reverse callback operations | `6e147d58~1:docs/rpc.md:46,55,293-323,357`; registry:106,165; dispatch:222-223; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Unattended sub-protocol | `negotiate_unattended` declaration/budget/scopes/allowlist | phase-2-gap | retired doc; docs/sdk.md §Coordinator MCP question pull loop |\n| `rpc-sessions` registry | Cross-process session registry and reattach semantics | phase-2-gap. Per-session discovery files are only partial endpoint location, not a registry/reattach protocol | parent source: `6e147d58~1:packages/coding-agent/src/modes/rpc/rpc-mode.ts:892-907,984-992`; `6e147d58~1:packages/coding-agent/src/modes/shared/agent-wire/session-registry.ts:1-53`; docs/sdk.md §Endpoint discovery, §Architecture |\n| Transport | stdio JSONL | retired. Direct stdio relay was removed; managed clients use Router-owned capabilities. | parent source: `6e147d58~1:packages/coding-agent/src/modes/rpc/rpc-mode.ts:892-907,984-992`; removal evidence: SDK-owned session lifecycle handoff |\n| Transport | `--listen` Unix socket | retired. Direct Unix-socket relay was removed; managed clients use Router-owned capabilities. | parent source: `6e147d58~1:packages/coding-agent/src/cli/args.ts:157-158`; removal evidence: SDK-owned session lifecycle handoff |\n\n## Five-gap reduction verdict\n\nSDK v3 has broad control/query coverage: the operation registry includes turn,\nmodel, thinking, queue, compaction, retry, bash, session, host callback, and\nworkflow operations (`operation-registry.ts:66-166`), and control dispatch\nimplements the control path (`dispatch.ts:138-253`). That does **not** erase the\nuser-perceived reduction. It is **REAL** across five dimensions:\n\n1. **stdio JSONL and Unix-socket transports.** The interim `gjc sdk serve` relay was retired; external attachment now requires Router-owned opaque capabilities and never forwards endpoint credentials.\n2. **Typed Python client.** The interim public Python transport client was retired with the direct relay; cross-process integrations require Router-owned capabilities.\n3. **`negotiate_unattended`.** No fail-closed unattended negotiation with the\n retired declaration, budget, scope, and allowlist exists: this remains Phase 2.\n4. **Cross-process session registry/reattach.** Discovery files locate a live\n endpoint but do not provide the retired registry or reattach lifecycle: this\n remains Phase 2.\n5. **Renderer-grade full event stream.** SDK v3's minimal frames and optional\n threaded-client frames are not the retired canonical session event stream.\n **No event-plane parity is claimed.**\n\n## Ranked Phase-2 follow-up register — NOT implemented\n\n1. **Unattended negotiation equivalent — NOT implemented.** Add a fail-closed\n equivalent to `negotiate_unattended` only with explicit actor, budget, scopes,\n allowlist, and audit enforcement. Partial equivalent only: Q12\n `workflow.gates.list` plus the Coordinator MCP pull loop can enumerate and\n answer durable workflow gates; they are not unattended negotiation\n (`docs/sdk.md §Coordinator MCP question pull loop`).\n2. **Reattachment semantics — implemented in SDK core.** `SessionRouter` exclusively resolves private endpoint publication, retains credentials, reconnects, replays, rotates transports, and issues exact opaque attachment capabilities. External adapters do not consume discovery files or raw endpoint credentials.\n3. **Full event stream — NOT implemented.** Define a renderer-grade session\n event contract only if consumers require it. Partial equivalent only:\n `action_needed`, `action_resolved`, `reply_rejected`, and optional threaded\n frames such as `turn_stream` exist, but there is **no `onSessionEvent`-style\n SDK equivalent** (`docs/sdk.md §Server → client`).\n\n## Completeness checklist\n\n- [x] Prompting — every retired command represented.\n- [x] State — every retired command represented.\n- [x] Model — every retired command represented.\n- [x] Thinking — every retired command represented.\n- [x] Queue modes — every retired command represented.\n- [x] Compaction — every retired command represented.\n- [x] Retry — every retired command represented.\n- [x] Bash — every retired command represented.\n- [x] Session — every retired command represented.\n- [x] Messages — every retired command represented.\n- [x] Outbound and inbound frame categories — every retired category represented.\n- [x] Workflow gate sub-protocol represented.\n- [x] Extension UI sub-protocol represented.\n- [x] Host tool sub-protocol represented.\n- [x] Host URI sub-protocol represented.\n- [x] `negotiate_unattended` sub-protocol represented.\n- [x] `rpc-sessions` registry represented from parent-commit source (supplemental to the recovered document inventory).\n- [x] stdio JSONL represented from the recovered document inventory; `--listen` Unix-socket transport represented from parent-commit source.\n",
104
- "sdk-session-cli.md": "# SDK session CLI\n\n`gjc sdk session` is the broker-bound command family for operating live GJC SDK\nsessions from the terminal. It replaces the removed `gjc daemon session` route.\n\nThe command family has seven semantic verbs — `list`, `inspect`,\n`send`, `status`, `tail`, `close`, and `retire` — plus the explicit `raw` hatch that dispatches one\nSDK operation as `control`, `query`, or `global`.\n\nThe session CLI is advisory tooling over the SDK: every semantic verb resolves\nsessions through the SDK broker, and output is rendered through a versioned,\ncredential-free DTO. Endpoint credentials are never printed.\n\n## Command-local help\n\nDiscovery is static and inert for every public SDK and daemon parent, leaf and\nalias; it does not initialize a broker, settings, transport or evidence store.\nUse the deepest known command for its arguments, applicable options and risks:\n\n```sh\ngjc sdk session raw query --help --json\ngjc daemon reload --help --help-section options --help-page 1\n```\n\n`-h` is equivalent to `--help`. Sections traverse in this fixed order:\n`overview`, `usage`, `children`, `arguments`, `options`, `examples`, `recovery`.\nThe defaults are `overview` and page `1`; an empty section has a valid page 1.\nEach response includes only the selected node and, in `children`, its immediate\nchildren. Follow the returned `next.argv` unchanged, including\n`--help-revision <sha256>`, section, page and `--json` when selected. A changed\nrevision requires a new traversal; do not merge pages from different revisions.\n\nValued selectors accept separate or `=` operands. Pages are positive decimal\nsafe integers. Repeated/invalid selectors, selectors without actual help, and\nunknown descendants fail with usage exit 2 at the nearest known command.\n`--help=json` is unsupported. Option values and tokens after `--` do not activate\nhelp or JSON mode. Syntax containing `<placeholders>` is documentation, not a\nready-to-run recovery command.\n\nText and `gjc.command-help` version 1 JSON responses are each at most 8192 UTF-8\nbytes including the newline. Text escapes controls and labels display literals;\nnever paste escaped display text as shell argv. Complete entries have stable\nIDs. Oversized values use non-executable base64 fragments: concatenate contiguous\n`offsetBytes` ranges, verify `totalBytes` and `sha256`, then decode the complete\nUTF-8 JSON value. `sectionComplete` ends a section; only `documentComplete: true`\nwith `next: null` ends traversal. Help paging never reruns an operation.\n\n## Broker authority\n\n`list`, `inspect`, `send`, `status`, and `tail` resolve sessions through the\nSDK broker and Router. The Router validates indexed endpoint authority and keeps\nthe connection credential in SDK core; the CLI receives only credential-free\nresults. The broker is started on demand (`ensureBroker`) when discovery is\nabsent, and an unavailable broker fails closed with a typed operational error\n(exit 1).\n\n### Attach-only mode\n\nSet `GJC_SDK_BROKER_AUTOSTART=0` when a supervisor (for example a system\nservice) owns the broker. Every SDK client entry point — `gjc sdk session`,\n`gjc sdk search`, `gjc sdk spawn`, the SDK and coordinator MCP servers, ACP,\nand session hosts — then only attaches to a live, reusable broker and never\nspawns, retires, or restarts one. When discovery is absent, stale, names a\ndead process, or names a live broker this client cannot reuse, the call fails\nwith `broker_unavailable` (exit 1) and leaves the incumbent untouched. Broker\nmanagement refuses too, before any effect: `gjc doctor` broker restart reports\n`attach_only_refused`, and `scripts/restart-sdk-broker.ts` exits with an error\nwithout closing hosts or stopping the broker. Restart through the supervisor\ninstead. Any other value, or an unset variable, keeps on-demand startup. The\nbroker entrypoints ignore the variable, so it is safe for broker descendants to\ninherit it. A supervisor that wants its broker's session hosts to stay\nattach-only sets the variable on the broker process as well.\n\n`--attach-only` is the per-invocation flag form on `gjc sdk session …`,\n`gjc sdk search`, `gjc sdk spawn` and `gjc mcp-serve`. `gjc sdk serve` needs\nneither: it only reads broker discovery and never starts a broker.\n\n### Supervised broker: `gjc sdk broker run`\n\n`gjc sdk broker run [--agent-dir <dir>]` is the stable foreground broker\ncommand for supervisors such as systemd or launchd. It owns the broker for the\nagent directory (default: the configured agent directory; a relative path\nresolves against the current directory) and never detaches: the published\ndiscovery pid is the supervised process itself.\n\n- It serves until SIGTERM or SIGINT, stops gracefully (discovery is removed),\n then exits with the signal status: 143 for SIGTERM, 130 for SIGINT. Under\n systemd, set `SuccessExitStatus=143` so a normal stop is not reported as a\n failure.\n- It exits 1 without serving when another live broker already owns the agent\n directory (even one this version cannot reuse), or when startup fails. It\n never retires the other owner. Startup also fails when the off-thread\n publication-liveness watchdog cannot be armed. That attempt removes its\n discovery file and lock instead of leaving a live pid published without\n the watchdog.\n- It exits 1 when it stops abnormally while serving: its discovery root was\n lost or replaced, or its heartbeat could not be renewed. A requested\n `broker.shutdown` is a clean stop (exit 0). Use `Restart=on-failure`.\n- It is SIGKILLed (signal status 137, not exit 1) when a discovery\n publication write blocks the process so the event loop cannot reach that\n same liveness deadline (`publication-liveness-timeout`, `max(60s, heartbeat\n TTL × 4)`). `Restart=on-failure` still restarts it; do not add 137 to\n `SuccessExitStatus`. stderr includes `sdk broker: publication-liveness-timeout`\n only when that write can finish without waiting. A full stderr pipe does\n not postpone the SIGKILL. While the event loop is still running, the\n exit-1 path above is the one that runs.\n- Pair it with attach-only clients (`GJC_SDK_BROKER_AUTOSTART=0`) so nothing\n else starts a competing broker. Set the variable on the broker unit as well\n if its session hosts should stay attach-only.\n\n```ini\n[Service]\nExecStart=/path/to/gjc sdk broker run --agent-dir /home/me/.gjc/agent\nEnvironment=GJC_SDK_BROKER_AUTOSTART=0\nSuccessExitStatus=143\nRestart=on-failure\n```\n\n`--agent-dir` selects the broker state directory. It may appear at the session\nfamily level before the verb (`gjc sdk session --agent-dir <dir> list`) or on a\nleaf command. `--repo` selects the workspace directory for scoped listing or\nsaved-session resolution (default: the current directory). For compatibility,\n`inspect`, `send`, `status`, and `raw query` also accept `--repo`, but ignore it:\nthe exact session ID selects the broker target. Successful calls print a\npath-free warning to stderr. Failed `--json` calls keep stderr empty and return\ntheir structured error; command-local help identifies `--repo` as compatibility-only.\n\n## Semantic verbs\n\n### list\n\n`gjc sdk session list` queries the broker `session.list` global and projects\nevery indexed session into the versioned row DTO (`SESSION_ROWS_VERSION`). Each\nrow is credential-free and carries:\n\nThe list is fully paginated before scope filtering. By default its effective\nscope is `repo`. Select a scope with:\n\n```sh\ngjc sdk session list --scope repo|cwd|worktree|all [--repo <path>]\n```\n\n`--repo` is the selected workspace path and defaults to the process cwd. The\nresult reports the effective `scope` and a bounded credential-free `selection`\ndescriptor containing the canonical selected path and, for Git selections,\nthe canonical worktree root and Git common directory.\n\n- `repo` matches the canonical Git common directory, so the main checkout and\n linked worktrees are included while another repository is excluded.\n- `worktree` matches only the selected path's canonical containing worktree.\n- `cwd` matches only the exact canonical selected workspace; nested directories\n do not match.\n- `all` preserves the complete unfiltered Broker listing.\n\nFor a path outside Git, `repo` and `worktree` fail with the typed\n`not_a_repository` operational error and an actionable suggestion to use\n`cwd` or `all`; they never broaden the result. `cwd` remains available for an\nexact canonical path match. Unreadable or removed row workspaces are excluded\nfrom Git scopes deterministically and reported in `warnings`.\n\nThe raw global `session.list` route remains unfiltered, and `inspect`, `send`,\n`status`, `tail`, `close`, `retire`, and raw control/query behavior is unchanged.\n\nFor a process-isolated caller that needs bounded discovery, request exactly one\nBroker page instead of the semantic all-pages list:\n\n```sh\ngjc sdk session raw global --op session.list --page --limit 20 \\\n --agent-dir <agent-dir>\ngjc sdk session raw global --op session.list --page --cursor <opaque-cursor> \\\n --limit 20 --agent-dir <agent-dir>\n```\n\n`--page` preserves the Broker page envelope, including `indexSeq`, `warnings`,\nthe bounded `sessions` array, and the opaque `continuationCursor`. The cursor\nis a snapshot continuation: pass it back unchanged, with the same `--limit`,\nand never restart from the first page or locally slice an all-pages result. A\ncontinuation cursor cannot be combined with `cwd` or `resolveSessionId`; exact\nsaved-session resolution is a separate, single-page lookup.\nSemantic `gjc sdk session list` remains the fully paginated, scope-filtered\noperation.\n\n- `sessionId` and the `locator` (`cwd`, `worktreeRoot`, `stateRoot`), where `cwd`\n is the canonical workspace directory and `worktreeRoot` is the canonical Git\n worktree root or `null` outside a worktree;\n- `endpointGeneration`, `pid`, `live`, `deleted` (tombstone), `indexSeq`;\n- `hostIncarnation` and `identityProvenance` (`composite` | `legacy`);\n- `activity` (`{state: active|idle, at}`) and `lastHeartbeatAt`;\n- `terminalUncertain`, `lifecycleRequestId`, `endpointMtimeMs`;\n- `ambiguous` when the same `sessionId` has more than one unresolved\n authority-fencing `stateRoot` (cross-repo duplicate). A proven non-endpoint\n bookkeeping registration (the direct-session GC fence row, endpoint\n generation 0) stays indexed without fencing endpoint attachment; every other\n unresolved root, including an unproven generation-0 `lifecycle_terminal`\n claim, still fences.\n\n### inspect\n\n`gjc sdk session inspect <sessionId>` renders one indexed row from the broker.\nIt never reads endpoint discovery records directly: a missing or unavailable\nbroker fails closed rather than exposing endpoint authority outside SDK core.\n\n### send\n\n`gjc sdk session send <sessionId> --text <prompt>` submits an ordered\n`turn.prompt` carrying a caller-chosen operation reference (a ULID by default,\nor `--op-ref`). The result envelope reports `accepted` with the receipt and the\noperation reference used for later reconciliation.\n\n- `--wait` polls `turn.result` with `kind: \"prompt\"` until the prompt reaches a\n terminal state or the wait window (`--timeout-ms`, default 30s) elapses.\n `send --wait` never cancels a running turn; a window that elapses before a\n terminal state is reported as `wait_timeout` with the last observed status.\n- `send` only starts a new turn. While the session is running a turn the host\n refuses `turn.prompt` before admission, and the CLI reports error code `busy`\n with `outcomeCertainty: \"not-applied\"`. To correct the running turn use\n `raw control <sessionId> --op turn.steer --json-input '{\"text\":\"...\"}'`;\n otherwise wait for the session to become idle and send again.\n\n- `--text` and the JSON input sources (`--json-input`,\n `--json-input-file` — which must be a `0600` regular file —\n `--json-input-stdin`) are mutually exclusive for the prompt body.\n- File input is read through one descriptor, rejects symlink/replacement races,\n and is capped at 4 MiB before JSON parsing.\n\n### status\n\n`gjc sdk session status <sessionId> <opRef>` performs a lossless `turn.result`\nlookup with `kind: \"prompt\"` for a previously submitted operation reference and\nreturns the full reconciliation record plus a `summary.completed` flag.\nSee [lossless prompt results](#lossless-prompt-results).\n\n\n### tail\n\n`gjc sdk session tail <sessionId>` replays the retained transcript from the\ndurable checkpoint and then follows the live event-ring frames, emitting the\ndefault tail kinds (session lifecycle and turn lifecycle events) plus retained\ntranscript entries.\n\n- `--strict` fails closed with `retention_gap` (exit 1) when retained history\n or the event ring dropped entries before the checkpoint.\n- `--until-idle` exits once the current turn reaches a terminal state; a session\n close exits any tail as `terminal: true`, while a bare live tail otherwise\n remains attached until `--timeout-ms`. Each item carries `revision` (the durable checkpoint revision); the triple `(revision,generation,seq)` is unique for the session, while `generation:seq` alone is revision-local and restarts each accepted prompt — key idempotency on the triple. Lifecycle\n events that carry a `(generation, seq)` position are reconciled and emitted in\n that canonical order rather than arrival order, so a retained terminal event\n from an earlier turn does not complete a newer turn that is still running, and\n a terminal event observed live is not undone by replayed history that arrives\n after it. Positioned event items are emitted canonically ahead of an\n arrival-ordered segment of events that carry no position; such an event stays\n visible but cannot supersede a positioned turn state. Different lifecycle\n kinds claiming the same `(generation, seq)` fail closed with `protocol_error`\n (exit 1) because no order between them can be proven, and that conflict\n outranks an otherwise successful idle or close completion. A canonical\n position is a pair of non-negative safe integers. A replayed event-ring row that\n states either coordinate property must state both validly; a row claiming only\n one coordinate, or a null, negative, fractional, non-finite, or\n unsafe-integer coordinate, fails closed with `protocol_error` rather than\n being treated as unpositioned. Only a row that states neither coordinate is\n unpositioned. Retained transcript entries are deduplicated independently of\n event-ring rows and are not event-ring authority, so a transcript row that\n projects the same kind and position as a real event never suppresses it. For a\n specific prompt operation, `status <sessionId> <opRef>` remains the lossless\n authority.\n- `--all-events` widens the emitted set to every event-ring kind.\n- `--cursor` resumes from a saved signed checkpoint claim. `session.checkpoint`\n verifies the unexpired claim and exchanges it for a fresh connection-owned\n cursor pinned to the exact prior revision; direct cross-connection cursor\n consumption remains rejected, so reconnect never echoes or rewinds a cursor.\n- `--after-transcript-id` selects the caller's last observed transcript row;\n live resumes require it alongside `--cursor`, while offline tails may use the\n boundary directly. The boundary is recoverable: an unknown row leaves the\n retained snapshot intact, while a boundary older than the retained window\n fails closed with a retention gap.\n- `--timeout-ms` bounds live follow; a session whose lifecycle already ended\n (terminal or `terminalUncertain`) replays retained history and exits instead\n of hanging.\n\nA deleted session has no tail (`session_deleted`). A stopped session replays\nits retained transcript without an endpoint (offline source), bounded to the\nmost recent retained entries.\n\n### close\n\n`gjc sdk session close <sessionId>` is the official semantic wrapper for the\n`session.close` broker global. It ends one live session: the broker validates\nindexed authority, asks the host to shut down gracefully, escalates to SIGTERM\nagainst the durably identified session process when graceful teardown exceeds\nthe bounded deadline, and appends terminal `session_closed` evidence. A session\nwhose ownership is `terminalUncertain` is refused rather than signalled — that\nis what `retire` is for.\n\n```sh\ngjc sdk session close <sessionId> [--agent-dir <agent-dir>] [--idempotency-key <key>]\n```\n\nClosing never attaches to the session. A Router attachment registers the\ncalling process as a live client and renews the host's abandonment window,\nwhich is the opposite of the intent, so the lifecycle mutation is answered by\nthe broker over its own client.\n\n`--idempotency-key` is optional here, unlike the raw `session.close` global. The\ndefault request key is derived from the session id and the current endpoint\ngeneration/incarnation. A retry against the same live host replays the same\nlifecycle request; a host resumed under the same session id receives a fresh\nrequest identity and is closed by a new lifecycle operation. Pass\n`--idempotency-key` explicitly to supply a caller-chosen attempt key.\n\n### retire\n\n`gjc sdk session retire <sessionId>` is the official semantic wrapper for the\n`session.reconcile_uncertain` broker global. It retires an indexed\n`terminalUncertain` create effect only when exactly one matching uncertain\ncreate identity exists, the recorded host is proven exited, the endpoint is\nabsent, and any lifecycle marker/readiness leftovers are regular files bound to\nthat same PID and incarnation. The broker removes only those verified\nleftovers, appends terminal `session_closed` evidence, and converts the\nmatching lifecycle receipt to a terminal error. Ambiguous identity, live or\nunverifiable hosts, endpoint presence, malformed leftovers, and mismatches\nrefuse without signalling a process. Supply `--idempotency-key`; the JSON\ninput is read from a `0600` file or stdin when it contains proof material.\n\n## Raw hatch\n\n`gjc sdk session raw <control|query|global>` dispatches exactly one SDK\noperation and returns the broker/host response:\n\n- `raw control <sessionId> --op <operation>` — one control operation with\n `--json-input*`; `--confirm` confirms destructive control operations.\n Turn control ids are `turn.prompt`, `turn.steer` (`{\"text\":...}`),\n `turn.follow_up`, `turn.abort` and `turn.abort_and_prompt`. An unknown id is\n a usage error (exit `2`, diagnostic `sdk_unknown_operation`) and is never\n dispatched.\n- `raw query <sessionId> --query <operation>` — one query; `--cursor` passes a\n continuation cursor.\n- `raw global --op <operation>` — one broker global. Lifecycle globals\n (`session.create`, `session.fork`, `session.resume`, `session.close`,\n `session.delete`, `session.reconcile_uncertain`, `session.lookup`) require\n `--idempotency-key`.\n\n`session.lookup` is a read-only reconciliation of a previously submitted\n`session.create`. Reuse the caller request key and the same create target that\nwere retained before dispatch; GJC derives the Broker identity and fingerprint\ninside the lifecycle boundary. It never calls `session.create` and never\nreplays session work:\n\n```sh\ngjc sdk session raw global --op session.lookup \\\n --idempotency-key <create-request-key> \\\n --json-input '{\"cwd\":\"/absolute/path/to/repo\"}' \\\n --agent-dir <agent-dir>\n```\n\nThe credential-free result identifies the original operation and request key.\n`status: \"found\"` carries the canonical session result; `pending`,\n`not_found`, `conflict`, `uncertain`, and `terminal` are distinct structured\noutcomes and retain `certainty` and the recovery identity. A missing record is\nnot proof that the create did not execute, so callers must not resubmit solely\nbecause lookup reports `not_found`. Reconciliation failures exit nonzero while\npreserving the structured outcome in JSON.\n\n### Legacy lifecycle admission fence\n\n`operation_failed` with diagnostic `lifecycle_legacy_history_ambiguous` means the\nbroker refused the **new request before dispatch** because a pre-index ledger\nrow cannot establish that its target is unrelated. The underlying broker code is\n`legacy_lifecycle_ambiguous`, distinct from key reuse (`idempotency_conflict`) and\nan already admitted operation with an uncertain effect (`terminal_uncertain`).\nThe public outcome and retryability remain `unknown`: refusing this request does\nnot settle earlier effects.\n\n- Preserve the original operation, caller request key, exact target/input, and\n ledger. A new key, empty/config-only transcript, `live:false`, or `deleted:false`\n is not proof that a legacy effect did not occur. Do not force deletion or edit,\n truncate, or remove the ledger.\n- For a prior `session.create` whose original key and create target are known,\n use the read-only `session.lookup` command above. `not_found`, `conflict`, and\n `uncertain` do not authorize another mutation. Public lookup currently supports\n create only; do not invent a lookup for delete or close.\n- `retire` is limited to its complete identity-bound uncertain-create proof.\n It is not an override for opaque legacy rows. Missing original identity or\n terminal effect evidence requires operator/maintainer investigation; there is\n no automatic safe retirement for such a row. Preserve it unresolved.\n- Proven final `terminal_ok`/`terminal_error` legacy rows recording only other\n sessions may already pass admission with an unrelated key. Rows recording the\n target, lacking target evidence, or reusing its key remain fenced. Unresolved\n `accepted`, `effect_started`, `awaiting_ready`, and `terminal_uncertain` legacy\n history stays fenced even for an apparently unrelated empty session. Recovery\n after restart marking a row `terminal_uncertain` is not proof of no effect.\n- `session.spawn` claim mirrors are not legacy requests. Current brokers mark\n each mirror with `spawnAnchor: true` in its row, and a marked mirror never\n fences, even after its claim leaves `sdk/spawn-authority.jsonl`. Mirrors\n written by brokers that predate the marker have no durable field, position, or\n timestamp that proves they are mirrors (in-place legacy migration rewrites\n row timestamps), so the broker never infers them: they stay fenced like any\n other unkeyed row.\n- Operator release of a pre-marker mirror: with the broker stopped, write\n `sdk/lifecycle-ledger.spawn-anchor-attestation.json` as\n `{\"version\":1,\"identities\":[\"<identity>\", ...]}` and start the broker. List\n only identities you can match to a `claim.lifecycleIdentity` in a preserved\n copy of `sdk/spawn-authority.jsonl` or other first-hand evidence; listing a\n genuine legacy request removes its fence. The broker never writes or derives\n this file. It honors a listed identity only while that identity's entire\n history is a single unkeyed, unmarked `accepted` row with no session target\n and no quarantined or corruption-uncertain history; other entries, and the\n whole file when it is malformed or cannot be read (for example a directory or\n a file without read permission), are ignored and stay fenced. An unreadable\n file is logged as a broker warning and never stops the broker from starting. A still-fenced\n identity keeps returning `legacy_lifecycle_ambiguous`. Removing an identity\n restores its fence at the next broker start.\n\nThis diagnostic identifies the actual admission guard without exposing ledger\nidentities, request hashes, filesystem paths, credentials, or broker free text.\nOther failures do not receive it; do not attribute every unknown delete outcome\nto legacy history.\n\nA separately connected local controller can stop the active turn and its exact\nowned work with an explicitly confirmed operator abort:\n\n```sh\ngjc sdk session raw control <sessionId> \\\n --op turn.abort \\\n --json-input '{\"mode\":\"terminal\",\"scope\":\"owned\",\"operator\":true}' \\\n --idempotency-key '<unique-key>' \\\n --confirm\n```\n\nThe JSON input accepts only operation fields (`mode`, `scope`, `operator`).\n`--confirm` and `--idempotency-key` are CLI authority inputs, not JSON fields.\nThe exact `operator:true` shape is routed through the local Broker, which\nrevalidates the current endpoint identity and injects a process-bound private\ncapability before dispatch. MCP, ACP, notifications, and ordinary SDK endpoint\nrequests cannot mint operator authority: copying `operator:true` and\n`confirm:true` into a public control frame is rejected. Omitting confirmation,\nomitting the key, or supplying `operator:false` fails closed without invoking\nthe terminal-abort surface. Non-operator terminal abort retains its existing\nconnection-ownership semantics.\n\nThe route reaches every session host on the current gjc, including a TUI or a\ndirect CLI session the Broker did not launch. A Broker-launched host is bound by\nits lifecycle request id; any other host publishes a process-local random marker\nin its own endpoint registration in the Broker-owned session index, and the\nBroker echoes exactly that marker. The marker never appears in endpoint\ndiscovery, `session list`, or any SDK response, so an endpoint-token holder\ncannot forge it. When the operator abort stops a turn, the host's terminal shows\n`Turn stopped by a remote operator.` A host that published no marker (a gjc that\npredates the operator route) is refused before dispatch with\n`authorization_denied`, `outcomeCertainty: \"not-applied\"`, and diagnostic\n`sdk_operator_control_unsupported`; unlike `endpoint_stale`, retrying the same\nrequest cannot succeed until the session restarts on a current gjc.\n\n`session.get_endpoint` is refused unconditionally: endpoint credentials remain\nan SDK-core implementation detail. The raw hatch validates operation names and\nadapter dispositions up front and never renders endpoint-disclosure results.\n\n## Lossless prompt results\n\n`turn.result` with `kind: \"prompt\"` reports `accepted`, `in_flight`,\n`terminal_ok`, or `failed`; only retained-record capacity eviction yields\n`unknown`. `turn.prompt_status` remains a legacy prompt-only alias. A prompt\nthat is active at process restart is finalized from its durable pending outcome\n(or `prompt_failed` when it has none), so it never reports as `unknown` while a\nrecord exists.\n\n`unknown` means uncertainty, never proof of non-execution: do not reuse an\noperation reference as a retry mechanism (`client_ref_conflict` while the\nrecord is retained; after eviction a reused ref may be admitted again with the\nprior outcome unknown). Use one fresh operation reference per logical prompt\nand reconcile with `status`.\n\n## Checkpoint gaps\n\n`tail` reports a `retention_gap` when retained history or the event ring\ndropped entries before the durable checkpoint: the gap carries the missing\nsequence range (`missing.from`/`missing.to`) and a `resync` checkpoint.\n`--strict` turns any gap into `retention_gap` with exit 1; without `--strict`,\ntail continues from the resync position and reports the gap in the envelope.\n\n\n## Migration from the removed daemon session route\n\n`gjc daemon session` is removed and no alias is provided. Migrate:\n\n| Removed route | Replacement |\n| --- | --- |\n| `gjc daemon session list` | `gjc sdk session list` |\n| `gjc daemon session inspect <sessionId>` | `gjc sdk session inspect <sessionId>` |\n| `gjc daemon session send <sessionId> --text <prompt>` | `gjc sdk session send <sessionId> --text <prompt>` |\n| `gjc daemon session tail <sessionId>` | `gjc sdk session tail <sessionId>` |\n| raw control/query dispatch | `gjc sdk session raw control|query|global` |\n\nThe broker-bound surface replaces the daemon-owned routing: sessions are\nresolved through the SDK broker with validated endpoint identity instead of\ndirect discovery-file reads, and output is versioned and credential-free.\n\n## Exit codes and error envelope\n\nSession verbs exit `0` and keep their existing successful JSON stdout contract,\nincluding when `--json` is absent. **Machine callers must explicitly add\n`--json` for machine-readable failures.** Ordinary failures otherwise write\nbounded text to stderr only. With `--json`, failure writes exactly one\n`gjc.command-error` version 1 envelope and newline to stdout, with empty stderr.\nBoth modes are limited to 8192 UTF-8 bytes including formatting and newline.\nUsage errors exit `2`; operation failures (including unavailable broker/session,\nretention gaps and `send --wait` timeouts) exit `1`. A live `session tail` wait\nwindow is a bounded observation rather than an operational failure: it exits `0`\nwith `terminal: false` and all items observed before the deadline.\n\nThe envelope preserves `command`, `error.code`, `category`, `retryability`,\n`outcomeCertainty`, allowlisted `references` and evidence-based `nextSteps`, plus\n`omittedOptional`, `complete`, `evidence` and `continuation`. Safe fields are\nexplicitly projected; arbitrary exception messages, request bodies, credentials\nand stacks are not public error evidence. Do not infer non-execution from a\nnonzero exit or timeout: `wait_timeout` after acceptance means applied work,\nwhereas `uncertain_after_send` means the outcome is unknown. Preserve session,\noperation, idempotency, claim, command and turn references when supplied.\nA `busy` refusal from `send` is definite: the prompt was never admitted, so\nthere is no operation record to reconcile. A `session list` whose scope needs a\nGit repository but runs outside one is a usage error with diagnostic\n`sdk_scope_requires_repository`.\n\nError guidance performs no additional probes, status calls, retries, restarts or\nkills. Execute an appropriate explicit observation only when the task warrants\nit; reconcile before considering replay. `retire` is a proof-bound mutation,\nnot a generic status check, and requires confirmed intent and actual applicable\nproof. Daemon `stop`/`restart` interrupt work; `--force` permits SIGKILL escalation.\nDaemon kinds are `telegram`, `discord`, `slack` (default `telegram`); `daemon`\ndefaults to `status`, and `reload` aliases `restart`. There is no\n`gjc daemon restart sdk` recovery command. Never repeat successful mutations\nwhen a multi-target daemon result contains failures.\n\n### Retained error evidence\n\nWhen essential sanitized reconciliation references do not fit inline, the CLI\nmay retain them locally, but advertises `evidence.status: \"retained\"` and a\ncontinuation only after successful publication and read-back verification.\nRetrieval is an explicit family-root operation, not an operation retry:\n\n```text\ngjc sdk --error-ref <32-lowercase-hex-id> --error-sha256 <64-lowercase-hex> [--error-page <N>] [--error-agent-dir <dir>] [--json]\ngjc daemon --error-ref <32-lowercase-hex-id> --error-sha256 <64-lowercase-hex> [--error-page <N>] [--error-agent-dir <dir>] [--json]\n```\n\nUse the actual returned argv rather than constructing a locator. Ref and digest\nare required together; page defaults to 1 and is a positive decimal safe integer.\nSelectors support separate/equals forms; duplicates are invalid. Retrieval is\nexclusive with help, operation positionals and operation flags. Scope/page\nselectors require retrieval. `--error-agent-dir` is distinct from SDK operation\n`--agent-dir`; daemon worker `--agent-dir` is private. Retrieval uses only the\nexplicit/default root and checks the stored family, never scans other roots.\n\nThe store is `<effective-agent-dir>/cli-error-evidence-v1`. Records expire exactly\n24 hours after creation, without sliding reads. Expiry refuses retrieval; lazy\ncleanup is not a promise of immediate secure erasure. Limits are 64 committed\nrecords, 1 MiB per record and 16 MiB committed aggregate, plus at most one 1 MiB\npending file and one 4096-byte lock: at most 66 files and 17 MiB + 4096 bytes of\nlogical payload (filesystem allocation overhead is separate). Unexpired records\nare not evicted to make space. POSIX owner-only permissions are verified; an\nunsupported secure permission mechanism fails closed. No success/help output\ninitializes the store and there is no background cleanup service.\n\nSuccessful retrieval is `gjc.command-error-evidence` version 1 on stdout, exit 0,\nwith each text/JSON page bounded to 8192 bytes. It means retrieval succeeded, not\nthat the original operation did. Follow the pinned digest/scope/mode continuation;\nreassemble contiguous base64 bytes and verify total length and SHA256 before\nUTF-8/JSON decoding or using references. Pages contain immutable retained evidence,\nnot resampled runtime state.\n\nDisk, permissions, quota, lock contention or verification failure may prevent\nretention. The original failure remains nonzero with its original outcome\ncertainty and retryability: `complete: false`, `evidence.status: \"unavailable\"`,\nand `continuation: null`, with the explicit warning that necessary evidence\ncould not be retained and **do not blindly retry the original operation**.\nMissing essential evidence is not described as optional, and no invented path,\nID, digest or fallback store is advertised. Lossless retrieval is guaranteed only\nfor successfully published records within their retention lifetime; expiry,\ndeletion or corruption can make later retrieval unavailable.\n\nThese bounds do not paginate successful session/search/spawn/guides results.\nSearch outside Git retains its successful exit-0 result; observing an unhealthy\ndaemon is still a successful status operation. Serve preflight errors use the\nordinary failure contract; after relay ownership, protocol framing and termination\nremain unchanged, without CLI error envelopes injected into the stream.\n\n## Scoped search (`gjc sdk search`)\n\n`gjc sdk search [--scope repo|pwd|global] [--limit N] [--cursor <token>] [--json]`\nlists broker-visible sessions inside one exact scope. The default scope is\n`repo`: the identical canonical Git worktree of the invoking directory, never a\npath prefix or subtree. `pwd` matches the exact canonical working directory and\n`global` covers every broker-visible row.\n\nEvery result — table and JSON, populated and empty — carries a scope/status\nenvelope: the requested scope, the canonical resolved scope, a status\n(`populated`, `empty`, `not-in-git-worktree`, `unavailable`), and the\nobservation time. Running `--scope repo` outside a Git worktree is a successful\nempty result (`not-in-git-worktree`, exit 0) and never falls back to `pwd` or\n`global`. Broker unavailability keeps the locally resolved scope, exits\nnon-zero, and stays credential-free. Continuation cursors are frozen: a\ncontinuation that supplies a different scope or anchor fails with\n`scope_cursor_mismatch` instead of re-scoping.\n\nRows are probed only after scope filtering, through broker/router-owned\ncredential-free attachments, yielding `reachable`, `unreachable`, or `stale`.\n\n## Read-only broker observation (`gjc sdk diagnostics broker`)\n\n`gjc sdk diagnostics broker [--agent-dir <dir>] [--expected-generation <id>] [--timeout-ms <ms>] [--json]`\nobserves an already running broker publication. Unlike every other broker\ncommand on this page it never calls `ensureBroker`: it cannot start, ensure,\nretire, restart or recover a broker, cannot spawn a host, and writes no error\nevidence — not even for malformed input. It answers only from an already-owned\nhealthy retained publication; a broker that owns no publication reports typed\nunavailability instead of acquiring the authority to reply.\n\nSelection is exact. Without `--agent-dir` no alternative root is scanned, and a\nreplacement publication observed mid-request fails closed rather than\nreconnecting. A second observation is a new explicit invocation, never an\nautomatic retry.\n\n`--timeout-ms` (1..10000, default 2000) is one absolute budget for the whole\nobservation — the native lease, connect, authentication and the final identity\nrecheck all draw from it, and expiry outranks a later refusal. It is a **work\nbudget, not a guaranteed wall-clock return**: the unchanged SDK client close\ngrace can extend settlement past the budget, and a synchronous kernel read\nalready in flight is not forcibly cancelled. Expect the observation to stop\ndoing new work at the deadline, not to return at exactly that instant.\n\nThe result is `schema: gjc.broker-observation`, `version: 1`, an `observedAt`\nclient timestamp, and exactly one of:\n\n- `broker`: `generation` (the broker's publication-incarnation id, fixed at its\n startup — not the endpoint generation and not the package version),\n `build.packageVersion`, `build.buildId` (startup-captured trusted metadata, or\n `null`), and `diagnosticProtocol: 1`.\n- `unavailable`: `reason` plus a fixed message keyed by that reason. The reasons\n are exactly `absent`, `stale`, `incompatible`, `authentication_failed`,\n `unsupported`, `generation_mismatch`, `transport_unavailable`, `timeout`,\n `invalid_response` and `unsafe_discovery`. Messages are literals, never\n exception text.\n\nMalformed input is **not** an unavailable reason. Bad argv is a usage error: the\nCLI prints the fixed usage block and exits `2` without observing anything, and\nthe SDK facade rejects the same inputs by throwing a typed options error to the\ncaller before any observation begins. `invalid_arguments` is that caller-error\nsurface, not a value the result DTO can carry.\n\nExit codes are `0` for an observed broker, `1` for typed unavailability, and `2`\nfor malformed argv. All output stays within 8192 UTF-8 bytes and identifying\nfields are never truncated into misleading values. A broker too old to publish a\ngeneration and diagnostic protocol is `unsupported`; compatibility is never\nsynthesized. The document carries no pid, path, URL, port, token, argv or\nenvironment value.\n\nObservation qualifies on darwin arm64 with Bun 1.4.0 on a local\nownership-enforcing APFS volume, and activates only the fixed package or cached\nnative artifact — a missing or mismatched artifact is `unsupported`, never\nextracted or repaired. It is observation, not signed supply-chain attestation.\n\n## Local-only spawn (`gjc sdk spawn`)\n\n`gjc sdk spawn --cwd <dir> --prompt <task> [--model <selector>] [--profile <name>] [--json]`\ncreates one task-seeded background child session through the broker. It is\nlegal only inside a live interactive `gjc --master` session: the command needs\nthe master's transient capability (threaded through the master session\nenvironment) and the broker verifies it against the live effective master host\nbefore any effect. Spawn is prohibited on MCP, ACP, daemon CLI/raw session CLI,\nTelegram, Discord, and Slack surfaces.\n\nEach invocation uses a fresh idempotency identity. One identity produces at\nmost one child substrate and one seed prompt; repeated requests replay the\nstored outcome. A semantically new task requires a new invocation, never a\nretry of an old identity. `spawn_in_progress` and `terminal_uncertain` are\nhonest durable states: inspect with `gjc sdk search` or session status instead\nof retrying blindly.\n\nThe task text and master capability never persist anywhere in broker state —\nno plaintext, hash, or derived verifier appears in the lifecycle ledger, spawn\nauthority journal, receipts, logs, or output. Spawn output renders only safe\nfields: result code, claim id, child session id, substrate kind, and opaque\nseed facts.\n\nClose spawned children through the standard `session.close` path; the broker\ncloses only an exactly re-proven substrate and retains uncertainty on identity\nmismatch. Children orphaned by confirmed master loss are reaped after\n`sdk.masterOrphanGraceMs` (default 120000 ms, bounded 60000..3600000), with the\norphan clock preserved across broker restarts.\n",
104
+ "sdk-session-cli.md": "# SDK session CLI\n\n`gjc sdk session` is the broker-bound command family for operating live GJC SDK\nsessions from the terminal. It replaces the removed `gjc daemon session` route.\n\nThe command family has seven semantic verbs — `list`, `inspect`,\n`send`, `status`, `tail`, `close`, and `retire` — plus the explicit `raw` hatch that dispatches one\nSDK operation as `control`, `query`, or `global`.\n\nThe session CLI is advisory tooling over the SDK: every semantic verb resolves\nsessions through the SDK broker, and output is rendered through a versioned,\ncredential-free DTO. Endpoint credentials are never printed.\n\n## Command-local help\n\nDiscovery is static and inert for every public SDK and daemon parent, leaf and\nalias; it does not initialize a broker, settings, transport or evidence store.\nUse the deepest known command for its arguments, applicable options and risks:\n\n```sh\ngjc sdk session raw query --help --json\ngjc daemon reload --help --help-section options --help-page 1\n```\n\n`-h` is equivalent to `--help`. Sections traverse in this fixed order:\n`overview`, `usage`, `children`, `arguments`, `options`, `examples`, `recovery`.\nThe defaults are `overview` and page `1`; an empty section has a valid page 1.\nEach response includes only the selected node and, in `children`, its immediate\nchildren. Follow the returned `next.argv` unchanged, including\n`--help-revision <sha256>`, section, page and `--json` when selected. A changed\nrevision requires a new traversal; do not merge pages from different revisions.\n\nValued selectors accept separate or `=` operands. Pages are positive decimal\nsafe integers. Repeated/invalid selectors, selectors without actual help, and\nunknown descendants fail with usage exit 2 at the nearest known command.\n`--help=json` is unsupported. Option values and tokens after `--` do not activate\nhelp or JSON mode. Syntax containing `<placeholders>` is documentation, not a\nready-to-run recovery command.\n\nText and `gjc.command-help` version 1 JSON responses are each at most 8192 UTF-8\nbytes including the newline. Text escapes controls and labels display literals;\nnever paste escaped display text as shell argv. Complete entries have stable\nIDs. Oversized values use non-executable base64 fragments: concatenate contiguous\n`offsetBytes` ranges, verify `totalBytes` and `sha256`, then decode the complete\nUTF-8 JSON value. `sectionComplete` ends a section; only `documentComplete: true`\nwith `next: null` ends traversal. Help paging never reruns an operation.\n\n## Broker authority\n\n`list`, `inspect`, `send`, `status`, and `tail` resolve sessions through the\nSDK broker and Router. The Router validates indexed endpoint authority and keeps\nthe connection credential in SDK core; the CLI receives only credential-free\nresults. The broker is started on demand (`ensureBroker`) when discovery is\nabsent, and an unavailable broker fails closed with a typed operational error\n(exit 1).\n\n### Attach-only mode\n\nSet `GJC_SDK_BROKER_AUTOSTART=0` when a supervisor (for example a system\nservice) owns the broker. Every SDK client entry point — `gjc sdk session`,\n`gjc sdk search`, `gjc sdk spawn`, the SDK and coordinator MCP servers, ACP,\nand session hosts — then only attaches to a live, reusable broker and never\nspawns, retires, or restarts one. When discovery is absent, stale, names a\ndead process, or names a live broker this client cannot reuse, the call fails\nwith `broker_unavailable` (exit 1) and leaves the incumbent untouched. Broker\nmanagement refuses too, before any effect: `gjc doctor` broker restart reports\n`attach_only_refused`, and `scripts/restart-sdk-broker.ts` exits with an error\nwithout closing hosts or stopping the broker. Restart through the supervisor\ninstead. Any other value, or an unset variable, keeps on-demand startup. The\nbroker entrypoints ignore the variable, so it is safe for broker descendants to\ninherit it. A supervisor that wants its broker's session hosts to stay\nattach-only sets the variable on the broker process as well.\n\n`--attach-only` is the per-invocation flag form on `gjc sdk session …`,\n`gjc sdk search`, `gjc sdk spawn` and `gjc mcp-serve`. `gjc sdk serve` needs\nneither: it only reads broker discovery and never starts a broker.\n\n### Supervised broker: `gjc sdk broker run`\n\n`gjc sdk broker run [--agent-dir <dir>]` is the stable foreground broker\ncommand for supervisors such as systemd or launchd. It owns the broker for the\nagent directory (default: the configured agent directory; a relative path\nresolves against the current directory) and never detaches: the published\ndiscovery pid is the supervised process itself.\n\n- It serves until SIGTERM or SIGINT, stops gracefully (discovery is removed),\n then exits with the signal status: 143 for SIGTERM, 130 for SIGINT. Under\n systemd, set `SuccessExitStatus=143` so a normal stop is not reported as a\n failure.\n- It exits 1 without serving when another live broker already owns the agent\n directory (even one this version cannot reuse), or when startup fails. It\n never retires the other owner. Startup also fails when the off-thread\n publication-liveness watchdog cannot be armed. That attempt removes its\n discovery file and lock instead of leaving a live pid published without\n the watchdog.\n- It exits 1 when it stops abnormally while serving: its discovery root was\n lost or replaced, or its heartbeat could not be renewed. A requested\n `broker.shutdown` is a clean stop (exit 0). Use `Restart=on-failure`.\n- It is SIGKILLed (signal status 137, not exit 1) when a discovery\n publication write blocks the process so the event loop cannot reach that\n same liveness deadline (`publication-liveness-timeout`, `max(60s, heartbeat\n TTL × 4)`). `Restart=on-failure` still restarts it; do not add 137 to\n `SuccessExitStatus`. The watchdog does not write a diagnostic before SIGKILL,\n so a blocked stderr cannot postpone termination. While the event loop is still running, the\n exit-1 path above is the one that runs.\n- Pair it with attach-only clients (`GJC_SDK_BROKER_AUTOSTART=0`) so nothing\n else starts a competing broker. Set the variable on the broker unit as well\n if its session hosts should stay attach-only.\n\n```ini\n[Service]\nExecStart=/path/to/gjc sdk broker run --agent-dir /home/me/.gjc/agent\nEnvironment=GJC_SDK_BROKER_AUTOSTART=0\nSuccessExitStatus=143\nRestart=on-failure\n```\n\n`--agent-dir` selects the broker state directory. It may appear at the session\nfamily level before the verb (`gjc sdk session --agent-dir <dir> list`) or on a\nleaf command. `--repo` selects the workspace directory for scoped listing or\nsaved-session resolution (default: the current directory). For compatibility,\n`inspect`, `send`, `status`, and `raw query` also accept `--repo`, but ignore it:\nthe exact session ID selects the broker target. Successful calls print a\npath-free warning to stderr. Failed `--json` calls keep stderr empty and return\ntheir structured error; command-local help identifies `--repo` as compatibility-only.\n\n## Semantic verbs\n\n### list\n\n`gjc sdk session list` queries the broker `session.list` global and projects\nevery indexed session into the versioned row DTO (`SESSION_ROWS_VERSION`). Each\nrow is credential-free and carries:\n\nThe list is fully paginated before scope filtering. By default its effective\nscope is `repo`. Select a scope with:\n\n```sh\ngjc sdk session list --scope repo|cwd|worktree|all [--repo <path>]\n```\n\n`--repo` is the selected workspace path and defaults to the process cwd. The\nresult reports the effective `scope` and a bounded credential-free `selection`\ndescriptor containing the canonical selected path and, for Git selections,\nthe canonical worktree root and Git common directory.\n\n- `repo` matches the canonical Git common directory, so the main checkout and\n linked worktrees are included while another repository is excluded.\n- `worktree` matches only the selected path's canonical containing worktree.\n- `cwd` matches only the exact canonical selected workspace; nested directories\n do not match.\n- `all` preserves the complete unfiltered Broker listing.\n\nFor a path outside Git, `repo` and `worktree` fail with the typed\n`not_a_repository` operational error and an actionable suggestion to use\n`cwd` or `all`; they never broaden the result. `cwd` remains available for an\nexact canonical path match. Unreadable or removed row workspaces are excluded\nfrom Git scopes deterministically and reported in `warnings`.\n\nThe raw global `session.list` route remains unfiltered, and `inspect`, `send`,\n`status`, `tail`, `close`, `retire`, and raw control/query behavior is unchanged.\n\nFor a process-isolated caller that needs bounded discovery, request exactly one\nBroker page instead of the semantic all-pages list:\n\n```sh\ngjc sdk session raw global --op session.list --page --limit 20 \\\n --agent-dir <agent-dir>\ngjc sdk session raw global --op session.list --page --cursor <opaque-cursor> \\\n --limit 20 --agent-dir <agent-dir>\n```\n\n`--page` preserves the Broker page envelope, including `indexSeq`, `warnings`,\nthe bounded `sessions` array, and the opaque `continuationCursor`. The cursor\nis a snapshot continuation: pass it back unchanged, with the same `--limit`,\nand never restart from the first page or locally slice an all-pages result. A\ncontinuation cursor cannot be combined with `cwd` or `resolveSessionId`; exact\nsaved-session resolution is a separate, single-page lookup.\nSemantic `gjc sdk session list` remains the fully paginated, scope-filtered\noperation.\n\n- `sessionId` and the `locator` (`cwd`, `worktreeRoot`, `stateRoot`), where `cwd`\n is the canonical workspace directory and `worktreeRoot` is the canonical Git\n worktree root or `null` outside a worktree;\n- `endpointGeneration`, `pid`, `live`, `deleted` (tombstone), `indexSeq`;\n- `hostIncarnation` and `identityProvenance` (`composite` | `legacy`);\n- `activity` (`{state: active|idle, at}`) and `lastHeartbeatAt`;\n- `terminalUncertain`, `lifecycleRequestId`, `endpointMtimeMs`;\n- `ambiguous` when the same `sessionId` has more than one unresolved\n authority-fencing `stateRoot` (cross-repo duplicate). A proven non-endpoint\n bookkeeping registration (the direct-session GC fence row, endpoint\n generation 0) stays indexed without fencing endpoint attachment; every other\n unresolved root, including an unproven generation-0 `lifecycle_terminal`\n claim, still fences.\n\n### inspect\n\n`gjc sdk session inspect <sessionId>` renders one indexed row from the broker.\nIt never reads endpoint discovery records directly: a missing or unavailable\nbroker fails closed rather than exposing endpoint authority outside SDK core.\n\n### send\n\n`gjc sdk session send <sessionId> --text <prompt>` submits an ordered\n`turn.prompt` carrying a caller-chosen operation reference (a ULID by default,\nor `--op-ref`). The result envelope reports `accepted` with the receipt and the\noperation reference used for later reconciliation.\n\n- `--wait` polls `turn.result` with `kind: \"prompt\"` until the prompt reaches a\n terminal state or the wait window (`--timeout-ms`, default 30s) elapses.\n `send --wait` never cancels a running turn; a window that elapses before a\n terminal state is reported as `wait_timeout` with the last observed status.\n- `send` only starts a new turn. While the session is running a turn the host\n refuses `turn.prompt` before admission, and the CLI reports error code `busy`\n with `outcomeCertainty: \"not-applied\"`. To correct the running turn use\n `raw control <sessionId> --op turn.steer --json-input '{\"text\":\"...\"}'`;\n otherwise wait for the session to become idle and send again.\n\n- `--text` and the JSON input sources (`--json-input`,\n `--json-input-file` — which must be a `0600` regular file —\n `--json-input-stdin`) are mutually exclusive for the prompt body.\n- File input is read through one descriptor, rejects symlink/replacement races,\n and is capped at 4 MiB before JSON parsing.\n\n### status\n\n`gjc sdk session status <sessionId> <opRef>` performs a lossless `turn.result`\nlookup with `kind: \"prompt\"` for a previously submitted operation reference and\nreturns the full reconciliation record plus a `summary.completed` flag.\nSee [lossless prompt results](#lossless-prompt-results).\n\n\n### tail\n\n`gjc sdk session tail <sessionId>` replays the retained transcript from the\ndurable checkpoint and then follows the live event-ring frames, emitting the\ndefault tail kinds (session lifecycle and turn lifecycle events) plus retained\ntranscript entries.\n\n- `--strict` fails closed with `retention_gap` (exit 1) when retained history\n or the event ring dropped entries before the checkpoint.\n- `--until-idle` exits once the current turn reaches a terminal state; a session\n close exits any tail as `terminal: true`, while a bare live tail otherwise\n remains attached until `--timeout-ms`. Each item carries `revision` (the durable checkpoint revision); the triple `(revision,generation,seq)` is unique for the session, while `generation:seq` alone is revision-local and restarts each accepted prompt — key idempotency on the triple. Lifecycle\n events that carry a `(generation, seq)` position are reconciled and emitted in\n that canonical order rather than arrival order, so a retained terminal event\n from an earlier turn does not complete a newer turn that is still running, and\n a terminal event observed live is not undone by replayed history that arrives\n after it. Positioned event items are emitted canonically ahead of an\n arrival-ordered segment of events that carry no position; such an event stays\n visible but cannot supersede a positioned turn state. Different lifecycle\n kinds claiming the same `(generation, seq)` fail closed with `protocol_error`\n (exit 1) because no order between them can be proven, and that conflict\n outranks an otherwise successful idle or close completion. A canonical\n position is a pair of non-negative safe integers. A replayed event-ring row that\n states either coordinate property must state both validly; a row claiming only\n one coordinate, or a null, negative, fractional, non-finite, or\n unsafe-integer coordinate, fails closed with `protocol_error` rather than\n being treated as unpositioned. Only a row that states neither coordinate is\n unpositioned. Retained transcript entries are deduplicated independently of\n event-ring rows and are not event-ring authority, so a transcript row that\n projects the same kind and position as a real event never suppresses it. For a\n specific prompt operation, `status <sessionId> <opRef>` remains the lossless\n authority.\n- `--all-events` widens the emitted set to every event-ring kind.\n- `--cursor` resumes from a saved signed checkpoint claim. `session.checkpoint`\n verifies the unexpired claim and exchanges it for a fresh connection-owned\n cursor pinned to the exact prior revision; direct cross-connection cursor\n consumption remains rejected, so reconnect never echoes or rewinds a cursor.\n- `--after-transcript-id` selects the caller's last observed transcript row;\n live resumes require it alongside `--cursor`, while offline tails may use the\n boundary directly. The boundary is recoverable: an unknown row leaves the\n retained snapshot intact, while a boundary older than the retained window\n fails closed with a retention gap.\n- `--timeout-ms` bounds live follow; a session whose lifecycle already ended\n (terminal or `terminalUncertain`) replays retained history and exits instead\n of hanging.\n\nA deleted session has no tail (`session_deleted`). A stopped session replays\nits retained transcript without an endpoint (offline source), bounded to the\nmost recent retained entries.\n\n### close\n\n`gjc sdk session close <sessionId>` is the official semantic wrapper for the\n`session.close` broker global. It ends one live session: the broker validates\nindexed authority, asks the host to shut down gracefully, escalates to SIGTERM\nagainst the durably identified session process when graceful teardown exceeds\nthe bounded deadline, and appends terminal `session_closed` evidence. A session\nwhose ownership is `terminalUncertain` is refused rather than signalled — that\nis what `retire` is for.\n\n```sh\ngjc sdk session close <sessionId> [--agent-dir <agent-dir>] [--idempotency-key <key>]\n```\n\nClosing never attaches to the session. A Router attachment registers the\ncalling process as a live client and renews the host's abandonment window,\nwhich is the opposite of the intent, so the lifecycle mutation is answered by\nthe broker over its own client.\n\n`--idempotency-key` is optional here, unlike the raw `session.close` global. The\ndefault request key is derived from the session id and the current endpoint\ngeneration/incarnation. A retry against the same live host replays the same\nlifecycle request; a host resumed under the same session id receives a fresh\nrequest identity and is closed by a new lifecycle operation. Pass\n`--idempotency-key` explicitly to supply a caller-chosen attempt key.\n\n### retire\n\n`gjc sdk session retire <sessionId>` is the official semantic wrapper for the\n`session.reconcile_uncertain` broker global. It retires an indexed\n`terminalUncertain` create effect only when exactly one matching uncertain\ncreate identity exists, the recorded host is proven exited, the endpoint is\nabsent, and any lifecycle marker/readiness leftovers are regular files bound to\nthat same PID and incarnation. The broker removes only those verified\nleftovers, appends terminal `session_closed` evidence, and converts the\nmatching lifecycle receipt to a terminal error. Ambiguous identity, live or\nunverifiable hosts, endpoint presence, malformed leftovers, and mismatches\nrefuse without signalling a process. Supply `--idempotency-key`; the JSON\ninput is read from a `0600` file or stdin when it contains proof material.\n\n## Raw hatch\n\n`gjc sdk session raw <control|query|global>` dispatches exactly one SDK\noperation and returns the broker/host response:\n\n- `raw control <sessionId> --op <operation>` — one control operation with\n `--json-input*`; `--confirm` confirms destructive control operations.\n Turn control ids are `turn.prompt`, `turn.steer` (`{\"text\":...}`),\n `turn.follow_up`, `turn.abort` and `turn.abort_and_prompt`. An unknown id is\n a usage error (exit `2`, diagnostic `sdk_unknown_operation`) and is never\n dispatched.\n- `raw query <sessionId> --query <operation>` — one query; `--cursor` passes a\n continuation cursor.\n- `raw global --op <operation>` — one broker global. Lifecycle globals\n (`session.create`, `session.fork`, `session.resume`, `session.close`,\n `session.delete`, `session.reconcile_uncertain`, `session.lookup`) require\n `--idempotency-key`.\n\n`session.lookup` is a read-only reconciliation of a previously submitted\n`session.create`. Reuse the caller request key and the same create target that\nwere retained before dispatch; GJC derives the Broker identity and fingerprint\ninside the lifecycle boundary. It never calls `session.create` and never\nreplays session work:\n\n```sh\ngjc sdk session raw global --op session.lookup \\\n --idempotency-key <create-request-key> \\\n --json-input '{\"cwd\":\"/absolute/path/to/repo\"}' \\\n --agent-dir <agent-dir>\n```\n\nThe credential-free result identifies the original operation and request key.\n`status: \"found\"` carries the canonical session result; `pending`,\n`not_found`, `conflict`, `uncertain`, and `terminal` are distinct structured\noutcomes and retain `certainty` and the recovery identity. A missing record is\nnot proof that the create did not execute, so callers must not resubmit solely\nbecause lookup reports `not_found`. Reconciliation failures exit nonzero while\npreserving the structured outcome in JSON.\n\n### Legacy lifecycle admission fence\n\n`operation_failed` with diagnostic `lifecycle_legacy_history_ambiguous` means the\nbroker refused the **new request before dispatch** because a pre-index ledger\nrow cannot establish that its target is unrelated. The underlying broker code is\n`legacy_lifecycle_ambiguous`, distinct from key reuse (`idempotency_conflict`) and\nan already admitted operation with an uncertain effect (`terminal_uncertain`).\nThe public outcome and retryability remain `unknown`: refusing this request does\nnot settle earlier effects.\n\n- Preserve the original operation, caller request key, exact target/input, and\n ledger. A new key, empty/config-only transcript, `live:false`, or `deleted:false`\n is not proof that a legacy effect did not occur. Do not force deletion or edit,\n truncate, or remove the ledger.\n- For a prior `session.create` whose original key and create target are known,\n use the read-only `session.lookup` command above. `not_found`, `conflict`, and\n `uncertain` do not authorize another mutation. Public lookup currently supports\n create only; do not invent a lookup for delete or close.\n- `retire` is limited to its complete identity-bound uncertain-create proof.\n It is not an override for opaque legacy rows. Missing original identity or\n terminal effect evidence requires operator/maintainer investigation; there is\n no automatic safe retirement for such a row. Preserve it unresolved.\n- Proven final `terminal_ok`/`terminal_error` legacy rows recording only other\n sessions may already pass admission with an unrelated key. Rows recording the\n target, lacking target evidence, or reusing its key remain fenced. Unresolved\n `accepted`, `effect_started`, `awaiting_ready`, and `terminal_uncertain` legacy\n history stays fenced even for an apparently unrelated empty session. Recovery\n after restart marking a row `terminal_uncertain` is not proof of no effect.\n- `session.spawn` claim mirrors are not legacy requests. Current brokers mark\n each mirror with `spawnAnchor: true` in its row, and a marked mirror never\n fences, even after its claim leaves `sdk/spawn-authority.jsonl`. Mirrors\n written by brokers that predate the marker have no durable field, position, or\n timestamp that proves they are mirrors (in-place legacy migration rewrites\n row timestamps), so the broker never infers them: they stay fenced like any\n other unkeyed row.\n- Operator release of a pre-marker mirror: with the broker stopped, write\n `sdk/lifecycle-ledger.spawn-anchor-attestation.json` as\n `{\"version\":1,\"identities\":[\"<identity>\", ...]}` and start the broker. List\n only identities you can match to a `claim.lifecycleIdentity` in a preserved\n copy of `sdk/spawn-authority.jsonl` or other first-hand evidence; listing a\n genuine legacy request removes its fence. The broker never writes or derives\n this file. It honors a listed identity only while that identity's entire\n history is a single unkeyed, unmarked `accepted` row with no session target\n and no quarantined or corruption-uncertain history; other entries, and the\n whole file when it is malformed or cannot be read (for example a directory or\n a file without read permission), are ignored and stay fenced. An unreadable\n file is logged as a broker warning and never stops the broker from starting. A still-fenced\n identity keeps returning `legacy_lifecycle_ambiguous`. Removing an identity\n restores its fence at the next broker start.\n\nThis diagnostic identifies the actual admission guard without exposing ledger\nidentities, request hashes, filesystem paths, credentials, or broker free text.\nOther failures do not receive it; do not attribute every unknown delete outcome\nto legacy history.\n\nA separately connected local controller can stop the active turn and its exact\nowned work with an explicitly confirmed operator abort:\n\n```sh\ngjc sdk session raw control <sessionId> \\\n --op turn.abort \\\n --json-input '{\"mode\":\"terminal\",\"scope\":\"owned\",\"operator\":true}' \\\n --idempotency-key '<unique-key>' \\\n --confirm\n```\n\nThe JSON input accepts only operation fields (`mode`, `scope`, `operator`).\n`--confirm` and `--idempotency-key` are CLI authority inputs, not JSON fields.\nThe exact `operator:true` shape is routed through the local Broker, which\nrevalidates the current endpoint identity and injects a process-bound private\ncapability before dispatch. MCP, ACP, notifications, and ordinary SDK endpoint\nrequests cannot mint operator authority: copying `operator:true` and\n`confirm:true` into a public control frame is rejected. Omitting confirmation,\nomitting the key, or supplying `operator:false` fails closed without invoking\nthe terminal-abort surface. Non-operator terminal abort retains its existing\nconnection-ownership semantics.\n\nThe route reaches every session host on the current gjc, including a TUI or a\ndirect CLI session the Broker did not launch. A Broker-launched host is bound by\nits lifecycle request id; any other host publishes a process-local random marker\nin its own endpoint registration in the Broker-owned session index, and the\nBroker echoes exactly that marker. The marker never appears in endpoint\ndiscovery, `session list`, or any SDK response, so an endpoint-token holder\ncannot forge it. When the operator abort stops a turn, the host's terminal shows\n`Turn stopped by a remote operator.` A host that published no marker (a gjc that\npredates the operator route) is refused before dispatch with\n`authorization_denied`, `outcomeCertainty: \"not-applied\"`, and diagnostic\n`sdk_operator_control_unsupported`; unlike `endpoint_stale`, retrying the same\nrequest cannot succeed until the session restarts on a current gjc.\n\n`session.get_endpoint` is refused unconditionally: endpoint credentials remain\nan SDK-core implementation detail. The raw hatch validates operation names and\nadapter dispositions up front and never renders endpoint-disclosure results.\n\n## Lossless prompt results\n\n`turn.result` with `kind: \"prompt\"` reports `accepted`, `in_flight`,\n`terminal_ok`, or `failed`; only retained-record capacity eviction yields\n`unknown`. `turn.prompt_status` remains a legacy prompt-only alias. A prompt\nthat is active at process restart is finalized from its durable pending outcome\n(or `prompt_failed` when it has none), so it never reports as `unknown` while a\nrecord exists.\n\n`unknown` means uncertainty, never proof of non-execution: do not reuse an\noperation reference as a retry mechanism (`client_ref_conflict` while the\nrecord is retained; after eviction a reused ref may be admitted again with the\nprior outcome unknown). Use one fresh operation reference per logical prompt\nand reconcile with `status`.\n\n## Checkpoint gaps\n\n`tail` reports a `retention_gap` when retained history or the event ring\ndropped entries before the durable checkpoint: the gap carries the missing\nsequence range (`missing.from`/`missing.to`) and a `resync` checkpoint.\n`--strict` turns any gap into `retention_gap` with exit 1; without `--strict`,\ntail continues from the resync position and reports the gap in the envelope.\n\n\n## Migration from the removed daemon session route\n\n`gjc daemon session` is removed and no alias is provided. Migrate:\n\n| Removed route | Replacement |\n| --- | --- |\n| `gjc daemon session list` | `gjc sdk session list` |\n| `gjc daemon session inspect <sessionId>` | `gjc sdk session inspect <sessionId>` |\n| `gjc daemon session send <sessionId> --text <prompt>` | `gjc sdk session send <sessionId> --text <prompt>` |\n| `gjc daemon session tail <sessionId>` | `gjc sdk session tail <sessionId>` |\n| raw control/query dispatch | `gjc sdk session raw control|query|global` |\n\nThe broker-bound surface replaces the daemon-owned routing: sessions are\nresolved through the SDK broker with validated endpoint identity instead of\ndirect discovery-file reads, and output is versioned and credential-free.\n\n## Exit codes and error envelope\n\nSession verbs exit `0` and keep their existing successful JSON stdout contract,\nincluding when `--json` is absent. **Machine callers must explicitly add\n`--json` for machine-readable failures.** Ordinary failures otherwise write\nbounded text to stderr only. With `--json`, failure writes exactly one\n`gjc.command-error` version 1 envelope and newline to stdout, with empty stderr.\nBoth modes are limited to 8192 UTF-8 bytes including formatting and newline.\nUsage errors exit `2`; operation failures (including unavailable broker/session,\nretention gaps and `send --wait` timeouts) exit `1`. A live `session tail` wait\nwindow is a bounded observation rather than an operational failure: it exits `0`\nwith `terminal: false` and all items observed before the deadline.\n\nThe envelope preserves `command`, `error.code`, `category`, `retryability`,\n`outcomeCertainty`, allowlisted `references` and evidence-based `nextSteps`, plus\n`omittedOptional`, `complete`, `evidence` and `continuation`. Safe fields are\nexplicitly projected; arbitrary exception messages, request bodies, credentials\nand stacks are not public error evidence. Do not infer non-execution from a\nnonzero exit or timeout: `wait_timeout` after acceptance means applied work,\nwhereas `uncertain_after_send` means the outcome is unknown. Preserve session,\noperation, idempotency, claim, command and turn references when supplied.\nA `busy` refusal from `send` is definite: the prompt was never admitted, so\nthere is no operation record to reconcile. A `session list` whose scope needs a\nGit repository but runs outside one is a usage error with diagnostic\n`sdk_scope_requires_repository`.\n\nError guidance performs no additional probes, status calls, retries, restarts or\nkills. Execute an appropriate explicit observation only when the task warrants\nit; reconcile before considering replay. `retire` is a proof-bound mutation,\nnot a generic status check, and requires confirmed intent and actual applicable\nproof. Daemon `stop`/`restart` interrupt work; `--force` permits SIGKILL escalation.\nDaemon kinds are `telegram`, `discord`, `slack` (default `telegram`); `daemon`\ndefaults to `status`, and `reload` aliases `restart`. There is no\n`gjc daemon restart sdk` recovery command. Never repeat successful mutations\nwhen a multi-target daemon result contains failures.\n\n### Retained error evidence\n\nWhen essential sanitized reconciliation references do not fit inline, the CLI\nmay retain them locally, but advertises `evidence.status: \"retained\"` and a\ncontinuation only after successful publication and read-back verification.\nRetrieval is an explicit family-root operation, not an operation retry:\n\n```text\ngjc sdk --error-ref <32-lowercase-hex-id> --error-sha256 <64-lowercase-hex> [--error-page <N>] [--error-agent-dir <dir>] [--json]\ngjc daemon --error-ref <32-lowercase-hex-id> --error-sha256 <64-lowercase-hex> [--error-page <N>] [--error-agent-dir <dir>] [--json]\n```\n\nUse the actual returned argv rather than constructing a locator. Ref and digest\nare required together; page defaults to 1 and is a positive decimal safe integer.\nSelectors support separate/equals forms; duplicates are invalid. Retrieval is\nexclusive with help, operation positionals and operation flags. Scope/page\nselectors require retrieval. `--error-agent-dir` is distinct from SDK operation\n`--agent-dir`; daemon worker `--agent-dir` is private. Retrieval uses only the\nexplicit/default root and checks the stored family, never scans other roots.\n\nThe store is `<effective-agent-dir>/cli-error-evidence-v1`. Records expire exactly\n24 hours after creation, without sliding reads. Expiry refuses retrieval; lazy\ncleanup is not a promise of immediate secure erasure. Limits are 64 committed\nrecords, 1 MiB per record and 16 MiB committed aggregate, plus at most one 1 MiB\npending file and one 4096-byte lock: at most 66 files and 17 MiB + 4096 bytes of\nlogical payload (filesystem allocation overhead is separate). Unexpired records\nare not evicted to make space. POSIX owner-only permissions are verified; an\nunsupported secure permission mechanism fails closed. No success/help output\ninitializes the store and there is no background cleanup service.\n\nSuccessful retrieval is `gjc.command-error-evidence` version 1 on stdout, exit 0,\nwith each text/JSON page bounded to 8192 bytes. It means retrieval succeeded, not\nthat the original operation did. Follow the pinned digest/scope/mode continuation;\nreassemble contiguous base64 bytes and verify total length and SHA256 before\nUTF-8/JSON decoding or using references. Pages contain immutable retained evidence,\nnot resampled runtime state.\n\nDisk, permissions, quota, lock contention or verification failure may prevent\nretention. The original failure remains nonzero with its original outcome\ncertainty and retryability: `complete: false`, `evidence.status: \"unavailable\"`,\nand `continuation: null`, with the explicit warning that necessary evidence\ncould not be retained and **do not blindly retry the original operation**.\nMissing essential evidence is not described as optional, and no invented path,\nID, digest or fallback store is advertised. Lossless retrieval is guaranteed only\nfor successfully published records within their retention lifetime; expiry,\ndeletion or corruption can make later retrieval unavailable.\n\nThese bounds do not paginate successful session/search/spawn/guides results.\nSearch outside Git retains its successful exit-0 result; observing an unhealthy\ndaemon is still a successful status operation. Serve preflight errors use the\nordinary failure contract; after relay ownership, protocol framing and termination\nremain unchanged, without CLI error envelopes injected into the stream.\n\n## Scoped search (`gjc sdk search`)\n\n`gjc sdk search [--scope repo|pwd|global] [--limit N] [--cursor <token>] [--json]`\nlists broker-visible sessions inside one exact scope. The default scope is\n`repo`: the identical canonical Git worktree of the invoking directory, never a\npath prefix or subtree. `pwd` matches the exact canonical working directory and\n`global` covers every broker-visible row.\n\nEvery result — table and JSON, populated and empty — carries a scope/status\nenvelope: the requested scope, the canonical resolved scope, a status\n(`populated`, `empty`, `not-in-git-worktree`, `unavailable`), and the\nobservation time. Running `--scope repo` outside a Git worktree is a successful\nempty result (`not-in-git-worktree`, exit 0) and never falls back to `pwd` or\n`global`. Broker unavailability keeps the locally resolved scope, exits\nnon-zero, and stays credential-free. Continuation cursors are frozen: a\ncontinuation that supplies a different scope or anchor fails with\n`scope_cursor_mismatch` instead of re-scoping.\n\nRows are probed only after scope filtering, through broker/router-owned\ncredential-free attachments, yielding `reachable`, `unreachable`, or `stale`.\n\n## Read-only broker observation (`gjc sdk diagnostics broker`)\n\n`gjc sdk diagnostics broker [--agent-dir <dir>] [--expected-generation <id>] [--timeout-ms <ms>] [--json]`\nobserves an already running broker publication. Unlike every other broker\ncommand on this page it never calls `ensureBroker`: it cannot start, ensure,\nretire, restart or recover a broker, cannot spawn a host, and writes no error\nevidence — not even for malformed input. It answers only from an already-owned\nhealthy retained publication; a broker that owns no publication reports typed\nunavailability instead of acquiring the authority to reply.\n\nSelection is exact. Without `--agent-dir` no alternative root is scanned, and a\nreplacement publication observed mid-request fails closed rather than\nreconnecting. A second observation is a new explicit invocation, never an\nautomatic retry.\n\n`--timeout-ms` (1..10000, default 2000) is one absolute budget for the whole\nobservation — the native lease, connect, authentication and the final identity\nrecheck all draw from it, and expiry outranks a later refusal. It is a **work\nbudget, not a guaranteed wall-clock return**: the unchanged SDK client close\ngrace can extend settlement past the budget, and a synchronous kernel read\nalready in flight is not forcibly cancelled. Expect the observation to stop\ndoing new work at the deadline, not to return at exactly that instant.\n\nThe result is `schema: gjc.broker-observation`, `version: 1`, an `observedAt`\nclient timestamp, and exactly one of:\n\n- `broker`: `generation` (the broker's publication-incarnation id, fixed at its\n startup — not the endpoint generation and not the package version),\n `build.packageVersion`, `build.buildId` (startup-captured trusted metadata, or\n `null`), and `diagnosticProtocol: 1`.\n- `unavailable`: `reason` plus a fixed message keyed by that reason. The reasons\n are exactly `absent`, `stale`, `incompatible`, `authentication_failed`,\n `unsupported`, `generation_mismatch`, `transport_unavailable`, `timeout`,\n `invalid_response` and `unsafe_discovery`. Messages are literals, never\n exception text.\n\nMalformed input is **not** an unavailable reason. Bad argv is a usage error: the\nCLI prints the fixed usage block and exits `2` without observing anything, and\nthe SDK facade rejects the same inputs by throwing a typed options error to the\ncaller before any observation begins. `invalid_arguments` is that caller-error\nsurface, not a value the result DTO can carry.\n\nExit codes are `0` for an observed broker, `1` for typed unavailability, and `2`\nfor malformed argv. All output stays within 8192 UTF-8 bytes and identifying\nfields are never truncated into misleading values. A broker too old to publish a\ngeneration and diagnostic protocol is `unsupported`; compatibility is never\nsynthesized. The document carries no pid, path, URL, port, token, argv or\nenvironment value.\n\nObservation qualifies on darwin arm64 with Bun 1.4.0 on a local\nownership-enforcing APFS volume, and activates only the fixed package or cached\nnative artifact — a missing or mismatched artifact is `unsupported`, never\nextracted or repaired. It is observation, not signed supply-chain attestation.\n\n## Local-only spawn (`gjc sdk spawn`)\n\n`gjc sdk spawn --cwd <dir> --prompt <task> [--model <selector>] [--profile <name>] [--json]`\ncreates one task-seeded background child session through the broker. It is\nlegal only inside a live interactive `gjc --master` session: the command needs\nthe master's transient capability (threaded through the master session\nenvironment) and the broker verifies it against the live effective master host\nbefore any effect. Spawn is prohibited on MCP, ACP, daemon CLI/raw session CLI,\nTelegram, Discord, and Slack surfaces.\n\nEach invocation uses a fresh idempotency identity. One identity produces at\nmost one child substrate and one seed prompt; repeated requests replay the\nstored outcome. A semantically new task requires a new invocation, never a\nretry of an old identity. `spawn_in_progress` and `terminal_uncertain` are\nhonest durable states: inspect with `gjc sdk search` or session status instead\nof retrying blindly.\n\nThe task text and master capability never persist anywhere in broker state —\nno plaintext, hash, or derived verifier appears in the lifecycle ledger, spawn\nauthority journal, receipts, logs, or output. Spawn output renders only safe\nfields: result code, claim id, child session id, substrate kind, and opaque\nseed facts.\n\nClose spawned children through the standard `session.close` path; the broker\ncloses only an exactly re-proven substrate and retains uncertainty on identity\nmismatch. Children orphaned by confirmed master loss are reaped after\n`sdk.masterOrphanGraceMs` (default 120000 ms, bounded 60000..3600000), with the\norphan clock preserved across broker restarts.\n",
105
105
  "sdk.md": "# Gajae-Code SDK\n\nFor embedding GJC in-process, see [the embedding SDK guide](./sdk-embedding.md).\nFor a beginner-friendly application development guide (recipes, customization, and surface selection), see [Building applications on the SDK](./sdk-app-guide.md).\n\n<p align=\"center\">\n <img src=\"../assets/telegram-mobile-hero.png\" alt=\"Gajae Code mobile answers for coding agents hero illustration\" width=\"100%\" />\n</p>\n\nThe SDK exposes a generic action/reply protocol without requiring integrations to scrape the terminal. SDK core owns all managed attachment discovery and credential-bearing clients through `SessionRouter`; Telegram, Discord, Slack, ACP, MCP, and CLI adapters receive only capability-scoped operations and never endpoint credentials.\n\n> Status: the Rust core (`crates/gjc-sdk`) provides the session-local wire protocol and endpoint record. TypeScript SDK core provides Broker lifecycle authority and `SessionRouter` attachment authority. Endpoint records and tokens are internal implementation details, not an external attachment surface.\n\n## External attachment policy\n\nExternal and managed integrations attach through SDK-core surfaces only:\n\n- lifecycle mutations use `SessionLifecycleService` and the Broker lifecycle ledger;\n- read-only uncertain-create reconciliation uses the public `session.lookup` lifecycle operation with the original request key and target;\n- live session controls use opaque `SessionAttachment` capabilities issued by `SessionRouter`;\n- endpoint URL/token discovery, raw WebSocket relays, and `gjc sdk serve` are not public attachment mechanisms;\n- lifecycle-equivalent per-session controls are prohibited on Telegram, Discord, Slack, ACP, MCP, and daemon CLI adapters.\n\nFor terminal-side session operation, use the broker-bound [SDK session CLI](./sdk-session-cli.md):\n`gjc sdk session list|inspect|send|status|tail|close|retire` plus the explicit `raw`\n`control|query|global` hatch. The CLI resolves the exact attachment through SDK\ncore and emits credential-free JSON on success. Add `--json` explicitly for\nmachine failures; default ordinary failures are text on stderr, not JSON stdout.\n\n### Public CLI discovery and recovery\n\nUse `gjc sdk --help` or deepest local help such as\n`gjc sdk session raw query --help --json` and `gjc daemon reload --help`.\nHelp covers only public paths and immediate children, never worker-only actions\nor flags. `--help-section overview|usage|children|arguments|options|examples|recovery`\nand `--help-page <N>` select bounded static pages; follow `next.argv` with its\n`--help-revision <sha256>` and mode intact. `-h` is accepted, `--help=json` is not.\nSelectors require actual help, valid positive decimal pages and no duplicates.\nHelp is inert and never initializes runtime services or storage.\n\nOrdinary errors use `gjc.command-error` version 1: with `--json`, one stdout\nJSON envelope and empty stderr; otherwise stderr text. Help, errors and explicit\nerror-evidence pages are bounded to 8192 UTF-8 bytes including newline. Existing\nsuccessful result shapes and serve frames after relay ownership are unchanged.\nDo not treat a timeout as proof that work was not applied; preserve returned\nreferences and reconcile rather than blindly replaying a mutation.\n\nOversized essential sanitized error evidence may be retained for exactly 24\nhours in `<effective-agent-dir>/cli-error-evidence-v1`: 64 committed records,\n1 MiB each, 16 MiB committed aggregate, with one additional pending file up to\n1 MiB and a lock up to 4096 bytes. Follow a verified continuation using\n`gjc sdk` or `gjc daemon` with `--error-ref <id> --error-sha256 <digest>`, optional\n`--error-page <N> --error-agent-dir <dir>`, and `--json` for machine output.\nRetrieval is root-only, exclusive with help/operations, and never searches other\nroots or replays the original operation. Reconstruct contiguous base64 fragments\nand verify length/digest before decoding; fragments are not executable commands.\nPublication failure reports incomplete/unavailable evidence and no continuation,\nnot fabricated recovery. Availability is conditional on successful publication\nand the retention lifetime, not a promise to overcome disk failure or deletion.\n\nRecovery hints perform no extra probes or automatic recovery. Daemon targets are\n`telegram`, `discord`, `slack`, not the SDK broker: never suggest\n`gjc daemon restart sdk`. `reload` aliases `restart`; stop/restart interrupt work\nand `--force` permits hard-kill escalation. Session retirement requires actual\nproof and confirmed intent, not merely an uncertain result. See the\n[full help, error and evidence contract](./sdk-session-cli.md#command-local-help).\n\n## Migration from removed external transports\n\nThe retired `--mode rpc`, `rpc-ui` and `bridge` transports have no replacement\nwire client. The existing `gjc sdk serve` relay remains discoverable CLI tooling,\nnot an external SDK attachment API. Process-isolated controllers use Coordinator\nMCP, `gjc sdk session`, or a configured managed adapter. In-process applications\nuse the [embedding SDK](./sdk-embedding.md).\n\n## External-agent SDK skills\n\nThe generated `sdk-skills/` bundle provides host-neutral guidance for scripts\nthat invoke the broker-bound session CLI. It is intentionally separate from\nGJC's four internal workflow skills, the coordinator MCP plugin, and the SDK MCP\nadapter. Its TypeScript and Python templates do not discover endpoints or create\ntransport clients.\n\nThe bundle owns exactly six files:\n\n- `manifest.json`\n- `gjc-sdk-discover/SKILL.md`\n- `gjc-sdk-operate/SKILL.md`\n- `gjc-sdk-author/SKILL.md`\n- `gjc-sdk-author/templates/direct-sdk.ts`\n- `gjc-sdk-author/templates/direct-sdk.py`\n\nRegenerate with `bun run generate-sdk-skills`; CI checks byte-for-byte content\nand rejects unexpected files with `bun run check:sdk-skills`.\n\n### Bundle format version\n\n`manifest.json` is the versioned root of the on-disk bundle contract. It\nidentifies `formatVersion` (currently `1`) and the exact relative file closure\nthat regeneration owns. Consumers must treat a bundle whose manifest is missing,\nmalformed, or declares an unsupported version as unreadable and fail closed. The\nskill prompts are authored as static Markdown sources under\n`scripts/gjc-sdk-skills/prompts/`; the generator copies them verbatim and\n`check:sdk-skills` proves the committed bundle matches the generated artifacts.\n\n### Trust boundary\n\nThe templates invoke only the broker-bound CLI. `SessionRouter` keeps endpoint\nresolution, credentials, SDK clients, replay, reconnect, and rotation inside SDK\ncore. The templates' fixed allowlists and nonce-bound approval are trusted-local\nprocedural safeguards, not capability isolation; lifecycle and attachment\nauthority remain enforced by Broker and Router.\n\nNo renderer-grade cross-process event stream is exposed to external scripts.\nUse managed adapters or Coordinator MCP for event-driven orchestration; see the\n[RPC-to-SDK v3 parity audit](./sdk-rpc-parity-audit.md) for remaining gaps.\n\n## Architecture\n\n```\nBroker lifecycle → Session runtime endpoint → SessionRouter → opaque adapter capability\n```\n\nThe Broker is the sole lifecycle executor and durable terminal authority. `SessionRouter` is the sole credential-bearing external attachment manager. Provider supervisors own only provider transport and presentation state.\n\n- **One endpoint per top-level session.** Each top-level session runs its own loopback WebSocket server. Subagents do not host endpoints. The Broker index is the authoritative live-session catalog, and `SessionRouter` multiplexes managed provider attachments across indexed sessions.\n- **Hosted by default.** SDK hosting is independent of notification configuration. Set `GJC_SDK_DISABLE=1` to opt out of hosting for a top-level session.\n- **Notification delivery is optional.** Configure and enable a managed notification adapter only when remote delivery is needed; the SDK endpoint remains available without one.\n- **Managed integrations use opaque attachments.** Telegram, Discord, Slack, ACP, MCP, and CLI adapters compose SDK-core services; they do not discover endpoint files or receive URL/token credentials.\n- **Zero wire-protocol change.** New transports do not require changes to `crates/gjc-sdk` or the JSON protocol.\n- **tmux-agnostic.** The endpoint behaves identically with or without tmux.\n\n## Internal endpoint publication\n\nA running session publishes an implementation-private credential record for Broker resolution. `SessionRouter` is the sole consumer of the resolved URL/token pair and the sole owner of per-session SDK clients, replay, reconnect, rotation, prepared activation, and opaque attachment capabilities.\n\nThe record path, schema, credential transport, and handshake are not public client contracts. ACP, MCP, Coordinator, CLI, provider daemons, extensions, and integrations must use Router-issued attachments or Broker lifecycle services; they must not scan state roots, parse discovery files, retain endpoint credentials, or open raw per-session WebSockets. Broker and Router validate process identity, endpoint generation, incarnation, and file integrity before attachment, and fail closed on stale or uncertain state.\n\n### Internal broker launch isolation\n\nWhen the SDK starts its default internal broker or session host from the published TypeScript source, GJC uses a fixed Bun launch policy: `--no-env-file`, a product-owned empty `bunfig.toml`, absolute product entrypoint paths, and no inherited `BUN_OPTIONS` or mutable compiled-mode markers. The broker bootstraps from the product SDK directory rather than the caller project; a session host still runs with the lifecycle-authorized workspace as its process cwd.\n\nThis boundary prevents a child from newly loading caller-cwd or user-global Bun preload/dotenv policy. It cannot determine how a value already present in the parent environment was originally loaded, so ordinary provider/GJC environment values remain inherited. Default internal children, including compiled self-spawns, remove inherited `BUN_OPTIONS` so parent eval/test/inspect/debug/runtime options cannot be replayed into a detached child. Compiled binaries otherwise retain their existing self-spawn command contract, corroborated by a dedicated embedded marker and exact anchored Bun virtual-filesystem identity. The explicit `GJC_SDK_SESSION_COMMAND` session-host override remains a trusted legacy operator boundary and is not parsed as a shell-safe general command API. There is no broker-command override.\n\nBroker and per-session discovery tokens remain in their authoritative private discovery files for SDK-core resolution. Launch errors, logs, and diagnostics redact those tokens and never include the child environment or isolation configuration contents.\n\n### Recovering legacy broker locks\n\nBroker lock records written before host qualification do not identify which machine created them. A machine cannot safely infer that such a lock belongs to it from a locally absent or reused PID when the agent directory is shared. GJC therefore refuses to reclaim a host-unqualified lock automatically; the error links here rather than treating an unverifiable owner as dead.\n\nFor a legacy lock that blocks startup, recover it only after confirming that every GJC broker using the same agent directory has stopped on every host that can access that directory:\n\n1. Stop the GJC processes or supervisor services that can start the broker on each host using that agent directory. Do not proceed while any broker may still be serving sessions or renewing discovery.\n2. Copy `sdk/broker.lock` and `sdk/broker.json` to a backup location outside the agent directory. Preserve the originals until startup and session state have been checked.\n3. Move both `sdk/broker.lock` and `sdk/broker.json` out of the `sdk` directory. Do not use this procedure for a host-qualified lock or while any host may still be running a broker.\n4. Start one GJC process using the agent directory and verify broker startup before restarting other clients or supervisors.\n\nThis procedure is a one-time operator migration for old host-unqualified records, not a way to take over a lock from a running broker. If all brokers cannot be stopped or the record already contains a host identity, preserve the lock and investigate the owning host instead.\n\n## Internal protocol\n\nThe following frame shapes are SDK-core implementation details for maintainers.\nExternal integrations must not construct these frames, attach to session\ntransports, or receive endpoint credentials; use a Router-issued attachment, the\nbroker-bound CLI, or Coordinator MCP instead.\n\n### Server → client\n\n`action_needed` — something needs attention:\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"act_9e31\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"workflowGateId\": \"wg_run_stage_1\",\n \"question\": \"Proceed?\", \"options\": [\"Yes\", \"No\"], \"recommendedIndex\": 1 }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"act_a42f\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"question\": \"Choose a target\", \"options\": [\"A\", \"B\"] }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"idle-sess-1-7\", \"kind\": \"idle\",\n \"sessionId\": \"sess-1\", \"summary\": \"finished refactor; awaiting next step\" }\n```\n\n- `id` is an opaque, transient presentation/action ID. It is the **only** authority accepted by generic `reply.id` through the current Router-issued attachment. It is not a durable workflow ID.\n- `workflowGateId?: string` is optional, additive SDK v3 correlation metadata, present only for the active presentation of a durable workflow gate. When present, it equals that gate's Q12 `gate_id`. Its public correlation key is `(sessionId, workflowGateId)` on the current Router-issued attachment; it never authorizes generic `reply`.\n- `kind: \"ask\"` is answerable in interactive/TUI and SDK workflow-gate sessions. `kind: \"idle\"` is notify-only and ephemeral (not replayed to attachments that start later). Ordinary asks and idle frames omit `workflowGateId`.\n- `recommendedIndex?: number` is optional, zero-based display metadata for `options`. Clients must validate that it is an in-range integer and ignore malformed values. Raw option labels and reply indices remain authoritative; never decorate submitted answers or infer a recommendation from position. The additive field is wire-compatible, but Rust consumers constructing the public `ActionNeeded` struct by literal must provide `recommended_index: None` when no recommendation exists.\n- This corrects the pre-v3 documentation invariant that `action_needed.id == gate_id`: they are deliberately different values. Clients must not preserve that invariant, infer a relationship from question/options/order, or retain private route, claim, receipt, epoch, token, or endpoint-generation maps.\n\n`action_resolved` — a pending action is now terminal and **non-repliable**:\n\n```json\n{ \"type\": \"action_resolved\", \"id\": \"act_9e31\", \"resolvedBy\": \"local\" }\n```\n\n`resolvedBy` is `local` (a local/direct control retired the presentation), `client` (a remote generic reply won), or `timeout`.\n\n`reply_rejected` — sent only to the client whose reply failed:\n\n```json\n{ \"type\": \"reply_rejected\", \"id\": \"act_9e31\", \"reason\": \"already_answered\" }\n```\n\nReasons: `already_answered`, `unknown_action`, `invalid_answer`,\n`resolver_unavailable`, `idempotency_conflict`, `unauthorized`.\n\nLive tool content on both the broker-created bus host and SDK-only host uses\n`{type:\"event\", kind:\"tool_execution_start\"|\"tool_execution_update\"|\"tool_execution_end\", payload:{event_type,event}}`.\nPrompt owners receive their command/turn correlation. Explicit observers must\nnegotiate **both** `turn_stream` and `session_host_observer_v1`; their copy has no\nother client's correlation, and an owner also observing receives no duplicate.\nOn the internal wire, `hello` or `event_replay.capabilities` declares these opt-ins\non the authenticated connection; replay negotiation merges with existing caps.\nCapabilities do not confer lifecycle or session identity authority.\nTool updates carry cumulative `partialResult` snapshots, not append-only deltas;\nreplace the prior snapshot. The end frame's `result` is authoritative, including\nwhen it differs from the last update. These content frames are best-effort and\nnot retained in lifecycle replay. A replay acknowledgement without content is\nnot evidence that a running tool produced no output.\n\n`bash_folded` — a foreground bash wait left the foreground and became a background job. Emitted once per fold into the replayable session event ring (so late-connecting clients receive it on `event_replay`), with `generic_safe` adapter disposition:\n\n```json\n{ \"type\": \"bash_folded\", \"sessionId\": \"sess-1\", \"jobId\": \"bg_3\", \"generation\": \"bg_3:1\", \"reason\": \"steer\" }\n```\n\n- `reason` is `steer` (a user steer arrived while the command had run past the 2 s grace window), `chord` (the `app.tool.backgroundFold` keybinding), `timer` (`bash.autoBackground`), or `sdk_control` (the `bash.background` control).\n- A replayed frame may describe a job that has since finished. `runtime.jobs.list` is the source of truth: each running/recent job carries `metadata.backgrounded` and, whenever it is `true` because of a fold, `metadata.foldReason` with the same values. The `bash.background` control (C52) returns `{ backgrounded: true, jobId }` on a fresh fold, `already_backgrounded` when no wait is foldable but a running job of this session was already folded (by steer, chord, timer, or a prior control call), `no_active_bash` when nothing is running or foldable, and `not_foldable` when a live wait refused the fold.\n- A `turn.steer` that folds a running bash behaves exactly like a TUI steer: the steer is consumed by the same turn at the tool boundary immediately after the fold result; `turn.steer_status` is unchanged.\n\nThe frames above are the internal transport contract implemented by SDK-core attachments. Managed adapters may receive optional server → client frames they can render or ignore: `identity_header` (one-time per-session repo/branch/machine header; Telegram topic-capable sessions additionally carry `telegramTopicsEnabled`), `context_update` (last message, task, goal, token usage, model, diff), `turn_stream` (live/finalized turn output), `image_attachment` (agent-produced images), `activity` (busy/idle, drives the typing indicator), `inbound_ack` (delivery state of an injected user message), `session_closed` (endpoint teardown; threaded adapters may delete/archive the remote conversation), `config_update` (current verbosity/redact), `hello` (server capability/version), `awaiting_input` (a foreground `bash` command printed a browser sign-in or device-code prompt and went quiet; carries only `toolCallId`, `toolName`, and `kind` — `browser_auth` or `device_code` — never the URL, code, command, or output; delivered live only to clients that advertise the `awaiting_input_v1` capability and never retained for `event_replay`; a notice detected while notification policy is provisional is held and delivered on activation if the command is still running), and `pong`.\n\n### Internal inbound frames\n\nSDK core creates inbound frames only after Router attachment checks. External\nintegrations must not construct or persist them. Managed adapters use their\nopaque `SessionAttachment`, and process-isolated scripts use `gjc sdk session`;\nneither path receives transport credentials.\n\n## Model catalog query (Q10)\n\nThe SDK exposes the model catalog through the paged Q10 registry query. `Q10`,\n`models.list/current`, `models.list`, and `models.current` are exact aliases:\neach returns the same paged registry array, not a current-model singleton or a\nfiltered list. Continue using the returned cursor until `page.complete` is\ntrue.\n\nEach row preserves the five legacy fields (`provider`, `id`, `name`,\n`contextWindow`, and `maxTokens`) and additively includes `reasoning`,\n`thinking`, and `current`. `currentThinkingLevel` appears only on the current\nrow when the live session has a thinking level. The exported DTO types are\n`Q10Model`, `Q10ThinkingCapabilities`, `Q10ThinkingEffort`,\n`Q10SettableThinkingLevel`, `Q10CurrentThinkingLevel`, and\n`Q10ThinkingMode`, all from `@gajae-code/coding-agent/sdk`; there is no public\n`/sdk/models` subpath.\n\n```json\n{\n \"provider\": \"runtime-provider\",\n \"id\": \"reasoning-model\",\n \"name\": \"Reasoning Model\",\n \"contextWindow\": 128000,\n \"maxTokens\": 8192,\n \"reasoning\": true,\n \"thinking\": {\n \"validLevels\": [\"off\", \"minimal\", \"low\", \"medium\", \"high\"],\n \"minLevel\": \"minimal\",\n \"maxLevel\": \"high\",\n \"mode\": \"effort\",\n \"defaultLevel\": \"low\"\n },\n \"current\": true,\n \"currentThinkingLevel\": \"high\"\n}\n```\n\n`thinking.validLevels` is always present and starts with `\"off\"`; it is the\ncanonical menu for `model.set` and never contains `\"inherit\"`. For a\nnon-reasoning model it is exactly `[\"off\"]`. Successful reasoning rows always\ninclude `minLevel`, `maxLevel`, and `mode`; only `defaultLevel` and raw `levels`\nare optional. Raw `levels` deliberately keeps its descriptor order and\nduplicates, while `validLevels` is the canonical, deduplicated menu clients\nshould render. `\"inherit\"` is a current-state readback value only and is rejected\nas a `model.set` input.\n\nMalformed reasoning descriptors are not client-recoverable catalog data. The\nquery returns the SDK's safe `internal` error rather than exposing a partially\nformed row or descriptor details.\n### Model profiles as synthetic models (`gajae-code/<profile>`)\n\nThe Q10 catalog also exposes model profiles as logical synthetic models under\nthe reserved provider namespace `gajae-code`, e.g. `gajae-code/codex-eco`.\nThese rows let clients (such as ACP model pickers) offer presets like ordinary\nmodels without provider-specific metadata:\n\n```json\n{\n \"provider\": \"gajae-code\",\n \"id\": \"codex-eco\",\n \"name\": \"Codex Eco\",\n \"contextWindow\": 222222,\n \"maxTokens\": 8888,\n \"reasoning\": false,\n \"thinking\": { \"validLevels\": [\"off\"] },\n \"current\": false\n}\n```\n\n- `gajae-code/<profile>` is a **logical namespace, not a callable provider**. No\n API transport, credentials, or streaming route is registered for it; send the\n value back through the generic `model.set` control (or the ACP `Model`\n select) to activate the profile.\n- Synthetic rows are **availability-filtered**: only profiles whose required and\n alternative providers have usable stored credentials are listed. The profile\n id suffix is parsed losslessly after the first namespace slash, so profile ids\n containing additional slashes or punctuation round-trip exactly.\n- `contextWindow`/`maxTokens` mirror the profile's resolvable default model when\n available and otherwise fall back to the shared unknown-model constants\n (222222 / 8888); the profile's real default model remains authoritative.\n- Synthetic rows are non-reasoning with `validLevels: [\"off\"]`: a `model.set`\n on a synthetic id with any thinking level other than `off` is rejected with\n `invalid_input`, and only an absent or `off` level is forwarded as a session\n override.\n- **Current-state semantics:** while a profile is active for the session, exactly\n the synthetic row carries `current: true` with `currentThinkingLevel:\n \"inherit\"`, and the underlying concrete row is not marked current. A persisted\n `modelProfile.default` alone (without an in-session active marker) never\n creates a synthetic current row. Selecting a concrete `provider/model` clears\n the active marker and restores concrete current semantics.\n- **Selecting a synthetic profile is session-scoped.** `model.set` with\n `gajae-code/<profile>` activates the full profile in the live session without\n writing `modelProfile.default`, `modelRoles`, or\n `task.agentModelOverrides`. Persisting a profile remains an explicit TUI\n choice (`/model` → default), mirroring `gjc --mpreset <profile> --default`.\n Unknown or ambiguous synthetic ids fail with `invalid_input`; missing profile\n credentials fail with the existing authentication-required error.\n- `gajae-code` is **reserved**: a user-defined `models.yml` provider of the same\n name disables the synthetic facade (rows are omitted and synthetic selection\n is rejected) rather than being silently shadowed. Q27 (`models.profiles.list`)\n remains the full profile catalog with explicit `available` status; Q10 is the\n availability-aware facade for client selection.\n`config.patch` mutations are serialized through the same session admission\nboundary as profile activation and default-model selection, so a patch racing\na synthetic `gajae-code/*` selection (or another patch) is applied in a\ndeterministic order and is never lost or clobbered by an activation rollback.\nThe cost is the same as `model.set`: an external `config.patch` queues behind\nany in-flight prompt admission rather than applying mid-turn.\n\n## Prompt acceptance, termination, and reconciliation (Q26)\n\n`runtime.capabilities.promptTerminalOutcomeVersion` is `1` when this contract is available. Its normalized TypeScript terminal outcome is:\n\n```ts\ntype SdkPromptTerminalOutcome =\n\t| {\n\t\t\tkind: \"stopped\";\n\t\t\treason: \"end_turn\" | \"max_tokens\" | \"max_turn_requests\" | \"refusal\" | \"cancelled\";\n\t\t\tprovenance: \"agent\" | \"client_cancel\";\n\t }\n\t| {\n\t\t\tkind: \"failed\";\n\t\t\tcode: \"prompt_failed\" | \"prompt_deadline_exceeded\";\n\t\t\tmessage: string;\n\t\t\tprovenance: \"agent_failed\" | \"deadline\";\n\t\tphase: \"submission\" | \"post_start\";\n\t\tcategory: \"provider_transport\" | \"provider_rejected\" | \"agent_runtime\" | \"deadline\" | \"unknown\";\n\t\tproviderCode?: string;\n\t\t\tproviderDiagnostic?: {\n\t\t\t\tcategory:\n\t\t\t\t\t| \"auth\"\n\t\t\t\t\t| \"rate_limit\"\n\t\t\t\t\t| \"quota\"\n\t\t\t\t\t| \"context_limit\"\n\t\t\t\t\t| \"invalid_request\"\n\t\t\t\t\t| \"provider_unavailable\"\n\t\t\t\t\t| \"unknown\";\n\t\t\t\thttpStatus?: number;\n\t\t\t\tcode?: string;\n\t\t\t\tevidence: \"structured_status\" | \"structured_code\";\n\t\t\t};\n\t };\n```\n\n`providerDiagnostic` is an optional, purely additive classification of *why* the\nprovider refused, so a caller can distinguish an auth rejection from a rate\nlimit or an upstream outage without parsing text. It is minted only by a\nprovider adapter from structured provider metadata (an SDK HTTP error or an\nexplicit SSE protocol error envelope); message text, response headers, request\nIDs and the legacy heuristic HTTP status never produce one. `httpStatus` is an\ninteger in `400..599` and is absent for an error delivered inside an HTTP 200\nstream. `code` is a canonical token from a closed allowlist, never a raw\nprovider string. Contradictory evidence (a status that disagrees with the code,\na conflicting nested code, an out-of-range status, an unreadable field) yields\nno diagnostic at all rather than a guess, and an unsupported code is discarded\nwhile an independently valid status may still classify. `billing_error` maps to\n`unknown`, not `quota`; `quota` and `context_limit` are reserved and are not\nemitted by the current adapter.\n\nThe field never changes the outcome it accompanies: `code`, `message`,\n`provenance`, `phase`, `category`, `providerCode`, receipt state, retry and\nfallback behaviour and CLI exit codes are identical with or without it. Records\nwithout the field stay valid, and a decoded or persisted diagnostic that fails\nrevalidation is stripped rather than invalidating the record. A late\n`agent_failed` may fill a diagnostic that a settled failure is missing; it never\nreplaces an existing one and never rewrites the settled terminal.\n\n`turn.prompt` returns `{ accepted: true, commandId, turnId, clientRef? }` only after\nits asynchronous preflight accepts the prompt. That receipt is a durable,\n**non-terminal pending claim**, not a process-durable terminal result. The SDK\nlater finalizes that claim with exactly one `SdkPromptTerminalOutcome`; cleanup\nmay follow only after the claim is durable.\n\nThe authoritative public reconciliation query is `Q26` / `turn.result`, scoped\nto the same live session runtime. Callers supply `kind: \"prompt\"`; its `outcome`\nfield is exposed only after finalization. A pending claim is never represented\nor exposed as a terminal outcome. `turn.prompt_status` remains a legacy\nprompt-only alias that injects the same `kind`.\n\nEvery prompt and skill status response includes `receiptState`: active records are `absent`; terminal records are `present`, `missing`, or `unknown`; unknown lookup is `unknown`. Ordinary success requires `status: \"terminal_ok\"`, `receiptState: \"present\"`, and readable non-empty text or an artifact path. A failed execution may retain a partial `present` receipt. Legacy version-1 reconciliation records without this additive field remain readable and project `unknown` rather than optimistic success.\n\nCallers that must recover from a lost acknowledgement should assign one fresh\n`clientRef` (a trimmed, non-empty string of at most 128 characters) to each\nlogical prompt, then reconcile through the broker-bound CLI:\n\n```sh\ngjc sdk session raw query <sessionId> --query turn.result \\\n --json-input '{\"kind\":\"prompt\",\"clientRef\":\"request-018f\"}'\n```\n\nThe alternate selector uses the same `kind` with\n`{\"kind\":\"prompt\",\"commandId\":\"command-id\",\"turnId\":\"turn-id\"}` as its JSON input.\n\nThe result status is `accepted`, `in_flight`, `terminal_ok`, `failed`, or\n`unknown`. Known records include `acceptedAt`; in-flight and terminal records add\n`startedAt` and/or `terminalAt`; finalized records include `outcome`; failed records\nalso include a bounded sanitized `error.code` and `error.message`. Cursors, partial\ngenerated-ID pairs, mixed selectors, and extra selector fields are rejected.\n\nCorrelated `agent_failed` is a sanitized, nonterminal diagnostic. It may arrive\nwhile the prompt is still `accepted` or `in_flight` and does not itself carry a\nfinalized `outcome` or prove that the exact run and its tools have settled. The\ncorrelated `agent_end` carries the terminal `outcome`; a finalized Q26 record\nremains the authoritative result. Clients correlate these frames and Q26 by the\nprompt identifiers, not infer terminality from a failure diagnostic, stream\nactivity, or an earlier pending claim.\n\nReconciliation state survives client disconnect/reconnect. With the session-private durable store (`.sdk-reconciliation/`), accepted and terminal prompt records also survive **GJC session-process restart** for the same session identity within capacity, subject to crash-consistent fsync. An ordinary non-terminal prompt record at restart finalizes its pending outcome and receipt state. A prompt with the explicit `deadlineRecoveryPending` marker is the exception: it remains `accepted` or `in_flight`, and its staged pending outcome is not exposed by Q26 while the SDK retains a durable recovery owner. A process restart does not recreate a missing exact-run/tool observation, so the pending outcome stays private until a real terminal event or new settlement evidence arrives. If ownership or settlement remains uncertain, the record stays nonterminal and recoverable instead of being converted into a synthetic deadline failure. A stopped prompt without receipt evidence becomes `terminal_ok + missing`; failed prompt or skill settlement without body evidence becomes `unknown`. Eviction or absence still returns honest `unknown`; that means the prior outcome is unknowable, not that execution did not occur. Active records are capped at 128 per kind and are never aged into terminal. Terminal records are capped at 256 per kind and evicted oldest-terminal first, with no age-based eviction. Reconciliation stores no prompt, transcript, credential, or provider-response body.\n\n### Request-owned queue cancellation and execution deadlines\n\nSDK-only ordinary abort cancels a snapshot of its authenticated requester's\nalready-admitted preflights, including a prompt accepted durably but not yet\nstarted. It cancels those per-request controllers without borrowing another\nrun's abort authority; foreign admissions and later pipelined requests are not\npart of the snapshot. A local `aborted: true` acknowledges cancellation, not a\nnew durable execution terminal. For already-accepted work, recover its original\n`clientRef` through `turn.result`; unconfirmed terminal persistence remains\nuncertain and never permits mutation replay.\n\nBefore consumption, a prompt diverted into steering retains its own queue-removal\ncapability; cancelling it must not abort unrelated active work. After consumption,\nits durable completion belongs to the exact consuming run and cancellation domain.\nA trusted natural terminal settles each joined accepted prompt with its own\ncorrelation. Confirmed queue removal settles only that submission, without waiting\nfor an unrelated run. A deterministic cancellation receipt waits for that\nsubmission's durable terminal; held or failed persistence remains uncertain,\nincluding same-key replay. Retired queue authority cannot be reused to abort the\nroot run.\n\nConfirmed queue residence suspends the terminal lease. Actual consumption or\nown-run promotion starts a fresh bounded lease, renewed only by attributable\nprogress in the same consuming run and cancellation domain. Session teardown\nretires joined attribution; late predecessor progress or terminal events cannot\nadopt or settle a successor. Transport or delivery failure alone does not prove\nexecution settled and does not retire a live unsettled execution owner.\n\n`turn.prompt` remains ordered and non-idempotent. Its envelope `idempotencyKey`\ndoes not replay a response or produce `idempotency_conflict`. A retained duplicate\n`clientRef` fails before execution with `client_ref_conflict`, but callers must not\nreuse a `clientRef` as a retry mechanism: after eviction the same value can identify\na new prompt while the old outcome remains unknown.\n\n### GJC-private SDK image staging\n\nAuthenticated machine attachments can stage image bytes without enlarging the\nprivate SDK's **256 KiB frame limit**. These are internal GJC SDK controls, not\nstandard ACP methods, MCP tools, or a new public image capability. Attach through\nthe Router as usual; do not discover endpoints or credentials yourself.\n\nWhen installed, `runtime.capabilities` lists `turn.image.begin`, `turn.image.append`,\n`turn.image.finish`, and `turn.image.discard`. A custom host transport must provide\nauthoritative live-connection membership and the host's internal read-only definitive\nexact-run settlement observer; otherwise the controls are not installed. This observer\nwaits for ledger settlement events independently of socket or host lifetime, not a\nbounded snapshot or polling loop.\nThe notifications host also requires its internal run-domain and terminal-owner\nobservers and active-handle accessor before advertising staging.\nModel-facing and chat adapters cannot invoke these machine-only controls.\n\n1. `turn.image.begin` takes `{ mimeType, byteLength, sha256, batchId? }` and returns\n `{ id, nextSequence: 0 }`. Supported formats are static PNG, JPEG, WebP, and GIF;\n animated PNG, WebP, and GIF images are rejected. The digest is lowercase SHA-256\n of the original bytes. Each image is at most 20 MiB.\n2. `turn.image.append` takes `{ id, sequence, data }`, with canonical base64 encoding\n of at most 96 KiB decoded bytes, and acknowledges `nextSequence` and\n `receivedBytes`. Sequence numbers must be contiguous.\n3. `turn.image.finish` takes `{ id }` and validates size, digest, MIME, dimensions,\n static-image policy, and decoding without transcoding. `turn.image.discard` takes\n `{ id }` to abandon an unredeemed upload.\n4. `turn.prompt` can take `stagedImages: [{ id }]` instead of `images`. Redemption is\n atomic, ordered, one-shot, and scoped to the same authenticated connection and\n session. Image-only prompts may use `text: \"\"`; direct and staged images cannot\n be mixed. Provider input retains the original bytes and MIME without transcoding or\n byte loss.\n\nThere are at most 16 outstanding uploads and 16 images per prompt. Each session has\nseparate 64 MiB upload-source and accepted-source admission limits; accepted base64\nstorage is charged in addition to source bytes against the shared 256 MiB process\nbudget, along with transient decode memory. Each session permits one in-flight\n`finish`; another session can decode concurrently when process capacity permits. Uploads\nexpire after 120 seconds without valid progress; optional same-owner `batchId`\nprogress renews only live peers. Stock hosts remove unredeemed leases on\ndisconnect. Custom transports should forward close notifications for immediate\nreclamation; every access, including async decode completion, also rechecks\nliveness, and missed close notifications cannot extend the expiry deadline. Accepted\nimage capacity belongs to the consuming execution, not the socket: disconnect,\nqueue residence, an unrelated terminal, or uncertainty alone cannot release it.\nFailed admission or confirmed queue removal releases never-executed capacity.\nAcceptance or cancellation does not prove an unadopted producer has stopped retaining\nimages: its capacity remains charged until that submission actually settles.\nOnce execution starts, an exact resource-settlement proof is required: even an\nattributed cancelled `agent_end` or transport teardown may leave tools running.\nEach retiring reservation has one event-driven settlement continuation, with no\npolling or observation cutoff. A late exact settlement releases its capacity even\nafter transport or host teardown; shutdown does not wait for a still-live producer.\nUnknown runs, quarantine, and observer failures retain capacity and produce a\nprocess-level diagnostic rather than claiming settlement. Internal budget snapshots\nexpose retained process source/accepted and transient charges. The existing ledger makes quarantine\nirreversible, so quarantined executions remain charged for this process's lifetime\nrather than claiming their tombstones prove settlement. An adopted execution\nwithout an exact resource handle or owning domain also stays charged.\n\nPromotion into an own-run is not itself proof that execution started. If the\nexecution producer positively refuses that exact queued invocation before it can\nstart, its image reservation retires only after the enqueue producer returns.\nThis is a failed prompt, not queue cancellation; normal durable-publication\nrules still apply. Missing lifecycle events, a generic error, or teardown do not\nprovide this no-start authority. Started or ambiguous executions still require\ntheir exact settlement proof.\n\nAn ambiguous prompt response must not be replayed: recover the original `clientRef`\nthrough the correlated `turn.result` contract. Reusing a retained\n`clientRef` returns `client_ref_conflict` before staged-image redemption, not a second\nacknowledgement or execution. Missing or consumed staged IDs return\n`image_upload_unavailable`; a closed session or connection still returns `resource_gone`.\n\nACP's public contract remains `session/prompt` with `ContentBlock.image`\n(`type: \"image\"`, base64 `data`, and `mimeType`), negotiated through\n`agentCapabilities.promptCapabilities.image`. See the pinned\n`@agentclientprotocol/sdk` schema and the upstream [content specification](https://agentclientprotocol.com/protocol/content).\nThat capability does not advertise `turn.image.*` or `stagedImages`. This SDK\nfoundation does **not** change ACP's existing oversized-prompt rejection; a separate\nACP consumer must bridge standard inline images to staging internally and retain\nthe public schema, cancellation, and capability semantics.\n\n### Cancellation outcomes and inactivity limits\n\n`turn.abort` returns a typed disposition. A caller that does not own the target\nreceives `resource_gone`; it must not treat that result as cancellation of another\nprompt.\n\n`sdk.promptDeadlineMs` defaults to `3_600_000`. It accepts only safe integers in\n`[60_000, 86_400_000]`; there is no disable value. The SDK snapshots the setting\nwhen the prompt is durably accepted as the initial inactivity lease. Fresh\n**attributable** progress for the exact accepted `commandId`/`turnId` — `tool_execution_start` /\n`tool_execution_update` / `tool_execution_end` observed at the prompt/agent runtime boundary — renews the deadline to\n`lastProgressAt + sdk.promptDeadlineMs`, bounded by the hard maximum `sdk.promptMaxRuntimeMs`\n(default `21_600_000`, same `60_000–86_400_000` range). Only tool-execution events for the\naccepted turn count — including a running tool's partial-result `tool_execution_update`, so a\nlong-running tool that streams output (e.g. a multi-minute compile) keeps renewing the lease mid-run;\nheartbeats, streaming text/thinking deltas, retries, other turns/sessions, and\nunrelated session noise do not renew the lease, and out-of-order delivery never shortens it. The\nhard maximum is never unbounded: every renewal is capped at `acceptedAt + sdk.promptMaxRuntimeMs` so a\nwedged or continuously noisy prompt still reaches a deterministic terminal outcome. Terminalization then has a fixed `10_000` ms\ngrace period, which is not configurable. A controlled terminal failure reaches ACP\nas JSON-RPC `-32603` with `data.code` of `prompt_failed` or\n`prompt_deadline_exceeded`.\n\n`sdk.flushWorktreeOnDeadline` (default `true`) autosaves uncommitted changes in the session's linked worktree as a WIP commit when a prompt deadline retires a prompt. For a primary checkout it requires an explicit `true` opt-in written in the user/global config or a runtime override, because the checkout is a directory the session does not own. A project `.gjc/settings.json` or `.gjc/config.yml` does not count. Without that user opt-in, the flush is skipped and the prompt still terminalizes.\n\nAt expiry, the SDK fences the exact accepted prompt's run and waits for its\ndispatched tools to settle before publishing `prompt_deadline_exceeded`. A\ncancellation produced by that deadline fence does not replace the timeout\nfailure with a successful `cancelled` result. If exact run or tool settlement\ncannot be proven, Q26 remains `accepted` or `in_flight`, the pending outcome\nstays private, and no terminal frame is published until recovery proves\nsettlement.\n\n## Read-only broker observation\n\n`observeExistingBroker({ agentDir, expectedGeneration?, timeoutMs? })` reports what an\nalready running broker publishes about itself and does nothing else. It owns discovery\nand authentication internally, so callers never receive credentials, socket coordinates\nor private discovery objects, and it is the only broker entry point that cannot start,\nensure, retire, restart or recover a broker, spawn a host, replay a lifecycle operation\nor write error evidence. It never enters `SessionRouter`, `SessionLifecycleService`,\n`ensureBroker`, `Broker.start`, retirement, recovery, lifecycle lookup, model resolution\nor session enumeration.\n\nThe returned value is a detached frozen snapshot, not the broker's mutable discovery\nobject: `schema: \"gjc.broker-observation\"`, `version: 1`, `ok`, `observedAt`, and either\na `broker` record (`generation`, `build.packageVersion`, `build.buildId | null`,\n`diagnosticProtocol: 1`) or an `unavailable` record whose `message` is a fixed literal\nkeyed by `reason`. `generation` is the broker's publication-incarnation id fixed at its\nstartup — not `endpointGeneration`, not the package version — and the initial\npublication, the authenticated response and the final publication must all agree on it\nand on the internal owner/process/root identity. An `expectedGeneration` mismatch, or a\nreplacement publication observed during the request, fails closed without reconnecting\nor ensuring.\n\n`timeoutMs` (1..10000, default 2000) is a single absolute budget for the whole\nobservation; expiry outranks a later refusal. A broker that publishes no generation or\ndiagnostic protocol is `unsupported` rather than a synthesized compatibility success.\nThe CLI projection of this facade is `gjc sdk diagnostics broker` — see\n[SDK session CLI](sdk-session-cli.md). Observation qualifies on darwin arm64 with Bun\n1.4.0 on a local ownership-enforcing APFS volume and activates only the fixed package or\ncached native artifact; a missing or mismatched artifact is `unsupported`, never\nextracted or repaired. This is observation, not signed supply-chain attestation.\n\n## Skill invoke reconciliation\n\n`skill.invoke` accepts optional `clientRef` and returns an early accepted receipt\n`{ accepted: true, commandId, turnId, clientRef?, name, path, lineCount?, args? }` after\ndurable/preflight accept (SDK control path), not after skill completion. Query prior\nstatus with `Q26` / `turn.result` and `kind: \"skill\"`. `skill.invoke_status`\nremains a legacy skill-only alias that injects the same `kind`. Kind-scoped indexes\nmean prompt and skill `clientRef` values never collide. Skill records use the same\ncapacity/retention limits, but an active skill record at restart settles with\n`error.code = process_restart`.\n\n## Correlated steer acknowledgement (Q30)\n\n`turn.steer` accepts an optional `clientRef` (trimmed, non-empty, at most 128 characters). When present, GJC hashes the exact validated steer text with SHA-256 and durably reserves `dispatching` before queueing. The result contains `sessionId`, `clientRef`, `status: accepted | rejected | uncertain`, known `acceptedAt` or `terminalAt`, and bounded error metadata; it never echoes text.\n\nReplay the same `clientRef` with the same text to recover the retained result without dispatching again. Reuse with different text returns `client_ref_conflict`. A live `dispatching` record and a restart during dispatch both project `uncertain`; GJC never automatically redispatches an ordered control whose first effect is unknowable. Query the same session with:\n\n```json\n{ \"type\": \"query_request\", \"query\": \"turn.steer_status\", \"input\": { \"clientRef\": \"steer-018f\" } }\n```\n\nQ31 returns `accepted`, `rejected`, `uncertain`, or `unknown` and never dispatches work. Settled steer records share the 256-record oldest-terminal-first capacity bound with no age-based eviction; live dispatching records are not terminal-evicted. Existing uncorrelated `turn.steer` calls retain their legacy non-idempotent behavior. Version-1 reconciliation remains additive, and only digest plus bounded metadata is stored—never steer text.\n\n## Model profile discovery and validation (Q27)\n\n`Q27` / `models.profiles.list` pages the effective model-profile catalog owned by\nthe attached session. Rows are sorted by exact ID and contain only:\n\n```json\n{ \"id\": \"codex-medium\", \"displayName\": \"codex-medium\", \"source\": \"builtin\" }\n```\n\n`source` is `builtin` or `configured`. Profiles from `<agentDir>/models.yml`\noverride built-ins with the same exact ID, including their display label. Profile\nIDs are not trimmed, case-folded, sanitized, or restricted to safe-token names;\ndiscover the exact ID and send it unchanged. The retired `codex-standard` alias is\nfallback-only and never shadows a configured profile with that exact ID.\n\nQ27 uses retained-revision, connection-bound pagination. Continue an issued cursor\nto finish its stable snapshot; a fresh cursorless query observes the current\nregistry. The query accepts no root, path, or selector input. An invalid or\nunreadable `models.yml` fails closed with `model_profile_registry_error` rather\nthan returning a plausible built-ins-only catalog.\n\nBroker `session.create`, `session.fork`, and `session.resume` validate `modelPreset`\nbefore spawning against the same `<broker.settings.agentDir>/models.yml` authority\nthat the child receives through `GJC_AGENT_DIR` / `GJC_CODING_AGENT_DIR`. Unknown\nIDs return `unknown_model_profile`. Both typed errors include bounded `details`\nwith `requestedProfile` where applicable, whole exact `availableProfiles` entries\nthat fit the detail budget, and `discoveryQuery: \"models.profiles.list\"`. The\ndiscovery pointer is authoritative when the bounded error cannot include every ID.\n\nThe same lifecycle operations accept an optional `modelId`: an explicit\n`provider/model` pin with `gjc --model` grammar (#4707). Coordinators resolve it\nagainst the full model registry (the CLI `--model` surface, not the\nauthenticated-only subset) before sending the create request, so unknown ids are\nrejected before any session exists. The broker guards only its shape; the child\napplies it exactly like a CLI `--model` selection, which also means an explicit\n`modelId` wins over `modelPreset` when both are supplied — the same precedence as\n`gjc --mpreset <profile> --model <model>`. The full effective order is:\n\n```\nmodelId pin > modelPreset > configured modelProfile.default > role/resume/default\n```\n\nThe pin is a guarantee, not a preference. The coordinator validates against its\nown registry and the child owns the registry that actually serves requests, so\nthe two can drift (a model removed, a provider disabled, an extension that\nfailed to register). On drift the child fails session construction before\nreadiness and before any profile application, disposes the partial session, and\nreports an error naming the pinned selector — it never publishes success on a\nsubstituted model. Coordinator validation refreshes the registry per request\n(offline, from the on-disk discovery cache) so ids added or removed after an\nearlier pin are judged against current contents.\n\n### Active provider query (Q29)\n\n`Q29` / `providers.list/active` pages the providers currently eligible for model\nselection through the same authenticated, retained-snapshot envelope as Q10. Each\nrow is the non-secret DTO `{ provider, connectionKind }`, where `connectionKind`\nis `credential` or `credentialless`.\n\nProvider IDs are returned exactly as they appear in Q10 `model.provider`: existing\nmixed-case, spaced, punctuated, and long custom IDs are preserved without aliases\nor normalization. Rows are deduplicated and ordered by UTF-8 provider bytes.\nJoin Q29 to Q10 by exact provider ID; Q10 remains the full configured catalog.\n\nA credentialed discovery-only provider appears only after fresh discovery proves\nthe exact model is usable. Static configured models can appear without a network\nprobe. The query never invokes a model, refreshes credentials, probes a remote\naccount, or exposes credentials, account metadata, paths, or provider responses.\n\nResolver failures are atomic and return\n`{ \"code\": \"internal\", \"message\": \"Unable to resolve active providers.\" }`.\nThey omit a page and restart metadata. An expired continuation follows the shared\ncursor contract and returns `error.code: \"cursor_expired\"` with\n`error.restartQuery: true`. Malformed cursor strings return `invalid_cursor`;\ncross-query or selector mismatches return `invalid_input`.\n\n## Project progress snapshot (Q32)\n\n`Q32` / `session.progress` returns one read-only snapshot of the session's\nproject progress for orchestrators that supervise GJC as a subordinate agent.\nIt is the same projection the human `/progress` command renders — one\ncomputation over durable session state (goal mode, todos, the session ultragoal\nplan `goals.json`, active workflow state and HUD chips, subagent lifecycle\nrecords, recorded quality-gate receipts and review verdicts) — so the two views\ncannot disagree. The query accepts no input fields, writes nothing, and never\ninvokes a model.\n\nACP clients call it through `_gjc/sdk/query`:\n\n```json\n{ \"method\": \"_gjc/sdk/query\", \"params\": { \"sessionId\": \"…\", \"query\": \"session.progress\" } }\n```\n\nACP clients also get the human view as the builtin `/progress` command: it is\nadvertised in `available_commands_update`, and a prompt consisting of exactly\n`/progress` is answered by rendering this query's snapshot as plain text\n(`end_turn`, no model turn). Like any prompt it holds the session's prompt\nslot until the report is published, so a concurrent prompt gets `conflict`;\n`session/cancel` ends it with `cancelled` without aborting a host turn.\n`/progress` with arguments is an ordinary prompt, as in the TUI.\n\nMCP (`gjc_session_query`) and `gjc sdk session raw query` use the same query\nname. The response is a single-item page whose item has\n`schema: \"gjc.project_progress.v1\"`:\n\n| Field | Meaning |\n| --- | --- |\n| `state` | Completion state: `complete`, `awaiting-completion`, `blocked`, `in-progress`, `not-started`, `paused`, `dropped`, or `no-tracked-work`. |\n| `completion` | `basis` (`ultragoal-stories`, `todos`, `goal-status`, or `none`), `done`/`total` counted units, `percent` (`null` when `basis` is `none`), `allUnitsDone`, `complete`, and a plain-language `explanation`. |\n| `execution` | `goal`, `ultragoal` (story counts plus bounded per-story status and receipt flag), and `todos` (counts plus bounded items); each is `null` when not recorded. |\n| `activeWork` | `story`: the active story, else the next pending one, in Ultragoal scheduler order — blocked, review-blocked, and failed stories are never reported as next (failed stories are only retried on an explicit `--retry-failed`), so it is `null` when nothing is schedulable. `todo`: the in-progress (or next pending) todo. `workflows`: active workflows with their phase and HUD chips, bounded, with `omittedWorkflows` counting the rest. `agents`: subagent counts, where `waiting` is queued or paused subagents. |\n| `verification` | `storyReceipts` (complete stories with and without a quality-gate receipt; `null` until a story is complete) and published `reviewVerdicts`. |\n| `attention` | Blockers, pending items, and notes, each with `kind`, `source` (`ultragoal`, `workflow`, `subagents`, `todos`, `goal`, `state`), a stable `ref` (story id, workflow skill, or source key) when one exists, and text. |\n| `sources` | `sessionStateRead`; `unreadable` durable sources (`workflow-state`, `ultragoal-plan`), which are excluded from every count; `recovered` sources whose snapshot record was unreadable but whose contents were rebuilt from authoritative per-entry records (per-workflow `active/<skill>.json` entries); `discarded` sources whose readable derived snapshot still listed workflows without an active per-entry record (for example, a deactivation whose snapshot rebuild never ran), which are dropped as stale and never reported as active; and `unresolved` sources listing workflows without an active per-entry record in a snapshot that cannot be identified as legacy state or a derived snapshot, which are neither reported as active nor confirmed absent. An unreadable workflow snapshot with nothing to recover from is `unreadable`, never reported as \"no active workflows\". Workflows are always projected from the per-entry records; only a legacy root-only snapshot without `active_skills` keeps its own workflow. A per-entry record that exists but cannot be read or holds no usable workflow entry (for example `{}`) makes `workflow-state` `unreadable`; it is never treated as a deleted entry. |\n\nSemantics are deliberately conservative. Units count equally and open units\nearn no partial credit; `percent` is never 100 while a unit is open and never 0\nonce one is done. `complete` requires every counted unit done, the session goal\n(if any) complete, and no subagent running, queued, or paused; otherwise a\nfinished unit set reports `awaiting-completion`. Verification reports only\nrecorded receipts and verdicts: receipt freshness is not validated and no\nconfidence score is derived. Subagent counts come from the session's in-memory job records and\nreset with the process. Lists are bounded (`omittedStories`, `omittedItems`,\n`omittedWorkflows`, `omittedAttention` report the remainder) and durable text is stripped of\ncontrol characters and length-bounded. A session without a progress source\nreturns `unavailable`; input fields return `invalid_request`.\n\n## Answer semantics\n\nA remote reply answers a pending ask in every session state:\n\n- **Interactive / TUI mode:** the ask tool races the local selector against the\n remote reply (first valid answer wins). A client submits generic `reply` using\n the active presentation `id`; a local answer emits `action_resolved`\n (`resolvedBy: \"local\"`) and that presentation becomes non-repliable.\n- **SDK workflow gate:** generic `reply` still uses the active presentation\n `id`, never `workflowGateId`. The resolved gate drives the session the same\n way a local answer would.\n\nA session has at most one active answerable presentation. Interactive asks and durable workflow gates are serialized; further Q12 gates wait in a durable queue. A same-server reconnect replays the active `action_needed` with the same presentation ID. After a process restart, previously pending or accepted-but-unadvanced records are quarantined diagnostics and a reconstructed workflow remints fresh durable gate and presentation IDs. Terminal, stale, and reissued action IDs never regain authority.\n\nGeneric and direct controls may race. Once the native generic claim is acquired, it wins; a direct control that atomically retires the exact unclaimed active presentation first wins instead. Losing direct controls fail without advancing the gate, and losing generic replies are stale/non-repliable. Clients must not retry by matching text, durable IDs, or presentation history; they must fail closed rather than guess when session or action identity is unsafe or ambiguous.\n\n### Durable workflow controls and Q12\n\n`workflow.gate_answer` and `workflow.plan_approve` operate on the durable Q12\n`gate_id`, not `action_needed.id`. Managed adapters bind `expectedSessionId`\nfrom their current Router-issued attachment. Process-isolated operators use the\nraw-control flow documented in the [SDK session CLI guide](./sdk-session-cli.md); they do not construct protocol frames.\n\n`expectedSessionId` omission remains accepted and audited for the entire SDK v3 line so deployed v3 control clients continue to work; new clients must send it now. It cannot become mandatory, or be removed from the controls, before SDK v4 and at least one full published deprecation release/window with deployed-client notice. A supplied session mismatch is rejected before the gate resolver runs. Neither control accepts a presentation ID, remaps an old ID to a reminted gate, or uses heuristic matching.\n\nQ12 (`workflow.gates.list`) exposes durable query records and additive SDK v3 diagnostics. A pending record preserves its workflow fields including `gate_id` and adds `id: \"pending:<gate_id>\"`, `tag: \"pending\"`, and `answer_recorded: false`. An accepted-answer diagnostic uses `id: \"diagnostic:<gate_id>\"`, `tag: \"accepted\"`, `answer_recorded: true`, and `resolved_at`, plus one `post_accept_disposition` value (`accepted`, `terminalized`, `advanced`, `continuation_lost`, or `unknown`) so an operator can distinguish a gate still waiting for continuation from one whose answer was durably advanced. A restart quarantine diagnostic uses `id: \"diagnostic:<gate_id>\"`, `tag: \"quarantined\"`, `answer_recorded`, and optional `lifecycle` containing `state: \"quarantined\"`, its restart reason, `quarantinedAt`, and an optional `supersededByGateId` after a remint. When available, every record also carries the originating `runtime_turn_id`; raw answers and derived answer values are never exposed. Diagnostics are query-only: they cannot be routed, answered, or promoted. Treat Q12 as the durable status surface, not as generic-reply authority.\n\n### Coordinator MCP question pull loop\n\nThe Coordinator MCP bridge is a separate, public-safe pull surface for external coordinators. `gjc_coordinator_list_questions` requires `session_id` and reconciles pending `workflow.gates.list` rows on every call, returning bounded public `questions`, `diagnostics`, and `reconciliation`. It accepts `status: \"pending\"`; `status: \"open\"` remains a compatibility alias. Multiple pending rows can be returned. A pending row carries its safe question shape, public option ids, and `answer_binding`, never raw/private gate payloads or values.\n\n`gjc_coordinator_submit_question_answer` requires `session_id`, `turn_id`, `question_id`, `answer_binding`, `answer`, `idempotency_key`, and `allow_mutation: true`. It re-lists/revalidates after restart and resolves through `workflow.gate_answer`, not generic `ask.answer`. An incomplete reconciliation returns `terminal_uncertain`; stale, terminal, missing, or ownership-mismatched rows cannot be answered. Re-list after restart rather than retaining old identifiers. An identical retry with the same idempotency key replays the accepted result; conflicting reuse returns `idempotency_conflict`.\n\nThis contract does not change #2549/#2551 or unattended plain-CLI behavior.\n\n### Rust and N-API compatibility\n\nThe Rust `ActionNeeded`, `ServerMessage`, and `register_ask` APIs remain\nlegacy-compatible and uncorrelated. Correlation is available through additive\nRust workflow-frame decoding/current-reader APIs and the workflow registration\npath; consumers that need correlation must opt in explicitly. N-API likewise\nretains `registerAsk`, and adds `registerWorkflowGateAsk` for a correlated wire\nframe plus `registerArbitratedAsk` and `retireIfUnclaimed` for in-process\npresentation arbitration. The arbitration lease and all claim/receipt/epoch\nstate remain private: these APIs do not create a public authority value.\n\n### Runtime and native addon release pairing\n\nThe `@gajae-code/coding-agent` runtime and `@gajae-code/natives` native addon ship from the same source release at exact matching package versions. The native loader requires the matching version sentinel; mixed native/runtime versions are unsupported and must not claim SDK compatibility.\n\n## Minimal provider adapter example\n\nProvider integrations compose SDK core's `SessionRouter`; they never read endpoint files or retain URL/token credentials:\n\n```js\nimport { router } from \"@gajae-code/coding-agent/sdk\";\n\nconst sessionRouter = new router.SessionRouter({\n agentDir,\n deps: {\n onAttachment: (attachment) => provider.bind(attachment.sessionId, attachment),\n onFrame: (attachment, frame) => provider.render(attachment.sessionId, frame.body),\n onSessionRemoved: (attachment) => provider.unbind(attachment.sessionId),\n },\n});\n\nawait sessionRouter.start();\nconst attachment = sessionRouter.attachment(sessionId);\nif (!attachment) throw new Error(\"session attachment unavailable\");\nawait attachment.send({ type: \"reply\", id: actionId, answer });\n```\n\n`SessionRouter` performs exact endpoint resolution, credential custody, replay, reconnect, rotation, and dispatch-time stale-lease rejection.\n\n### Exact fenced session close\n\nCapture the endpoint authority from the original Router binding and pass that\nsame pair to the lifecycle service. The generation and incarnation are one\nauthority: a partial target is rejected before it reaches the Broker.\n\n```ts\nconst authority = await sessionRouter.bindingAuthority(sessionId);\nif (!authority) throw new Error(\"session endpoint authority unavailable\");\n\nconst outcome = await lifecycleService.close({\n actor,\n capability: \"session.close\",\n requestKey: \"close-captured-session\",\n target: authority,\n});\n```\n\n`bindingAuthority()` returns `undefined` when the Router cannot prove the\ncurrent endpoint and its incarnation. Do not reconstruct the authority from\nendpoint credentials, process metadata, or a later session lookup; a\nreplacement may become current between capture and close.\n\n### Exact generation reconciliation\n\nManaged consumers that close or delete a session can reconcile one previously\npersisted attachment generation without retaining endpoint credentials:\n\n```ts\nconst proof = await sessionRouter.generationStatus(sessionId, endpointGeneration);\n\nswitch (proof.status) {\n case \"current\":\n // This exact generation is the current live indexed authority.\n break;\n case \"retired\":\n // A retained host-unregister, close, or delete event positively retired it.\n break;\n case \"replaced\":\n // A different live generation is current; use proof.currentGeneration.\n break;\n case \"unknown\":\n // Do not infer retirement or retry a possibly-applied lifecycle mutation.\n break;\n}\n```\n\n`generationStatus(sessionId, endpointGeneration)` is credential-free and may be\ncalled after `SessionLifecycleService` returns from `session.close` or\n`session.delete`, and after `SessionRouter.stop()`. Its evidence contains only\n`source: \"session_index\"`, a coherent `observedIndexSeq`, the successful\nstate's `evidenceIndexSeq`, and, for positive retirement, the safe terminal\nevent kind. It never exposes endpoint URL/token data, process IDs, locators,\nprivate Broker responses, or lifecycle cleanup payloads.\n\nThe result is an observation of one locked index reconciliation cut:\n\n- `current` requires the queried generation to be the current live, unambiguous\n indexed authority.\n- `retired` requires a retained exact-generation `host_unregistered`,\n `session_closed`, or `session_deleted` event. Missing attachment, missing\n endpoint publication, Router shutdown, or a dead/unreachable host never imply\n retirement.\n- `replaced` requires both prior observation of the queried generation and a\n strictly greater current live generation. Same-generation reconnect remains\n current; reuse, regression, or safe-integer wrap is classified as unknown\n rather than manufacturing a successor relationship.\n- `unknown` is fail-closed. Reasons distinguish invalid input, unavailable or\n incomplete index reconciliation, an unobserved session/generation, ambiguous\n authority, and detected generation reuse. Consumers must preserve uncertain\n lifecycle state rather than treating `unknown` as retired. Expired positive\n evidence is reported specifically as `proof_expired`.\n\nEvery host start takes a new event generation from the agent directory's\nper-session generation authority (`sdk/event-generations/`). Allocation runs\nunder a cross-process file lock and returns a value strictly above every\ngeneration previously issued for the session, with the host's wall-clock start\ntime in milliseconds only as a floor. A session resumed by a later process\ntherefore never reuses an earlier host's `(generation, seq)` event coordinates,\nstill outranks the earlier host's retained index identity after the clock steps\nbackward, and two hosts started in the same millisecond never share a\ngeneration. A host that cannot reach the authority keeps serving its local\nendpoint but publishes to the session index only after the authority reserves\nexactly the generation it serves. Treat a generation as an opaque safe integer,\nnot a counter that starts at `1`: replay cursors and notification publication\nids recorded against one generation never position or deduplicate another\ngeneration's events.\n\nProof survives Broker, Router, and consumer process restart because it is read\nfrom the durable session index, not Router attachment memory. Positive terminal\nproof has the session-index `maxAgeMs` lifetime (30 days by default), even when a\ndelete tombstone is retained longer for audit. Row-bound compaction may evict a\nwhole session sooner. After proof expiry, compaction eviction, or loss of a\ncomplete index, `generationStatus` returns `unknown`, never a synthetic\nretirement proof. This bounds the public proof surface independently of durable\nBroker audit retention.\n\n## Fallback chains\n\nModel-role selectors may be ordered fallback chains; see [Fallback chains](./models.md#fallback-chains) for configuration and retry-budget details. Resolution-time skips do not consume attempts. When a request-time retry advances to another eligible entry, the selected default fallback remains sticky for later prompts in that session until an explicit model selection or a chain reset changes it.\n\n`model_fallback_switched { eventId, from, to, reason, role, scope, activeIndex, chainLength, attemptsUsed }` is the canonical session lifecycle event for every real fallback-model switch. It replaces the legacy `retry_fallback_applied` / `retry_fallback_succeeded` event names. Embedding clients can subscribe to this in-process session event; managed adapters receive only the status projections their SDK-core integration supports.\n\n\n## Managed session-directory adapter guidance\n\nSDK adapters that need to inspect saved sessions must import only the supported public surface from `@gajae-code/coding-agent/sdk`:\n\n```ts\nimport {\n SESSION_DIRECTORY_API_VERSION,\n listManagedSessionCandidates,\n resolveManagedSessionScope,\n} from \"@gajae-code/coding-agent/sdk\";\n\nif (SESSION_DIRECTORY_API_VERSION !== 1) throw new Error(\"Unsupported session-directory API\");\nconst resolved = await resolveManagedSessionScope({ cwd: process.cwd() });\nif (resolved.kind === \"resolved\") {\n const listing = await listManagedSessionCandidates({ scope: resolved.scope });\n // Consume only listing.kind === \"complete\" and its owned candidates.\n}\n```\n\nThis is a readonly resolver/listing contract. Do not import `@gajae-code/coding-agent/session/internal/*`, derive `v2-…` names, write bindings, or implement migration/cleanup in an adapter; private internal subpaths are intentionally unavailable from the packaged module. Treat `network_unsupported`, binding/security errors, incomplete listings, invalid candidates, and foreign candidates as non-authoritative results rather than retrying with a guessed path.\n\nThe resolver uses canonical native identity: supported POSIX and Windows local aliases can designate one scope, while UNC/network workspaces are unsupported. Scope digests are collision-resistant identifiers, not injective aliases, credentials, or authentication. The owner-only checks protect managed local storage paths but do not authenticate an adapter or make hostile concurrent filesystem races safe. Adapters that need mutations must use the higher-level lifecycle/session APIs rather than the readonly directory API.\n## Managed notification adapters\n\nGJC ships managed SDK adapters for Telegram, Discord, and Slack. `SessionRouter` resolves one session-owned endpoint per attachment and keeps every endpoint credential inside SDK core. Provider daemons receive only opaque attachment capabilities; they neither change the wire protocol nor expose a remote shell.\n\nThe recommended interactive path is `/settings` → **Notifications**. It owns\nsetup, health, test, recovery, reconnect, local enablement, and Telegram\nremoval without exposing stored credentials.\n`gjc notify setup` remains the authoritative CLI fallback for headless and\nautomated environments.\n\nNotification credentials and `notifications.*` settings are global-only.\nProject notification keys are\nignored and runtime notification overrides are rejected. Telegram pairing\nrevalidates the complete bot-token/chat identity immediately before polling and\nagain before activation. A foreign or unknown owner is never killed, reloaded, or taken over;\nsetup fails closed without saving or exposing the raw token.\n\nConfiguration completeness, provider-local quarantine, durable desired intent, effective enablement, runtime readiness, and delivery outcomes are separate contracts. The global `notifications.enabled` master never erases provider credentials or desired flags. `/settings` edits secrets through explicit `keep`, `replace`, or `remove` actions, commits only the selected provider in one CAS batch, and reports post-commit observer or activation failures without pretending the durable save rolled back. Malformed provider-local values are quarantined for explicit repair while safe sibling providers remain usable; malformed global notification structure remains fail-closed.\n\n`GJC_NOTIFICATIONS=0` suppresses only automatic generic current-session admission. Explicit `/notify on` can opt the current session back in without mutating durable provider state, and direct provider APIs remain governed by provider effectiveness and their own runtime readiness. Telegram, Discord, and Slack attachments are reconstructed through `SessionRouter`; no provider receives the shared endpoint token.\n\n- [Telegram notification onboarding](./telegram-onboarding.md) documents\n `gjc notify setup` and private-chat pairing.\n- [Discord notification onboarding](./discord-onboarding.md) documents\n `gjc notify setup discord`, required configuration, thread lifecycle, and\n least-privilege permissions.\n- [Slack notification onboarding](./slack-onboarding.md) documents\n `gjc notify setup slack`, Socket Mode configuration, immediate envelope ack,\n and thread lifecycle.\n\n`gjc notify status` reports provider completeness, repair/quarantine state, desired intent, effective enablement, and masked tokens. Destination identifiers remain visible and may be sensitive. The Discord and Slack setup commands are non-interactive and require their documented identifier and token flags; supply secrets through an approved local mechanism, not examples, committed files, shell history, logs, or chat. `gjc notify health --provider <provider> --probe` performs a provider-owned REST diagnostic even when complete credentials are intentionally inactive, while `gjc notify test --provider <provider>` additionally requires effective enablement and runtime readiness.\n\nSession lifecycle and attachment routing are SDK-core services shared by every\nchat provider. `SessionLifecycleService` authorizes typed create, fork, resume,\nclose, delete, and list requests, derives the Broker idempotency identity, and\nprojects credential-free outcomes. The Broker remains the only lifecycle\nexecutor and durable terminal authority.\n\n`SessionRouter` consumes the Broker `SessionIndex`, resolves exact endpoint\nauthority, retains endpoint credentials and SDK clients, and owns replay,\nreconnect, rotation, and stale-attachment revocation. Telegram, Discord, and\nSlack receive only opaque current-generation attachments. Provider daemons own\ntransport leases, cursors, rate limits, threads/topics/messages, presentation\njournals, and delivery receipts; they cannot read endpoint files or tokens,\nallocate SessionIds, or perform session process lifecycle effects.\n\n\n## Managed Telegram daemon (bundled reference client)\n\nThe managed Telegram client is a provider supervisor and presentation adapter.\nIt owns the single `getUpdates` poller and Telegram topic state, while\n`SessionRouter` reconstructs SDK attachments from Broker state. A provider\nrestart never creates, resumes, closes, or mutates a GJC session by itself.\n\nFor Telegram forum topics, the daemon deletes the presentation topic through\n`deleteForumTopic` (falling back to `closeForumTopic` when deletion is unavailable)\nwhen `SessionRouter` retires the current attachment or when the daemon confirms\nthat no eligible local session still owns the topic. A resumed session creates or\nrebinds a fresh current-generation topic before sending again. Topic cleanup is\nbest-effort and cannot change the Broker lifecycle result.\n\n### Singleton poller and trust model\n\nTelegram `getUpdates` allows only one active long-poll owner per bot token. The\nmanaged daemon enforces **one bot token = one getUpdates poller** with a local\nlock/state file under the agent directory. New sessions attach to the existing\nfresh daemon owner instead of starting another poller, preventing Telegram 409\nconflicts.\n\nThe trust model is intentionally strict:\n\n- setup pairs exactly one private Telegram chat;\n- runtime accepts updates only from that paired chat id;\n- groups, supergroups, channels, and unpaired users never receive session names,\n action ids, pending status, or configuration hints;\n- daemon state stores a token fingerprint, not the raw bot token.\n\n### Routing in private-chat topics\n\nThe paired private chat prefers Telegram topics for coordinator/lifecycle sessions\n(Threaded Mode). The daemon tags messages by session, stores compact callback\naliases for inline buttons, and routes replies back to the exact session/action.\nOrdinary sessions use flat delivery and do not create topics. A forum-enabled\nsupergroup is no longer required: when the bot owner enables Threaded Mode in\n@BotFather, the daemon creates topics only for admitted orchestration sessions.\nGJC cannot enable Threaded Mode through the Bot API; setup only verifies the\ncapability and guides the manual BotFather toggle.\n\nIf BotFather's per-bot **Bot Settings** menu does not show **Threads Settings**\nor **Threaded Mode**, the supported fallback is the normal private-chat pairing.\nSetup can be saved as `threaded=unverified`/`threaded=unknown`, and the daemon\nstill tries topics when Telegram allows them. When `createForumTopic` is refused,\nthe daemon does not drop the send: it routes the notification to the normal\n(flat) paired private chat and posts a one-time nudge: `Flat Telegram private chat\nsupports outbound notifications and inline ask buttons only. Enable Threaded Mode\nin @BotFather > Bot Settings > Threads Settings for free-text replies and session\ncommands.` Pairing is private-only, so flat delivery stays within the user's own\nprivate DM.\n\nSupported reply paths:\n\n- tap an inline button on an ask notification;\n- reply inside the session's thread/topic (replies are thread-native; the\n topic identifies the session, so no session tag is needed).\n\nIn threaded mode the user can also adjust per-session behaviour with in-thread\nconfig commands: `/verbose` (per-tool-turn assistant text), `/lean` (settled\nassistant answer at idle plus immediate ask lead-ins; the default),\n`/verbosity <lean|verbose>`, and `/redact <on|off>`. The legacy\n`/answer <session-tag> <answer>` command is removed — replies are routed by the\ntopic they arrive in.\n\nFlat fallback keeps outbound notifications and inline-button answers working, but\nplain free-text never guesses from the global pending-ask set. Free-text replies\nand `/verbose`/`/lean`/`/verbosity`/`/redact` commands are thread-native and\nrequire Threaded Mode/topic routing. Enable Threaded Mode in @BotFather > Bot\nSettings > Threads Settings when you need free-text replies or session commands.\nDo not pair a group, supergroup, or channel to work around a missing BotFather\nmenu; the bundled setup flow is\nprivate-chat only, and non-private chat ids remain fail-closed to avoid session\ndata leaks.\nBecause the flat private chat has no per-session topic, flat idle markers carry\na short session tag (`🟢 Agent idle · <tag>`, last six characters of the session\nid) so concurrent sessions stay distinguishable. The tag never appears on asks\nor in threaded/topic delivery, where the topic itself identifies the session.\n\nUnknown, expired, or restart-unvalidated callback aliases fail closed: the daemon\nsends guidance and does not guess a target session or action.\n\n### Discord and Slack setup\n\nDiscord and Slack use the same internal notification events and reply protocol as\nTelegram. Store only runtime credentials in local GJC settings or environment;\nnever paste bot tokens, webhook URLs, transcripts, prompts, host paths, or raw logs\ninto docs, tests, issues, or PR comments.\n\nConfiguration keys:\n\n```yaml\nnotifications:\n enabled: true\n discord:\n botToken: \"<local Discord bot token>\"\n applicationId: \"<Discord application id>\"\n guildId: \"<Discord guild id>\"\n parentChannelId: \"<Discord parent channel id>\"\n slack:\n botToken: \"<local Slack bot token>\"\n appToken: \"<local Slack app-level token>\"\n workspaceId: \"<Slack workspace id>\"\n channelId: \"<Slack channel id>\"\n authorizedUserId: \"<Slack user id authorized for inbound replies and commands>\"\n redact: true\n```\n\nThe bundled adapters intentionally render public-safe message bodies and return\nroute metadata only for pending internal actions. They do not own polling,\nsession scans, daemon locks, rate limits, or SDK lifecycle. Production transport\nsenders should consume the adapter payloads and keep all credential-bearing HTTP\nor gateway details outside logged payloads.\n### Redaction\n\n`notifications.redact` strips sensitive content before remote delivery, but\n**asks are exempt**: an ask is an interactive prompt the human must read and\nanswer remotely, so its `question` and `options` are always sent unredacted\n(otherwise it would be unanswerable). When redaction is enabled, `idle`\nsummaries are removed and streamed content frames (`turn_stream`,\n`context_update`, `image_attachment`) are suppressed at their emit sites. The\ncontent-free `awaiting_input` notice is still delivered. When redaction is\ndisabled, all content is delivered unchanged.\n\n### Local `/notify`\n\nInside a GJC session, `/notify` controls the current session only:\n\n- `/notify status` reports enabled/disabled state, daemon observation when known,\n and redaction state without printing secrets;\n- `/notify off` disables the current session's notification endpoint and removes\n its discovery record without mutating global Settings;\n- `/notify on` re-enables the current session when global setup is complete and\n `GJC_NOTIFICATIONS=0` is not forcing opt-out.\n\n## Session lifecycle and attachment surfaces\n\nSDK core exposes two related provider-neutral capabilities:\n\n1. **`SessionLifecycleService`** accepts an authenticated actor, an explicit\n operation capability, a stable caller request key, and a typed target. It\n derives one Broker idempotency key and invokes the canonical Broker lifecycle\n operation. Results never expose endpoint URLs, tokens, process identities,\n cleanup paths, or raw Broker receipts. Successful create, fork, and resume\n results may include the credential-free `endpointGeneration` plus opaque\n `endpointIncarnation`; pass both back for an exact close authority rather\n than deriving a replacement identity from the numeric generation. A supplied\n saved-session `sessionIdentity` is enforced as the transcript snapshot\n precondition for resume and fork.\n For `session.list` targets that include `cwd` and `resolveSessionId`, the\n Broker attaches `savedSession` only after managed-scope and transcript\n identity validation. It retries one complete scan after a concurrent source\n change; a continued omission includes `savedSessionOmission` with the\n requested session ID and a path-free reason, never transcript contents or\n credentials.\n2. **`SessionRouter`** owns live attachment discovery and transport. It validates\n the exact indexed endpoint generation, keeps credentials and `SdkClient`\n instances private, replays from the attachment cursor, reconnects after\n rotation, and revokes stale capabilities. Provider-facing attachments expose\n only `sessionId`, `generation`, `isCurrent()`, and `send()`.\n\n### Dispatch-boundary observers (managed router path)\n\n`SessionRouter.request(sessionId, frame, expectedGeneration?, expectedAttachment?, options)`\naccepts two optional synchronous observers in `options` — the supported\ndispatch-boundary surface for transport-close-aware consumers (#4640):\n\n- **`beforeDispatch(context)`** — fires immediately before the wire write.\n Throwing (or any synchronous failure) aborts the dispatch with nothing on the\n wire, no sent record, and a retryable rejection carrying the caller's own\n error. Returning a thenable (e.g. an `async` function) is a contract\n violation: the dispatch aborts pre-send and the eventual rejection is sunk.\n- **`onDispatch(context)`** — fires synchronously immediately after the frame is\n handed to the socket, never before. `context.frame.id` is the exact correlated\n identity a response must carry; from this point a transport close before the\n response settles the request as `uncertain_after_send`. Observer throws and\n returned-thenable rejections are sunk; they can neither displace settlement\n nor reach the process unhandled-rejection channel.\n\nThe observer `context.frame` is a **deep-frozen, credential-redacted copy**: the\ninjected session endpoint `token` (and any other credential field) exists only\non the internal wire frame and is never handed to observer code, and mutation\nattempts throw in strict mode. The raw credential-bearing `SdkClient` remains\nunexported (`./sdk/client` is blocked in the package export map); the router is\nthe only supported path to this boundary.\n\nThere is no daemon-owned lifecycle control endpoint, provider lifecycle ledger,\nnotification-root scanner, or provider-created SessionId. Telegram `/session_*`\ncommands call the SDK lifecycle service directly. A Telegram update or topic\nreservation supplies the stable provider request identity; the Broker allocates\nthe SessionId, and Telegram CAS-binds the returned opaque ID to its presentation\nmapping.\n\n### Lifecycle trust and recovery\n\n- paired provider identity and operation capability are checked before the\n Broker call;\n- retries reuse the same provider request key; replay and at-most-once lifecycle\n effects apply while the Broker ledger retains that identity. Eligible settled\n identities may be evicted oldest-first under capacity pressure;\n- `terminal_uncertain` remains uncertain and is reconciled from Broker ledger,\n effect marker, process incarnation, endpoint/index, readiness, and exact\n cleanup evidence only;\n- provider transport restart reloads cursor and presentation state, while the\n Router reconstructs attachments from Broker state;\n- stale endpoint generations and attachments fail closed;\n- provider topic/thread/message cleanup cannot rewrite a confirmed lifecycle\n outcome.\n\n### Phone test guide (create / close / resume from Telegram)\n\nEnd-to-end manual check once `gjc notify setup` has paired your private chat:\n\n1. Run `gjc notify setup` and start or reload the Telegram provider supervisor.\n The supervisor owns only the Telegram poller and presentation state.\n2. Send `/session_create path <repo-dir>`, `/session_create worktree <repo>\n <branch>`, or `/session_create dir <newdir>`. The SDK lifecycle service submits\n one canonical Broker create request; the bot reports the credential-free\n outcome.\n3. `/session_recent` lists verified recent managed sessions, marks whether each\n session is connected or only saved, and includes a resume command for saved\n sessions.\n4. `/session_close <sessionId>` asks Broker lifecycle to close the exact managed\n session and preserves history. Inside that session's Telegram topic, omit the\n ID and use `/session_close`.\n5. `/session_resume <sessionId|prefix>` resolves verified managed history,\n reattaches a live session or performs canonical Broker resume, and refuses\n ambiguous prefixes. Inside an existing session topic, including an inactive\n topic left by a stopped session, omit the ID and use `/session_resume`.\n\nCommands are accepted only from the paired chat. Duplicate Telegram updates and\nreplayed topic reservations reuse their original request identity; they never\nallocate or spawn a second session.\n\n## Master mode surfaces\n\n`gjc --master [--scope repo|pwd|global]` launches an interactive-TUI-only\nmaster session (default scope `repo`). The master receives one bundled\noperating-guidance block appended after custom system-prompt transformation and\nexactly one scoped, no-probe broker peer snapshot immediately before its first\naccepted provider request; idle masters collect nothing.\n\nMaster orchestration is broker-routed and best effort: scoped discovery via\n`gjc sdk search` (result-level scope envelopes, exact worktree/cwd identity,\nno scope fallback) and task-seeded children via the local-only `gjc sdk spawn`\n(see docs/sdk-session-cli.md). The broker is the sole authority for\nmaster-created lifecycle: an opaque idempotency identity serializes each spawn\nbehind a durable create-or-join claim, seed delivery is Q26-correlated (never\nblindly re-sent), close mutates only an exactly re-proven substrate, and\norphaned children converge through ordinary close after\n`sdk.masterOrphanGraceMs`. Task text and master capability are transient\ndispatch inputs and never persist in any durable store, log, or output.\n",
106
106
  "secrets.md": "# Secret Obfuscation\n\nPrevents sensitive values (API keys, tokens, passwords) from being sent to LLM providers. When enabled, secrets are replaced with authenticated placeholders before leaving the process, and restored in tool call arguments returned by the model.\n\n## Enabling\n\nDisabled by default. Toggle via `/settings` UI or directly in `config.yml`:\n\n```yaml\nsecrets:\n enabled: true\n```\n\n## How it works\n\n1. On session startup, secrets are collected from two sources:\n - **Environment variables** whose names match common secret patterns (`KEY`, `SECRET`, `TOKEN`, `PASSWORD`, `PASS`, `AUTH`, `CREDENTIAL`, `PRIVATE`, `OAUTH`) with values >= 8 characters\n - **`secrets.yml` files** (see below)\n\n2. Outbound text messages to the LLM have secret values replaced with authenticated, versioned placeholders like `#GJC1_…#`.\n\n3. Session context/tool arguments returned from the model are deep-walked and obfuscation placeholders are restored to original values before display or execution.\n\nTwo modes control what happens to each secret:\n\n| Mode | Behavior | Reversible |\n| --------------------- | ----------------------------------------------- | ----------------------------------------------- |\n| `obfuscate` (default) | Replaced with authenticated `#GJC1_…#` token | Yes (deobfuscated in tool args/session context) |\n| `replace` | Replaced with deterministic same-length string | No (one-way) |\n\nAuthenticated placeholders use a process-local key. Plain-secret tokens remain stable across sessions, reloads, and forks within the running process; after a process restart, earlier tokens intentionally remain opaque.\n\nRegex-discovered tokens are reversible only by the originating obfuscator instance. A fresh obfuscator in the same process or after restart keeps them opaque because regex matches are not reconstructed from persisted placeholders.\n\n## secrets.yml\n\nDefine custom secret entries in YAML. Two locations are checked:\n\n| Level | Path | Purpose |\n| ------- | -------------------------- | --------------------------- |\n| Global | `~/.gjc/agent/secrets.yml` | Plain and regex secrets across all projects |\n| Project | `<cwd>/.gjc/secrets.yml` | Project-specific plain secrets |\n\nProject plain entries override global plain entries with matching `content`; a global regex with the same `content` remains active. Project-scope regex entries are ignored because workspace-contained files are not trusted to supply executable regex patterns. This project scope includes `<cwd>/.gjc/secrets.yml` and any caller-supplied agent directory whose lexical or canonical path is contained within the workspace.\n\n### Schema\n\nEach entry in the array has these fields:\n\n| Field | Type | Required | Description |\n| ------------- | ---------------------------- | -------- | ------------------------------------------------- |\n| `type` | `\"plain\"` or `\"regex\"` | Yes | Match strategy |\n| `content` | string | Yes | The secret value (plain) or regex pattern (regex) |\n| `mode` | `\"obfuscate\"` or `\"replace\"` | No | Default: `\"obfuscate\"` |\n| `replacement` | string | No | Custom replacement (replace mode only) |\n| `flags` | string | No | Regex flags (regex type only) |\n\n### Examples\n\n#### Plain secrets\n\n```yaml\n# Obfuscate a specific API key (default mode)\n- type: plain\n content: sk-proj-abc123def456\n\n# Replace a database password with a fixed string\n- type: plain\n content: hunter2\n mode: replace\n replacement: \"********\"\n```\n\n#### Regex secrets\n\nRegex entries are supported only by agent configuration outside the current workspace (normally `~/.gjc/agent/secrets.yml`). Use `type: plain` for workspace-contained configuration.\n\n```yaml\n# Obfuscate any AWS-style key\n- type: regex\n content: \"AKIA[0-9A-Z]{16}\"\n\n# Case-insensitive match with explicit flags\n- type: regex\n content: \"api[_-]?key\\\\s*=\\\\s*\\\\w+\"\n flags: \"i\"\n\n# Regex literal syntax (pattern and flags in one string)\n- type: regex\n content: \"/bearer\\\\s+[a-zA-Z0-9._~+\\\\/=-]+/i\"\n```\n\nRegex entries always scan globally (the `g` flag is enforced automatically). The regex literal syntax `/pattern/flags` is supported as an alternative to separate `content` + `flags` fields. Escaped slashes within the pattern (`\\\\/`) are handled correctly. The sticky `y` flag is rejected because it would prevent global scanning.\n\n#### Replace mode with regex\n\n```yaml\n# One-way replace connection strings (not reversible)\n- type: regex\n content: \"postgres://[^\\\\s]+\"\n mode: replace\n replacement: \"postgres://***\"\n```\n\n## Interaction with env var detection\n\nEnvironment variables are collected first, then file-defined entries are appended. File entries can cover secrets that don't live in env vars (config files, hardcoded values, etc.). If the same plain value appears in both env and file entries, the env entry's obfuscate-mode mapping is used first.\n\n## Key files\n\n- `packages/coding-agent/src/secrets/index.ts` -- loading, merging, env var collection\n- `packages/coding-agent/src/secrets/obfuscator.ts` -- `SecretObfuscator` class, placeholder generation, message obfuscation\n- `packages/coding-agent/src/secrets/regex.ts` -- regex literal parsing and compilation\n- `packages/coding-agent/src/config/settings-schema.ts` -- `secrets.enabled` setting definition\n\n## See also\n\n- [`auth-broker-gateway.md`](./auth-broker-gateway.md) -- remote credential vault and forward-proxy that keep provider OAuth refresh tokens and access tokens off developer hosts entirely (complementary to in-process obfuscation).\n",
107
107
  "session-import.md": "# Session Import (Codex and Claude)\n\nGJC can import an external **Codex** or **Claude** session transcript into a new\nGJC session so you can continue the conversation with reconstructed context\ninstead of copying a long session by hand.\n\n## CLI surface\n\n```\n/import-session <transcript-file> [--provider codex|claude]\n```\n\n- Available on Linux in the interactive TUI and trusted local startup command path. It is\n deliberately excluded from ACP and remote-control transports because it reads\n an operator-selected local file.\n- `<transcript-file>` is an explicit, user-selected export/transcript file\n (absolute or cwd-relative; quote paths containing spaces).\n- `--provider` narrows detection to one provider and fails closed on a\n mismatch. Without it, the format is detected deterministically from the file\n content.\n- On success, GJC creates a **new** resumable session containing the reconstructed\n context and reports its id. The active session is not switched automatically;\n use `/resume` to select the import.\n\nThere is intentionally no flag-less provider-directory mode: import never\nenumerates `~/.codex` / `~/.claude` or reads live provider process state. You\nalways name the exact file to import.\n\n## Supported source formats\n\nDetection is content-based and provider-neutral; unknown shapes fail with an\nactionable diagnostic instead of a speculative parse.\n\n| Format | Provider | File shape |\n| --- | --- | --- |\n| `codex-rollout-jsonl` | Codex | Codex CLI rollout transcript (`rollout-*.jsonl`): `session_meta`, `response_item` (`message`, `function_call`, `function_call_output`, `custom_tool_call*`, `local_shell_call`, `web_search_call`, `reasoning`), `event_msg`, `turn_context` records, one JSON object per line. |\n| `claude-code-jsonl` | Claude | Claude Code session transcript (`~/.claude/projects/<slug>/<uuid>.jsonl` copied out explicitly): `user` / `assistant` / `summary` / `system` records with `uuid`/`sessionId` envelopes. |\n| `claude-export-json` | Claude | claude.ai data export: one conversation object (or an array of conversations) with `uuid`, `name`, and `chat_messages[]` (`sender: \"human\" \\| \"assistant\"`, `content[]` text blocks). |\n\nNormalization rules:\n\n- User/assistant text is preserved; consecutive assistant text is merged.\n- Tool calls and results become bounded, single-line **tool/file evidence**\n entries (`$ <tool> <summary>` / `→ <output>`) attached to the preceding\n assistant message. Tool payloads are never executable in the new session.\n- Model-internal content (Codex `reasoning`, Claude `thinking`) and provider\n bookkeeping (`turn_context`, `system`, `isMeta` rows, file-history snapshots)\n are recognized and skipped by design.\n- Records that fail to parse, lack required fields, or use unknown record types\n are **quarantined** — counted and digested (record number, byte length,\n SHA-256) in provenance and the import summary, never silently dropped.\n- A native GJC session file is rejected with `unsupported_format` (use\n resume/fork for those).\n\n## Provenance and identity\n\nEvery imported session persists a `custom` entry with\n`customType: \"session-import\"` recording: provider, format, source file\nbasename, source session id and title (when the format carries them), SHA-256\nand byte size of the exact imported source bytes, converter/sanitizer versions,\nmapped/quarantined/redacted/omitted counts, truncation flag, target session id,\nand import timestamp. The reconstructed context lives in a separate\n`custom_message` entry (same `customType`, `display: true`) so imported context\nis visually and structurally distinct from native GJC history, while still\nparticipating in LLM context like a handoff document.\n\nRe-importing the same file is allowed and creates another distinct session;\nthe provenance record (source hash + import timestamp) is what distinguishes\nthem. The source file is only ever read — verify with the recorded digest.\n\n## Redaction\n\nBefore any imported text enters the new session it passes a fail-closed\nsanitizer (`sanitizerVersion` in provenance): Anthropic/OpenAI/GitHub/Slack/\nGoogle/AWS keys, JWTs, bearer tokens, PEM private keys, long hex secrets,\nURL-embedded credentials, and `NAME=value` / `key: value` assignments whose\nname contains a sensitive word (key/secret/token/password/credential/…).\nMatches are replaced with `[REDACTED]`; the redaction count and rule kinds are\nreported in the summary and provenance. False positives are preferred over\nfalse negatives: a benign look-alike loses one span, a missed credential would\nleak into a durable transcript.\n\n## Bounds and diagnostics\n\n| Bound | Value | Over-limit behavior |\n| --- | --- | --- |\n| Source file size | 64 MiB | `content_too_large` with limit/observed bytes |\n| Normalized messages | 5,000 | `content_too_large` with observed count |\n| Rendered continuation context | 120,000 chars | deterministic head+tail bounding with an explicit elision marker (`omitted` count in provenance) |\n| Single message text | 16,000 chars | truncated with an ellipsis marker |\n| Tool evidence per message | 100 lines / 8 KiB | marked truncated |\n| Quarantine records | 512 retained | count kept, `quarantine.truncated` flag set |\n\nError codes are deterministic and actionable: `invalid_request`,\n`source_not_found`, `source_unreadable`, `source_changed` (retryable),\n`unsupported_format`, `format_mismatch`, `malformed_input`, `content_too_large`,\n`destination_conflict`, `io_failed`. If persistence or post-write verification\nfails, the partially created session file is removed best-effort and the\ncurrent session is never modified.\n\n## Non-goals\n\n- **No model-internal state cloning.** Reasoning traces, token telemetry,\n provider session caches, and tool-execution state are not imported; this is\n context reconstruction, not process migration.\n- **No live provider scraping.** Import reads only the file you name.\n- **No silent drops.** Every source record is mapped, recognized-and-skipped,\n or quarantined with a digest; totals are reported.\n- **No mid-stream import.** `/import-session` refuses to run while a response\n is streaming.\n",
@@ -1,5 +1,3 @@
1
- import { dlopen, FFIType } from "bun:ffi";
2
- import * as fs from "node:fs";
3
1
  import * as nodeWorkerThreads from "node:worker_threads";
4
2
  import {
5
3
  PUBLICATION_LIVENESS_INDEX_ACTIVE,
@@ -23,65 +21,6 @@ if (!(buffer instanceof SharedArrayBuffer)) {
23
21
 
24
22
  const view = new BigInt64Array(buffer);
25
23
  const port = parentPort;
26
- const DIAGNOSTIC = "sdk broker: publication-liveness-timeout\n";
27
- const F_GETFL = 3;
28
- const F_SETFL = 4;
29
-
30
- interface FcntlBinding {
31
- fcntl(fd: number, command: number, flags: number): number;
32
- }
33
-
34
- let fcntlBinding: FcntlBinding | undefined;
35
-
36
- function libcAndNonblockFlag(): { library: string; nonblock: number } | undefined {
37
- if (process.platform === "linux") return { library: "libc.so.6", nonblock: 0o4000 };
38
- if (process.platform === "darwin") return { library: "libSystem.B.dylib", nonblock: 0x4 };
39
- return undefined;
40
- }
41
-
42
- function fcntl(): FcntlBinding | undefined {
43
- if (fcntlBinding) return fcntlBinding;
44
- const target = libcAndNonblockFlag();
45
- if (!target) return undefined;
46
- const opened = dlopen(target.library, {
47
- fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
48
- });
49
- fcntlBinding = opened.symbols;
50
- return fcntlBinding;
51
- }
52
-
53
- /**
54
- * Best-effort stderr line. A full pipe must not block this worker: the SIGKILL
55
- * below is the liveness contract, and a blocking `writeSync` on fd 2 would
56
- * postpone it indefinitely. Platforms without `O_NONBLOCK` skip the line.
57
- */
58
- function writeDiagnosticWithoutBlocking(): void {
59
- const target = libcAndNonblockFlag();
60
- if (!target) return;
61
- let binding: FcntlBinding | undefined;
62
- let previous: number | undefined;
63
- try {
64
- binding = fcntl();
65
- if (!binding) return;
66
- const current = binding.fcntl(2, F_GETFL, 0);
67
- if (current < 0) return;
68
- if ((current & target.nonblock) === 0) {
69
- if (binding.fcntl(2, F_SETFL, current | target.nonblock) < 0) return;
70
- previous = current;
71
- }
72
- fs.writeSync(2, DIAGNOSTIC);
73
- } catch {
74
- // EAGAIN, a missing libc, or a short write. Drop the line.
75
- } finally {
76
- if (binding && previous !== undefined) {
77
- try {
78
- binding.fcntl(2, F_SETFL, previous);
79
- } catch {
80
- // The process is about to be killed.
81
- }
82
- }
83
- }
84
- }
85
24
 
86
25
  function readSlot(slot: number, nowMs: bigint): PublicationLivenessSample {
87
26
  const base = slot * PUBLICATION_LIVENESS_SLOT_STRIDE;
@@ -103,11 +42,8 @@ setInterval(() => {
103
42
  if (!publicationLivenessMustExit(readSlot(slot, nowMs))) continue;
104
43
  const confirmed = readSlot(slot, BigInt(Math.floor(performance.now())));
105
44
  if (!publicationLivenessMustExit(confirmed)) continue;
106
- writeDiagnosticWithoutBlocking();
107
- // Disarm is synchronous at the start of shutdown. Re-check after the
108
- // diagnostic write so that teardown wins over a kill already in progress.
109
- const stillDue = readSlot(slot, BigInt(Math.floor(performance.now())));
110
- if (!publicationLivenessMustExit(stillDue)) continue;
45
+ // Do not write diagnostics here: even a nonblocking descriptor can stall
46
+ // on regular files or devices, preventing the liveness kill.
111
47
  process.kill(process.pid, "SIGKILL");
112
48
  return;
113
49
  }
@@ -40,8 +40,8 @@ function watchdogUnavailable(error: unknown): Error {
40
40
  });
41
41
  }
42
42
 
43
- function noteLiveWatchdogLost(): void {
44
- if (!workerIsLive) return;
43
+ function noteLiveWatchdogLost(worker: Worker): void {
44
+ if (!workerIsLive || liveWorker !== worker) return;
45
45
  workerIsLive = false;
46
46
  liveWorker = undefined;
47
47
  workerReady = undefined;
@@ -151,14 +151,14 @@ function ensureWorker(): Promise<void> {
151
151
  failBeforeReady(`publication liveness watchdog failed: ${message}`);
152
152
  return;
153
153
  }
154
- noteLiveWatchdogLost();
154
+ noteLiveWatchdogLost(worker);
155
155
  });
156
156
  const onGone: EventListener = () => {
157
157
  if (!settled) {
158
158
  failBeforeReady("publication liveness watchdog closed before ready");
159
159
  return;
160
160
  }
161
- noteLiveWatchdogLost();
161
+ noteLiveWatchdogLost(worker);
162
162
  };
163
163
  worker.addEventListener("close", onGone);
164
164
  worker.addEventListener("exit", onGone);
@@ -330,8 +330,8 @@ export class SessionSdkHost {
330
330
  }
331
331
 
332
332
  async #startHost(): Promise<"started"> {
333
- // Allocated before any host state changes, so nothing is emitted under a
334
- // generation the authority has not issued.
333
+ // Allocate before emitting; an unavailable authority leaves the local
334
+ // generation unreserved until registration can reserve it exactly.
335
335
  const floor = await this.#allocateGeneration();
336
336
  this.#reportedActivityState = undefined;
337
337
  this.events.restart(floor);
@@ -373,15 +373,19 @@ export class SessionSdkHost {
373
373
  const allocate = this.#options.allocateEventGeneration;
374
374
  this.#generationReserved = allocate === undefined;
375
375
  if (!allocate) return 0;
376
+ const floor = Math.max(Date.now(), this.events.generation + 1);
377
+ if (!Number.isSafeInteger(floor)) throw new Error("SDK event generation is exhausted.");
376
378
  try {
377
- const generation = await allocate(this.#options.sessionId, Date.now());
379
+ const generation = await allocate(this.#options.sessionId, floor);
380
+ if (!Number.isSafeInteger(generation) || generation < floor)
381
+ throw new Error("SDK event generation authority returned an invalid generation.");
378
382
  this.#generationReserved = true;
379
383
  return generation;
380
384
  } catch (error) {
381
385
  logger.warn("sdk: event generation authority unavailable; host stays unpublished until it is reserved", {
382
386
  error: String(error),
383
387
  });
384
- return Date.now();
388
+ return floor;
385
389
  }
386
390
  }
387
391
 
@@ -69,7 +69,7 @@ function convertSchema(schema: unknown): unknown {
69
69
 
70
70
  // Properties form: { properties: {...}, optionalProperties: {...} }
71
71
  if (isJTDProperties(schema)) {
72
- const properties: Record<string, unknown> = {};
72
+ const properties: Record<string, unknown> = Object.create(null);
73
73
  const required: string[] = [];
74
74
 
75
75
  // Required properties
@@ -107,7 +107,7 @@ function convertSchema(schema: unknown): unknown {
107
107
  for (const [tag, props] of Object.entries(schema.mapping)) {
108
108
  const converted = convertSchema(props) as Record<string, unknown>;
109
109
  // Add the discriminator property
110
- const properties = (converted.properties || {}) as Record<string, unknown>;
110
+ const properties = Object.assign(Object.create(null), converted.properties || {}) as Record<string, unknown>;
111
111
  properties[schema.discriminator] = { const: tag };
112
112
 
113
113
  const required = ((converted.required as string[]) || []).slice();
@@ -187,7 +187,7 @@ function normalizeMixedSchemaNode(schema: unknown): unknown {
187
187
  return convertSchema(schema);
188
188
  }
189
189
 
190
- const normalized: Record<string, unknown> = {};
190
+ const normalized: Record<string, unknown> = Object.create(null);
191
191
  for (const [key, value] of Object.entries(schema)) {
192
192
  normalized[key] = normalizeMixedSchemaNode(value);
193
193
  }
@@ -87,7 +87,7 @@ function frameYieldSchemaText(text: string, direction: "tail" | "head-tail"): st
87
87
  }
88
88
 
89
89
  function frameSchemaMap(map: Record<string, unknown>, seen: WeakSet<object>): Record<string, unknown> {
90
- const output: Record<string, unknown> = {};
90
+ const output: Record<string, unknown> = Object.create(null);
91
91
  for (const name of Object.keys(map)) output[name] = frameSchemaProse(map[name], seen);
92
92
  return output;
93
93
  }
@@ -97,7 +97,7 @@ function frameSchemaProse(value: unknown, seen: WeakSet<object>, keepRootDescrip
97
97
  if (seen.has(value)) return value;
98
98
  seen.add(value);
99
99
  if (Array.isArray(value)) return value.map(item => frameSchemaProse(item, seen));
100
- const output: Record<string, unknown> = {};
100
+ const output: Record<string, unknown> = Object.create(null);
101
101
  for (const key of Object.keys(value)) {
102
102
  const child = (value as Record<string, unknown>)[key];
103
103
  if (SCHEMA_MAP_KEYS.has(key) && child && typeof child === "object" && !Array.isArray(child)) {