zcode-acp-server 0.18.2 → 0.19.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 (37) hide show
  1. package/dist/handlers/sandbox-allow.d.ts +67 -5
  2. package/dist/handlers/sandbox-allow.d.ts.map +1 -1
  3. package/dist/handlers/sandbox-allow.js +136 -29
  4. package/dist/handlers/sandbox-allow.js.map +1 -1
  5. package/dist/handlers/session.d.ts.map +1 -1
  6. package/dist/handlers/session.js +28 -8
  7. package/dist/handlers/session.js.map +1 -1
  8. package/dist/i18n.d.ts +3 -2
  9. package/dist/i18n.d.ts.map +1 -1
  10. package/dist/i18n.js +4 -4
  11. package/dist/i18n.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/remote/config.d.ts +18 -0
  16. package/dist/remote/config.d.ts.map +1 -1
  17. package/dist/remote/config.js +13 -1
  18. package/dist/remote/config.js.map +1 -1
  19. package/dist/remote/endpoint.d.ts.map +1 -1
  20. package/dist/remote/endpoint.js +4 -0
  21. package/dist/remote/endpoint.js.map +1 -1
  22. package/dist/remote/hub-server.d.ts +49 -3
  23. package/dist/remote/hub-server.d.ts.map +1 -1
  24. package/dist/remote/hub-server.js +381 -44
  25. package/dist/remote/hub-server.js.map +1 -1
  26. package/dist/remote/session-list-endpoint.d.ts +51 -0
  27. package/dist/remote/session-list-endpoint.d.ts.map +1 -0
  28. package/dist/remote/session-list-endpoint.js +153 -0
  29. package/dist/remote/session-list-endpoint.js.map +1 -0
  30. package/dist/server.d.ts +15 -3
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +14 -2
  33. package/dist/server.js.map +1 -1
  34. package/docs/REMOTE-CLIENTS.md +88 -26
  35. package/docs/REMOTE.md +30 -8
  36. package/docs/SANDBOX.md +3 -1
  37. package/package.json +1 -1
@@ -24,12 +24,14 @@
24
24
  */
25
25
  import { spawn } from "node:child_process";
26
26
  import { createHash, timingSafeEqual } from "node:crypto";
