pi-sdk-web 0.2.4 → 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 +31 -14
- package/dist/server.js +95 -14
- package/dist/static/app.js +44 -0
- 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
|
// ------------------------------------------------------------------
|
|
@@ -1205,6 +1222,8 @@ class PiWebClient {
|
|
|
1205
1222
|
this.send({ type: 'export', path: path });
|
|
1206
1223
|
} else if (cmd.action === 'session') {
|
|
1207
1224
|
this.send({ type: 'session' });
|
|
1225
|
+
} else if (cmd.action === 'resume') {
|
|
1226
|
+
this.openResumePicker();
|
|
1208
1227
|
} else if (cmd.action === 'name') {
|
|
1209
1228
|
const newName = window.prompt('Set session display name:', '');
|
|
1210
1229
|
if (newName && newName.trim()) {
|
|
@@ -1531,6 +1550,31 @@ class PiWebClient {
|
|
|
1531
1550
|
});
|
|
1532
1551
|
}
|
|
1533
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
|
+
|
|
1534
1578
|
// ------------------------------------------------------------------
|
|
1535
1579
|
// Scoped models picker (multi-select: models available for cycling)
|
|
1536
1580
|
// ------------------------------------------------------------------
|
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);
|