pi-sdk-web 0.5.13 → 0.5.15

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,7 +91,12 @@ 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));
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
+ });
95
100
  }
96
101
  // ------------------------------------------------------------------
97
102
  // Lifecycle
@@ -325,6 +330,13 @@ export class PiWebServer {
325
330
  widgetPlacement: w.placement,
326
331
  });
327
332
  }
333
+ // Pending extension dialogs (select/confirm/input/editor): the request may
334
+ // have been broadcast while this browser was away (closed tab, reload), so
335
+ // re-send it - otherwise the dialog would sit here unanswered and block the
336
+ // agent loop. The id is preserved so the response resolves the same dialog.
337
+ for (const request of this.uiContext.getPendingDialogs()) {
338
+ this.sendJson(ws, { type: "extension_ui_request", ...request });
339
+ }
328
340
  }
329
341
  sendJson(ws, obj) {
330
342
  if (ws.readyState === WebSocket.OPEN) {
@@ -882,22 +894,37 @@ export class PiWebServer {
882
894
  }
883
895
  };
884
896
  const unsubscribe = this.session.subscribe(listener);
885
- // hasUI by extension: most extensions degrade gracefully to notify()
886
- // (our notify -> browser dialog/toast), but a few (magic-context) use
887
- // ctx.ui.custom() - an extension-drawn TUI panel we can't render. For
888
- // those, hasUI:false routes them to their own text fallback
889
- // (appendEntry -> modal), same as before.
897
+ // hasUI per extension: extensions whose command handler draws a TUI
898
+ // panel via ctx.ui.custom() when hasUI=true need hasUI:false so they
899
+ // use their text fallback instead (pi-web can't render custom()).
900
+ // magic-context /ctx-status -> appendEntry (custom entry -> modal);
901
+ // aft-pi /aft-status (its only command) -> ui.notify(text).
902
+ // Everything else gets hasUI:true so notify-based output works.
890
903
  const cmdPath = (cmd.sourceInfo && cmd.sourceInfo.path) || "";
891
- const customOnly = /pi-magic-context|magic-context/.test(cmdPath);
904
+ const customOnly = /pi-magic-context|magic-context|aft-pi/.test(cmdPath);
905
+ // While a command runs, tag extension notify() broadcasts with the
906
+ // command name as title so the browser renders them as a titled,
907
+ // Markdown modal (e.g. /ctx-status, /aft-status) - the command's status
908
+ // report is a dialog, not a transient stream line. Restores the design
909
+ // in 68b56f1 (recorded in pi-web-design.md §14.9); 41112f5 had dropped
910
+ // the wrapping, so command notifies fell back to persistent status
911
+ // lines while custom-entry commands (/ctx-status) kept the modal.
912
+ const ui = this.uiContext;
913
+ const originalSink = ui.sink;
892
914
  try {
893
- // No sink wrapping: notify() broadcasts go through as-is (no title),
894
- // so the frontend renders them as persistent status lines - the same
895
- // as TUI's notify -> showStatus (a dim line appended to the chat),
896
- // whether or not they come from a command. Command *results* (custom
897
- // entries) still become titled modals via the captured entries below.
915
+ ui.sink = (obj) => {
916
+ const req = obj;
917
+ if (req && req.method === "notify") {
918
+ originalSink({ ...obj, title: `/${name}` });
919
+ }
920
+ else {
921
+ originalSink(obj);
922
+ }
923
+ };
898
924
  await cmd.handler(args, this.buildCommandContext(customOnly ? false : true));
899
925
  }
900
926
  finally {
927
+ ui.sink = originalSink;
901
928
  unsubscribe();
902
929
  }
903
930
  // Show command output as a modal (TUI-like). The session entry remains
@@ -375,6 +375,9 @@ class PiWebClient {
375
375
  case 'ext_status':
376
376
  this.applyExtStatusSnapshot(data.data);
377
377
  break;
378
+ case 'dialog_dismissed':
379
+ this.dismissExtensionDialog(data);
380
+ break;
378
381
  case 'extension_ui_request':
379
382
  this.handleExtensionUIRequest(data);
380
383
  break;
@@ -1996,6 +1999,21 @@ class PiWebClient {
1996
1999
  });
1997
2000
  }