27
- import { realpathSync } from "node:fs";
27
+ import { chmodSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
28
28
  import { readdir, readFile, stat } from "node:fs/promises";
29
29
  import { createServer, get as httpGet, request as httpRequest, } from "node:http";
30
+ import { EventEmitter } from "node:events";
30
31
  import net from "node:net";
31
32
  import path from "node:path";
32
33
  import process from "node:process";
34
+ import { tmpdir } from "node:os";
33
35
  import { fileURLToPath } from "node:url";
34
36
  import { WebSocket, WebSocketServer } from "ws";
35
37
  import { AGENT_INFO, compareVersions, log, warn } from "../utils.js";
@@ -110,14 +112,180 @@ function parseOrigin(raw) {
110
112
  return raw === "serve" ? "serve" : "editor";
111
113
  }
112
114
  /**
113
- * Default serve-bridge spawner (remote session-create, ADR-0014): this node
114
- * running this package's cli.js `serve` subcommand, detached in the project's
115
- * cwd with the ENV the bridge needs to find its way back to this hub. Mirrors
116
- * endpoint.ts's spawnHub: detached + unref'd (the hub must not own its life),
117
- * stderr piped and tail-logged so startup failures surface instead of
118
- * silently vanishing with an "ignore" pipe.
115
+ * The REPL-in-a-terminal incubation budget (ADR-0016): a visible terminal
116
+ * adds a GUI round-trip (Terminal app launch, REPL boot, its bridge child)
117
+ * ahead of the hub registration — double the headless budget.
119
118
  */
120
- function defaultSpawnServe(opts) {
119
+ const REPL_REGISTER_TIMEOUT_MS = 20_000;
120
+ /** `open -a <terminal>` must answer fast or the incubation falls back. */
121
+ const TERMINAL_OPEN_TIMEOUT_MS = 3_000;
122
+ /** Set ZCODE_ACP_HUB_TERMINAL=0 to keep remote session-create headless. */
123
+ const TERMINAL_GATE_ENV = "ZCODE_ACP_HUB_TERMINAL";
124
+ /** Single-quote for sh: ' → '\'' . */
125
+ function shQuote(s) {
126
+ return `'${s.replace(/'/g, "'\\''")}'`;
127
+ }
128
+ /**
129
+ * Built-in launch recipes for well-known macOS terminals (ADR-0016 §5). Each
130
+ * mechanism is the app's documented, verified way to run a command in a fresh
131
+ * window: Terminal and iTerm execute `.command` files handed over via open;
132
+ * WezTerm's `start --` runs an alternative program (wezterm.org/cli/start.html);
133
+ * kitty takes the program as normal positional arguments
134
+ * (sw.kovidgoyal.net/kitty/invocation); Alacritty and Ghostty support the
135
+ * common `-e` flag. The script does its own `cd`, so no per-app cwd flags.
136
+ * Warp is deliberately ABSENT — it cannot execute `.command` files (#1917)
137
+ * and has no programmatic command execution at all (URI scheme opens dirs
138
+ * only; still an open request in #3959/#9083). Hyper is absent for the same
139
+ * reason (#3677).
140
+ */
141
+ const TERMINAL_APP_LAUNCHERS = {
142
+ terminal: { kind: "openApp", app: "Terminal" },
143
+ apple_terminal: { kind: "openApp", app: "Terminal" },
144
+ iterm: { kind: "openApp", app: "iTerm" },
145
+ iterm2: { kind: "openApp", app: "iTerm" },
146
+ wezterm: { kind: "openAppArgs", app: "WezTerm", args: ["start", "--"] },
147
+ kitty: { kind: "openAppArgs", app: "kitty", args: [] },
148
+ alacritty: { kind: "openAppArgs", app: "Alacritty", args: ["-e"] },
149
+ ghostty: { kind: "openAppArgs", app: "Ghostty", args: ["-e"] },
150
+ };
151
+ /**
152
+ * Pick how to open the REPL script. The hub is a background daemon — its env
153
+ * carries no terminal and macOS has no default-terminal setting — so nothing
154
+ * is detected. Priority: the explicit command template (the universal escape
155
+ * hatch) → a built-in launcher by app name (aliases are case- and
156
+ * `.app`-suffix-insensitive) → plain Terminal.app. Warp gets a special warning
157
+ * because it accepts the open but cannot run the script; any other unmatched
158
+ * name passes through to `open -a` unchanged.
159
+ */
160
+ export function resolveTerminalLaunch(env) {
161
+ const command = (env.ZCODE_ACP_HUB_TERMINAL_COMMAND ?? "").trim();
162
+ if (command)
163
+ return { launch: { kind: "shell", command } };
164
+ const raw = (env.ZCODE_ACP_HUB_TERMINAL_APP ?? "").trim();
165
+ const name = raw.toLowerCase().replace(/\.app$/, "");
166
+ const launcher = TERMINAL_APP_LAUNCHERS[name];
167
+ if (launcher)
168
+ return { launch: launcher };
169
+ if (name === "warp") {
170
+ return {
171
+ launch: { kind: "openApp", app: raw || "Warp" },
172
+ warning: "Warp cannot execute .command scripts (warpdotdev/warp#1917) and has no " +
173
+ "programmatic command execution (warpdotdev/warp#3959, #9083) — the window " +
174
+ "will idle and session-create falls back to a headless bridge after the " +
175
+ "register timeout; set ZCODE_ACP_HUB_TERMINAL_COMMAND to drive Warp another way",
176
+ };
177
+ }
178
+ return { launch: { kind: "openApp", app: raw || "Terminal" } };
179
+ }
180
+ /**
181
+ * The .command script body. The incubation env MUST be embedded as exports:
182
+ * the script runs in a fresh shell spawned by the terminal app, which
183
+ * inherits launchd's environment — NOT the hub's — so without them the REPL
184
+ * would boot as a plain local session and never register back (the
185
+ * incubation would stall into its timeout). Everything ZCODE_ACP_* travels;
186
+ * values are single-quoted.
187
+ */
188
+ export function terminalReplScript(cwd, cliJs, env) {
189
+ const exports = Object.keys(env)
190
+ .filter((k) => k.startsWith("ZCODE_ACP_"))
191
+ .map((k) => `export ${k}=${shQuote(String(env[k]))}`);
192
+ return [
193
+ "#!/bin/sh",
194
+ `# Hub-incubated REPL session (ADR-0016): closing this window ends the bridge.`,
195
+ `cd ${shQuote(cwd)} || exit 1`,
196
+ ...exports,
197
+ `exec ${shQuote(process.execPath)} ${shQuote(cliJs)}`,
198
+ "",
199
+ ].join("\n");
200
+ }
201
+ /**
202
+ * Spawn session-create as a VISIBLE interactive REPL (ADR-0016): write a
203
+ * throwaway .command script (`cd <project> && exec node cli.js`) and hand it
204
+ * to a terminal (resolveTerminalLaunch) — the user gets a real terminal
205
+ * window running the local CLI instead of an invisible daemon. The REPL's
206
+ * bridge child inherits the remote ENV, so the incubation registers exactly
207
+ * like a serve bridge; closing the window ends the bridge (its lifetime
208
+ * follows the terminal, the ADR-0001 anchor). Returns null when a terminal
209
+ * can't be used — platform, gated off via ZCODE_ACP_HUB_TERMINAL, or the
210
+ * open failing (headless/SSH) — and the caller falls back to the detached
211
+ * serve spawn.
212
+ */
213
+ async function spawnTerminalRepl(opts) {
214
+ if (process.platform !== "darwin")
215
+ return null;
216
+ const gate = (process.env[TERMINAL_GATE_ENV] ?? "").trim().toLowerCase();
217
+ if (["0", "false", "off"].includes(gate))
218
+ return null;
219
+ const cliJs = fileURLToPath(new URL("../cli.js", import.meta.url));
220
+ const script = path.join(mkdtempSync(path.join(tmpdir(), "zcode-acp-term-")), "repl.command");
221
+ writeFileSync(script, terminalReplScript(opts.cwd, cliJs, opts.env), { mode: 0o700 });
222
+ chmodSync(script, 0o700);
223
+ const { launch, warning } = resolveTerminalLaunch(process.env);
224
+ if (warning)
225
+ warn(`hub: ${warning}`);
226
+ let argv;
227
+ if (launch.kind === "shell") {
228
+ const rendered = launch.command.includes("{script}")
229
+ ? launch.command.replace("{script}", shQuote(script))
230
+ : `${launch.command} ${shQuote(script)}`;
231
+ argv = ["/bin/sh", "-c", rendered];
232
+ }
233
+ else if (launch.kind === "openApp") {
234
+ argv = ["open", "-a", launch.app, script];
235
+ }
236
+ else {
237
+ argv = ["open", "-na", launch.app, "--args", ...launch.args, "/bin/sh", script];
238
+ }
239
+ // Async spawn — spawnSync would freeze the hub's event loop (WS proxying,
240
+ // heartbeats for every live bridge) for up to the full timeout while a GUI
241
+ // app cold-starts.
242
+ const errChunks = [];
243
+ const opened = await new Promise((resolve) => {
244
+ const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "ignore", "pipe"] });
245
+ child.stderr?.on("data", (c) => errChunks.push(c));
246
+ const timer = setTimeout(() => {
247
+ child.kill();
248
+ resolve({ timedOut: true });
249
+ }, TERMINAL_OPEN_TIMEOUT_MS);
250
+ child.once("error", (e) => {
251
+ clearTimeout(timer);
252
+ resolve({ error: e });
253
+ });
254
+ child.once("exit", (code) => {
255
+ clearTimeout(timer);
256
+ resolve({ code });
257
+ });
258
+ });
259
+ if (opened.error || opened.timedOut || opened.code !== 0) {
260
+ const detail = opened.timedOut
261
+ ? `timed out after ${TERMINAL_OPEN_TIMEOUT_MS}ms`
262
+ : (opened.error?.message ?? Buffer.concat(errChunks).toString("utf8").trim()) ||
263
+ `exit ${opened.code ?? "?"}`;
264
+ warn(`hub: no terminal window for ${opts.cwd} (${detail}) — falling back to a headless bridge`);
265
+ return null;
266
+ }
267
+ log(`hub: opened a Terminal REPL for ${opts.cwd}`);
268
+ // `open` has already exited; incubation only needs a child that reads as
269
+ // alive until the bridge registers. A window that dies early surfaces as
270
+ // the register timeout — the terminal window itself shows the reason.
271
+ const fake = new EventEmitter();
272
+ fake.pid = -1;
273
+ fake.exitCode = null;
274
+ fake.signalCode = null;
275
+ return fake;
276
+ }
277
+ /**
278
+ * Default bridge spawner for the remote session-create endpoints: a visible
279
+ * terminal REPL for session-create (ADR-0016, macOS + not gated off), a
280
+ * detached headless serve bridge otherwise (the ADR-0014 original — also the
281
+ * fallback whenever the terminal window can't be opened).
282
+ */
283
+ async function defaultSpawnServe(opts) {
284
+ if (opts.kind === "repl") {
285
+ const viaTerminal = await spawnTerminalRepl(opts);
286
+ if (viaTerminal)
287
+ return viaTerminal;
288
+ }
121
289
  // dist/remote/hub-server.js → dist/cli.js (one level up).
122
290
  const cliJs = fileURLToPath(new URL("../cli.js", import.meta.url));
123
291
  const child = spawn(process.execPath, [cliJs, "serve"], {
@@ -186,6 +354,56 @@ function portOpen(port, timeoutMs) {
186
354
  socket.connect(port, "127.0.0.1");
187
355
  });
188
356
  }
357
+ /** Body cap for a bridge's GET /sessions answer (session lists are small). */
358
+ const MAX_SESSION_LIST_BYTES = 4 * 1024 * 1024;
359
+ /**
360
+ * A cold bridge must spawn its backend before the store answers, so the
361
+ * budget covers an incubation plus one session/list round-trip (the bridge
362
+ * gives its own query 15s).
363
+ */
364
+ const SESSION_LIST_TIMEOUT_MS = 12_000;
365
+ /**
366
+ * Fetch a bridge's GET /sessions payload (ADR-0015), forwarding the
367
+ * pagination query ("?limit=..&before=.."). Resolves with the parsed JSON;
368
+ * rejects on transport failure, a non-200 answer, truncation, or a non-JSON
369
+ * body — the caller turns every rejection into a 502.
370
+ */
371
+ function fetchBridgeSessions(port, query = "") {
372
+ return new Promise((resolve, reject) => {
373
+ const req = httpGet({ host: "127.0.0.1", port, path: `/sessions${query}` }, (up) => {
374
+ if ((up.statusCode ?? 500) !== 200) {
375
+ up.resume();
376
+ reject(new Error(`bridge answered ${up.statusCode ?? "?"}`));
377
+ return;
378
+ }
379
+ const chunks = [];
380
+ let size = 0;
381
+ up.on("data", (c) => {
382
+ size += c.length;
383
+ if (size > MAX_SESSION_LIST_BYTES) {
384
+ up.destroy();
385
+ reject(new Error("bridge session list too large"));
386
+ return;
387
+ }
388
+ chunks.push(c);
389
+ });
390
+ up.on("end", () => {
391
+ try {
392
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
393
+ }
394
+ catch {
395
+ reject(new Error("bridge returned an invalid session list"));
396
+ }
397
+ });
398
+ up.on("error", () => reject(new Error("bridge session list failed")));
399
+ });
400
+ req.on("error", () => reject(new Error("bridge unreachable")));
401
+ req.setTimeout(SESSION_LIST_TIMEOUT_MS, () => {
402
+ req.destroy();
403
+ reject(new Error("bridge session list timed out"));
404
+ });
405
+ });
406
+ }
189
407
  /**
190
408
  * Where /api/upgrade looks for on-disk code: root package.json + dist/.
191
409
  */
@@ -275,6 +493,7 @@ export function startHub(options) {
275
493
  * findServe check (check-then-act). Check-then-set is one synchronous
276
494
  * block, so requests can only ever observe "no entry" one at a time.
277
495
  */
496
+ // Single-flight incubation per workspace (see joinIncubation below).
278
497
  const serveIncubations = new Map();
279
498
  /** The live serve instance for a workspace, if any (per-workspace dedupe). */
280
499
  const findServeInstance = (workspacePath) => {
@@ -295,19 +514,22 @@ export function startHub(options) {
295
514
  return Array.from(instances.values()).find((e) => e.origin === "serve" && key(e.workspace) === wanted);
296
515
  };
297
516
  /**
298
- * Spawn a serve bridge and poll until it registers (or fails fast on child
299
- * exit / timeout). Shared by every POST /api/instances currently incubating
300
- * this workspace — all joiners see the same outcome.
517
+ * Spawn a bridge and poll until it registers (or fails fast on child exit /
518
+ * timeout). Shared by every incubating endpoint for this workspace — all
519
+ * joiners see the same outcome. Kind picks the spawn surface: "repl" opens
520
+ * a visible terminal (remote session-create, ADR-0016), "serve" the
521
+ * detached headless bridge (background listing, ADR-0015).
301
522
  */
302
- const incubateServe = async (workspacePath) => {
523
+ const incubateServe = async (workspacePath, kind) => {
303
524
  // Async spawn failures (ENOENT — the dir vanished between the whitelist
304
525
  // check and the spawn) arrive as an 'error' event with exitCode still
305
- // null; without this flag the poll burns the full 10s budget.
526
+ // null; without this flag the poll burns the full budget.
306
527
  let spawnError = null;
307
528
  let child;
308
529
  try {
309
- child = spawnServe({
530
+ child = await spawnServe({
310
531
  cwd: workspacePath,
532
+ kind,
311
533
  env: {
312
534
  ...process.env,
313
535
  ZCODE_ACP_REMOTE: "1",
@@ -316,6 +538,13 @@ export function startHub(options) {
316
538
  // Parity with the bridge-side spawnHub: the serve child may have to
317
539
  // (re)spawn the hub itself, and must bind the same configured host.
318
540
  ZCODE_ACP_HUB_HOST: host,
541
+ // ADR-0016: the incubated bridge registers as THIS project's serve
542
+ // bridge (the hub's per-workspace dedupe matches it) and pins its
543
+ // session roots to the project cwd, so ADR-0014's whitelist
544
+ // semantics survive the visible-terminal lifecycle. The detached
545
+ // serve path ignores both (it hardcodes origin/serveMode itself).
546
+ ZCODE_ACP_REMOTE_ORIGIN: "serve",
547
+ ZCODE_ACP_REMOTE_PIN_CWD: "1",
319
548
  },
320
549
  });
321
550
  child.once("error", (e) => {
@@ -326,15 +555,18 @@ export function startHub(options) {
326
555
  warn(`hub: serve spawn failed: ${e instanceof Error ? e.message : String(e)}`);
327
556
  throw new Error("serve bridge spawn failed");
328
557
  }
329
- log(`hub: spawned serve bridge for ${workspacePath} (pid ${child.pid})`);
330
- const deadline = Date.now() + SERVE_REGISTER_TIMEOUT_MS;
558
+ log(`hub: spawned ${kind === "repl" ? "terminal REPL" : "serve bridge"} for ${workspacePath} (pid ${child.pid})`);
559
+ const budget = kind === "repl" ? REPL_REGISTER_TIMEOUT_MS : SERVE_REGISTER_TIMEOUT_MS;
560
+ const deadline = Date.now() + budget;
331
561
  for (;;) {
332
562
  await new Promise((resolve) => setTimeout(resolve, SERVE_REGISTER_POLL_MS).unref?.());
333
563
  const entry = findServeInstance(workspacePath);
334
564
  if (entry)
335
565
  return { id: entry.id, reused: false };
336
566
  // The child dying is the honest fast-fail (missing cwd perms, port
337
- // exhaustion, crash) — without this check the loop burns the full 10s.
567
+ // exhaustion, crash) — without this check the loop burns the full budget.
568
+ // (A terminal REPL reports through the window itself, so only the
569
+ // timeout applies there.)
338
570
  if (spawnError || child.exitCode !== null || child.signalCode !== null) {
339
571
  warn(`hub: serve bridge for ${workspacePath} exited during startup`);
340
572
  throw new Error("serve bridge exited during startup");
@@ -345,6 +577,34 @@ export function startHub(options) {
345
577
  }
346
578
  }
347
579
  };
580
+ /**
581
+ * Single-flight incubation per workspace: concurrent endpoint calls join one
582
+ * spawn instead of racing duplicates. A "serve" caller (the listing) joins
583
+ * ANY in-flight incubation — the outcome it waits for is "a serve bridge is
584
+ * registered", whichever surface spawned it. A "repl" caller (session-
585
+ * create) joins only another repl: the visible window IS the feature
586
+ * (ADR-0016), so it never piggybacks on an invisible listing spawn.
587
+ */
588
+ const joinIncubation = (workspacePath, kind) => {
589
+ const inflight = serveIncubations.get(workspacePath);
590
+ if (inflight && (inflight.kind === kind || kind === "serve")) {
591
+ log(`hub: joining the incubating serve bridge for ${workspacePath}`);
592
+ return inflight.promise;
593
+ }
594
+ const promise = incubateServe(workspacePath, kind);
595
+ serveIncubations.set(workspacePath, { kind, promise });
596
+ // Drop the entry once settled (identity-checked — a newer incubation may
597
+ // already have replaced it). The catch keeps the DERIVED promise handled;
598
+ // the original is awaited by its creating request.
599
+ promise
600
+ .catch(() => undefined)
601
+ .finally(() => {
602
+ const current = serveIncubations.get(workspacePath);
603
+ if (current && current.promise === promise)
604
+ serveIncubations.delete(workspacePath);
605
+ });
606
+ return promise;
607
+ };
348
608
  /**
349
609
  * Reply first, then gracefully stop (close() releases the port) and hand
350
610
  * over. `close` is declared below and hoisted — it only runs inside the
@@ -519,15 +779,105 @@ export function startHub(options) {
519
779
  res.end(JSON.stringify(projects));
520
780
  return;
521
781
  }
522
- // POST /api/instances — create (or reuse) a headless serve bridge for one
523
- // known project. The hub spawns `zcode-acp serve` detached in the project
524
- // cwd and waits for its heartbeat registration; the bridge lives its own
525
- // idle-timed life afterwards (ADR-0014). Dedupe: one serve instance per
526
- // workspace — an existing live one is reused, and concurrent POSTs join
527
- // the same in-flight incubation instead of double-spawning. Accepted
528
- // bound: a hub restart clears the instance table, so a POST inside the
529
- // bridges' ≤10s re-registration window may incubate a duplicate —
530
- // harmless (both serve, future POSTs dedupe, an idle one exits in 10min).
782
+ // GET /api/projects/sessions?workspacePath=… — the project's backend
783
+ // session store (ADR-0015), including closed ones no bridge advertises.
784
+ // Discovery stays running-scoped by design; this is the deliberate
785
+ // "resume an old session" surface. PAGINATED (long-lived projects hold
786
+ // dozens of sessions): ?limit= (default 20, max 200) rows newest-first,
787
+ // ?before=<ms> resumes an older page; the answer's nextCursor (null on
788
+ // the last page) feeds the next call. Same whitelist gate as
789
+ // POST /api/instances (the check bounds which cwds may incubate a serve
790
+ // bridge), then the ADR-0014 incubation ensures a serve bridge and the
791
+ // query proxies to its loopback /sessions. The answer wraps the bridge
792
+ // payload with the instance a list-then-load client attaches to
793
+ // (session/load reuses the same one).
794
+ if (url.pathname === "/api/projects/sessions" && req.method === "GET") {
795
+ if (!authorized(req, url, token)) {
796
+ res.writeHead(401, { "Content-Type": "text/plain" });
797
+ res.end("unauthorized");
798
+ return;
799
+ }
800
+ const workspacePath = (url.searchParams.get("workspacePath") ?? "").trim();
801
+ if (!workspacePath) {
802
+ res.writeHead(400, { "Content-Type": "text/plain" });
803
+ res.end("workspacePath required");
804
+ return;
805
+ }
806
+ // Pagination forwarding: validate here so a bad page request fails
807
+ // fast with 400 instead of surfacing as the bridge's 502.
808
+ const pagination = new URLSearchParams();
809
+ for (const name of ["limit", "before"]) {
810
+ const raw = url.searchParams.get(name);
811
+ if (raw === null)
812
+ continue;
813
+ if (!/^\d+$/.test(raw) || (name === "limit" && parseInt(raw, 10) < 1)) {
814
+ res.writeHead(400, { "Content-Type": "text/plain" });
815
+ res.end(`invalid ${name} — positive integer expected`);
816
+ return;
817
+ }
818
+ pagination.set(name, raw);
819
+ }
820
+ const beforeId = url.searchParams.get("beforeId");
821
+ if (beforeId !== null) {
822
+ if (!/^[\w.:-]+$/.test(beforeId)) {
823
+ res.writeHead(400, { "Content-Type": "text/plain" });
824
+ res.end("invalid beforeId — session id expected");
825
+ return;
826
+ }
827
+ pagination.set("beforeId", beforeId);
828
+ }
829
+ const known = await listKnownWorkspaces(projectsDbPath);
830
+ if (!known.some((p) => p.workspacePath === workspacePath)) {
831
+ res.writeHead(403, { "Content-Type": "text/plain" });
832
+ res.end("unknown project");
833
+ return;
834
+ }
835
+ try {
836
+ let entry = findServeInstance(workspacePath);
837
+ if (!entry) {
838
+ // "serve": a background listing must not pop a terminal window
839
+ // (ADR-0015) — only session-create does (ADR-0016). joinIncubation
840
+ // makes concurrent listings (and a listing racing a create) share
841
+ // one spawn instead of doubling bridges.
842
+ await joinIncubation(workspacePath, "serve");
843
+ entry = findServeInstance(workspacePath);
844
+ }
845
+ if (!entry) {
846
+ throw new Error("serve bridge did not register");
847
+ }
848
+ const qs = pagination.toString();
849
+ const payload = await fetchBridgeSessions(entry.port, qs ? `?${qs}` : "");
850
+ res.writeHead(200, {
851
+ "Content-Type": "application/json",
852
+ "Cache-Control": "no-store",
853
+ });
854
+ res.end(JSON.stringify({
855
+ workspacePath,
856
+ instance: { id: entry.id, origin: entry.origin },
857
+ ...(Array.isArray(payload.sessions)
858
+ ? { sessions: payload.sessions }
859
+ : { sessions: [] }),
860
+ nextCursor: payload.nextCursor ?? null,
861
+ }));
862
+ }
863
+ catch (e) {
864
+ res.writeHead(502, { "Content-Type": "text/plain" });
865
+ res.end(e instanceof Error ? e.message : "session list failed");
866
+ }
867
+ return;
868
+ }
869
+ // POST /api/instances — create (or reuse) a bridge for one known project.
870
+ // Session-create incubates a VISIBLE interactive REPL in the user's
871
+ // terminal (ADR-0016, macOS; ZCODE_ACP_HUB_TERMINAL=0 / headless falls
872
+ // back to the detached `zcode-acp serve` of ADR-0014). The hub waits for
873
+ // the bridge's heartbeat registration; the bridge lives its own life
874
+ // afterwards (a terminal REPL until the window closes, a serve bridge on
875
+ // its idle timer). Dedupe: one serve-origin instance per workspace — an
876
+ // existing live one is reused, and concurrent POSTs join the same
877
+ // in-flight incubation instead of double-spawning. Accepted bound: a hub
878
+ // restart clears the instance table, so a POST inside the bridges' ≤10s
879
+ // re-registration window may incubate a duplicate — harmless (both serve,
880
+ // future POSTs dedupe, an idle one exits in 10min).
531
881
  if (url.pathname === "/api/instances" && req.method === "POST") {
532
882
  if (!authorized(req, url, token)) {
533
883
  res.writeHead(401, { "Content-Type": "text/plain" });
@@ -555,24 +905,11 @@ export function startHub(options) {
555
905
  res.end(JSON.stringify({ id: existing.id, reused: true }));
556
906
  return;
557
907
  }
558
- let incubation = serveIncubations.get(workspacePath);
559
- if (!incubation) {
560
- incubation = incubateServe(workspacePath);
561
- serveIncubations.set(workspacePath, incubation);
562
- // Drop the entry once settled (identity-checked — a newer incubation
563
- // may already have replaced it). The catch keeps the DERIVED promise
564
- // handled; the original is awaited by its creating POST.
565
- incubation
566
- .catch(() => undefined)
567
- .finally(() => {
568
- if (serveIncubations.get(workspacePath) === incubation) {
569
- serveIncubations.delete(workspacePath);
570
- }
571
- });
572
- }
573
- else {
574
- log(`hub: joining the incubating serve bridge for ${workspacePath}`);
575
- }
908
+ // Session-create incubates a VISIBLE terminal REPL (ADR-0016) — the
909
+ // user gets a real local CLI window, not an invisible daemon; the
910
+ // detached serve spawn is the fallback (no GUI / gated off / macOS
911
+ // open failure).
912
+ const incubation = joinIncubation(workspacePath, "repl");
576
913
  try {
577
914
  const out = await incubation;
578
915
  res.writeHead(200, { "Content-Type": "application/json" });