zcode-acp-server 0.21.4 → 0.23.0

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 (70) hide show
  1. package/README.md +6 -7
  2. package/dist/cli.d.ts +8 -5
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +22 -14
  5. package/dist/cli.js.map +1 -1
  6. package/dist/config/user-config.d.ts +48 -0
  7. package/dist/config/user-config.d.ts.map +1 -0
  8. package/dist/config/user-config.js +101 -0
  9. package/dist/config/user-config.js.map +1 -0
  10. package/dist/handlers/session.d.ts +33 -3
  11. package/dist/handlers/session.d.ts.map +1 -1
  12. package/dist/handlers/session.js +152 -9
  13. package/dist/handlers/session.js.map +1 -1
  14. package/dist/i18n.d.ts +2 -0
  15. package/dist/i18n.d.ts.map +1 -1
  16. package/dist/i18n.js +2 -0
  17. package/dist/i18n.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/remote/config.d.ts +36 -24
  22. package/dist/remote/config.d.ts.map +1 -1
  23. package/dist/remote/config.js +70 -36
  24. package/dist/remote/config.js.map +1 -1
  25. package/dist/remote/hub-server.d.ts +24 -13
  26. package/dist/remote/hub-server.d.ts.map +1 -1
  27. package/dist/remote/hub-server.js +78 -63
  28. package/dist/remote/hub-server.js.map +1 -1
  29. package/dist/server.d.ts +22 -0
  30. package/dist/server.d.ts.map +1 -1
  31. package/dist/server.js +25 -0
  32. package/dist/server.js.map +1 -1
  33. package/dist/tui.d.ts +38 -0
  34. package/dist/tui.d.ts.map +1 -0
  35. package/dist/tui.js +96 -0
  36. package/dist/tui.js.map +1 -0
  37. package/dist/utils.d.ts +8 -0
  38. package/dist/utils.d.ts.map +1 -1
  39. package/dist/utils.js +10 -0
  40. package/dist/utils.js.map +1 -1
  41. package/docs/CLI.md +33 -76
  42. package/docs/REMOTE-CLIENTS.md +26 -20
  43. package/docs/REMOTE.md +62 -23
  44. package/docs/TROUBLESHOOTING.md +5 -1
  45. package/docs/adr/0007-unified-cli-entry-and-bin-pruning.md +2 -0
  46. package/package.json +3 -7
  47. package/dist/repl/App.d.ts +0 -108
  48. package/dist/repl/App.d.ts.map +0 -1
  49. package/dist/repl/App.js +0 -732
  50. package/dist/repl/App.js.map +0 -1
  51. package/dist/repl/history.d.ts +0 -25
  52. package/dist/repl/history.d.ts.map +0 -1
  53. package/dist/repl/history.js +0 -73
  54. package/dist/repl/history.js.map +0 -1
  55. package/dist/repl/hub.d.ts +0 -87
  56. package/dist/repl/hub.d.ts.map +0 -1
  57. package/dist/repl/hub.js +0 -188
  58. package/dist/repl/hub.js.map +0 -1
  59. package/dist/repl/input-buffer.d.ts +0 -77
  60. package/dist/repl/input-buffer.d.ts.map +0 -1
  61. package/dist/repl/input-buffer.js +0 -174
  62. package/dist/repl/input-buffer.js.map +0 -1
  63. package/dist/repl/model.d.ts +0 -312
  64. package/dist/repl/model.d.ts.map +0 -1
  65. package/dist/repl/model.js +0 -674
  66. package/dist/repl/model.js.map +0 -1
  67. package/dist/repl/run.d.ts +0 -12
  68. package/dist/repl/run.d.ts.map +0 -1
  69. package/dist/repl/run.js +0 -1245
  70. package/dist/repl/run.js.map +0 -1
