diff --git a/AGENTS.md b/AGENTS.md index 5974fc6..cb7b9c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,15 +9,15 @@ methods and streams events back as ACP `session/update` notifications. ## Commands -| Task | Command | -|------|---------| -| Build | `pnpm build` | -| Typecheck | `pnpm typecheck` | -| Test (all) | `pnpm test` | -| Test (single file) | `npx vitest run tests/.test.ts` | -| Lint | `pnpm lint` | -| Format (changed files only) | `pnpm prettier --write ` | -| Smoke test | `pnpm smoke` | +| Task | Command | +| --------------------------- | ------------------------------------- | +| Build | `pnpm build` | +| Typecheck | `pnpm typecheck` | +| Test (all) | `pnpm test` | +| Test (single file) | `npx vitest run tests/.test.ts` | +| Lint | `pnpm lint` | +| Format (changed files only) | `pnpm prettier --write ` | +| Smoke test | `pnpm smoke` | **Package manager**: pnpm. **Node**: >=22. **Module system**: ESM (`"type": "module"`). @@ -35,6 +35,7 @@ src/ ├── handlers/ ACP method handlers │ ├── session.ts session/new, session/prompt (turn loop), load, resume │ ├── slash.ts Slash-command interception (/compact, /mcp, etc.) +│ ├── account.ts account/usage_stats — plan quota for remote clients │ ├── extensions.ts ZCode extensions (fork, rewind, compact, steer, …) │ ├── dispatch.ts InternalEvent → ACP session/update dispatch │ ├── io.ts Client notification helpers @@ -51,8 +52,15 @@ src/ │ ├── projection-differ.ts Snapshot diff for turn-completion reconciliation │ └── tool-helpers.ts Diff builder, location extractor ├── interaction/ Permission, ExitPlanMode, AskUserQuestion handling +├── remote/ Remote access (opt-in via ZCODE_ACP_REMOTE=1) +│ ├── broadcast.ts ClientRegistry + broadcast proxy (notify fan-out, request first-wins) +│ ├── config.ts ENV parsing (gate, mandatory token, hub/bridge ports) +│ ├── endpoint.ts Loopback ACP endpoint + hub registration heartbeat +│ └── hub-server.ts zcode-acp-hub: auth, discovery, byte-level WS proxy, ?probe=1 liveness ├── quota/ GLM Coding Plan usage API client (/quota command) -└── bin/quota.ts Standalone zcode-quota CLI +└── bin/ + ├── hub.ts Standalone zcode-acp-hub daemon entry + └── quota.ts Standalone zcode-quota CLI ``` **Key boundary**: `backend/` talks to the ZCode subprocess. `handlers/` talks to @@ -85,6 +93,15 @@ ZCode protocol types into ACP notifications directly — always translate. `withPreemptLock`. Don't bypass it — two simultaneous turns corrupt the listener. - **AGENTS.md is workspace-scoped**: the global `~/.zcode/AGENTS.md` also exists; this file takes precedence for this repo. +- **WS proxy frame type**: the SDK's WS server drops non-text frames, and + `ws.send(buffer)` defaults to a BINARY frame. The hub proxy must forward with + `{ binary: isBinary }` — losing the flag silently eats every proxied message. +- **Broadcast loser promises settle late**: after first-response-wins, aborted + loser requests resolve/reject only when the peer answers the cancellation. + Every raced promise needs a no-op `.catch` or Node crashes on + unhandledRejection. See `src/remote/broadcast.ts`. +- **Remote failures never touch stdio**: any remote-side failure (port, hub, + token) must warn and disable remote only — the editor link stays up. ## Docs to read before sensitive changes diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..31f43b2 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,61 @@ +# zcode-acp-server + +A bridge that connects the ZCode agent backend to ACP-compatible editors. The +bridge process is the session authority; editors and remote clients attach to +it. + +## Language + +**Bridge**: +A running zcode-acp-server process owning one ZCode backend subprocess, the +session registry, and the turn loops. One editor connection = one bridge. +_Avoid_: server (ambiguous with the ACP agent role), hub + +**Primary Client**: +The editor connection over stdio that spawned the bridge and owns its +lifetime (Zed, JetBrains). When it disconnects, the bridge exits. +_Avoid_: host, master client + +**Remote Client**: +Any additional ACP client attached over the network to watch and drive the +same sessions as the Primary Client. +_Avoid_: secondary client, web client (the web UI is just one kind) + +**Session Authority**: +The property that session state (id mappings, turn loops, pending +interactions) lives inside the Bridge process, not in any client or external +store. +_Avoid_: session owner, session store + +**Broadcast**: +Delivering every agent-originated notification to all attached clients +(Primary + Remote), and delivering interaction requests to all of them with +first-response-wins semantics. +_Avoid_: fan-out (fine informally, but Broadcast is the canonical term) + +**Hub**: +The machine-level singleton daemon that is the only public entry point for +remote access. It does token auth, instance discovery, and byte-level +WebSocket proxying — it holds no session state and understands no ACP. +_Avoid_: gateway, broker + +**Instance**: +One registered bridge as seen through the Hub. A remote connection binds to +exactly one instance; instance switching means reconnecting. +_Avoid_: agent, server, worker + +**Replay**: +Delivering a session's stored history to an attaching client as session/update +notifications. Serves both the initial attach and reconnect catch-up. +_Avoid_: history sync, restore, backfill + +**Turn**: +One span of session history from a user message up to (not including) the next +user message. The alignment unit for replay cuts — a replay never starts +mid-turn. Leading non-user messages belong to the first turn. +_Avoid_: round, exchange, message (a turn contains many messages) + +**Cursor**: +An opaque handle identifying the oldest replayed Turn, used to page further +back into history. Valid only while the history it points into is unchanged. +_Avoid_: token (collides with the auth token), offset, bookmark diff --git a/README.md b/README.md index ed41e2f..5a020c9 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,92 @@ automatically. Point `ZCODE_BIN` at the bundled `zcode.cjs`: ## Environment variables -| Variable | Default | Purpose | -| ----------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `ZCODE_BIN` | `zcode` | Path to the ZCode CLI binary or its `.cjs` entry | -| `ZCODE_NODE` | _(discovered)_ | Explicit Node binary to run `ZCODE_BIN` with (must support `node:sqlite`) | -| `ZCODE_MODEL` | _(from config)_ | Override the active model id | -| `ZCODE_BASE_URL` | _(from config)_ | Override the provider base URL | -| `ZCODE_ACP_AUTO_COMPACT_THRESHOLD` | _(unset)_ | Absolute token count that triggers automatic context compaction. After each successful turn (`end_turn`), if `contextUsed >= threshold`, the server invokes `session/compact` to free up context before the next prompt. Set to `0` or leave unset to disable (default). Example: `240000` triggers compaction at 240K tokens. The compaction target itself is decided by the ZCode backend. | -| `ZCODE_ACP_DEBUG` | _(unset)_ | Set to `1` to enable verbose diagnostic logs (event flow, probe loops, status updates). Default is quiet — only warnings (backend pipe errors, command/permission failures, lock timeouts) are emitted. Enable this when diagnosing bridge issues; the logs appear in `Zed.log` prefixed with `[zcode-acp]`. | +| Variable | Default | Purpose | +| ---------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ZCODE_BIN` | `zcode` | Path to the ZCode CLI binary or its `.cjs` entry | +| `ZCODE_NODE` | _(discovered)_ | Explicit Node binary to run `ZCODE_BIN` with (must support `node:sqlite`) | +| `ZCODE_MODEL` | _(from config)_ | Override the active model id | +| `ZCODE_BASE_URL` | _(from config)_ | Override the provider base URL | +| `ZCODE_ACP_AUTO_COMPACT_THRESHOLD` | _(unset)_ | Absolute token count that triggers automatic context compaction. After each successful turn (`end_turn`), if `contextUsed >= threshold`, the server invokes `session/compact` to free up context before the next prompt. Set to `0` or leave unset to disable (default). Example: `240000` triggers compaction at 240K tokens. The compaction target itself is decided by the ZCode backend. | +| `ZCODE_ACP_DEBUG` | _(unset)_ | Set to `1` to enable verbose diagnostic logs (event flow, probe loops, status updates). Default is quiet — only warnings (backend pipe errors, command/permission failures, lock timeouts) are emitted. Enable this when diagnosing bridge issues; the logs appear in `Zed.log` prefixed with `[zcode-acp]`. | +| `ZCODE_ACP_REMOTE` | _(unset)_ | Set to `1` to enable [remote access](#remote-access) — serve the same sessions to additional ACP clients over WebSocket. | +| `ZCODE_ACP_REMOTE_TOKEN` | _(unset)_ | Auth token for remote access. **Mandatory** when `ZCODE_ACP_REMOTE=1`; remote stays disabled without it. | +| `ZCODE_ACP_HUB_PORT` | `8377` | Port of the machine-level `zcode-acp-hub`. Map exactly this one port in your tunnel. | +| `ZCODE_ACP_HUB_HOST` | `127.0.0.1` | Hub bind address. `0.0.0.0` exposes a token-only, unencrypted surface — only for a containerized tunnel agent on a private interface (see [Remote Access](#remote-access)). | +| `ZCODE_ACP_REMOTE_PORT` | `8378` | First loopback port for the bridge's ACP endpoint. Each bridge (each editor window) auto-increments to the next free port. | + +## Remote Access + +With `ZCODE_ACP_REMOTE=1` the bridge additionally accepts ACP connections over +WebSocket, so a phone or browser can watch and drive the **same sessions** as +your editor. Zed (or any ACP editor over stdio) remains the primary client and +owns the process: when the editor disconnects, the bridge — and every remote +attachment — exits with it. + +```text +phone / browser ──WS── tunnel ── hub (127.0.0.1:8377, single entry) + │ byte-level proxy + ▼ + bridge ACP endpoint (127.0.0.1:8378+n) + │ same AgentApp as stdio +Zed ──────── stdio ────────────────┘ +``` + +Enable it per-agent in Zed's settings (Zed merges these into the agent's +environment): + +```json +"agents": { + "ZCode": { + "command": "zcode-acp-server", + "env": { + "ZCODE_ACP_REMOTE": "1", + "ZCODE_ACP_REMOTE_TOKEN": "" + } + } +} +``` + +**Hub.** The first bridge with remote enabled spawns `zcode-acp-hub` as a +detached, machine-level singleton on `ZCODE_ACP_HUB_PORT` (it can also be run +manually). It does three things only: token auth, instance discovery, and +byte-level WebSocket proxying — no session state, no ACP semantics. It exits +after ~10 idle minutes and is re-spawned on demand. Each bridge registers +every 10s as a heartbeat and drops out of discovery ~30s after it stops. + +**Discovery API** (for client authors; fields are additive-only): + +```text +GET /api/instances → [{"id","port","pid","startedAt","workspace", + "sessions":[{"sessionId","title?","updatedAt"}]}] +WS /acp?instance= → proxied to that bridge's endpoint +``` + +Auth is `Authorization: Bearer ` or `?token=` (browsers cannot set WS +headers); `/api/*` sends `Access-Control-Allow-Origin: *` — the token is the +security boundary. A proxied connection stays bound to one instance; switching +instances means reconnecting. + +Building a remote client — web, mobile, or CLI? The full integration contract +(endpoints, framing, lifecycle timings, failure recovery, platform notes) +lives in [docs/REMOTE-CLIENTS.md](docs/REMOTE-CLIENTS.md). + +**Semantics.** All agent notifications are broadcast to every client. +Permission / elicitation requests go to every client and the **first answer +wins**; losing clients receive `$/cancel_request` so their dialogs close. +Concurrent prompts for one session are serialized exactly as they are for a +single editor. Capabilities declared by any client are OR-merged. + +**Tunnels.** Designed for one-port tunnels (Cloudflare Tunnel, frp): map the +hub port only. frp's `tcp` mode passes WebSocket as-is; Cloudflare Tunnel +drops idle WebSocket connections, so the hub sends 30s keepalive pings on both +legs. The bridge endpoint itself is loopback-only and never exposed. + +**Binding beyond loopback.** The hub speaks plain HTTP/WS — the token travels +and authorizes in cleartext, so `ZCODE_ACP_HUB_HOST=0.0.0.0` (needed only when +the tunnel agent runs in its own container) is exactly as safe as the network +it lands on. Keep the bind loopback unless that interface is private to the +tunnel agent, and put TLS in front before mapping it anywhere untrusted. ## Standalone Quota CLI @@ -247,12 +325,12 @@ ZCode backend over **local pipes**; that data reaches the GLM cloud API only because the ZCode backend itself sends it there for inference — this server adds no extra destinations. -| Concern | What & why | -| ------- | ---------- | -| Network | Only one outbound request in the whole codebase: the quota GET (`open.bigmodel.cn` / `api.z.ai`), carrying just your API key — needed to fetch your usage numbers, sends no user content | -| Credentials | API key read from `~/.zcode/v2/config.json` to authenticate the ZCode subprocess and quota request. Never logged, never written elsewhere. OAuth handled entirely by the ZCode subprocess | -| Disk | No new files created. Writes only to the existing `~/.zcode/v2/tasks-index.sqlite` — this **syncs sessions to the ZCode app** so they appear in its history list and full-text search (stores the session title and first prompt) | -| Logging | Diagnostics to stderr for troubleshooting bridge issues. Even with `ZCODE_ACP_DEBUG=1`, no prompts/code/keys are ever logged | +| Concern | What & why | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Network | Only one outbound request in the whole codebase: the quota GET (`open.bigmodel.cn` / `api.z.ai`), carrying just your API key — needed to fetch your usage numbers, sends no user content | +| Credentials | API key read from `~/.zcode/v2/config.json` to authenticate the ZCode subprocess and quota request. Never logged, never written elsewhere. OAuth handled entirely by the ZCode subprocess | +| Disk | No new files created. Writes only to the existing `~/.zcode/v2/tasks-index.sqlite` — this **syncs sessions to the ZCode app** so they appear in its history list and full-text search (stores the session title and first prompt) | +| Logging | Diagnostics to stderr for troubleshooting bridge issues. Even with `ZCODE_ACP_DEBUG=1`, no prompts/code/keys are ever logged | ## License diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 34bd368..8963528 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,7 +14,7 @@ application-client (Zed / JetBrains) | v zcode-acp-server (stdio JSON-RPC ACP) - |-- handlers/ session, extensions, dispatch, server-requests, io, slash + |-- handlers/ session, extensions, dispatch, server-requests, io, slash, account |-- translators/ event-translator, projection-differ, tool-helpers |-- interaction/ adapter |-- config/ options, runtime-model, model-cache @@ -142,12 +142,12 @@ local relay: prompts, code, and tool outputs pass through process memory on their way between the editor and the ZCode subprocess, but reach the GLM cloud API only because the ZCode backend itself sends them for inference. -| Concern | Detail | -| ------- | ------ | -| Network | One outbound request in the whole codebase — `src/quota/client.ts` GET to the quota API, Bearer token only, no body | -| Credentials | API key from `~/.zcode/v2/config.json` (authenticates the subprocess + quota request), never logged. OAuth handled by the ZCode subprocess, not this server | -| Disk | No new files. Writes only to the existing `~/.zcode/v2/tasks-index.sqlite` — syncs sessions into the ZCode app's history & search (session title + first prompt) | -| Logging | `log()`/`warn()` → stderr only for troubleshooting; even with `ZCODE_ACP_DEBUG=1`, no prompts/code/keys are logged | +| Concern | Detail | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Network | One outbound request in the whole codebase — `src/quota/client.ts` GET to the quota API, Bearer token only, no body | +| Credentials | API key from `~/.zcode/v2/config.json` (authenticates the subprocess + quota request), never logged. OAuth handled by the ZCode subprocess, not this server | +| Disk | No new files. Writes only to the existing `~/.zcode/v2/tasks-index.sqlite` — syncs sessions into the ZCode app's history & search (session title + first prompt) | +| Logging | `log()`/`warn()` → stderr only for troubleshooting; even with `ZCODE_ACP_DEBUG=1`, no prompts/code/keys are logged | ## Module Responsibilities @@ -170,15 +170,16 @@ API only because the ZCode backend itself sends them for inference. ### `handlers/` — ACP method handling -| File | Responsibility | -| -------------------- | ------------------------------------------------------------------------------------------------------------ | -| `session.ts` | session/new/list/resume/load/prompt/set_config_option/cancel | -| `extensions.ts` | fork/rewind/rewindCascade/goal/compact/steer/cancelBackgroundTask/setModel/setMode/setThoughtLevel | -| `dispatch.ts` | dispatchEvent single exit point: InternalEvent → ACP session/update | +| File | Responsibility | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session.ts` | session/new/list/resume/load/prompt/set_config_option/cancel | +| `extensions.ts` | fork/rewind/rewindCascade/goal/compact/steer/cancelBackgroundTask/setModel/setMode/setThoughtLevel | +| `dispatch.ts` | dispatchEvent single exit point: InternalEvent → ACP session/update | | `background-tasks.ts` | Session-scoped `BackgroundTaskListener` — forwards background sub-agent status (`session.updated` taskId) + completion-notification turns to the client OUTSIDE request handlers (lives across prompts) | -| `server-requests.ts` | Handle zcode interaction/* requests (tool auth, ExitPlanMode, AskUserQuestion), protocol negotiation routing | -| `io.ts` | ACP notification helpers (including `sendAvailableCommandsDeferred` deferred notification) | -| `slash.ts` | Interception of `/`-prefixed commands (/compact /goal /fork /rewind /steer /model /mode /thought) | +| `server-requests.ts` | Handle zcode interaction/* requests (tool auth, ExitPlanMode, AskUserQuestion), protocol negotiation routing | +| `io.ts` | ACP notification helpers (including `sendAvailableCommandsDeferred` deferred notification) | +| `slash.ts` | Interception of `/`-prefixed commands (/compact /goal /fork /rewind /steer /model /mode /thought); non-advertised `/x` prompts are neutralized into plain text (`neutralizeSlashText`) | +| `account.ts` | `account/usage_stats` — account-level plan quota for remote clients (Proposal 0002; quota pipeline + graceful error) | ### `interaction/` — Interaction bridging @@ -194,6 +195,37 @@ API only because the ZCode backend itself sends them for inference. | `runtime-model.ts` | runtimeModel overlay construction and application | | `model-cache.ts` | Model ID cache and usage initialization | +### `remote/` — Remote access (opt-in via `ZCODE_ACP_REMOTE=1`) + +| File | Responsibility | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `broadcast.ts` | ClientRegistry + broadcast proxy: notify fans out to all clients; request is first-response-wins with loser `$/cancel_request` | +| `config.ts` | ENV parsing (gate, mandatory token, hub/bridge ports) | +| `endpoint.ts` | Loopback ACP endpoint (SDK AcpServer transport, port auto-increment) + hub registration/heartbeat | +| `hub-server.ts` | The hub singleton: token auth, instance discovery, byte-level WS proxying, heartbeat pruning, on-demand `?probe=1` liveness, idle exit, version self-upgrade | + +When enabled, the same `AgentApp` serves the stdio editor and a loopback +WebSocket endpoint. Every connection (editor or remote) joins the broadcast +registry via `trackConnections`, so one turn's notifications reach all clients +regardless of who prompted. The bridge registers itself with the machine-level +`zcode-acp-hub` (`bin/hub.ts`), which is the only public entry point and holds +no session state (see `docs/adr/0002`). The bridge's lifetime still follows the +stdio client (ADR-0001); the listener is `unref()`'d so remote clients alone +never keep the process alive. + +Hub upgrades are self-managing: each heartbeat carries the bridge's package +version, and a hub that sees a NEWER bridge replies `{ok, restarting}`, exits, +and is re-spawned by that bridge from its own (upgraded) `dist/` within a few +seconds. Equal, older, or absent versions never trigger a restart — downgrades +and mixed-version fleets are fine. Without this handshake a long-lived hub +would keep running pre-upgrade code until its 10-minute idle exit. + +Discovery liveness has two layers: the heartbeat TTL (30s, pruned every 5s) +drops bridges that stopped registering — the fallback for hard kills — and +`GET /api/instances?probe=1` actively TCP-probes each registered loopback port +on demand, so a client refresh gets an immediately-honest list with no +background probing cost. + ## Key State Machines ### Turn state diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f746af8..9559313 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -33,7 +33,9 @@ have no ACP equivalent. Listed for completeness only — the bridge does not intend to surface them. `automation/create`, `automation/list`, `automation/delete` (scheduled tasks), -`usage/stats`, `workspace/readState`, `workspace/upsertModelProvider`, +`usage/stats` (token analytics; the account-level plan quota it does NOT cover +is exposed via the bridge's own `account/usage_stats` — see Proposal 0002), +`workspace/readState`, `workspace/upsertModelProvider`, `workspace/removeModelProvider`, `workspace/updateProviderRegistry`, `workspace/setDefaultModel`, `workspace/setDefaultThoughtLevel`, `workspace/setDefaultMode`, `workspace/generateText`, `mcp/list`, diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 7ca5aab..7146247 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -12,11 +12,11 @@ resembles JSON-RPC, but **does not include the `jsonrpc` field**. Messages are classified by the presence of `id` and `method`: -| Combination | Type | Direction | -|------|------|------| -| `id` + no `method` | Response | zcode -> bridge | -| `id` + `method` | Request | bridge -> zcode or zcode -> bridge | -| `method` + no `id` | Notification | bidirectional | +| Combination | Type | Direction | +| ------------------ | ------------ | ---------------------------------- | +| `id` + no `method` | Response | zcode -> bridge | +| `id` + `method` | Request | bridge -> zcode or zcode -> bridge | +| `method` + no `id` | Notification | bidirectional | ### Request format @@ -84,6 +84,7 @@ the backend session (this RPC) on the first prompt / config change / extension method, so an editor startup that never sends a message leaves no session. **Request:** + ```json { "id": 1, @@ -99,6 +100,7 @@ method, so an editor startup that never sends a message leaves no session. ``` **Response:** + ```json { "id": 1, @@ -117,6 +119,7 @@ method, so an editor startup that never sends a message leaves no session. List all sessions. **Request:** + ```json { "id": 2, @@ -142,6 +145,7 @@ empty one if the placeholder was never used. Real ids from `session/list` pass through unchanged. **Request:** + ```json { "id": 3, @@ -161,6 +165,7 @@ through unchanged. Send a prompt. **Request:** + ```json { "id": 4, @@ -173,6 +178,7 @@ Send a prompt. ``` **Response:** + ```json { "id": 4, @@ -200,6 +206,7 @@ Stop the current turn (fire-and-forget). Read the session state and projection. **Request:** + ```json { "id": 5, @@ -211,6 +218,7 @@ Read the session state and projection. ``` **Response:** + ```json { "id": 5, @@ -226,9 +234,7 @@ Read the session state and projection. "model": { "current": { "modelId": "GLM-5.2" } }, "thoughtLevel": { "current": "high" } }, - "todos": [ - { "content": "Implement login", "status": "pending", "priority": "high" } - ] + "todos": [{ "content": "Implement login", "status": "pending", "priority": "high" }] } } ``` @@ -238,6 +244,7 @@ Read the session state and projection. Fetch the session's historical messages. **Request:** + ```json { "id": 6, @@ -255,6 +262,7 @@ Fetch the session's historical messages. Subscribe to a session's event push. **Request:** + ```json { "id": 7, @@ -269,6 +277,7 @@ Subscribe to a session's event push. ``` **Response:** + ```json { "id": 7, @@ -322,6 +331,7 @@ Model streaming output. ``` `kind` can be: + - `text_delta`: text delta - `reasoning_delta`: reasoning text delta - `tool_call`: tool call declaration (caches toolName and input) @@ -348,6 +358,7 @@ Tool status update. ``` `kind` can be: + - `scheduled`: tool scheduled - `started`: tool started executing - `progress`: progress update (stdoutTail / stderrTail) @@ -497,6 +508,7 @@ Tool permission request. User input request (ExitPlanMode / AskUserQuestion). **ExitPlanMode:** + ```json { "id": 101, @@ -512,6 +524,7 @@ User input request (ExitPlanMode / AskUserQuestion). ``` **AskUserQuestion:** + ```json { "id": 102, @@ -539,17 +552,18 @@ User input request (ExitPlanMode / AskUserQuestion). ZCode `interaction/*` requests are routed to different ACP interaction mechanisms based on client capabilities: -| Request type | Client supports elicitation.form | Client does not | -|---------|:------------------------:|:----------:| -| Tool auth (`interaction/requestPermission`) | `session/request_permission` | `session/request_permission` | -| ExitPlanMode (`interaction/requestUserInput` + plan_approval) | `elicitation/create` (approve/reject form) | `session/request_permission` | -| AskUserQuestion (`interaction/requestUserInput`) | `elicitation/create` (single form) | per-question `session/request_permission` | +| Request type | Client supports elicitation.form | Client does not | +| ------------------------------------------------------------- | :----------------------------------------: | :---------------------------------------: | +| Tool auth (`interaction/requestPermission`) | `session/request_permission` | `session/request_permission` | +| ExitPlanMode (`interaction/requestUserInput` + plan_approval) | `elicitation/create` (approve/reject form) | `session/request_permission` | +| AskUserQuestion (`interaction/requestUserInput`) | `elicitation/create` (single form) | per-question `session/request_permission` | **Capability detection**: at `initialize` time the client declares support via `clientCapabilities.elicitation.form`. The server detects it with `server.supportsElicitationForm()`. **elicitation form example** (AskUserQuestion): + ```json { "method": "elicitation/create", @@ -591,6 +605,7 @@ overrides the dropdown (single-select) or is appended to the picked values that question without cancelling the form. **elicitation response** (accept/decline/cancel): + ```json { "action": "accept", @@ -604,6 +619,7 @@ typing into the field is the reject action. Submitting with the field empty approves the plan; submitting with text rejects it and returns the text to zcode as the decline `reason` (so the agent sees the redirection when it re-plans). The cancel/decline button is a plain reject with no reason. + ```json { "method": "elicitation/create", @@ -633,6 +649,7 @@ re-plans). The cancel/decline button is a plain reject with no reason. Fork a new session from a checkpoint. **Request:** + ```json { "id": 8, @@ -649,6 +666,7 @@ Fork a new session from a checkpoint. Rewind to a checkpoint. **Request:** + ```json { "id": 9, @@ -666,6 +684,7 @@ Rewind to a checkpoint. Read / set / replace / clear the goal. **Request:** + ```json { "id": 10, @@ -685,6 +704,7 @@ Read / set / replace / clear the goal. Compact the conversation history. **Request:** + ```json { "id": 11, @@ -700,6 +720,7 @@ Compact the conversation history. Append instructions to a running turn. **Request:** + ```json { "id": 12, @@ -716,6 +737,7 @@ Append instructions to a running turn. Switch the session mode. **Request:** + ```json { "id": 13, @@ -732,6 +754,7 @@ Switch the session mode. Set the thought level. **Request:** + ```json { "id": 14, @@ -807,10 +830,10 @@ lifecycle on the same stream: The bridge's session-scoped `BackgroundTaskListener` turns these into a dedicated ACP tool card (`[background] `) plus status updates: -| Backend event | ACP notification | -|---|---| +| Backend event | ACP notification | +| ------------------------------------------ | -------------------------------------------------------------- | | first `session.updated` (status `running`) | `tool_call` (new card, `kind:"other"`, `status:"in_progress"`) | -| `session.updated` (status `completed`) | `tool_call_update` (`status:"completed"`) | +| `session.updated` (status `completed`) | `tool_call_update` (`status:"completed"`) | `session.updated` events WITHOUT a `taskId` (e.g. usage updates) are ignored by the background listener — they remain owned by the turn loop. @@ -883,11 +906,11 @@ The mechanism: `toolCallId` is unknown to `terminalSentData` (sub-agent case), the listener falls back to minting a fresh `bg_*` card — the Agent sub-agent path above. -| Backend event | ACP notification (background Bash) | -|---|---| -| first `session.updated` (status `running`) | `tool_call_update` on the launch card (`status:"in_progress"`) | +| Backend event | ACP notification (background Bash) | +| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| first `session.updated` (status `running`) | `tool_call_update` on the launch card (`status:"in_progress"`) | | `session.updated` (status `completed`, with `outputTail`) | `terminal_output` (final output, if not already streamed) + `tool_call_update` with `terminal_exit` (`status:"completed"`) | -| `session.updated` (status `failed`) | `tool_call_update` with `terminal_exit` (`status:"failed"`, exit_code 1) | +| `session.updated` (status `failed`) | `tool_call_update` with `terminal_exit` (`status:"failed"`, exit_code 1) | `session/cancelBackgroundTask` for a background Bash task additionally emits `terminal_exit` with `_meta.backgroundTask.cancelled = true` so the terminal @@ -898,13 +921,11 @@ UI closes on cancellation. Cancels a background task. The bridge additionally marks the corresponding ACP tool card as `failed` with `_meta.backgroundTask.cancelled = true`. - - -| ZCode CLI version | session/subscribe | Extension methods | Notes | -|---------------|-------------------|----------|------| -| >= 0.15.0 | Supported | All supported | Full functionality | -| >= 0.14.8 | Supported | Partially supported | workspace/* unavailable | -| 0.14.5 ~ 0.14.7 | Not supported | Not supported | Incompatible with this project | +| ZCode CLI version | session/subscribe | Extension methods | Notes | +| ----------------- | ----------------- | ------------------- | ------------------------------ | +| >= 0.15.0 | Supported | All supported | Full functionality | +| >= 0.14.8 | Supported | Partially supported | workspace/* unavailable | +| 0.14.5 ~ 0.14.7 | Not supported | Not supported | Incompatible with this project | ## Additional backend methods (not wired into the bridge) @@ -912,3 +933,22 @@ The backend exposes more RPC methods than the bridge uses (sub-agent listing, event pull, session usage/close, automation, workspace config, MCP/plugins). These have no ACP-side counterpart yet. See [`BACKLOG.md`](./BACKLOG.md) for the full list and which are candidates for future support. + +## Multi-client semantics (remote access) + +When `ZCODE_ACP_REMOTE=1` is enabled, the bridge accepts additional ACP clients +over WebSocket (via the machine-level hub) alongside the stdio editor. All +clients share the same backend sessions; the rules below define how one agent +serves many clients. + +| Aspect | Behaviour | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session/update` notifications | Broadcast to every connected client. A client that never saw a session (e.g. an editor receiving a phone-created session) simply ignores the update. | +| `session/request_permission`, `elicitation/create` | Sent to every client; the **first response wins**. Losing requests are aborted, which emits `$/cancel_request` so the losing client dismisses its dialog and replies `RequestCancelled`. | +| Capabilities | OR-merged across clients at each `initialize` (booleans union, `_meta` shallow-merged). A capability any client declares is enabled for interaction routing. | +| Concurrent `session/prompt` on one session | Serialized by the per-session preempt lock — identical to the single-client case; a second client's prompt preempts or queues the same way. | +| `session/cancel` | Affects the shared turn regardless of which client sent it. | +| Process lifetime | Follows the stdio client: when the editor disconnects, the bridge (and every remote attachment) exits. Remote clients never extend the lifetime. | + +Transport details (hub discovery API, token auth, tunnel notes) live in the +[Remote Access](../README.md#remote-access) section of the README. diff --git a/docs/REMOTE-CLIENTS.md b/docs/REMOTE-CLIENTS.md new file mode 100644 index 0000000..7d2307a --- /dev/null +++ b/docs/REMOTE-CLIENTS.md @@ -0,0 +1,260 @@ +# Remote Clients — Integration Guide + +How to attach any out-of-editor client — browser SPA, mobile app, CLI, desktop +tool — to bridge sessions over the network. This document IS the contract: +everything here is implemented by `zcode-acp-hub` and the bridge's remote +endpoint; anything not written here is not part of the contract. + +ACP method semantics are defined by the [ACP spec](https://agentclientprotocol.com); +this guide covers only the transport, discovery, and the multi-client behaviors +on top of it. For how ACP methods map to the ZCode backend, see +[PROTOCOL.md](PROTOCOL.md). + +## Topology + +```text +remote client ──WS── tunnel ── hub (single entry, one mapped port) + │ byte-level proxy, no ACP semantics + ▼ + bridge ACP endpoint (loopback, never exposed) + │ same AgentApp as stdio +ACP editor ────── stdio ──────────┘ +``` + +- The hub is the **only** public entry. It does token auth, instance discovery, + and byte-level WebSocket proxying — no session state, no ACP semantics + (ADR-0002). The bridge endpoint is loopback-only; nothing dials it but the + hub. +- One WS connection is bound to **one bridge instance** for its whole lifetime. + Switching instances means opening a new connection. +- The bridge process lives and dies with the editor that spawned it (ADR-0001): + close the editor and every remote attachment drops. There is no standalone + server that outlives the editor. + +## Security model + +- One shared bearer token (`ZCODE_ACP_REMOTE_TOKEN`) guards both the discovery + API and the ACP WebSocket. Possession of the token equals **full control of + every agent session** — prompting, answering permissions, tool-driven file + writes. Treat it like a password: long, random, never committed. +- The hub speaks plain HTTP/WS. TLS is expected from the tunnel in front + (Cloudflare Tunnel terminates it; with frp, terminate TLS in front or keep + the network trusted). The token on cleartext HTTP over an untrusted network + is a credential leak. +- `/api/*` responses carry `Access-Control-Allow-Origin: *` — the token is the + security boundary; there is no origin restriction. + +## Discovery API + +| Endpoint | Auth | Purpose | +| -------------------- | -------- | ------------------------------------------------------------ | +| `GET /api/health` | none | Liveness probe; `200` body `ok`. | +| `GET /api/instances` | required | Registered bridge instances. Add `?probe=1` to verify first. | + +HTTP auth: `Authorization: Bearer ` or `?token=`. + +`/api/instances` returns a JSON array (sorted by start time): + +```json +[ + { + "id": "72341", + "port": 8378, + "pid": 72341, + "startedAt": 1723800000000, + "workspace": "/Users/me/proj", + "sessions": [{ "sessionId": "5f0c…", "title": "Fix login bug", "updatedAt": 1723800012000 }] + } +] +``` + +- `id` is the bridge process id — stable for that editor window's lifetime, + unique per window. +- **On refresh, call `/api/instances?probe=1`**: the hub TCP-probes each + registered bridge's loopback port and prunes unreachable ones before + answering. A plain `GET` returns the heartbeat-based view, which can list a + hard-killed bridge for up to the 30s heartbeat TTL. +- `sessions[].sessionId` is the ACP session id: pass it to `session/load` + after connecting. `title` is adopted from the backend for resumed sessions + and set after a fresh session's first turn — it can still be absent for a + session that has never completed a turn. +- Poll every 3–5s. There is no push notification for registry changes yet. +- Fields are **additive-only** across releases — ignore fields you don't know. + +Lifecycle timings: a bridge re-registers every 10s (the registration doubles as +heartbeat); an instance disappears ~30s after its heartbeats stop; the hub +exits after ~10 idle minutes with no instances and no proxies, and the next +bridge re-spawns it on demand. + +## Connecting + +```text +ws(s):///acp?instance=&token= +``` + +- Native clients may send `Authorization: Bearer ` instead of the query + parameter; browsers cannot set WS headers, which is why `?token=` exists. + Prefer the header when you can — it keeps the token out of URLs and logs. +- Handshake failures (bad token, unknown instance id) destroy the socket + before open. Treat any non-open outcome as "re-discover, then retry". +- Framing: one JSON-RPC message per **text** frame. Binary frames are ignored. +- The hub sends WebSocket pings every 30s on both legs (tunnels drop idle + links). Browser and native WS stacks answer pongs automatically — nothing to + implement, but don't disable pongs. + +## ACP session flow + +1. `initialize` — `protocolVersion` MUST be the **number** `1` (a string is + rejected). Nothing else may be sent before it. +2. Attach or create: + - `session/load { sessionId, cwd, mcpServers }` with an id from discovery — + replays the conversation history (text + tool summaries) as + `session/update`s, so a freshly attached client can render the full + story. `cwd` and `mcpServers` (even `[]`) are required — the SDK's params + schema rejects the request without them. + - `session/new { cwd? }` — a new session on that bridge. + - `session/list` enumerates the bridge's known sessions. +3. Drive: `session/prompt`, `session/cancel`, `session/set_config_option` + (model / mode / thought level), slash commands in the prompt text — + see [PROTOCOL.md](PROTOCOL.md). + +## Account quota (`account/usage_stats`) + +Non-standard, additive (Proposal 0002). Plan quota is **account-level**, so it +is a pull-only request — callable any time after `initialize`, no session +required. Fetch once after attach and on demand; quota changes are slow, there +is no push. + +The response mirrors the `zcode-quota` CLI card's data model — one GLM section +plus one Opencode Go section — so clients can reproduce the CLI layout +exactly: + +```json +→ { "id": 7, "method": "account/usage_stats", "params": {} } +← { "id": 7, "result": { + "glm": { + "kind": "success", + "level": "pro", + "items": [ + { "key": "token_5h", "label": "5h", "usedPercent": 35, + "nextResetTime": 1723812000000 }, + { "key": "mcp", "label": "MCP", "usedPercent": 10, "usedCount": 3, + "totalCount": 30, "nextResetTime": 1723812000000, + "detail": [{ "modelCode": "search-prime", "usage": 2 }] } + ] + }, + "opencode": { + "kind": "success", + "windows": [ + { "key": "rolling", "label": "5h", "usagePercent": 5, + "resetsAt": 1723812000000 }, + { "key": "weekly", "label": "Week", "usagePercent": 25, + "resetsAt": 1724071200000 } + ] + } + } } +``` + +- `glm` (`kind`: `success` | `auth_error` | `rate_limited` | `unavailable`): + on success, `level` is the plan level and `items` carries one entry per + window (`5h` / `Week` / `MCP`) with `usedPercent` (0–100) always present; + `usedCount`/`totalCount`/`nextResetTime` (epoch ms) and the per-model + `detail` breakdown only when the API reports them. +- `opencode` (`kind`: `success` | `not_configured` | `auth_error` | + `unavailable`): on success, `windows` carries the rolling (`5h`) / weekly + (`Week`) / monthly (`Month`, when exposed) windows; the dashboard's relative + countdown is resolved to an absolute `resetsAt` (epoch ms). `not_configured` + means the user never set OpenCode Go credentials — omit the section, like + the CLI does. +- Provider failures are per-section `kind` strings, not JSON-RPC errors — + render the same status line the CLI would (e.g. auth expired) and retry + later. Only transport-level failures reject the request. +- Cached ~10s server-side (same caches as the `/quota` command). + +## Slash-command handling + +Only the commands the bridge advertises via `available_commands_update` (plus +`skill`/`init` and `$`-skills) are treated as commands. Any other `/`-leading +prompt — e.g. a pasted directory path — is delivered to the model as plain +text with an invisible zero-width-space prefix; clients see the text verbatim +in replay and echoes. Clients should not special-case this. + +## Tail replay and history pagination + +Replaying a long session's full history is O(history) on every attach and +reconnect. The bridge supports tail replay (non-standard, additive — omit +everything below and you get the full replay): + +- **Tail limit**: `session/load` with `_meta.zcode.limit` (NOT top-level — + the SDK's params schema strips unknown top-level keys; `_meta` is the + preserved extension channel). It counts **messages**, and the replay is + aligned back to the start of the turn containing the oldest message — never + a mid-turn cut. `0` attaches with metadata only. Clamped to `[0, 500]`. +- **`replayMeta`** rides top-level in the result: + + ```json + { + "replayMeta": { + "cursor": "…", + "hasMore": true, + "replayedMessages": 47, + "replayedTurns": 12, + "totalMessages": 1893, + "totalTurns": 412 + } + } + ``` + +- **`session/load_earlier`** (`{ sessionId, before, limit }`, limit defaults + to 50) delivers one page of `session/update`s strictly older than `before`, + oldest → newest — prepend them. Same `replayMeta` shape in the result; + `hasMore: false` ends pagination. Requires the session to be attached in + this bridge; it never triggers an implicit backend resume. +- **Cursor expiry**: a cursor is valid only while the history it points into + is unchanged — turns appended after it was minted (the session moved on) + keep it valid. After the session compacts or truncates, `load_earlier` + returns a `"cursor expired"` error — the recovery is a fresh `session/load`. + +While a replay batch is in flight, live updates for the same session queue +behind it: batches are atomic and never interleave with the live turn. + +UI-side recipes for consuming all of this — state model, prepend handling, +scroll pagination, reconnect recovery — live in +[REPLAY-GUIDE.md](REPLAY-GUIDE.md). + +## Multi-client semantics + +The stdio editor and every remote client are peers on the same sessions: + +- All agent notifications (`session/update`) are broadcast to every client. +- Permission and elicitation requests go to **every** client and the **first + response wins**. Losers receive `$/cancel_request` for the pending request + id — close the dialog and drop it. Never leave a request unanswered forever. +- Capabilities are OR-merged across clients: a remote client advertising e.g. + `elicitation.form` upgrades the shared interaction for the whole bridge. +- Concurrent prompts for one session are serialized by the bridge — two + clients prompting at once cannot interleave turns. + +## Failure & recovery + +| Symptom | Cause | Client action | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| WS closes | bridge exited (editor closed) or network drop | Poll `/api/instances`; if the instance is gone, its sessions are gone too — drop it from the UI. | +| Instance missing from `/api/instances` | Heartbeats stopped >30s, or `?probe=1` found the bridge port unreachable | Remove the instance from the UI. | +| Connect fails for a while | Hub process died; a bridge re-spawns it on the next heartbeat (typically ≤10s, worst case ~1min under the spawn throttle). Also expected for a few seconds after a bridge upgrade: the hub notices a newer bridge, restarts, and is re-spawned from the upgraded install | Retry with backoff. | +| Disconnect mid-turn | Mobile network flap, background suspension | The turn continues server-side. Reconnect and `session/load` — history replay is the recovery path. | + +Updates emitted while you are disconnected are not individually re-delivered; +`session/load` replay is the catch-up mechanism. + +## Platform notes + +- **Browser**: a page served over `https://` can only open `wss://` — take TLS + from the tunnel. CORS is `*`, so any static host works; the client needs no + backend of its own. +- **Mobile**: background suspension kills the socket; on resume, reconnect and + `session/load` the previously open session. Store hub URL + token locally; + reconnect with exponential backoff. The 30s hub pings keep NAT mappings warm + while foregrounded. +- **CLI / native tools**: prefer the `Authorization` header; a one-shot + `session/prompt` + update stream is a perfectly fine first client. diff --git a/docs/REPLAY-GUIDE.md b/docs/REPLAY-GUIDE.md new file mode 100644 index 0000000..76a8fd2 --- /dev/null +++ b/docs/REPLAY-GUIDE.md @@ -0,0 +1,124 @@ +# Replay guide — building a client UI on tail replay + +Audience: frontend implementors (web, mobile, CLI TUI) of any ACP client for +this bridge. The **wire contract** (field names, errors, framing) lives in +[REMOTE-CLIENTS.md](REMOTE-CLIENTS.md) — this guide does not repeat it; it +shows how to _consume_ it: the UI state model, scroll-up pagination, and +reconnect recovery. + +## What changed and why you care + +Before tail replay, every `session/load` (initial attach AND every reconnect) +replayed the **entire** history as `session/update` notifications. A measured +280-message session cost ~800 notifications; sessions only grow. With tail +replay the same attach ships only the visible tail and older history arrives +on demand. Live numbers from the reference e2e run (471-message / 82-turn +session, `limit: 30`): + +- `session/load` replayed 36 messages (30 requested, aligned to a turn start) + as 118 notifications, and returned `replayMeta` — projected full replay for + that session is ~1350 notifications (~91% cut). +- A follow-up `session/load_earlier` page delivered 30 more messages / 10 + turns as 51 notifications. + +Everything is additive: omit `_meta.zcode.limit` and you get the old +full-replay behavior unchanged. + +## Attach strategy + +Pick the limit from your UI budget, not from the history size: + +- `limit: 0` — metadata-only attach. You get `replayMeta` + (`totalMessages`, `totalTurns`, `hasMore: true`, cursor at the end of + history) and zero replayed messages. Render an empty/"load older" state. +- `limit: N` — replay at most the last N **messages**, aligned back to the + start of the turn containing the oldest one. Expect + `replayedMessages ≥ N` when alignment extends the batch (the e2e run asked + for 30 and got 36). Never a mid-turn cut: a tool call always arrives with + its updates. +- No `_meta` — full replay (legacy/Zed path). + +The response always carries `replayMeta`. `hasMore: false` means the whole +history is already in front of you — hide the "load older" affordance. + +## UI state model + +Three id kinds arrive in `session/update` notifications; each kind merges +differently: + +| Update kind | Id field | Merge rule | +| -------------------------------------------- | ------------ | ----------------------------------------------------------------------- | +| `user_message_chunk` / `agent_message_chunk` | `messageId` | append text to that message's bubble | +| `agent_thought_chunk` | `messageId` | append; ids carry a `thought_` prefix, so thoughts are their own stream | +| `tool_call` / `tool_call_update` | `toolCallId` | first `tool_call` creates the card, later updates mutate it | + +- `messageId`s are the backend's stable message ids (the e2e run saw zero + fallback ids across hundreds of messages) — key your message list by them + and dedupe on every insert. +- One message = several chunks (text, thoughts, tool calls). Group chunks by + `messageId`/`toolCallId`, not by arrival order alone. +- Ordering rule: replay batches and `load_earlier` pages arrive **oldest → + newest and must be prepended**; live-turn updates arrive newest-last and + append. The bridge serializes a replay batch against the live turn for the + same session (they never interleave), so you can apply live updates while a + pagination page is in flight without ordering races. +- `usage_update` / `available_commands_update` are session-level metadata, + not list items. + +## Scroll-up pagination + +``` +state: cursor = attachResult.replayMeta.cursor + hasMore = attachResult.replayMeta.hasMore + +onScrolledNearTop(): + if !hasMore or requestInFlight: return + res = request("session/load_earlier", { sessionId, before: cursor, limit: 50 }) + prependUpdates(res.deliveredSessionUpdates) // keep the user's scroll anchor + cursor = res.replayMeta.cursor + hasMore = res.replayMeta.hasMore +``` + +- `limit` defaults to 50; clamp is `[0, 500]`. +- `hasMore: false` ends the loop. A redundant extra call is harmless: it + returns an empty page with `hasMore: false`. +- Keep a scroll anchor when prepending, or every page will yank the viewport + to the top. + +## Cursor expiry — the one error to handle + +A cursor dies only when the history **shrank** (compaction, truncation): +`session/load_earlier` then fails with `-32602 "cursor expired"`. Turns +**appended** after the cursor was minted (the conversation moved on) keep it +valid — you do NOT need to refresh the cursor after every live turn. + +Recovery for `"cursor expired"`: re-run `session/load` with your tail limit +and rebuild the visible list from its `replayMeta`; deeper history comes back +through normal pagination. Treat it as a rare event, not a flow. + +Never parse the cursor — it is opaque. (For the curious it round-trips +`{ v, index, totalTurns, id? }`, but the shape may change without notice.) + +## Reconnect recipe + +1. Re-discover the instance (`/api/instances`) — the bridge pid changes on + editor restart. Then `initialize` (`protocolVersion`: the number `1`), + then `session/load { sessionId, cwd, mcpServers: [] }` — `cwd` and + `mcpServers` are required even when empty. +2. Attach with `limit` = your viewport budget, not what the user had scrolled + to. Diff against your cached messages by `messageId` (ids are stable + across restarts of both bridge and backend). +3. Live updates fill the tail from here. If the user scrolls into history you + no longer have, `load_earlier` from the new cursor refetches just those + pages — do not try to restore the full old scroll depth on reconnect. + +## Checklist + +- [ ] `limit` rides in `_meta.zcode.limit` (top-level unknown keys are + stripped by the SDK schema — silently). +- [ ] `session/load` params include `cwd` and `mcpServers` (even `[]`). +- [ ] Message list keyed/deduped by `messageId`; tool cards by `toolCallId`. +- [ ] Pagination pages prepended, live updates appended. +- [ ] `"cursor expired"` handled by full re-attach. +- [ ] Cursor stored per session, never parsed, never persisted across app + runs (it is only meaningful to the bridge that minted it). diff --git a/docs/adr/0001-bridge-lifetime-follows-primary-client.md b/docs/adr/0001-bridge-lifetime-follows-primary-client.md new file mode 100644 index 0000000..52d29a8 --- /dev/null +++ b/docs/adr/0001-bridge-lifetime-follows-primary-client.md @@ -0,0 +1,14 @@ +# Bridge lifetime follows the Primary Client + +When remote access is enabled, remote clients attach to a bridge that the +Primary Client (the editor over stdio) spawned. We decided the bridge process +lives and dies with its Primary Client: when the editor disconnects, the +bridge (and every remote attachment) exits, even if remote clients are +mid-turn. + +Rationale: session authority lives inside the bridge process. Keeping a +bridge alive after the editor leaves means the editor's reconnect spawns a +second bridge with its own backend subprocess, and both compete for the same +ZCode session files. Tying lifetime to the Primary Client matches the +existing mental model — the editor owns the session, remote clients are a +live window onto it. diff --git a/docs/adr/0002-stateless-hub-over-per-bridge-acp-endpoints.md b/docs/adr/0002-stateless-hub-over-per-bridge-acp-endpoints.md new file mode 100644 index 0000000..6a596b3 --- /dev/null +++ b/docs/adr/0002-stateless-hub-over-per-bridge-acp-endpoints.md @@ -0,0 +1,23 @@ +# Remote access: stateless hub over per-bridge ACP endpoints + +Remote clients must reach any active bridge through a single tunneled port +(Cloudflare Tunnel / frp); exposing one port per bridge does not survive that +constraint. We decided each bridge serves its own ACP endpoint on loopback +only (auto-incrementing ports from 8378), and a machine-singleton hub +(`zcode-acp-hub`, fixed port 8377, auto-spawned detached by bridges, idle-exits +after ~10 minutes without registrations) does exactly three things: token +authentication, instance discovery, and byte-level WebSocket proxying +(`WS /acp?instance=` → the chosen bridge). + +The hub has no ACP semantics and no business state; session authority stays +in the bridges. A remote connection binds to one instance for its whole +lifetime; switching instances means opening a new connection. We rejected a +globally-routing gateway that aggregates sessions across bridges and speaks +ACP itself — it would re-create session management outside the bridges, which +is the "fat hub" design this project deliberately avoids. + +The hub is the only public entry point, so it is the only place that enforces +the token; the tunnel maps exactly this one port. WebSocket ping/pong +heartbeat (~30s) is mandatory on both hub and proxy connections because ACP +streams are silent when idle and proxy layers (notably Cloudflare) drop idle +connections. diff --git a/docs/adr/0003-tail-replay-meta-and-cursor-pagination.md b/docs/adr/0003-tail-replay-meta-and-cursor-pagination.md new file mode 100644 index 0000000..ed55800 --- /dev/null +++ b/docs/adr/0003-tail-replay-meta-and-cursor-pagination.md @@ -0,0 +1,40 @@ +# Tail replay: extension params ride in _meta, history pages by cursor + +`session/load` replays full history to attaching clients, so attach and +reconnect cost grow with session age (Proposal 0001). Extending the protocol +for tail replay required three decisions that are now wire contract and hard +to reverse. + +**Extension parameters on spec methods ride in `_meta.zcode`, not top-level.** +The ACP SDK registers spec methods like `session/load` with a zod +`z.object` params schema (`zLoadSessionRequest`), and zod's default behavior +strips unknown keys during `.parse()` — a top-level `limit` would be silently +removed before our handler ever sees it. `_meta` is the one channel the schema +preserves (`record(string, unknown)`), and it is also where the ACP spec +points extension payloads. Responses need no escape hatch: the SDK's response +mapping for `session/load` is a passthrough, so `replayMeta` rides top-level +in the result. Our own non-standard method `session/load_earlier` takes a +bridge-provided parser, so its params stay top-level — the asymmetry is +intentional and documented in REMOTE-CLIENTS.md. + +**`limit` counts messages, aligned back to turn boundaries.** Clients render +messages (the app shows the last ~30), but turns are the atomic semantic unit +(a cut must not orphan a tool_call from its updates). The bridge replays at +most the last `limit` messages, extended backwards to the start of the turn +containing the oldest one; `limit: 0` attaches with metadata only. A turn +spans from a user message to the next; leading non-user messages belong to the +first turn. We rejected turn-count limits — tool-heavy turns make them +unpredictable for UI budgets. + +**Cursor pagination over a full fetch, with expiry.** The backend's +`session/messages` has no pagination, but the fetch is local stdio IPC — the +expensive part is the wire to the client, so the bridge fetches all, slices in +memory, and ships only the tail. The cursor is an opaque base64 of +`{ id?, index, totalTurns }`: it validates only while the history it points +into is unchanged (compaction/truncation expires it). An expired or unknown +cursor returns a fixed `"cursor expired"` error the client maps to a full +re-`session/load` — we rejected a push-only update log with id-gap fill as +heavier machinery for the same result. During any replay batch the bridge +holds a per-session replay lock (patterned on `preemptLocks`) that live-turn +dispatch for the same session also acquires, so a batch is never interleaved +with live updates. diff --git a/docs/proposals/0001-tail-session-replay.md b/docs/proposals/0001-tail-session-replay.md new file mode 100644 index 0000000..b03763c --- /dev/null +++ b/docs/proposals/0001-tail-session-replay.md @@ -0,0 +1,136 @@ +# Proposal 0001 — Tail session replay with incremental history fetch + +Status: implemented (2026-08-17; decisions in ADR-0003, contract documented in +REMOTE-CLIENTS.md "Tail replay and history pagination") · Date: 2026-08-17 · +Affects: ACP endpoint (`session/load`), remote clients + +## Problem + +`session/load` replays the **entire** conversation as `session/update` +notifications. This is the attach path AND the reconnect catch-up path +(REMOTE-CLIENTS.md: "history replay is the recovery mechanism"), so its cost is +paid on every attach and every reconnect. For long-lived sessions the cost is +unbounded: + +- A real working session measured today replays **2,000+ update chunks** + (user/agent/thought/tool) per attach. The mobile client (zcode-acp-app) + saturated its main thread for tens of seconds even after shipping windowed + rendering — every chunk used to trigger a full React commit. The client now + batches replay into single store writes, which fixes rendering, but the + **wire transfer, JSON parse, and state application remain O(full history)** + on every attach. That part is irreducible client-side. +- Mobile clients reconnect often (background suspension kills the socket), so + the most latency-sensitive clients pay the largest cost, repeatedly. +- Recovery after a network flap should be fast; instead it grows with session + age. The catch-up path degrades exactly when the session is most valuable. + +Clients render only the tail (the app renders the last ~30 messages and loads +older on scroll-up), yet the protocol forces them to receive and process all +of it up front. + +## Transport constraint (verified against SDK 1.3.0) + +The SDK registers spec methods with zod `z.object` params schemas; zod's +default `.parse()` **strips unknown top-level keys**. A top-level `limit` on +`session/load` would never reach the handler. `_meta` is the preserved +extension channel, so all bridge extension parameters on spec methods ride in +`_meta.zcode`. Response fields are unaffected (the SDK's response mapping is a +passthrough), so `replayMeta` rides top-level in the result. Our own method +`session/load_earlier` takes a bridge-provided parser, so its params stay +top-level. + +## Proposed API + +Additive and backward compatible — omitting the new fields keeps today's +full-replay behavior byte-identical (Zed sends neither, and is unaffected). + +### 1. `session/load` gains an optional tail limit (in `_meta.zcode`) + +```json +{ "sessionId": "…", "cwd": "…", "mcpServers": [], "_meta": { "zcode": { "limit": 30 } } } +``` + +- Replays at most the **last `limit` messages, aligned back to the start of + the turn containing the oldest one** — never a mid-turn cut, never an + orphaned tool_call. A turn spans from a user message to the next; leading + non-user messages belong to the first turn. +- `limit: 0` attaches with **metadata only** (no replay) — for clients that + build the connection first and page history on demand. +- Clamped to `[0, 500]`; invalid values clamp, never error. +- Result gains replay metadata: + +```json +{ + "replayMeta": { + "cursor": "…", + "hasMore": true, + "replayedMessages": 47, + "replayedTurns": 12, + "totalMessages": 1893, + "totalTurns": 412 + } +} +``` + +`cursor` is an opaque bridge-chosen handle identifying the oldest replayed +turn (clients never interpret it). Both `session/load` and +`session/load_earlier` results use this same shape. + +### 2. New request `session/load_earlier` (params top-level) + +```json +{ "sessionId": "…", "before": "…", "limit": 50 } +``` + +- Delivers updates strictly older than `before` as `session/update` + notifications — the same delivery mechanism as replay, so clients reuse + their existing apply path. Within a batch, updates arrive oldest → newest; + the client prepends at the head of its history. +- Requires the session to already be registered in this bridge (attached via + `session/load`); unknown sessions error — pagination never triggers an + implicit backend resume. +- `hasMore: false` ends pagination. A cursor only expires when the history + shrank (session compacted/truncated, no longer matching the cursor's + anchor); appended turns keep it valid. An expired or unknown cursor returns + a fixed `"cursor expired"` error the client maps to a full re- + `session/load`. + +## Semantics & edge cases + +- **Cursor representation** (bridge-internal): opaque base64 of + `{ id?, index, totalTurns }`. Valid only while `index` is in range AND the + history it points into is unchanged; stable backend message ids are used + when present, with index/total as the consistency check otherwise. +- **Concurrent live turn.** A turn may stream while replay or pagination runs. + A per-session replay lock (patterned on `preemptLocks`) is held for the + duration of each replay batch, and live-turn dispatch for the same session + acquires the same lock — batches are atomic and never interleave with live + forwards. Concurrent `load_earlier` calls serialize naturally. +- **Bridge-side slicing.** The backend `session/messages` RPC has no + pagination; the bridge fetches full history (local stdio IPC, cheap) and + slices in memory — the wire to the client carries only the tail. A future + backend limit parameter can slot in without contract change. +- **Editor/stdio clients are unaffected** — no `_meta.zcode` ⇒ today's + behavior. + +## Alternatives considered + +- **Top-level `limit` on `session/load`**: rejected — the SDK's zod params + parsing strips unknown top-level keys; it cannot work. +- **Turn-count limit**: rejected — tool-heavy turns make turn budgets + unpredictable for UI; message count with turn alignment serves both. +- **`limit` only, no `load_earlier`**: simpler, but "scroll up for older" then + forces a full re-load — the exact cost this proposal removes. +- **Client-side windowing alone** (shipped in zcode-acp-app today): bounds + rendering but not wire/parse/apply; reconnect cost still grows unboundedly. +- **Push-only history with gap-fill by update id**: requires the bridge to + retain an update log keyed by id and clients to track continuity — heavier + than cursor pagination for the same result. + +## Rollout + +1. `_meta.zcode.limit` on `session/load` + `replayMeta` + replay lock + (unblocks fast mobile attach). +2. `session/load_earlier` (unblocks infinite-scroll into history). + +REMOTE-CLIENTS.md gains the two parameters once implemented. diff --git a/docs/proposals/0002-plan-quota-usage.md b/docs/proposals/0002-plan-quota-usage.md new file mode 100644 index 0000000..5017875 --- /dev/null +++ b/docs/proposals/0002-plan-quota-usage.md @@ -0,0 +1,81 @@ +# Proposal 0002 — Expose plan quota usage to remote clients + +Status: implemented (bridge `account/usage_stats`, 2026-08-17) · Date: 2026-08-17 · Affects: bridge (new ACP method), remote clients + +## Problem + +The mobile client now shows the session **context bar** (`usage_update +{used, size}`) — that part is done. The other "usage" users care about is the +**plan quota**: how much of the current billing window (e.g. a coding plan's +prompt allowance) is consumed and when it resets. The editor shows this; a +remote client has no way to see it. + +Today the bridge surfaces nothing for quotas: + +- app-server has the RPC (`usageStats: "usage/stats"` in the method enum, plus + the `zcode quotas` CLI and `v4/usage/stats` backend endpoint), but the + bridge's BACKLOG lists `usage/stats` under **Not planned** ("desktop client + / config layer"). +- `session/usage` (per-session tokens) is also unwired (BACKLOG candidate + table) — lower value, the context bar already covers session-level usage. + +## Proposed API (minimal, pull-only) + +Quota is **account-level**, not session-level, so it does not fit a +`session/update` kind. One request method on the bridge, callable any time +after `initialize` (no session required): + +```json +{ "id": 7, "method": "account/usage_stats", "params": {} } +``` + +Response — shape to mirror whatever app-server's `usage/stats` actually +returns (fields below are the client's expectation, not a hard contract): + +```json +{ + "plans": [ + { + "id": "bigmodel-coding-plan", + "name": "GLM Coding Plan", + "used": 42, + "limit": 120, + "unit": "prompts", + "windowHours": 5, + "resetsAt": 1723812000000 + } + ] +} +``` + +Semantics: + +- Pull-only v1: the client fetches once after attach and on demand (or every + few minutes). No push notification needed yet — quota changes are slow. +- Non-standard, additive method name (`account/…`); nothing existing changes. +- Failure should degrade gracefully: error → the client hides the quota UI. + +Implementation notes (2026-08-17): + +- Data source is the bridge's own `quota/` pipeline (GLM usage API + 10s + cache, same as `/quota`), NOT the app-server `usage/stats` RPC — inspected + live, that RPC returns token analytics over a time range (per-day token + counts, model/tool breakdowns), not billing-window quotas. The wire shape + above is adapted accordingly: `usedPercent` is always present; `used`/ + `limit` only when the API reports absolute counts; `windowHours` is derived + from the window id (5h → 5, week → 168). +- Failures map to JSON-RPC `-32003` with the kind in `data.kind` + (`auth_error` | `rate_limited` | `unavailable`). + +## Client UI (once available) + +Drawer section under Session config: one row per plan showing +`used/limit` with a small progress bar and a "resets in Xh" hint. Reuse of +the existing context-bar styling. + +## Alternatives considered + +- **`usage_update` extension**: wrong scope — that kind is per-session and + replayed on attach; quota is account-wide and would replay stale values. +- **Hub-level `/api/usage`**: violates ADR-0002 (the hub is a stateless byte + proxy with no backend connection; only bridges talk to app-server). diff --git a/eslint.config.js b/eslint.config.js index 4aa0b16..df932b6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,14 @@ import tsparser from "@typescript-eslint/parser"; export default [ { - ignores: ["dist/**", "node_modules/**", "coverage/**", "*.config.js", "eslint.config.js"], + ignores: [ + "dist/**", + "node_modules/**", + "coverage/**", + ".zcode/**", + "*.config.js", + "eslint.config.js", + ], }, js.configs.recommended, { diff --git a/package.json b/package.json index 3138938..22393b2 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ ], "bin": { "zcode-acp-server": "dist/index.js", + "zcode-acp-hub": "dist/bin/hub.js", "zcode-quota": "dist/bin/quota.js" }, "main": "dist/index.js", @@ -46,7 +47,8 @@ "prepublishOnly": "pnpm run build && pnpm run test" }, "dependencies": { - "@agentclientprotocol/sdk": "^1.1.0" + "@agentclientprotocol/sdk": "^1.3.0", + "ws": "^8.21.3" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -54,6 +56,7 @@ "devDependencies": { "@eslint/js": "^9.0.0", "@types/node": "^22.0.0", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^9.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c433c73..4b38c07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,11 @@ importers: .: dependencies: '@agentclientprotocol/sdk': - specifier: ^1.1.0 - version: 1.1.0(zod@3.25.76) + specifier: ^1.3.0 + version: 1.3.0(zod@3.25.76) + ws: + specifier: ^8.21.3 + version: 8.21.3 devDependencies: '@eslint/js': specifier: ^9.0.0 @@ -18,6 +21,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.20.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@typescript-eslint/eslint-plugin': specifier: ^8.0.0 version: 8.62.1(@typescript-eslint/parser@8.62.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) @@ -42,8 +48,8 @@ importers: packages: - '@agentclientprotocol/sdk@1.1.0': - resolution: {integrity: sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg==} + '@agentclientprotocol/sdk@1.3.0': + resolution: {integrity: sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -393,6 +399,9 @@ packages: '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.62.1': resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -978,6 +987,18 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -987,7 +1008,7 @@ packages: snapshots: - '@agentclientprotocol/sdk@1.1.0(zod@3.25.76)': + '@agentclientprotocol/sdk@1.3.0(zod@3.25.76)': dependencies: zod: 3.25.76 @@ -1207,6 +1228,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.0 + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1822,6 +1847,8 @@ snapshots: word-wrap@1.2.5: {} + ws@8.21.3: {} + yocto-queue@0.1.0: {} zod@3.25.76: {} diff --git a/src/bin/hub.ts b/src/bin/hub.ts new file mode 100644 index 0000000..2fe1c5b --- /dev/null +++ b/src/bin/hub.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +/** + * Standalone zcode-acp-hub daemon entry. + * + * Usually spawned detached by the first bridge that enables remote access + * (see src/remote/endpoint.ts); running it manually is also fine, e.g. under + * launchd/systemd or directly for debugging: + * + * ZCODE_ACP_REMOTE_TOKEN= zcode-acp-hub + * + * Refuses to start without ZCODE_ACP_REMOTE_TOKEN — the hub is the only public + * entry point and never runs unauthenticated. Exits 0 on EADDRINUSE: another + * hub already owns the port, which is the desired machine-singleton behaviour. + */ + +import process from "node:process"; + +import { parseHubConfig } from "../remote/config.js"; +import { startHub } from "../remote/hub-server.js"; +import { warn } from "../utils.js"; + +async function main(): Promise { + const config = parseHubConfig(); + if (!config) process.exit(1); + const hub = await startHub({ + port: config.hubPort, + host: config.hubHost, + token: config.token, + onIdleExit: () => process.exit(0), + }); + process.on("SIGTERM", () => void hub.close().then(() => process.exit(0))); + process.on("SIGINT", () => void hub.close().then(() => process.exit(0))); +} + +main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + if ((err as NodeJS.ErrnoException)?.code === "EADDRINUSE") { + // Another hub already listens on this port — nothing to do. + process.exit(0); + } + warn(`hub: fatal: ${message}`); + process.exit(1); +}); diff --git a/src/handlers/account.ts b/src/handlers/account.ts new file mode 100644 index 0000000..fb9b0de --- /dev/null +++ b/src/handlers/account.ts @@ -0,0 +1,88 @@ +/** + * Account-level usage stats — Proposal 0002 (`account/usage_stats`). + * + * Exposes the combined dual-provider quota behind the `zcode-quota` CLI + * (GLM Coding Plan + Opencode Go) to remote clients as a pull-only ACP + * method, callable any time after `initialize` (no session required — quota + * is account-level, so it fits no `session/update` kind). + * + * The response mirrors the CLI card's data model so clients can reproduce it + * exactly: one GLM section (plan level + per-window items with per-model + * details) and one Opencode Go section (rolling/weekly/monthly windows, the + * relative reset countdown converted to an absolute timestamp). Provider + * failures are reported per-section as `kind` strings rather than throwing — + * the client renders the same status line the CLI would (a `not_configured` + * Go section is simply omitted, matching the CLI). + */ + +import { queryCombined } from "../quota/combined.js"; +import type { GoQueryResult, GoWindowKey } from "../quota/opencode-go/types.js"; +import type { QuotaItem, QuotaResult } from "../quota/types.js"; + +/** GLM section — `items` present only on success. */ +export interface GlmUsageStats { + kind: QuotaResult["kind"]; + level?: string; + items?: QuotaItem[]; +} + +/** One Opencode Go window with the reset countdown resolved to epoch ms. */ +export interface GoWindowEntry { + key: GoWindowKey; + label: string; + usagePercent: number; + resetsAt: number; +} + +/** Opencode Go section — `windows` present only on success. */ +export interface GoUsageStats { + kind: GoQueryResult["kind"]; + windows?: GoWindowEntry[]; +} + +export interface UsageStatsResult { + glm: GlmUsageStats; + opencode: GoUsageStats; +} + +/** Window labels matching the CLI's card (`5h` / `Week` / `Month`). */ +const GO_WINDOW_LABELS: Record = { + rolling: "5h", + weekly: "Week", + monthly: "Month", +}; + +/** GLM items pass through verbatim — the client renders the CLI layout. */ +function toGlmStats(result: QuotaResult): GlmUsageStats { + if (result.kind !== "success") return { kind: result.kind }; + return { kind: "success", level: result.level, items: result.items }; +} + +/** + * Go windows with the same absolute-reset math the CLI formatter uses: + * subtract the elapsed time since the fetch snapshot from `resetInSec`. + */ +function toGoStats(result: GoQueryResult, now = Date.now()): GoUsageStats { + if (result.kind !== "success") return { kind: result.kind }; + const elapsedSec = Math.max(0, (now - result.fetchedAt) / 1000); + const windows = (["rolling", "weekly", "monthly"] as const).flatMap((key) => { + const w = result[key]; + if (!w) return []; + const remainingSec = Math.max(0, w.resetInSec - elapsedSec); + return [ + { + key, + label: GO_WINDOW_LABELS[key], + usagePercent: w.usagePercent, + resetsAt: result.fetchedAt + remainingSec * 1000, + }, + ]; + }); + return { kind: "success", windows }; +} + +/** `account/usage_stats` handler — both providers, queried in parallel. */ +export async function accountUsageStats(): Promise { + const { glm, go } = await queryCombined("all"); + return { glm: toGlmStats(glm), opencode: toGoStats(go) }; +} diff --git a/src/handlers/io.ts b/src/handlers/io.ts index e487eaf..ec4ac73 100644 --- a/src/handlers/io.ts +++ b/src/handlers/io.ts @@ -13,13 +13,77 @@ import { RequestError } from "@agentclientprotocol/sdk"; import type { ZcodeAcpServer } from "../server.js"; import { warn } from "../utils.js"; -/** Send a `session/update` notification to the client. */ +/** + * Send a `session/update` notification to the client, serialized through the + * per-session replay guard (see `enqueueSessionSend`). + */ export function sendSessionUpdate( cx: acp.AgentContext, sessionId: string, update: acp.SessionUpdate, ): Promise { - return cx.notify("session/update", { sessionId, update }); + return enqueueSessionSend(sessionId, () => cx.notify("session/update", { sessionId, update })); +} + +/** Per-session replay guard: a FIFO chain of notification sends. */ +interface ReplayGuard { + tail: Promise; +} +const replayGuards = new Map(); + +/** Append a job to a guard's chain; a rejected job never breaks later sends. */ +function enqueue(guard: ReplayGuard, job: () => Promise): Promise { + const run = guard.tail.then(job); + guard.tail = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +/** + * Run one client-notification send through the per-session replay guard: + * while a replay batch (`withReplayBatch`) is in flight for this session, the + * send queues behind it so a batch is never interleaved with live updates — + * this applies to background-task emissions too, not just handler dispatch. + * Sessions that never replay take the lock-free fast path. + */ +export function enqueueSessionSend(sessionId: string, send: () => Promise): Promise { + const guard = replayGuards.get(sessionId); + if (!guard) return send(); + return enqueue(guard, send); +} + +/** + * Run one replay batch for a session under exclusive use of its guard. + * While the batch runs, `sendSessionUpdate` calls for the SAME session (live + * turn dispatch) queue behind it; the batch's own sends go through + * `replayMessages`, which notifies directly — that bypass is what makes the + * batch atomic without a re-entrant lock. Concurrent batches serialize. + */ +export async function withReplayBatch(sessionId: string, fn: () => Promise): Promise { + let guard = replayGuards.get(sessionId); + if (!guard) { + guard = { tail: Promise.resolve() }; + replayGuards.set(sessionId, guard); + } + // Take ownership: later senders (including other batches) chain behind us. + const prev = guard.tail; + let release!: () => void; + const held = new Promise((resolve) => (release = resolve)); + guard.tail = held; + await prev; + try { + return await fn(); + } finally { + release(); + // Drop the entry when nobody chained behind us. enqueue and + // withReplayBatch are synchronous up to their first await, so a concurrent + // taker always swaps guard.tail before this check runs — no race window. + if (replayGuards.get(sessionId) === guard && guard.tail === held) { + replayGuards.delete(sessionId); + } + } } /** Send an `agent_message_chunk` text notification. */ diff --git a/src/handlers/replay.ts b/src/handlers/replay.ts new file mode 100644 index 0000000..7d9bc2c --- /dev/null +++ b/src/handlers/replay.ts @@ -0,0 +1,316 @@ +/** + * Tail replay kernel — slicing, cursor pagination, and the `session/load` + * replay/`session/load_earlier` handlers (Proposal 0001 / ADR-0003). + * + * session/load replays history as session/update notifications; for long + * sessions that cost is O(full history) on every attach and reconnect. The + * helpers here slice the fetched messages into turn-aligned batches and page + * backwards with an opaque cursor. Batches are sent under the per-session + * replay lock (`withReplayBatch` in io.ts) so a batch never interleaves with + * live-turn updates for the same session; `replayMessages` is the one sender + * that bypasses the per-message lock — it only runs inside a batch. + */ + +import { randomUUID } from "node:crypto"; + +import type * as acp from "@agentclientprotocol/sdk"; + +import type { ZcodeMessage, ZcodeMessagesResult } from "../backend/types.js"; +import type { ZcodeAcpServer } from "../server.js"; +import { log } from "../utils.js"; +import { throwError, withReplayBatch } from "./io.js"; + +/** Upper bound for a requested tail/page size (values above clamp to this). */ +export const MAX_REPLAY_LIMIT = 500; +/** Page size for `session/load_earlier` when the request omits `limit`. */ +export const DEFAULT_EARLIER_LIMIT = 50; + +/** Wire metadata describing one delivered batch (additive-only over time). */ +export interface ReplayMeta { + cursor: string; + hasMore: boolean; + replayedMessages: number; + replayedTurns: number; + totalMessages: number; + totalTurns: number; +} + +export interface ReplaySlice { + batch: ZcodeMessage[]; + meta: ReplayMeta; +} + +/** `session/load_earlier` params (top-level — our parser, not an ACP spec method). */ +export interface LoadEarlierParams { + sessionId: string; + before?: string; + limit?: number; +} + +interface CursorPayload { + v: 1; + id?: string; + index: number; + totalTurns: number; +} + +/** + * Indices where a turn starts: every user message, plus 0 so leading + * non-user messages (system preambles) belong to the first turn. + */ +function turnStarts(messages: ZcodeMessage[]): number[] { + const starts: number[] = messages.length > 0 ? [0] : []; + messages.forEach((m, i) => { + if (m.info?.role === "user" && i > 0) starts.push(i); + }); + return starts; +} + +/** Count of turn starts inside [start, end). */ +function turnsInRange(starts: number[], start: number, end: number): number { + return starts.filter((s) => s >= start && s < end).length; +} + +function encodeCursor(messages: ZcodeMessage[], index: number, totalTurns: number): string { + const anchor = messages[index]?.info?.id; + const payload: CursorPayload = { v: 1, index, totalTurns }; + if (anchor) payload.id = anchor; + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); +} + +function decodeCursor(before: string): CursorPayload { + try { + const raw = JSON.parse(Buffer.from(before, "base64url").toString("utf8")) as CursorPayload; + if ( + raw?.v !== 1 || + !Number.isInteger(raw.index) || + raw.index < 0 || + !Number.isInteger(raw.totalTurns) + ) { + throw new Error("bad shape"); + } + return raw; + } catch { + // Garbage or foreign cursors are indistinguishable from expired ones. + return throwError(-32602, "cursor expired"); + } +} + +function buildSlice( + messages: ZcodeMessage[], + starts: number[], + start: number, + end: number, +): ReplaySlice { + const totalTurns = starts.length; + return { + batch: messages.slice(start, end), + meta: { + cursor: encodeCursor(messages, start, totalTurns), + hasMore: start > 0, + replayedMessages: end - start, + replayedTurns: turnsInRange(starts, start, end), + totalMessages: messages.length, + totalTurns, + }, + }; +} + +/** The greatest turn start at or before `pos` (0 when none — starts include 0). */ +function alignToTurnStart(starts: number[], pos: number): number { + let aligned = 0; + for (const s of starts) { + if (s <= pos) aligned = s; + else break; + } + return aligned; +} + +function clampLimit(limit: number): number { + return Math.max(0, Math.min(Math.floor(limit), MAX_REPLAY_LIMIT)); +} + +/** + * Slice the last `limit` messages, aligned back to the start of the turn + * containing the oldest one — never a mid-turn cut. `limit: 0` attaches with + * metadata only (cursor anchors at the end of history). + */ +export function sliceTail(messages: ZcodeMessage[], limit: number): ReplaySlice { + const starts = turnStarts(messages); + const clamped = clampLimit(limit); + if (clamped === 0) { + // Metadata-only attach: an empty batch whose cursor anchors at the end of + // history, so load_earlier pages the whole tail. + return buildSlice(messages, starts, messages.length, messages.length); + } + if (clamped >= messages.length) return buildSlice(messages, starts, 0, messages.length); + const start = alignToTurnStart(starts, messages.length - clamped); + return buildSlice(messages, starts, start, messages.length); +} + +/** Full-history slice (no `_meta.zcode.limit` on session/load). */ +export function fullSlice(messages: ZcodeMessage[]): ReplaySlice { + return buildSlice(messages, turnStarts(messages), 0, messages.length); +} + +/** + * Slice up to `limit` messages strictly older than the `before` cursor. + * The cursor points into a prefix of history, so turns appended after it was + * minted keep it valid; only a history that shrank (compaction/truncation) + * throws `cursor expired` — clients map that to a full re-`session/load`. + */ +export function sliceBefore(messages: ZcodeMessage[], before: string, limit: number): ReplaySlice { + const starts = turnStarts(messages); + const cur = decodeCursor(before); + if (cur.index > messages.length || cur.totalTurns > starts.length) { + return throwError(-32602, "cursor expired"); + } + if (cur.id != null && cur.index < messages.length && messages[cur.index].info?.id !== cur.id) { + return throwError(-32602, "cursor expired"); + } + const end = cur.index; + if (end === 0) return buildSlice(messages, starts, 0, 0); + const clamped = clampLimit(limit); + const start = clamped === 0 ? end : alignToTurnStart(starts, Math.max(0, end - clamped)); + return buildSlice(messages, starts, start, end); +} + +/** + * Read the tail limit from `session/load`'s `_meta.zcode.limit`. The SDK's + * zod params schema strips unknown top-level keys, so bridge extensions ride + * in `_meta` (ADR-0003). Returns null when absent/non-finite = full replay. + */ +export function readTailLimit(params: acp.LoadSessionRequest): number | null { + const zcode = (params._meta as { zcode?: { limit?: unknown } } | undefined)?.zcode; + const raw = zcode?.limit; + if (typeof raw !== "number" || !Number.isFinite(raw)) return null; + return clampLimit(raw); +} + +/** Fetch session/messages from zcode (the bridge's only history source). */ +export async function fetchMessages( + server: ZcodeAcpServer, + zcodeSid: string, +): Promise { + const backend = server.ensureBackend(); + const resp = await backend.request( + server.nextId(), + "session/messages", + { sessionId: zcodeSid }, + 8000, + ); + if (resp.error) return []; + const result = (resp.result ?? {}) as ZcodeMessagesResult; + return result.messages ?? []; +} + +/** + * Strip harness-injected reminder blocks from user text. The agent runtime + * appends `` blocks (TodoWrite nudges, + * context handoffs) to user turns as context plumbing — they are not user + * speech, and replaying them verbatim makes clients render them as user input. + */ +function stripSystemReminders(text: string): string { + return text.replace(/[\s\S]*?<\/system-reminder>/g, "").trim(); +} + +/** + * Replay messages as session/update notifications, oldest → newest. + * + * MUST run inside `withReplayBatch` for the session: this is the one sender + * that bypasses the per-message lock in sendSessionUpdate (the batch already + * holds it), which is what makes the batch atomic against live dispatch. + */ +export async function replayMessages( + cx: acp.AgentContext, + acpSid: string, + messages: ZcodeMessage[], +): Promise { + let replayed = 0; + for (const m of messages) { + const info = m.info ?? {}; + const role = info.role; + const mid = info.id ?? `hist_${randomUUID().slice(0, 12)}`; + for (const p of m.parts ?? []) { + if (!p || typeof p !== "object") continue; + const ptype = (p as { type?: string }).type; + if (ptype === "text") { + let text = (p as { text?: string }).text ?? ""; + if (!text) continue; + if (role === "user") { + text = stripSystemReminders(text); + if (!text) continue; + } + await cx.notify("session/update", { + sessionId: acpSid, + update: { + sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk", + content: { type: "text", text }, + messageId: mid, + }, + }); + } else if (ptype === "reasoning") { + const rp = p as { text?: string; content?: string }; + const text = rp.text ?? rp.content ?? ""; + if (text) { + await cx.notify("session/update", { + sessionId: acpSid, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text }, + messageId: `thought_${mid}`, + }, + }); + } + } else if (ptype === "tool") { + const tp = p as { + id?: string; + tool?: string; + title?: string; + status?: string; + }; + const title = tp.title ?? tp.tool ?? "tool call"; + const histToolName = tp.tool ?? ""; + await cx.notify("session/update", { + sessionId: acpSid, + update: { + sessionUpdate: "tool_call", + toolCallId: tp.id ?? `histtool_${randomUUID().slice(0, 8)}`, + title, + kind: "other", + status: (tp.status as acp.ToolCallStatus) ?? "completed", + ...(histToolName ? { _meta: { claudeCode: { toolName: histToolName } } } : {}), + }, + }); + } + // patch / step-start / other: skipped (history replay focuses on text + tool summary) + } + replayed += 1; + } + return replayed; +} + +/** + * `session/load_earlier` — deliver one page of history strictly older than + * the `before` cursor, oldest → newest (clients prepend). Requires the + * session to already be attached in this bridge; pagination never triggers + * an implicit backend resume. + */ +export async function loadEarlier( + server: ZcodeAcpServer, + params: LoadEarlierParams, + cx: acp.AgentContext, +): Promise<{ replayMeta: ReplayMeta }> { + const acpSid = params.sessionId; + const zcodeSid = server.resolveSid(acpSid); + if (!zcodeSid) { + return throwError(-32602, "session not registered — attach via session/load first"); + } + if (!params.before) return throwError(-32602, "before (cursor) required"); + + const messages = await fetchMessages(server, zcodeSid); + const slice = sliceBefore(messages, params.before, params.limit ?? DEFAULT_EARLIER_LIMIT); + await withReplayBatch(acpSid, () => replayMessages(cx, acpSid, slice.batch)); + log(`session/load_earlier: ${slice.meta.replayedMessages} messages before cursor`); + return { replayMeta: slice.meta }; +} diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 0579f17..8576e7c 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -17,13 +17,7 @@ import type * as acp from "@agentclientprotocol/sdk"; import { RequestError } from "@agentclientprotocol/sdk"; import { EventStreamListener, TurnMonitor } from "../backend/listener.js"; -import type { - ZcodeCreateResult, - ZcodeListResult, - ZcodeMessage, - ZcodeMessagesResult, - ZcodeSnapshot, -} from "../backend/types.js"; +import type { ZcodeCreateResult, ZcodeListResult, ZcodeSnapshot } from "../backend/types.js"; import { buildModes, buildConfigOptions } from "../config/options.js"; import { emitInitialUsage } from "../config/model-cache.js"; import { buildProviderRegistry } from "../config/provider-registry.js"; @@ -45,7 +39,8 @@ import type { InternalEvent } from "../translators/index.js"; import { log, warn } from "../utils.js"; import type { PendingTurn, ZcodeAcpServer } from "../server.js"; import { dispatchEvent } from "./dispatch.js"; -import { sendSessionUpdate, sendTextChunk } from "./io.js"; +import { sendSessionUpdate, sendTextChunk, withReplayBatch } from "./io.js"; +import { fetchMessages, fullSlice, readTailLimit, replayMessages, sliceTail } from "./replay.js"; import { handleServerRequests } from "./server-requests.js"; /** Workspace descriptor used in session create/resume calls. */ @@ -103,6 +98,9 @@ export async function newSession( // backend session materializes; never shown in session/list. const acpSid = randomUUID(); server.pendingSessions.set(acpSid, { cwd, mcpServers: params.mcpServers }); + // Persists past materialization (pendingSessions is cleared on first use) so + // the remote discovery payload can still label the workspace. + server.sessionCwds.set(acpSid, cwd); // Durable alias so the placeholder survives a bridge restart and session/ // resume can still resolve it (best-effort; failures are swallowed inside // the store). @@ -147,6 +145,7 @@ export async function ensureRealSession(server: ZcodeAcpServer, acpSid: string): if (record) { pending = { cwd: record.cwd }; server.pendingSessions.set(acpSid, pending); + server.sessionCwds.set(acpSid, record.cwd); } } if (!pending) throw new Error(`session ${acpSid} not found`); @@ -241,6 +240,38 @@ export async function listSessions( return { sessions }; } +/** + * Adopt the backend's stored title for a loaded/resumed session. + * + * The prompt loop's auto-title only fires for freshly created sessions + * (`titleEligibleSessions`), so a session resumed across a bridge restart + * would otherwise appear title-less in the hub's discovery API — remote + * clients have no editor-side session storage to fall back on. The backend's + * session/list is the only title source for sessions born in a previous + * bridge lifetime. Best-effort: failures log and leave the session untitled. + */ +async function adoptStoredTitle( + server: ZcodeAcpServer, + acpSid: string, + zcodeSid: string, +): Promise { + if (server.sessionTitles.has(acpSid)) return; + try { + const backend = server.ensureBackend(); + const resp = await backend.request(server.nextId(), "session/list", {}, 15000); + if (resp.error) return; + const result = (resp.result ?? {}) as ZcodeListResult; + const hit = (result.sessions ?? []).find((s) => s.sessionId === zcodeSid); + if (hit?.title) { + server.sessionTitles.set(acpSid, hit.title); + server.touchSessionSummary(acpSid, hit.title); + log(`adopted stored title for ${acpSid.slice(0, 8)}: ${hit.title}`); + } + } catch (e) { + log(`stored title lookup failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`); + } +} + /** * Resolve the backend session id for `session/resume` / `session/load`. * @@ -321,6 +352,7 @@ export async function resumeSession( server.registerSession(acpSid, zcodeSid); log(`session/resume -> ${zcodeSid}`); server.ensureBackgroundListener(zcodeSid); + await adoptStoredTitle(server, acpSid, zcodeSid); // Initial usage_update so the editor shows the context bar immediately for a // resumed session (mirrors Python _on_session_resume → _emit_initial_usage). await emitInitialUsage(server, cx, acpSid, zcodeSid, getOrCreateDiffer(server, zcodeSid)); @@ -365,59 +397,19 @@ export async function loadSession( server.registerSession(acpSid, zcodeSid); log(`session/load → ${zcodeSid}`); server.ensureBackgroundListener(zcodeSid); + await adoptStoredTitle(server, acpSid, zcodeSid); const messages = await fetchMessages(server, zcodeSid); - let replayed = 0; - for (const m of messages) { - const info = m.info ?? {}; - const role = info.role; - const mid = info.id ?? `hist_${randomUUID().slice(0, 12)}`; - for (const p of m.parts ?? []) { - if (!p || typeof p !== "object") continue; - const ptype = (p as { type?: string }).type; - if (ptype === "text") { - const text = (p as { text?: string }).text ?? ""; - if (!text) continue; - const sessionUpdate = role === "user" ? "user_message_chunk" : "agent_message_chunk"; - await sendSessionUpdate(cx, acpSid, { - sessionUpdate, - content: { type: "text", text }, - messageId: mid, - }); - } else if (ptype === "reasoning") { - const rp = p as { text?: string; content?: string }; - const text = rp.text ?? rp.content ?? ""; - if (text) { - await sendSessionUpdate(cx, acpSid, { - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text }, - messageId: `thought_${mid}`, - }); - } - } else if (ptype === "tool") { - const tp = p as { - id?: string; - tool?: string; - title?: string; - status?: string; - }; - const title = tp.title ?? tp.tool ?? "tool call"; - const histToolName = tp.tool ?? ""; - const update: acp.SessionUpdate = { - sessionUpdate: "tool_call", - toolCallId: tp.id ?? `histtool_${randomUUID().slice(0, 8)}`, - title, - kind: "other", - status: (tp.status as acp.ToolCallStatus) ?? "completed", - ...(histToolName ? { _meta: { claudeCode: { toolName: histToolName } } } : {}), - }; - await sendSessionUpdate(cx, acpSid, update); - } - // patch / step-start / other: skipped (history replay focuses on text + tool summary) - } - replayed += 1; - } - log(`session/load: replayed ${replayed} messages`); + // Tail replay (Proposal 0001): a `_meta.zcode.limit` replays only the last + // N messages aligned to turn boundaries — the full replay stays the default + // for editors that send no `_meta` (Zed path unchanged). + const limit = readTailLimit(params); + const slice = limit === null ? fullSlice(messages) : sliceTail(messages, limit); + await withReplayBatch(acpSid, () => replayMessages(cx, acpSid, slice.batch)); + log( + `session/load: replayed ${slice.meta.replayedMessages} messages` + + `${limit === null ? "" : ` (tail limit ${limit}, total ${slice.meta.totalMessages})`}`, + ); // Replay the existing todo list as an initial plan so a loaded session shows // its todos immediately (filter to PlanUpdate only — text/tools were already @@ -440,10 +432,13 @@ export async function loadSession( const modes = await buildModes(server, zcodeSid); server.lastMode.set(acpSid, modes.currentModeId); - return { + const result = { modes, configOptions: await buildConfigOptions(server, zcodeSid), + // Additive replay metadata — the anchor for load_earlier pagination. + replayMeta: slice.meta, }; + return result as acp.LoadSessionResponse; } /** `session/prompt` → subscribe-before-send, run the event-driven turn loop. */ @@ -467,11 +462,18 @@ export async function prompt( const zcodeSid = await ensureRealSession(server, params.sessionId); // Slash-command interception: dispatches directly to ZCode methods and - // returns end_turn without entering the turn loop. Unknown /x falls through. - const { handleSlashCommand } = await import("./slash.js"); + // returns end_turn without entering the turn loop. Known passthrough + // commands and unknown /x both return null for the normal turn loop. + const { handleSlashCommand, neutralizeSlashText } = await import("./slash.js"); const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text); if (intercepted) return intercepted; + // Wire text for the backend: unknown `/x` prompts (not advertised commands) + // are neutralized so the backend's command resolver never sees them — an + // unresolvable name can hard-fail the turn. Known commands pass through + // unchanged. The title/auto-compact paths below keep using the raw `text`. + const sendText = neutralizeSlashText(text); + // Register self + preempt others under a per-session lock. The lock // serializes the critical section so that two concurrent prompts (B, C) for // the same session can't both miss each other and register at once: C waits @@ -573,8 +575,8 @@ export async function prompt( const SEND_RETRY_TIMEOUT_MS = 30_000; const sendParams = attachments.length > 0 - ? { sessionId: zcodeSid, content: text, attachments } - : { sessionId: zcodeSid, content: text }; + ? { sessionId: zcodeSid, content: sendText, attachments } + : { sessionId: zcodeSid, content: sendText }; const sendT0 = Date.now(); let sendAttempt = 0; while (true) { @@ -658,6 +660,7 @@ export async function prompt( .find((l) => l.length > 0) ?.slice(0, 80) ?? text.slice(0, 80); server.sessionTitles.set(params.sessionId, title); + server.touchSessionSummary(params.sessionId, title); const { updateSessionTitle } = await import("../tasks-index.js"); void updateSessionTitle(zcodeSid, title, text); await sendSessionUpdate(cx, params.sessionId, { @@ -713,6 +716,9 @@ export async function prompt( } finally { backend.unregisterEventListener(zcodeSid, listener); server.pendingTurns.delete(requestId); + // Turn end = session activity — refresh the discovery summary timestamp + // regardless of outcome (end_turn, cancelled, retries exhausted). + server.touchSessionSummary(params.sessionId); } } @@ -1085,20 +1091,6 @@ async function resumeBackendSession( } } -/** Fetch session/messages from zcode. */ -async function fetchMessages(server: ZcodeAcpServer, zcodeSid: string): Promise { - const backend = server.ensureBackend(); - const resp = await backend.request( - server.nextId(), - "session/messages", - { sessionId: zcodeSid }, - 8000, - ); - if (resp.error) return []; - const result = (resp.result ?? {}) as ZcodeMessagesResult; - return result.messages ?? []; -} - /** Get or create the session-level ProjectionDiffer (persists across turns). */ function getOrCreateDiffer(server: ZcodeAcpServer, zcodeSid: string): ProjectionDiffer { let d = server.differs.get(zcodeSid); diff --git a/src/handlers/slash.ts b/src/handlers/slash.ts index 4e210cf..48fdc01 100644 --- a/src/handlers/slash.ts +++ b/src/handlers/slash.ts @@ -4,7 +4,7 @@ * When the prompt text starts with `/`, dispatch the matching ZCode method * directly (compact/goal/fork/rewind/steer/model/mode/thought), emit a short * feedback `agent_message_chunk`, and return `end_turn` — never reaching the - * normal turn loop. Unknown `/x` falls through to the model (extensibility). + * normal turn loop. * * Commands handled by the ZCode backend (skill/init/code-review and other * plugin commands) are NOT intercepted here — they pass through to @@ -20,6 +20,13 @@ * `/quota` is the exception: it does not call ZCode at all — it queries the * GLM Coding Plan usage API directly and renders the result. * + * Anything else starting with `/` is NOT a command: only the names advertised + * in the editor's `/` completion menu (plus the passthrough built-ins above) + * go the command route. Unknown `/x` is sent to the model as plain text via + * {@link neutralizeSlashText} — the backend's command resolver must never see + * it, because an unresolvable name can hard-fail the turn and wedge the + * session (e.g. pasting a directory path like `/Users/me/project`). + * * Returns the PromptResponse when intercepted, or null to let the caller run a * normal turn. */ @@ -31,8 +38,9 @@ import { RequestError } from "@agentclientprotocol/sdk"; import { applyModelSwitch } from "../config/runtime-model.js"; import { emitConfigOptionUpdate } from "../config/options.js"; import { formatMcpServers, loadMcpServers } from "../config/mcp-discovery.js"; +import { loadPluginCommands } from "../config/plugin-commands.js"; import { formatQuota, queryQuota } from "../quota/index.js"; -import { CONFIG_DISPATCH, warn } from "../utils.js"; +import { CONFIG_DISPATCH, SLASH_COMMANDS, warn } from "../utils.js"; import type { ZcodeAcpServer } from "../server.js"; import { sendTextChunk } from "./io.js"; import { compact, fork, goal, rewind, steer } from "./extensions.js"; @@ -66,10 +74,58 @@ const PASSTHROUGH_COMMANDS = new Set([ "skill", "init", // Plugin commands (code-review, android-dev, etc.) are also passthrough, - // but since they're dynamic we don't list them here — unknown commands - // fall through to return null (passthrough) by default. + // but since they're dynamic we don't list them here — they join the known + // set below via loadPluginCommands(). ]); +/** + * Command names the bridge treats as real commands: the static list advertised + * in the `/` completion menu, backend-resolvable built-ins, TUI-only names + * (which get a friendly error), and plugin commands. Built lazily on first + * use (plugin commands don't change mid-session — same freshness as the + * advertised list in index.ts) so importing this module does no fs work. + */ +let knownCommands: Set | null = null; +function knownCommandSet(): Set { + if (!knownCommands) { + knownCommands = new Set([ + ...SLASH_COMMANDS.map((c) => c.name), + ...PASSTHROUGH_COMMANDS, + ...UNSUPPORTED_TUI_COMMANDS, + ...loadPluginCommands().map((c) => c.name), + ]); + } + return knownCommands; +} + +/** Whether `cmd` (already lowercased, no leading slash) is a real command. */ +function isKnownCommand(cmd: string): boolean { + // $-prefixed names are discovered Skills (e.g. /$tdd) — always passthrough. + return cmd.startsWith("$") || knownCommandSet().has(cmd); +} + +/** + * Neutralise slash-command resolution for prompts that are NOT real commands. + * + * The backend parses any prompt whose trimmed text starts with `/` as a + * command invocation (`name + args`), and an unresolvable name can fail the + * whole turn. This helper decides the wire text for `/`-leading prompts: + * - known command → returned unchanged (the backend resolves it); + * - anything else (e.g. a pasted path `/Users/me/proj`) → prefixed with a + * zero-width space. U+200B survives the backend's trim(), so the + * `^\/` command parse can never match, while the model sees the prompt + * verbatim (ZWSP is invisible and tokenizes as nothing). + * + * Non-slash prompts pass through unchanged. + */ +export function neutralizeSlashText(text: string): string { + const stripped = text.trimStart(); + if (!stripped.startsWith("/")) return text; + const parts = stripped.slice(1).split(/\s(.*)/s); + const cmd = (parts[0] ?? "").toLowerCase(); + return isKnownCommand(cmd) ? text : `\u200B${text}`; +} + /** Try to intercept a slash command. Returns a PromptResponse when handled, null otherwise. */ export async function handleSlashCommand( server: ZcodeAcpServer, @@ -185,8 +241,14 @@ export async function handleSlashCommand( // visual grouping marker for the editor's completion menu. Pass through // as-is — the model sees /$name and resolves it via the Skill tool. if (cmd.startsWith("$")) return null; - // Truly unknown /x → don't intercept, send to the model as normal - // text (extensibility — plugin commands or future commands). + // Plugin commands advertised in the completion menu (the remaining + // known names at this point — static/TUI/passthrough were all consumed + // above) → passthrough for the backend to resolve. + if (knownCommandSet().has(cmd)) return null; + // Unknown /x (not advertised, not a built-in — e.g. a pasted directory + // path): NOT a command. Return null for the normal turn loop; the + // caller runs the prompt through neutralizeSlashText() so the backend + // never attempts command resolution on it. return null; } } catch (e) { diff --git a/src/index.ts b/src/index.ts index 3724f38..66367c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { Readable, Writable } from "node:stream"; import * as acp from "@agentclientprotocol/sdk"; import { z } from "zod"; +import { accountUsageStats } from "./handlers/account.js"; import { cancel, listSessions, @@ -37,8 +38,12 @@ import { updateRuntimeModelConfig, } from "./handlers/extensions.js"; import { sendAvailableCommandsDeferred } from "./handlers/io.js"; +import { loadEarlier } from "./handlers/replay.js"; import { loadPluginCommands } from "./config/plugin-commands.js"; import { loadSkillCommands } from "./config/skill-discovery.js"; +import { trackConnections } from "./remote/broadcast.js"; +import { parseRemoteConfig } from "./remote/config.js"; +import { startRemoteEndpoint, type RemoteEndpointHandle } from "./remote/endpoint.js"; import { ZcodeAcpServer } from "./server.js"; import { AGENT_INFO, SLASH_COMMANDS, log, warn } from "./utils.js"; @@ -80,6 +85,11 @@ async function main(): Promise { log(`starting ${AGENT_INFO.name} ${AGENT_INFO.version}, ACP protocol v${acp.PROTOCOL_VERSION}`); + // Remote access handle (null unless ZCODE_ACP_REMOTE is enabled and the + // loopback endpoint came up). Declared before shutdown so signal handlers + // always see the initialized binding. + let remoteHandle: RemoteEndpointHandle | null = null; + // Graceful shutdown: ensure the zcode subprocess group is reaped on signal, // stdin close (Zed disconnect), or backend death (so no orphans survive). // Zed force-kills the bridge on reconnect; without this the SIGTERM handler @@ -89,6 +99,9 @@ async function main(): Promise { if (shuttingDown) return; shuttingDown = true; log(`shutting down (${reason})`); + // Stop the remote endpoint first (bounded by its 1.5s unregister timeout); + // the hub's heartbeat TTL also prunes us if this doesn't complete. + if (remoteHandle) await remoteHandle.stop(); if (server.backend) await server.backend.close(); process.exit(0); }; @@ -103,30 +116,44 @@ async function main(): Promise { }, 2000); backendDeathInterval.unref(); - const connection = acp + const app = acp .agent({ name: AGENT_INFO.name }) .onRequest("initialize", (ctx) => server.initialize(ctx.params)) .onRequest("session/new", async (ctx) => { const result = await newSession(server, ctx.params); - sendAvailableCommandsDeferred(ctx.client, result.sessionId, allCommands); + sendAvailableCommandsDeferred(server.clients.broadcast(), result.sessionId, allCommands); return result; }) .onRequest("session/list", (ctx) => listSessions(server, ctx.params)) .onRequest("session/resume", async (ctx) => { - const result = await resumeSession(server, ctx.params, ctx.client); - sendAvailableCommandsDeferred(ctx.client, ctx.params.sessionId, allCommands); + const result = await resumeSession(server, ctx.params, server.clients.broadcast()); + sendAvailableCommandsDeferred(server.clients.broadcast(), ctx.params.sessionId, allCommands); return result; }) .onRequest("session/load", async (ctx) => { - const result = await loadSession(server, ctx.params, ctx.client); - sendAvailableCommandsDeferred(ctx.client, ctx.params.sessionId, allCommands); + const result = await loadSession(server, ctx.params, server.clients.broadcast()); + sendAvailableCommandsDeferred(server.clients.broadcast(), ctx.params.sessionId, allCommands); return result; }) + // Tail-replay pagination (non-standard; Proposal 0001) — params stay + // top-level because the parser below is ours, unlike spec methods where + // bridge extensions must ride in `_meta.zcode`. + .onRequest( + "session/load_earlier", + z + .object({ sessionId: z.string(), before: z.string(), limit: z.number().optional() }) + .passthrough(), + (ctx) => loadEarlier(server, ctx.params, server.clients.broadcast()), + ) + // Account-level plan quota for remote clients (non-standard; Proposal + // 0002). Pull-only, no session required; errors carry the failure kind in + // `data.kind` so clients can hide the quota UI. + .onRequest("account/usage_stats", z.object({}).passthrough(), () => accountUsageStats()) .onRequest("session/prompt", (ctx) => - prompt(server, ctx.params, ctx.client, ctx.requestId as number), + prompt(server, ctx.params, server.clients.broadcast(), ctx.requestId as number), ) .onRequest("session/set_config_option", (ctx) => - setConfigOptionHandler(server, ctx.params, ctx.client), + setConfigOptionHandler(server, ctx.params, server.clients.broadcast()), ) // ZCode-specific extensions (non-standard ACP methods). Use a passthrough // zod parser so all param fields survive into the handler. @@ -134,7 +161,9 @@ async function main(): Promise { .onRequest("session/rewind", extParams, (ctx) => rewind(server, ctx.params)) .onRequest("session/rewindCascade", extParams, (ctx) => rewindCascade(server, ctx.params)) .onRequest("session/goal", extParams, (ctx) => goal(server, ctx.params)) - .onRequest("session/compact", extParams, (ctx) => compact(server, ctx.params, ctx.client)) + .onRequest("session/compact", extParams, (ctx) => + compact(server, ctx.params, server.clients.broadcast()), + ) .onRequest("session/steer", extParams, (ctx) => steer(server, ctx.params)) .onRequest("session/cancelBackgroundTask", extParams, (ctx) => cancelBackgroundTask(server, ctx.params), @@ -144,19 +173,31 @@ async function main(): Promise { updateRuntimeModelConfig(server, ctx.params), ) .onRequest("session/setModel", extParams, (ctx) => setModel(server, ctx.params)) - .onRequest("session/setMode", extParams, (ctx) => setMode(server, ctx.params, ctx.client)) + .onRequest("session/setMode", extParams, (ctx) => + setMode(server, ctx.params, server.clients.broadcast()), + ) // Spec spelling of the same call (ACP session-modes uses snake_case with // `modeId`); the handler normalizes the param. Without this route, spec-only // clients (e.g. Paseo) get -32601 and cannot create agents in a non-default // mode. - .onRequest("session/set_mode", extParams, (ctx) => setMode(server, ctx.params, ctx.client)) - .onNotification("session/cancel", (ctx) => cancel(server, ctx.params)) - .connect(stream); - - // Capture the connection-scoped AgentContext so background listeners can push - // session/update notifications outside of request handlers (e.g. when a - // background task completes after session/prompt has returned). - server.acpClient = connection.client; + .onRequest("session/set_mode", extParams, (ctx) => + setMode(server, ctx.params, server.clients.broadcast()), + ) + .onNotification("session/cancel", (ctx) => cancel(server, ctx.params)); + + // Register broadcast tracking BEFORE connect so the stdio connection is + // captured, then wire the stdio transport. The same app is later shared + // with the remote WS endpoint. + trackConnections(app, server.clients); + app.connect(stream); + + // Remote access (opt-in via ENV): serve the same app on a loopback WS/HTTP + // endpoint and register with the machine-level hub. Failures warn and leave + // the stdio link untouched. + const remoteConfig = parseRemoteConfig(); + if (remoteConfig) { + remoteHandle = await startRemoteEndpoint(server, app, remoteConfig); + } } main().catch((err) => { diff --git a/src/remote/broadcast.ts b/src/remote/broadcast.ts new file mode 100644 index 0000000..5dcbb35 --- /dev/null +++ b/src/remote/broadcast.ts @@ -0,0 +1,154 @@ +/** + * Multi-client broadcast layer for remote access. + * + * The bridge historically served ONE ACP client (the editor over stdio). With + * remote access enabled, additional clients attach over WebSocket; every + * agent-originated message must reach all of them. This module owns the client + * registry and a stable proxy that quacks like an `AgentContext`: + * + * - `notify` fans out to every client; a single dead/slow client is warned + * about and never fails the others. + * - `request` (permission / elicitation) is sent to every client and the FIRST + * response wins. Losers are aborted via `cancellationSignal`, which makes + * the SDK emit `$/cancel_request` so the losing editor dismisses its dialog + * (verified against Zed's ACP client). + * + * Loser promises settle late (the peer answers the cancellation eventually) — + * every raced promise carries a no-op catch so late settlements can't surface + * as unhandledRejection (Node ≥15 crashes on those by default). + */ + +import type * as acp from "@agentclientprotocol/sdk"; + +import { warn } from "../utils.js"; + +/** The AgentContext surface the bridge actually calls. */ +export interface ClientLike { + notify(method: string, params?: unknown): Promise; + request(method: string, params?: unknown, options?: acp.SendRequestOptions): Promise; +} + +/** + * Track every connection opened on the app (stdio editor + remote WebSocket) + * in the registry, removing each on close. Wired once by the entry point + * BEFORE `connect()` so the stdio connection is captured too. + */ +export function trackConnections(app: acp.AgentApp, clients: ClientRegistry): void { + app.onConnect((conn) => { + clients.add(conn.client); + void conn.closed.then(() => clients.remove(conn.client)); + }); +} + +/** One raced request outcome: which client won and what it answered. */ +interface RaceWinner { + value: unknown; + index: number; +} + +/** + * Registry of connected ACP clients (stdio editor + remote WebSocket clients). + * Membership is managed by the entry point via the SDK's per-connection + * lifecycle; the broadcast proxy reads membership live on every call. + */ +export class ClientRegistry { + private readonly clients = new Set(); + private proxy: acp.AgentContext | null = null; + + add(cx: ClientLike): void { + this.clients.add(cx); + } + + remove(cx: ClientLike): void { + this.clients.delete(cx); + } + + get size(): number { + return this.clients.size; + } + + /** Stable broadcast proxy satisfying the `AgentContext` call surface. */ + broadcast(): acp.AgentContext { + if (!this.proxy) this.proxy = createBroadcastProxy(this); + return this.proxy; + } + + snapshot(): ClientLike[] { + return Array.from(this.clients); + } +} + +/** Build the stable proxy once per registry (module factory: no `this` alias). */ +function createBroadcastProxy(registry: ClientRegistry): acp.AgentContext { + const proxy: Record = Object.create(null); + proxy.notify = (method: string, params?: unknown): Promise => + notifyAll(registry, method, params); + proxy.request = ( + method: string, + params?: unknown, + options?: acp.SendRequestOptions, + ): Promise => requestAny(registry, method, params, options); + return proxy as unknown as acp.AgentContext; +} + +async function notifyAll( + registry: ClientRegistry, + method: string, + params?: unknown, +): Promise { + const results = await Promise.allSettled( + registry.snapshot().map((cx) => cx.notify(method, params)), + ); + for (const r of results) { + if (r.status === "rejected") { + warn( + `broadcast: notify ${method} failed on one client: ` + + `${r.reason instanceof Error ? r.reason.message : String(r.reason)}`, + ); + } + } +} + +async function requestAny( + registry: ClientRegistry, + method: string, + params?: unknown, + options?: acp.SendRequestOptions, +): Promise { + const clients = registry.snapshot(); + if (clients.length === 0) { + throw new Error(`broadcast: no connected clients (${method})`); + } + const controllers = clients.map(() => new AbortController()); + // Link a caller-provided signal: aborting it cancels EVERY inner request. + const outerSignal = options?.cancellationSignal; + const onOuterAbort = () => { + for (const c of controllers) c.abort(); + }; + if (outerSignal) { + if (outerSignal.aborted) onOuterAbort(); + else outerSignal.addEventListener("abort", onOuterAbort, { once: true }); + } + const attempts = clients.map((cx, i) => { + const promise = cx.request(method, params, { + ...options, + cancellationSignal: controllers[i]!.signal, + }); + // Mark handled: losing promises settle AFTER Promise.any is done. + promise.catch(() => undefined); + return promise.then((value): RaceWinner => ({ value, index: i })); + }); + try { + const winner = await Promise.any(attempts); + for (let i = 0; i < controllers.length; i++) { + if (i !== winner.index) controllers[i]!.abort(); + } + return winner.value; + } catch (e) { + // All clients failed — surface the first error like a single client would. + if (e instanceof AggregateError) throw e.errors[0] ?? e; + throw e; + } finally { + if (outerSignal) outerSignal.removeEventListener("abort", onOuterAbort); + } +} diff --git a/src/remote/config.ts b/src/remote/config.ts new file mode 100644 index 0000000..2d332dd --- /dev/null +++ b/src/remote/config.ts @@ -0,0 +1,76 @@ +/** + * Remote access configuration from environment variables. + * + * Remote access is opt-in via ZCODE_ACP_REMOTE=1 and REQUIRES a token — the + * endpoint is expected to sit behind a public tunnel (Cloudflare Tunnel, frp), + * so "loopback-only" is never a safe assumption here. A missing token disables + * the feature with a warning instead of failing the bridge: the stdio link to + * the editor must keep working no matter what. + * + * Variables: + * ZCODE_ACP_REMOTE=1 enable the remote endpoint (gate) + * ZCODE_ACP_REMOTE_TOKEN= auth token (mandatory when enabled) + * ZCODE_ACP_HUB_PORT=8377 hub's fixed port (the one a tunnel maps) + * ZCODE_ACP_HUB_HOST=127.0.0.1 hub bind address (e.g. 0.0.0.0 for a + * containerized tunnel agent) + * ZCODE_ACP_REMOTE_PORT=8378 bridge endpoint start port (auto-increment + * when taken; loopback only) + */ + +import { warn } from "../utils.js"; + +export interface RemoteConfig { + token: string; + hubPort: number; + hubHost: string; + bridgePort: number; +} + +export const DEFAULT_HUB_PORT = 8377; +export const DEFAULT_BRIDGE_PORT = 8378; +export const DEFAULT_HUB_HOST = "127.0.0.1"; + +function parsePort(raw: string | undefined, fallback: number, envName: string): number { + if (!raw) return fallback; + const port = Number.parseInt(raw, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + warn(`remote: invalid ${envName}="${raw}", falling back to ${fallback}`); + return fallback; + } + return port; +} + +/** Parse remote config; null = disabled (or misconfigured → warned). */ +export function parseRemoteConfig(env: NodeJS.ProcessEnv = process.env): RemoteConfig | null { + const gate = (env.ZCODE_ACP_REMOTE ?? "").trim().toLowerCase(); + if (!["1", "true", "yes", "on"].includes(gate)) return null; + const token = (env.ZCODE_ACP_REMOTE_TOKEN ?? "").trim(); + if (!token) { + warn( + "remote: ZCODE_ACP_REMOTE is enabled but ZCODE_ACP_REMOTE_TOKEN is missing — " + + "remote access disabled (stdio unaffected)", + ); + return null; + } + return { + token, + hubPort: parsePort(env.ZCODE_ACP_HUB_PORT, DEFAULT_HUB_PORT, "ZCODE_ACP_HUB_PORT"), + hubHost: (env.ZCODE_ACP_HUB_HOST ?? "").trim() || DEFAULT_HUB_HOST, + bridgePort: parsePort(env.ZCODE_ACP_REMOTE_PORT, DEFAULT_BRIDGE_PORT, "ZCODE_ACP_REMOTE_PORT"), + }; +} + +/** Parse hub-side config for the standalone `zcode-acp-hub` bin. */ +export function parseHubConfig(env: NodeJS.ProcessEnv = process.env): RemoteConfig | null { + const token = (env.ZCODE_ACP_REMOTE_TOKEN ?? "").trim(); + if (!token) { + warn("hub: ZCODE_ACP_REMOTE_TOKEN is required — refusing to start without auth"); + return null; + } + return { + token, + hubPort: parsePort(env.ZCODE_ACP_HUB_PORT, DEFAULT_HUB_PORT, "ZCODE_ACP_HUB_PORT"), + hubHost: (env.ZCODE_ACP_HUB_HOST ?? "").trim() || DEFAULT_HUB_HOST, + bridgePort: parsePort(env.ZCODE_ACP_REMOTE_PORT, DEFAULT_BRIDGE_PORT, "ZCODE_ACP_REMOTE_PORT"), + }; +} diff --git a/src/remote/endpoint.ts b/src/remote/endpoint.ts new file mode 100644 index 0000000..e79cd50 --- /dev/null +++ b/src/remote/endpoint.ts @@ -0,0 +1,244 @@ +/** + * Loopback ACP endpoint + hub registration for remote access. + * + * When ZCODE_ACP_REMOTE is enabled, the bridge serves the SAME AgentApp that + * handles the stdio editor connection on a loopback HTTP/WebSocket endpoint + * (SDK AcpServer transport). Each remote connection gets its own JSON-RPC id + * space; fan-out to all clients is handled by the broadcast registry, not + * here. This endpoint is intentionally NOT exposed to the network — the hub + * (`zcode-acp-hub`) is the single public entry and proxies into it. + * + * The bridge also registers itself with the hub (spawning one if none is + * listening) and re-registers every 10s as a heartbeat carrying fresh session + * summaries. Everything here is best-effort: any failure warns and disables + * the remote side without touching the stdio link. + */ + +import { spawn } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { fileURLToPath } from "node:url"; + +import type * as acp from "@agentclientprotocol/sdk"; +import { AcpServer } from "@agentclientprotocol/sdk/experimental/server"; +import { + createNodeHttpHandler, + createNodeWebSocketUpgradeHandler, +} from "@agentclientprotocol/sdk/experimental/node"; +import { WebSocketServer } from "ws"; + +import type { ZcodeAcpServer } from "../server.js"; +import { AGENT_INFO, log, warn } from "../utils.js"; +import type { RemoteConfig } from "./config.js"; + +/** How often the bridge re-registers with the hub (also the heartbeat). */ +const HEARTBEAT_MS = 10_000; +/** Minimum spacing between hub spawn attempts (avoids spawn storms). */ +const SPAWN_THROTTLE_MS = 60_000; +/** Max ports probed above ZCODE_ACP_REMOTE_PORT before giving up. */ +const MAX_PORT_PROBES = 100; + +export interface RemoteEndpointHandle { + /** Actual loopback port the endpoint bound (may differ from config). */ + port: number; + /** Stop the endpoint and unregister from the hub (best-effort). */ + stop(): Promise; +} + +/** Probe one loopback port; false = taken or otherwise unusable. */ +function tryListen(server: Server, port: number): Promise { + return new Promise((resolve) => { + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => resolve(true)); + }); +} + +/** Session summaries for the hub's discovery API. */ +function sessionsPayload( + server: ZcodeAcpServer, +): Array<{ sessionId: string; title?: string; updatedAt: number }> { + return Array.from(server.sessionSummaries.entries(), ([sessionId, s]) => ({ + sessionId, + ...(s.title !== undefined ? { title: s.title } : {}), + updatedAt: s.updatedAt, + })); +} + +async function postJson(url: string, body: unknown, timeoutMs = 3000): Promise { + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); +} + +/** + * Start the loopback endpoint and hub registration. Never throws — failures + * warn and leave the bridge running stdio-only. + */ +export async function startRemoteEndpoint( + server: ZcodeAcpServer, + app: acp.AgentApp, + config: RemoteConfig, +): Promise { + const acpServer = new AcpServer({ agent: app }); + const acpHttpHandler = createNodeHttpHandler(acpServer); + const wss = new WebSocketServer({ noServer: true }); + const upgradeHandler = createNodeWebSocketUpgradeHandler(acpServer, wss); + + const httpServer = createServer((req, res) => { + const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname; + if (path === "/acp") acpHttpHandler(req, res); + else { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("not found"); + } + }); + httpServer.on("upgrade", (req, socket, head) => { + const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname; + if (path === "/acp") upgradeHandler(req, socket, head); + else socket.destroy(); + }); + + // Port scan: several Zed windows spawn several bridges, each takes the next + // free port starting at ZCODE_ACP_REMOTE_PORT. + let port = 0; + for (let probe = 0; probe < MAX_PORT_PROBES; probe++) { + if (await tryListen(httpServer, config.bridgePort + probe)) { + port = config.bridgePort + probe; + break; + } + } + if (!port) { + warn( + `remote: no free loopback port in ${config.bridgePort}..${config.bridgePort + MAX_PORT_PROBES - 1} — remote disabled`, + ); + wss.close(); + return null; + } + // The listener must not keep the process alive on its own (ADR-0001: the + // bridge's lifetime follows the stdio editor, not remote clients). + httpServer.unref(); + httpServer.on("error", (e) => warn(`remote: endpoint error: ${e.message}`)); + log(`remote: ACP endpoint listening on 127.0.0.1:${port}/acp`); + + // ---- hub registration (heartbeat loop) ---- + const instanceId = String(process.pid); + let stopped = false; + let authRejected = false; + let spawnThrottledUntil = 0; + + const payload = () => ({ + token: config.token, + id: instanceId, + port, + pid: process.pid, + workspace: server.workspaceLabel(), + sessions: sessionsPayload(server), + // Lets the hub detect that it is older than this bridge and restart + // itself (we then re-spawn it from this dist — see registerOnce). + version: AGENT_INFO.version, + }); + + const spawnHub = (): void => { + try { + // dist/remote/endpoint.js → dist/bin/hub.js (one level up, then bin/). + const hubJs = fileURLToPath(new URL("../bin/hub.js", import.meta.url)); + const child = spawn(process.execPath, [hubJs], { + detached: true, + // Surface the daemon's stderr through the bridge's diagnostics — a + // detached "ignore" pipe silently eats startup failures. + stdio: ["ignore", "ignore", "pipe"], + env: { + ...process.env, + ZCODE_ACP_HUB_PORT: String(config.hubPort), + ZCODE_ACP_HUB_HOST: config.hubHost, + ZCODE_ACP_REMOTE_TOKEN: config.token, + }, + }); + child.stderr?.on("data", (d: Buffer) => { + for (const line of d.toString().split("\n")) { + if (line.trim()) warn(`remote: hub: ${line}`); + } + }); + // Spawn failures (ENOENT when run from src without a build) arrive as an + // async 'error' event — without a listener Node crashes the bridge. + child.once("error", (e) => { + warn(`remote: hub spawn failed: ${e.message}`); + }); + child.unref(); + log(`remote: spawned hub on port ${config.hubPort} (pid ${child.pid})`); + } catch (e) { + warn(`remote: hub spawn failed: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + const registerOnce = async (): Promise => { + if (stopped || authRejected) return; + try { + const res = await postJson(`http://127.0.0.1:${config.hubPort}/api/register`, payload()); + if (res.status === 401) { + authRejected = true; + warn( + "remote: hub rejected the token (401) — registration stopped, check ZCODE_ACP_REMOTE_TOKEN", + ); + return; + } + // Version handshake: the hub saw a newer bridge and is exiting. It is + // gone by now (it exits ~0.5s after replying) — re-spawn it from THIS + // dist (the upgraded code) and re-register. Throttled like the spawn + // below so a hub that keeps answering `restarting` can't loop us. + if (res.ok) { + const body = (await res.json().catch(() => null)) as { restarting?: boolean } | null; + if (body?.restarting) { + log("remote: hub is older than this bridge — respawning upgraded hub"); + const respawn = setTimeout(() => { + if (stopped || authRejected) return; + if (Date.now() < spawnThrottledUntil) return; + spawnThrottledUntil = Date.now() + SPAWN_THROTTLE_MS; + spawnHub(); + const retry = setTimeout(() => void registerOnce(), 1500); + retry.unref(); + }, 2000); + respawn.unref(); + } + } + } catch { + // Hub unreachable: (re)spawn it, throttled so a failing spawn can't + // storm, then retry registration shortly after the daemon warms up + // instead of waiting a full heartbeat cycle. + if (Date.now() >= spawnThrottledUntil) { + spawnThrottledUntil = Date.now() + SPAWN_THROTTLE_MS; + spawnHub(); + const retry = setTimeout(() => void registerOnce(), 1500); + retry.unref(); + } + } + }; + + void registerOnce(); + const heartbeat = setInterval(() => void registerOnce(), HEARTBEAT_MS); + heartbeat.unref(); + + return { + port, + async stop(): Promise { + stopped = true; + clearInterval(heartbeat); + try { + await postJson( + `http://127.0.0.1:${config.hubPort}/api/unregister`, + { token: config.token, id: instanceId }, + 1500, + ); + } catch { + // Hub gone or unreachable — its heartbeat TTL will drop us anyway. + } + for (const client of wss.clients) client.terminate(); + wss.close(); + await acpServer.close().catch(() => undefined); + httpServer.closeAllConnections?.(); + httpServer.close(); + }, + }; +} diff --git a/src/remote/hub-server.ts b/src/remote/hub-server.ts new file mode 100644 index 0000000..d59160a --- /dev/null +++ b/src/remote/hub-server.ts @@ -0,0 +1,412 @@ +/** + * zcode-acp-hub — machine-level singleton for remote access. + * + * The hub is the ONLY public entry point (the port a tunnel maps). It does + * exactly three things (ADR-0002): token auth, instance discovery, and + * byte-level WebSocket proxying from a remote client to one bridge's loopback + * ACP endpoint. It holds no session state and understands no ACP — a proxied + * connection stays bound to one instance for its whole lifetime. + * + * Bridges register via POST /api/register every 10s (the registration doubles + * as the heartbeat; entries older than the heartbeat TTL are pruned). A client + * that needs an immediately-honest list (e.g. a phone app's pull-to-refresh) + * passes ?probe=1 to /api/instances: the hub TCP-probes each registered + * loopback port and prunes unreachable bridges before answering — no periodic + * probing, the cost is paid only when someone refreshes. When no instance is + * registered and no proxy is active for `idleExitMs`, the hub exits — the + * next bridge re-spawns it on demand. + */ + +import { createHash, timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import net from "node:net"; + +import { WebSocket, WebSocketServer, type RawData } from "ws"; + +import { AGENT_INFO, compareVersions, log, warn } from "../utils.js"; + +export interface HubOptions { + port: number; + host: string; + token: string; + /** Registration TTL before an instance is pruned (default 30s). */ + heartbeatTimeoutMs?: number; + /** Idle time with zero instances and zero proxies before exit (default 10min). */ + idleExitMs?: number; + /** WebSocket keepalive ping interval (default 30s; tunnels drop idle links). */ + pingIntervalMs?: number; +} + +export interface HubHandle { + port: number; + close(): Promise; +} + +interface SessionSummary { + sessionId: string; + title?: string; + updatedAt: number; +} + +interface InstanceEntry { + id: string; + port: number; + pid: number; + startedAt: number; + workspace: string; + sessions: SessionSummary[]; + lastSeen: number; +} + +const HEARTBEAT_TIMEOUT_MS = 30_000; +const IDLE_EXIT_MS = 10 * 60_000; +const PING_INTERVAL_MS = 30_000; +/** Per-instance TCP probe timeout for /api/instances?probe=1. */ +const PROBE_TIMEOUT_MS = 500; +const MAX_BODY_BYTES = 1024 * 1024; + +/** Constant-time token compare (hash both to equal length first). */ +function tokenEquals(a: string, b: string): boolean { + const ha = createHash("sha256").update(a).digest(); + const hb = createHash("sha256").update(b).digest(); + return timingSafeEqual(ha, hb); +} + +function authorized(req: IncomingMessage, url: URL, token: string): boolean { + const header = req.headers.authorization; + if (header?.startsWith("Bearer ")) return tokenEquals(header.slice(7), token); + const query = url.searchParams.get("token"); + return query !== null && tokenEquals(query, token); +} + +function setCors(res: ServerResponse): void { + // The web UI is deployed as a separate origin; the token is the boundary. + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type"); +} + +async function readJson(req: IncomingMessage): Promise | null> { + const chunks: Buffer[] = []; + let size = 0; + try { + // The async iterator rejects when the client aborts mid-body — a truncated + // POST must degrade to "invalid body", not reject into the event loop. + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > MAX_BODY_BYTES) return null; + chunks.push(chunk as Buffer); + } + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function validSessions(raw: unknown): SessionSummary[] | null { + if (!Array.isArray(raw)) return null; + const out: SessionSummary[] = []; + for (const s of raw) { + const rec = s as { sessionId?: unknown; title?: unknown; updatedAt?: unknown }; + if (typeof rec?.sessionId !== "string") return null; + out.push({ + sessionId: rec.sessionId, + ...(typeof rec.title === "string" ? { title: rec.title } : {}), + updatedAt: typeof rec.updatedAt === "number" ? rec.updatedAt : Date.now(), + }); + } + return out; +} + +/** + * TCP-probe a bridge's loopback endpoint. Loopback refusals are instant, so + * the timeout only guards pathological cases; a bare connect+destroy is + * harmless to the bridge's HTTP server. + */ +function portOpen(port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + const done = (ok: boolean): void => { + socket.destroy(); + resolve(ok); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => done(true)); + socket.once("timeout", () => done(false)); + socket.once("error", () => done(false)); + socket.connect(port, "127.0.0.1"); + }); +} + +/** + * Start the hub. Resolves once listening; rejects on bind failure (including + * EADDRINUSE when another hub already owns the port). + */ +export function startHub(options: HubOptions & { onIdleExit?: () => void }): Promise { + const { + port, + host, + token, + heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, + idleExitMs = IDLE_EXIT_MS, + pingIntervalMs = PING_INTERVAL_MS, + onIdleExit, + } = options; + + const instances = new Map(); + const proxyPairs = new Set<{ client: WebSocket; bridge: WebSocket }>(); + const timers: Array> = []; + + let idleSince: number | null = null; + + const wss = new WebSocketServer({ noServer: true }); + + const server: Server = createServer((req, res) => { + // Async handler failures (malformed URL, aborted body) must never escape + // into the event loop — warn and drop the connection. + void handleHttp(req, res).catch((e) => { + warn(`hub: request failed: ${e instanceof Error ? e.message : String(e)}`); + res.destroy(); + }); + }); + + server.on("upgrade", (req, socket, head) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== "/acp") { + socket.destroy(); + return; + } + if (!authorized(req, url, token)) { + warn("hub: unauthorized WS upgrade rejected"); + socket.destroy(); + return; + } + const entry = instances.get(url.searchParams.get("instance") ?? ""); + if (!entry) { + warn("hub: WS upgrade for unknown instance rejected"); + socket.destroy(); + return; + } + // Dial the bridge's loopback endpoint before accepting the client side, + // so a dead bridge fails the upgrade instead of half-opening a pipe. + const bridge = new WebSocket(`ws://127.0.0.1:${entry.port}/acp`); + bridge.once("open", () => { + wss.handleUpgrade(req, socket, head, (client) => startProxy(client, bridge)); + }); + bridge.once("error", (e) => { + warn(`hub: dial bridge :${entry.port} failed: ${e.message}`); + socket.destroy(); + }); + }); + + async function handleHttp(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + setCors(res); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + if (url.pathname === "/api/health") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + return; + } + if (url.pathname === "/api/instances" && req.method === "GET") { + if (!authorized(req, url, token)) { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + return; + } + // On-demand liveness probe (?probe=1): verify every registered bridge's + // loopback port and prune the unreachable ones before answering, so a + // client refresh gets an honest list instead of waiting out the + // heartbeat TTL (hard-killed bridges never unregister). + if (["1", "true"].includes((url.searchParams.get("probe") ?? "").toLowerCase())) { + const probes = await Promise.all( + Array.from(instances.entries(), async ([id, entry]) => ({ + id, + ok: await portOpen(entry.port, PROBE_TIMEOUT_MS), + })), + ); + for (const { id, ok } of probes) { + if (!ok) { + instances.delete(id); + idleSince = null; // re-arm the idle clock on membership change + log(`hub: pruned instance ${id} (probe: endpoint unreachable)`); + } + } + } + const list = Array.from(instances.values()) + .sort((a, b) => a.startedAt - b.startedAt) + .map((e) => ({ + id: e.id, + port: e.port, + pid: e.pid, + startedAt: e.startedAt, + workspace: e.workspace, + sessions: e.sessions, + })); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(list)); + return; + } + if ( + (url.pathname === "/api/register" || url.pathname === "/api/unregister") && + req.method === "POST" + ) { + const body = await readJson(req); + if (!body || typeof body.token !== "string" || !tokenEquals(body.token, token)) { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + return; + } + const id = typeof body.id === "string" ? body.id : ""; + if (!id) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("missing id"); + return; + } + if (url.pathname === "/api/register") { + const bridgePort = typeof body.port === "number" ? body.port : 0; + const sessions = validSessions(body.sessions); + if (!(bridgePort >= 1 && bridgePort <= 65535) || !sessions) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("invalid register payload"); + return; + } + const prev = instances.get(id); + instances.set(id, { + id, + port: bridgePort, + pid: typeof body.pid === "number" ? body.pid : 0, + startedAt: prev?.startedAt ?? Date.now(), + workspace: typeof body.workspace === "string" ? body.workspace : "", + sessions, + lastSeen: Date.now(), + }); + } else { + instances.delete(id); + idleSince = null; // re-arm the idle clock on membership change + } + // Version self-upgrade: a bridge NEWER than this hub just registered, + // so this process is running stale code. Reply first (the bridge + // re-spawns the hub from its own, newer dist when it sees `restarting`), + // then exit. Equal/older/absent versions never trigger a restart. + const stale = + url.pathname === "/api/register" && + typeof body.version === "string" && + compareVersions(body.version, AGENT_INFO.version) > 0; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(stale ? { ok: true, restarting: true } : { ok: true })); + if (stale) { + log( + `hub: bridge ${body.version} is newer than hub ${AGENT_INFO.version} — restarting to upgrade`, + ); + const restart = setTimeout(() => { + void close().finally(() => onIdleExit?.()); + }, 500); + restart.unref(); + } + return; + } + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("not found"); + } + + function startProxy(client: WebSocket, bridge: WebSocket): void { + const pair = { client, bridge }; + proxyPairs.add(pair); + const teardown = (): void => { + if (!proxyPairs.delete(pair)) return; + client.close(); + bridge.close(); + }; + // Forward with the frame's binary flag intact: `ws.send(buffer)` defaults + // to a BINARY frame, and the SDK's WS server drops non-text frames. + const forward = + (to: WebSocket) => + (data: RawData, isBinary: boolean): void => { + if (to.readyState === WebSocket.OPEN) to.send(data, { binary: isBinary }); + }; + client.on("message", forward(bridge)); + bridge.on("message", forward(client)); + client.on("close", teardown); + client.on("error", teardown); + bridge.on("close", teardown); + bridge.on("error", teardown); + } + + // Prune instances whose bridge stopped heartbeating (crash or Zed exit). + const pruner = setInterval( + () => { + const now = Date.now(); + for (const [id, entry] of instances) { + if (now - entry.lastSeen > heartbeatTimeoutMs) { + instances.delete(id); + idleSince = null; + log(`hub: pruned instance ${id} (heartbeat timeout)`); + } + } + }, + Math.min(heartbeatTimeoutMs / 2, 5_000), + ); + pruner.unref(); + timers.push(pruner); + + // Keepalive pings on both legs — tunnels (notably Cloudflare) drop idle WS. + const pinger = setInterval(() => { + for (const { client, bridge } of proxyPairs) { + if (client.readyState === WebSocket.OPEN) client.ping(); + if (bridge.readyState === WebSocket.OPEN) bridge.ping(); + } + }, pingIntervalMs); + pinger.unref(); + timers.push(pinger); + + // Idle exit: with nothing registered and nobody proxied, the hub exits; the + // next bridge re-spawns it on demand (see endpoint.ts). + const idleCheck = setInterval( + () => { + if (instances.size > 0 || proxyPairs.size > 0) { + idleSince = null; + return; + } + if (idleSince === null) idleSince = Date.now(); + if (Date.now() - idleSince >= idleExitMs) { + log("hub: idle for too long with no instances — exiting"); + clearInterval(idleCheck); + void close().finally(() => onIdleExit?.()); + } + }, + Math.min(idleExitMs / 4, 10_000), + ); + idleCheck.unref(); + timers.push(idleCheck); + + async function close(): Promise { + for (const t of timers) clearInterval(t); + for (const { client, bridge } of proxyPairs) { + client.terminate(); + bridge.terminate(); + } + proxyPairs.clear(); + wss.close(); + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + } + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + // port 0 binds an ephemeral port — report the actual one (used by tests). + const addr = server.address(); + const bound = typeof addr === "object" && addr !== null ? addr.port : port; + log(`hub: listening on ${host}:${bound} (instances: 0)`); + resolve({ port: bound, close }); + }); + }); +} diff --git a/src/server.ts b/src/server.ts index 982dfde..091d3f4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,8 @@ import { ZcodeBackend, } from "./backend/index.js"; import { BackgroundTaskListener } from "./handlers/background-tasks.js"; +import { enqueueSessionSend } from "./handlers/io.js"; +import { ClientRegistry } from "./remote/broadcast.js"; import { AGENT_INFO, PROTOCOL_VERSION, log } from "./utils.js"; /** Client capabilities advertised in the initialize request. */ @@ -67,16 +69,26 @@ export class ZcodeAcpServer { * create is running, so concurrent first-uses (e.g. a raced double prompt) * share one `session/create` instead of creating two backend sessions. */ - readonly pendingSessions = new Map; - /** Client-provided MCP servers from session/new, replayed verbatim into - * the backend's session/create when the lazy session materializes. The - * backend's mcpServers schema matches the ACP array shape (stdio entries - * carry command/args/env; remote entries carry type/url), so entries are - * passed through unchanged. */ - mcpServers?: acp.McpServer[]; - }>(); + readonly pendingSessions = new Map< + string, + { + cwd: string; + creating?: Promise; + /** Client-provided MCP servers from session/new, replayed verbatim into + * the backend's session/create when the lazy session materializes. The + * backend's mcpServers schema matches the ACP array shape (stdio entries + * carry command/args/env; remote entries carry type/url), so entries are + * passed through unchanged. */ + mcpServers?: acp.McpServer[]; + } + >(); + /** + * Session cwds (acp_sid → cwd), recorded at session/new and lazy-recovery. + * Unlike pendingSessions this survives materialization — the hub discovery + * payload needs a workspace label for live sessions, whose pending entries + * are deleted on first use. + */ + readonly sessionCwds = new Map(); /** Currently running turns, keyed by the ACP request id. */ readonly pendingTurns = new Map(); /** @@ -85,16 +97,24 @@ export class ZcodeAcpServer { * concurrent prompts from both missing each other and registering at once. */ readonly preemptLocks = new Map>(); - /** Capabilities advertised by the connected client (Zed, JetBrains, ...). */ + /** Capabilities advertised by connected clients (Zed, JetBrains, remote). */ clientCapabilities: ClientCapabilities = {}; /** - * Connection-scoped AgentContext, captured at `connect()` time. Held so that - * background listeners can push `session/update` notifications OUTSIDE a - * request handler (e.g. when a background task completes after `session/ - * prompt` has already returned). Null before `connect()` runs (only during - * the brief startup window — sessions can't be created before connect). + * All connected ACP clients (the stdio editor plus any remote WebSocket + * clients). Handlers push notifications through `clients.broadcast()` so + * every attached client sees the same stream; the registry replaces the old + * single `acpClient` reference. Background listeners use it to push + * `session/update` notifications outside request handlers. */ - acpClient: acp.AgentContext | null = null; + readonly clients = new ClientRegistry(); + /** + * Lightweight session summaries for the remote hub's discovery API + * (acp_sid → { title, updatedAt }). In-memory only — the hub holds no + * business state and the bridge dies with its editor, so persistence would + * buy nothing. Maintained by `touchSessionSummary` at session registration, + * title set, and turn completion. + */ + readonly sessionSummaries = new Map(); /** Session titles already set, to enforce set-once (acp_sid → title). */ readonly sessionTitles = new Map(); /** @@ -167,6 +187,24 @@ export class ZcodeAcpServer { registerSession(acpSid: string, zcodeSid: string): void { this.sessionMap.set(acpSid, zcodeSid); this.acpSidByZcodeSid.set(zcodeSid, acpSid); + this.touchSessionSummary(acpSid); + } + + /** Update a session's discovery summary (title sticky once set). */ + touchSessionSummary(acpSid: string, title?: string): void { + const existing = this.sessionSummaries.get(acpSid); + this.sessionSummaries.set(acpSid, { + title: title ?? existing?.title, + updatedAt: Date.now(), + }); + } + + /** + * Best-effort workspace label for the hub discovery payload: first known + * session cwd, else the bridge process cwd. + */ + workspaceLabel(): string { + return this.sessionCwds.values().next().value ?? process.cwd(); } /** @@ -201,12 +239,16 @@ export class ZcodeAcpServer { * the bridge on a notification failure. */ async notifyByZcodeSid(zcodeSid: string, update: acp.SessionUpdate): Promise { - const cx = this.acpClient; - if (!cx) return false; + if (this.clients.size === 0) return false; const acpSid = this.resolveAcpSid(zcodeSid); if (!acpSid) return false; try { - await cx.notify("session/update", { sessionId: acpSid, update }); + // Broadcast notify swallows per-client failures internally (warn only). + // Serialized through the replay guard so a background emission queues + // behind an in-flight replay batch for the same session. + await enqueueSessionSend(acpSid, () => + this.clients.broadcast().notify("session/update", { sessionId: acpSid, update }), + ); return true; } catch (e) { log(`notifyByZcodeSid: session/update failed: ${e instanceof Error ? e.message : String(e)}`); @@ -230,10 +272,33 @@ export class ZcodeAcpServer { return this.clientCapabilities.elicitation?.form != null; } + /** + * OR-merge capabilities from a newly connected client. Each connection runs + * its own `initialize`; boolean capabilities are unioned across clients so a + * feature advertised by ANY attached client (Zed or a remote one) enables the + * richer interaction path, and `_meta` flags (e.g. terminal_output) merge + * shallowly. Idempotent for re-connecting clients with equal capabilities. + */ + mergeClientCapabilities(caps: ClientCapabilities): void { + const cur = this.clientCapabilities; + const next: ClientCapabilities = { ...cur, ...caps }; + next.fs = { + readTextFile: cur.fs?.readTextFile || caps.fs?.readTextFile, + writeTextFile: cur.fs?.writeTextFile || caps.fs?.writeTextFile, + }; + next.terminal = cur.terminal || caps.terminal; + next.elicitation = { + form: cur.elicitation?.form || caps.elicitation?.form, + url: cur.elicitation?.url || caps.elicitation?.url, + }; + if (cur._meta || caps._meta) next._meta = { ...cur._meta, ...caps._meta }; + this.clientCapabilities = next; + } + /** Handle `initialize`: negotiate version + declare agent capabilities. */ async initialize(params: acp.InitializeRequest): Promise { const clientInfo = (params.clientInfo as { name?: string; version?: string } | null) ?? null; - this.clientCapabilities = (params.clientCapabilities as ClientCapabilities) ?? {}; + this.mergeClientCapabilities((params.clientCapabilities as ClientCapabilities) ?? {}); log( `initialize: client protocolVersion=${params.protocolVersion}` + `, client=${clientInfo?.name ?? "unknown"}` + diff --git a/src/utils.ts b/src/utils.ts index 294b4ac..4ff5423 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,17 +4,35 @@ * Logging goes to stderr so it never corrupts the stdout ACP protocol stream. */ +import { readFileSync } from "node:fs"; import path from "node:path"; import process from "node:process"; /** ACP protocol version this server speaks. */ export const PROTOCOL_VERSION = 1; +/** + * Package version, read once from package.json. Kept in sync with releases by + * construction (the hardcoded constant used to drift from package.json); the + * hub-vs-bridge version handshake in remote/ relies on it changing per + * release. + */ +const PACKAGE_VERSION: string = (() => { + try { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + version?: string; + }; + return pkg.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +})(); + /** Agent identity advertised in the initialize response. */ export const AGENT_INFO = { name: "zcode-acp-server", title: "ZCode", - version: "0.1.0", + version: PACKAGE_VERSION, } as const; /** Path to the ZCode v2 config (credentials + provider/model metadata). */ diff --git a/tests/account-usage.test.ts b/tests/account-usage.test.ts new file mode 100644 index 0000000..2b461f6 --- /dev/null +++ b/tests/account-usage.test.ts @@ -0,0 +1,127 @@ +/** + * account/usage_stats handler tests (Proposal 0002). + * + * Verifies the combined dual-provider mapping: GLM items pass through with + * plan level and per-model details; Opencode Go windows are emitted with the + * relative reset countdown converted to an absolute timestamp; per-provider + * failures become `kind` strings (never thrown), and a `not_configured` Go + * section is reported as-is so the client can omit it. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { queryCombinedMock } = vi.hoisted(() => ({ queryCombinedMock: vi.fn() })); + +vi.mock("../src/quota/combined.js", async () => { + const actual = await vi.importActual( + "../src/quota/combined.js", + ); + return { ...actual, queryCombined: queryCombinedMock }; +}); + +import { accountUsageStats } from "../src/handlers/account.js"; +import type { CombinedResult } from "../src/quota/combined.js"; + +const NOW = 1_700_000_000_000; + +beforeEach(() => { + queryCombinedMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("accountUsageStats", () => { + it("passes GLM items through with level and per-model details", async () => { + queryCombinedMock.mockResolvedValue({ + glm: { + kind: "success", + level: "pro", + items: [ + { + key: "token_5h", + label: "5h", + usedPercent: 35, + leftPercent: 65, + nextResetTime: 1723812000000, + }, + { + key: "mcp", + label: "MCP", + usedPercent: 10, + leftPercent: 90, + usedCount: 3, + totalCount: 30, + detail: [{ modelCode: "search-prime", usage: 2 }], + }, + ], + }, + go: { kind: "not_configured" }, + } satisfies CombinedResult); + + const out = await accountUsageStats(); + expect(out.glm.kind).toBe("success"); + expect(out.glm.level).toBe("pro"); + expect(out.glm.items).toHaveLength(2); + // Verbatim passthrough: counts and details ride along for the client. + expect(out.glm.items![1]).toMatchObject({ + key: "mcp", + usedCount: 3, + totalCount: 30, + detail: [{ modelCode: "search-prime", usage: 2 }], + }); + // not_configured Go is reported as-is — the client omits the section. + expect(out.opencode).toEqual({ kind: "not_configured" }); + }); + + it("converts Go reset countdowns to absolute timestamps", async () => { + const fetchedAt = NOW - 60_000; // snapshot is 1 minute old + queryCombinedMock.mockResolvedValue({ + glm: { kind: "unavailable" }, + go: { + kind: "success", + fetchedAt, + rolling: { usagePercent: 5, resetInSec: 3600 }, // 1h left at fetch time + weekly: { usagePercent: 25, resetInSec: 86_400 }, + monthly: null, // absent window is dropped, not rendered as "(no data)" + }, + } satisfies CombinedResult); + + const out = await accountUsageStats(); + expect(out.opencode.kind).toBe("success"); + expect(out.opencode.windows).toEqual([ + // resetsAt = fetchedAt + (resetInSec − 60s elapsed since the snapshot) + { key: "rolling", label: "5h", usagePercent: 5, resetsAt: fetchedAt + 3_540_000 }, + { key: "weekly", label: "Week", usagePercent: 25, resetsAt: fetchedAt + 86_340_000 }, + ]); + // GLM failure is a kind string, never a thrown JSON-RPC error. + expect(out.glm).toEqual({ kind: "unavailable" }); + }); + + it("reports auth failures as section kinds instead of throwing", async () => { + queryCombinedMock.mockResolvedValue({ + glm: { kind: "auth_error" }, + go: { kind: "auth_error" }, + } satisfies CombinedResult); + + const out = await accountUsageStats(); + expect(out).toEqual({ + glm: { kind: "auth_error" }, + opencode: { kind: "auth_error" }, + }); + }); + + it("returns empty sections when the API reports no windows", async () => { + queryCombinedMock.mockResolvedValue({ + glm: { kind: "success", level: "pro", items: [] }, + go: { kind: "not_configured" }, + } satisfies CombinedResult); + + const out = await accountUsageStats(); + expect(out.glm.items).toEqual([]); + expect(out.opencode.kind).toBe("not_configured"); + }); +}); diff --git a/tests/hub.test.ts b/tests/hub.test.ts new file mode 100644 index 0000000..2bb1e56 --- /dev/null +++ b/tests/hub.test.ts @@ -0,0 +1,412 @@ +/** + * Hub integration tests — real hub on an ephemeral port: auth, discovery, + * register/unregister lifecycle, heartbeat pruning, WS byte proxying, and the + * idle-exit policy. + */ + +import net from "node:net"; + +import { WebSocket, WebSocketServer } from "ws"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { startHub, type HubHandle } from "../src/remote/hub-server.js"; + +const TOKEN = "test-hub-token"; +const BASE_PORT = 18400; // bridge ports start here; ephemeral hub uses port 0 + +const cleanups: Array<() => Promise | void> = []; + +function track(value: T, stop: (v: T) => Promise | void): T { + cleanups.push(() => stop(value)); + return value; +} + +async function startTestHub( + opts: Partial[0]> = {}, +): Promise { + const hub = await startHub({ port: 0, host: "127.0.0.1", token: TOKEN, ...opts }); + cleanups.push(() => hub.close()); + return hub; +} + +afterEach(async () => { + while (cleanups.length) { + const stop = cleanups.pop()!; + await stop(); + } +}); + +function registerBody(overrides: Record = {}) { + return { + token: TOKEN, + id: "inst-1", + port: BASE_PORT, + pid: 123, + workspace: "/tmp/proj", + sessions: [{ sessionId: "s1", title: "hello", updatedAt: 1 }], + ...overrides, + }; +} + +async function listInstances(hub: HubHandle, token = TOKEN): Promise { + return fetch(`http://127.0.0.1:${hub.port}/api/instances`, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), ms)), + ]); +} + +describe("hub discovery API", () => { + it("answers health without auth", async () => { + const hub = await startTestHub(); + const res = await fetch(`http://127.0.0.1:${hub.port}/api/health`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + }); + + it("rejects /api/instances without or with a wrong token", async () => { + const hub = await startTestHub(); + expect((await fetch(`http://127.0.0.1:${hub.port}/api/instances`)).status).toBe(401); + expect((await listInstances(hub, "wrong")).status).toBe(401); + }); + + it("returns CORS headers and handles preflight", async () => { + const hub = await startTestHub(); + const res = await fetch(`http://127.0.0.1:${hub.port}/api/instances`, { + method: "OPTIONS", + }); + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("survives a client aborting mid-POST (no unhandled rejection)", async () => { + const hub = await startTestHub(); + await new Promise((resolve) => { + const sock = net.connect({ host: "127.0.0.1", port: hub.port }, () => { + // Announce more body bytes than are sent, then drop the connection — + // readJson's async iterator rejects on the aborted request body. + sock.write( + "POST /api/register HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Content-Type: application/json\r\nContent-Length: 100\r\n\r\n" + + '{"token":"test-hub-token"', + ); + sock.destroy(); + resolve(); + }); + }); + // Give the aborted request's rejection a beat to surface, then confirm the + // hub is still serving. + await new Promise((resolve) => setTimeout(resolve, 50)); + const res = await fetch(`http://127.0.0.1:${hub.port}/api/health`); + expect(res.status).toBe(200); + }); + + it("lists registered instances with their sessions", async () => { + const hub = await startTestHub(); + const reg = await fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registerBody()), + }); + expect(reg.status).toBe(200); + + const res = await listInstances(hub); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + const list = (await res.json()) as Array>; + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ + id: "inst-1", + port: BASE_PORT, + workspace: "/tmp/proj", + sessions: [{ sessionId: "s1", title: "hello", updatedAt: 1 }], + }); + expect(list[0]!["lastSeen"]).toBeUndefined(); + }); + + it("rejects a register with a wrong body token or bad payload", async () => { + const hub = await startTestHub(); + const wrongToken = await fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registerBody({ token: "nope" })), + }); + expect(wrongToken.status).toBe(401); + + const badPayload = await fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registerBody({ port: 0, sessions: "x" })), + }); + expect(badPayload.status).toBe(400); + expect((await listInstances(hub)).status === 200).toBe(true); + }); + + it("preserves startedAt across heartbeats and removes on unregister", async () => { + const hub = await startTestHub(); + const post = (body: unknown) => + fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + await post(registerBody()); + await new Promise((r) => setTimeout(r, 30)); + await post(registerBody({ sessions: [] })); // heartbeat re-register + + const list1 = (await (await listInstances(hub)).json()) as Array<{ + startedAt: number; + sessions: unknown[]; + }>; + expect(list1).toHaveLength(1); + const startedAt = list1[0]!.startedAt; + expect(list1[0]!.sessions).toEqual([]); + + const unreg = await fetch(`http://127.0.0.1:${hub.port}/api/unregister`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: TOKEN, id: "inst-1" }), + }); + expect(unreg.status).toBe(200); + const list2 = (await (await listInstances(hub)).json()) as unknown[]; + expect(list2).toEqual([]); + expect(startedAt).toBeGreaterThan(0); + }); + + it("prunes instances whose heartbeat stopped", async () => { + const hub = await startTestHub({ heartbeatTimeoutMs: 250 }); + await fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registerBody()), + }); + const before = (await (await listInstances(hub)).json()) as unknown[]; + expect(before).toHaveLength(1); + + await new Promise((r) => setTimeout(r, 700)); // > TTL + prune interval + const after = (await (await listInstances(hub)).json()) as unknown[]; + expect(after).toEqual([]); + }); +}); + +describe("hub on-demand probe", () => { + /** Bare TCP listener — enough for the probe's connect check. */ + function startTcpListener(): Promise<{ server: net.Server; port: number }> { + return new Promise((resolve) => { + const server = net.createServer(() => {}); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + resolve({ server, port: typeof addr === "object" && addr ? addr.port : 0 }); + }); + }); + } + + async function registerInstance(hub: HubHandle, body: unknown): Promise { + const res = await fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + } + + async function listProbed(hub: HubHandle): Promise { + const res = await fetch(`http://127.0.0.1:${hub.port}/api/instances?probe=1`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + return res.json(); + } + + it("prunes dead-port instances on ?probe=1 but not on a plain list", async () => { + const hub = await startTestHub(); + await registerInstance(hub, registerBody()); // BASE_PORT: nothing listens + + const plain = (await (await listInstances(hub)).json()) as unknown[]; + expect(plain).toHaveLength(1); // no probe param = unverified list + + const probed = (await listProbed(hub)) as unknown[]; + expect(probed).toEqual([]); + + // The prune is persistent: the plain list stays empty afterwards. + const after = (await (await listInstances(hub)).json()) as unknown[]; + expect(after).toEqual([]); + }); + + it("keeps live instances when probing", async () => { + const hub = await startTestHub(); + const listener = track( + await startTcpListener(), + ({ server }) => new Promise((resolve) => server.close(() => resolve())), + ); + await registerInstance(hub, registerBody({ port: listener.port })); + + const probed = (await listProbed(hub)) as Array>; + expect(probed).toHaveLength(1); + expect(probed[0]).toMatchObject({ id: "inst-1", port: listener.port }); + }); +}); + +describe("hub WS proxy", () => { + function startEchoBridge(): Promise<{ server: WebSocketServer; port: number }> { + return new Promise((resolve) => { + const server = new WebSocketServer({ port: 0, host: "127.0.0.1" }, () => { + const addr = server.address(); + resolve({ server, port: typeof addr === "object" && addr ? addr.port : 0 }); + }); + server.on("connection", (ws) => { + ws.on("message", (data) => ws.send(data)); + }); + }); + } + + it("proxies bytes between a remote client and the bridge endpoint", async () => { + const hub = await startTestHub(); + const echo = track( + await startEchoBridge(), + ({ server }) => new Promise((resolve) => server.close(() => resolve())), + ); + await fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registerBody({ port: echo.port })), + }); + + const client = track( + new WebSocket(`ws://127.0.0.1:${hub.port}/acp?instance=inst-1&token=${TOKEN}`), + (ws) => + new Promise((resolve) => { + if (ws.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + ws.close(); + ws.once("close", () => resolve()); + }), + ); + await withTimeout( + new Promise((resolve, reject) => { + client.once("open", () => resolve()); + client.once("error", (e) => reject(e)); + }), + 3000, + "ws open", + ); + + const reply = withTimeout( + new Promise((resolve) => client.once("message", (d) => resolve(d.toString()))), + 3000, + "ws echo", + ); + client.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" })); + expect(JSON.parse(await reply)).toEqual({ jsonrpc: "2.0", id: 1, method: "ping" }); + }); + + it("refuses WS upgrades with a bad token or unknown instance", async () => { + const hub = await startTestHub(); + const cases = [ + `ws://127.0.0.1:${hub.port}/acp?instance=inst-1&token=wrong`, + `ws://127.0.0.1:${hub.port}/acp?instance=unknown&token=${TOKEN}`, + ]; + for (const url of cases) { + const client = new WebSocket(url); + const closed = new Promise((resolve) => { + client.once("error", () => resolve()); + client.once("close", () => resolve()); + }); + await withTimeout(closed, 3000, "ws reject"); + expect(client.readyState).not.toBe(WebSocket.OPEN); + } + }); +}); + +describe("hub idle exit", () => { + it("exits after the idle window with no instances and no proxies", async () => { + let exited = false; + const hub = await startTestHub({ + idleExitMs: 150, + onIdleExit: () => { + exited = true; + }, + }); + await fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registerBody()), + }); + await new Promise((r) => setTimeout(r, 250)); + expect(exited).toBe(false); // busy: one instance registered + + await fetch(`http://127.0.0.1:${hub.port}/api/unregister`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: TOKEN, id: "inst-1" }), + }); + await new Promise((r) => setTimeout(r, 500)); + expect(exited).toBe(true); + }); +}); + +describe("hub version self-upgrade", () => { + it("restarts when a newer bridge registers", async () => { + let exited = false; + const hub = await startTestHub({ + onIdleExit: () => { + exited = true; + }, + }); + const res = await withTimeout( + fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registerBody({ version: "9999.0.0" })), + }), + 3000, + "register with newer version", + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, restarting: true }); + + // The hub exits ~500ms after replying so the response flushes first. + await withTimeout( + new Promise((resolve) => { + const check = setInterval(() => { + if (exited) { + clearInterval(check); + resolve(); + } + }, 50); + }), + 5000, + "hub self-exit after newer-bridge register", + ); + expect(exited).toBe(true); + }); + + it("does not restart for the same version or when no version is sent", async () => { + let exited = false; + const hub = await startTestHub({ + onIdleExit: () => { + exited = true; + }, + }); + const { AGENT_INFO } = await import("../src/utils.js"); + for (const version of [AGENT_INFO.version, undefined, "0.0.1"]) { + const res = await fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registerBody(version === undefined ? {} : { version })), + }); + expect(await res.json()).toEqual({ ok: true }); + } + await new Promise((r) => setTimeout(r, 800)); + expect(exited).toBe(false); + const health = await fetch(`http://127.0.0.1:${hub.port}/api/health`); + expect(health.status).toBe(200); + }); +}); diff --git a/tests/load-tail.test.ts b/tests/load-tail.test.ts new file mode 100644 index 0000000..eee07b9 --- /dev/null +++ b/tests/load-tail.test.ts @@ -0,0 +1,270 @@ +/** + * Handler-level tail replay tests — session/load with `_meta.zcode.limit` + * replays only the turn-aligned tail and returns replayMeta; session/ + * load_earlier pages backwards with the cursor; expired cursors and + * unregistered sessions error. + * + * Mock layout mirrors tests/session-lazy.test.ts (tasks-index and the durable + * alias store are mocked away from real disk; the fake backend serves + * configurable session/messages). + */ + +import type * as acp from "@agentclientprotocol/sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ZcodeBackend } from "../src/backend/client.js"; +import type { ZcodeMessage } from "../src/backend/types.js"; +import { loadSession } from "../src/handlers/session.js"; +import { loadEarlier } from "../src/handlers/replay.js"; +import { ZcodeAcpServer } from "../src/server.js"; + +vi.mock("../src/tasks-index.js", () => ({ + upsertSessionTask: async () => true, + updateSessionTitle: async () => true, +})); + +vi.mock("../src/lazy-sessions.js", () => ({ + rememberLazySession: () => {}, + recordMaterializedSession: () => {}, + lookupLazySession: () => undefined, +})); + +// Layout: 8 messages, 4 turns (starts at 0, 1, 3, 6). +function hist(): ZcodeMessage[] { + const m = (id: string, role: "user" | "assistant" | "system", text: string): ZcodeMessage => ({ + info: { id, role }, + parts: [{ type: "text", text }], + }); + return [ + m("s0", "system", "sys"), + m("u1", "user", "one"), + m("a1", "assistant", "A1"), + m("u2", "user", "two"), + m("a2a", "assistant", "A2a"), + m("a2b", "assistant", "A2b"), + m("u3", "user", "three"), + m("a3", "assistant", "A3"), + ]; +} + +/** Fake backend with a mutable message history (tests swap it for compaction). */ +function fakeBackend(history: ZcodeMessage[]): ZcodeBackend { + const backend = { + isDead: false, + request: async (_id: number, method: string) => { + switch (method) { + case "session/resume": + case "workspace/updateProviderRegistry": + return { result: {} }; + case "session/read": + return { result: { projection: { contextUsed: 0 }, settings: {} } }; + case "session/messages": + return { result: { messages: history } }; + default: + return { error: { message: `unhandled ${method}` } }; + } + }, + registerEventListener: () => {}, + unregisterEventListener: () => {}, + } as unknown as ZcodeBackend; + return backend; +} + +/** cx that collects session/update payloads. */ +function collectCx(): { cx: acp.AgentContext; updates: acp.SessionUpdate[] } { + const updates: acp.SessionUpdate[] = []; + const cx = { + notify: async (_method: string, params: { update: acp.SessionUpdate }) => { + updates.push(params.update); + }, + request: async () => ({}), + } as unknown as acp.AgentContext; + return { cx, updates }; +} + +function chunks(updates: acp.SessionUpdate[]): string[] { + return updates + .filter( + (u) => u.sessionUpdate === "user_message_chunk" || u.sessionUpdate === "agent_message_chunk", + ) + .map((u) => (u as { content?: { text?: string } }).content?.text ?? ""); +} + +function loadParams(extra: Record = {}): acp.LoadSessionRequest { + return { + sessionId: "sess_tail", + cwd: "/tmp/ws", + mcpServers: [], + ...extra, + } as acp.LoadSessionRequest; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("session/load tail limit", () => { + it("replays only the turn-aligned tail and returns replayMeta", async () => { + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(hist()); + const { cx, updates } = collectCx(); + + const result = await loadSession(server, loadParams({ _meta: { zcode: { limit: 2 } } }), cx); + + expect(chunks(updates)).toEqual(["three", "A3"]); + expect((result as { replayMeta?: unknown }).replayMeta).toMatchObject({ + hasMore: true, + replayedMessages: 2, + replayedTurns: 1, + totalMessages: 8, + totalTurns: 4, + }); + }); + + it("limit 0 attaches without replaying anything", async () => { + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(hist()); + const { cx, updates } = collectCx(); + + await loadSession(server, loadParams({ _meta: { zcode: { limit: 0 } } }), cx); + + expect(chunks(updates)).toEqual([]); + }); + + it("without _meta the full history replays (Zed path regression)", async () => { + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(hist()); + const { cx, updates } = collectCx(); + + const result = await loadSession(server, loadParams(), cx); + + expect(chunks(updates)).toEqual(["sys", "one", "A1", "two", "A2a", "A2b", "three", "A3"]); + expect((result as { replayMeta?: unknown }).replayMeta).toMatchObject({ + hasMore: false, + replayedMessages: 8, + totalMessages: 8, + totalTurns: 4, + }); + }); +}); + +describe("system-reminder stripping in replay", () => { + it("strips reminder blocks from user text and drops reminder-only messages", async () => { + const history = hist(); + // u2: reminder prefix + real text. u3: reminder only — must vanish. + history[3] = { + info: { id: "u2", role: "user" }, + parts: [{ type: "text", text: "todo nudge\n\ntwo" }], + }; + history[6] = { + info: { id: "u3", role: "user" }, + parts: [{ type: "text", text: "todo nudge" }], + }; + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(history); + const { cx, updates } = collectCx(); + + await loadSession(server, loadParams(), cx); + + const texts = chunks(updates); + expect(texts).toEqual(["sys", "one", "A1", "two", "A2a", "A2b", "A3"]); + expect(texts.join("\n")).not.toContain("system-reminder"); + }); + + it("leaves assistant text that literally mentions the tag untouched", async () => { + const history = hist(); + history[4] = { + info: { id: "a2a", role: "assistant" }, + parts: [{ type: "text", text: "A2a discusses tags" }], + }; + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(history); + const { cx, updates } = collectCx(); + + await loadSession(server, loadParams(), cx); + + expect(chunks(updates)).toContain("A2a discusses tags"); + }); +}); + +describe("session/load_earlier", () => { + async function attachTail(server: ZcodeAcpServer): Promise { + const { cx } = collectCx(); + const result = await loadSession(server, loadParams({ _meta: { zcode: { limit: 2 } } }), cx); + return (result as { replayMeta: { cursor: string } }).replayMeta.cursor; + } + + it("pages backwards until hasMore is false, then returns an empty page", async () => { + const server = new ZcodeAcpServer(); + const history = hist(); + server.backend = fakeBackend(history); + + let cursor = await attachTail(server); + const pages: string[][] = []; + let hasMore = true; + while (hasMore) { + const { cx, updates } = collectCx(); + const res = await loadEarlier( + server, + { sessionId: "sess_tail", before: cursor, limit: 2 }, + cx, + ); + pages.push(chunks(updates)); + hasMore = res.replayMeta.hasMore; + cursor = res.replayMeta.cursor; + } + expect(pages).toEqual([["two", "A2a", "A2b"], ["one", "A1"], ["sys"]]); + }); + + it("still pages when new turns arrived since attach", async () => { + const server = new ZcodeAcpServer(); + const history = hist(); + server.backend = fakeBackend(history); + const cursor = await attachTail(server); + + // The live session moved on (append-only) — the cursor's prefix is intact. + history.push( + { info: { id: "u4", role: "user" }, parts: [{ type: "text", text: "four" }] }, + { info: { id: "a4", role: "assistant" }, parts: [{ type: "text", text: "A4" }] }, + ); + + const { cx, updates } = collectCx(); + const res = await loadEarlier(server, { sessionId: "sess_tail", before: cursor, limit: 2 }, cx); + expect(chunks(updates)).toEqual(["two", "A2a", "A2b"]); + expect(res.replayMeta).toMatchObject({ hasMore: true, totalMessages: 10, totalTurns: 5 }); + }); + + it("errors with cursor expired after the history compacted", async () => { + const server = new ZcodeAcpServer(); + const history = hist(); + server.backend = fakeBackend(history); + const cursor = await attachTail(server); + + // Simulate compaction: the old turn count no longer matches the cursor. + history.splice(0, 4); + + const { cx } = collectCx(); + await expect( + loadEarlier(server, { sessionId: "sess_tail", before: cursor, limit: 2 }, cx), + ).rejects.toThrow("cursor expired"); + }); + + it("errors for a session that was never attached in this bridge", async () => { + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(hist()); + const { cx } = collectCx(); + await expect( + loadEarlier(server, { sessionId: "never-attached", before: "whatever", limit: 2 }, cx), + ).rejects.toThrow("session not registered"); + }); + + it("requires a before cursor", async () => { + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(hist()); + server.registerSession("sess_tail", "sess_tail"); + const { cx } = collectCx(); + await expect( + loadEarlier(server, { sessionId: "sess_tail", limit: 2 } as never, cx), + ).rejects.toThrow("before"); + }); +}); diff --git a/tests/remote-broadcast.test.ts b/tests/remote-broadcast.test.ts new file mode 100644 index 0000000..8cd6f47 --- /dev/null +++ b/tests/remote-broadcast.test.ts @@ -0,0 +1,140 @@ +/** + * ClientRegistry broadcast semantics: notify fan-out with per-client failure + * isolation, request first-response-wins with loser cancellation, outer-signal + * linkage, and the empty/all-failed edge cases. + */ + +import type * as acp from "@agentclientprotocol/sdk"; + +import { describe, expect, it } from "vitest"; + +import { ClientRegistry, type ClientLike } from "../src/remote/broadcast.js"; + +interface FakeClient { + cx: ClientLike; + notifies: Array<[string, unknown]>; + signals: Array; + /** Registered per-request handler; defaults to resolving `{ default: true }`. */ + onRequest?: (method: string, signal: AbortSignal | undefined) => Promise; +} + +function fakeClient(opts: { failNotify?: boolean } = {}): FakeClient { + const notifies: Array<[string, unknown]> = []; + const signals: Array = []; + const fake: FakeClient = { + notifies, + signals, + onRequest: undefined, + }; + fake.cx = { + notify(method: string, params?: unknown): Promise { + notifies.push([method, params]); + return opts.failNotify ? Promise.reject(new Error("dead client")) : Promise.resolve(); + }, + async request( + method: string, + _params?: unknown, + options?: acp.SendRequestOptions, + ): Promise { + signals.push(options?.cancellationSignal); + if (fake.onRequest) return fake.onRequest(method, options?.cancellationSignal); + return { default: true }; + }, + }; + return fake; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +describe("ClientRegistry broadcast", () => { + it("fans notify out to every client", async () => { + const registry = new ClientRegistry(); + const a = fakeClient(); + const b = fakeClient(); + registry.add(a.cx); + registry.add(b.cx); + expect(registry.size).toBe(2); + + await registry.broadcast().notify("session/update", { x: 1 }); + + expect(a.notifies).toHaveLength(1); + expect(b.notifies).toHaveLength(1); + expect(a.notifies[0]![0]).toBe("session/update"); + }); + + it("isolates a failing client on notify", async () => { + const registry = new ClientRegistry(); + const ok = fakeClient(); + const dead = fakeClient({ failNotify: true }); + registry.add(ok.cx); + registry.add(dead.cx); + + await expect(registry.broadcast().notify("session/update", {})).resolves.toBeUndefined(); + expect(ok.notifies).toHaveLength(1); + }); + + it("first response wins and losers are cancelled", async () => { + const registry = new ClientRegistry(); + const slow = fakeClient(); + const fast = fakeClient(); + slow.onRequest = () => sleep(30).then(() => "slow"); + fast.onRequest = () => Promise.resolve("fast"); + registry.add(slow.cx); + registry.add(fast.cx); + + const result = await registry.broadcast().request("session/request_permission", {}); + + expect(result).toBe("fast"); + // Loser got aborted so the SDK emits $/cancel_request; winner untouched. + expect(slow.signals[0]!.aborted).toBe(true); + expect(fast.signals[0]!.aborted).toBe(false); + }); + + it("links an outer cancellation signal to all clients", async () => { + const registry = new ClientRegistry(); + const a = fakeClient(); + const b = fakeClient(); + a.onRequest = (_m, signal) => + new Promise((_resolve, reject) => + signal?.addEventListener("abort", () => reject(new Error("outer abort")), { once: true }), + ); + b.onRequest = a.onRequest; + registry.add(a.cx); + registry.add(b.cx); + + const outer = new AbortController(); + const pending = registry.broadcast().request("m", {}, { cancellationSignal: outer.signal }); + outer.abort(); + await expect(pending).rejects.toThrow("outer abort"); + expect(a.signals[0]!.aborted).toBe(true); + expect(b.signals[0]!.aborted).toBe(true); + }); + + it("rejects when every client fails, surfacing the first error", async () => { + const registry = new ClientRegistry(); + const a = fakeClient(); + const b = fakeClient(); + a.onRequest = () => Promise.reject(new Error("boom-a")); + b.onRequest = () => Promise.reject(new Error("boom-b")); + registry.add(a.cx); + registry.add(b.cx); + + await expect(registry.broadcast().request("m", {})).rejects.toThrow(/boom-/); + }); + + it("rejects immediately with no clients", async () => { + const registry = new ClientRegistry(); + await expect(registry.broadcast().request("m", {})).rejects.toThrow(/no connected clients/); + }); + + it("removes clients from the fan-out", async () => { + const registry = new ClientRegistry(); + const a = fakeClient(); + registry.add(a.cx); + registry.remove(a.cx); + expect(registry.size).toBe(0); + await expect(registry.broadcast().request("m", {})).rejects.toThrow(); + }); +}); diff --git a/tests/remote-config.test.ts b/tests/remote-config.test.ts new file mode 100644 index 0000000..73cb002 --- /dev/null +++ b/tests/remote-config.test.ts @@ -0,0 +1,94 @@ +/** + * Remote config parsing — env gate, mandatory token, port/host defaults and + * fallbacks. parseRemoteConfig takes an explicit env so tests never touch the + * real process environment. + */ + +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_BRIDGE_PORT, + DEFAULT_HUB_HOST, + DEFAULT_HUB_PORT, + parseHubConfig, + parseRemoteConfig, +} from "../src/remote/config.js"; + +const BASE_ENV = { + ZCODE_ACP_REMOTE: "1", + ZCODE_ACP_REMOTE_TOKEN: "secret", +}; + +describe("parseRemoteConfig", () => { + it("is disabled when the gate is unset", () => { + expect(parseRemoteConfig({})).toBeNull(); + }); + + it("is disabled for falsy gate values", () => { + for (const gate of ["", "0", "false", "off", "nope"]) { + expect(parseRemoteConfig({ ZCODE_ACP_REMOTE: gate })).toBeNull(); + } + }); + + it("accepts truthy gate spellings", () => { + for (const gate of ["1", "true", "YES", "on"]) { + expect(parseRemoteConfig({ ...BASE_ENV, ZCODE_ACP_REMOTE: gate })?.token).toBe("secret"); + } + }); + + it("requires a token when enabled", () => { + expect(parseRemoteConfig({ ZCODE_ACP_REMOTE: "1" })).toBeNull(); + expect(parseRemoteConfig({ ZCODE_ACP_REMOTE: "1", ZCODE_ACP_REMOTE_TOKEN: " " })).toBeNull(); + }); + + it("applies port and host defaults", () => { + const config = parseRemoteConfig(BASE_ENV); + expect(config).toEqual({ + token: "secret", + hubPort: DEFAULT_HUB_PORT, + hubHost: DEFAULT_HUB_HOST, + bridgePort: DEFAULT_BRIDGE_PORT, + }); + }); + + it("falls back on invalid ports", () => { + const config = parseRemoteConfig({ + ...BASE_ENV, + ZCODE_ACP_HUB_PORT: "not-a-port", + ZCODE_ACP_REMOTE_PORT: "99999", + }); + expect(config?.hubPort).toBe(DEFAULT_HUB_PORT); + expect(config?.bridgePort).toBe(DEFAULT_BRIDGE_PORT); + }); + + it("honours explicit ports and host", () => { + const config = parseRemoteConfig({ + ...BASE_ENV, + ZCODE_ACP_HUB_PORT: "9000", + ZCODE_ACP_HUB_HOST: "0.0.0.0", + ZCODE_ACP_REMOTE_PORT: "9001", + }); + expect(config).toEqual({ + token: "secret", + hubPort: 9000, + hubHost: "0.0.0.0", + bridgePort: 9001, + }); + }); +}); + +describe("parseHubConfig", () => { + it("refuses to start without a token", () => { + expect(parseHubConfig({})).toBeNull(); + }); + + it("uses hub host/port from env", () => { + const config = parseHubConfig({ + ZCODE_ACP_REMOTE_TOKEN: "t", + ZCODE_ACP_HUB_PORT: "8400", + ZCODE_ACP_HUB_HOST: "0.0.0.0", + }); + expect(config?.hubPort).toBe(8400); + expect(config?.hubHost).toBe("0.0.0.0"); + }); +}); diff --git a/tests/remote-endpoint.test.ts b/tests/remote-endpoint.test.ts new file mode 100644 index 0000000..5d939da --- /dev/null +++ b/tests/remote-endpoint.test.ts @@ -0,0 +1,255 @@ +/** + * Remote endpoint integration: the bridge's loopback ACP endpoint (real + * AgentApp + SDK AcpServer transport) joined with a real hub — registration, + * discovery, and an end-to-end initialize handshake proxied through the hub. + * The WS connection must also appear in the broadcast registry while open. + */ + +import * as acp from "@agentclientprotocol/sdk"; +import { createServer, type Server } from "node:http"; +import { WebSocket } from "ws"; + +import { afterEach, describe, expect, it } from "vitest"; + +import type { RemoteConfig } from "../src/remote/config.js"; +import { trackConnections } from "../src/remote/broadcast.js"; +import { startRemoteEndpoint } from "../src/remote/endpoint.js"; +import { startHub } from "../src/remote/hub-server.js"; +import { ZcodeAcpServer } from "../src/server.js"; +import { AGENT_INFO } from "../src/utils.js"; + +const TOKEN = "test-endpoint-token"; + +const cleanups: Array<() => Promise | void> = []; + +function trackStop(stop: () => Promise | void): void { + cleanups.push(stop); +} + +afterEach(async () => { + while (cleanups.length) { + const stop = cleanups.pop()!; + await stop(); + } +}); + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), ms)), + ]); +} + +function testConfig(hubPort: number, bridgePort: number): RemoteConfig { + return { token: TOKEN, hubPort, hubHost: "127.0.0.1", bridgePort }; +} + +/** + * Minimal stand-in hub: records parsed /api/register bodies and replies with a + * fixed JSON document. Used where the assertion is about the bridge's request + * behaviour rather than a real hub's response logic. + */ +function startMockHub( + bodies: Array>, + reply: Record, +): { port: number; ready: Promise; server: Server } { + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + if (req.url === "/api/register") { + try { + bodies.push(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch { + /* unreadable body — ignore */ + } + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(reply)); + }); + }); + const handle = { port: 0, ready: Promise.resolve(), server }; + // The port is only known once listen() completes — capture it in the + // callback, not from an eagerly-read address(). + handle.ready = new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + handle.port = typeof addr === "object" && addr ? addr.port : 0; + resolve(); + }); + }); + return handle; +} + +function stopMockHub(mock: { server: Server }): Promise { + return new Promise((resolve) => { + mock.server.closeAllConnections?.(); + mock.server.close(() => resolve()); + }); +} + +describe("remote endpoint", () => { + it("registers with the hub and serves initialize over a proxied WS", async () => { + const hub = await startHub({ port: 0, host: "127.0.0.1", token: TOKEN }); + trackStop(() => hub.close()); + + const server = new ZcodeAcpServer(); + // Same wiring as index.ts: initialize is the only handler needed here (it + // never touches the backend, so no zcode subprocess is spawned), and + // connection tracking feeds the broadcast registry. + const app = acp + .agent({ name: AGENT_INFO.name }) + .onRequest("initialize", (ctx) => server.initialize(ctx.params)); + trackConnections(app, server.clients); + + const endpoint = await startRemoteEndpoint(server, app, testConfig(hub.port, 18500)); + expect(endpoint).not.toBeNull(); + trackStop(() => endpoint!.stop()); + + // Registration is fired immediately; give the POST a beat to land. + await new Promise((r) => setTimeout(r, 250)); + const res = await fetch(`http://127.0.0.1:${hub.port}/api/instances`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + const list = (await res.json()) as Array<{ id: string; port: number }>; + expect(list).toHaveLength(1); + expect(list[0]!.port).toBe(endpoint!.port); + + // End-to-end: WS client → hub proxy → loopback endpoint → initialize. + const ws = new WebSocket( + `ws://127.0.0.1:${hub.port}/acp?instance=${list[0]!.id}&token=${TOKEN}`, + ); + trackStop( + () => + new Promise((resolve) => { + if (ws.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + ws.close(); + ws.once("close", () => resolve()); + }), + ); + await withTimeout( + new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", (e) => reject(e)); + }), + 5000, + "ws open", + ); + + // The SDK promotes a WS connection to an app connection only after the + // initialize handshake, so send it through the proxied pipe first. + const reply = withTimeout( + new Promise((resolve) => ws.once("message", (d) => resolve(d.toString()))), + 5000, + "initialize response", + ); + ws.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} }, + }), + ); + const response = JSON.parse(await reply) as { + result?: { agentInfo?: { name?: string } }; + error?: { message?: string }; + }; + expect(response.error).toBeUndefined(); + expect(response.result?.agentInfo?.name).toBe(AGENT_INFO.name); + + // Once initialized, the WS connection is a full ACP client and joins the + // broadcast registry (same path the stdio editor takes). + expect(server.clients.size).toBe(1); + + ws.close(); + await withTimeout( + new Promise((resolve) => ws.once("close", () => resolve())), + 5000, + "ws close", + ); + // Closed connections leave the registry (give the close event a beat to + // propagate through the hub proxy). + await new Promise((r) => setTimeout(r, 200)); + expect(server.clients.size).toBe(0); + }); + + it("scans to the next free port when the configured one is taken", async () => { + const hub = await startHub({ port: 0, host: "127.0.0.1", token: TOKEN }); + trackStop(() => hub.close()); + + const server1 = new ZcodeAcpServer(); + const app1 = acp + .agent({ name: "t1" }) + .onRequest("initialize", (ctx) => server1.initialize(ctx.params)); + const first = await startRemoteEndpoint(server1, app1, testConfig(hub.port, 18600)); + expect(first).not.toBeNull(); + trackStop(() => first!.stop()); + + const server2 = new ZcodeAcpServer(); + const app2 = acp + .agent({ name: "t2" }) + .onRequest("initialize", (ctx) => server2.initialize(ctx.params)); + const second = await startRemoteEndpoint(server2, app2, testConfig(hub.port, 18600)); + expect(second).not.toBeNull(); + trackStop(() => second!.stop()); + + expect(second!.port).toBe(first!.port + 1); + }); +}); + +describe("hub version handshake (bridge side)", () => { + it("sends its version in the register payload", async () => { + const bodies: Array> = []; + const mock = startMockHub(bodies, { ok: true }); + trackStop(() => stopMockHub(mock)); + await mock.ready; + + const server = new ZcodeAcpServer(); + const app = acp + .agent({ name: AGENT_INFO.name }) + .onRequest("initialize", (ctx) => server.initialize(ctx.params)); + trackConnections(app, server.clients); + const endpoint = await startRemoteEndpoint(server, app, testConfig(mock.port, 18510)); + trackStop(() => endpoint!.stop()); + await new Promise((r) => setTimeout(r, 300)); + + expect(bodies.length).toBeGreaterThanOrEqual(1); + expect(bodies[0]!.version).toBe(AGENT_INFO.version); + }); + + it("re-registers after a hub answers restarting (upgrade respawn)", async () => { + const bodies: Array> = []; + const mock = startMockHub(bodies, { ok: true, restarting: true }); + trackStop(() => stopMockHub(mock)); + await mock.ready; + + const server = new ZcodeAcpServer(); + const app = acp + .agent({ name: AGENT_INFO.name }) + .onRequest("initialize", (ctx) => server.initialize(ctx.params)); + trackConnections(app, server.clients); + const endpoint = await startRemoteEndpoint(server, app, testConfig(mock.port, 18511)); + trackStop(() => endpoint!.stop()); + + // First register lands immediately; the respawn-retry arrives ~3.5s later + // (2s respawn delay + 1.5s re-register). The spawned hub cannot bind the + // mock's port (EADDRINUSE → exit 0 by design), so the mock stays authoritative. + await withTimeout( + new Promise((resolve) => { + const check = setInterval(() => { + if (bodies.length >= 2) { + clearInterval(check); + resolve(); + } + }, 100); + }), + 8000, + "second register after restarting reply", + ); + expect(bodies.length).toBeGreaterThanOrEqual(2); + }, 15000); +}); diff --git a/tests/replay.test.ts b/tests/replay.test.ts new file mode 100644 index 0000000..0f498fc --- /dev/null +++ b/tests/replay.test.ts @@ -0,0 +1,267 @@ +/** + * Tail replay kernel tests — turn-aligned slicing, cursor pagination and + * expiry, the `_meta.zcode.limit` reader, and the per-session replay batch + * lock (live sends queue behind an in-flight batch for the same session). + */ + +import { describe, expect, it } from "vitest"; + +import type { ZcodeMessage } from "../src/backend/types.js"; +import { enqueueSessionSend, sendSessionUpdate, withReplayBatch } from "../src/handlers/io.js"; +import { + MAX_REPLAY_LIMIT, + fullSlice, + readTailLimit, + sliceBefore, + sliceTail, +} from "../src/handlers/replay.js"; + +function msg(id: string, role: "user" | "assistant" | "system", text: string): ZcodeMessage { + return { info: { id, role }, parts: [{ type: "text", text }] }; +} + +// Layout: 8 messages, 4 turns. Turn starts at 0 (leading system), 1, 3, 6. +const MSGS: ZcodeMessage[] = [ + msg("s0", "system", "sys"), + msg("u1", "user", "one"), + msg("a1", "assistant", "A1"), + msg("u2", "user", "two"), + msg("a2a", "assistant", "A2a"), + msg("a2b", "assistant", "A2b"), + msg("u3", "user", "three"), + msg("a3", "assistant", "A3"), +]; + +function ids(batch: ZcodeMessage[]): string[] { + return batch.map((m) => m.info.id!); +} + +describe("sliceTail", () => { + it("slices the last N messages aligned to the containing turn's start", () => { + const s = sliceTail(MSGS, 2); + expect(ids(s.batch)).toEqual(["u3", "a3"]); + expect(s.meta).toMatchObject({ + hasMore: true, + replayedMessages: 2, + replayedTurns: 1, + totalMessages: 8, + totalTurns: 4, + }); + }); + + it("alignment may extend past the limit — never a mid-turn cut", () => { + // cut = 7 lands inside turn 3 (start 6) → batch extends back to u3. + expect(ids(sliceTail(MSGS, 1).batch)).toEqual(["u3", "a3"]); + // cut = 5 lands inside turn 2 (start 3) → five messages, two turns. + const s = sliceTail(MSGS, 3); + expect(ids(s.batch)).toEqual(["u2", "a2a", "a2b", "u3", "a3"]); + expect(s.meta.replayedTurns).toBe(2); + }); + + it("limit 0 attaches with metadata only; cursor anchors at history's end", () => { + const s = sliceTail(MSGS, 0); + expect(s.batch).toEqual([]); + expect(s.meta.hasMore).toBe(true); + expect(s.meta.totalMessages).toBe(8); + // Paging from that cursor delivers the tail. + const page = sliceBefore(MSGS, s.meta.cursor, 2); + expect(ids(page.batch)).toEqual(["u3", "a3"]); + }); + + it("limit >= length replays everything", () => { + const s = sliceTail(MSGS, 99); + expect(s.batch).toHaveLength(8); + expect(s.meta.hasMore).toBe(false); + }); + + it("clamps oversized limits", () => { + expect(ids(sliceTail(MSGS, MAX_REPLAY_LIMIT + 100).batch)).toHaveLength(8); + }); + + it("handles empty history", () => { + const s = sliceTail([], 30); + expect(s.batch).toEqual([]); + expect(s.meta).toMatchObject({ hasMore: false, totalMessages: 0, totalTurns: 0 }); + }); +}); + +describe("sliceBefore pagination", () => { + it("pages backwards to the beginning of history", () => { + let cursor = sliceTail(MSGS, 2).meta.cursor; + const pages: string[][] = []; + let hasMore = true; + while (hasMore) { + const page = sliceBefore(MSGS, cursor, 2); + pages.push(ids(page.batch)); + hasMore = page.meta.hasMore; + cursor = page.meta.cursor; + } + // hasMore:false on the last non-empty page terminates pagination — the + // client never needs the empty page. + expect(pages).toEqual([["u2", "a2a", "a2b"], ["u1", "a1"], ["s0"]]); + // A redundant extra call returns an empty page, still hasMore:false. + const extra = sliceBefore(MSGS, cursor, 2); + expect(extra.batch).toEqual([]); + expect(extra.meta.hasMore).toBe(false); + }); + + it("fullSlice's cursor pages nothing", () => { + const s = fullSlice(MSGS); + expect(s.meta.hasMore).toBe(false); + const page = sliceBefore(MSGS, s.meta.cursor, 10); + expect(page.batch).toEqual([]); + expect(page.meta.hasMore).toBe(false); + }); + + it("keeps paging when turns were appended after the cursor was minted", () => { + const grown = [...MSGS, msg("u4", "user", "four"), msg("a4", "assistant", "A4")]; + const cursor = sliceTail(MSGS, 2).meta.cursor; + const page = sliceBefore(grown, cursor, 2); + expect(ids(page.batch)).toEqual(["u2", "a2a", "a2b"]); + expect(page.meta).toMatchObject({ hasMore: true, totalMessages: 10, totalTurns: 5 }); + }); + + it("throws cursor expired on totalTurns mismatch (compaction)", () => { + const cursor = sliceTail(MSGS, 2).meta.cursor; + const compacted = MSGS.slice(4); // fewer turns → cursor's totalTurns stale + expect(() => sliceBefore(compacted, cursor, 2)).toThrow("cursor expired"); + }); + + it("throws cursor expired on out-of-range index", () => { + const cursor = Buffer.from(JSON.stringify({ v: 1, index: 99, totalTurns: 4 })).toString( + "base64url", + ); + expect(() => sliceBefore(MSGS, cursor, 2)).toThrow("cursor expired"); + }); + + it("throws cursor expired on anchor id mismatch", () => { + const cursor = Buffer.from( + JSON.stringify({ v: 1, id: "wrong", index: 6, totalTurns: 4 }), + ).toString("base64url"); + expect(() => sliceBefore(MSGS, cursor, 2)).toThrow("cursor expired"); + }); + + it("throws cursor expired on garbage cursors", () => { + expect(() => sliceBefore(MSGS, "!!!not-base64url-json", 2)).toThrow("cursor expired"); + }); +}); + +describe("readTailLimit", () => { + it("reads the limit from _meta.zcode", () => { + expect(readTailLimit({ _meta: { zcode: { limit: 30 } } } as never)).toBe(30); + }); + + it("returns null (full replay) for absent or non-finite limits", () => { + expect(readTailLimit({} as never)).toBeNull(); + expect(readTailLimit({ _meta: { zcode: { limit: "30" } } } as never)).toBeNull(); + expect(readTailLimit({ _meta: {} } as never)).toBeNull(); + }); + + it("clamps negative and oversized values", () => { + expect(readTailLimit({ _meta: { zcode: { limit: -5 } } } as never)).toBe(0); + expect(readTailLimit({ _meta: { zcode: { limit: 99999 } } } as never)).toBe(MAX_REPLAY_LIMIT); + }); +}); + +describe("replay batch lock", () => { + function cx(label: string, events: string[]) { + return { + notify: async () => { + events.push(label); + }, + } as never; + } + + it("queues live sends behind an in-flight batch for the same session", async () => { + const events: string[] = []; + let releaseBatch!: () => void; + const batch = withReplayBatch("lock-s1", async () => { + events.push("batch-start"); + await new Promise((resolve) => (releaseBatch = resolve)); + events.push("batch-end"); + }); + const live = sendSessionUpdate(cx("live", events), "lock-s1", { + sessionUpdate: "plan", + entries: [], + }); + await Promise.resolve(); + expect(events).toEqual(["batch-start"]); + releaseBatch(); + await Promise.all([batch, live]); + expect(events).toEqual(["batch-start", "batch-end", "live"]); + }); + + it("does not block other sessions while a batch runs", async () => { + const events: string[] = []; + let releaseBatch!: () => void; + const batch = withReplayBatch("lock-a", async () => { + await new Promise((resolve) => (releaseBatch = resolve)); + }); + await sendSessionUpdate(cx("other-session", events), "lock-b", { + sessionUpdate: "plan", + entries: [], + }); + expect(events).toEqual(["other-session"]); + releaseBatch(); + await batch; + }); + + it("serializes concurrent batches for the same session", async () => { + const events: string[] = []; + const [r1, r2] = await Promise.all([ + withReplayBatch("lock-c", async () => { + events.push("b1-start"); + await new Promise((r) => setTimeout(r, 10)); + events.push("b1-end"); + }), + withReplayBatch("lock-c", async () => { + events.push("b2-start"); + await new Promise((r) => setTimeout(r, 1)); + events.push("b2-end"); + }), + ]); + expect(r1).toBeUndefined(); + expect(r2).toBeUndefined(); + expect(events).toEqual(["b1-start", "b1-end", "b2-start", "b2-end"]); + }); + + it("queues background sends (enqueueSessionSend) behind an in-flight batch", async () => { + const events: string[] = []; + let releaseBatch!: () => void; + const batch = withReplayBatch("lock-d", async () => { + events.push("batch-start"); + await new Promise((resolve) => (releaseBatch = resolve)); + events.push("batch-end"); + }); + const bg = enqueueSessionSend("lock-d", async () => { + events.push("background"); + }); + await Promise.resolve(); + expect(events).toEqual(["batch-start"]); + releaseBatch(); + await Promise.all([batch, bg]); + expect(events).toEqual(["batch-start", "batch-end", "background"]); + }); + + it("re-uses a session correctly after its guard entry was cleaned up", async () => { + // The first batch drains with nothing chained → withReplayBatch deletes + // the map entry. Deletion itself is unobservable by design; this guards + // that a fresh batch on the same session still serializes afterwards. + await withReplayBatch("lock-e", async () => {}); + const events: string[] = []; + let releaseBatch!: () => void; + const batch = withReplayBatch("lock-e", async () => { + events.push("batch-start"); + await new Promise((resolve) => (releaseBatch = resolve)); + events.push("batch-end"); + }); + const send = enqueueSessionSend("lock-e", async () => { + events.push("send"); + }); + await Promise.resolve(); + expect(events).toEqual(["batch-start"]); + releaseBatch(); + await Promise.all([batch, send]); + expect(events).toEqual(["batch-start", "batch-end", "send"]); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 6cbfa02..b35e786 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -78,3 +78,28 @@ describe("ZcodeAcpServer.initialize", () => { expect(server.backend).toBeNull(); }); }); + +describe("ZcodeAcpServer discovery summaries", () => { + it("touchSessionSummary bumps updatedAt and keeps the title sticky", async () => { + const server = new ZcodeAcpServer(); + server.touchSessionSummary("s1"); + expect(server.sessionSummaries.get("s1")?.title).toBeUndefined(); + + server.touchSessionSummary("s1", "My title"); + const titled = server.sessionSummaries.get("s1")!; + expect(titled.title).toBe("My title"); + + await new Promise((resolve) => setTimeout(resolve, 5)); + server.touchSessionSummary("s1"); + const touched = server.sessionSummaries.get("s1")!; + expect(touched.title).toBe("My title"); + expect(touched.updatedAt).toBeGreaterThan(titled.updatedAt); + }); + + it("workspaceLabel prefers a known session cwd and falls back to process cwd", () => { + const server = new ZcodeAcpServer(); + expect(server.workspaceLabel()).toBe(process.cwd()); + server.sessionCwds.set("s1", "/tmp/proj"); + expect(server.workspaceLabel()).toBe("/tmp/proj"); + }); +}); diff --git a/tests/session-lazy.test.ts b/tests/session-lazy.test.ts index 84b84f4..cb5d9b6 100644 --- a/tests/session-lazy.test.ts +++ b/tests/session-lazy.test.ts @@ -56,10 +56,11 @@ beforeEach(() => { /** * Fake backend: answers session/create (counting creates), session/resume, - * session/read (empty projection/settings), session/messages (empty) and the - * provider-registry push; errors on everything else. + * session/read (empty projection/settings), session/messages (empty), the + * provider-registry push, and session/list (from `listed`, for title + * adoption); errors on everything else. */ -function fakeBackend(): ZcodeBackend & { +function fakeBackend(listed: Array<{ sessionId: string; title?: string }> = []): ZcodeBackend & { calls: Array<{ method: string; params: unknown }>; } { const calls: Array<{ method: string; params: unknown }> = []; @@ -80,6 +81,8 @@ function fakeBackend(): ZcodeBackend & { case "session/resume": case "workspace/updateProviderRegistry": return { id, result: {} }; + case "session/list": + return { id, result: { sessions: listed } }; case "session/read": return { id, result: { projection: { contextUsed: 0 }, settings: {} } }; case "session/messages": @@ -230,13 +233,8 @@ describe("resumeSession with lazy placeholders", () => { // it, so client-configured stdio servers were silently dropped. The lazy // placeholder must carry them into session/create verbatim. const server = new ZcodeAcpServer(); - const mcpServers = [ - { name: "echo", command: "node", args: ["/tmp/mcp-echo.mjs"], env: [] }, - ]; - const resp = await newSession( - server, - { cwd: "/tmp/ws", mcpServers } as acp.NewSessionRequest, - ); + const mcpServers = [{ name: "echo", command: "node", args: ["/tmp/mcp-echo.mjs"], env: [] }]; + const resp = await newSession(server, { cwd: "/tmp/ws", mcpServers } as acp.NewSessionRequest); expect(server.pendingSessions.get(resp.sessionId)).toMatchObject({ mcpServers }); const { backend, calls } = fakeBackend(); @@ -381,3 +379,64 @@ describe("loadSession with lazy placeholders", () => { expect(resume?.params).toMatchObject({ sessionId: "sess_real" }); }); }); + +describe("stored title adoption on load/resume", () => { + it("adopts the backend's stored title into the discovery summary", async () => { + const server = new ZcodeAcpServer(); + const { backend } = fakeBackend([{ sessionId: "sess_real", title: "Historical title" }]); + server.backend = backend; + + await resumeSession( + server, + { sessionId: "sess_real" } as acp.ResumeSessionRequest, + {} as acp.AgentContext, + ); + + expect(server.sessionTitles.get("sess_real")).toBe("Historical title"); + expect(server.sessionSummaries.get("sess_real")?.title).toBe("Historical title"); + }); + + it("does not overwrite an in-process title", async () => { + const server = new ZcodeAcpServer(); + server.sessionTitles.set("sess_real", "In-process title"); + const { backend, calls } = fakeBackend([{ sessionId: "sess_real", title: "Historical title" }]); + server.backend = backend; + + await resumeSession( + server, + { sessionId: "sess_real" } as acp.ResumeSessionRequest, + {} as acp.AgentContext, + ); + + expect(server.sessionTitles.get("sess_real")).toBe("In-process title"); + expect(calls.some((c) => c.method === "session/list")).toBe(false); + }); + + it("leaves the session untitled when the backend has no stored title", async () => { + const server = new ZcodeAcpServer(); + const { backend } = fakeBackend(); + server.backend = backend; + + await resumeSession( + server, + { sessionId: "sess_real" } as acp.ResumeSessionRequest, + {} as acp.AgentContext, + ); + + expect(server.sessionSummaries.get("sess_real")?.title).toBeUndefined(); + }); + + it("loadSession adopts the title the same way", async () => { + const server = new ZcodeAcpServer(); + const { backend } = fakeBackend([{ sessionId: "sess_real", title: "Historical title" }]); + server.backend = backend; + + await loadSession( + server, + { sessionId: "sess_real" } as acp.LoadSessionRequest, + {} as acp.AgentContext, + ); + + expect(server.sessionSummaries.get("sess_real")?.title).toBe("Historical title"); + }); +}); diff --git a/tests/slash-text.test.ts b/tests/slash-text.test.ts new file mode 100644 index 0000000..312ed03 --- /dev/null +++ b/tests/slash-text.test.ts @@ -0,0 +1,159 @@ +/** + * neutralizeSlashText tests — the unknown-slash-command text path. + * + * Rule under test: only commands the bridge advertises (static list + + * passthrough built-ins + TUI-only names + plugin commands) or `$`-prefixed + * skills go the command route unchanged. Any other `/`-leading prompt (e.g. a + * pasted directory path) is prefixed with a zero-width space so the backend's + * trim() + `^\/` command parse can never match, while the visible text is + * unchanged. + * + * The fs mock feeds loadPluginCommands one fake plugin command ("demo-cmd") so + * the known-command set is deterministic. + */ + +import { homedir } from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --- mock fs (plugin-commands reads ~/.zcode/cli/config.json + cache dir) --- + +const mockFiles = new Map(); +const mockDirs = new Set(); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: (p: string) => mockDirs.has(p) || mockFiles.has(p), + readFileSync: ((p: string, ...args: unknown[]) => { + if (mockFiles.has(p)) return mockFiles.get(p)!; + return actual.readFileSync(p, ...(args as [unknown])); + }) as typeof actual.readFileSync, + readdirSync: (p: string) => { + const entries: string[] = []; + const prefix = p + "/"; + for (const key of mockDirs) { + const rest = key.slice(prefix.length); + if (key.startsWith(prefix) && !rest.includes("/")) entries.push(rest); + } + for (const key of mockFiles.keys()) { + const rest = key.slice(prefix.length); + if (key.startsWith(prefix) && !rest.includes("/")) entries.push(rest); + } + return entries; + }, + statSync: (p: string) => { + if (mockDirs.has(p)) return { isDirectory: () => true } as ReturnType; + return actual.statSync(p); + }, + }; +}); + +const CLI_CONFIG = path.join(homedir(), ".zcode", "cli", "config.json"); +const CMD_DIR = path.join( + homedir(), + ".zcode", + "cli", + "plugins", + "cache", + "market", + "demo", + "1.0.0", + "commands", +); + +beforeEach(() => { + mockFiles.clear(); + mockDirs.clear(); + mockFiles.set( + CLI_CONFIG, + JSON.stringify({ plugins: { enabledPlugins: { "demo@market": true } } }), + ); + // loadPluginCommands existsSync-checks each path segment down to commands/. + mockDirs.add(path.join(homedir(), ".zcode", "cli", "plugins", "cache")); + mockDirs.add(path.join(homedir(), ".zcode", "cli", "plugins", "cache", "market")); + mockDirs.add(path.join(homedir(), ".zcode", "cli", "plugins", "cache", "market", "demo")); + mockDirs.add( + path.join(homedir(), ".zcode", "cli", "plugins", "cache", "market", "demo", "1.0.0"), + ); + mockDirs.add(CMD_DIR); + mockFiles.set( + path.join(CMD_DIR, "demo-cmd.md"), + "---\ndescription: Demo plugin command\n---\nbody", + ); + // loadPluginCommands runs at module load; reset modules so each test sees + // the fresh fs mock state. + vi.resetModules(); +}); + +async function load() { + const { neutralizeSlashText } = await import("../src/handlers/slash.js"); + return neutralizeSlashText; +} + +const ZWSP = "\u200B"; + +describe("neutralizeSlashText", () => { + it("leaves non-slash prompts unchanged", async () => { + const f = await load(); + expect(f("hello world")).toBe("hello world"); + expect(f("look at Users/foo")).toBe("look at Users/foo"); + }); + + it("leaves advertised static commands unchanged (backend command path)", async () => { + const f = await load(); + expect(f("/quota")).toBe("/quota"); + expect(f("/compact now")).toBe("/compact now"); + expect(f("/model GLM-5.3")).toBe("/model GLM-5.3"); + }); + + it("leaves passthrough built-ins unchanged", async () => { + const f = await load(); + expect(f("/skill tdd")).toBe("/skill tdd"); + expect(f("/init")).toBe("/init"); + }); + + it("leaves $-prefixed skills unchanged", async () => { + const f = await load(); + expect(f("/$tdd args")).toBe("/$tdd args"); + }); + + it("leaves discovered plugin commands unchanged", async () => { + const f = await load(); + expect(f("/demo-cmd x")).toBe("/demo-cmd x"); + }); + + it("neutralizes unknown commands and pasted paths", async () => { + const f = await load(); + expect(f("/notacommand")).toBe(`${ZWSP}/notacommand`); + // The reported bug: a directory path pasted into the chat. + expect(f("/Users/william/Downloads/mitm/fashion")).toBe( + `${ZWSP}/Users/william/Downloads/mitm/fashion`, + ); + expect(f("/tmp")).toBe(`${ZWSP}/tmp`); + }); + + it("neutralizes even with leading whitespace (backend trims)", async () => { + const f = await load(); + const out = f(" /Users/william/project"); + expect(out.startsWith(ZWSP)).toBe(true); + expect(out.trimStart().startsWith("/")).toBe(false); + }); + + it("neutralized text survives the backend's trim + ^\\/ parse", async () => { + const f = await load(); + const out = f("/Users/william/project"); + // Mirror of the backend parser: Sua = /^\/([^\s]+)(?:\s+([\s\S]*))?$/ on trimmed text. + const backendParse = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(out.trim()); + expect(backendParse).toBeNull(); + }); + + it("preserves the visible text verbatim after neutralization", async () => { + const f = await load(); + const msg = "/Users/william/project\nplease review this directory"; + const out = f(msg); + expect(out.replace(ZWSP, "")).toBe(msg); + }); +});