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
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ src/
│ │ input box). No alternate screen, no wheel capture.
│ ├── input-buffer.ts Pure caret-editing line editor (code-point caret,
│ │ Ctrl-B/F/A/E/U chords) — no React, testable
│ ├── history.ts Per-project prompt history (JSONL under
│ │ ~/.zcode/acp/repl-history), pure + testable
│ └── run.ts Orchestration: spawn bridge, pump updates
└── bin/
├── hub.ts Hub daemon entry (`zcode-acp hub`; spawned by absolute path)
Expand Down Expand Up @@ -103,6 +105,12 @@ ZCode protocol types into ACP notifications directly — always translate.
- **ZCode backend version drift**: the backend may change event payloads between
releases. When diff display or event handling breaks, check the raw backend
event with `ZCODE_ACP_DEBUG=1` before changing translator code.
- **The backend ignores `session/stop`** (verified against app-server 0.16.5 —
the model stream runs to its natural end no matter what). Cancel is therefore
bridge-side only: the turn loop returns `cancelled` at once, and the next
prompt's turn-attribution gate (armed on a recent cancel) drops the abandoned
turn's leftover stream. Never "wait for the backend terminal event" after a
cancel — that made ESC feel dead for the whole remaining generation.
- **`session/prompt` ordering**: subscribe to events BEFORE calling `session/send`
— short turns can complete before a late subscribe catches them.
- **Preempt lock**: concurrent prompts for the same session are serialized via
Expand Down Expand Up @@ -131,6 +139,31 @@ ZCode protocol types into ACP notifications directly — always translate.
- **REPL render state lives in run.ts, not React**: App re-renders from fresh
snapshots; anything that must persist across them (prompt-line editor,
queue, entries) belongs to run.ts's external store passed via snapshot props.
- **Aug-28 app-server build (still "0.16.5") ignores `session/stop`**: the
RPC returns `{}` but the model stream runs to its natural end (verified by
raw-backend probe; the backend's own log records `hadActivePrompt: false` —
the in-flight generation's abort controller is never registered). The
official desktop app never hits that path: its stop button sends a
`v4/command` RPC of type `stop` (`payload.expectedForegroundExecutionId`
optional), which asks the runtime to stop the active foreground execution
— found by grepping the app bundle. stopBackendTurn sends both: the
session/stop formality plus the v4 stop, which kills the generation
instantly (verified: `turn.completed` in 0.0s). Cancel is otherwise
bridge-side: the turn loop returns `stopReason: "cancelled"` on the flag,
and a send after a recent cancel settles the backend first (drain gate:
poll-until-idle, with a `session/close` escalation after a 5s grace if a
generation somehow survives both stops — a mid-generation send is accepted
as steer input and silently dropped when the old turn ends; the
`turn.steerQueued` event proves the swallow and the bridge reports it at
once instead of hanging). After a close-escalation reload the drain gate
must resubscribe the event stream (the reload revives the session but not
its push — the next turn would run deaf) and re-baseline the projection
differ (the abandoned turn committed messages while waiting — a stale
baseline replays that residue as the next reply).
- **The backend rejects JSON-RPC frames carrying a `jsonrpc` field** (strict
zod: "Unrecognized key: jsonrpc", code -32600). The bridge's backend
client never sends one — keep it that way when hand-probing
`zcode app-server --stdio` (frames are bare `{id, method, params}`).

## Docs to read before sensitive changes

Expand Down
86 changes: 86 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.14.1] - 2026-08-31

### Fixed

- REPL live turn: streamed prose now interleaves with thinking and tool
entries in stream order. Prose segments are flushed as entries whenever
thinking resumes, a fresh tool row starts, or a plan note arrives —
previously the whole reply accumulated in a single buffer pinned to the
bottom of the live-turn tail until the turn ended, rendering later
thinking/tool entries above earlier prose and letting long replies crowd
the tail. Whitespace-only thought chunks are ignored as segment
transitions so they cannot shred prose.

## [0.14.0] - 2026-08-31

### Added

