clankie 0.2.1 → 0.2.3

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.
Files changed (41) hide show
  1. package/README.md +29 -13
  2. package/dist/cli.js +301851 -0
  3. package/dist/koffi-216xhpes.node +0 -0
  4. package/dist/koffi-2erktc37.node +0 -0
  5. package/dist/koffi-2rrez93a.node +0 -0
  6. package/dist/koffi-2wv0r22g.node +0 -0
  7. package/dist/koffi-3kae4xj3.node +0 -0
  8. package/dist/koffi-3rkr2zqv.node +0 -0
  9. package/dist/koffi-abxfktv9.node +0 -0
  10. package/dist/koffi-c67c0c5b.node +0 -0
  11. package/dist/koffi-cnf0q0dx.node +0 -0
  12. package/dist/koffi-df38sqz5.node +0 -0
  13. package/dist/koffi-gfbqb3a0.node +0 -0
  14. package/dist/koffi-kjemmmem.node +0 -0
  15. package/dist/koffi-kkrfq9yv.node +0 -0
  16. package/dist/koffi-mzaqwwqy.node +0 -0
  17. package/dist/koffi-q49fgkeq.node +0 -0
  18. package/dist/koffi-q54bk8bf.node +0 -0
  19. package/dist/koffi-x1790w0j.node +0 -0
  20. package/dist/koffi-yxvjwcj6.node +0 -0
  21. package/package.json +17 -7
  22. package/web-ui-dist/_shell.html +2 -2
  23. package/web-ui-dist/assets/{card-kSKmECr1.js → card-BUP-xovx.js} +1 -1
  24. package/web-ui-dist/assets/extensions-DC620Nmx.js +1 -0
  25. package/web-ui-dist/assets/{index-CXJ3n5rE.js → index-DurjG9O_.js} +1 -1
  26. package/web-ui-dist/assets/{loader-circle-C5ib508E.js → loader-circle-DbOtKfCA.js} +1 -1
  27. package/web-ui-dist/assets/{main-cBOaKYCP.js → main-B2sRcuyZ.js} +8 -8
  28. package/web-ui-dist/assets/{sessions._sessionId-BIeINoSQ.js → sessions._sessionId-BJazw9EJ.js} +1 -1
  29. package/web-ui-dist/assets/{settings-CO37Obvo.js → settings-Bv8oeIho.js} +1 -1
  30. package/web-ui-dist/assets/styles-D2oHO1JL.css +1 -0
  31. package/src/agent.ts +0 -107
  32. package/src/channels/channel.ts +0 -57
  33. package/src/channels/slack.ts +0 -374
  34. package/src/channels/web.ts +0 -1362
  35. package/src/cli.ts +0 -505
  36. package/src/config.ts +0 -257
  37. package/src/daemon.ts +0 -380
  38. package/src/service.ts +0 -372
  39. package/src/sessions.ts +0 -251
  40. package/web-ui-dist/assets/extensions-CFPfugfg.js +0 -1
  41. package/web-ui-dist/assets/styles-BQfA8H-l.css +0 -1
