zcode-acp-server 0.75.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.
package/README.md CHANGED
@@ -315,7 +315,7 @@ Thanks to everyone who has contributed (updated automatically from the
315
315
  <a href="https://github.com/william0wang/zcode-acp/graphs/contributors">
316
316
  <!-- release-please re-stamps &v= on every release: the new URL busts the
317
317
  contrib.rocks and GitHub camo image caches. -->
318
- <img src="https://contrib.rocks/image?repo=william0wang/zcode-acp&v=0.75.0" alt="Contributors" /> <!-- x-release-please-version -->
318
+ <img src="https://contrib.rocks/image?repo=william0wang/zcode-acp&v=0.76.0" alt="Contributors" /> <!-- x-release-please-version -->
319
319
  </a>
320
320
 
321
321
  ## Related Projects
@@ -1,4 +1,4 @@
1
1
  {
2
- "fingerprint": "b4386246ef9f520a5a03732f83d1ad3744d394a9528dddeff18165074dfc9432",
3
- "fileCount": 126
2
+ "fingerprint": "6515a0790990fa74f6e478857d6b64cceefe7f4dbfff637d0c969cff6d73d211",
3
+ "fileCount": 127
4
4
  }
@@ -57,6 +57,11 @@ export interface HubOptions {
57
57
  * shrink it).
58
58
  */
59
59
  tuiRegisterTimeoutMs?: number;
60
+ /**
61
+ * How long after its last stamped HTTP call the desktop client still counts
62
+ * as present (default DESKTOP_PRESENCE_TTL_MS; tests shrink it).
63
+ */
64
+ desktopPresenceTtlMs?: number;
60
65
  /**
61
66
  * Fires when the hub decided it should restart onto newer on-disk code
62
67
  * (a newer bridge registered, or POST /api/upgrade found the dist newer).
@@ -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();
@@ -930,7 +943,9 @@ export function initializeClientName(data) {
930
943
  * presence (`desktopPairs`) needs a proxied ACP connection to a bridge, so a
931
944
  * cold hub (instances: 0, nothing to connect to) would open a terminal for
932
945
  * the very FIRST session create — the request itself is the one presence
933
- * signal that always exists.
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.
934
949
  */
935
950
  const DESKTOP_CLIENT_HEADER = "x-zcode-client";
936
951
  function requestIsDesktopClient(req) {
@@ -944,7 +959,7 @@ function requestIsDesktopClient(req) {
944
959
  * EADDRINUSE when another hub already owns the port).
945
960
  */
946
961
  export function startHub(options) {
947
- 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;
948
963
  /** Frozen at hub start — the anchor the /api/upgrade signals compare to. */
949
964
  const startedAt = Date.now();
950
965
  /**
@@ -972,6 +987,27 @@ export function startHub(options) {
972
987
  * creates. Sessions that already have a window are never touched.
973
988
  */
974
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
+ };
975
1011
  const timers = [];
976
1012
  /**
977
1013
  * Single-flight incubation per workspace (remote session-create,
@@ -1005,7 +1041,7 @@ export function startHub(options) {
1005
1041
  * requested session (ADR-0017), "serve" the detached headless bridge
1006
1042
  * (background listing, ADR-0015).
1007
1043
  */
1008
- const incubateServe = async (workspacePath, kind, sessionId, tabTitle, desktopCreate = false) => {
1044
+ const incubateServe = async (workspacePath, kind, sessionId, tabTitle, desktop = null) => {
1009
1045
  // Async spawn failures (ENOENT — the dir vanished between the whitelist
1010
1046
  // check and the spawn) arrive as an 'error' event with exitCode still
1011
1047
  // null; without this flag the poll burns the full budget.
@@ -1097,23 +1133,23 @@ export function startHub(options) {
1097
1133
  // never churns when the bridge takes over the script's initial printf.
1098
1134
  env.ZCODE_ACP_TAB_TITLE = sessionTabTitle(tabTitle, workspacePath);
1099
1135
  }
1100
- // Desktop Presence (amendment to ADR-0016): while a desktop client is
1101
- // connected — OR the request itself is the desktop client (header
1102
- // identity; a cold hub has no proxied connection yet) — session-create
1103
- // AND resume incubate in the headless serve form: the desktop app is the
1104
- // presenting surface, so a terminal window would re-render the same
1105
- // stream for nobody (a resume's window was ADR-0017's mobile surface; a
1106
- // desktop-originated resume loads the session itself after attach).
1107
- // Equivalent to the exhausted-terminal-list fallback: the launch list is
1108
- // empty, so openNextTerminal yields to the detached serve spawn at once.
1109
- // Checked once, here, at session-creation time (a desktop client dropping
1110
- // mid-flight never opens a window retroactively). The listing was already
1111
- // headless; `ZCODE_ACP_HUB_TERMINAL=0` stays the static variant.
1112
- const desktopPresent = (kind === "tui" || kind === "resume") && (desktopPairs.size > 0 || desktopCreate);
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;
1113
1149
  if (desktopPresent) {
1114
1150
  // warn (not log): the daemon's stderr is null in production, so the
1115
1151
  // diary is the only place this state change is observable.
1116
- warn(`hub: desktop client connected — incubating ${workspacePath} headless (Desktop Presence${desktopCreate ? ", request identity" : ""})`);
1152
+ warn(`hub: desktop app present (${desktop}) — incubating ${workspacePath} headless (Desktop Presence)`);
1117
1153
  }
1118
1154
  // Terminal preference list (ADR-0016 amendment): visible-terminal kinds
1119
1155
  // walk it — a failed launch moves down at once, a window that never
@@ -1290,14 +1326,14 @@ export function startHub(options) {
1290
1326
  * key carries the session id, so resumes of different sessions each get
1291
1327
  * their own window.
1292
1328
  */
1293
- const joinIncubation = (workspacePath, kind, sessionId, tabTitle, desktopCreate = false) => {
1329
+ const joinIncubation = (workspacePath, kind, sessionId, tabTitle, desktop = null) => {
1294
1330
  const mapKey = kind === "resume" ? `${workspacePath}\u0000resume\u0000${sessionId ?? ""}` : workspacePath;
1295
1331
  const inflight = serveIncubations.get(mapKey);
1296
1332
  if (inflight && (inflight.kind === kind || kind === "serve")) {
1297
1333
  log(`hub: joining the incubating serve bridge for ${workspacePath}`);
1298
1334
  return inflight.promise;
1299
1335
  }
1300
- const promise = incubateServe(workspacePath, kind, sessionId, tabTitle, desktopCreate);
1336
+ const promise = incubateServe(workspacePath, kind, sessionId, tabTitle, desktop);
1301
1337
  serveIncubations.set(mapKey, { kind, promise });
1302
1338
  // Drop the entry once settled (identity-checked — a newer incubation may
1303
1339
  // already have replaced it). The catch keeps the DERIVED promise handled;
@@ -1467,6 +1503,13 @@ export function startHub(options) {
1467
1503
  res.end("ok");
1468
1504
  return;
1469
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();
1470
1513
  if (url.pathname === "/api/instances" && req.method === "GET") {
1471
1514
  if (!authorized(req, url, token)) {
1472
1515
  res.writeHead(401, { "Content-Type": "text/plain" });
@@ -1850,6 +1893,9 @@ export function startHub(options) {
1850
1893
  // reuses a live serve bridge: the App flow always runs the history
1851
1894
  // listing first, which incubates a headless serve instance, and reuse
1852
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.
1853
1899
  // The hub waits for the bridge's heartbeat registration; the bridge lives
1854
1900
  // its own life afterwards (a terminal TUI until the window closes, a
1855
1901
  // serve bridge on its idle timer). Concurrent identical POSTs join one
@@ -1903,6 +1949,35 @@ export function startHub(options) {
1903
1949
  res.end("session deleted");
1904
1950
  return;
1905
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
+ }
1906
1981
  if (resumeSid) {
1907
1982
  // Tab title, best-effort: the App browsed this conversation through
1908
1983
  // the serve bridge's /sessions history, which is normally still live
@@ -1920,12 +1995,13 @@ export function startHub(options) {
1920
1995
  // Resume never reuses the live serve bridge: the window IS the
1921
1996
  // feature for MOBILE-originated resumes (the attaching client and the
1922
1997
  // terminal share one bridge and one backend process for the session;
1923
- // ADR-0017) — but a desktop-originated resume incubates headless (the
1924
- // desktop loads the session itself after attach; Desktop Presence
1925
- // request identity). A bogus id still gets its honest spawn — the
1926
- // surface shows the load failure and falls back to a fresh session,
1927
- // the same honesty as a window that dies early (ADR-0016 §4).
1928
- const incubation = joinIncubation(workspacePath, "resume", resumeSid, tabTitle, requestIsDesktopClient(req));
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);
1929
2005
  try {
1930
2006
  const out = await incubation;
1931
2007
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1938,18 +2014,18 @@ export function startHub(options) {
1938
2014
  return;
1939
2015
  }
1940
2016
  // Session-create ALWAYS incubates a VISIBLE terminal TUI (ADR-0016 as
1941
- // amended) — UNLESS Desktop Presence applies (a connected desktop
1942
- // client, or this request carrying the desktop identity header — the
1943
- // cold-start gap: a fresh hub has no proxied connection to key
1944
- // presence on). The App flow lists the project's history first, which
1945
- // incubates a headless serve bridge — reusing that instance (the
1946
- // original behaviour) meant a window could NEVER open once the project
1947
- // was browsed, and every create ran invisibly in the background. The
1948
- // answer is the NEW spawn's instance; concurrent identical POSTs join
1949
- // one incubation, and the detached headless serve spawn remains the
1950
- // fallback (no GUI / gated off / open failure). Clients that want a
1951
- // headless attach use the listing's instance id instead of this POST.
1952
- const incubation = joinIncubation(workspacePath, "tui", undefined, undefined, requestIsDesktopClient(req));
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);
1953
2029
  try {
1954
2030
  const out = await incubation;
1955
2031
  res.writeHead(200, { "Content-Type": "application/json" });