magpie-agent-console 0.1.0 → 1.0.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/index.mjs CHANGED
@@ -1,244 +1,453 @@
1
- // Agent Console v1.0 — magpie 插件入口(§9.1)
2
- // 职责:确保后台 daemon(独立 Node 进程)在运行并复用;不在宿主里跑业务。
3
- // 模块加载即执行一次「确保 daemon」(沿用旧版防白屏做法);
4
- // magpie 回调 server(input, options) 传入不同 port 时重启 daemon 迁移端口。
5
-
6
- import { spawn, execFile } from "node:child_process";
7
- import { readFileSync, existsSync, openSync, closeSync, statSync } from "node:fs";
8
- import { mkdir, readFile } from "node:fs/promises";
1
+ // Agent Console — Magpie plugin entry (loaded by Magpie's Bun plugin host).
2
+ //
3
+ // Lifecycle and token handoff (design after the PR #535 review):
4
+ // - Importing this module has no side effects: no port, no process, no file writes.
5
+ // - When Magpie calls server(input, options): generate two random secrets for this host — the gate key (written to
6
+ // page/session.js) and the daemon token (handed to the daemon page only via the # fragment of the gate's 302, never written to any static file) —
7
+ // and bind a tiny "gate" (lib/gate.mjs) on 127.0.0.1:<port> (default 3488). Only after a successful bind is
8
+ // page/session.js written atomically; the plugin page (sandbox iframe) reads it from the page directory Magpie serves. If the bind fails (port in use)
9
+ // it retries every 2 seconds in the background; server() itself returns immediately and never blocks the plugin host from loading.
10
+ // - The daemon is started only when the plugin page navigates to the gate's /open?k=<key> ("start on page open"): a non-detached child,
11
+ // stdin is a lifeline pipe and it gets --parent-pid; later /open calls reuse the same child.
12
+ // - The daemon exits by itself when the host exits (stdin closes or the parent disappears) or after an idle timeout (60 minutes);
13
+ // stop() closes the gate, ends the daemon and deletes session.js.
14
+ // - Note: Magpie's plugin host treats the return value of server() as an OpenCode hooks object and never calls stop();
15
+ // the host calls process.exit() after Magpie closes its stdin. So what really runs is the synchronous exit safety net:
16
+ // close the daemon's lifeline pipe and delete the session.js this host wrote.
17
+ // - No SIGINT/SIGTERM handlers (they would swallow the host's default termination behavior).
18
+
19
+ import { execFile, spawn } from "node:child_process";
20
+ import { randomBytes } from "node:crypto";
21
+ import {
22
+ appendFileSync,
23
+ closeSync,
24
+ mkdirSync,
25
+ openSync,
26
+ readFileSync,
27
+ renameSync,
28
+ unlinkSync,
29
+ writeFileSync,
30
+ } from "node:fs";
31
+ import http from "node:http";
9
32
  import { homedir } from "node:os";
10
- import { join, dirname } from "node:path";
33
+ import { dirname, join } from "node:path";
11
34
  import { fileURLToPath } from "node:url";
12
- import http from "node:http";
35
+ import { createGate } from "./lib/gate.mjs";
36
+ import { NODE_TOO_OLD_MESSAGE, requireNode } from "./lib/pathscan.mjs";
13
37
 
14
38
  const here = dirname(fileURLToPath(import.meta.url));
15
- const pkg = JSON.parse(readFileSync(join(here, "package.json"), "utf8"));
16
- const VERSION = String(pkg.version || "0.0.0");
17
-
18
- const dataRoot = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
19
- const dataDir = join(dataRoot, "magpie", "agent-console");
20
- const daemonJsonPath = join(dataDir, "daemon.json");
21
39
  const DEFAULT_PORT = 3488;
40
+ // Longest wait for the daemon's ready line (Windows cold start + antivirus scanning can be slow)
41
+ const READY_TIMEOUT_MS = 15000;
42
+ const BIND_RETRY_MS = 2000;
22
43
 
