magpie-agent-console 1.0.0 → 1.2.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
@@ -40,6 +40,13 @@ const DEFAULT_PORT = 3488;
40
40
  // Longest wait for the daemon's ready line (Windows cold start + antivirus scanning can be slow)
41
41
  const READY_TIMEOUT_MS = 15000;
42
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
+ }
43
50
 
44
51
  // Paths are computed again inside server(): tests change environment variables after import
45
52
  function paths() {
@@ -51,6 +58,8 @@ function paths() {
51
58
  logsDir: join(dataDir, "logs"),
52
59
  spawnLog: join(dataDir, "logs", "daemon-spawn.log"),
53
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"),
54
63
  pageDir: process.env.AGENT_CONSOLE_PAGE_DIR || join(here, "page"),
55
64
  };
56
65
  }
@@ -182,14 +191,47 @@ async function shutdownLegacy(info, log) {
182
191
 
183
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.
184
193
  // 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) {
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) {
186
201
  mkdirSync(pageDir, { recursive: true });
187
- const json = JSON.stringify({ v: 2, port, key }).replace(/</g, "\\u003c");
202
+ const json = JSON.stringify({ v: 2, port, key, ts: Date.now(), beat }).replace(/</g, "\\u003c");
188
203
  const temp = join(pageDir, `.session-${process.pid}-${randomBytes(4).toString("hex")}.tmp`);
189
204
  writeFileSync(temp, `globalThis.AgentConsoleSession = ${json};\n`, { mode: 0o600 });
190
205
  renameSync(temp, join(pageDir, "session.js"));
191
206
  }
