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 +31 -14
- package/dist/server.js +95 -14
- package/dist/static/app.js +57 -6
- package/dist/ui-context.js +10 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
63
|
-
resourceLoaderOptions: { extensionFactories: await loadBuiltinExtensions() },
|
|
87
|
+
sessionManager,
|
|
64
88
|
});
|
|
65
|
-
|
|
66
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
/**
|
|
499
|
-
|
|
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;
|
package/dist/static/app.js
CHANGED
|
@@ -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
|
-
//
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/ui-context.js
CHANGED
|
@@ -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);
|