pi-sdk-web 0.1.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 +131 -0
- package/dist/server.js +526 -0
- package/dist/session.js +52 -0
- package/dist/ui-context.js +147 -0
- package/dist/verify-sdk.js +112 -0
- package/package.json +47 -0
- package/static/app.js +1624 -0
- package/static/index.html +54 -0
- package/static/style.css +925 -0
- package/static/vendor/marked.min.js +74 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pi-web - browser Web access for Pi via the Pi SDK.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* pi-web r <name> [--port <port>] Run web mode for a session (default port 4080)
|
|
7
|
+
* pi-web list List all sessions (name, id, cwd), newest first
|
|
8
|
+
* pi-web help Show this help
|
|
9
|
+
*/
|
|
10
|
+
import { SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, resolveModelScopeWithDiagnostics, } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { findSessionByName, listSessions, loadBuiltinExtensions } from "./session.js";
|
|
12
|
+
import { PiWebServer } from "./server.js";
|
|
13
|
+
const DEFAULT_PORT = 4080;
|
|
14
|
+
const DOC = `pi-web - browser Web access for Pi (via Pi SDK)
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
pi-web r <name> [--port <port>] Run web mode for the session (default port ${DEFAULT_PORT})
|
|
18
|
+
pi-web list List all sessions (name, id, cwd), newest first
|
|
19
|
+
pi-web help Show this help
|
|
20
|
+
`;
|
|
21
|
+
function parsePort(value) {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return DEFAULT_PORT;
|
|
24
|
+
if (!/^\d+$/.test(value))
|
|
25
|
+
throw new Error(`Invalid port: ${value} (must be a number)`);
|
|
26
|
+
const port = parseInt(value, 10);
|
|
27
|
+
if (!(1 <= port && port <= 65535))
|
|
28
|
+
throw new Error(`Invalid port: ${port} (must be 1-65535)`);
|
|
29
|
+
return port;
|
|
30
|
+
}
|
|
31
|
+
async function cmdList() {
|
|
32
|
+
const sessions = await listSessions();
|
|
33
|
+
if (sessions.length === 0) {
|
|
34
|
+
console.log("(no sessions)");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const width = Math.max(...sessions.map((s) => (s.name ?? "(unnamed)").length));
|
|
38
|
+
for (const s of sessions) {
|
|
39
|
+
console.log(`${(s.name ?? "(unnamed)").padEnd(width)} ${s.id} ${s.cwd}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function cmdResume(name, port) {
|
|
43
|
+
const { info, sessionManager } = await findSessionByName(name);
|
|
44
|
+
const cwd = sessionManager.getCwd();
|
|
45
|
+
if (cwd) {
|
|
46
|
+
try {
|
|
47
|
+
process.chdir(cwd);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Session cwd no longer exists - keep current directory (same as pii)
|
|
51
|
+
console.error(`Session cwd not found (${cwd}), keeping current directory`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Create services the way Pi's CLI does: extensions (including built-in
|
|
55
|
+
// llama.cpp and packages that register providers like deepinfra) load into
|
|
56
|
+
// the same modelRuntime used below, so scopedModels resolution sees them.
|
|
57
|
+
const agentDir = getAgentDir();
|
|
58
|
+
const settingsManager = SettingsManager.create(sessionManager.getCwd(), agentDir);
|
|
59
|
+
const services = await createAgentSessionServices({
|
|
60
|
+
cwd: sessionManager.getCwd(),
|
|
61
|
+
agentDir,
|
|
62
|
+
settingsManager,
|
|
63
|
+
resourceLoaderOptions: { extensionFactories: await loadBuiltinExtensions() },
|
|
64
|
+
});
|
|
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 });
|
|
74
|
+
await server.start();
|
|
75
|
+
console.log(`server at http://127.0.0.1:${port}/ (session: ${info.name ?? info.id})`);
|
|
76
|
+
const shutdown = async (signal) => {
|
|
77
|
+
console.log(`\n${signal} received, shutting down...`);
|
|
78
|
+
await server.stop();
|
|
79
|
+
try {
|
|
80
|
+
session.dispose();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
process.exit(0);
|
|
86
|
+
};
|
|
87
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
88
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
89
|
+
// Keep the process alive (http server handles this naturally)
|
|
90
|
+
}
|
|
91
|
+
async function main() {
|
|
92
|
+
const args = process.argv.slice(2);
|
|
93
|
+
const cmd = args[0];
|
|
94
|
+
if (!cmd || cmd === "help" || cmd === "-h" || cmd === "--help") {
|
|
95
|
+
console.log(DOC);
|
|
96
|
+
process.exit(cmd ? 0 : 1);
|
|
97
|
+
}
|
|
98
|
+
if (cmd === "list") {
|
|
99
|
+
await cmdList();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (cmd === "r" || cmd === "resume") {
|
|
103
|
+
let name;
|
|
104
|
+
let port;
|
|
105
|
+
for (let i = 1; i < args.length; i++) {
|
|
106
|
+
const arg = args[i];
|
|
107
|
+
if (arg === "--port") {
|
|
108
|
+
port = parsePort(args[i + 1]);
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
else if (arg.startsWith("--port=")) {
|
|
112
|
+
port = parsePort(arg.slice("--port=".length));
|
|
113
|
+
}
|
|
114
|
+
else if (name === undefined) {
|
|
115
|
+
name = arg;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
throw new Error(`unexpected argument: ${arg}\nusage: pi-web r <name> [--port <port>]`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!name)
|
|
122
|
+
throw new Error("usage: pi-web r <name> [--port <port>]");
|
|
123
|
+
await cmdResume(name, port ?? DEFAULT_PORT);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`unknown command: ${cmd}\n${DOC}`);
|
|
127
|
+
}
|
|
128
|
+
main().catch((err) => {
|
|
129
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
130
|
+
process.exit(1);
|
|
131
|
+
});
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-sdk-web server: HTTP static files + WebSocket bridge to a Pi AgentSession.
|
|
3
|
+
*
|
|
4
|
+
* The WebSocket protocol mirrors the existing Python bridge (server.py) so the
|
|
5
|
+
* browser client (static/app.js) works unchanged:
|
|
6
|
+
* - On connect: {type:"state", data:{...}} then {type:"history", data:{entries,leafId}}
|
|
7
|
+
* - Pi events: broadcast verbatim (message_start/update/end, tool_execution_*, ...)
|
|
8
|
+
* - UI requests: extension_ui_request broadcast (resolved via extension_ui_response)
|
|
9
|
+
* - Client msgs: prompt/abort/get_stats/bash/cycle_model/set_model/... -> session methods
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { createServer } from "node:http";
|
|
15
|
+
import { dirname, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { ModelRegistry, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
19
|
+
import { WebUIContext } from "./ui-context.js";
|
|
20
|
+
const DEFAULT_PORT = 4080;
|
|
21
|
+
// Static frontend: prefer the in-package copy (built by `npm run build` for
|
|
22
|
+
// global installs), fall back to the repo-root static/ during development.
|
|
23
|
+
const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const STATIC_DIR = [resolve(PACKAGE_DIR, "static"), resolve(PACKAGE_DIR, "..", "static")].find((p) => existsSync(p)) ??
|
|
25
|
+
resolve(PACKAGE_DIR, "..", "static");
|
|
26
|
+
// Events after which footer stats should be refreshed (TUI does this too)
|
|
27
|
+
const STATS_REFRESH_EVENTS = new Set([
|
|
28
|
+
"agent_settled",
|
|
29
|
+
"turn_end",
|
|
30
|
+
"tool_execution_end",
|
|
31
|
+
"compaction_end",
|
|
32
|
+
"entry_appended",
|
|
33
|
+
"session_info_changed",
|
|
34
|
+
"thinking_level_changed",
|
|
35
|
+
]);
|
|
36
|
+
const MIME = {
|
|
37
|
+
".html": "text/html; charset=utf-8",
|
|
38
|
+
".css": "text/css; charset=utf-8",
|
|
39
|
+
".js": "application/javascript; charset=utf-8",
|
|
40
|
+
".json": "application/json; charset=utf-8",
|
|
41
|
+
".svg": "image/svg+xml",
|
|
42
|
+
".png": "image/png",
|
|
43
|
+
".jpg": "image/jpeg",
|
|
44
|
+
".ico": "image/x-icon",
|
|
45
|
+
};
|
|
46
|
+
export class PiWebServer {
|
|
47
|
+
session;
|
|
48
|
+
port;
|
|
49
|
+
staticDir;
|
|
50
|
+
uiContext;
|
|
51
|
+
httpServer = null;
|
|
52
|
+
wsServer = null;
|
|
53
|
+
clients = new Set();
|
|
54
|
+
unsubscribe = null;
|
|
55
|
+
constructor(session, options = {}) {
|
|
56
|
+
this.session = session;
|
|
57
|
+
this.port = options.port ?? DEFAULT_PORT;
|
|
58
|
+
this.staticDir = options.staticDir ?? STATIC_DIR;
|
|
59
|
+
this.uiContext = new WebUIContext((obj) => this.broadcast(obj));
|
|
60
|
+
}
|
|
61
|
+
// ------------------------------------------------------------------
|
|
62
|
+
// Lifecycle
|
|
63
|
+
// ------------------------------------------------------------------
|
|
64
|
+
async start() {
|
|
65
|
+
// Bind extensions with the web UI context (replaces the TUI/RPC context)
|
|
66
|
+
await this.session.bindExtensions({
|
|
67
|
+
uiContext: this.uiContext,
|
|
68
|
+
// "rpc" is the closest ExtensionMode: dialog-capable UI (hasUI=true),
|
|
69
|
+
// but not terminal-only UI
|
|
70
|
+
mode: "rpc",
|
|
71
|
+
});
|
|
72
|
+
this.httpServer = createServer((req, res) => this.handleHttp(req, res));
|
|
73
|
+
// Some components (ws internals, MCP-style extensions) accumulate 'close'
|
|
74
|
+
// listeners on the server; raise the limit to avoid MaxListenersExceededWarning
|
|
75
|
+
this.httpServer.setMaxListeners(50);
|
|
76
|
+
this.wsServer = new WebSocketServer({ server: this.httpServer, path: "/ws" });
|
|
77
|
+
this.wsServer.setMaxListeners(50);
|
|
78
|
+
this.wsServer.on("connection", (ws) => this.handleConnection(ws));
|
|
79
|
+
await new Promise((resolvePromise, reject) => {
|
|
80
|
+
this.httpServer.once("error", reject);
|
|
81
|
+
this.httpServer.listen(this.port, "127.0.0.1", () => resolvePromise());
|
|
82
|
+
});
|
|
83
|
+
// Forward session events to all browser clients
|
|
84
|
+
this.unsubscribe = this.session.subscribe((event) => {
|
|
85
|
+
this.broadcast(event);
|
|
86
|
+
if (STATS_REFRESH_EVENTS.has(event.type)) {
|
|
87
|
+
this.broadcastStats();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async stop() {
|
|
92
|
+
this.unsubscribe?.();
|
|
93
|
+
this.unsubscribe = null;
|
|
94
|
+
for (const client of this.clients) {
|
|
95
|
+
try {
|
|
96
|
+
client.close();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.clients.clear();
|
|
103
|
+
await new Promise((resolvePromise) => {
|
|
104
|
+
this.wsServer?.close(() => resolvePromise());
|
|
105
|
+
if (!this.wsServer)
|
|
106
|
+
resolvePromise();
|
|
107
|
+
});
|
|
108
|
+
await new Promise((resolvePromise) => {
|
|
109
|
+
this.httpServer?.close(() => resolvePromise());
|
|
110
|
+
if (!this.httpServer)
|
|
111
|
+
resolvePromise();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// ------------------------------------------------------------------
|
|
115
|
+
// Broadcast helpers
|
|
116
|
+
// ------------------------------------------------------------------
|
|
117
|
+
broadcast(obj) {
|
|
118
|
+
if (this.clients.size === 0)
|
|
119
|
+
return;
|
|
120
|
+
let message;
|
|
121
|
+
try {
|
|
122
|
+
message = JSON.stringify(obj);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const client of this.clients) {
|
|
128
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
129
|
+
client.send(message);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
broadcastStats() {
|
|
134
|
+
try {
|
|
135
|
+
const stats = this.session.getSessionStats();
|
|
136
|
+
this.broadcast({ type: "stats", data: stats });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// stats unavailable - skip
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Broadcast the full state (model/thinking/session name) after mutations. */
|
|
143
|
+
broadcastState() {
|
|
144
|
+
try {
|
|
145
|
+
this.broadcast({ type: "state", data: this.buildState() });
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// state unavailable - skip
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ------------------------------------------------------------------
|
|
152
|
+
// HTTP: static files
|
|
153
|
+
// ------------------------------------------------------------------
|
|
154
|
+
async handleHttp(req, res) {
|
|
155
|
+
if (req.method !== "GET") {
|
|
156
|
+
res.writeHead(405).end();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
let path = (req.url ?? "/").split("?")[0];
|
|
160
|
+
if (path === "/")
|
|
161
|
+
path = "/index.html";
|
|
162
|
+
const filePath = resolve(this.staticDir, "." + path);
|
|
163
|
+
if (!filePath.startsWith(resolve(this.staticDir))) {
|
|
164
|
+
res.writeHead(403).end();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const data = await readFile(filePath);
|
|
169
|
+
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
|
170
|
+
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
171
|
+
res.end(data);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
res.writeHead(404).end();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// ------------------------------------------------------------------
|
|
178
|
+
// WebSocket: connection, initial state, client messages
|
|
179
|
+
// ------------------------------------------------------------------
|
|
180
|
+
handleConnection(ws) {
|
|
181
|
+
this.clients.add(ws);
|
|
182
|
+
ws.on("close", () => this.clients.delete(ws));
|
|
183
|
+
ws.on("error", () => this.clients.delete(ws));
|
|
184
|
+
ws.on("message", (data) => this.handleClientMessage(ws, String(data)));
|
|
185
|
+
// Initial state (state + history), mirroring the Python bridge
|
|
186
|
+
this.sendJson(ws, { type: "state", data: this.buildState() });
|
|
187
|
+
this.sendJson(ws, { type: "history", data: this.buildHistory() });
|
|
188
|
+
}
|
|
189
|
+
sendJson(ws, obj) {
|
|
190
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
191
|
+
ws.send(JSON.stringify(obj));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
buildState() {
|
|
195
|
+
const state = {
|
|
196
|
+
model: this.session.model,
|
|
197
|
+
thinkingLevel: this.session.thinkingLevel,
|
|
198
|
+
sessionId: this.session.sessionId,
|
|
199
|
+
sessionName: this.session.sessionManager.getSessionName(),
|
|
200
|
+
autoCompactionEnabled: this.session.autoCompactionEnabled,
|
|
201
|
+
messageCount: this.session.messages.length,
|
|
202
|
+
version: VERSION,
|
|
203
|
+
cwd: this.formatCwd(this.session.sessionManager.getCwd()),
|
|
204
|
+
gitBranch: this.getGitBranch(this.session.sessionManager.getCwd()),
|
|
205
|
+
commands: this.getCommands(),
|
|
206
|
+
};
|
|
207
|
+
try {
|
|
208
|
+
state.sessionStats = this.session.getSessionStats();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// ignore
|
|
212
|
+
}
|
|
213
|
+
return state;
|
|
214
|
+
}
|
|
215
|
+
buildHistory() {
|
|
216
|
+
try {
|
|
217
|
+
return {
|
|
218
|
+
entries: this.session.sessionManager.getEntries(),
|
|
219
|
+
leafId: this.session.sessionManager.getLeafId(),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return { entries: [], leafId: null };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
formatCwd(cwd) {
|
|
227
|
+
const home = process.env.HOME ?? "";
|
|
228
|
+
if (home && (cwd === home || cwd.startsWith(home + "/"))) {
|
|
229
|
+
return "~" + cwd.slice(home.length);
|
|
230
|
+
}
|
|
231
|
+
return cwd;
|
|
232
|
+
}
|
|
233
|
+
getGitBranch(cwd) {
|
|
234
|
+
try {
|
|
235
|
+
const stdout = execFileSync("git", ["branch", "--show-current"], {
|
|
236
|
+
cwd,
|
|
237
|
+
timeout: 3000,
|
|
238
|
+
encoding: "utf8",
|
|
239
|
+
// execFileSync prints child stderr to our stderr on failure (e.g.
|
|
240
|
+
// "fatal: not a git repository" in non-git dirs) - suppress it
|
|
241
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
242
|
+
});
|
|
243
|
+
return stdout.trim() || null;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
getCommands() {
|
|
250
|
+
try {
|
|
251
|
+
// Mirror Pi's get_commands RPC response: extension commands, prompt
|
|
252
|
+
// templates, and skills (source: extension | prompt | skill)
|
|
253
|
+
const commands = [];
|
|
254
|
+
for (const c of this.session.extensionRunner.getRegisteredCommands()) {
|
|
255
|
+
commands.push({ name: c.invocationName, description: c.description, source: "extension", sourceInfo: c.sourceInfo });
|
|
256
|
+
}
|
|
257
|
+
for (const t of this.session.promptTemplates) {
|
|
258
|
+
commands.push({ name: t.name, description: t.description, source: "prompt", sourceInfo: t.sourceInfo });
|
|
259
|
+
}
|
|
260
|
+
for (const s of this.session.resourceLoader.getSkills().skills) {
|
|
261
|
+
commands.push({ name: `skill:${s.name}`, description: s.description, source: "skill", sourceInfo: s.sourceInfo });
|
|
262
|
+
}
|
|
263
|
+
return commands;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
handleClientMessage(ws, raw) {
|
|
270
|
+
let data;
|
|
271
|
+
try {
|
|
272
|
+
data = JSON.parse(raw);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
this.sendJson(ws, { type: "error", error: "Invalid JSON" });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const cmdType = data.type;
|
|
279
|
+
if (typeof cmdType !== "string") {
|
|
280
|
+
this.sendJson(ws, { type: "error", error: "Missing 'type'" });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.dispatch(cmdType, data).catch((err) => {
|
|
284
|
+
this.sendJson(ws, { type: "error", error: err instanceof Error ? err.message : String(err) });
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async dispatch(cmdType, data) {
|
|
288
|
+
switch (cmdType) {
|
|
289
|
+
case "prompt": {
|
|
290
|
+
const message = typeof data.message === "string" ? data.message : "";
|
|
291
|
+
if (!message)
|
|
292
|
+
throw new Error("Missing 'message'");
|
|
293
|
+
await this.session.prompt(message);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "abort":
|
|
297
|
+
await this.session.abort();
|
|
298
|
+
break;
|
|
299
|
+
case "get_stats":
|
|
300
|
+
this.broadcastStats();
|
|
301
|
+
break;
|
|
302
|
+
case "bash": {
|
|
303
|
+
const command = typeof data.command === "string" ? data.command : "";
|
|
304
|
+
if (!command)
|
|
305
|
+
throw new Error("Missing 'command'");
|
|
306
|
+
const excludeFromContext = data.excludeFromContext === true;
|
|
307
|
+
// Let extensions intercept/enhance the command (same as Pi RPC/TUI):
|
|
308
|
+
// user_bash handlers may provide a result or operations for execution.
|
|
309
|
+
const eventResult = await this.session.extensionRunner.emitUserBash({
|
|
310
|
+
type: "user_bash",
|
|
311
|
+
command,
|
|
312
|
+
excludeFromContext,
|
|
313
|
+
cwd: this.session.sessionManager.getCwd(),
|
|
314
|
+
});
|
|
315
|
+
if (eventResult?.result) {
|
|
316
|
+
this.session.recordBashResult(command, eventResult.result, { excludeFromContext });
|
|
317
|
+
this.broadcast({ type: "bash_result", command, data: eventResult.result });
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
const result = await this.session.executeBash(command, undefined, {
|
|
321
|
+
excludeFromContext,
|
|
322
|
+
operations: eventResult?.operations,
|
|
323
|
+
});
|
|
324
|
+
this.broadcast({ type: "bash_result", command, data: result });
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case "cycle_model":
|
|
328
|
+
await this.session.cycleModel();
|
|
329
|
+
this.broadcastState();
|
|
330
|
+
break;
|
|
331
|
+
case "set_model": {
|
|
332
|
+
const provider = data.provider;
|
|
333
|
+
const modelId = data.modelId;
|
|
334
|
+
if (!provider || !modelId)
|
|
335
|
+
throw new Error("Missing 'provider' or 'modelId'");
|
|
336
|
+
const model = this.session.modelRuntime.getAvailableSnapshot().find((m) => m.provider === provider && m.id === modelId);
|
|
337
|
+
if (!model)
|
|
338
|
+
throw new Error(`Model not found: ${provider}/${modelId}`);
|
|
339
|
+
await this.session.setModel(model);
|
|
340
|
+
this.broadcastState();
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
case "get_available_models":
|
|
344
|
+
this.broadcast({ type: "models", data: this.session.modelRuntime.getAvailableSnapshot() });
|
|
345
|
+
break;
|
|
346
|
+
case "cycle_thinking_level":
|
|
347
|
+
this.session.cycleThinkingLevel();
|
|
348
|
+
this.broadcastState();
|
|
349
|
+
break;
|
|
350
|
+
case "set_thinking_level": {
|
|
351
|
+
const level = data.level;
|
|
352
|
+
if (!level)
|
|
353
|
+
throw new Error("Missing 'level'");
|
|
354
|
+
this.session.setThinkingLevel(level);
|
|
355
|
+
this.broadcastState();
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "get_available_thinking_levels":
|
|
359
|
+
this.broadcast({ type: "thinking_levels", data: this.session.getAvailableThinkingLevels() });
|
|
360
|
+
break;
|
|
361
|
+
case "compact":
|
|
362
|
+
await this.session.compact(typeof data.customInstructions === "string" ? data.customInstructions : undefined);
|
|
363
|
+
break;
|
|
364
|
+
case "reload":
|
|
365
|
+
// Align with TUI /reload: refuse while streaming or compacting
|
|
366
|
+
if (!this.session.isIdle)
|
|
367
|
+
throw new Error("Wait for the current response to finish before reloading.");
|
|
368
|
+
if (this.session.isCompacting)
|
|
369
|
+
throw new Error("Wait for compaction to finish before reloading.");
|
|
370
|
+
await this.session.reload();
|
|
371
|
+
this.broadcastState();
|
|
372
|
+
break;
|
|
373
|
+
case "export": {
|
|
374
|
+
const path = typeof data.path === "string" && data.path ? data.path : undefined;
|
|
375
|
+
const exportedPath = path?.endsWith(".jsonl")
|
|
376
|
+
? this.session.exportToJsonl(path)
|
|
377
|
+
: await this.session.exportToHtml(path);
|
|
378
|
+
this.broadcast({
|
|
379
|
+
type: "extension_ui_request",
|
|
380
|
+
id: crypto.randomUUID(),
|
|
381
|
+
method: "notify",
|
|
382
|
+
title: "/export",
|
|
383
|
+
message: `Session exported to: ${exportedPath}`,
|
|
384
|
+
notifyType: "info",
|
|
385
|
+
});
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case "get_scoped_models":
|
|
389
|
+
this.broadcast({
|
|
390
|
+
type: "scoped_models",
|
|
391
|
+
data: {
|
|
392
|
+
scoped: this.session.scopedModels.map((s) => ({
|
|
393
|
+
provider: s.model.provider,
|
|
394
|
+
id: s.model.id,
|
|
395
|
+
thinkingLevel: s.thinkingLevel,
|
|
396
|
+
})),
|
|
397
|
+
available: this.session.modelRuntime.getAvailableSnapshot(),
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
break;
|
|
401
|
+
case "set_scoped_models": {
|
|
402
|
+
const models = Array.isArray(data.models) ? data.models : [];
|
|
403
|
+
const resolved = [];
|
|
404
|
+
for (const m of models) {
|
|
405
|
+
const provider = m.provider;
|
|
406
|
+
const modelId = m.modelId;
|
|
407
|
+
if (!provider || !modelId)
|
|
408
|
+
continue;
|
|
409
|
+
const model = this.session.modelRuntime
|
|
410
|
+
.getAvailableSnapshot()
|
|
411
|
+
.find((x) => x.provider === provider && x.id === modelId);
|
|
412
|
+
if (model)
|
|
413
|
+
resolved.push({ model, thinkingLevel: m.thinkingLevel });
|
|
414
|
+
}
|
|
415
|
+
this.session.setScopedModels(resolved);
|
|
416
|
+
this.broadcastState();
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case "set_session_name": {
|
|
420
|
+
const name = typeof data.name === "string" ? data.name : "";
|
|
421
|
+
if (!name)
|
|
422
|
+
throw new Error("Missing 'name'");
|
|
423
|
+
this.session.setSessionName(name);
|
|
424
|
+
this.broadcastState();
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
case "extension_ui_response": {
|
|
428
|
+
const id = typeof data.id === "string" ? data.id : "";
|
|
429
|
+
if (!id)
|
|
430
|
+
throw new Error("Missing 'id'");
|
|
431
|
+
const extra = Object.fromEntries(Object.entries(data).filter(([k]) => k !== "type" && k !== "id"));
|
|
432
|
+
this.uiContext.respond(id, extra);
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "command": {
|
|
436
|
+
const name = typeof data.name === "string" ? data.name : "";
|
|
437
|
+
const args = typeof data.args === "string" ? data.args : "";
|
|
438
|
+
if (!name)
|
|
439
|
+
throw new Error("Missing 'name'");
|
|
440
|
+
await this.executeCommand(name, args);
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
default:
|
|
444
|
+
throw new Error(`Unsupported command: ${cmdType}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Execute an extension slash command (e.g. /ctx-status) by invoking its
|
|
449
|
+
* registered handler with a command context.
|
|
450
|
+
*
|
|
451
|
+
* The context reports `hasUI: false` so extensions that offer a TUI dialog
|
|
452
|
+
* fall back to text output (e.g. magic-context's /ctx-status writes a custom
|
|
453
|
+
* session entry, which flows to the browser via entry_appended). Custom
|
|
454
|
+
* entries produced by the command are additionally shown as a browser
|
|
455
|
+
* modal (mirroring the TUI dialog behavior).
|
|
456
|
+
*/
|
|
457
|
+
async executeCommand(name, args) {
|
|
458
|
+
const cmd = this.session.extensionRunner.getCommand(name);
|
|
459
|
+
if (!cmd)
|
|
460
|
+
throw new Error(`Unknown command: ${name}`);
|
|
461
|
+
// Capture custom entries appended while the command runs
|
|
462
|
+
const captured = [];
|
|
463
|
+
const listener = (event) => {
|
|
464
|
+
const ev = event;
|
|
465
|
+
if (ev.type === "entry_appended" && ev.entry?.type === "custom") {
|
|
466
|
+
captured.push({ customType: ev.entry.customType ?? "custom", data: ev.entry.data });
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
const unsubscribe = this.session.subscribe(listener);
|
|
470
|
+
try {
|
|
471
|
+
await cmd.handler(args, this.buildCommandContext());
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
unsubscribe();
|
|
475
|
+
}
|
|
476
|
+
// Show command output as a modal (TUI-like). The session entry remains
|
|
477
|
+
// for history; the modal is the transient presentation.
|
|
478
|
+
for (const entry of captured) {
|
|
479
|
+
const data = entry.data;
|
|
480
|
+
const message = data?.text ?? (data !== undefined ? JSON.stringify(data, null, 2) : "");
|
|
481
|
+
this.broadcast({
|
|
482
|
+
type: "extension_ui_request",
|
|
483
|
+
id: crypto.randomUUID(),
|
|
484
|
+
method: "notify",
|
|
485
|
+
title: data?.title ?? `/${name}`,
|
|
486
|
+
message,
|
|
487
|
+
notifyType: "info",
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
buildCommandContext() {
|
|
492
|
+
const sm = this.session.sessionManager;
|
|
493
|
+
return {
|
|
494
|
+
ui: this.uiContext,
|
|
495
|
+
// No dialog-capable UI: extension commands fall back to text output
|
|
496
|
+
mode: "print",
|
|
497
|
+
hasUI: false,
|
|
498
|
+
cwd: sm.getCwd(),
|
|
499
|
+
sessionManager: sm,
|
|
500
|
+
modelRegistry: new ModelRegistry(this.session.modelRuntime),
|
|
501
|
+
model: this.session.model,
|
|
502
|
+
scopedModels: [],
|
|
503
|
+
thinkingLevel: this.session.thinkingLevel,
|
|
504
|
+
isIdle: () => this.session.isIdle,
|
|
505
|
+
isProjectTrusted: () => true,
|
|
506
|
+
signal: undefined,
|
|
507
|
+
abort: () => {
|
|
508
|
+
void this.session.abort();
|
|
509
|
+
},
|
|
510
|
+
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
|
511
|
+
shutdown: () => { },
|
|
512
|
+
getContextUsage: () => this.session.getContextUsage(),
|
|
513
|
+
compact: (options) => {
|
|
514
|
+
void this.session.compact(options?.customInstructions);
|
|
515
|
+
},
|
|
516
|
+
getSystemPrompt: () => this.session.systemPrompt,
|
|
517
|
+
getSystemPromptOptions: () => ({}),
|
|
518
|
+
waitForIdle: () => this.session.waitForIdle(),
|
|
519
|
+
newSession: async () => ({ cancelled: true }),
|
|
520
|
+
fork: async () => ({ cancelled: true }),
|
|
521
|
+
navigateTree: async () => ({ cancelled: true }),
|
|
522
|
+
switchSession: async () => ({ cancelled: true }),
|
|
523
|
+
reload: async () => { },
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|