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.
- package/README.md +1 -1
- package/dist/code-fingerprint.json +2 -2
- package/dist/handlers/replay.d.ts.map +1 -1
- package/dist/handlers/replay.js +7 -0
- package/dist/handlers/replay.js.map +1 -1
- package/dist/handlers/session.d.ts +16 -0
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +21 -14
- package/dist/handlers/session.js.map +1 -1
- package/dist/quota/cliproxy/client.d.ts.map +1 -1
- package/dist/quota/cliproxy/client.js +10 -4
- package/dist/quota/cliproxy/client.js.map +1 -1
- package/dist/remote/hub-server.d.ts +5 -0
- package/dist/remote/hub-server.d.ts.map +1 -1
- package/dist/remote/hub-server.js +152 -34
- package/dist/remote/hub-server.js.map +1 -1
- package/dist/remote/session-close-endpoint.d.ts +8 -0
- package/dist/remote/session-close-endpoint.d.ts.map +1 -1
- package/dist/remote/session-close-endpoint.js +62 -4
- package/dist/remote/session-close-endpoint.js.map +1 -1
- package/dist/remote/settings-endpoint.d.ts.map +1 -1
- package/dist/remote/settings-endpoint.js +104 -2
- package/dist/remote/settings-endpoint.js.map +1 -1
- package/dist/settings/agents-config.d.ts +53 -18
- package/dist/settings/agents-config.d.ts.map +1 -1
- package/dist/settings/agents-config.js +161 -53
- package/dist/settings/agents-config.js.map +1 -1
- package/dist/settings/model-connectivity.d.ts +79 -0
- package/dist/settings/model-connectivity.d.ts.map +1 -0
- package/dist/settings/model-connectivity.js +170 -0
- package/dist/settings/model-connectivity.js.map +1 -0
- package/dist/settings/provider-config.d.ts +16 -2
- package/dist/settings/provider-config.d.ts.map +1 -1
- package/dist/settings/provider-config.js +68 -2
- package/dist/settings/provider-config.js.map +1 -1
- package/docs/BACKLOG.md +52 -0
- package/docs/REMOTE-CLIENTS.md +65 -27
- package/docs/REMOTE.md +7 -1
- package/docs/adr/0006-remote-session-close.md +14 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hub-server.d.ts","sourceRoot":"","sources":["../../src/remote/hub-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AA8B9D,OAAO,EAA8B,KAAK,OAAO,EAAE,MAAM,IAAI,CAAC;AAK9D,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAuB9E,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;;;OAIG;IACH,SAAS,CAAC,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC;IAC/B;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE;QAClB,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;QACvB,sFAAsF;QACtF,IAAI,EAAE,KAAK,GAAG,OAAO,CAAC;QACtB,gEAAgE;QAChE,MAAM,CAAC,EAAE,cAAc,CAAC;KACzB,KAAK,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IACzD;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,cAAc,EAAE,CAAC;IACpC;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;
|
|
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
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
|
|
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
|
|
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
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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.
|
|
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
|
|
1885
|
-
//
|
|
1886
|
-
//
|
|
1887
|
-
//
|
|
1888
|
-
//
|
|
1889
|
-
|
|
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)
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
|
|
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" });
|