pi-sdk-web 0.5.16 → 0.5.18
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/dist/cli.js +74 -3
- package/dist/log.js +28 -0
- package/dist/server.js +89 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { SettingsManager, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, resolveModelScopeWithDiagnostics, } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { existsSync } from "node:fs";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { totalmem } from "node:os";
|
|
12
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { getHeapStatistics } from "node:v8";
|
|
13
16
|
import { dirname, join } from "node:path";
|
|
14
17
|
import { findSessionByName, listSessions, loadBuiltinExtensions } from "./session.js";
|
|
15
18
|
import { PiWebServer } from "./server.js";
|
|
19
|
+
import { logError, logInfo } from "./log.js";
|
|
16
20
|
// ---------------------------------------------------------------------------
|
|
17
21
|
// Pretend to be Pi for in-process extensions.
|
|
18
22
|
//
|
|
@@ -43,6 +47,68 @@ if (PI_CLI_ENTRY && process.argv[1] !== PI_CLI_ENTRY) {
|
|
|
43
47
|
process.argv[1] = PI_CLI_ENTRY;
|
|
44
48
|
}
|
|
45
49
|
const DEFAULT_PORT = 4080;
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Heap headroom for long sessions.
|
|
52
|
+
//
|
|
53
|
+
// A large session plus in-process extensions (magic-context's local embedding
|
|
54
|
+
// model, historians, indexes) can outgrow Node's default old-space limit -
|
|
55
|
+
// ~2.2GB on a 15GB host - and abort the server mid-turn with
|
|
56
|
+
// "FATAL ERROR: Reached heap limit". pi-web therefore restarts itself once
|
|
57
|
+
// with a larger --max-old-space-size, but only for the long-running `r`
|
|
58
|
+
// command and only when the host has memory to spare.
|
|
59
|
+
//
|
|
60
|
+
// Override the target with PI_WEB_MAX_OLD_SPACE_MB=<mb>. An explicit
|
|
61
|
+
// --max-old-space-size in NODE_OPTIONS / argv is always respected untouched,
|
|
62
|
+
// and the restart happens at most once (PI_WEB_HEAP_REEXEC guard).
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
const DEFAULT_MAX_OLD_SPACE_MB = 4096;
|
|
65
|
+
/** Current V8 old-space limit in MB (what the crash log calls the heap limit). */
|
|
66
|
+
function heapLimitMb() {
|
|
67
|
+
return getHeapStatistics().heap_size_limit / (1024 * 1024);
|
|
68
|
+
}
|
|
69
|
+
function requestedHeapMb() {
|
|
70
|
+
const raw = Number(process.env.PI_WEB_MAX_OLD_SPACE_MB);
|
|
71
|
+
if (Number.isFinite(raw) && raw >= 512 && raw <= 32_768)
|
|
72
|
+
return Math.floor(raw);
|
|
73
|
+
return DEFAULT_MAX_OLD_SPACE_MB;
|
|
74
|
+
}
|
|
75
|
+
/** Did the user (or a wrapper) already choose a heap size themselves? */
|
|
76
|
+
function hasExplicitHeapFlag() {
|
|
77
|
+
const fromEnv = (process.env.NODE_OPTIONS ?? "").split(/\s+/);
|
|
78
|
+
return [...process.execArgv, ...fromEnv].some((arg) => /^--max[-_]old[-_]space[-_]size(=|$)/.test(arg));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Re-exec with a larger heap when needed. Returns true when a child has been
|
|
82
|
+
* started (the caller must stop: this process only waits for it).
|
|
83
|
+
*/
|
|
84
|
+
function ensureHeapHeadroom() {
|
|
85
|
+
if (process.env.PI_WEB_HEAP_REEXEC === "1")
|
|
86
|
+
return false; // already restarted
|
|
87
|
+
if (hasExplicitHeapFlag())
|
|
88
|
+
return false; // user decided - leave it alone
|
|
89
|
+
const target = requestedHeapMb();
|
|
90
|
+
if (heapLimitMb() >= target * 0.95)
|
|
91
|
+
return false; // close enough already
|
|
92
|
+
const totalMb = totalmem() / (1024 * 1024);
|
|
93
|
+
if (totalMb < target * 2) {
|
|
94
|
+
logInfo(`heap: keeping the default limit (${heapLimitMb().toFixed(0)}MB) - ` +
|
|
95
|
+
`raising it to ${target}MB needs ~${target * 2}MB of RAM, host has ${totalMb.toFixed(0)}MB`);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
logInfo(`heap: restarting with --max-old-space-size=${target} (current limit ${heapLimitMb().toFixed(0)}MB)`);
|
|
99
|
+
const child = spawn(process.execPath, [`--max-old-space-size=${target}`, fileURLToPath(import.meta.url), ...process.argv.slice(2)], { stdio: "inherit", env: { ...process.env, PI_WEB_HEAP_REEXEC: "1" } });
|
|
100
|
+
// Stay alive without acting on the signal ourselves: the child is in the
|
|
101
|
+
// same process group and runs its own graceful shutdown, and we exit with
|
|
102
|
+
// its status once it is done.
|
|
103
|
+
process.on("SIGINT", () => { });
|
|
104
|
+
process.on("SIGTERM", () => { });
|
|
105
|
+
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 1)));
|
|
106
|
+
child.on("error", (err) => {
|
|
107
|
+
logError(`heap: failed to restart with a larger heap: ${err.message}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
});
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
46
112
|
const DOC = `pi-web - browser Web access for Pi (via Pi SDK)
|
|
47
113
|
|
|
48
114
|
Usage:
|
|
@@ -80,7 +146,7 @@ async function cmdResume(name, port) {
|
|
|
80
146
|
}
|
|
81
147
|
catch {
|
|
82
148
|
// Session cwd no longer exists - keep current directory (same as pii)
|
|
83
|
-
|
|
149
|
+
logError(`Session cwd not found (${cwd}), keeping current directory`);
|
|
84
150
|
}
|
|
85
151
|
}
|
|
86
152
|
// Create services the way Pi's CLI does: extensions (including built-in
|
|
@@ -121,13 +187,14 @@ async function cmdResume(name, port) {
|
|
|
121
187
|
const { session } = runtime;
|
|
122
188
|
const server = new PiWebServer(runtime, { port });
|
|
123
189
|
await server.start();
|
|
124
|
-
|
|
190
|
+
logInfo(`server at http://127.0.0.1:${port}/ (session: ${info.name ?? info.id})`);
|
|
191
|
+
logInfo(`heap limit: ${heapLimitMb().toFixed(0)}MB`);
|
|
125
192
|
let shuttingDown = false;
|
|
126
193
|
const shutdown = async (signal) => {
|
|
127
194
|
if (shuttingDown)
|
|
128
195
|
return; // Repeated Ctrl+C must not re-enter teardown
|
|
129
196
|
shuttingDown = true;
|
|
130
|
-
|
|
197
|
+
logInfo(`${signal} received, shutting down...`);
|
|
131
198
|
try {
|
|
132
199
|
await server.stop();
|
|
133
200
|
}
|
|
@@ -178,6 +245,10 @@ async function main() {
|
|
|
178
245
|
}
|
|
179
246
|
if (!name)
|
|
180
247
|
throw new Error("usage: pi-web r <name> [--port <port>]");
|
|
248
|
+
// Before anything heavy (session open, extensions): make sure this process
|
|
249
|
+
// has enough heap. A restart here means the child takes over the command.
|
|
250
|
+
if (ensureHeapHeadroom())
|
|
251
|
+
return;
|
|
181
252
|
await cmdResume(name, port ?? DEFAULT_PORT);
|
|
182
253
|
return;
|
|
183
254
|
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timestamped logging for pi-web's own diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* pi-web is a long-running server: its warnings (backpressure, memory
|
|
5
|
+
* watermarks, session issues) are read minutes to hours after the fact and
|
|
6
|
+
* mixed with other processes' output, so every line carries a local timestamp
|
|
7
|
+
* (`[2026-09-12 08:15:30] pi-web: …`).
|
|
8
|
+
*
|
|
9
|
+
* Only pi-web's own log lines go through here - command output (session
|
|
10
|
+
* lists, help text) stays plain so it can be piped or copied as-is.
|
|
11
|
+
*/
|
|
12
|
+
function timestamp(now) {
|
|
13
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
14
|
+
return (`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
|
|
15
|
+
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`);
|
|
16
|
+
}
|
|
17
|
+
/** Informational line (startup, status) on stdout. */
|
|
18
|
+
export function logInfo(message) {
|
|
19
|
+
console.log(`[${timestamp(new Date())}] ${message}`);
|
|
20
|
+
}
|
|
21
|
+
/** Diagnostic warning on stderr. */
|
|
22
|
+
export function logWarn(message) {
|
|
23
|
+
console.warn(`[${timestamp(new Date())}] ${message}`);
|
|
24
|
+
}
|
|
25
|
+
/** Error line on stderr. */
|
|
26
|
+
export function logError(message) {
|
|
27
|
+
console.error(`[${timestamp(new Date())}] ${message}`);
|
|
28
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -18,6 +18,7 @@ import { ModelRegistry, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
|
18
18
|
import { WebSocket, WebSocketServer } from "ws";
|
|
19
19
|
import { listSessions } from "./session.js";
|
|
20
20
|
import { WebUIContext } from "./ui-context.js";
|
|
21
|
+
import { logError, logWarn } from "./log.js";
|
|
21
22
|
const DEFAULT_PORT = 4080;
|
|
22
23
|
// Static frontend: prefer the in-package copy (built by `npm run build` for
|
|
23
24
|
// global installs), fall back to the repo-root static/ during development.
|
|
@@ -64,6 +65,31 @@ const STATS_REFRESH_EVENTS = new Set([
|
|
|
64
65
|
"session_info_changed",
|
|
65
66
|
"thinking_level_changed",
|
|
66
67
|
]);
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// WebSocket backpressure guard.
|
|
70
|
+
//
|
|
71
|
+
// `ws.send()` queues in process memory without bound when the peer reads
|
|
72
|
+
// slowly - a backgrounded/throttled tab is the common case. A long turn (LLM
|
|
73
|
+
// deltas) or a long `!! … --follow` stream then grows the process until the
|
|
74
|
+
// V8 heap dies (observed: FATAL ERROR: Reached heap limit after ~70 minutes).
|
|
75
|
+
//
|
|
76
|
+
// Two limits, per client:
|
|
77
|
+
// SOFT - above this, *self-healing* delta events are skipped rather than
|
|
78
|
+
// queued. They carry no unique information: message_update /
|
|
79
|
+
// tool_execution_update re-render from the full payload of the next
|
|
80
|
+
// event, and message_end / tool_execution_end / bash_result deliver
|
|
81
|
+
// the complete state.
|
|
82
|
+
// HARD - above this the client is treated as gone and terminated. The
|
|
83
|
+
// browser reconnects (static/app.js) and reloads, fetching fresh
|
|
84
|
+
// session history - so nothing is lost, and memory is bounded.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
const WS_BUFFER_SOFT_LIMIT = 8 * 1024 * 1024;
|
|
87
|
+
const WS_BUFFER_HARD_LIMIT = 64 * 1024 * 1024;
|
|
88
|
+
const DROPPABLE_DELTA_EVENTS = new Set(["message_update", "tool_execution_update"]);
|
|
89
|
+
/** RSS watermarks (MB) at which to log one diagnostic line: growth curve plus
|
|
90
|
+
* what each client has queued, so a future OOM report says where it went. */
|
|
91
|
+
const MEMORY_LOG_THRESHOLDS_MB = [512, 1024, 1536, 2048];
|
|
92
|
+
const MEMORY_LOG_INTERVAL_MS = 30_000;
|
|
67
93
|
const MIME = {
|
|
68
94
|
".html": "text/html; charset=utf-8",
|
|
69
95
|
".css": "text/css; charset=utf-8",
|
|
@@ -83,6 +109,11 @@ export class PiWebServer {
|
|
|
83
109
|
wsServer = null;
|
|
84
110
|
clients = new Set();
|
|
85
111
|
unsubscribe = null;
|
|
112
|
+
/** Clients currently over the soft buffer limit (delta skipping active). */
|
|
113
|
+
backpressured = new WeakSet();
|
|
114
|
+
/** Memory watermarks already logged (diagnostics), and throttle clock. */
|
|
115
|
+
memoryLogged = new Set();
|
|
116
|
+
lastMemoryCheck = 0;
|
|
86
117
|
/** Current session (may change on /resume session switching). */
|
|
87
118
|
get session() {
|
|
88
119
|
return this.runtime.session;
|
|
@@ -172,7 +203,7 @@ export class PiWebServer {
|
|
|
172
203
|
process.chdir(cwd);
|
|
173
204
|
}
|
|
174
205
|
catch {
|
|
175
|
-
|
|
206
|
+
logError(`Session cwd not found (${cwd}), keeping current directory`);
|
|
176
207
|
}
|
|
177
208
|
}
|
|
178
209
|
this.broadcastState();
|
|
@@ -233,9 +264,33 @@ export class PiWebServer {
|
|
|
233
264
|
catch {
|
|
234
265
|
return;
|
|
235
266
|
}
|
|
236
|
-
|
|
267
|
+
const type = obj.type;
|
|
268
|
+
const droppable = typeof type === "string" && DROPPABLE_DELTA_EVENTS.has(type);
|
|
269
|
+
for (const client of [...this.clients]) {
|
|
237
270
|
if (client.readyState !== WebSocket.OPEN)
|
|
238
271
|
continue;
|
|
272
|
+
const buffered = client.bufferedAmount;
|
|
273
|
+
if (buffered > WS_BUFFER_HARD_LIMIT) {
|
|
274
|
+
// Far beyond reading - treat as gone. The browser reconnects and
|
|
275
|
+
// reloads, so no state is lost, and memory stays bounded.
|
|
276
|
+
logWarn(`pi-web: client not reading (${(buffered / 1048576).toFixed(0)}MB queued) - closing it; ` +
|
|
277
|
+
"the browser will reconnect and reload history");
|
|
278
|
+
this.backpressured.delete(client);
|
|
279
|
+
client.terminate();
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (droppable && buffered > WS_BUFFER_SOFT_LIMIT) {
|
|
283
|
+
if (!this.backpressured.has(client)) {
|
|
284
|
+
this.backpressured.add(client);
|
|
285
|
+
logWarn(`pi-web: client is behind (${(buffered / 1048576).toFixed(1)}MB queued) - ` +
|
|
286
|
+
"skipping stream deltas until it catches up");
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (this.backpressured.has(client) && buffered < WS_BUFFER_SOFT_LIMIT / 2) {
|
|
291
|
+
this.backpressured.delete(client);
|
|
292
|
+
logWarn("pi-web: client caught up - stream deltas resumed");
|
|
293
|
+
}
|
|
239
294
|
try {
|
|
240
295
|
client.send(message);
|
|
241
296
|
}
|
|
@@ -246,6 +301,28 @@ export class PiWebServer {
|
|
|
246
301
|
this.uiContext.setBrowserAttached(false);
|
|
247
302
|
}
|
|
248
303
|
}
|
|
304
|
+
this.maybeLogMemory();
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* One diagnostic line per RSS watermark (plus what each client has queued),
|
|
308
|
+
* so a future out-of-memory report shows where the growth went. Throttled:
|
|
309
|
+
* `broadcast` runs per streaming delta and memoryUsage() is not free.
|
|
310
|
+
*/
|
|
311
|
+
maybeLogMemory() {
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
if (now - this.lastMemoryCheck < MEMORY_LOG_INTERVAL_MS)
|
|
314
|
+
return;
|
|
315
|
+
this.lastMemoryCheck = now;
|
|
316
|
+
const { rss, heapUsed, heapTotal } = process.memoryUsage();
|
|
317
|
+
const rssMb = rss / 1048576;
|
|
318
|
+
for (const threshold of MEMORY_LOG_THRESHOLDS_MB) {
|
|
319
|
+
if (rssMb < threshold || this.memoryLogged.has(threshold))
|
|
320
|
+
continue;
|
|
321
|
+
this.memoryLogged.add(threshold);
|
|
322
|
+
const queued = [...this.clients].map((c) => `${(c.bufferedAmount / 1048576).toFixed(1)}MB`).join(", ") || "no clients";
|
|
323
|
+
logWarn(`pi-web: memory ${rssMb.toFixed(0)}MB (heap ${(heapUsed / 1048576).toFixed(0)}/` +
|
|
324
|
+
`${(heapTotal / 1048576).toFixed(0)}MB), queued per client: ${queued}`);
|
|
325
|
+
}
|
|
249
326
|
}
|
|
250
327
|
broadcastStats() {
|
|
251
328
|
try {
|
|
@@ -348,9 +425,17 @@ export class PiWebServer {
|
|
|
348
425
|
}
|
|
349
426
|
}
|
|
350
427
|
sendJson(ws, obj) {
|
|
351
|
-
if (ws.readyState
|
|
352
|
-
|
|
428
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
429
|
+
return;
|
|
430
|
+
// Same hard limit as broadcast(): the initial state/history snapshot is
|
|
431
|
+
// the largest single payload, so a client that never reads it must not be
|
|
432
|
+
// allowed to queue forever.
|
|
433
|
+
if (ws.bufferedAmount > WS_BUFFER_HARD_LIMIT) {
|
|
434
|
+
logWarn("pi-web: client not reading its initial snapshot - closing it");
|
|
435
|
+
ws.terminate();
|
|
436
|
+
return;
|
|
353
437
|
}
|
|
438
|
+
ws.send(JSON.stringify(obj));
|
|
354
439
|
}
|
|
355
440
|
buildState() {
|
|
356
441
|
const state = {
|