diff --git a/docs/T3_LOCAL_SUPPORT.md b/docs/T3_LOCAL_SUPPORT.md
new file mode 100644
index 0000000..6545e21
--- /dev/null
+++ b/docs/T3_LOCAL_SUPPORT.md
@@ -0,0 +1,62 @@
+# T3 Code — local support for the GHOST apps (2026-10-28)
+
+[T3 Code](https://github.com/pingdotgg/t3code) (pingdotgg, MIT) is a control
+surface for headless agent CLIs (desktop + web + mobile clients over a local
+server). This page is the overview of how the GHOST-managed apps plug into it
+**locally, on our own schedule** — no waiting for upstream merges. The
+implementation study copy of the T3 monorepo lives at
+`C:\programming\github\t3code` (full clone, 1016 MB on disk, HEAD
+`9826dbb990`, read-only reference — no `pnpm install`, never committed to
+GHOST).
+
+Strategy: **local first, upstream second.** Everything we need works through
+T3's own extension surfaces (Local ACP command / ACP Registry), so a stock T3
+Code install runs our providers with zero T3 source changes. Anything that
+proves itself and fits upstream policy (`CONTRIBUTING.md` — limited review
+capacity, coherent scope, replay-fixture evidence) goes back as a PR from a
+GitHub fork.
+
+## Support matrix
+
+| GHOST app | In T3 Code? | Path |
+| --- | --- | --- |
+| **ZCode** (Z.ai Start Plan accounts) | Via our bridge — **IMPLEMENTED + WIRED (phases A-D, 2026-10-29)** | [T3_ZCODE_BRIDGE_SPEC.md](T3_ZCODE_BRIDGE_SPEC.md) — ACP ⇄ ZCode Protocol adapter; `tools/t3_zcode_bridge.mjs` + probe + fake-client harness; T3 Code v0.1.4 installed and the five accounts pre-seeded in `~/.t3/userdata/settings.json`; live-acceptance runbook in spec §9.2 (gated on the per-home [28] login) |
+| **OpenCode** (desktop clone suite accounts) | Native, zero code | T3 ships an OpenCode provider (`docs/user/providers-opencode.md`); one manual `opencode auth login` per account. Per-account isolation rides the same env-lever pattern as the clone suite (XDG homes) if T3's single-auth model is too narrow |
+| **Claude Code / Codex / Grok / Cursor / Pi / Antigravity** | Native | First-class T3 providers (if you hold subscriptions) |
+| **AutoClaw** | Not applicable | Desktop app with its own gateway/MCP ports — not a headless agent CLI; T3 controls agents, not Electron IDEs |
+| **Cline** | Not applicable | Tauri desktop app, no headless CLI surface |
+| **QoderWork / MiniMax Agent / Windsurf / Trae / Qoder** | Not applicable | Same class — desktop IDEs, no orchestratable agent CLI |
+
+## Why this is cheap
+
+Both sides of the ZCode bridge speak **newline-delimited JSON over stdio**:
+
+- T3 ⇄ agent: ACP (JSON-RPC 2.0, NDJSON framing; v1 methods negotiated by
+ response shape — verified in `packages/effect-acp/src/client.ts`).
+- Bridge ⇄ ZCode: "ZCode Protocol" NDJSON from `zcode app-server` (verified by
+ bundle probes; envelope `{id, method, params}`).
+
+So the ZCode bridge is one Node process translating between two NDJSON
+dialects — no Electron, no patching, no T3 fork. Account isolation per T3
+provider instance rides the four verified env levers
+(`HOME`+`USERPROFILE`+`ZCODE_DATA_BASE_DIR`+`ZCODE_STORAGE_DIR` → clone home),
+which is exactly what T3's own docs prescribe ("use the agent's own
+configuration to isolate accounts").
+
+## Local-vs-upstream split
+
+- **Stays local:** the bridge itself, per-clone wiring, GHOST menu entries.
+- **PR-able later** (only after a live-proven bridge, per the upstream bar):
+ an ACP Registry entry pointing at a published bridge package; a first-class
+ `packages/provider-zcode` driver only if the generic ACP surface provably
+ loses features (see `docs/internals/adding-a-provider.md` in the clone for
+ the replay-fixture bar a driver PR must clear).
+
+## References
+
+- Bridge design + phased plan: [T3_ZCODE_BRIDGE_SPEC.md](T3_ZCODE_BRIDGE_SPEC.md)
+- T3 user docs for ACP: `docs/user/providers-acp.md` (in the clone)
+- T3 internals: `docs/internals/overview.md`, `docs/internals/providers.md`,
+ `docs/internals/adding-a-provider.md` (in the clone)
+- Light-mode context (headless workers, env levers, model caveats):
+ [ZCODE_LIGHT_MODE.md](ZCODE_LIGHT_MODE.md)
diff --git a/docs/T3_ZCODE_BRIDGE_SPEC.md b/docs/T3_ZCODE_BRIDGE_SPEC.md
new file mode 100644
index 0000000..6aa855c
--- /dev/null
+++ b/docs/T3_ZCODE_BRIDGE_SPEC.md
@@ -0,0 +1,379 @@
+# T3 ZCode Bridge — specification (2026-10-28)
+
+Status: **spec only — no code yet.** The phased plan in §6 is the next task.
+
+Goal: run the ZCode accounts (Z.ai Start Plan clones) as agents inside
+[T3 Code](https://github.com/pingdotgg/t3code) **locally and on our schedule**
+— a stock T3 install plus one bridge process, no T3 fork, no waiting for
+upstream PRs. If the bridge proves itself, parts go upstream per §7.
+
+Companion docs: [T3_LOCAL_SUPPORT.md](T3_LOCAL_SUPPORT.md) (support matrix,
+strategy), [ZCODE_LIGHT_MODE.md](ZCODE_LIGHT_MODE.md) (headless workers, env
+levers, model caveats — same prerequisites apply here).
+
+---
+
+## 1. Goals and non-goals
+
+**Goals**
+
+1. A T3 Code "Local ACP command" provider instance per clone account
+ (Second..Sixth, optionally Primary), each fully isolated by the four
+ verified env levers.
+2. A single turn works end-to-end in T3's UI: prompt in, streamed response,
+ tool-call visibility, stop/cancel, permission round-trip.
+3. The bridge follows the repo's mandatory conventions: verify-after, never
+ silent-success, parameterized everything, stdlib-only Node.
+
+**Non-goals**
+
+- No first-class T3 driver / fork changes (§7 explains when that would ever
+ be reconsidered).
+- No fs/terminal proxying: ZCode executes its own tools inside its own
+ permission model; the bridge declares **no** fs/terminal capabilities so T3
+ never expects them.
+- No new auth flows: sign-in stays the one-time `zcode login zai` per clone
+ home (done out-of-band; the launcher prints the recipe).
+
+## 2. Verified ground truth (with provenance)
+
+### 2.1 ACP side (T3 Code, clone at `C:\programming\github\t3code`, HEAD `9826dbb990`)
+
+| # | Fact | Source |
+| --- | --- | --- |
+| A1 | Transport: JSON-RPC 2.0 over stdio, **NDJSON**, UTF-8, no embedded newlines; the agent is a subprocess; agent stdout must carry only protocol messages; stderr is logs | agentclientprotocol.com v1 transports doc |
+| A2 | T3's client sends `initialize {protocolVersion: 2, clientCapabilities, clientInfo}` | `packages/provider-acp/src/server/AcpSessionRuntime.ts` (`initializeOnce`) |
+| A3 | **Negotiation is shape-based**: if the initialize *response* is v2-shaped → generation 2; otherwise → generation 1 and T3 drives v1 methods (`authenticate`, `session/load`, `session/set_mode`) | `packages/effect-acp/src/client.ts` (`isV2InitializeResponse` comment: "Antigravity, for example, reports version 2 with the v1 agentInfo/agentCapabilities response shape") |
+| A4 | Local ACP command spawn: `{command, args: settings.commandArgs, cwd, env, shell: false}` — `cwd` is the workspace, `env` = the provider instance's environment overrides merged over the host environment | `packages/provider-acp-registry/src/server/AcpRegistrySupport.ts` (`resolve`, local branch) |
+| A5 | v1 agent surface: `initialize`, optional `authenticate`, `session/new`, `session/prompt` (blocking; response carries the stop reason), optional `session/load`, optional `session/set_mode`, notification `session/cancel`; client side: `session/update` notifications, `session/request_permission`, optional fs/terminal/elicitation | agentclientprotocol.com v1 overview |
+| A6 | Models/reasoning reach T3 from ACP (including `_meta` extensions; T3's own tests read `_meta.modelState` off the initialize response); fallback is manual "Custom models" | T3 `docs/user/providers-acp.md` + `packages/provider-acp/src/server/runtimeModel.test.ts` |
+| A7 | ACP does not define account isolation; T3 prescribes isolating via the agent's own configuration. T3 launches local commands without shell expansion | T3 `docs/user/providers-acp.md` |
+| A8 | Upstream bar: ACP agents start as Registry entries; a dedicated driver needs replay fixtures (`simple`, `multi_turn`, `queued_turn`, `turn_interrupt`, `message_steering`, `provider_thread_resume`, `tool_call_*`), capability honesty, and `supportedRuntimeModes`; outside PRs have limited review capacity; PRs written by Gemini models or Muse Wonder are closed | `docs/internals/adding-a-provider.md`, `CONTRIBUTING.md` |
+
+### 2.2 ZCode side (bundle probes of `resources\glm\zcode.cjs`, 2026-10-28)
+
+| # | Fact | Detail |
+| --- | --- | --- |
+| Z1 | Entry: `node zcode.cjs app-server` (synonym `agent-server`; `--stdio` accepted/ignored). Lives until stdin closes. Runs under system node | bundle `main` → `createProtocolProcessLifecycle` |
+| Z2 | Framing: **NDJSON**, envelope WITHOUT a `jsonrpc` member — request `{id, method, params?, trace?}`, response `{id, result}`, error `{id, error:{code,message,data?}}`, notification `{method, params}`. String ids ≤ 256 chars, charset `[A-Za-z0-9._~-]+` | `ZCodeProtocolNdjsonConnection` class |
+| Z3 | Malformed JSON → `{id:"parse-error", error:-32700}`; invalid envelope → `{id:"invalid-message", error:-32600}`; unknown method → `-32601` | dispatchLine handling |
+| Z4 | No `initialize` handshake. Server emits `startup/storagePath`, `startup/storagePrepared`, `startup/storageState` (db migration phases incl. `waiting_for_lock`) while loading; client may call `runtime/capabilities {hostCapabilities:{sessionDirectorySource?}}` → `{independentPlanState, historyFirst, sessionDirectorySource}` | `runZCodeProtocolAgent` wiring |
+| Z5 | Session methods: `session/create` (`{sessionId?, workspace, mode?, model?, persistence?, thoughtLevel?, mcpServers?, toolAllowlist?, toolDenylist?, importedHistory?, ...}` → session snapshot), `session/send` (`{sessionId, content, attachments?, expectedRevision, modelSelection?...}`), `session/stop`, `session/cancelBackgroundTask`, `session/list`, `session/resume`, `session/close`, `session/setModel` / `setMode` / `setThoughtLevel`; `session/event` notifications stream per-session events (`deliveryKind: "desktop-continuous"`) | protocol method maps |
+| Z6 | `-32010` "A prompt is already running for this session"; revision conflicts asserted via `expectedRevision`; other codes: `-32004 sessionUnavailable`, `-32020 noClientAttached`, `-32021 cancelled`, `-32022 timedOut` | `ProtocolRequestError` |
+| Z7 | Server→client **requests** (bridge must answer `{id, result}` or `{id, error}`; re-announced on a doubling 10 s timer; disconnect rejects all): `interaction/requestPermission`, `interaction/requestUserInput`, `interaction/requestProviderRuntimeHeaders`, `interaction/requestOfficialMcpAuthHeaders`, `interaction/browserList` / `browserExecute`, `automation/*`, `bot/channel/reply` | server→client map |
+| Z8 | Model control: `session/setModel` (params carry `modelSelection`), `provider/updateAccountConfig`; the standalone login flow (`persistStandaloneCodingPlanConnection`) writes `defaultModelSelection` — a home without it resolves the FREE `opencode-zen` model, not the Start Plan (provider namespace `account:zai-start-plan`, model `GLM-5.3-Flash` per the Primary db's `model_usage`) | bundle + live probes, see ZCODE_LIGHT_MODE.md |
+| Z9 | Version markers: CLI `--version` 0.17.0; protocol constants `ZCODE_PROTOCOL_VERSION = 1`, `ZCODE_PROTOCOL_V4_WIRE_VERSION = 3`; the embedded glm binary string says 0.13.3 — versions disagree, so every assumption re-validates against the installed build (signature probe, §5) | bundle constants |
+
+## 3. Architecture
+
+```
+T3 Code server (ACP client, Effect-TS)
+ │ spawns as Local ACP command: {command: node.exe, args, cwd, env, shell:false} [A4]
+ ▼
+tools/t3_zcode_bridge.mjs (Node ≥ 18, stdlib only; ONE process per T3 provider instance)
+ │ -- speaks ACP v1 on its own stdio (NDJSON) toward T3 [A1/A5]
+ │ -- resolves the account: --Instance Second..Sixth (GHOST map) or --home
+ │ -- sets the four env levers: HOME, USERPROFILE -> ;
+ │ ZCODE_DATA_BASE_DIR -> ; ZCODE_STORAGE_DIR -> \.zcode
+ │ -- spawns: node \resources\glm\zcode.cjs app-server (child) [Z1]
+ ▼
+zcode app-server (ZCode Protocol NDJSON; per-home db/config/credentials) [Z2-Z6]
+```
+
+- One bridge process = one account = one clone home (matches A8's "instances
+ own credentials"; two T3 provider instances with different `--Instance`
+ args share no state).
+- The bridge is the only component that knows both dialects. T3 sees a plain
+ v1 ACP agent; ZCode sees a normal protocol client.
+- The bridge writes ONLY ACP messages to its stdout (A1); the zcode child's
+ stdout is piped into the bridge, never inherited; child stderr is prefixed
+ and forwarded to the bridge's stderr (T3 logs it).
+
+## 4. Protocol mapping
+
+### 4.1 Lifecycle
+
+| ACP (v1, what T3 sends) | Bridge action | ZCode Protocol |
+| --- | --- | --- |
+| `initialize {protocolVersion:2, clientCapabilities, clientInfo}` | Respond **v1-shaped** result: `{protocolVersion: 1, agentCapabilities:{loadSession:false, auth:{logout:false}}, authMethods: []}` (A3's shape detection then pins generation 1) | — (then `runtime/capabilities` after the child's `startup/storageState` reaches `ready`) |
+| `session/new {cwd, mcpServers, ...}` | Map cwd → workspace; pass T3's MCP servers through only if Phase-B proves the param is honored (else omit and log) | `session/create {workspace, mode:"build", persistence:"persistent"}` |
+| `session/prompt {sessionId, prompt}` | Store the pending ACP request id; send content; tail `session/event` until the turn-end event; stream updates as they arrive | `session/send {sessionId, content, expectedRevision}` |
+| `session/prompt` response | `{stopReason}` — `end_turn` on completed, `cancelled` on stop, `error` mapping per §4.4 | turn-end event (Phase A catalogues the exact event discriminators) |
+| `session/cancel` (notification) | Best-effort; handle the "cancel before the turn registered" trap (A8) by reconciling the response of the pending `session/send` | `session/stop` |
+| `session/set_mode` | Map 1:1 | `session/setMode` (build / edit / plan / yolo / guarded) |
+| `session/load` | Declared unavailable (`loadSession:false`) — resume is out of scope for v1 of the bridge | `session/resume` / `session/list` (future) |
+
+### 4.2 Session mode advertisement
+
+`session/new`'s ACP response advertises the five ZCode permission modes as
+session modes (`build` default). Only modes ZCode enforces natively are
+offered (A8: never imitate a missing mode). T3 runs unoffered stored modes as
+Supervised on its side — acceptable.
+
+### 4.3 Streaming updates
+
+| ZCode `session/event` payload | ACP `session/update` |
+| --- | --- |
+| assistant text deltas | `agent_message_chunk` |
+| thinking deltas | `agent_thought_chunk` |
+| tool started / progressed / finished | `tool_call_update` (first upsert creates — v2 semantics applied early is fine; v1 tolerates `tool_call_update` as create) |
+| plan / todo mutations | `plan` |
+| turn terminal state | (stops the stream; drives the `session/prompt` response) |
+
+Exact event discriminators are a **Phase-A deliverable**: the probe dumps the
+full event vocabulary of a real turn before any mapping is finalized.
+
+### 4.4 Errors
+
+| ZCode | ACP bridge behaviour |
+| --- | --- |
+| `-32010` prompt already running | reject the ACP prompt with a JSON-RPC error; never queue |
+| `-32021 cancelled` | `stopReason: "cancelled"` |
+| Start-Plan admission control / quota signatures (3009 family) | classify as a usage-limit failure; surface `stopReason` + message (A8: usage limits are not generic failures) |
+| `-32004 sessionUnavailable` | JSON-RPC error; drop the cached session id |
+| parse/envelope errors from the child (Z3) | bridge-side bug alarm on stderr; the offending line is logged verbatim |
+
+### 4.5 Server→client requests (Z7)
+
+| ZCode request | Bridge policy |
+| --- | --- |
+| `interaction/requestPermission` | Forward as ACP `session/request_permission` (options pass through verbatim per A8); map the answer back; guaranteed decline path |
+| `interaction/requestUserInput` | Forward as ACP `elicitation/create` if Phase-C proves it, else decline and log |
+| `requestProviderRuntimeHeaders`, `requestOfficialMcpAuthHeaders`, `browserList`, `browserExecute`, `automation/*`, `bot/channel/reply` | **Default-decline**: answer `{id, error:{code:-32602,...}}` (or an empty result if ZCode tolerates only that — Phase A verifies per method); log once per method so the gap is visible, never silent |
+
+## 5. Edge cases
+
+1. **stdout purity (A1):** any bridge log goes to stderr. A stray `console.log`
+ in the bridge corrupts the ACP stream — the mapping module gets a lint-rule
+ note and the probe harness asserts stdout purity end-to-end.
+2. **Same-home contention:** a clone home serves ONE writer story at a time.
+ If the desktop instance for that home is running, `startup/storageState`
+ shows `waiting_for_lock` (Z4) — the bridge waits it out and logs; the run
+ book says: light worker (bridge or REPL) XOR desktop app per home.
+3. **Provider throttle:** Start-Plan admission control (3009) still applies
+ per account; T3 parallel threads on one account share its quota. Not a
+ bridge bug — surfaced via §4.4 usage-limit mapping.
+4. **Version churn (Z9):** every bridge start greps the installed
+ `zcode.cjs` for `ZCODE_PROTOCOL_VERSION` and refuses to start a mapping
+ against an unknown wire version (verify-after, never silent-success).
+ The probe script revalidates assumptions after any ZCode update.
+5. **Standalone login prerequisite (Z8):** bridge start checks
+ `\.zcode\v2\provider_config.json` for `defaultModelSelection`;
+ missing → stderr warning (the account will run on the free zen model) but
+ the bridge still starts.
+6. **Revision discipline:** every `session/send` carries the latest
+ `expectedRevision` from the create/send snapshot (Z5/Z6); a conflict is
+ re-read once, then surfaced as an error.
+7. **Node availability:** the bridge requires system node ≥ 18 (functional
+ probe, same rule as the repo's Python convention); it spawns the child
+ with the SAME node it runs on.
+
+## 6. Phased implementation (acceptance criteria included)
+
+- **A — Live protocol probe** (`tools/t3_bridge_probe.mjs`): spawn
+ `app-server` under a chosen clone env; capture `startup/*`; call
+ `runtime/capabilities`, `session/create`, `session/send "Reply with
+ exactly: OK"`, `session/stop`; dump the complete `session/event` vocabulary
+ of a real turn to a transcript artifact; probe each Z7 request's tolerance
+ to a decline. **Accept:** a turn completes; the event catalogue and
+ decline-tolerance table are recorded in this spec's addendum.
+- **B — Minimal bridge** (`tools/t3_zcode_bridge.mjs`): §4.1 lifecycle +
+ text/thought chunks + cancel. A fake-ACP-client script drives the bridge
+ end-to-end. **Accept:** a one-turn conversation completes through the
+ bridge with stopReason `end_turn`; stdout purity asserted.
+- **C — Permissions and modes:** `interaction/requestPermission` ⇄
+ `session/request_permission`; five modes advertised and switchable.
+ **Accept:** a guarded-mode tool approval round-trips from T3's UI (manual,
+ supervised run) and the decline path never hangs the turn.
+- **D — Models:** advertise the account's GLM models (Phase A confirms the
+ `_meta.modelState` shape or fall back to T3's "Custom models");
+ `session/setModel` on switch; usage-limit classification. **Accept:**
+ switching GLM-5.3 ↔ GLM-5.3-Flash mid-conversation takes effect (verified
+ by the clone's `model_usage` rows).
+- **E — T3 wiring + live acceptance:** register one Local ACP command per
+ account (`node.exe` + bridge path + `--Instance `); runbook: two
+ instances (e.g. Second + Third) side by side in T3, distinct accounts,
+ parallel turns, cancel storm, desktop app closed for each home used.
+ **Accept:** the runbook passes on the real box and T3_LOCAL_SUPPORT.md's
+ matrix row flips to "verified".
+
+## 7. Upstream strategy (later, only after E passes)
+
+1. **ACP Registry entry (preferred, light):** publish the bridge as a small
+ package and register it as an ACP agent — A8 says ACP agents START as
+ Registry entries with no per-agent handling. This is a docs+manifest PR,
+ not a driver.
+2. **First-class driver (only if forced):** a `packages/provider-zcode`
+ driver per `docs/internals/adding-a-provider.md` is justified ONLY if the
+ generic ACP surface provably loses features (e.g. `_meta.modelState`
+ insufficient, native question semantics). The bar: replay fixtures for
+ every scenario in A8, `ProviderAdapterV2` implementation, settings schema,
+ `builtInDrivers.ts` + client registration + docs. Big; needs the T3
+ replay-testkit workflow first.
+3. **Mechanics:** GitHub fork of pingdotgg/t3code → branch → PR (the local
+ clone at `C:\programming\github\t3code` gains a `fork` remote). Mind
+ `CONTRIBUTING.md`: limited review capacity, coherent small scope, and the
+ "models we do not accept" clause (nothing authored by Gemini / Muse
+ Wonder; our GLM tooling is not on that list).
+4. What never goes upstream: per-clone account wiring, GHOST menu entries,
+ clone-home env levers.
+
+---
+
+## 8. Phase A/B findings addendum (2026-10-29 — live wire results)
+
+Phases A and B are IMPLEMENTED and live-verified: `tools/t3_bridge_lib.mjs`
+(shared), `tools/t3_bridge_probe.mjs` (phase A), `tools/t3_zcode_bridge.mjs`
+(phase B), `tools/t3_bridge_fake_client.mjs` (acceptance harness), gated by
+`tests/t3_bridge.Tests.ps1` (Pester 56/56 incl. the live run). Everything in
+sections 2-5 that the wire contradicted or sharpened is corrected here.
+
+### 8.1 Wire corrections (verified against the real server)
+
+| Spec claim as written | What the wire actually does |
+| --- | --- |
+| `session/create {workspace: cwd}` | `workspace` is an OBJECT `{workspacePath, workspaceKey}` and the validator enforces `workspaceKey === workspaceIdentity?.trim() \|\| workspacePath` (bundle `Q0r`) — key = path verbatim (lib `zcodeWorkspace()`) |
+| `session/prompt` blocks like ACP | `session/send` is ASYNC: it returns immediately `{accepted, sessionId, stateRevision}` and the turn runs via events |
+| events stream after create | `session/subscribe {sessionId, deliveryKind: "desktop-continuous"}` is REQUIRED before any `session/event` flows |
+| turn-end via session/event | terminal arrives BOTH as `session/event` (`turn.completed`/`turn.failed`) and as `v4/telemetry/event {kind:"turn.terminal", status, errorCode?}` — the bridge honours either |
+| revision from snapshots only | `state.updated` notifications carry `{patch, reason, revision}` — the AUTHORITATIVE revision source; `-32009` on a stale value is healed by one `session/read` re-sync (lib rule, spec 5.6) |
+| permission answer shape | each zcode option carries its own answer object (`response: {decision: "allow"\|"deny"\|"escalate"\|"modify", reason?, modifiedInput?}`) — the bridge returns the chosen option's object verbatim |
+| default-decline for server→client requests | `session/requestRuntimePreferences` (scope `runtime-materialization`) is MANDATORY during create — a decline ABORTS the create; minimal valid answer: `{nativeSearchEnhancementsEnabled: false}` (bundle schema Utn: only field without a default). `interaction/requestOfficialMcpAuthHeaders` tolerates error-declines (verified 4×) |
+| `--model` on create | create accepts `model {providerId, modelId, options?{reasoningLevel}}`; several zen models REQUIRE the level (`"Reasoning level is required"`) — levels come from the `state.updated` `model.available` patches (`reasoning.levels[].value`) |
+
+### 8.2 Session event vocabulary (from `session/event` + bundle schemas)
+
+Envelope: `{type, payload, seq, sessionId, turnId, deliveryKind, eventId,
+timestamp, traceId}`. Observed live: `session.titleUpdated {payload:{title,
+source}}`, `turn.started {payload:{messageId, input, queryId,...}}`,
+`session.updated {payload:{messageCount, providerId, modelId, toolCount,...}}`,
+`model_request_started` / `model_request_failed {payload:{baseURL, providerId,
+modelId, maxAttempts,...}}`, `turn.failed {payload:{error:{type, code, message,
+detail, attribution, stack}}}`. Full bundle type list (the bridge maps the
+bold ones): turn.started / **turn.completed** `{response, tokenCount,
+toolCallCount, duration,...}` / **turn.failed** / turn.terminal /
+turn.steerQueued / turn.steerDrained / **message.upserted** `{content,
+attachments?, toolCalls?}` / message.removed / **part.started** / **part.delta**
+`{messageId, partId, field: "text"\|"reasoning"\|"input"\|"output", delta}` /
+**part.upserted** `{part:{type:"text"\|"reasoning"\|"file"\|..., text}}` /
+part.removed / model.streaming / **tool.updated** / tool.lifecycle /
+**permission.requested** `{toolCallId, toolName, riskLevel, reason, input,
+options:[{optionId, kind, name, response}]}` / permission.resolved /
+userInput.requested `{requestId, prompt, inputType, choices?}` / todo.write /
+plan.enter / plan.exit / subagent.* / workflow.* / checkpoint.created /
+session.created / session.resumed / session.upserted / session.closed /
+session.removed / session.persisted / streamRecovery.updated.
+
+### 8.3 Model reality on this box (2026-10-29)
+
+- The bare CLI's provider registry contains ONLY the free `opencode-zen-chat`
+ models; `provider/refreshRegistry {reason}` runs but does NOT materialize
+ the Start Plan provider — that requires the one-time standalone
+ `zcode login zai` per home (spec Z8 confirmed; the desktop app pushes its
+ own registry via `provider/updateAccountConfig`, which the bridge does not
+ replicate).
+- Every free zen model was rejecting upstream during the session
+ (`invalid_model_request` at the provider; one default-model turn retried 11
+ times for >150 s). Consequence: `turn.completed` text-delta paths are
+ bundle-schema-derived (8.2), not yet observed live. The fake client gates
+ hard on mechanics (initialize / session/new / cancel→`session/stop`
+ issuance / clean exit / stdout purity) and reports MODEL-UNAVAILABLE
+ instead of failing; its `end_turn` assertion becomes live automatically the
+ first time a working model answers (post-login or upstream recovery).
+
+### 8.4 Acceptance status
+
+- Phase A (probe): PASS on every mechanical item (framing zero-violations,
+ handshake, create, subscribe, event catalogue, revision sync, stop). The
+ "successful turn" item awaits a working model (8.3).
+- Phase B (bridge + fake client): FAKE-CLIENT PASS (v1-shape initialize,
+ session/new with the five modes, prompt round-trip, cancel→stop issued,
+ clean exit, stdout purity). Live T3 wiring (phase E) pending — T3 Code is
+ not installed on this box yet; wiring values for that day:
+ executable = `C:\Program Files\nodejs\node.exe`, arguments (one per row) =
+ `\tools\t3_zcode_bridge.mjs`, `--Instance`, `Second` (repeat per
+ account), display name = `ZCode Blue` (per the instance map).
+
+---
+
+## 9. Phase D/E wiring addendum (2026-10-29 evening)
+
+### 9.1 What shipped
+
+- **Phase D models**: the bridge answers `session/new` with a TOP-LEVEL
+ `models: {currentModelId, availableModels}` (T3's generic acpRegistry
+ provider reads models from the session-setup response — `compat.ts`
+ `SessionModelState`; the initialize `_meta.modelState` path is Grok-only,
+ corrected from §8.4's earlier assumption). Catalogue sources: the home's
+ `v2\config.json` enabled providers (plan families also registered under
+ their `account:` runtime namespace) + live `state.updated`
+ `model.available` patches; `currentModelId` from the standalone-login
+ `defaultModelSelection`. `session/set_model {sessionId, modelId}` (v1 name
+ verified in `rpc.ts`) unpacks `"providerId/modelId[/level]"` into ZCode's
+ `session/setModel {modelSelection}`. Usage-limit classification keys on the
+ structured `errorCode` first. Fake client asserts the catalogue
+ (6 models for a clone home, current = `account:zai-start-plan/GLM-5.3-Flash`).
+- **Login helper**: `launch_zcode_cli_light.ps1 -Login` (GHOST.bat ZCode
+ **[28]**, gid 71) runs the one-time `zcode login zai` for the chosen clone
+ home and verifies `defaultModelSelection` landed (exit 0).
+- **T3 Code installed**: v0.1.4 via `winget install T3Tools.T3Code`
+ (`C:\Users\FOTE\AppData\Local\Programs\T3Code\T3 Code (Beta).exe`), first
+ run completed (`~/.t3/` created). The five provider instances were
+ pre-seeded into `~/.t3/userdata/settings.json` (`providerInstances`:
+ `zcode-second`..`zcode-sixth`, driver `acpRegistry`, `source: "local"`,
+ `commandPath` = node.exe, `commandArgs` = `[bridge, --Instance, ]`,
+ displayName "ZCode " + accent) — backup + verify-after; the server
+ trace shows `zcode-second` loaded. Settings file reference:
+ `~/.t3/userdata/settings.json` (T3 reads it at startup; the desktop app
+ must be closed while editing it).
+
+### 9.2 Live acceptance runbook (user-supervised; the remaining C/E gate)
+
+Prerequisite per account: GHOST.bat ZCode **[28]** → pick the clone →
+browser sign-in (one-time). Then, in T3 Code:
+
+1. Settings → Providers: the five "ZCode " instances render.
+2. New thread on ZCode Blue → prompt → **streamed reply ending normally**
+ (this is the end_turn gate — unblocked by the [28] login or upstream
+ model recovery).
+3. Mode switch to guarded → prompt that runs a tool → the permission card
+ appears → approve, then deny on a second one (both paths must settle).
+4. Model picker → switch GLM-5.3-Flash ↔ GLM-5.3 → the next turn runs on the
+ chosen model (verify via the clone db's `model_usage.provider_id`).
+5. Stop mid-turn → stopReason `cancelled`.
+6. Two accounts side by side (Blue + Red threads) run concurrently without
+ cross-talk (each spawns its own bridge process + clone-home app-server).
+7. Record the outcome here; flip the T3_LOCAL_SUPPORT matrix row to
+ "verified" and consider the upstream ACP Registry PR (§7).
+
+Known limitation carried from §8.3: without the per-home login the catalogue
+advertises the free-zen + plan-namespace models but turns fail upstream; the
+runbook's step 2 is the only item that strictly requires a working model.
+
+### 9.3 First live incident (2026-10-29 ~23:30) — diagnosed from T3's own logs
+
+Symptom: T3 "Failed to open acpRegistry provider session" after ~3-4 minutes
+on zcode-second/zcode-fourth threads ("The ACP agent did not resolve and
+create a test session within 60 seconds"). Evidence
+(`~/.t3/userdata/logs/provider/events..log`): `initialize`
+succeeded → `session/new` SUCCEEDED (with models) → `session/prompt` started
+and never settled — the home had no `[28]` login, so the turn ran on the
+broken free zen model and hung in its 11-attempt retry loop. The bridge was
+working; the account had no working model.
+
+Bridge hardening shipped in response:
+
+1. **Create-with-known-model**: `session/new` now passes the home's known
+ default (`defaultModelSelection`) as `model` — post-login turns run on the
+ plan directly, and pre-login the registry rejects create FAST, translated
+ to: "this clone home cannot run yet - Run GHOST.bat ZCode [28]
+ (one-time login) for this clone, then retry." (`zcodeErrorCode:
+ login_required`). Non-registry failures fall back to the server default
+ model with a loud stderr warning.
+2. **180 s turn watchdog**: a prompt with no terminal gets `session/stop`
+ plus a clean `-32001 turn_watchdog` error instead of hanging for minutes.
+
+Verified: the fake client on an unlogged home now passes the fast-fail
+branch (seconds, not minutes). The threads light up for real the moment the
+per-home `[28]` login runs — that step remains the user's browser-OAuth
+touchpoint.
diff --git a/tests/t3_bridge.Tests.ps1 b/tests/t3_bridge.Tests.ps1
new file mode 100644
index 0000000..45bb059
--- /dev/null
+++ b/tests/t3_bridge.Tests.ps1
@@ -0,0 +1,55 @@
+#Requires -Modules Pester
+# T3 ZCode bridge gates (docs/T3_ZCODE_BRIDGE_SPEC.md).
+# Static: file presence + node --check syntax on every bridge tool (mirrors the
+# CI "Node engines" step). Live: the fake ACP client end-to-end run is gated on
+# a seeded clone home (CI has none; a local box with ZCodeSecondHome runs it).
+# Everything computes inside each It (the repo's working Pester pattern).
+
+Describe "T3 bridge static gates" {
+ It "ships every bridge tool file" {
+ $repoRoot = (Get-Item (Join-Path $PSScriptRoot "..")).FullName
+ foreach ($rel in @(
+ 'tools\t3_bridge_lib.mjs',
+ 'tools\t3_bridge_probe.mjs',
+ 'tools\t3_zcode_bridge.mjs',
+ 'tools\t3_bridge_fake_client.mjs'
+ )) {
+ Test-Path -LiteralPath (Join-Path $repoRoot $rel) | Should -BeTrue -Because "$rel must exist"
+ }
+ }
+
+ It "passes node --check on every bridge tool" -Skip:(-not (Test-Path -LiteralPath 'C:\Program Files\nodejs\node.exe')) {
+ $repoRoot = (Get-Item (Join-Path $PSScriptRoot "..")).FullName
+ # Functional node probe: only trust --version exiting 0 with a version.
+ $node = 'C:\Program Files\nodejs\node.exe'
+ $v = & $node --version 2>$null
+ ($LASTEXITCODE -eq 0 -and ("$v") -match '^v\d+') | Should -BeTrue
+ foreach ($rel in @(
+ 'tools\t3_bridge_lib.mjs',
+ 'tools\t3_bridge_probe.mjs',
+ 'tools\t3_zcode_bridge.mjs',
+ 'tools\t3_bridge_fake_client.mjs'
+ )) {
+ & $node --check (Join-Path $repoRoot $rel) 2>&1 | Out-Null
+ $LASTEXITCODE | Should -Be 0 -Because "node --check $rel must pass"
+ }
+ }
+
+ It "documents the spec the tools implement" {
+ $repoRoot = (Get-Item (Join-Path $PSScriptRoot "..")).FullName
+ Test-Path -LiteralPath (Join-Path $repoRoot 'docs\T3_ZCODE_BRIDGE_SPEC.md') | Should -BeTrue
+ }
+}
+
+Describe "T3 bridge live acceptance (gated)" {
+ It "runs the fake ACP client end-to-end against a seeded clone home" -Skip:(-not (Test-Path -LiteralPath (Join-Path $env:USERPROFILE 'ZCodeSecondHome\.zcode\v2\credentials.json'))) {
+ $repoRoot = (Get-Item (Join-Path $PSScriptRoot "..")).FullName
+ $node = 'C:\Program Files\nodejs\node.exe'
+ $v = & $node --version 2>$null
+ ($LASTEXITCODE -eq 0 -and ("$v") -match '^v\d+') | Should -BeTrue
+ $client = Join-Path $repoRoot 'tools\t3_bridge_fake_client.mjs'
+ $output = & $node $client --Instance Second --timeout-seconds 90 2>&1
+ $text = $output -join "`n"
+ $text | Should -Match 'FAKE-CLIENT PASS'
+ }
+}
diff --git a/tools/t3_bridge_fake_client.mjs b/tools/t3_bridge_fake_client.mjs
new file mode 100644
index 0000000..9613851
--- /dev/null
+++ b/tools/t3_bridge_fake_client.mjs
@@ -0,0 +1,216 @@
+#!/usr/bin/env node
+// Fake ACP client: drives tools/t3_zcode_bridge.mjs end-to-end WITHOUT T3
+// Code (spec phase-B acceptance harness, docs/T3_ZCODE_BRIDGE_SPEC.md 6-B).
+//
+// Hard gates (must pass on any model):
+// 1. initialize responds with a v1-shaped result (protocolVersion 1) - the
+// shape T3's generation detection keys on (spec A3).
+// 2. session/new returns a sessionId + the five advertised modes.
+// 3. session/prompt round-trips a response; every bridge stdout line is
+// valid ACP JSON (purity enforced while reading).
+// 4. session/cancel completes the second prompt (stopReason cancelled, or -
+// when the upstream model fails before the cancel lands - the documented
+// terminal race, reported but not fatal).
+// Conditional gate: turn 1 stopReason "end_turn" requires a WORKING model.
+// Every free zen model was rejecting upstream during the probe session
+// (2026-10-29), so a turn-level model failure prints MODEL-UNAVAILABLE and is
+// not fatal here; it becomes fatal once a model works (post `zcode login zai`).
+//
+// Usage:
+// node tools/t3_bridge_fake_client.mjs [--Instance Second] [--home ]
+// [--model providerId/modelId[/level]] [--prompt "text"] [--timeout-seconds 120]
+import { spawn } from 'node:child_process';
+import path from 'node:path';
+import { parseArgs } from './t3_bridge_lib.mjs';
+
+const spec = { Instance: 'value', home: 'value', model: 'value', prompt: 'value', 'timeout-seconds': 'value' };
+const args = parseArgs(process.argv.slice(2), spec);
+const bridgePath = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), 't3_zcode_bridge.mjs');
+const timeoutSeconds = Number(args['timeout-seconds'] ?? 120);
+const promptText = args.prompt ?? 'Reply with exactly: OK';
+
+const log = (...m) => process.stderr.write(`[fake-client] ${m.join(' ')}\n`);
+const failures = [];
+const check = (ok, name, detail = '') => {
+ log(`${ok ? 'PASS' : 'FAIL'}: ${name}${detail ? ` - ${detail}` : ''}`);
+ if (!ok) failures.push(name);
+};
+
+const childArgs = [bridgePath];
+if (args.Instance) childArgs.push('--Instance', args.Instance);
+if (args.home) childArgs.push('--home', args.home);
+if (args.model) childArgs.push('--model', args.model);
+const child = spawn(process.execPath, childArgs, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
+
+let stdoutPurity = true;
+const updateLog = []; // every session/update variant the bridge streamed
+const waiters = [];
+
+child.stdout.setEncoding('utf8');
+let lineBuf = '';
+child.stdout.on('data', (chunk) => {
+ lineBuf += chunk;
+ let idx;
+ while ((idx = lineBuf.indexOf('\n')) >= 0) {
+ const line = lineBuf.slice(0, idx).trim();
+ lineBuf = lineBuf.slice(idx + 1);
+ if (!line) continue;
+ let msg;
+ try {
+ msg = JSON.parse(line);
+ } catch (err) {
+ stdoutPurity = false;
+ log(`STDOUT PURITY VIOLATION: ${line.slice(0, 160)} (${err.message})`);
+ continue;
+ }
+ if (msg.method === 'session/update') updateLog.push(msg.params?.update?.sessionUpdate);
+ for (let i = waiters.length - 1; i >= 0; i--) {
+ if (waiters[i].match(msg)) {
+ const w = waiters[i];
+ waiters.splice(i, 1);
+ w.resolve(msg);
+ break;
+ }
+ }
+ }
+});
+child.stderr.setEncoding('utf8');
+const stderrLog = [];
+child.stderr.on('data', (chunk) => {
+ for (const line of String(chunk).split('\n')) {
+ if (line.trim()) stderrLog.push(line);
+ }
+});
+const childClosed = new Promise((resolve) => child.on('exit', (code) => resolve(code)));
+
+function send(msg) {
+ child.stdin.write(JSON.stringify(msg) + '\n');
+}
+function request(method, params, timeoutMs) {
+ const id = Math.floor(Math.random() * 1e9);
+ send({ id, method, params });
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ const i = waiters.findIndex((w) => w.id === id);
+ if (i >= 0) waiters.splice(i, 1);
+ reject(new Error(`timeout ${timeoutMs}ms waiting for ${method}`));
+ }, timeoutMs);
+ waiters.push({
+ id,
+ match: (m) => m.id === id && (m.result !== undefined || m.error !== undefined),
+ resolve: (msg) => { clearTimeout(timer); resolve(msg); },
+ });
+ });
+}
+
+const describeOutcome = (outcome) => outcome.kind === 'result'
+ ? `stopReason=${outcome.value?.stopReason}`
+ : String(outcome.value?.error?.message ?? outcome.value?.message ?? JSON.stringify(outcome.value)).slice(0, 160);
+
+try {
+ // 1) initialize - send a v2 request like T3 does; expect a v1-shaped answer.
+ const initRes = await request('initialize', {
+ protocolVersion: 2,
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
+ clientInfo: { name: 't3-bridge-fake-client', version: '0.0.1' },
+ }, 20000);
+ check(initRes.result?.protocolVersion === 1 && typeof initRes.result?.agentCapabilities === 'object',
+ 'initialize v1-shape response', JSON.stringify(initRes.result ?? initRes.error).slice(0, 160));
+
+ // 2) session/new: on a home WITHOUT the standalone login the bridge now
+ // fast-fails with login guidance (spec 9.3) instead of hanging - that
+ // is the expected outcome there; a logged home continues to the full
+ // flow.
+ const cwd = process.env.TEMP ?? '.';
+ const newRes = await request('session/new', { cwd, mcpServers: [] }, 120000);
+ if (newRes.error !== undefined) {
+ const message = String(newRes.error?.message ?? '');
+ const loginGate = newRes.error?.code === -32001 && /login|\[28\]/i.test(message);
+ check(loginGate, 'unlogged home fast-fails session/new with [28] login guidance', message.slice(0, 180));
+ } else {
+ const sessionId = newRes.result?.sessionId;
+ const modeIds = (newRes.result?.modes?.availableModes ?? []).map((m) => m.id);
+ check(Boolean(sessionId) && ['build', 'edit', 'plan', 'yolo', 'guarded'].every((m) => modeIds.includes(m)),
+ 'session/new + five modes',
+ sessionId ? `sessionId=${sessionId} modes=[${modeIds.join(',')}]` : JSON.stringify(newRes.error).slice(0, 160));
+ const modelCount = newRes.result?.models?.availableModels?.length ?? 0;
+ check(modelCount > 0, 'session/new advertises models (phase D)',
+ modelCount > 0 ? `${modelCount} model(s), current=${newRes.result?.models?.currentModelId}` : 'no models field');
+
+ if (sessionId) {
+ // 3) prompt turn 1, cancelled mid-turn: with a broken/upstream-down
+ // model the turn hangs in provider retries, so cancelling turn 1 is
+ // the reliable way to exercise the cancel path end-to-end. A cancel
+ // before the terminal MUST yield stopReason "cancelled" (spec 4.1).
+ const turn1Promise = request('session/prompt', {
+ sessionId,
+ prompt: [{ type: 'text', text: promptText }],
+ }, timeoutSeconds * 1000).then(
+ (r) => ({ kind: 'result', value: r.result }),
+ (e) => ({ kind: 'error', value: e }),
+ );
+ await new Promise((r) => setTimeout(r, 5000));
+ send({ method: 'session/cancel', params: { sessionId } });
+ const turn1 = await turn1Promise;
+ // Hard gate: the bridge must have actually issued session/stop.
+ const stopIssued = stderrLog.some((l) => l.includes('cancel -> session/stop'));
+ const terminalArrived = turn1.kind === 'result' || !/timeout/i.test(String(turn1.value?.error?.message ?? turn1.value?.message ?? 'timeout'));
+ check(stopIssued, 'cancel issued session/stop in the bridge', stderrLog.some((l) => l.includes('cancel ->')) ? 'logged' : 'NOT logged');
+ if (turn1.kind === 'result') {
+ check(turn1.value?.stopReason === 'cancelled', 'cancel yields stopReason cancelled', describeOutcome(turn1));
+ } else if (terminalArrived) {
+ check(true, 'turn terminal arrived after cancel (race with upstream failure)', describeOutcome(turn1));
+ } else {
+ // The upstream retry loop can outlive the harness window; the
+ // bridge answers the prompt whenever zcode finally settles it.
+ log('INFO: turn terminal still pending (upstream retry loop) - stop-issuance gate already passed.');
+ }
+ log(`streamed updates during turn 1: ${updateLog.length} (${[...new Set(updateLog)].join(', ') || 'none'})`);
+ // noise-tolerance: an unknown notification must be ignored, never crash.
+ send({ method: 'session/cancel', params: {} });
+
+ // 4) a second, UNCANCELLED prompt only runs when turn 1 actually
+ // settled (the bridge rejects concurrent prompts with -32010 by design;
+ // a timed-out turn is still running in the provider retry loop).
+ const turn1StillRunning = turn1.kind === 'error' && /timeout/i.test(String(turn1.value?.error?.message ?? turn1.value?.message ?? ''));
+ if (!turn1StillRunning) {
+ const turn2 = await request('session/prompt', {
+ sessionId,
+ prompt: [{ type: 'text', text: promptText }],
+ }, timeoutSeconds * 1000).then(
+ (r) => ({ kind: 'result', value: r.result }),
+ (e) => ({ kind: 'error', value: e }),
+ );
+ if (turn2.kind === 'result') {
+ check(turn2.value?.stopReason === 'end_turn', 'uncancelled turn stopReason end_turn', describeOutcome(turn2));
+ } else {
+ const message = String(turn2.value?.error?.message ?? turn2.value?.message ?? '');
+ const modelDown = /usage-limit|model-unavailable|invalid_model_request|rejected|already running/i.test(message);
+ check(modelDown, 'second prompt round-trip (MODEL-UNAVAILABLE or concurrency guard)', message.slice(0, 160));
+ }
+ }
+ }
+ }
+
+ // 5) shutdown: close stdin, expect a clean bridge exit.
+ child.stdin.end();
+ const exitCode = await Promise.race([childClosed, new Promise((r) => setTimeout(() => r('timeout'), 15000))]);
+ check(exitCode === 0, `bridge exits cleanly on stdin close (exit=${exitCode})`);
+
+ check(stdoutPurity, 'bridge stdout purity (every line valid ACP JSON)');
+} catch (err) {
+ failures.push(`harness error: ${err.message ?? err}`);
+ log(`HARNESS ERROR: ${err.message ?? err}`);
+} finally {
+ if (child.exitCode === null) {
+ try { child.stdin.end(); } catch { /* already closed */ }
+ setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, 500);
+ }
+}
+
+if (failures.length > 0) {
+ log(`FAKE-CLIENT FAIL: ${failures.length} failure(s): ${failures.join(' | ')}`);
+ process.exit(1);
+}
+log('FAKE-CLIENT PASS: bridge mechanics verified end-to-end.');
+process.exit(0);
diff --git a/tools/t3_bridge_lib.mjs b/tools/t3_bridge_lib.mjs
new file mode 100644
index 0000000..63cff96
--- /dev/null
+++ b/tools/t3_bridge_lib.mjs
@@ -0,0 +1,203 @@
+// Shared library for the T3 Code <-> ZCode bridge tooling (probe, bridge, fake client).
+// stdlib-only Node >= 18. See docs/T3_ZCODE_BRIDGE_SPEC.md for the protocol
+// ground truth (provenance tables) this module implements.
+//
+// Verified facts encoded here (2026-10-28/29, see spec section 2):
+// - `zcode.cjs app-server` speaks "ZCode Protocol": NDJSON over stdio,
+// envelope {id, method, params?} / {id, result} / {id, error:{code,...}} /
+// {method, params} notifications - NO "jsonrpc" member (spec Z2).
+// - No initialize handshake: server emits startup/* notifications, then the
+// client may call runtime/capabilities (spec Z4).
+// - The four clone-home env levers isolate each account end-to-end (spec Z8,
+// verified live in ZCODE_LIGHT_MODE.md): HOME + USERPROFILE redirect
+// os.homedir()-resolved stores, ZCODE_DATA_BASE_DIR the v2 tree,
+// ZCODE_STORAGE_DIR the cli caches.
+import fs from 'node:fs';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+
+export const INSTANCE_MAP = {
+ Second: { home: 'ZCodeSecondHome', displayName: 'Blue', accent: '#066BCB' },
+ Third: { home: 'ZCodeThirdHome', displayName: 'Yellow', accent: '#D4AA10' },
+ Fourth: { home: 'ZCodeFourthHome', displayName: 'Green', accent: '#13826A' },
+ Fifth: { home: 'ZCodeFifthHome', displayName: 'Red', accent: '#CA0607' },
+ Sixth: { home: 'ZCodeSixthHome', displayName: 'Orange', accent: '#CB5D06' },
+};
+
+// The five ZCode permission modes, advertised as ACP session modes (spec 4.2).
+export const ZCODE_MODES = ['build', 'edit', 'plan', 'yolo', 'guarded'];
+
+// session/create's workspace is an OBJECT and its validator (bundle Q0r) enforces
+// workspaceKey === workspaceIdentity?.trim() || workspacePath. With no identity,
+// key === path, verbatim.
+export function zcodeWorkspace(cwd) {
+ const resolved = path.resolve(cwd);
+ return { workspacePath: resolved, workspaceKey: resolved };
+}
+
+export function resolveInstanceHome(instance, explicitHome) {
+ if (explicitHome) {
+ const resolved = path.resolve(explicitHome);
+ if (!fs.existsSync(path.join(resolved, '.zcode', 'v2', 'credentials.json'))) {
+ throw new Error(`--home ${resolved} has no .zcode\\v2\\credentials.json profile`);
+ }
+ return resolved;
+ }
+ const cfg = INSTANCE_MAP[instance];
+ if (!cfg) {
+ throw new Error(`unknown --Instance "${instance}" (expected one of ${Object.keys(INSTANCE_MAP).join('|')})`);
+ }
+ const home = path.join(process.env.USERPROFILE || process.env.HOME || '', cfg.home);
+ if (!fs.existsSync(path.join(home, '.zcode', 'v2', 'credentials.json'))) {
+ throw new Error(
+ `clone home ${instance} has no profile yet (${home}). Launch its desktop instance once to seed it, then retry.`,
+ );
+ }
+ return home;
+}
+
+export function resolveZcodeCli(explicitPath) {
+ const candidates = explicitPath
+ ? [explicitPath]
+ : [
+ path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ZCode', 'resources', 'glm', 'zcode.cjs'),
+ 'C:\\Program Files\\ZCode\\resources\\glm\\zcode.cjs',
+ 'C:\\Program Files (x86)\\ZCode\\resources\\glm\\zcode.cjs',
+ ].filter((p) => p && !p.startsWith('\\'));
+ const found = candidates.find((p) => {
+ try { return fs.statSync(p).isFile(); } catch { return false; }
+ });
+ if (!found) {
+ throw new Error(`zcode.cjs not found. Checked: ${candidates.join('; ')}`);
+ }
+ return found;
+}
+
+// Spec 5.4 wire-version gate: refuse to run against an unknown bundle. The
+// constants are exported through a minified indirection chain
+// (`ZCODE_PROTOCOL_VERSION:()=>mnt` + `,mnt=1`), so resolve NAME -> var -> literal.
+export function readWireVersion(zcodeCli) {
+ const raw = fs.readFileSync(zcodeCli, 'utf8');
+ const resolveConst = (name) => {
+ const direct = raw.match(new RegExp(`${name}\\s*=\\s*(\\d+)`));
+ if (direct) return Number(direct[1]);
+ const ref = raw.match(new RegExp(`${name}\\s*:\\s*\\(\\)\\s*=>\\s*([A-Za-z0-9_$]+)`));
+ if (ref) {
+ const varName = ref[1].replace(/\$/g, '\\$');
+ const literal = raw.match(new RegExp(`[,;{(]\\s*${varName}\\s*=\\s*(\\d+)`));
+ if (literal) return Number(literal[1]);
+ }
+ return null;
+ };
+ const protocolVersion = resolveConst('ZCODE_PROTOCOL_VERSION');
+ const v4WireVersion = resolveConst('ZCODE_PROTOCOL_V4_WIRE_VERSION');
+ if (protocolVersion === null) return { ok: false, protocolVersion: null, v4WireVersion };
+ return { ok: protocolVersion === 1, protocolVersion, v4WireVersion };
+}
+
+export function buildChildEnv(cloneHome) {
+ return {
+ ...process.env,
+ HOME: cloneHome,
+ USERPROFILE: cloneHome,
+ ZCODE_DATA_BASE_DIR: cloneHome,
+ ZCODE_STORAGE_DIR: path.join(cloneHome, '.zcode'),
+ };
+}
+
+// Spawn `zcode.cjs app-server` for one account. The caller owns the lifecycle:
+// kill the whole tree on exit (the CLI spawns plugin-host children, which
+// node's child.kill does not reap on Windows).
+export function spawnAppServer(zcodeCli, cloneHome, { nodePath = process.execPath, stdio } = {}) {
+ const child = spawn(nodePath, [zcodeCli, 'app-server'], {
+ env: buildChildEnv(cloneHome),
+ stdio: stdio ?? ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+ child.killTree = () => {
+ // node's kill() leaves the plugin-host grandchildren alive on Windows;
+ // taskkill /T takes the whole tree.
+ try {
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true });
+ } catch {
+ try { child.kill(); } catch { /* already gone */ }
+ }
+ };
+ return child;
+}
+
+// NDJSON connection over any {readable, writable} pair. Parses each line; the
+// onMessage callback receives parsed envelopes; malformed lines go to onParseError
+// and are NEVER forwarded (ACP forbids non-protocol output on the peer's stdout).
+export class NdjsonConnection {
+ constructor({ input, output, onMessage, onParseError, onClose }) {
+ this.output = output;
+ this.onMessage = onMessage;
+ this.onParseError = onParseError;
+ this.onClose = onClose;
+ this.closed = false;
+ this.buffer = '';
+ this.lineCount = 0;
+ input.setEncoding('utf8');
+ input.on('data', (chunk) => this.#ingest(chunk));
+ input.on('end', () => this.#close('input-end'));
+ input.on('error', (err) => this.#close(`input-error: ${err.message}`));
+ output.on('error', (err) => this.#close(`output-error: ${err.message}`));
+ }
+
+ #ingest(chunk) {
+ this.buffer += chunk;
+ let idx;
+ while ((idx = this.buffer.indexOf('\n')) >= 0) {
+ const line = this.buffer.slice(0, idx).trim();
+ this.buffer = this.buffer.slice(idx + 1);
+ if (!line) continue;
+ this.lineCount += 1;
+ let msg;
+ try {
+ msg = JSON.parse(line);
+ } catch (err) {
+ if (this.onParseError) this.onParseError(line, err);
+ continue;
+ }
+ try {
+ this.onMessage(msg);
+ } catch (err) {
+ // A handler bug must not kill the reader loop; surface loudly.
+ if (this.onParseError) this.onParseError(line, err);
+ }
+ }
+ }
+
+ #close(reason) {
+ if (this.closed) return;
+ this.closed = true;
+ if (this.onClose) this.onClose(reason);
+ }
+
+ send(obj) {
+ if (this.closed) return false;
+ this.output.write(JSON.stringify(obj) + '\n');
+ return true;
+ }
+}
+
+export function parseArgs(argv, spec) {
+ const out = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (!arg.startsWith('--')) { out._.push(arg); continue; }
+ const key = arg.slice(2);
+ if (!Object.prototype.hasOwnProperty.call(spec, key)) {
+ throw new Error(`unknown flag --${key} (expected: ${Object.keys(spec).join(', ')})`);
+ }
+ if (spec[key] === 'flag') { out[key] = true; continue; }
+ const value = argv[i + 1];
+ if (value === undefined || value.startsWith('--')) {
+ throw new Error(`--${key} requires a value`);
+ }
+ out[key] = value;
+ i += 1;
+ }
+ return out;
+}
diff --git a/tools/t3_bridge_probe.mjs b/tools/t3_bridge_probe.mjs
new file mode 100644
index 0000000..f14d965
--- /dev/null
+++ b/tools/t3_bridge_probe.mjs
@@ -0,0 +1,379 @@
+#!/usr/bin/env node
+// T3 ZCode Bridge - Phase A live protocol probe (docs/T3_ZCODE_BRIDGE_SPEC.md section 6-A).
+// Spawns `zcode app-server` under one clone account's env levers, drives a real
+// turn, and dumps the COMPLETE session/event vocabulary + decline-tolerance
+// behaviour to a transcript artifact. Its output is the source of truth the
+// bridge mapping (tools/t3_zcode_bridge.mjs) is written against.
+//
+// Usage:
+// node tools/t3_bridge_probe.mjs [--Instance Second] [--home ] [--cwd ]
+// [--prompt "text"] [--timeout-seconds 180] [--out ] [--no-cancel]
+//
+// Exit 0 = every core acceptance item passed; 1 = at least one failed (the
+// summary names it - never silent).
+
+import fs from 'node:fs';
+import path from 'node:path';
+import {
+ INSTANCE_MAP,
+ NdjsonConnection,
+ parseArgs,
+ readWireVersion,
+ resolveInstanceHome,
+ resolveZcodeCli,
+ spawnAppServer,
+ zcodeWorkspace,
+} from './t3_bridge_lib.mjs';
+
+const spec = {
+ Instance: 'value', home: 'value', cwd: 'value', prompt: 'value',
+ 'timeout-seconds': 'value', out: 'value', 'no-cancel': 'flag', model: 'value',
+};
+const args = parseArgs(process.argv.slice(2), spec);
+const instance = args.Instance ?? 'Second';
+const cloneHome = resolveInstanceHome(instance, args.home);
+const zcodeCli = resolveZcodeCli();
+const cwd = path.resolve(args.cwd ?? process.env.TEMP ?? '.');
+const prompt = args.prompt ?? 'Reply with exactly: OK';
+const timeoutSeconds = Number(args['timeout-seconds'] ?? 180);
+const outPath = path.resolve(args.out ?? path.join(
+ process.env.TEMP ?? '.', `t3-bridge-probe-${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`));
+
+const log = (...m) => process.stderr.write(`[probe] ${m.join(' ')}\n`);
+const fail = (...m) => process.stderr.write(`[probe] FAIL: ${m.join(' ')}\n`);
+
+// --- wire-version gate (spec 5.4) -------------------------------------------
+const wire = readWireVersion(zcodeCli);
+if (!wire.ok) {
+ fail(`zcode.cjs wire version unexpected: ${JSON.stringify(wire)} - refusing to probe (spec 5.4).`);
+ process.exit(1);
+}
+log(`wire gate OK: ZCODE_PROTOCOL_VERSION=${wire.protocolVersion} V4_WIRE=${wire.v4WireVersion}`);
+
+// --- transcript + catalogue ---------------------------------------------------
+const transcript = fs.createWriteStream(outPath, { encoding: 'utf8' });
+const record = (dir, msg) => {
+ transcript.write(JSON.stringify({ ts: new Date().toISOString(), dir, msg }) + '\n');
+};
+const eventCatalogue = new Map(); // discriminator -> {count, sample}
+const catalogueKey = (params) => {
+ // The event shape is Phase A's discovery target: try known candidates, else
+ // fall back to the top-level key list so even unknown shapes group sensibly.
+ const p = params ?? {};
+ const payload = p.payload ?? {};
+ for (const candidate of [payload.event, payload.kind, payload.type, p.event, p.kind, p.type]) {
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate;
+ }
+ return 'keys:' + Object.keys(p).sort().join(',');
+};
+const truncate = (value, max = 600) => {
+ const s = JSON.stringify(value);
+ if (s === undefined) return '';
+ return s.length <= max ? s : s.slice(0, max) + `...<+${s.length - max} chars>`;
+};
+
+// --- request plumbing ----------------------------------------------------------
+let nextId = 1;
+const pending = new Map(); // id -> {resolve, reject, timer, method}
+const serverToClientSeen = new Map(); // method -> {count, answer}
+let terminalWaiter = null; // resolved by the next turn.terminal telemetry event
+let latestRevision = null; // tracked from state.updated patches (authoritative)
+let stateRevision = null; // last revision we KNOW (create snapshot / send result / session/read)
+const availableModels = new Set(); // from state.updated model.available patches
+
+// Optional --model "providerId/modelId[/reasoningLevel]" -> the Ll modelSelection
+// shape that session/create accepts (options.reasoningLevel required by some
+// models, e.g. nemotron). Without it the bare CLI falls back to the free
+// opencode-zen default (spec Z8) whose turns fail upstream today.
+const modelSelection = args.model
+ ? (() => {
+ const parts = args.model.split('/');
+ if (parts.length < 2) throw new Error(`--model expects providerId/modelId[/level], got "${args.model}"`);
+ return {
+ providerId: parts[0],
+ modelId: parts[1],
+ ...(parts[2] ? { options: { reasoningLevel: parts[2] } } : {}),
+ };
+ })()
+ : undefined;
+
+function request(method, params, timeoutMs = 30000) {
+ const id = nextId++;
+ const msg = { id, method, params };
+ record('out', msg);
+ conn.send(msg);
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pending.delete(id);
+ reject(new Error(`timeout after ${timeoutMs}ms waiting for ${method} (id ${id})`));
+ }, timeoutMs);
+ pending.set(id, { resolve, reject, method });
+ });
+}
+
+function notify(method, params) {
+ const msg = { method, params };
+ record('out', msg);
+ conn.send(msg);
+}
+
+function respond(id, payload) {
+ const msg = { id, ...payload };
+ record('out', msg);
+ conn.send(msg);
+}
+
+function waitForTerminal(timeoutMs) {
+ return new Promise((resolve, reject) => {
+ if (terminalWaiter) return reject(new Error('another terminal wait is active'));
+ const timer = setTimeout(() => { terminalWaiter = null; reject(new Error(`no turn.terminal within ${timeoutMs}ms`)); }, timeoutMs);
+ terminalWaiter = (params) => { clearTimeout(timer); terminalWaiter = null; resolve(params); };
+ });
+}
+
+// session/send with the spec's revision discipline (5.6): on -32009 mismatch,
+// re-read the authoritative revision once, then retry; a second mismatch surfaces.
+async function sendWithRevisionSync(sessionId, content, extra = {}) {
+ try {
+ return await request('session/send', { sessionId, content, expectedRevision: latestRevision ?? stateRevision, ...extra }, 30000);
+ } catch (err) {
+ if (!String(err.message).includes('-32009')) throw err;
+ const read = await request('session/read', { sessionId, contentProfile: 'index' }, 15000);
+ if (typeof read?.runtime?.stateRevision === 'number') {
+ stateRevision = read.runtime.stateRevision;
+ latestRevision = stateRevision;
+ }
+ return await request('session/send', { sessionId, content, expectedRevision: latestRevision ?? stateRevision, ...extra }, 30000);
+ }
+}
+
+// --- child + connection ----------------------------------------------------------
+log(`instance=${instance} home=${cloneHome}`);
+log(`cli=${zcodeCli}`);
+const child = spawnAppServer(zcodeCli, cloneHome);
+let nonProtocolLines = 0;
+let storageReady = false;
+let storageReadyWaiter = null;
+
+const conn = new NdjsonConnection({
+ input: child.stdout,
+ output: child.stdin,
+ onMessage: (msg) => {
+ record('in', msg);
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
+ const p = pending.get(msg.id);
+ if (p) {
+ clearTimeout(p.timer);
+ pending.delete(msg.id);
+ if (msg.error !== undefined) p.reject(Object.assign(new Error(`zcode error ${msg.error.code}: ${msg.error.message}`), { zcode: msg.error }));
+ else p.resolve(msg.result);
+ }
+ return;
+ }
+ if (msg.method !== undefined && msg.id !== undefined) {
+ // server->client REQUEST (spec Z7). requestRuntimePreferences is
+ // MANDATORY during session/create (scope runtime-materialization):
+ // a decline aborts the create, so answer its minimal valid shape
+ // (bundle schema Utn: nativeSearchEnhancementsEnabled is the only
+ // field without a default). Everything else: decline by default.
+ const entry = serverToClientSeen.get(msg.method) ?? { count: 0, answer: null };
+ entry.count += 1;
+ if (msg.method === 'session/requestRuntimePreferences') {
+ entry.answer = 'minimal-prefs';
+ respond(msg.id, { result: { nativeSearchEnhancementsEnabled: false } });
+ } else {
+ entry.answer = 'declined-error';
+ respond(msg.id, { error: { code: -32602, message: 'probe decline' } });
+ }
+ serverToClientSeen.set(msg.method, entry);
+ return;
+ }
+ if (msg.method !== undefined) {
+ const method = msg.method;
+ if (method === 'startup/storageState') {
+ const phase = msg.params?.phase ?? msg.params?.payload?.phase;
+ if (phase === 'ready') {
+ storageReady = true;
+ if (storageReadyWaiter) { const w = storageReadyWaiter; storageReadyWaiter = null; w(); }
+ }
+ }
+ if (method === 'session/event') {
+ const key = catalogueKey(msg.params);
+ const entry = eventCatalogue.get(key) ?? { count: 0, samples: [] };
+ entry.count += 1;
+ if (entry.samples.length < 2) entry.samples.push(truncate(msg.params));
+ eventCatalogue.set(key, entry);
+ return;
+ }
+ if (method === 'state.updated') {
+ if (typeof msg.params?.revision === 'number') latestRevision = msg.params.revision;
+ const available = msg.params?.patch?.model?.available;
+ if (Array.isArray(available) && available.length > 0) {
+ for (const entry of available) {
+ const ref = entry?.ref;
+ if (ref?.providerId && ref?.modelId) {
+ availableModels.add(`${ref.providerId}/${ref.modelId}`);
+ }
+ }
+ }
+ return;
+ }
+ if (method === 'v4/telemetry/event' && msg.params?.kind === 'turn.terminal') {
+ const w = terminalWaiter;
+ if (w) w(msg.params);
+ }
+ return;
+ }
+ },
+ onParseError: (line, err) => {
+ nonProtocolLines += 1;
+ record('parse-error', { line: String(line).slice(0, 300), error: err.message });
+ fail(`non-protocol stdout line (#${nonProtocolLines}): ${String(line).slice(0, 200)}`);
+ },
+ onClose: (reason) => log(`child transport closed: ${reason}`),
+});
+
+child.stderr.setEncoding('utf8');
+child.stderr.on('data', (chunk) => {
+ for (const line of String(chunk).split('\n')) {
+ if (line.trim()) record('child-stderr', { line: line.slice(0, 400) });
+ }
+});
+child.on('exit', (code) => log(`child exited code=${code}`));
+
+const waitStorageReady = (timeoutMs) => new Promise((resolve, reject) => {
+ if (storageReady) return resolve();
+ const timer = setTimeout(() => reject(new Error(`storage not ready after ${timeoutMs}ms`)), timeoutMs);
+ storageReadyWaiter = () => { clearTimeout(timer); resolve(); };
+});
+
+const waitForQuiet = async (ms) => new Promise((r) => setTimeout(r, ms));
+
+// --- the probe sequence ------------------------------------------------------------
+const summary = {
+ instance, cloneHome, zcodeCli, wire,
+ framingOk: null, storageReady: false, capabilitiesOk: false,
+ createOk: false, turnCompleted: false, sendResult: null,
+ snapshotKeys: [], revisionFieldsSeen: [],
+ eventCatalogue: {}, serverToClientRequests: {},
+ cancelTest: { attempted: false },
+ nonProtocolLines: 0,
+};
+
+try {
+ log('waiting for storage ready...');
+ await waitStorageReady(90000);
+ summary.storageReady = true;
+ log('storage ready. capabilities handshake...');
+ const caps = await request('runtime/capabilities', { hostCapabilities: {} });
+ summary.capabilitiesOk = true;
+ summary.capabilitiesResult = truncate(caps);
+
+ log('provider/refreshRegistry (scan credentials for plan providers)...');
+ try {
+ const refresh = await request('provider/refreshRegistry', { reason: 't3-bridge-probe' }, 30000);
+ summary.refreshRegistryResult = truncate(refresh);
+ } catch (err) {
+ summary.refreshRegistryError = String(err.message);
+ }
+
+ log(`session/create (workspace=${cwd}, default model)...)`);
+ let snapshot;
+ snapshot = await request('session/create', { workspace: zcodeWorkspace(cwd) }, 60000);
+ summary.createWithModel = false;
+ summary.createOk = true;
+ summary.snapshotKeys = Object.keys(snapshot ?? {});
+ for (const key of Object.keys(snapshot ?? {})) {
+ if (/revision/i.test(key)) summary.revisionFieldsSeen.push(key);
+ }
+ if (snapshot?.value) {
+ for (const key of Object.keys(snapshot.value)) {
+ if (/revision/i.test(key)) summary.revisionFieldsSeen.push(`value.${key}`);
+ }
+ }
+ log(`snapshot keys: ${summary.snapshotKeys.join(', ')}; revision candidates: ${summary.revisionFieldsSeen.join(', ') || ''}`);
+ const sessionId = snapshot?.session?.sessionId ?? snapshot?.sessionId ?? snapshot?.value?.sessionId;
+ if (!sessionId) throw new Error(`no sessionId in snapshot: ${truncate(snapshot, 400)}`);
+ stateRevision = snapshot?.runtime?.stateRevision ?? 0;
+ summary.revisionFieldsSeen.push(`runtime.stateRevision=${stateRevision}`);
+ log(`sessionId=${sessionId} stateRevision=${stateRevision}`);
+
+ log('subscribing to session events (deliveryKind=desktop-continuous)...');
+ const subResult = await request('session/subscribe', { sessionId, deliveryKind: 'desktop-continuous' }, 15000);
+ summary.subscribeResult = truncate(subResult);
+
+ log(`turn 1: session/send "${prompt}"${modelSelection ? ` (model=${modelSelection.providerId}/${modelSelection.modelId})` : ''}...`);
+ const terminalPromise = waitForTerminal(timeoutSeconds * 1000);
+ const sendResult = await sendWithRevisionSync(sessionId, prompt, modelSelection ? { modelSelection } : {});
+ summary.sendResult = truncate(sendResult);
+ if (typeof sendResult?.stateRevision === 'number') stateRevision = sendResult.stateRevision;
+ const terminal = await terminalPromise;
+ summary.turnTerminal = truncate(terminal);
+ summary.turnCompleted = true;
+ log(`turn 1 terminal: ${summary.turnTerminal}`);
+
+ if (!args['no-cancel']) {
+ log('turn 2 + cancel test...');
+ summary.cancelTest.attempted = true;
+ try {
+ // Re-sync the authoritative revision after turn 1's terminal: the
+ // terminal itself moves it and -32009 punishes a stale value.
+ const read = await request('session/read', { sessionId, contentProfile: 'index' }, 15000);
+ if (typeof read?.runtime?.stateRevision === 'number') {
+ stateRevision = read.runtime.stateRevision;
+ latestRevision = stateRevision;
+ summary.cancelTest.revisionResync = stateRevision;
+ }
+ const terminal2Promise = waitForTerminal(timeoutSeconds * 1000);
+ const send2 = sendWithRevisionSync(sessionId, 'Count slowly from 1 to 40, one number per line, no other text.').then(
+ (r) => {
+ summary.cancelTest.sendResult = truncate(r);
+ if (typeof r?.stateRevision === 'number') stateRevision = r.stateRevision;
+ },
+ (e) => { summary.cancelTest.sendError = String(e.message); },
+ );
+ await send2;
+ await waitForQuiet(2500);
+ const stopResult = await request('session/stop', { sessionId }, 15000).then(
+ (r) => { summary.cancelTest.stopResult = truncate(r); },
+ (e) => { summary.cancelTest.stopError = String(e.message); },
+ );
+ void stopResult;
+ summary.cancelTest.terminal = truncate(await terminal2Promise);
+ } catch (err) {
+ summary.cancelTest.error = String(err.message);
+ }
+ log(`cancel test: ${truncate(summary.cancelTest)}`);
+ }
+
+ summary.eventCatalogue = Object.fromEntries([...eventCatalogue.entries()].map(([k, v]) => [k, v]));
+ summary.serverToClientRequests = Object.fromEntries([...serverToClientSeen.entries()].map(([k, v]) => [k, v]));
+ summary.availableModels = [...availableModels].sort();
+ summary.nonProtocolLines = nonProtocolLines;
+ summary.framingOk = nonProtocolLines === 0;
+
+ const coreOk = summary.framingOk && summary.storageReady && summary.capabilitiesOk
+ && summary.createOk && summary.turnCompleted
+ && (summary.turnTerminal ? !/"status":"failed"/.test(summary.turnTerminal) || Boolean(process.env.PROBE_ALLOW_FAILED_TURNS) : false);
+ log('--- SUMMARY ---');
+ log(truncate(summary, 4000));
+ fs.writeFileSync(outPath.replace(/\.jsonl$/, '-summary.json'), JSON.stringify(summary, null, 2));
+ if (coreOk) {
+ log(`PROBE PASS - transcript at ${outPath}`);
+ process.exitCode = 0;
+ } else {
+ fail(`PROBE FAIL - see summary above; transcript at ${outPath}`);
+ process.exitCode = 1;
+ }
+} catch (err) {
+ fail(String(err.message ?? err));
+ summary.fatal = String(err.message ?? err);
+ fs.writeFileSync(outPath.replace(/\.jsonl$/, '-summary.json'), JSON.stringify(summary, null, 2));
+ process.exitCode = 1;
+} finally {
+ transcript.end();
+ child.killTree();
+ // give taskkill a beat before the process exits
+ setTimeout(() => process.exit(process.exitCode ?? 0), 500);
+}
+void INSTANCE_MAP;
diff --git a/tools/t3_zcode_bridge.mjs b/tools/t3_zcode_bridge.mjs
new file mode 100644
index 0000000..d5bba3a
--- /dev/null
+++ b/tools/t3_zcode_bridge.mjs
@@ -0,0 +1,768 @@
+#!/usr/bin/env node
+// T3 Code <-> ZCode bridge (docs/T3_ZCODE_BRIDGE_SPEC.md, phase B).
+//
+// ONE process per T3 "Local ACP command" provider instance = one ZCode clone
+// account. Speaks ACP v1 on THIS process's stdio (what T3 launches) and drives
+// `zcode.cjs app-server` (ZCode Protocol, NDJSON) as a child under the four
+// clone-home env levers. Stdout of THIS process carries ONLY ACP messages
+// (ACP transports rule) - every log line goes to stderr.
+//
+// Mapping provenance: spec section 2 (ACP shapes from the t3code clone's
+// generated v1 schema; ZCode shapes from bundle probes + live transcripts
+// 2026-10-29). Content-delta shapes (part.delta / turn.completed payloads)
+// are bundle-schema-derived; no free zen model was reachable upstream during
+// the probe session, so those paths activate the first time a working model
+// exists (they follow the verified envelope contract).
+//
+// Usage (what T3 runs):
+// node tools\t3_zcode_bridge.mjs --Instance Second
+// node tools\t3_zcode_bridge.mjs --home C:\Users\FOTE\SixthCloneHome
+import fs from 'node:fs';
+import path from 'node:path';
+import {
+ NdjsonConnection,
+ ZCODE_MODES,
+ parseArgs,
+ readWireVersion,
+ resolveInstanceHome,
+ resolveZcodeCli,
+ spawnAppServer,
+ zcodeWorkspace,
+} from './t3_bridge_lib.mjs';
+
+const spec = { Instance: 'value', home: 'value', 'zcode-cli': 'value', mode: 'value', model: 'value' };
+const args = parseArgs(process.argv.slice(2), spec);
+const defaultMode = ZCODE_MODES.includes(args.mode) ? args.mode : 'build';
+// Optional session model override (Ll shape): "providerId/modelId[/reasoningLevel]".
+// Without it the session uses the home's default model (the standalone-login
+// plan default once `zcode login zai` ran; the free zen fallback before that).
+const sessionModel = args.model
+ ? (() => {
+ const parts = args.model.split('/');
+ if (parts.length < 2) throw new Error(`--model expects providerId/modelId[/level], got "${args.model}"`);
+ return {
+ providerId: parts[0],
+ modelId: parts[1],
+ ...(parts[2] ? { options: { reasoningLevel: parts[2] } } : {}),
+ };
+ })()
+ : undefined;
+
+const log = (...m) => process.stderr.write(`[t3-zcode-bridge] ${m.join(' ')}\n`);
+const warnOnce = new Set();
+const warnOnceLog = (key, ...m) => {
+ if (warnOnce.has(key)) return;
+ warnOnce.add(key);
+ log('WARN:', ...m);
+};
+
+// --- model catalogue (phase D) ------------------------------------------------
+// T3's generic Local ACP command provider reads models from the session/new
+// response's TOP-LEVEL `models` field ({currentModelId, availableModels:
+// [{modelId, name}]}) — initialize _meta.modelState is Grok-only (spec 8.5).
+// ACP model ids are encoded "providerId/modelId[/reasoningLevel]" (the same
+// format the probe accepts) so session/set_model can unpack them back into
+// ZCode's Ll modelSelection shape.
+const modelCatalogue = new Map(); // acpModelId -> {modelId, name}
+let currentModelAcpId = null;
+
+function addCatalogueModel(providerId, modelId) {
+ if (!providerId || !modelId) return;
+ const acpId = `${providerId}/${modelId}`;
+ if (!modelCatalogue.has(acpId)) modelCatalogue.set(acpId, { modelId: acpId, name: `${providerId}/${modelId}` });
+}
+
+// Seed from the home's v2 config: enabled providers expose their model lists
+// there (app-side keys like "builtin:zai-start-plan"). For plan-family keys we
+// ALSO register the observed runtime namespace variant ("account:zai-start-plan")
+// because the runtime registry materializes plan providers under it (spec Z8/§8).
+function seedModelCatalogueFromHome() {
+ try {
+ const cfg = JSON.parse(fs.readFileSync(path.join(cloneHome, '.zcode', 'v2', 'config.json'), 'utf8'));
+ const providers = cfg?.provider ?? {};
+ for (const [key, entry] of Object.entries(providers)) {
+ if (entry?.enabled !== true) continue;
+ const models = entry.models ?? {};
+ const variants = [key];
+ const family = key.replace(/^builtin:/, '');
+ if (/^(zai|bigmodel)-(start|coding)-plan$/.test(family)) variants.push(`account:${family}`);
+ for (const modelId of Object.keys(models)) {
+ for (const variant of variants) addCatalogueModel(variant, modelId);
+ }
+ }
+ } catch (err) {
+ warnOnceLog('catalogue', `config.json model seed skipped (${err.message ?? err}).`);
+ }
+ // Current selection: the standalone-login default when present.
+ try {
+ const provCfg = JSON.parse(fs.readFileSync(path.join(cloneHome, '.zcode', 'v2', 'provider_config.json'), 'utf8'));
+ const sel = provCfg?.config?.defaultModelSelection;
+ if (sel?.providerId && sel?.modelId) {
+ currentModelAcpId = sel.options?.reasoningLevel
+ ? `${sel.providerId}/${sel.modelId}/${sel.options.reasoningLevel}`
+ : `${sel.providerId}/${sel.modelId}`;
+ }
+ } catch { /* catalogue stays without a current id */ }
+ if (currentModelAcpId && !modelCatalogue.has(currentModelAcpId)) {
+ addCatalogueModel(currentModelAcpId.split('/')[0], currentModelAcpId.split('/')[1]);
+ }
+}
+
+function buildModelState() {
+ if (modelCatalogue.size === 0) return null; // T3 falls back to Custom models
+ const availableModels = [...modelCatalogue.values()];
+ const currentModelId = currentModelAcpId && modelCatalogue.has(currentModelAcpId)
+ ? currentModelAcpId
+ : availableModels[0].modelId;
+ return { currentModelId, availableModels };
+}
+
+function parseAcpModelId(acpId) {
+ const parts = String(acpId ?? '').split('/');
+ if (parts.length < 2) return null;
+ return {
+ providerId: parts[0],
+ modelId: parts[1],
+ ...(parts[2] ? { options: { reasoningLevel: parts[2] } } : {}),
+ };
+}
+
+// --- startup: resolve, gate, spawn -------------------------------------------
+const cloneHome = resolveInstanceHome(args.Instance ?? 'Second', args.home);
+const zcodeCli = resolveZcodeCli(args['zcode-cli']);
+const wire = readWireVersion(zcodeCli);
+if (!wire.ok) {
+ log(`FATAL: wire-version gate failed (${JSON.stringify(wire)}) - refusing to start (spec 5.4).`);
+ process.exit(1);
+}
+log(`instance=${args.Instance ?? '(home)'} home=${cloneHome}`);
+log(`cli=${zcodeCli} wire=${wire.protocolVersion}/v4:${wire.v4WireVersion}`);
+
+// Standalone-login check (spec 5.5): without defaultModelSelection the child
+// runs on the free opencode-zen model, not the account's Start Plan.
+(() => {
+ try {
+ const provCfg = JSON.parse(fs.readFileSync(path.join(cloneHome, '.zcode', 'v2', 'provider_config.json'), 'utf8'));
+ if (!provCfg?.config?.defaultModelSelection) {
+ warnOnceLog('login', `no standalone CLI login in this home - turns will run on the FREE zen model.`);
+ warnOnceLog('login', `one-time fix: set the four env levers (see docs/ZCODE_LIGHT_MODE.md) then run: node "${zcodeCli}" login zai`);
+ }
+ } catch { warnOnceLog('login', 'provider_config.json unreadable - login state unknown.'); }
+})();
+
+// Catalogue seed runs AFTER cloneHome exists (it reads the home's config).
+seedModelCatalogueFromHome();
+
+// --- child (zcode app-server) --------------------------------------------------
+const child = spawnAppServer(zcodeCli, cloneHome);
+let childExit = null;
+child.on('exit', (code) => {
+ childExit = code;
+ log(`zcode app-server exited (code=${code}) - failing all pending ACP requests.`);
+ for (const p of acpPending.values()) {
+ p.reject(jsonRpcError(p.id, -32001, `zcode app-server exited (code ${code})`));
+ }
+ acpPending.clear();
+});
+
+// ZCode-side request plumbing
+let zcNextId = 1;
+const zcPending = new Map();
+let storageReady = false;
+let storageWaiter = null;
+let latestRevision = null;
+
+function zcRequest(method, params, timeoutMs = 30000) {
+ if (childExit !== null) return Promise.reject(new Error(`zcode app-server already exited (code ${childExit})`));
+ const id = zcNextId++;
+ zc.send({ id, method, params });
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { zcPending.delete(id); reject(new Error(`timeout ${timeoutMs}ms: ${method}`)); }, timeoutMs);
+ zcPending.set(id, { resolve, reject, method, timer });
+ });
+}
+
+function zcRespond(id, payload) {
+ zc.send({ id, ...payload });
+}
+
+async function zcSendWithRevisionSync(sessionId, content, extra = {}) {
+ const attempt = () => zcRequest('session/send', {
+ sessionId, content, expectedRevision: latestRevision ?? 0, ...extra,
+ });
+ try {
+ return await attempt();
+ } catch (err) {
+ if (!/-32009/.test(String(err.message))) throw err;
+ const read = await zcRequest('session/read', { sessionId, contentProfile: 'index' });
+ if (typeof read?.runtime?.stateRevision === 'number') latestRevision = read.runtime.stateRevision;
+ return await attempt();
+ }
+}
+
+function answerServerToClient(msg, payload) {
+ zcRespond(msg.id, payload);
+}
+
+const declineServerToClient = (msg) => {
+ warnOnceLog(`decline:${msg.method}`, `declining server->client request "${msg.method}" (params: ${JSON.stringify(msg.params ?? {}).slice(0, 200)}).`);
+ answerServerToClient(msg.id, { error: { code: -32602, message: `t3-zcode-bridge does not support ${msg.method}` } });
+};
+
+// --- ACP side -------------------------------------------------------------------
+const acpPending = new Map(); // id -> {resolve, reject}
+let acpNextId = 1;
+const sessions = new Map(); // acpSessionId (= zcode sessionId) -> session state
+
+function acpSend(msg) {
+ // THE only stdout writer in this process.
+ process.stdout.write(JSON.stringify(msg) + '\n');
+}
+function acpNotify(method, params) {
+ acpSend({ method, params });
+}
+function jsonRpcError(id, code, message, data) {
+ return { id, error: { code, message, ...(data ? { data } : {}) } };
+}
+function acpRespond(id, payload) {
+ // Client-request responses are written inline (no pending entry needed):
+ // acpPending only tracks bridge->client requests awaiting answers.
+ acpSend({ id, ...payload });
+}
+
+function acpRequest(method, params, timeoutMs = 60000) {
+ const id = acpNextId++;
+ acpSend({ id, method, params });
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { acpPending.delete(id); reject(new Error(`client timeout ${timeoutMs}ms: ${method}`)); }, timeoutMs);
+ acpPending.set(id, {
+ resolve: (result) => { clearTimeout(timer); resolve(result); },
+ reject: (error) => { clearTimeout(timer); reject(error); },
+ id,
+ });
+ });
+}
+
+// --- session update streaming ---------------------------------------------------
+function emitUpdate(sessionId, update) {
+ acpNotify('session/update', { sessionId, update });
+}
+
+const textChunk = (text) => ({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } });
+const thoughtChunk = (text) => ({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text } });
+
+const TOOL_KIND_BY_NAME = (name = '') => {
+ const n = name.toLowerCase();
+ if (/(bash|shell|exec|command|terminal)/.test(n)) return 'execute';
+ if (/(edit|write|patch|apply)/.test(n)) return 'edit';
+ if (/(read|grep|glob|search|list|find)/.test(n)) return 'search';
+ if (/(fetch|web|browser|http)/.test(n)) return 'fetch';
+ if (/(think|plan|todo)/.test(n)) return 'think';
+ return 'other';
+};
+
+const PERMISSION_KIND_BY_OPTION = (kind = '') => {
+ const k = kind.toLowerCase();
+ if (k.startsWith('reject') || k.startsWith('deny')) return 'reject_once';
+ if (k.startsWith('allow_always') || k === 'always') return 'allow_always';
+ return 'allow_once';
+};
+
+// Per-turn streaming state: keyed by zcode turnId, tracks how much of each
+// part/message we already streamed so upserts never duplicate deltas.
+function turnState(session, turnId) {
+ let t = session.turns.get(turnId);
+ if (!t) {
+ t = { emittedByPart: new Map(), sawPartEvents: false, sawContent: false, cancelRequested: false };
+ session.turns.set(turnId, t);
+ }
+ return t;
+}
+
+function streamPartText(session, turnId, partId, fullText, field) {
+ if (typeof fullText !== 'string' || fullText.length === 0) return;
+ const t = turnState(session, turnId);
+ const prev = t.emittedByPart.get(partId) ?? '';
+ if (fullText.length <= prev.length) return;
+ const delta = fullText.slice(prev.length);
+ t.emittedByPart.set(partId, fullText);
+ t.sawPartEvents = true;
+ t.sawContent = true;
+ emitUpdate(session.id, field === 'reasoning' ? thoughtChunk(delta) : textChunk(delta));
+}
+
+function mapStopError(zcodeError) {
+ const message = zcodeError?.message ?? zcodeError?.underlyingErrorMessage ?? 'turn failed';
+ const code = String(zcodeError?.code ?? '');
+ // Spec 4.4/§8: classify on the structured errorCode FIRST, then the
+ // message — quota/admission/model-availability failures are usage-limit
+ // class, everything else passes through verbatim.
+ const modelClass = /(invalid_model_request|model_unavailable|quota|rate.?limit|3009|admission|concurrency|throttl)/i;
+ const usageLimit = modelClass.test(code) || modelClass.test(message);
+ return jsonRpcError(null, -32001, usageLimit ? `usage-limit/model-unavailable: ${message}` : message, {
+ zcodeErrorCode: code || undefined,
+ }).error;
+}
+
+// --- zcode event pump -------------------------------------------------------------
+const zc = new NdjsonConnection({
+ input: child.stdout,
+ output: child.stdin,
+ onMessage: (msg) => {
+ // server->client REQUEST (must be answered; spec Z7)
+ if (msg.id !== undefined && msg.method !== undefined) {
+ handleServerToClientRequest(msg);
+ return;
+ }
+ // response to OUR request
+ if (msg.id !== undefined) {
+ const p = zcPending.get(msg.id);
+ if (p) {
+ clearTimeout(p.timer);
+ zcPending.delete(msg.id);
+ if (msg.error !== undefined) p.reject(Object.assign(new Error(`zcode error ${msg.error.code}: ${msg.error.message}`), { zcode: msg.error }));
+ else p.resolve(msg.result);
+ }
+ return;
+ }
+ // notification
+ if (msg.method === 'startup/storageState' && msg.params?.phase === 'ready') {
+ storageReady = true;
+ if (storageWaiter) { const w = storageWaiter; storageWaiter = null; w(); }
+ return;
+ }
+ if (msg.method === 'state.updated') {
+ if (typeof msg.params?.revision === 'number') latestRevision = msg.params.revision;
+ // Live model catalogue augmentation: runtime registry truth wins.
+ const available = msg.params?.patch?.model?.available;
+ if (Array.isArray(available)) {
+ for (const entry of available) {
+ const ref = entry?.ref;
+ if (ref?.providerId && ref?.modelId) {
+ addCatalogueModel(ref.providerId, ref.modelId);
+ if (!currentModelAcpId) currentModelAcpId = `${ref.providerId}/${ref.modelId}`;
+ }
+ }
+ }
+ return;
+ }
+ if (msg.method === 'session/event') {
+ handleSessionEvent(msg.params ?? {});
+ return;
+ }
+ if (msg.method === 'v4/telemetry/event' && msg.params?.kind === 'turn.terminal') {
+ settleTurnByTelemetry(msg.params);
+ return;
+ }
+ // startup/* and other informational notifications: ignored by design.
+ },
+ onParseError: (line, err) => {
+ warnOnceLog('child-parse', `non-protocol line from zcode child: ${String(line).slice(0, 160)} (${err.message})`);
+ },
+ onClose: (reason) => log(`zcode transport closed: ${reason}`),
+});
+
+child.stderr.setEncoding('utf8');
+child.stderr.on('data', (chunk) => {
+ for (const line of String(chunk).split('\n')) {
+ if (line.trim()) log(`child: ${line.slice(0, 240)}`);
+ }
+});
+
+function handleServerToClientRequest(msg) {
+ switch (msg.method) {
+ case 'session/requestRuntimePreferences': {
+ // MANDATORY during session/create (bundle schema Utn: this boolean
+ // is the only field without a default) - a decline aborts create.
+ answerServerToClient(msg, { result: { nativeSearchEnhancementsEnabled: false } });
+ return;
+ }
+ case 'interaction/requestPermission': {
+ forwardPermissionRequest(msg);
+ return;
+ }
+ case 'interaction/requestUserInput': {
+ // Phase-C refinement target; for now use the legal decline path.
+ warnOnceLog('user-input', `userInput.requested declined (interactive questions not bridged yet).`);
+ answerServerToClient(msg, { result: { cancelled: true } });
+ return;
+ }
+ default:
+ declineServerToClient(msg);
+ }
+}
+
+async function forwardPermissionRequest(msg) {
+ const params = msg.params ?? {};
+ const sessionId = params.sessionId;
+ const options = Array.isArray(params.options) ? params.options : [];
+ try {
+ if (!sessionId || !sessions.has(sessionId) || options.length === 0) {
+ // No ACP session to ask: pick the first deny-ish option, else deny.
+ const deny = options.find((o) => /deny|reject/i.test(String(o.kind))) ?? null;
+ answerServerToClient(msg.id, { result: deny ? deny.response : { decision: 'deny' } });
+ return;
+ }
+ const acpOptions = options.map((o) => ({
+ optionId: o.optionId,
+ name: String(o.name ?? o.optionId),
+ kind: PERMISSION_KIND_BY_OPTION(String(o.kind ?? '')),
+ }));
+ const outcome = await acpRequest('session/request_permission', {
+ sessionId,
+ toolCall: {
+ toolCallId: String(params.toolCallId ?? `perm-${params.requestId ?? 'unknown'}`),
+ title: String(params.toolName ?? 'tool'),
+ kind: TOOL_KIND_BY_NAME(String(params.toolName ?? '')),
+ },
+ options: acpOptions,
+ }, 300000);
+ const chosenId = outcome?.outcome?.optionId;
+ const chosen = options.find((o) => o.optionId === chosenId);
+ if (outcome?.outcome?.outcome === 'cancelled' || !chosen) {
+ const deny = options.find((o) => /deny|reject/i.test(String(o.kind))) ?? null;
+ answerServerToClient(msg.id, { result: deny ? deny.response : { decision: 'deny', reason: 'cancelled in client' } });
+ return;
+ }
+ // Each zcode option carries its own MF-shaped response - pass verbatim.
+ answerServerToClient(msg.id, { result: chosen.response ?? { decision: 'deny' } });
+ } catch (err) {
+ log(`permission forward failed (${err.message ?? err}) - denying.`);
+ answerServerToClient(msg.id, { result: { decision: 'deny', reason: `bridge error: ${err.message ?? err}` } });
+ }
+}
+
+function sessionByZcodeId(sessionId) {
+ return sessions.get(sessionId) ?? null;
+}
+
+function handleSessionEvent(params) {
+ const sessionId = params.sessionId;
+ const session = sessionByZcodeId(sessionId);
+ if (!session) return; // events for sessions T3 did not open - ignore
+ const turnId = params.turnId ?? '(no-turn)';
+ const payload = params.payload ?? {};
+ switch (params.type) {
+ case 'part.delta': {
+ const field = payload.field ?? 'text';
+ if (field === 'text' || field === 'reasoning') {
+ streamDelta(session, turnId, payload.messageId, payload.partId, payload.delta, field);
+ }
+ return;
+ }
+ case 'part.started':
+ case 'part.upserted': {
+ const part = payload.part ?? {};
+ const kind = part.type;
+ if (kind === 'text') streamPartText(session, turnId, part.id ?? part.partId ?? '(part)', part.text, 'text');
+ else if (kind === 'reasoning') streamPartText(session, turnId, part.id ?? part.partId ?? '(part)', part.text, 'reasoning');
+ else if (kind === 'tool') emitToolUpdate(session, turnId, part);
+ return;
+ }
+ case 'message.upserted': {
+ const t = turnState(session, turnId);
+ if (!t.sawPartEvents && typeof payload.content === 'string' && payload.content.length > 0) {
+ t.sawContent = true;
+ emitUpdate(sessionId, textChunk(payload.content));
+ }
+ return;
+ }
+ case 'tool.updated': {
+ emitToolUpdate(session, turnId, payload);
+ return;
+ }
+ case 'turn.completed': {
+ const t = turnState(session, turnId);
+ if (!t.sawContent && typeof payload.response === 'string' && payload.response.length > 0) {
+ emitUpdate(sessionId, textChunk(payload.response));
+ t.sawContent = true;
+ }
+ settleTurn(session, turnId, { status: 'completed' });
+ return;
+ }
+ case 'turn.failed': {
+ settleTurn(session, turnId, { status: 'failed', error: payload.error });
+ return;
+ }
+ default:
+ // session.titleUpdated / session.updated / model_* / todo / plan /
+ // subagent / workflow events: no ACP v1 counterpart in this bridge.
+ return;
+ }
+}
+
+function streamDelta(session, turnId, messageId, partId, delta, field) {
+ if (typeof delta !== 'string' || delta.length === 0) return;
+ const t = turnState(session, turnId);
+ t.sawPartEvents = true;
+ t.sawContent = true;
+ emitUpdate(session.id, field === 'reasoning' ? thoughtChunk(delta) : textChunk(delta));
+ void messageId; void partId; // deltas are append-only; upserts track their own full text
+}
+
+function emitToolUpdate(session, turnId, payload) {
+ const toolCallId = String(payload.toolCallId ?? payload.id ?? `tool-${turnId}-${payload.toolName ?? 'unknown'}`);
+ const known = session.tools.get(toolCallId) ?? { emitted: false };
+ const rawOutput = payload.result ?? payload.output;
+ const failed = payload.error !== undefined || /error/i.test(String(payload.state ?? payload.status ?? ''));
+ const status = failed ? 'failed'
+ : (rawOutput !== undefined || /complete|result|done/i.test(String(payload.state ?? payload.status ?? ''))) ? 'completed'
+ : known.emitted ? 'in_progress' : 'pending';
+ const update = {
+ sessionUpdate: known.emitted ? 'tool_call_update' : 'tool_call',
+ toolCallId,
+ title: String(payload.title ?? payload.description ?? payload.toolName ?? 'tool'),
+ kind: TOOL_KIND_BY_NAME(String(payload.toolName ?? payload.title ?? '')),
+ status,
+ };
+ if (payload.input !== undefined) update.rawInput = payload.input;
+ if (rawOutput !== undefined) update.rawOutput = rawOutput;
+ session.tools.set(toolCallId, { emitted: true });
+ emitUpdate(session.id, update);
+}
+
+// Turn settlement: an ACP session/prompt resolves exactly once per turn.
+function settleTurn(session, turnId, { status, error }) {
+ const pendingPrompt = session.pendingPrompt;
+ if (!pendingPrompt || pendingPrompt.turnId !== turnId) return;
+ session.pendingPrompt = null;
+ clearTimeout(pendingPrompt.watchdog);
+ if (pendingPrompt.cancelRequested) {
+ pendingPrompt.resolve({ stopReason: 'cancelled' });
+ return;
+ }
+ if (status === 'completed') {
+ pendingPrompt.resolve({ stopReason: 'end_turn' });
+ return;
+ }
+ pendingPrompt.reject(error ? mapStopError(error) : jsonRpcError(null, -32001, `turn ${status}`).error);
+}
+
+function settleTurnByTelemetry(params) {
+ const session = sessionByZcodeId(params.sessionId);
+ if (!session) return;
+ const pendingPrompt = session.pendingPrompt;
+ if (!pendingPrompt) return;
+ // Belt-and-braces: the explicit turn.completed/failed events normally win;
+ // this catches terminals whose session/event form was never delivered.
+ if (pendingPrompt.turnId && params.turnId && pendingPrompt.turnId !== params.turnId) return;
+ settleTurn(session, pendingPrompt.turnId, { status: params.status ?? 'completed', error: params.errorCode ? { code: params.errorCode, message: params.errorMessage } : undefined });
+}
+
+// --- ACP method handlers ----------------------------------------------------------
+async function waitForStorageReady(timeoutMs = 90000) {
+ if (storageReady) return;
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`zcode storage not ready after ${timeoutMs}ms`)), timeoutMs);
+ storageWaiter = () => { clearTimeout(timer); resolve(); };
+ });
+}
+
+async function handleInitialize(params) {
+ // Respond v1-SHAPED: T3's negotiation pins generation 1 from the response
+ // shape (spec A3), so no fs/terminal/mcp capabilities are ever expected.
+ return {
+ protocolVersion: 1,
+ agentCapabilities: {
+ loadSession: false,
+ promptCapabilities: { image: false, audio: false, embeddedContext: false },
+ mcpCapabilities: { http: false, sse: false, acp: false },
+ },
+ };
+}
+
+async function handleSessionNew(params) {
+ await waitForStorageReady();
+ // Prefer the home's KNOWN default model (the standalone-login selection):
+ // with it, turns run on the plan; without it, the registry rejects create
+ // FAST with a clear login-guidance error instead of a turn that hangs in
+ // the free-model retry loop for minutes (spec 9.3 incident, 2026-10-29).
+ const createModel = sessionModel ?? parseAcpModelId(currentModelAcpId ?? '');
+ let snapshot;
+ try {
+ snapshot = await zcRequest('session/create', {
+ workspace: zcodeWorkspace(params.cwd),
+ mode: defaultMode,
+ ...(createModel ? { model: createModel } : {}),
+ }, 60000);
+ } catch (err) {
+ const message = String(err?.message ?? err);
+ if (createModel && /Provider Registry|-32603/i.test(message)) {
+ throw jsonRpcError(null, -32001,
+ `this clone home cannot run "${currentModelAcpId}" yet - its provider is not materialized. Run GHOST.bat ZCode [28] (one-time login) for this clone, then retry.`,
+ { zcodeErrorCode: 'login_required' });
+ }
+ if (!createModel || sessionModel) throw err;
+ // The home default failed for a NON-registry reason: fall back to the
+ // server default (loudly) rather than dead-ending the provider.
+ warnOnceLog('create-fallback', `session/create with the home default failed (${message.slice(0, 140)}) - falling back to the server default model.`);
+ snapshot = await zcRequest('session/create', {
+ workspace: zcodeWorkspace(params.cwd),
+ mode: defaultMode,
+ }, 60000);
+ }
+ const sessionId = snapshot?.session?.sessionId;
+ if (!sessionId) throw jsonRpcError(null, -32001, 'session/create returned no sessionId');
+ latestRevision = snapshot?.runtime?.stateRevision ?? 0;
+ const session = {
+ id: sessionId,
+ turns: new Map(),
+ tools: new Map(),
+ pendingPrompt: null,
+ };
+ sessions.set(sessionId, session);
+ await zcRequest('session/subscribe', { sessionId, deliveryKind: 'desktop-continuous' }, 15000);
+ const models = buildModelState();
+ return {
+ sessionId,
+ modes: {
+ currentModeId: defaultMode,
+ availableModes: ZCODE_MODES.map((id) => ({ id, name: id })),
+ },
+ // T3's model picker gate: setup.models != null enables switching.
+ ...(models ? { models } : {}),
+ };
+}
+
+async function handleSetModel(params) {
+ const session = sessions.get(params.sessionId);
+ if (!session) throw jsonRpcError(null, -32004, `unknown session ${params.sessionId}`);
+ const selection = parseAcpModelId(params.modelId);
+ if (!selection) throw jsonRpcError(null, -32602, `modelId must be "providerId/modelId[/level]", got "${params.modelId}"`);
+ await zcRequest('session/setModel', { sessionId: session.id, modelSelection: selection }, 15000);
+ currentModelAcpId = params.modelId;
+ return {};
+}
+
+// Turn watchdog (spec 9.3): a prompt with NO terminal within this window gets
+// stopped and answered with a clear error - T3's provider window is 60 s and
+// a broken/unavailable model can otherwise hang the turn for minutes.
+const TURN_WATCHDOG_MS = 180000;
+
+async function handleSessionPrompt(params) {
+ const session = sessions.get(params.sessionId);
+ if (!session) throw jsonRpcError(null, -32004, `unknown session ${params.sessionId}`);
+ if (session.pendingPrompt) throw jsonRpcError(null, -32010, 'a prompt is already running for this session');
+ const blocks = Array.isArray(params.prompt) ? params.prompt : [];
+ const text = blocks
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
+ .map((b) => b.text)
+ .join('\n');
+ if (!text) throw jsonRpcError(null, -32602, 'only text prompts are supported by this bridge');
+ const turnId = `t3-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // real turnId arrives via turn.started
+ const pendingPrompt = { turnId, resolve: null, reject: null, cancelRequested: false, watchdog: null };
+ const result = await new Promise((resolve, reject) => {
+ pendingPrompt.resolve = resolve;
+ pendingPrompt.reject = reject;
+ session.pendingPrompt = pendingPrompt;
+ pendingPrompt.watchdog = setTimeout(() => {
+ if (session.pendingPrompt !== pendingPrompt) return;
+ session.pendingPrompt = null;
+ log('turn watchdog fired - stopping the turn (no terminal within 180s).');
+ zcRequest('session/stop', { sessionId: session.id }, 15000).catch(() => { /* best effort */ });
+ reject(jsonRpcError(null, -32001,
+ 'turn exceeded 180s without a terminal - stopped by the bridge (likely a broken or unavailable model; run GHOST.bat ZCode [28] login for this clone).',
+ { zcodeErrorCode: 'turn_watchdog' }).error);
+ }, TURN_WATCHDOG_MS);
+ zcSendWithRevisionSync(session.id, text).then(
+ (sendResult) => {
+ if (typeof sendResult?.stateRevision === 'number') latestRevision = sendResult.stateRevision;
+ },
+ (err) => {
+ if (session.pendingPrompt === pendingPrompt) {
+ session.pendingPrompt = null;
+ clearTimeout(pendingPrompt.watchdog);
+ reject(err.zcode ? mapStopError(err.zcode) : jsonRpcError(null, -32001, String(err.message)).error);
+ }
+ },
+ );
+ });
+ return result;
+}
+
+function handleSessionCancel(params) {
+ const session = sessions.get(params.sessionId);
+ if (!session?.pendingPrompt) return {};
+ session.pendingPrompt.cancelRequested = true;
+ // ACP: after session/cancel the prompt response MUST be stopReason
+ // "cancelled" even if the underlying turn errors out (spec 4.1 trap).
+ // The terminal settles the prompt whenever zcode delivers it - with a
+ // misbehaving upstream model the retry loop can delay that for minutes,
+ // so callers must not assume a fast answer (fake client gates on THIS
+ // log line, not on the terminal).
+ log(`cancel -> session/stop (session ${session.id})`);
+ zcRequest('session/stop', { sessionId: session.id }, 15000).catch(() => { /* stop races are fine */ });
+ return {};
+}
+
+async function handleSetMode(params) {
+ const session = sessions.get(params.sessionId);
+ if (!session) throw jsonRpcError(null, -32004, `unknown session ${params.sessionId}`);
+ if (!ZCODE_MODES.includes(params.modeId)) throw jsonRpcError(null, -32602, `unknown mode ${params.modeId}`);
+ await zcRequest('session/setMode', { sessionId: session.id, mode: params.modeId }, 15000);
+ return {};
+}
+
+const acpMethods = {
+ initialize: handleInitialize,
+ 'session/new': handleSessionNew,
+ 'session/prompt': handleSessionPrompt,
+ 'session/cancel': (params) => handleSessionCancel(params), // notification-style, still answerable
+ 'session/set_mode': handleSetMode,
+ 'session/set_model': handleSetModel,
+};
+
+const acpConn = new NdjsonConnection({
+ input: process.stdin,
+ output: process.stdout,
+ onMessage: async (msg) => {
+ // Notifications carry no id: ACP v1 session/cancel arrives this way.
+ if (msg.id === undefined) {
+ if (msg.method === 'session/cancel') handleSessionCancel(msg.params ?? {});
+ else warnOnceLog(`notify:${msg.method}`, `ignoring client notification ${msg.method}.`);
+ return;
+ }
+ const handler = acpMethods[msg.method];
+ if (!handler) {
+ acpRespond(msg.id, { error: { code: -32601, message: `method not found: ${msg.method}` } });
+ return;
+ }
+ try {
+ const result = await handler(msg.params ?? {});
+ acpRespond(msg.id, { result });
+ } catch (err) {
+ // Handlers throw either a pre-shaped JSON-RPC error object
+ // ({error:{code,message}} from jsonRpcError) or a plain Error.
+ const error = (err && err.error !== undefined && typeof err.error?.code === 'number')
+ ? err.error
+ : (err && typeof err.code === 'number' && typeof err.message === 'string' && err.error === undefined && err.result === undefined)
+ ? err
+ : jsonRpcError(null, -32001, String(err?.message ?? err)).error;
+ acpRespond(msg.id, { error });
+ }
+ },
+ onParseError: (line, err) => {
+ // ACP client sent garbage - log to stderr, never echo stdout.
+ warnOnceLog('acp-parse', `unparseable line from ACP client: ${String(line).slice(0, 120)} (${err.message})`);
+ },
+ onClose: (reason) => {
+ log(`ACP client disconnected (${reason}) - shutting down.`);
+ child.killTree();
+ setTimeout(() => process.exit(0), 400);
+ },
+});
+
+process.stdin.on('end', () => {
+ // NdjsonConnection also fires onClose; this guards non-piped invocations.
+ if (!acpConn.closed) {
+ log('stdin ended.');
+ child.killTree();
+ setTimeout(() => process.exit(0), 400);
+ }
+});
+process.on('SIGINT', () => {
+ log('SIGINT - killing zcode child tree.');
+ child.killTree();
+ setTimeout(() => process.exit(0), 400);
+});
+void acpConn; // the connection lives for the process lifetime