23
- function envPort() {
24
- const p = Number(process.env.AGENT_CONSOLE_PORT || "");
25
- return Number.isInteger(p) && p >= 1 && p <= 65535 ? p : DEFAULT_PORT;
44
+ // Paths are computed again inside server(): tests change environment variables after import
45
+ function paths() {
46
+ const dataRoot = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
47
+ const dataDir = join(dataRoot, "magpie", "agent-console");
48
+ return {
49
+ dataRoot,
50
+ dataDir,
51
+ logsDir: join(dataDir, "logs"),
52
+ spawnLog: join(dataDir, "logs", "daemon-spawn.log"),
53
+ daemonJson: join(dataDir, "daemon.json"),
54
+ pageDir: process.env.AGENT_CONSOLE_PAGE_DIR || join(here, "page"),
55
+ };
26
56
  }
27
57
 
58
+ function sleep(ms) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+
62
+ // ESRCH = gone; EPERM = exists but we may not signal it (common on Windows), counts as alive
28
63
  function pidAlive(pid) {
29
64
  try {
30
65
  process.kill(pid, 0);
31
66
  return true;
67
+ } catch (error) {
68
+ return error?.code === "EPERM";
69
+ }
70
+ }
71
+
72
+ // A failed log write (directory deleted, disk full) must never make the host's flow throw
73
+ function makeLogger(p) {
74
+ return (message) => {
75
+ try {
76
+ mkdirSync(p.logsDir, { recursive: true });
77
+ appendFileSync(p.spawnLog, `[${new Date().toISOString()}] ${message}\n`);
78
+ } catch {}
79
+ };
80
+ }
81
+
82
+ function readDaemonInfo(p) {
83
+ try {
84
+ return JSON.parse(readFileSync(p.daemonJson, "utf8"));
32
85
  } catch {
33
- return false;
86
+ return null;
34
87
  }
35
88
  }
36
89
 
37
90
  function ping(port, token) {
38
- return new Promise((res) => {
39
- const req = http.get(
40
- { host: "127.0.0.1", port, path: "/api/ping", headers: { "x-agent-console": token }, timeout: 2000 },
41
- (r) => {
42
- let b = "";
43
- r.on("data", (c) => (b += c));
44
- r.on("end", () => {
91
+ return new Promise((resolve) => {
92
+ const request = http.get(
93
+ {
94
+ host: "127.0.0.1",
95
+ port,
96
+ path: "/api/ping",
97
+ headers: { "x-agent-console": token },
98
+ timeout: 1500,
99
+ },
100
+ (response) => {
101
+ let body = "";
102
+ response.on("data", (chunk) => {
103
+ body += chunk;
104
+ });
105
+ response.on("end", () => {
45
106
  try {
46
- res(JSON.parse(b));
107
+ resolve(JSON.parse(body));
47
108
  } catch {
48
- res(null);
109
+ resolve(null);
49
110
  }
50
111
  });
51
- }
112
+ },
52
113
  );
53
- req.on("error", () => res(null));
54
- req.on("timeout", () => {
55
- req.destroy();
56
- res(null);
114
+ request.on("error", () => resolve(null));
115
+ request.on("timeout", () => {
116
+ request.destroy();
117
+ resolve(null);
57
118
  });
58
119
  });
59
120
  }
60
121
 
61
- function sleep(ms) {
62
- return new Promise((r) => setTimeout(r, ms));
63
- }
64
-
65
- function findNode() {
66
- return new Promise((res) => {
67
- execFile("where.exe", ["node"], { windowsHide: true, timeout: 4000 }, (e, out) => {
68
- if (e) return res(process.execPath);
69
- const hits = String(out)
70
- .split(/\r?\n/)
71
- .map((s) => s.trim())
72
- .filter(Boolean);
73
- res(hits.find((h) => /node\.exe$/i.test(h)) || hits[0] || process.execPath);
74
- });
75
- });
122
+ async function killPid(pid) {
123
+ if (process.platform === "win32") {
124
+ await new Promise((resolve) =>
125
+ execFile("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, () => resolve()),
126
+ );
127
+ return;
128
+ }
129
+ try {
130
+ process.kill(pid, "SIGTERM");
131
+ } catch {}
132
+ for (let i = 0; i < 20 && pidAlive(pid); i++) await sleep(100);
133
+ if (pidAlive(pid)) {
134
+ try {
135
+ process.kill(pid, "SIGKILL");
136
+ } catch {}
137
+ }
76
138
  }
77
139
 
78
- async function shutdownViaWs(port, token) {
140
+ // The 0.1.0 daemon was detached and may still hold 3488 after an upgrade.
141
+ // Only after it answered a ping with the token from daemon.json (confirming it really is our daemon, guarding against pid reuse)
142
+ // do we first shut it down gracefully via WS app.shutdown, then force-kill it if it's still there.
143
+ async function shutdownLegacy(info, log) {
144
+ if (!info?.pid || !info?.port || !info?.token || !pidAlive(info.pid)) return;
145
+ const answer = await ping(info.port, info.token);
146
+ if (!answer?.ok || answer.pid !== info.pid) return;
147
+ log(`stopping legacy daemon pid ${info.pid} on port ${info.port}`);
79
148
  try {
80
149
  const { WebSocket } = await import("ws");
81
- await new Promise((res) => {
82
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
83
- const t = setTimeout(() => {
84
- try {
85
- ws.terminate();
86
- } catch {}
87
- res();
150
+ await new Promise((resolve) => {
151
+ const socket = new WebSocket(`ws://127.0.0.1:${info.port}/ws`);
152
+ const timer = setTimeout(() => {
153
+ socket.terminate();
154
+ resolve();
88
155
  }, 3000);
89
- ws.on("open", () => {
90
- ws.send(JSON.stringify({ t: "hello", token, v: 1 }));
91
- });
92
- ws.on("message", (d) => {
93
- let m;
156
+ socket.on("open", () => socket.send(JSON.stringify({ t: "hello", token: info.token, v: 1 })));
157
+ socket.on("message", (data) => {
158
+ let message;
94
159
  try {
95
- m = JSON.parse(String(d));
160
+ message = JSON.parse(String(data));
96
161
  } catch {
97
162
  return;
98
163
  }
99
- if (m.t === "hello.ok") {
100
- ws.send(JSON.stringify({ t: "req", id: 1, m: "app.shutdown", p: {} }));
164
+ if (message.t === "hello.ok") {
165
+ socket.send(JSON.stringify({ t: "req", id: 1, m: "app.shutdown", p: {} }));
101
166
  }
102
- if (m.t === "res" && m.id === 1) {
103
- clearTimeout(t);
104
- ws.close();
105
- res();
167
+ if (message.t === "res" && message.id === 1) {
168
+ clearTimeout(timer);
169
+ socket.close();
170
+ resolve();
106
171
  }
107
172
  });
108
- ws.on("error", () => {
109
- clearTimeout(t);
110
- res();
111
- });
112
- ws.on("close", () => {
113
- clearTimeout(t);
114
- res();
173
+ socket.on("error", () => {
174
+ clearTimeout(timer);
175
+ resolve();
115
176
  });
116
177
  });
117
178
  } catch {}
179
+ for (let i = 0; i < 30 && pidAlive(info.pid); i++) await sleep(100);
180
+ if (pidAlive(info.pid)) await killPid(info.pid);
118
181
  }
119
182
 
120
- async function killPid(pid) {
121
- if (process.platform === "win32") {
122
- await new Promise((res) => {
123
- execFile("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, timeout: 5000 }, () => res());
124
- });
125
- } else {
126
- try {
127
- process.kill(pid, "SIGTERM");
128
- } catch {}
129
- await sleep(1500);
130
- try {
131
- process.kill(pid, "SIGKILL");
132
- } catch {}
133
- }
183
+ // session.js is a script file holding only the gate port (a number) and the gate key (hex); "<" is still escaped just in case.
184
+ // Any page that knows Magpie's page path can read it with <script src>, so the daemon token must never go here.
185
+ function writeSession(pageDir, port, key) {
186
+ mkdirSync(pageDir, { recursive: true });
187
+ const json = JSON.stringify({ v: 2, port, key }).replace(/</g, "\\u003c");
188
+ const temp = join(pageDir, `.session-${process.pid}-${randomBytes(4).toString("hex")}.tmp`);
189
+ writeFileSync(temp, `globalThis.AgentConsoleSession = ${json};\n`, { mode: 0o600 });
190
+ renameSync(temp, join(pageDir, "session.js"));
134
191
  }
135
192
 
136
- async function shutdownDaemon(info) {
137
- if (info?.port && info?.token) await shutdownViaWs(info.port, info.token);
138
- for (let i = 0; i < 12 && pidAlive(info.pid); i++) await sleep(250);
139
- if (pidAlive(info.pid)) await killPid(info.pid);
193
+ function removeSession(pageDir, key) {
194
+ const sessionPath = join(pageDir, "session.js");
195
+ try {
196
+ // Another host may already have written its own session.js; delete only ours
197
+ if (readFileSync(sessionPath, "utf8").includes(key)) unlinkSync(sessionPath);
198
+ } catch {}
140
199
  }
141
200
 
142
- async function startDaemon(port) {
143
- const node = await findNode();
144
- await mkdir(join(dataDir, "logs"), { recursive: true });
145
- const logFd = openSync(join(dataDir, "logs", "daemon-spawn.log"), "a");
146
- try {
147
- const child = spawn(
148
- node,
149
- [join(here, "daemon", "dist", "main.js"), "--port", String(port), "--data", dataRoot],
150
- { detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true }
151
- );
152
- child.unref();
153
- return child;
154
- } finally {
155
- closeSync(logFd);
156
- }
201
+ // Synchronous safety net when the host process exits (Magpie never calls stop(); this is the only cleanup that runs on a normal exit).
202
+ // The daemon also exits by itself when stdin closes / the parent disappears; this only speeds it up;
203
+ // on Windows child.kill() is a hard kill that leaves the daemon no time to end the Agent processes it started, so we only close the pipe.
204
+ // Also delete the session.js this host wrote so no stale key is left behind.
205
+ const liveChildren = new Set();
206
+ const liveSessions = new Map(); // pageDir -> key
207
+ let exitHookInstalled = false;
208
+ function installExitHook() {
209
+ if (exitHookInstalled) return;
210
+ exitHookInstalled = true;
211
+ process.once("exit", () => {
212
+ for (const [pageDir, key] of liveSessions) removeSession(pageDir, key);
213
+ for (const child of liveChildren) {
214
+ try {
215
+ child.stdin?.destroy();
216
+ } catch {}
217
+ if (process.platform !== "win32") {
218
+ try {
219
+ child.kill("SIGTERM");
220
+ } catch {}
221
+ }
222
+ }
223
+ });
157
224
  }
158
225
 
159
- function readDaemonJson() {
160
- try {
161
- return JSON.parse(readFileSync(daemonJsonPath, "utf8"));
162
- } catch {
163
- return null;
226
+ function createHost(port) {
227
+ const p = paths();
228
+ const log = makeLogger(p);
229
+ // Gate key: written to session.js; even if leaked it only buys one navigation to the daemon
230
+ const key = randomBytes(32).toString("hex");
231
+ // Daemon token: given to the daemon only via an environment variable, and to the page via the 302's # fragment
232
+ const token = randomBytes(32).toString("hex");
233
+ let stopped = false;
234
+ let child = null;
235
+ let childPort = null;
236
+ let starting = null;
237
+ let legacyChecked = false;
238
+ let wakeBindLoop = null;
239
+
240
+ async function checkLegacyOnce() {
241
+ if (legacyChecked) return;
242
+ legacyChecked = true;
243
+ const info = readDaemonInfo(p);
244
+ if (info && (!child || info.pid !== child.pid)) await shutdownLegacy(info, log);
164
245
  }
165
- }
166
246
 
167
- function buildStamp() {
168
- try {
169
- return String(statSync(join(here, "daemon", "dist", "main.js")).mtimeMs);
170
- } catch {
171
- return "0";
247
+ async function spawnDaemon() {
248
+ await checkLegacyOnce();
249
+ if (stopped) throw new Error("plugin host stopped");
250
+ mkdirSync(p.logsDir, { recursive: true });
251
+ let node;
252
+ try {
253
+ // Inside Magpie process.execPath is Bun; the daemon and node-pty need real Node
254
+ node = requireNode();
255
+ } catch (error) {
256
+ log(`cannot spawn daemon: ${error.message} (PATH=${process.env.PATH ?? ""})`);
257
+ throw error;
258
+ }
259
+ log(`spawning daemon with ${node}`);
260
+ const stderrFd = openSync(p.spawnLog, "a");
261
+ let spawned;
262
+ try {
263
+ spawned = spawn(
264
+ node,
265
+ [
266
+ join(here, "daemon", "dist", "main.js"),
267
+ "--port",
268
+ "0",
269
+ "--data",
270
+ p.dataRoot,
271
+ "--parent-pid",
272
+ String(process.pid),
273
+ ],
274
+ {
275
+ // The token goes through an environment variable, not the command line (command lines are visible to other users on the machine)
276
+ env: { ...process.env, AGENT_CONSOLE_TOKEN: token },
277
+ stdio: ["pipe", "pipe", stderrFd],
278
+ windowsHide: true,
279
+ },
280
+ );
281
+ } finally {
282
+ closeSync(stderrFd);
283
+ }
284
+ child = spawned;
285
+ childPort = null;
286
+ liveChildren.add(spawned);
287
+ installExitHook();
288
+
289
+ return new Promise((resolve, reject) => {
290
+ let settled = false;
291
+ let stdoutBuffer = "";
292
+ const finish = (error, port) => {
293
+ if (settled) return;
294
+ settled = true;
295
+ clearTimeout(timer);
296
+ if (error) {
297
+ // Failed start: clear the state so the next /open starts it again
298
+ try {
299
+ spawned.kill();
300
+ } catch {}
301
+ if (child === spawned) {
302
+ child = null;
303
+ childPort = null;
304
+ }
305
+ reject(error);
306
+ } else {
307
+ resolve(port);
308
+ }
309
+ };
310
+ const timer = setTimeout(() => finish(new Error("daemon ready timeout")), READY_TIMEOUT_MS);
311
+
312
+ // A spawn failure (ENOENT etc.) must not crash the Bun plugin host: log it and fail this /open
313
+ spawned.on("error", (spawnError) => {
314
+ log(`spawn error: ${spawnError}`);
315
+ finish(spawnError);
316
+ });
317
+ spawned.stdin.on("error", () => {});
318
+ // Keep reading stdout into the log so the pipe never fills up; the ready line may span chunks, so buffer and parse by line
319
+ spawned.stdout.on("data", (chunk) => {
320
+ const text = String(chunk);
321
+ try {
322
+ appendFileSync(p.spawnLog, text);
323
+ } catch {}
324
+ stdoutBuffer += text;
325
+ const lines = stdoutBuffer.split(/\r?\n/);
326
+ stdoutBuffer = lines.pop() ?? "";
327
+ for (const line of lines) {
328
+ let ready;
329
+ try {
330
+ ready = JSON.parse(line);
331
+ } catch {
332
+ continue;
333
+ }
334
+ if (ready?.agentConsoleReady && Number.isInteger(ready.port) && ready.port > 0) {
335
+ if (child === spawned) childPort = ready.port;
336
+ finish(null, ready.port);
337
+ }
338
+ }
339
+ });
340
+ spawned.once("exit", (code, signal) => {
341
+ log(`daemon pid ${spawned.pid} exited (code ${code}, signal ${signal})`);
342
+ liveChildren.delete(spawned);
343
+ if (child === spawned) {
344
+ child = null;
345
+ childPort = null;
346
+ }
347
+ const error = new Error(`daemon exited before ready (code ${code})`);
348
+ // The daemon uses exit code 3 for a Node version that's too old
349
+ if (code === 3) error.userMessage = NODE_TOO_OLD_MESSAGE;
350
+ finish(error);
351
+ });
352
+ });
353
+ }
354
+
355
+ // Called on every gate /open: reuse the child if it's alive, start one only if not; concurrent calls share one start
356
+ async function ensureDaemon() {
357
+ if (stopped) throw new Error("plugin host stopped");
358
+ if (child && child.exitCode === null && child.signalCode === null && childPort) {
359
+ return { port: childPort };
360
+ }
361
+ if (!starting) {
362
+ starting = spawnDaemon().finally(() => {
363
+ starting = null;
364
+ });
365
+ }
366
+ return { port: await starting };
367
+ }
368
+
369
+ const gate = createGate({ port, key, token, ensureDaemon });
370
+
371
+ function waitForRetry() {
372
+ return new Promise((resolve) => {
373
+ const timer = setTimeout(resolve, BIND_RETRY_MS);
374
+ wakeBindLoop = () => {
375
+ clearTimeout(timer);
376
+ resolve();
377
+ };
378
+ });
172
379
  }
173
- }
174
380
 
175
- async function ensureDaemon(port) {
176
- const stamp = buildStamp();
177
- // a. 已有 daemon.json 且进程活着:按端口与版本决定复用或重启
178
- const info = readDaemonJson();
179
- if (info?.pid && pidAlive(info.pid)) {
180
- const alive = await ping(info.port ?? port, info.token);
181
- if (alive?.ok) {
182
- if (info.port === port && alive.version === VERSION && alive.buildStamp === stamp) {
183
- return { ok: true, reused: true, port };
381
+ async function bindLoop() {
382
+ const loggedCodes = new Set();
383
+ while (!stopped) {
384
+ try {
385
+ await gate.listen();
386
+ } catch (error) {
387
+ const code = error?.code || String(error);
388
+ if (!loggedCodes.has(code)) {
389
+ loggedCodes.add(code);
390
+ log(`gate port ${port} bind failed (${code}); retrying every ${BIND_RETRY_MS / 1000}s`);
391
+ }
392
+ // Port in use: if a detached 0.1.0 daemon is holding it, confirm its identity and shut it down
393
+ if (code === "EADDRINUSE" && !legacyChecked) {
394
+ const info = readDaemonInfo(p);
395
+ if (info?.port === port) await checkLegacyOnce();
396
+ }
397
+ if (stopped) return;
398
+ await waitForRetry();
399
+ continue;
400
+ }
401
+ // stop() may be called while listen is in progress: close immediately and don't write session.js
402
+ if (stopped) {
403
+ await gate.close();
404
+ return;
405
+ }
406
+ try {
407
+ writeSession(p.pageDir, port, key);
408
+ liveSessions.set(p.pageDir, key);
409
+ installExitHook();
410
+ log(`gate listening on 127.0.0.1:${port}; session.js written`);
411
+ } catch (error) {
412
+ log(`writing session.js failed: ${error}`);
184
413
  }
185
- // 版本不同或端口不同:关掉重启
186
- await shutdownDaemon(info);
187
- } else {
188
- // 进程活着但 ping 不通:直接杀(可能是别的进程复用了 pid,taskkill 只杀该 pid 树)
189
- await killPid(info.pid);
414
+ return;
190
415
  }
191
416
  }
192
417
 
193
- // b. 启动新 daemon
194
- const child = await startDaemon(port);
195
- for (let i = 0; i < 40; i++) {
196
- await sleep(250);
197
- const info2 = readDaemonJson();
198
- if (info2?.token) {
199
- const p = await ping(port, info2.token);
200
- if (p?.ok) return { ok: true, reused: false, port, pid: info2.pid };
418
+ const binding = bindLoop().catch((error) => log(`gate failed: ${error}`));
419
+
420
+ async function stopDaemon() {
421
+ const current = child;
422
+ if (!current) return;
423
+ // Close the lifeline first so the daemon exits gracefully (ending the Agent processes it started); force-kill after a timeout
424
+ try {
425
+ current.stdin.end();
426
+ } catch {}
427
+ for (let i = 0; i < 30 && current.exitCode === null && current.signalCode === null; i++) await sleep(100);
428
+ if (current.exitCode === null && current.signalCode === null && current.pid) {
429
+ await killPid(current.pid);
201
430
  }
202
- if (child.exitCode !== null) break;
203
431
  }
204
- return { ok: false, reason: `daemon 启动超时(端口 ${port})` };
205
- }
206
432
 
207
- let ensuring = null;
208
- function ensure(port) {
209
- if (!ensuring) {
210
- ensuring = ensureDaemon(port)
211
- .then((r) => {
212
- if (!r.ok) console.error("[agent-console] daemon 启动失败:" + r.reason);
213
- return r;
214
- })
215
- .catch((e) => {
216
- console.error("[agent-console] daemon 启动异常:" + String(e));
217
- return { ok: false, reason: String(e) };
218
- })
219
- .finally(() => {
220
- ensuring = null;
221
- });
433
+ async function stop() {
434
+ stopped = true;
435
+ wakeBindLoop?.();
436
+ await binding;
437
+ await gate.close();
438
+ if (starting) await starting.catch(() => {});
439
+ await stopDaemon();
440
+ removeSession(p.pageDir, key);
441
+ if (liveSessions.get(p.pageDir) === key) liveSessions.delete(p.pageDir);
222
442
  }
223
- return ensuring;
224
- }
225
443
 
226
- // 模块加载即确保 daemon(防白屏:带路页跳转前服务就要在)
227
- const bootPort = envPort();
228
- void ensure(bootPort);
444
+ return { stop };
445
+ }
229
446
 
230
- // magpie OpenCode v1 契约:server(input, options)
231
447
  async function server(_input, options) {
232
- const port = Number(options?.port || "") || bootPort;
233
- if (port !== bootPort) {
234
- await ensure(port); // 端口迁移:ensureDaemon 会按 daemon.json 判断重启
235
- }
236
- return {
237
- async stop() {
238
- const info = readDaemonJson();
239
- if (info?.pid && pidAlive(info.pid)) await shutdownDaemon(info);
240
- },
241
- };
448
+ const requested = Number(options?.port) || Number(process.env.AGENT_CONSOLE_PORT) || DEFAULT_PORT;
449
+ const port = Number.isInteger(requested) && requested > 0 && requested <= 65535 ? requested : DEFAULT_PORT;
450
+ return createHost(port);
242
451
  }
243
452
 
244
453
  export default { id: "agent-console", server };
package/lib/gate.d.mts ADDED
@@ -0,0 +1,9 @@
1
+ import type { Server } from "node:http";
2
+ export function failureHtml(error: unknown): string;
3
+ export function createGate(options: {
4
+ port: number;
5
+ key: string;
6
+ token: string;
7
+ ensureDaemon: () => Promise<{ port: number }>;
8
+ host?: string;
9
+ }): { listen(): Promise<Server>; close(): Promise<void> };