- REPL prompt history: every submit is recorded per project
(`~/.zcode/acp/repl-history/<sha1(cwd)>.jsonl`, newest 500 kept, runs of
duplicates collapsed) and recalled across restarts with `↑`/`↓` while the
completion menu is closed — the first `↑` stashes the live draft and `↓`
past the newest entry restores it.
- Pasted text is folded to a single line before it reaches the prompt:
bracketed-paste mode (`?2004`) is armed so ink delivers pastes as one
chunk, and newlines/tabs inside them (or any multi-character chunk
carrying a newline, for terminals without `?2004`) become single spaces.
Previously every newline in a paste submitted mid-paste, firing a
multi-paragraph paste line-by-line as separate prompts.
- REPL `/new` starts a fresh session without leaving the terminal: the live
session is swapped client-side for a new `session/new` placeholder
(config selects reseeded from the response), a divider note marks the
boundary, and the prompt draft is cleared. A running turn refuses it
(`esc` interrupts first); it is registered as a one-shot command, so
picking it in the completion menu executes immediately.
- A live status row while a turn runs — `⠋ working… (12s · esc to interrupt)`,
phase-labeled thinking/writing/working — re-rendering every second so
stretches with no streamed output (long tool calls) are visibly alive; the
old dim "ctrl-c to cancel" line carried no liveness signal. Help lines and
the input-box hint now advertise `esc` as the interrupt (ctrl-c is quit).
- Interactive REPL (bare `zcode-acp`): an Ink terminal chat over the same
bridge the editor uses, including slash-command completion with an
interactive menu, a caret-aware prompt line (arrows/Ctrl-B/F/A/E/U),
Expand Down Expand Up @@ -52,6 +89,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `esc`/stop now takes effect immediately. The Aug-28 app-server build
(still reporting 0.16.5) accepts `session/stop` but never aborts the
in-flight model stream — its own log records every stop with
`hadActivePrompt: false`, i.e. the generation's abort controller is never
registered, so the stream ran ~10s past the stop to its natural end while
the turn loop waited for a terminal event. Digging through the desktop
app's bundle revealed the stop path the official client actually uses: a
`v4/command` RPC of type `stop` that asks the runtime to stop the active
foreground execution (not the broken `session/stop`). The bridge now sends
that v4 stop alongside `session/stop` — verified live: the generation dies
the instant the command lands (`turn.completed` in 0.0s, vs +39.7s natural
drift before). The turn loop also returns `stopReason: "cancelled"` at
once instead of waiting for a terminal event.
- A follow-up prompt sent right after a cancel/preempt is no longer silently
dropped. The same backend build accepts a mid-generation `session/send`
as a steer and discards its input when the old turn finishes (verified:
only one `turn.completed` ever arrives, for the old prompt). The bridge
now settles the backend before sending: with the v4 stop the probe sees
idle immediately; on a backend that honours `session/stop` it polls the
projection until idle; if a generation somehow survives both stops, a
`session/close` escalation after a 5s grace tears down the runtime (the
probe then fails into a session reload). A visible
`[上一个回复仍在生成,等待结束后发送…]` note explains the wait — bounded at
90s, still interruptible with `esc`, falling back to a direct send on
timeout or probe failure. Two edge paths found in review are also closed:
after a close-escalation reload the bridge re-subscribes the event stream
(the reload revives the session but not its push — without this the next
turn runs deaf until the watchdog) and re-baselines the projection differ
so the cancelled turn's residue is never replayed as the next reply; and a
send that does land mid-generation is reported at once via the backend's
`turn.steerQueued` event (`[消息被并入仍在生成的回合,将被丢弃,请重新发送]`)
instead of hanging silently until the 120s watchdog.
- Pressing ↓ with no completion menu open no longer zombifies the whole UI:
the setState updater dereferenced a null menu during render, unmounting
React's tree under ink without any crash signal (found by review,
Expand All @@ -65,6 +134,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
overwrite the resumed one; the placeholder is now discarded.
- Resume no longer force-pins a session to the first config.json model
(faithful model preservation, overlay demoted to one-retry fallback).
- Turns driven from another client (mobile app, second editor) now render
live in the REPL even when the two hold different ACP session ids for the
same conversation — the common "fresh REPL session, mobile follow-up"
path previously stayed completely silent (no live turn, no streaming, no
completion, while the other client saw everything). Session-scoped
notifications (updates, turnState, prompt echo) are now emitted once per
attached session alias.
- ESC (and ctrl-c) now interrupts a running turn immediately. The backend
ignores `session/stop` (verified against app-server 0.16.5 — the model
stream runs to its natural end regardless), and the turn loop used to wait
for that terminal event before reporting cancelled, so the reply kept
streaming for the whole remaining generation (10s+ observed) while the
status row kept spinning. The loop now returns `cancelled` at once; a
follow-up prompt sent during the abandoned turn's finalisation arms the
turn-attribution gate so the residue is dropped instead of bleeding into
the new reply. REPL hint copy now advertises esc as the interrupt
("esc interrupt") instead of ctrl-c.