192
207
 
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;
214
+ try {
215
+ owner = JSON.parse(readFileSync(p.sessionOwner, "utf8"));
216
+ } catch {
217
+ return;
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 {}
225
+ }
226
+
227
+ function writeSessionOwner(p, key) {
228
+ try {
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 {}
233
+ }
234
+
193
235
  function removeSession(pageDir, key) {
194
236
  const sessionPath = join(pageDir, "session.js");
195
237
  try {
@@ -224,6 +266,7 @@ function installExitHook() {
224
266
  }
225
267
 
226
268
  function createHost(port) {
269
+ const gatePort = port;
227
270
  const p = paths();
228
271
  const log = makeLogger(p);
229
272
  // Gate key: written to session.js; even if leaked it only buys one navigation to the daemon
@@ -236,6 +279,25 @@ function createHost(port) {
236
279
  let starting = null;
237
280
  let legacyChecked = false;
238
281
  let wakeBindLoop = null;
282
+ let beat = null;
283
+ let bound = false;
284
+ removeDeadSession(p);
285
+
286
+ // 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
287
+ // its connection can tell this live host's file from a dead host's. Before that there is nothing to tell apart: no writes.
288
+ function startBeat() {
289
+ if (beat || stopped || !bound) return;
290
+ const refresh = () => {
291
+ if (stopped) return;
292
+ try {
293
+ const cur = readFileSync(join(p.pageDir, "session.js"), "utf8");
294
+ if (cur.includes(key)) writeSession(p.pageDir, port, key, true);
295
+ } catch { /* deleted or unreadable: leave it */ }
296
+ };
297
+ refresh();
298
+ beat = setInterval(refresh, SESSION_BEAT_MS);
299
+ beat.unref?.();
300
+ }
239
301
 
240
302
  async function checkLegacyOnce() {
241
303
  if (legacyChecked) return;
@@ -270,6 +332,9 @@ function createHost(port) {
270
332
  p.dataRoot,
271
333
  "--parent-pid",
272
334
  String(process.pid),
335
+ // the preview proxy refuses the gate's port
336
+ "--gate-port",
337
+ String(gatePort),
273
338
  ],
274
339
  {
275
340
  // The token goes through an environment variable, not the command line (command lines are visible to other users on the machine)
@@ -366,7 +431,14 @@ function createHost(port) {
366
431
  return { port: await starting };
367
432
  }
368
433
 
369
- const gate = createGate({ port, key, token, ensureDaemon });
434
+ // The gate calls this only for an /open with the right key
435
+ const gate = createGate({
436
+ port, key, token,
437
+ ensureDaemon: () => {
438
+ startBeat();
439
+ return ensureDaemon();
440
+ },
441
+ });
370
442
 
371
443
  function waitForRetry() {
372
444
  return new Promise((resolve) => {
@@ -380,12 +452,25 @@ function createHost(port) {
380
452
 
381
453
  async function bindLoop() {
382
454
  const loggedCodes = new Set();
455
+ let busyTries = 0;
456
+ // B16: every `magpie` CLI command starts a short-lived plugin host that calls server() too. Waiting a moment before binding
457
+ // means such a host usually exits before it takes the gate or writes session.js, and a port held by the long-lived host
458
+ // (EADDRINUSE) is only logged once it has stayed busy for a while, not once per CLI command.
459
+ const delay = bindDelayMs();
460
+ if (delay > 0) {
461
+ await new Promise((resolve) => {
462
+ const timer = setTimeout(resolve, delay);
463
+ wakeBindLoop = () => { clearTimeout(timer); resolve(); };
464
+ });
465
+ }
383
466
  while (!stopped) {
384
467
  try {
385
468
  await gate.listen();
386
469
  } catch (error) {
387
470
  const code = error?.code || String(error);
388
- if (!loggedCodes.has(code)) {
471
+ if (code === "EADDRINUSE") busyTries++;
472
+ const quiet = code === "EADDRINUSE" && busyTries < BUSY_LOG_AFTER;
473
+ if (!quiet && !loggedCodes.has(code)) {
389
474
  loggedCodes.add(code);
390
475
  log(`gate port ${port} bind failed (${code}); retrying every ${BIND_RETRY_MS / 1000}s`);
391
476
  }
@@ -404,7 +489,9 @@ function createHost(port) {
404
489
  return;
405
490
  }
406
491
  try {
407
- writeSession(p.pageDir, port, key);
492
+ writeSession(p.pageDir, port, key, false);
493
+ writeSessionOwner(p, key);
494
+ bound = true;
408
495
  liveSessions.set(p.pageDir, key);
409
496
  installExitHook();
410
497
  log(`gate listening on 127.0.0.1:${port}; session.js written`);
@@ -432,6 +519,7 @@ function createHost(port) {
432
519
 
433
520
  async function stop() {
434
521
  stopped = true;
522
+ if (beat) clearInterval(beat);
435
523
  wakeBindLoop?.();
436
524
  await binding;
437
525
  await gate.close();
package/lib/gate.mjs CHANGED
@@ -56,6 +56,24 @@ export function failureHtml(error) {
56
56
  );
57
57
  }
58
58
 
59
+ /**
60
+ * The Magpie plugin page boot.js came from (?back=), so the daemon page can go back through it after the plugin host restarts
61
+ * (fresh session.js → new gate → new daemon). Only a loopback Magpie plugin page is accepted, so the daemon page can't be turned
62
+ * into an open redirect; it travels in the 302's # fragment next to the token and never reaches a server.
63
+ * Keep in sync with web/src/api/backToMagpie.ts.
64
+ */
65
+ export function safeBackUrl(raw) {
66
+ if (typeof raw !== "string" || !raw || raw.length > 2048) return null;
67
+ let u;
68
+ try { u = new URL(raw); } catch { return null; }
69
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
70
+ if (u.username || u.password) return null;
71
+ const hostname = u.hostname.toLowerCase();
72
+ if (hostname !== "127.0.0.1" && hostname !== "localhost" && hostname !== "[::1]") return null;
73
+ if (!/^\/_magpie\/plugin\/[A-Za-z0-9_-]{1,128}\/(?:page\/)?index\.html$/.test(u.pathname)) return null;
74
+ return `${u.protocol}//${u.host}${u.pathname}${u.search}`;
75
+ }
76
+
59
77
  const REOPEN_HTML =
60
78
  "<!doctype html><meta charset=utf-8><p>Agent Console: 链接已失效,请在 Magpie 里重新打开插件页。" +
61
79
  "<br>The link is no longer valid. Please reopen the Agent Console page in Magpie.</p>";
@@ -117,7 +135,9 @@ export function createGate({ port, key, token, ensureDaemon, host = "127.0.0.1"
117
135
  for (const name of ["theme", "lang", "tokens"]) {
118
136
  if (url.searchParams.has(name)) query.set(name, url.searchParams.get(name));
119
137
  }
120
- const location = `http://127.0.0.1:${daemon.port}/?${query}#t=${encodeURIComponent(token)}`;
138
+ const back = safeBackUrl(url.searchParams.get("back"));
139
+ const fragment = `t=${encodeURIComponent(token)}` + (back ? `&b=${encodeURIComponent(back)}` : "");
140
+ const location = `http://127.0.0.1:${daemon.port}/?${query}#${fragment}`;
121
141
  res.writeHead(302, { ...SAFE_HEADERS, location });
122
142
  res.end();
123
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-agent-console",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "A Magpie page plugin that drives local Agent CLIs (Claude Code, Codex, Cursor, and others) with a persistent daemon and a structured timeline.",
5
5
  "author": "qingnian108",
6
6
  "homepage": "https://github.com/qingnian108/magpie-agent-console",
@@ -44,7 +44,8 @@
44
44
  },
45
45
  "file": "page/index.html",
46
46
  "icon": "assets/icon.svg"
47
- }
47
+ },
48
+ "permissions": []
48
49
  },
49
50
  "main": "./index.mjs",
50
51
  "type": "module",
@@ -61,7 +62,9 @@
61
62
  "check:v1": "tsc -p tsconfig.daemon.json --noEmit && tsc -p tsconfig.web.json --noEmit",
62
63
  "e2e:v1": "node scripts/e2e-v1.mjs",
63
64
  "shot:v1": "node scripts/shot-v1.mjs",
64
- "verify:build": "node scripts/verify-build.mjs"
65
+ "verify:build": "node scripts/verify-build.mjs",
66
+ "test:real": "vitest run --config vitest.real.config.ts",
67
+ "test:preview-attack": "node scripts/preview-attack.mjs"
65
68
  },
66
69
  "engines": {
67
70
  "node": ">=20",
@@ -69,32 +72,33 @@
69
72
  },
70
73
  "dependencies": {
71
74
  "@agentclientprotocol/sdk": "2.1.0",
72
- "@anthropic-ai/claude-agent-sdk": "0.3.312",
73
- "@tanstack/react-virtual": "3.14.24",
74
- "@xterm/addon-fit": "0.11.0",
75
- "@xterm/xterm": "6.0.0",
76
- "clsx": "2.1.1",
77
- "dompurify": "3.4.17",
78
- "highlight.js": "11.12.0",
79
75
  "ignore": "7.0.14",
80
- "lucide-react": "1.64.0",
81
- "marked": "18.1.2",
82
- "node-pty": "1.2.0-beta.15",
83
- "react": "19.3.1",
84
- "react-dom": "19.3.1",
85
76
  "ws": "8.22.0",
86
- "zod": "4.6.7",
87
- "zustand": "5.0.16"
77
+ "zod": "4.6.7"
78
+ },
79
+ "optionalDependencies": {
80
+ "node-pty": "1.2.0-beta.15"
88
81
  },
89
82
  "devDependencies": {
83
+ "@tanstack/react-virtual": "3.14.24",
90
84
  "@types/dompurify": "3.0.5",
91
85
  "@types/node": "26.6.6",
92
86
  "@types/react": "19.3.0",
93
87
  "@types/react-dom": "19.3.0",
94
88
  "@types/ws": "8.18.2",
89
+ "@xterm/addon-fit": "0.11.0",
90
+ "@xterm/xterm": "6.0.0",
91
+ "clsx": "2.1.1",
92
+ "dompurify": "3.4.17",
95
93
  "esbuild": "0.28.2",
94
+ "highlight.js": "11.12.0",
95
+ "lucide-react": "1.64.0",
96
+ "marked": "18.1.2",
96
97
  "playwright-core": "1.64.1",
98
+ "react": "19.3.1",
99
+ "react-dom": "19.3.1",
97
100
  "typescript": "7.0.2",
98
- "vitest": "5.0.5"
101
+ "vitest": "5.0.5",
102
+ "zustand": "5.0.16"
99
103
  }
100
104
  }
package/page/boot.js CHANGED
@@ -27,6 +27,17 @@
27
27
  if (said) said.textContent = zh ? "正在启动本地服务…" : "Starting local service…";
28
28
  if (brand) brand.textContent = zh ? "Agent 控制台" : "Agent Console";
29
29
 
30
+ // ts: when the host wrote the file. beat: true once the host refreshes it every few seconds (from the first page open on);
31
+ // a beating file whose ts stopped moving belongs to a host that was killed (Magpie restarting it) and its gate is gone, so wait
32
+ // for the new host's file instead of navigating to a dead port. A file that doesn't beat (the page was never opened) can be old
33
+ // and still live; a dead host's one is deleted by the next host when it starts.
34
+ const STALE_MS = 12000;
35
+ // ac_lost: set by the daemon page when it lost its connection and came back here. The old host's file was last refreshed before
36
+ // that moment, so only a session.js refreshed after it (a live host's) is used.
37
+ const here = new URL(location.href);
38
+ const lostAt = Number(here.searchParams.get("ac_lost")) || 0;
39
+ here.searchParams.delete("ac_lost");
40
+ here.hash = "";
30
41
  function validSession(s) {
31
42
  return (
32
43
  s &&
@@ -35,9 +46,16 @@
35
46
  s.port > 0 &&
36
47
  s.port <= 65535 &&
37
48
  typeof s.key === "string" &&
38
- /^[0-9a-f]{64}$/.test(s.key)
49
+ /^[0-9a-f]{64}$/.test(s.key) &&
50
+ fresh(s)
39
51
  );
40
52
  }
53
+ function fresh(s) {
54
+ if (typeof s.ts !== "number") return true; // a host from before ts
55
+ if (lostAt && !(s.ts > lostAt)) return false;
56
+ if (s.beat === false) return true;
57
+ return Math.abs(Date.now() - s.ts) < STALE_MS;
58
+ }
41
59
 
42
60
  // Reload an uncached copy of session.js (the key changes after the plugin host restarts).
43
61
  // Returns null when the file doesn't exist (the host isn't up yet or the port isn't bound yet).
@@ -62,8 +80,8 @@
62
80
  const preloaded = validSession(globalThis.AgentConsoleSession) ? globalThis.AgentConsoleSession : null;
63
81
  let session = await loadFreshSession(0);
64
82
  if (!session && preloaded) session = preloaded;
65
- // Right after the host starts the file may not be written yet: retry every 500ms for about 10 seconds
66
- for (let attempt = 1; !session && attempt <= 20; attempt++) {
83
+ // Right after the host starts (or restarts) the file may be missing or stale: retry every 500ms for about 30 seconds
84
+ for (let attempt = 1; !session && attempt <= 60; attempt++) {
67
85
  await new Promise((resolve) => setTimeout(resolve, 500));
68
86
  session = await loadFreshSession(attempt);
69
87
  }
@@ -94,6 +112,12 @@
94
112
  if (Object.keys(slim).length) {
95
113
  try { q.set("tokens", btoa(unescape(encodeURIComponent(JSON.stringify(slim))))); } catch { /* fine if it can't be passed along */ }
96
114
  }
115
+ // This page's own URL: when Magpie restarts the plugin host (new gate key, the old daemon exits), the daemon page comes back here
116
+ // to read the fresh session.js. The gate checks it and puts it only in the # fragment.
117
+ try {
118
+ const back = here.toString();
119
+ if (back.length <= 2048) q.set("back", back);
120
+ } catch { /* no self-heal, the page still works */ }
97
121
 
98
122
  location.replace("http://127.0.0.1:" + session.port + "/open?" + q.toString());
99
123
  })();