zcode-acp-server 0.30.0 → 0.31.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.
@@ -83,14 +83,31 @@ export interface HubOptions {
83
83
  * dist/cli.js — an interactive TUI in a visible terminal for
84
84
  * session-create and session-resume ("tui"; resume carries the requested
85
85
  * session in ZCODE_ACP_RESUME_SESSION), a detached headless serve bridge
86
- * for background queries ("serve").
86
+ * for background queries ("serve"). A "tui" attempt returns null when the
87
+ * launch fails; the caller then walks down the terminal preference list
88
+ * (terminalLaunches) and only spawns "serve" once every entry failed.
87
89
  */
88
90
  spawnServe?: (opts: {
89
91
  cwd: string;
90
92
  env: NodeJS.ProcessEnv;
91
93
  /** "tui" = visible terminal (session-create/-resume); "serve" = detached headless. */
92
94
  kind: "tui" | "serve";
93
- }) => ChildProcess | Promise<ChildProcess>;
95
+ /** The resolved launch for a "tui" attempt (one list entry). */
96
+ launch?: TerminalLaunch;
97
+ }) => ChildProcess | null | Promise<ChildProcess | null>;
98
+ /**
99
+ * Override the ordered terminal preference list the create/resume
100
+ * incubation walks (tests inject fixed entries). Default: resolved LIVE
101
+ * per incubation from remoteTerminalPrefs (config file > env), so editing
102
+ * the file takes effect without a hub restart.
103
+ */
104
+ terminalLaunches?: TerminalLaunch[];
105
+ /**
106
+ * How long after startup the hub ignores newer-bridge stale votes (tests
107
+ * shrink to 0). Default STALE_VOTE_COOLDOWN_MS — the loop breaker so a
108
+ * same-age respawn can never be voted into a restart churn.
109
+ */
110
+ staleVoteCooldownMs?: number;
94
111
  }