@@ -1,1362 +0,0 @@
1
- /**
2
- * WebSocket channel — bridges pi's RPC protocol over WebSocket.
3
- *
4
- * Protocol:
5
- * - Client → Server: { sessionId?: string, command: RpcCommand }
6
- * - Server → Client: { sessionId: string, event: AgentEvent | RpcResponse | RpcExtensionUIRequest }
7
- *
8
- * One WebSocket connection can handle multiple sessions.
9
- * Sessions are identified by unique sessionId from pi's AgentSession.
10
- */
11
-
12
- import * as crypto from "node:crypto";
13
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
- import { join } from "node:path";
15
- import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
16
- import type { ImageContent, OAuthLoginCallbacks } from "@mariozechner/pi-ai";
17
- import { type AgentSession, type AgentSessionEvent, AuthStorage } from "@mariozechner/pi-coding-agent";
18
- import type { ServerWebSocket } from "bun";
19
- import { getAppDir, getAuthPath, loadConfig } from "../config.ts";
20
- import { getOrCreateSession } from "../sessions.ts";
21
- import type { Channel, MessageHandler } from "./channel.ts";
22
-
23
- // ─── Types ─────────────────────────────────────────────────────────────────────
24
-
25
- export interface WebChannelOptions {
26
- /** Port to listen on (default: 3100) */
27
- port: number;
28
- /** Required shared secret for authentication */
29
- authToken: string;
30
- /** Allowed origins for CORS-like validation (empty = allow all) */
31
- allowedOrigins?: string[];
32
- /** Path to built web-ui static files (enables same-origin serving) */
33
- staticDir?: string;
34
- }
35
-
36
- /** Inbound message from client */
37
- interface InboundWebMessage {
38
- sessionId?: string;
39
- command: RpcCommand;
40
- }
41
-
42
- /** Outbound message to client */
43
- interface OutboundWebMessage {
44
- sessionId: string; // "_auth" for auth events
45
- event: AgentSessionEvent | RpcResponse | RpcExtensionUIRequest | AuthEvent;
46
- }
47
-
48
- /** RPC command types from pi */
49
- type RpcCommand =
50
- | { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" }
51
- | { id?: string; type: "steer"; message: string; images?: ImageContent[] }
52
- | { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
53
- | { id?: string; type: "abort" }
54
- | { id?: string; type: "upload_attachment"; fileName: string; data: string; mimeType: string }
55
- | { id?: string; type: "new_session"; parentSession?: string }
56
- | { id?: string; type: "list_sessions" }
57
- | { id?: string; type: "get_state" }
58
- | { id?: string; type: "set_model"; provider: string; modelId: string }
59
- | { id?: string; type: "cycle_model" }
60
- | { id?: string; type: "get_available_models" }
61
- | { id?: string; type: "set_thinking_level"; level: ThinkingLevel }
62
- | { id?: string; type: "cycle_thinking_level" }
63
- | { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" }
64
- | { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" }
65
- | { id?: string; type: "compact"; customInstructions?: string }
66
- | { id?: string; type: "set_auto_compaction"; enabled: boolean }
67
- | { id?: string; type: "set_auto_retry"; enabled: boolean }
68
- | { id?: string; type: "abort_retry" }
69
- | { id?: string; type: "bash"; command: string }
70
- | { id?: string; type: "abort_bash" }
71
- | { id?: string; type: "get_session_stats" }
72
- | { id?: string; type: "export_html"; outputPath?: string }
73
- | { id?: string; type: "switch_session"; sessionPath: string }
74
- | { id?: string; type: "fork"; entryId: string }
75
- | { id?: string; type: "get_fork_messages" }
76
- | { id?: string; type: "get_last_assistant_text" }
77
- | { id?: string; type: "set_session_name"; name: string }
78
- | { id?: string; type: "get_messages" }
79
- | { id?: string; type: "get_commands" }
80
- | { id?: string; type: "get_extensions" }
81
- | { id?: string; type: "get_skills" }
82
- | { id?: string; type: "install_package"; source: string; local?: boolean }
83
- | { id?: string; type: "get_auth_providers" }
84
- | { id?: string; type: "auth_login"; providerId: string }
85
- | { id?: string; type: "auth_set_api_key"; providerId: string; apiKey: string }
86
- | { id?: string; type: "auth_login_input"; loginFlowId: string; value: string }
87
- | { id?: string; type: "auth_login_cancel"; loginFlowId: string }
88
- | { id?: string; type: "auth_logout"; providerId: string };
89
-
90
- /** RPC response types from pi */
91
- type RpcResponse =
92
- | { id?: string; type: "response"; command: string; success: true; data?: unknown }
93
- | { id?: string; type: "response"; command: string; success: false; error: string };
94
-
95
- /** Auth event types (sent during login flows) */
96
- type AuthEvent =
97
- | { type: "auth_event"; loginFlowId: string; event: "url"; url: string; instructions?: string }
98
- | { type: "auth_event"; loginFlowId: string; event: "prompt"; message: string; placeholder?: string }
99
- | { type: "auth_event"; loginFlowId: string; event: "manual_input" }
100
- | { type: "auth_event"; loginFlowId: string; event: "progress"; message: string }
101
- | { type: "auth_event"; loginFlowId: string; event: "complete"; success: boolean; error?: string };
102
-
103
- /** Extension UI request types from pi */
104
- type RpcExtensionUIRequest =
105
- | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }
106
- | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }
107
- | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number }
108
- | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
109
- | {
110
- type: "extension_ui_request";
111
- id: string;
112
- method: "notify";
113
- message: string;
114
- notifyType?: "info" | "warning" | "error";
115
- }
116
- | { type: "extension_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined }
117
- | {
118
- type: "extension_ui_request";
119
- id: string;
120
- method: "setWidget";
121
- widgetKey: string;
122
- widgetLines: string[] | undefined;
123
- widgetPlacement?: "aboveEditor" | "belowEditor";
124
- }
125
- | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
126
- | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
127
-
128
- /** Extension UI response from client */
129
- type RpcExtensionUIResponse =
130
- | { type: "extension_ui_response"; id: string; value: string }
131
- | { type: "extension_ui_response"; id: string; confirmed: boolean }
132
- | { type: "extension_ui_response"; id: string; cancelled: true };
133
-
134
- interface ConnectionData {
135
- authenticated: boolean;
136
- }
137
-
138
- // ─── WebChannel ────────────────────────────────────────────────────────────────
139
-
140
- export class WebChannel implements Channel {
141
- readonly name = "web";
142
- private options: WebChannelOptions;
143
- private server: ReturnType<typeof Bun.serve> | null = null;
144
-
145
- /** Map of sessionId → Set of WebSocket connections subscribed to that session */
146
- private sessionSubscriptions = new Map<string, Set<ServerWebSocket<ConnectionData>>>();
147
-
148
- /** Map of sessionId → AgentSession */
149
- private sessions = new Map<string, AgentSession>();
150
-
151
- /** Map of sessionId → unsubscribe function for session event listener */
152
- private sessionUnsubscribers = new Map<string, () => void>();
153
-
154
- /** Pending extension UI requests: Map<requestId, { sessionId, ws }> */
155
- private pendingExtensionRequests = new Map<string, { sessionId: string; ws: ServerWebSocket<ConnectionData> }>();
156
-
157
- /** Pending auth login flows: Map<loginFlowId, { ws, inputResolver, abortController }> */
158
- private pendingLoginFlows = new Map<
159
- string,
160
- {
161
- ws: ServerWebSocket<ConnectionData>;
162
- inputResolver: ((value: string) => void) | null;
163
- abortController: AbortController;
164
- }
165
- >();
166
-
167
- constructor(options: WebChannelOptions) {
168
- this.options = options;
169
- }
170
-
171
- async start(handler: MessageHandler): Promise<void> {
172
- this.handler = handler;
173
-
174
- this.server = Bun.serve({
175
- port: this.options.port,
176
- websocket: {
177
- open: (ws) => this.handleOpen(ws),
178
- message: (ws, message) => this.handleMessage(ws, message),
179
- close: (ws) => this.handleClose(ws),
180
- },
181
- fetch: (req, server) => {
182
- const isWebSocket = req.headers.get("Upgrade")?.toLowerCase() === "websocket";
183
-
184
- // ─── WebSocket upgrade path ───────────────────────────────────────
185
-
186
- if (isWebSocket) {
187
- // Validate auth token from Authorization header or URL query param
188
- const authHeader = req.headers.get("Authorization");
189
- const headerToken = authHeader?.replace(/^Bearer\s+/i, "");
190
-
191
- // Also check URL query param (for browser WebSocket clients that can't send headers)
192
- const url = new URL(req.url, `http://${req.headers.get("host")}`);
193
- const queryToken = url.searchParams.get("token");
194
-
195
- const token = headerToken || queryToken;
196
-
197
- if (token !== this.options.authToken) {
198
- return new Response("Unauthorized", { status: 401 });
199
- }
200
-
201
- // ─── Origin validation ────────────────────────────────────────
202
-
203
- // When staticDir is set, enforce same-origin by comparing Origin vs Host
204
- if (this.options.staticDir) {
205
- const origin = req.headers.get("Origin");
206
- const host = req.headers.get("Host");
207
-
208
- if (!origin || !host) {
209
- return new Response("Forbidden - missing headers", { status: 403 });
210
- }
211
-
212
- try {
213
- const originHost = new URL(origin).host;
214
- // Compare hostnames (ignoring scheme — reverse proxy handles TLS)
215
- if (originHost !== host) {
216
- console.warn(`[web] Blocked cross-origin WebSocket: origin=${origin}, host=${host}`);
217
- return new Response("Forbidden - cross-origin not allowed", { status: 403 });
218
- }
219
- } catch (err) {
220
- console.error("[web] Invalid Origin header:", err);
221
- return new Response("Forbidden - invalid origin", { status: 403 });
222
- }
223
- }
224
- // Legacy allowedOrigins check (still works as override when staticDir is not set)
225
- else if (this.options.allowedOrigins && this.options.allowedOrigins.length > 0) {
226
- const origin = req.headers.get("Origin");
227
- if (!origin || !this.options.allowedOrigins.includes(origin)) {
228
- return new Response("Forbidden", { status: 403 });
229
- }
230
- }
231
-
232
- // Upgrade to WebSocket
233
- const upgraded = server.upgrade(req, {
234
- data: { authenticated: true } as ConnectionData,
235
- });
236
-
237
- if (!upgraded) {
238
- return new Response("WebSocket upgrade failed", { status: 400 });
239
- }
240
-
241
- // biome-ignore lint/suspicious/noExplicitAny: Bun requires undefined return after upgrade
242
- return undefined as any; // upgrade successful
243
- }
244
-
245
- // ─── Static file serving path ─────────────────────────────────────
246
-
247
- if (this.options.staticDir) {
248
- return this.serveStaticFile(req);
249
- }
250
-
251
- // ─── No static dir configured — reject non-WebSocket requests ─────
252
-
253
- return new Response("Upgrade Required - this endpoint only accepts WebSocket connections", {
254
- status: 426,
255
- headers: { Upgrade: "websocket" },
256
- });
257
- },
258
- });
259
-
260
- console.log(`[web] WebSocket server listening on port ${this.options.port}`);
261
- console.log(`[web] Open in browser: http://localhost:${this.options.port}?token=${this.options.authToken}`);
262
- }
263
-
264
- async send(_chatId: string, _text: string, _options?: { threadId?: string }): Promise<void> {
265
- // No-op — WebChannel uses direct session streaming, not channel.send()
266
- }
267
-
268
- async stop(): Promise<void> {
269
- if (this.server) {
270
- this.server.stop();
271
- this.server = null;
272
- }
273
-
274
- // Unsubscribe from all sessions
275
- for (const unsubscribe of this.sessionUnsubscribers.values()) {
276
- unsubscribe();
277
- }
278
- this.sessionUnsubscribers.clear();
279
- this.sessionSubscriptions.clear();
280
-
281
- console.log("[web] WebSocket server stopped");
282
- }
283
-
284
- // ─── WebSocket handlers ────────────────────────────────────────────────────
285
-
286
- private handleOpen(_ws: ServerWebSocket<ConnectionData>): void {
287
- console.log("[web] Client connected");
288
- }
289
-
290
- private async handleMessage(ws: ServerWebSocket<ConnectionData>, message: string | Buffer): Promise<void> {
291
- try {
292
- const text = typeof message === "string" ? message : message.toString("utf-8");
293
- const parsed = JSON.parse(text);
294
-
295
- // Handle extension UI responses
296
- if (parsed.type === "extension_ui_response") {
297
- this.handleExtensionUIResponse(parsed as RpcExtensionUIResponse);
298
- return;
299
- }
300
-
301
- // Handle RPC commands
302
- const inbound = parsed as InboundWebMessage;
303
- await this.handleCommand(ws, inbound);
304
- } catch (err) {
305
- console.error("[web] Error handling message:", err);
306
- this.sendError(
307
- ws,
308
- undefined,
309
- "parse",
310
- `Failed to parse message: ${err instanceof Error ? err.message : String(err)}`,
311
- );
312
- }
313
- }
314
-
315
- private handleClose(ws: ServerWebSocket<ConnectionData>): void {
316
- console.log("[web] Client disconnected");
317
-
318
- // Remove this connection from all session subscriptions
319
- for (const [sessionId, subscribers] of this.sessionSubscriptions.entries()) {
320
- subscribers.delete(ws);
321
- if (subscribers.size === 0) {
322
- this.sessionSubscriptions.delete(sessionId);
323
- // Optionally unsubscribe from session events if no one is listening
324
- // But keep the session alive for reconnection
325
- }
326
- }
327
- }
328
-
329
- // ─── Command handling ──────────────────────────────────────────────────────
330
-
331
- private async handleCommand(ws: ServerWebSocket<ConnectionData>, inbound: InboundWebMessage): Promise<void> {
332
- const command = inbound.command;
333
- const commandId = command.id;
334
-
335
- try {
336
- // Special case: new_session doesn't need a sessionId
337
- if (command.type === "new_session") {
338
- const config = loadConfig();
339
- const chatKey = `web_${crypto.randomUUID()}`;
340
- console.log(`[web] Creating new session with chatKey: ${chatKey}`);
341
- const session = await getOrCreateSession(chatKey, config);
342
- console.log(
343
- `[web] After getOrCreateSession - session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
344
- );
345
-
346
- const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
347
- const cancelled = !(await session.newSession(options));
348
- console.log(
349
- `[web] After session.newSession() - session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
350
- );
351
-
352
- // Subscribe using the chatKey (not session.sessionId) for consistency
353
- this.subscribeToSessionWithKey(chatKey, session, ws);
354
-
355
- // Return the chatKey as sessionId so client uses it for future commands
356
- console.log(`[web] Returning sessionId to client: ${chatKey}, cancelled: ${cancelled}`);
357
- this.sendResponse(ws, chatKey, {
358
- id: commandId,
359
- type: "response",
360
- command: "new_session",
361
- success: true,
362
- data: { sessionId: chatKey, cancelled },
363
- });
364
- return;
365
- }
366
-
367
- // Auth commands don't need a sessionId
368
- if (
369
- command.type === "get_auth_providers" ||
370
- command.type === "auth_login" ||
371
- command.type === "auth_set_api_key" ||
372
- command.type === "auth_login_input" ||
373
- command.type === "auth_login_cancel" ||
374
- command.type === "auth_logout"
375
- ) {
376
- await this.handleAuthCommand(ws, command, commandId);
377
- return;
378
- }
379
-
380
- // Special case: list_sessions doesn't need a sessionId
381
- if (command.type === "list_sessions") {
382
- const sessions = await this.listAllSessions();
383
-
384
- this.sendResponse(ws, undefined, {
385
- id: commandId,
386
- type: "response",
387
- command: "list_sessions",
388
- success: true,
389
- data: { sessions },
390
- });
391
- return;
392
- }
393
-
394
- // All other commands require sessionId
395
- if (!inbound.sessionId) {
396
- this.sendError(ws, undefined, command.type, "sessionId is required", commandId);
397
- return;
398
- }
399
-
400
- const sessionId = inbound.sessionId;
401
-
402
- // Get existing session or try to restore from disk
403
- // Note: sessionId here is the chatKey (web_xxx), not the internal session ID
404
- let session = this.sessions.get(sessionId);
405
- if (!session) {
406
- // Try to restore session from disk
407
- try {
408
- const config = loadConfig();
409
- console.log(`[web] Restoring session from disk - chatKey: ${sessionId}`);
410
- session = await getOrCreateSession(sessionId, config);
411
- console.log(
412
- `[web] After restore - chatKey: ${sessionId}, session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
413
- );
414
- this.subscribeToSessionWithKey(sessionId, session, ws);
415
- console.log(`[web] Restored session from disk: ${sessionId}`);
416
- } catch (_err) {
417
- this.sendError(ws, sessionId, command.type, `Session not found: ${sessionId}`, commandId);
418
- return;
419
- }
420
- } else {
421
- // Ensure this ws is subscribed (handles reconnection with new ws)
422
- this.subscribeToSessionWithKey(sessionId, session, ws);
423
- console.log(
424
- `[web] Using cached session - chatKey: ${sessionId}, session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
425
- );
426
- }
427
-
428
- // Execute command (mirrors rpc-mode.ts logic)
429
- const response = await this.executeCommand(sessionId, session, command);
430
- this.sendResponse(ws, sessionId, response);
431
- } catch (err) {
432
- console.error("[web] Command error:", err);
433
- this.sendError(ws, inbound.sessionId, command.type, err instanceof Error ? err.message : String(err), commandId);
434
- }
435
- }
436
-
437
- private async executeCommand(sessionId: string, session: AgentSession, command: RpcCommand): Promise<RpcResponse> {
438
- const id = command.id;
439
-
440
- switch (command.type) {
441
- case "prompt": {
442
- console.log(
443
- `[web] Executing prompt - session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
444
- );
445
- // Don't await - events will stream
446
- session
447
- .prompt(command.message, {
448
- images: command.images,
449
- streamingBehavior: command.streamingBehavior,
450
- source: "rpc",
451
- })
452
- .catch((e) => {
453
- console.error("[web] Prompt error:", e);
454
- });
455
- console.log(
456
- `[web] After prompt - session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
457
- );
458
- return { id, type: "response", command: "prompt", success: true };
459
- }
460
-
461
- case "steer": {
462
- await session.steer(command.message, command.images);
463
- return { id, type: "response", command: "steer", success: true };
464
- }
465
-
466
- case "follow_up": {
467
- await session.followUp(command.message, command.images);
468
- return { id, type: "response", command: "follow_up", success: true };
469
- }
470
-
471
- case "abort": {
472
- await session.abort();
473
- return { id, type: "response", command: "abort", success: true };
474
- }
475
-
476
- case "upload_attachment": {
477
- const { fileName, data, mimeType } = command;
478
-
479
- // Save attachment to disk
480
- const { mkdirSync, writeFileSync } = await import("node:fs");
481
- const { join } = await import("node:path");
482
-
483
- // Use sessionId (which is the chatKey like web_xxx) to organize attachments
484
- const dir = join(getAppDir(), "attachments", sessionId);
485
- mkdirSync(dir, { recursive: true });
486
-
487
- // Create a unique filename with timestamp
488
- const timestamp = Date.now();
489
- const sanitizedName = fileName.replace(/[^a-zA-Z0-9.-]/g, "_");
490
- const uniqueFileName = `${timestamp}_${sanitizedName}`;
491
- const filePath = join(dir, uniqueFileName);
492
-
493
- // Write the base64 data to disk
494
- writeFileSync(filePath, Buffer.from(data, "base64"));
495
-
496
- console.log(`[web] Saved attachment: ${filePath} (${mimeType})`);
497
-
498
- return {
499
- id,
500
- type: "response",
501
- command: "upload_attachment",
502
- success: true,
503
- data: { path: filePath, fileName: uniqueFileName },
504
- };
505
- }
506
-
507
- case "get_state": {
508
- const state = {
509
- model: session.model,
510
- thinkingLevel: session.thinkingLevel,
511
- isStreaming: session.isStreaming,
512
- isCompacting: session.isCompacting,
513
- steeringMode: session.steeringMode,
514
- followUpMode: session.followUpMode,
515
- sessionFile: session.sessionFile,
516
- sessionId: session.sessionId,
517
- sessionName: session.sessionName,
518
- autoCompactionEnabled: session.autoCompactionEnabled,
519
- messageCount: session.messages.length,
520
- pendingMessageCount: session.pendingMessageCount,
521
- };
522
- return { id, type: "response", command: "get_state", success: true, data: state };
523
- }
524
-
525
- case "set_model": {
526
- const models = await session.modelRegistry.getAvailable();
527
- const model = models.find((m) => m.provider === command.provider && m.id === command.modelId);
528
- if (!model) {
529
- return {
530
- id,
531
- type: "response",
532
- command: "set_model",
533
- success: false,
534
- error: `Model not found: ${command.provider}/${command.modelId}`,
535
- };
536
- }
537
- console.log(`[web] Setting model for session ${sessionId}:`, model);
538
- await session.setModel(model);
539
- console.log(`[web] Model set successfully for session ${sessionId}`);
540
-
541
- // Manually broadcast model_changed event (pi SDK may not emit it automatically)
542
- this.broadcastEvent(sessionId, {
543
- type: "model_changed",
544
- model: model,
545
- });
546
-
547
- return { id, type: "response", command: "set_model", success: true, data: model };
548
- }
549
-
550
- case "cycle_model": {
551
- const result = await session.cycleModel();
552
- return { id, type: "response", command: "cycle_model", success: true, data: result ?? null };
553
- }
554
-
555
- case "get_available_models": {
556
- const models = await session.modelRegistry.getAvailable();
557
- return { id, type: "response", command: "get_available_models", success: true, data: { models } };
558
- }
559
-
560
- case "set_thinking_level": {
561
- session.setThinkingLevel(command.level);
562
-
563
- // Manually broadcast thinking_level_changed event
564
- this.broadcastEvent(sessionId, {
565
- type: "thinking_level_changed",
566
- level: command.level,
567
- });
568
-
569
- return { id, type: "response", command: "set_thinking_level", success: true };
570
- }
571
-
572
- case "cycle_thinking_level": {
573
- const level = session.cycleThinkingLevel();
574
- return { id, type: "response", command: "cycle_thinking_level", success: true, data: level ? { level } : null };
575
- }
576
-
577
- case "set_steering_mode": {
578
- session.setSteeringMode(command.mode);
579
- return { id, type: "response", command: "set_steering_mode", success: true };
580
- }
581
-
582
- case "set_follow_up_mode": {
583
- session.setFollowUpMode(command.mode);
584
- return { id, type: "response", command: "set_follow_up_mode", success: true };
585
- }
586
-
587
- case "compact": {
588
- const result = await session.compact(command.customInstructions);
589
- return { id, type: "response", command: "compact", success: true, data: result };
590
- }
591
-
592
- case "set_auto_compaction": {
593
- session.setAutoCompactionEnabled(command.enabled);
594
- return { id, type: "response", command: "set_auto_compaction", success: true };
595
- }
596
-
597
- case "set_auto_retry": {
598
- session.setAutoRetryEnabled(command.enabled);
599
- return { id, type: "response", command: "set_auto_retry", success: true };
600
- }
601
-
602
- case "abort_retry": {
603
- session.abortRetry();
604
- return { id, type: "response", command: "abort_retry", success: true };
605
- }
606
-
607
- case "bash": {
608
- const result = await session.executeBash(command.command);
609
- return { id, type: "response", command: "bash", success: true, data: result };
610
- }
611
-
612
- case "abort_bash": {
613
- session.abortBash();
614
- return { id, type: "response", command: "abort_bash", success: true };
615
- }
616
-
617
- case "get_session_stats": {
618
- const stats = session.getSessionStats();
619
- return { id, type: "response", command: "get_session_stats", success: true, data: stats };
620
- }
621
-
622
- case "export_html": {
623
- const path = await session.exportToHtml(command.outputPath);
624
- return { id, type: "response", command: "export_html", success: true, data: { path } };
625
- }
626
-
627
- case "switch_session": {
628
- const cancelled = !(await session.switchSession(command.sessionPath));
629
- return { id, type: "response", command: "switch_session", success: true, data: { cancelled } };
630
- }
631
-
632
- case "fork": {
633
- const result = await session.fork(command.entryId);
634
- return {
635
- id,
636
- type: "response",
637
- command: "fork",
638
- success: true,
639
- data: { text: result.selectedText, cancelled: result.cancelled },
640
- };
641
- }
642
-
643
- case "get_fork_messages": {
644
- const messages = session.getUserMessagesForForking();
645
- return { id, type: "response", command: "get_fork_messages", success: true, data: { messages } };
646
- }
647
-
648
- case "get_last_assistant_text": {
649
- const text = session.getLastAssistantText();
650
- return { id, type: "response", command: "get_last_assistant_text", success: true, data: { text } };
651
- }
652
-
653
- case "set_session_name": {
654
- const name = command.name.trim();
655
- if (!name) {
656
- return {
657
- id,
658
- type: "response",
659
- command: "set_session_name",
660
- success: false,
661
- error: "Session name cannot be empty",
662
- };
663
- }
664
- session.setSessionName(name);
665
- return { id, type: "response", command: "set_session_name", success: true };
666
- }
667
-
668
- case "get_messages": {
669
- return { id, type: "response", command: "get_messages", success: true, data: { messages: session.messages } };
670
- }
671
-
672
- case "get_commands": {
673
- const commands: Array<{
674
- name: string;
675
- description?: string;
676
- source: string;
677
- location?: string;
678
- path?: string;
679
- }> = [];
680
-
681
- // Extension commands
682
- for (const { command: cmd, extensionPath } of session.extensionRunner?.getRegisteredCommandsWithPaths() ?? []) {
683
- commands.push({
684
- name: cmd.name,
685
- description: cmd.description,
686
- source: "extension",
687
- path: extensionPath,
688
- });
689
- }
690
-
691
- // Prompt templates
692
- for (const template of session.promptTemplates) {
693
- commands.push({
694
- name: template.name,
695
- description: template.description,
696
- source: "prompt",
697
- location: template.source,
698
- path: template.filePath,
699
- });
700
- }
701
-
702
- // Skills
703
- for (const skill of session.resourceLoader.getSkills().skills) {
704
- commands.push({
705
- name: `skill:${skill.name}`,
706
- description: skill.description,
707
- source: "skill",
708
- location: skill.source,
709
- path: skill.filePath,
710
- });
711
- }
712
-
713
- return { id, type: "response", command: "get_commands", success: true, data: { commands } };
714
- }
715
-
716
- case "get_extensions": {
717
- const extensionsResult = session.resourceLoader.getExtensions();
718
- const extensions = extensionsResult.extensions.map((ext) => ({
719
- path: ext.path,
720
- resolvedPath: ext.resolvedPath,
721
- tools: Array.from(ext.tools.keys()),
722
- commands: Array.from(ext.commands.keys()),
723
- flags: Array.from(ext.flags.keys()),
724
- shortcuts: Array.from(ext.shortcuts.keys()),
725
- }));
726
-
727
- return {
728
- id,
729
- type: "response",
730
- command: "get_extensions",
731
- success: true,
732
- data: {
733
- extensions,
734
- errors: extensionsResult.errors,
735
- },
736
- };
737
- }
738
-
739
- case "get_skills": {
740
- const skillsResult = session.resourceLoader.getSkills();
741
- const skills = skillsResult.skills.map((skill) => ({
742
- name: skill.name,
743
- description: skill.description,
744
- filePath: skill.filePath,
745
- baseDir: skill.baseDir,
746
- source: skill.source,
747
- disableModelInvocation: skill.disableModelInvocation,
748
- }));
749
-
750
- return {
751
- id,
752
- type: "response",
753
- command: "get_skills",
754
- success: true,
755
- data: {
756
- skills,
757
- diagnostics: skillsResult.diagnostics,
758
- },
759
- };
760
- }
761
-
762
- case "install_package": {
763
- const { source, local } = command;
764
- const installCommand = `pi install ${local ? "-l " : ""}${source}`;
765
-
766
- try {
767
- // Run pi install via bash
768
- const result = await session.executeBash(installCommand);
769
-
770
- if (result.exitCode === 0) {
771
- // Successful install - reload the session to pick up new extensions/skills
772
- await session.reload();
773
-
774
- return {
775
- id,
776
- type: "response",
777
- command: "install_package",
778
- success: true,
779
- data: {
780
- output: result.output,
781
- exitCode: result.exitCode,
782
- },
783
- };
784
- }
785
-
786
- // Non-zero exit code - return as success but with exitCode info
787
- return {
788
- id,
789
- type: "response",
790
- command: "install_package",
791
- success: true,
792
- data: {
793
- output: result.output,
794
- exitCode: result.exitCode,
795
- },
796
- };
797
- } catch (err) {
798
- return {
799
- id,
800
- type: "response",
801
- command: "install_package",
802
- success: false,
803
- error: err instanceof Error ? err.message : String(err),
804
- };
805
- }
806
- }
807
-
808
- default: {
809
- // biome-ignore lint/suspicious/noExplicitAny: Need to access .type property on unknown command
810
- const unknownCommand = command as any;
811
- return {
812
- id,
813
- type: "response",
814
- command: unknownCommand.type,
815
- success: false,
816
- error: `Unknown command: ${unknownCommand.type}`,
817
- };
818
- }
819
- }
820
- }
821
-
822
- // ─── Auth command handling ─────────────────────────────────────────────────
823
-
824
- private async handleAuthCommand(
825
- ws: ServerWebSocket<ConnectionData>,
826
- command: RpcCommand,
827
- commandId?: string,
828
- ): Promise<void> {
829
- const authStorage = AuthStorage.create(getAuthPath());
830
-
831
- try {
832
- switch (command.type) {
833
- case "get_auth_providers": {
834
- // Get OAuth providers from pi SDK
835
- const oauthProviders = authStorage.getOAuthProviders();
836
- const oauthIds = new Set(oauthProviders.map((p) => p.id));
837
-
838
- // List of API key providers (filter out those that have OAuth)
839
- const apiKeyProviders = [
840
- { id: "anthropic", name: "Anthropic" },
841
- { id: "openai", name: "OpenAI" },
842
- { id: "google", name: "Google (Gemini)" },
843
- { id: "xai", name: "xAI (Grok)" },
844
- { id: "groq", name: "Groq" },
845
- { id: "openrouter", name: "OpenRouter" },
846
- { id: "mistral", name: "Mistral" },
847
- ].filter((p) => !oauthIds.has(p.id));
848
-
849
- // Combine both lists
850
- const providers = [
851
- ...oauthProviders.map((p) => ({
852
- id: p.id,
853
- name: p.name,
854
- type: "oauth" as const,
855
- hasAuth: authStorage.hasAuth(p.id),
856
- usesCallbackServer: p.usesCallbackServer ?? false,
857
- })),
858
- ...apiKeyProviders.map((p) => ({
859
- id: p.id,
860
- name: p.name,
861
- type: "apikey" as const,
862
- hasAuth: authStorage.hasAuth(p.id),
863
- usesCallbackServer: false,
864
- })),
865
- ];
866
-
867
- this.sendAuthResponse(ws, {
868
- id: commandId,
869
- type: "response",
870
- command: "get_auth_providers",
871
- success: true,
872
- data: { providers },
873
- });
874
- break;
875
- }
876
-
877
- case "auth_login": {
878
- const { providerId } = command;
879
-
880
- // Check if there's already an active login flow for this connection
881
- for (const [_flowId, flow] of this.pendingLoginFlows.entries()) {
882
- if (flow.ws === ws) {
883
- this.sendAuthResponse(ws, {
884
- id: commandId,
885
- type: "response",
886
- command: "auth_login",
887
- success: false,
888
- error: "Another login flow is already in progress",
889
- });
890
- return;
891
- }
892
- }
893
-
894
- const loginFlowId = crypto.randomUUID();
895
- const abortController = new AbortController();
896
-
897
- // Store the flow
898
- this.pendingLoginFlows.set(loginFlowId, {
899
- ws,
900
- inputResolver: null,
901
- abortController,
902
- });
903
-
904
- // Send initial response with flow ID
905
- this.sendAuthResponse(ws, {
906
- id: commandId,
907
- type: "response",
908
- command: "auth_login",
909
- success: true,
910
- data: { loginFlowId },
911
- });
912
-
913
- // Start the OAuth/login flow
914
- try {
915
- const callbacks: OAuthLoginCallbacks = {
916
- onAuth: (info) => {
917
- this.sendAuthEvent(ws, loginFlowId, {
918
- type: "auth_event",
919
- loginFlowId,
920
- event: "url",
921
- url: info.url,
922
- instructions: info.instructions,
923
- });
924
- },
925
- onPrompt: async (prompt) => {
926
- // Send prompt event and wait for client response
927
- return new Promise<string>((resolve) => {
928
- const flow = this.pendingLoginFlows.get(loginFlowId);
929
- if (flow) {
930
- flow.inputResolver = resolve;
931
- this.sendAuthEvent(ws, loginFlowId, {
932
- type: "auth_event",
933
- loginFlowId,
934
- event: "prompt",
935
- message: prompt.message,
936
- placeholder: prompt.placeholder,
937
- });
938
- } else {
939
- resolve(""); // Flow was cancelled
940
- }
941
- });
942
- },
943
- onProgress: (message) => {
944
- this.sendAuthEvent(ws, loginFlowId, {
945
- type: "auth_event",
946
- loginFlowId,
947
- event: "progress",
948
- message,
949
- });
950
- },
951
- onManualCodeInput: async () => {
952
- // Show manual input UI and wait for client response
953
- this.sendAuthEvent(ws, loginFlowId, {
954
- type: "auth_event",
955
- loginFlowId,
956
- event: "manual_input",
957
- });
958
-
959
- return new Promise<string>((resolve) => {
960
- const flow = this.pendingLoginFlows.get(loginFlowId);
961
- if (flow) {
962
- flow.inputResolver = resolve;
963
- } else {
964
- resolve(""); // Flow was cancelled
965
- }
966
- });
967
- },
968
- signal: abortController.signal,
969
- };
970
-
971
- await authStorage.login(providerId, callbacks);
972
-
973
- // Success
974
- this.sendAuthEvent(ws, loginFlowId, {
975
- type: "auth_event",
976
- loginFlowId,
977
- event: "complete",
978
- success: true,
979
- });
980
- } catch (err) {
981
- // Error or cancelled
982
- const isAborted = err instanceof Error && err.name === "AbortError";
983
- this.sendAuthEvent(ws, loginFlowId, {
984
- type: "auth_event",
985
- loginFlowId,
986
- event: "complete",
987
- success: false,
988
- error: isAborted ? "Login cancelled" : err instanceof Error ? err.message : String(err),
989
- });
990
- } finally {
991
- // Clean up
992
- this.pendingLoginFlows.delete(loginFlowId);
993
- }
994
- break;
995
- }
996
-
997
- case "auth_set_api_key": {
998
- const { providerId, apiKey } = command;
999
- authStorage.set(providerId, { type: "api_key", key: apiKey });
1000
-
1001
- this.sendAuthResponse(ws, {
1002
- id: commandId,
1003
- type: "response",
1004
- command: "auth_set_api_key",
1005
- success: true,
1006
- });
1007
- break;
1008
- }
1009
-
1010
- case "auth_login_input": {
1011
- const { loginFlowId, value } = command;
1012
- const flow = this.pendingLoginFlows.get(loginFlowId);
1013
-
1014
- if (flow?.inputResolver) {
1015
- flow.inputResolver(value);
1016
- flow.inputResolver = null;
1017
- }
1018
- // No response needed — this is fire-and-forget
1019
- break;
1020
- }
1021
-
1022
- case "auth_login_cancel": {
1023
- const { loginFlowId } = command;
1024
- const flow = this.pendingLoginFlows.get(loginFlowId);
1025
-
1026
- if (flow) {
1027
- flow.abortController.abort();
1028
- this.pendingLoginFlows.delete(loginFlowId);
1029
- }
1030
- // No response needed
1031
- break;
1032
- }
1033
-
1034
- case "auth_logout": {
1035
- const { providerId } = command;
1036
- authStorage.logout(providerId);
1037
-
1038
- this.sendAuthResponse(ws, {
1039
- id: commandId,
1040
- type: "response",
1041
- command: "auth_logout",
1042
- success: true,
1043
- });
1044
- break;
1045
- }
1046
- }
1047
- } catch (err) {
1048
- this.sendAuthResponse(ws, {
1049
- id: commandId,
1050
- type: "response",
1051
- command: command.type,
1052
- success: false,
1053
- error: err instanceof Error ? err.message : String(err),
1054
- });
1055
- }
1056
- }
1057
-
1058
- private sendAuthEvent(ws: ServerWebSocket<ConnectionData>, _loginFlowId: string, event: AuthEvent): void {
1059
- const message: OutboundWebMessage = {
1060
- sessionId: "_auth",
1061
- event,
1062
- };
1063
- ws.send(JSON.stringify(message));
1064
- }
1065
-
1066
- private sendAuthResponse(ws: ServerWebSocket<ConnectionData>, response: RpcResponse): void {
1067
- const message: OutboundWebMessage = {
1068
- sessionId: "_auth",
1069
- event: response,
1070
- };
1071
- ws.send(JSON.stringify(message));
1072
- }
1073
-
1074
- // ─── Session subscription ──────────────────────────────────────────────────
1075
-
1076
- private subscribeToSessionWithKey(chatKey: string, session: AgentSession, ws: ServerWebSocket<ConnectionData>): void {
1077
- // Track session with the chatKey (web_xxx)
1078
- this.sessions.set(chatKey, session);
1079
-
1080
- // Add connection to subscription set
1081
- let subscribers = this.sessionSubscriptions.get(chatKey);
1082
- if (!subscribers) {
1083
- subscribers = new Set();
1084
- this.sessionSubscriptions.set(chatKey, subscribers);
1085
- }
1086
- subscribers.add(ws);
1087
-
1088
- // Subscribe to session events if not already subscribed
1089
- if (!this.sessionUnsubscribers.has(chatKey)) {
1090
- const unsubscribe = session.subscribe((event) => {
1091
- this.broadcastEvent(chatKey, event);
1092
- });
1093
- this.sessionUnsubscribers.set(chatKey, unsubscribe);
1094
- }
1095
- }
1096
-
1097
- private broadcastEvent(sessionId: string, event: AgentSessionEvent): void {
1098
- const subscribers = this.sessionSubscriptions.get(sessionId);
1099
- if (!subscribers) {
1100
- console.log(`[web] No subscribers for session ${sessionId}, event ${event.type}`);
1101
- return;
1102
- }
1103
-
1104
- console.log(`[web] Broadcasting event ${event.type} to ${subscribers.size} subscribers for session ${sessionId}`);
1105
- const message: OutboundWebMessage = { sessionId, event };
1106
- const json = JSON.stringify(message);
1107
-
1108
- for (const ws of subscribers) {
1109
- try {
1110
- ws.send(json);
1111
- } catch (err) {
1112
- console.error("[web] Failed to send event:", err);
1113
- }
1114
- }
1115
- }
1116
-
1117
- // ─── Extension UI handling ─────────────────────────────────────────────────
1118
-
1119
- private handleExtensionUIResponse(response: RpcExtensionUIResponse): void {
1120
- const pending = this.pendingExtensionRequests.get(response.id);
1121
- if (!pending) {
1122
- console.warn(`[web] Received extension UI response for unknown request: ${response.id}`);
1123
- return;
1124
- }
1125
-
1126
- this.pendingExtensionRequests.delete(response.id);
1127
-
1128
- // Forward response to the session's extension runtime
1129
- // This is handled by the extension runtime's pending request map
1130
- // We just need to route it back through the session
1131
-
1132
- // For now, log a warning - full extension UI support needs more plumbing
1133
- console.warn("[web] Extension UI responses not yet fully implemented");
1134
- }
1135
-
1136
- // ─── Helpers ───────────────────────────────────────────────────────────────
1137
-
1138
- /**
1139
- * Extract title from a session directory by reading the last user message from JSONL files
1140
- */
1141
- private getSessionTitleFromDisk(sessionPath: string): string | undefined {
1142
- try {
1143
- // Find the most recent .jsonl file
1144
- const files = readdirSync(sessionPath)
1145
- .filter((f) => f.endsWith(".jsonl"))
1146
- .map((f) => ({
1147
- name: f,
1148
- path: join(sessionPath, f),
1149
- mtime: statSync(join(sessionPath, f)).mtime.getTime(),
1150
- }))
1151
- .sort((a, b) => b.mtime - a.mtime);
1152
-
1153
- if (files.length === 0) return undefined;
1154
-
1155
- // Read the most recent file and parse JSONL
1156
- const content = readFileSync(files[0].path, "utf-8");
1157
- const lines = content.trim().split("\n");
1158
-
1159
- // Find the last user message
1160
- let lastUserMessage: string | undefined;
1161
- for (let i = lines.length - 1; i >= 0; i--) {
1162
- try {
1163
- const entry = JSON.parse(lines[i]);
1164
- if (entry.type === "message" && entry.message?.role === "user") {
1165
- // Extract text content
1166
- const textContent = entry.message.content
1167
- ?.filter((c: any) => c.type === "text")
1168
- .map((c: any) => c.text)
1169
- .join(" ");
1170
- if (textContent) {
1171
- lastUserMessage = textContent.substring(0, 100);
1172
- break;
1173
- }
1174
- }
1175
- } catch {}
1176
- }
1177
-
1178
- return lastUserMessage;
1179
- } catch (_err) {
1180
- return undefined;
1181
- }
1182
- }
1183
-
1184
- private async listAllSessions(): Promise<Array<{ sessionId: string; title?: string; messageCount: number }>> {
1185
- const sessions: Array<{ sessionId: string; title?: string; messageCount: number }> = [];
1186
- const sessionsDir = join(getAppDir(), "sessions");
1187
-
1188
- if (!existsSync(sessionsDir)) {
1189
- return sessions;
1190
- }
1191
-
1192
- try {
1193
- // Get all web_* session directories
1194
- const dirs = readdirSync(sessionsDir);
1195
- const webSessions = dirs
1196
- .filter((dir) => dir.startsWith("web_"))
1197
- .map((dir) => ({ sessionId: dir, path: join(sessionsDir, dir) }))
1198
- .filter(({ path }) => {
1199
- try {
1200
- return statSync(path).isDirectory();
1201
- } catch {
1202
- return false;
1203
- }
1204
- })
1205
- // Sort by modification time, newest first
1206
- .sort((a, b) => {
1207
- try {
1208
- const aMtime = statSync(a.path).mtime.getTime();
1209
- const bMtime = statSync(b.path).mtime.getTime();
1210
- return bMtime - aMtime;
1211
- } catch {
1212
- return 0;
1213
- }
1214
- });
1215
-
1216
- // For each session directory, check if it's in memory or read from disk
1217
- for (const { sessionId, path } of webSessions) {
1218
- const inMemorySession = this.sessions.get(sessionId);
1219
-
1220
- if (inMemorySession) {
1221
- // Use in-memory session data
1222
- // Get the last user message as the title (like pi's /resume command)
1223
- const lastUserMessage = [...inMemorySession.messages].reverse().find((msg) => msg.role === "user");
1224
-
1225
- let title: string | undefined;
1226
- if (lastUserMessage) {
1227
- // Extract text from content
1228
- if (typeof lastUserMessage.content === "string") {
1229
- title = lastUserMessage.content.substring(0, 100);
1230
- } else if (Array.isArray(lastUserMessage.content)) {
1231
- const textContent = lastUserMessage.content
1232
- .filter((c: any) => c.type === "text")
1233
- .map((c: any) => c.text)
1234
- .join(" ");
1235
- title = textContent?.substring(0, 100) || inMemorySession.sessionName;
1236
- }
1237
- }
1238
-
1239
- if (!title) {
1240
- title = inMemorySession.sessionName;
1241
- }
1242
-
1243
- sessions.push({
1244
- sessionId,
1245
- title,
1246
- messageCount: inMemorySession.messages.length,
1247
- });
1248
- } else {
1249
- // For sessions not in memory, read title from disk
1250
- const title = this.getSessionTitleFromDisk(path);
1251
-
1252
- sessions.push({
1253
- sessionId,
1254
- title,
1255
- messageCount: 0, // We don't count messages for sessions not in memory
1256
- });
1257
- }
1258
- }
1259
- } catch (err) {
1260
- console.error("[web] Failed to list sessions:", err);
1261
- }
1262
-
1263
- return sessions;
1264
- }
1265
-
1266
- private sendResponse(
1267
- ws: ServerWebSocket<ConnectionData>,
1268
- sessionId: string | undefined,
1269
- response: RpcResponse,
1270
- ): void {
1271
- if (!sessionId) {
1272
- // Special case for responses without session context
1273
- ws.send(JSON.stringify(response));
1274
- return;
1275
- }
1276
-
1277
- const message: OutboundWebMessage = { sessionId, event: response };
1278
- ws.send(JSON.stringify(message));
1279
- }
1280
-
1281
- private sendError(
1282
- ws: ServerWebSocket<ConnectionData>,
1283
- sessionId: string | undefined,
1284
- command: string,
1285
- error: string,
1286
- commandId?: string,
1287
- ): void {
1288
- const response: RpcResponse = {
1289
- id: commandId,
1290
- type: "response",
1291
- command,
1292
- success: false,
1293
- error,
1294
- };
1295
- this.sendResponse(ws, sessionId, response);
1296
- }
1297
-
1298
- // ─── Static file serving ───────────────────────────────────────────────────
1299
-
1300
- private async serveStaticFile(req: Request): Promise<Response> {
1301
- if (!this.options.staticDir) {
1302
- return new Response("Not Found", { status: 404 });
1303
- }
1304
-
1305
- try {
1306
- const url = new URL(req.url);
1307
- let pathname = url.pathname;
1308
-
1309
- // Remove leading slash
1310
- if (pathname.startsWith("/")) {
1311
- pathname = pathname.substring(1);
1312
- }
1313
-
1314
- // Default to index for root
1315
- if (pathname === "" || pathname === "/") {
1316
- pathname = "_shell.html";
1317
- }
1318
-
1319
- // Try to serve the requested file
1320
- const filePath = join(this.options.staticDir, pathname);
1321
-
1322
- // Security: ensure the resolved path is within staticDir (prevent directory traversal)
1323
- const { resolve } = await import("node:path");
1324
- const resolvedPath = resolve(this.options.staticDir, pathname);
1325
- if (!resolvedPath.startsWith(resolve(this.options.staticDir))) {
1326
- return new Response("Forbidden", { status: 403 });
1327
- }
1328
-
1329
- // Check if file exists
1330
- if (existsSync(resolvedPath) && statSync(resolvedPath).isFile()) {
1331
- const file = Bun.file(resolvedPath);
1332
-
1333
- // Set caching headers for hashed assets
1334
- const headers = new Headers();
1335
- if (pathname.startsWith("assets/")) {
1336
- headers.set("Cache-Control", "public, max-age=31536000, immutable");
1337
- } else {
1338
- headers.set("Cache-Control", "public, max-age=3600");
1339
- }
1340
-
1341
- return new Response(file, { headers });
1342
- }
1343
-
1344
- // SPA fallback: serve _shell.html for non-file routes
1345
- const shellPath = join(this.options.staticDir, "_shell.html");
1346
- if (existsSync(shellPath)) {
1347
- const file = Bun.file(shellPath);
1348
- return new Response(file, {
1349
- headers: {
1350
- "Content-Type": "text/html",
1351
- "Cache-Control": "no-cache",
1352
- },
1353
- });
1354
- }
1355
-
1356
- return new Response("Not Found", { status: 404 });
1357
- } catch (err) {
1358
- console.error("[web] Error serving static file:", err);
1359
- return new Response("Internal Server Error", { status: 500 });
1360
- }
1361
- }
1362
- }