magpie-agent-console 0.1.0 → 1.1.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,537 @@
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;
43
+ const SESSION_BEAT_MS = 4000;
44
+ // A port still busy after this many tries (~10 s) is worth a log line (see bindLoop)
45
+ const BUSY_LOG_AFTER = 5;
46
+ function bindDelayMs() {
47
+ const v = Number(process.env.AGENT_CONSOLE_BIND_DELAY_MS);
48
+ return Number.isFinite(v) && v >= 0 ? v : 1200;
49
+ }
50
+
51
+ // Paths are computed again inside server(): tests change environment variables after import
52
+ function paths() {
53
+ const dataRoot = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
54
+ const dataDir = join(dataRoot, "magpie", "agent-console");
55
+ return {
56
+ dataRoot,
57
+ dataDir,
58
+ logsDir: join(dataDir, "logs"),
59
+ spawnLog: join(dataDir, "logs", "daemon-spawn.log"),
60
+ daemonJson: join(dataDir, "daemon.json"),
61
+ // pid of the host that wrote page/session.js (kept out of the page directory, which any page can read)
62
+ sessionOwner: join(dataDir, "session-owner.json"),
63
+ pageDir: process.env.AGENT_CONSOLE_PAGE_DIR || join(here, "page"),
64
+ };
65
+ }
22
66
 
