pi-sdk-web 0.2.3 → 0.3.0

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
  // ------------------------------------------------------------------
@@ -487,10 +526,110 @@ export class PiWebServer {
487
526
  await this.executeCommand(name, args);
488
527
  break;
489
528
  }
529
+ case "session":
530
+ // Read-only session info (TUI /session equivalent)
531
+ this.broadcastSessionInfo();
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
+ }
490
552
  default:
491
553
  throw new Error(`Unsupported command: ${cmdType}`);
492
554
  }
493
555
  }
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() {
581
+ try {
582
+ const stats = this.session.getSessionStats();
583
+ const sm = this.session.sessionManager;
584
+ const entries = sm.getEntries();
585
+ const model = this.session.model;
586
+ const lines = [];
587
+ lines.push("## Session Info", "");
588
+ const name = sm.getSessionName();
589
+ if (name)
590
+ lines.push(`**Name:** ${name}`);
591
+ lines.push(`**ID:** ${this.session.sessionId}`);
592
+ if (stats.sessionFile)
593
+ lines.push(`**File:** ${stats.sessionFile}`);
594
+ if (model)
595
+ lines.push(`**Model:** ${model.provider}/${model.id}`);
596
+ lines.push(`**Thinking:** ${this.session.thinkingLevel}`, "");
597
+ lines.push("### Messages");
598
+ lines.push(`- User: ${stats.userMessages}`);
599
+ lines.push(`- Assistant: ${stats.assistantMessages}`);
600
+ lines.push(`- Tool calls: ${stats.toolCalls}`);
601
+ lines.push(`- Tool results: ${stats.toolResults}`);
602
+ lines.push(`- Total: ${stats.totalMessages}`);
603
+ lines.push(`- Entries: ${entries.length}`, "");
604
+ const t = stats.tokens;
605
+ if (t) {
606
+ lines.push("### Tokens");
607
+ lines.push(`- Input: ${t.input}`);
608
+ lines.push(`- Output: ${t.output}`);
609
+ lines.push(`- Cache read: ${t.cacheRead}`);
610
+ lines.push(`- Cache write: ${t.cacheWrite}`);
611
+ lines.push(`- Total: ${t.total}`, "");
612
+ }
613
+ lines.push("### Cost");
614
+ lines.push(`$${stats.cost.toFixed(4)}`);
615
+ const cu = stats.contextUsage;
616
+ if (cu?.contextWindow) {
617
+ lines.push("", "### Context");
618
+ lines.push(`- ${cu.percent}% / ${cu.contextWindow} tokens`);
619
+ }
620
+ this.broadcast({
621
+ type: "extension_ui_request",
622
+ id: crypto.randomUUID(),
623
+ method: "notify",
624
+ title: "/session",
625
+ message: lines.join("\n"),
626
+ notifyType: "info",
627
+ });
628
+ }
629
+ catch {
630
+ // session info unavailable - skip
631
+ }
632
+ }
494
633
  /**
495
634
  * Execute an extension slash command (e.g. /ctx-status) by invoking its
496
635
  * registered handler with a command context.
@@ -8,6 +8,8 @@ const BUILTIN_COMMANDS = [
8
8
  { name: 'compact', description: 'Manually compact the session context', builtin: true, action: 'compact' },
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
+ { name: 'session', description: 'Show session information', builtin: true, action: 'session' },
12
+ { name: 'resume', description: 'Switch to another session', builtin: true, action: 'resume' },
11
13
  { name: 'name', description: 'Set session display name', builtin: true, action: 'name' },
12
14
  { name: 'login', description: 'Configure provider authentication (not supported in web)', builtin: true, unsupported: true },
13
15
  { name: 'logout', description: 'Remove provider authentication (not supported in web)', builtin: true, unsupported: true },
@@ -164,6 +166,9 @@ class PiWebClient {
164
166
  case 'scoped_models':
165
167
  this.handleScopedModels(data.data);
166
168
  break;
169
+ case 'sessions':
170
+ this.handleSessions(data.data);
171
+ break;
167
172
  case 'error':
168
173
  this.appendError(data.error);
169
174
  break;
@@ -589,6 +594,19 @@ class PiWebClient {
589
594
  this.toolTimers.clear();
590
595
  this.toolEls.clear();
591
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';
592
610
  }
593
611
 
594
612
  // ------------------------------------------------------------------
@@ -1202,6 +1220,10 @@ class PiWebClient {
1202
1220
  } else if (cmd.action === 'export') {
1203
1221
  const path = (args || '').trim();
1204
1222
  this.send({ type: 'export', path: path });
1223
+ } else if (cmd.action === 'session') {
1224
+ this.send({ type: 'session' });
1225
+ } else if (cmd.action === 'resume') {
1226
+ this.openResumePicker();
1205
1227
  } else if (cmd.action === 'name') {
1206
1228
  const newName = window.prompt('Set session display name:', '');
1207
1229
  if (newName && newName.trim()) {
@@ -1469,12 +1491,30 @@ class PiWebClient {
1469
1491
  }
1470
1492
 
1471
1493
  openExtensionNotify(req) {
1472
- this.openModal(req.title || 'Notification', 'extension-notify');
1473
- this.currentExtRequest = req;
1474
- this.modalSearch.style.display = 'none';
1475
- // Render as markdown (sanitized) so command outputs (/ctx-status etc.) look right
1476
- this.modalList.innerHTML = `<div class="modal-message body-text">${this.renderMarkdown(req.message || '')}</div>`;
1477
- // Close button in modal footer is enough
1494
+ // Command output notifications (title starts with "/", e.g. /ctx-status) keep
1495
+ // the modal. Other extension notifies are lightweight toasts (TUI shows
1496
+ // notify as a transient status message, not a dialog).
1497
+ if (req.title && String(req.title).startsWith('/')) {
1498
+ this.openModal(req.title || 'Notification', 'extension-notify');
1499
+ this.currentExtRequest = req;
1500
+ this.modalSearch.style.display = 'none';
1501
+ this.modalList.innerHTML = `<div class="modal-message body-text">${this.renderMarkdown(req.message || '')}</div>`;
1502
+ return;
1503
+ }
1504
+ this.showToast(req.message || '', req.notifyType);
1505
+ }
1506
+
1507
+ showToast(message, type) {
1508
+ let container = document.getElementById('toast-container');
1509
+ if (!container) return;
1510
+ const toast = document.createElement('div');
1511
+ toast.className = 'toast' + (type ? ` toast-${type}` : '');
1512
+ toast.textContent = message;
1513
+ container.appendChild(toast);
1514
+ setTimeout(() => {
1515
+ toast.classList.add('toast-hide');
1516
+ setTimeout(() => toast.remove(), 300);
1517
+ }, type === 'error' ? 8000 : 5000);
1478
1518
  }
1479
1519
 
1480
1520
  handleModels(models) {
@@ -1510,6 +1550,31 @@ class PiWebClient {
1510
1550
  });
1511
1551
  }
1512
1552
 
1553
+ // ------------------------------------------------------------------
1554
+ // Resume picker (switch to another session)
1555
+ // ------------------------------------------------------------------
1556
+
1557
+ openResumePicker() {
1558
+ this.openModal('Resume Session', 'resume');
1559
+ this.modalList.innerHTML = '<div class="modal-message">Loading sessions...</div>';
1560
+ this.send({ type: 'get_sessions' });
1561
+ }
1562
+
1563
+ handleSessions(data) {
1564
+ if (this.modalMode !== 'resume') return;
1565
+ const sessions = data || [];
1566
+ if (sessions.length === 0) {
1567
+ this.modalList.innerHTML = '<div class="modal-message">No other sessions available</div>';
1568
+ return;
1569
+ }
1570
+ this.renderModalItems(
1571
+ sessions.map((s) => ({ name: s.name || s.id, desc: s.cwd, value: s.path })),
1572
+ (item) => {
1573
+ this.send({ type: 'resume', path: item.value });
1574
+ },
1575
+ );
1576
+ }
1577
+
1513
1578
  // ------------------------------------------------------------------
1514
1579
  // Scoped models picker (multi-select: models available for cycling)
1515
1580
  // ------------------------------------------------------------------
@@ -1614,11 +1679,24 @@ class PiWebClient {
1614
1679
  ...((this.lastState && this.lastState.commands) || []),
1615
1680
  ...BUILTIN_COMMANDS,
1616
1681
  ];
1617
- const filtered = commands.filter((c) => {
1618
- const name = (c.name || '').replace(/^skill:/, '').toLowerCase();
1619
- const desc = (c.description || c.source || '').toLowerCase();
1620
- return name.includes(query) || desc.includes(query);
1621
- });
1682
+ const filtered = commands
1683
+ .filter((c) => {
1684
+ const name = (c.name || '').replace(/^skill:/, '').toLowerCase();
1685
+ const desc = (c.description || c.source || '').toLowerCase();
1686
+ return name.includes(query) || desc.includes(query);
1687
+ })
1688
+ .sort((a, b) => {
1689
+ // Exact-name match first, then name-prefix, then name-contains,
1690
+ // then description-contains (avoids unrelated commands flooding the list)
1691
+ const score = (c) => {
1692
+ const name = (c.name || '').replace(/^skill:/, '').toLowerCase();
1693
+ if (name === query) return 0;
1694
+ if (name.startsWith(query)) return 1;
1695
+ if (name.includes(query)) return 2;
1696
+ return 3;
1697
+ };
1698
+ return score(a) - score(b);
1699
+ });
1622
1700
 
1623
1701
  if (filtered.length === 0) {
1624
1702
  this.hideCommandMenu();
@@ -53,6 +53,7 @@
53
53
  <div id="modal-close">Close (Esc)</div>
54
54
  </div>
55
55
  </div>
56
+ <div id="toast-container"></div>
56
57
  <script src="vendor/marked.min.js"></script>
57
58
  <script src="app.js"></script>
58
59
  </body>
@@ -734,6 +734,44 @@ body {
734
734
  color: var(--text);
735
735
  }
736
736
 
737
+ /* Lightweight extension notifications (TUI shows notify as transient status) */
738
+ #toast-container {
739
+ position: fixed;
740
+ top: 12px;
741
+ right: 12px;
742
+ z-index: 200;
743
+ display: flex;
744
+ flex-direction: column;
745
+ gap: 8px;
746
+ max-width: 420px;
747
+ }
748
+
749
+ .toast {
750
+ background: var(--modal-bg);
751
+ border: 1px solid var(--border);
752
+ border-left: 3px solid var(--accent);
753
+ border-radius: 6px;
754
+ padding: 8px 12px;
755
+ color: var(--text);
756
+ font-size: 13px;
757
+ line-height: 1.5;
758
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
759
+ opacity: 1;
760
+ transition: opacity 0.3s;
761
+ }
762
+
763
+ .toast-warning {
764
+ border-left-color: var(--warning);
765
+ }
766
+
767
+ .toast-error {
768
+ border-left-color: var(--error);
769
+ }
770
+
771
+ .toast-hide {
772
+ opacity: 0;
773
+ }
774
+
737
775
  .modal-message {
738
776
  color: var(--text);
739
777
  font-size: 13px;
@@ -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.3",
3
+ "version": "0.3.0",
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": {