@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/README.md +64 -19
- package/docs/CHANGELOG.md +38 -1
- package/package.json +12 -8
- package/public/app.js +50 -8
- package/public/style.css +8 -0
- package/scripts/install-service.sh +0 -0
- package/scripts/uninstall-service.sh +0 -0
- package/server.js +16 -1130
package/server.js
CHANGED
|
@@ -1,1140 +1,26 @@
|
|
|
1
|
-
// server.js — pi-web-chat
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
556
|
-
|
|
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"));
|