Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added

- Process watchdog (`backend/client.ts`): a detached child polls the bridge
pid every 2s and SIGKILLs the zcode process group if the bridge disappears
without running its signal handlers (Zed force-kill on reconnect, crash,
Expand All @@ -17,8 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `server.lastMode` map recording the mode value advertised to the client,
used by the new mode reconciliation.
- `.node-version` pinning node 22 for fnm.
- ACP Registry compatibility: `initialize` now advertises an agent-type
`authMethods` entry (`zcode-credentials`) declaring that the bridge reads
the GLM API key itself from `~/.zcode/v2/config.json` — no editor-side
credentials are required. The registry CI rejects empty `authMethods`, so
this is required for submission. Submission assets live under
`registry/zcode-acp-server/` (`agent.json` + `icon.svg`).

### Fixed

- `/mode` and `/thought` slash commands now emit `current_mode_update` and
`config_option_update`. Previously they switched the backend mode but never
notified the editor UI, because slash commands return `end_turn` and bypass
Expand All @@ -33,17 +41,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
turn completion (`emitModeIfChanged`), since they bypass `session/setMode`.

### Changed

- `SLASH_COMMANDS` rewritten: command names dropped the `/` prefix,
descriptions reworded.
- `package.json` no longer declares `packageManager`; pnpm is managed via the
local environment (corepack/fnm).
- Removed the empty `agentCapabilities.auth: {}` from the `initialize`
response; the auth story is now carried by the new `authMethods` entry.

## [0.1.0] - 2026-07-04

Initial release: a standalone TypeScript ACP server bridging the headless
ZCode app-server to ACP-compatible editors (Zed, JetBrains).

### Added

- ZCode subprocess client with reader-loop multiplexing and process-group
isolation.
- Event-stream listener (`session/subscribe` + `session/event`).
Expand Down Expand Up @@ -73,6 +85,7 @@ ZCode app-server to ACP-compatible editors (Zed, JetBrains).
Development, Troubleshooting).

### Fixed

- `buildSnapshot` flattens `todoGroups` as a **list** (it was read as a single
object); the plan list is no longer empty on `session/load` and
`PlanUpdate` is correctly emitted at turn completion.
Expand All @@ -89,6 +102,7 @@ ZCode app-server to ACP-compatible editors (Zed, JetBrains).
- Stable plan signatures (sorted keys) prevent spurious `PlanUpdate`.

### Changed

- `engines.node` is `>=22.0.0` (the bridge requires `node:sqlite`).
- `package.json` declares `files`, `repository`, `keywords`, `types`,
`packageManager`.
Expand Down
20 changes: 12 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

## What This Project Touches

This is a stdio ACP bridge — no network listener, no own auth. However, in
order to bridge the headless ZCode CLI to ACP-compatible editors, it does
read and write a small number of sensitive files owned by the ZCode app:

| Path | Read/Write | What it touches |
|------|-----------|-----------------|
| `~/.zcode/v2/config.json` | read | Reads the active provider's `baseURL` and `apiKey` (in `backend/credentials.ts`) and the model list (in `config/options.ts`, `config/runtime-model.ts`). The `apiKey` is forwarded to the ZCode subprocess via an environment variable (`ANTHROPIC_API_KEY`) and is never written to logs, stdout, or any other file. |
| `~/.zcode/v2/tasks-index.sqlite` | **read/write** | Inserts/updates rows in the `tasks` table (`tasks-index.ts`) so that sessions created via ACP appear in the ZCode app's UI. Only the `tasks` table is touched, using `INSERT OR IGNORE` / bounded `UPDATE`. |
This is a stdio ACP bridge — no network listener. It does not implement its
own authentication logic or validate credentials; it advertises an agent-type
`authMethods` entry in its `initialize` response so editors know auth is
self-handled, and reads the GLM key from `~/.zcode/v2/config.json`. In order
to bridge the headless ZCode CLI to ACP-compatible editors, it does read and
write a small number of sensitive files owned by the ZCode app:

