zcode-acp-server 0.18.3 → 0.19.1
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/dist/bin/hub.d.ts +7 -0
- package/dist/bin/hub.d.ts.map +1 -1
- package/dist/bin/hub.js +21 -0
- package/dist/bin/hub.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/remote/config.d.ts +18 -0
- package/dist/remote/config.d.ts.map +1 -1
- package/dist/remote/config.js +13 -1
- package/dist/remote/config.js.map +1 -1
- package/dist/remote/endpoint.d.ts.map +1 -1
- package/dist/remote/endpoint.js +4 -0
- package/dist/remote/endpoint.js.map +1 -1
- package/dist/remote/hub-sandbox.d.ts +60 -0
- package/dist/remote/hub-sandbox.d.ts.map +1 -0
- package/dist/remote/hub-sandbox.js +155 -0
- package/dist/remote/hub-sandbox.js.map +1 -0
- package/dist/remote/hub-server.d.ts +49 -3
- package/dist/remote/hub-server.d.ts.map +1 -1
- package/dist/remote/hub-server.js +381 -44
- package/dist/remote/hub-server.js.map +1 -1
- package/dist/remote/session-list-endpoint.d.ts +51 -0
- package/dist/remote/session-list-endpoint.d.ts.map +1 -0
- package/dist/remote/session-list-endpoint.js +153 -0
- package/dist/remote/session-list-endpoint.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +8 -0
- package/dist/server.js.map +1 -1
- package/docs/REMOTE-CLIENTS.md +88 -26
- package/docs/REMOTE.md +35 -9
- package/package.json +1 -1
|
@@ -56,18 +56,64 @@ export interface HubOptions {
|
|
|
56
56
|
*/
|
|
57
57
|
projectsDbPath?: string;
|
|
58
58
|
/**
|
|
59
|
-
* Override how
|
|
60
|
-
* inject a fake). Default: this node + this package's dist/cli.js
|
|
59
|
+
* Override how the remote session-create endpoints spawn a bridge (tests
|
|
60
|
+
* inject a fake). Default: this node + this package's dist/cli.js — an
|
|
61
|
+
* interactive REPL in a visible terminal for session-create ("repl"),
|
|
62
|
+
* a detached headless serve bridge for background queries ("serve").
|
|
61
63
|
*/
|
|
62
64
|
spawnServe?: (opts: {
|
|
63
65
|
cwd: string;
|
|
64
66
|
env: NodeJS.ProcessEnv;
|
|
65
|
-
|
|
67
|
+
/** "repl" = visible terminal (session-create); "serve" = detached headless. */
|
|
68
|
+
kind: "repl" | "serve";
|
|
69
|
+
}) => ChildProcess | Promise<ChildProcess>;
|
|
66
70
|
}
|
|
67
71
|
export interface HubHandle {
|
|
68
72
|
port: number;
|
|
69
73
|
close(): Promise<void>;
|
|
70
74
|
}
|
|
75
|
+
/** How the hub hands the .command script to a terminal (ADR-0016). */
|
|
76
|
+
export type TerminalLaunch =
|
|
77
|
+
/** ZCODE_ACP_HUB_TERMINAL_COMMAND: a shell command; `{script}` (if present)
|
|
78
|
+
* is replaced by the quoted script path, else the path is appended. */
|
|
79
|
+
{
|
|
80
|
+
kind: "shell";
|
|
81
|
+
command: string;
|
|
82
|
+
}
|
|
83
|
+
/** `.command`-executing apps (Terminal, iTerm): `open -a <app> <script>`. */
|
|
84
|
+
| {
|
|
85
|
+
kind: "openApp";
|
|
86
|
+
app: string;
|
|
87
|
+
}
|
|
88
|
+
/** Terminals driven by their own CLI: `open -na <app> --args <args> <sh>
|
|
89
|
+
* <script>` — args come first, the script program is appended. */
|
|
90
|
+
| {
|
|
91
|
+
kind: "openAppArgs";
|
|
92
|
+
app: string;
|
|
93
|
+
args: string[];
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Pick how to open the REPL script. The hub is a background daemon — its env
|
|
97
|
+
* carries no terminal and macOS has no default-terminal setting — so nothing
|
|
98
|
+
* is detected. Priority: the explicit command template (the universal escape
|
|
99
|
+
* hatch) → a built-in launcher by app name (aliases are case- and
|
|
100
|
+
* `.app`-suffix-insensitive) → plain Terminal.app. Warp gets a special warning
|
|
101
|
+
* because it accepts the open but cannot run the script; any other unmatched
|
|
102
|
+
* name passes through to `open -a` unchanged.
|
|
103
|
+
*/
|
|
104
|
+
export declare function resolveTerminalLaunch(env: NodeJS.ProcessEnv): {
|
|
105
|
+
launch: TerminalLaunch;
|
|
106
|
+
warning?: string;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* The .command script body. The incubation env MUST be embedded as exports:
|
|
110
|
+
* the script runs in a fresh shell spawned by the terminal app, which
|
|
111
|
+
* inherits launchd's environment — NOT the hub's — so without them the REPL
|
|
112
|
+
* would boot as a plain local session and never register back (the
|
|
113
|
+
* incubation would stall into its timeout). Everything ZCODE_ACP_* travels;
|
|
114
|
+
* values are single-quoted.
|
|
115
|
+
*/
|
|
116
|
+
export declare function terminalReplScript(cwd: string, cliJs: string, env: NodeJS.ProcessEnv): string;
|
|
71
117
|
/** Reset the quota cache (test helper). */
|
|
72
118
|
export declare function resetQuotaCacheForTest(): void;
|
|
73
119
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hub-server.d.ts","sourceRoot":"","sources":["../../src/remote/hub-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"hub-server.d.ts","sourceRoot":"","sources":["../../src/remote/hub-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AA0B9D,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;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,CAAC;IACxB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE;QAClB,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;QACvB,+EAA+E;QAC/E,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,KAAK,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAwHD,sEAAsE;AACtE,MAAM,MAAM,cAAc;AACxB;uEACuE;AACrE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AACpC,6EAA6E;GAC3E;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE;AAClC;kEACkE;GAChE;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AA0BzD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG;IAC7D,MAAM,EAAE,cAAc,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAkBA;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,MAAM,CAY7F;AAgJD,2CAA2C;AAC3C,wBAAgB,sBAAsB,IAAI,IAAI,CAG7C;AA4JD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,UAAU,GAAG;IAAE,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,GAAG,OAAO,CAAC,SAAS,CAAC,CA6uB9F"}
|
|
@@ -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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
-
|
|
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
|
|
299
|
-
*
|
|
300
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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" });
|