zcode-acp-server 0.21.3 → 0.22.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.
- package/README.md +6 -7
- package/dist/backend/sandbox.d.ts.map +1 -1
- package/dist/backend/sandbox.js +12 -0
- package/dist/backend/sandbox.js.map +1 -1
- package/dist/cli.d.ts +8 -5
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +22 -14
- package/dist/cli.js.map +1 -1
- package/dist/handlers/dispatch.d.ts +2 -0
- package/dist/handlers/dispatch.d.ts.map +1 -1
- package/dist/handlers/dispatch.js +28 -0
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/session.d.ts +17 -1
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +92 -4
- package/dist/handlers/session.js.map +1 -1
- package/dist/remote/hub-server.d.ts +7 -7
- package/dist/remote/hub-server.d.ts.map +1 -1
- package/dist/remote/hub-server.js +33 -33
- package/dist/remote/hub-server.js.map +1 -1
- package/dist/server.d.ts +5 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +6 -0
- package/dist/server.js.map +1 -1
- package/dist/tui.d.ts +38 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +96 -0
- package/dist/tui.js.map +1 -0
- package/docs/CLI.md +33 -76
- package/docs/REMOTE-CLIENTS.md +13 -9
- package/docs/REMOTE.md +11 -13
- package/docs/TROUBLESHOOTING.md +27 -1
- package/docs/adr/0007-unified-cli-entry-and-bin-pruning.md +2 -0
- package/package.json +3 -7
- package/dist/repl/App.d.ts +0 -108
- package/dist/repl/App.d.ts.map +0 -1
- package/dist/repl/App.js +0 -732
- package/dist/repl/App.js.map +0 -1
- package/dist/repl/history.d.ts +0 -25
- package/dist/repl/history.d.ts.map +0 -1
- package/dist/repl/history.js +0 -73
- package/dist/repl/history.js.map +0 -1
- package/dist/repl/hub.d.ts +0 -87
- package/dist/repl/hub.d.ts.map +0 -1
- package/dist/repl/hub.js +0 -188
- package/dist/repl/hub.js.map +0 -1
- package/dist/repl/input-buffer.d.ts +0 -77
- package/dist/repl/input-buffer.d.ts.map +0 -1
- package/dist/repl/input-buffer.js +0 -174
- package/dist/repl/input-buffer.js.map +0 -1
- package/dist/repl/model.d.ts +0 -312
- package/dist/repl/model.d.ts.map +0 -1
- package/dist/repl/model.js +0 -674
- package/dist/repl/model.js.map +0 -1
- package/dist/repl/run.d.ts +0 -12
- package/dist/repl/run.d.ts.map +0 -1
- package/dist/repl/run.js +0 -1245
- 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
|