| Path | Read/Write | What it touches |
| -------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `~/.zcode/v2/config.json` | read | Reads the active provider's `baseURL` and `apiKey` (in `backend/credentials.ts`) and the model list (in `config/options.ts`, `config/runtime-model.ts`). The `apiKey` is forwarded to the ZCode subprocess via an environment variable (`ANTHROPIC_API_KEY`) and is never written to logs, stdout, or any other file. |
| `~/.zcode/v2/tasks-index.sqlite` | **read/write** | Inserts/updates rows in the `tasks` table (`tasks-index.ts`) so that sessions created via ACP appear in the ZCode app's UI. Only the `tasks` table is touched, using `INSERT OR IGNORE` / bounded `UPDATE`. |

The bridge does **not**:

- send credentials, tokens, or session data anywhere except the local ZCode subprocess;
- modify the ZCode CLI binary, the app, or any file outside `tasks-index.sqlite`;
- expose any network port.
Expand Down
103 changes: 66 additions & 37 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,32 @@ zcode-acp-server (stdio JSON-RPC ACP)
zcode app-server --stdio (line-delimited JSON)
```

## ACP Handshake

The `initialize` request (`server.ts`) negotiates the protocol version and
declares the agent's shape to the editor:

- **`protocolVersion`** — pinned to `PROTOCOL_VERSION` (currently 1).
- **`agentInfo`** — name/title/version from `AGENT_INFO` in `utils.ts`.
- **`agentCapabilities`** — `loadSession`, plus `sessionCapabilities.list /
resume / fork`. The prompt capabilities (image/audio/embeddedContext) and
MCP capabilities are all off.
- **`authMethods`** — a single agent-type entry (`zcode-credentials`). The
bridge reads the GLM API key itself from `~/.zcode/v2/config.json` and
forwards it to the ZCode subprocess via `ANTHROPIC_API_KEY`; the editor
never supplies credentials. Omitting the `type` field defaults to `"agent"`,
which the ACP registry's auth-check accepts as "agent self-handles auth".

`initialize` does **not** spawn the backend. The backend is lazily created on
the first `session/new` (via `ensureBackend()`), so the handshake succeeds
even in an environment without `~/.zcode/v2/config.json` (e.g. the registry
CI runs `initialize` with an isolated `HOME`).

Client capabilities advertised at `initialize` are recorded on the server
(`clientCapabilities`) and drive later behaviour: `supportsElicitationForm()`
gates form-based elicitation, and `supportsTerminalOutput()` gates Zed's Bash
terminal UI.

## Core Data Flow

### 1. Session lifecycle
Expand Down Expand Up @@ -52,11 +78,13 @@ pollEvent() consumes → EventTranslator.translate()
### 3. Dual-path event handling

#### Real-time path (EventTranslator)

- Listens to zcode `session/event` pushes
- Translates each event to an ACP `session/update` in real time
- Maintains `seenToolIds` to avoid duplicates

#### Snapshot path (ProjectionDiffer)

- On turn completion, builds a snapshot from `session/messages` + `session/read`
- Diffs two snapshots to produce new events (PlanUpdate / TextDelta / ToolCallNew, etc.)
- Used for turn-completion triage and stall recovery
Expand All @@ -82,6 +110,7 @@ Key: **`seenToolIds` synchronization**

In `session.ts:629`, after the event path finishes processing, the state is
synced to the differ:

```typescript
for (const seenId of translator.seenToolIds) {
differ.markToolSeen(seenId);
Expand All @@ -96,45 +125,45 @@ content-less ToolCallNew.

### `backend/` — ZCode process communication

| File | Responsibility |
|------|------|
| `client.ts` | Spawn/manage the zcode subprocess, reader-loop, request/response multiplexing, process watchdog |
| `listener.ts` | EventStreamListener (subscribe/consume the event stream) and TurnMonitor (snapshot polling) |
| `types.ts` | ZCode JSON-RPC message type definitions |
| File | Responsibility |
| ------------- | ----------------------------------------------------------------------------------------------- |
| `client.ts` | Spawn/manage the zcode subprocess, reader-loop, request/response multiplexing, process watchdog |
| `listener.ts` | EventStreamListener (subscribe/consume the event stream) and TurnMonitor (snapshot polling) |
| `types.ts` | ZCode JSON-RPC message type definitions |

### `translators/` — Event translation

| File | Responsibility |
|------|------|
| `event-translator.ts` | Translate zcode events to InternalEvent (real-time path) |
| `projection-differ.ts` | Diff two snapshots to produce InternalEvent (snapshot path) |
| `tool-helpers.ts` | Tool-related pure functions: title generation, output rendering, diff parsing, location extraction |
| `types.ts` | InternalEvent union type and plan entry builders |
| File | Responsibility |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| `event-translator.ts` | Translate zcode events to InternalEvent (real-time path) |
| `projection-differ.ts` | Diff two snapshots to produce InternalEvent (snapshot path) |
| `tool-helpers.ts` | Tool-related pure functions: title generation, output rendering, diff parsing, location extraction |
| `types.ts` | InternalEvent union type and plan entry builders |

### `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 |
| `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) |
| `io.ts` | ACP notification helpers (including `sendAvailableCommandsDeferred` deferred notification) |
| `slash.ts` | Interception of `/`-prefixed commands (/compact /goal /fork /rewind /steer /model /mode /thought) |

### `interaction/` — Interaction bridging

| File | Responsibility |
|------|------|
| File | Responsibility |
| ------------ | ------------------------------------------------------------------------------------------------ |
| `adapter.ts` | Conversion adapter from zcode interaction requests to ACP (requestPermission + elicitation form) |

### `config/` — Configuration management

| File | Responsibility |
|------|------|
| `options.ts` | configOptions / modes construction, set_config_option dispatch |
| `runtime-model.ts` | runtimeModel overlay construction and application |
| `model-cache.ts` | Model ID cache and usage initialization |
| File | Responsibility |
| ------------------ | -------------------------------------------------------------- |
| `options.ts` | configOptions / modes construction, set_config_option dispatch |
| `runtime-model.ts` | runtimeModel overlay construction and application |
| `model-cache.ts` | Model ID cache and usage initialization |

## Key State Machines

Expand Down Expand Up @@ -222,12 +251,12 @@ mirroring the Python bridge's `_pending_post_notifs` queue +
The session mode can change through four entry points, all of which must
notify the editor UI:

| Trigger | Path | Notifies UI |
|------|------|:---:|
| `session/setMode` request | `extensions.ts:setMode` | yes |
| `session/set_config_option` (mode) | `session.ts:setConfigOptionHandler` → `emitConfigOptionUpdate` | yes |
| `/mode` slash command | `slash.ts` → `emitConfigOptionUpdate` | yes |
| In-turn `EnterPlanMode`/`ExitPlanMode` | reconciled at turn completion | yes |
| Trigger | Path | Notifies UI |
| -------------------------------------- | -------------------------------------------------------------- | :---------: |
| `session/setMode` request | `extensions.ts:setMode` | yes |
| `session/set_config_option` (mode) | `session.ts:setConfigOptionHandler` → `emitConfigOptionUpdate` | yes |
| `/mode` slash command | `slash.ts` → `emitConfigOptionUpdate` | yes |
| In-turn `EnterPlanMode`/`ExitPlanMode` | reconciled at turn completion | yes |

The in-turn path bypasses the bridge entirely, so `prompt()` runs
`emitModeIfChanged` (`session.ts`) at turn completion: it re-reads the
Expand Down Expand Up @@ -277,12 +306,12 @@ zcode process exits, so a normal shutdown leaves no lingering watchdog.

### Why a dual path?

| Scenario | Real-time path | Snapshot path |
|------|---------|----------|
| Normal streaming | Low latency | Must wait for turn end |
| Lost events | Loses data | Recovers from snapshot |
| Deduplication | seenToolIds | seenMessageIds + markToolSeen() |
| Turn-completion triage | Not triggered | PlanUpdate / usage_update |
| Scenario | Real-time path | Snapshot path |
| ---------------------- | -------------- | ------------------------------- |
| Normal streaming | Low latency | Must wait for turn end |
| Lost events | Loses data | Recovers from snapshot |
| Deduplication | seenToolIds | seenMessageIds + markToolSeen() |
| Turn-completion triage | Not triggered | PlanUpdate / usage_update |

### Why no polling fallback?

Expand Down
5 changes: 5 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ cat ~/.zcode/v2/config.json
# confirm a provider is enabled and has models
```

The bridge reads the GLM API key from this file and forwards it to the ZCode
subprocess — no editor-side API key or environment variable is required. If
`config.json` is missing or no provider is enabled, install and log into the
ZCode desktop app so it writes a valid file.

### Test session/subscribe

```bash
Expand Down
Loading
Loading