zcode-acp-server 0.74.0 → 0.76.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 (40) hide show
  1. package/README.md +1 -1
  2. package/dist/code-fingerprint.json +2 -2
  3. package/dist/handlers/replay.d.ts.map +1 -1
  4. package/dist/handlers/replay.js +7 -0
  5. package/dist/handlers/replay.js.map +1 -1
  6. package/dist/handlers/session.d.ts +16 -0
  7. package/dist/handlers/session.d.ts.map +1 -1
  8. package/dist/handlers/session.js +21 -14
  9. package/dist/handlers/session.js.map +1 -1
  10. package/dist/quota/cliproxy/client.d.ts.map +1 -1
  11. package/dist/quota/cliproxy/client.js +10 -4
  12. package/dist/quota/cliproxy/client.js.map +1 -1
  13. package/dist/remote/hub-server.d.ts +5 -0
  14. package/dist/remote/hub-server.d.ts.map +1 -1
  15. package/dist/remote/hub-server.js +152 -34
  16. package/dist/remote/hub-server.js.map +1 -1
  17. package/dist/remote/session-close-endpoint.d.ts +8 -0
  18. package/dist/remote/session-close-endpoint.d.ts.map +1 -1
  19. package/dist/remote/session-close-endpoint.js +62 -4
  20. package/dist/remote/session-close-endpoint.js.map +1 -1
  21. package/dist/remote/settings-endpoint.d.ts.map +1 -1
  22. package/dist/remote/settings-endpoint.js +104 -2
  23. package/dist/remote/settings-endpoint.js.map +1 -1
  24. package/dist/settings/agents-config.d.ts +53 -18
  25. package/dist/settings/agents-config.d.ts.map +1 -1
  26. package/dist/settings/agents-config.js +161 -53
  27. package/dist/settings/agents-config.js.map +1 -1
  28. package/dist/settings/model-connectivity.d.ts +79 -0
  29. package/dist/settings/model-connectivity.d.ts.map +1 -0
  30. package/dist/settings/model-connectivity.js +170 -0
  31. package/dist/settings/model-connectivity.js.map +1 -0
  32. package/dist/settings/provider-config.d.ts +16 -2
  33. package/dist/settings/provider-config.d.ts.map +1 -1
  34. package/dist/settings/provider-config.js +68 -2
  35. package/dist/settings/provider-config.js.map +1 -1
  36. package/docs/BACKLOG.md +52 -0
  37. package/docs/REMOTE-CLIENTS.md +65 -27
  38. package/docs/REMOTE.md +7 -1
  39. package/docs/adr/0006-remote-session-close.md +14 -0
  40. package/package.json +1 -1
@@ -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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"}
@@ -77,6 +77,19 @@ const PROBE_TIMEOUT_MS = 2_000;
77
77
  */
78
78
  const PROBE_GRACE_MS = 8_000;
79
79
  const MAX_BODY_BYTES = 1024 * 1024;
80
+ /**
81
+ * Desktop Presence by recent request (amendment to ADR-0016): the desktop app
82
+ * polls GET /api/instances every 15s for its whole lifetime (DISCOVERY_REFRESH_MS
83
+ * in the app's runtime.rs), every call stamped with DESKTOP_CLIENT_HEADER. That
84
+ * poll is the one presence signal an IDLE desktop still emits: connection
85
+ * presence (`desktopPairs`) ends with the last proxied ACP connection, and a
86
+ * serve bridge self-exits once its last session closes — so a running desktop
87
+ * app with no open tab was invisible, and the next phone-originated
88
+ * create/resume opened a terminal window next to it. Three poll intervals of
89
+ * slack: the app schedules each poll AFTER the previous answer lands, so a
90
+ * slow hub stretches the gap past 15s.
91
+ */
92
+ const DESKTOP_PRESENCE_TTL_MS = 45_000;
80
93
  /** Constant-time token compare (hash both to equal length first). */