## [0.13.0] - 2026-08-26

Expand Down
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,16 @@ menu is open. The status row carries a compact plan-quota readout
(`5h 16% · wk 4%`) refreshed every 10 minutes — `/quota` prints the full
card. Pasted or dragged-in content (error logs, file drops) is sanitized and
size-capped before it reaches the editor, so long pastes batch cleanly.
`Ctrl-C` cancels a running turn; `esc` interrupts one too; while idle, press
Ctrl-C twice to quit. `/exit` leaves; the session itself persists in the
ZCode backend and is available to your editor.
`esc` interrupts a running turn (immediately — the bridge resolves the prompt
as cancelled without waiting for the backend); `Ctrl-C` also interrupts, and
while idle press it twice to quit. `/exit` leaves; the session itself persists
in the ZCode backend and is available to your editor.

Sending a follow-up right after an interrupt waits for the backend to finish
the cancelled generation first — a `[上一个回复仍在生成,等待结束后发送…]`
note explains the pause (the Aug-28 app-server accepts mid-generation sends
as steer input but drops them when the old turn ends; the bridge polls until
the session is idle, up to 90s, so the message actually runs).

Messages typed while a turn is running (or the session is still starting) are
queued, not lost: each shows up in the transcript immediately and a `⏸ queued`
Expand All @@ -241,12 +248,21 @@ completion menu — `↑`/`↓` move, `enter` picks the highlighted entry (or `t
`→`; typing the exact form already sends), `esc` dismisses. After picking
`/model`, `/mode`, or `/thought` the same menu lists the config options (the
current one marked `●`) and **enter on a row switches immediately** — no second
confirmation. Argument-free commands (`/exit`, `/help`, `/sessions`,
confirmation. Argument-free commands (`/exit`, `/help`, `/sessions`, `/new`,
`/compact`, `/mcp`, `/quota`) run on pick as well; every other completion
(skills, plugins) only fills the line, since those usually expect arguments.
The arg-less forms still print a static listing over the same slash-command
path the editor uses. `/help` lists every command the bridge advertises,
including plugin commands.
including plugin commands. `/new` swaps in a fresh session without leaving
the terminal (the old conversation stays in `/sessions` and in scrollback).

Submitted prompts are history: `↑`/`↓` (with the completion menu closed)
recall them per project across restarts — the first `↑` stashes the draft
and `↓` past the newest entry restores it. Pasted text folds to a single
line (newlines and tabs become spaces), so a multi-paragraph paste lands in
the box as one prompt instead of firing line-by-line. While a reply streams,
the footer shows a live status row — `⠋ working… (12s · esc to interrupt)` —
so stretches with no streamed output (long tool calls) still visibly tick.

Unexpected internal errors never take the REPL down silently: they print to
stderr and surface as an `-- error absorbed: …` note in the transcript while
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-acp-server",
"version": "0.13.0",
"version": "0.14.1",
"description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.",
"type": "module",
"license": "Apache-2.0",
Expand Down
11 changes: 6 additions & 5 deletions src/backend/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,12 @@ export class EventStreamListener {
*/
async subscribe(nextId: NextId): Promise<ZcodeSnapshot> {
// Retry transient timeouts as a lightweight safety net for cold-start /
// network blips. The cancel-preempt path no longer needs subscribe retries
// to absorb a backend stop-finalization window: the turn loop now blocks
// until the backend emits turn.completed/turn.failed before its prompt()
// exits, so by the time the next prompt reaches subscribe the backend is
// already idle. These retries are just a last-resort cushion.
// network blips. The cancel path returns from prompt() at once (the
// backend ignores session/stop), so a prompt sent right after a cancel
// CAN reach subscribe while the abandoned turn is still generating —
// prompt()'s drain gate polls the backend to idle before sending, so
// residue is gone by the time this subscribes. These retries are just a
// last-resort cushion.
//
// Only `timeout` is retried — non-transient errors (reader dead, pipe
// broken, method-not-found, session-level business error) fail fast.
Expand Down
78 changes: 42 additions & 36 deletions src/handlers/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,42 +36,48 @@ export async function dispatchEvent(
ev: InternalEvent,
chunkMsgId: string,
): Promise<void> {
switch (ev.kind) {
case "ToolCallNew":
await dispatchToolCallNew(server, cx, acpSid, ev);
break;
case "ToolCallUpdate":
await dispatchToolCallUpdate(server, cx, acpSid, ev);
break;
case "UsageDelta":
await dispatchUsageDelta(server, cx, acpSid, ev);
break;
case "TextDelta":
await sendSessionUpdate(cx, acpSid, {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: ev.text },
messageId: chunkMsgId,
});
break;
case "ReasoningDelta":
await sendSessionUpdate(cx, acpSid, {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: ev.text },
messageId: `thought_${chunkMsgId}`,
});
break;
case "PlanUpdate":
await sendSessionUpdate(cx, acpSid, {
sessionUpdate: "plan",
entries: ev.entries,
});
break;
case "FilesChanged":
await dispatchFilesChanged(cx, acpSid, ev);
break;
case "ConfigChanged":
await dispatchConfigChanged(server, cx, acpSid, ev);
break;
// One conversation can be attached under several ACP ids (see
// server.sessionAliases): emit once per alias with that alias as the
// payload sessionId, or every client but the prompter starves silently.
const targets = server.sessionAliases(acpSid);
for (const sid of targets) {
switch (ev.kind) {
case "ToolCallNew":
await dispatchToolCallNew(server, cx, sid, ev);
break;
case "ToolCallUpdate":
await dispatchToolCallUpdate(server, cx, sid, ev);
break;
case "UsageDelta":
await dispatchUsageDelta(server, cx, sid, ev);
break;
case "TextDelta":
await sendSessionUpdate(cx, sid, {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: ev.text },
messageId: chunkMsgId,
});
break;
case "ReasoningDelta":
await sendSessionUpdate(cx, sid, {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: ev.text },
messageId: `thought_${chunkMsgId}`,
});
break;
case "PlanUpdate":
await sendSessionUpdate(cx, sid, {
sessionUpdate: "plan",
entries: ev.entries,
});
break;
case "FilesChanged":
await dispatchFilesChanged(cx, sid, ev);
break;
case "ConfigChanged":
await dispatchConfigChanged(server, cx, sid, ev);
break;
}
}
}

Expand Down
36 changes: 22 additions & 14 deletions src/handlers/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,23 +126,31 @@ export function echoUserPromptToOthers(
.trim();
if (!text) return;
const messageId = `uprompt_${randomUUID()}`;
void enqueueSessionSend(params.sessionId, () =>
server.clients
.notifyOthers(prompter, "session/update", {
sessionId: params.sessionId,
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text },
messageId,
},
})
.catch((e: unknown) => {
void enqueueSessionSend(params.sessionId, async () => {
// Emit once per attached alias (server.sessionAliases): clients route
// session/update by payload sessionId, so the prompter's id alone never
// reaches a client holding this conversation under a different id.
const results = await Promise.allSettled(
server.sessionAliases(params.sessionId).map((sid) =>
server.clients.notifyOthers(prompter, "session/update", {
sessionId: sid,
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text },
messageId,
},
}),
),
);
for (const r of results) {
if (r.status === "rejected") {
warn(
`user-prompt echo failed (sid=${params.sessionId}): ` +
`${e instanceof Error ? e.message : String(e)}`,
`${r.reason instanceof Error ? r.reason.message : String(r.reason)}`,
);
}),
);
}
}
});
}

/** Shape of a slash command entry (matches ACP's AvailableCommand). */
Expand Down
Loading
Loading