pi-sdk-web 0.5.15 → 0.5.17
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 +70 -0
- package/dist/server.js +104 -11
- package/dist/static/app.js +31 -0
- package/dist/ui-context.js +74 -33
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
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";
|
|
@@ -43,6 +46,68 @@ if (PI_CLI_ENTRY && process.argv[1] !== PI_CLI_ENTRY) {
|
|
|
43
46
|
process.argv[1] = PI_CLI_ENTRY;
|
|
44
47
|
}
|
|
45
48
|
const DEFAULT_PORT = 4080;
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Heap headroom for long sessions.
|
|
51
|
+
//
|
|
52
|
+
// A large session plus in-process extensions (magic-context's local embedding
|
|
53
|
+
// model, historians, indexes) can outgrow Node's default old-space limit -
|
|
54
|
+
// ~2.2GB on a 15GB host - and abort the server mid-turn with
|
|
55
|
+
// "FATAL ERROR: Reached heap limit". pi-web therefore restarts itself once
|
|
56
|
+
// with a larger --max-old-space-size, but only for the long-running `r`
|
|
57
|
+
// command and only when the host has memory to spare.
|
|
58
|
+
//
|
|
59
|
+
// Override the target with PI_WEB_MAX_OLD_SPACE_MB=<mb>. An explicit
|
|
60
|
+
// --max-old-space-size in NODE_OPTIONS / argv is always respected untouched,
|
|
61
|
+
// and the restart happens at most once (PI_WEB_HEAP_REEXEC guard).
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
const DEFAULT_MAX_OLD_SPACE_MB = 4096;
|
|
64
|
+
/** Current V8 old-space limit in MB (what the crash log calls the heap limit). */
|
|
65
|
+
function heapLimitMb() {
|
|
66
|
+
return getHeapStatistics().heap_size_limit / (1024 * 1024);
|
|
67
|
+
}
|
|
68
|
+
function requestedHeapMb() {
|
|
69
|
+
const raw = Number(process.env.PI_WEB_MAX_OLD_SPACE_MB);
|
|
70
|
+
if (Number.isFinite(raw) && raw >= 512 && raw <= 32_768)
|
|
71
|
+
return Math.floor(raw);
|
|
72
|
+
return DEFAULT_MAX_OLD_SPACE_MB;
|
|
73
|
+
}
|
|
74
|
+
/** Did the user (or a wrapper) already choose a heap size themselves? */
|
|
75
|
+
function hasExplicitHeapFlag() {
|
|
76
|
+
const fromEnv = (process.env.NODE_OPTIONS ?? "").split(/\s+/);
|
|
77
|
+
return [...process.execArgv, ...fromEnv].some((arg) => /^--max[-_]old[-_]space[-_]size(=|$)/.test(arg));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Re-exec with a larger heap when needed. Returns true when a child has been
|
|
81
|
+
* started (the caller must stop: this process only waits for it).
|
|
82
|
+
*/
|
|
83
|
+
function ensureHeapHeadroom() {
|
|
84
|
+
if (process.env.PI_WEB_HEAP_REEXEC === "1")
|
|
85
|
+
return false; // already restarted
|
|
86
|
+
if (hasExplicitHeapFlag())
|
|
87
|
+
return false; // user decided - leave it alone
|
|
88
|
+
const target = requestedHeapMb();
|
|
89
|
+
if (heapLimitMb() >= target * 0.95)
|
|
90
|
+
return false; // close enough already
|
|
91
|
+
const totalMb = totalmem() / (1024 * 1024);
|
|
92
|
+
if (totalMb < target * 2) {
|
|
93
|
+
console.log(`heap: keeping the default limit (${heapLimitMb().toFixed(0)}MB) - ` +
|
|
94
|
+
`raising it to ${target}MB needs ~${target * 2}MB of RAM, host has ${totalMb.toFixed(0)}MB`);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
console.log(`heap: restarting with --max-old-space-size=${target} (current limit ${heapLimitMb().toFixed(0)}MB)`);
|
|
98
|
+
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" } });
|
|
99
|
+
// Stay alive without acting on the signal ourselves: the child is in the
|
|
100
|
+
// same process group and runs its own graceful shutdown, and we exit with
|
|
101
|
+
// its status once it is done.
|
|
102
|
+
process.on("SIGINT", () => { });
|
|
103
|
+
process.on("SIGTERM", () => { });
|
|
104
|
+
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 1)));
|
|
105
|
+
child.on("error", (err) => {
|
|
106
|
+
console.error(`heap: failed to restart with a larger heap: ${err.message}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
46
111
|
const DOC = `pi-web - browser Web access for Pi (via Pi SDK)
|
|
47
112
|
|
|
48
113
|
Usage:
|
|
@@ -122,6 +187,7 @@ async function cmdResume(name, port) {
|
|
|
122
187
|
const server = new PiWebServer(runtime, { port });
|
|
123
188
|
await server.start();
|
|
124
189
|
console.log(`server at http://127.0.0.1:${port}/ (session: ${info.name ?? info.id})`);
|
|
190
|
+
console.log(`heap limit: ${heapLimitMb().toFixed(0)}MB`);
|
|
125
191
|
let shuttingDown = false;
|
|
126
192
|
const shutdown = async (signal) => {
|
|
127
193
|
if (shuttingDown)
|
|
@@ -178,6 +244,10 @@ async function main() {
|
|
|
178
244
|
}
|
|
179
245
|
if (!name)
|
|
180
246
|
throw new Error("usage: pi-web r <name> [--port <port>]");
|
|
247
|
+
// Before anything heavy (session open, extensions): make sure this process
|
|
248
|
+
// has enough heap. A restart here means the child takes over the command.
|
|
249
|
+
if (ensureHeapHeadroom())
|
|
250
|
+
return;
|
|
181
251
|
await cmdResume(name, port ?? DEFAULT_PORT);
|
|
182
252
|
return;
|
|
183
253
|
}
|
package/dist/server.js
CHANGED
|
@@ -64,6 +64,31 @@ const STATS_REFRESH_EVENTS = new Set([
|
|
|
64
64
|
"session_info_changed",
|
|
65
65
|
"thinking_level_changed",
|
|
66
66
|
]);
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// WebSocket backpressure guard.
|
|
69
|
+
//
|
|
70
|
+
// `ws.send()` queues in process memory without bound when the peer reads
|
|
71
|
+
// slowly - a backgrounded/throttled tab is the common case. A long turn (LLM
|
|
72
|
+
// deltas) or a long `!! … --follow` stream then grows the process until the
|
|
73
|
+
// V8 heap dies (observed: FATAL ERROR: Reached heap limit after ~70 minutes).
|
|
74
|
+
//
|
|
75
|
+
// Two limits, per client:
|
|
76
|
+
// SOFT - above this, *self-healing* delta events are skipped rather than
|
|
77
|
+
// queued. They carry no unique information: message_update /
|
|
78
|
+
// tool_execution_update re-render from the full payload of the next
|
|
79
|
+
// event, and message_end / tool_execution_end / bash_result deliver
|
|
80
|
+
// the complete state.
|
|
81
|
+
// HARD - above this the client is treated as gone and terminated. The
|
|
82
|
+
// browser reconnects (static/app.js) and reloads, fetching fresh
|
|
83
|
+
// session history - so nothing is lost, and memory is bounded.
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
const WS_BUFFER_SOFT_LIMIT = 8 * 1024 * 1024;
|
|
86
|
+
const WS_BUFFER_HARD_LIMIT = 64 * 1024 * 1024;
|
|
87
|
+
const DROPPABLE_DELTA_EVENTS = new Set(["message_update", "tool_execution_update"]);
|
|
88
|
+
/** RSS watermarks (MB) at which to log one diagnostic line: growth curve plus
|
|
89
|
+
* what each client has queued, so a future OOM report says where it went. */
|
|
90
|
+
const MEMORY_LOG_THRESHOLDS_MB = [512, 1024, 1536, 2048];
|
|
91
|
+
const MEMORY_LOG_INTERVAL_MS = 30_000;
|
|
67
92
|
const MIME = {
|
|
68
93
|
".html": "text/html; charset=utf-8",
|
|
69
94
|
".css": "text/css; charset=utf-8",
|
|
@@ -83,6 +108,11 @@ export class PiWebServer {
|
|
|
83
108
|
wsServer = null;
|
|
84
109
|
clients = new Set();
|
|
85
110
|
unsubscribe = null;
|
|
111
|
+
/** Clients currently over the soft buffer limit (delta skipping active). */
|
|
112
|
+
backpressured = new WeakSet();
|
|
113
|
+
/** Memory watermarks already logged (diagnostics), and throttle clock. */
|
|
114
|
+
memoryLogged = new Set();
|
|
115
|
+
lastMemoryCheck = 0;
|
|
86
116
|
/** Current session (may change on /resume session switching). */
|
|
87
117
|
get session() {
|
|
88
118
|
return this.runtime.session;
|
|
@@ -91,12 +121,10 @@ export class PiWebServer {
|
|
|
91
121
|
this.runtime = runtime;
|
|
92
122
|
this.port = options.port ?? DEFAULT_PORT;
|
|
93
123
|
this.staticDir = options.staticDir ?? STATIC_DIR;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
hasClients: () => this.clients.size > 0,
|
|
99
|
-
});
|
|
124
|
+
// Dialog timeouts follow client presence (see WebUIContext): attached ->
|
|
125
|
+
// wait indefinitely (TUI parity), detached -> fallback guard so a closed
|
|
126
|
+
// tab cannot block the agent loop. handleConnection / ws close update it.
|
|
127
|
+
this.uiContext = new WebUIContext((obj) => this.broadcast(obj));
|
|
100
128
|
}
|
|
101
129
|
// ------------------------------------------------------------------
|
|
102
130
|
// Lifecycle
|
|
@@ -235,17 +263,65 @@ export class PiWebServer {
|
|
|
235
263
|
catch {
|
|
236
264
|
return;
|
|
237
265
|
}
|
|
238
|
-
|
|
266
|
+
const type = obj.type;
|
|
267
|
+
const droppable = typeof type === "string" && DROPPABLE_DELTA_EVENTS.has(type);
|
|
268
|
+
for (const client of [...this.clients]) {
|
|
239
269
|
if (client.readyState !== WebSocket.OPEN)
|
|
240
270
|
continue;
|
|
271
|
+
const buffered = client.bufferedAmount;
|
|
272
|
+
if (buffered > WS_BUFFER_HARD_LIMIT) {
|
|
273
|
+
// Far beyond reading - treat as gone. The browser reconnects and
|
|
274
|
+
// reloads, so no state is lost, and memory stays bounded.
|
|
275
|
+
console.warn(`pi-web: client not reading (${(buffered / 1048576).toFixed(0)}MB queued) - closing it; ` +
|
|
276
|
+
"the browser will reconnect and reload history");
|
|
277
|
+
this.backpressured.delete(client);
|
|
278
|
+
client.terminate();
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (droppable && buffered > WS_BUFFER_SOFT_LIMIT) {
|
|
282
|
+
if (!this.backpressured.has(client)) {
|
|
283
|
+
this.backpressured.add(client);
|
|
284
|
+
console.warn(`pi-web: client is behind (${(buffered / 1048576).toFixed(1)}MB queued) - ` +
|
|
285
|
+
"skipping stream deltas until it catches up");
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (this.backpressured.has(client) && buffered < WS_BUFFER_SOFT_LIMIT / 2) {
|
|
290
|
+
this.backpressured.delete(client);
|
|
291
|
+
console.warn("pi-web: client caught up - stream deltas resumed");
|
|
292
|
+
}
|
|
241
293
|
try {
|
|
242
294
|
client.send(message);
|
|
243
295
|
}
|
|
244
296
|
catch {
|
|
245
297
|
// One broken client must not block delivery to the others
|
|
246
298
|
this.clients.delete(client);
|
|
299
|
+
if (this.clients.size === 0)
|
|
300
|
+
this.uiContext.setBrowserAttached(false);
|
|
247
301
|
}
|
|
248
302
|
}
|
|
303
|
+
this.maybeLogMemory();
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* One diagnostic line per RSS watermark (plus what each client has queued),
|
|
307
|
+
* so a future out-of-memory report shows where the growth went. Throttled:
|
|
308
|
+
* `broadcast` runs per streaming delta and memoryUsage() is not free.
|
|
309
|
+
*/
|
|
310
|
+
maybeLogMemory() {
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
if (now - this.lastMemoryCheck < MEMORY_LOG_INTERVAL_MS)
|
|
313
|
+
return;
|
|
314
|
+
this.lastMemoryCheck = now;
|
|
315
|
+
const { rss, heapUsed, heapTotal } = process.memoryUsage();
|
|
316
|
+
const rssMb = rss / 1048576;
|
|
317
|
+
for (const threshold of MEMORY_LOG_THRESHOLDS_MB) {
|
|
318
|
+
if (rssMb < threshold || this.memoryLogged.has(threshold))
|
|
319
|
+
continue;
|
|
320
|
+
this.memoryLogged.add(threshold);
|
|
321
|
+
const queued = [...this.clients].map((c) => `${(c.bufferedAmount / 1048576).toFixed(1)}MB`).join(", ") || "no clients";
|
|
322
|
+
console.warn(`pi-web: memory ${rssMb.toFixed(0)}MB (heap ${(heapUsed / 1048576).toFixed(0)}/` +
|
|
323
|
+
`${(heapTotal / 1048576).toFixed(0)}MB), queued per client: ${queued}`);
|
|
324
|
+
}
|
|
249
325
|
}
|
|
250
326
|
broadcastStats() {
|
|
251
327
|
try {
|
|
@@ -306,8 +382,17 @@ export class PiWebServer {
|
|
|
306
382
|
// ------------------------------------------------------------------
|
|
307
383
|
handleConnection(ws) {
|
|
308
384
|
this.clients.add(ws);
|
|
309
|
-
|
|
310
|
-
|
|
385
|
+
// A browser is watching again: pending dialogs stop counting down (see
|
|
386
|
+
// WebUIContext.setBrowserAttached) - the user gets unlimited time.
|
|
387
|
+
this.uiContext.setBrowserAttached(true);
|
|
388
|
+
const onGone = () => {
|
|
389
|
+
this.clients.delete(ws);
|
|
390
|
+
// Last browser left: arm the fallback so dialogs cannot block the loop.
|
|
391
|
+
if (this.clients.size === 0)
|
|
392
|
+
this.uiContext.setBrowserAttached(false);
|
|
393
|
+
};
|
|
394
|
+
ws.on("close", onGone);
|
|
395
|
+
ws.on("error", onGone);
|
|
311
396
|
ws.on("message", (data) => this.handleClientMessage(ws, String(data)));
|
|
312
397
|
// Initial state (state + history), mirroring the Python bridge
|
|
313
398
|
this.sendJson(ws, { type: "state", data: this.buildState() });
|
|
@@ -339,9 +424,17 @@ export class PiWebServer {
|
|
|
339
424
|
}
|
|
340
425
|
}
|
|
341
426
|
sendJson(ws, obj) {
|
|
342
|
-
if (ws.readyState
|
|
343
|
-
|
|
427
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
428
|
+
return;
|
|
429
|
+
// Same hard limit as broadcast(): the initial state/history snapshot is
|
|
430
|
+
// the largest single payload, so a client that never reads it must not be
|
|
431
|
+
// allowed to queue forever.
|
|
432
|
+
if (ws.bufferedAmount > WS_BUFFER_HARD_LIMIT) {
|
|
433
|
+
console.warn("pi-web: client not reading its initial snapshot - closing it");
|
|
434
|
+
ws.terminate();
|
|
435
|
+
return;
|
|
344
436
|
}
|
|
437
|
+
ws.send(JSON.stringify(obj));
|
|
345
438
|
}
|
|
346
439
|
buildState() {
|
|
347
440
|
const state = {
|
package/dist/static/app.js
CHANGED
|
@@ -204,6 +204,8 @@ class PiWebClient {
|
|
|
204
204
|
this.modalList = document.getElementById('modal-list');
|
|
205
205
|
this.modalClose = document.getElementById('modal-close');
|
|
206
206
|
this.modalMode = null; // 'model' | 'thinking'
|
|
207
|
+
this.dialogCountdownTimer = null; // extension dialog countdown (TUI parity)
|
|
208
|
+
this.dialogCountdownBase = '';
|
|
207
209
|
this.hasConnectedBefore = false;
|
|
208
210
|
this.commandMenuIndex = -1;
|
|
209
211
|
this.extStatus = {};
|
|
@@ -1794,6 +1796,7 @@ class PiWebClient {
|
|
|
1794
1796
|
|
|
1795
1797
|
openModal(title, mode) {
|
|
1796
1798
|
this.modalMode = mode;
|
|
1799
|
+
this.stopDialogCountdown();
|
|
1797
1800
|
this.modalTitle.textContent = title;
|
|
1798
1801
|
this.modalSearch.value = '';
|
|
1799
1802
|
this.modalList.innerHTML = '';
|
|
@@ -1801,6 +1804,29 @@ class PiWebClient {
|
|
|
1801
1804
|
this.modalSearch.focus();
|
|
1802
1805
|
}
|
|
1803
1806
|
|
|
1807
|
+
/** Countdown for extension dialogs that carry a deadline (an extension's own
|
|
1808
|
+
* opts.timeout). TUI shows the same "(Ns)" ticker; without a deadline the
|
|
1809
|
+
* dialog waits indefinitely while a browser is attached. */
|
|
1810
|
+
startDialogCountdown(req) {
|
|
1811
|
+
this.stopDialogCountdown();
|
|
1812
|
+
if (!req || typeof req.deadline !== 'number') return;
|
|
1813
|
+
this.dialogCountdownBase = this.modalTitle.textContent || '';
|
|
1814
|
+
const tick = () => {
|
|
1815
|
+
const remain = Math.max(0, Math.ceil((req.deadline - Date.now()) / 1000));
|
|
1816
|
+
this.modalTitle.textContent = `${this.dialogCountdownBase} (${remain}s)`;
|
|
1817
|
+
if (remain <= 0) this.stopDialogCountdown();
|
|
1818
|
+
};
|
|
1819
|
+
tick();
|
|
1820
|
+
this.dialogCountdownTimer = setInterval(tick, 1000);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
stopDialogCountdown() {
|
|
1824
|
+
if (this.dialogCountdownTimer) {
|
|
1825
|
+
clearInterval(this.dialogCountdownTimer);
|
|
1826
|
+
this.dialogCountdownTimer = null;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1804
1830
|
closeModal() {
|
|
1805
1831
|
// If an extension dialog (select/confirm/input/editor) was open, notify
|
|
1806
1832
|
// the server that it was cancelled - otherwise the extension's promise
|
|
@@ -1814,6 +1840,7 @@ class PiWebClient {
|
|
|
1814
1840
|
this.modalSearch.style.display = 'block';
|
|
1815
1841
|
this.modalMode = null;
|
|
1816
1842
|
this.currentExtRequest = null;
|
|
1843
|
+
this.stopDialogCountdown();
|
|
1817
1844
|
this.inputEl.focus();
|
|
1818
1845
|
}
|
|
1819
1846
|
|
|
@@ -1920,6 +1947,7 @@ class PiWebClient {
|
|
|
1920
1947
|
openExtensionSelect(req) {
|
|
1921
1948
|
this.openModal(req.title || 'Select', 'extension-select');
|
|
1922
1949
|
this.currentExtRequest = req;
|
|
1950
|
+
this.startDialogCountdown(req);
|
|
1923
1951
|
this.modalSearch.style.display = 'block';
|
|
1924
1952
|
const items = (req.options || []).map((opt) => ({ name: opt, desc: '', value: opt }));
|
|
1925
1953
|
this.renderModalItems(items, (item) => {
|
|
@@ -1930,6 +1958,7 @@ class PiWebClient {
|
|
|
1930
1958
|
openExtensionConfirm(req) {
|
|
1931
1959
|
this.openModal(req.title || 'Confirm', 'extension-confirm');
|
|
1932
1960
|
this.currentExtRequest = req;
|
|
1961
|
+
this.startDialogCountdown(req);
|
|
1933
1962
|
this.modalSearch.style.display = 'none';
|
|
1934
1963
|
this.modalList.innerHTML = `
|
|
1935
1964
|
<div class="modal-message">${this.escapeHtml(req.message || '')}</div>
|
|
@@ -1950,6 +1979,7 @@ class PiWebClient {
|
|
|
1950
1979
|
openExtensionInput(req) {
|
|
1951
1980
|
this.openModal(req.title || 'Input', 'extension-input');
|
|
1952
1981
|
this.currentExtRequest = req;
|
|
1982
|
+
this.startDialogCountdown(req);
|
|
1953
1983
|
this.modalSearch.style.display = 'none';
|
|
1954
1984
|
this.modalList.innerHTML = `
|
|
1955
1985
|
<div class="modal-message">${this.escapeHtml(req.message || '')}</div>
|
|
@@ -1979,6 +2009,7 @@ class PiWebClient {
|
|
|
1979
2009
|
openExtensionEditor(req) {
|
|
1980
2010
|
this.openModal(req.title || 'Editor', 'extension-editor');
|
|
1981
2011
|
this.currentExtRequest = req;
|
|
2012
|
+
this.startDialogCountdown(req);
|
|
1982
2013
|
this.modalSearch.style.display = 'none';
|
|
1983
2014
|
this.modalList.innerHTML = `
|
|
1984
2015
|
<div class="modal-message">${this.escapeHtml(req.title || '')}</div>
|
package/dist/ui-context.js
CHANGED
|
@@ -3,21 +3,24 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Fallback dialog timeout (pi-web specific guard).
|
|
7
7
|
*
|
|
8
|
-
* TUI and RPC
|
|
9
|
-
* the RPC client owns the timeout.
|
|
10
|
-
* dialog
|
|
8
|
+
* TUI and RPC wait for an answer indefinitely - the user is at the terminal,
|
|
9
|
+
* or the RPC client owns the timeout. With a browser attached pi-web does the
|
|
10
|
+
* same: a dialog waits forever, so an answer is never taken away from the user
|
|
11
|
+
* just because they were busy elsewhere.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* The one case that needs a guard is a browser that is NOT attached (tab
|
|
14
|
+
* closed mid-turn): nobody can answer, and an extension awaiting the dialog
|
|
15
|
+
* would block the agent loop forever. Then - and only then - the dialog
|
|
16
|
+
* settles after this grace period with its default value (confirm -> false,
|
|
17
|
+
* others -> undefined). Reconnecting cancels the fallback, so a user who comes
|
|
18
|
+
* back gets as much time as they need.
|
|
16
19
|
*
|
|
17
|
-
* Extensions that pass their own `timeout` (ms) keep it
|
|
20
|
+
* Extensions that pass their own `timeout` (ms) keep it exactly (that timer is
|
|
21
|
+
* their decision, never cancelled here).
|
|
18
22
|
*/
|
|
19
|
-
const
|
|
20
|
-
const DIALOG_TIMEOUT_NO_CLIENT_MS = 60_000;
|
|
23
|
+
const DIALOG_FALLBACK_TIMEOUT_MS = 10 * 60_000;
|
|
21
24
|
/**
|
|
22
25
|
* Load a Pi theme (dark.json/light.json colors) so extensions calling
|
|
23
26
|
* ui.theme.fg("accent", text) / .bg(...) get REAL ANSI escapes — identical
|
|
@@ -85,18 +88,16 @@ export class WebUIContext {
|
|
|
85
88
|
/** Latest setWidget lines per key (persistent widgets, e.g. TodoOverlay):
|
|
86
89
|
* replayed to late-connecting browsers like setStatus snapshots. */
|
|
87
90
|
widgetMap = new Map();
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
fallbackTimeoutMs;
|
|
92
|
+
/** Whether a browser is attached right now (updated by the server). */
|
|
93
|
+
browserAttached = false;
|
|
91
94
|
/** Current widget snapshot (key -> lines) for new connections. */
|
|
92
95
|
getWidgetSnapshot() {
|
|
93
96
|
return Object.fromEntries(this.widgetMap);
|
|
94
97
|
}
|
|
95
98
|
constructor(sink, options = {}) {
|
|
96
99
|
this.sink = sink;
|
|
97
|
-
this.
|
|
98
|
-
this.dialogTimeoutMs = options.dialogTimeoutMs ?? DIALOG_TIMEOUT_MS;
|
|
99
|
-
this.dialogTimeoutNoClientMs = options.dialogTimeoutNoClientMs ?? DIALOG_TIMEOUT_NO_CLIENT_MS;
|
|
100
|
+
this.fallbackTimeoutMs = options.fallbackTimeoutMs ?? DIALOG_FALLBACK_TIMEOUT_MS;
|
|
100
101
|
// Pi's ExtensionRunner wraps the ui context with `{...ui}` (a shallow
|
|
101
102
|
// spread) when building the extension ctx - class prototype members
|
|
102
103
|
// (methods AND the theme getter) would be LOST by that spread (only own
|
|
@@ -163,6 +164,40 @@ export class WebUIContext {
|
|
|
163
164
|
dismissDialog(id, reason) {
|
|
164
165
|
this.sink({ type: "dialog_dismissed", id, reason });
|
|
165
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Browser presence changed (WS connect/disconnect). Only dialogs without an
|
|
169
|
+
* extension timeout are affected: while a browser is attached they wait
|
|
170
|
+
* forever (TUI parity - never take an answer away from the user), while no
|
|
171
|
+
* browser is attached a fallback timer keeps a closed tab from blocking the
|
|
172
|
+
* agent loop. Reconnecting cancels the fallback.
|
|
173
|
+
*/
|
|
174
|
+
setBrowserAttached(attached) {
|
|
175
|
+
if (this.browserAttached === attached)
|
|
176
|
+
return;
|
|
177
|
+
this.browserAttached = attached;
|
|
178
|
+
for (const [id, entry] of [...this.pending.entries()]) {
|
|
179
|
+
if (entry.extensionTimeout !== undefined)
|
|
180
|
+
continue; // extension's own timer
|
|
181
|
+
if (attached) {
|
|
182
|
+
if (entry.fallbackTimer && entry.timer) {
|
|
183
|
+
clearTimeout(entry.timer);
|
|
184
|
+
entry.timer = undefined;
|
|
185
|
+
entry.fallbackTimer = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (!entry.timer) {
|
|
189
|
+
this.armFallbackTimer(id, entry);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Arm the no-browser fallback (settles with the dialog default). */
|
|
194
|
+
armFallbackTimer(id, entry) {
|
|
195
|
+
entry.fallbackTimer = true;
|
|
196
|
+
entry.timer = setTimeout(() => {
|
|
197
|
+
entry.resolve(undefined);
|
|
198
|
+
this.dismissDialog(id, "timeout");
|
|
199
|
+
}, this.fallbackTimeoutMs);
|
|
200
|
+
}
|
|
166
201
|
createDialog(request, opts, defaultValue, parse) {
|
|
167
202
|
const id = crypto.randomUUID();
|
|
168
203
|
// Same as Pi's RPC/TUI: an already-aborted dialog settles immediately.
|
|
@@ -187,30 +222,36 @@ export class WebUIContext {
|
|
|
187
222
|
cleanup();
|
|
188
223
|
resolve(useDefault ? defaultValue : parse(value));
|
|
189
224
|
};
|
|
190
|
-
// Extension timeout (ms) wins; otherwise the web guard applies so a
|
|
191
|
-
// closed browser cannot block the agent loop forever.
|
|
192
|
-
const timeoutMs = typeof opts?.timeout === "number"
|
|
193
|
-
? opts.timeout
|
|
194
|
-
: this.hasClients()
|
|
195
|
-
? this.dialogTimeoutMs
|
|
196
|
-
: this.dialogTimeoutNoClientMs;
|
|
197
|
-
const timer = setTimeout(() => {
|
|
198
|
-
settle(undefined, true);
|
|
199
|
-
this.dismissDialog(id, "timeout");
|
|
200
|
-
}, timeoutMs);
|
|
201
225
|
const onAbort = () => {
|
|
202
226
|
settle(undefined, true);
|
|
203
227
|
this.dismissDialog(id, "aborted");
|
|
204
228
|
};
|
|
205
229
|
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
206
|
-
|
|
230
|
+
const extensionTimeout = typeof opts?.timeout === "number" && opts.timeout > 0 ? opts.timeout : undefined;
|
|
231
|
+
const deadline = extensionTimeout !== undefined ? Date.now() + extensionTimeout : undefined;
|
|
232
|
+
const entry = {
|
|
207
233
|
resolve: (value) => settle(value, false),
|
|
208
|
-
|
|
209
|
-
|
|
234
|
+
// `deadline` lets the browser show the same countdown TUI does.
|
|
235
|
+
request: deadline !== undefined ? { ...request, id, deadline } : { ...request, id },
|
|
236
|
+
deadline,
|
|
237
|
+
extensionTimeout,
|
|
238
|
+
fallbackTimer: false,
|
|
210
239
|
signal: opts?.signal,
|
|
211
240
|
onAbort,
|
|
212
|
-
}
|
|
213
|
-
this.
|
|
241
|
+
};
|
|
242
|
+
this.pending.set(id, entry);
|
|
243
|
+
if (extensionTimeout !== undefined) {
|
|
244
|
+
// The extension asked for this deadline - honour it exactly (TUI
|
|
245
|
+
// shows the same countdown and auto-dismisses on expiry).
|
|
246
|
+
entry.timer = setTimeout(() => {
|
|
247
|
+
settle(undefined, true);
|
|
248
|
+
this.dismissDialog(id, "timeout");
|
|
249
|
+
}, extensionTimeout);
|
|
250
|
+
}
|
|
251
|
+
else if (!this.browserAttached) {
|
|
252
|
+
this.armFallbackTimer(id, entry);
|
|
253
|
+
}
|
|
254
|
+
this.sink({ type: "extension_ui_request", id, ...request, ...(deadline !== undefined ? { deadline } : {}) });
|
|
214
255
|
});
|
|
215
256
|
}
|
|
216
257
|
// ------------------------------------------------------------------
|