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/index.mjs
CHANGED
|
@@ -1,244 +1,453 @@
|
|
|
1
|
-
// Agent Console
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 {
|
|
33
|
+
import { dirname, join } from "node:path";
|
|
11
34
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
86
|
+
return null;
|
|
34
87
|
}
|
|
35
88
|
}
|
|
36
89
|
|
|
37
90
|
function ping(port, token) {
|
|
38
|
-
return new Promise((
|
|
39
|
-
const
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
107
|
+
resolve(JSON.parse(body));
|
|
47
108
|
} catch {
|
|
48
|
-
|
|
109
|
+
resolve(null);
|
|
49
110
|
}
|
|
50
111
|
});
|
|
51
|
-
}
|
|
112
|
+
},
|
|
52
113
|
);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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((
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
160
|
+
message = JSON.parse(String(data));
|
|
96
161
|
} catch {
|
|
97
162
|
return;
|
|
98
163
|
}
|
|
99
|
-
if (
|
|
100
|
-
|
|
164
|
+
if (message.t === "hello.ok") {
|
|
165
|
+
socket.send(JSON.stringify({ t: "req", id: 1, m: "app.shutdown", p: {} }));
|
|
101
166
|
}
|
|
102
|
-
if (
|
|
103
|
-
clearTimeout(
|
|
104
|
-
|
|
105
|
-
|
|
167
|
+
if (message.t === "res" && message.id === 1) {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
socket.close();
|
|
170
|
+
resolve();
|
|
106
171
|
}
|
|
107
172
|
});
|
|
108
|
-
|
|
109
|
-
clearTimeout(
|
|
110
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
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> };
|