zcode-acp-server 0.11.8 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +144 -32
  2. package/dist/backend/client.d.ts.map +1 -1
  3. package/dist/backend/client.js +10 -0
  4. package/dist/backend/client.js.map +1 -1
  5. package/dist/backend/listener.d.ts.map +1 -1
  6. package/dist/backend/listener.js +6 -5
  7. package/dist/backend/listener.js.map +1 -1
  8. package/dist/backend/resolve.d.ts.map +1 -1
  9. package/dist/backend/resolve.js +35 -1
  10. package/dist/backend/resolve.js.map +1 -1
  11. package/dist/bin/hub.d.ts +3 -3
  12. package/dist/bin/hub.d.ts.map +1 -1
  13. package/dist/bin/hub.js +22 -12
  14. package/dist/bin/hub.js.map +1 -1
  15. package/dist/bin/quota.d.ts +14 -6
  16. package/dist/bin/quota.d.ts.map +1 -1
  17. package/dist/bin/quota.js +27 -19
  18. package/dist/bin/quota.js.map +1 -1
  19. package/dist/cli.d.ts +38 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +129 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/config/runtime-model.d.ts +10 -10
  24. package/dist/config/runtime-model.js +10 -10
  25. package/dist/handlers/account.d.ts +1 -1
  26. package/dist/handlers/account.js +1 -1
  27. package/dist/handlers/dispatch.d.ts.map +1 -1
  28. package/dist/handlers/dispatch.js +42 -36
  29. package/dist/handlers/dispatch.js.map +1 -1
  30. package/dist/handlers/io.d.ts.map +1 -1
  31. package/dist/handlers/io.js +19 -13
  32. package/dist/handlers/io.js.map +1 -1
  33. package/dist/handlers/session.d.ts +58 -4
  34. package/dist/handlers/session.d.ts.map +1 -1
  35. package/dist/handlers/session.js +317 -87
  36. package/dist/handlers/session.js.map +1 -1
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +27 -8
  40. package/dist/index.js.map +1 -1
  41. package/dist/interaction/adapter.d.ts +1 -0
  42. package/dist/interaction/adapter.d.ts.map +1 -1
  43. package/dist/interaction/adapter.js +5 -1
  44. package/dist/interaction/adapter.js.map +1 -1
  45. package/dist/quota/color.d.ts +4 -4
  46. package/dist/quota/color.d.ts.map +1 -1
  47. package/dist/quota/color.js +7 -5
  48. package/dist/quota/color.js.map +1 -1
  49. package/dist/quota/combined.d.ts +1 -1
  50. package/dist/quota/combined.js +4 -4
  51. package/dist/quota/combined.js.map +1 -1
  52. package/dist/quota/format.d.ts +1 -1
  53. package/dist/quota/opencode-go/format.d.ts.map +1 -1
  54. package/dist/quota/opencode-go/format.js +5 -1
  55. package/dist/quota/opencode-go/format.js.map +1 -1
  56. package/dist/quota/opencode-go/index.d.ts +1 -1
  57. package/dist/quota/opencode-go/index.js +1 -1
  58. package/dist/quota/opencode-go/types.d.ts +1 -1
  59. package/dist/quota/opencode-go/types.js +1 -1
  60. package/dist/quota/rounding.d.ts +8 -0
  61. package/dist/quota/rounding.d.ts.map +1 -0
  62. package/dist/quota/rounding.js +10 -0
  63. package/dist/quota/rounding.js.map +1 -0
  64. package/dist/remote/config.d.ts +1 -1
  65. package/dist/remote/config.d.ts.map +1 -1
  66. package/dist/remote/config.js +1 -1
  67. package/dist/remote/config.js.map +1 -1
  68. package/dist/remote/endpoint.d.ts +1 -1
  69. package/dist/remote/endpoint.d.ts.map +1 -1
  70. package/dist/remote/endpoint.js +6 -1
  71. package/dist/remote/endpoint.js.map +1 -1
  72. package/dist/remote/hub-server.d.ts +1 -1
  73. package/dist/remote/hub-server.d.ts.map +1 -1
  74. package/dist/remote/hub-server.js +13 -10
  75. package/dist/remote/hub-server.js.map +1 -1
  76. package/dist/remote/session-rename-endpoint.d.ts +20 -0
  77. package/dist/remote/session-rename-endpoint.d.ts.map +1 -0
  78. package/dist/remote/session-rename-endpoint.js +108 -0
  79. package/dist/remote/session-rename-endpoint.js.map +1 -0
  80. package/dist/repl/App.d.ts +108 -0
  81. package/dist/repl/App.d.ts.map +1 -0
  82. package/dist/repl/App.js +706 -0
  83. package/dist/repl/App.js.map +1 -0
  84. package/dist/repl/history.d.ts +25 -0
  85. package/dist/repl/history.d.ts.map +1 -0
  86. package/dist/repl/history.js +73 -0
  87. package/dist/repl/history.js.map +1 -0
  88. package/dist/repl/input-buffer.d.ts +77 -0
  89. package/dist/repl/input-buffer.d.ts.map +1 -0
  90. package/dist/repl/input-buffer.js +174 -0
  91. package/dist/repl/input-buffer.js.map +1 -0
  92. package/dist/repl/model.d.ts +275 -0
  93. package/dist/repl/model.d.ts.map +1 -0
  94. package/dist/repl/model.js +596 -0
  95. package/dist/repl/model.js.map +1 -0
  96. package/dist/repl/mouse.d.ts +49 -0
  97. package/dist/repl/mouse.d.ts.map +1 -0
  98. package/dist/repl/mouse.js +165 -0
  99. package/dist/repl/mouse.js.map +1 -0
  100. package/dist/repl/run.d.ts +12 -0
  101. package/dist/repl/run.d.ts.map +1 -0
  102. package/dist/repl/run.js +902 -0
  103. package/dist/repl/run.js.map +1 -0
  104. package/dist/server.d.ts +19 -1
  105. package/dist/server.d.ts.map +1 -1
  106. package/dist/server.js +32 -4
  107. package/dist/server.js.map +1 -1
  108. package/dist/tasks-index.d.ts +7 -0
  109. package/dist/tasks-index.d.ts.map +1 -1
  110. package/dist/tasks-index.js +50 -9
  111. package/dist/tasks-index.js.map +1 -1
  112. package/dist/translators/tool-helpers.d.ts.map +1 -1
  113. package/dist/translators/tool-helpers.js +3 -0
  114. package/dist/translators/tool-helpers.js.map +1 -1
  115. package/docs/ARCHITECTURE.md +1 -1
  116. package/docs/BACKLOG.md +40 -21
  117. package/docs/REMOTE-CLIENTS.md +37 -8
  118. package/docs/TROUBLESHOOTING.md +1 -1
  119. package/docs/adr/0007-unified-cli-entry-and-bin-pruning.md +40 -0
  120. package/package.json +9 -4
