@gajae-code/coding-agent 0.11.0 → 0.11.1
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 +9 -1
- package/dist/types/async/job-manager.d.ts +46 -7
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -2
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +1 -0
- package/dist/types/modes/types.d.ts +2 -2
- package/dist/types/sdk/broker/ensure.d.ts +9 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -0
- package/dist/types/tools/subagent-render.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +6 -1
- package/package.json +8 -8
- package/scripts/build-sdk-package-smoke.ts +5 -0
- package/src/async/job-manager.ts +406 -63
- package/src/gjc-runtime/ultragoal-guard.ts +55 -8
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +72 -8
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/modes/controllers/command-controller.ts +9 -15
- package/src/modes/controllers/extension-ui-controller.ts +48 -24
- package/src/modes/interactive-mode.ts +28 -16
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +7 -3
- package/src/modes/types.ts +2 -2
- package/src/sdk/broker/discovery.ts +20 -1
- package/src/sdk/broker/ensure.ts +82 -24
- package/src/sdk/bus/chat-effect-journal.ts +6 -4
- package/src/sdk/bus/discord-daemon.ts +25 -8
- package/src/sdk/bus/index.ts +57 -21
- package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +102 -10
- package/src/session/agent-session.ts +176 -29
- package/src/task/index.ts +26 -16
- package/src/tools/subagent-render.ts +19 -8
- package/src/tools/subagent.ts +84 -32
|
@@ -12,7 +12,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
|
|
|
12
12
|
"auth-broker-gateway.md": "# Auth Broker and Auth Gateway\n\nThe auth broker and auth gateway are two cooperating HTTP services that move OAuth refresh tokens and provider access tokens off developer laptops and into a single broker host.\n\n- **`gjc auth-broker serve`** holds the canonical SQLite credential vault, performs OAuth refreshes, and exposes a small REST API (`/v1/snapshot`, `/v1/credential/:id/refresh`, `/v1/credential/:id/disable`, `/v1/credential`, `/v1/usage`, `/v1/healthz`).\n- **`gjc auth-gateway serve`** is a forward-proxy. It accepts OpenAI Chat Completions, Anthropic Messages, and OpenAI Responses requests, injects the broker-resolved access token, and forwards the bytes to the real provider. Clients (containerised gjc, llm-git, the macOS usage widget, …) never see the access token.\n\nTransport security between operator, broker, and gateway is delegated to the operator (Tailscale / Wireguard / reverse proxy + TLS). Every endpoint except `/v1/healthz` (broker) and `/healthz` (gateway) requires a bearer token.\n\nSource: `packages/ai/src/auth-broker/`, `packages/ai/src/auth-gateway/`, `packages/coding-agent/src/cli/auth-broker-cli.ts`, `packages/coding-agent/src/cli/auth-gateway-cli.ts`, `packages/coding-agent/src/session/auth-broker-config.ts`.\n\n## Data flow\n\n```\n ┌────────────────────────────────────────────────────────────┐\n │ broker host │\n │ │\n developer ──▶ │ ┌──────────────────────────┐ ┌────────────────────┐ │\n laptop / │ │ gjc auth-broker serve │◀──▶│ SQLite agent.db │ │\n CI │ │ - holds refresh tokens │ │ (canonical writer)│ │\n │ │ - background refresher │ └────────────────────┘ │\n │ │ /v1/{snapshot,refresh,…}│ │\n │ └─────────┬────────────────┘ │\n │ │ bearer ($CONFIG_DIR/auth-broker.token) │\n │ ▼ │\n │ ┌──────────────────────────┐ │\n │ │ gjc auth-gateway serve │ RemoteAuthCredentialStore │\n │ │ /v1/{chat,messages,…} │ pulls /v1/snapshot at boot, │\n │ │ /v1/usage, /v1/models │ refreshes credentials by id │\n │ └─────────┬────────────────┘ via the broker on expiry │\n └────────────┼───────────────────────────────────────────────┘\n │ bearer ($CONFIG_DIR/auth-gateway.token)\n ▼\n unauthenticated clients\n (llm-git, macOS widget, IDE plugins, …)\n │\n ▼ same path is forwarded with Authorization\n api.anthropic.com / api.openai.com / …\n```\n\nThe broker is the only writer of OAuth refresh tokens. Clients (including the gateway itself) load a redacted snapshot in which every `refresh` field has been replaced with `REMOTE_REFRESH_SENTINEL`; when an access token expires the client calls `POST /v1/credential/:id/refresh` and the broker performs the refresh server-side. `RemoteAuthCredentialStore` rejects any local code path that tries to write through it, with an error pointing at `gjc auth-broker login` / `gjc auth-broker logout`.\n\n## auth-broker\n\n### CLI\n\n```\ngjc auth-broker serve [--bind=host:port] # boot the broker\ngjc auth-broker token [--regenerate] [--json] # print or rotate the bearer token\ngjc auth-broker login <provider> [--via=user@host] [--dry-run]\ngjc auth-broker logout <provider>\ngjc auth-broker import <file|dir> [--provider=<id>] [--include-disabled] [--dry-run] [--json]\ngjc auth-broker migrate --from-local [--dry-run] [--json]\ngjc auth-broker status [--json]\n```\n\n- `serve` opens the local SQLite store at `getAgentDbPath()` and binds an HTTP listener (default `127.0.0.1:8765`). On startup a token is ensured at `<config-dir>/auth-broker.token` (mode `0600`, `0700` parent dir). The background refresher refreshes any OAuth credential whose `expires - Date.now() < refreshSkewMs` (default 5 min) every `refreshIntervalMs` (default 60 s).\n- `token` prints the cached bearer or generates a new one. `--regenerate` rotates it.\n- `login <provider>` runs the per-provider OAuth flow locally, or — with `--via=user@host` — `ssh -L <callback-port>:127.0.0.1:<callback-port> user@host gjc auth-broker login <provider>` so the OAuth callback hits the local browser but the credential is written on the broker host. Built-in callback ports: `anthropic:54545`, `openai-code:1455`, `google-gemini-cli:8085`, `google-antigravity:51121`, `gitlab-duo:8080`.\n- `logout <provider>` deletes every credential row for `<provider>`.\n- `import <file|dir>` imports CLIProxyAPI-style JSON credentials into the local SQLite store. Maps `type` field → gjc provider (`anthropic-model → anthropic`, `openai-code → openai-code`, `gemini → google-gemini-cli`, `antigravity → google-antigravity`, `gemini-cli → google-gemini-cli`).\n- `migrate --from-local` walks the local SQLite store + env-derived credentials and idempotently uploads them to the configured broker (`POST /v1/credential`).\n- `status` health-pings the configured remote broker.\n\n### Endpoints\n\n| Method | Path | Auth | Purpose |\n| ------ | ---- | ---- | ------- |\n| `GET` | `/v1/healthz` | none | Liveness + version |\n| `GET` | `/v1/snapshot` | bearer | Redacted snapshot (refresh tokens replaced by sentinel) |\n| `POST` | `/v1/credential` | bearer | Upsert one OAuth or API-key credential |\n| `POST` | `/v1/credential/:id/refresh` | bearer | Force-refresh one OAuth credential |\n| `POST` | `/v1/credential/:id/disable` | bearer | Disable one credential with a recorded cause |\n| `GET` | `/v1/usage` | bearer | Aggregate `UsageReport[]` across credentials |\n\nRequests use `Authorization: Bearer <token>`. The server compares against an in-memory token allow-list; the gateway’s implementation uses a timing-safe comparison.\n\n### Background refresher\n\n`AuthBrokerRefresher` iterates active OAuth credentials at `refreshIntervalMs` cadence and refreshes any within `refreshSkewMs` of expiry. Refreshes are single-flighted per credential id so a slow refresh cannot be retriggered. The refresher distinguishes:\n\n- **definitive failures** (`invalid_grant`, `invalid_token`, `revoked`, unauthorized refresh-token, 401/403 not from a network blip) — credentials are passed to `AuthStorage.disableCredentialById(id, cause)` so the next snapshot pull surfaces a clean delete on the client;\n- **transient failures** (timeout / ECONNREFUSED / fetch failed) — left in place for the next sweep.\n\n## auth-gateway\n\n### CLI\n\n```\ngjc auth-gateway serve [--bind=host:port] [--no-auth]\ngjc auth-gateway token [--regenerate] [--json]\ngjc auth-gateway status [--json]\n```\n\n- `serve` requires `GJC_AUTH_BROKER_URL` (or `auth.broker.url` in `config.yml`) — the gateway is itself a broker client. It calls `AuthBrokerClient.fetchSnapshot()`, wraps it in `RemoteAuthCredentialStore`, and constructs an `AuthStorage` that resolves access tokens through the broker. Default bind is `127.0.0.1:4000`. The gateway token is stored at `<config-dir>/auth-gateway.token` (`0600`); `--no-auth` disables the bearer check entirely (loopback-only use).\n- `token` / `status` mirror the broker’s equivalents.\n\n### Endpoints\n\n| Method | Path | Auth | Purpose |\n| ------ | ---- | ---- | ------- |\n| `GET` | `/healthz` | none | Liveness + version |\n| `GET` | `/v1/usage` | bearer | Aggregate `UsageReport[]` (proxied through `AuthStorage`) |\n| `GET` | `/v1/models` | bearer | Bundled-model catalog filtered to providers with credentials |\n| `POST` | `/v1/chat/completions` | bearer | OpenAI Chat Completions wire format |\n| `POST` | `/v1/messages` | bearer | Anthropic Messages wire format |\n| `POST` | `/v1/responses` | bearer | OpenAI Responses wire format |\n\nThe model id is read from the top-level `model` field. The gateway picks the first bundled `Model<Api>` matching that id and:\n\n- **Passthrough fast-path** — when the inbound wire format matches the model’s native API (`openai-chat → openai-completions`, `anthropic-messages → anthropic-messages`, `openai-responses → openai-responses`), the request body is forwarded byte-for-byte with the client `Authorization`/`x-api-key` stripped and replaced by `Authorization: Bearer <resolved-access-token>`. Provider-specific fields (`cache_control`, `service_tier`, tool-choice extensions, …) flow through unmodified. Hop-by-hop headers (RFC 7230) plus `Content-Encoding`/`Content-Length` are stripped from the upstream response.\n- **Translate path** — when the inbound format and the resolved model’s API differ (e.g. `/v1/chat/completions` targeting an Anthropic model, or `/v1/responses` targeting `openai-code-responses` which runs over a websocket transport), the request is parsed against the wire schema, rebuilt into an gjc `Context`, dispatched through `streamSimple()`, and re-encoded back to the inbound format (SSE for streamed responses).\n\n`idleTimeout` on the underlying `Bun.serve` is set to `255 s` so long thinking-budget calls do not get killed by Bun’s default idle timeout.\n\n## Usage cache: server-side 5-min jitter + client-side 15 s single-flight\n\nTwo layers cache the aggregate provider-usage report. Both are intentional and stacked.\n\n### Server-side cache (broker `AuthStorage`)\n\n`AuthStorage` caches each credential’s `UsageReport` in the broker’s SQLite store at a **5-minute per-credential TTL with ±25 % jitter**. Anthropic and OpenAI rate-limit `/usage` aggressively per source IP, and a synchronized 5-credential fan-out trips 429s every cycle; the jitter decorrelates refresh times within a few cycles. On fetch failure the store keeps the **last-good** report for up to 24 h with a short jittered re-poll window — so a transient upstream blip never blanks out the widget.\n\nConstants: `USAGE_REPORT_TTL_MS = 5 * 60_000`, `USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000` (`packages/ai/src/auth-storage.ts`).\n\n### Client-side single-flight (`RemoteAuthCredentialStore`)\n\nWhen the gateway (or any other broker client) calls `fetchUsageReports()` / `getUsageReport(provider, credential)`, `RemoteAuthCredentialStore` coalesces concurrent calls into a single `GET /v1/usage` round-trip and caches the result for **15 s** in memory.\n\n- `USAGE_CACHE_TTL_MS = 15_000` (`packages/ai/src/auth-broker/remote-store.ts`).\n- A single `#usageInflight` promise is shared across all callers; a per-caller `AbortSignal` is **raced** against the shared promise, not threaded into it, so one caller’s abort never cascades into a peer’s in-flight request.\n- On fetch failure the rejected promise is logged and the awaited value is `null` — callers (`AuthStorage.fetchUsageReports`, `#getUsageReport`) treat a `null` report as \"no usage signal for this cycle\" and proceed without it. **This is the 15 s TTL fallback**: the client absorbs transient broker outages by suppressing the error, returning `null` to ranking, and re-attempting after the 15 s window.\n\nThe 15 s client window deliberately sits below the broker’s 5 min server cache, so almost every client poll is served from the broker’s already-cached value; the client cache exists to absorb the parallel fan-out generated by `AuthStorage.#rankOAuthSelections` into a single broker round-trip.\n\n## Operator opt-in\n\nThe broker is **off** unless `GJC_AUTH_BROKER_URL` (or `auth.broker.url` in `config.yml`) is set. When set, `discoverAuthStorage` in `packages/coding-agent/src/sdk/session.ts` swaps the local SQLite credential store for `RemoteAuthCredentialStore` and every API call resolves credentials through the broker.\n\n### Environment variables\n\n| Variable | Purpose | Required when |\n| -------- | ------- | ------------- |\n| `GJC_AUTH_BROKER_URL` | Base URL of the remote auth-broker (e.g. `https://broker.tailnet:8765`). Selecting this puts the client in broker mode — local SQLite is bypassed. | Any time the gjc client should resolve credentials through a broker (and required by `gjc auth-gateway serve`). |\n| `GJC_AUTH_BROKER_TOKEN` | Bearer token used for every broker endpoint except `/v1/healthz`. | When `GJC_AUTH_BROKER_URL` is set and no token is available from `auth.broker.token` or `<config-dir>/auth-broker.token`. |\n\nResolution order in `resolveAuthBrokerConfig()`:\n\n1. `GJC_AUTH_BROKER_URL` env (else `auth.broker.url` from `config.yml`, with `$ENV_NAME` resolution);\n2. `GJC_AUTH_BROKER_TOKEN` env (else `auth.broker.token` from `config.yml`, else `<config-dir>/auth-broker.token`);\n3. URL set but no token resolvable → hard error pointing at the token file path.\n\nThe gateway has no dedicated env vars — it inherits `GJC_AUTH_BROKER_*` because it is itself a broker client.\n\n### `config.yml` keys\n\n| Key | Default | Purpose |\n| --- | ------- | ------- |\n| `auth.broker.url` | unset | Same as `GJC_AUTH_BROKER_URL`; env wins. Hidden from the settings UI. |\n| `auth.broker.token` | unset | Same as `GJC_AUTH_BROKER_TOKEN`; env wins. Values may be the literal token or `$ENV_NAME` to indirect through env. |\n\n### Token files\n\n| Path | Owner | Mode |\n| ---- | ----- | ---- |\n| `<config-dir>/auth-broker.token` | `gjc auth-broker serve` (created at first start) | `0600` in a `0700` parent dir |\n| `<config-dir>/auth-gateway.token` | `gjc auth-gateway serve` (skipped under `--no-auth`) | `0600` in a `0700` parent dir |\n\n`<config-dir>` resolves to `~/.gjc/` (respecting `GJC_CONFIG_DIR`).\n\n## Interaction with the local API-key resolution order\n\nThe broker only owns OAuth credentials and provider-API-key credentials that were uploaded to it. The standard credential ladder in `models.md` (`Auth and API key resolution order`) is preserved, with one addition committed alongside the gateway:\n\n- `AuthStorage.setConfigApiKey / removeConfigApiKey / clearConfigApiKeys` let a `models.yml` `apiKey` beat a stored OAuth token **without** overriding an explicit `--api-key`. This is what allows a broker-resolved OAuth credential to be reliably shadowed by a per-environment `models.yml` config key when both are present.\n\n## See also\n\n- [`secrets.md`](./secrets.md) — secret obfuscation around tokens that *do* leak through (e.g. `GJC_AUTH_BROKER_TOKEN` in shell output).\n- [`models.md`](./models.md) — provider auth resolution order; the broker plugs in at layers 2–3 (stored credentials).\n- [`environment-variables.md`](./environment-variables.md) — full env reference including `GJC_AUTH_BROKER_URL` / `GJC_AUTH_BROKER_TOKEN`.\n",
|
|
13
13
|
"bash-tool-runtime.md": "# Bash tool runtime\n\nThis document describes the **`bash` tool** runtime path used by agent tool calls, from command normalization to execution, truncation/artifacts, and rendering.\n\nIt also calls out where behavior diverges in interactive TUI, print mode, ACP, and user-initiated bang (`!`) shell execution.\n\n## Scope and runtime surfaces\n\nThere are two different bash execution surfaces in coding-agent:\n\n1. **Tool-call surface** (`toolName: \"bash\"`): used when the model calls the bash tool.\n - Entry point: `BashTool.execute()`.\n - Parameters include `command`, optional `env`, `timeout`, `cwd`, `head`, `tail`, `pty`, and, when `async.enabled` is true, `async`.\n2. **User bang-command surface** (`!cmd` from interactive input): session-level helper path.\n - Entry point: `AgentSession.executeBash()`.\n\nBoth eventually use `executeBash()` in `src/exec/bash-executor.ts` for non-PTY execution, but only the tool-call path runs normalization/interception, optional managed background-job handling, and tool renderer logic.\n\n## End-to-end tool-call pipeline\n\n## 1) Input handling and parameter merge\n\n`BashTool.execute()` currently handles input before execution as follows:\n\n- validates optional `env` names against shell-variable syntax,\n- extracts a leading `cd <path> && ...` into `cwd` when `cwd` was not supplied,\n- rejects `async: true` when `async.enabled` is false,\n- uses only explicit `head`/`tail` tool args for post-run filtering.\n\n`normalizeBashCommand()` still exists in `src/tools/bash-normalize.ts`, but `BashTool.execute()` does not call it in the current source. Trailing shell pipes such as `| head -n 50` remain part of the shell command unless the caller uses the structured `head`/`tail` args.\n\n## 2) Optional interception (blocked-command path)\n\nIf `bashInterceptor.enabled` is true, `BashTool` loads rules from settings and runs `checkBashInterception()` against the normalized command.\n\nInterception behavior:\n\n- command is blocked **only** when:\n - regex rule matches, and\n - the suggested tool is present in `ctx.toolNames`.\n- invalid regex rules are silently skipped.\n- on block, `BashTool` throws `ToolError` with message:\n - `Blocked: ...`\n - original command included.\n\nDefault rule patterns (defined in code) target common misuses:\n\n- file readers (`cat`, `head`, `tail`, ...)\n- search tools (`grep`, `rg`, ...)\n- file finders (`find`, `fd`, ...)\n- in-place editors (`sed -i`, `perl -i`, `awk -i inplace`)\n- shell redirection writes (`echo ... > file`, heredoc redirection)\n\n### Caveat\n\n`InterceptionResult` includes `suggestedTool`, but `BashTool` currently surfaces only the message text (no structured suggested-tool field in `details`).\n\n## 3) CWD validation and timeout clamping\n\n`cwd` is resolved relative to session cwd (`resolveToCwd`), then validated via `stat`:\n\n- missing path -> `ToolError(\"Working directory does not exist: ...\")`\n- non-directory -> `ToolError(\"Working directory is not a directory: ...\")`\n\nTimeout is clamped to `[1, 3600]` seconds and converted to milliseconds.\n\n## 4) Artifact allocation\n\nBefore execution, the tool allocates an artifact path/id (best-effort) for truncated output storage.\n\n- artifact allocation failure is non-fatal (execution continues without artifact spill file),\n- artifact id/path are passed into execution path for full-output persistence on truncation.\n\n## 5) PTY vs non-PTY execution selection\n\n`BashTool` chooses PTY execution only when all are true:\n\n- tool input `pty === true`\n- `GJC_NO_PTY !== \"1\"`\n- tool context has UI (`ctx.hasUI === true` and `ctx.ui` set)\n\nOtherwise it uses non-interactive `executeBash()`.\n\nThat means print mode and non-UI tool contexts always use non-PTY.\n\n## Non-interactive execution engine (`executeBash`)\n\n## Shell session reuse model\n\n`executeBash()` caches native `Shell` instances in a process-global map keyed by:\n\n- shell path,\n- configured command prefix,\n- snapshot path,\n- serialized shell env,\n- optional agent session key.\n\nSession-level bang-command executions pass `sessionKey: this.sessionId`.\n\nTool-call executions pass `sessionKey: this.session.getSessionId?.()`, when available. In both surfaces, a session key isolates shell reuse per session; without one, reuse falls back to shell config/snapshot/env.\n\n## Shell config and snapshot behavior\n\nAt each call, executor loads settings shell config (`shell`, `env`, optional `prefix`).\n\nIf selected shell includes `bash`, it attempts `getOrCreateSnapshot()`:\n\n- snapshot captures aliases/functions/options from user rc,\n- snapshot creation is best-effort,\n- failure falls back to no snapshot.\n\nIf `prefix` is configured, command becomes:\n\n```text\n<prefix> <command>\n```\n\n## Streaming and cancellation\n\n`Shell.run()` streams chunks to `OutputSink` and optional `onChunk` callback.\n\nCancellation:\n\n- aborted signal triggers `shellSession.abort(...)`,\n- timeout from native result is mapped to `cancelled: true` + annotation text,\n- explicit cancellation similarly returns `cancelled: true` + annotation.\n\nNo exception is thrown inside executor for timeout/cancel; it returns structured `BashResult` and lets caller map error semantics.\n\n## Interactive PTY path (`runInteractiveBashPty`)\n\nWhen PTY is enabled, tool runs `runInteractiveBashPty()` which opens an overlay console component and drives a native `PtySession`.\n\nBehavior highlights:\n\n- xterm-headless virtual terminal renders viewport in overlay,\n- keyboard input is normalized (including Kitty sequences and application cursor mode handling),\n- `esc` while running kills the PTY session,\n- terminal resize propagates to PTY (`session.resize(cols, rows)`).\n\nEnvironment hardening defaults are injected for unattended runs:\n\n- pagers disabled (`PAGER=cat`, `GIT_PAGER=cat`, etc.),\n- editor prompts disabled (`GIT_EDITOR=true`, `EDITOR=true`, ...),\n- terminal/auth prompts reduced (`GIT_TERMINAL_PROMPT=0`, `SSH_ASKPASS=/usr/bin/false`, `CI=1`),\n- package-manager/tool automation flags for non-interactive behavior.\n\nPTY output is normalized (`CRLF`/`CR` to `LF`, `sanitizeText`) and written into `OutputSink`, including artifact spill support.\n\nOn PTY startup/runtime error, sink receives `PTY error: ...` line and command finalizes with undefined exit code.\n\n## Output handling: streaming, truncation, artifact spill\n\nBoth PTY and non-PTY paths use `OutputSink`.\n\n## OutputSink semantics\n\n- keeps an in-memory UTF-8-safe tail buffer (`DEFAULT_MAX_BYTES`, currently 50KB),\n- tracks total bytes/lines seen,\n- if artifact path exists and output overflows (or file already active), writes full stream to artifact file,\n- when memory threshold overflows, trims in-memory buffer to tail (UTF-8 boundary safe),\n- marks `truncated` when overflow/file spill occurs.\n\n`dump()` returns:\n\n- `output` (possibly annotated prefix),\n- `truncated`,\n- `totalLines/totalBytes`,\n- `outputLines/outputBytes`,\n- `artifactId` if artifact file was active.\n\n### Long-output caveat\n\nRuntime truncation is byte-threshold based in `OutputSink` (50KB default). It does not enforce a hard 2000-line cap in this code path.\n\n## Live tool updates and async jobs\n\nFor non-PTY foreground execution, `BashTool` uses a separate `TailBuffer` for partial updates and emits `onUpdate` snapshots while command is running.\n\nFor PTY execution, live rendering is handled by custom UI overlay, not by `onUpdate` text chunks.\n\nWhen `async.enabled` is true and the call passes `async: true`, `BashTool` starts a managed bash job, returns a running job result with a job id, and stores completion through the session managed-job path. Auto-backgrounding can also start this path after `bash.autoBackground.thresholdMs`.\n\n## Result shaping, metadata, and error mapping\n\nAfter execution:\n\n1. `cancelled` handling:\n - if abort signal is aborted -> throw `ToolAbortError` (abort semantics),\n - else -> throw `ToolError` (treated as tool failure).\n2. PTY `timedOut` -> throw `ToolError`.\n3. apply head/tail filters to final output text (`applyHeadTail`, head then tail).\n4. empty output becomes `(no output)`.\n5. attach truncation metadata via `toolResult(...).truncationFromSummary(result, { direction: \"tail\" })`.\n6. exit-code mapping:\n - missing exit code -> `ToolError(\"... missing exit status\")`\n - non-zero exit -> `ToolError(\"... Command exited with code N\")`\n - zero exit -> success result.\n\nSuccess payload structure:\n\n- `content`: text output,\n- `details.meta.truncation` when truncated, including:\n - `direction`, `truncatedBy`, total/output line+byte counts,\n - `shownRange`,\n - `artifactId` when available.\n\nBecause built-in tools are wrapped with `wrapToolWithMetaNotice()`, truncation notice text is appended to final text content automatically (for example: `Full: artifact://<id>`).\n\n## Rendering paths\n\n## Tool-call renderer (`bashToolRenderer`)\n\n`bashToolRenderer` is used for tool-call messages (`toolCall` / `toolResult`):\n\n- collapsed mode shows visual-line-truncated preview,\n- expanded mode shows all currently available output text,\n- warning line includes truncation reason and `artifact://<id>` when truncated,\n- timeout value (from args) is shown in footer metadata line.\n\n### Caveat: full artifact expansion\n\n`BashRenderContext` has `isFullOutput`, but current renderer context builder does not set it for bash tool results. Expanded view still uses the text already in result content (tail/truncated output) unless another caller provides full artifact content.\n\n## User bang-command component (`BashExecutionComponent`)\n\n`BashExecutionComponent` is for user `!` commands in interactive mode (not model tool calls):\n\n- streams chunks live,\n- collapsed preview keeps last 20 logical lines,\n- line clamp at 4000 chars per line,\n- shows truncation + artifact warnings when metadata is present,\n- marks cancelled/error/exit state separately.\n\nThis component is wired by `CommandController.handleBashCommand()` and fed from `AgentSession.executeBash()`.\n\n## Mode-specific behavior differences\n\n| Surface | Entry path | PTY eligible | Live output UX | Error surfacing |\n| ------------------------------ | ----------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------ |\n| Interactive tool call | `BashTool.execute` | Yes, when `pty=true` and UI exists and `GJC_NO_PTY!=1` | PTY overlay (interactive) or streamed tail updates | Tool errors become `toolResult.isError` |\n| Print mode tool call | `BashTool.execute` | No (no UI context) | No TUI overlay; output appears in event stream/final assistant text flow | Same tool error mapping |\n| ACP tool call (agent tooling) | `BashTool.execute` | Usually no UI -> non-PTY | Structured protocol events/results | Same tool error mapping |\n| Interactive bang command (`!`) | `AgentSession.executeBash` + `BashExecutionComponent` | No (uses executor directly) | Dedicated bash execution component | Controller catches exceptions and shows UI error |\n\n## Operational caveats\n\n- Interceptor only blocks commands when suggested tool is currently available in context.\n- If artifact allocation fails, truncation still occurs but no `artifact://` back-reference is available.\n- Shell session cache has no explicit eviction in this module; lifetime is process-scoped.\n- PTY and non-PTY timeout surfaces differ:\n - PTY exposes explicit `timedOut` result field,\n - non-PTY maps timeout into `cancelled + annotation` summary.\n\n## Implementation files\n\n- [`src/tools/bash.ts`](../packages/coding-agent/src/tools/bash.ts) — tool entrypoint, input handling/interception, async and PTY/non-PTY selection, result/error mapping, bash tool renderer.\n- [`src/tools/bash-normalize.ts`](../packages/coding-agent/src/tools/bash-normalize.ts) — post-run head/tail filtering; also contains an unused command-normalization helper.\n- [`src/tools/bash-interceptor.ts`](../packages/coding-agent/src/tools/bash-interceptor.ts) — interceptor rule matching and blocked-command messages.\n- [`src/exec/bash-executor.ts`](../packages/coding-agent/src/exec/bash-executor.ts) — non-PTY executor, shell session reuse, cancellation wiring, output sink integration.\n- [`src/tools/bash-interactive.ts`](../packages/coding-agent/src/tools/bash-interactive.ts) — PTY runtime, overlay UI, input normalization, non-interactive env defaults.\n- [`src/session/streaming-output.ts`](../packages/coding-agent/src/session/streaming-output.ts) — `OutputSink`, `TailBuffer`, truncation/artifact spill, and summary metadata.\n- [`src/tools/output-meta.ts`](../packages/coding-agent/src/tools/output-meta.ts) — truncation metadata shape + notice injection wrapper.\n- [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts) — session-level `executeBash`, message recording, abort lifecycle.\n- [`src/modes/components/bash-execution.ts`](../packages/coding-agent/src/modes/components/bash-execution.ts) — interactive `!` command execution component.\n- [`src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.ts) — wiring for interactive `!` command UI stream/update completion.\n- [`src/internal-urls/artifact-protocol.ts`](../packages/coding-agent/src/internal-urls/artifact-protocol.ts) — `artifact://<id>` resolution.\n",
|
|
14
14
|
"blob-artifact-architecture.md": "# Blob and artifact storage architecture\n\nThis document describes how coding-agent stores large/binary payloads outside session JSONL, how truncated tool output is persisted, and how internal URLs (`artifact://`, `agent://`) resolve back to stored data.\n\n## Why two storage systems exist\n\nThe runtime uses two different persistence mechanisms for different data shapes:\n\n- **Content-addressed blobs** (`blob:sha256:<hash>`): global storage used to externalize large image base64 payloads and provider image data URLs from persisted session entries.\n- **Session-scoped artifacts** (files under `<sessionFile-without-.jsonl>/`): per-session text files used for full tool outputs and subagent outputs.\n\nThey are intentionally separate:\n\n- blob storage optimizes deduplication and stable references by content hash,\n- artifact storage optimizes append-only session tooling and human/tool retrieval by local IDs.\n\n## Storage boundaries and on-disk layout\n\n## Blob store boundary (global)\n\n`SessionManager` constructs `BlobStore(getBlobsDir())`, so blob files live in a shared global blob directory (not in a session folder).\n\nBlob file naming:\n\n- file path: `<blobsDir>/<sha256-hex>`\n- no extension\n- reference string stored in entries: `blob:sha256:<sha256-hex>`\n\nImplications:\n\n- same binary content across sessions resolves to the same hash/path,\n- writes are idempotent at the content level,\n- blobs can outlive any individual session file.\n\n## Artifact boundary (session-local)\n\n`ArtifactManager` derives artifact directory from session file path:\n\n- session file: `.../<timestamp>_<sessionId>.jsonl`\n- artifacts directory: `.../<timestamp>_<sessionId>/` (strip `.jsonl`)\n\nArtifact types share this directory:\n\n- truncated tool output files: `<numericId>.<toolType>.log` (for `artifact://`)\n- subagent output files: `<outputId>.md` (for `agent://`)\n\n## ID and name allocation schemes\n\n## Blob IDs: content hash\n\n`BlobStore.put()` computes SHA-256 over the bytes it is given and returns:\n\n- `hash`: hex digest,\n- `path`: `<blobsDir>/<hash>`,\n- `ref`: `blob:sha256:<hash>`.\n\nNo session-local counter is used.\n\n## Artifact IDs: session-local monotonic integer\n\n`ArtifactManager` scans existing `*.log` artifact files on first use to find max existing numeric ID and sets `nextId = max + 1`.\n\nAllocation behavior:\n\n- file format: `{id}.{toolType}.log`\n- IDs are sequential strings (`\"0\"`, `\"1\"`, ...)\n- resume does not overwrite existing artifacts because scan happens before allocation.\n\nIf artifact directory is missing, scanning yields empty list and allocation starts from `0`.\n\n## Agent output IDs (`agent://`)\n\n`AgentOutputManager` allocates IDs for subagent outputs as `<index>-<requestedId>` (optionally nested under parent prefix, e.g. `0-Parent.1-Child`). It scans existing `.md` files on initialization to continue from the next index on resume.\n\n## Persistence dataflow\n\n## 1) Session entry persistence rewrite path\n\nBefore session entries are written (`#rewriteFile` / incremental persist), `SessionManager` calls `prepareEntryForPersistence()` (via `truncateForPersistence`).\n\nKey behaviors:\n\n1. **Large string truncation**: oversized strings are cut and suffixed with `\"[Session persistence truncated large content]\"`; signature fields (`thinkingSignature`, `thoughtSignature`, `textSignature`) are cleared instead of truncated.\n2. **Transient field stripping**: `partialJson` and `jsonlEvents` are removed from persisted entries.\n3. **Image externalization to blobs**:\n - image blocks in `content` arrays are externalized when `data` is not already a blob ref and base64 length is at least threshold (`BLOB_EXTERNALIZE_THRESHOLD = 1024`),\n - provider-style `image_url` data URLs are externalized when they start with `data:image/` and contain `;base64,`,\n - image block `data` is stored as decoded binary bytes,\n - provider data URLs are stored as the original UTF-8 data URL string,\n - persisted values are replaced with `blob:sha256:<hash>`.\n\nThis keeps session JSONL compact while preserving recoverability.\n\n## 2) Session load rehydration path\n\nWhen opening a session (`setSessionFile`), after migrations, `SessionManager` runs `resolveBlobRefsInEntries()`.\n\nFor message/custom-message image blocks with `blob:sha256:<hash>` and for persisted provider `image_url` fields with blob refs:\n\n- reads blob bytes from blob store,\n- converts image-block bytes back to base64,\n- converts provider `image_url` blobs back to the original string,\n- mutates in-memory entry fields for runtime consumers.\n\nIf blob is missing:\n\n- `resolveImageData()` logs warning,\n- returns original ref string unchanged,\n- load continues (no hard crash).\n\n## 3) Tool output spill/truncation path\n\n`OutputSink` powers streaming output in bash/python/ssh and related executors.\n\nBehavior:\n\n1. Every chunk is sanitized and appended to in-memory tail buffer.\n2. When in-memory bytes exceed spill threshold (`DEFAULT_MAX_BYTES`, 50KB), sink marks output truncated.\n3. If an artifact path is available, sink opens a file writer and writes:\n - existing buffered content once,\n - all subsequent chunks.\n4. In-memory buffer is always trimmed to tail window for display.\n5. `dump()` returns summary including `artifactId` only when file sink was successfully created.\n\nPractical effect:\n\n- UI/tool return shows truncated tail,\n- full output is preserved in artifact file and referenced as `artifact://<id>`.\n\nIf file sink creation fails (I/O error, missing path, etc.), sink silently falls back to in-memory truncation only; full output is not persisted.\n\n## URL access model\n\n## `blob:` references\n\n`blob:sha256:<hash>` is a persistence reference inside session entry payloads, not an internal URL scheme handled by the router. Resolution is done by `SessionManager` during session load.\n\n## `artifact://<id>`\n\nHandled by `ArtifactProtocolHandler`:\n\n- requires active session artifact directory,\n- ID must be numeric,\n- resolves by matching filename prefix `<id>.`,\n- returns raw text (`text/plain`) from the matched `.log` file,\n- when missing, error includes list of available artifact IDs.\n\nMissing directory behavior:\n\n- if artifacts directory does not exist, throws `No artifacts directory found`.\n\n## `agent://<id>`\n\nHandled by `AgentProtocolHandler` over `<artifactsDir>/<id>.md`:\n\n- plain form returns markdown text,\n- `/path` or `?q=` forms perform JSON extraction,\n- path and query extraction cannot be combined,\n- if extraction requested, file content must parse as JSON.\n\nMissing directory behavior:\n\n- throws `No artifacts directory found`.\n\nMissing output behavior:\n\n- throws `Not found: <id>` with available IDs from existing `.md` files.\n\nRead tool integration:\n\n- `read` supports offset/limit pagination for non-extraction internal URL reads,\n- rejects `offset/limit` when `agent://` extraction is used.\n\n## Resume, fork, and move semantics\n\n## Resume\n\n- `ArtifactManager` scans existing `{id}.*.log` files on first allocation and continues numbering.\n- `AgentOutputManager` scans existing `.md` output IDs and continues numbering.\n- `SessionManager` rehydrates blob refs to base64 on load.\n\n## Fork\n\n`SessionManager.fork()` creates a new session file with new session ID and `parentSession` link, then returns old/new file paths. Artifact copying is handled by `AgentSession.fork()`:\n\n- attempts recursive copy of old artifact directory to new artifact directory,\n- missing old directory is tolerated,\n- non-ENOENT copy errors are logged as warnings and fork still completes.\n\nID implications after fork:\n\n- if copy succeeded, artifact counters in new session continue after max copied ID,\n- if copy failed/skipped, new session artifact IDs start from `0`.\n\nBlob implications after fork:\n\n- blobs are global and content-addressed, so no blob directory copy is required.\n\n## Move to new cwd\n\n`SessionManager.moveTo()` renames both session file and artifact directory to the new default session directory, with rollback logic if a later step fails. This preserves artifact identity while relocating session scope.\n\n## Failure handling and fallback paths\n\n| Case | Behavior |\n| -------------------------------------------------------- | --------------------------------------------------------------------- |\n| Blob file missing during rehydration | Warn and keep `blob:sha256:` ref string in-memory |\n| Blob read ENOENT via `BlobStore.get` | Returns `null` |\n| Artifact directory missing (`ArtifactManager.listFiles`) | Returns empty list (allocation can start fresh) |\n| Artifact directory missing (`artifact://` / `agent://`) | Throws explicit `No artifacts directory found` |\n| Artifact ID not found | Throws with available IDs listing |\n| OutputSink artifact writer init fails | Continues with tail-only truncation (no full-output artifact) |\n| No session file (some task paths) | Task tool falls back to temp artifacts directory for subagent outputs |\n\n## Binary blob externalization vs text-output artifacts\n\n- **Blob externalization** is for image payloads inside persisted session entry content and provider image data URLs; it replaces inline payload strings in JSONL with stable content refs.\n- **Artifacts** are plain text files for execution output and subagent output; they are addressable by session-local IDs through internal URLs.\n\nThe two systems intersect only indirectly (both reduce session JSONL bloat) but have different identity, lifetime, and retrieval paths.\n\n## Implementation files\n\n- [`src/session/blob-store.ts`](../packages/coding-agent/src/session/blob-store.ts) — blob reference format, hashing, put/get, externalize/resolve helpers.\n- [`src/session/artifacts.ts`](../packages/coding-agent/src/session/artifacts.ts) — session artifact directory model and numeric artifact ID/path allocation.\n- [`src/session/streaming-output.ts`](../packages/coding-agent/src/session/streaming-output.ts) — `OutputSink` truncation/spill-to-file behavior and summary metadata.\n- [`src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts) — persistence transforms, blob rehydration on load, session fork/move interactions.\n- [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts) — artifact directory copy during interactive fork.\n- [`src/internal-urls/artifact-protocol.ts`](../packages/coding-agent/src/internal-urls/artifact-protocol.ts) — `artifact://` resolver.\n- [`src/internal-urls/agent-protocol.ts`](../packages/coding-agent/src/internal-urls/agent-protocol.ts) — `agent://` resolver + JSON extraction.\n- [`src/sdk/session.ts`](../packages/coding-agent/src/sdk/session.ts) — internal URL router wiring and artifacts-dir resolver.\n- [`src/task/output-manager.ts`](../packages/coding-agent/src/task/output-manager.ts) — session-scoped agent output ID allocation for `agent://`.\n- [`src/task/executor.ts`](../packages/coding-agent/src/task/executor.ts) — subagent output artifact writes (`<id>.md`) and temp artifact directory fallback.\n",
|
|
15
|
-
"bot-integration.md": "# External controller integration guide\n\nThis guide is for authors of bots and orchestrators that want to drive Gajae-Code (`gjc`) without scraping terminal scrollback. Hermes, OpenClaw, GitHub bots, chatops bots, and custom schedulers are examples of external controllers; none of them need bespoke GJC behavior if they can speak the Coordinator MCP tools or the SDK WebSocket lifecycle below.\n\nGJC is an external runner. Your controller owns queueing, identity, policy, and credentials; GJC owns the coding-agent session, workflows, tools, artifacts, and evidence inside the selected repository or worktree.\n\n## Integration surfaces\n\nUse the smallest surface that fits your bot:\n\n| Surface | Best for | Command | Stability notes |\n| --- | --- | --- | --- |\n| Coordinator MCP | Any external controller that can discover SDK-backed sessions, send turns, answer questions, and read artifacts. | `gjc mcp-serve coordinator` | Preferred orchestration surface. `gjc mcp-serve hermes` is a compatibility alias, not a separate contract. |\n| Setup adapter | Rendering a portable MCP config and operator instructions for a controller profile. | `gjc setup hermes --root /path/to/repo` | Compatibility-oriented config renderer; does not call an LLM or validate provider credentials. |\n| SDK WebSocket | A controller that drives one live session directly: state queries, events, actions, and workflow-gate replies. | Connect to the session's loopback SDK endpoint (see [`docs/sdk.md`](./sdk.md)) | The canonical machine interface. `--mode rpc`, `--mode rpc-ui`, and `--mode bridge` have been removed. |\n| Daemon session CLI | Scripted control/queries against a live session with JSON output. | `gjc daemon session list\\|control\\|query\\|global` | A pure SDK client; honors the same protocol and dispositions. |\n\n## Recommended architecture\n\n```text\nexternal controller / bot\n ├─ chooses repo/worktree and task policy\n ├─ starts MCP server: gjc mcp-serve coordinator\n ├─ discovers or starts one SDK-backed GJC session\n ├─ sends one bounded turn at a time\n ├─ answers structured questions explicitly\n ├─ marks turn completion/failure with report_status\n └─ reads artifacts/reports from allowlisted roots\n```\n\nDo not infer completion from terminal output. Treat SDK-backed durable turn state as authoritative. Tmux identifiers, when present, are advisory process metadata only.\n\n## Coordinator MCP setup\n\nRender a non-mutating config preview:\n\n```sh\ngjc setup hermes --root /path/to/repo --profile my-bot --repo my-repo\n```\n\nInstall into a Hermes-compatible profile only when the target path is intentional:\n\n```sh\ngjc setup hermes \\\n --root /path/to/repo \\\n --profile my-bot \\\n --repo my-repo \\\n --mutation sessions,questions,reports \\\n --profile-dir /path/to/hermes/profile \\\n --install\n```\n\nRun provider-independent contract smokes before trying a live model:\n\n```sh\ngjc setup hermes --root /path/to/repo --smoke --json\ngjc mcp-serve coordinator --check --json\n```\n\nThe generated config uses these environment variables:\n\n| Variable | Purpose |\n| --- | --- |\n| `GJC_COORDINATOR_MCP_WORKDIR_ROOTS` | Required allowlist for workdirs and artifact paths. |\n| `GJC_COORDINATOR_MCP_MUTATIONS` | Startup opt-in for mutation classes: `sessions`, `questions`, `reports`, or `all`. |\n| `GJC_COORDINATOR_MCP_SESSION_COMMAND` | Command used to start real GJC sessions, defaulting to `gjc --worktree` in generated setup. |\n| `GJC_COORDINATOR_MCP_PROFILE` | Optional profile namespace so one bot cannot enumerate another profile's state. |\n| `GJC_COORDINATOR_MCP_REPO` | Optional repo namespace so one repo cannot enumerate another repo's state. |\n| `GJC_COORDINATOR_MCP_STATE_ROOT` | Optional coordination state root; defaults under `.gjc/state/coordinator-mcp`. |\n| `GJC_COORDINATOR_MCP_ARTIFACT_BYTE_CAP` | Maximum bytes returned by artifact reads. |\n\nMutating calls require both startup opt-in, per-call `allow_mutation: true`, and the required caller-provided `idempotency_key`. Missing any one fails closed.\n\n## Generic smoke strategy\n\nUse three different smoke levels so CI does not depend on one operator's model, API key, or desktop:\n\n| Smoke | Required for CI | What it proves | Example |\n| --- | --- | --- | --- |\n| Contract smoke | Yes | MCP server metadata, tool discovery, exported tool names, input schemas, read-only default, and mutation-gate failures. No provider credentials required. | `gjc mcp-serve coordinator --check --json` and focused tests around `tools/list` plus mutation denial. |\n| Dry-run lifecycle smoke | Yes when changed behavior affects lifecycle state | A generic controller can discover a mocked SDK session, send a turn, observe active-turn protection, report terminal status, and read the completed turn without a real LLM. | `bun test packages/coding-agent/test/coordinator-mcp-server.test.ts` uses mocked SDK services and temporary state roots. |\n| Optional live smoke | No | One operator's local provider/model/profile setup can run end-to-end in their chosen repo. Failure diagnoses that setup; it must not fail CI or PR validation. | Start `gjc mcp-serve coordinator` with local env, dispatch a tiny task, then report/read evidence. |\n\nA public bot integration change should at least preserve the contract smoke and local-leak docs test. Live smokes are diagnostics, not mandatory gates.\n\n## MCP tool contract\n\nRead-only tools:\n\n- `gjc_coordinator_list_sessions`\n- `gjc_coordinator_read_status`\n- `gjc_coordinator_read_tail`\n- `gjc_coordinator_read_turn`\n- `gjc_coordinator_await_turn`\n- `gjc_coordinator_list_questions`\n- `gjc_coordinator_list_artifacts`\n- `gjc_coordinator_read_artifact`\n- `gjc_coordinator_read_coordination_status`\n- `gjc_coordinator_watch_events`\n\nMutating tools:\n\n- `gjc_coordinator_start_session`\n- `gjc_coordinator_register_session`\n- `gjc_coordinator_send_prompt`\n- `gjc_coordinator_submit_question_answer`\n- `gjc_coordinator_report_status`\n- `gjc_coordinator_stop_session`\n\n`gjc_coordinator_stop_session` closes a coordinator delegate-created (ephemeral) session through canonical SDK broker lifecycle control, then removes its coordinator metadata only after the broker reports success. It refuses sessions with an active turn. User-registered sessions require both `force: true` and the `GJC_COORDINATOR_MCP_FORCE_STOP` capability; the same SDK lifecycle path reaps abandoned ephemeral delegate sessions after the configured idle TTL.\n\nHigh-level delegation tools:\n\n- `gjc_delegate_plan`\n- `gjc_delegate_execute`\n- `gjc_delegate_team`\n\nThe `gjc_delegate_*` tools package common GJC workflows for hosts that want to delegate an entire planning, execution, or team turn without manually composing `start_session` and `send_prompt`. They use the same coordinator mutation gates and workdir allowlists as the lower-level session tools.\n\n### Start a managed GJC session\n\nCall `gjc_coordinator_start_session` with a canonical workdir inside `GJC_COORDINATOR_MCP_WORKDIR_ROOTS`:\n\n```json\n{\n \"cwd\": \"/path/to/repo\",\n \"prompt\": \"Optional first bounded task prompt\",\n \"idempotency_key\": \"start-gjc-demo-1\",\n \"allow_mutation\": true\n}\n```\n\nThe returned payload includes `session.session_id`, `session_state`, and, when a prompt is provided, `turn_id`, `active_turn_id`, `status`, `delivery`, `queued`, and `delivered`. The top-level `status`, `queued`, and `delivered` exactly mirror the nested durable turn; `active_turn_id` is the current active turn.\n\n### Register an SDK-discoverable session\n\nRegister an already-running GJC session only after its endpoint is discoverable from the selected workdir:\n\n```json\n{\n \"session_id\": \"visible-gjc-1\",\n \"cwd\": \"/path/to/repo\",\n \"idempotency_key\": \"register-visible-gjc-1\",\n \"allow_mutation\": true\n}\n```\n\n`gjc_coordinator_register_session` validates the session id and workdir allowlist, then verifies SDK endpoint discovery before writing coordinator state. Optional `tmux_session` and `tmux_target` fields are advisory process metadata only.\n\n### Send work as turns\n\nSend one bounded task prompt and persist the returned `turn_id`:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"prompt\": \"Use /skill:ralplan to build a plan for ...\",\n \"idempotency_key\": \"send-gjc-demo-1\",\n \"allow_mutation\": true\n}\n```\n\nA session may have one active turn by default. A second prompt returns `active_turn_exists` unless the bot passes:\n\n- `queue: true` to enqueue a durable follow-up turn, or\n- `force: true` to supersede the previous active turn and audit the supersession.\n\n### Wait or watch for completion\n\nUse `gjc_coordinator_read_turn` for polling or `gjc_coordinator_await_turn` for bounded waiting:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"turn_id\": \"turn-00000000-0000-0000-0000-000000000000\",\n \"timeout_ms\": 30000,\n \"poll_interval_ms\": 1000,\n \"lines\": 80\n}\n```\n\nTerminal turn statuses are `completed`, `failed`, `cancelled`, and `superseded`. Non-terminal statuses include `queued`, `delivering`, `active`, `waiting_for_answer`, and `completing`.\n\nWhen the work is done, your bot must call `gjc_coordinator_report_status` with the turn id. This writes the final response/error, evidence paths, and coordinator report that later reads consume:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"turn_id\": \"turn-00000000-0000-0000-0000-000000000000\",\n \"status\": \"completed\",\n \"summary\": \"Implemented the requested fix and ran focused tests.\",\n \"evidence_paths\": [\"/path/to/repo/test-output.txt\"],\n \"idempotency_key\": \"report-gjc-demo-1\",\n \"allow_mutation\": true\n}\n```\n\nUse `status: \"failed\"` plus `blocker` for provider failures, unrecoverable tool failures, missing credentials, policy denial, or task blockers.\nUse `status: \"cancelled\"` when the coordinator policy intentionally stops tracking an active turn, for example after an operator abort or a bot-side shutdown decision. This records the turn as terminal in coordinator state; it does not kill or control any tmux process. To supersede one active turn with replacement work, send the replacement prompt with `force: true` and preserve the superseded turn id in your audit trail.\n\n### Forward finish/stop lifecycle notifications\n\nDiscord, Hermes, Clawhip, and similar external notifiers should be opt-in and should forward only the public lifecycle surface. Use one of these supported paths:\n\n- Coordinator controllers: watch or poll turn state with `gjc_coordinator_watch_events`, `gjc_coordinator_await_turn`, or `gjc_coordinator_read_turn`, then notify from the terminal turn status your controller records with `gjc_coordinator_report_status`.\n- In-process extensions or hooks: subscribe to the public lifecycle events `turn_end` and `agent_end` from the shared hook/extension event contract.\n\nRecommended notification mapping:\n\n| Notification intent | Public surface | Safe meaning |\n| --- | --- | --- |\n| Turn finished | `turn_end` or terminal coordinator turn status `completed` | One LLM turn produced its final assistant message. |\n| Agent stopped / finished | `agent_end` | The agent loop ended for the submitted prompt. |\n| Waiting for user | Coordinator turn status `waiting_for_answer` | The agent is blocked on a structured question. |\n| Failed or blocked | Coordinator status `failed` with a public `blocker` summary | The controller recorded a terminal failure. |\n| Cancelled / superseded | Coordinator status `cancelled` or `superseded` | The controller intentionally stopped tracking or replaced the turn. |\n\nDo not forward raw prompts, transcripts, tool outputs, hidden instructions, private configs, host paths, channel ids, webhook URLs, or tokens. If your notifier needs a human-readable sentence, create a caller-supplied sanitized summary and keep provider/tool details out of the payload.\n\nExample public-safe extension event payloads:\n\n```json\n{ \"type\": \"turn_end\", \"turnIndex\": 2, \"summary\": \"Turn finished; review the local GJC session for details.\" }\n```\n\n```json\n{ \"type\": \"agent_end\", \"summary\": \"Agent loop ended; no raw transcript is included.\" }\n```\n\nExample opt-in forwarding policy:\n\n```json\n{\n \"enabled\": true,\n \"events\": [\"turn_end\", \"agent_end\"],\n \"destination\": \"external-notifier-profile\",\n \"redaction\": \"metadata-only\"\n}\n```\n\nGJC does not currently expose a structured stop-reason field on `agent_end`; integrators that need `waiting_for_answer`, `failed`, `cancelled`, or `superseded` should prefer the Coordinator MCP turn status because it is explicit, terminal-state oriented, and safe to relay after controller-side redaction.\n\n### Answer structured questions\n\nList pending questions:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"status\": \"pending\"\n}\n```\n\nThen answer by id:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"turn_id\": \"turn-00000000-0000-0000-0000-000000000000\",\n \"question_id\": \"question-1\",\n \"answer\": { \"decision\": \"approve\" },\n \"idempotency_key\": \"answer-gjc-demo-1\",\n \"allow_mutation\": true\n}\n```\n\nAlways answer the advertised shape. Do not synthesize approvals for destructive actions unless your bot policy explicitly permits that action.\n\n### Read artifacts and reports\n\nUse `gjc_coordinator_list_artifacts` to inspect safe roots and `gjc_coordinator_read_artifact` to read a bounded artifact:\n\n```json\n{ \"path\": \"/path/to/repo/.gjc/ultragoal/ledger.jsonl\" }\n```\n\nArtifact paths are canonicalized, symlink escapes are rejected, and output is byte-capped. Use `gjc_coordinator_read_coordination_status` for status reports written through `gjc_coordinator_report_status`.\n\n## SDK WebSocket integration\n\nUse the SDK when your bot owns a single live session rather than an MCP coordinator. Each running session exposes a loopback WebSocket endpoint discovered via `.gjc/state/sdk/<sessionId>.json`; the wire protocol (state queries, control operations, event subscription and replay, workflow-gate replies, reverse host-tool leases) is documented in [`docs/sdk.md`](./sdk.md).\n\nKey SDK workflow-gate facts:\n- The discovery file carries the endpoint URL and per-session token; a wrong\n token is rejected at the WebSocket handshake. `server_hello` marks a\n connection ready, and `gjc daemon session control|query|global` uses the same\n protocol for shell scripts.\n\n- `action_needed.id` is an opaque, transient presentation ID. It is the only\n generic `reply.id` authority. Do not equate it with a durable workflow gate.\n- A durable workflow-gate presentation optionally includes additive SDK v3 `workflowGateId`. It correlates to Q12's durable `gate_id` only within `(sessionId, workflowGateId)` on the current authenticated endpoint; it never authorizes generic reply.\n- `workflow.gate_answer` and `workflow.plan_approve` use the durable `gate_id`. `expectedSessionId` omission remains accepted and audited for the entire SDK v3 line so deployed v3 clients continue to work, but new clients must send it. Mandatory enforcement or removal may occur no earlier than SDK v4 and only after at least one full published deprecation release/window with deployed-client notice. A supplied session mismatch is rejected before resolution.\n- One session has one active answerable presentation. Additional Q12 gates stay queued while Q12 exposes durable pending records and additive SDK v3 diagnostics. A same-server reconnect replays the active action ID; a process restart quarantines old records and a rebuilt workflow remints fresh gate and presentation IDs.\n- A native generic reply claim wins a direct-control race once acquired; a direct control wins only by atomically retiring the exact unclaimed active presentation. Terminal, stale, and reissued action IDs never regain authority. Do not use text, option/order, durable-ID, or history heuristics, and fail closed rather than guess when identity is unsafe or ambiguous. Do not persist private route/claim/receipt/epoch/generation state.\n- Rust/N-API compatibility is additive: legacy `ActionNeeded`, `register_ask`,\n and `registerAsk` stay uncorrelated; explicit workflow reader/registration\n APIs preserve correlation without exposing private arbitration state.\n- The `@gajae-code/coding-agent` runtime and `@gajae-code/natives` native addon ship from the same source release at exact matching package versions (currently `0.10.2`); the native loader version sentinel enforces the pair. Mixed native/runtime versions are unsupported and cannot claim SDK compatibility.\n\nThe prior documented invariant `action_needed.id == gate_id` is incorrect for\nv3 and must not be implemented by controllers. See [`docs/sdk.md`](./sdk.md)\nfor exact wire examples, Q12 tags/lifecycle diagnostics, and control payloads.\n\n`--mode rpc`, `--mode rpc-ui`, and `--mode bridge` have been removed along with their JSONL/HTTPS protocols and the former Python RPC client. There are no compatibility shims; migrate controllers to the SDK endpoint or Coordinator MCP.\n\n## Error handling playbook\n\n| Situation | Bot behavior |\n| --- | --- |\n| `coordinator_mutation_class_disabled:*` | Re-render setup with the required mutation class, or keep the bot in read-only mode. |\n| `coordinator_mutation_call_not_allowed:*` | Add `allow_mutation: true` only after policy approval for that specific call. |\n| `unknown_session` | Re-list sessions; start a new managed session or register a session after its SDK endpoint is discoverable. |\n| `active_turn_exists` | Poll the active turn, send with `queue: true`, or use `force: true` only when supersession is intentional. |\n| `timeout` from `await_turn` | Treat as non-terminal. Poll again or inspect `read_status`; do not mark failure solely from a bounded wait timeout. |\n| Coordinator cancellation | Use `gjc_coordinator_report_status` with `status: \"cancelled\"` for an intentionally stopped turn, or send replacement work with `force: true` when supersession is policy-approved. This is coordinator state, not process control. |\n| Stale session state | Check `read_status.session_state` and SDK endpoint discovery. Register a new discoverable session or report the turn failed with a recoverable blocker. |\n| Provider/auth failure | Capture the model/provider error in `report_status` with `status: \"failed\"`; do not retry forever without a policy budget. |\n| Artifact denied | Keep the artifact inside allowlisted roots and avoid symlink escapes. |\n| Malformed or invalid question answer | Re-read the question/gate schema and submit a value matching the advertised shape. |\n| Bot shutdown | Persist `session_id` and active `turn_id`; on restart use `read_turn` and `read_status` before sending more work. |\n\n## Controller examples\n\nGeneric MCP controller config:\n\n```json\n{\n \"mcp_servers\": {\n \"gjc_coordinator\": {\n \"command\": \"gjc\",\n \"args\": [\"mcp-serve\", \"coordinator\"],\n \"env\": {\n \"GJC_COORDINATOR_MCP_WORKDIR_ROOTS\": \"/home/bot/src/project:/home/bot/src/worktrees\",\n \"GJC_COORDINATOR_MCP_MUTATIONS\": \"sessions,questions,reports\",\n \"GJC_COORDINATOR_MCP_PROFILE\": \"controller-prod\",\n \"GJC_COORDINATOR_MCP_REPO\": \"project\",\n \"GJC_COORDINATOR_MCP_SESSION_COMMAND\": \"gjc --worktree\"\n },\n \"enabled\": true\n }\n }\n}\n```\n\nExample controller loop:\n\n```text\n1. Start `gjc mcp-serve coordinator` with repo/worktree roots allowlisted.\n2. Call `gjc_coordinator_start_session` for a GJC-managed worktree session.\n3. Send `/skill:deep-interview`, `/skill:ralplan`, or an approved `gjc ultragoal ...` task as one turn.\n4. Await the turn; answer `gjc_coordinator_list_questions` entries using bot policy.\n5. Report terminal status with evidence paths.\n6. Read artifacts/reports for the user-facing bot response.\n```\n\nHermes and OpenClaw can use the same MCP tool contract. Their names here are examples of controller products, not privileged integration modes.\n\n## Security and credential boundaries\n\n- Do not put provider API keys, GitHub tokens, or bot secrets in prompts.\n- Prefer host tools, host URI schemes, or bot-side sidecars for credentialed external writes.\n- Keep `GJC_COORDINATOR_MCP_WORKDIR_ROOTS` narrow; do not allow `/`, `/home`, or broad parent directories.\n- Use namespaces for multi-tenant bots.\n- Keep mutation classes minimal: read-only for dashboards, `sessions` for work dispatch, `questions` for answering questions, and `reports` for final state.\n- Treat `.gjc/` as local runtime state and evidence. Do not expose it wholesale to untrusted users.\n\n## Related references\n\n- [`docs/hermes-mcp-bridge.md`](./hermes-mcp-bridge.md) — coordinator MCP details and setup adapter behavior.\n- [`docs/sdk.md`](./sdk.md) — SDK wire protocol, event frames, workflow gates, host tools, and host URI schemes.\n- [`docs/external-control-readiness.md`](./external-control-readiness.md) — readiness classification of the supported external-control surfaces.\n",
|
|
15
|
+
"bot-integration.md": "# External controller integration guide\n\nThis guide is for authors of bots and orchestrators that want to drive Gajae-Code (`gjc`) without scraping terminal scrollback. Hermes, OpenClaw, GitHub bots, chatops bots, and custom schedulers are examples of external controllers; none of them need bespoke GJC behavior if they can speak the Coordinator MCP tools or the SDK WebSocket lifecycle below.\n\nGJC is an external runner. Your controller owns queueing, identity, policy, and credentials; GJC owns the coding-agent session, workflows, tools, artifacts, and evidence inside the selected repository or worktree.\n\n## Integration surfaces\n\nUse the smallest surface that fits your bot:\n\n| Surface | Best for | Command | Stability notes |\n| --- | --- | --- | --- |\n| Coordinator MCP | Any external controller that can discover SDK-backed sessions, send turns, answer questions, and read artifacts. | `gjc mcp-serve coordinator` | Preferred orchestration surface. `gjc mcp-serve hermes` is a compatibility alias, not a separate contract. |\n| Setup adapter | Rendering a portable MCP config and operator instructions for a controller profile. | `gjc setup hermes --root /path/to/repo` | Compatibility-oriented config renderer; does not call an LLM or validate provider credentials. |\n| SDK WebSocket | A controller that drives one live session directly: state queries, events, actions, and workflow-gate replies. | Connect to the session's loopback SDK endpoint (see [`docs/sdk.md`](./sdk.md)) | The canonical machine interface. `--mode rpc`, `--mode rpc-ui`, and `--mode bridge` have been removed. |\n| Daemon session CLI | Scripted control/queries against a live session with JSON output. | `gjc daemon session list\\|control\\|query\\|global` | A pure SDK client; honors the same protocol and dispositions. |\n\n## Recommended architecture\n\n```text\nexternal controller / bot\n ├─ chooses repo/worktree and task policy\n ├─ starts MCP server: gjc mcp-serve coordinator\n ├─ discovers or starts one SDK-backed GJC session\n ├─ sends one bounded turn at a time\n ├─ answers structured questions explicitly\n ├─ marks turn completion/failure with report_status\n └─ reads artifacts/reports from allowlisted roots\n```\n\nDo not infer completion from terminal output. Treat SDK-backed durable turn state as authoritative. Tmux identifiers, when present, are advisory process metadata only.\n\n## Coordinator MCP setup\n\nRender a non-mutating config preview:\n\n```sh\ngjc setup hermes --root /path/to/repo --profile my-bot --repo my-repo\n```\n\nInstall into a Hermes-compatible profile only when the target path is intentional:\n\n```sh\ngjc setup hermes \\\n --root /path/to/repo \\\n --profile my-bot \\\n --repo my-repo \\\n --mutation sessions,questions,reports \\\n --profile-dir /path/to/hermes/profile \\\n --install\n```\n\nRun provider-independent contract smokes before trying a live model:\n\n```sh\ngjc setup hermes --root /path/to/repo --smoke --json\ngjc mcp-serve coordinator --check --json\n```\n\nThe generated config uses these environment variables:\n\n| Variable | Purpose |\n| --- | --- |\n| `GJC_COORDINATOR_MCP_WORKDIR_ROOTS` | Required allowlist for workdirs and artifact paths. |\n| `GJC_COORDINATOR_MCP_MUTATIONS` | Startup opt-in for mutation classes: `sessions`, `questions`, `reports`, or `all`. |\n| `GJC_COORDINATOR_MCP_SESSION_COMMAND` | Command used to start real GJC sessions, defaulting to `gjc --worktree` in generated setup. |\n| `GJC_COORDINATOR_MCP_PROFILE` | Optional profile namespace so one bot cannot enumerate another profile's state. |\n| `GJC_COORDINATOR_MCP_REPO` | Optional repo namespace so one repo cannot enumerate another repo's state. |\n| `GJC_COORDINATOR_MCP_STATE_ROOT` | Optional coordination state root; defaults under `.gjc/state/coordinator-mcp`. |\n| `GJC_COORDINATOR_MCP_ARTIFACT_BYTE_CAP` | Maximum bytes returned by artifact reads. |\n\nMutating calls require both startup opt-in, per-call `allow_mutation: true`, and the required caller-provided `idempotency_key`. Missing any one fails closed.\n\n## Generic smoke strategy\n\nUse three different smoke levels so CI does not depend on one operator's model, API key, or desktop:\n\n| Smoke | Required for CI | What it proves | Example |\n| --- | --- | --- | --- |\n| Contract smoke | Yes | MCP server metadata, tool discovery, exported tool names, input schemas, read-only default, and mutation-gate failures. No provider credentials required. | `gjc mcp-serve coordinator --check --json` and focused tests around `tools/list` plus mutation denial. |\n| Dry-run lifecycle smoke | Yes when changed behavior affects lifecycle state | A generic controller can discover a mocked SDK session, send a turn, observe active-turn protection, report terminal status, and read the completed turn without a real LLM. | `bun test packages/coding-agent/test/coordinator-mcp-server.test.ts` uses mocked SDK services and temporary state roots. |\n| Optional live smoke | No | One operator's local provider/model/profile setup can run end-to-end in their chosen repo. Failure diagnoses that setup; it must not fail CI or PR validation. | Start `gjc mcp-serve coordinator` with local env, dispatch a tiny task, then report/read evidence. |\n\nA public bot integration change should at least preserve the contract smoke and local-leak docs test. Live smokes are diagnostics, not mandatory gates.\n\n## MCP tool contract\n\nRead-only tools:\n\n- `gjc_coordinator_list_sessions`\n- `gjc_coordinator_read_status`\n- `gjc_coordinator_read_tail`\n- `gjc_coordinator_read_turn`\n- `gjc_coordinator_await_turn`\n- `gjc_coordinator_list_questions`\n- `gjc_coordinator_list_artifacts`\n- `gjc_coordinator_read_artifact`\n- `gjc_coordinator_read_coordination_status`\n- `gjc_coordinator_watch_events`\n\nMutating tools:\n\n- `gjc_coordinator_start_session`\n- `gjc_coordinator_register_session`\n- `gjc_coordinator_send_prompt`\n- `gjc_coordinator_submit_question_answer`\n- `gjc_coordinator_report_status`\n- `gjc_coordinator_stop_session`\n\n`gjc_coordinator_stop_session` closes a coordinator delegate-created (ephemeral) session through canonical SDK broker lifecycle control, then removes its coordinator metadata only after the broker reports success. It refuses sessions with an active turn. User-registered sessions require both `force: true` and the `GJC_COORDINATOR_MCP_FORCE_STOP` capability; the same SDK lifecycle path reaps abandoned ephemeral delegate sessions after the configured idle TTL.\n\nHigh-level delegation tools:\n\n- `gjc_delegate_plan`\n- `gjc_delegate_execute`\n- `gjc_delegate_team`\n\nThe `gjc_delegate_*` tools package common GJC workflows for hosts that want to delegate an entire planning, execution, or team turn without manually composing `start_session` and `send_prompt`. They use the same coordinator mutation gates and workdir allowlists as the lower-level session tools.\n\n### Start a managed GJC session\n\nCall `gjc_coordinator_start_session` with a canonical workdir inside `GJC_COORDINATOR_MCP_WORKDIR_ROOTS`:\n\n```json\n{\n \"cwd\": \"/path/to/repo\",\n \"prompt\": \"Optional first bounded task prompt\",\n \"idempotency_key\": \"start-gjc-demo-1\",\n \"allow_mutation\": true\n}\n```\n\nThe returned payload includes `session.session_id`, `session_state`, and, when a prompt is provided, `turn_id`, `active_turn_id`, `status`, `delivery`, `queued`, and `delivered`. The top-level `status`, `queued`, and `delivered` exactly mirror the nested durable turn; `active_turn_id` is the current active turn.\n\n### Register an SDK-discoverable session\n\nRegister an already-running GJC session only after its endpoint is discoverable from the selected workdir:\n\n```json\n{\n \"session_id\": \"visible-gjc-1\",\n \"cwd\": \"/path/to/repo\",\n \"idempotency_key\": \"register-visible-gjc-1\",\n \"allow_mutation\": true\n}\n```\n\n`gjc_coordinator_register_session` validates the session id and workdir allowlist, then verifies SDK endpoint discovery before writing coordinator state. Optional `tmux_session` and `tmux_target` fields are advisory process metadata only.\n\n### Send work as turns\n\nSend one bounded task prompt and persist the returned `turn_id`:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"prompt\": \"Use /skill:ralplan to build a plan for ...\",\n \"idempotency_key\": \"send-gjc-demo-1\",\n \"allow_mutation\": true\n}\n```\n\nA session may have one active turn by default. A second prompt returns `active_turn_exists` unless the bot passes:\n\n- `queue: true` to enqueue a durable follow-up turn, or\n- `force: true` to supersede the previous active turn and audit the supersession.\n\n### Wait or watch for completion\n\nUse `gjc_coordinator_read_turn` for polling or `gjc_coordinator_await_turn` for bounded waiting:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"turn_id\": \"turn-00000000-0000-0000-0000-000000000000\",\n \"timeout_ms\": 30000,\n \"poll_interval_ms\": 1000,\n \"lines\": 80\n}\n```\n\nTerminal turn statuses are `completed`, `failed`, `cancelled`, and `superseded`. Non-terminal statuses include `queued`, `delivering`, `active`, `waiting_for_answer`, and `completing`.\n\nWhen the work is done, your bot must call `gjc_coordinator_report_status` with the turn id. This writes the final response/error, evidence paths, and coordinator report that later reads consume:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"turn_id\": \"turn-00000000-0000-0000-0000-000000000000\",\n \"status\": \"completed\",\n \"summary\": \"Implemented the requested fix and ran focused tests.\",\n \"evidence_paths\": [\"/path/to/repo/test-output.txt\"],\n \"idempotency_key\": \"report-gjc-demo-1\",\n \"allow_mutation\": true\n}\n```\n\nUse `status: \"failed\"` plus `blocker` for provider failures, unrecoverable tool failures, missing credentials, policy denial, or task blockers.\nUse `status: \"cancelled\"` when the coordinator policy intentionally stops tracking an active turn, for example after an operator abort or a bot-side shutdown decision. This records the turn as terminal in coordinator state; it does not kill or control any tmux process. To supersede one active turn with replacement work, send the replacement prompt with `force: true` and preserve the superseded turn id in your audit trail.\n\n### Forward finish/stop lifecycle notifications\n\nDiscord, Hermes, Clawhip, and similar external notifiers should be opt-in and should forward only the public lifecycle surface. Use one of these supported paths:\n\n- Coordinator controllers: watch or poll turn state with `gjc_coordinator_watch_events`, `gjc_coordinator_await_turn`, or `gjc_coordinator_read_turn`, then notify from the terminal turn status your controller records with `gjc_coordinator_report_status`.\n- In-process extensions or hooks: subscribe to the public lifecycle events `turn_end` and `agent_end` from the shared hook/extension event contract.\n\nRecommended notification mapping:\n\n| Notification intent | Public surface | Safe meaning |\n| --- | --- | --- |\n| Turn finished | `turn_end` or terminal coordinator turn status `completed` | One LLM turn produced its final assistant message. |\n| Agent stopped / finished | `agent_end` | The agent loop ended for the submitted prompt. |\n| Waiting for user | Coordinator turn status `waiting_for_answer` | The agent is blocked on a structured question. |\n| Failed or blocked | Coordinator status `failed` with a public `blocker` summary | The controller recorded a terminal failure. |\n| Cancelled / superseded | Coordinator status `cancelled` or `superseded` | The controller intentionally stopped tracking or replaced the turn. |\n\nDo not forward raw prompts, transcripts, tool outputs, hidden instructions, private configs, host paths, channel ids, webhook URLs, or tokens. If your notifier needs a human-readable sentence, create a caller-supplied sanitized summary and keep provider/tool details out of the payload.\n\nExample public-safe extension event payloads:\n\n```json\n{ \"type\": \"turn_end\", \"turnIndex\": 2, \"summary\": \"Turn finished; review the local GJC session for details.\" }\n```\n\n```json\n{ \"type\": \"agent_end\", \"summary\": \"Agent loop ended; no raw transcript is included.\" }\n```\n\nExample opt-in forwarding policy:\n\n```json\n{\n \"enabled\": true,\n \"events\": [\"turn_end\", \"agent_end\"],\n \"destination\": \"external-notifier-profile\",\n \"redaction\": \"metadata-only\"\n}\n```\n\nGJC does not currently expose a structured stop-reason field on `agent_end`; integrators that need `waiting_for_answer`, `failed`, `cancelled`, or `superseded` should prefer the Coordinator MCP turn status because it is explicit, terminal-state oriented, and safe to relay after controller-side redaction.\n\n### Answer structured questions\n\nList pending questions:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"status\": \"pending\"\n}\n```\n\nThen answer by id:\n\n```json\n{\n \"session_id\": \"gjc-demo\",\n \"turn_id\": \"turn-00000000-0000-0000-0000-000000000000\",\n \"question_id\": \"question-1\",\n \"answer\": { \"decision\": \"approve\" },\n \"idempotency_key\": \"answer-gjc-demo-1\",\n \"allow_mutation\": true\n}\n```\n\nAlways answer the advertised shape. Do not synthesize approvals for destructive actions unless your bot policy explicitly permits that action.\n\n### Read artifacts and reports\n\nUse `gjc_coordinator_list_artifacts` to inspect safe roots and `gjc_coordinator_read_artifact` to read a bounded artifact:\n\n```json\n{ \"path\": \"/path/to/repo/.gjc/ultragoal/ledger.jsonl\" }\n```\n\nArtifact paths are canonicalized, symlink escapes are rejected, and output is byte-capped. Use `gjc_coordinator_read_coordination_status` for status reports written through `gjc_coordinator_report_status`.\n\n## SDK WebSocket integration\n\nUse the SDK when your bot owns a single live session rather than an MCP coordinator. Each running session exposes a loopback WebSocket endpoint discovered via `.gjc/state/sdk/<sessionId>.json`; the wire protocol (state queries, control operations, event subscription and replay, workflow-gate replies, reverse host-tool leases) is documented in [`docs/sdk.md`](./sdk.md).\n\nKey SDK workflow-gate facts:\n- The discovery file carries the endpoint URL and per-session token; a wrong\n token is rejected at the WebSocket handshake. `server_hello` marks a\n connection ready, and `gjc daemon session control|query|global` uses the same\n protocol for shell scripts.\n\n- `action_needed.id` is an opaque, transient presentation ID. It is the only\n generic `reply.id` authority. Do not equate it with a durable workflow gate.\n- A durable workflow-gate presentation optionally includes additive SDK v3 `workflowGateId`. It correlates to Q12's durable `gate_id` only within `(sessionId, workflowGateId)` on the current authenticated endpoint; it never authorizes generic reply.\n- `workflow.gate_answer` and `workflow.plan_approve` use the durable `gate_id`. `expectedSessionId` omission remains accepted and audited for the entire SDK v3 line so deployed v3 clients continue to work, but new clients must send it. Mandatory enforcement or removal may occur no earlier than SDK v4 and only after at least one full published deprecation release/window with deployed-client notice. A supplied session mismatch is rejected before resolution.\n- One session has one active answerable presentation. Additional Q12 gates stay queued while Q12 exposes durable pending records and additive SDK v3 diagnostics. A same-server reconnect replays the active action ID; a process restart quarantines old records and a rebuilt workflow remints fresh gate and presentation IDs.\n- A native generic reply claim wins a direct-control race once acquired; a direct control wins only by atomically retiring the exact unclaimed active presentation. Terminal, stale, and reissued action IDs never regain authority. Do not use text, option/order, durable-ID, or history heuristics, and fail closed rather than guess when identity is unsafe or ambiguous. Do not persist private route/claim/receipt/epoch/generation state.\n- Rust/N-API compatibility is additive: legacy `ActionNeeded`, `register_ask`,\n and `registerAsk` stay uncorrelated; explicit workflow reader/registration\n APIs preserve correlation without exposing private arbitration state.\n- The `@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 version sentinel enforces the pair. Mixed native/runtime versions are unsupported and cannot claim SDK compatibility.\n\nThe prior documented invariant `action_needed.id == gate_id` is incorrect for\nv3 and must not be implemented by controllers. See [`docs/sdk.md`](./sdk.md)\nfor exact wire examples, Q12 tags/lifecycle diagnostics, and control payloads.\n\n`--mode rpc`, `--mode rpc-ui`, and `--mode bridge` have been removed along with their JSONL/HTTPS protocols and the former Python RPC client. There are no compatibility shims; migrate controllers to the SDK endpoint or Coordinator MCP.\n\n## Error handling playbook\n\n| Situation | Bot behavior |\n| --- | --- |\n| `coordinator_mutation_class_disabled:*` | Re-render setup with the required mutation class, or keep the bot in read-only mode. |\n| `coordinator_mutation_call_not_allowed:*` | Add `allow_mutation: true` only after policy approval for that specific call. |\n| `unknown_session` | Re-list sessions; start a new managed session or register a session after its SDK endpoint is discoverable. |\n| `active_turn_exists` | Poll the active turn, send with `queue: true`, or use `force: true` only when supersession is intentional. |\n| `timeout` from `await_turn` | Treat as non-terminal. Poll again or inspect `read_status`; do not mark failure solely from a bounded wait timeout. |\n| Coordinator cancellation | Use `gjc_coordinator_report_status` with `status: \"cancelled\"` for an intentionally stopped turn, or send replacement work with `force: true` when supersession is policy-approved. This is coordinator state, not process control. |\n| Stale session state | Check `read_status.session_state` and SDK endpoint discovery. Register a new discoverable session or report the turn failed with a recoverable blocker. |\n| Provider/auth failure | Capture the model/provider error in `report_status` with `status: \"failed\"`; do not retry forever without a policy budget. |\n| Artifact denied | Keep the artifact inside allowlisted roots and avoid symlink escapes. |\n| Malformed or invalid question answer | Re-read the question/gate schema and submit a value matching the advertised shape. |\n| Bot shutdown | Persist `session_id` and active `turn_id`; on restart use `read_turn` and `read_status` before sending more work. |\n\n## Controller examples\n\nGeneric MCP controller config:\n\n```json\n{\n \"mcp_servers\": {\n \"gjc_coordinator\": {\n \"command\": \"gjc\",\n \"args\": [\"mcp-serve\", \"coordinator\"],\n \"env\": {\n \"GJC_COORDINATOR_MCP_WORKDIR_ROOTS\": \"/home/bot/src/project:/home/bot/src/worktrees\",\n \"GJC_COORDINATOR_MCP_MUTATIONS\": \"sessions,questions,reports\",\n \"GJC_COORDINATOR_MCP_PROFILE\": \"controller-prod\",\n \"GJC_COORDINATOR_MCP_REPO\": \"project\",\n \"GJC_COORDINATOR_MCP_SESSION_COMMAND\": \"gjc --worktree\"\n },\n \"enabled\": true\n }\n }\n}\n```\n\nExample controller loop:\n\n```text\n1. Start `gjc mcp-serve coordinator` with repo/worktree roots allowlisted.\n2. Call `gjc_coordinator_start_session` for a GJC-managed worktree session.\n3. Send `/skill:deep-interview`, `/skill:ralplan`, or an approved `gjc ultragoal ...` task as one turn.\n4. Await the turn; answer `gjc_coordinator_list_questions` entries using bot policy.\n5. Report terminal status with evidence paths.\n6. Read artifacts/reports for the user-facing bot response.\n```\n\nHermes and OpenClaw can use the same MCP tool contract. Their names here are examples of controller products, not privileged integration modes.\n\n## Security and credential boundaries\n\n- Do not put provider API keys, GitHub tokens, or bot secrets in prompts.\n- Prefer host tools, host URI schemes, or bot-side sidecars for credentialed external writes.\n- Keep `GJC_COORDINATOR_MCP_WORKDIR_ROOTS` narrow; do not allow `/`, `/home`, or broad parent directories.\n- Use namespaces for multi-tenant bots.\n- Keep mutation classes minimal: read-only for dashboards, `sessions` for work dispatch, `questions` for answering questions, and `reports` for final state.\n- Treat `.gjc/` as local runtime state and evidence. Do not expose it wholesale to untrusted users.\n\n## Related references\n\n- [`docs/hermes-mcp-bridge.md`](./hermes-mcp-bridge.md) — coordinator MCP details and setup adapter behavior.\n- [`docs/sdk.md`](./sdk.md) — SDK wire protocol, event frames, workflow gates, host tools, and host URI schemes.\n- [`docs/external-control-readiness.md`](./external-control-readiness.md) — readiness classification of the supported external-control surfaces.\n",
|
|
16
16
|
"brand-assets.md": "# Brand assets\n\nGajae-Code uses the current GJC character and hero images in `assets/` for README and documentation surfaces.\n\n| Asset | Purpose |\n| --- | --- |\n| [`assets/logo-vertical.png`](../assets/logo-vertical.png) | Vertical README/docs logo lockup for Gajae-Code. |\n| [`assets/hero.png`](../assets/hero.png) | Wide README/docs hero image for Gajae-Code. |\n| [`assets/character.png`](../assets/character.png) | Standalone Gajae-Code character mascot. |\n| [`assets/rlm.png`](../assets/rlm.png) | Feature card for the `rlm` research/REPL mode (scientist mascot). |\n| [`assets/computer-use.png`](../assets/computer-use.png) | Feature card for the `computer-use` desktop-control surface (operator mascot). |\n| [`assets/telegram-mobile-hero.png`](../assets/telegram-mobile-hero.png) | Feature card for the Telegram/mobile notifications flow. |\n| [`assets/tool-image-fixture.webp`](../assets/tool-image-fixture.webp) | Minimal WebP fixture for terminal image rendering tests. Not a product brand asset. |\n\nThe old legacy demo artwork has been removed from the active asset set; new public surfaces should reference the Gajae-Code assets above.\n",
|
|
17
17
|
"codebase-overview.md": "# Codebase Overview\n\nThis document maps the main parts of the `gajae-code` repository. The root README stays intentionally small; this file is the architecture-oriented companion.\n\n## Product shape\n\nGajae-Code (`gjc`) is centered on `packages/coding-agent/`. The public workflow surface is intentionally fixed at four source-bundled skills and four public role subagents. Runtime state, specs, plans, goals, team state, and local overrides live under `.gjc/`.\n\nDefault workflow skills are embedded from:\n\n```text\npackages/coding-agent/src/defaults/gjc/skills/<name>/SKILL.md\n```\n\nPublic role subagent prompts are embedded from:\n\n```text\npackages/coding-agent/src/prompts/agents/<role>.md\n```\n\nThe runtime can still discover project/user overrides, but the bundled defaults are loaded from source so a missing project `.gjc` directory does not remove the default workflow surface.\n\n## Packages\n\n### `packages/coding-agent/`\n\nMain `gjc` CLI and product runtime.\n\n- `packages/coding-agent/package.json` exposes the `gjc` binary at `src/cli.ts` and the SDK/barrel entrypoint at `src/index.ts`.\n- `packages/coding-agent/src/cli.ts` is the executable bootstrap. It registers CLI commands such as `setup`, `deep-interview`, `ralplan`, `ultragoal`, `team`, and the default launch path.\n- `packages/coding-agent/src/main.ts` adapts CLI options into session creation and dispatches interactive, print, and ACP modes; external machine clients use the SDK WebSocket interface.\n- `packages/coding-agent/src/sdk/session.ts` assembles settings, model registry, auth, workspace/context discovery, skills, rules, tools, system prompt, and the underlying `@gajae-code/agent-core` agent.\n- `packages/coding-agent/src/tools/index.ts` is the built-in tool registry for file/code/runtime tools such as read, bash, edit, AST tools, eval, find/search, LSP, browser, task/subagent, recipe, IRC, todo, web search, and write. Memory backends are private integrations, not public coding-harness tools.\n- `packages/coding-agent/src/defaults/gjc-defaults.ts` embeds and installs the default workflow skills.\n- `packages/coding-agent/src/task/agents.ts` embeds bundled task-agent prompts. The public contract is `executor`, `architect`, `planner`, and `critic`; other bundled prompts are internal/runtime utilities.\n- `packages/coding-agent/src/coordinator/contract.ts` defines the transport-neutral third-party coordinator contract used by `gjc mcp-serve coordinator`, `gjc coordinator`, and `gjc setup hermes`.\n- `packages/coding-agent/src/coordinator-mcp/server.ts` implements the outward MCP adapter for bot/coordinator integrations, including session start/register, turn state, question answering, status reports, and artifact reads.\n- `docs/external-control-readiness.md` classifies the public external-control surfaces: SDK WebSocket for live session control, Coordinator MCP for multi-session control planes, and ACP for editor/ACP clients. `docs/bot-integration.md` is the end-to-end guide for external controller authors.\n\n### `packages/ai/`\n\nProvider/model boundary for LLM access.\n\n- `packages/ai/src/index.ts` exports model registry/resolution, provider implementations, auth broker/gateway/storage, streaming, usage, retry/overflow utilities, OAuth, discovery, and validation helpers.\n- `packages/ai/src/types.ts` defines provider, model, context, message, tool, usage, reasoning, and stream-event contracts.\n- `packages/ai/src/stream.ts` dispatches model-driven streams to the right provider/API implementation and normalizes streaming events.\n- `packages/ai/src/model-manager.ts` merges static, cached, dynamic, and remote model sources.\n- `packages/ai/README.md` documents tool calling, partial streaming tool calls, thinking/reasoning, provider configuration, context handoff, and OAuth flows.\n\n### `packages/agent/`\n\nStateful agent runtime built on `@gajae-code/ai`.\n\n- `packages/agent/src/index.ts` exports the `Agent`, loop APIs, append-only context, compaction, telemetry, proxy utilities, thinking helpers, and shared types.\n- `packages/agent/src/agent-loop.ts` owns the turn loop: transform context, call the model stream, execute tool calls, append tool results, and emit lifecycle events.\n- `packages/agent/src/agent.ts` wraps the loop with mutable state, subscriptions, prompt/continue/abort APIs, queues, provider session state, telemetry, and state mutation helpers.\n- `packages/agent/src/types.ts` defines `AgentMessage`, `AgentTool`, loop config, event, and runtime state contracts.\n\n### `packages/tui/`\n\nTerminal UI framework used by the CLI.\n\n- `packages/tui/src/index.ts` exports components, keybindings, autocomplete, terminal abstractions, image support, TUI core, and utilities.\n- `packages/tui/src/tui.ts` manages component rendering, focus, overlays, terminal dimensions, diff state, and synchronized output.\n- `packages/tui/src/terminal.ts` abstracts terminal lifecycle, dimensions, cursor controls, title/progress, Kitty protocol state, and appearance notifications.\n- `packages/tui/README.md` documents the component model and built-in components such as text, input, editor, markdown, loaders, select/settings lists, spacer, image, box, and container.\n\n### `packages/natives/` and Rust crates\n\nNative helper layer exposed through N-API.\n\n- `packages/natives/package.json` exports `native/index.js` and generated TypeScript definitions.\n- `packages/natives/native/loader-state.js` resolves platform/CPU-specific native binaries and validates package/native version alignment.\n- `crates/pi-natives/src/lib.rs` is the N-API root for appearance, AST search/editing, clipboard, filesystem scan/cache, grep/glob, syntax highlighting, HTML-to-Markdown, keyboard parsing, process/PTY/shell support, SIXEL, code summarization, token counting, text measurement/wrapping/truncation, workspace scanning, power assertions, and isolation helpers.\n- `crates/pi-shell/src/lib.rs` exposes brush-based shell execution primitives used by the native shell adapter.\n- `crates/pi-shell/src/shell.rs` implements persistent and one-shot shell execution, streaming, environment handling, cancellation, and output minimizer telemetry.\n- `crates/pi-shell/src/fixup.rs` performs conservative AST-based bash command fixups.\n- `crates/pi-natives/src/pty.rs` implements interactive PTY sessions.\n\n### `packages/utils/`\n\nShared TypeScript utilities.\n\n- `packages/utils/src/index.ts` exports abortable/async helpers, color/env/dir utilities, fetch retry, formatting, frontmatter, glob helpers, JSON helpers, logging, MIME detection, prompt rendering, process-tree helpers, sanitization, streams, temp files, tab spacing, type guards, and executable lookup.\n- `packages/utils/src/ptree.ts` and `packages/utils/src/procmgr.ts` wrap native process helpers for ergonomic TypeScript use.\n\n### `packages/stats/`\n\nLocal observability dashboard for session and model usage.\n\n- `packages/stats/src/index.ts` exposes the `gjc-stats` CLI entrypoint and exports aggregation/server APIs.\n- `packages/stats/src/aggregator.ts` parses session-derived request metrics and writes aggregated data through SQLite.\n- `packages/stats/src/server.ts` serves local dashboard API routes and static SPA assets.\n- `packages/stats/src/types.ts` and `packages/stats/src/shared-types.ts` define dashboard and aggregate metric shapes.\n\n### `packages/typescript-edit-benchmark/`\n\nPrivate benchmark package for TypeScript edit tasks.\n\n- `packages/typescript-edit-benchmark/package.json` exposes `typescript-edit-benchmark` and depends on the coding-agent, agent-core, ai, tui, utils, diff, prettier, and Babel tooling.\n- `packages/typescript-edit-benchmark/src/index.ts` is the benchmark CLI: it resolves fixtures, loads tasks, runs edit attempts, records progress, and writes reports/conversation dumps under `runs/`.\n\n## Python packages\n\n### External machine interfaces\n\nExternal machine clients use the SDK WebSocket interface documented in `docs/sdk.md`. Coordinator MCP supplies multi-session orchestration, while ACP remains the stdio editor protocol. The former Python RPC client and bot integration paths were removed with the RPC ingress mode.\n\n## Runtime flow\n\nA normal CLI session starts in `packages/coding-agent/src/cli.ts`, routes through command handling, then reaches `packages/coding-agent/src/main.ts`. `main.ts` converts CLI/runtime settings into `CreateAgentSessionOptions` and calls `createAgentSession()` in `packages/coding-agent/src/sdk/session.ts`.\n\nThe SDK builds the session context, loads the default skills, creates built-in tools, resolves model/auth state through `@gajae-code/ai`, constructs the system prompt, and instantiates `@gajae-code/agent-core`. The agent loop streams model events, executes tools, records tool results, and hands state back to the selected interactive TUI, print, or ACP mode while exposing external control through the SDK WebSocket interface.\n\n## Verification and gates\n\nPackage-local checks are defined in each `package.json`. For workflow-definition or default-surface changes, the focused gates are:\n\n```sh\nbun scripts/check-visible-definitions.ts\nbun scripts/verify-g002-gates.ts\nbun scripts/rebrand-inventory.ts --strict\nbun test packages/coding-agent/test/default-gjc-definitions.test.ts\n```\n\nFor broader TypeScript verification, use the root script:\n\n```sh\nbun run check:ts\n```\n\nDo not use `tsc` or `npx tsc` directly in this repository.\n",
|
|
18
18
|
"codegraph-custom-tool.md": "# CodeGraph as a custom tool\n\n[CodeGraph](https://github.com/colbymchenry/codegraph) is a local, language-agnostic\ncode knowledge graph for AI agents. It pre-indexes symbols, call edges, and\ndependencies in a project so an agent can answer structural questions (\"how does X\nwork\", \"who calls X\", \"what breaks if I change X\") in a few graph queries instead\nof crawling files with `search`/`read`.\n\nThis guide shows how to wire CodeGraph into GJC through the **custom-tool extension\npath** — no core changes, no built-in provider. GJC intentionally keeps third-party\nCLI integrations like this in the user/project extension layer rather than bundling\nthem, so you own the integration and its lifecycle.\n\n> CodeGraph integrates with other agents over MCP, but this guide wires it as a GJC\n> custom tool around CodeGraph's local **CLI** — it does not add an MCP server or a\n> built-in provider. For how GJC treats MCP servers in standalone sessions, see\n> [`standalone-mcp.md`](standalone-mcp.md).\n\n## 1. Install and index\n\n```bash\n# Install the CodeGraph CLI (or use the install script from CodeGraph's README).\nnpm i -g @colbymchenry/codegraph\n\n# Build the local index for a project.\ncd your-project\ncodegraph init\n```\n\n`codegraph init` creates a local `.codegraph/` directory. No data leaves your\nmachine — it is a local SQLite index.\n\n## 2. Add the custom tool\n\nGJC discovers custom tools from a `tools/` directory in its config dirs:\n\n- **Project-scoped**: `<project>/.gjc/tools/`\n- **User-scoped (all projects)**: `~/.gjc/agent/tools/`\n\nA `*.ts` tool file's default export is a factory `(pi) => CustomTool`. The factory\nreceives an API (`pi`) with members such as `exec`, `cwd`, `zod`, and `logger` — so\nthe tool needs no imports from GJC internals.\n\nSave the following as `.gjc/tools/codegraph.ts` (project) or\n`~/.gjc/agent/tools/codegraph.ts` (user):\n\n```typescript\n/**\n * CodeGraph custom tool for gajae-code (GJC).\n *\n * Wraps the local CodeGraph CLI (https://github.com/colbymchenry/codegraph) so the\n * agent can query a project's code knowledge graph instead of crawling files.\n *\n * It only runs CodeGraph's query-style (read-only) subcommands and never edits your\n * source files. It does not run indexing or sync commands. (CodeGraph maintains its\n * own local `.codegraph/` index via its own CLI; this tool only reads from it.)\n *\n * Prereqs: `npm i -g @colbymchenry/codegraph` and `codegraph init` in the project.\n */\nimport type { CustomToolFactory } from \"@gajae-code/coding-agent\"; // optional: editor types only\n\nconst CODEGRAPH_CLI = \"codegraph\";\nconst TIMEOUT_MS = 60_000;\nconst SEARCH_LIMIT_DEFAULT = 10;\nconst MAX = 100;\n\nconst codegraph: CustomToolFactory = (pi) => {\n\tconst z = pi.zod;\n\n\tconst parameters = z\n\t\t.object({\n\t\t\top: z\n\t\t\t\t.enum([\"explore\", \"search\", \"callers\", \"callees\", \"impact\", \"status\"])\n\t\t\t\t.describe(\n\t\t\t\t\t\"explore: context (relevant source + call paths) for a natural-language query — prefer for 'how does X work'; search: full-text symbol search (target=query); callers: who calls target; callees: what target calls; impact: blast radius of changing target; status: index health (no target).\",\n\t\t\t\t),\n\t\t\ttarget: z\n\t\t\t\t.string()\n\t\t\t\t.optional()\n\t\t\t\t.describe(\n\t\t\t\t\t\"For explore: a natural-language query or symbol(s). For callers/callees/impact: a symbol name. For search: the query. Omit for status.\",\n\t\t\t\t),\n\t\t\tlimit: z.number().int().min(1).max(MAX).optional().describe(`Max search results (default ${SEARCH_LIMIT_DEFAULT}).`),\n\t\t\tmaxFiles: z.number().int().min(1).max(MAX).optional().describe(\"For explore: cap files whose source is included.\"),\n\t\t})\n\t\t.strict();\n\n\ttype Params = import(\"zod/v4\").infer<typeof parameters>;\n\n\tfunction buildArgs(params: Params): string[] {\n\t\tif (params.op === \"status\") return [\"status\", pi.cwd, \"--json\"];\n\t\tconst target = params.target?.trim();\n\t\tif (!target) throw new Error(`codegraph ${params.op} requires a non-empty \"target\".`);\n\t\tif (params.op === \"search\") {\n\t\t\tconst limit = Math.min(params.limit ?? SEARCH_LIMIT_DEFAULT, MAX);\n\t\t\treturn [\"query\", target, \"--json\", \"--limit\", String(limit), \"--path\", pi.cwd];\n\t\t}\n\t\tif (params.op === \"explore\") {\n\t\t\tconst args = [\"explore\", target, \"--path\", pi.cwd];\n\t\t\tif (params.maxFiles !== undefined) args.push(\"--max-files\", String(params.maxFiles));\n\t\t\treturn args;\n\t\t}\n\t\treturn [params.op, target, \"--json\", \"--path\", pi.cwd];\n\t}\n\n\tfunction ref(r: { name: string; kind: string; filePath: string; startLine: number }): string {\n\t\treturn ` - ${r.name} (${r.kind}) — ${r.filePath}:${r.startLine}`;\n\t}\n\n\tfunction render(params: Params, stdout: string): string {\n\t\tif (params.op === \"explore\") return stdout.trim() || `No exploration results for \"${params.target?.trim() ?? \"\"}\".`;\n\t\tlet data: any;\n\t\ttry {\n\t\t\tdata = JSON.parse(stdout);\n\t\t} catch {\n\t\t\tthrow new Error(`codegraph ${params.op} returned unparseable output.`);\n\t\t}\n\t\tif (params.op === \"search\") {\n\t\t\tconst hits = data as Array<{ node: any }>;\n\t\t\tif (hits.length === 0) return `No symbols matched \"${params.target?.trim() ?? \"\"}\".`;\n\t\t\treturn [\n\t\t\t\t`${hits.length} symbol(s) matching \"${params.target?.trim() ?? \"\"}\":`,\n\t\t\t\t...hits.map(({ node }) => {\n\t\t\t\t\tconst exp = node.isExported ? \" [exported]\" : \"\";\n\t\t\t\t\tconst sig = node.signature ? ` ${node.signature}` : \"\";\n\t\t\t\t\treturn ` - ${node.name} (${node.kind})${sig}${exp} — ${node.filePath}:${node.startLine}`;\n\t\t\t\t}),\n\t\t\t].join(\"\\n\");\n\t\t}\n\t\tif (params.op === \"callers\") {\n\t\t\tconst list = data.callers ?? [];\n\t\t\treturn list.length === 0\n\t\t\t\t? `No callers found for \"${data.symbol}\".`\n\t\t\t\t: [`${list.length} caller(s) of \"${data.symbol}\":`, ...list.map(ref)].join(\"\\n\");\n\t\t}\n\t\tif (params.op === \"callees\") {\n\t\t\tconst list = data.callees ?? [];\n\t\t\treturn list.length === 0\n\t\t\t\t? `\"${data.symbol}\" has no recorded callees.`\n\t\t\t\t: [`${list.length} callee(s) of \"${data.symbol}\":`, ...list.map(ref)].join(\"\\n\");\n\t\t}\n\t\tif (params.op === \"impact\") {\n\t\t\tconst header = `Impact of changing \"${data.symbol}\" (depth ${data.depth}): ${data.nodeCount} node(s), ${data.edgeCount} edge(s) affected.`;\n\t\t\tconst list = data.affected ?? [];\n\t\t\treturn list.length === 0 ? header : [header, \"Affected:\", ...list.map(ref)].join(\"\\n\");\n\t\t}\n\t\t// status\n\t\tif (!data.initialized) return `CodeGraph is not initialized for ${data.projectPath}. Run \\`codegraph init\\`.`;\n\t\tconst lines = [\n\t\t\t`CodeGraph index for ${data.projectPath}:`,\n\t\t\t` files: ${data.fileCount}, nodes: ${data.nodeCount}, edges: ${data.edgeCount}`,\n\t\t];\n\t\tif (data.languages?.length) lines.push(` languages: ${data.languages.join(\", \")}`);\n\t\tconst p = data.pendingChanges;\n\t\tif (p && (p.added || p.modified || p.removed)) lines.push(` pending sync: +${p.added} ~${p.modified} -${p.removed}`);\n\t\treturn lines.join(\"\\n\");\n\t}\n\n\treturn {\n\t\tname: \"codegraph\",\n\t\tlabel: \"CodeGraph\",\n\t\tdescription:\n\t\t\t\"Query the project's CodeGraph code knowledge graph (symbols, callers, callees, impact, and an 'explore' context query) via the local codegraph CLI. Read-only with respect to your source. Prefer over search/read for structural questions. Requires `codegraph init` to have been run in the project.\",\n\t\tparameters,\n\t\tstrict: true,\n\t\tasync execute(_id: string, params: Params, _onUpdate: unknown, _ctx: unknown, signal?: AbortSignal) {\n\t\t\tlet result: { stdout: string; stderr: string; code: number };\n\t\t\ttry {\n\t\t\t\tresult = await pi.exec(CODEGRAPH_CLI, buildArgs(params), { cwd: pi.cwd, signal, timeout: TIMEOUT_MS });\n\t\t\t} catch (e) {\n\t\t\t\tconst msg = e instanceof Error ? e.message : String(e);\n\t\t\t\tif (/not found|enoent/i.test(msg)) {\n\t\t\t\t\tthrow new Error(\"The `codegraph` CLI is not installed. Install it: npm i -g @colbymchenry/codegraph\");\n\t\t\t\t}\n\t\t\t\tthrow e;\n\t\t\t}\n\t\t\tif (result.code !== 0) {\n\t\t\t\tconst err = result.stderr.toLowerCase();\n\t\t\t\tif (err.includes(\"not initialized\") || err.includes(\".codegraph\") || err.includes(\"no index\")) {\n\t\t\t\t\tthrow new Error(\"CodeGraph is not initialized for this project. Run `codegraph init` in the project root.\");\n\t\t\t\t}\n\t\t\t\tthrow new Error(result.stderr.trim() || \"codegraph failed with no diagnostic output.\");\n\t\t\t}\n\t\t\treturn { content: [{ type: \"text\", text: render(params, result.stdout) }] };\n\t\t},\n\t};\n};\n\nexport default codegraph;\n```\n\nThe `import type` line is optional — it only provides editor types when GJC is\nresolvable from your tool file. It is erased at runtime, so the tool loads fine\nwithout it.\n\n## 3. Use it\n\nStart GJC in the project. The `codegraph` tool is now available to the model. Ask\na structural question and it will call the tool, for example:\n\n- `codegraph` with `{ \"op\": \"explore\", \"target\": \"how requests are routed\" }` can\n return relevant source plus graph context in one call.\n- `{ \"op\": \"callers\", \"target\": \"MyClass.handle\" }` lists callers.\n- `{ \"op\": \"impact\", \"target\": \"parseConfig\" }` shows what a change would affect.\n- `{ \"op\": \"status\" }` reports index health.\n\n## Operations\n\n| `op` | `target` | Description |\n| --- | --- | --- |\n| `explore` | natural-language query | Context query: relevant symbols' source plus graph context (CodeGraph's `explore`). `maxFiles` caps included source. |\n| `search` | query | Full-text symbol search (`limit`, default 10). |\n| `callers` | symbol | Functions/methods that call the symbol, including dynamic dispatch. |\n| `callees` | symbol | Functions/methods the symbol calls. |\n| `impact` | symbol | Blast radius of changing the symbol. |\n| `status` | — | Index health: file/node/edge counts, languages, pending sync. |\n\n## Notes\n\n- **Read-only with respect to your code.** The tool only runs CodeGraph's\n query-style subcommands; it never edits your files and does not run indexing or\n sync commands. CodeGraph maintains its own local `.codegraph/` index via its CLI.\n If results look stale or `status` reports pending changes, refresh the index with\n CodeGraph's CLI (e.g. `codegraph sync`) outside GJC.\n- **Safe argument handling.** The example spawns CodeGraph with argv arrays via\n `pi.exec` (no shell), `op` is constrained to a fixed enum, and numeric inputs are\n capped — there is no shell interpolation of model-provided values.\n- **Scope.** Use a project-scoped file to limit the tool to one repo, or a\n user-scoped file to make it available everywhere `codegraph init` has been run.\n- **Naming.** The tool registers as `codegraph`; rename it in the file if it\n collides with another tool in your setup.\n- **Fallback.** If the graph reports a symbol is missing, a file is flagged as\n pending sync, or you need a non-structural text search, refresh the CodeGraph\n index outside GJC or fall back to `read`/`search`.\n- For background on how GJC treats external tools and MCP servers in standalone\n sessions, see [`standalone-mcp.md`](standalone-mcp.md).\n",
|
|
@@ -71,7 +71,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
|
|
|
71
71
|
"resolve-tool-runtime.md": "# Resolve tool runtime internals\n\nThis document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the tool-choice queue and `pushPendingAction`.\n\n## Scope and key files\n\n- [`src/tools/resolve.ts`](../packages/coding-agent/src/tools/resolve.ts)\n- [`src/tools/ast-edit.ts`](../packages/coding-agent/src/tools/ast-edit.ts)\n- [`src/extensibility/custom-tools/types.ts`](../packages/coding-agent/src/extensibility/custom-tools/types.ts)\n- [`src/extensibility/custom-tools/loader.ts`](../packages/coding-agent/src/extensibility/custom-tools/loader.ts)\n- [`src/sdk/session.ts`](../packages/coding-agent/src/sdk/session.ts)\n\n## What `resolve` does\n\n`resolve` is a hidden tool that finalizes a pending preview action.\n\n- `action: \"apply\"` executes the queued action's `apply(reason)` callback and returns that result with resolve metadata.\n- `action: \"discard\"` invokes `reject(reason)` if provided; otherwise returns `Discarded: <label>. Reason: <reason>`.\n\nIf no pending action exists, `resolve` fails with:\n\n- `No pending action to resolve. Nothing to apply or discard.`\n\n## Pending actions use the tool-choice queue\n\nPreview producers call `queueResolveHandler(...)`, which pushes a one-shot forced `resolve` directive onto the session tool-choice queue and adds a `resolve-reminder` steering message.\n\nRuntime behavior:\n\n- the queued handler owns the pending `apply`/`reject` callbacks,\n- `resolve` looks up the current queue invoker with `session.peekQueueInvoker()`,\n- if the model rejects the forced tool choice, the queue directive is requeued,\n- `resolve` does not maintain a separate pending-action stack.\n\nMultiple pending previews therefore follow the active tool-choice queue ordering, not an independent pending-action store.\n\n## Built-in producer example (`ast_edit`)\n\n`ast_edit` previews structural replacements first. When the preview has replacements and is not applied yet, it queues a resolve handler that contains:\n\n- label (human-readable summary)\n- `sourceToolName` (`ast_edit`)\n- `apply(reason: string)` callback that reruns AST edit with `dryRun: false`\n\n`resolve(action=\"apply\", reason=\"...\")` passes `reason` into this callback.\n\n## Custom tools: `pushPendingAction`\n\nCustom tools can register resolve-compatible pending actions through `CustomToolAPI.pushPendingAction(...)`. The custom tool loader forwards these actions to `queueResolveHandler(...)` when that hook is available.\n\n`CustomToolPendingAction`:\n\n- `label: string` (required)\n- `apply(reason: string): Promise<AgentToolResult<unknown>>` (required) — invoked on apply; `reason` is the string passed to `resolve`\n- `reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>` (optional) — invoked on discard; return value replaces the default \"Discarded\" message if provided\n- `details?: unknown` exists on the public custom-tool type but is not currently forwarded by the loader into resolve metadata\n- `sourceToolName?: string` (optional, defaults to `\"custom_tool\"`)\n\n### Minimal usage example\n\n```ts\nimport type { CustomToolFactory } from \"@gajae-code/coding-agent\";\n\nconst factory: CustomToolFactory = (pi) => ({\n name: \"batch_rename_preview\",\n label: \"Batch Rename Preview\",\n description: \"Previews renames and defers commit to resolve\",\n parameters: pi.zod.object({\n files: pi.zod.array(pi.zod.string()),\n }),\n\n async execute(_toolCallId, params) {\n const previewSummary = `Prepared rename plan for ${params.files.length} files`;\n\n pi.pushPendingAction({\n label: `Batch rename: ${params.files.length} files`,\n sourceToolName: \"batch_rename_preview\",\n apply: async (reason) => {\n // apply writes here\n return {\n content: [\n { type: \"text\", text: `Applied batch rename. Reason: ${reason}` },\n ],\n };\n },\n reject: async (reason) => {\n // optional: cleanup or notify on discard\n return {\n content: [\n { type: \"text\", text: `Discarded batch rename. Reason: ${reason}` },\n ],\n };\n },\n });\n\n return {\n content: [\n {\n type: \"text\",\n text: `${previewSummary}. Call resolve to apply or discard.`,\n },\n ],\n };\n },\n});\n\nexport default factory;\n```\n\n## Runtime availability and failures\n\n`pushPendingAction` is wired by the custom tool loader through the active session's resolve queue hook.\n\nIf the runtime did not provide the resolve queue hook, `pushPendingAction` throws:\n\n- `Pending action store unavailable for custom tools in this runtime.`\n\n## Tool-choice behavior\n\nWhen `queueResolveHandler(...)` registers a preview, the agent runtime forces a one-shot `resolve` tool choice so pending previews are explicitly finalized before normal tool flow continues.\n\n## Developer guidance\n\n- Use pending actions only for destructive or high-impact operations that should support explicit apply/discard.\n- Keep `label` concise and specific; it is shown in resolve renderer output.\n- Ensure `apply(reason)` is deterministic and idempotent enough for one-shot execution; `reason` is informational and should not change behavior.\n- Implement `reject(reason)` when the discard needs cleanup (temp state, locks, notifications); omit it for stateless previews where the default message suffices.\n- If your tool can stage multiple previews, remember they are mediated by the tool-choice queue rather than a separate pending-action stack.\n",
|
|
72
72
|
"rulebook-matching-pipeline.md": "# Rulebook Matching Pipeline\n\nThis document describes how coding-agent discovers rules from supported config formats, normalizes them into a single `Rule` shape, resolves precedence conflicts, and splits the result into:\n\n- **Rulebook rules** (available to the model via system prompt + `rule://` URLs)\n- **TTSR rules** (time-travel stream interruption rules)\n\nIt reflects the current implementation, including partial semantics and metadata that is parsed but not enforced.\n\n## Implementation files\n\n- [`../src/capability/rule.ts`](../packages/coding-agent/src/capability/rule.ts)\n- [`../src/capability/index.ts`](../packages/coding-agent/src/capability/index.ts)\n- [`../src/discovery/index.ts`](../packages/coding-agent/src/discovery/index.ts)\n- [`../src/discovery/helpers.ts`](../packages/coding-agent/src/discovery/helpers.ts)\n- [`../src/discovery/builtin.ts`](../packages/coding-agent/src/discovery/builtin.ts)\n- [`../src/discovery/cursor.ts`](../packages/coding-agent/src/discovery/cursor.ts)\n- [`../src/discovery/windsurf.ts`](../packages/coding-agent/src/discovery/windsurf.ts)\n- [`../src/discovery/cline.ts`](../packages/coding-agent/src/discovery/cline.ts)\n- [`../src/sdk/session.ts`](../packages/coding-agent/src/sdk/session.ts)\n- [`../src/system-prompt.ts`](../packages/coding-agent/src/system-prompt.ts)\n- [`../src/internal-urls/rule-protocol.ts`](../packages/coding-agent/src/internal-urls/rule-protocol.ts)\n- [`../src/utils/frontmatter.ts`](../packages/coding-agent/src/utils/frontmatter.ts)\n\n## 1. Canonical rule shape\n\nAll providers normalize source files into `Rule`:\n\n```ts\ninterface Rule {\n name: string;\n path: string;\n content: string;\n globs?: string[];\n alwaysApply?: boolean;\n description?: string;\n condition?: string[];\n scope?: string[];\n interruptMode?: \"never\" | \"prose-only\" | \"tool-only\" | \"always\";\n _source: SourceMeta;\n}\n```\n\nCapability identity is `rule.name` (`ruleCapability.key = rule => rule.name`).\n\nConsequence: precedence and deduplication are **name-based only**. Two different files with the same `name` are considered the same logical rule.\n\n## 2. Discovery sources and normalization\n\n`src/discovery/index.ts` auto-registers providers. For `rules`, current providers are:\n\n- `native` (priority `100`)\n- `cursor` (priority `50`)\n- `windsurf` (priority `50`)\n- `cline` (priority `40`)\n\n### Native provider (`builtin.ts`)\n\nLoads `.gjc` rules from:\n\n- project: `<cwd>/.gjc/rules/*.{md,mdc}`\n- user: `~/.gjc/agent/rules/*.{md,mdc}`\n\nNormalization:\n\n- `name` = filename without `.md`/`.mdc`\n- frontmatter parsed via `parseFrontmatter`\n- `content` = body (frontmatter stripped)\n- `globs`, `alwaysApply`, `description`, `condition`/legacy `ttsr_trigger`, `scope`, and `interruptMode` are parsed by `buildRuleFromMarkdown`\n\nImportant caveat: `condition` values that look like file globs are converted into `tool:edit(...)` / `tool:write(...)` scope shorthands with catch-all condition `.*`.\n\n### Cursor provider (`cursor.ts`)\n\nLoads from:\n\n- user: `~/.cursor/rules/*.{mdc,md}`\n- project: `<cwd>/.cursor/rules/*.{mdc,md}`\n\nNormalization (`transformMDCRule`):\n\n- `description`: kept only if string\n- `alwaysApply`: only `true` is preserved (`false` becomes `undefined`)\n- `globs`: accepts array (string elements only) or single string\n- `condition`/legacy `ttsr_trigger`, `scope`, and `interruptMode` are parsed by shared rule helpers\n- `name` from filename without extension\n\n### Windsurf provider (`windsurf.ts`)\n\nLoads from:\n\n- user: `~/.codeium/windsurf/memories/global_rules.md` (fixed rule name `global_rules`)\n- project: `<cwd>/.windsurf/rules/*.md`\n\nNormalization:\n\n- `globs`: array-of-string or single string\n- `alwaysApply`, `description`, `condition`/legacy `ttsr_trigger`, `scope`, and `interruptMode` parsed by shared rule helpers\n- `name` is fixed to `global_rules` for the user global file and derived from filename for project rules\n\n### Cline provider (`cline.ts`)\n\nSearches upward from `cwd` for nearest `.clinerules`:\n\n- if directory: loads `*.md` inside it\n- if file: loads single file as rule named `clinerules`\n\nNormalization:\n\n- `globs`: array-of-string or single string\n- `alwaysApply`, `description`, `condition`/legacy `ttsr_trigger`, `scope`, and `interruptMode` parsed by shared rule helpers\n- `name` is fixed to `clinerules` for a `.clinerules` file and derived from filename for `.clinerules/*.md`\n\n## 3. Frontmatter parsing behavior and ambiguity\n\nAll providers use `parseFrontmatter` (`utils/frontmatter.ts`) with these semantics:\n\n1. Frontmatter is parsed only when content starts with `---` and has a closing `\\n---`.\n2. Body is trimmed after frontmatter extraction.\n3. If YAML parse fails:\n - warning is logged,\n - parser falls back to simple `key: value` line parsing (`^(\\w+):\\s*(.*)$`).\n\nAmbiguity consequences:\n\n- Fallback parser does not support arrays, nested objects, quoting rules, or hyphenated keys.\n- Fallback values become strings (for example `alwaysApply: true` becomes string `\"true\"`), so providers requiring boolean/string types may drop metadata.\n- `ttsr_trigger` works in fallback (underscore key); keys like `thinking-level` would not.\n- Files without valid frontmatter still load as rules with empty metadata and full content body.\n\n## 4. Provider precedence and deduplication\n\n`loadCapability(\"rules\")` (`capability/index.ts`) merges provider outputs and then deduplicates by `rule.name`.\n\n### Precedence model\n\n- Providers are ordered by priority descending.\n- Equal priority keeps registration order (`cursor` before `windsurf` from `discovery/index.ts`).\n- Dedup is first-wins: first encountered rule name is kept; later same-name items are marked `_shadowed` in `all` and excluded from `items`.\n\nEffective rule provider order is currently:\n\n1. `native` (100)\n2. `cursor` (50)\n3. `windsurf` (50)\n4. `cline` (40)\n\n### Intra-provider ordering caveat\n\nWithin a provider, item order comes from `loadFilesFromDir` glob result ordering plus explicit push order. This is deterministic enough for normal use but not explicitly sorted in code.\n\nNotable source-order differences:\n\n- `native` appends project then user config dirs.\n- `cursor` appends user then project results.\n- `windsurf` appends user `global_rules` first, then project rules.\n- `cline` loads only nearest `.clinerules` source.\n\n## 5. Split into Rulebook, Always-Apply, and TTSR buckets\n\nAfter rule discovery in `createAgentSession` (`sdk.ts`):\n\n1. All discovered rules are scanned.\n2. Rules with `condition` entries are registered into `TtsrManager`; legacy `ttsr_trigger` / `ttsrTrigger` are accepted during rule parsing as condition fallbacks.\n3. A separate `rulebookRules` list is built with this predicate:\n\n```ts\n!isTtsrRule && rule.alwaysApply !== true && !!rule.description;\n```\n\n4. An `alwaysApplyRules` list is built:\n\n```ts\n!isTtsrRule && rule.alwaysApply === true;\n```\n\n### Bucket behavior\n\n- **TTSR bucket**: any rule with a non-empty parsed `condition` that `TtsrManager.addRule(...)` accepts. Takes priority over other buckets.\n- **Always-apply bucket**: `alwaysApply === true`, not TTSR. Full content injected into system prompt. Resolvable via `rule://`.\n- **Rulebook bucket**: must have description, must not be TTSR, must not be `alwaysApply`. Listed in system prompt by name+description; content read on demand via `rule://`.\n- A rule with both `condition` and `alwaysApply` goes to TTSR only (TTSR takes priority).\n- A rule with both `alwaysApply` and `description` goes to always-apply only (not rulebook).\n\n## 6. How metadata affects runtime surfaces\n\n### `description`\n\n- Required for inclusion in rulebook.\n- Rendered in system prompt `<rules>` block.\n- Missing description means rule is not available via `rule://` and not listed in system prompt rules.\n\n### `globs`\n\n- Carried through on `Rule`.\n- Rendered as `<glob>...</glob>` entries in the system prompt rules block.\n- Exposed in rules UI state (`extensions` mode list).\n- **Not enforced for automatic matching in this pipeline.** There is no runtime glob matcher selecting rules by current file/tool target.\n\n### `alwaysApply`\n\n- Parsed and preserved by providers.\n- Used in UI display (`\"always\"` trigger label in extensions state manager).\n- Used as an exclusion condition from `rulebookRules`.\n- **Full rule content is auto-injected into the system prompt** (before the rulebook rules section).\n- Rule is also addressable via `rule://<name>` for re-reading.\n\n### `condition`, `scope`, and `interruptMode`\n\n- `condition` is the current TTSR trigger field; legacy `ttsr_trigger` / `ttsrTrigger` are accepted as fallback inputs during parsing.\n- `scope` narrows TTSR matching scope. A condition token that looks like a file glob becomes `tool:edit(<glob>)` and `tool:write(<glob>)` scope entries plus catch-all condition `.*`.\n- `interruptMode` can override the global TTSR interrupt mode for the rule.\n\n## 7. System prompt inclusion path\n\n`buildSystemPromptInternal` receives both `rules` (rulebook) and `alwaysApplyRules`.\n\nAlways-apply rules are rendered first, injecting their raw content directly into the prompt.\n\nRulebook rules are rendered in a `# Rules` section with:\n\n- `Read rule://<name> when working in matching domain`\n- Each rule's `name`, `description`, and optional `<glob>` list\n\nThis is advisory/contextual: prompt text asks the model to read applicable rules, but code does not enforce glob applicability.\n\n## 8. `rule://` internal URL behavior\n\n`RuleProtocolHandler` is registered with:\n\n```ts\nnew RuleProtocolHandler({\n getRules: () => [...rulebookRules, ...alwaysApplyRules],\n});\n```\n\nImplications:\n\n- `rule://<name>` resolves against both **rulebookRules** and **alwaysApplyRules**.\n- TTSR-only rules and rules with no description and no `alwaysApply` are not addressable via `rule://`.\n- Resolution is exact name match.\n- Unknown names return error listing available rule names.\n- Returned content is raw `rule.content` (frontmatter stripped), content type `text/markdown`.\n\n## 9. Known partial / non-enforced semantics\n\n1. Provider descriptions mention legacy files (`.cursorrules`, `.windsurfrules`), but current loader code paths do not actually read those files.\n2. `globs` metadata is surfaced to prompt/UI but not enforced by rule selection logic.\n3. Rule selection for `rule://` includes rulebook and always-apply rules, but not TTSR-only rules.\n4. Discovery warnings (`loadCapability(\"rules\").warnings`) are produced but `createAgentSession` does not currently surface/log them in this path.\n",
|
|
73
73
|
"sdk-embedding.md": "# SDK\n\nFor the external control and notification wire protocol, 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 cross-language or process-isolated control, use the [SDK WebSocket machine interface](./sdk.md).\n\n## Installation\n\n```bash\nbun add @gajae-code/coding-agent\n```\n\nFor process-isolated TypeScript integrations, install `@gajae-code/bridge-client` and import `SdkClient` from that standalone transport-only package. `@gajae-code/coding-agent/sdk` remains a compatibility re-export with the same `SdkClient` class identity and associated types. Both surfaces use only the v3 SDK transport; no historical BridgeClient backend protocol, handshake/commands/SSE endpoint, or direct host-control path is restored.\n\n## Entry points\n\n`@gajae-code/coding-agent` exports the SDK APIs from the package root (and also via `@gajae-code/coding-agent/sdk`).\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- `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\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\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\", \"grep\", \"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`). Implementation lives in `preconnectModelHost()` in `packages/coding-agent/src/sdk/session.ts`. If `fetch.preconnect` is unavailable (non-Bun runtime) or the call throws, the optimization is silently skipped — 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\", \"grep\", \"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",
|
|
74
|
-
"sdk.md": "# Gajae-Code SDK\n\nFor embedding GJC in-process, see [the embedding SDK guide](./sdk-embedding.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\nA small, transport-agnostic SDK for receiving **action-needed** signals from a\nGJC session and sending **replies** back without scraping the terminal.\n\nThe stable contract is deliberately generic: every top-level running session\nhosts one loopback WebSocket endpoint by default, and integrations are\nuser-written clients that connect to that endpoint. Telegram, Discord, Slack,\nmobile apps, and local tools all use the same JSON protocol. No upstream Rust,\nN-API, or wire-protocol change is required for a new integration.\n\n> Status: the Rust core (`crates/gjc-sdk`) provides the wire protocol, action\n> lifecycle, loopback WebSocket server, and endpoint discovery file. The bundled\n> Telegram daemon is a reference client layered on top of this SDK; it is not the\n> upstream topology.\n\n## TypeScript transport client\n\nInstall the standalone transport-only client when connecting to the v3 SDK WebSocket endpoint from TypeScript:\n\n```bash\nbun add @gajae-code/bridge-client\n```\n\n```ts\nimport { SdkClient } from \"@gajae-code/bridge-client\";\n```\n\n`@gajae-code/coding-agent/sdk` remains a compatibility re-export of this same `SdkClient` class and associated types, so both entry points preserve class identity. The package is a client for the documented v3 transport only: it does not restore the historical BridgeClient backend protocol, handshake/commands/SSE endpoints, or any direct host-control path.\n\n## Architecture\n\n```\nGJC session (upstream) your client (anywhere)\n┌───────────────────────────────┐ ┌──────────────────────────┐\n│ ask-tool fires / agent idle │ action_needed │ Telegram / Discord / ... │\n│ → notifications core │ ─────────────▶ │ render + collect reply │\n│ ws://127.0.0.1:<port> (+token) │ ◀───────────── │ │\n│ reply → resolve ask gate │ reply │ │\n└───────────────────────────────┘ └──────────────────────────┘\n```\n\n- **One endpoint per top-level session.** Each top-level session runs its own\n loopback WebSocket server. Subagents do not host endpoints. Upstream does not\n maintain a shared daemon, singleton, or chat-to-session registry;\n multiplexing many sessions into one integration is a client-side concern.\n- **Hosted by default.** SDK hosting is independent of notification\n configuration. Set `GJC_SDK_DISABLE=1` to opt out of hosting for a top-level\n session.\n- **Notification delivery is optional.** Configure and enable a managed\n notification adapter only when remote delivery is needed; the SDK endpoint\n remains available without one.\n- **Integrations are clients.** A client discovers endpoint files, connects to\n one or more WebSockets, renders `action_needed`, and sends `reply` messages.\n- **Zero upstream change.** New transports do not require changes to\n `crates/gjc-sdk` or the JSON protocol.\n- **tmux-agnostic.** The endpoint behaves identically with or without tmux.\n\n## Endpoint discovery\n\nA running session writes a discovery file at:\n\n```\n<repo>/.gjc/state/sdk/<sessionId>.json\n```\n\n(`.gjc/state/` is git-ignored.) Shape:\n\n```json\n{\n \"version\": 1,\n \"sessionId\": \"019edd41-...\",\n \"pid\": 12345,\n \"host\": \"127.0.0.1\",\n \"port\": 53124,\n \"url\": \"ws://127.0.0.1:53124\",\n \"token\": \"<per-session token>\",\n \"startedAt\": 1718760000000,\n \"updatedAt\": 1718760000000,\n \"stale\": false\n}\n```\n\n- The file is created `0700`/`0600` (unix) and written atomically.\n- The **token is in the file** because clients need it; never log it raw.\n Stale files (dead PID, past TTL, or explicitly marked) are cleaned up on the\n next start.\n\nConnect with the token as a query parameter:\n\n```\nws://127.0.0.1:<port>/?token=<token>\n```\n\nA wrong/missing token is rejected at the handshake with HTTP `401`.\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 because clients need them. Launch errors, logs, and diagnostics redact those tokens and never include the child environment or isolation configuration contents.\n\n## Protocol\n\nJSON text frames. Field names are `camelCase`; the `type` discriminator is\n`snake_case`.\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\"] }\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`; use it only with the current authenticated endpoint. 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)` at the current authenticated endpoint; 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 clients that connect later). Ordinary asks and idle frames omit `workflowGateId`.\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\nThe frames above are the minimal contract every client implements. Threaded\nclients (like the managed Telegram daemon) may also receive optional\nserver → client frames they can render or ignore: `identity_header` (one-time\nper-session repo/branch/machine header), `context_update` (last message, task,\ngoal, token usage, model, diff), `turn_stream` (live/finalized turn output),\n`image_attachment` (agent-produced images), `activity` (busy/idle, drives the\ntyping indicator), `inbound_ack` (delivery state of an injected user message),\n`session_closed` (endpoint teardown; threaded clients may delete/archive the\nremote conversation), `config_update` (current verbosity/redact), `hello`\n(server capability/version), and `pong`. A minimal client only needs\n`action_needed`, `action_resolved`, and `reply_rejected`.\n\n### Client → server\n\n`reply` — answer a pending `ask`:\n\n```json\n{ \"type\": \"reply\", \"id\": \"act_9e31\", \"answer\": 0, \"token\": \"<token>\" }\n```\n\n`answer` accepts:\n\n- a number — zero-based option index (`0` = first option);\n- a string — an option label, or free text;\n- an object — `{ \"selected\": [0, \"Maybe\"], \"custom\": \"...\" }` for multi-select.\n\nOptional `idempotencyKey` makes retries safe: the same key + same body re-acks;\nthe same key + different body is rejected with `idempotency_conflict`.\n\nThreaded clients may also send optional client → server frames: `user_message`\n(inject/steer a turn with free text), `config_command` (toggle verbosity/redact\nin-thread), `hello` (capability/version), and `ping`. A minimal client only\nneeds `reply`.\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\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\nQ12 `gate_id`, not `action_needed.id`. Both accept optional\n`expectedSessionId`; clients should always send the `sessionId` observed from\nthe current authenticated endpoint:\n\n```json\n{ \"type\": \"control_request\", \"operation\": \"workflow.gate_answer\",\n \"input\": { \"id\": \"wg_run_stage_1\", \"response\": \"approve\", \"expectedSessionId\": \"sess-1\" } }\n```\n\n```json\n{ \"type\": \"control_request\", \"operation\": \"workflow.plan_approve\",\n \"input\": { \"id\": \"wg_run_stage_1\", \"choice\": \"approve\", \"expectedSessionId\": \"sess-1\" } }\n```\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>\"` and `tag: \"pending\"`. A restart quarantine diagnostic uses `id: \"diagnostic:<gate_id>\"`, `tag: \"quarantined\"`, and optional `lifecycle` containing `state: \"quarantined\"`, its restart reason, `quarantinedAt`, and an optional `supersededByGateId` after a remint. 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### 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 (currently `0.10.2`). The native loader requires the matching version sentinel; mixed native/runtime versions are unsupported and must not claim SDK compatibility.\n\n## Minimal client example\n\n```js\nimport { readFileSync } from \"node:fs\";\nimport WebSocket from \"ws\";\n\nconst { url, token } = JSON.parse(\n readFileSync(`.gjc/state/sdk/${sessionId}.json`, \"utf8\"),\n);\n\nconst ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);\n\nws.on(\"message\", (data) => {\n const msg = JSON.parse(data.toString());\n if (msg.type === \"action_needed\" && msg.kind === \"ask\") {\n // present msg.question / msg.options to the human, then:\n ws.send(JSON.stringify({ type: \"reply\", id: msg.id, answer: 0, token }));\n } else if (msg.type === \"action_resolved\") {\n // mark this action as no longer answerable in your UI\n } else if (msg.type === \"reply_rejected\") {\n // e.g. reason === \"already_answered\" → the ask was answered elsewhere\n }\n});\n```\n\nSwap `ws` for a Telegram bot's long-poll loop, a Discord gateway client, or a\nSlack socket-mode app — the contract above is all you implement.\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 session event; generic WebSocket clients should use only the protocol frames documented above and any adapter-specific status updates they support.\n\n## Managed notification adapters\n\nGJC ships managed SDK-client adapters for Telegram, Discord, and Slack. They use\none local SDK endpoint per session; the adapters do not change the wire protocol,\nkeep endpoint credentials in provider state, or 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\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 configured providers while masking every token. The\nDiscord and Slack setup commands are non-interactive and require their documented\nidentifier and token flags; supply secrets through an approved local mechanism,\nnot examples, committed files, shell history, logs, or chat.\n\nThe daemon/session engine is shared. Session discovery, WebSocket protocol,\nredaction decisions, rate-limit pooling, reply routing, singleton ownership, and\nlifecycle control are not reimplemented by each chat surface. Telegram, Discord,\nand Slack adapters are thin presentation layers: they render internal notification\nevents into transport payloads and map transport interactions back to `{sessionId,\nactionId,answer}` replies.\n\nDiscord maps a session to an archiveable thread; resume unarchives it or creates\na replacement, and stale/superseded thread input fails closed. Slack maps a\nsession to an immutable root thread; resume creates a new root, acknowledges all\nSocket Mode envelopes immediately, and does not persist a Socket Mode cursor.\n\nThe Discord and Slack acceptance suites use fake providers only. They exercise\nprovider failure, reconciliation, restart, dedupe, lifecycle, and reconnect paths\nwithout live credentials or live-provider end-to-end tests.\n\n## Managed Telegram daemon (bundled reference client)\n\nGJC also ships a managed Telegram reference client for the common phone-notify\nworkflow. It remains a client of the generic SDK: it scans session discovery\nfiles, opens each session WebSocket, and routes Telegram replies back to the\nmatching endpoint. Run `gjc notify setup` once to complete Telegram's interactive\nprivate-chat pairing flow.\n\nFor Telegram forum topics, the daemon deletes the per-session topic when the local\nnotification endpoint shuts down, so it disappears from the topic list. A resumed\nsession creates a fresh topic before sending again. The bot must be allowed to\ndelete messages in that chat; without that permission, deletion is best-effort and\ndelivery continues.\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 per-session Telegram topics (Threaded Mode). The\ndaemon tags messages by session, stores compact callback aliases for inline\nbuttons, and routes replies back to the exact session/action. A forum-enabled\nsupergroup is no longer required: when the bot owner enables Threaded Mode in\n@BotFather, the daemon creates one topic per session in the paired private chat.\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`, `/lean`, `/verbosity <lean|verbose>`, and\n`/redact <on|off>`. The legacy `/answer <session-tag> <answer>` command is\nremoved — replies are routed by the topic 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.\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. When\nredaction is disabled, 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### Manual Telegram CLI is for debugging\n\n`packages/coding-agent/src/sdk/bus/telegram-cli.ts` remains as a manual\nreference/debug client and template for other integrations. It is not the primary\nTelegram UX.\n\n```sh\nbun run packages/coding-agent/src/sdk/bus/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nBy default it refuses to start when a fresh managed daemon already owns the same\nbot token for the same paired chat, because a second poller will cause Telegram\n409 conflicts. Use `--force` only for deliberate debugging when you have stopped\nor intentionally want to override the daemon guard.\n## Two client surfaces: per-session vs daemon-owned lifecycle control\n\nThe SDK now exposes **two distinct surfaces**. Do not confuse them:\n\n1. **Per-session notification clients (the normal, documented contract above).**\n A client discovers `<repo>/.gjc/state/sdk/<sessionId>.json`, connects\n to that session's loopback WebSocket, and handles `action_needed`,\n `action_resolved`, `reply_rejected`, and the optional threaded frames. This is\n all an ordinary integration (Telegram, Discord, Slack, mobile, local tools)\n needs. It requires **zero** upstream changes.\n\n2. **The daemon-owned session *lifecycle* control endpoint (privileged).**\n A separate, **session-independent**, loopback-only, authenticated control\n endpoint that accepts `session_create` / `session_close` / `session_resume`\n frames. It exists because creating a session cannot use a per-session socket\n (none exists before the session does). It is **not** part of the normal\n integration contract: ordinary clients never implement it. Only the bundled,\n trusted daemon (e.g. the managed Telegram daemon) speaks it.\n\n### Lifecycle control endpoint\n\n- **Discovery:** `<agentDir>/notifications/control.json` (daemon-owned, mode\n `0600`), distinct from per-session endpoint files. It carries only non-secret\n endpoint metadata (url/host/port/pid/owner). The control token is held **in\n memory** by the daemon (the sole client) and is **never** written to disk.\n- **Auth and routing:** the loopback SDK broker requires\n `?token=<control-token>` (HTTP `401` otherwise) and re-checks every\n lifecycle frame's `token` (`unauthorized` on mismatch). It routes accepted\n requests through the canonical SDK lifecycle operation.\n- **Frames:** `session_create` (target `existing_path` | `worktree` |\n `plain_dir`), `session_close` (hard-kill, history preserved, recoverable),\n `session_resume` (reattach if alive, else cold-restart from history); responses\n `session_create_response` / `session_close_response` / `session_resume_response`\n / `session_lifecycle_error`. The protocol also defines a replayable\n `session_ready` per-session frame for readiness-gated creates; the current MVP\n daemon replies once the tmux launch is requested (see the phone guide) rather\n than waiting on it. Inline prompt text (`-- <prompt>`) is rejected in the MVP.\n\n### Trust model and hardening (daemon side)\n\nThe control endpoint trusts the configured paired chat for any path (an accepted\nrisk). It is hardened around that boundary:\n\n- **Strict paired-chat gating** — non-paired chats are rejected *before* any path\n parsing, filesystem, or process action.\n- **Durable idempotency** — a locked, atomic, fsynced ledger keyed by\n `chatId:updateId` + request hash (`telegram-lifecycle-idempotency.json`).\n Duplicate updates never repeat side effects, including across daemon restart; a\n duplicate while in-progress reports pending (never a second spawn); a same id\n with a different body is `duplicate_conflict`; an effect failure is recorded\n `terminal_uncertain` (never auto-respawned).\n- **Per-chat create rate limit.**\n- **Audit log** — append-only `telegram-lifecycle-audit.jsonl` (`0600`) recording\n every accept/reject/duplicate/rate-limit/spawn/success/failure. Raw control\n tokens and raw prompts are never logged (prompt hash + byte length only).\n- **Inline prompts rejected (MVP)** — `session_create` with `-- <prompt>` text is\n rejected with usage; no prompt is ever placed in argv, audit, or responses. (A\n redacted prompt-ref flow is reserved for a future revision.)\n- **GJC-managed-only close** — force-close re-reads the exact `@gjc-profile`\n immediately before kill and requires the `@gjc-session-id` (and optional\n `@gjc-session-state-file`) tag to match; it never touches non-GJC tmux.\n- **Recent-activity picker** — sessions are ranked by history-file mtime and\n enriched with terminal breadcrumbs so the operator picks a recent repo/session\n instead of typing raw paths. Ambiguous resumes fail closed with candidates.\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. **Pair + start.** Run `gjc notify setup` (BotFather token, DM the bot to pair).\n Start any GJC session with notifications enabled so the daemon owner is\n running (`gjc launch` in a repo, or `GJC_NOTIFICATIONS=1`). The owner starts\n the loopback control endpoint and accepts `/session_*` while running; with zero\n active sessions it still idle-exits after the inactivity timeout.\n2. **Create.** From your paired chat, pick `/session_create` from the Telegram\n command menu or send `/session_create path <repo-dir>` (or\n `/session_create worktree <repo> <branch>`, or `/session_create dir <newdir>`).\n `<repo-dir>`, `<repo>`, and `<newdir>` may use `~`/`~/...` for your own home\n directory; named-user forms such as `~alice/repo` are rejected. The bot replies\n once the tmux launch is requested; the session shows up in `/session_recent`\n once it is ready. (Inline prompts via `-- <text>` are rejected for now with\n usage text.)\n3. **List.** `/session_recent` shows recent sessions (most-recent first) to copy\n an id from.\n4. **Close.** `/session_close <sessionId>` hard-kills the GJC-managed session\n (history is preserved); the bot confirms.\n5. **Resume.** `/session_resume <sessionId|prefix>` reattaches if it is still\n alive, otherwise cold-restarts it from saved history. An ambiguous prefix\n replies with the matching candidates instead of guessing.\n\nCommands are accepted **only** from the paired chat; **create** is rate-limited,\nand all lifecycle commands are idempotent per Telegram update id and audited (no\ntokens or prompts are logged).\nFor an automated proof of the wire path without a real bot, see\n`packages/coding-agent/scripts/g011-daemon-path-smoke.ts` (real native control\nendpoint + loopback WebSocket).\n",
|
|
74
|
+
"sdk.md": "# Gajae-Code SDK\n\nFor embedding GJC in-process, see [the embedding SDK guide](./sdk-embedding.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\nA small, transport-agnostic SDK for receiving **action-needed** signals from a\nGJC session and sending **replies** back without scraping the terminal.\n\nThe stable contract is deliberately generic: every top-level running session\nhosts one loopback WebSocket endpoint by default, and integrations are\nuser-written clients that connect to that endpoint. Telegram, Discord, Slack,\nmobile apps, and local tools all use the same JSON protocol. No upstream Rust,\nN-API, or wire-protocol change is required for a new integration.\n\n> Status: the Rust core (`crates/gjc-sdk`) provides the wire protocol, action\n> lifecycle, loopback WebSocket server, and endpoint discovery file. The bundled\n> Telegram daemon is a reference client layered on top of this SDK; it is not the\n> upstream topology.\n\n## TypeScript transport client\n\nInstall the standalone transport-only client when connecting to the v3 SDK WebSocket endpoint from TypeScript:\n\n```bash\nbun add @gajae-code/bridge-client\n```\n\n```ts\nimport { SdkClient } from \"@gajae-code/bridge-client\";\n```\n\n`@gajae-code/coding-agent/sdk` remains a compatibility re-export of this same `SdkClient` class and associated types, so both entry points preserve class identity. The package is a client for the documented v3 transport only: it does not restore the historical BridgeClient backend protocol, handshake/commands/SSE endpoints, or any direct host-control path.\n\n## Architecture\n\n```\nGJC session (upstream) your client (anywhere)\n┌───────────────────────────────┐ ┌──────────────────────────┐\n│ ask-tool fires / agent idle │ action_needed │ Telegram / Discord / ... │\n│ → notifications core │ ─────────────▶ │ render + collect reply │\n│ ws://127.0.0.1:<port> (+token) │ ◀───────────── │ │\n│ reply → resolve ask gate │ reply │ │\n└───────────────────────────────┘ └──────────────────────────┘\n```\n\n- **One endpoint per top-level session.** Each top-level session runs its own\n loopback WebSocket server. Subagents do not host endpoints. Upstream does not\n maintain a shared daemon, singleton, or chat-to-session registry;\n multiplexing many sessions into one integration is a client-side concern.\n- **Hosted by default.** SDK hosting is independent of notification\n configuration. Set `GJC_SDK_DISABLE=1` to opt out of hosting for a top-level\n session.\n- **Notification delivery is optional.** Configure and enable a managed\n notification adapter only when remote delivery is needed; the SDK endpoint\n remains available without one.\n- **Integrations are clients.** A client discovers endpoint files, connects to\n one or more WebSockets, renders `action_needed`, and sends `reply` messages.\n- **Zero upstream change.** New transports do not require changes to\n `crates/gjc-sdk` or the JSON protocol.\n- **tmux-agnostic.** The endpoint behaves identically with or without tmux.\n\n## Endpoint discovery\n\nA running session writes a discovery file at:\n\n```\n<repo>/.gjc/state/sdk/<sessionId>.json\n```\n\n(`.gjc/state/` is git-ignored.) Shape:\n\n```json\n{\n \"version\": 1,\n \"sessionId\": \"019edd41-...\",\n \"pid\": 12345,\n \"host\": \"127.0.0.1\",\n \"port\": 53124,\n \"url\": \"ws://127.0.0.1:53124\",\n \"token\": \"<per-session token>\",\n \"startedAt\": 1718760000000,\n \"updatedAt\": 1718760000000,\n \"stale\": false\n}\n```\n\n- The file is created `0700`/`0600` (unix) and written atomically.\n- The **token is in the file** because clients need it; never log it raw.\n Stale files (dead PID, past TTL, or explicitly marked) are cleaned up on the\n next start.\n\nConnect with the token as a query parameter:\n\n```\nws://127.0.0.1:<port>/?token=<token>\n```\n\nA wrong/missing token is rejected at the handshake with HTTP `401`.\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 because clients need them. Launch errors, logs, and diagnostics redact those tokens and never include the child environment or isolation configuration contents.\n\n## Protocol\n\nJSON text frames. Field names are `camelCase`; the `type` discriminator is\n`snake_case`.\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\"] }\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`; use it only with the current authenticated endpoint. 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)` at the current authenticated endpoint; 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 clients that connect later). Ordinary asks and idle frames omit `workflowGateId`.\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\nThe frames above are the minimal contract every client implements. Threaded\nclients (like the managed Telegram daemon) may also receive optional\nserver → client frames they can render or ignore: `identity_header` (one-time\nper-session repo/branch/machine header), `context_update` (last message, task,\ngoal, token usage, model, diff), `turn_stream` (live/finalized turn output),\n`image_attachment` (agent-produced images), `activity` (busy/idle, drives the\ntyping indicator), `inbound_ack` (delivery state of an injected user message),\n`session_closed` (endpoint teardown; threaded clients may delete/archive the\nremote conversation), `config_update` (current verbosity/redact), `hello`\n(server capability/version), and `pong`. A minimal client only needs\n`action_needed`, `action_resolved`, and `reply_rejected`.\n\n### Client → server\n\n`reply` — answer a pending `ask`:\n\n```json\n{ \"type\": \"reply\", \"id\": \"act_9e31\", \"answer\": 0, \"token\": \"<token>\" }\n```\n\n`answer` accepts:\n\n- a number — zero-based option index (`0` = first option);\n- a string — an option label, or free text;\n- an object — `{ \"selected\": [0, \"Maybe\"], \"custom\": \"...\" }` for multi-select.\n\nOptional `idempotencyKey` makes retries safe: the same key + same body re-acks;\nthe same key + different body is rejected with `idempotency_conflict`.\n\nThreaded clients may also send optional client → server frames: `user_message`\n(inject/steer a turn with free text), `config_command` (toggle verbosity/redact\nin-thread), `hello` (capability/version), and `ping`. A minimal client only\nneeds `reply`.\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\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\nQ12 `gate_id`, not `action_needed.id`. Both accept optional\n`expectedSessionId`; clients should always send the `sessionId` observed from\nthe current authenticated endpoint:\n\n```json\n{ \"type\": \"control_request\", \"operation\": \"workflow.gate_answer\",\n \"input\": { \"id\": \"wg_run_stage_1\", \"response\": \"approve\", \"expectedSessionId\": \"sess-1\" } }\n```\n\n```json\n{ \"type\": \"control_request\", \"operation\": \"workflow.plan_approve\",\n \"input\": { \"id\": \"wg_run_stage_1\", \"choice\": \"approve\", \"expectedSessionId\": \"sess-1\" } }\n```\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>\"` and `tag: \"pending\"`. A restart quarantine diagnostic uses `id: \"diagnostic:<gate_id>\"`, `tag: \"quarantined\"`, and optional `lifecycle` containing `state: \"quarantined\"`, its restart reason, `quarantinedAt`, and an optional `supersededByGateId` after a remint. 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### 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 client example\n\n```js\nimport { readFileSync } from \"node:fs\";\nimport WebSocket from \"ws\";\n\nconst { url, token } = JSON.parse(\n readFileSync(`.gjc/state/sdk/${sessionId}.json`, \"utf8\"),\n);\n\nconst ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);\n\nws.on(\"message\", (data) => {\n const msg = JSON.parse(data.toString());\n if (msg.type === \"action_needed\" && msg.kind === \"ask\") {\n // present msg.question / msg.options to the human, then:\n ws.send(JSON.stringify({ type: \"reply\", id: msg.id, answer: 0, token }));\n } else if (msg.type === \"action_resolved\") {\n // mark this action as no longer answerable in your UI\n } else if (msg.type === \"reply_rejected\") {\n // e.g. reason === \"already_answered\" → the ask was answered elsewhere\n }\n});\n```\n\nSwap `ws` for a Telegram bot's long-poll loop, a Discord gateway client, or a\nSlack socket-mode app — the contract above is all you implement.\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 session event; generic WebSocket clients should use only the protocol frames documented above and any adapter-specific status updates they support.\n\n## Managed notification adapters\n\nGJC ships managed SDK-client adapters for Telegram, Discord, and Slack. They use\none local SDK endpoint per session; the adapters do not change the wire protocol,\nkeep endpoint credentials in provider state, or 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\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 configured providers while masking every token. The\nDiscord and Slack setup commands are non-interactive and require their documented\nidentifier and token flags; supply secrets through an approved local mechanism,\nnot examples, committed files, shell history, logs, or chat.\n\nThe daemon/session engine is shared. Session discovery, WebSocket protocol,\nredaction decisions, rate-limit pooling, reply routing, singleton ownership, and\nlifecycle control are not reimplemented by each chat surface. Telegram, Discord,\nand Slack adapters are thin presentation layers: they render internal notification\nevents into transport payloads and map transport interactions back to `{sessionId,\nactionId,answer}` replies.\n\nDiscord maps a session to an archiveable thread; resume unarchives it or creates\na replacement, and stale/superseded thread input fails closed. Slack maps a\nsession to an immutable root thread; resume creates a new root, acknowledges all\nSocket Mode envelopes immediately, and does not persist a Socket Mode cursor.\n\nThe Discord and Slack acceptance suites use fake providers only. They exercise\nprovider failure, reconciliation, restart, dedupe, lifecycle, and reconnect paths\nwithout live credentials or live-provider end-to-end tests.\n\n## Managed Telegram daemon (bundled reference client)\n\nGJC also ships a managed Telegram reference client for the common phone-notify\nworkflow. It remains a client of the generic SDK: it scans session discovery\nfiles, opens each session WebSocket, and routes Telegram replies back to the\nmatching endpoint. Run `gjc notify setup` once to complete Telegram's interactive\nprivate-chat pairing flow.\n\nFor Telegram forum topics, the daemon deletes the per-session topic when the local\nnotification endpoint shuts down, so it disappears from the topic list. A resumed\nsession creates a fresh topic before sending again. The bot must be allowed to\ndelete messages in that chat; without that permission, deletion is best-effort and\ndelivery continues.\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 per-session Telegram topics (Threaded Mode). The\ndaemon tags messages by session, stores compact callback aliases for inline\nbuttons, and routes replies back to the exact session/action. A forum-enabled\nsupergroup is no longer required: when the bot owner enables Threaded Mode in\n@BotFather, the daemon creates one topic per session in the paired private chat.\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`, `/lean`, `/verbosity <lean|verbose>`, and\n`/redact <on|off>`. The legacy `/answer <session-tag> <answer>` command is\nremoved — replies are routed by the topic 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.\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. When\nredaction is disabled, 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### Manual Telegram CLI is for debugging\n\n`packages/coding-agent/src/sdk/bus/telegram-cli.ts` remains as a manual\nreference/debug client and template for other integrations. It is not the primary\nTelegram UX.\n\n```sh\nbun run packages/coding-agent/src/sdk/bus/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nBy default it refuses to start when a fresh managed daemon already owns the same\nbot token for the same paired chat, because a second poller will cause Telegram\n409 conflicts. Use `--force` only for deliberate debugging when you have stopped\nor intentionally want to override the daemon guard.\n## Two client surfaces: per-session vs daemon-owned lifecycle control\n\nThe SDK now exposes **two distinct surfaces**. Do not confuse them:\n\n1. **Per-session notification clients (the normal, documented contract above).**\n A client discovers `<repo>/.gjc/state/sdk/<sessionId>.json`, connects\n to that session's loopback WebSocket, and handles `action_needed`,\n `action_resolved`, `reply_rejected`, and the optional threaded frames. This is\n all an ordinary integration (Telegram, Discord, Slack, mobile, local tools)\n needs. It requires **zero** upstream changes.\n\n2. **The daemon-owned session *lifecycle* control endpoint (privileged).**\n A separate, **session-independent**, loopback-only, authenticated control\n endpoint that accepts `session_create` / `session_close` / `session_resume`\n frames. It exists because creating a session cannot use a per-session socket\n (none exists before the session does). It is **not** part of the normal\n integration contract: ordinary clients never implement it. Only the bundled,\n trusted daemon (e.g. the managed Telegram daemon) speaks it.\n\n### Lifecycle control endpoint\n\n- **Discovery:** `<agentDir>/notifications/control.json` (daemon-owned, mode\n `0600`), distinct from per-session endpoint files. It carries only non-secret\n endpoint metadata (url/host/port/pid/owner). The control token is held **in\n memory** by the daemon (the sole client) and is **never** written to disk.\n- **Auth and routing:** the loopback SDK broker requires\n `?token=<control-token>` (HTTP `401` otherwise) and re-checks every\n lifecycle frame's `token` (`unauthorized` on mismatch). It routes accepted\n requests through the canonical SDK lifecycle operation.\n- **Frames:** `session_create` (target `existing_path` | `worktree` |\n `plain_dir`), `session_close` (hard-kill, history preserved, recoverable),\n `session_resume` (reattach if alive, else cold-restart from history); responses\n `session_create_response` / `session_close_response` / `session_resume_response`\n / `session_lifecycle_error`. The protocol also defines a replayable\n `session_ready` per-session frame for readiness-gated creates; the current MVP\n daemon replies once the tmux launch is requested (see the phone guide) rather\n than waiting on it. Inline prompt text (`-- <prompt>`) is rejected in the MVP.\n\n### Trust model and hardening (daemon side)\n\nThe control endpoint trusts the configured paired chat for any path (an accepted\nrisk). It is hardened around that boundary:\n\n- **Strict paired-chat gating** — non-paired chats are rejected *before* any path\n parsing, filesystem, or process action.\n- **Durable idempotency** — a locked, atomic, fsynced ledger keyed by\n `chatId:updateId` + request hash (`telegram-lifecycle-idempotency.json`).\n Duplicate updates never repeat side effects, including across daemon restart; a\n duplicate while in-progress reports pending (never a second spawn); a same id\n with a different body is `duplicate_conflict`; an effect failure is recorded\n `terminal_uncertain` (never auto-respawned).\n- **Per-chat create rate limit.**\n- **Audit log** — append-only `telegram-lifecycle-audit.jsonl` (`0600`) recording\n every accept/reject/duplicate/rate-limit/spawn/success/failure. Raw control\n tokens and raw prompts are never logged (prompt hash + byte length only).\n- **Inline prompts rejected (MVP)** — `session_create` with `-- <prompt>` text is\n rejected with usage; no prompt is ever placed in argv, audit, or responses. (A\n redacted prompt-ref flow is reserved for a future revision.)\n- **GJC-managed-only close** — force-close re-reads the exact `@gjc-profile`\n immediately before kill and requires the `@gjc-session-id` (and optional\n `@gjc-session-state-file`) tag to match; it never touches non-GJC tmux.\n- **Recent-activity picker** — sessions are ranked by history-file mtime and\n enriched with terminal breadcrumbs so the operator picks a recent repo/session\n instead of typing raw paths. Ambiguous resumes fail closed with candidates.\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. **Pair + start.** Run `gjc notify setup` (BotFather token, DM the bot to pair).\n Start any GJC session with notifications enabled so the daemon owner is\n running (`gjc launch` in a repo, or `GJC_NOTIFICATIONS=1`). The owner starts\n the loopback control endpoint and accepts `/session_*` while running; with zero\n active sessions it still idle-exits after the inactivity timeout.\n2. **Create.** From your paired chat, pick `/session_create` from the Telegram\n command menu or send `/session_create path <repo-dir>` (or\n `/session_create worktree <repo> <branch>`, or `/session_create dir <newdir>`).\n `<repo-dir>`, `<repo>`, and `<newdir>` may use `~`/`~/...` for your own home\n directory; named-user forms such as `~alice/repo` are rejected. The bot replies\n once the tmux launch is requested; the session shows up in `/session_recent`\n once it is ready. (Inline prompts via `-- <text>` are rejected for now with\n usage text.)\n3. **List.** `/session_recent` shows recent sessions (most-recent first) to copy\n an id from.\n4. **Close.** `/session_close <sessionId>` hard-kills the GJC-managed session\n (history is preserved); the bot confirms.\n5. **Resume.** `/session_resume <sessionId|prefix>` reattaches if it is still\n alive, otherwise cold-restarts it from saved history. An ambiguous prefix\n replies with the matching candidates instead of guessing.\n\nCommands are accepted **only** from the paired chat; **create** is rate-limited,\nand all lifecycle commands are idempotent per Telegram update id and audited (no\ntokens or prompts are logged).\nFor an automated proof of the wire path without a real bot, see\n`packages/coding-agent/scripts/g011-daemon-path-smoke.ts` (real native control\nendpoint + loopback WebSocket).\n",
|
|
75
75
|
"secrets.md": "# Secret Obfuscation\n\nPrevents sensitive values (API keys, tokens, passwords) from being sent to LLM providers. When enabled, secrets are replaced with deterministic 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 deterministic placeholders like `#AB12#`.\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 deterministic placeholder `#[A-Z0-9]{4}#` | Yes (deobfuscated in tool args/session context) |\n| `replace` | Replaced with deterministic same-length string | No (one-way) |\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` | Secrets across all projects |\n| Project | `<cwd>/.gjc/secrets.yml` | Project-specific secrets |\n\nProject entries override global entries with matching `content`.\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\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.\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",
|
|
76
76
|
"session-operations-export-share-fork-resume.md": "# Session Operations: export, dump, share, fork, resume/continue\n\nThis document describes operator-visible behavior for session export/share/fork/resume operations as currently implemented.\n\n## Implementation files\n\n- [`../src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.ts)\n- [`../src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)\n- [`../src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)\n- [`../src/export/html/index.ts`](../packages/coding-agent/src/export/html/index.ts)\n- [`../src/export/custom-share.ts`](../packages/coding-agent/src/export/custom-share.ts)\n- [`../src/main.ts`](../packages/coding-agent/src/main.ts)\n\n## Operation matrix\n\n| Operation | Entry path | Session mutation | Session file creation/switch | Output artifact |\n| --------------------------------------- | ------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ---- |\n| `/dump` | Interactive slash command | No | No | Clipboard text |\n| `/export [path]` | Interactive slash command | No | No | HTML file |\n| `--export <session.jsonl> [outputPath]` | CLI startup fast-path | No runtime session mutation | No active session; reads target file | HTML file |\n| `/share` | Interactive slash command | No | No | Temp HTML + share URL/gist |\n| `/fork` | Interactive slash command | Yes (active session identity changes) | Creates new session file and switches current session to it (persistent mode only) | Copies artifact directory to new session namespace when present |\n| `--fork <id | path>` | CLI startup | Yes after session creation | Creates a new session fork from the selected source into current cwd/session dir | None |\n| `/resume` | Interactive slash command | Yes (active in-memory state replaced) | Switches to selected existing session file | None |\n| `--resume` | CLI startup (picker) | Yes after session creation | Opens selected existing session file | None |\n| `--resume <id | path>` | CLI startup | Yes after session creation | Opens existing session; cross-project case can fork into current project | None |\n| `--continue` | CLI startup | Yes after session creation | Opens terminal breadcrumb or most-recent session; creates new one if none exists | None |\n\n## Export and dump\n\n### `/export [outputPath]` (interactive)\n\nFlow:\n\n1. `InputController` routes `/export...` to `CommandController.handleExportCommand`.\n2. The command splits on whitespace and uses only the first argument after `/export` as `outputPath`.\n3. `AgentSession.exportToHtml()` calls `exportSessionToHtml(sessionManager, state, { outputPath, themeName })`.\n4. On success, UI shows path and opens the file in browser.\n\nBehavior details:\n\n- `--copy`, `clipboard`, and `copy` arguments are explicitly rejected with a warning to use `/dump`.\n- Export embeds session header/entries/leaf plus current `systemPrompt` and tool descriptions from agent state.\n- No session entries are appended during export.\n\nCaveat:\n\n- Argument parsing is whitespace-based (`text.split(/\\s+/)`), so quoted paths with spaces are not preserved as a single path by this command path.\n\n### `--export <inputSessionFile> [outputPath]` (CLI)\n\nFlow in `main.ts`:\n\n1. Handled early (before interactive/session startup).\n2. Calls `exportFromFile(inputPath, outputPath?)`.\n3. `SessionManager.open(inputPath)` loads entries, then HTML is generated and written.\n4. Process prints `Exported to: ...` and exits.\n\nBehavior details:\n\n- Missing input file surfaces as `File not found: <path>`.\n- This path does not create an `AgentSession` and does not mutate any running session.\n\n### `/dump` (interactive clipboard export)\n\nFlow:\n\n1. `CommandController.handleDumpCommand()` calls `session.formatSessionAsText()`.\n2. If empty string, reports `No messages to dump yet.`\n3. Otherwise copies to clipboard via native `copyToClipboard`.\n\nDump content includes:\n\n- System prompt\n- Active model/thinking level\n- Tool definitions + parameters\n- User/assistant messages\n- Thinking blocks and tool calls\n- Tool results and execution blocks (except `excludeFromContext` bash/python entries)\n- Custom/hook/file mention/branch summary/compaction summary entries\n\nNo session persistence changes are made by dumping.\n\n## Share\n\n`/share` is interactive-only and always starts by exporting current session to a temp HTML file.\n\n### Phase 1: temp export\n\n- Temp file path: `${os.tmpdir()}/${Snowflake.next()}.html`\n- Uses `session.exportToHtml(tmpFile)`\n- If export fails (notably in-memory sessions), share ends with error.\n\n### Phase 2: custom share handler (if present)\n\n`loadCustomShare()` checks `~/.gjc/agent` for first existing candidate:\n\n- `share.ts`\n- `share.js`\n- `share.mjs`\n\nRequirements:\n\n- Module must default-export a function `(htmlPath) => Promise<CustomShareResult | string | undefined>`.\n\nIf present and valid:\n\n- UI enters `Sharing...` loader state.\n- Handler result interpretation:\n - string => treated as URL, shown and opened\n - object => `url` and/or `message` shown; `url` opened\n - `undefined`/falsy => generic `Session shared`\n- Temp file is removed after completion.\n\nCritical fallback behavior:\n\n- If custom handler exists but loading fails, command errors and returns.\n- If custom handler executes and throws, command errors and returns.\n- In both failure cases, it **does not** fall back to GitHub gist.\n- Gist fallback happens only when no custom share script exists.\n\n### Phase 3: default gist fallback\n\nOnly when no custom share handler is found:\n\n1. Validates `gh auth status`.\n2. Shows `Creating gist...` loader.\n3. Runs `gh gist create --public=false <tmpFile>`.\n4. Parses gist URL, derives gist id, builds preview URL `https://gistpreview.github.io/?<id>`.\n5. Shows both preview and gist URLs; opens preview.\n\nCancellation/abort semantics in share:\n\n- Loader has `onAbort` hook that restores editor UI and reports `Share cancelled`.\n- The underlying `gh gist create` command is not passed an abort signal in this code path; cancellation is UI-level and checked after command returns.\n\n## Fork\n\nInteractive `/fork` creates a new session from the current one and switches the active session identity.\n\n### Preconditions and immediate guards\n\n- If agent is streaming, `/fork` is rejected with warning.\n- UI status/loading indicators are cleared before operation.\n\n### Session-level flow\n\n`AgentSession.fork()`:\n\n1. Emits `session_before_switch` with `reason: \"fork\"` (cancellable).\n2. Flushes pending writes.\n3. Calls `SessionManager.fork()`.\n4. Copies artifacts directory from old session namespace to new namespace (best-effort; non-ENOENT copy failures are logged, not fatal).\n5. Updates `agent.sessionId`.\n6. Emits `session_switch` with `reason: \"fork\"`.\n\n`SessionManager.fork()` behavior:\n\n- Requires persistent mode and existing session file.\n- Creates new session id and new JSONL file path.\n- Rewrites header with:\n - new `id`\n - new timestamp\n - `cwd` unchanged\n - `parentSession` set to previous session id\n- Keeps all non-header entries unchanged in the new file.\n\n### Non-persistent behavior\n\n- In-memory session manager returns `undefined` from `fork()`.\n- `AgentSession.fork()` returns `false`.\n- UI reports `Fork failed (session not persisted or cancelled)`.\n\n### CLI `--fork <id|path>`\n\nStartup `--fork` is resolved before normal session creation:\n\n1. `--fork` is rejected with `--no-session`.\n2. Path-like values (`/`, `\\`, or `.jsonl`) call `SessionManager.forkFrom(path, cwd, sessionDir)`.\n3. Other values resolve like resumable session ids via current scope and then global search when allowed.\n4. The forked file is created in the current cwd/session-dir scope and becomes the active session manager for startup.\n\n## Resume and continue\n\n## Interactive `/resume`\n\nFlow:\n\n1. Opens session selector populated via `SessionManager.list(currentCwd, currentSessionDir)`.\n2. On selection, `SelectorController.handleResumeSession(sessionPath)` calls `session.switchSession(sessionPath)`.\n3. UI clears/rebuilds chat and todos, then reports `Resumed session`.\n\nNotes:\n\n- This picker only lists sessions in the current session directory scope.\n- It does not use global cross-project search.\n\n## CLI `--resume`\n\n### `--resume` (no value)\n\n- `main.ts` lists sessions for current cwd/sessionDir and opens picker.\n- Selected path is opened with `SessionManager.open(selectedPath)` before session creation.\n\n### `--resume <value>`\n\n`createSessionManager()` resolution order:\n\n1. If value looks like path (`/`, `\\`, or `.jsonl`), open directly.\n2. Else treat as id prefix:\n - search current scope (`SessionManager.list(cwd, sessionDir)`)\n - if not found and no explicit `sessionDir`, search global (`SessionManager.listAll()`)\n\nCross-project id match behavior:\n\n- If matched session cwd differs from current cwd, CLI asks:\n - `Session found in different project ... Fork into current directory? [y/N]`\n- On yes: `SessionManager.forkFrom(match.path, cwd, sessionDir)` creates a new local forked file.\n- On no/non-TTY default: command errors.\n\n## CLI `--continue`\n\n`SessionManager.continueRecent(cwd, sessionDir)`:\n\n1. Resolves session dir for current cwd.\n2. Reads terminal-scoped breadcrumb first.\n3. Falls back to most recently modified session file.\n4. Opens found session; if none exists, creates new session.\n\nThis is startup-only behavior; there is no interactive `/continue` slash command.\n\n## How session switching actually mutates runtime state\n\n`AgentSession.switchSession(sessionPath)` does the runtime transition used by resume-like operations:\n\n1. Emit `session_before_switch` with `reason: \"resume\"` and `targetSessionFile` (cancellable).\n2. Disconnect agent event subscription and abort in-flight work.\n3. Clear queued steering/follow-up/next-turn messages.\n4. Flush current session manager writes.\n5. `sessionManager.setSessionFile(sessionPath)` and update `agent.sessionId`.\n6. Build session context from loaded entries.\n7. Emit `session_switch` with `reason: \"resume\"`.\n8. Replace agent messages from context.\n9. Restore model (if available in current registry).\n10. Restore or initialize thinking level.\n11. Reconnect agent event subscription.\n\nNo new session file is created by `switchSession()` itself.\n\n## Event emissions and cancellation points\n\n### Switch/fork lifecycle hooks\n\nFor `newSession`, `fork`, and `switchSession`:\n\n- Before event: `session_before_switch`\n - reasons: `new`, `fork`, `resume`\n - cancellable by returning `{ cancel: true }`\n- After event: `session_switch`\n - same reason set\n - includes `previousSessionFile`\n\n`ExtensionRunner.emit()` returns early on the first cancelling before-event result.\n\n### Custom tool `onSession` behavior\n\nSDK bridges extension session events to custom tool `onSession` callbacks:\n\n- `session_switch` -> `onSession({ reason: \"switch\", previousSessionFile })`\n- `session_branch` -> `reason: \"branch\"`\n- `session_start` -> `reason: \"start\"`\n- `session_tree` -> `reason: \"tree\"`\n- `session_shutdown` -> `reason: \"shutdown\"`\n\nThese callbacks are observational; they do not cancel switch/fork.\n\n### Other cancellation surfaces relevant to this doc\n\n- `/fork` is blocked while streaming (user must wait/abort current response first).\n- `/resume` selector can be cancelled by user closing selector.\n- Cross-project `--resume <id>` can be cancelled by declining fork prompt.\n- `/share` has UI abort path (`Share cancelled`) for gist flow; it does not wire process-kill semantics for `gh gist create` in this code path.\n\n## Non-persistent (in-memory) session behavior\n\nWhen session manager is created with `SessionManager.inMemory()` (`--no-session`):\n\n- Session file path is absent.\n- `/export` and `/share` fail with `Cannot export in-memory session to HTML` (propagated to command error UI).\n- `/fork` fails because `SessionManager.fork()` requires persistence.\n- `/dump` still works because it serializes in-memory agent state.\n- CLI resume/continue semantics are bypassed if `--no-session` is set, because manager creation returns in-memory immediately.\n\n## Known implementation caveats (as of current code)\n\n- `SelectorController.handleResumeSession()` does not check the boolean result from `session.switchSession(...)`; a hook-cancelled switch can still proceed through UI \"Resumed session\" repaint/status path.\n- `/share` custom-share failures do not degrade to default gist fallback; they terminate the command with error.\n- `/export` argument tokenization is simplistic and does not preserve quoted paths with spaces.\n",
|
|
77
77
|
"session-switching-and-recent-listing.md": "# Session switching and recent session listing\n\nThis document describes how coding-agent discovers recent sessions, resolves `--resume` targets, presents session pickers, and switches the active runtime session.\n\nIt focuses on current implementation behavior, including fallback paths and caveats.\n\n## Implementation files\n\n- [`../src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)\n- [`../src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)\n- [`../src/cli/session-picker.ts`](../packages/coding-agent/src/cli/session-picker.ts)\n- [`../src/modes/components/session-selector.ts`](../packages/coding-agent/src/modes/components/session-selector.ts)\n- [`../src/modes/controllers/selector-controller.ts`](../packages/coding-agent/src/modes/controllers/selector-controller.ts)\n- [`../src/main.ts`](../packages/coding-agent/src/main.ts)\n- [`../src/sdk/session.ts`](../packages/coding-agent/src/sdk/session.ts)\n- [`../src/modes/interactive-mode.ts`](../packages/coding-agent/src/modes/interactive-mode.ts)\n- [`../src/modes/utils/ui-helpers.ts`](../packages/coding-agent/src/modes/utils/ui-helpers.ts)\n\n## Recent-session discovery\n\n### Directory scope\n\n`SessionManager` stores sessions under a cwd-scoped directory by default:\n\n- `~/.gjc/agent/sessions/--<cwd-encoded>--/*.jsonl`\n\n`SessionManager.list(cwd, sessionDir?)` reads only that directory unless an explicit `sessionDir` is provided.\n\n### Two listing paths with different payloads\n\nThere are two different listing pipelines:\n\n1. `getRecentSessions(sessionDir, limit)` (welcome/summary view)\n - Reads only a 4KB prefix (`readTextPrefix(..., 4096)`) from each file.\n - Parses header + earliest user text preview.\n - Returns lightweight `RecentSessionInfo` with lazy `name` and `timeAgo` getters.\n - Sorts by file `mtime` descending.\n\n2. `SessionManager.list(...)` / `SessionManager.listAll()` (resume pickers and ID matching)\n - Reads full session files.\n - Builds `SessionInfo` objects (`id`, `cwd`, `title`, `messageCount`, `firstMessage`, `allMessagesText`, timestamps).\n - Drops sessions with zero `message` entries.\n - Sorts by `modified` descending.\n\n### Metadata fallback behavior\n\nFor recent summaries (`RecentSessionInfo`):\n\n- display name preference: `header.title` -> first user prompt -> `header.id` -> filename\n- name is truncated to 40 chars for compact displays\n- control characters/newlines are stripped/sanitized from title-derived names\n\nFor `SessionInfo` list entries:\n\n- `title` is `header.title` or latest compaction `shortSummary`\n- `firstMessage` is first user message text or `\"(no messages)\"`\n\n## `--continue` resolution and terminal breadcrumb preference\n\n`SessionManager.continueRecent(cwd, sessionDir?)` resolves the target in this order:\n\n1. Read terminal-scoped breadcrumb (`~/.gjc/agent/terminal-sessions/<terminal-id>`)\n2. Validate breadcrumb:\n - current terminal can be identified\n - breadcrumb cwd matches current cwd (resolved path compare)\n - referenced file still exists\n3. If breadcrumb is invalid/missing, fall back to newest file by mtime in the session dir (`findMostRecentSession`)\n4. If none found, create a new session\n\nTerminal ID derivation prefers TTY path and falls back to env-based identifiers (`KITTY_WINDOW_ID`, `TMUX_PANE`, `TERM_SESSION_ID`, `WT_SESSION`).\n\nBreadcrumb writes are best-effort and non-fatal.\n\n## Startup-time resume target resolution (`main.ts`)\n\n### `--resume <value>`\n\n`createSessionManager(...)` handles string-valued `--resume` in two modes:\n\n1. Path-like value (contains `/`, `\\\\`, or ends with `.jsonl`)\n - direct `SessionManager.open(sessionArg, parsed.sessionDir)`\n\n2. ID prefix value\n - find match in `SessionManager.list(cwd, sessionDir)` by `id.startsWith(sessionArg)`\n - if no local match and `sessionDir` is not forced, try `SessionManager.listAll()`\n - first match is used (no ambiguity prompt)\n\nCross-project match behavior:\n\n- if matched session cwd differs from current cwd, CLI prompts whether to fork into current project\n- yes -> `SessionManager.forkFrom(...)`\n- no -> throws error (`Session \"...\" is in another project (...)`)\n\nNo match -> throws error (`Session \"...\" not found.`).\n\n### `--resume` (no value)\n\nHandled after initial session-manager construction:\n\n1. list local sessions with `SessionManager.list(cwd, parsed.sessionDir)`\n2. if empty: print `No sessions found` and exit early\n3. open TUI picker (`selectSession`)\n4. if canceled: print `No session selected` and exit early\n5. if selected: `SessionManager.open(selectedPath)`\n\n### `--continue`\n\nUses `SessionManager.continueRecent(...)` directly (breadcrumb-first behavior above).\n\n## Picker-based selection internals\n\n## CLI picker (`src/cli/session-picker.ts`)\n\n`selectSession(sessions)` creates a standalone TUI with `SessionSelectorComponent` and resolves exactly once:\n\n- selection -> resolves selected path\n- cancel (Esc) -> resolves `null`\n- hard exit (Ctrl+C path) -> stops TUI and `process.exit(0)`\n\n## Interactive in-session picker (`SelectorController.showSessionSelector`)\n\nFlow:\n\n1. fetch sessions from current session dir via `SessionManager.list(currentCwd, currentSessionDir)`\n2. mount `SessionSelectorComponent` in editor area using `showSelector(...)`\n3. callbacks:\n - select -> close selector and call `handleResumeSession(sessionPath)`\n - cancel -> restore editor and rerender\n - exit -> `ctx.shutdown()`\n\n## Session selector component behavior\n\n`SessionList` supports:\n\n- arrow/page navigation\n- Enter to select\n- Esc to cancel\n- Ctrl+C to exit\n- fuzzy search across session id/title/cwd/first message/all messages/path\n\nEmpty-list render behavior:\n\n- renders a message instead of crashing\n- Enter on empty does nothing (no callback)\n- Esc/Ctrl+C still work\n\nCaveat: UI text says `Press Tab to view all`, but this component currently has no Tab handler and current wiring only lists current-scope sessions.\n\n## Runtime switch execution (`AgentSession.switchSession`)\n\n`switchSession(sessionPath)` is the core in-process switch path.\n\nLifecycle/state transition:\n\n1. capture `previousSessionFile`\n2. emit `session_before_switch` hook event (`reason: \"resume\"`, cancellable)\n3. if canceled -> return `false` with no switch\n4. disconnect from current agent event stream\n5. abort active generation/tool flow\n6. clear queued steering/follow-up/next-turn message buffers\n7. flush session writer (`sessionManager.flush()`) to persist pending writes\n8. `sessionManager.setSessionFile(sessionPath)`\n - updates session file pointer\n - writes terminal breadcrumb\n - loads entries / migrates / blob-resolves / reindexes\n - if missing/invalid file data: initializes a new session at that path and rewrites header\n9. update `agent.sessionId`\n10. rebuild display context via `buildDisplaySessionContext()`\n11. restore persisted/discovered MCP tool selections and rebuild active tools/system prompt when discovery is enabled\n12. emit `session_switch` hook event (`reason: \"resume\"`, `previousSessionFile`)\n13. replace agent messages with rebuilt context and sync todos\n14. close provider sessions when switching to a different session or when same-session reload changed replay messages\n15. restore default model from `sessionContext.models.default` if available and present in model registry\n16. restore thinking level and service tier:\n - thinking uses persisted `thinking_level_change`, otherwise the configured default clamped to model capability\n - service tier uses persisted `service_tier_change`, otherwise the configured `serviceTier` setting (`\"none\"` becomes unset)\n17. reconnect agent listeners and return `true`\n\n## UI state rebuild after interactive switch\n\n`SelectorController.handleResumeSession` performs UI reset around `switchSession`:\n\n- stop loading animation\n- clear status container\n- clear pending-message UI and pending tool map\n- reset streaming component/message references\n- call `session.switchSession(...)`\n- clear chat container and rerender from session context (`renderInitialMessages`)\n- reload todos from new session artifacts\n- show `Resumed session`\n\nSo visible conversation/todo state is rebuilt from the new session file.\n\n## Startup resume vs in-session switch\n\n### Startup resume (`--continue`, `--resume`, direct open)\n\n- Session file is chosen before `createAgentSession(...)`.\n- `sdk.ts` builds `existingSession = sessionManager.buildSessionContext()`.\n- Agent messages are restored once during session creation.\n- Model/thinking are selected during creation (including restore/fallback logic).\n- Interactive mode then runs `#restoreModeFromSession()` to re-enter persisted mode state (currently plan/plan_paused).\n\n### In-session switch (`/resume`-style selector path)\n\n- Uses `AgentSession.switchSession(...)` on an already-running `AgentSession`.\n- Messages/model/thinking are rebuilt immediately in place.\n- Hook `session_before_switch`/`session_switch` events are emitted.\n- UI chat/todos are refreshed.\n- No dedicated post-switch mode restore call is made in selector flow; mode re-entry behavior is not symmetric with startup `#restoreModeFromSession()`.\n\n## Failure and edge-case behavior\n\n### Cancellation paths\n\n- CLI picker cancel -> returns `null`, caller prints `No session selected`, process exits early.\n- Interactive picker cancel -> editor restored, no session change.\n- Hook cancellation (`session_before_switch`) -> `switchSession()` returns `false`.\n\n### Empty list paths\n\n- CLI `--resume` (no value): empty list prints `No sessions found` and exits.\n- Interactive selector: empty list renders message and remains cancellable.\n\n### Missing/invalid target session file\n\nWhen opening/switching to a specific path (`setSessionFile`):\n\n- ENOENT -> treated as empty -> new session initialized at that exact path and persisted.\n- malformed/invalid header (or effectively unreadable parsed entries) -> treated as empty -> new session initialized and persisted.\n\nThis is recovery behavior, not hard failure.\n\n### Hard failures\n\nSwitch/open can still throw on true I/O failures (permission errors, rewrite failures, etc.), which propagate to callers.\n\n### ID prefix matching caveats\n\n- ID matching uses `startsWith` and takes first match in sorted list.\n- No ambiguity UI if multiple sessions share prefix.\n- `SessionManager.list(...)` excludes sessions with zero messages, so those sessions are not resumable via ID match/list picker.\n",
|