package/dist/repl/run.js DELETED
@@ -1,1245 +0,0 @@
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 { sandboxActive } from "../backend/sandbox.js";
21
- import { AGENT_INFO, warn } from "../utils.js";
22
- import { App, } from "./App.js";
23
- import { applyStatusUpdate, applyUpdate, createReplStatus, createTurnState, finishTurn, formatQuotaLine, handleLocalCommand, parseCommand, parseQuestionForm, planTextFromRawInput, seedStatusFromNewSession, selectLabel, } from "./model.js";
24
- import { HISTORY_MAX, historyPath, loadHistory, pushHistory, saveHistory } from "./history.js";
25
- import { fetchInstances, openHubSocket, resolveHubClient, sameWorkspace, } from "./hub.js";
26
- import { createLineEditor, replaceText } from "./input-buffer.js";
27
- export async function runRepl() {
28
- // Crash containment: an unexpected throw is SURFACED, never fatal — it
29
- // prints to stderr (stack included), lands as a dim note in the
30
- // transcript, and the REPL keeps running. A circuit breaker is the only
31
- // exit: if errors keep firing within the window the render tree itself is
32
- // almost certainly broken and every frame would throw again — shutting
33
- // down then beats a spinning error loop. Rejected promises are always
34
- // log-only; the SDK resolves raced/aborted request promises late by
35
- // design, and those settle as rejections we deliberately ignore.
36
- const CRASH_WINDOW_MS = 10_000;
37
- const CRASH_LIMIT = 5;
38
- let crashTimes = [];
39
- let repaintQueued = false;
40
- process.on("uncaughtException", (err) => {
41
- const now = Date.now();
42
- crashTimes = crashTimes.filter((t) => now - t < CRASH_WINDOW_MS);
43
- crashTimes.push(now);
44
- const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
45
- warn(`repl absorbed an error (${crashTimes.length}/${CRASH_LIMIT} recent): ${detail}`);
46
- if (crashTimes.length >= CRASH_LIMIT) {
47
- warn("repl: too many errors in a row — UI is likely broken, shutting down");
48
- // Unmount so ink's stdin cleanup effects run (raw mode, bracketed
49
- // paste): process.exit alone would leave the tty wedged. The tree may
50
- // be exactly what's broken — guard the unmount.
51
- try {
52
- ink.unmount();
53
- }
54
- catch {
55
- // ignore
56
- }
57
- try {
58
- child.stdin?.end();
59
- }
60
- catch {
61
- // ignore
62
- }
63
- setTimeout(() => {
64
- try {
65
- child.kill("SIGKILL");
66
- }
67
- catch {
68
- // ignore
69
- }
70
- process.exit(1);
71
- }, 1500).unref();
72
- return;
73
- }
74
- const msg = (err instanceof Error ? err.message : String(err)).split("\n")[0] || "unknown error";
75
- entries = [...entries, { kind: "note", text: `error absorbed: ${msg}` }];
76
- // Repaint on a later tick: if the throw happened INSIDE a rerender,
77
- // re-entering ink synchronously from this handler would just rethrow.
78
- if (!repaintQueued && !exited) {
79
- repaintQueued = true;
80
- setTimeout(() => {
81
- repaintQueued = false;
82
- rerender();
83
- }, 50);
84
- }
85
- });
86
- process.on("unhandledRejection", (reason) => {
87
- warn(`repl ignored an async failure: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`);
88
- });
89
- const serverJs = fileURLToPath(new URL("../index.js", import.meta.url));
90
- const child = spawn(process.execPath, [serverJs], {
91
- stdio: ["pipe", "pipe", "pipe"],
92
- });
93
- // Bridge stderr (gated debug logs + warns) never interleaves with the Ink
94
- // UI; keep a tail so a bridge crash can be explained on exit.
95
- let stderrTail = "";
96
- child.stderr?.setEncoding("utf8");
97
- child.stderr?.on("data", (d) => {
98
- stderrTail = (stderrTail + d).slice(-2000);
99
- });
100
- // --- UI state (external store; every mutation rerenders a fresh snapshot) ---
101
- let entries = [];
102
- let turn = null;
103
- let permission = null;
104
- let question = null;
105
- // Non-null while the /sessions picker is open (run-side holds the list; the
106
- // App owns the row index).
107
- let sessionPick = null;
108
- let status = createReplStatus();
109
- let busy = true;
110
- let exited = false;
111
- // True between a user submit and the stop message. The bridge also pushes
112
- // idle-time updates right after session/new (available_commands, mode
113
- // state, …) — without this gate those would spin up a phantom turn and
114
- // show the "running" spinner with nothing in flight.
115
- let turnActive = false;
116
- // True while a prompt WE sent is in flight (its stop message closes the
117
- // turn). Remote-initiated turns (mobile/second client) never deliver a stop
118
- // here — they are closed by `$/zcode/turnState running:false` instead.
119
- let promptInFlight = false;
120
- // True between a `/sessions` resume and the drained history backlog: during
121
- // this window streamed updates fold into static entries (see the pump).
122
- // `loadSettled` gates the DRAIN: the bridge streams replay notifications
123
- // while the load request is still in flight, so consuming early would let a
124
- // >10ms gap between two replayed messages falsely end replay mode.
125
- let replayMode = false;
126
- // The live ACP session. Starts as the fresh session/new placeholder; a
127
- // `/sessions` resume swaps it for an attached load of the picked backend
128
- // session (same pump, same update routing). Declared with the other
129
- // state, NOT at its bootstrap assignment below: /sessions can run while
130
- // that await is still in flight, and assigning a `let` before its
131
- // declaration executes is a TDZ ReferenceError.
132
- let activeSession = null;
133
- let resumedDuringStartup = false;
134
- let loadSettled = true;
135
- let replayTurn = null;
136
- const REPLAY_QUIET_MS = 10;
137
- // Resume loads only the last N messages (turn-aligned, ADR-0003 tail
138
- // replay). Full replay of a huge session would flood the native
139
- // scrollback with thousands of lines in one burst — a bounded recent tail
140
- // gives context without the wall-of-text scroll jump.
141
- const RESUME_TAIL_LIMIT = 50;
142
- // Hub-proxied attach (ADR-0018): budget for the WebSocket open round-trip.
143
- const REMOTE_OPEN_TIMEOUT_MS = 5000;
144
- // Non-null while a hub-proxied remote attach is live: the raw socket pair
145
- // feeding the remote connection. Teardown is client-side only — the owning
146
- // serve bridge and its session are never touched.
147
- let remote = null;
148
- // ACP context of the connection that owns activeSession — the local bridge
149
- // (assigned once cx exists) or, while attached, the remote bridge.
150
- // Out-of-band calls (session/cancel) must reach the OWNING connection.
151
- let activeCx = null;
152
- // Hub discovery from env; null = no hub configured — the picker then works
153
- // exactly as before this feature existed.
154
- const hubRef = resolveHubClient();
155
- const sleep = (ms) => new Promise((resolve) => setTimeout(() => resolve(null), ms));
156
- // Prompts submitted while another turn runs (or before the session is
157
- // ready); drained one per stop, FIFO.
158
- const promptQueue = [];
159
- // Native-scrollback model: completed entries print once via ink <Static>
160
- // and become the terminal's own history (native smooth scroll, selection,
161
- // search — the Claude Code pattern). Only a compact dynamic footer below
162
- // (live-turn tail, queue panel, completion menu, input box) ever repaints.
163
- // --- prompt line editor (external store): the App re-renders from fresh
164
- // snapshots, so draft text must live out here to survive rerenders.
165
- let editor = createLineEditor();
166
- // --- prompt history (ADR-0008) ---
167
- // Entries load once at startup; `historyIdx` points at the recalled entry
168
- // (-1 = live draft). ↑ stashes the draft before walking back; ↓ past the
169
- // newest entry restores it. State out here so recall survives repaints.
170
- const historyFile = historyPath(process.cwd());
171
- let history = [];
172
- let historyIdx = -1;
173
- let historyDraft = null;
174
- try {
175
- history = loadHistory(historyFile);
176
- }
177
- catch {
178
- // loadHistory is already best-effort; this guard keeps even a throw from
179
- // its own write-back path from killing startup.
180
- }
181
- /** Recall one entry older; stashes the live draft on the first step. */
182
- function historyUp() {
183
- if (history.length === 0 || exited)
184
- return;
185
- if (historyIdx === -1) {
186
- historyDraft = editor.text;
187
- historyIdx = history.length - 1;
188
- }
189
- else if (historyIdx > 0) {
190
- historyIdx--;
191
- }
192
- else {
193
- return;
194
- }
195
- editor = replaceText(history[historyIdx]);
196
- rerender();
197
- }
198
- /** Walk one entry newer; past the newest, restore the stashed draft. */
199
- function historyDown() {
200
- if (historyIdx === -1 || exited)
201
- return;
202
- if (historyIdx < history.length - 1) {
203
- historyIdx++;
204
- editor = replaceText(history[historyIdx]);
205
- }
206
- else {
207
- historyIdx = -1;
208
- editor = replaceText(historyDraft ?? "");
209
- historyDraft = null;
210
- }
211
- rerender();
212
- }
213
- // --- completion-menu visibility mirror ---
214
- // InputLine publishes whether its menu is showing (plain var, no React
215
- // state); the app-level key handler reads it so an open menu takes the
216
- // first esc — dismissing it must not also interrupt a queued-up turn.
217
- let completionMenuOpen = false;
218
- // --- plan-quota indicator (prompt-line status suffix) ---
219
- // Fetched once at startup and refreshed every QUOTA_TTL_MS; null (hidden)
220
- // while the fetch runs or fails, so offline / auth-expired sessions just
221
- // lose the suffix instead of nagging. The full card stays available via
222
- // /quota. Failures are silent by design — warn() on every TTL tick would
223
- // spam long-lived sessions.
224
- let quotaLine = null;
225
- let quotaFetchId = 0;
226
- let quotaTimer = null;
227
- const QUOTA_TTL_MS = 10 * 60 * 1000;
228
- async function refreshQuota() {
229
- const id = ++quotaFetchId;
230
- try {
231
- const result = await queryQuota();
232
- if (exited || id !== quotaFetchId)
233
- return; // stale reply — a newer fetch owns the line
234
- const line = formatQuotaLine(result);
235
- if (line !== quotaLine) {
236
- quotaLine = line;
237
- rerender();
238
- }
239
- }
240
- catch {
241
- // best-effort indicator: leave whatever was last shown in place
242
- }
243
- }
244
- void refreshQuota();
245
- quotaTimer = setInterval(() => void refreshQuota(), QUOTA_TTL_MS);
246
- quotaTimer.unref();
247
- // Bracketed paste (?2004) is armed by ink's usePaste hook inside InputLine
248
- // and disarmed with its unmount — no manual terminal-mode management here.
249
- const renderOpts = { exitOnCtrlC: false };
250
- let ink = render(createElement(App, snapshot()), renderOpts);
251
- function snapshot() {
252
- return {
253
- entries,
254
- turn,
255
- permission,
256
- question,
257
- sessionPick,
258
- queued: [...promptQueue],
259
- status,
260
- busy,
261
- replaying: replayMode,
262
- editor,
263
- applyEdit: (op) => {
264
- if (exited)
265
- return;
266
- editor = op(editor);
267
- rerender();
268
- },
269
- quotaLine,
270
- isMenuOpen: () => completionMenuOpen,
271
- onMenuOpenChange: (open) => {
272
- completionMenuOpen = open;
273
- },
274
- onSubmit: (text) => void onSubmit(text),
275
- onCancelTurn,
276
- onAnswerPermission: (id) => permissionResolver?.(id),
277
- onAnswerQuestion: (answer) => {
278
- const resolve = questionResolver;
279
- questionResolver = null;
280
- question = null;
281
- rerender();
282
- resolve?.(answer);
283
- },
284
- onPickSession: (sid) => sessionPickResolver?.(sid),
285
- onHistoryUp: historyUp,
286
- onHistoryDown: historyDown,
287
- onExit: () => cleanup(0),
288
- };
289
- }
290
- function rerender() {
291
- if (!exited)
292
- ink.rerender(createElement(App, snapshot()));
293
- }
294
- // --- Permission bridging: agent request → picker → response ---
295
- let permissionResolver = null;
296
- // --- AskUserQuestion bridging: elicitation form → picker → response ---
297
- let questionResolver = null;
298
- function askQuestion(form, signal) {
299
- return new Promise((resolve) => {
300
- const onAbort = () => {
301
- questionResolver = null;
302
- question = null;
303
- rerender();
304
- resolve(null);
305
- };
306
- const settle = (answer) => {
307
- signal?.removeEventListener("abort", onAbort);
308
- resolve(answer);
309
- };
310
- signal?.addEventListener("abort", onAbort, { once: true });
311
- questionResolver = settle;
312
- question = {
313
- title: form.title,
314
- multiSelect: form.multiSelect,
315
- options: form.options,
316
- };
317
- rerender();
318
- });
319
- }
320
- // --- /sessions bridging: list → picker → resume ---
321
- let sessionPickResolver = null;
322
- // Bumped by every session swap; async continuations compare their captured
323
- // generation after each await and bail when another swap went first —
324
- // otherwise a slow session/load could reseed status into a FRESH session.
325
- let swapGen = 0;
326
- // --- ACP client connection ---
327
- const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
328
- // One ACP client connection with the REPL's full handler chain — used for
329
- // the local bridge AND, per attach, for hub-proxied remote bridges
330
- // (ADR-0018): both peers are plain ACP agents, and permission, elicitation,
331
- // and turn-state handling are identical.
332
- function buildConnection(acpStream) {
333
- const app = acp
334
- .client({ name: "zcode-acp" })
335
- .onRequest("session/request_permission", async (ctx) => {
336
- // A remote client (mobile/hub) may answer the broadcast request first;
337
- // the bridge then cancels this losing copy and the SDK aborts
338
- // ctx.signal — settle here so our picker dismisses instead of hanging.
339
- const id = await new Promise((resolve) => {
340
- const onAbort = () => {
341
- permissionResolver = null;
342
- permission = null;
343
- rerender();
344
- resolve(null);
345
- };
346
- ctx.signal.addEventListener("abort", onAbort, { once: true });
347
- permissionResolver = (v) => {
348
- ctx.signal.removeEventListener("abort", onAbort);
349
- resolve(v);
350
- };
351
- const options = ctx.params.options.map((o) => ({ id: o.optionId, name: o.name }));
352
- // ExitPlanMode arrives as an approve/reject permission pair (see
353
- // interaction/adapter.ts). The plan text rides toolCall.rawInput
354
- // ({plan}); the footer popup cannot scroll a long document, so print
355
- // the full plan into the transcript and keep the popup to a pointer.
356
- const isPlan = options.length === 2 && options.every((o) => o.id === "approve" || o.id === "reject");
357
- const raw = ctx.params.toolCall?.rawInput;
358
- let detail;
359
- if (isPlan) {
360
- const plan = planTextFromRawInput(raw);
361
- if (plan) {
362
- entries = [...entries, { kind: "plan", text: plan }];
363
- const firstLine = plan.split("\n").find((l) => l.trim()) ?? "";
364
- detail = `plan printed above — ${firstLine.trim().slice(0, 80)}`;
365
- }
366
- else {
367
- detail = "(no plan text in the request)";
368
- }
369
- }
370
- else {
371
- detail = typeof raw === "string" ? raw : undefined;
372
- }
373
- permission = {
374
- heading: isPlan ? "plan approval" : "permission requested",
375
- title: ctx.params.toolCall?.title ?? "tool call",
376
- detail,
377
- options,
378
- };
379
- rerender();
380
- });
381
- permissionResolver = null;
382
- permission = null;
383
- rerender();
384
- return id === null
385
- ? { outcome: { outcome: "cancelled" } }
386
- : { outcome: { outcome: "selected", optionId: id } };
387
- })
388
- .onRequest("elicitation/create", async (ctx) => {
389
- // AskUserQuestion arrives as the bridge's form elicitation. Each
390
- // question walks the picker sequentially; skipped questions omit
391
- // their key (the backend treats them as unanswered).
392
- const forms = parseQuestionForm(ctx.params);
393
- if (!forms)
394
- return { action: "decline", reason: "unsupported elicitation form" };
395
- const content = {};
396
- for (const form of forms) {
397
- const answer = await askQuestion(form, ctx.signal);
398
- // A remote client winning the broadcast aborts our request via
399
- // ctx.signal — stop answering instead of walking remaining questions.
400
- if (ctx.signal.aborted)
401
- return { action: "decline", reason: "cancelled" };
402
- if (answer === null)
403
- continue; // skipped
404
- if (form.multiSelect) {
405
- const merged = [...answer.picked, ...(answer.custom ? [answer.custom] : [])];
406
- if (merged.length > 0)
407
- content[form.key] = merged;
408
- }
409
- else {
410
- content[form.key] = answer.custom || answer.picked[0] || "";
411
- }
412
- }
413
- return { action: "accept", content };
414
- })
415
- // Out-of-band turn indicator broadcast by the bridge on every prompt start
416
- // and end. Drives turns the REPL did NOT start itself: a mobile/second
417
- // client prompting this session must render here too — without it those
418
- // updates would be silently dropped (the pump only folds updates while a
419
- // turn is active).
420
- .onNotification("$/zcode/turnState", z.object({ sessionId: z.string(), running: z.boolean() }), (ctx) => {
421
- if (!activeSession || ctx.params.sessionId !== activeSession.sessionId)
422
- return;
423
- if (ctx.params.running) {
424
- // Local prompts already opened the turn in startTurn; idempotent.
425
- if (!turnActive && !replayMode) {
426
- turn = createTurnState();
427
- turnActive = true;
428
- rerender();
429
- }
430
- }
431
- else if (turnActive && !promptInFlight) {
432
- // Remote turn finished (no stop message reaches non-initiators).
433
- entries = [...entries, ...finishTurn(turn ?? createTurnState())];
434
- turn = null;
435
- turnActive = false;
436
- drainQueue();
437
- rerender();
438
- }
439
- })
440
- .connect(acpStream);
441
- return app;
442
- }
443
- const app = buildConnection(stream);
444
- const cx = app.agent;
445
- // Advertise form-elicitation so AskUserQuestion routes here as a
446
- // structured form instead of permission-popups labeled "permission
447
- // requested". The SDK never sends a protocol-level initialize on the
448
- // client's behalf, so this request() IS the handshake (the bridge rejects
449
- // any earlier non-initialize message). Sent via the generic request() —
450
- // the typed helper lives on the Agent interface, not the ClientContext we
451
- // hold.
452
- await cx.request("initialize", {
453
- protocolVersion: 1,
454
- clientCapabilities: {
455
- fs: { readTextFile: false, writeTextFile: false },
456
- elicitation: { form: {} },
457
- },
458
- clientInfo: { name: "zcode-acp", title: "zcode-acp REPL", version: AGENT_INFO.version },
459
- });
460
- // Hub-incubated session resume (ADR-0017): the .command script exports the
461
- // requested backend session id, so a remotely resumed conversation boots
462
- // straight into it instead of a fresh prompt line. Empty when started by
463
- // hand — plain local runs never see the variable.
464
- const bootResumeSid = (process.env.ZCODE_ACP_RESUME_SESSION ?? "").trim();
465
- try {
466
- if (bootResumeSid) {
467
- // Boot directly into the requested session; on a failed load (store
468
- // entry gone, foreign workspace refused by serve mode) fall back to a
469
- // fresh one so the window still lands on a usable prompt line — the
470
- // failure note stays visible above.
471
- const resumed = await resumeInto({
472
- sessionId: bootResumeSid,
473
- cwd: process.cwd(),
474
- title: null,
475
- updatedAt: null,
476
- });
477
- if (resumed === "failed") {
478
- const fresh = await cx.buildSession(process.cwd()).start();
479
- activeSession = fresh;
480
- status = seedStatusFromNewSession(status, fresh.newSessionResponse);
481
- }
482
- // "superseded" (the user raced a /sessions pick during this load)
483
- // leaves the state to whichever swap went last — no fallback.
484
- }
485
- else {
486
- const fresh = await cx.buildSession(process.cwd()).start();
487
- if (resumedDuringStartup) {
488
- // The user picked a session from /sessions while this cold-start
489
- // round-trip was still in flight — the fresh placeholder is already
490
- // obsolete and its default status must not overwrite the resumed
491
- // session's own seeded selects. Discard it, keep what they expect.
492
- fresh.dispose();
493
- }
494
- else {
495
- activeSession = fresh;
496
- // The initial config options ride the session/new response body, not a
497
- // notification — seed the status from it; later switches push updates.
498
- status = seedStatusFromNewSession(status, activeSession.newSessionResponse);
499
- }
500
- }
501
- }
502
- catch (err) {
503
- entries = [
504
- ...entries,
505
- {
506
- kind: "note",
507
- text: `failed to start session: ${err instanceof Error ? err.message : String(err)}`,
508
- },
509
- ];
510
- rerender();
511
- cleanup(1);
512
- return;
513
- }
514
- busy = false;
515
- drainQueue();
516
- // --- Update pump: fold notifications into the live turn, stop closes it ---
517
- void (async () => {
518
- for (;;) {
519
- let msg;
520
- try {
521
- if (replayMode) {
522
- // Wait for the load response before draining: only then is the
523
- // whole replay backlog guaranteed to be queued, making the quiet
524
- // timeout a reliable end-of-replay signal.
525
- if (!loadSettled) {
526
- await sleep(REPLAY_QUIET_MS);
527
- continue;
528
- }
529
- msg = await Promise.race([activeSession.nextUpdate(), sleep(REPLAY_QUIET_MS)]);
530
- if (msg === null) {
531
- // Backlog drained — flush the folded history as static entries.
532
- replayMode = false;
533
- if (replayTurn) {
534
- entries = [...entries, ...finishTurn(replayTurn)];
535
- replayTurn = null;
536
- }
537
- rerender();
538
- continue;
539
- }
540
- }
541
- else {
542
- msg = await activeSession.nextUpdate();
543
- }
544
- }
545
- catch {
546
- // A `/sessions` resume disposes the previous ActiveSession, which
547
- // fails this pending read — re-arm on the (possibly new) session
548
- // instead of killing the pump and orphaning every later update.
549
- await sleep(REPLAY_QUIET_MS * 2);
550
- continue;
551
- }
552
- if (msg.kind === "stop") {
553
- promptInFlight = false;
554
- entries = [...entries, ...finishTurn(turn ?? createTurnState(), msg.response.stopReason)];
555
- turn = null;
556
- turnActive = false;
557
- drainQueue();
558
- }
559
- else {
560
- // Status pushes (command menu, config selects) fold regardless of turn
561
- // activity — the update that follows a `/model X` switch arrives while
562
- // that slash turn is still open. Turn payloads and replayed history
563
- // fold into their respective buffers.
564
- const nextStatus = applyStatusUpdate(status, msg.update);
565
- const statusChanged = nextStatus !== status;
566
- status = nextStatus;
567
- if (replayMode && replayTurn) {
568
- // Fold silently: replayed history isn't rendered per message
569
- // (thousands of full-frame renders froze input on big sessions).
570
- // App shows one static "restoring history…" row instead; the drain
571
- // below paints the whole transcript once.
572
- replayTurn = applyUpdate(replayTurn, msg.update);
573
- }
574
- else if (turnActive) {
575
- turn = applyUpdate(turn ?? createTurnState(), msg.update);
576
- rerender();
577
- }
578
- else if (msg.update.sessionUpdate === "user_message_chunk") {
579
- // A remote-initiated prompt's echo lands BEFORE the turnState
580
- // running notification (separate frames through the hub): open the
581
- // turn here so the prompt text renders instead of being dropped.
582
- // The running:true that follows is idempotent; running:false closes
583
- // this turn exactly like any remote turn.
584
- turn = applyUpdate(createTurnState(), msg.update);
585
- turnActive = true;
586
- rerender();
587
- }
588
- else if (statusChanged) {
589
- rerender();
590
- }
591
- }
592
- }
593
- })();
594
- async function onSubmit(text, viaQueue = false) {
595
- // Every submit is history (slash commands included, verbatim) — recall
596
- // exists precisely for command incantations. Recorded ONCE, at first
597
- // submission: the queue drain re-enters through here (viaQueue) and its
598
- // entries were already recorded when they were queued.
599
- if (!viaQueue) {
600
- // Keep memory in step with the file: saveHistory trims to the newest
601
- // HISTORY_MAX entries on disk; slice here so a long-lived session's
602
- // in-memory array cannot outgrow the same bound.
603
- history = pushHistory(history, text).slice(-HISTORY_MAX);
604
- saveHistory(historyFile, history);
605
- historyIdx = -1;
606
- historyDraft = null;
607
- }
608
- const cmd = parseCommand(text);
609
- if (cmd === "exit") {
610
- cleanup(0);
611
- return;
612
- }
613
- if (cmd === "sessions") {
614
- entries = [...entries, { kind: "user", text }];
615
- void openSessionPicker();
616
- return;
617
- }
618
- if (cmd === "new") {
619
- void startFreshSession();
620
- return;
621
- }
622
- entries = [...entries, { kind: "user", text }];
623
- // REPL-local commands (help / arg-less listing forms) render here and
624
- // never reach the bridge; everything else is a prompt (slash interception
625
- // lives bridge-side, same path editors use).
626
- const local = handleLocalCommand(text, status);
627
- if (local) {
628
- entries = [...entries, ...local];
629
- rerender();
630
- return;
631
- }
632
- if (!activeSession || turnActive) {
633
- // Follow-ups while a turn is running (or the session is still starting)
634
- // are queued; the stop handler drains them one at a time. Resetting the
635
- // live turn here would discard its entries mid-stream.
636
- promptQueue.push(text);
637
- rerender();
638
- return;
639
- }
640
- await startTurn(text);
641
- }
642
- async function startTurn(text) {
643
- turn = createTurnState();
644
- turnActive = true;
645
- promptInFlight = true;
646
- rerender();
647
- try {
648
- await activeSession.prompt(text);
649
- }
650
- catch (err) {
651
- // The pump's stop message still flushes the turn; surface the error too.
652
- // The queue is NOT drained here: racing a queued prompt against the
653
- // pending stop would close that next turn prematurely.
654
- entries = [
655
- ...entries,
656
- {
657
- kind: "note",
658
- text: `prompt failed: ${err instanceof Error ? err.message : String(err)}`,
659
- },
660
- ];
661
- turn = null;
662
- turnActive = false;
663
- promptInFlight = false;
664
- rerender();
665
- }
666
- }
667
- /** Start the next queued prompt after a turn ends; go idle when empty. */
668
- function drainQueue() {
669
- const next = promptQueue.shift();
670
- if (next === undefined) {
671
- turnActive = false;
672
- rerender();
673
- return;
674
- }
675
- // Route through onSubmit, not startTurn directly: queued entries still
676
- // go through command parsing (a queued "/help" must render locally,
677
- // "/exit" must exit — never reach the bridge as a literal prompt).
678
- void onSubmit(next, true);
679
- }
680
- function onCancelTurn() {
681
- if (!activeSession || !turn)
682
- return;
683
- try {
684
- // The connection that OWNS the active session — the local bridge, or
685
- // the hub-proxied remote bridge while attached (ADR-0018).
686
- void (activeCx ?? cx).notify("session/cancel", { sessionId: activeSession.sessionId });
687
- }
688
- catch {
689
- // best-effort; a second ctrl-c exits outright
690
- }
691
- }
692
- /**
693
- * `/sessions`: list this project's sessions via ACP `session/list` (the
694
- * backend filters by workspace), merge in sessions currently live on hub
695
- * instances (ADR-0018, marked `live`), let the user pick one interactively,
696
- * then attach: through the hub when the session is live somewhere (live
697
- * updates), otherwise a local `session/load` resume (tail replay).
698
- */
699
- async function openSessionPicker() {
700
- if (turnActive) {
701
- entries = [
702
- ...entries,
703
- { kind: "note", text: "a turn is running — try /sessions when it finishes" },
704
- ];
705
- rerender();
706
- return;
707
- }
708
- let list;
709
- try {
710
- list = (await cx.request("session/list", { cwd: process.cwd() }));
711
- }
712
- catch (err) {
713
- entries = [
714
- ...entries,
715
- {
716
- kind: "note",
717
- text: `failed to list sessions: ${err instanceof Error ? err.message : String(err)}`,
718
- },
719
- ];
720
- rerender();
721
- return;
722
- }
723
- const items = (list.sessions ?? []).map((s) => ({
724
- sessionId: s.sessionId,
725
- cwd: s.cwd ?? "",
726
- title: s.title ?? null,
727
- updatedAt: s.updatedAt ?? null,
728
- }));
729
- // Live view (ADR-0018): merge sessions currently registered on hub
730
- // instances for this project. Best-effort — no hub configured, hub down,
731
- // or auth trouble just means no `live` markers and plain local resumes,
732
- // exactly like before the feature existed.
733
- const liveOn = new Map();
734
- if (hubRef) {
735
- try {
736
- for (const inst of await fetchInstances(hubRef, { timeoutMs: 2000 })) {
737
- if (!sameWorkspace(inst.workspace, process.cwd()))
738
- continue;
739
- for (const s of inst.sessions ?? []) {
740
- liveOn.set(s.sessionId, { inst, running: s.status === "running" });
741
- }
742
- }
743
- }
744
- catch {
745
- // hub unreachable — local list only
746
- }
747
- }
748
- for (const [sid, { inst, running }] of liveOn) {
749
- const existing = items.find((s) => s.sessionId === sid);
750
- const meta = inst.sessions?.find((s) => s.sessionId === sid);
751
- if (existing) {
752
- existing.live = true;
753
- existing.running = running;
754
- existing.title = existing.title ?? meta?.title ?? null;
755
- existing.updatedAt = existing.updatedAt ?? meta?.updatedAt ?? null;
756
- }
757
- else {
758
- items.push({
759
- sessionId: sid,
760
- cwd: inst.workspace ?? "",
761
- title: meta?.title ?? null,
762
- updatedAt: meta?.updatedAt ?? null,
763
- live: true,
764
- running,
765
- });
766
- }
767
- }
768
- if (items.length === 0) {
769
- entries = [...entries, { kind: "note", text: "no previous sessions in this project yet" }];
770
- rerender();
771
- return;
772
- }
773
- const sid = await new Promise((resolve) => {
774
- sessionPickResolver = resolve;
775
- sessionPick = { items };
776
- rerender();
777
- });
778
- sessionPick = null;
779
- sessionPickResolver = null;
780
- rerender();
781
- const picked = items.find((s) => s.sessionId === sid);
782
- if (sid && picked) {
783
- resumedDuringStartup = true;
784
- const live = liveOn.get(sid);
785
- if (hubRef && live)
786
- await attachRemote(picked, live.inst);
787
- else
788
- await resumeInto(picked);
789
- }
790
- }
791
- /**
792
- * Swap the live session for a loaded one. The bridge streams the whole
793
- * conversation back as `session/update` notifications before resolving the
794
- * `session/load` request, so an ActiveSession must be attached BEFORE the
795
- * request goes out or every replayed message is dropped by the SDK's
796
- * update router. `attachSession` is @internal in the typings but stable at
797
- * runtime — the only client-side path from a raw sessionId to update
798
- * routing (`buildSession().start()` only covers session/new). Resolves
799
- * "loaded" once the restored history is seeded, "failed" when the load
800
- * request errored (a note is printed; the attached session stays put —
801
- * the caller decides any fallback), "superseded" when another swap went
802
- * first and owns the state now.
803
- */
804
- async function resumeInto(picked) {
805
- const gen = ++swapGen;
806
- const loaded = cx.attachSession({ sessionId: picked.sessionId });
807
- // Switching to a LOCAL session tears down any hub attach first: its
808
- // socket, connection context, and permission-handler slot must not
809
- // outlive the swap.
810
- closeRemoteAttach();
811
- activeSession?.dispose();
812
- activeSession = loaded;
813
- replayTurn = createTurnState();
814
- replayMode = true;
815
- loadSettled = false;
816
- // Paint the restoring-history hint BEFORE the (long) load round-trip.
817
- rerender();
818
- let resumeMeta = null;
819
- try {
820
- const resp = (await cx.request("session/load", {
821
- sessionId: picked.sessionId,
822
- cwd: process.cwd(),
823
- mcpServers: [],
824
- // Tail replay (ADR-0003): bounded recent history instead of the full
825
- // transcript — the REPL scrollback doesn't need 10k lines at once.
826
- _meta: { zcode: { limit: RESUME_TAIL_LIMIT } },
827
- }));
828
- if (gen !== swapGen)
829
- return "superseded"; // another swap went first — this load is stale
830
- status = seedStatusFromNewSession(status, resp);
831
- resumeMeta = resp.replayMeta ?? null;
832
- }
833
- catch (err) {
834
- if (gen !== swapGen)
835
- return "superseded"; // stale — another swap owns the state now
836
- replayMode = false;
837
- replayTurn = null;
838
- entries = [
839
- ...entries,
840
- {
841
- kind: "note",
842
- text: `resume failed: ${err instanceof Error ? err.message : String(err)}`,
843
- },
844
- ];
845
- rerender();
846
- return "failed";
847
- }
848
- finally {
849
- // Same generation guard as attachRemote: a stale resume resolving after
850
- // a newer swap must not end that swap's replay drain early.
851
- if (gen === swapGen)
852
- loadSettled = true;
853
- }
854
- const title = picked.title?.trim() || picked.sessionId.slice(0, 8);
855
- const m = resumeMeta;
856
- // Loaded mid-turn (e.g. a hub client prompted this local bridge while the
857
- // REPL was detached): the running flag rides the load response — no start
858
- // notification will fire for an already-started turn.
859
- if (m?.turnActive && !turnActive) {
860
- turn = createTurnState();
861
- turnActive = true;
862
- }
863
- const truncated = m?.hasMore && typeof m.replayedMessages === "number" && typeof m.totalMessages === "number"
864
- ? ` — showing last ${m.replayedMessages} of ${m.totalMessages} messages`
865
- : "";
866
- entries = [
867
- ...entries,
868
- { kind: "note", text: `resumed "${title}"${truncated || " — history restored above"}` },
869
- ];
870
- rerender();
871
- return "loaded";
872
- }
873
- /**
874
- * Client-side teardown of a hub-proxied attach. Never touches the owning
875
- * bridge: closing the proxied socket just removes this client from the
876
- * owner's broadcast registry — the session (and any turn on it) continues.
877
- */
878
- function closeRemoteAttach() {
879
- if (!remote)
880
- return;
881
- try {
882
- remote.ws.close();
883
- }
884
- catch {
885
- // ignore
886
- }
887
- remote = null;
888
- activeCx = null;
889
- }
890
- /**
891
- * Attach to a session live on ANOTHER bridge instance through the hub's WS
892
- * proxy (ADR-0018): the REPL becomes a second ACP client at the owner, so
893
- * replay and live stream are native client fan-out — the only way to watch
894
- * a session another process is driving (the CLI's own backend holds just a
895
- * frozen resident snapshot of those). Any failure falls back to the plain
896
- * local resume.
897
- */
898
- async function attachRemote(picked, inst) {
899
- if (!hubRef)
900
- return;
901
- const gen = ++swapGen;
902
- let socket = null;
903
- // A remote client can race a prompt into the CURRENT session during the
904
- // attach's await windows; swapping then would orphan that turn (same
905
- // discipline as /new) — bail out and stay put instead.
906
- const racedTurn = () => {
907
- if (gen !== swapGen) {
908
- socket?.ws.close();
909
- return true;
910
- }
911
- if (turnActive) {
912
- socket?.ws.close();
913
- entries = [
914
- ...entries,
915
- { kind: "note", text: "a turn started while attaching — staying in the current session" },
916
- ];
917
- rerender();
918
- return true;
919
- }
920
- return false;
921
- };
922
- try {
923
- socket = await openHubSocket(hubRef, inst.id, REMOTE_OPEN_TIMEOUT_MS);
924
- const sock = socket;
925
- if (racedTurn())
926
- return;
927
- const rapp = buildConnection(acp.ndJsonStream(Writable.toWeb(sock.duplex), Readable.toWeb(sock.duplex)));
928
- const rcx = rapp.agent;
929
- // The bridge answers only initialize until the handshake completes —
930
- // same requirement as the local connection's startup.
931
- await rcx.request("initialize", {
932
- protocolVersion: 1,
933
- clientCapabilities: {
934
- fs: { readTextFile: false, writeTextFile: false },
935
- elicitation: { form: {} },
936
- },
937
- clientInfo: { name: "zcode-acp", title: "zcode-acp REPL", version: AGENT_INFO.version },
938
- });
939
- if (racedTurn())
940
- return;
941
- // Attach BEFORE the load request goes out — same ordering constraint as
942
- // the local resume, or every replayed update is dropped by the SDK's
943
- // update router.
944
- const loaded = rcx.attachSession({ sessionId: picked.sessionId });
945
- // Loss detection: the hub or the owning bridge going away must not leave
946
- // a frozen "live" view (or the pump spinning on a dead read).
947
- sock.ws.once("close", () => {
948
- // Recovery only when THIS socket is still the active attach. The
949
- // generation is deliberately not consulted: if a second attach is
950
- // still mid-flight, the first attach's session IS the live view, so
951
- // its loss must recover; once another attach (or /new) owns the slot,
952
- // the ws identity check is what makes this handler inert.
953
- if (remote?.ws !== sock.ws)
954
- return;
955
- remote = null;
956
- activeCx = null;
957
- // The remote stream is dead — no stop or turnState will ever arrive,
958
- // so any open turn UI is force-closed here rather than left spinning.
959
- if (turnActive) {
960
- entries = [
961
- ...entries,
962
- ...finishTurn(turn ?? createTurnState(), "remote connection lost"),
963
- ];
964
- turn = null;
965
- turnActive = false;
966
- promptInFlight = false;
967
- }
968
- entries = [
969
- ...entries,
970
- {
971
- kind: "note",
972
- text: "remote attach lost — starting a fresh local session (use /sessions to reload)",
973
- },
974
- ];
975
- rerender();
976
- void startFreshSession();
977
- });
978
- // Any previous attach is torn down first — its socket, its connection
979
- // context, and its permission-handler slot all go with it.
980
- closeRemoteAttach();
981
- activeSession?.dispose();
982
- activeSession = loaded;
983
- activeCx = rcx;
984
- remote = { ws: sock.ws, duplex: sock.duplex };
985
- replayTurn = createTurnState();
986
- replayMode = true;
987
- loadSettled = false;
988
- rerender();
989
- let resp;
990
- try {
991
- resp = (await rcx.request("session/load", {
992
- sessionId: picked.sessionId,
993
- cwd: process.cwd(),
994
- mcpServers: [],
995
- // Tail replay (ADR-0003), same bound as the local resume.
996
- _meta: { zcode: { limit: RESUME_TAIL_LIMIT } },
997
- }));
998
- }
999
- finally {
1000
- // The whole backlog is queued once the load resolves — without this
1001
- // the pump waits for a quiet window that never opens. Generation
1002
- // guarded: a stale load resolving after a newer swap (which owns the
1003
- // flag now) must not end that swap's replay drain early.
1004
- if (gen === swapGen)
1005
- loadSettled = true;
1006
- }
1007
- if (gen !== swapGen)
1008
- return; // stale — another swap owns the state now
1009
- status = seedStatusFromNewSession(status, resp);
1010
- const m = resp.replayMeta ?? null;
1011
- // A turn already running at attach time (the mobile mid-turn — the main
1012
- // scenario) never sends a start notification we could hear; this flag in
1013
- // the load response is the only signal.
1014
- if (m?.turnActive && !turnActive) {
1015
- turn = createTurnState();
1016
- turnActive = true;
1017
- }
1018
- const title = picked.title?.trim() || picked.sessionId.slice(0, 8);
1019
- const truncated = m?.hasMore && typeof m.replayedMessages === "number" && typeof m.totalMessages === "number"
1020
- ? ` — showing last ${m.replayedMessages} of ${m.totalMessages} messages`
1021
- : "";
1022
- entries = [
1023
- ...entries,
1024
- {
1025
- kind: "note",
1026
- text: `attached live "${title}" via hub (${inst.origin ?? "remote"} instance ${inst.id.slice(0, 8)})${truncated}`,
1027
- },
1028
- ];
1029
- rerender();
1030
- }
1031
- catch (err) {
1032
- const why = err instanceof Error ? err.message : String(err);
1033
- if (gen !== swapGen)
1034
- return; // another swap owns the state now
1035
- try {
1036
- socket?.ws.close();
1037
- }
1038
- catch {
1039
- // ignore
1040
- }
1041
- // Only this attach's own state may be cleared here: a failure BEFORE
1042
- // installation leaves the previous attach's socket live in `remote`,
1043
- // and clearing it would orphan that ws (its permission fan-out would
1044
- // keep reaching this REPL with nothing left to close it).
1045
- if (socket && remote?.ws === socket.ws) {
1046
- remote = null;
1047
- activeCx = null;
1048
- }
1049
- entries = [
1050
- ...entries,
1051
- { kind: "note", text: `remote attach failed (${why}) — loading locally instead` },
1052
- ];
1053
- rerender();
1054
- // Fall back to the plain local load. If the swap had already completed,
1055
- // resumeInto disposes the (now stream-less) remote session itself; if it
1056
- // hadn't, it disposes the previous session as any resume would.
1057
- await resumeInto(picked);
1058
- }
1059
- }
1060
- /** Fresh welcome panel entry; reused by startup and `/new`. */
1061
- function welcomeEntry() {
1062
- return {
1063
- kind: "welcome",
1064
- info: {
1065
- version: AGENT_INFO.version,
1066
- cwd: process.cwd(),
1067
- model: selectLabel(status.model),
1068
- mode: selectLabel(status.mode),
1069
- thought: selectLabel(status.thought),
1070
- // Live decision (env OR this project's sandbox.json — ADR-0011) so a
1071
- // project-armed sandbox shows here too; on non-macOS this may warn
1072
- // alongside the bridge, which is honest about the unsupported request.
1073
- sandbox: sandboxActive() ? "sandbox: on — writes confined to the workspace" : "",
1074
- },
1075
- };
1076
- }
1077
- /**
1078
- * `/new` (ADR-0010): swap the live session for a fresh backend-created one,
1079
- * strictly client-side — routing the command to the backend's slash
1080
- * interception would rotate the session id out from under the update pump.
1081
- * Reuses the session/new bootstrap; the swap refuses while a turn runs or
1082
- * startup is still in flight (same preempt discipline as /sessions).
1083
- */
1084
- async function startFreshSession() {
1085
- if (busy || turnActive) {
1086
- entries = [
1087
- ...entries,
1088
- { kind: "user", text: "/new" },
1089
- {
1090
- kind: "note",
1091
- text: turnActive
1092
- ? "a turn is running — esc interrupts it first"
1093
- : "still starting up — try /new again in a moment",
1094
- },
1095
- ];
1096
- rerender();
1097
- return;
1098
- }
1099
- const gen = ++swapGen;
1100
- try {
1101
- const fresh = await cx.buildSession(process.cwd()).start();
1102
- if (gen !== swapGen) {
1103
- fresh.dispose(); // another swap went first — this placeholder is junk
1104
- return;
1105
- }
1106
- if (turnActive) {
1107
- // A turn raced into the old session during the session/new roundtrip
1108
- // (a local submit or a remote client). Swapping now would orphan it:
1109
- // disposing the session takes its update pump down and the
1110
- // completion event is filtered by session id, so turnActive would
1111
- // stick forever and wedge the REPL. Keep the current session.
1112
- fresh.dispose();
1113
- entries = [
1114
- ...entries,
1115
- { kind: "user", text: "/new" },
1116
- {
1117
- kind: "note",
1118
- text: "a turn started while /new was swapping — staying in the current session",
1119
- },
1120
- ];
1121
- rerender();
1122
- return;
1123
- }
1124
- // `/new` means a fresh LOCAL session: a hub-proxied attach is torn down
1125
- // first (client-side only — the owner keeps running).
1126
- closeRemoteAttach();
1127
- activeSession?.dispose();
1128
- activeSession = fresh;
1129
- status = seedStatusFromNewSession(status, fresh.newSessionResponse);
1130
- // Any in-flight /sessions replay is dead: its pump read fails into the
1131
- // catch/re-arm path, and its stale continuations bail on swapGen.
1132
- replayMode = false;
1133
- replayTurn = null;
1134
- loadSettled = true;
1135
- // Native-scrollback model: entries are APPEND-ONLY — ink <Static> has
1136
- // already printed everything before its print cursor, so shrinking the
1137
- // array would silently drop the divider. The old conversation stays in
1138
- // the terminal's own history (that's the model's whole point).
1139
- entries = [
1140
- ...entries,
1141
- { kind: "user", text: "/new" },
1142
- {
1143
- kind: "note",
1144
- text: "── new session started — the previous conversation stays in /sessions ──",
1145
- },
1146
- ];
1147
- editor = createLineEditor();
1148
- rerender();
1149
- }
1150
- catch (err) {
1151
- entries = [
1152
- ...entries,
1153
- { kind: "user", text: "/new" },
1154
- {
1155
- kind: "note",
1156
- text: `failed to start a new session: ${err instanceof Error ? err.message : String(err)}`,
1157
- },
1158
- ];
1159
- rerender();
1160
- }
1161
- }
1162
- // Terminal resize: the dynamic footer re-wraps at the new width via a
1163
- // plain rerender. Already-printed scrollback keeps its old wrapping —
1164
- // that's exactly how native-history CLIs (Claude Code et al.) behave.
1165
- const onResize = () => {
1166
- if (exited)
1167
- return;
1168
- rerender();
1169
- };
1170
- if (process.stdout.isTTY)
1171
- process.stdout.on("resize", onResize);
1172
- function cleanup(code) {
1173
- if (exited)
1174
- return;
1175
- exited = true;
1176
- try {
1177
- if (quotaTimer !== null)
1178
- clearInterval(quotaTimer);
1179
- quotaTimer = null;
1180
- }
1181
- catch {
1182
- // ignore
1183
- }
1184
- try {
1185
- process.stdout.off("resize", onResize);
1186
- }
1187
- catch {
1188
- // ignore
1189
- }
1190
- // Each step is guarded: one throwing (e.g. unmount during a render) must
1191
- // not skip the child shutdown and leave the process hanging.
1192
- try {
1193
- activeSession?.dispose();
1194
- }
1195
- catch {
1196
- // ignore
1197
- }
1198
- try {
1199
- // Closing the proxied socket only detaches this client — the owning
1200
- // serve bridge and its session are never touched from here.
1201
- closeRemoteAttach();
1202
- }
1203
- catch {
1204
- // ignore
1205
- }
1206
- try {
1207
- ink.unmount();
1208
- }
1209
- catch {
1210
- // ignore
1211
- }
1212
- try {
1213
- // Graceful path: closing stdin triggers the bridge's own shutdown,
1214
- // which reaps the zcode backend group.
1215
- child.stdin?.end();
1216
- }
1217
- catch {
1218
- // ignore
1219
- }
1220
- setTimeout(() => child.kill("SIGKILL"), 3000).unref();
1221
- // Hard fallback: if the bridge ignores stdin-close shutdown (or the exit
1222
- // event is lost), the REPL still exits instead of hanging forever.
1223
- setTimeout(() => process.exit(code), 5000);
1224
- child.once("exit", () => {
1225
- if (code !== 0 && stderrTail.trim()) {
1226
- process.stderr.write(`\nbridge stderr tail:\n${stderrTail}\n`);
1227
- }
1228
- process.exit(code);
1229
- });
1230
- }
1231
- // Bridge died under us (crash, backend failure) — report and exit hard.
1232
- child.once("exit", (code) => {
1233
- if (exited)
1234
- return;
1235
- exited = true;
1236
- ink.unmount();
1237
- process.stderr.write(`bridge exited unexpectedly (code ${code})\n${stderrTail.trim() ? `stderr tail:\n${stderrTail}` : ""}\n`);
1238
- process.exit(1);
1239
- });
1240
- // Welcome panel as the first transcript entry — branding, session info,
1241
- // seeded config, and key hints; pushed into scrollback by the first prompt.
1242
- entries = [welcomeEntry()];
1243
- rerender();
1244
- }
1245
- //# sourceMappingURL=run.js.map