81
94
  function tokenEquals(a, b) {
82
95
  const ha = createHash("sha256").update(a).digest();
@@ -924,12 +937,29 @@ export function initializeClientName(data) {
924
937
  const info = params?.clientInfo;
925
938
  return typeof info?.name === "string" ? info.name : "";
926
939
  }
940
+ /**
941
+ * Desktop Presence request identity (amendment to ADR-0016): the desktop
942
+ * client stamps every hub HTTP call with its clientInfo name. Connection
943
+ * presence (`desktopPairs`) needs a proxied ACP connection to a bridge, so a
944
+ * cold hub (instances: 0, nothing to connect to) would open a terminal for
945
+ * the very FIRST session create — the request itself is the one presence
946
+ * signal that always exists. Every authorized stamped call also refreshes the
947
+ * recent-request presence (DESKTOP_PRESENCE_TTL_MS), which is what keeps an
948
+ * idle desktop — no open tab, no proxied connection — visible to the hub.
949
+ */
950
+ const DESKTOP_CLIENT_HEADER = "x-zcode-client";
951
+ function requestIsDesktopClient(req) {
952
+ const value = req.headers[DESKTOP_CLIENT_HEADER];
953
+ if (typeof value === "string")
954
+ return value === DESKTOP_CLIENT_NAME;
955
+ return Array.isArray(value) && value.includes(DESKTOP_CLIENT_NAME);
956
+ }
927
957
  /**
928
958
  * Start the hub. Resolves once listening; rejects on bind failure (including
929
959
  * EADDRINUSE when another hub already owns the port).
930
960
  */
931
961
  export function startHub(options) {
932
- const { port, host, token, webDir, heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, probeGraceMs = PROBE_GRACE_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;
962
+ const { port, host, token, webDir, heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, probeGraceMs = PROBE_GRACE_MS, idleExitMs = IDLE_EXIT_MS, pingIntervalMs = PING_INTERVAL_MS, tuiRegisterTimeoutMs = TUI_REGISTER_TIMEOUT_MS, desktopPresenceTtlMs = DESKTOP_PRESENCE_TTL_MS, onIdleExit, onRestart, codePaths = defaultCodePaths(), projectsDbPath, stayAliveCheck = () => remoteEnabledLive(), spawnServe = defaultSpawnServe, terminalLaunches, staleVoteCooldownMs = STALE_VOTE_COOLDOWN_MS, } = options;
933
963
  /** Frozen at hub start — the anchor the /api/upgrade signals compare to. */
934
964
  const startedAt = Date.now();
935
965
  /**
@@ -957,6 +987,27 @@ export function startHub(options) {
957
987
  * creates. Sessions that already have a window are never touched.
958
988
  */
959
989
  const desktopPairs = new Set();
990
+ /**
991
+ * Desktop Presence by recent request: when the last AUTHORIZED call stamped
992
+ * with the desktop identity landed (0 = never). Refreshed by handleHttp for
993
+ * every route — the 15s discovery poll is the steady source, but any
994
+ * stamped call counts.
995
+ */
996
+ let desktopSeenAt = 0;
997
+ /**
998
+ * The one incubation-time presence verdict (amendment to ADR-0016). Checked
999
+ * at the endpoint, once per request — a desktop client dropping mid-flight
1000
+ * never opens a window retroactively.
1001
+ */
1002
+ const desktopPresence = (req) => {
1003
+ if (desktopPairs.size > 0)
1004
+ return "connection";
1005
+ if (requestIsDesktopClient(req))
1006
+ return "request";
1007
+ if (desktopSeenAt > 0 && Date.now() - desktopSeenAt < desktopPresenceTtlMs)
1008
+ return "recent";
1009
+ return null;
1010
+ };
960
1011
  const timers = [];
961
1012
  /**
962
1013
  * Single-flight incubation per workspace (remote session-create,
@@ -990,7 +1041,7 @@ export function startHub(options) {
990
1041
  * requested session (ADR-0017), "serve" the detached headless bridge
991
1042
  * (background listing, ADR-0015).
992
1043
  */
993
- const incubateServe = async (workspacePath, kind, sessionId, tabTitle) => {
1044
+ const incubateServe = async (workspacePath, kind, sessionId, tabTitle, desktop = null) => {
994
1045
  // Async spawn failures (ENOENT — the dir vanished between the whitelist
995
1046
  // check and the spawn) arrive as an 'error' event with exitCode still
996
1047
  // null; without this flag the poll burns the full budget.
@@ -1082,20 +1133,23 @@ export function startHub(options) {
1082
1133
  // never churns when the bridge takes over the script's initial printf.
1083
1134
  env.ZCODE_ACP_TAB_TITLE = sessionTabTitle(tabTitle, workspacePath);
1084
1135
  }
1085
- // Desktop Presence (amendment to ADR-0016): while a desktop client is
1086
- // connected, session-create takes the headless serve form — the desktop
1087
- // app is the presenting surface, so a terminal window would re-render the
1088
- // same stream for nobody. Equivalent to the exhausted-terminal-list
1089
- // fallback: the launch list is empty, so openNextTerminal yields to the
1090
- // detached serve spawn at once. Checked once, here, at session-creation
1091
- // time (a desktop client dropping mid-flight never opens a window
1092
- // retroactively). Resume keeps its window (ADR-0017) and the listing was
1093
- // already headless; `ZCODE_ACP_HUB_TERMINAL=0` stays the static variant.
1094
- const desktopPresent = kind === "tui" && desktopPairs.size > 0;
1136
+ // Desktop Presence (amendment to ADR-0016): while the desktop app is
1137
+ // present — a proxied connection, the request's own identity header, or a
1138
+ // stamped call within the presence TTL (see desktopPresence) —
1139
+ // session-create AND resume incubate in the headless serve form: the
1140
+ // desktop app is the presenting surface, so a terminal window would
1141
+ // re-render the same stream for nobody (a resume's window was ADR-0017's
1142
+ // mobile surface; a desktop-originated resume loads the session itself
1143
+ // after attach). Equivalent to the exhausted-terminal-list fallback: the
1144
+ // launch list is empty, so openNextTerminal yields to the detached serve
1145
+ // spawn at once. The verdict was taken at the endpoint, once per request.
1146
+ // The listing was already headless; `ZCODE_ACP_HUB_TERMINAL=0` stays the
1147
+ // static variant.
1148
+ const desktopPresent = (kind === "tui" || kind === "resume") && desktop !== null;
1095
1149
  if (desktopPresent) {
1096
1150
  // warn (not log): the daemon's stderr is null in production, so the
1097
1151
  // diary is the only place this state change is observable.
1098
- warn(`hub: desktop client connected — incubating ${workspacePath} headless (Desktop Presence)`);
1152
+ warn(`hub: desktop app present (${desktop}) — incubating ${workspacePath} headless (Desktop Presence)`);
1099
1153
  }
1100
1154
  // Terminal preference list (ADR-0016 amendment): visible-terminal kinds
1101
1155
  // walk it — a failed launch moves down at once, a window that never
@@ -1272,14 +1326,14 @@ export function startHub(options) {
1272
1326
  * key carries the session id, so resumes of different sessions each get
1273
1327
  * their own window.
1274
1328
  */
1275
- const joinIncubation = (workspacePath, kind, sessionId, tabTitle) => {
1329
+ const joinIncubation = (workspacePath, kind, sessionId, tabTitle, desktop = null) => {
1276
1330
  const mapKey = kind === "resume" ? `${workspacePath}\u0000resume\u0000${sessionId ?? ""}` : workspacePath;
1277
1331
  const inflight = serveIncubations.get(mapKey);
1278
1332
  if (inflight && (inflight.kind === kind || kind === "serve")) {
1279
1333
  log(`hub: joining the incubating serve bridge for ${workspacePath}`);
1280
1334
  return inflight.promise;
1281
1335
  }
1282
- const promise = incubateServe(workspacePath, kind, sessionId, tabTitle);
1336
+ const promise = incubateServe(workspacePath, kind, sessionId, tabTitle, desktop);
1283
1337
  serveIncubations.set(mapKey, { kind, promise });
1284
1338
  // Drop the entry once settled (identity-checked — a newer incubation may
1285
1339
  // already have replaced it). The catch keeps the DERIVED promise handled;
@@ -1405,15 +1459,34 @@ export function startHub(options) {
1405
1459
  };
1406
1460
  socket.once("close", onClientGone);
1407
1461
  socket.once("error", onClientGone);
1408
- bridge.once("open", () => {
1409
- if (clientGone)
1410
- return; // terminated above; "open" can no longer fire
1462
+ // Dial-phase guard: exactly one of open/fail settles the upgrade. The
1463
+ // error path must stay attached AFTER the first emission (Bun's
1464
+ // WebSocket reports one failed connect through two dispatch paths —
1465
+ // once via the listener, once as an unhandled 'error' — observed live
1466
+ // 2026-10-20: a corpse dial logged the warn AND threw an uncaught
1467
+ // ErrorEvent), and must never fire into an upgraded socket.
1468
+ let settled = false;
1469
+ bridge.on("open", () => {
1470
+ if (settled || clientGone)
1471
+ return; // terminated above; "open" is moot
1472
+ settled = true;
1411
1473
  socket.removeListener("close", onClientGone);
1412
1474
  socket.removeListener("error", onClientGone);
1413
1475
  wss.handleUpgrade(req, socket, head, (client) => startProxy(client, bridge));
1414
1476
  });
1415
- bridge.once("error", (e) => {
1477
+ bridge.on("error", (e) => {
1478
+ if (settled)
1479
+ return;
1480
+ settled = true;
1416
1481
  warn(`hub: dial bridge :${entry.port} failed: ${e.message}`);
1482
+ // ECONNREFUSED on loopback means nothing is bound — the bridge process
1483
+ // is gone (a busy-but-alive bridge's OS backlog still accepts). Prune
1484
+ // now so the next client attempt reads "unknown instance" and falls
1485
+ // back to re-incubation, instead of dialing a corpse until the
1486
+ // heartbeat TTL expires. A wrong prune self-heals on the bridge's next
1487
+ // heartbeat.
1488
+ instances.delete(entry.id);
1489
+ idleSince = null; // re-arm the idle clock on membership change
1417
1490
  rejectUpgrade(socket, 502, "Bridge Unreachable");
1418
1491
  });
1419
1492
  });
@@ -1430,6 +1503,13 @@ export function startHub(options) {
1430
1503
  res.end("ok");
1431
1504
  return;
1432
1505
  }
1506
+ // Desktop Presence by recent request: every AUTHORIZED call the desktop
1507
+ // client stamped refreshes the TTL (the 15s discovery poll in practice).
1508
+ // Authorization first — a bare header must never suppress windows. The
1509
+ // routes re-check authorization themselves; this extra compare only runs
1510
+ // for stamped requests.
1511
+ if (requestIsDesktopClient(req) && authorized(req, url, token))
1512
+ desktopSeenAt = Date.now();
1433
1513
  if (url.pathname === "/api/instances" && req.method === "GET") {
1434
1514
  if (!authorized(req, url, token)) {
1435
1515
  res.writeHead(401, { "Content-Type": "text/plain" });
@@ -1813,6 +1893,9 @@ export function startHub(options) {
1813
1893
  // reuses a live serve bridge: the App flow always runs the history
1814
1894
  // listing first, which incubates a headless serve instance, and reuse
1815
1895
  // would mean the window can never open (the pre-amendment behaviour).
1896
+ // Desktop Presence inverts both rules (see desktopPresence below): no
1897
+ // window is coming, so the workspace's live serve bridge hosts the session
1898
+ // too, and only a workspace without one spawns — headless.
1816
1899
  // The hub waits for the bridge's heartbeat registration; the bridge lives
1817
1900
  // its own life afterwards (a terminal TUI until the window closes, a
1818
1901
  // serve bridge on its idle timer). Concurrent identical POSTs join one
@@ -1866,6 +1949,35 @@ export function startHub(options) {
1866
1949
  res.end("session deleted");
1867
1950
  return;
1868
1951
  }
1952
+ // Desktop Presence (amendment to ADR-0016): the desktop app is the
1953
+ // presenting surface, so no window opens — and with no window to be the
1954
+ // feature, the pre-amendment reuse is right again: the workspace's live
1955
+ // serve bridge (the listing's, an earlier create's, an open window's)
1956
+ // hosts this session too. The client attaches and session/new-s or
1957
+ // session/loads there, exactly as it already does after a headless
1958
+ // rescue — one bridge and one backend process per project instead of
1959
+ // one per session. A resume prefers the bridge that holds the session
1960
+ // LIVE (any origin): two bridges must never load one conversation. Only
1961
+ // a workspace with no live serve bridge still spawns, headless. An
1962
+ // unhealthy-marked bridge is skipped (a dead one would fail the dial
1963
+ // and only be pruned afterwards); a stalled-but-alive one costs a
1964
+ // duplicate, the status quo before this reuse.
1965
+ const desktop = desktopPresence(req);
1966
+ if (desktop) {
1967
+ const holder = resumeSid
1968
+ ? Array.from(instances.values()).find((e) => e.sessions.some((s) => s.sessionId === resumeSid))
1969
+ : undefined;
1970
+ const live = holder ?? findServeInstances(workspacePath).find((e) => e.unhealthySince === undefined);
1971
+ if (live) {
1972
+ // warn (not log): the diary is the only place this decision is
1973
+ // observable on the detached daemon (the phone's create that
1974
+ // deliberately did NOT open a window).
1975
+ warn(`hub: desktop app present (${desktop}) — ${resumeSid ? `resume ${resumeSid.slice(0, 8)}` : "create"} for ${workspacePath} reuses ${live.origin} bridge ${live.id}`);
1976
+ res.writeHead(200, { "Content-Type": "application/json" });
1977
+ res.end(JSON.stringify({ id: live.id, reused: true }));
1978
+ return;
1979
+ }
1980
+ }
1869
1981
  if (resumeSid) {
1870
1982
  // Tab title, best-effort: the App browsed this conversation through
1871
1983
  // the serve bridge's /sessions history, which is normally still live
@@ -1881,12 +1993,15 @@ export function startHub(options) {
1881
1993
  tabTitle = sanitizeTabTitle(row?.title);
1882
1994
  }
1883
1995
  // Resume never reuses the live serve bridge: the window IS the
1884
- // feature, and the answer must be the NEW instance so the attaching
1885
- // client and the terminal share one bridge (and one backend process)
1886
- // for the session. A bogus id still opens the window — the TUI
1887
- // shows the load failure and falls back to a fresh session, the same
1888
- // honesty as a window that dies early (ADR-0016 §4).
1889
- const incubation = joinIncubation(workspacePath, "resume", resumeSid, tabTitle);
1996
+ // feature for MOBILE-originated resumes (the attaching client and the
1997
+ // terminal share one bridge and one backend process for the session;
1998
+ // ADR-0017). Under Desktop Presence the reuse above already answered
1999
+ // when a live bridge existed; what reaches here incubates headless
2000
+ // (the client loads the session itself after attach). A bogus id
2001
+ // still gets its honest spawn — the surface shows the load failure
2002
+ // and falls back to a fresh session, the same honesty as a window
2003
+ // that dies early (ADR-0016 §4).
2004
+ const incubation = joinIncubation(workspacePath, "resume", resumeSid, tabTitle, desktop);
1890
2005
  try {
1891
2006
  const out = await incubation;
1892
2007
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1899,15 +2014,18 @@ export function startHub(options) {
1899
2014
  return;
1900
2015
  }
1901
2016
  // Session-create ALWAYS incubates a VISIBLE terminal TUI (ADR-0016 as
1902
- // amended): the App flow lists the project's history first, which
1903
- // incubates a headless serve bridge — reusing that instance (the
1904
- // original behaviour) meant a window could NEVER open once the project
1905
- // was browsed, and every create ran invisibly in the background. The
1906
- // answer is the NEW window's instance; concurrent identical POSTs join
1907
- // one incubation, and the detached headless serve spawn remains the
1908
- // fallback (no GUI / gated off / open failure). Clients that want a
1909
- // headless attach use the listing's instance id instead of this POST.
1910
- const incubation = joinIncubation(workspacePath, "tui");
2017
+ // amended) — UNLESS Desktop Presence applies (see desktopPresence; a
2018
+ // workspace with a live bridge was already answered above, so what
2019
+ // reaches here under presence spawns headless). The App flow lists the
2020
+ // project's history first, which incubates a headless serve bridge —
2021
+ // reusing that instance (the original behaviour) meant a window could
2022
+ // NEVER open once the project was browsed, and every create ran
2023
+ // invisibly in the background. The answer is the NEW spawn's instance;
2024
+ // concurrent identical POSTs join one incubation, and the detached
2025
+ // headless serve spawn remains the fallback (no GUI / gated off / open
2026
+ // failure). Clients that want a headless attach use the listing's
2027
+ // instance id instead of this POST.
2028
+ const incubation = joinIncubation(workspacePath, "tui", undefined, undefined, desktop);
1911
2029
  try {
1912
2030
  const out = await incubation;
1913
2031
  res.writeHead(200, { "Content-Type": "application/json" });