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/README.md +120 -77
- package/THIRD-PARTY-NOTICES.md +20 -18
- package/app/app.js +50 -50
- package/app/index.html +12 -4
- package/daemon/dist/main.js +842 -403
- package/daemon/dist/main.js.map +1 -7
- package/index.mjs +386 -177
- package/lib/gate.d.mts +9 -0
- package/lib/gate.mjs +157 -0
- package/lib/pathscan.d.mts +15 -0
- package/lib/pathscan.mjs +152 -0
- package/package.json +33 -30
- package/page/boot.js +73 -22
- package/page/index.html +8 -1
package/lib/gate.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Plugin gate: runs inside Magpie's Bun plugin host process and listens on 127.0.0.1:<port> (default 3488).
|
|
2
|
+
//
|
|
3
|
+
// Two independent secrets:
|
|
4
|
+
// - gate key (key): written to page/session.js; the plugin page uses it to open the gate.
|
|
5
|
+
// - daemon token (token): unlocks the daemon's API / WebSocket; it only appears in the # fragment of the gate's 302
|
|
6
|
+
// and is never written to any static file.
|
|
7
|
+
//
|
|
8
|
+
// Why separate them: Magpie serves the page directory at /_magpie/plugin/<token that changes every launch>/ without checking Origin,
|
|
9
|
+
// Host, Sec-Fetch-* or cookies; the only protection is that the path can't be guessed. session.js is a script, and any page that knows the path
|
|
10
|
+
// can include it with <script src> and read its contents (classic script includes aren't subject to CORS). So session.js may only hold
|
|
11
|
+
// things that can't reach the daemon even if leaked: the gate key only buys one navigation to the daemon, and the token sits in the target window's
|
|
12
|
+
// # fragment, which cross-origin pages can't read.
|
|
13
|
+
//
|
|
14
|
+
// Token handoff:
|
|
15
|
+
// 1. The plugin host's server() creates the key and token; once the gate binds its port it writes page/session.js (port and key only).
|
|
16
|
+
// 2. The plugin page (sandbox iframe) reads session.js and navigates itself to http://127.0.0.1:<port>/open?k=<key>.
|
|
17
|
+
// This is an ordinary navigation: no Origin header, and the gate returns no CORS headers, so other pages can't read the response.
|
|
18
|
+
// 3. The gate checks the key, starts (or reuses) the daemon and 302s to http://127.0.0.1:<daemonPort>/?<theme params>#t=<token>.
|
|
19
|
+
// The token lives only in the # fragment: fragments aren't sent to servers and don't appear in Referer or daemon logs;
|
|
20
|
+
// after the navigation the window belongs to the daemon's origin (opaque inside the plugin iframe), and the initiating page can't read its URL.
|
|
21
|
+
//
|
|
22
|
+
// The gate itself: every request checks Host (blocks DNS rebinding); any request with an Origin header is refused (navigations only);
|
|
23
|
+
// everything except GET /open is 404.
|
|
24
|
+
import http from "node:http";
|
|
25
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
26
|
+
|
|
27
|
+
// Constant-time compare against timing attacks; hash to the same length first.
|
|
28
|
+
function tokenEqual(a, b) {
|
|
29
|
+
const left = createHash("sha256").update(a).digest();
|
|
30
|
+
const right = createHash("sha256").update(b).digest();
|
|
31
|
+
return timingSafeEqual(left, right);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SAFE_HEADERS = {
|
|
35
|
+
"cache-control": "no-store",
|
|
36
|
+
"x-content-type-options": "nosniff",
|
|
37
|
+
"referrer-policy": "no-referrer",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function respond(res, status, body) {
|
|
41
|
+
res.writeHead(status, { ...SAFE_HEADERS, "content-type": "text/html; charset=utf-8" });
|
|
42
|
+
res.end(body);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function escapeHtml(text) {
|
|
46
|
+
return String(text).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 503 page: show only a userMessage that ensureDaemon explicitly provides (e.g. "Node.js not found"); other error details go to the log only. */
|
|
50
|
+
export function failureHtml(error) {
|
|
51
|
+
const hint = typeof error?.userMessage === "string" && error.userMessage ? error.userMessage : "";
|
|
52
|
+
return (
|
|
53
|
+
"<!doctype html><meta charset=utf-8><p>Agent Console: 本地服务启动失败 / local service failed to start</p>" +
|
|
54
|
+
(hint ? `<p>${escapeHtml(hint)}</p>` : "") +
|
|
55
|
+
"<p>日志 / log: ~/.config/magpie/agent-console/logs/daemon-spawn.log</p>"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const REOPEN_HTML =
|
|
60
|
+
"<!doctype html><meta charset=utf-8><p>Agent Console: 链接已失效,请在 Magpie 里重新打开插件页。" +
|
|
61
|
+
"<br>The link is no longer valid. Please reopen the Agent Console page in Magpie.</p>";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} options
|
|
65
|
+
* @param {number} options.port gate port
|
|
66
|
+
* @param {string} options.key gate key (the one in session.js), only used to open /open
|
|
67
|
+
* @param {string} options.token daemon token, only placed in the 302's # fragment
|
|
68
|
+
* @param {() => Promise<{ port: number }>} options.ensureDaemon starts or reuses the daemon and returns its actual port
|
|
69
|
+
* @param {string} [options.host] listen address, default 127.0.0.1
|
|
70
|
+
*/
|
|
71
|
+
export function createGate({ port, key, token, ensureDaemon, host = "127.0.0.1" }) {
|
|
72
|
+
if (typeof key !== "string" || key.length < 32 || typeof token !== "string" || token.length < 32 || key === token) {
|
|
73
|
+
throw new Error("gate: key and token must be two different secrets");
|
|
74
|
+
}
|
|
75
|
+
let server = null;
|
|
76
|
+
// Concurrent /open calls share one ensureDaemon so two daemons are never started
|
|
77
|
+
let opening = null;
|
|
78
|
+
|
|
79
|
+
async function handle(req, res) {
|
|
80
|
+
const expectedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
81
|
+
if (!expectedHosts.includes(String(req.headers.host ?? "").toLowerCase())) {
|
|
82
|
+
respond(res, 421, "Bad Host");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// The gate only accepts top-level navigations of the plugin page, which carry no Origin. Anything with an Origin (including null) is a script request.
|
|
86
|
+
if (req.headers.origin !== undefined) {
|
|
87
|
+
respond(res, 403, "Origin not allowed");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
91
|
+
if (url.pathname !== "/open" || req.method !== "GET") {
|
|
92
|
+
respond(res, 404, "Not found");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!tokenEqual(url.searchParams.get("k") ?? "", key)) {
|
|
96
|
+
respond(res, 403, REOPEN_HTML);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!opening) {
|
|
101
|
+
opening = Promise.resolve()
|
|
102
|
+
.then(() => ensureDaemon())
|
|
103
|
+
.finally(() => {
|
|
104
|
+
opening = null;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
let daemon;
|
|
108
|
+
try {
|
|
109
|
+
daemon = await opening;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
respond(res, 503, failureHtml(error));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Theme, language and color tokens are forwarded to the daemon page as-is; the daemon token goes only into the # fragment
|
|
116
|
+
const query = new URLSearchParams();
|
|
117
|
+
for (const name of ["theme", "lang", "tokens"]) {
|
|
118
|
+
if (url.searchParams.has(name)) query.set(name, url.searchParams.get(name));
|
|
119
|
+
}
|
|
120
|
+
const location = `http://127.0.0.1:${daemon.port}/?${query}#t=${encodeURIComponent(token)}`;
|
|
121
|
+
res.writeHead(302, { ...SAFE_HEADERS, location });
|
|
122
|
+
res.end();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function listen() {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const candidate = http.createServer((req, res) => {
|
|
128
|
+
handle(req, res).catch(() => {
|
|
129
|
+
if (!res.headersSent) respond(res, 500, "Internal error");
|
|
130
|
+
else res.end();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
candidate.once("error", reject);
|
|
134
|
+
candidate.listen(port, host, () => {
|
|
135
|
+
candidate.off("error", reject);
|
|
136
|
+
server = candidate;
|
|
137
|
+
resolve(candidate);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function close() {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
const current = server;
|
|
145
|
+
server = null;
|
|
146
|
+
if (!current) {
|
|
147
|
+
resolve();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
current.close(() => resolve());
|
|
151
|
+
// Don't wait for keep-alive connections to close by themselves; free the port immediately
|
|
152
|
+
current.closeAllConnections?.();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { listen, close };
|
|
157
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ScanOptions = {
|
|
2
|
+
platform?: NodeJS.Platform;
|
|
3
|
+
env?: Record<string, string | undefined>;
|
|
4
|
+
home?: string;
|
|
5
|
+
extra?: boolean;
|
|
6
|
+
exists?: (file: string) => boolean;
|
|
7
|
+
};
|
|
8
|
+
export function extraBinDirs(opts?: ScanOptions): string[];
|
|
9
|
+
export function searchDirs(opts?: ScanOptions): string[];
|
|
10
|
+
export function findOnPath(name: string, opts?: ScanOptions): string[];
|
|
11
|
+
export function findNode(opts?: ScanOptions): string | null;
|
|
12
|
+
export function isNodeExecutable(file: string): boolean;
|
|
13
|
+
export declare const NODE_MISSING_MESSAGE: string;
|
|
14
|
+
export declare const NODE_TOO_OLD_MESSAGE: string;
|
|
15
|
+
export declare function requireNode(opts?: { execPath?: string; find?: () => string | null }): string;
|
package/lib/pathscan.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Cross-platform executable lookup in PATH (shared by the plugin host index.mjs and the daemon; esbuild bundles it into dist for the daemon).
|
|
2
|
+
//
|
|
3
|
+
// which / where.exe are no longer used:
|
|
4
|
+
// - some minimal Linux systems have no which;
|
|
5
|
+
// - where.exe writes in the console code page (CP936 on Chinese Windows), so a Chinese user name or path is garbled when decoded as UTF-8,
|
|
6
|
+
// existsSync fails and the CLI "can't be found".
|
|
7
|
+
// Windows searches in PATHEXT order (.COM;.EXE;.BAT;.CMD), with .ps1 last; extensionless npm sh shims are never returned.
|
|
8
|
+
// Besides PATH, add a set of common install directories: GUI apps launched from the macOS Dock/Finder only get launchd's minimal PATH
|
|
9
|
+
// (Magpie adds the login shell's PATH itself; this is the fallback): Homebrew, npm global, Volta, Claude's native install, etc.
|
|
10
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
function defaultExists(file, platform) {
|
|
15
|
+
try {
|
|
16
|
+
if (!statSync(file).isFile()) return false;
|
|
17
|
+
if (platform !== "win32") accessSync(file, constants.X_OK);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function envGet(env, name) {
|
|
25
|
+
if (env[name] !== undefined) return env[name];
|
|
26
|
+
// Windows environment variable names are case-insensitive (Path / PATH)
|
|
27
|
+
const lower = name.toLowerCase();
|
|
28
|
+
for (const key of Object.keys(env)) if (key.toLowerCase() === lower) return env[key];
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Common install directories that aren't necessarily in a GUI process's PATH (candidates only; callers check whether they exist). */
|
|
33
|
+
export function extraBinDirs({ platform = process.platform, env = process.env, home = homedir() } = {}) {
|
|
34
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
35
|
+
const dirs = [];
|
|
36
|
+
if (platform === "win32") {
|
|
37
|
+
const appData = envGet(env, "APPDATA");
|
|
38
|
+
const localAppData = envGet(env, "LOCALAPPDATA");
|
|
39
|
+
const programFiles = envGet(env, "ProgramFiles") || "C:\\Program Files";
|
|
40
|
+
const nvmSymlink = envGet(env, "NVM_SYMLINK");
|
|
41
|
+
if (home) dirs.push(p.join(home, ".local", "bin"));
|
|
42
|
+
if (appData) dirs.push(p.join(appData, "npm"));
|
|
43
|
+
if (nvmSymlink) dirs.push(nvmSymlink);
|
|
44
|
+
dirs.push(p.join(programFiles, "nodejs"));
|
|
45
|
+
if (localAppData) dirs.push(p.join(localAppData, "Programs", "nodejs"));
|
|
46
|
+
if (home) {
|
|
47
|
+
dirs.push(p.join(home, ".volta", "bin"));
|
|
48
|
+
dirs.push(p.join(home, ".bun", "bin"));
|
|
49
|
+
}
|
|
50
|
+
return dirs;
|
|
51
|
+
}
|
|
52
|
+
if (home) {
|
|
53
|
+
dirs.push(p.join(home, ".local", "bin"));
|
|
54
|
+
dirs.push(p.join(home, ".npm-global", "bin"));
|
|
55
|
+
dirs.push(p.join(home, ".volta", "bin"));
|
|
56
|
+
dirs.push(p.join(home, ".bun", "bin"));
|
|
57
|
+
dirs.push(p.join(home, ".claude", "local"));
|
|
58
|
+
}
|
|
59
|
+
if (platform === "darwin") dirs.push("/opt/homebrew/bin");
|
|
60
|
+
dirs.push("/usr/local/bin", "/usr/bin", "/bin");
|
|
61
|
+
return dirs;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** PATH's directories followed by extraBinDirs; deduped (case-insensitive on Windows). */
|
|
65
|
+
export function searchDirs({ platform = process.platform, env = process.env, home = homedir(), extra = true } = {}) {
|
|
66
|
+
const sep = platform === "win32" ? ";" : ":";
|
|
67
|
+
const raw = String(envGet(env, "PATH") ?? "")
|
|
68
|
+
.split(sep)
|
|
69
|
+
.map((d) => d.trim().replace(/^"(.*)"$/, "$1"))
|
|
70
|
+
.filter(Boolean);
|
|
71
|
+
const all = extra ? [...raw, ...extraBinDirs({ platform, env, home })] : raw;
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const d of all) {
|
|
75
|
+
const key = platform === "win32" ? d.toLowerCase().replace(/[\\/]+$/, "") : d.replace(/\/+$/, "");
|
|
76
|
+
if (seen.has(key)) continue;
|
|
77
|
+
seen.add(key);
|
|
78
|
+
out.push(d);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function windowsExts(env) {
|
|
84
|
+
const list = String(envGet(env, "PATHEXT") || ".COM;.EXE;.BAT;.CMD")
|
|
85
|
+
.split(";")
|
|
86
|
+
.map((e) => e.trim().toLowerCase())
|
|
87
|
+
.filter((e) => e.startsWith("."));
|
|
88
|
+
if (!list.includes(".ps1")) list.push(".ps1");
|
|
89
|
+
return list;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return the full paths of every hit, in order (PATH first, then extraBinDirs).
|
|
94
|
+
* @param {string} name command name (may include an extension on Windows)
|
|
95
|
+
*/
|
|
96
|
+
export function findOnPath(
|
|
97
|
+
name,
|
|
98
|
+
{ platform = process.platform, env = process.env, home = homedir(), extra = true, exists } = {},
|
|
99
|
+
) {
|
|
100
|
+
if (!name) return [];
|
|
101
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
102
|
+
const check = exists ?? ((file) => defaultExists(file, platform));
|
|
103
|
+
// Already a path: check it directly
|
|
104
|
+
if (name.includes("/") || (platform === "win32" && name.includes("\\"))) {
|
|
105
|
+
return check(name) ? [name] : [];
|
|
106
|
+
}
|
|
107
|
+
let names = [name];
|
|
108
|
+
if (platform === "win32") {
|
|
109
|
+
const exts = windowsExts(env);
|
|
110
|
+
const ext = p.extname(name).toLowerCase();
|
|
111
|
+
names = ext && exts.includes(ext) ? [name] : exts.map((e) => name + e);
|
|
112
|
+
}
|
|
113
|
+
const hits = [];
|
|
114
|
+
for (const dir of searchDirs({ platform, env, home, extra })) {
|
|
115
|
+
for (const n of names) {
|
|
116
|
+
const file = p.join(dir, n);
|
|
117
|
+
if (check(file) && !hits.includes(file)) hits.push(file);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return hits;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The resolved node executable, or null if not found (the plugin host is Bun, so process.execPath can't serve as node). */
|
|
124
|
+
export function findNode(opts = {}) {
|
|
125
|
+
return findOnPath("node", opts).find((file) => isNodeExecutable(file)) ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether process.execPath itself is node (rather than Bun / Electron etc.). */
|
|
129
|
+
export function isNodeExecutable(file) {
|
|
130
|
+
return /^node(\.exe)?$/i.test(String(file || "").split(/[\\/]/).pop() ?? "");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const NODE_MISSING_MESSAGE =
|
|
134
|
+
"找不到 Node.js(需要 20 或更高版本)。请安装 Node.js(https://nodejs.org)后完全退出并重新打开 Magpie。" +
|
|
135
|
+
" / Node.js 20+ was not found on PATH. Install it, then quit and reopen Magpie.";
|
|
136
|
+
export const NODE_TOO_OLD_MESSAGE =
|
|
137
|
+
"Node.js 版本太旧,需要 20 或更高版本。升级后完全退出并重新打开 Magpie。" +
|
|
138
|
+
" / Node.js 20+ is required. Upgrade it, then quit and reopen Magpie.";
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* For the plugin host: return the real node path (the host itself is Bun). If none is found, throw an error with a userMessage,
|
|
142
|
+
* which the gate's 503 page displays; the daemon is no longer silently run on Bun.
|
|
143
|
+
*/
|
|
144
|
+
export function requireNode({ execPath = process.execPath, find = findNode } = {}) {
|
|
145
|
+
if (isNodeExecutable(execPath)) return execPath;
|
|
146
|
+
const hit = find();
|
|
147
|
+
if (hit) return hit;
|
|
148
|
+
const error = new Error("node not found on PATH");
|
|
149
|
+
error.code = "ENODE";
|
|
150
|
+
error.userMessage = NODE_MISSING_MESSAGE;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie-agent-console",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
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",
|
|
7
7
|
"bugs": {
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"index.mjs",
|
|
16
|
-
"
|
|
16
|
+
"lib",
|
|
17
|
+
"page/index.html",
|
|
18
|
+
"page/boot.js",
|
|
17
19
|
"app",
|
|
18
20
|
"daemon/dist",
|
|
19
21
|
"assets",
|
|
@@ -58,40 +60,41 @@
|
|
|
58
60
|
"test:v1": "vitest run",
|
|
59
61
|
"check:v1": "tsc -p tsconfig.daemon.json --noEmit && tsc -p tsconfig.web.json --noEmit",
|
|
60
62
|
"e2e:v1": "node scripts/e2e-v1.mjs",
|
|
61
|
-
"shot:v1": "node scripts/shot-v1.mjs"
|
|
63
|
+
"shot:v1": "node scripts/shot-v1.mjs",
|
|
64
|
+
"verify:build": "node scripts/verify-build.mjs"
|
|
62
65
|
},
|
|
63
66
|
"engines": {
|
|
64
67
|
"node": ">=20",
|
|
65
68
|
"magpie": ">=0.1.1340"
|
|
66
69
|
},
|
|
67
70
|
"dependencies": {
|
|
68
|
-
"@agentclientprotocol/sdk": "
|
|
69
|
-
"@anthropic-ai/claude-agent-sdk": "
|
|
70
|
-
"@tanstack/react-virtual": "
|
|
71
|
-
"@xterm/addon-fit": "
|
|
72
|
-
"@xterm/xterm": "
|
|
73
|
-
"clsx": "
|
|
74
|
-
"dompurify": "
|
|
75
|
-
"highlight.js": "
|
|
76
|
-
"ignore": "
|
|
77
|
-
"lucide-react": "
|
|
78
|
-
"marked": "
|
|
79
|
-
"node-pty": "
|
|
80
|
-
"react": "
|
|
81
|
-
"react-dom": "
|
|
82
|
-
"ws": "
|
|
83
|
-
"zod": "
|
|
84
|
-
"zustand": "
|
|
71
|
+
"@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
|
+
"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
|
+
"ws": "8.22.0",
|
|
86
|
+
"zod": "4.6.7",
|
|
87
|
+
"zustand": "5.0.16"
|
|
85
88
|
},
|
|
86
89
|
"devDependencies": {
|
|
87
|
-
"@types/dompurify": "
|
|
88
|
-
"@types/node": "
|
|
89
|
-
"@types/react": "
|
|
90
|
-
"@types/react-dom": "
|
|
91
|
-
"@types/ws": "
|
|
92
|
-
"esbuild": "
|
|
93
|
-
"playwright-core": "
|
|
94
|
-
"typescript": "
|
|
95
|
-
"vitest": "
|
|
90
|
+
"@types/dompurify": "3.0.5",
|
|
91
|
+
"@types/node": "26.6.6",
|
|
92
|
+
"@types/react": "19.3.0",
|
|
93
|
+
"@types/react-dom": "19.3.0",
|
|
94
|
+
"@types/ws": "8.18.2",
|
|
95
|
+
"esbuild": "0.28.2",
|
|
96
|
+
"playwright-core": "1.64.1",
|
|
97
|
+
"typescript": "7.0.2",
|
|
98
|
+
"vitest": "5.0.5"
|
|
96
99
|
}
|
|
97
100
|
}
|
package/page/boot.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Bootstrap page: the only network action of this sandboxed page (no allow-same-origin, connect-src 'none') is navigating itself elsewhere.
|
|
2
|
+
//
|
|
3
|
+
// Handoff: once the plugin host binds the gate port, it writes {port, key} into session.js in this directory.
|
|
4
|
+
// key is only the gate key, not the daemon token: Magpie serves this directory at /_magpie/plugin/<path that changes every launch>/
|
|
5
|
+
// without checking the origin, so any page that knows the path can read session.js via <script src>; nothing that unlocks the daemon goes there.
|
|
6
|
+
// This page navigates to the gate at http://127.0.0.1:<port>/open?k=<key>; the gate starts the daemon and 302s to the daemon page,
|
|
7
|
+
// with the daemon token only in the #t= fragment.
|
|
8
|
+
// The window's theme, language and color tokens are passed along too, so that page matches magpie.
|
|
3
9
|
(async () => {
|
|
4
|
-
const DEFAULT_PORT = 3488;
|
|
5
|
-
let port = DEFAULT_PORT;
|
|
6
10
|
let theme = "";
|
|
7
11
|
let lang = "";
|
|
8
12
|
let tokens = null;
|
|
@@ -14,14 +18,72 @@
|
|
|
14
18
|
if (typeof t.lang === "string") lang = t.lang;
|
|
15
19
|
if (t.tokens && typeof t.tokens === "object") tokens = t.tokens;
|
|
16
20
|
}
|
|
17
|
-
} catch { /*
|
|
21
|
+
} catch { /* no bridge: use defaults */ }
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
const zh = !lang || lang.toLowerCase().startsWith("zh");
|
|
24
|
+
const said = document.getElementById("said");
|
|
25
|
+
const brand = document.querySelector(".box b");
|
|
26
|
+
const retry = document.getElementById("retry");
|
|
27
|
+
if (said) said.textContent = zh ? "正在启动本地服务…" : "Starting local service…";
|
|
28
|
+
if (brand) brand.textContent = zh ? "Agent 控制台" : "Agent Console";
|
|
29
|
+
|
|
30
|
+
function validSession(s) {
|
|
31
|
+
return (
|
|
32
|
+
s &&
|
|
33
|
+
typeof s === "object" &&
|
|
34
|
+
Number.isInteger(s.port) &&
|
|
35
|
+
s.port > 0 &&
|
|
36
|
+
s.port <= 65535 &&
|
|
37
|
+
typeof s.key === "string" &&
|
|
38
|
+
/^[0-9a-f]{64}$/.test(s.key)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Reload an uncached copy of session.js (the key changes after the plugin host restarts).
|
|
43
|
+
// Returns null when the file doesn't exist (the host isn't up yet or the port isn't bound yet).
|
|
44
|
+
function loadFreshSession(attempt) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
globalThis.AgentConsoleSession = undefined;
|
|
47
|
+
const script = document.createElement("script");
|
|
48
|
+
script.src = `session.js?r=${Date.now()}-${attempt}`;
|
|
49
|
+
script.onload = () => {
|
|
50
|
+
script.remove();
|
|
51
|
+
resolve(validSession(globalThis.AgentConsoleSession) ? globalThis.AgentConsoleSession : null);
|
|
52
|
+
};
|
|
53
|
+
script.onerror = () => {
|
|
54
|
+
script.remove();
|
|
55
|
+
resolve(null);
|
|
56
|
+
};
|
|
57
|
+
document.head.appendChild(script);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The copy loaded statically by index.html (possibly cached) is only a fallback
|
|
62
|
+
const preloaded = validSession(globalThis.AgentConsoleSession) ? globalThis.AgentConsoleSession : null;
|
|
63
|
+
let session = await loadFreshSession(0);
|
|
64
|
+
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++) {
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
68
|
+
session = await loadFreshSession(attempt);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!session) {
|
|
72
|
+
if (said) {
|
|
73
|
+
said.textContent = zh
|
|
74
|
+
? "本地服务还没有就绪。请稍后重试;如果一直这样,请在 Magpie 里重新加载插件。"
|
|
75
|
+
: "The local service is not ready yet. Try again in a moment; if this persists, reload the plugin in Magpie.";
|
|
76
|
+
}
|
|
77
|
+
if (retry) {
|
|
78
|
+
retry.textContent = zh ? "重试" : "Retry";
|
|
79
|
+
retry.hidden = false;
|
|
80
|
+
retry.onclick = () => location.reload();
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
23
84
|
|
|
24
85
|
const q = new URLSearchParams();
|
|
86
|
+
q.set("k", session.key);
|
|
25
87
|
if (theme) q.set("theme", theme);
|
|
26
88
|
if (lang) q.set("lang", lang);
|
|
27
89
|
const slim = {};
|
|
@@ -30,19 +92,8 @@
|
|
|
30
92
|
if (s) slim[k] = s;
|
|
31
93
|
}
|
|
32
94
|
if (Object.keys(slim).length) {
|
|
33
|
-
try { q.set("tokens", btoa(unescape(encodeURIComponent(JSON.stringify(slim))))); } catch { /*
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const said = document.getElementById("said");
|
|
37
|
-
const brand = document.querySelector(".box b");
|
|
38
|
-
if (said) {
|
|
39
|
-
const zh = !lang || lang.toLowerCase().startsWith("zh");
|
|
40
|
-
said.textContent = zh ? "正在启动本地服务…" : "Starting local service…";
|
|
41
|
-
}
|
|
42
|
-
if (brand) {
|
|
43
|
-
const zh = !lang || lang.toLowerCase().startsWith("zh");
|
|
44
|
-
brand.textContent = zh ? "Agent 控制台" : "Agent Console";
|
|
95
|
+
try { q.set("tokens", btoa(unescape(encodeURIComponent(JSON.stringify(slim))))); } catch { /* fine if it can't be passed along */ }
|
|
45
96
|
}
|
|
46
97
|
|
|
47
|
-
location.replace("http://127.0.0.1:" + port + "
|
|
98
|
+
location.replace("http://127.0.0.1:" + session.port + "/open?" + q.toString());
|
|
48
99
|
})();
|
package/page/index.html
CHANGED
|
@@ -14,11 +14,18 @@
|
|
|
14
14
|
}
|
|
15
15
|
.box { text-align: center; color: var(--magpie-muted, GrayText); }
|
|
16
16
|
.box b { display: block; color: var(--magpie-fg, CanvasText); font-size: 14px; margin-bottom: 4px; }
|
|
17
|
+
.box button { margin-top: 12px; }
|
|
17
18
|
</style>
|
|
18
19
|
</head>
|
|
19
20
|
<body>
|
|
20
|
-
<div class="box"
|
|
21
|
+
<div class="box">
|
|
22
|
+
<b>Agent Console</b>
|
|
23
|
+
<span id="said">正在启动本地服务…</span>
|
|
24
|
+
<div><button id="retry" type="button" hidden>Retry</button></div>
|
|
25
|
+
</div>
|
|
21
26
|
<script src="_magpie/bridge.js"></script>
|
|
27
|
+
<!-- {port, key} written by the plugin host after it binds the gate port (the gate key, not the daemon token); boot.js retries if it doesn't exist -->
|
|
28
|
+
<script src="session.js"></script>
|
|
22
29
|
<script src="boot.js"></script>
|
|
23
30
|
</body>
|
|
24
31
|
</html>
|