23
- function envPort() {
24
- const p = Number(process.env.AGENT_CONSOLE_PORT || "");
25
- return Number.isInteger(p) && p >= 1 && p <= 65535 ? p : DEFAULT_PORT;
67
+ function sleep(ms) {
68
+ return new Promise((resolve) => setTimeout(resolve, ms));
26
69
  }
27
70
 
71
+ // ESRCH = gone; EPERM = exists but we may not signal it (common on Windows), counts as alive
28
72
  function pidAlive(pid) {
29
73
  try {
30
74
  process.kill(pid, 0);
31
75
  return true;
76
+ } catch (error) {
77
+ return error?.code === "EPERM";
78
+ }
79
+ }
80
+
81
+ // A failed log write (directory deleted, disk full) must never make the host's flow throw
82
+ function makeLogger(p) {
83
+ return (message) => {
84
+ try {
85
+ mkdirSync(p.logsDir, { recursive: true });
86
+ appendFileSync(p.spawnLog, `[${new Date().toISOString()}] ${message}\n`);
87
+ } catch {}
88
+ };
89
+ }
90
+
91
+ function readDaemonInfo(p) {
92
+ try {
93
+ return JSON.parse(readFileSync(p.daemonJson, "utf8"));
32
94
  } catch {
33
- return false;
95
+ return null;
34
96
  }
35
97
  }
36
98
 
37
99
  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", () => {
100
+ return new Promise((resolve) => {
101
+ const request = http.get(
102
+ {
103
+ host: "127.0.0.1",
104
+ port,
105
+ path: "/api/ping",
106
+ headers: { "x-agent-console": token },
107
+ timeout: 1500,
108
+ },
109
+ (response) => {
110
+ let body = "";
111
+ response.on("data", (chunk) => {
112
+ body += chunk;
113
+ });
114
+ response.on("end", () => {
45
115
  try {
46
- res(JSON.parse(b));
116
+ resolve(JSON.parse(body));
47
117
  } catch {
48
- res(null);
118
+ resolve(null);
49
119
  }
50
120
  });
51
- }
121
+ },
52
122
  );
53
- req.on("error", () => res(null));
54
- req.on("timeout", () => {
55
- req.destroy();
56
- res(null);
123
+ request.on("error", () => resolve(null));
124
+ request.on("timeout", () => {
125
+ request.destroy();
126
+ resolve(null);
57
127
  });
58
128
  });
59
129
  }
60
130
 
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
- });
131
+ async function killPid(pid) {
132
+ if (process.platform === "win32") {
133
+ await new Promise((resolve) =>
134
+ execFile("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, () => resolve()),
135
+ );
136
+ return;
137
+ }
138
+ try {
139
+ process.kill(pid, "SIGTERM");
140
+ } catch {}
141
+ for (let i = 0; i < 20 && pidAlive(pid); i++) await sleep(100);
142
+ if (pidAlive(pid)) {
143
+ try {
144
+ process.kill(pid, "SIGKILL");
145
+ } catch {}
146
+ }
76
147
  }
77
148
 
78
- async function shutdownViaWs(port, token) {
149
+ // The 0.1.0 daemon was detached and may still hold 3488 after an upgrade.
150
+ // Only after it answered a ping with the token from daemon.json (confirming it really is our daemon, guarding against pid reuse)
151
+ // do we first shut it down gracefully via WS app.shutdown, then force-kill it if it's still there.
152
+ async function shutdownLegacy(info, log) {
153
+ if (!info?.pid || !info?.port || !info?.token || !pidAlive(info.pid)) return;
154
+ const answer = await ping(info.port, info.token);
155
+ if (!answer?.ok || answer.pid !== info.pid) return;
156
+ log(`stopping legacy daemon pid ${info.pid} on port ${info.port}`);
79
157
  try {
80
158
  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();
159
+ await new Promise((resolve) => {
160
+ const socket = new WebSocket(`ws://127.0.0.1:${info.port}/ws`);
161
+ const timer = setTimeout(() => {
162
+ socket.terminate();
163
+ resolve();
88
164
  }, 3000);
89
- ws.on("open", () => {
90
- ws.send(JSON.stringify({ t: "hello", token, v: 1 }));
91
- });
92
- ws.on("message", (d) => {
93
- let m;
165
+ socket.on("open", () => socket.send(JSON.stringify({ t: "hello", token: info.token, v: 1 })));
166
+ socket.on("message", (data) => {
167
+ let message;
94
168
  try {
95
- m = JSON.parse(String(d));
169
+ message = JSON.parse(String(data));
96
170
  } catch {
97
171
  return;
98
172
  }
99
- if (m.t === "hello.ok") {
100
- ws.send(JSON.stringify({ t: "req", id: 1, m: "app.shutdown", p: {} }));
173
+ if (message.t === "hello.ok") {
174
+ socket.send(JSON.stringify({ t: "req", id: 1, m: "app.shutdown", p: {} }));
101
175
  }
102
- if (m.t === "res" && m.id === 1) {
103
- clearTimeout(t);
104
- ws.close();
105
- res();
176
+ if (message.t === "res" && message.id === 1) {
177
+ clearTimeout(timer);
178
+ socket.close();
179
+ resolve();
106
180
  }
107
181
  });
108
- ws.on("error", () => {
109
- clearTimeout(t);
110
- res();
111
- });
112
- ws.on("close", () => {
113
- clearTimeout(t);
114
- res();
182
+ socket.on("error", () => {
183
+ clearTimeout(timer);
184
+ resolve();
115
185
  });
116
186
  });
117
187
  } catch {}
188
+ for (let i = 0; i < 30 && pidAlive(info.pid); i++) await sleep(100);
189
+ if (pidAlive(info.pid)) await killPid(info.pid);
118
190
  }
119
191
 
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
- }
134
- }
135
-
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);
192
+ // session.js is a script file holding only the gate port (a number) and the gate key (hex); "<" is still escaped just in case.
193
+ // Any page that knows Magpie's page path can read it with <script src>, so the daemon token must never go here.
194
+ // ts: when it was written. beat: whether this host keeps refreshing it (every SESSION_BEAT_MS, from the first page open on).
195
+ // A host that is killed hard (Magpie restarting it, Windows) can't delete its session.js:
196
+ // - boot.js treats a beating file whose ts stopped moving as stale, and after a lost connection (ac_lost) takes only a file written
197
+ // after the loss, so it waits for the new host's file instead of opening a dead gate;
198
+ // - a file that never beat (the page was never opened) says nothing by its age, so the next host deletes it at startup when the
199
+ // host that wrote it is gone (removeDeadSession), before Magpie serves the page.
200
+ function writeSession(pageDir, port, key, beat = false) {
201
+ mkdirSync(pageDir, { recursive: true });
202
+ const json = JSON.stringify({ v: 2, port, key, ts: Date.now(), beat }).replace(/</g, "\\u003c");
203
+ const temp = join(pageDir, `.session-${process.pid}-${randomBytes(4).toString("hex")}.tmp`);
204
+ writeFileSync(temp, `globalThis.AgentConsoleSession = ${json};\n`, { mode: 0o600 });
205
+ renameSync(temp, join(pageDir, "session.js"));
140
206
  }
141
207
 
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");
208
+ // At host startup (synchronously, so before Magpie serves the page that made it start this host): a session.js left by a host
209
+ // that is no longer running points at a dead gate; delete it so boot.js waits for this host's file.
210
+ // session-owner.json holds the writer's pid; a live writer (another host, e.g. Magpie's own while a `magpie` CLI command runs) is left alone.
211
+ function removeDeadSession(p) {
212
+ const sessionPath = join(p.pageDir, "session.js");
213
+ let owner;
146
214
  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);
215
+ owner = JSON.parse(readFileSync(p.sessionOwner, "utf8"));
216
+ } catch {
217
+ return;
156
218
  }
219
+ if (!owner || !Number.isInteger(owner.pid) || owner.pid === process.pid || pidAlive(owner.pid)) return;
220
+ try {
221
+ const cur = readFileSync(sessionPath, "utf8");
222
+ if (typeof owner.key === "string" && owner.key && !cur.includes(owner.key)) return;
223
+ unlinkSync(sessionPath);
224
+ } catch {}
157
225
  }
158
226
 
159
- function readDaemonJson() {
227
+ function writeSessionOwner(p, key) {
160
228
  try {
161
- return JSON.parse(readFileSync(daemonJsonPath, "utf8"));
162
- } catch {
163
- return null;
164
- }
229
+ mkdirSync(p.dataDir, { recursive: true });
230
+ // Only the first 16 hex chars of the gate key: enough to recognise the file, useless for opening the gate
231
+ writeFileSync(p.sessionOwner, JSON.stringify({ pid: process.pid, key: key.slice(0, 16) }), { mode: 0o600 });
232
+ } catch {}
165
233
  }
166
234
 
167
- function buildStamp() {
235
+ function removeSession(pageDir, key) {
236
+ const sessionPath = join(pageDir, "session.js");
168
237
  try {
169
- return String(statSync(join(here, "daemon", "dist", "main.js")).mtimeMs);
170
- } catch {
171
- return "0";
172
- }
238
+ // Another host may already have written its own session.js; delete only ours
239
+ if (readFileSync(sessionPath, "utf8").includes(key)) unlinkSync(sessionPath);
240
+ } catch {}
173
241
  }
174
242
 
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 };
243
+ // Synchronous safety net when the host process exits (Magpie never calls stop(); this is the only cleanup that runs on a normal exit).
244
+ // The daemon also exits by itself when stdin closes / the parent disappears; this only speeds it up;
245
+ // 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.
246
+ // Also delete the session.js this host wrote so no stale key is left behind.
247
+ const liveChildren = new Set();
248
+ const liveSessions = new Map(); // pageDir -> key
249
+ let exitHookInstalled = false;
250
+ function installExitHook() {
251
+ if (exitHookInstalled) return;
252
+ exitHookInstalled = true;
253
+ process.once("exit", () => {
254
+ for (const [pageDir, key] of liveSessions) removeSession(pageDir, key);
255
+ for (const child of liveChildren) {
256
+ try {
257
+ child.stdin?.destroy();
258
+ } catch {}
259
+ if (process.platform !== "win32") {
260
+ try {
261
+ child.kill("SIGTERM");
262
+ } catch {}
184
263
  }
185
- // 版本不同或端口不同:关掉重启
186
- await shutdownDaemon(info);
187
- } else {
188
- // 进程活着但 ping 不通:直接杀(可能是别的进程复用了 pid,taskkill 只杀该 pid 树)
189
- await killPid(info.pid);
190
264
  }
265
+ });
266
+ }
267
+
268
+ function createHost(port) {
269
+ const p = paths();
270
+ const log = makeLogger(p);
271
+ // Gate key: written to session.js; even if leaked it only buys one navigation to the daemon
272
+ const key = randomBytes(32).toString("hex");
273
+ // Daemon token: given to the daemon only via an environment variable, and to the page via the 302's # fragment
274
+ const token = randomBytes(32).toString("hex");
275
+ let stopped = false;
276
+ let child = null;
277
+ let childPort = null;
278
+ let starting = null;
279
+ let legacyChecked = false;
280
+ let wakeBindLoop = null;
281
+ let beat = null;
282
+ let bound = false;
283
+ removeDeadSession(p);
284
+
285
+ // From the first page open on, session.js is rewritten every SESSION_BEAT_MS (only while it is still ours), so a page that lost
286
+ // its connection can tell this live host's file from a dead host's. Before that there is nothing to tell apart: no writes.
287
+ function startBeat() {
288
+ if (beat || stopped || !bound) return;
289
+ const refresh = () => {
290
+ if (stopped) return;
291
+ try {
292
+ const cur = readFileSync(join(p.pageDir, "session.js"), "utf8");
293
+ if (cur.includes(key)) writeSession(p.pageDir, port, key, true);
294
+ } catch { /* deleted or unreadable: leave it */ }
295
+ };
296
+ refresh();
297
+ beat = setInterval(refresh, SESSION_BEAT_MS);
298
+ beat.unref?.();
299
+ }
300
+
301
+ async function checkLegacyOnce() {
302
+ if (legacyChecked) return;
303
+ legacyChecked = true;
304
+ const info = readDaemonInfo(p);
305
+ if (info && (!child || info.pid !== child.pid)) await shutdownLegacy(info, log);
306
+ }
307
+
308
+ async function spawnDaemon() {
309
+ await checkLegacyOnce();
310
+ if (stopped) throw new Error("plugin host stopped");
311
+ mkdirSync(p.logsDir, { recursive: true });
312
+ let node;
313
+ try {
314
+ // Inside Magpie process.execPath is Bun; the daemon and node-pty need real Node
315
+ node = requireNode();
316
+ } catch (error) {
317
+ log(`cannot spawn daemon: ${error.message} (PATH=${process.env.PATH ?? ""})`);
318
+ throw error;
319
+ }
320
+ log(`spawning daemon with ${node}`);
321
+ const stderrFd = openSync(p.spawnLog, "a");
322
+ let spawned;
323
+ try {
324
+ spawned = spawn(
325
+ node,
326
+ [
327
+ join(here, "daemon", "dist", "main.js"),
328
+ "--port",
329
+ "0",
330
+ "--data",
331
+ p.dataRoot,
332
+ "--parent-pid",
333
+ String(process.pid),
334
+ ],
335
+ {
336
+ // The token goes through an environment variable, not the command line (command lines are visible to other users on the machine)
337
+ env: { ...process.env, AGENT_CONSOLE_TOKEN: token },
338
+ stdio: ["pipe", "pipe", stderrFd],
339
+ windowsHide: true,
340
+ },
341
+ );
342
+ } finally {
343
+ closeSync(stderrFd);
344
+ }
345
+ child = spawned;
346
+ childPort = null;
347
+ liveChildren.add(spawned);
348
+ installExitHook();
349
+
350
+ return new Promise((resolve, reject) => {
351
+ let settled = false;
352
+ let stdoutBuffer = "";
353
+ const finish = (error, port) => {
354
+ if (settled) return;
355
+ settled = true;
356
+ clearTimeout(timer);
357
+ if (error) {
358
+ // Failed start: clear the state so the next /open starts it again
359
+ try {
360
+ spawned.kill();
361
+ } catch {}
362
+ if (child === spawned) {
363
+ child = null;
364
+ childPort = null;
365
+ }
366
+ reject(error);
367
+ } else {
368
+ resolve(port);
369
+ }
370
+ };
371
+ const timer = setTimeout(() => finish(new Error("daemon ready timeout")), READY_TIMEOUT_MS);
372
+
373
+ // A spawn failure (ENOENT etc.) must not crash the Bun plugin host: log it and fail this /open
374
+ spawned.on("error", (spawnError) => {
375
+ log(`spawn error: ${spawnError}`);
376
+ finish(spawnError);
377
+ });
378
+ spawned.stdin.on("error", () => {});
379
+ // Keep reading stdout into the log so the pipe never fills up; the ready line may span chunks, so buffer and parse by line
380
+ spawned.stdout.on("data", (chunk) => {
381
+ const text = String(chunk);
382
+ try {
383
+ appendFileSync(p.spawnLog, text);
384
+ } catch {}
385
+ stdoutBuffer += text;
386
+ const lines = stdoutBuffer.split(/\r?\n/);
387
+ stdoutBuffer = lines.pop() ?? "";
388
+ for (const line of lines) {
389
+ let ready;
390
+ try {
391
+ ready = JSON.parse(line);
392
+ } catch {
393
+ continue;
394
+ }
395
+ if (ready?.agentConsoleReady && Number.isInteger(ready.port) && ready.port > 0) {
396
+ if (child === spawned) childPort = ready.port;
397
+ finish(null, ready.port);
398
+ }
399
+ }
400
+ });
401
+ spawned.once("exit", (code, signal) => {
402
+ log(`daemon pid ${spawned.pid} exited (code ${code}, signal ${signal})`);
403
+ liveChildren.delete(spawned);
404
+ if (child === spawned) {
405
+ child = null;
406
+ childPort = null;
407
+ }
408
+ const error = new Error(`daemon exited before ready (code ${code})`);
409
+ // The daemon uses exit code 3 for a Node version that's too old
410
+ if (code === 3) error.userMessage = NODE_TOO_OLD_MESSAGE;
411
+ finish(error);
412
+ });
413
+ });
191
414
  }
192
415
 
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 };
416
+ // Called on every gate /open: reuse the child if it's alive, start one only if not; concurrent calls share one start
417
+ async function ensureDaemon() {
418
+ if (stopped) throw new Error("plugin host stopped");
419
+ if (child && child.exitCode === null && child.signalCode === null && childPort) {
420
+ return { port: childPort };
421
+ }
422
+ if (!starting) {
423
+ starting = spawnDaemon().finally(() => {
424
+ starting = null;
425
+ });
201
426
  }
202
- if (child.exitCode !== null) break;
427
+ return { port: await starting };
428
+ }
429
+
430
+ // The gate calls this only for an /open with the right key
431
+ const gate = createGate({
432
+ port, key, token,
433
+ ensureDaemon: () => {
434
+ startBeat();
435
+ return ensureDaemon();
436
+ },
437
+ });
438
+
439
+ function waitForRetry() {
440
+ return new Promise((resolve) => {
441
+ const timer = setTimeout(resolve, BIND_RETRY_MS);
442
+ wakeBindLoop = () => {
443
+ clearTimeout(timer);
444
+ resolve();
445
+ };
446
+ });
203
447
  }
204
- return { ok: false, reason: `daemon 启动超时(端口 ${port})` };
205
- }
206
-
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;
448
+
449
+ async function bindLoop() {
450
+ const loggedCodes = new Set();
451
+ let busyTries = 0;
452
+ // B16: every `magpie` CLI command starts a short-lived plugin host that calls server() too. Waiting a moment before binding
453
+ // means such a host usually exits before it takes the gate or writes session.js, and a port held by the long-lived host
454
+ // (EADDRINUSE) is only logged once it has stayed busy for a while, not once per CLI command.
455
+ const delay = bindDelayMs();
456
+ if (delay > 0) {
457
+ await new Promise((resolve) => {
458
+ const timer = setTimeout(resolve, delay);
459
+ wakeBindLoop = () => { clearTimeout(timer); resolve(); };
221
460
  });
461
+ }
462
+ while (!stopped) {
463
+ try {
464
+ await gate.listen();
465
+ } catch (error) {
466
+ const code = error?.code || String(error);
467
+ if (code === "EADDRINUSE") busyTries++;
468
+ const quiet = code === "EADDRINUSE" && busyTries < BUSY_LOG_AFTER;
469
+ if (!quiet && !loggedCodes.has(code)) {
470
+ loggedCodes.add(code);
471
+ log(`gate port ${port} bind failed (${code}); retrying every ${BIND_RETRY_MS / 1000}s`);
472
+ }
473
+ // Port in use: if a detached 0.1.0 daemon is holding it, confirm its identity and shut it down
474
+ if (code === "EADDRINUSE" && !legacyChecked) {
475
+ const info = readDaemonInfo(p);
476
+ if (info?.port === port) await checkLegacyOnce();
477
+ }
478
+ if (stopped) return;
479
+ await waitForRetry();
480
+ continue;
481
+ }
482
+ // stop() may be called while listen is in progress: close immediately and don't write session.js
483
+ if (stopped) {
484
+ await gate.close();
485
+ return;
486
+ }
487
+ try {
488
+ writeSession(p.pageDir, port, key, false);
489
+ writeSessionOwner(p, key);
490
+ bound = true;
491
+ liveSessions.set(p.pageDir, key);
492
+ installExitHook();
493
+ log(`gate listening on 127.0.0.1:${port}; session.js written`);
494
+ } catch (error) {
495
+ log(`writing session.js failed: ${error}`);
496
+ }
497
+ return;
498
+ }
222
499
  }
223
- return ensuring;
224
- }
225
500
 
