From eb478bac3514a6f28e78776af7af39603e186aa0 Mon Sep 17 00:00:00 2001 From: fotedev Date: Fri, 30 Oct 2026 04:50:10 +0200 Subject: [PATCH] feat(t3): local T3/ZCode ACP bridge tooling + tests + specs --- docs/T3_LOCAL_SUPPORT.md | 62 +++ docs/T3_ZCODE_BRIDGE_SPEC.md | 379 ++++++++++++++++ tests/t3_bridge.Tests.ps1 | 55 +++ tools/t3_bridge_fake_client.mjs | 216 +++++++++ tools/t3_bridge_lib.mjs | 203 +++++++++ tools/t3_bridge_probe.mjs | 379 ++++++++++++++++ tools/t3_zcode_bridge.mjs | 768 ++++++++++++++++++++++++++++++++ 7 files changed, 2062 insertions(+) create mode 100644 docs/T3_LOCAL_SUPPORT.md create mode 100644 docs/T3_ZCODE_BRIDGE_SPEC.md create mode 100644 tests/t3_bridge.Tests.ps1 create mode 100644 tools/t3_bridge_fake_client.mjs create mode 100644 tools/t3_bridge_lib.mjs create mode 100644 tools/t3_bridge_probe.mjs create mode 100644 tools/t3_zcode_bridge.mjs 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