pi-sdk-web 0.5.14 → 0.5.16

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/server.js CHANGED
@@ -91,6 +91,9 @@ export class PiWebServer {
91
91
  this.runtime = runtime;
92
92
  this.port = options.port ?? DEFAULT_PORT;
93
93
  this.staticDir = options.staticDir ?? STATIC_DIR;
94
+ // Dialog timeouts follow client presence (see WebUIContext): attached ->
95
+ // wait indefinitely (TUI parity), detached -> fallback guard so a closed
96
+ // tab cannot block the agent loop. handleConnection / ws close update it.
94
97
  this.uiContext = new WebUIContext((obj) => this.broadcast(obj));
95
98
  }
96
99
  // ------------------------------------------------------------------
@@ -239,6 +242,8 @@ export class PiWebServer {
239
242
  catch {
240
243
  // One broken client must not block delivery to the others
241
244
  this.clients.delete(client);
245
+ if (this.clients.size === 0)
246
+ this.uiContext.setBrowserAttached(false);
242
247
  }
243
248
  }
244
249
  }
@@ -301,8 +306,17 @@ export class PiWebServer {
301
306
  // ------------------------------------------------------------------
302
307
  handleConnection(ws) {
303
308
  this.clients.add(ws);
304
- ws.on("close", () => this.clients.delete(ws));
305
- ws.on("error", () => this.clients.delete(ws));
309
+ // A browser is watching again: pending dialogs stop counting down (see
310
+ // WebUIContext.setBrowserAttached) - the user gets unlimited time.
311
+ this.uiContext.setBrowserAttached(true);
312
+ const onGone = () => {
313
+ this.clients.delete(ws);
314
+ // Last browser left: arm the fallback so dialogs cannot block the loop.
315
+ if (this.clients.size === 0)
316
+ this.uiContext.setBrowserAttached(false);
317
+ };
318
+ ws.on("close", onGone);
319
+ ws.on("error", onGone);
306
320
  ws.on("message", (data) => this.handleClientMessage(ws, String(data)));
307
321
  // Initial state (state + history), mirroring the Python bridge
308
322
  this.sendJson(ws, { type: "state", data: this.buildState() });
@@ -325,6 +339,13 @@ export class PiWebServer {
325
339
  widgetPlacement: w.placement,
326
340
  });
327
341
  }
342
+ // Pending extension dialogs (select/confirm/input/editor): the request may
343
+ // have been broadcast while this browser was away (closed tab, reload), so
344
+ // re-send it - otherwise the dialog would sit here unanswered and block the
345
+ // agent loop. The id is preserved so the response resolves the same dialog.
346
+ for (const request of this.uiContext.getPendingDialogs()) {
347
+ this.sendJson(ws, { type: "extension_ui_request", ...request });
348
+ }
328
349
  }
