@lelouchhe/webagent 0.1.7 → 0.1.10
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 +21 -18
- package/config.toml +1 -1
- package/dist/index.html +2 -2
- package/dist/js/app.IXP5KGP6.js +8 -0
- package/dist/{styles.mmmmfxhu.css → styles.01a9ju9l.css} +8 -0
- package/dist/sw.js +1 -1
- package/package.json +7 -8
- package/dist/js/app.mmmmfxhu.js +0 -34
- package/dist/js/commands.mmmmfxhu.js +0 -647
- package/dist/js/connection.mmmmfxhu.js +0 -87
- package/dist/js/events.mmmmfxhu.js +0 -694
- package/dist/js/images.mmmmfxhu.js +0 -58
- package/dist/js/input.mmmmfxhu.js +0 -215
- package/dist/js/render.mmmmfxhu.js +0 -200
- package/dist/js/state.mmmmfxhu.js +0 -203
- package/lib/bridge.js +0 -284
- package/lib/config.js +0 -62
- package/lib/daemon.js +0 -278
- package/lib/event-handler.js +0 -95
- package/lib/push-service.js +0 -112
- package/lib/routes.js +0 -202
- package/lib/server.js +0 -70
- package/lib/session-manager.js +0 -199
- package/lib/store.js +0 -120
- package/lib/title-service.js +0 -71
- package/lib/types.js +0 -48
- package/lib/ws-handler.js +0 -280
package/lib/bridge.js
DELETED
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
import { spawn, ChildProcess } from "node:child_process";
|
|
2
|
-
import { Writable, Readable } from "node:stream";
|
|
3
|
-
import { EventEmitter } from "node:events";
|
|
4
|
-
import * as acp from "@agentclientprotocol/sdk";
|
|
5
|
-
export class AgentBridge extends EventEmitter {
|
|
6
|
-
proc = null;
|
|
7
|
-
conn = null;
|
|
8
|
-
permissionResolvers = new Map();
|
|
9
|
-
permissionRequestSessions = new Map();
|
|
10
|
-
silentSessions = new Set(); // Sessions that don't emit events
|
|
11
|
-
silentBuffers = new Map(); // Text buffers for silent sessions
|
|
12
|
-
agentCmd;
|
|
13
|
-
constructor(agentCmd) {
|
|
14
|
-
super();
|
|
15
|
-
this.agentCmd = agentCmd;
|
|
16
|
-
}
|
|
17
|
-
async start() {
|
|
18
|
-
const [cmd, ...args] = this.agentCmd.split(/\s+/);
|
|
19
|
-
this.proc = spawn(cmd, args, {
|
|
20
|
-
stdio: ["pipe", "pipe", "inherit"],
|
|
21
|
-
});
|
|
22
|
-
if (!this.proc.stdin || !this.proc.stdout) {
|
|
23
|
-
throw new Error(`Failed to start: ${this.agentCmd}`);
|
|
24
|
-
}
|
|
25
|
-
const input = Writable.toWeb(this.proc.stdin);
|
|
26
|
-
const output = Readable.toWeb(this.proc.stdout);
|
|
27
|
-
const stream = acp.ndJsonStream(input, output);
|
|
28
|
-
const client = {
|
|
29
|
-
requestPermission: async (params) => this.handlePermission(params),
|
|
30
|
-
sessionUpdate: async (params) => this.handleSessionUpdate(params),
|
|
31
|
-
readTextFile: async (params) => this.handleReadFile(params),
|
|
32
|
-
writeTextFile: async (params) => this.handleWriteFile(params),
|
|
33
|
-
};
|
|
34
|
-
this.conn = new acp.ClientSideConnection((_agent) => client, stream);
|
|
35
|
-
const init = await this.conn.initialize({
|
|
36
|
-
protocolVersion: acp.PROTOCOL_VERSION,
|
|
37
|
-
clientCapabilities: {
|
|
38
|
-
fs: { readTextFile: true, writeTextFile: true },
|
|
39
|
-
terminal: true,
|
|
40
|
-
},
|
|
41
|
-
});
|
|
42
|
-
const agentInfo = init.agentInfo;
|
|
43
|
-
this.emit("event", {
|
|
44
|
-
type: "connected",
|
|
45
|
-
agent: {
|
|
46
|
-
name: agentInfo?.name ?? "unknown",
|
|
47
|
-
version: agentInfo?.version ?? "?",
|
|
48
|
-
},
|
|
49
|
-
configOptions: [],
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
async newSession(cwd, opts) {
|
|
53
|
-
if (!this.conn)
|
|
54
|
-
throw new Error("Not connected");
|
|
55
|
-
const session = await this.conn.newSession({ cwd, mcpServers: [] });
|
|
56
|
-
if (!opts?.silent) {
|
|
57
|
-
this.emit("event", {
|
|
58
|
-
type: "session_created",
|
|
59
|
-
sessionId: session.sessionId,
|
|
60
|
-
cwd,
|
|
61
|
-
configOptions: session.configOptions ?? [],
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
return session.sessionId;
|
|
65
|
-
}
|
|
66
|
-
async loadSession(sessionId, cwd) {
|
|
67
|
-
if (!this.conn)
|
|
68
|
-
throw new Error("Not connected");
|
|
69
|
-
const session = await this.conn.loadSession({ sessionId, cwd, mcpServers: [] });
|
|
70
|
-
this.emit("event", {
|
|
71
|
-
type: "session_created",
|
|
72
|
-
sessionId: session.sessionId,
|
|
73
|
-
cwd,
|
|
74
|
-
configOptions: session.configOptions ?? [],
|
|
75
|
-
});
|
|
76
|
-
return { sessionId: session.sessionId, configOptions: session.configOptions ?? [] };
|
|
77
|
-
}
|
|
78
|
-
async setConfigOption(sessionId, configId, value) {
|
|
79
|
-
if (!this.conn)
|
|
80
|
-
throw new Error("Not connected");
|
|
81
|
-
const result = await this.conn.setSessionConfigOption({ sessionId, configId, value });
|
|
82
|
-
return result.configOptions ?? [];
|
|
83
|
-
}
|
|
84
|
-
async prompt(sessionId, text, images) {
|
|
85
|
-
if (!this.conn)
|
|
86
|
-
throw new Error("Not connected");
|
|
87
|
-
try {
|
|
88
|
-
const promptParts = [];
|
|
89
|
-
if (images) {
|
|
90
|
-
for (const img of images) {
|
|
91
|
-
promptParts.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
promptParts.push({ type: "text", text });
|
|
95
|
-
const result = await this.conn.prompt({
|
|
96
|
-
sessionId,
|
|
97
|
-
prompt: promptParts,
|
|
98
|
-
});
|
|
99
|
-
this.emit("event", {
|
|
100
|
-
type: "prompt_done",
|
|
101
|
-
sessionId,
|
|
102
|
-
stopReason: result.stopReason ?? "end_turn",
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
catch (err) {
|
|
106
|
-
const message = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
|
|
107
|
-
if (/cancel/i.test(message)) {
|
|
108
|
-
this.emit("event", {
|
|
109
|
-
type: "prompt_done",
|
|
110
|
-
sessionId,
|
|
111
|
-
stopReason: "cancelled",
|
|
112
|
-
});
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
this.emit("event", { type: "error", sessionId, message });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
async cancel(sessionId) {
|
|
119
|
-
for (const [requestId, requestSessionId] of this.permissionRequestSessions) {
|
|
120
|
-
if (requestSessionId === sessionId) {
|
|
121
|
-
this.denyPermission(requestId);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
await this.conn?.cancel({ sessionId });
|
|
125
|
-
}
|
|
126
|
-
/** Send a prompt and collect the full text response without emitting events. */
|
|
127
|
-
async promptForText(sessionId, text) {
|
|
128
|
-
if (!this.conn)
|
|
129
|
-
throw new Error("Not connected");
|
|
130
|
-
this.silentSessions.add(sessionId);
|
|
131
|
-
this.silentBuffers.set(sessionId, "");
|
|
132
|
-
try {
|
|
133
|
-
await this.conn.prompt({ sessionId, prompt: [{ type: "text", text }] });
|
|
134
|
-
return this.silentBuffers.get(sessionId) ?? "";
|
|
135
|
-
}
|
|
136
|
-
catch (err) {
|
|
137
|
-
const message = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
|
|
138
|
-
if (/cancel/i.test(message)) {
|
|
139
|
-
return "";
|
|
140
|
-
}
|
|
141
|
-
throw err;
|
|
142
|
-
}
|
|
143
|
-
finally {
|
|
144
|
-
this.silentSessions.delete(sessionId);
|
|
145
|
-
this.silentBuffers.delete(sessionId);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
resolvePermission(requestId, optionId) {
|
|
149
|
-
const resolve = this.permissionResolvers.get(requestId);
|
|
150
|
-
if (resolve) {
|
|
151
|
-
resolve({ outcome: { outcome: "selected", optionId } });
|
|
152
|
-
this.permissionResolvers.delete(requestId);
|
|
153
|
-
this.permissionRequestSessions.delete(requestId);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
denyPermission(requestId) {
|
|
157
|
-
const resolve = this.permissionResolvers.get(requestId);
|
|
158
|
-
if (resolve) {
|
|
159
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
160
|
-
this.permissionResolvers.delete(requestId);
|
|
161
|
-
this.permissionRequestSessions.delete(requestId);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
async shutdown() {
|
|
165
|
-
// Reject all pending permissions
|
|
166
|
-
for (const [id, resolve] of this.permissionResolvers) {
|
|
167
|
-
resolve({ outcome: { outcome: "cancelled" } });
|
|
168
|
-
}
|
|
169
|
-
this.permissionResolvers.clear();
|
|
170
|
-
this.permissionRequestSessions.clear();
|
|
171
|
-
if (this.proc && this.proc.exitCode === null) {
|
|
172
|
-
this.proc.kill();
|
|
173
|
-
await new Promise((resolve) => {
|
|
174
|
-
const timer = setTimeout(() => {
|
|
175
|
-
this.proc?.kill(process.platform === "win32" ? undefined : "SIGKILL");
|
|
176
|
-
resolve();
|
|
177
|
-
}, 5000);
|
|
178
|
-
this.proc?.on("exit", () => {
|
|
179
|
-
clearTimeout(timer);
|
|
180
|
-
resolve();
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
this.proc = null;
|
|
185
|
-
this.conn = null;
|
|
186
|
-
}
|
|
187
|
-
// --- ACP Client callbacks ---
|
|
188
|
-
handlePermission(params) {
|
|
189
|
-
const requestId = crypto.randomUUID();
|
|
190
|
-
const title = params.toolCall?.title ?? "Permission requested";
|
|
191
|
-
const toolCallId = params.toolCall?.toolCallId ?? null;
|
|
192
|
-
return new Promise((resolve) => {
|
|
193
|
-
// Register resolver BEFORE emitting, so synchronous auto-approve can find it
|
|
194
|
-
this.permissionResolvers.set(requestId, resolve);
|
|
195
|
-
this.permissionRequestSessions.set(requestId, params.sessionId);
|
|
196
|
-
this.emit("event", {
|
|
197
|
-
type: "permission_request",
|
|
198
|
-
requestId,
|
|
199
|
-
sessionId: params.sessionId,
|
|
200
|
-
title,
|
|
201
|
-
toolCallId,
|
|
202
|
-
options: params.options,
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
handleSessionUpdate(params) {
|
|
207
|
-
const update = params.update;
|
|
208
|
-
const sessionId = params.sessionId;
|
|
209
|
-
// Silent sessions: only buffer text, don't emit events
|
|
210
|
-
if (this.silentSessions.has(sessionId)) {
|
|
211
|
-
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
|
212
|
-
const buf = (this.silentBuffers.get(sessionId) ?? "") + update.content.text;
|
|
213
|
-
this.silentBuffers.set(sessionId, buf);
|
|
214
|
-
}
|
|
215
|
-
return Promise.resolve();
|
|
216
|
-
}
|
|
217
|
-
switch (update.sessionUpdate) {
|
|
218
|
-
case "agent_message_chunk":
|
|
219
|
-
if (update.content.type === "text") {
|
|
220
|
-
this.emit("event", {
|
|
221
|
-
type: "message_chunk",
|
|
222
|
-
sessionId,
|
|
223
|
-
text: update.content.text,
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
break;
|
|
227
|
-
case "agent_thought_chunk":
|
|
228
|
-
if (update.content.type === "text") {
|
|
229
|
-
this.emit("event", {
|
|
230
|
-
type: "thought_chunk",
|
|
231
|
-
sessionId,
|
|
232
|
-
text: update.content.text,
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
break;
|
|
236
|
-
case "tool_call":
|
|
237
|
-
this.emit("event", {
|
|
238
|
-
type: "tool_call",
|
|
239
|
-
sessionId,
|
|
240
|
-
id: update.toolCallId ?? "",
|
|
241
|
-
title: update.title ?? "",
|
|
242
|
-
kind: update.kind ?? "unknown",
|
|
243
|
-
rawInput: update.rawInput,
|
|
244
|
-
});
|
|
245
|
-
break;
|
|
246
|
-
case "tool_call_update":
|
|
247
|
-
this.emit("event", {
|
|
248
|
-
type: "tool_call_update",
|
|
249
|
-
sessionId,
|
|
250
|
-
id: update.toolCallId ?? "",
|
|
251
|
-
status: update.status ?? "",
|
|
252
|
-
content: update.content ?? undefined,
|
|
253
|
-
});
|
|
254
|
-
break;
|
|
255
|
-
case "plan":
|
|
256
|
-
this.emit("event", {
|
|
257
|
-
type: "plan",
|
|
258
|
-
sessionId,
|
|
259
|
-
entries: update.entries ?? [],
|
|
260
|
-
});
|
|
261
|
-
break;
|
|
262
|
-
case "config_option_update":
|
|
263
|
-
this.emit("event", {
|
|
264
|
-
type: "config_option_update",
|
|
265
|
-
sessionId,
|
|
266
|
-
configOptions: update.configOptions ?? [],
|
|
267
|
-
});
|
|
268
|
-
break;
|
|
269
|
-
}
|
|
270
|
-
return Promise.resolve();
|
|
271
|
-
}
|
|
272
|
-
async handleReadFile(params) {
|
|
273
|
-
const { readFile } = await import("node:fs/promises");
|
|
274
|
-
const content = await readFile(params.path, "utf-8");
|
|
275
|
-
return { content };
|
|
276
|
-
}
|
|
277
|
-
async handleWriteFile(params) {
|
|
278
|
-
const { writeFile, mkdir } = await import("node:fs/promises");
|
|
279
|
-
const { dirname } = await import("node:path");
|
|
280
|
-
await mkdir(dirname(params.path), { recursive: true });
|
|
281
|
-
await writeFile(params.path, params.content);
|
|
282
|
-
return {};
|
|
283
|
-
}
|
|
284
|
-
}
|
package/lib/config.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { parse as parseTOML } from "smol-toml";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
const ConfigSchema = z.object({
|
|
5
|
-
port: z.number().int().positive().default(6800),
|
|
6
|
-
data_dir: z.string().default("data"),
|
|
7
|
-
default_cwd: z.string().default(process.cwd()),
|
|
8
|
-
public_dir: z.string().default("dist"),
|
|
9
|
-
agent_cmd: z.string().default("copilot --acp"),
|
|
10
|
-
limits: z.object({
|
|
11
|
-
bash_output: z.number().int().positive().default(1_048_576), // 1 MB
|
|
12
|
-
image_upload: z.number().int().positive().default(10_485_760), // 10 MB
|
|
13
|
-
cancel_timeout: z.number().int().nonnegative().default(10_000), // 10s; 0 disables
|
|
14
|
-
}).default({
|
|
15
|
-
bash_output: 1_048_576,
|
|
16
|
-
image_upload: 10_485_760,
|
|
17
|
-
cancel_timeout: 10_000,
|
|
18
|
-
}),
|
|
19
|
-
push: z.object({
|
|
20
|
-
vapid_subject: z.string().default("mailto:webagent@localhost"),
|
|
21
|
-
}).default({
|
|
22
|
-
vapid_subject: "mailto:webagent@localhost",
|
|
23
|
-
}),
|
|
24
|
-
});
|
|
25
|
-
let _config = null;
|
|
26
|
-
function parseArgs() {
|
|
27
|
-
const idx = process.argv.indexOf("--config");
|
|
28
|
-
if (idx !== -1 && idx + 1 < process.argv.length) {
|
|
29
|
-
return process.argv[idx + 1];
|
|
30
|
-
}
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
export function loadConfig() {
|
|
34
|
-
const configPath = parseArgs();
|
|
35
|
-
let raw = {};
|
|
36
|
-
if (configPath) {
|
|
37
|
-
try {
|
|
38
|
-
const content = readFileSync(configPath, "utf-8");
|
|
39
|
-
raw = parseTOML(content);
|
|
40
|
-
console.log(`[config] loaded: ${configPath}`);
|
|
41
|
-
}
|
|
42
|
-
catch (err) {
|
|
43
|
-
console.error(`[config] failed to read ${configPath}:`, err);
|
|
44
|
-
process.exit(1);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
console.log("[config] no --config provided, using defaults");
|
|
49
|
-
}
|
|
50
|
-
const result = ConfigSchema.safeParse(raw);
|
|
51
|
-
if (!result.success) {
|
|
52
|
-
console.error("[config] validation error:", result.error.format());
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
_config = result.data;
|
|
56
|
-
return _config;
|
|
57
|
-
}
|
|
58
|
-
export function getConfig() {
|
|
59
|
-
if (!_config)
|
|
60
|
-
throw new Error("Config not loaded. Call loadConfig() first.");
|
|
61
|
-
return _config;
|
|
62
|
-
}
|
package/lib/daemon.js
DELETED
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Constants
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
const PID_FILE = "webagent.pid";
|
|
10
|
-
const LOG_FILE = "webagent.log";
|
|
11
|
-
const RESTART_DELAY_INITIAL = 1_000;
|
|
12
|
-
const RESTART_DELAY_MAX = 30_000;
|
|
13
|
-
const STABLE_THRESHOLD_MS = 60_000;
|
|
14
|
-
const KILL_GRACE_MS = 5_000;
|
|
15
|
-
const SUBCOMMANDS = ["start", "stop", "status", "restart"];
|
|
16
|
-
/** Read and validate the PID file at `filePath`. Returns null if missing or stale. */
|
|
17
|
-
export function readPidInfo(filePath) {
|
|
18
|
-
if (!existsSync(filePath))
|
|
19
|
-
return null;
|
|
20
|
-
try {
|
|
21
|
-
const info = JSON.parse(readFileSync(filePath, "utf8"));
|
|
22
|
-
if (typeof info.pid !== "number" || !Number.isFinite(info.pid))
|
|
23
|
-
return null;
|
|
24
|
-
process.kill(info.pid, 0); // existence check — throws if dead
|
|
25
|
-
return info;
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
// Process is dead or file corrupt — clean up
|
|
29
|
-
try {
|
|
30
|
-
unlinkSync(filePath);
|
|
31
|
-
}
|
|
32
|
-
catch { /* ignore */ }
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
/** Write PID info to `filePath`. */
|
|
37
|
-
export function writePidInfo(filePath, info) {
|
|
38
|
-
writeFileSync(filePath, JSON.stringify(info) + "\n");
|
|
39
|
-
}
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
// Arg helpers
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
export function isSubcommand(arg) {
|
|
44
|
-
return SUBCOMMANDS.includes(arg);
|
|
45
|
-
}
|
|
46
|
-
/** Resolve relative `--config` values to absolute paths (based on cwd). */
|
|
47
|
-
export function resolveArgs(args) {
|
|
48
|
-
const result = [...args];
|
|
49
|
-
for (let i = 0; i < result.length; i++) {
|
|
50
|
-
if (result[i] === "--config" && i + 1 < result.length && !isAbsolute(result[i + 1])) {
|
|
51
|
-
result[i + 1] = resolve(result[i + 1]);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return result;
|
|
55
|
-
}
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Command dispatch
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
export async function run(command, args) {
|
|
60
|
-
const pidFile = join(process.cwd(), PID_FILE);
|
|
61
|
-
const logFile = join(process.cwd(), LOG_FILE);
|
|
62
|
-
switch (command) {
|
|
63
|
-
case "start": return cmdStart(pidFile, logFile, args);
|
|
64
|
-
case "stop": return cmdStop(pidFile);
|
|
65
|
-
case "status": return cmdStatus(pidFile, logFile);
|
|
66
|
-
case "restart": return cmdRestart(pidFile, logFile);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
// Commands
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
async function cmdStart(pidFile, logFile, args) {
|
|
73
|
-
const existing = readPidInfo(pidFile);
|
|
74
|
-
if (existing) {
|
|
75
|
-
console.log(`webagent is already running (pid ${existing.pid})`);
|
|
76
|
-
process.exitCode = 1;
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
const serverJs = join(__dirname, "server.js");
|
|
80
|
-
if (!existsSync(serverJs)) {
|
|
81
|
-
console.error(`server not found: ${serverJs}`);
|
|
82
|
-
console.error('run "npx tsc -p tsconfig.build.json" first if developing from source');
|
|
83
|
-
process.exitCode = 1;
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const resolved = resolveArgs(args);
|
|
87
|
-
const daemonJs = join(__dirname, "daemon.js");
|
|
88
|
-
const log = openSync(logFile, "a");
|
|
89
|
-
const child = spawn(process.execPath, [daemonJs, "__supervisor", ...resolved], { detached: true, stdio: ["ignore", log, log], cwd: process.cwd() });
|
|
90
|
-
child.unref();
|
|
91
|
-
closeSync(log);
|
|
92
|
-
// Poll for PID file (supervisor writes it on startup)
|
|
93
|
-
for (let i = 0; i < 6; i++) {
|
|
94
|
-
await sleep(500);
|
|
95
|
-
const info = readPidInfo(pidFile);
|
|
96
|
-
if (info) {
|
|
97
|
-
console.log(`webagent started (pid ${info.pid})`);
|
|
98
|
-
console.log(`log: ${logFile}`);
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
console.error("webagent failed to start");
|
|
103
|
-
console.error(`check log: ${logFile}`);
|
|
104
|
-
process.exitCode = 1;
|
|
105
|
-
}
|
|
106
|
-
async function cmdStop(pidFile) {
|
|
107
|
-
const info = readPidInfo(pidFile);
|
|
108
|
-
if (!info) {
|
|
109
|
-
console.log("webagent is not running");
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
process.kill(info.pid, "SIGTERM");
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
console.log("webagent is not running (stale pid file removed)");
|
|
117
|
-
try {
|
|
118
|
-
unlinkSync(pidFile);
|
|
119
|
-
}
|
|
120
|
-
catch { /* ignore */ }
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
// Wait for exit
|
|
124
|
-
const deadline = Date.now() + 10_000;
|
|
125
|
-
while (Date.now() < deadline) {
|
|
126
|
-
await sleep(300);
|
|
127
|
-
try {
|
|
128
|
-
process.kill(info.pid, 0);
|
|
129
|
-
}
|
|
130
|
-
catch {
|
|
131
|
-
// Gone — supervisor cleans up PID file, but be safe
|
|
132
|
-
try {
|
|
133
|
-
unlinkSync(pidFile);
|
|
134
|
-
}
|
|
135
|
-
catch { /* ignore */ }
|
|
136
|
-
console.log("webagent stopped");
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
console.error(`webagent (pid ${info.pid}) did not stop within 10s`);
|
|
141
|
-
console.error(`try: kill -9 ${info.pid}`);
|
|
142
|
-
process.exitCode = 1;
|
|
143
|
-
}
|
|
144
|
-
async function cmdStatus(pidFile, logFile) {
|
|
145
|
-
const info = readPidInfo(pidFile);
|
|
146
|
-
if (!info) {
|
|
147
|
-
console.log("webagent is not running");
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
const uptimeMs = Date.now() - new Date(info.started).getTime();
|
|
151
|
-
const h = Math.floor(uptimeMs / 3_600_000);
|
|
152
|
-
const m = Math.floor((uptimeMs % 3_600_000) / 60_000);
|
|
153
|
-
console.log(`webagent is running (pid ${info.pid})`);
|
|
154
|
-
console.log(` started: ${info.started}`);
|
|
155
|
-
console.log(` uptime: ${h}h ${m}m`);
|
|
156
|
-
console.log(` args: ${info.args.join(" ") || "(none)"}`);
|
|
157
|
-
console.log(` log: ${logFile}`);
|
|
158
|
-
}
|
|
159
|
-
async function cmdRestart(pidFile, logFile) {
|
|
160
|
-
const info = readPidInfo(pidFile);
|
|
161
|
-
if (!info) {
|
|
162
|
-
console.log("webagent is not running");
|
|
163
|
-
process.exitCode = 1;
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (process.platform === "win32") {
|
|
167
|
-
// No SIGHUP on Windows — fall back to stop + start (non-atomic)
|
|
168
|
-
await cmdStop(pidFile);
|
|
169
|
-
await cmdStart(pidFile, logFile, info.args);
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
// Unix: atomic restart via SIGHUP to supervisor
|
|
173
|
-
try {
|
|
174
|
-
process.kill(info.pid, "SIGHUP");
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
console.error(`failed to signal webagent (pid ${info.pid})`);
|
|
178
|
-
process.exitCode = 1;
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
// Wait briefly and verify
|
|
182
|
-
await sleep(2000);
|
|
183
|
-
const newInfo = readPidInfo(pidFile);
|
|
184
|
-
if (newInfo) {
|
|
185
|
-
console.log(`webagent restarted (pid ${newInfo.pid})`);
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
console.error("webagent may have failed to restart");
|
|
189
|
-
console.error(`check log: ${logFile}`);
|
|
190
|
-
process.exitCode = 1;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
// ---------------------------------------------------------------------------
|
|
194
|
-
// Supervisor (internal — launched by `start` as a detached process)
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
function runSupervisor(serverArgs) {
|
|
197
|
-
const serverJs = join(__dirname, "server.js");
|
|
198
|
-
const pidFile = join(process.cwd(), PID_FILE);
|
|
199
|
-
writePidInfo(pidFile, { pid: process.pid, args: serverArgs, started: new Date().toISOString() });
|
|
200
|
-
let child = null;
|
|
201
|
-
let stopping = false;
|
|
202
|
-
let lastStart = 0;
|
|
203
|
-
let delay = RESTART_DELAY_INITIAL;
|
|
204
|
-
let timer = null;
|
|
205
|
-
function spawnServer() {
|
|
206
|
-
lastStart = Date.now();
|
|
207
|
-
child = spawn(process.execPath, [serverJs, ...serverArgs], { stdio: "inherit" });
|
|
208
|
-
child.on("exit", onChildExit);
|
|
209
|
-
}
|
|
210
|
-
function onChildExit(code, signal) {
|
|
211
|
-
child = null;
|
|
212
|
-
if (stopping)
|
|
213
|
-
return;
|
|
214
|
-
if (Date.now() - lastStart > STABLE_THRESHOLD_MS) {
|
|
215
|
-
delay = RESTART_DELAY_INITIAL;
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
delay = Math.min(delay * 2, RESTART_DELAY_MAX);
|
|
219
|
-
}
|
|
220
|
-
console.log(`[supervisor] server exited (code=${code} signal=${signal}), restarting in ${delay}ms`);
|
|
221
|
-
timer = setTimeout(spawnServer, delay);
|
|
222
|
-
}
|
|
223
|
-
function killChild() {
|
|
224
|
-
if (timer) {
|
|
225
|
-
clearTimeout(timer);
|
|
226
|
-
timer = null;
|
|
227
|
-
}
|
|
228
|
-
return new Promise((resolve) => {
|
|
229
|
-
if (!child) {
|
|
230
|
-
resolve();
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
const c = child;
|
|
234
|
-
c.once("exit", () => resolve());
|
|
235
|
-
c.kill("SIGTERM");
|
|
236
|
-
setTimeout(() => { try {
|
|
237
|
-
c.kill("SIGKILL");
|
|
238
|
-
}
|
|
239
|
-
catch { /* ignore */ } }, KILL_GRACE_MS);
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
async function shutdown() {
|
|
243
|
-
if (stopping)
|
|
244
|
-
return;
|
|
245
|
-
stopping = true;
|
|
246
|
-
await killChild();
|
|
247
|
-
try {
|
|
248
|
-
unlinkSync(pidFile);
|
|
249
|
-
}
|
|
250
|
-
catch { /* ignore */ }
|
|
251
|
-
process.exit(0);
|
|
252
|
-
}
|
|
253
|
-
process.on("SIGTERM", () => { shutdown(); });
|
|
254
|
-
process.on("SIGINT", () => { shutdown(); });
|
|
255
|
-
if (process.platform !== "win32") {
|
|
256
|
-
process.on("SIGHUP", async () => {
|
|
257
|
-
console.log("[supervisor] SIGHUP received, restarting server");
|
|
258
|
-
delay = RESTART_DELAY_INITIAL;
|
|
259
|
-
await killChild();
|
|
260
|
-
if (!stopping)
|
|
261
|
-
spawnServer();
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
console.log(`[supervisor] started (pid ${process.pid})`);
|
|
265
|
-
spawnServer();
|
|
266
|
-
}
|
|
267
|
-
// ---------------------------------------------------------------------------
|
|
268
|
-
// Utility
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
function sleep(ms) {
|
|
271
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
272
|
-
}
|
|
273
|
-
// ---------------------------------------------------------------------------
|
|
274
|
-
// Direct execution: node daemon.js __supervisor [server args...]
|
|
275
|
-
// ---------------------------------------------------------------------------
|
|
276
|
-
if (process.argv[2] === "__supervisor") {
|
|
277
|
-
runSupervisor(process.argv.slice(3));
|
|
278
|
-
}
|