pi-sdk-web 0.5.15 → 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,12 +91,10 @@ 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
- this.uiContext = new WebUIContext((obj) => this.broadcast(obj), {
95
- // Dialog timeout tier: with a browser attached the user may step away,
96
- // so dialogs wait long; with none attached they settle after a short
97
- // grace period instead of blocking the agent loop forever.
98
- hasClients: () => this.clients.size > 0,
99
- });
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.
97
+ this.uiContext = new WebUIContext((obj) => this.broadcast(obj));
100
98
  }
101
99
  // ------------------------------------------------------------------
102
100
  // Lifecycle
@@ -244,6 +242,8 @@ export class PiWebServer {
244
242
  catch {
245
243
  // One broken client must not block delivery to the others
246
244
  this.clients.delete(client);
245
+ if (this.clients.size === 0)
246
+ this.uiContext.setBrowserAttached(false);
247
247
  }
248
248
  }
249
249
  }
@@ -306,8 +306,17 @@ export class PiWebServer {
306
306
  // ------------------------------------------------------------------
307
307
  handleConnection(ws) {
308
308
  this.clients.add(ws);
309
- ws.on("close", () => this.clients.delete(ws));
310
- 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);
311
320
  ws.on("message", (data) => this.handleClientMessage(ws, String(data)));
312
321
  // Initial state (state + history), mirroring the Python bridge
313
322
  this.sendJson(ws, { type: "state", data: this.buildState() });
@@ -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>
@@ -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
- * Default dialog timeouts (pi-web specific guard).
6
+ * Fallback dialog timeout (pi-web specific guard).
7
7
  *
8
- * TUI and RPC block until the client answers - the user is at the terminal, or
9
- * the RPC client owns the timeout. A browser tab can be closed mid-turn, so a
10
- * dialog nobody can see would block the agent loop forever. Two tiers:
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
- * - browser attached: the user may have stepped away; wait long, then settle
13
- * with the dialog's default value (confirm -> false, others -> undefined);
14
- * - no browser attached: a closed tab must not stall the loop, but a page
15
- * reload/reconnect takes a moment - short grace period, not instant.
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 DIALOG_TIMEOUT_MS = 10 * 60_000;
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
- hasClients;
89
- dialogTimeoutMs;
90
- dialogTimeoutNoClientMs;
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.hasClients = options.hasClients ?? (() => false);
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
- this.pending.set(id, {
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
- request: { ...request, id },
209
- timer,
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.sink({ type: "extension_ui_request", id, ...request });
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
  // ------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.5.15",
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": {