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/README.md +69 -8
- package/THIRD-PARTY-NOTICES.md +11 -7
- package/app/app.css +1 -1
- package/app/app.js +89 -86
- package/assets/icon.svg +6 -8
- package/daemon/dist/main.js +2982 -852
- package/daemon/dist/main.js.map +1 -1
- package/index.mjs +93 -5
- package/lib/gate.mjs +21 -1
- package/package.json +22 -18
- package/page/boot.js +27 -3
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
66
|
-
for (let attempt = 1; !session && 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
|
})();
|