329
350
  sendJson(ws, obj) {
330
351
  if (ws.readyState === WebSocket.OPEN) {
@@ -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 = {};
@@ -375,6 +377,9 @@ class PiWebClient {
375
377
  case 'ext_status':
376
378
  this.applyExtStatusSnapshot(data.data);
377
379
  break;
380
+ case 'dialog_dismissed':
381
+ this.dismissExtensionDialog(data);
382
+ break;
378
383
  case 'extension_ui_request':
379
384
  this.handleExtensionUIRequest(data);
380
385
  break;
@@ -1791,6 +1796,7 @@ class PiWebClient {
1791
1796
 
1792
1797
  openModal(title, mode) {
1793
1798
  this.modalMode = mode;
1799
+ this.stopDialogCountdown();
1794
1800
  this.modalTitle.textContent = title;
1795
1801
  this.modalSearch.value = '';
1796
1802
  this.modalList.innerHTML = '';
@@ -1798,6 +1804,29 @@ class PiWebClient {
1798
1804
  this.modalSearch.focus();
1799
1805
  }
1800
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
+
1801
1830
  closeModal() {
1802
1831
  // If an extension dialog (select/confirm/input/editor) was open, notify
1803
1832
  // the server that it was cancelled - otherwise the extension's promise
@@ -1811,6 +1840,7 @@ class PiWebClient {
1811
1840
  this.modalSearch.style.display = 'block';
1812
1841
  this.modalMode = null;
1813
1842
  this.currentExtRequest = null;
1843
+ this.stopDialogCountdown();
1814
1844
  this.inputEl.focus();
1815
1845
  }
1816
1846
 
@@ -1917,6 +1947,7 @@ class PiWebClient {
1917
1947
  openExtensionSelect(req) {
1918
1948
  this.openModal(req.title || 'Select', 'extension-select');
1919
1949
  this.currentExtRequest = req;
1950
+ this.startDialogCountdown(req);
1920
1951
  this.modalSearch.style.display = 'block';
1921
1952
  const items = (req.options || []).map((opt) => ({ name: opt, desc: '', value: opt }));
1922
1953
  this.renderModalItems(items, (item) => {
@@ -1927,6 +1958,7 @@ class PiWebClient {
1927
1958
  openExtensionConfirm(req) {
1928
1959
  this.openModal(req.title || 'Confirm', 'extension-confirm');
1929
1960
  this.currentExtRequest = req;
1961
+ this.startDialogCountdown(req);
1930
1962
  this.modalSearch.style.display = 'none';
1931
1963
  this.modalList.innerHTML = `
1932
1964
  <div class="modal-message">${this.escapeHtml(req.message || '')}</div>
@@ -1947,6 +1979,7 @@ class PiWebClient {
1947
1979
  openExtensionInput(req) {
1948
1980
  this.openModal(req.title || 'Input', 'extension-input');
1949
1981
  this.currentExtRequest = req;
1982
+ this.startDialogCountdown(req);
1950
1983
  this.modalSearch.style.display = 'none';
1951
1984
  this.modalList.innerHTML = `
1952
1985
  <div class="modal-message">${this.escapeHtml(req.message || '')}</div>
@@ -1976,6 +2009,7 @@ class PiWebClient {
1976
2009
  openExtensionEditor(req) {
1977
2010
  this.openModal(req.title || 'Editor', 'extension-editor');
1978
2011
  this.currentExtRequest = req;
2012
+ this.startDialogCountdown(req);
1979
2013
  this.modalSearch.style.display = 'none';
1980
2014
  this.modalList.innerHTML = `
1981
2015
  <div class="modal-message">${this.escapeHtml(req.title || '')}</div>
@@ -1996,6 +2030,21 @@ class PiWebClient {
1996
2030
  });
1997
2031
  }
1998
2032
 
2033
+ /** Server-side dismissal (timeout / abort / session switch): the dialog was
2034
+ * already settled server-side, so close the modal and say why - do NOT send
2035
+ * an extension_ui_response (there is nothing left to answer). */
2036
+ dismissExtensionDialog({ id, reason } = {}) {
2037
+ if (!id || !this.currentExtRequest || this.currentExtRequest.id !== id) return;
2038
+ const title = this.currentExtRequest.title || 'Dialog';
2039
+ const why =
2040
+ reason === 'aborted' ? 'operation aborted'
2041
+ : reason === 'session-switch' ? 'session switched'
2042
+ : 'no answer received in time';
2043
+ this.currentExtRequest = null; // closeModal() must not answer for us
2044
+ this.closeModal();
2045
+ this.appendNotifyLine(`${title} — auto-dismissed (${why})`, 'warning');
2046
+ }
2047
+
1999
2048
  openExtensionNotify(req) {
2000
2049
  // Command output notifications (title starts with "/", e.g. /ctx-status) keep
2001
2050
  // the modal. Other extension notifies are appended to the chat stream as
@@ -2,6 +2,25 @@ import { Theme as PiTheme } from "@earendil-works/pi-coding-agent";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ /**
6
+ * Fallback dialog timeout (pi-web specific guard).
7
+ *
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.
12
+ *
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.
19
+ *
20
+ * Extensions that pass their own `timeout` (ms) keep it exactly (that timer is
21
+ * their decision, never cancelled here).
22
+ */
23
+ const DIALOG_FALLBACK_TIMEOUT_MS = 10 * 60_000;
5
24
  /**
6
25
  * Load a Pi theme (dark.json/light.json colors) so extensions calling
7
26
  * ui.theme.fg("accent", text) / .bg(...) get REAL ANSI escapes — identical
@@ -69,12 +88,16 @@ export class WebUIContext {
69
88
  /** Latest setWidget lines per key (persistent widgets, e.g. TodoOverlay):
70
89
  * replayed to late-connecting browsers like setStatus snapshots. */
71
90
  widgetMap = new Map();
91
+ fallbackTimeoutMs;
92
+ /** Whether a browser is attached right now (updated by the server). */
93
+ browserAttached = false;
72
94
  /** Current widget snapshot (key -> lines) for new connections. */
73
95
  getWidgetSnapshot() {
74
96
  return Object.fromEntries(this.widgetMap);
75
97
  }
76
- constructor(sink) {
98
+ constructor(sink, options = {}) {
77
99
  this.sink = sink;
100
+ this.fallbackTimeoutMs = options.fallbackTimeoutMs ?? DIALOG_FALLBACK_TIMEOUT_MS;
78
101
  // Pi's ExtensionRunner wraps the ui context with `{...ui}` (a shallow
79
102
  // spread) when building the extension ctx - class prototype members
80
103
  // (methods AND the theme getter) would be LOST by that spread (only own
@@ -106,23 +129,26 @@ export class WebUIContext {
106
129
  }
107
130
  /** Drop state tied to the previous session (dialogs + status snapshots). */
108
131
  clearSessionState() {
109
- for (const pending of this.pending.values()) {
110
- if (pending.timer)
111
- clearTimeout(pending.timer);
132
+ // Copy first: settling deletes from the map while we iterate.
133
+ for (const [id, pending] of [...this.pending.entries()]) {
112
134
  pending.resolve(undefined);
135
+ // The browser may still show the dialog; close it there too.
136
+ this.dismissDialog(id, "session-switch");
113
137
  }
114
138
  this.pending.clear();
115
139
  this.statusMap.clear();
116
140
  this.widgetMap.clear();
117
141
  }
142
+ /** Pending dialogs (wire payloads) for replay to a newly connected browser. */
143
+ getPendingDialogs() {
144
+ return [...this.pending.values()].map((entry) => entry.request);
145
+ }
118
146
  /** Handle a browser `extension_ui_response` message. */
119
147
  respond(id, response) {
120
148
  const pending = this.pending.get(id);
121
149
  if (!pending)
122
150
  return false;
123
- this.pending.delete(id);
124
- if (pending.timer)
125
- clearTimeout(pending.timer);
151
+ // Cleanup happens inside settle() (timer / abort listener / map entry).
126
152
  if (response.cancelled) {
127
153
  pending.resolve(undefined);
128
154
  }
@@ -134,37 +160,114 @@ export class WebUIContext {
134
160
  }
135
161
  return true;
136
162
  }
137
- createDialog(request, defaultValue, parse) {
163
+ /** Tell the browser to close a dialog that already settled server-side. */
164
+ dismissDialog(id, reason) {
165
+ this.sink({ type: "dialog_dismissed", id, reason });
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
+ }
201
+ createDialog(request, opts, defaultValue, parse) {
138
202
  const id = crypto.randomUUID();
203
+ // Same as Pi's RPC/TUI: an already-aborted dialog settles immediately.
204
+ if (opts?.signal?.aborted)
205
+ return Promise.resolve(defaultValue);
139
206
  return new Promise((resolve) => {
140
- const timeoutMs = typeof request.timeout === "number" ? request.timeout : undefined;
141
- const timer = timeoutMs
142
- ? setTimeout(() => {
143
- this.pending.delete(id);
144
- resolve(defaultValue);
145
- }, timeoutMs)
146
- : undefined;
147
- this.pending.set(id, {
148
- resolve: (value) => resolve(parse(value)),
149
- timer,
150
- });
151
- this.sink({ type: "extension_ui_request", id, ...request });
207
+ let settled = false;
208
+ const cleanup = () => {
209
+ const entry = this.pending.get(id);
210
+ if (!entry)
211
+ return;
212
+ if (entry.timer)
213
+ clearTimeout(entry.timer);
214
+ if (entry.signal && entry.onAbort)
215
+ entry.signal.removeEventListener("abort", entry.onAbort);
216
+ this.pending.delete(id);
217
+ };
218
+ const settle = (value, useDefault) => {
219
+ if (settled)
220
+ return;
221
+ settled = true;
222
+ cleanup();
223
+ resolve(useDefault ? defaultValue : parse(value));
224
+ };
225
+ const onAbort = () => {
226
+ settle(undefined, true);
227
+ this.dismissDialog(id, "aborted");
228
+ };
229
+ opts?.signal?.addEventListener("abort", onAbort, { once: true });
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 = {
233
+ resolve: (value) => settle(value, false),
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,
239
+ signal: opts?.signal,
240
+ onAbort,
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 } : {}) });
152
255
  });
153
256
  }
154
257
  // ------------------------------------------------------------------
155
258
  // Dialogs (browser resolves via extension_ui_response)
156
259
  // ------------------------------------------------------------------
157
260
  select(title, options, opts) {
158
- return this.createDialog({ method: "select", title, options, timeout: opts?.timeout }, undefined, (v) => (typeof v === "string" ? v : undefined));
261
+ return this.createDialog({ method: "select", title, options, timeout: opts?.timeout }, opts, undefined, (v) => (typeof v === "string" ? v : undefined));
159
262
  }
160
263
  confirm(title, message, opts) {
161
- return this.createDialog({ method: "confirm", title, message, timeout: opts?.timeout }, false, (v) => v === true);
264
+ return this.createDialog({ method: "confirm", title, message, timeout: opts?.timeout }, opts, false, (v) => v === true);
162
265
  }
163
266
  input(title, placeholder, opts) {
164
- return this.createDialog({ method: "input", title, placeholder, timeout: opts?.timeout }, undefined, (v) => (typeof v === "string" ? v : undefined));
267
+ return this.createDialog({ method: "input", title, placeholder, timeout: opts?.timeout }, opts, undefined, (v) => (typeof v === "string" ? v : undefined));
165
268
  }
166
269
  editor(title, prefill) {
167
- return this.createDialog({ method: "editor", title, prefill }, undefined, (v) => typeof v === "string" ? v : undefined);
270
+ return this.createDialog({ method: "editor", title, prefill }, undefined, undefined, (v) => typeof v === "string" ? v : undefined);
168
271
  }
169
272
  // ------------------------------------------------------------------
170
273
  // Fire-and-forget UI events (broadcast to browser)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
4
4
  "description": "Browser Web access for Pi (AI coding agent) via the Pi SDK - standalone module, zero modification to Pi itself",
5
5
  "type": "module",
6
6
  "bin": {