1998
2001
 
2002
+ /** Server-side dismissal (timeout / abort / session switch): the dialog was
2003
+ * already settled server-side, so close the modal and say why - do NOT send
2004
+ * an extension_ui_response (there is nothing left to answer). */
2005
+ dismissExtensionDialog({ id, reason } = {}) {
2006
+ if (!id || !this.currentExtRequest || this.currentExtRequest.id !== id) return;
2007
+ const title = this.currentExtRequest.title || 'Dialog';
2008
+ const why =
2009
+ reason === 'aborted' ? 'operation aborted'
2010
+ : reason === 'session-switch' ? 'session switched'
2011
+ : 'no answer received in time';
2012
+ this.currentExtRequest = null; // closeModal() must not answer for us
2013
+ this.closeModal();
2014
+ this.appendNotifyLine(`${title} — auto-dismissed (${why})`, 'warning');
2015
+ }
2016
+
1999
2017
  openExtensionNotify(req) {
2000
2018
  // Command output notifications (title starts with "/", e.g. /ctx-status) keep
2001
2019
  // the modal. Other extension notifies are appended to the chat stream as
@@ -2,6 +2,22 @@ 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
+ * Default dialog timeouts (pi-web specific guard).
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:
11
+ *
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.
16
+ *
17
+ * Extensions that pass their own `timeout` (ms) keep it.
18
+ */
19
+ const DIALOG_TIMEOUT_MS = 10 * 60_000;
20
+ const DIALOG_TIMEOUT_NO_CLIENT_MS = 60_000;
5
21
  /**
6
22
  * Load a Pi theme (dark.json/light.json colors) so extensions calling
7
23
  * ui.theme.fg("accent", text) / .bg(...) get REAL ANSI escapes — identical
@@ -69,12 +85,18 @@ export class WebUIContext {
69
85
  /** Latest setWidget lines per key (persistent widgets, e.g. TodoOverlay):
70
86
  * replayed to late-connecting browsers like setStatus snapshots. */
71
87
  widgetMap = new Map();
88
+ hasClients;
89
+ dialogTimeoutMs;
90
+ dialogTimeoutNoClientMs;
72
91
  /** Current widget snapshot (key -> lines) for new connections. */
73
92
  getWidgetSnapshot() {
74
93
  return Object.fromEntries(this.widgetMap);
75
94
  }
76
- constructor(sink) {
95
+ constructor(sink, options = {}) {
77
96
  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;
78
100
  // Pi's ExtensionRunner wraps the ui context with `{...ui}` (a shallow
79
101
  // spread) when building the extension ctx - class prototype members
80
102
  // (methods AND the theme getter) would be LOST by that spread (only own
@@ -106,23 +128,26 @@ export class WebUIContext {
106
128
  }
107
129
  /** Drop state tied to the previous session (dialogs + status snapshots). */
108
130
  clearSessionState() {
109
- for (const pending of this.pending.values()) {
110
- if (pending.timer)
111
- clearTimeout(pending.timer);
131
+ // Copy first: settling deletes from the map while we iterate.
132
+ for (const [id, pending] of [...this.pending.entries()]) {
112
133
  pending.resolve(undefined);
134
+ // The browser may still show the dialog; close it there too.
135
+ this.dismissDialog(id, "session-switch");
113
136
  }
114
137
  this.pending.clear();
115
138
  this.statusMap.clear();
116
139
  this.widgetMap.clear();
117
140
  }
141
+ /** Pending dialogs (wire payloads) for replay to a newly connected browser. */
142
+ getPendingDialogs() {
143
+ return [...this.pending.values()].map((entry) => entry.request);
144
+ }
118
145
  /** Handle a browser `extension_ui_response` message. */
119
146
  respond(id, response) {
120
147
  const pending = this.pending.get(id);
121
148
  if (!pending)
122
149
  return false;
123
- this.pending.delete(id);
124
- if (pending.timer)
125
- clearTimeout(pending.timer);
150
+ // Cleanup happens inside settle() (timer / abort listener / map entry).
126
151
  if (response.cancelled) {
127
152
  pending.resolve(undefined);
128
153
  }
@@ -134,19 +159,56 @@ export class WebUIContext {
134
159
  }
135
160
  return true;
136
161
  }
137
- createDialog(request, defaultValue, parse) {
162
+ /** Tell the browser to close a dialog that already settled server-side. */
163
+ dismissDialog(id, reason) {
164
+ this.sink({ type: "dialog_dismissed", id, reason });
165
+ }
166
+ createDialog(request, opts, defaultValue, parse) {
138
167
  const id = crypto.randomUUID();
168
+ // Same as Pi's RPC/TUI: an already-aborted dialog settles immediately.
169
+ if (opts?.signal?.aborted)
170
+ return Promise.resolve(defaultValue);
139
171
  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;
172
+ let settled = false;
173
+ const cleanup = () => {
174
+ const entry = this.pending.get(id);
175
+ if (!entry)
176
+ return;
177
+ if (entry.timer)
178
+ clearTimeout(entry.timer);
179
+ if (entry.signal && entry.onAbort)
180
+ entry.signal.removeEventListener("abort", entry.onAbort);
181
+ this.pending.delete(id);
182
+ };
183
+ const settle = (value, useDefault) => {
184
+ if (settled)
185
+ return;
186
+ settled = true;
187
+ cleanup();
188
+ resolve(useDefault ? defaultValue : parse(value));
189
+ };
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
+ const onAbort = () => {
202
+ settle(undefined, true);
203
+ this.dismissDialog(id, "aborted");
204
+ };
205
+ opts?.signal?.addEventListener("abort", onAbort, { once: true });
147
206
  this.pending.set(id, {
148
- resolve: (value) => resolve(parse(value)),
207
+ resolve: (value) => settle(value, false),
208
+ request: { ...request, id },
149
209
  timer,
210
+ signal: opts?.signal,
211
+ onAbort,
150
212
  });
151
213
  this.sink({ type: "extension_ui_request", id, ...request });
152
214
  });
@@ -155,16 +217,16 @@ export class WebUIContext {
155
217
  // Dialogs (browser resolves via extension_ui_response)
156
218
  // ------------------------------------------------------------------
157
219
  select(title, options, opts) {
158
- return this.createDialog({ method: "select", title, options, timeout: opts?.timeout }, undefined, (v) => (typeof v === "string" ? v : undefined));
220
+ return this.createDialog({ method: "select", title, options, timeout: opts?.timeout }, opts, undefined, (v) => (typeof v === "string" ? v : undefined));
159
221
  }
160
222
  confirm(title, message, opts) {
161
- return this.createDialog({ method: "confirm", title, message, timeout: opts?.timeout }, false, (v) => v === true);
223
+ return this.createDialog({ method: "confirm", title, message, timeout: opts?.timeout }, opts, false, (v) => v === true);
162
224
  }
163
225
  input(title, placeholder, opts) {
164
- return this.createDialog({ method: "input", title, placeholder, timeout: opts?.timeout }, undefined, (v) => (typeof v === "string" ? v : undefined));
226
+ return this.createDialog({ method: "input", title, placeholder, timeout: opts?.timeout }, opts, undefined, (v) => (typeof v === "string" ? v : undefined));
165
227
  }
166
228
  editor(title, prefill) {
167
- return this.createDialog({ method: "editor", title, prefill }, undefined, (v) => typeof v === "string" ? v : undefined);
229
+ return this.createDialog({ method: "editor", title, prefill }, undefined, undefined, (v) => typeof v === "string" ? v : undefined);
168
230
  }
169
231
  // ------------------------------------------------------------------
170
232
  // 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.13",
3
+ "version": "0.5.15",
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": {