226
- // 模块加载即确保 daemon(防白屏:带路页跳转前服务就要在)
227
- const bootPort = envPort();
228
- void ensure(bootPort);
501
+ const binding = bindLoop().catch((error) => log(`gate failed: ${error}`));
229
502
 
230
- // magpie OpenCode v1 契约:server(input, options)
231
- async function server(_input, options) {
232
- const port = Number(options?.port || "") || bootPort;
233
- if (port !== bootPort) {
234
- await ensure(port); // 端口迁移:ensureDaemon 会按 daemon.json 判断重启
503
+ async function stopDaemon() {
504
+ const current = child;
505
+ if (!current) return;
506
+ // Close the lifeline first so the daemon exits gracefully (ending the Agent processes it started); force-kill after a timeout
507
+ try {
508
+ current.stdin.end();
509
+ } catch {}
510
+ for (let i = 0; i < 30 && current.exitCode === null && current.signalCode === null; i++) await sleep(100);
511
+ if (current.exitCode === null && current.signalCode === null && current.pid) {
512
+ await killPid(current.pid);
513
+ }
235
514
  }
236
- return {
237
- async stop() {
238
- const info = readDaemonJson();
239
- if (info?.pid && pidAlive(info.pid)) await shutdownDaemon(info);
240
- },
241
- };
515
+
516
+ async function stop() {
517
+ stopped = true;
518
+ if (beat) clearInterval(beat);
519
+ wakeBindLoop?.();
520
+ await binding;
521
+ await gate.close();
522
+ if (starting) await starting.catch(() => {});
523
+ await stopDaemon();
524
+ removeSession(p.pageDir, key);
525
+ if (liveSessions.get(p.pageDir) === key) liveSessions.delete(p.pageDir);
526
+ }
527
+
528
+ return { stop };
529
+ }
530
+
531
+ async function server(_input, options) {
532
+ const requested = Number(options?.port) || Number(process.env.AGENT_CONSOLE_PORT) || DEFAULT_PORT;
533
+ const port = Number.isInteger(requested) && requested > 0 && requested <= 65535 ? requested : DEFAULT_PORT;
534
+ return createHost(port);
242
535
  }
243
536
 
244
537
  export default { id: "agent-console", server };