@liguoshuai/pi-web-chat 1.10.3 → 2.1.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/server.js CHANGED
@@ -1,1140 +1,26 @@
1
- // server.js — pi-web-chat backend
2
- // Bridges a browser WebSocket to a `pi --mode rpc` subprocess, and REST APIs
3
- // for listing sessions and reading session history from the JSONL store.
4
- import { spawn } from "child_process";
5
- import { randomUUID } from "crypto";
6
- import { readFile, readdir, stat, writeFile, mkdir, realpath as fsRealpath, unlink } from "fs/promises";
7
- import { readFileSync, existsSync } from "fs";
8
- import { StringDecoder } from "string_decoder";
9
- import express from "express";
10
- import { WebSocketServer } from "ws";
1
+ // server.js — pi-web-chat runner
2
+ // Thin adapter launching the unified @liguoshuai/pi-chat-server gateway engine
3
+ import { createServer } from "@liguoshuai/pi-chat-server";
4
+ import { config } from "@liguoshuai/pi-chat-server/config";
11
5
  import path from "path";
12
- import os from "os";
13
6
  import { fileURLToPath } from "url";
14
- import { dirname } from "path";
15
7
 
16
- const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const publicDir = path.join(__dirname, "public");
17
10
 
18
- let PKG_VERSION = "1.0.0";
19
- try {
20
- const pkg = JSON.parse(readFileSync(path.join(__dirname, "package.json"), "utf8"));
21
- if (pkg.version) PKG_VERSION = pkg.version;
22
- } catch {}
23
-
24
- // Resolve pi binary: prefer PI_BIN env, else search PATH, else fall back to ~/.npm-global/bin/pi
25
- function resolvePiBin() {
26
- if (process.env.PI_BIN && existsSync(process.env.PI_BIN)) return process.env.PI_BIN;
27
- const home = os.homedir();
28
- const candidates = [
29
- path.join(home, ".npm-global/bin/pi"),
30
- "/usr/local/bin/pi",
31
- "/usr/bin/pi",
32
- ];
33
- for (const c of candidates) if (existsSync(c)) return c;
34
- return "pi"; // hope it's on PATH of the spawned shell
35
- }
36
- const PI_BIN = resolvePiBin();
37
- function home() { return os.homedir(); }
38
-
39
- function normalizePath(p) {
40
- if (!p) return "";
41
- let resolved = p;
42
- if (p.startsWith("~")) {
43
- resolved = path.join(home(), p.slice(1));
44
- }
45
- return path.resolve(resolved);
46
- }
47
-
48
- function normalizeCwd(dir) {
49
- if (!dir) return process.cwd() || home();
50
- return normalizePath(dir);
51
- }
52
-
53
- // Where pi stores sessions, organized by cwd-encoded subdirectory.
54
- const SESSIONS_DIR = process.env.PI_SESSIONS_DIR || path.join(home(), ".pi", "agent", "sessions");
55
- const PORT = process.env.PORT || 3000;
56
-
57
- // Idle timeout (ms). When a pi agent has NO WebSocket attached AND is truly
58
- // idle (no streaming/pending tasks), after this many ms the subprocess is
59
- // killed to reclaim memory. Browser-close alone does NOT trigger this — only
60
- // true idleness does — so a task that is mid-flight keeps running in the
61
- // background after you close the tab, and survives until it finishes.
62
- // Set IDLE_TIMEOUT_MS=0 to disable idle reclamation entirely (agents live
63
- // until MAX_AGENT_LIFETIME_MS or server shutdown).
64
- const IDLE_TIMEOUT_MS = (() => {
65
- const raw = process.env.IDLE_TIMEOUT_MS;
66
- if (raw === undefined || raw === "") return 5 * 60 * 1000;
67
- const n = Number(raw);
68
- if (!Number.isFinite(n) || n < 0) {
69
- console.warn(`[pi-web-chat] Invalid IDLE_TIMEOUT_MS="${raw}", falling back to 300000`);
70
- return 5 * 60 * 1000;
71
- }
72
- return n;
73
- })();
74
-
75
- // Hard ceiling on how long a background pi agent may live, even if still busy.
76
- // Protects against runaway agents that never terminate. 0 = unlimited.
77
- const MAX_AGENT_LIFETIME_MS = (() => {
78
- const raw = process.env.MAX_AGENT_LIFETIME_MS;
79
- if (raw === undefined || raw === "") return 30 * 60 * 1000; // 30 min
80
- const n = Number(raw);
81
- if (!Number.isFinite(n) || n < 0) {
82
- console.warn(`[pi-web-chat] Invalid MAX_AGENT_LIFETIME_MS="${raw}", falling back to 1800000`);
83
- return 30 * 60 * 1000;
84
- }
85
- return n;
86
- })();
87
-
88
- // How many pi->browser events to buffer while no WebSocket is attached, so a
89
- // reconnecting client can replay what happened in the background after they
90
- // closed the tab. Ring buffer; oldest events are dropped on overflow.
91
- const EVENT_BUFFER_SIZE = (() => {
92
- const raw = process.env.EVENT_BUFFER_SIZE;
93
- if (raw === undefined || raw === "") return 2000;
94
- const n = Number(raw);
95
- if (!Number.isInteger(n) || n < 0) return 2000;
96
- return n;
97
- })();
98
-
99
- // Maximum number of concurrently-pooled pi RPC subprocesses. New WebSocket
100
- // connections beyond the cap are rejected with a clear message instead of
101
- // silently exhausting memory. Set MAX_CONCURRENT_AGENTS=0 to disable.
102
- const MAX_CONCURRENT_AGENTS = (() => {
103
- const raw = process.env.MAX_CONCURRENT_AGENTS;
104
- if (raw === undefined || raw === "") return 0; // 0 = unlimited (back-compat)
105
- const n = Number(raw);
106
- if (!Number.isInteger(n) || n < 0) {
107
- console.warn(`[pi-web-chat] Invalid MAX_CONCURRENT_AGENTS="${raw}", falling back to 0 (unlimited)`);
108
- return 0;
109
- }
110
- return n;
111
- })();
112
-
113
- // If true, when pi becomes idle (no WebSocket attached) we attempt to give the
114
- // OS an early hint that this agent's memory is reclaimable. Node has no direct
115
- // API for this, but --expose-gc + global.gc() drops the V8 heap lazily. When
116
- // enabled, idle agents will voluntarily release heap before being killed by the
117
- // idle timer, reducing pressure on small-memory hosts. Default: false.
118
- const IDLE_DROP_HEAP = process.env.IDLE_DROP_HEAP === "1" || process.env.IDLE_DROP_HEAP === "true";
119
-
120
- const nowMs = () => Date.now();
121
-
122
- // Active pi RPC processes pooled by session key (`${cwd}:${resolvedSessionPath}`)
123
- const activeAgents = new Map();
124
- // Master set of all live PiAgent instances (both keyed and unkeyed)
125
- const allAgents = new Set();
126
-
127
- class PiAgent {
128
- constructor(cwd) {
129
- this.sockets = new Set();
130
- this.cwd = normalizeCwd(cwd);
131
- this.sessionKey = null;
132
- this.reqId = 0;
133
- this.pending = new Map(); // reqId -> resolve()
134
- this.proc = null;
135
- this.buffer = "";
136
- this.decoder = new StringDecoder("utf8");
137
- this.alive = false;
138
- // lifecycle / background-task state
139
- this.state = "idle"; // "idle" | "streaming"
140
- this.idleTimer = null; // true-idle reclamation timer
141
- this.lifetimeTimer = null; // hard max-lifetime kill
142
- this.startedAt = 0;
143
- this.lastActivityAt = 0;
144
- // ring buffer of pi->browser events while no socket is attached, so a
145
- // reconnecting client can replay what happened in the background.
146
- this.eventBuffer = [];
147
- this.bufferHead = 0;
148
- // lightweight summary of the task currently running in background, for
149
- // the /api/agents dashboard and reconnecting clients.
150
- this.lastUserPrompt = null;
151
- }
152
-
153
- get hasWs() { return this.sockets.size > 0; }
154
- get isBusy() { return this.state === "streaming" || this.pending.size > 0; }
155
-
156
- attachWs(ws) {
157
- this.sockets.add(ws);
158
- this.cancelIdleKill();
159
- // Replay buffered background events to the newly attached client so it can
160
- // catch up on whatever pi produced while no one was watching.
161
- this.replayBuffered(ws);
162
- }
163
-
164
- detachWs(ws) {
165
- this.sockets.delete(ws);
166
- if (this.sockets.size === 0) {
167
- // Reset event buffer only if NOT busy.
168
- // If we are currently streaming/busy, we must retain the buffer so a reconnecting client
169
- // can replay the full stream from the beginning of the active generation.
170
- if (!this.isBusy) {
171
- this.eventBuffer = [];
172
- this.bufferHead = 0;
173
- }
174
- // Browser closed. We do NOT kill the subprocess here: a background task
175
- // keeps running. We only arm the idle-kill, which fires once the agent
176
- // is truly idle (no streaming, no pending requests) for IDLE_TIMEOUT_MS.
177
- if (IDLE_DROP_HEAP) {
178
- try { if (typeof global.gc === "function") global.gc(); } catch {}
179
- }
180
- // If unkeyed AND truly idle (never had a session assigned and not busy),
181
- // no client can ever re-attach to it. Stop immediately to reclaim memory.
182
- if (!this.sessionKey && !this.isBusy) {
183
- this.stop();
184
- return;
185
- }
186
- this.maybeScheduleIdleKill();
187
- }
188
- }
189
-
190
- setSessionKey(cwd, sessionPath) {
191
- if (!sessionPath) return;
192
- const resolved = normalizePath(sessionPath);
193
- const key = `${cwd}:${resolved}`;
194
- if (this.sessionKey && this.sessionKey !== key) {
195
- activeAgents.delete(this.sessionKey);
196
- }
197
- this.sessionKey = key;
198
- activeAgents.set(key, this);
199
- }
200
-
201
- markActivity() {
202
- this.lastActivityAt = nowMs();
203
- // any activity cancels a pending idle-kill; it will be re-armed when idle.
204
- this.cancelIdleKill();
205
- if (!this.hasWs && !this.isBusy) this.maybeScheduleIdleKill();
206
- }
207
-
208
- setStreaming(streaming) {
209
- this.state = streaming ? "streaming" : "idle";
210
- this.markActivity();
211
- }
212
-
213
- maybeScheduleIdleKill() {
214
- // Only arm the reclamation timer when: no client attached AND truly idle.
215
- // A background task that is still running must never be killed here.
216
- if (!this.alive || this.hasWs || this.isBusy) return;
217
- if (IDLE_TIMEOUT_MS === 0) return; // disabled
218
- this.cancelIdleKill();
219
- const ms = IDLE_TIMEOUT_MS;
220
- this.idleTimer = setTimeout(() => {
221
- this.idleTimer = null;
222
- // re-check at fire time — a reconnect or new task may have started.
223
- if (!this.alive || this.hasWs || this.isBusy) return;
224
- console.log(`Reclaiming truly-idle pi agent after ${Math.round(ms / 1000)}s (key=${this.sessionKey || "unkeyed"})`);
225
- if (IDLE_DROP_HEAP) {
226
- try { if (typeof global.gc === "function") global.gc(); } catch {}
227
- }
228
- this.stop();
229
- }, ms);
230
- }
231
-
232
- cancelIdleKill() {
233
- if (this.idleTimer) {
234
- clearTimeout(this.idleTimer);
235
- this.idleTimer = null;
236
- }
237
- }
238
-
239
- // Back-compat shim for any caller that used the old name.
240
- scheduleCleanup(delayMs = 300000) {
241
- this.cancelIdleKill();
242
- if (delayMs === 0) { this.stop(); return; }
243
- // treat as immediate idle-kill after the given delay only if truly idle
244
- this.idleTimer = setTimeout(() => {
245
- this.idleTimer = null;
246
- if (this.hasWs || this.isBusy) return;
247
- this.stop();
248
- }, delayMs);
249
- }
250
- cancelCleanup() { this.cancelIdleKill(); }
251
-
252
- start() {
253
- const args = [PI_BIN, "--mode", "rpc", "--session-dir", SESSIONS_DIR];
254
- this.proc = spawn(args[0], args.slice(1), {
255
- cwd: this.cwd,
256
- env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" },
257
- });
258
- this.alive = true;
259
- allAgents.add(this);
260
- this.startedAt = nowMs();
261
- this.lastActivityAt = this.startedAt;
262
- if (MAX_AGENT_LIFETIME_MS > 0) {
263
- this.lifetimeTimer = setTimeout(() => {
264
- console.warn(`pi agent hit MAX_AGENT_LIFETIME_MS (${Math.round(MAX_AGENT_LIFETIME_MS / 1000)}s), force-stopping (key=${this.sessionKey || "unkeyed"})`);
265
- this.stop();
266
- }, MAX_AGENT_LIFETIME_MS);
267
- }
268
- this.proc.on("error", (err) => {
269
- this.alive = false;
270
- allAgents.delete(this);
271
- if (this.sessionKey) activeAgents.delete(this.sessionKey);
272
- this.cancelIdleKill();
273
- if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
274
- for (const [id, resolve] of this.pending) {
275
- resolve({ type: "response", id, success: false, error: err.message });
276
- }
277
- this.pending.clear();
278
- console.error(`[pi spawn error]`, err);
279
- this.wsSend({ type: "pi_exit", error: err.message });
280
- for (const s of this.sockets) { try { s.close(); } catch {} }
281
- this.sockets.clear();
282
- });
283
- if (this.proc.stdin) {
284
- this.proc.stdin.on("error", (err) => {
285
- console.warn(`[pi stdin error]`, err.message);
286
- });
287
- }
288
- this.proc.stdout.on("data", (d) => this.onStdout(d));
289
- this.proc.stderr.on("data", (d) => {
290
- process.stderr.write(`[pi stderr] ${d}`);
291
- });
292
- this.proc.on("exit", (code) => {
293
- this.alive = false;
294
- allAgents.delete(this);
295
- console.log(`pi exited (code=${code})`);
296
- this.wsSend({ type: "pi_exit", code });
297
- if (this.sessionKey) activeAgents.delete(this.sessionKey);
298
- for (const [id, resolve] of this.pending) {
299
- resolve({ type: "response", id, success: false, error: "pi exited" });
300
- }
301
- this.pending.clear();
302
- for (const s of this.sockets) { try { s.close(); } catch {} }
303
- this.sockets.clear();
304
- this.cancelIdleKill();
305
- if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
306
- });
307
- }
308
-
309
- onStdout(chunk) {
310
- this.buffer += this.decoder.write(chunk);
311
- while (true) {
312
- const nl = this.buffer.indexOf("\n");
313
- if (nl === -1) break;
314
- let line = this.buffer.slice(0, nl);
315
- this.buffer = this.buffer.slice(nl + 1);
316
- if (line.endsWith("\r")) line = line.slice(0, -1);
317
- if (!line.trim()) continue;
318
- let obj;
319
- try { obj = JSON.parse(line); } catch { continue; }
320
- this.onPiMessage(obj);
321
- }
322
- }
323
-
324
- onPiMessage(obj) {
325
- // Automatically register sessionKey if sessionFile is present in RPC response/event
326
- if (obj.data?.sessionFile) {
327
- this.setSessionKey(this.cwd, obj.data.sessionFile);
328
- }
329
- // Track streaming lifecycle so background tasks are not killed mid-flight.
330
- switch (obj.type) {
331
- case "agent_start": this.setStreaming(true); break;
332
- case "agent_end": this.setStreaming(false); break;
333
- case "agent_settled":
334
- this.setStreaming(false);
335
- this.eventBuffer = [];
336
- this.bufferHead = 0;
337
- // If unkeyed, actively query state from pi so sessionKey is recorded
338
- if (!this.sessionKey) {
339
- this.send({ type: "get_state" });
340
- }
341
- break;
342
- case "pi_exit": this.state = "idle"; break;
343
- }
344
- // Capture the most recent user prompt for the background-task dashboard.
345
- if (obj.type === "remote_user_prompt" || obj.type === "remote_user_steer") {
346
- this.lastUserPrompt = { text: obj.message, isSteer: !!obj.isSteer, at: nowMs() };
347
- }
348
- // RPC responses carry `id`; events do not.
349
- if (obj.type === "response" && obj.id) {
350
- const res = this.pending.get(obj.id);
351
- if (res) { this.pending.delete(obj.id); res(obj); }
352
- this.markActivity();
353
- }
354
- // Forward every event / response to connected browsers as-is.
355
- this.wsSend(obj);
356
- // If nobody is listening, or if the agent is currently busy (streaming), remember it
357
- // so any reconnecting or newly connecting clients can replay and catch up.
358
- if (!this.hasWs || this.isBusy) {
359
- this.bufferEvent(obj);
360
- }
361
- }
362
-
363
- bufferEvent(obj) {
364
- if (EVENT_BUFFER_SIZE <= 0) return;
365
- if (this.eventBuffer.length < EVENT_BUFFER_SIZE) {
366
- this.eventBuffer.push(obj);
367
- } else {
368
- this.eventBuffer[this.bufferHead] = obj;
369
- this.bufferHead = (this.bufferHead + 1) % EVENT_BUFFER_SIZE;
370
- }
371
- }
372
-
373
- replayBuffered(ws) {
374
- if (this.eventBuffer.length === 0) return;
375
- if (ws.readyState !== 1) return;
376
- const count = this.eventBuffer.length;
377
- // Send a marker so the client knows the next burst is backfill, not live.
378
- try { ws.send(JSON.stringify({ type: "backfill_start", count })); } catch {}
379
- const start = count === EVENT_BUFFER_SIZE ? this.bufferHead : 0;
380
- for (let i = 0; i < count; i++) {
381
- const idx = (start + i) % EVENT_BUFFER_SIZE;
382
- const ev = this.eventBuffer[idx];
383
- try { ws.send(JSON.stringify(ev)); } catch {}
384
- }
385
- try { ws.send(JSON.stringify({ type: "backfill_end", streaming: this.isBusy, state: this.state })); } catch {}
386
- // Only clear the buffer if the agent is not busy.
387
- // If the agent is still busy, keep the buffer so that other clients or future reconnects can still catch up.
388
- if (!this.isBusy) {
389
- this.eventBuffer = [];
390
- this.bufferHead = 0;
391
- }
392
- }
393
-
394
- send(cmd) {
395
- return new Promise((resolve) => {
396
- if (!this.alive || !this.proc || !this.proc.stdin || this.proc.stdin.destroyed) {
397
- return resolve({ type: "response", id: cmd.id || "0", success: false, error: "pi process not alive" });
398
- }
399
- const id = String(++this.reqId);
400
- const payload = { ...cmd, id };
401
- // Long-running commands (prompt/steer) are tracked for their whole
402
- // lifetime: pi's response may legitimately take many minutes, and the
403
- // pending entry doubles as the isBusy guard that protects a working
404
- // agent from idle-kill. Fire-and-forget queries (get_state etc.) keep a
405
- // safety timeout so a dropped response doesn't leak the promise.
406
- const longRunning = cmd.type === "prompt" || cmd.type === "steer";
407
- let timeoutId = null;
408
- const settled = { done: false };
409
- const complete = (obj) => {
410
- if (settled.done) return;
411
- settled.done = true;
412
- this.pending.delete(id);
413
- if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
414
- resolve(obj);
415
- };
416
- this.pending.set(id, complete);
417
- this.markActivity();
418
- try {
419
- this.proc.stdin.write(JSON.stringify(payload) + "\n");
420
- } catch (err) {
421
- this.pending.delete(id);
422
- return resolve({ type: "response", id, success: false, error: err.message });
423
- }
424
- if (!longRunning) {
425
- timeoutId = setTimeout(() => {
426
- if (this.pending.has(id)) {
427
- this.pending.delete(id);
428
- resolve({ type: "response", id, success: false, error: "timeout" });
429
- }
430
- }, 60000);
431
- }
432
- });
433
- }
434
-
435
- sendNoReply(cmd) {
436
- if (!this.alive || !this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
437
- this.markActivity();
438
- try {
439
- this.proc.stdin.write(JSON.stringify(cmd) + "\n");
440
- } catch (e) {
441
- console.error("[pi sendNoReply error]", e);
442
- }
443
- }
444
-
445
- wsSend(obj, excludeSocket = null) {
446
- const payload = typeof obj === "string" ? obj : JSON.stringify(obj);
447
- for (const ws of [...this.sockets]) {
448
- if (ws.readyState > 1) { // CLOSING or CLOSED
449
- this.sockets.delete(ws);
450
- continue;
451
- }
452
- if (ws !== excludeSocket && ws.readyState === 1) {
453
- try {
454
- ws.send(payload);
455
- } catch (e) {
456
- console.error("wsSend error", e);
457
- this.sockets.delete(ws);
458
- }
459
- }
460
- }
461
- }
462
-
463
- status() {
464
- return {
465
- cwd: this.cwd,
466
- sessionKey: this.sessionKey,
467
- alive: this.alive,
468
- state: this.state,
469
- busy: this.isBusy,
470
- hasClients: this.hasWs,
471
- clientCount: this.sockets.size,
472
- pendingRequests: this.pending.size,
473
- startedAt: this.startedAt || null,
474
- lastActivityAt: this.lastActivityAt || null,
475
- uptimeMs: this.startedAt ? nowMs() - this.startedAt : 0,
476
- bufferedEvents: this.eventBuffer.length,
477
- lastUserPrompt: this.lastUserPrompt,
478
- };
479
- }
480
-
481
- stop() {
482
- this.alive = false;
483
- allAgents.delete(this);
484
- this.cancelIdleKill();
485
- if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
486
- if (this.sessionKey) {
487
- activeAgents.delete(this.sessionKey);
488
- }
489
- for (const [id, resolve] of this.pending) {
490
- resolve({ type: "response", id, success: false, error: "pi process stopped" });
491
- }
492
- this.pending.clear();
493
- for (const s of this.sockets) { try { s.close(); } catch {} }
494
- this.sockets.clear();
495
- this.buffer = "";
496
- this.eventBuffer = [];
497
- this.bufferHead = 0;
498
- this.lastUserPrompt = null;
499
- try { this.proc && this.proc.kill("SIGTERM"); } catch {}
500
- }
501
- }
502
-
503
- // ---- REST: list sessions + read one session's messages ----
504
- const app = express();
505
- app.use(express.json({ limit: "50mb" }));
506
- app.use(express.static(path.join(__dirname, "public")));
507
-
508
- // Endpoint to catch front-end errors for deep diagnostics
509
- app.post("/api/log-error", (req, res) => {
510
- const { message, source, lineno, colno, error, userAgent } = req.body;
511
- const logStr = `\n[CLIENT ERROR] ${new Date().toISOString()}\nMessage: ${message}\nSource: ${source}:${lineno}:${colno}\nError: ${JSON.stringify(error)}\nUA: ${userAgent}\n`;
512
- process.stderr.write(logStr);
513
- res.json({ ok: true });
11
+ const { listen, close } = createServer({
12
+ staticDir: publicDir,
514
13
  });
515
14
 
516
- // Helper to resolve pi settings (global and project-level)
517
- async function getPiSettings(targetCwd) {
518
- const globalPath = path.join(home(), ".pi", "agent", "settings.json");
519
- let globalSettings = {};
520
- let globalExists = false;
521
- try {
522
- if (existsSync(globalPath)) {
523
- globalSettings = JSON.parse(await readFile(globalPath, "utf8"));
524
- globalExists = true;
525
- }
526
- } catch {}
527
-
528
- let projectSettings = {};
529
- let projectExists = false;
530
- if (targetCwd) {
531
- try {
532
- const pPath = path.join(targetCwd, ".pi", "settings.json");
533
- if (existsSync(pPath)) {
534
- projectSettings = JSON.parse(await readFile(pPath, "utf8"));
535
- projectExists = true;
536
- }
537
- } catch {}
538
- }
15
+ const PORT = Number(process.env.PORT) || config.port || 3000;
16
+ const HOST = process.env.HOST || config.host || "0.0.0.0";
539
17
 
540
- const defaultProvider = projectSettings.defaultProvider || globalSettings.defaultProvider || null;
541
- const defaultModel = projectSettings.defaultModel || globalSettings.defaultModel || null;
542
- const defaultThinkingLevel = projectSettings.defaultThinkingLevel || globalSettings.defaultThinkingLevel || null;
543
- const source = (projectExists && projectSettings.defaultModel) ? "project" : ((globalExists && globalSettings.defaultModel) ? "global" : "default");
18
+ await listen(PORT, HOST);
544
19
 
545
- return {
546
- defaultProvider,
547
- defaultModel,
548
- defaultThinkingLevel,
549
- source,
550
- globalSettings,
551
- projectSettings,
552
- };
20
+ function handleShutdown(sig) {
21
+ console.log(`\n[pi-web-chat] Received ${sig}, shutting down...`);
22
+ close().then(() => process.exit(0));
553
23
  }
554
24
 
555
- // Endpoint to get server environment config (home dir, server process cwd, default model settings)
556
- app.get("/api/config", async (req, res) => {
557
- const reqCwd = normalizeCwd(req.query.cwd || "");
558
- const settings = await getPiSettings(reqCwd);
559
- res.json({
560
- home: home(),
561
- serverCwd: process.cwd(),
562
- version: PKG_VERSION,
563
- defaultModel: {
564
- provider: settings.defaultProvider,
565
- id: settings.defaultModel,
566
- thinkingLevel: settings.defaultThinkingLevel,
567
- source: settings.source,
568
- },
569
- });
570
- });
571
-
572
- // Endpoint to set default model in global or project settings.json
573
- app.post("/api/set-default-model", async (req, res) => {
574
- try {
575
- const { provider, modelId, thinkingLevel, scope, cwd: reqCwd } = req.body;
576
- if (!provider || !modelId) {
577
- return res.status(400).json({ ok: false, error: "缺少 provider 或 modelId 参数" });
578
- }
579
-
580
- const isProject = scope === "project" && reqCwd;
581
- let settingsPath;
582
- if (isProject) {
583
- const resolvedCwd = normalizeCwd(reqCwd);
584
- try {
585
- const s = await stat(resolvedCwd);
586
- if (!s.isDirectory()) {
587
- return res.status(400).json({ ok: false, error: "指定的工作目录不是一个有效目录" });
588
- }
589
- } catch {
590
- return res.status(400).json({ ok: false, error: "指定的工作目录不存在或无权访问" });
591
- }
592
- settingsPath = path.join(resolvedCwd, ".pi", "settings.json");
593
- } else {
594
- settingsPath = path.join(home(), ".pi", "agent", "settings.json");
595
- }
596
-
597
- await mkdir(path.dirname(settingsPath), { recursive: true });
598
-
599
- let current = {};
600
- try {
601
- if (existsSync(settingsPath)) {
602
- current = JSON.parse(await readFile(settingsPath, "utf8"));
603
- }
604
- } catch {}
605
-
606
- current.defaultProvider = provider;
607
- current.defaultModel = modelId;
608
- if (thinkingLevel !== undefined) {
609
- current.defaultThinkingLevel = thinkingLevel;
610
- }
611
-
612
- // If enabledModels is configured in settings.json, ensure the new default model
613
- // is in the enabledModels list, otherwise Pi core startup logic will fall back
614
- // to enabledModels[0] instead of using defaultModel.
615
- if (Array.isArray(current.enabledModels) && current.enabledModels.length > 0) {
616
- const fullId = `${provider}/${modelId}`;
617
- const inList = current.enabledModels.some(item =>
618
- item === fullId || item === modelId || item === `${provider}/*` || item === "*"
619
- );
620
- if (!inList) {
621
- current.enabledModels.unshift(fullId);
622
- }
623
- }
624
-
625
- await writeFile(settingsPath, JSON.stringify(current, null, 2), "utf8");
626
-
627
- res.json({
628
- ok: true,
629
- scope: isProject ? "project" : "global",
630
- defaultProvider: provider,
631
- defaultModel: modelId,
632
- defaultThinkingLevel: current.defaultThinkingLevel || null,
633
- });
634
- } catch (err) {
635
- res.status(500).json({ ok: false, error: err.message });
636
- }
637
- });
638
-
639
- // Endpoint to validate if a directory path exists on the server
640
- app.get("/api/validate-dir", async (req, res) => {
641
- const target = normalizeCwd(req.query.path || "");
642
- try {
643
- const s = await stat(target);
644
- if (s.isDirectory()) {
645
- res.json({ ok: true, path: target });
646
- } else {
647
- res.json({ ok: false, error: "指定路径存在但不是一个目录" });
648
- }
649
- } catch (e) {
650
- res.json({ ok: false, error: "目录不存在或没有访问权限" });
651
- }
652
- });
653
-
654
- // Endpoint listing all live background pi agents (for dashboards / debug).
655
- // Shows which sessions are still running headlessly after the browser closed.
656
- app.get("/api/agents", (req, res) => {
657
- const agents = [];
658
- for (const a of allAgents) {
659
- if (!a.alive) continue;
660
- agents.push({ key: a.sessionKey || "unkeyed", ...a.status() });
661
- }
662
- res.json({ count: agents.length, idleTimeoutMs: IDLE_TIMEOUT_MS, maxLifetimeMs: MAX_AGENT_LIFETIME_MS, agents });
663
- });
664
-
665
- // Scan SESSIONS_DIR for .jsonl files in BOTH the root AND every subdirectory.
666
- // Why both? Because pi stores sessions under a cwd-encoded subdir (e.g.
667
- // `--home-zrlgs--`) when left to its own device, but our server passes
668
- // `--session-dir` directly — in that mode pi drops new session files straight
669
- // into the directory root (no cwd subdir). So to robustly list every session
670
- // regardless of how it got there, we walk the whole tree: root + subdirs.
671
- async function listAllSessionFiles() {
672
- if (!existsSync(SESSIONS_DIR)) return [];
673
- const files = [];
674
-
675
- // Root-level .jsonl files (created when we pass --session-dir to pi).
676
- const top = await readdir(SESSIONS_DIR, { withFileTypes: true }).catch(() => []);
677
- for (const e of top) {
678
- if ((e.isFile() || e.isSymbolicLink()) && e.name.endsWith(".jsonl")) {
679
- files.push(path.join(SESSIONS_DIR, e.name));
680
- }
681
- }
682
-
683
- // Subdirectory .jsonl files (created by pi itself when cwd is encoded).
684
- const subdirs = top.filter(d => d.isDirectory() || (d.isSymbolicLink() && !d.name.endsWith(".jsonl")));
685
- for (const d of subdirs) {
686
- const dp = path.join(SESSIONS_DIR, d.name);
687
- const names = (await readdir(dp).catch(() => [])).filter(f => f.endsWith(".jsonl"));
688
- for (const n of names) files.push(path.join(dp, n));
689
- }
690
- return files;
691
- }
692
-
693
- // Memory metadata cache keyed by file path -> { mtimeMs, cwd, sessionInfo }
694
- const sessionMetadataCache = new Map();
695
-
696
- async function getSessionMetadata(file) {
697
- const fileStat = await stat(file);
698
- const cached = sessionMetadataCache.get(file);
699
- if (cached && cached.mtimeMs === fileStat.mtimeMs) {
700
- return cached;
701
- }
702
-
703
- const content = await readFile(file, "utf8");
704
- const lines = content.split("\n").filter(Boolean);
705
- let header = null, title = null, sessionName = null, msgCount = 0;
706
- for (const line of lines) {
707
- let o;
708
- try { o = JSON.parse(line); } catch { continue; }
709
- if (o.type === "session") header = o;
710
- if (o.type === "session_info" && o.name) {
711
- sessionName = o.name.trim();
712
- }
713
- if (o.type === "message" && o.message && o.message.role === "user" && !title) {
714
- title = extractText(o.message.content).slice(0, 80);
715
- }
716
- if (o.type === "message") msgCount++;
717
- }
718
-
719
- if (!header) return null;
720
-
721
- const result = {
722
- mtimeMs: fileStat.mtimeMs,
723
- cwd: normalizeCwd(header.cwd),
724
- sessionInfo: {
725
- file,
726
- name: path.basename(file),
727
- id: header.id,
728
- sessionName: sessionName || null,
729
- timestamp: header.timestamp,
730
- firstUser: title,
731
- messageCount: msgCount,
732
- },
733
- };
734
- sessionMetadataCache.set(file, result);
735
- return result;
736
- }
737
-
738
- app.get("/api/sessions", async (req, res) => {
739
- try {
740
- const cwd = normalizeCwd(req.query.cwd);
741
- const all = await listAllSessionFiles();
742
- const results = await Promise.all(
743
- all.map(async (full) => {
744
- try {
745
- const meta = await getSessionMetadata(full);
746
- if (meta && meta.cwd === cwd) {
747
- return meta.sessionInfo;
748
- }
749
- } catch {}
750
- return null;
751
- })
752
- );
753
- const sessions = results.filter(Boolean);
754
- sessions.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
755
- res.json({ cwd, sessions });
756
- } catch (e) {
757
- console.error(e);
758
- res.status(500).json({ error: String(e) });
759
- }
760
- });
761
-
762
- function extractText(content) {
763
- if (typeof content === "string") return content;
764
- if (!Array.isArray(content)) return "";
765
- return content
766
- .filter(c => c && (c.type === "text" || typeof c === "string"))
767
- .map(c => typeof c === "string" ? c : (c.text || ""))
768
- .join("");
769
- }
770
-
771
- // Return a session as a linear chat transcript (walking the parent chain to the leaf).
772
- app.get("/api/session", async (req, res) => {
773
- try {
774
- const file = req.query.file;
775
- if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
776
-
777
- // Security check: ensure the file path is within SESSIONS_DIR. We resolve the
778
- // canonical (real) path rather than just the lexical one, otherwise a
779
- // symlink placed inside SESSIONS_DIR could point outside and let a caller
780
- // read arbitrary .jsonl files via the traversal check above.
781
- const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
782
- const requestedPath = normalizePath(file);
783
- const relPath = path.relative(resolvedSessionsDir, requestedPath);
784
- if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
785
- return res.status(403).json({ error: "Access denied" });
786
- }
787
- // Canonicalize both target file and sessions directory to verify the real
788
- // target still lives under the real SESSIONS_DIR even when symlinks exist.
789
- let resolvedFile;
790
- try {
791
- resolvedFile = await fsRealpath(requestedPath);
792
- } catch {
793
- // Fall back to the lexical path if realpath fails (e.g. missing file);
794
- // the read below will surface the actual error.
795
- resolvedFile = requestedPath;
796
- }
797
- let canonicalSessionsDir = resolvedSessionsDir;
798
- try {
799
- canonicalSessionsDir = await fsRealpath(resolvedSessionsDir);
800
- } catch {}
801
- const realRel = path.relative(canonicalSessionsDir, resolvedFile);
802
- if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
803
- return res.status(403).json({ error: "Access denied" });
804
- }
805
-
806
- const content = await readFile(resolvedFile, "utf8");
807
- const lines = content.split("\n").filter(Boolean);
808
- const entries = [];
809
- let header = null;
810
- let sessionName = null;
811
- for (const line of lines) {
812
- let o; try { o = JSON.parse(line); } catch { continue; }
813
- if (o.type === "session") header = o;
814
- if (o.type === "session_info" && o.name) {
815
- sessionName = o.name.trim();
816
- }
817
- entries.push(o);
818
- }
819
- // Build a map and reconstruct the active path from root -> leaf.
820
- const byId = new Map();
821
- for (const e of entries) if (e.id) byId.set(e.id, e);
822
- // find true leaf = last entry with no children
823
- const childCount = new Map();
824
- for (const e of entries) {
825
- if (e.parentId) childCount.set(e.parentId, (childCount.get(e.parentId) || 0) + 1);
826
- }
827
- let leafId = null;
828
- for (const e of entries) {
829
- if (e.id && !childCount.has(e.id)) leafId = e.id;
830
- }
831
- // Walk parent chain from leaf to root.
832
- const entryChain = [];
833
- let cur = leafId;
834
- const guard = new Set();
835
- while (cur && !guard.has(cur)) {
836
- guard.add(cur);
837
- const e = byId.get(cur);
838
- if (!e) break;
839
- entryChain.unshift(e);
840
- cur = e.parentId;
841
- }
842
-
843
- // Extract the active model used in this session chain
844
- let sessionModel = null;
845
- for (let i = entryChain.length - 1; i >= 0; i--) {
846
- const e = entryChain[i];
847
- if (e.type === "model_change" && (e.modelId || e.model)) {
848
- sessionModel = {
849
- provider: e.provider || "",
850
- id: e.modelId || e.model,
851
- name: e.modelId || e.model
852
- };
853
- break;
854
- }
855
- if (e.type === "message" && e.message && e.message.role === "assistant" && e.message.model) {
856
- sessionModel = {
857
- provider: e.message.provider || e.provider || "",
858
- id: e.message.model,
859
- name: e.message.model
860
- };
861
- break;
862
- }
863
- }
864
-
865
- res.json({ header, entries: entryChain, model: sessionModel, sessionName });
866
- } catch (e) {
867
- console.error(e);
868
- res.status(500).json({ error: String(e) });
869
- }
870
- });
871
-
872
- // Delete a session file from disk and clean up in-memory caches / active agents
873
- app.delete("/api/session", async (req, res) => {
874
- try {
875
- const file = req.query.file || req.body?.file;
876
- if (!file || !file.endsWith(".jsonl")) {
877
- return res.status(400).json({ error: "bad file" });
878
- }
879
-
880
- const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
881
- const requestedPath = normalizePath(file);
882
- const relPath = path.relative(resolvedSessionsDir, requestedPath);
883
- if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
884
- return res.status(403).json({ error: "Access denied" });
885
- }
886
-
887
- let resolvedFile;
888
- try {
889
- resolvedFile = await fsRealpath(requestedPath);
890
- } catch (err) {
891
- if (err.code === "ENOENT") {
892
- return res.status(404).json({ error: "File not found" });
893
- }
894
- resolvedFile = requestedPath;
895
- }
896
-
897
- let canonicalSessionsDir = resolvedSessionsDir;
898
- try {
899
- canonicalSessionsDir = await fsRealpath(resolvedSessionsDir);
900
- } catch {}
901
-
902
- const realRel = path.relative(canonicalSessionsDir, resolvedFile);
903
- if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
904
- return res.status(403).json({ error: "Access denied" });
905
- }
906
-
907
- // Stop and unpool any active PiAgent managing this session
908
- for (const [key, agent] of activeAgents.entries()) {
909
- if (key.endsWith(`:${requestedPath}`) || key.endsWith(`:${resolvedFile}`)) {
910
- activeAgents.delete(key);
911
- try {
912
- agent.stop();
913
- } catch {}
914
- }
915
- }
916
-
917
- // Invalidate session cache
918
- sessionMetadataCache.delete(requestedPath);
919
- sessionMetadataCache.delete(resolvedFile);
920
-
921
- // Delete the session JSONL file
922
- await unlink(resolvedFile);
923
-
924
- res.json({ success: true, file: requestedPath });
925
- } catch (e) {
926
- console.error("Delete session error:", e);
927
- res.status(500).json({ error: String(e) });
928
- }
929
- });
930
-
931
- // ---- WebSocket: 1 browser conn = 1 pi RPC conn (with process persistence) ----
932
- const httpServer = app.listen(PORT, () => {
933
- console.log(`pi-web-chat on http://localhost:${PORT}`);
934
- });
935
-
936
- // Origin check helper to defend against Cross-Site WebSocket Hijacking (CSWSH)
937
- function isAllowedOrigin(origin, host) {
938
- if (!origin) return true; // Direct non-browser clients (curl, electron, etc.)
939
- try {
940
- const originUrl = new URL(origin);
941
- const originHost = originUrl.host;
942
- // Direct match with Host header (same-origin)
943
- if (originHost.toLowerCase() === (host || "").toLowerCase()) return true;
944
-
945
- // Allow localhost / 127.0.0.1 loopback variations
946
- const isLocalOrigin = originUrl.hostname === "localhost" || originUrl.hostname === "127.0.0.1" || originUrl.hostname === "::1";
947
- const hostName = (host || "").split(":")[0].toLowerCase();
948
- const isLocalHost = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1";
949
- if (isLocalOrigin && isLocalHost) return true;
950
-
951
- // Custom allowed origins from ALLOWED_ORIGINS env var (comma-separated)
952
- if (process.env.ALLOWED_ORIGINS) {
953
- const allowed = process.env.ALLOWED_ORIGINS.split(",").map(s => s.trim().toLowerCase());
954
- if (allowed.includes(origin.toLowerCase()) || allowed.includes(originUrl.origin.toLowerCase())) {
955
- return true;
956
- }
957
- }
958
- return false;
959
- } catch {
960
- return false;
961
- }
962
- }
963
-
964
- const wss = new WebSocketServer({
965
- server: httpServer,
966
- path: "/ws",
967
- verifyClient: (info, callback) => {
968
- const origin = info.origin || info.req.headers.origin;
969
- const host = info.req.headers.host;
970
- if (!isAllowedOrigin(origin, host)) {
971
- console.warn(`[pi-web-chat] Rejected WebSocket connection from unauthorized origin: ${origin} (host: ${host})`);
972
- return callback(false, 403, "Forbidden: Cross-origin WebSocket connection denied");
973
- }
974
- callback(true);
975
- }
976
- });
977
-
978
- // WebSocket server heartbeat (ping/pong at protocol level)
979
- const heartbeatInterval = setInterval(() => {
980
- wss.clients.forEach((ws) => {
981
- if (ws.isAlive === false) {
982
- console.log("Terminating unresponsive WebSocket client");
983
- if (ws.piAgent) {
984
- ws.piAgent.detachWs(ws);
985
- }
986
- return ws.terminate();
987
- }
988
- ws.isAlive = false;
989
- try { ws.ping(); } catch {}
990
- });
991
- }, 30000);
992
-
993
- wss.on("close", () => {
994
- clearInterval(heartbeatInterval);
995
- });
996
-
997
- wss.on("connection", (ws, req) => {
998
- ws.isAlive = true;
999
- ws.on("pong", () => {
1000
- ws.isAlive = true;
1001
- });
1002
-
1003
- const url = new URL(req.url, "http://x");
1004
- const cwd = normalizeCwd(url.searchParams.get("cwd"));
1005
- const session = url.searchParams.get("session") || null;
1006
- const key = session ? `${cwd}:${normalizePath(session)}` : null;
1007
-
1008
- let agent = null;
1009
- if (key && activeAgents.has(key) && activeAgents.get(key).alive) {
1010
- agent = activeAgents.get(key);
1011
- console.log(`ws re-attaching to existing pi process for key=${key}`);
1012
- agent.attachWs(ws);
1013
- } else {
1014
- // Enforce concurrent-agent cap before spawn. We count *all* live agents,
1015
- // not just this key, because each represents one pi RPC subprocess in
1016
- // memory. The cap protects small-memory hosts from runaway browser tabs.
1017
- if (MAX_CONCURRENT_AGENTS > 0) {
1018
- let live = 0;
1019
- for (const a of allAgents) if (a.alive) live++;
1020
- if (live >= MAX_CONCURRENT_AGENTS) {
1021
- const msg = `Server is at capacity (${live}/${MAX_CONCURRENT_AGENTS} pi agents). ` +
1022
- `Close another tab or raise MAX_CONCURRENT_AGENTS.`;
1023
- console.warn(`[pi-web-chat] refusing new ws: ${msg}`);
1024
- try {
1025
- ws.send(JSON.stringify({ type: "error", code: "capacity", message: msg }));
1026
- } catch {}
1027
- try { ws.close(1013, "capacity"); } catch {} // 1013 = "try again later"
1028
- return;
1029
- }
1030
- }
1031
- agent = new PiAgent(cwd);
1032
- agent.start();
1033
- agent.attachWs(ws);
1034
- if (session) {
1035
- agent.setSessionKey(cwd, session);
1036
- agent.sendNoReply({ type: "switch_session", sessionPath: session });
1037
- }
1038
- }
1039
- ws.piAgent = agent;
1040
- console.log(`ws connected (cwd=${cwd}, session=${session || "new"})`);
1041
-
1042
- ws.on("message", (raw) => {
1043
- let msg;
1044
- try { msg = JSON.parse(raw.toString()); } catch { return; }
1045
- if (msg.type === "ping") {
1046
- try { ws.send(JSON.stringify({ type: "pong" })); } catch {}
1047
- return;
1048
- }
1049
- switch (msg.type) {
1050
- case "prompt":
1051
- // Sync prompt to other connected clients in the same session
1052
- agent.wsSend({ type: "remote_user_prompt", message: msg.message, images: msg.images }, ws);
1053
- agent.lastUserPrompt = { text: msg.message, isSteer: false, at: nowMs() };
1054
- agent.send({ type: "prompt", message: msg.message, images: msg.images });
1055
- break;
1056
- case "abort":
1057
- agent.sendNoReply({ type: "abort" });
1058
- break;
1059
- case "new_session":
1060
- if (agent.sessionKey) {
1061
- activeAgents.delete(agent.sessionKey);
1062
- agent.sessionKey = null;
1063
- }
1064
- agent.lastUserPrompt = null;
1065
- agent.eventBuffer = [];
1066
- agent.bufferHead = 0;
1067
- agent.send({ type: "new_session" });
1068
- break;
1069
- case "switch_session":
1070
- if (!msg.sessionPath) break; // refuse malformed request; never forward an undefined path to pi
1071
- agent.setSessionKey(cwd, msg.sessionPath);
1072
- agent.send({ type: "switch_session", sessionPath: msg.sessionPath });
1073
- break;
1074
- case "steer":
1075
- // Sync steer instruction to other connected clients in the same session
1076
- agent.wsSend({ type: "remote_user_prompt", message: msg.message, isSteer: true }, ws);
1077
- agent.lastUserPrompt = { text: msg.message, isSteer: true, at: nowMs() };
1078
- agent.send({ type: "steer", message: msg.message });
1079
- break;
1080
- case "set_session_name":
1081
- agent.send({ type: "set_session_name", name: msg.name });
1082
- break;
1083
- case "get_entries":
1084
- agent.send({ type: "get_entries", since: msg.since });
1085
- break;
1086
- case "get_state":
1087
- agent.send({ type: "get_state" });
1088
- break;
1089
- case "get_available_models":
1090
- agent.send({ type: "get_available_models" });
1091
- break;
1092
- case "set_model":
1093
- agent.send({ type: "set_model", provider: msg.provider, modelId: msg.modelId });
1094
- break;
1095
- case "set_thinking_level":
1096
- agent.send({ type: "set_thinking_level", level: msg.level });
1097
- break;
1098
- case "cycle_thinking_level":
1099
- agent.send({ type: "cycle_thinking_level" });
1100
- break;
1101
- case "extension_ui_response":
1102
- agent.sendNoReply({ type: "extension_ui_response", ...msg });
1103
- break;
1104
- default:
1105
- // Unknown — just forward, might be a raw RPC command.
1106
- agent.send(msg);
1107
- }
1108
- });
1109
-
1110
- ws.on("close", () => {
1111
- console.log("ws connection closed, detaching agent");
1112
- if (ws.piAgent) {
1113
- ws.piAgent.detachWs(ws);
1114
- }
1115
- });
1116
- ws.on("error", () => {
1117
- if (ws.piAgent) {
1118
- ws.piAgent.detachWs(ws);
1119
- }
1120
- });
1121
- });
1122
-
1123
- // ---- Graceful shutdown: stop all background pi agents on exit ----
1124
- function shutdownAllAgents(reason) {
1125
- console.log(`\n[pi-web-chat] ${reason}: stopping ${allAgents.size} background pi agent(s)…`);
1126
- for (const a of [...allAgents]) {
1127
- try { a.stop(); } catch {}
1128
- }
1129
- clearInterval(heartbeatInterval);
1130
- try { wss.close(); } catch {}
1131
- try { httpServer.close(); } catch {}
1132
- }
1133
- process.on("SIGINT", () => { shutdownAllAgents("SIGINT"); process.exit(0); });
1134
- process.on("SIGTERM", () => { shutdownAllAgents("SIGTERM"); process.exit(0); });
1135
- process.on("exit", () => {
1136
- // best-effort: kill any still-living children synchronously on hard exit
1137
- for (const a of allAgents) {
1138
- try { a.proc && a.proc.kill("SIGKILL"); } catch {}
1139
- }
1140
- });
25
+ process.on("SIGINT", () => handleShutdown("SIGINT"));
26
+ process.on("SIGTERM", () => handleShutdown("SIGTERM"));