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 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
+ }