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