pi-sdk-web 0.2.4 → 0.3.1

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 CHANGED
@@ -7,7 +7,7 @@
7
7
  * pi-web list List all sessions (name, id, cwd), newest first
8
8
  * pi-web help Show this help
9
9
  */
10
- import { SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, resolveModelScopeWithDiagnostics, } from "@earendil-works/pi-coding-agent";
10
+ import { SettingsManager, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, resolveModelScopeWithDiagnostics, } from "@earendil-works/pi-coding-agent";
11
11
  import { findSessionByName, listSessions, loadBuiltinExtensions } from "./session.js";
12
12
  import { PiWebServer } from "./server.js";
13
13
  const DEFAULT_PORT = 4080;
@@ -56,21 +56,38 @@ async function cmdResume(name, port) {
56
56
  // the same modelRuntime used below, so scopedModels resolution sees them.
57
57
  const agentDir = getAgentDir();
58
58
  const settingsManager = SettingsManager.create(sessionManager.getCwd(), agentDir);
59
- const services = await createAgentSessionServices({
59
+ // Runtime factory: re-invoked by AgentSessionRuntime whenever the session is
60
+ // replaced (e.g. /resume switches to another session with a different cwd).
61
+ const createRuntime = async ({ cwd, agentDir: dir, sessionManager: sm, sessionStartEvent }) => {
62
+ const settings = SettingsManager.create(cwd, dir);
63
+ const services = await createAgentSessionServices({
64
+ cwd,
65
+ agentDir: dir,
66
+ settingsManager: settings,
67
+ resourceLoaderOptions: { extensionFactories: await loadBuiltinExtensions() },
68
+ });
69
+ // Resolve enabledModels (settings) into scopedModels, matching Pi's CLI
70
+ const enabledModels = settings.getEnabledModels();
71
+ const scopedModels = enabledModels && enabledModels.length > 0
72
+ ? (await resolveModelScopeWithDiagnostics(enabledModels, services.modelRuntime, {
73
+ signal: AbortSignal.timeout(15_000),
74
+ })).scopedModels
75
+ : [];
76
+ const created = await createAgentSessionFromServices({
77
+ services,
78
+ sessionManager: sm,
79
+ sessionStartEvent,
80
+ scopedModels,
81
+ });
82
+ return { ...created, services, diagnostics: [] };
83
+ };
84
+ const runtime = await createAgentSessionRuntime(createRuntime, {
60
85
  cwd: sessionManager.getCwd(),
61
86
  agentDir,
62
- settingsManager,
63
- resourceLoaderOptions: { extensionFactories: await loadBuiltinExtensions() },
87
+ sessionManager,
64
88
  });
65
- // Resolve enabledModels (settings) into scopedModels, matching Pi's CLI
66
- const enabledModels = settingsManager.getEnabledModels();
67
- const scopedModels = enabledModels && enabledModels.length > 0
68
- ? (await resolveModelScopeWithDiagnostics(enabledModels, services.modelRuntime, {
69
- signal: AbortSignal.timeout(15_000),
70
- })).scopedModels
71
- : [];
72
- const { session } = await createAgentSessionFromServices({ services, sessionManager, scopedModels });
73
- const server = new PiWebServer(session, { port });
89
+ const { session } = runtime;
90
+ const server = new PiWebServer(runtime, { port });
74
91
  await server.start();
75
92
  console.log(`server at http://127.0.0.1:${port}/ (session: ${info.name ?? info.id})`);
76
93
  let shuttingDown = false;
@@ -86,7 +103,7 @@ async function cmdResume(name, port) {
86
103
  // ignore teardown errors - we still need to exit
87
104
  }
88
105
  try {
89
- session.dispose();
106
+ runtime.dispose();
90
107
  }
91
108
  catch {
92
109
  // ignore
package/dist/server.js CHANGED
@@ -16,6 +16,7 @@ import { dirname, join, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { ModelRegistry, VERSION, } from "@earendil-works/pi-coding-agent";
18
18
  import { WebSocket, WebSocketServer } from "ws";
19
+ import { listSessions } from "./session.js";
19
20
  import { WebUIContext } from "./ui-context.js";
20
21
  const DEFAULT_PORT = 4080;
21
22
  // Static frontend: prefer the in-package copy (built by `npm run build` for
@@ -62,7 +63,7 @@ const MIME = {
62
63
  ".ico": "image/x-icon",
63
64
  };
64
65
  export class PiWebServer {
65
- session;
66
+ runtime;
66
67
  port;
67
68
  staticDir;
68
69
  uiContext;
@@ -70,8 +71,12 @@ export class PiWebServer {
70
71
  wsServer = null;
71
72
  clients = new Set();
72
73
  unsubscribe = null;
73
- constructor(session, options = {}) {
74
- this.session = session;
74
+ /** Current session (may change on /resume session switching). */
75
+ get session() {
76
+ return this.runtime.session;
77
+ }
78
+ constructor(runtime, options = {}) {
79
+ this.runtime = runtime;
75
80
  this.port = options.port ?? DEFAULT_PORT;
76
81
  this.staticDir = options.staticDir ?? STATIC_DIR;
77
82
  this.uiContext = new WebUIContext((obj) => this.broadcast(obj));
@@ -80,13 +85,10 @@ export class PiWebServer {
80
85
  // Lifecycle
81
86
  // ------------------------------------------------------------------
82
87
  async start() {
83
- // Bind extensions with the web UI context (replaces the TUI/RPC context)
84
- await this.session.bindExtensions({
85
- uiContext: this.uiContext,
86
- // "rpc" is the closest ExtensionMode: dialog-capable UI (hasUI=true),
87
- // but not terminal-only UI
88
- mode: "rpc",
89
- });
88
+ // Rebind hook: invoked by the runtime whenever the session is replaced
89
+ // (/resume switches to another session).
90
+ this.runtime.setRebindSession(() => this.bindCurrentSession(true));
91
+ await this.bindCurrentSession(false);
90
92
  this.httpServer = createServer((req, res) => this.handleHttp(req, res));
91
93
  // Some components (ws internals, MCP-style extensions) accumulate 'close'
92
94
  // listeners on the server; raise the limit to avoid MaxListenersExceededWarning
@@ -98,13 +100,46 @@ export class PiWebServer {
98
100
  this.httpServer.once("error", reject);
99
101
  this.httpServer.listen(this.port, "127.0.0.1", () => resolvePromise());
100
102
  });
101
- // Forward session events to all browser clients
102
- this.unsubscribe = this.session.subscribe((event) => {
103
+ }
104
+ /**
105
+ * Bind extensions + subscribe events for the current session, and sync the
106
+ * process cwd. On session replacement (`afterSwitch`), also tell browsers to
107
+ * reload their view (new state + history) and switch the process cwd.
108
+ */
109
+ async bindCurrentSession(afterSwitch) {
110
+ const session = this.runtime.session;
111
+ // Unsubscribe from the previous session first (also avoids stale events
112
+ // during teardown/dispose of the old session).
113
+ this.unsubscribe?.();
114
+ this.unsubscribe = null;
115
+ await session.bindExtensions({
116
+ uiContext: this.uiContext,
117
+ // "rpc" is the closest ExtensionMode: dialog-capable UI (hasUI=true),
118
+ // but not terminal-only UI
119
+ mode: "rpc",
120
+ });
121
+ this.unsubscribe = session.subscribe((event) => {
103
122
  this.broadcast(event);
104
123
  if (STATS_REFRESH_EVENTS.has(event.type)) {
105
124
  this.broadcastStats();
106
125
  }
107
126
  });
127
+ if (afterSwitch) {
128
+ // Align process cwd with the resumed session (same as startup chdir)
129
+ const cwd = session.sessionManager.getCwd();
130
+ if (cwd) {
131
+ try {
132
+ process.chdir(cwd);
133
+ }
134
+ catch {
135
+ console.error(`Session cwd not found (${cwd}), keeping current directory`);
136
+ }
137
+ }
138
+ // Stale extension dialogs/status from the previous session are dropped
139
+ this.uiContext.clearSessionState();
140
+ this.broadcastState();
141
+ this.broadcastHistory();
142
+ }
108
143
  }
109
144
  async stop() {
110
145
  this.unsubscribe?.();
@@ -172,6 +207,10 @@ export class PiWebServer {
172
207
  // state unavailable - skip
173
208
  }
174
209
  }
210
+ /** Broadcast the current session's history (used after session switching). */
211
+ broadcastHistory() {
212
+ this.broadcast({ type: "history", data: this.buildHistory() });
213
+ }
175
214
  // ------------------------------------------------------------------
176
215
  // HTTP: static files
177
216
  // ------------------------------------------------------------------
@@ -491,12 +530,54 @@ export class PiWebServer {
491
530
  // Read-only session info (TUI /session equivalent)
492
531
  this.broadcastSessionInfo();
493
532
  break;
533
+ case "get_sessions": {
534
+ // All sessions except the current one (for /resume)
535
+ const currentFile = this.session.sessionFile;
536
+ const all = await listSessions();
537
+ this.broadcast({
538
+ type: "sessions",
539
+ data: all
540
+ .filter((s) => s.path !== currentFile)
541
+ .map((s) => ({ name: s.name ?? "", id: s.id, cwd: s.cwd, path: s.path })),
542
+ });
543
+ break;
544
+ }
545
+ case "resume": {
546
+ const path = typeof data.path === "string" && data.path ? data.path : "";
547
+ if (!path)
548
+ throw new Error("Missing 'path'");
549
+ await this.resumeSession(path);
550
+ break;
551
+ }
494
552
  default:
495
553
  throw new Error(`Unsupported command: ${cmdType}`);
496
554
  }
497
555
  }
498
- /** TUI /session equivalent: show session info as a modal (read-only). */
499
- broadcastSessionInfo() {
556
+ /**
557
+ * Resume (switch to) another session file. The runtime tears down the current
558
+ * session, creates the new one and invokes our rebind callback, which rebinds
559
+ * extensions, resubscribes, switches cwd and broadcasts state/history.
560
+ */
561
+ async resumeSession(path) {
562
+ // Unsubscribe from the current session before teardown so stale events from
563
+ // the old session are not broadcast during disposal.
564
+ this.unsubscribe?.();
565
+ this.unsubscribe = null;
566
+ try {
567
+ const result = await this.runtime.switchSession(path);
568
+ if (result.cancelled)
569
+ return;
570
+ }
571
+ catch (err) {
572
+ // Rebind hook already ran on failure? No: on error the old session may be
573
+ // gone - resubscribe defensively to keep the server usable.
574
+ await this.bindCurrentSession(false);
575
+ throw err;
576
+ }
577
+ // On success the rebind hook (bindCurrentSession(true)) already ran inside
578
+ // switchSession; nothing more to do here.
579
+ }
580
+ /** TUI /session equivalent: show session info as a modal (read-only). */ broadcastSessionInfo() {
500
581
  try {
501
582
  const stats = this.session.getSessionStats();
502
583
  const sm = this.session.sessionManager;
@@ -9,6 +9,7 @@ const BUILTIN_COMMANDS = [
9
9
  { name: 'reload', description: 'Reload session resources and extensions', builtin: true, action: 'reload' },
10
10
  { name: 'export', description: 'Export session to HTML (or .jsonl)', builtin: true, action: 'export' },
11
11
  { name: 'session', description: 'Show session information', builtin: true, action: 'session' },
12
+ { name: 'resume', description: 'Switch to another session', builtin: true, action: 'resume' },
12
13
  { name: 'name', description: 'Set session display name', builtin: true, action: 'name' },
13
14
  { name: 'login', description: 'Configure provider authentication (not supported in web)', builtin: true, unsupported: true },
14
15
  { name: 'logout', description: 'Remove provider authentication (not supported in web)', builtin: true, unsupported: true },
@@ -165,6 +166,9 @@ class PiWebClient {
165
166
  case 'scoped_models':
166
167
  this.handleScopedModels(data.data);
167
168
  break;
169
+ case 'sessions':
170
+ this.handleSessions(data.data);
171
+ break;
168
172
  case 'error':
169
173
  this.appendError(data.error);
170
174
  break;
@@ -590,6 +594,19 @@ class PiWebClient {
590
594
  this.toolTimers.clear();
591
595
  this.toolEls.clear();
592
596
  this.streaming = { active: false, el: null, role: 'assistant' };
597
+ // Drop stale UI state from the previous session (on /resume reload)
598
+ const widgets = document.getElementById('widgets');
599
+ if (widgets) {
600
+ widgets.innerHTML = '';
601
+ widgets.style.display = 'none';
602
+ }
603
+ this.extStatus = {};
604
+ const extStatusEl = document.getElementById('ext-status');
605
+ if (extStatusEl) extStatusEl.style.display = 'none';
606
+ const pendingEl = document.getElementById('pending');
607
+ if (pendingEl) pendingEl.style.display = 'none';
608
+ const statusEl = document.getElementById('status');
609
+ if (statusEl) statusEl.style.display = 'none';
593
610
  }
594
611
 
595
612
  // ------------------------------------------------------------------
@@ -1169,10 +1186,18 @@ class PiWebClient {
1169
1186
  this.send({ type: 'bash', command: command });
1170
1187
  }
1171
1188
  } else if (text.startsWith('/')) {
1172
- // Slash command: builtins are handled locally, everything else is
1189
+ // Skill commands (/skill:name args) go through prompt expansion in Pi -
1190
+ // sent as-is (with the skill: prefix) so session.prompt expands them.
1191
+ if (text.startsWith('/skill:')) {
1192
+ this.send({ type: 'prompt', message: text });
1193
+ this.inputEl.value = '';
1194
+ this.hideCommandMenu();
1195
+ return;
1196
+ }
1197
+ // Other slash commands: builtins are handled locally, everything else is
1173
1198
  // executed as an extension command by the server.
1174
1199
  const m = text.slice(1).match(/^(\S+)\s*(.*)$/);
1175
- const name = (m ? m[1] : text.slice(1)).replace(/^skill:/, '');
1200
+ const name = m ? m[1] : text.slice(1);
1176
1201
  const args = m ? m[2] : '';
1177
1202
  const builtin = BUILTIN_COMMANDS.find((c) => c.name === name);
1178
1203
  if (builtin && builtin.action && !builtin.unsupported) {
@@ -1205,6 +1230,8 @@ class PiWebClient {
1205
1230
  this.send({ type: 'export', path: path });
1206
1231
  } else if (cmd.action === 'session') {
1207
1232
  this.send({ type: 'session' });
1233
+ } else if (cmd.action === 'resume') {
1234
+ this.openResumePicker();
1208
1235
  } else if (cmd.action === 'name') {
1209
1236
  const newName = window.prompt('Set session display name:', '');
1210
1237
  if (newName && newName.trim()) {
@@ -1531,6 +1558,31 @@ class PiWebClient {
1531
1558
  });
1532
1559
  }
1533
1560
 
1561
+ // ------------------------------------------------------------------
1562
+ // Resume picker (switch to another session)
1563
+ // ------------------------------------------------------------------
1564
+
1565
+ openResumePicker() {
1566
+ this.openModal('Resume Session', 'resume');
1567
+ this.modalList.innerHTML = '<div class="modal-message">Loading sessions...</div>';
1568
+ this.send({ type: 'get_sessions' });
1569
+ }
1570
+
1571
+ handleSessions(data) {
1572
+ if (this.modalMode !== 'resume') return;
1573
+ const sessions = data || [];
1574
+ if (sessions.length === 0) {
1575
+ this.modalList.innerHTML = '<div class="modal-message">No other sessions available</div>';
1576
+ return;
1577
+ }
1578
+ this.renderModalItems(
1579
+ sessions.map((s) => ({ name: s.name || s.id, desc: s.cwd, value: s.path })),
1580
+ (item) => {
1581
+ this.send({ type: 'resume', path: item.value });
1582
+ },
1583
+ );
1584
+ }
1585
+
1534
1586
  // ------------------------------------------------------------------
1535
1587
  // Scoped models picker (multi-select: models available for cycling)
1536
1588
  // ------------------------------------------------------------------
@@ -1663,7 +1715,7 @@ class PiWebClient {
1663
1715
  .map(
1664
1716
  (c, i) =>
1665
1717
  `<div class="command-item" data-index="${i}">` +
1666
- `<span class="command-name">/${this.escapeHtml(c.name.replace(/^skill:/, ''))}</span>` +
1718
+ `<span class="command-name">/${this.escapeHtml(c.name)}</span>` +
1667
1719
  `<span class="command-desc">${this.escapeHtml(c.description || c.source || '')}</span>` +
1668
1720
  (c.unsupported ? `<span class="command-unsupported">not supported</span>` : '') +
1669
1721
  `</div>`,
@@ -1714,9 +1766,8 @@ class PiWebClient {
1714
1766
  return;
1715
1767
  }
1716
1768
 
1717
- // Regular commands: insert into input
1718
- const name = cmd.name.replace(/^skill:/, '');
1719
- this.inputEl.value = '/' + name + ' ';
1769
+ // Regular commands: insert into input (keep skill: prefix - Pi expands it)
1770
+ this.inputEl.value = '/' + cmd.name + ' ';
1720
1771
  this.inputEl.focus();
1721
1772
  this.hideCommandMenu();
1722
1773
  }
@@ -30,6 +30,16 @@ export class WebUIContext {
30
30
  getStatusSnapshot() {
31
31
  return Object.fromEntries(this.statusMap);
32
32
  }
33
+ /** Drop state tied to the previous session (dialogs + status snapshots). */
34
+ clearSessionState() {
35
+ for (const pending of this.pending.values()) {
36
+ if (pending.timer)
37
+ clearTimeout(pending.timer);
38
+ pending.resolve(undefined);
39
+ }
40
+ this.pending.clear();
41
+ this.statusMap.clear();
42
+ }
33
43
  /** Handle a browser `extension_ui_response` message. */
34
44
  respond(id, response) {
35
45
  const pending = this.pending.get(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
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": {