95
112
  export interface HubHandle {
96
113
  port: number;
@@ -149,6 +166,17 @@ export declare function resolveTerminalLaunch(env: NodeJS.ProcessEnv, prefs?: Te
149
166
  launch: TerminalLaunch;
150
167
  warning?: string;
151
168
  };
169
+ /**
170
+ * The ORDERED terminal preference list (ADR-0016 amendment): the hub walks
171
+ * it when a window fails — a launch that errors moves down the list at
172
+ * once, a window that never registers moves down at its registration
173
+ * timeout — and only the exhaustion of every entry falls back to headless.
174
+ * Resolution per entry mirrors resolveTerminalLaunch: the explicit command
175
+ * template (the universal escape hatch) replaces the whole list; otherwise
176
+ * a built-in launcher by normalized app name, else `open -a` passthrough.
177
+ * Disabled prefs resolve to [] (no visible-terminal attempts at all).
178
+ */
179
+ export declare function resolveTerminalLaunches(prefs: TerminalPrefs): TerminalLaunch[];
152
180
  /**
153
181
  * The .command script body. The incubation env MUST be embedded as exports:
154
182
  * the script runs in a fresh shell spawned by the terminal app, which
@@ -1 +1 @@
1
- {"version":3,"file":"hub-server.d.ts","sourceRoot":"","sources":["../../src/remote/hub-server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hub-server.d.ts","sourceRoot":"","sources":["../../src/remote/hub-server.ts"],"names":[],"mappings":"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"}
@@ -124,6 +124,13 @@ function validSessions(raw) {
124
124
  /** How long POST /api/instances waits for the spawned bridge to register. */
125
125
  const SERVE_REGISTER_TIMEOUT_MS = 10_000;
126
126
  const SERVE_REGISTER_POLL_MS = 300;
127
+ /**
128
+ * A freshly started hub ignores newer-bridge stale votes for this long —
129
+ * the loop breaker for a restart that re-spawned a hub of the same age
130
+ * (the vote would otherwise fire again immediately; at most one restart
131
+ * per cooldown window).
132
+ */
133
+ const STALE_VOTE_COOLDOWN_MS = 60_000;
127
134
  /** Register-origin parser: only "serve" is special; anything else is "editor". */
128
135
  function parseOrigin(raw) {
129
136
  return raw === "serve" ? "serve" : "editor";
@@ -183,17 +190,32 @@ const TERMINAL_APP_LAUNCHERS = {
183
190
  * name passes through to `open -a` unchanged.
184
191
  */
185
192
  export function resolveTerminalLaunch(env, prefs = remoteTerminalPrefs(env)) {
193
+ const launches = resolveTerminalLaunches(prefs);
194
+ return { launch: launches[0] ?? { kind: "openApp", app: "Terminal" } };
195
+ }
196
+ /**
197
+ * The ORDERED terminal preference list (ADR-0016 amendment): the hub walks
198
+ * it when a window fails — a launch that errors moves down the list at
199
+ * once, a window that never registers moves down at its registration
200
+ * timeout — and only the exhaustion of every entry falls back to headless.
201
+ * Resolution per entry mirrors resolveTerminalLaunch: the explicit command
202
+ * template (the universal escape hatch) replaces the whole list; otherwise
203
+ * a built-in launcher by normalized app name, else `open -a` passthrough.
204
+ * Disabled prefs resolve to [] (no visible-terminal attempts at all).
205
+ */
206
+ export function resolveTerminalLaunches(prefs) {
207
+ if (prefs.enabled === false)
208
+ return [];
186
209
  if (prefs.command)
187
- return { launch: { kind: "shell", command: prefs.command } };
188
- const raw = prefs.app ?? "";
189
- const name = raw
190
- .toLowerCase()
191
- .replace(/\.app$/, "")
192
- .replace(/\s+/g, "-");
193
- const launcher = TERMINAL_APP_LAUNCHERS[name];
194
- if (launcher)
195
- return { launch: launcher };
196
- return { launch: { kind: "openApp", app: raw || "Terminal" } };
210
+ return [{ kind: "shell", command: prefs.command }];
211
+ const names = prefs.terminals?.length ? prefs.terminals : prefs.app ? [prefs.app] : ["Terminal"];
212
+ return names.map((raw) => {
213
+ const name = raw
214
+ .toLowerCase()
215
+ .replace(/\.app$/, "")
216
+ .replace(/\s+/g, "-");
217
+ return TERMINAL_APP_LAUNCHERS[name] ?? { kind: "openApp", app: raw };
218
+ });
197
219
  }
198
220
  /**
199
221
  * The .command script body. The incubation env MUST be embedded as exports:
@@ -316,30 +338,22 @@ export function writeTuiScript(workspace, cliJs, env) {
316
338
  /**
317
339
  * Spawn session-create as a VISIBLE interactive TUI (ADR-0016): write a
318
340
  * throwaway .command script (`cd <project> && exec node cli.js`) and hand it
319
- * to a terminal (resolveTerminalLaunch) — the user gets a real terminal
320
- * window running the local CLI instead of an invisible daemon. The TUI's
321
- * bridge child inherits the remote ENV, so the incubation registers exactly
322
- * like a serve bridge; closing the window ends the bridge (its lifetime
323
- * follows the terminal, the ADR-0001 anchor). Returns null when a terminal
324
- * can't be used — platform, gated off via ZCODE_ACP_HUB_TERMINAL, or the
325
- * open failing (headless/SSH) — and the caller falls back to the detached
326
- * serve spawn.
341
+ * to ONE resolved terminal launch — the user gets a real terminal window
342
+ * running the local CLI instead of an invisible daemon. The TUI's bridge
343
+ * child inherits the remote ENV, so the incubation registers exactly like a
344
+ * serve bridge; closing the window ends the bridge (its lifetime follows
345
+ * the terminal, the ADR-0001 anchor). Returns null when the launch fails —
346
+ * platform without GUI support or the open erroring (pre-1.3 Ghostty,
347
+ * denied Automation permission) — and the CALLER walks down the terminal
348
+ * preference list, falling back to the detached serve spawn only after the
349
+ * list is exhausted.
327
350
  */
328
351
  async function spawnTerminalTui(opts) {
329
352
  if (process.platform !== "darwin")
330
353
  return null;
331
- // Terminal prefs are read LIVE here (config file first, env fallback): the
332
- // hub outlives the shells that configured it, and its birth env rotates
333
- // between GUI editors and interactive shells — only the file is stable.
334
- // Set remote.terminal.enabled=false in ~/.config/zcode-acp/config.json (or
335
- // ZCODE_ACP_HUB_TERMINAL=0) to keep remote session-create headless.
336
- if (!remoteTerminalPrefs(process.env).enabled)
337
- return null;
338
354
  const cliJs = fileURLToPath(new URL("../cli.js", import.meta.url));
339
355
  const script = writeTuiScript(opts.cwd, cliJs, opts.env);
340
- const { launch, warning } = resolveTerminalLaunch(process.env);
341
- if (warning)
342
- warn(`hub: ${warning}`);
356
+ const { launch } = opts;
343
357
  let argv;
344
358
  if (launch.kind === "shell") {
345
359
  const rendered = launch.command.includes("{script}")
@@ -413,11 +427,16 @@ async function spawnTerminalTui(opts) {
413
427
  * detached headless serve bridge otherwise (the ADR-0014 original — also the
414
428
  * fallback whenever the terminal window can't be opened).
415
429
  */
430
+ /**
431
+ * One spawn attempt. "tui" = one resolved terminal launch (returns null when
432
+ * the launch fails — the caller walks the preference list); "serve" = the
433
+ * detached headless bridge (the documented no-GUI fallback, ADR-0014).
434
+ */
416
435
  async function defaultSpawnServe(opts) {
417
436
  if (opts.kind === "tui") {
418
- const viaTerminal = await spawnTerminalTui(opts);
419
- if (viaTerminal)
420
- return viaTerminal;
437
+ if (!opts.launch)
438
+ return null;
439
+ return spawnTerminalTui(opts);
421
440
  }
422
441
  // dist/remote/hub-server.js → dist/cli.js (one level up).
423
442
  const cliJs = fileURLToPath(new URL("../cli.js", import.meta.url));
@@ -675,7 +694,7 @@ async function diskCodeIsNewer(paths, startedAt, runningFingerprint) {
675
694
  * EADDRINUSE when another hub already owns the port).
676
695
  */
677
696
  export function startHub(options) {
678
- const { port, host, token, heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, idleExitMs = IDLE_EXIT_MS, pingIntervalMs = PING_INTERVAL_MS, tuiRegisterTimeoutMs = TUI_REGISTER_TIMEOUT_MS, onIdleExit, onRestart, codePaths = defaultCodePaths(), projectsDbPath, stayAliveCheck = () => remoteEnabledLive(), spawnServe = defaultSpawnServe, } = options;
697
+ const { port, host, token, heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, idleExitMs = IDLE_EXIT_MS, pingIntervalMs = PING_INTERVAL_MS, tuiRegisterTimeoutMs = TUI_REGISTER_TIMEOUT_MS, onIdleExit, onRestart, codePaths = defaultCodePaths(), projectsDbPath, stayAliveCheck = () => remoteEnabledLive(), spawnServe = defaultSpawnServe, terminalLaunches, staleVoteCooldownMs = STALE_VOTE_COOLDOWN_MS, } = options;
679
698
  /** Frozen at hub start — the anchor the /api/upgrade signals compare to. */
680
699
  const startedAt = Date.now();
681
700
  /**
@@ -790,26 +809,84 @@ export function startHub(options) {
790
809
  // (terminals otherwise name the tab after the process, "node").
791
810
  env.ZCODE_ACP_TAB_TITLE = tabTitle ?? path.basename(workspacePath);
792
811
  }
812
+ // Terminal preference list (ADR-0016 amendment): visible-terminal kinds
813
+ // walk it — a failed launch moves down at once, a window that never
814
+ // registers moves down at its timeout — and only the exhausted list
815
+ // falls back to the detached headless spawn. "serve" never pops a window.
816
+ const launches = kind === "serve"
817
+ ? []
818
+ : (terminalLaunches ?? resolveTerminalLaunches(remoteTerminalPrefs(process.env)));
819
+ let li = 0;
820
+ /**
821
+ * Try launches[li..] until one OPENS; advance li past every failure and
822
+ * leave it parked on the first opened attempt's successor. null = the
823
+ * remaining list failed/exhausted (caller falls back to headless).
824
+ */
825
+ const openNextTerminal = async () => {
826
+ while (li < launches.length) {
827
+ const attempt = launches[li];
828
+ li++;
829
+ try {
830
+ const opened = await spawnServe({
831
+ cwd: workspacePath,
832
+ kind: "tui",
833
+ env,
834
+ launch: attempt,
835
+ });
836
+ if (opened) {
837
+ opened.once("error", (e) => {
838
+ spawnError = e;
839
+ });
840
+ spawnError = null;
841
+ log(`hub: spawned terminal TUI via ${attempt.kind === "shell"
842
+ ? "command template"
843
+ : attempt.kind === "ghosttyScript"
844
+ ? `Ghostty (${attempt.app})`
845
+ : attempt.kind === "warpUri"
846
+ ? `Warp (${attempt.app})`
847
+ : `open -a ${attempt.app}`} for ${workspacePath} (pid ${opened.pid})`);
848
+ return opened;
849
+ }
850
+ warn(`hub: terminal launch ${li}/${launches.length} failed to open — trying the next preference`);
851
+ }
852
+ catch (e) {
853
+ warn(`hub: terminal launch threw: ${e instanceof Error ? e.message : String(e)}`);
854
+ }
855
+ }
856
+ return null;
857
+ };
793
858
  try {
794
- // Resume shares session-create's visible-terminal surface (and its
795
- // detached-serve fallback for headless machines); only a background
796
- // listing spawns the headless form — it must never pop a window.
797
- child = await spawnServe({
798
- cwd: workspacePath,
799
- kind: kind === "serve" ? "serve" : "tui",
800
- env,
801
- });
802
- child.once("error", (e) => {
803
- spawnError = e;
804
- });
859
+ const opened = await openNextTerminal();
860
+ if (opened) {
861
+ child = opened;
862
+ }
863
+ else {
864
+ // Resume shares session-create's visible-terminal surface (and its
865
+ // detached-serve fallback for headless machines); only a background
866
+ // listing spawns the headless form — it must never pop a window.
867
+ const spawned = await spawnServe({
868
+ cwd: workspacePath,
869
+ kind: "serve",
870
+ env,
871
+ });
872
+ if (!spawned)
873
+ throw new Error("headless spawn returned null");
874
+ child = spawned;
875
+ child.once("error", (e) => {
876
+ spawnError = e;
877
+ });
878
+ }
805
879
  }
806
880
  catch (e) {
807
881
  warn(`hub: serve spawn failed: ${e instanceof Error ? e.message : String(e)}`);
808
882
  throw new Error("serve bridge spawn failed");
809
883
  }
810
- log(`hub: spawned ${kind === "resume" ? "resume TUI" : kind === "tui" ? "terminal TUI" : "serve bridge"} for ${workspacePath} (pid ${child.pid})`);
884
+ log(`hub: incubating ${kind === "resume" ? "resume TUI" : kind === "tui" ? "terminal TUI" : "serve bridge"} for ${workspacePath} (pid ${child.pid})`);
811
885
  const budget = kind === "serve" ? SERVE_REGISTER_TIMEOUT_MS : tuiRegisterTimeoutMs;
812
- const deadline = Date.now() + budget;
886
+ let deadline = Date.now() + budget;
887
+ // One headless rescue per incubation — a second timeout means the rescue
888
+ // bridge also failed to register, and retrying again just burns time.
889
+ let rescued = false;
813
890
  // A visible-terminal incubation (create OR resume) runs NEXT TO the serve
814
891
  // bridge the listing already incubated, and several incubations can race
815
892
  // for one workspace: the poll matches only a registration that is neither
@@ -851,6 +928,46 @@ export function startHub(options) {
851
928
  return { id: fallback.id, reused: true };
852
929
  }
853
930
  }
931
+ // Next terminal preference: the window opened but never registered —
932
+ // on a locked/display-asleep screen Ghostty's AppleScript tab opens
933
+ // fine but its surface init fails (error.OutOfMemory, ghostty
934
+ // #10712), leaving the tab dead on its error page (the fake child
935
+ // never exits, so the fast-fail above can't fire). Walk down the
936
+ // user's list with a fresh budget before giving up on windows.
937
+ if (kind !== "serve" && li < launches.length) {
938
+ warn(`hub: terminal TUI for ${workspacePath} never registered — trying the next terminal preference`);
939
+ const next = await openNextTerminal();
940
+ if (next) {
941
+ deadline = Date.now() + budget;
942
+ continue;
943
+ }
944
+ }
945
+ // Headless rescue (once, after the terminal list is exhausted):
946
+ // spawn the SAME incubation headlessly — the session-bind env rides
947
+ // along (create's pre-generated id, resume's target) and the nonce is
948
+ // unchanged, so this very loop matches the rescue bridge's
949
+ // registration and answers with a working session either way.
950
+ if (kind !== "serve" && !rescued) {
951
+ rescued = true;
952
+ warn(`hub: ${kind === "resume" ? "resume TUI" : "terminal TUI"} for ${workspacePath} ` +
953
+ `never registered — rescuing the request with a headless serve bridge`);
954
+ try {
955
+ const rescuedChild = await spawnServe({ cwd: workspacePath, kind: "serve", env });
956
+ if (!rescuedChild)
957
+ throw new Error("rescue spawn returned null");
958
+ child = rescuedChild;
959
+ child.once("error", (e) => {
960
+ spawnError = e;
961
+ });
962
+ spawnError = null;
963
+ }
964
+ catch (e) {
965
+ warn(`hub: headless rescue spawn failed: ${e instanceof Error ? e.message : String(e)}`);
966
+ throw new Error("serve bridge did not register in time");
967
+ }
968
+ deadline = Date.now() + budget;
969
+ continue;
970
+ }
854
971
  warn(`hub: serve bridge for ${workspacePath} never registered`);
855
972
  throw new Error("serve bridge did not register in time");
856
973
  }
@@ -1490,34 +1607,33 @@ export function startHub(options) {
1490
1607
  instances.delete(id);
1491
1608
  idleSince = null; // re-arm the idle clock on membership change
1492
1609
  }
1493
- // Self-upgrade: a bridge running DIFFERENT code than this hub just
1494
- // registered, so this process is stale. Reply first (the bridge
1495
- // re-spawns the hub from its own dist when it sees `restarting`), then
1496
- // exit. Fingerprints are compared when both sides have one — content
1497
- // cannot lie the way a version frozen from package.json can (a hub
1498
- // born between a release merge and the dist rebuild). The lexical
1499
- // tie-break (bridge fp must sort HIGHER) gives coexisting dists on one
1500
- // machine a stable winner, so two differently-built bridges can never
1501
- // ping-pong the hub. Bridges without a fingerprint fall back to the
1502
- // legacy strictly-newer version check.
1503
- const bridgeFingerprint = typeof body.codeFingerprint === "string" && body.codeFingerprint
1504
- ? body.codeFingerprint
1505
- : null;
1506
- const fingerprintStale = bridgeFingerprint !== null &&
1507
- hubFingerprint !== null &&
1508
- bridgeFingerprint !== hubFingerprint &&
1509
- bridgeFingerprint > hubFingerprint;
1510
- const versionStale = bridgeFingerprint === null &&
1511
- hubFingerprint === null &&
1512
- typeof body.version === "string" &&
1513
- compareVersions(body.version, AGENT_INFO.version) > 0;
1514
- const stale = url.pathname === "/api/register" && (fingerprintStale || versionStale);
1610
+ // Self-upgrade: a bridge with a strictly NEWER VERSION just registered,
1611
+ // so this process is stale. Reply first (the bridge re-spawns the hub
1612
+ // from its own dist when it sees `restarting`), then exit.
1613
+ //
1614
+ // Content fingerprints are deliberately NOT a vote signal: a hash has
1615
+ // no ordering (a lexically-higher fingerprint can be OLDER code), and
1616
+ // the hub's respawn source is not guaranteed to be the voting bridge's
1617
+ // dist — differing fingerprints could only ever produce a
1618
+ // non-converging restart loop (observed live: a coexisting dist voted
1619
+ // every respawned hub stale, ~5s churn, all instances wiped).
1620
+ // Fingerprint comparison belongs to /api/upgrade ONLY, where the
1621
+ // comparison target is the DISK a respawn would load — self-negating
1622
+ // by construction.
1623
+ //
1624
+ // Cooldown: a fresh hub ignores stale votes for the first minute. If
1625
+ // the respawn race ever produces another same-age hub, this breaks the
1626
+ // loop — at most one restart per STALE_VOTE_COOLDOWN_MS.
1627
+ const newerVersion = typeof body.version === "string" && compareVersions(body.version, AGENT_INFO.version) > 0;
1628
+ const cooledDown = Date.now() - startedAt > staleVoteCooldownMs;
1629
+ const stale = url.pathname === "/api/register" && newerVersion && cooledDown;
1515
1630
  res.writeHead(200, { "Content-Type": "application/json" });
1516
1631
  res.end(JSON.stringify(stale ? { ok: true, restarting: true } : { ok: true }));
1517
1632
  if (stale) {
1518
- restartSoon(fingerprintStale
1519
- ? `hub: bridge fingerprint ${bridgeFingerprint.slice(0, 12)} differs from hub ${hubFingerprint.slice(0, 12)} — restarting to upgrade`
1520
- : `hub: bridge ${body.version} is newer than hub ${AGENT_INFO.version} — restarting to upgrade`);
1633
+ restartSoon(`hub: bridge ${body.version} is newer than hub ${AGENT_INFO.version} — restarting to upgrade`);
1634
+ }
1635
+ else if (url.pathname === "/api/register" && newerVersion && !cooledDown) {
1636
+ log("hub: newer-bridge vote ignored (restart cooldown after a recent restart)");
1521
1637
  }
1522
1638
  return;
1523
1639
  }