@@ -0,0 +1,902 @@
1
+ /**
2
+ * REPL orchestration (bare `zcode-acp`): spawn the bridge subprocess, connect
3
+ * to it as an ACP client over stdio, create a session rooted at the current
4
+ * directory, and pump session updates into the Ink UI.
5
+ *
6
+ * State lives here (plain variables + full rerender) — the App component is a
7
+ * pure view over a snapshot. One process per conversation: closing the REPL
8
+ * closes the bridge (its stdin-end shutdown path), which reaps the zcode
9
+ * backend through the bridge's normal cleanup.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { Readable, Writable } from "node:stream";
13
+ import process from "node:process";
14
+ import { fileURLToPath } from "node:url";
15
+ import * as acp from "@agentclientprotocol/sdk";
16
+ import { render } from "ink";
17
+ import { createElement } from "react";
18
+ import { z } from "zod";
19
+ import { queryQuota } from "../quota/index.js";
20
+ import { AGENT_INFO, warn } from "../utils.js";
21
+ import { App, } from "./App.js";
22
+ import { applyStatusUpdate, applyUpdate, createReplStatus, createTurnState, finishTurn, formatQuotaLine, handleLocalCommand, parseCommand, parseQuestionForm, seedStatusFromNewSession, selectLabel, } from "./model.js";
23
+ import { HISTORY_MAX, historyPath, loadHistory, pushHistory, saveHistory } from "./history.js";
24
+ import { createLineEditor, replaceText } from "./input-buffer.js";
25
+ export async function runRepl() {
26
+ // Crash containment: an unexpected throw is SURFACED, never fatal — it
27
+ // prints to stderr (stack included), lands as a dim note in the
28
+ // transcript, and the REPL keeps running. A circuit breaker is the only
29
+ // exit: if errors keep firing within the window the render tree itself is
30
+ // almost certainly broken and every frame would throw again — shutting
31
+ // down then beats a spinning error loop. Rejected promises are always
32
+ // log-only; the SDK resolves raced/aborted request promises late by
33
+ // design, and those settle as rejections we deliberately ignore.
34
+ const CRASH_WINDOW_MS = 10_000;
35
+ const CRASH_LIMIT = 5;
36
+ let crashTimes = [];
37
+ let repaintQueued = false;
38
+ process.on("uncaughtException", (err) => {
39
+ const now = Date.now();
40
+ crashTimes = crashTimes.filter((t) => now - t < CRASH_WINDOW_MS);
41
+ crashTimes.push(now);
42
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
43
+ warn(`repl absorbed an error (${crashTimes.length}/${CRASH_LIMIT} recent): ${detail}`);
44
+ if (crashTimes.length >= CRASH_LIMIT) {
45
+ warn("repl: too many errors in a row — UI is likely broken, shutting down");
46
+ // Unmount so ink's stdin cleanup effects run (raw mode, bracketed
47
+ // paste): process.exit alone would leave the tty wedged. The tree may
48
+ // be exactly what's broken — guard the unmount.
49
+ try {
50
+ ink.unmount();
51
+ }
52
+ catch {
53
+ // ignore
54
+ }
55
+ try {
56
+ child.stdin?.end();
57
+ }
58
+ catch {
59
+ // ignore
60
+ }
61
+ setTimeout(() => {
62
+ try {
63
+ child.kill("SIGKILL");
64
+ }
65
+ catch {
66
+ // ignore
67
+ }
68
+ process.exit(1);
69
+ }, 1500).unref();
70
+ return;
71
+ }
72
+ const msg = (err instanceof Error ? err.message : String(err)).split("\n")[0] || "unknown error";
73
+ entries = [...entries, { kind: "note", text: `error absorbed: ${msg}` }];
74
+ // Repaint on a later tick: if the throw happened INSIDE a rerender,
75
+ // re-entering ink synchronously from this handler would just rethrow.
76
+ if (!repaintQueued && !exited) {
77
+ repaintQueued = true;
78
+ setTimeout(() => {
79
+ repaintQueued = false;
80
+ rerender();
81
+ }, 50);
82
+ }
83
+ });
84
+ process.on("unhandledRejection", (reason) => {
85
+ warn(`repl ignored an async failure: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`);
86
+ });
87
+ const serverJs = fileURLToPath(new URL("../index.js", import.meta.url));
88
+ const child = spawn(process.execPath, [serverJs], {
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ });
91
+ // Bridge stderr (gated debug logs + warns) never interleaves with the Ink
92
+ // UI; keep a tail so a bridge crash can be explained on exit.
93
+ let stderrTail = "";
94
+ child.stderr?.setEncoding("utf8");
95
+ child.stderr?.on("data", (d) => {
96
+ stderrTail = (stderrTail + d).slice(-2000);
97
+ });
98
+ // --- UI state (external store; every mutation rerenders a fresh snapshot) ---
99
+ let entries = [];
100
+ let turn = null;
101
+ let permission = null;
102
+ let question = null;
103
+ // Non-null while the /sessions picker is open (run-side holds the list; the
104
+ // App owns the row index).
105
+ let sessionPick = null;
106
+ let status = createReplStatus();
107
+ let busy = true;
108
+ let exited = false;
109
+ // True between a user submit and the stop message. The bridge also pushes
110
+ // idle-time updates right after session/new (available_commands, mode
111
+ // state, …) — without this gate those would spin up a phantom turn and
112
+ // show the "running" spinner with nothing in flight.
113
+ let turnActive = false;
114
+ // True while a prompt WE sent is in flight (its stop message closes the
115
+ // turn). Remote-initiated turns (mobile/second client) never deliver a stop
116
+ // here — they are closed by `$/zcode/turnState running:false` instead.
117
+ let promptInFlight = false;
118
+ // True between a `/sessions` resume and the drained history backlog: during
119
+ // this window streamed updates fold into static entries (see the pump).
120
+ // `loadSettled` gates the DRAIN: the bridge streams replay notifications
121
+ // while the load request is still in flight, so consuming early would let a
122
+ // >10ms gap between two replayed messages falsely end replay mode.
123
+ let replayMode = false;
124
+ // The live ACP session. Starts as the fresh session/new placeholder; a
125
+ // `/sessions` resume swaps it for an attached load of the picked backend
126
+ // session (same pump, same update routing). Declared with the other
127
+ // state, NOT at its bootstrap assignment below: /sessions can run while
128
+ // that await is still in flight, and assigning a `let` before its
129
+ // declaration executes is a TDZ ReferenceError.
130
+ let activeSession = null;
131
+ let resumedDuringStartup = false;
132
+ let loadSettled = true;
133
+ let replayTurn = null;
134
+ const REPLAY_QUIET_MS = 10;
135
+ // Resume loads only the last N messages (turn-aligned, ADR-0003 tail
136
+ // replay). Full replay of a huge session would flood the native
137
+ // scrollback with thousands of lines in one burst — a bounded recent tail
138
+ // gives context without the wall-of-text scroll jump.
139
+ const RESUME_TAIL_LIMIT = 50;
140
+ const sleep = (ms) => new Promise((resolve) => setTimeout(() => resolve(null), ms));
141
+ // Prompts submitted while another turn runs (or before the session is
142
+ // ready); drained one per stop, FIFO.
143
+ const promptQueue = [];
144
+ // Native-scrollback model: completed entries print once via ink <Static>
145
+ // and become the terminal's own history (native smooth scroll, selection,
146
+ // search — the Claude Code pattern). Only a compact dynamic footer below
147
+ // (live-turn tail, queue panel, completion menu, input box) ever repaints.
148
+ // --- prompt line editor (external store): the App re-renders from fresh
149
+ // snapshots, so draft text must live out here to survive rerenders.
150
+ let editor = createLineEditor();
151
+ // --- prompt history (ADR-0008) ---
152
+ // Entries load once at startup; `historyIdx` points at the recalled entry
153
+ // (-1 = live draft). ↑ stashes the draft before walking back; ↓ past the
154
+ // newest entry restores it. State out here so recall survives repaints.
155
+ const historyFile = historyPath(process.cwd());
156
+ let history = [];
157
+ let historyIdx = -1;
158
+ let historyDraft = null;
159
+ try {
160
+ history = loadHistory(historyFile);
161
+ }
162
+ catch {
163
+ // loadHistory is already best-effort; this guard keeps even a throw from
164
+ // its own write-back path from killing startup.
165
+ }
166
+ /** Recall one entry older; stashes the live draft on the first step. */
167
+ function historyUp() {
168
+ if (history.length === 0 || exited)
169
+ return;
170
+ if (historyIdx === -1) {
171
+ historyDraft = editor.text;
172
+ historyIdx = history.length - 1;
173
+ }
174
+ else if (historyIdx > 0) {
175
+ historyIdx--;
176
+ }
177
+ else {
178
+ return;
179
+ }
180
+ editor = replaceText(history[historyIdx]);
181
+ rerender();
182
+ }
183
+ /** Walk one entry newer; past the newest, restore the stashed draft. */
184
+ function historyDown() {
185
+ if (historyIdx === -1 || exited)
186
+ return;
187
+ if (historyIdx < history.length - 1) {
188
+ historyIdx++;
189
+ editor = replaceText(history[historyIdx]);
190
+ }
191
+ else {
192
+ historyIdx = -1;
193
+ editor = replaceText(historyDraft ?? "");
194
+ historyDraft = null;
195
+ }
196
+ rerender();
197
+ }
198
+ // --- completion-menu visibility mirror ---
199
+ // InputLine publishes whether its menu is showing (plain var, no React
200
+ // state); the app-level key handler reads it so an open menu takes the
201
+ // first esc — dismissing it must not also interrupt a queued-up turn.
202
+ let completionMenuOpen = false;
203
+ // --- plan-quota indicator (prompt-line status suffix) ---
204
+ // Fetched once at startup and refreshed every QUOTA_TTL_MS; null (hidden)
205
+ // while the fetch runs or fails, so offline / auth-expired sessions just
206
+ // lose the suffix instead of nagging. The full card stays available via
207
+ // /quota. Failures are silent by design — warn() on every TTL tick would
208
+ // spam long-lived sessions.
209
+ let quotaLine = null;
210
+ let quotaFetchId = 0;
211
+ let quotaTimer = null;
212
+ const QUOTA_TTL_MS = 10 * 60 * 1000;
213
+ async function refreshQuota() {
214
+ const id = ++quotaFetchId;
215
+ try {
216
+ const result = await queryQuota();
217
+ if (exited || id !== quotaFetchId)
218
+ return; // stale reply — a newer fetch owns the line
219
+ const line = formatQuotaLine(result);
220
+ if (line !== quotaLine) {
221
+ quotaLine = line;
222
+ rerender();
223
+ }
224
+ }
225
+ catch {
226
+ // best-effort indicator: leave whatever was last shown in place
227
+ }
228
+ }
229
+ void refreshQuota();
230
+ quotaTimer = setInterval(() => void refreshQuota(), QUOTA_TTL_MS);
231
+ quotaTimer.unref();
232
+ // Bracketed paste (?2004) is armed by ink's usePaste hook inside InputLine
233
+ // and disarmed with its unmount — no manual terminal-mode management here.
234
+ const renderOpts = { exitOnCtrlC: false };
235
+ let ink = render(createElement(App, snapshot()), renderOpts);
236
+ function snapshot() {
237
+ return {
238
+ entries,
239
+ turn,
240
+ permission,
241
+ question,
242
+ sessionPick,
243
+ queued: [...promptQueue],
244
+ status,
245
+ busy,
246
+ replaying: replayMode,
247
+ editor,
248
+ applyEdit: (op) => {
249
+ if (exited)
250
+ return;
251
+ editor = op(editor);
252
+ rerender();
253
+ },
254
+ quotaLine,
255
+ isMenuOpen: () => completionMenuOpen,
256
+ onMenuOpenChange: (open) => {
257
+ completionMenuOpen = open;
258
+ },
259
+ onSubmit: (text) => void onSubmit(text),
260
+ onCancelTurn,
261
+ onAnswerPermission: (id) => permissionResolver?.(id),
262
+ onAnswerQuestion: (answer) => {
263
+ const resolve = questionResolver;
264
+ questionResolver = null;
265
+ question = null;
266
+ rerender();
267
+ resolve?.(answer);
268
+ },
269
+ onPickSession: (sid) => sessionPickResolver?.(sid),
270
+ onHistoryUp: historyUp,
271
+ onHistoryDown: historyDown,
272
+ onExit: () => cleanup(0),
273
+ };
274
+ }
275
+ function rerender() {
276
+ if (!exited)
277
+ ink.rerender(createElement(App, snapshot()));
278
+ }
279
+ // --- Permission bridging: agent request → picker → response ---
280
+ let permissionResolver = null;
281
+ // --- AskUserQuestion bridging: elicitation form → picker → response ---
282
+ let questionResolver = null;
283
+ function askQuestion(form, signal) {
284
+ return new Promise((resolve) => {
285
+ const onAbort = () => {
286
+ questionResolver = null;
287
+ question = null;
288
+ rerender();
289
+ resolve(null);
290
+ };
291
+ const settle = (answer) => {
292
+ signal?.removeEventListener("abort", onAbort);
293
+ resolve(answer);
294
+ };
295
+ signal?.addEventListener("abort", onAbort, { once: true });
296
+ questionResolver = settle;
297
+ question = {
298
+ title: form.title,
299
+ multiSelect: form.multiSelect,
300
+ options: form.options,
301
+ };
302
+ rerender();
303
+ });
304
+ }
305
+ // --- /sessions bridging: list → picker → resume ---
306
+ let sessionPickResolver = null;
307
+ // Bumped by every session swap; async continuations compare their captured
308
+ // generation after each await and bail when another swap went first —
309
+ // otherwise a slow session/load could reseed status into a FRESH session.
310
+ let swapGen = 0;
311
+ // --- ACP client connection ---
312
+ const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
313
+ const app = acp
314
+ .client({ name: "zcode-acp" })
315
+ .onRequest("session/request_permission", async (ctx) => {
316
+ // A remote client (mobile/hub) may answer the broadcast request first;
317
+ // the bridge then cancels this losing copy and the SDK aborts
318
+ // ctx.signal — settle here so our picker dismisses instead of hanging.
319
+ const id = await new Promise((resolve) => {
320
+ const onAbort = () => {
321
+ permissionResolver = null;
322
+ permission = null;
323
+ rerender();
324
+ resolve(null);
325
+ };
326
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
327
+ permissionResolver = (v) => {
328
+ ctx.signal.removeEventListener("abort", onAbort);
329
+ resolve(v);
330
+ };
331
+ const options = ctx.params.options.map((o) => ({ id: o.optionId, name: o.name }));
332
+ // ExitPlanMode arrives as an approve/reject permission pair (see
333
+ // interaction/adapter.ts); head it as a plan approval and show the
334
+ // plan text so the decision is actually reviewable.
335
+ const isPlan = options.length === 2 && options.every((o) => o.id === "approve" || o.id === "reject");
336
+ const raw = ctx.params.toolCall?.rawInput;
337
+ permission = {
338
+ heading: isPlan ? "plan approval" : "permission requested",
339
+ title: ctx.params.toolCall?.title ?? "tool call",
340
+ detail: typeof raw === "string" ? raw : undefined,
341
+ options,
342
+ };
343
+ rerender();
344
+ });
345
+ permissionResolver = null;
346
+ permission = null;
347
+ rerender();
348
+ return id === null
349
+ ? { outcome: { outcome: "cancelled" } }
350
+ : { outcome: { outcome: "selected", optionId: id } };
351
+ })
352
+ .onRequest("elicitation/create", async (ctx) => {
353
+ // AskUserQuestion arrives as the bridge's form elicitation. Each
354
+ // question walks the picker sequentially; skipped questions omit
355
+ // their key (the backend treats them as unanswered).
356
+ const forms = parseQuestionForm(ctx.params);
357
+ if (!forms)
358
+ return { action: "decline", reason: "unsupported elicitation form" };
359
+ const content = {};
360
+ for (const form of forms) {
361
+ const answer = await askQuestion(form, ctx.signal);
362
+ // A remote client winning the broadcast aborts our request via
363
+ // ctx.signal — stop answering instead of walking remaining questions.
364
+ if (ctx.signal.aborted)
365
+ return { action: "decline", reason: "cancelled" };
366
+ if (answer === null)
367
+ continue; // skipped
368
+ if (form.multiSelect) {
369
+ const merged = [...answer.picked, ...(answer.custom ? [answer.custom] : [])];
370
+ if (merged.length > 0)
371
+ content[form.key] = merged;
372
+ }
373
+ else {
374
+ content[form.key] = answer.custom || answer.picked[0] || "";
375
+ }
376
+ }
377
+ return { action: "accept", content };
378
+ })
379
+ // Out-of-band turn indicator broadcast by the bridge on every prompt start
380
+ // and end. Drives turns the REPL did NOT start itself: a mobile/second
381
+ // client prompting this session must render here too — without it those
382
+ // updates would be silently dropped (the pump only folds updates while a
383
+ // turn is active).
384
+ .onNotification("$/zcode/turnState", z.object({ sessionId: z.string(), running: z.boolean() }), (ctx) => {
385
+ if (!activeSession || ctx.params.sessionId !== activeSession.sessionId)
386
+ return;
387
+ if (ctx.params.running) {
388
+ // Local prompts already opened the turn in startTurn; idempotent.
389
+ if (!turnActive && !replayMode) {
390
+ turn = createTurnState();
391
+ turnActive = true;
392
+ rerender();
393
+ }
394
+ }
395
+ else if (turnActive && !promptInFlight) {
396
+ // Remote turn finished (no stop message reaches non-initiators).
397
+ entries = [...entries, ...finishTurn(turn ?? createTurnState())];
398
+ turn = null;
399
+ turnActive = false;
400
+ drainQueue();
401
+ rerender();
402
+ }
403
+ })
404
+ .connect(stream);
405
+ const cx = app.agent;
406
+ // Advertise form-elicitation so AskUserQuestion routes here as a
407
+ // structured form instead of permission-popups labeled "permission
408
+ // requested". The SDK never sends a protocol-level initialize on the
409
+ // client's behalf, so this request() IS the handshake (the bridge rejects
410
+ // any earlier non-initialize message). Sent via the generic request() —
411
+ // the typed helper lives on the Agent interface, not the ClientContext we
412
+ // hold.
413
+ await cx.request("initialize", {
414
+ protocolVersion: 1,
415
+ clientCapabilities: {
416
+ fs: { readTextFile: false, writeTextFile: false },
417
+ elicitation: { form: {} },
418
+ },
419
+ clientInfo: { name: "zcode-acp", title: "zcode-acp REPL", version: AGENT_INFO.version },
420
+ });
421
+ try {
422
+ const fresh = await cx.buildSession(process.cwd()).start();
423
+ if (resumedDuringStartup) {
424
+ // The user picked a session from /sessions while this cold-start
425
+ // round-trip was still in flight — the fresh placeholder is already
426
+ // obsolete and its default status must not overwrite the resumed
427
+ // session's own seeded selects. Discard it, keep what they expect.
428
+ fresh.dispose();
429
+ }
430
+ else {
431
+ activeSession = fresh;
432
+ // The initial config options ride the session/new response body, not a
433
+ // notification — seed the status from it; later switches push updates.
434
+ status = seedStatusFromNewSession(status, activeSession.newSessionResponse);
435
+ }
436
+ }
437
+ catch (err) {
438
+ entries = [
439
+ ...entries,
440
+ {
441
+ kind: "note",
442
+ text: `failed to start session: ${err instanceof Error ? err.message : String(err)}`,
443
+ },
444
+ ];
445
+ rerender();
446
+ cleanup(1);
447
+ return;
448
+ }
449
+ busy = false;
450
+ drainQueue();
451
+ // --- Update pump: fold notifications into the live turn, stop closes it ---
452
+ void (async () => {
453
+ for (;;) {
454
+ let msg;
455
+ try {
456
+ if (replayMode) {
457
+ // Wait for the load response before draining: only then is the
458
+ // whole replay backlog guaranteed to be queued, making the quiet
459
+ // timeout a reliable end-of-replay signal.
460
+ if (!loadSettled) {
461
+ await sleep(REPLAY_QUIET_MS);
462
+ continue;
463
+ }
464
+ msg = await Promise.race([activeSession.nextUpdate(), sleep(REPLAY_QUIET_MS)]);
465
+ if (msg === null) {
466
+ // Backlog drained — flush the folded history as static entries.
467
+ replayMode = false;
468
+ if (replayTurn) {
469
+ entries = [...entries, ...finishTurn(replayTurn)];
470
+ replayTurn = null;
471
+ }
472
+ rerender();
473
+ continue;
474
+ }
475
+ }
476
+ else {
477
+ msg = await activeSession.nextUpdate();
478
+ }
479
+ }
480
+ catch {
481
+ // A `/sessions` resume disposes the previous ActiveSession, which
482
+ // fails this pending read — re-arm on the (possibly new) session
483
+ // instead of killing the pump and orphaning every later update.
484
+ await sleep(REPLAY_QUIET_MS * 2);
485
+ continue;
486
+ }
487
+ if (msg.kind === "stop") {
488
+ promptInFlight = false;
489
+ entries = [...entries, ...finishTurn(turn ?? createTurnState(), msg.response.stopReason)];
490
+ turn = null;
491
+ turnActive = false;
492
+ drainQueue();
493
+ }
494
+ else {
495
+ // Status pushes (command menu, config selects) fold regardless of turn
496
+ // activity — the update that follows a `/model X` switch arrives while
497
+ // that slash turn is still open. Turn payloads and replayed history
498
+ // fold into their respective buffers.
499
+ const nextStatus = applyStatusUpdate(status, msg.update);
500
+ const statusChanged = nextStatus !== status;
501
+ status = nextStatus;
502
+ if (replayMode && replayTurn) {
503
+ // Fold silently: replayed history isn't rendered per message
504
+ // (thousands of full-frame renders froze input on big sessions).
505
+ // App shows one static "restoring history…" row instead; the drain
506
+ // below paints the whole transcript once.
507
+ replayTurn = applyUpdate(replayTurn, msg.update);
508
+ }
509
+ else if (turnActive) {
510
+ turn = applyUpdate(turn ?? createTurnState(), msg.update);
511
+ rerender();
512
+ }
513
+ else if (statusChanged) {
514
+ rerender();
515
+ }
516
+ }
517
+ }
518
+ })();
519
+ async function onSubmit(text, viaQueue = false) {
520
+ // Every submit is history (slash commands included, verbatim) — recall
521
+ // exists precisely for command incantations. Recorded ONCE, at first
522
+ // submission: the queue drain re-enters through here (viaQueue) and its
523
+ // entries were already recorded when they were queued.
524
+ if (!viaQueue) {
525
+ // Keep memory in step with the file: saveHistory trims to the newest
526
+ // HISTORY_MAX entries on disk; slice here so a long-lived session's
527
+ // in-memory array cannot outgrow the same bound.
528
+ history = pushHistory(history, text).slice(-HISTORY_MAX);
529
+ saveHistory(historyFile, history);
530
+ historyIdx = -1;
531
+ historyDraft = null;
532
+ }
533
+ const cmd = parseCommand(text);
534
+ if (cmd === "exit") {
535
+ cleanup(0);
536
+ return;
537
+ }
538
+ if (cmd === "sessions") {
539
+ entries = [...entries, { kind: "user", text }];
540
+ void openSessionPicker();
541
+ return;
542
+ }
543
+ if (cmd === "new") {
544
+ void startFreshSession();
545
+ return;
546
+ }
547
+ entries = [...entries, { kind: "user", text }];
548
+ // REPL-local commands (help / arg-less listing forms) render here and
549
+ // never reach the bridge; everything else is a prompt (slash interception
550
+ // lives bridge-side, same path editors use).
551
+ const local = handleLocalCommand(text, status);
552
+ if (local) {
553
+ entries = [...entries, ...local];
554
+ rerender();
555
+ return;
556
+ }
557
+ if (!activeSession || turnActive) {
558
+ // Follow-ups while a turn is running (or the session is still starting)
559
+ // are queued; the stop handler drains them one at a time. Resetting the
560
+ // live turn here would discard its entries mid-stream.
561
+ promptQueue.push(text);
562
+ rerender();
563
+ return;
564
+ }
565
+ await startTurn(text);
566
+ }
567
+ async function startTurn(text) {
568
+ turn = createTurnState();
569
+ turnActive = true;
570
+ promptInFlight = true;
571
+ rerender();
572
+ try {
573
+ await activeSession.prompt(text);
574
+ }
575
+ catch (err) {
576
+ // The pump's stop message still flushes the turn; surface the error too.
577
+ // The queue is NOT drained here: racing a queued prompt against the
578
+ // pending stop would close that next turn prematurely.
579
+ entries = [
580
+ ...entries,
581
+ {
582
+ kind: "note",
583
+ text: `prompt failed: ${err instanceof Error ? err.message : String(err)}`,
584
+ },
585
+ ];
586
+ turn = null;
587
+ turnActive = false;
588
+ promptInFlight = false;
589
+ rerender();
590
+ }
591
+ }
592
+ /** Start the next queued prompt after a turn ends; go idle when empty. */
593
+ function drainQueue() {
594
+ const next = promptQueue.shift();
595
+ if (next === undefined) {
596
+ turnActive = false;
597
+ rerender();
598
+ return;
599
+ }
600
+ // Route through onSubmit, not startTurn directly: queued entries still
601
+ // go through command parsing (a queued "/help" must render locally,
602
+ // "/exit" must exit — never reach the bridge as a literal prompt).
603
+ void onSubmit(next, true);
604
+ }
605
+ function onCancelTurn() {
606
+ if (!activeSession || !turn)
607
+ return;
608
+ try {
609
+ void cx.notify("session/cancel", { sessionId: activeSession.sessionId });
610
+ }
611
+ catch {
612
+ // best-effort; a second ctrl-c exits outright
613
+ }
614
+ }
615
+ /**
616
+ * `/sessions`: list this project's sessions via ACP `session/list` (the
617
+ * backend filters by workspace), let the user pick one interactively, then
618
+ * resume it with `session/load` (full history replayed into the transcript).
619
+ */
620
+ async function openSessionPicker() {
621
+ if (turnActive) {
622
+ entries = [
623
+ ...entries,
624
+ { kind: "note", text: "a turn is running — try /sessions when it finishes" },
625
+ ];
626
+ rerender();
627
+ return;
628
+ }
629
+ let list;
630
+ try {
631
+ list = (await cx.request("session/list", { cwd: process.cwd() }));
632
+ }
633
+ catch (err) {
634
+ entries = [
635
+ ...entries,
636
+ {
637
+ kind: "note",
638
+ text: `failed to list sessions: ${err instanceof Error ? err.message : String(err)}`,
639
+ },
640
+ ];
641
+ rerender();
642
+ return;
643
+ }
644
+ const items = (list.sessions ?? []).map((s) => ({
645
+ sessionId: s.sessionId,
646
+ cwd: s.cwd ?? "",
647
+ title: s.title ?? null,
648
+ updatedAt: s.updatedAt ?? null,
649
+ }));
650
+ if (items.length === 0) {
651
+ entries = [...entries, { kind: "note", text: "no previous sessions in this project yet" }];
652
+ rerender();
653
+ return;
654
+ }
655
+ const sid = await new Promise((resolve) => {
656
+ sessionPickResolver = resolve;
657
+ sessionPick = { items };
658
+ rerender();
659
+ });
660
+ sessionPick = null;
661
+ sessionPickResolver = null;
662
+ rerender();
663
+ const picked = items.find((s) => s.sessionId === sid);
664
+ if (sid && picked) {
665
+ resumedDuringStartup = true;
666
+ await resumeInto(picked);
667
+ }
668
+ }
669
+ /**
670
+ * Swap the live session for a loaded one. The bridge streams the whole
671
+ * conversation back as `session/update` notifications before resolving the
672
+ * `session/load` request, so an ActiveSession must be attached BEFORE the
673
+ * request goes out or every replayed message is dropped by the SDK's
674
+ * update router. `attachSession` is @internal in the typings but stable at
675
+ * runtime — the only client-side path from a raw sessionId to update
676
+ * routing (`buildSession().start()` only covers session/new).
677
+ */
678
+ async function resumeInto(picked) {
679
+ const gen = ++swapGen;
680
+ const loaded = cx.attachSession({ sessionId: picked.sessionId });
681
+ activeSession?.dispose();
682
+ activeSession = loaded;
683
+ replayTurn = createTurnState();
684
+ replayMode = true;
685
+ loadSettled = false;
686
+ // Paint the restoring-history hint BEFORE the (long) load round-trip.
687
+ rerender();
688
+ let resumeMeta = null;
689
+ try {
690
+ const resp = (await cx.request("session/load", {
691
+ sessionId: picked.sessionId,
692
+ cwd: process.cwd(),
693
+ mcpServers: [],
694
+ // Tail replay (ADR-0003): bounded recent history instead of the full
695
+ // transcript — the REPL scrollback doesn't need 10k lines at once.
696
+ _meta: { zcode: { limit: RESUME_TAIL_LIMIT } },
697
+ }));
698
+ if (gen !== swapGen)
699
+ return; // another swap went first — this load is stale
700
+ status = seedStatusFromNewSession(status, resp);
701
+ resumeMeta = resp.replayMeta ?? null;
702
+ }
703
+ catch (err) {
704
+ if (gen !== swapGen)
705
+ return; // stale — another swap owns the state now
706
+ replayMode = false;
707
+ replayTurn = null;
708
+ entries = [
709
+ ...entries,
710
+ {
711
+ kind: "note",
712
+ text: `resume failed: ${err instanceof Error ? err.message : String(err)}`,
713
+ },
714
+ ];
715
+ rerender();
716
+ return;
717
+ }
718
+ finally {
719
+ loadSettled = true;
720
+ }
721
+ const title = picked.title?.trim() || picked.sessionId.slice(0, 8);
722
+ const m = resumeMeta;
723
+ const truncated = m?.hasMore && typeof m.replayedMessages === "number" && typeof m.totalMessages === "number"
724
+ ? ` — showing last ${m.replayedMessages} of ${m.totalMessages} messages`
725
+ : "";
726
+ entries = [
727
+ ...entries,
728
+ { kind: "note", text: `resumed "${title}"${truncated || " — history restored above"}` },
729
+ ];
730
+ rerender();
731
+ }
732
+ /** Fresh welcome panel entry; reused by startup and `/new`. */
733
+ function welcomeEntry() {
734
+ return {
735
+ kind: "welcome",
736
+ info: {
737
+ version: AGENT_INFO.version,
738
+ cwd: process.cwd(),
739
+ model: selectLabel(status.model),
740
+ mode: selectLabel(status.mode),
741
+ thought: selectLabel(status.thought),
742
+ },
743
+ };
744
+ }
745
+ /**
746
+ * `/new` (ADR-0010): swap the live session for a fresh backend-created one,
747
+ * strictly client-side — routing the command to the backend's slash
748
+ * interception would rotate the session id out from under the update pump.
749
+ * Reuses the session/new bootstrap; the swap refuses while a turn runs or
750
+ * startup is still in flight (same preempt discipline as /sessions).
751
+ */
752
+ async function startFreshSession() {
753
+ if (busy || turnActive) {
754
+ entries = [
755
+ ...entries,
756
+ { kind: "user", text: "/new" },
757
+ {
758
+ kind: "note",
759
+ text: turnActive
760
+ ? "a turn is running — esc interrupts it first"
761
+ : "still starting up — try /new again in a moment",
762
+ },
763
+ ];
764
+ rerender();
765
+ return;
766
+ }
767
+ const gen = ++swapGen;
768
+ try {
769
+ const fresh = await cx.buildSession(process.cwd()).start();
770
+ if (gen !== swapGen) {
771
+ fresh.dispose(); // another swap went first — this placeholder is junk
772
+ return;
773
+ }
774
+ if (turnActive) {
775
+ // A turn raced into the old session during the session/new roundtrip
776
+ // (a local submit or a remote client). Swapping now would orphan it:
777
+ // disposing the session takes its update pump down and the
778
+ // completion event is filtered by session id, so turnActive would
779
+ // stick forever and wedge the REPL. Keep the current session.
780
+ fresh.dispose();
781
+ entries = [
782
+ ...entries,
783
+ { kind: "user", text: "/new" },
784
+ {
785
+ kind: "note",
786
+ text: "a turn started while /new was swapping — staying in the current session",
787
+ },
788
+ ];
789
+ rerender();
790
+ return;
791
+ }
792
+ activeSession?.dispose();
793
+ activeSession = fresh;
794
+ status = seedStatusFromNewSession(status, fresh.newSessionResponse);
795
+ // Any in-flight /sessions replay is dead: its pump read fails into the
796
+ // catch/re-arm path, and its stale continuations bail on swapGen.
797
+ replayMode = false;
798
+ replayTurn = null;
799
+ loadSettled = true;
800
+ // Native-scrollback model: entries are APPEND-ONLY — ink <Static> has
801
+ // already printed everything before its print cursor, so shrinking the
802
+ // array would silently drop the divider. The old conversation stays in
803
+ // the terminal's own history (that's the model's whole point).
804
+ entries = [
805
+ ...entries,
806
+ { kind: "user", text: "/new" },
807
+ {
808
+ kind: "note",
809
+ text: "── new session started — the previous conversation stays in /sessions ──",
810
+ },
811
+ ];
812
+ editor = createLineEditor();
813
+ rerender();
814
+ }
815
+ catch (err) {
816
+ entries = [
817
+ ...entries,
818
+ { kind: "user", text: "/new" },
819
+ {
820
+ kind: "note",
821
+ text: `failed to start a new session: ${err instanceof Error ? err.message : String(err)}`,
822
+ },
823
+ ];
824
+ rerender();
825
+ }
826
+ }
827
+ // Terminal resize: the dynamic footer re-wraps at the new width via a
828
+ // plain rerender. Already-printed scrollback keeps its old wrapping —
829
+ // that's exactly how native-history CLIs (Claude Code et al.) behave.
830
+ const onResize = () => {
831
+ if (exited)
832
+ return;
833
+ rerender();
834
+ };
835
+ if (process.stdout.isTTY)
836
+ process.stdout.on("resize", onResize);
837
+ function cleanup(code) {
838
+ if (exited)
839
+ return;
840
+ exited = true;
841
+ try {
842
+ if (quotaTimer !== null)
843
+ clearInterval(quotaTimer);
844
+ quotaTimer = null;
845
+ }
846
+ catch {
847
+ // ignore
848
+ }
849
+ try {
850
+ process.stdout.off("resize", onResize);
851
+ }
852
+ catch {
853
+ // ignore
854
+ }
855
+ // Each step is guarded: one throwing (e.g. unmount during a render) must
856
+ // not skip the child shutdown and leave the process hanging.
857
+ try {
858
+ activeSession?.dispose();
859
+ }
860
+ catch {
861
+ // ignore
862
+ }
863
+ try {
864
+ ink.unmount();
865
+ }
866
+ catch {
867
+ // ignore
868
+ }
869
+ try {
870
+ // Graceful path: closing stdin triggers the bridge's own shutdown,
871
+ // which reaps the zcode backend group.
872
+ child.stdin?.end();
873
+ }
874
+ catch {
875
+ // ignore
876
+ }
877
+ setTimeout(() => child.kill("SIGKILL"), 3000).unref();
878
+ // Hard fallback: if the bridge ignores stdin-close shutdown (or the exit
879
+ // event is lost), the REPL still exits instead of hanging forever.
880
+ setTimeout(() => process.exit(code), 5000);
881
+ child.once("exit", () => {
882
+ if (code !== 0 && stderrTail.trim()) {
883
+ process.stderr.write(`\nbridge stderr tail:\n${stderrTail}\n`);
884
+ }
885
+ process.exit(code);
886
+ });
887
+ }
888
+ // Bridge died under us (crash, backend failure) — report and exit hard.
889
+ child.once("exit", (code) => {
890
+ if (exited)
891
+ return;
892
+ exited = true;
893
+ ink.unmount();
894
+ process.stderr.write(`bridge exited unexpectedly (code ${code})\n${stderrTail.trim() ? `stderr tail:\n${stderrTail}` : ""}\n`);
895
+ process.exit(1);
896
+ });
897
+ // Welcome panel as the first transcript entry — branding, session info,
898
+ // seeded config, and key hints; pushed into scrollback by the first prompt.
899
+ entries = [welcomeEntry()];
900
+ rerender();
901
+ }
902
+ //# sourceMappingURL=run.js.map