@ccpocket-base-auth/bridge 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +67 -0
  2. package/dist/archive-store.d.ts +28 -0
  3. package/dist/archive-store.js +68 -0
  4. package/dist/archive-store.js.map +1 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +82 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/codex-process.d.ts +171 -0
  9. package/dist/codex-process.js +1928 -0
  10. package/dist/codex-process.js.map +1 -0
  11. package/dist/debug-trace-store.d.ts +15 -0
  12. package/dist/debug-trace-store.js +78 -0
  13. package/dist/debug-trace-store.js.map +1 -0
  14. package/dist/doctor.d.ts +58 -0
  15. package/dist/doctor.js +663 -0
  16. package/dist/doctor.js.map +1 -0
  17. package/dist/firebase-auth.d.ts +35 -0
  18. package/dist/firebase-auth.js +132 -0
  19. package/dist/firebase-auth.js.map +1 -0
  20. package/dist/gallery-store.d.ts +67 -0
  21. package/dist/gallery-store.js +333 -0
  22. package/dist/gallery-store.js.map +1 -0
  23. package/dist/image-store.d.ts +23 -0
  24. package/dist/image-store.js +142 -0
  25. package/dist/image-store.js.map +1 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +191 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/mdns.d.ts +7 -0
  30. package/dist/mdns.js +49 -0
  31. package/dist/mdns.js.map +1 -0
  32. package/dist/parser.d.ts +465 -0
  33. package/dist/parser.js +251 -0
  34. package/dist/parser.js.map +1 -0
  35. package/dist/project-history.d.ts +10 -0
  36. package/dist/project-history.js +73 -0
  37. package/dist/project-history.js.map +1 -0
  38. package/dist/prompt-history-backup.d.ts +15 -0
  39. package/dist/prompt-history-backup.js +46 -0
  40. package/dist/prompt-history-backup.js.map +1 -0
  41. package/dist/proxy.d.ts +15 -0
  42. package/dist/proxy.js +95 -0
  43. package/dist/proxy.js.map +1 -0
  44. package/dist/push-i18n.d.ts +7 -0
  45. package/dist/push-i18n.js +75 -0
  46. package/dist/push-i18n.js.map +1 -0
  47. package/dist/push-relay.d.ts +29 -0
  48. package/dist/push-relay.js +70 -0
  49. package/dist/push-relay.js.map +1 -0
  50. package/dist/recording-store.d.ts +51 -0
  51. package/dist/recording-store.js +158 -0
  52. package/dist/recording-store.js.map +1 -0
  53. package/dist/screenshot.d.ts +28 -0
  54. package/dist/screenshot.js +98 -0
  55. package/dist/screenshot.js.map +1 -0
  56. package/dist/sdk-process.d.ts +180 -0
  57. package/dist/sdk-process.js +937 -0
  58. package/dist/sdk-process.js.map +1 -0
  59. package/dist/session.d.ts +142 -0
  60. package/dist/session.js +615 -0
  61. package/dist/session.js.map +1 -0
  62. package/dist/sessions-index.d.ts +128 -0
  63. package/dist/sessions-index.js +1767 -0
  64. package/dist/sessions-index.js.map +1 -0
  65. package/dist/setup-launchd.d.ts +8 -0
  66. package/dist/setup-launchd.js +109 -0
  67. package/dist/setup-launchd.js.map +1 -0
  68. package/dist/setup-systemd.d.ts +8 -0
  69. package/dist/setup-systemd.js +118 -0
  70. package/dist/setup-systemd.js.map +1 -0
  71. package/dist/startup-info.d.ts +8 -0
  72. package/dist/startup-info.js +92 -0
  73. package/dist/startup-info.js.map +1 -0
  74. package/dist/usage.d.ts +69 -0
  75. package/dist/usage.js +545 -0
  76. package/dist/usage.js.map +1 -0
  77. package/dist/version.d.ts +13 -0
  78. package/dist/version.js +43 -0
  79. package/dist/version.js.map +1 -0
  80. package/dist/websocket.d.ts +127 -0
  81. package/dist/websocket.js +2482 -0
  82. package/dist/websocket.js.map +1 -0
  83. package/dist/worktree-store.d.ts +25 -0
  84. package/dist/worktree-store.js +59 -0
  85. package/dist/worktree-store.js.map +1 -0
  86. package/dist/worktree.d.ts +47 -0
  87. package/dist/worktree.js +313 -0
  88. package/dist/worktree.js.map +1 -0
  89. package/package.json +68 -0
@@ -0,0 +1,2482 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, unlink } from "node:fs/promises";
4
+ import { resolve, extname } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { WebSocketServer, WebSocket } from "ws";
7
+ import { SessionManager } from "./session.js";
8
+ import { SdkProcess } from "./sdk-process.js";
9
+ import { CodexProcess } from "./codex-process.js";
10
+ import { parseClientMessage } from "./parser.js";
11
+ import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, loadCodexSessionNames, renameClaudeSession, renameCodexSession } from "./sessions-index.js";
12
+ import { ArchiveStore } from "./archive-store.js";
13
+ import { WorktreeStore } from "./worktree-store.js";
14
+ import { listWorktrees, removeWorktree, worktreeExists, getMainBranch } from "./worktree.js";
15
+ import { listWindows, takeScreenshot } from "./screenshot.js";
16
+ import { DebugTraceStore } from "./debug-trace-store.js";
17
+ import { PushRelayClient } from "./push-relay.js";
18
+ import { normalizePushLocale, t } from "./push-i18n.js";
19
+ import { fetchAllUsage } from "./usage.js";
20
+ import { getPackageVersion } from "./version.js";
21
+ // ---- Available model lists (delivered to clients via session_list) ----
22
+ const CLAUDE_MODELS = [
23
+ "claude-opus-4-6[1m]",
24
+ "claude-opus-4-6",
25
+ "claude-sonnet-4-6",
26
+ "claude-haiku-4-6",
27
+ ];
28
+ const CODEX_MODELS = [
29
+ "gpt-5.4",
30
+ "gpt-5.4-mini",
31
+ "gpt-5.3-codex",
32
+ "gpt-5.3-codex-spark",
33
+ "gpt-5.2-codex",
34
+ ];
35
+ // ---- Codex mode mapping helpers ----
36
+ /** Map unified PermissionMode to Codex approval_policy.
37
+ * Only "bypassPermissions" maps to "never"; all others use "on-request". */
38
+ function permissionModeToApprovalPolicy(mode) {
39
+ return mode === "bypassPermissions" ? "never" : "on-request";
40
+ }
41
+ /** Map simplified SandboxMode (on/off) to Codex internal sandbox mode. */
42
+ function sandboxModeToInternal(mode) {
43
+ switch (mode) {
44
+ case "danger-full-access":
45
+ case "workspace-write":
46
+ case "read-only":
47
+ return mode;
48
+ case "off":
49
+ return "danger-full-access";
50
+ default:
51
+ return "workspace-write";
52
+ }
53
+ }
54
+ /** Map Codex internal sandbox mode back to simplified on/off for clients. */
55
+ function sandboxModeToExternal(mode) {
56
+ return mode === "danger-full-access" ? "off" : "on";
57
+ }
58
+ function threadTimestampToIso(value) {
59
+ return value > 0 ? new Date(value * 1000).toISOString() : "";
60
+ }
61
+ function codexThreadToRecentSession(thread, indexed) {
62
+ return {
63
+ sessionId: thread.id,
64
+ provider: "codex",
65
+ ...(thread.name ? { name: thread.name } : {}),
66
+ ...(thread.agentNickname ? { agentNickname: thread.agentNickname } : {}),
67
+ ...(thread.agentRole ? { agentRole: thread.agentRole } : {}),
68
+ summary: thread.preview || undefined,
69
+ firstPrompt: thread.preview || "",
70
+ created: threadTimestampToIso(thread.createdAt),
71
+ modified: threadTimestampToIso(thread.updatedAt),
72
+ gitBranch: thread.gitBranch ?? "",
73
+ projectPath: thread.cwd,
74
+ ...(indexed?.resumeCwd ? { resumeCwd: indexed.resumeCwd } : {}),
75
+ isSidechain: false,
76
+ ...(indexed?.codexSettings ? { codexSettings: indexed.codexSettings } : {}),
77
+ };
78
+ }
79
+ export class BridgeWebSocketServer {
80
+ static MAX_DEBUG_EVENTS = 800;
81
+ static MAX_HISTORY_SUMMARY_ITEMS = 300;
82
+ wss;
83
+ sessionManager;
84
+ apiKey;
85
+ allowedDirs;
86
+ imageStore;
87
+ galleryStore;
88
+ projectHistory;
89
+ debugTraceStore;
90
+ recordingStore;
91
+ worktreeStore;
92
+ pushRelay;
93
+ promptHistoryBackup;
94
+ recentSessionsRequestId = 0;
95
+ debugEvents = new Map();
96
+ notifiedPermissionToolUses = new Map();
97
+ archiveStore;
98
+ /** FCM token → push notification locale */
99
+ tokenLocales = new Map();
100
+ tokenPrivacyMode = new Map();
101
+ constructor(options) {
102
+ const { server, apiKey, allowedDirs, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup } = options;
103
+ this.apiKey = apiKey ?? null;
104
+ this.allowedDirs = allowedDirs ?? [];
105
+ this.imageStore = imageStore ?? null;
106
+ this.galleryStore = galleryStore ?? null;
107
+ this.projectHistory = projectHistory ?? null;
108
+ this.debugTraceStore = debugTraceStore ?? new DebugTraceStore();
109
+ this.recordingStore = recordingStore ?? null;
110
+ this.worktreeStore = new WorktreeStore();
111
+ this.pushRelay = new PushRelayClient({ firebaseAuth });
112
+ this.promptHistoryBackup = promptHistoryBackup ?? null;
113
+ this.archiveStore = new ArchiveStore();
114
+ void this.debugTraceStore.init().catch((err) => {
115
+ console.error("[ws] Failed to initialize debug trace store:", err);
116
+ });
117
+ if (this.recordingStore) {
118
+ void this.recordingStore.init().catch((err) => {
119
+ console.error("[ws] Failed to initialize recording store:", err);
120
+ });
121
+ }
122
+ void this.archiveStore.init().catch((err) => {
123
+ console.error("[ws] Failed to initialize archive store:", err);
124
+ });
125
+ if (!this.pushRelay.isConfigured) {
126
+ console.log("[ws] Push relay disabled (Firebase auth not available)");
127
+ }
128
+ else {
129
+ console.log("[ws] Push relay enabled (Firebase Anonymous Auth)");
130
+ }
131
+ this.wss = new WebSocketServer({ server });
132
+ this.sessionManager = new SessionManager((sessionId, msg) => {
133
+ this.broadcastSessionMessage(sessionId, msg);
134
+ }, imageStore, galleryStore,
135
+ // Broadcast gallery_new_image when a new image is added
136
+ (meta) => {
137
+ if (this.galleryStore) {
138
+ const info = this.galleryStore.metaToInfo(meta);
139
+ this.broadcast({ type: "gallery_new_image", image: info });
140
+ }
141
+ }, this.worktreeStore);
142
+ this.wss.on("connection", (ws, req) => {
143
+ // API key authentication
144
+ if (this.apiKey) {
145
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
146
+ const token = url.searchParams.get("token");
147
+ if (token !== this.apiKey) {
148
+ console.log("[ws] Client rejected: invalid token");
149
+ ws.close(4001, "Unauthorized");
150
+ return;
151
+ }
152
+ }
153
+ console.log("[ws] Client connected");
154
+ this.handleConnection(ws);
155
+ });
156
+ this.wss.on("error", (err) => {
157
+ console.error("[ws] Server error:", err.message);
158
+ });
159
+ console.log(`[ws] WebSocket server attached to HTTP server`);
160
+ }
161
+ /**
162
+ * Validate that a project path is within the allowed directories.
163
+ * Returns true if the path is allowed, false otherwise.
164
+ */
165
+ isPathAllowed(path) {
166
+ if (this.allowedDirs.length === 0)
167
+ return true;
168
+ const resolved = resolve(path);
169
+ return this.allowedDirs.some((dir) => resolved === dir || resolved.startsWith(dir + "/"));
170
+ }
171
+ /** Build a user-friendly error for disallowed project paths. */
172
+ buildPathNotAllowedError(projectPath) {
173
+ return {
174
+ type: "error",
175
+ message: `⚠ Project path not allowed\n\n"${projectPath}" is not in the allowed directories.\n\nFix: Update BRIDGE_ALLOWED_DIRS on the Bridge server to include this path.`,
176
+ errorCode: "path_not_allowed",
177
+ };
178
+ }
179
+ close() {
180
+ console.log("[ws] Shutting down...");
181
+ this.sessionManager.destroyAll();
182
+ this.debugEvents.clear();
183
+ this.wss.close();
184
+ }
185
+ /** Return session count for /health endpoint. */
186
+ get sessionCount() {
187
+ return this.sessionManager.list().length;
188
+ }
189
+ /** Return connected WebSocket client count. */
190
+ get clientCount() {
191
+ return this.wss.clients.size;
192
+ }
193
+ handleConnection(ws) {
194
+ // Send session list and project history on connect
195
+ this.sendSessionList(ws);
196
+ const projects = this.projectHistory?.getProjects() ?? [];
197
+ this.send(ws, { type: "project_history", projects });
198
+ ws.on("message", (data) => {
199
+ const raw = data.toString();
200
+ const msg = parseClientMessage(raw);
201
+ if (!msg) {
202
+ // Try to extract the message type so the client can decide how to
203
+ // handle the unsupported message (suppress vs show update hint).
204
+ let rawType;
205
+ try {
206
+ rawType = JSON.parse(raw)?.type;
207
+ }
208
+ catch { /* ignore */ }
209
+ console.error("[ws] Unsupported message:", rawType ?? raw.slice(0, 200));
210
+ this.send(ws, {
211
+ type: "error",
212
+ errorCode: "unsupported_message",
213
+ message: rawType ?? "unknown",
214
+ });
215
+ return;
216
+ }
217
+ console.log(`[ws] Received: ${msg.type}`);
218
+ this.handleClientMessage(msg, ws);
219
+ });
220
+ ws.on("close", () => {
221
+ console.log("[ws] Client disconnected");
222
+ });
223
+ ws.on("error", (err) => {
224
+ console.error("[ws] Client error:", err.message);
225
+ });
226
+ }
227
+ async handleClientMessage(msg, ws) {
228
+ const incomingSessionId = this.extractSessionIdFromClientMessage(msg);
229
+ const isActiveRuntimeSession = incomingSessionId != null && this.sessionManager.get(incomingSessionId) != null;
230
+ if (incomingSessionId && isActiveRuntimeSession) {
231
+ this.recordDebugEvent(incomingSessionId, {
232
+ direction: "incoming",
233
+ channel: "ws",
234
+ type: msg.type,
235
+ detail: this.summarizeClientMessage(msg),
236
+ });
237
+ this.recordingStore?.record(incomingSessionId, "incoming", msg);
238
+ }
239
+ switch (msg.type) {
240
+ case "start": {
241
+ if (!this.isPathAllowed(msg.projectPath)) {
242
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
243
+ break;
244
+ }
245
+ try {
246
+ const provider = msg.provider ?? "claude";
247
+ if (provider === "codex") {
248
+ console.log(`[ws] start(codex): permissionMode=${msg.permissionMode} → collaboration=${msg.permissionMode === "plan" ? "plan" : "default"}`);
249
+ }
250
+ const cached = provider === "claude" ? this.sessionManager.getCachedCommands(msg.projectPath) : undefined;
251
+ const sessionId = this.sessionManager.create(msg.projectPath, {
252
+ sessionId: msg.sessionId,
253
+ continueMode: msg.continue,
254
+ permissionMode: msg.permissionMode,
255
+ model: msg.model,
256
+ effort: msg.effort,
257
+ maxTurns: msg.maxTurns,
258
+ maxBudgetUsd: msg.maxBudgetUsd,
259
+ fallbackModel: msg.fallbackModel,
260
+ forkSession: msg.forkSession,
261
+ persistSession: msg.persistSession,
262
+ // Claude sandbox: map "on"/"off" to boolean
263
+ ...(provider === "claude" && msg.sandboxMode
264
+ ? { sandboxEnabled: msg.sandboxMode === "on" }
265
+ : {}),
266
+ }, undefined, {
267
+ useWorktree: msg.useWorktree,
268
+ worktreeBranch: msg.worktreeBranch,
269
+ existingWorktreePath: msg.existingWorktreePath,
270
+ }, provider, provider === "codex"
271
+ ? {
272
+ approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
273
+ sandboxMode: sandboxModeToInternal(msg.sandboxMode),
274
+ model: msg.model,
275
+ modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
276
+ networkAccessEnabled: msg.networkAccessEnabled,
277
+ webSearchMode: msg.webSearchMode ?? undefined,
278
+ threadId: msg.sessionId,
279
+ collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
280
+ }
281
+ : undefined);
282
+ const createdSession = this.sessionManager.get(sessionId);
283
+ // Load saved session name from CLI storage (for resumed sessions)
284
+ void this.loadAndSetSessionName(createdSession, provider, msg.projectPath, msg.sessionId).then(() => {
285
+ this.send(ws, {
286
+ type: "system",
287
+ subtype: "session_created",
288
+ sessionId,
289
+ provider,
290
+ projectPath: msg.projectPath,
291
+ ...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
292
+ ...(msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
293
+ ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills, ...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}) } : {}),
294
+ ...(createdSession?.worktreePath ? {
295
+ worktreePath: createdSession.worktreePath,
296
+ worktreeBranch: createdSession.worktreeBranch,
297
+ } : {}),
298
+ });
299
+ this.broadcastSessionList();
300
+ // Send a gentle tip when the project is not a git repository
301
+ if (createdSession && !createdSession.gitBranch) {
302
+ const tipMsg = {
303
+ type: "system",
304
+ subtype: "tip",
305
+ tipCode: "git_not_available",
306
+ sessionId,
307
+ };
308
+ createdSession.history.push(tipMsg);
309
+ this.send(ws, tipMsg);
310
+ }
311
+ });
312
+ this.debugEvents.set(sessionId, []);
313
+ this.recordDebugEvent(sessionId, {
314
+ direction: "internal",
315
+ channel: "bridge",
316
+ type: "session_created",
317
+ detail: `provider=${provider} projectPath=${msg.projectPath}`,
318
+ });
319
+ this.recordingStore?.saveMeta(sessionId, {
320
+ bridgeSessionId: sessionId,
321
+ projectPath: msg.projectPath,
322
+ createdAt: new Date().toISOString(),
323
+ });
324
+ this.projectHistory?.addProject(msg.projectPath);
325
+ }
326
+ catch (err) {
327
+ console.error(`[ws] Failed to start session:`, err);
328
+ this.send(ws, { type: "error", message: `Failed to start session: ${err.message}` });
329
+ }
330
+ break;
331
+ }
332
+ case "input": {
333
+ const session = this.resolveSession(msg.sessionId);
334
+ if (!session) {
335
+ this.send(ws, { type: "error", message: "No active session. Send 'start' first." });
336
+ return;
337
+ }
338
+ const text = msg.text;
339
+ // Codex: reject if the process is not waiting for input (turn-based, no internal queue)
340
+ if (session.provider === "codex" && !session.process.isWaitingForInput) {
341
+ this.send(ws, { type: "input_rejected", sessionId: session.id, reason: "Process is busy" });
342
+ break;
343
+ }
344
+ // Snapshot busy state before dispatch. We prefer the actual enqueue
345
+ // result returned by SdkProcess sendInput* below, but keep this as a
346
+ // fallback for test doubles and async paths.
347
+ const isAgentBusySnapshot = session.provider === "claude" && !session.process.isWaitingForInput;
348
+ // Normalize images: support new `images` array and legacy single-image fields
349
+ let images = [];
350
+ if (msg.images && msg.images.length > 0) {
351
+ images = msg.images;
352
+ }
353
+ else if (msg.imageBase64 && msg.mimeType) {
354
+ // Legacy single-image fallback
355
+ images = [{ base64: msg.imageBase64, mimeType: msg.mimeType }];
356
+ }
357
+ // Add user_input to in-memory history.
358
+ // The SDK stream does NOT emit user messages, so session.history would
359
+ // otherwise lack them. This ensures get_history responses include user
360
+ // messages and replaceEntries on the client side preserves them.
361
+ // We do NOT broadcast this back — Flutter already shows it via sendMessage().
362
+ //
363
+ // Register images in the image store so they can be served via HTTP
364
+ // when the client re-enters the session and loads history.
365
+ let imageRefs;
366
+ if (images.length > 0 && this.imageStore) {
367
+ imageRefs = [];
368
+ for (const img of images) {
369
+ const ref = this.imageStore.registerFromBase64(img.base64, img.mimeType);
370
+ if (ref)
371
+ imageRefs.push(ref);
372
+ }
373
+ if (imageRefs.length === 0)
374
+ imageRefs = undefined;
375
+ }
376
+ session.history.push({
377
+ type: "user_input",
378
+ text,
379
+ timestamp: new Date().toISOString(),
380
+ ...(images.length > 0 ? { imageCount: images.length } : {}),
381
+ ...(imageRefs ? { images: imageRefs } : {}),
382
+ });
383
+ // Persist images to Gallery Store asynchronously (fire-and-forget)
384
+ if (images.length > 0 && this.galleryStore && session.projectPath) {
385
+ for (const img of images) {
386
+ this.galleryStore.addImageFromBase64(img.base64, img.mimeType, session.projectPath, msg.sessionId).catch((err) => {
387
+ console.warn(`[ws] Failed to persist image to gallery: ${err}`);
388
+ });
389
+ }
390
+ }
391
+ // Codex input path
392
+ if (session.provider === "codex") {
393
+ this.send(ws, { type: "input_ack", sessionId: session.id, queued: false });
394
+ const codexProc = session.process;
395
+ if (images.length > 0) {
396
+ codexProc.sendInputWithImages(text, images);
397
+ }
398
+ else if (msg.imageId && this.galleryStore) {
399
+ this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
400
+ if (imageData) {
401
+ codexProc.sendInputWithImages(text, [imageData]);
402
+ }
403
+ else {
404
+ console.warn(`[ws] Image not found: ${msg.imageId}`);
405
+ codexProc.sendInput(text);
406
+ }
407
+ }).catch((err) => {
408
+ console.error(`[ws] Failed to load image: ${err}`);
409
+ codexProc.sendInput(text);
410
+ });
411
+ }
412
+ else if (msg.skill) {
413
+ codexProc.sendInputWithSkill(text, msg.skill);
414
+ }
415
+ else {
416
+ codexProc.sendInput(text);
417
+ }
418
+ break;
419
+ }
420
+ // Claude Code input path — enqueue first, then interrupt if busy
421
+ const claudeProc = session.process;
422
+ let wasQueued = false;
423
+ if (images.length > 0) {
424
+ console.log(`[ws] Sending message with ${images.length} inline Base64 image(s)`);
425
+ const result = claudeProc.sendInputWithImages(text, images);
426
+ wasQueued = typeof result === "boolean" ? result : isAgentBusySnapshot;
427
+ }
428
+ // Legacy imageId mode (backward compatibility)
429
+ else if (msg.imageId && this.galleryStore) {
430
+ this.send(ws, {
431
+ type: "input_ack",
432
+ sessionId: session.id,
433
+ queued: isAgentBusySnapshot,
434
+ });
435
+ this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
436
+ let queuedAfterResolve = false;
437
+ if (imageData) {
438
+ const result = claudeProc.sendInputWithImages(text, [imageData]);
439
+ queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
440
+ }
441
+ else {
442
+ console.warn(`[ws] Image not found: ${msg.imageId}`);
443
+ const result = session.process.sendInput(text);
444
+ queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
445
+ }
446
+ if (queuedAfterResolve) {
447
+ console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
448
+ claudeProc.interrupt();
449
+ }
450
+ }).catch((err) => {
451
+ console.error(`[ws] Failed to load image: ${err}`);
452
+ const result = session.process.sendInput(text);
453
+ const queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
454
+ if (queuedAfterResolve) {
455
+ console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
456
+ claudeProc.interrupt();
457
+ }
458
+ });
459
+ break;
460
+ }
461
+ // Text-only message
462
+ else {
463
+ const result = session.process.sendInput(text);
464
+ wasQueued = typeof result === "boolean" ? result : isAgentBusySnapshot;
465
+ }
466
+ // Acknowledge receipt so the client can mark the message state.
467
+ // queued=true means the input was enqueued instead of being consumed
468
+ // immediately by the SDK stream.
469
+ this.send(ws, { type: "input_ack", sessionId: session.id, queued: wasQueued });
470
+ if (wasQueued) {
471
+ console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
472
+ claudeProc.interrupt();
473
+ }
474
+ break;
475
+ }
476
+ case "push_register": {
477
+ const locale = normalizePushLocale(msg.locale);
478
+ const privacyMode = msg.privacyMode === true;
479
+ console.log(`[ws] push_register received (platform: ${msg.platform}, locale: ${locale}, privacy: ${privacyMode}, configured: ${this.pushRelay.isConfigured})`);
480
+ if (!this.pushRelay.isConfigured) {
481
+ this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
482
+ return;
483
+ }
484
+ this.tokenLocales.set(msg.token, locale);
485
+ this.tokenPrivacyMode.set(msg.token, privacyMode);
486
+ this.pushRelay.registerToken(msg.token, msg.platform, locale).then(() => {
487
+ console.log("[ws] push_register: token registered successfully");
488
+ }).catch((err) => {
489
+ const detail = err instanceof Error ? err.message : String(err);
490
+ console.error(`[ws] push_register failed: ${detail}`);
491
+ this.send(ws, { type: "error", message: `Failed to register push token: ${detail}` });
492
+ });
493
+ break;
494
+ }
495
+ case "push_unregister": {
496
+ console.log("[ws] push_unregister received");
497
+ if (!this.pushRelay.isConfigured) {
498
+ this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
499
+ return;
500
+ }
501
+ this.tokenLocales.delete(msg.token);
502
+ this.tokenPrivacyMode.delete(msg.token);
503
+ this.pushRelay.unregisterToken(msg.token).then(() => {
504
+ console.log("[ws] push_unregister: token unregistered successfully");
505
+ }).catch((err) => {
506
+ const detail = err instanceof Error ? err.message : String(err);
507
+ console.error(`[ws] push_unregister failed: ${detail}`);
508
+ this.send(ws, { type: "error", message: `Failed to unregister push token: ${detail}` });
509
+ });
510
+ break;
511
+ }
512
+ case "set_permission_mode": {
513
+ const session = this.resolveSession(msg.sessionId);
514
+ if (!session) {
515
+ this.send(ws, { type: "error", message: "No active session." });
516
+ return;
517
+ }
518
+ if (session.provider === "codex") {
519
+ // Permission mode for Codex requires a session restart (like sandbox mode).
520
+ // approvalPolicy and collaborationMode are thread-level settings that
521
+ // only take effect reliably at thread/start or thread/resume time.
522
+ const newApproval = permissionModeToApprovalPolicy(msg.mode);
523
+ const newCollaboration = msg.mode === "plan" ? "plan" : "default";
524
+ const currentApproval = session.process.approvalPolicy;
525
+ const currentCollaboration = session.process.collaborationMode;
526
+ if (newApproval === currentApproval && newCollaboration === currentCollaboration) {
527
+ break; // No change needed
528
+ }
529
+ console.log(`[ws] set_permission_mode(codex): mode=${msg.mode} → approval=${newApproval}, collaboration=${newCollaboration} (restart)`);
530
+ const oldSessionId = session.id;
531
+ const threadId = session.claudeSessionId;
532
+ const projectPath = session.projectPath;
533
+ const oldSettings = session.codexSettings ?? {};
534
+ const worktreePath = session.worktreePath;
535
+ const worktreeBranch = session.worktreeBranch;
536
+ const sessionName = session.name;
537
+ this.sessionManager.destroy(oldSessionId);
538
+ console.log(`[ws] Permission mode change: destroyed session ${oldSessionId}`);
539
+ const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") || (session.pastMessages && session.pastMessages.length > 0);
540
+ if (!threadId || !hasUserMessages) {
541
+ const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined, "codex", {
542
+ approvalPolicy: newApproval,
543
+ sandboxMode: oldSettings.sandboxMode,
544
+ model: oldSettings.model,
545
+ modelReasoningEffort: oldSettings.modelReasoningEffort,
546
+ networkAccessEnabled: oldSettings.networkAccessEnabled,
547
+ webSearchMode: oldSettings.webSearchMode,
548
+ collaborationMode: newCollaboration,
549
+ });
550
+ const newSession = this.sessionManager.get(newId);
551
+ if (newSession && sessionName)
552
+ newSession.name = sessionName;
553
+ this.broadcast({
554
+ type: "system",
555
+ subtype: "session_created",
556
+ sessionId: newId,
557
+ provider: "codex",
558
+ projectPath,
559
+ permissionMode: msg.mode,
560
+ ...(oldSettings.sandboxMode ? { sandboxMode: sandboxModeToExternal(oldSettings.sandboxMode) } : {}),
561
+ sourceSessionId: oldSessionId,
562
+ ...(newSession?.worktreePath ? { worktreePath: newSession.worktreePath, worktreeBranch: newSession.worktreeBranch } : {}),
563
+ });
564
+ this.broadcastSessionList();
565
+ console.log(`[ws] Permission mode change (no thread): created new session ${newId} (mode=${msg.mode})`);
566
+ break;
567
+ }
568
+ // Worktree resolution
569
+ const wtMapping = this.worktreeStore.get(threadId);
570
+ const effectiveProjectPath = wtMapping?.projectPath ?? projectPath;
571
+ let worktreeOpts;
572
+ if (wtMapping) {
573
+ if (worktreeExists(wtMapping.worktreePath)) {
574
+ worktreeOpts = { existingWorktreePath: wtMapping.worktreePath, worktreeBranch: wtMapping.worktreeBranch };
575
+ }
576
+ else {
577
+ worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
578
+ }
579
+ }
580
+ else if (worktreePath) {
581
+ worktreeOpts = { existingWorktreePath: worktreePath, worktreeBranch };
582
+ }
583
+ getCodexSessionHistory(threadId).then((pastMessages) => {
584
+ const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
585
+ threadId,
586
+ approvalPolicy: newApproval,
587
+ sandboxMode: oldSettings.sandboxMode,
588
+ model: oldSettings.model,
589
+ modelReasoningEffort: oldSettings.modelReasoningEffort,
590
+ networkAccessEnabled: oldSettings.networkAccessEnabled,
591
+ webSearchMode: oldSettings.webSearchMode,
592
+ collaborationMode: newCollaboration,
593
+ });
594
+ const newSession = this.sessionManager.get(newId);
595
+ if (newSession && sessionName) {
596
+ newSession.name = sessionName;
597
+ }
598
+ void this.loadAndSetSessionName(newSession, "codex", effectiveProjectPath, threadId).then(() => {
599
+ this.broadcast({
600
+ type: "system",
601
+ subtype: "session_created",
602
+ sessionId: newId,
603
+ provider: "codex",
604
+ projectPath: effectiveProjectPath,
605
+ permissionMode: msg.mode,
606
+ ...(oldSettings.sandboxMode ? { sandboxMode: sandboxModeToExternal(oldSettings.sandboxMode) } : {}),
607
+ sourceSessionId: oldSessionId,
608
+ ...(newSession?.worktreePath ? {
609
+ worktreePath: newSession.worktreePath,
610
+ worktreeBranch: newSession.worktreeBranch,
611
+ } : {}),
612
+ });
613
+ this.broadcastSessionList();
614
+ });
615
+ this.debugEvents.set(newId, []);
616
+ this.recordDebugEvent(newId, {
617
+ direction: "internal",
618
+ channel: "bridge",
619
+ type: "permission_mode_changed",
620
+ detail: `mode=${msg.mode} approval=${newApproval} collaboration=${newCollaboration} thread=${threadId} oldSession=${oldSessionId}`,
621
+ });
622
+ console.log(`[ws] Permission mode change: created new session ${newId} (thread=${threadId}, mode=${msg.mode})`);
623
+ }).catch((err) => {
624
+ this.send(ws, { type: "error", message: `Failed to restart session for permission mode change: ${err}` });
625
+ });
626
+ break;
627
+ }
628
+ session.process.setPermissionMode(msg.mode).catch((err) => {
629
+ this.send(ws, {
630
+ type: "error",
631
+ message: `Failed to set permission mode: ${err instanceof Error ? err.message : String(err)}`,
632
+ });
633
+ });
634
+ break;
635
+ }
636
+ case "set_sandbox_mode": {
637
+ const session = this.resolveSession(msg.sessionId);
638
+ if (!session) {
639
+ this.send(ws, { type: "error", message: "No active session." });
640
+ return;
641
+ }
642
+ if (msg.sandboxMode !== "on" && msg.sandboxMode !== "off") {
643
+ this.send(ws, { type: "error", message: `Invalid sandbox mode: ${msg.sandboxMode}` });
644
+ return;
645
+ }
646
+ // ---- Claude sandbox toggle ----
647
+ if (session.provider === "claude") {
648
+ const newEnabled = msg.sandboxMode === "on";
649
+ if (session.sandboxEnabled === newEnabled) {
650
+ break; // No change needed
651
+ }
652
+ // Sandbox is a query-level setting — requires session restart.
653
+ const oldSessionId = session.id;
654
+ const claudeSessionId = session.claudeSessionId;
655
+ const projectPath = session.projectPath;
656
+ const worktreePath = session.worktreePath;
657
+ const worktreeBranch = session.worktreeBranch;
658
+ const sessionName = session.name;
659
+ const permissionMode = session.process.permissionMode;
660
+ const model = session.process.model;
661
+ this.sessionManager.destroy(oldSessionId);
662
+ console.log(`[ws] Claude sandbox change: destroyed session ${oldSessionId}`);
663
+ const newId = this.sessionManager.create(projectPath, {
664
+ sessionId: claudeSessionId,
665
+ permissionMode,
666
+ model,
667
+ sandboxEnabled: newEnabled,
668
+ }, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined, "claude");
669
+ const newSession = this.sessionManager.get(newId);
670
+ if (newSession && sessionName)
671
+ newSession.name = sessionName;
672
+ void this.loadAndSetSessionName(newSession, "claude", projectPath, claudeSessionId).then(() => {
673
+ this.broadcast({
674
+ type: "system",
675
+ subtype: "session_created",
676
+ sessionId: newId,
677
+ provider: "claude",
678
+ projectPath,
679
+ sandboxMode: msg.sandboxMode,
680
+ sourceSessionId: oldSessionId,
681
+ ...(newSession?.worktreePath ? {
682
+ worktreePath: newSession.worktreePath,
683
+ worktreeBranch: newSession.worktreeBranch,
684
+ } : {}),
685
+ });
686
+ this.broadcastSessionList();
687
+ });
688
+ this.debugEvents.set(newId, []);
689
+ this.recordDebugEvent(newId, {
690
+ direction: "internal",
691
+ channel: "bridge",
692
+ type: "sandbox_mode_changed",
693
+ detail: `sandbox=${newEnabled} claude=${claudeSessionId} oldSession=${oldSessionId}`,
694
+ });
695
+ console.log(`[ws] Claude sandbox change: created new session ${newId} (sandbox=${newEnabled})`);
696
+ break;
697
+ }
698
+ // ---- Codex sandbox toggle ----
699
+ const newSandboxMode = sandboxModeToInternal(msg.sandboxMode);
700
+ const currentSandboxMode = session.codexSettings?.sandboxMode ?? "workspace-write";
701
+ if (newSandboxMode === currentSandboxMode) {
702
+ break; // No change needed
703
+ }
704
+ // Sandbox mode is a thread-level setting — it can only be applied at
705
+ // thread/start or thread/resume time, not per-turn. To apply the new
706
+ // mode we destroy the current session and resume the same Codex thread
707
+ // with the updated sandbox parameter (same pattern as clearContext).
708
+ const oldSessionId = session.id;
709
+ const threadId = session.claudeSessionId;
710
+ const projectPath = session.projectPath;
711
+ const oldSettings = session.codexSettings ?? {};
712
+ const worktreePath = session.worktreePath;
713
+ const worktreeBranch = session.worktreeBranch;
714
+ const sessionName = session.name;
715
+ const collaborationMode = session.process.collaborationMode;
716
+ this.sessionManager.destroy(oldSessionId);
717
+ console.log(`[ws] Sandbox mode change: destroyed session ${oldSessionId}`);
718
+ // Check if the user actually exchanged messages in this session.
719
+ // session.history always contains system events (init, status, etc.)
720
+ // even before the first user turn, so we check for user_input/assistant
721
+ // messages specifically.
722
+ const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") || (session.pastMessages && session.pastMessages.length > 0);
723
+ if (!threadId || !hasUserMessages) {
724
+ // Session has no thread yet, or has a thread but no messages exchanged.
725
+ // Create a fresh session with the new sandbox — no resume needed.
726
+ // (A thread with no messages cannot be resumed — Codex returns
727
+ // "no rollout found for thread id".)
728
+ const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined, "codex", {
729
+ approvalPolicy: oldSettings.approvalPolicy,
730
+ sandboxMode: newSandboxMode,
731
+ model: oldSettings.model,
732
+ modelReasoningEffort: oldSettings.modelReasoningEffort,
733
+ networkAccessEnabled: oldSettings.networkAccessEnabled,
734
+ webSearchMode: oldSettings.webSearchMode,
735
+ collaborationMode,
736
+ });
737
+ const newSession = this.sessionManager.get(newId);
738
+ if (newSession && sessionName)
739
+ newSession.name = sessionName;
740
+ this.broadcast({
741
+ type: "system",
742
+ subtype: "session_created",
743
+ sessionId: newId,
744
+ provider: "codex",
745
+ projectPath,
746
+ sandboxMode: sandboxModeToExternal(newSandboxMode),
747
+ sourceSessionId: oldSessionId,
748
+ ...(newSession?.worktreePath ? { worktreePath: newSession.worktreePath, worktreeBranch: newSession.worktreeBranch } : {}),
749
+ });
750
+ this.broadcastSessionList();
751
+ console.log(`[ws] Sandbox mode change (no thread): created new session ${newId} (sandbox=${newSandboxMode})`);
752
+ break;
753
+ }
754
+ // Worktree resolution (same as resume_session)
755
+ const wtMapping = this.worktreeStore.get(threadId);
756
+ const effectiveProjectPath = wtMapping?.projectPath ?? projectPath;
757
+ let worktreeOpts;
758
+ if (wtMapping) {
759
+ if (worktreeExists(wtMapping.worktreePath)) {
760
+ worktreeOpts = { existingWorktreePath: wtMapping.worktreePath, worktreeBranch: wtMapping.worktreeBranch };
761
+ }
762
+ else {
763
+ worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
764
+ }
765
+ }
766
+ else if (worktreePath) {
767
+ worktreeOpts = { existingWorktreePath: worktreePath, worktreeBranch };
768
+ }
769
+ getCodexSessionHistory(threadId).then((pastMessages) => {
770
+ const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
771
+ threadId,
772
+ approvalPolicy: oldSettings.approvalPolicy,
773
+ sandboxMode: newSandboxMode,
774
+ model: oldSettings.model,
775
+ modelReasoningEffort: oldSettings.modelReasoningEffort,
776
+ networkAccessEnabled: oldSettings.networkAccessEnabled,
777
+ webSearchMode: oldSettings.webSearchMode,
778
+ collaborationMode,
779
+ });
780
+ // Restore session name
781
+ const newSession = this.sessionManager.get(newId);
782
+ if (newSession && sessionName) {
783
+ newSession.name = sessionName;
784
+ }
785
+ void this.loadAndSetSessionName(newSession, "codex", effectiveProjectPath, threadId).then(() => {
786
+ this.broadcast({
787
+ type: "system",
788
+ subtype: "session_created",
789
+ sessionId: newId,
790
+ provider: "codex",
791
+ projectPath: effectiveProjectPath,
792
+ sandboxMode: sandboxModeToExternal(newSandboxMode),
793
+ sourceSessionId: oldSessionId,
794
+ ...(newSession?.worktreePath ? {
795
+ worktreePath: newSession.worktreePath,
796
+ worktreeBranch: newSession.worktreeBranch,
797
+ } : {}),
798
+ });
799
+ this.broadcastSessionList();
800
+ });
801
+ this.debugEvents.set(newId, []);
802
+ this.recordDebugEvent(newId, {
803
+ direction: "internal",
804
+ channel: "bridge",
805
+ type: "sandbox_mode_changed",
806
+ detail: `sandbox=${newSandboxMode} thread=${threadId} oldSession=${oldSessionId}`,
807
+ });
808
+ console.log(`[ws] Sandbox mode change: created new session ${newId} (thread=${threadId}, sandbox=${newSandboxMode})`);
809
+ }).catch((err) => {
810
+ this.send(ws, { type: "error", message: `Failed to restart session for sandbox mode change: ${err}` });
811
+ });
812
+ break;
813
+ }
814
+ case "approve": {
815
+ const session = this.resolveSession(msg.sessionId);
816
+ if (!session) {
817
+ this.send(ws, { type: "error", message: "No active session." });
818
+ return;
819
+ }
820
+ if (session.provider === "codex") {
821
+ session.process.approve(msg.id, msg.updatedInput);
822
+ break;
823
+ }
824
+ const sdkProc = session.process;
825
+ if (msg.clearContext) {
826
+ // Clear & Accept: immediately destroy this runtime session and
827
+ // create a fresh one that continues the same Claude conversation.
828
+ // This guarantees chat history is cleared in the mobile UI without
829
+ // waiting for additional in-turn tool approvals.
830
+ const pending = sdkProc.getPendingPermission(msg.id);
831
+ const mergedInput = {
832
+ ...(pending?.input ?? {}),
833
+ ...(msg.updatedInput ?? {}),
834
+ };
835
+ const planText = typeof mergedInput.plan === "string" ? mergedInput.plan : "";
836
+ // Use session.id (always present) instead of msg.sessionId.
837
+ const sessionId = session.id;
838
+ // Capture session properties before destroy.
839
+ const claudeSessionId = session.claudeSessionId;
840
+ const projectPath = session.projectPath;
841
+ const permissionMode = sdkProc.permissionMode;
842
+ const worktreePath = session.worktreePath;
843
+ const worktreeBranch = session.worktreeBranch;
844
+ this.sessionManager.destroy(sessionId);
845
+ console.log(`[ws] Clear context: destroyed session ${sessionId}`);
846
+ const newId = this.sessionManager.create(projectPath, {
847
+ ...(claudeSessionId
848
+ ? {
849
+ sessionId: claudeSessionId,
850
+ continueMode: true,
851
+ }
852
+ : {}),
853
+ permissionMode,
854
+ initialInput: planText || undefined,
855
+ }, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined);
856
+ console.log(`[ws] Clear context: created new session ${newId} (CLI session: ${claudeSessionId ?? "new"})`);
857
+ // Notify all clients. Broadcast is used so reconnecting clients also receive it.
858
+ const newSession = this.sessionManager.get(newId);
859
+ this.broadcast({
860
+ type: "system",
861
+ subtype: "session_created",
862
+ sessionId: newId,
863
+ provider: newSession?.provider ?? "claude",
864
+ projectPath,
865
+ ...(permissionMode ? { permissionMode } : {}),
866
+ clearContext: true,
867
+ sourceSessionId: sessionId,
868
+ });
869
+ this.broadcastSessionList();
870
+ }
871
+ else {
872
+ sdkProc.approve(msg.id, msg.updatedInput);
873
+ }
874
+ break;
875
+ }
876
+ case "approve_always": {
877
+ const session = this.resolveSession(msg.sessionId);
878
+ if (!session) {
879
+ this.send(ws, { type: "error", message: "No active session." });
880
+ return;
881
+ }
882
+ if (session.provider === "codex") {
883
+ session.process.approveAlways(msg.id);
884
+ break;
885
+ }
886
+ session.process.approveAlways(msg.id);
887
+ break;
888
+ }
889
+ case "reject": {
890
+ const session = this.resolveSession(msg.sessionId);
891
+ if (!session) {
892
+ this.send(ws, { type: "error", message: "No active session." });
893
+ return;
894
+ }
895
+ if (session.provider === "codex") {
896
+ session.process.reject(msg.id, msg.message);
897
+ break;
898
+ }
899
+ session.process.reject(msg.id, msg.message);
900
+ break;
901
+ }
902
+ case "answer": {
903
+ const session = this.resolveSession(msg.sessionId);
904
+ if (!session) {
905
+ this.send(ws, { type: "error", message: "No active session." });
906
+ return;
907
+ }
908
+ if (session.provider === "codex") {
909
+ session.process.answer(msg.toolUseId, msg.result);
910
+ break;
911
+ }
912
+ session.process.answer(msg.toolUseId, msg.result);
913
+ break;
914
+ }
915
+ case "list_sessions": {
916
+ this.sendSessionList(ws);
917
+ break;
918
+ }
919
+ case "stop_session": {
920
+ const session = this.sessionManager.get(msg.sessionId);
921
+ if (session) {
922
+ // Notify clients before destroying (destroy removes listeners)
923
+ this.broadcastSessionMessage(msg.sessionId, {
924
+ type: "result",
925
+ subtype: "stopped",
926
+ sessionId: session.claudeSessionId,
927
+ });
928
+ this.sessionManager.destroy(msg.sessionId);
929
+ this.recordDebugEvent(msg.sessionId, {
930
+ direction: "internal",
931
+ channel: "bridge",
932
+ type: "session_stopped",
933
+ });
934
+ this.debugEvents.delete(msg.sessionId);
935
+ this.notifiedPermissionToolUses.delete(msg.sessionId);
936
+ this.sendSessionList(ws);
937
+ }
938
+ else {
939
+ this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
940
+ }
941
+ break;
942
+ }
943
+ case "get_history": {
944
+ const session = this.sessionManager.get(msg.sessionId);
945
+ if (session) {
946
+ // Send past conversation from disk (resume) before in-memory history
947
+ if (session.pastMessages && session.pastMessages.length > 0) {
948
+ this.send(ws, {
949
+ type: "past_history",
950
+ claudeSessionId: session.claudeSessionId ?? msg.sessionId,
951
+ sessionId: msg.sessionId,
952
+ messages: session.pastMessages,
953
+ });
954
+ }
955
+ this.send(ws, { type: "history", messages: session.history, sessionId: msg.sessionId });
956
+ this.send(ws, { type: "status", status: session.status, sessionId: msg.sessionId });
957
+ // Send cached slash commands so the client can restore them even when
958
+ // the original init/supported_commands message was evicted from the
959
+ // in-memory history (MAX_HISTORY_PER_SESSION overflow).
960
+ const cached = this.sessionManager.getCachedCommands(session.projectPath);
961
+ if (cached && cached.slashCommands.length > 0) {
962
+ this.send(ws, {
963
+ type: "system",
964
+ subtype: "supported_commands",
965
+ sessionId: msg.sessionId,
966
+ slashCommands: cached.slashCommands,
967
+ skills: cached.skills,
968
+ ...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}),
969
+ });
970
+ }
971
+ }
972
+ else {
973
+ this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
974
+ }
975
+ break;
976
+ }
977
+ case "refresh_branch": {
978
+ const session = this.sessionManager.get(msg.sessionId);
979
+ if (session) {
980
+ const cwd = session.worktreePath ?? session.projectPath;
981
+ let branch = "";
982
+ try {
983
+ branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
984
+ cwd, encoding: "utf-8",
985
+ }).trim();
986
+ }
987
+ catch { /* not a git repo */ }
988
+ // Update stored branch so future session_list responses are also current
989
+ session.gitBranch = branch;
990
+ this.send(ws, {
991
+ type: "branch_update",
992
+ sessionId: msg.sessionId,
993
+ branch,
994
+ });
995
+ }
996
+ else {
997
+ this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
998
+ }
999
+ break;
1000
+ }
1001
+ case "get_debug_bundle": {
1002
+ const session = this.sessionManager.get(msg.sessionId);
1003
+ if (!session) {
1004
+ this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
1005
+ return;
1006
+ }
1007
+ const emitBundle = (diff, diffError) => {
1008
+ const traceLimit = msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS;
1009
+ const trace = this.getDebugEvents(msg.sessionId, traceLimit);
1010
+ const generatedAt = new Date().toISOString();
1011
+ const includeDiff = msg.includeDiff !== false;
1012
+ const bundlePayload = {
1013
+ type: "debug_bundle",
1014
+ sessionId: msg.sessionId,
1015
+ generatedAt,
1016
+ session: {
1017
+ id: session.id,
1018
+ provider: session.provider,
1019
+ status: session.status,
1020
+ projectPath: session.projectPath,
1021
+ worktreePath: session.worktreePath,
1022
+ worktreeBranch: session.worktreeBranch,
1023
+ claudeSessionId: session.claudeSessionId,
1024
+ createdAt: session.createdAt.toISOString(),
1025
+ lastActivityAt: session.lastActivityAt.toISOString(),
1026
+ },
1027
+ pastMessageCount: session.pastMessages?.length ?? 0,
1028
+ historySummary: this.buildHistorySummary(session.history),
1029
+ debugTrace: trace,
1030
+ traceFilePath: this.debugTraceStore.getTraceFilePath(msg.sessionId),
1031
+ reproRecipe: this.buildReproRecipe(session, traceLimit, includeDiff),
1032
+ agentPrompt: this.buildAgentPrompt(session),
1033
+ diff,
1034
+ diffError,
1035
+ };
1036
+ const savedBundlePath = this.debugTraceStore.getBundleFilePath(msg.sessionId, generatedAt);
1037
+ bundlePayload.savedBundlePath = savedBundlePath;
1038
+ this.debugTraceStore.saveBundleAtPath(savedBundlePath, bundlePayload);
1039
+ this.send(ws, bundlePayload);
1040
+ };
1041
+ if (msg.includeDiff === false) {
1042
+ emitBundle("");
1043
+ break;
1044
+ }
1045
+ const cwd = session.worktreePath ?? session.projectPath;
1046
+ this.collectGitDiff(cwd, ({ diff, error }) => {
1047
+ emitBundle(diff, error);
1048
+ });
1049
+ break;
1050
+ }
1051
+ case "get_usage": {
1052
+ fetchAllUsage().then((providers) => {
1053
+ this.send(ws, { type: "usage_result", providers });
1054
+ }).catch((err) => {
1055
+ this.send(ws, { type: "error", message: `Failed to fetch usage: ${err}` });
1056
+ });
1057
+ break;
1058
+ }
1059
+ case "list_recent_sessions": {
1060
+ const requestId = ++this.recentSessionsRequestId;
1061
+ this.listRecentSessions(msg).then(({ sessions, hasMore }) => {
1062
+ // Drop stale responses when rapid filter switches cause out-of-order completion
1063
+ if (requestId !== this.recentSessionsRequestId)
1064
+ return;
1065
+ this.send(ws, { type: "recent_sessions", sessions, hasMore });
1066
+ }).catch((err) => {
1067
+ if (requestId !== this.recentSessionsRequestId)
1068
+ return;
1069
+ this.send(ws, { type: "error", message: `Failed to list recent sessions: ${err}` });
1070
+ });
1071
+ break;
1072
+ }
1073
+ case "archive_session": {
1074
+ const { sessionId, provider, projectPath } = msg;
1075
+ this.archiveStore.archive(sessionId, provider, projectPath).then(() => {
1076
+ // For Codex sessions, also call thread/archive RPC (best-effort).
1077
+ // Requires a running Codex app-server process; skip if none active.
1078
+ if (provider === "codex") {
1079
+ const activeSessions = this.sessionManager.list();
1080
+ const codexSession = activeSessions.find((s) => s.provider === "codex");
1081
+ if (codexSession) {
1082
+ const session = this.sessionManager.get(codexSession.id);
1083
+ if (session) {
1084
+ session.process.archiveThread(sessionId).catch((err) => {
1085
+ console.warn(`[ws] Codex thread/archive failed (non-fatal): ${err}`);
1086
+ });
1087
+ }
1088
+ }
1089
+ }
1090
+ this.send(ws, {
1091
+ type: "archive_result",
1092
+ sessionId,
1093
+ success: true,
1094
+ });
1095
+ }).catch((err) => {
1096
+ this.send(ws, {
1097
+ type: "archive_result",
1098
+ sessionId,
1099
+ success: false,
1100
+ error: String(err),
1101
+ });
1102
+ });
1103
+ break;
1104
+ }
1105
+ case "resume_session": {
1106
+ console.log(`[ws] resume_session: sessionId=${msg.sessionId} projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`);
1107
+ if (!this.isPathAllowed(msg.projectPath)) {
1108
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
1109
+ break;
1110
+ }
1111
+ const provider = msg.provider ?? "claude";
1112
+ const sessionRefId = msg.sessionId;
1113
+ // Resume flow: keep past history in SessionInfo and deliver it only
1114
+ // via get_history(sessionId) to avoid duplicate/missed replay races.
1115
+ if (provider === "codex") {
1116
+ const wtMapping = this.worktreeStore.get(sessionRefId);
1117
+ const effectiveProjectPath = wtMapping?.projectPath ?? msg.projectPath;
1118
+ let worktreeOpts;
1119
+ if (wtMapping) {
1120
+ if (worktreeExists(wtMapping.worktreePath)) {
1121
+ worktreeOpts = {
1122
+ existingWorktreePath: wtMapping.worktreePath,
1123
+ worktreeBranch: wtMapping.worktreeBranch,
1124
+ };
1125
+ }
1126
+ else {
1127
+ worktreeOpts = {
1128
+ useWorktree: true,
1129
+ worktreeBranch: wtMapping.worktreeBranch,
1130
+ };
1131
+ }
1132
+ }
1133
+ getCodexSessionHistory(sessionRefId).then((pastMessages) => {
1134
+ const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
1135
+ threadId: sessionRefId,
1136
+ approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
1137
+ sandboxMode: sandboxModeToInternal(msg.sandboxMode),
1138
+ model: msg.model,
1139
+ modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
1140
+ networkAccessEnabled: msg.networkAccessEnabled,
1141
+ webSearchMode: msg.webSearchMode ?? undefined,
1142
+ collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
1143
+ });
1144
+ const createdSession = this.sessionManager.get(sessionId);
1145
+ void this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId).then(() => {
1146
+ this.send(ws, {
1147
+ type: "system",
1148
+ subtype: "session_created",
1149
+ sessionId,
1150
+ provider: "codex",
1151
+ projectPath: effectiveProjectPath,
1152
+ ...(createdSession?.codexSettings?.sandboxMode ? { sandboxMode: sandboxModeToExternal(createdSession.codexSettings.sandboxMode) } : {}),
1153
+ ...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
1154
+ ...(createdSession?.worktreePath ? {
1155
+ worktreePath: createdSession.worktreePath,
1156
+ worktreeBranch: createdSession.worktreeBranch,
1157
+ } : {}),
1158
+ });
1159
+ this.broadcastSessionList();
1160
+ });
1161
+ this.debugEvents.set(sessionId, []);
1162
+ this.recordDebugEvent(sessionId, {
1163
+ direction: "internal",
1164
+ channel: "bridge",
1165
+ type: "session_resumed",
1166
+ detail: `provider=codex thread=${sessionRefId}`,
1167
+ });
1168
+ this.projectHistory?.addProject(effectiveProjectPath);
1169
+ }).catch((err) => {
1170
+ this.send(ws, { type: "error", message: `Failed to load Codex session history: ${err}` });
1171
+ });
1172
+ break;
1173
+ }
1174
+ const claudeSessionId = sessionRefId;
1175
+ const cached = this.sessionManager.getCachedCommands(msg.projectPath);
1176
+ // Look up worktree mapping for this Claude session
1177
+ const wtMapping = this.worktreeStore.get(claudeSessionId);
1178
+ let worktreeOpts;
1179
+ if (wtMapping) {
1180
+ if (worktreeExists(wtMapping.worktreePath)) {
1181
+ // Worktree exists — reuse it directly
1182
+ worktreeOpts = {
1183
+ existingWorktreePath: wtMapping.worktreePath,
1184
+ worktreeBranch: wtMapping.worktreeBranch,
1185
+ };
1186
+ }
1187
+ else {
1188
+ // Worktree was deleted — recreate on the same branch
1189
+ worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
1190
+ }
1191
+ }
1192
+ getSessionHistory(claudeSessionId).then((pastMessages) => {
1193
+ const sessionId = this.sessionManager.create(msg.projectPath, {
1194
+ sessionId: claudeSessionId,
1195
+ permissionMode: msg.permissionMode,
1196
+ model: msg.model,
1197
+ effort: msg.effort,
1198
+ maxTurns: msg.maxTurns,
1199
+ maxBudgetUsd: msg.maxBudgetUsd,
1200
+ fallbackModel: msg.fallbackModel,
1201
+ forkSession: msg.forkSession,
1202
+ persistSession: msg.persistSession,
1203
+ ...(msg.sandboxMode ? { sandboxEnabled: msg.sandboxMode === "on" } : {}),
1204
+ }, pastMessages, worktreeOpts);
1205
+ const createdSession = this.sessionManager.get(sessionId);
1206
+ void this.loadAndSetSessionName(createdSession, "claude", msg.projectPath, claudeSessionId).then(() => {
1207
+ this.send(ws, {
1208
+ type: "system",
1209
+ subtype: "session_created",
1210
+ sessionId,
1211
+ claudeSessionId,
1212
+ provider: "claude",
1213
+ projectPath: msg.projectPath,
1214
+ ...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
1215
+ ...(msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
1216
+ ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills, ...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}) } : {}),
1217
+ ...(createdSession?.worktreePath ? {
1218
+ worktreePath: createdSession.worktreePath,
1219
+ worktreeBranch: createdSession.worktreeBranch,
1220
+ } : {}),
1221
+ });
1222
+ this.broadcastSessionList();
1223
+ });
1224
+ this.debugEvents.set(sessionId, []);
1225
+ this.recordDebugEvent(sessionId, {
1226
+ direction: "internal",
1227
+ channel: "bridge",
1228
+ type: "session_resumed",
1229
+ detail: `provider=claude session=${claudeSessionId}`,
1230
+ });
1231
+ this.projectHistory?.addProject(msg.projectPath);
1232
+ }).catch((err) => {
1233
+ this.send(ws, { type: "error", message: `Failed to load session history: ${err}` });
1234
+ });
1235
+ break;
1236
+ }
1237
+ case "list_gallery": {
1238
+ if (this.galleryStore) {
1239
+ const images = this.galleryStore.list({
1240
+ projectPath: msg.project,
1241
+ sessionId: msg.sessionId,
1242
+ });
1243
+ this.send(ws, { type: "gallery_list", images });
1244
+ }
1245
+ else {
1246
+ this.send(ws, { type: "gallery_list", images: [] });
1247
+ }
1248
+ break;
1249
+ }
1250
+ case "get_message_images": {
1251
+ void extractMessageImages(msg.claudeSessionId, msg.messageUuid).then((images) => {
1252
+ const refs = [];
1253
+ if (this.imageStore) {
1254
+ for (const img of images) {
1255
+ const ref = this.imageStore.registerFromBase64(img.base64, img.mimeType);
1256
+ if (ref)
1257
+ refs.push(ref);
1258
+ }
1259
+ }
1260
+ this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: refs });
1261
+ }).catch((err) => {
1262
+ console.error("[ws] Failed to extract message images:", err);
1263
+ this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: [] });
1264
+ });
1265
+ break;
1266
+ }
1267
+ case "interrupt": {
1268
+ const session = this.resolveSession(msg.sessionId);
1269
+ if (!session) {
1270
+ this.send(ws, { type: "error", message: "No active session." });
1271
+ return;
1272
+ }
1273
+ session.process.interrupt();
1274
+ break;
1275
+ }
1276
+ case "list_project_history": {
1277
+ const projects = this.projectHistory?.getProjects() ?? [];
1278
+ this.send(ws, { type: "project_history", projects });
1279
+ break;
1280
+ }
1281
+ case "remove_project_history": {
1282
+ this.projectHistory?.removeProject(msg.projectPath);
1283
+ const projects = this.projectHistory?.getProjects() ?? [];
1284
+ this.send(ws, { type: "project_history", projects });
1285
+ break;
1286
+ }
1287
+ case "list_files": {
1288
+ if (!this.isPathAllowed(msg.projectPath)) {
1289
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
1290
+ break;
1291
+ }
1292
+ execFile("git", ["ls-files"], { cwd: msg.projectPath, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
1293
+ if (err) {
1294
+ if (/not a git repository/i.test(err.message)) {
1295
+ // Non-git project: silently return empty list (file listing is auxiliary)
1296
+ this.send(ws, { type: "file_list", files: [] });
1297
+ }
1298
+ else {
1299
+ this.send(ws, { type: "error", message: `Failed to list files: ${err.message}` });
1300
+ }
1301
+ return;
1302
+ }
1303
+ const files = stdout.trim().split("\n").filter(Boolean);
1304
+ this.send(ws, { type: "file_list", files });
1305
+ });
1306
+ break;
1307
+ }
1308
+ case "list_recordings": {
1309
+ if (!this.recordingStore) {
1310
+ this.send(ws, { type: "recording_list", recordings: [] });
1311
+ break;
1312
+ }
1313
+ const store = this.recordingStore;
1314
+ void store.listRecordings().then(async (recordings) => {
1315
+ // First pass: extract info from JSONL for recordings missing firstPrompt
1316
+ // This covers both meta-less legacy recordings and new ones where sessions-index hasn't indexed yet
1317
+ await Promise.all(recordings.map(async (rec) => {
1318
+ const info = await store.extractInfoFromJsonl(rec.name);
1319
+ if (info.firstPrompt && !rec.firstPrompt)
1320
+ rec.firstPrompt = info.firstPrompt;
1321
+ if (info.lastPrompt && !rec.lastPrompt)
1322
+ rec.lastPrompt = info.lastPrompt;
1323
+ // Backfill meta for legacy recordings
1324
+ if (!rec.meta && (info.claudeSessionId || info.projectPath)) {
1325
+ rec.meta = {
1326
+ bridgeSessionId: rec.name,
1327
+ claudeSessionId: info.claudeSessionId,
1328
+ projectPath: info.projectPath ?? "",
1329
+ createdAt: rec.modified,
1330
+ };
1331
+ }
1332
+ }));
1333
+ // Second pass: look up sessions-index for summaries (if claudeSessionIds available)
1334
+ const claudeIds = new Set();
1335
+ const idToIdx = new Map();
1336
+ for (let i = 0; i < recordings.length; i++) {
1337
+ const cid = recordings[i].meta?.claudeSessionId;
1338
+ if (cid) {
1339
+ claudeIds.add(cid);
1340
+ const arr = idToIdx.get(cid) ?? [];
1341
+ arr.push(i);
1342
+ idToIdx.set(cid, arr);
1343
+ }
1344
+ }
1345
+ if (claudeIds.size > 0) {
1346
+ const sessionInfo = await findSessionsByClaudeIds(claudeIds);
1347
+ for (const [cid, info] of sessionInfo) {
1348
+ const indices = idToIdx.get(cid) ?? [];
1349
+ for (const idx of indices) {
1350
+ if (info.summary)
1351
+ recordings[idx].summary = info.summary;
1352
+ if (info.firstPrompt)
1353
+ recordings[idx].firstPrompt = info.firstPrompt;
1354
+ if (info.lastPrompt)
1355
+ recordings[idx].lastPrompt = info.lastPrompt;
1356
+ }
1357
+ }
1358
+ }
1359
+ this.send(ws, { type: "recording_list", recordings });
1360
+ });
1361
+ break;
1362
+ }
1363
+ case "get_recording": {
1364
+ if (!this.recordingStore) {
1365
+ this.send(ws, { type: "error", message: "Recording is not enabled on this server" });
1366
+ break;
1367
+ }
1368
+ void this.recordingStore.getRecordingContent(msg.sessionId).then((content) => {
1369
+ if (content !== null) {
1370
+ this.send(ws, { type: "recording_content", sessionId: msg.sessionId, content });
1371
+ }
1372
+ else {
1373
+ this.send(ws, { type: "error", message: `Recording ${msg.sessionId} not found` });
1374
+ }
1375
+ });
1376
+ break;
1377
+ }
1378
+ case "get_diff": {
1379
+ if (!this.isPathAllowed(msg.projectPath)) {
1380
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
1381
+ break;
1382
+ }
1383
+ this.collectGitDiff(msg.projectPath, ({ diff, error }) => {
1384
+ if (error) {
1385
+ if (/not a git repository/i.test(error)) {
1386
+ this.send(ws, {
1387
+ type: "diff_result",
1388
+ diff: "",
1389
+ error: "This project is not a git repository",
1390
+ errorCode: "git_not_available",
1391
+ });
1392
+ }
1393
+ else {
1394
+ this.send(ws, { type: "diff_result", diff: "", error: `Failed to get diff: ${error}` });
1395
+ }
1396
+ return;
1397
+ }
1398
+ void this.collectImageChanges(msg.projectPath, diff).then((imageChanges) => {
1399
+ if (imageChanges.length > 0) {
1400
+ this.send(ws, { type: "diff_result", diff, imageChanges });
1401
+ }
1402
+ else {
1403
+ this.send(ws, { type: "diff_result", diff });
1404
+ }
1405
+ });
1406
+ });
1407
+ break;
1408
+ }
1409
+ case "get_diff_image": {
1410
+ if (!this.isPathAllowed(msg.projectPath) || !this.isPathAllowed(resolve(msg.projectPath, msg.filePath))) {
1411
+ this.send(ws, { type: "error", message: `Path not allowed` });
1412
+ break;
1413
+ }
1414
+ if (msg.version === "both") {
1415
+ void (async () => {
1416
+ try {
1417
+ const [oldResult, newResult] = await Promise.all([
1418
+ this.loadDiffImageAsync(msg.projectPath, msg.filePath, "old"),
1419
+ this.loadDiffImageAsync(msg.projectPath, msg.filePath, "new"),
1420
+ ]);
1421
+ const errors = [oldResult.error, newResult.error].filter(Boolean);
1422
+ this.send(ws, {
1423
+ type: "diff_image_result",
1424
+ filePath: msg.filePath,
1425
+ version: "both",
1426
+ oldBase64: oldResult.base64,
1427
+ newBase64: newResult.base64,
1428
+ mimeType: oldResult.mimeType ?? newResult.mimeType,
1429
+ ...(errors.length > 0 ? { error: errors.join("; ") } : {}),
1430
+ });
1431
+ }
1432
+ catch {
1433
+ // WebSocket may have closed; ignore send errors.
1434
+ }
1435
+ })();
1436
+ }
1437
+ else {
1438
+ const version = msg.version;
1439
+ void (async () => {
1440
+ try {
1441
+ const result = await this.loadDiffImageAsync(msg.projectPath, msg.filePath, version);
1442
+ this.send(ws, { type: "diff_image_result", filePath: msg.filePath, version, ...result });
1443
+ }
1444
+ catch {
1445
+ // WebSocket may have closed; ignore send errors.
1446
+ }
1447
+ })();
1448
+ }
1449
+ break;
1450
+ }
1451
+ case "list_worktrees": {
1452
+ if (!this.isPathAllowed(msg.projectPath)) {
1453
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
1454
+ break;
1455
+ }
1456
+ try {
1457
+ const worktrees = listWorktrees(msg.projectPath);
1458
+ const mainBranch = getMainBranch(msg.projectPath);
1459
+ this.send(ws, { type: "worktree_list", worktrees, mainBranch });
1460
+ }
1461
+ catch (err) {
1462
+ this.send(ws, { type: "error", message: `Failed to list worktrees: ${err}` });
1463
+ }
1464
+ break;
1465
+ }
1466
+ case "remove_worktree": {
1467
+ if (!this.isPathAllowed(msg.projectPath)) {
1468
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
1469
+ break;
1470
+ }
1471
+ try {
1472
+ removeWorktree(msg.projectPath, msg.worktreePath);
1473
+ this.worktreeStore.deleteByWorktreePath(msg.worktreePath);
1474
+ this.send(ws, { type: "worktree_removed", worktreePath: msg.worktreePath });
1475
+ }
1476
+ catch (err) {
1477
+ this.send(ws, { type: "error", message: `Failed to remove worktree: ${err}` });
1478
+ }
1479
+ break;
1480
+ }
1481
+ case "rewind_dry_run": {
1482
+ const session = this.sessionManager.get(msg.sessionId);
1483
+ if (!session) {
1484
+ this.send(ws, { type: "rewind_preview", canRewind: false, error: `Session ${msg.sessionId} not found` });
1485
+ return;
1486
+ }
1487
+ this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid, true).then((result) => {
1488
+ this.send(ws, {
1489
+ type: "rewind_preview",
1490
+ canRewind: result.canRewind,
1491
+ filesChanged: result.filesChanged,
1492
+ insertions: result.insertions,
1493
+ deletions: result.deletions,
1494
+ error: result.error,
1495
+ });
1496
+ }).catch((err) => {
1497
+ this.send(ws, { type: "rewind_preview", canRewind: false, error: `Dry run failed: ${err}` });
1498
+ });
1499
+ break;
1500
+ }
1501
+ case "rewind": {
1502
+ const session = this.sessionManager.get(msg.sessionId);
1503
+ if (!session) {
1504
+ this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: `Session ${msg.sessionId} not found` });
1505
+ return;
1506
+ }
1507
+ const handleError = (err) => {
1508
+ const errMsg = err instanceof Error ? err.message : String(err);
1509
+ this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: errMsg });
1510
+ };
1511
+ if (msg.mode === "code") {
1512
+ // Code-only rewind: rewind files without restarting the conversation
1513
+ this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
1514
+ if (result.canRewind) {
1515
+ this.send(ws, { type: "rewind_result", success: true, mode: "code" });
1516
+ }
1517
+ else {
1518
+ this.send(ws, { type: "rewind_result", success: false, mode: "code", error: result.error ?? "Cannot rewind files" });
1519
+ }
1520
+ }).catch(handleError);
1521
+ }
1522
+ else if (msg.mode === "conversation") {
1523
+ // Conversation-only rewind: restart session at the target UUID
1524
+ try {
1525
+ this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
1526
+ this.send(ws, { type: "rewind_result", success: true, mode: "conversation" });
1527
+ // Notify the new session ID
1528
+ const newSession = this.sessionManager.get(newSessionId);
1529
+ const rewindPermMode = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
1530
+ this.send(ws, {
1531
+ type: "system",
1532
+ subtype: "session_created",
1533
+ sessionId: newSessionId,
1534
+ provider: newSession?.provider ?? "claude",
1535
+ projectPath: newSession?.projectPath ?? "",
1536
+ ...(rewindPermMode ? { permissionMode: rewindPermMode } : {}),
1537
+ sourceSessionId: msg.sessionId,
1538
+ });
1539
+ this.sendSessionList(ws);
1540
+ });
1541
+ }
1542
+ catch (err) {
1543
+ handleError(err);
1544
+ }
1545
+ }
1546
+ else {
1547
+ // Both: rewind files first, then rewind conversation
1548
+ this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
1549
+ if (!result.canRewind) {
1550
+ this.send(ws, { type: "rewind_result", success: false, mode: "both", error: result.error ?? "Cannot rewind files" });
1551
+ return;
1552
+ }
1553
+ try {
1554
+ this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
1555
+ this.send(ws, { type: "rewind_result", success: true, mode: "both" });
1556
+ const newSession = this.sessionManager.get(newSessionId);
1557
+ const rewindPermMode2 = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
1558
+ this.send(ws, {
1559
+ type: "system",
1560
+ subtype: "session_created",
1561
+ sessionId: newSessionId,
1562
+ provider: newSession?.provider ?? "claude",
1563
+ projectPath: newSession?.projectPath ?? "",
1564
+ ...(rewindPermMode2 ? { permissionMode: rewindPermMode2 } : {}),
1565
+ sourceSessionId: msg.sessionId,
1566
+ });
1567
+ this.sendSessionList(ws);
1568
+ });
1569
+ }
1570
+ catch (err) {
1571
+ handleError(err);
1572
+ }
1573
+ }).catch(handleError);
1574
+ }
1575
+ break;
1576
+ }
1577
+ case "list_windows": {
1578
+ listWindows()
1579
+ .then((windows) => {
1580
+ this.send(ws, { type: "window_list", windows });
1581
+ })
1582
+ .catch((err) => {
1583
+ this.send(ws, {
1584
+ type: "error",
1585
+ message: `Failed to list windows: ${err instanceof Error ? err.message : String(err)}`,
1586
+ });
1587
+ });
1588
+ break;
1589
+ }
1590
+ case "take_screenshot": {
1591
+ // For window mode, verify the window ID is still valid.
1592
+ // The user may have fetched the window list minutes ago and the
1593
+ // window could have been closed since then.
1594
+ const doCapture = async () => {
1595
+ if (msg.mode !== "window" || msg.windowId == null) {
1596
+ return { mode: msg.mode };
1597
+ }
1598
+ const current = await listWindows();
1599
+ if (current.some((w) => w.windowId === msg.windowId)) {
1600
+ return { mode: "window", windowId: msg.windowId };
1601
+ }
1602
+ // Window ID is stale — fall back to fullscreen and notify
1603
+ console.warn(`[screenshot] Window ID ${msg.windowId} no longer exists, falling back to fullscreen`);
1604
+ return { mode: "fullscreen" };
1605
+ };
1606
+ doCapture()
1607
+ .then((opts) => takeScreenshot(opts))
1608
+ .then(async (result) => {
1609
+ try {
1610
+ if (this.galleryStore) {
1611
+ const meta = await this.galleryStore.addImage(result.filePath, msg.projectPath, msg.sessionId);
1612
+ if (meta) {
1613
+ const info = this.galleryStore.metaToInfo(meta);
1614
+ this.send(ws, { type: "screenshot_result", success: true, image: info });
1615
+ this.broadcast({ type: "gallery_new_image", image: info });
1616
+ return;
1617
+ }
1618
+ }
1619
+ this.send(ws, {
1620
+ type: "screenshot_result",
1621
+ success: false,
1622
+ error: "Failed to save screenshot to gallery",
1623
+ });
1624
+ }
1625
+ finally {
1626
+ // Always clean up temp file
1627
+ unlink(result.filePath).catch(() => { });
1628
+ }
1629
+ })
1630
+ .catch((err) => {
1631
+ this.send(ws, {
1632
+ type: "screenshot_result",
1633
+ success: false,
1634
+ error: err instanceof Error ? err.message : String(err),
1635
+ });
1636
+ });
1637
+ break;
1638
+ }
1639
+ case "backup_prompt_history": {
1640
+ if (!this.promptHistoryBackup) {
1641
+ this.send(ws, { type: "prompt_history_backup_result", success: false, error: "Backup store not available" });
1642
+ break;
1643
+ }
1644
+ const buf = Buffer.from(msg.data, "base64");
1645
+ this.promptHistoryBackup.save(buf, msg.appVersion, msg.dbVersion).then((meta) => {
1646
+ this.send(ws, { type: "prompt_history_backup_result", success: true, backedUpAt: meta.backedUpAt });
1647
+ }).catch((err) => {
1648
+ this.send(ws, { type: "prompt_history_backup_result", success: false, error: err instanceof Error ? err.message : String(err) });
1649
+ });
1650
+ break;
1651
+ }
1652
+ case "restore_prompt_history": {
1653
+ if (!this.promptHistoryBackup) {
1654
+ this.send(ws, { type: "prompt_history_restore_result", success: false, error: "Backup store not available" });
1655
+ break;
1656
+ }
1657
+ this.promptHistoryBackup.load().then((result) => {
1658
+ if (result) {
1659
+ this.send(ws, {
1660
+ type: "prompt_history_restore_result",
1661
+ success: true,
1662
+ data: result.data.toString("base64"),
1663
+ appVersion: result.meta.appVersion,
1664
+ dbVersion: result.meta.dbVersion,
1665
+ backedUpAt: result.meta.backedUpAt,
1666
+ });
1667
+ }
1668
+ else {
1669
+ this.send(ws, { type: "prompt_history_restore_result", success: false, error: "No backup found" });
1670
+ }
1671
+ }).catch((err) => {
1672
+ this.send(ws, { type: "prompt_history_restore_result", success: false, error: err instanceof Error ? err.message : String(err) });
1673
+ });
1674
+ break;
1675
+ }
1676
+ case "get_prompt_history_backup_info": {
1677
+ if (!this.promptHistoryBackup) {
1678
+ this.send(ws, { type: "prompt_history_backup_info", exists: false });
1679
+ break;
1680
+ }
1681
+ this.promptHistoryBackup.getMeta().then((meta) => {
1682
+ if (meta) {
1683
+ this.send(ws, { type: "prompt_history_backup_info", exists: true, ...meta });
1684
+ }
1685
+ else {
1686
+ this.send(ws, { type: "prompt_history_backup_info", exists: false });
1687
+ }
1688
+ }).catch(() => {
1689
+ this.send(ws, { type: "prompt_history_backup_info", exists: false });
1690
+ });
1691
+ break;
1692
+ }
1693
+ case "rename_session": {
1694
+ const name = msg.name || null;
1695
+ await this.handleRenameSession(ws, msg.sessionId, name, msg);
1696
+ break;
1697
+ }
1698
+ }
1699
+ }
1700
+ /**
1701
+ * Load the saved session name from CLI storage and set it on the SessionInfo.
1702
+ * Called after SessionManager.create() so that session_created carries the name.
1703
+ */
1704
+ async loadAndSetSessionName(session, provider, projectPath, cliSessionId) {
1705
+ if (!session || !cliSessionId)
1706
+ return;
1707
+ try {
1708
+ if (provider === "claude") {
1709
+ const name = await getClaudeSessionName(projectPath, cliSessionId);
1710
+ if (name)
1711
+ session.name = name;
1712
+ }
1713
+ else if (provider === "codex") {
1714
+ const names = await loadCodexSessionNames();
1715
+ const name = names.get(cliSessionId);
1716
+ if (name)
1717
+ session.name = name;
1718
+ }
1719
+ }
1720
+ catch {
1721
+ // Non-critical: session works without name
1722
+ }
1723
+ }
1724
+ /**
1725
+ * Handle rename_session: update in-memory name and persist to CLI storage.
1726
+ *
1727
+ * Supports both running sessions (by bridge session id) and recent sessions
1728
+ * (by provider session id, i.e. claudeSessionId or codex threadId).
1729
+ */
1730
+ async handleRenameSession(ws, sessionId, name, msg) {
1731
+ // 1. Try running session first
1732
+ const runningSession = this.sessionManager.get(sessionId);
1733
+ if (runningSession) {
1734
+ this.sessionManager.renameSession(sessionId, name);
1735
+ // Persist to provider storage
1736
+ if (runningSession.provider === "claude" && runningSession.claudeSessionId) {
1737
+ await renameClaudeSession(runningSession.worktreePath ?? runningSession.projectPath, runningSession.claudeSessionId, name);
1738
+ }
1739
+ else if (runningSession.provider === "codex" && runningSession.process) {
1740
+ try {
1741
+ await runningSession.process.renameThread(name ?? "");
1742
+ }
1743
+ catch (err) {
1744
+ console.warn(`[websocket] Failed to rename Codex thread:`, err);
1745
+ }
1746
+ }
1747
+ this.broadcastSessionList();
1748
+ this.send(ws, { type: "rename_result", sessionId, name, success: true });
1749
+ return;
1750
+ }
1751
+ // 2. Recent session (not running) — use provider + providerSessionId + projectPath from message
1752
+ const renameMsg = msg;
1753
+ const provider = renameMsg.provider;
1754
+ const providerSessionId = renameMsg.providerSessionId;
1755
+ const projectPath = renameMsg.projectPath;
1756
+ if (provider === "claude" && providerSessionId && projectPath) {
1757
+ const success = await renameClaudeSession(projectPath, providerSessionId, name);
1758
+ this.send(ws, { type: "rename_result", sessionId, name, success });
1759
+ return;
1760
+ }
1761
+ // For Codex recent sessions, write directly to session_index.jsonl.
1762
+ if (provider === "codex" && providerSessionId) {
1763
+ const success = await renameCodexSession(providerSessionId, name);
1764
+ this.send(ws, { type: "rename_result", sessionId, name, success });
1765
+ return;
1766
+ }
1767
+ this.send(ws, { type: "rename_result", sessionId, name, success: false });
1768
+ }
1769
+ resolveSession(sessionId) {
1770
+ if (sessionId)
1771
+ return this.sessionManager.get(sessionId);
1772
+ return this.getFirstSession();
1773
+ }
1774
+ getFirstSession() {
1775
+ const sessions = this.sessionManager.list();
1776
+ if (sessions.length === 0)
1777
+ return undefined;
1778
+ return this.sessionManager.get(sessions[sessions.length - 1].id);
1779
+ }
1780
+ sendSessionList(ws) {
1781
+ this.pruneDebugEvents();
1782
+ const sessions = this.sessionManager.list();
1783
+ this.send(ws, { type: "session_list", sessions, allowedDirs: this.allowedDirs, claudeModels: CLAUDE_MODELS, codexModels: CODEX_MODELS, bridgeVersion: getPackageVersion() });
1784
+ }
1785
+ /** Broadcast session list to all connected clients. */
1786
+ broadcastSessionList() {
1787
+ this.pruneDebugEvents();
1788
+ const sessions = this.sessionManager.list();
1789
+ this.broadcast({ type: "session_list", sessions, allowedDirs: this.allowedDirs, claudeModels: CLAUDE_MODELS, codexModels: CODEX_MODELS, bridgeVersion: getPackageVersion() });
1790
+ }
1791
+ broadcastSessionMessage(sessionId, msg) {
1792
+ this.maybeSendPushNotification(sessionId, msg);
1793
+ this.recordDebugEvent(sessionId, {
1794
+ direction: "outgoing",
1795
+ channel: "session",
1796
+ type: msg.type,
1797
+ detail: this.summarizeServerMessage(msg),
1798
+ });
1799
+ this.recordingStore?.record(sessionId, "outgoing", msg);
1800
+ // Update recording meta with claudeSessionId when it becomes available
1801
+ if ((msg.type === "system" || msg.type === "result") && "sessionId" in msg && msg.sessionId) {
1802
+ const session = this.sessionManager.get(sessionId);
1803
+ if (session) {
1804
+ this.recordingStore?.saveMeta(sessionId, {
1805
+ bridgeSessionId: sessionId,
1806
+ claudeSessionId: msg.sessionId,
1807
+ projectPath: session.projectPath,
1808
+ createdAt: session.createdAt.toISOString(),
1809
+ });
1810
+ }
1811
+ }
1812
+ // Wrap the message with sessionId
1813
+ const data = JSON.stringify({ ...msg, sessionId });
1814
+ for (const client of this.wss.clients) {
1815
+ if (client.readyState === WebSocket.OPEN) {
1816
+ client.send(data);
1817
+ }
1818
+ }
1819
+ }
1820
+ async listRecentSessions(msg) {
1821
+ if (msg.provider === "codex") {
1822
+ try {
1823
+ return await this.listRecentCodexThreads(msg);
1824
+ }
1825
+ catch (err) {
1826
+ console.warn(`[ws] Codex thread/list failed, falling back to rollout scan: ${err}`);
1827
+ }
1828
+ }
1829
+ return getAllRecentSessions({
1830
+ limit: msg.limit,
1831
+ offset: msg.offset,
1832
+ projectPath: msg.projectPath,
1833
+ provider: msg.provider,
1834
+ namedOnly: msg.namedOnly,
1835
+ searchQuery: msg.searchQuery,
1836
+ archivedSessionIds: this.archiveStore.archivedIds(),
1837
+ });
1838
+ }
1839
+ getActiveCodexProcess() {
1840
+ const summary = this.sessionManager.list().find((session) => session.provider === "codex");
1841
+ if (!summary)
1842
+ return null;
1843
+ const session = this.sessionManager.get(summary.id);
1844
+ return session?.provider === "codex" ? session.process : null;
1845
+ }
1846
+ async listRecentCodexThreads(msg) {
1847
+ const limit = msg.limit ?? 20;
1848
+ const offset = msg.offset ?? 0;
1849
+ const process = this.getActiveCodexProcess() ?? await this.createStandaloneCodexProcess(msg.projectPath);
1850
+ const isStandalone = process !== this.getActiveCodexProcess();
1851
+ try {
1852
+ const result = await process.listThreads({
1853
+ limit: limit + offset,
1854
+ cwd: msg.projectPath,
1855
+ searchTerm: msg.searchQuery,
1856
+ });
1857
+ const archivedIds = this.archiveStore.archivedIds();
1858
+ const indexedSessions = await getAllRecentSessions({
1859
+ provider: "codex",
1860
+ projectPath: msg.projectPath,
1861
+ archivedSessionIds: archivedIds,
1862
+ });
1863
+ const indexedById = new Map(indexedSessions.sessions.map((session) => [
1864
+ session.sessionId,
1865
+ {
1866
+ codexSettings: session.codexSettings,
1867
+ resumeCwd: session.resumeCwd,
1868
+ },
1869
+ ]));
1870
+ const sessions = result.data
1871
+ .filter((thread) => !archivedIds.has(thread.id))
1872
+ .filter((thread) => !msg.namedOnly || !!thread.name)
1873
+ .slice(offset, offset + limit)
1874
+ .map((thread) => codexThreadToRecentSession(thread, indexedById.get(thread.id)));
1875
+ return {
1876
+ sessions,
1877
+ hasMore: result.nextCursor != null,
1878
+ };
1879
+ }
1880
+ finally {
1881
+ if (isStandalone) {
1882
+ process.stop();
1883
+ }
1884
+ }
1885
+ }
1886
+ async createStandaloneCodexProcess(projectPath) {
1887
+ const proc = new CodexProcess();
1888
+ await proc.initializeOnly(projectPath ?? process.cwd());
1889
+ return proc;
1890
+ }
1891
+ /** Extract a short project label from the full projectPath (last directory name). */
1892
+ projectLabel(sessionId) {
1893
+ const session = this.sessionManager.get(sessionId);
1894
+ if (!session?.projectPath)
1895
+ return "";
1896
+ const parts = session.projectPath.replace(/\/+$/, "").split("/");
1897
+ return parts[parts.length - 1] || "";
1898
+ }
1899
+ /** Get unique locales from registered tokens. Falls back to ["en"] if none registered. */
1900
+ getRegisteredLocales() {
1901
+ const locales = new Set(this.tokenLocales.values());
1902
+ return locales.size > 0 ? [...locales] : ["en"];
1903
+ }
1904
+ /** Whether any registered token has privacy mode enabled (conservative: privacy wins). */
1905
+ isPrivacyMode() {
1906
+ for (const privacy of this.tokenPrivacyMode.values()) {
1907
+ if (privacy)
1908
+ return true;
1909
+ }
1910
+ return false;
1911
+ }
1912
+ /** Get a display label for push notification title: "name (project)" or just project. */
1913
+ sessionLabel(sessionId) {
1914
+ const session = this.sessionManager.get(sessionId);
1915
+ const project = this.projectLabel(sessionId);
1916
+ if (session?.name) {
1917
+ return project ? `${session.name} (${project})` : session.name;
1918
+ }
1919
+ return project;
1920
+ }
1921
+ maybeSendPushNotification(sessionId, msg) {
1922
+ if (!this.pushRelay.isConfigured)
1923
+ return;
1924
+ const privacy = this.isPrivacyMode();
1925
+ const label = privacy ? "" : this.sessionLabel(sessionId);
1926
+ if (msg.type === "permission_request") {
1927
+ const seen = this.notifiedPermissionToolUses.get(sessionId) ?? new Set();
1928
+ if (seen.has(msg.toolUseId))
1929
+ return;
1930
+ seen.add(msg.toolUseId);
1931
+ this.notifiedPermissionToolUses.set(sessionId, seen);
1932
+ const isAskUserQuestion = msg.toolName === "AskUserQuestion";
1933
+ const isExitPlanMode = msg.toolName === "ExitPlanMode";
1934
+ const eventType = isAskUserQuestion ? "ask_user_question" : "approval_required";
1935
+ // Extract question text for AskUserQuestion (standard mode only)
1936
+ let questionText;
1937
+ if (!privacy && isAskUserQuestion) {
1938
+ const questions = msg.input?.questions;
1939
+ const firstQuestion = Array.isArray(questions) && questions.length > 0
1940
+ ? questions[0]?.question
1941
+ : undefined;
1942
+ if (typeof firstQuestion === "string" && firstQuestion.length > 0) {
1943
+ questionText = firstQuestion.slice(0, 120);
1944
+ }
1945
+ }
1946
+ const data = {
1947
+ sessionId,
1948
+ provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
1949
+ toolUseId: msg.toolUseId,
1950
+ toolName: msg.toolName,
1951
+ };
1952
+ for (const locale of this.getRegisteredLocales()) {
1953
+ let title;
1954
+ let body;
1955
+ if (isExitPlanMode) {
1956
+ const titleKey = "plan_ready_title";
1957
+ title = label ? `${t(locale, titleKey)} - ${label}` : t(locale, titleKey);
1958
+ body = t(locale, "plan_ready_body");
1959
+ }
1960
+ else if (isAskUserQuestion) {
1961
+ const titleKey = "ask_title";
1962
+ title = label ? `${t(locale, titleKey)} - ${label}` : t(locale, titleKey);
1963
+ body = privacy
1964
+ ? t(locale, "ask_body_private")
1965
+ : (questionText ?? t(locale, "ask_default_body"));
1966
+ }
1967
+ else {
1968
+ const titleKey = "approval_title";
1969
+ title = label ? `${t(locale, titleKey)} - ${label}` : t(locale, titleKey);
1970
+ body = privacy
1971
+ ? t(locale, "approval_body_private")
1972
+ : t(locale, "approval_body", { toolName: msg.toolName });
1973
+ }
1974
+ void this.pushRelay.notify({
1975
+ eventType,
1976
+ title,
1977
+ body,
1978
+ locale,
1979
+ data,
1980
+ }).catch((err) => {
1981
+ const detail = err instanceof Error ? err.message : String(err);
1982
+ console.warn(`[ws] Failed to send push notification (${eventType}, ${locale}): ${detail}`);
1983
+ });
1984
+ }
1985
+ return;
1986
+ }
1987
+ if (msg.type !== "result")
1988
+ return;
1989
+ if (msg.subtype === "stopped")
1990
+ return;
1991
+ if (msg.subtype !== "success" && msg.subtype !== "error")
1992
+ return;
1993
+ const isSuccess = msg.subtype === "success";
1994
+ const eventType = isSuccess ? "session_completed" : "session_failed";
1995
+ const pieces = [];
1996
+ if (isSuccess) {
1997
+ if (msg.duration != null)
1998
+ pieces.push(`${msg.duration.toFixed(1)}s`);
1999
+ if (msg.cost != null)
2000
+ pieces.push(`$${msg.cost.toFixed(4)}`);
2001
+ }
2002
+ const stats = pieces.length > 0 ? ` (${pieces.join(", ")})` : "";
2003
+ const data = {
2004
+ sessionId,
2005
+ provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
2006
+ subtype: msg.subtype,
2007
+ };
2008
+ if (msg.stopReason)
2009
+ data.stopReason = msg.stopReason;
2010
+ if (msg.sessionId)
2011
+ data.providerSessionId = msg.sessionId;
2012
+ for (const locale of this.getRegisteredLocales()) {
2013
+ let title;
2014
+ if (privacy) {
2015
+ title = isSuccess ? t(locale, "task_completed") : t(locale, "error_occurred");
2016
+ }
2017
+ else {
2018
+ title = label
2019
+ ? (isSuccess ? `✅ ${label}` : `❌ ${label}`)
2020
+ : (isSuccess ? t(locale, "task_completed") : t(locale, "error_occurred"));
2021
+ }
2022
+ let body;
2023
+ if (privacy) {
2024
+ const privateBody = isSuccess
2025
+ ? t(locale, "result_success_body_private")
2026
+ : t(locale, "result_error_body_private");
2027
+ body = isSuccess ? `${privateBody}${stats}` : privateBody;
2028
+ }
2029
+ else if (isSuccess) {
2030
+ body = msg.result
2031
+ ? `${msg.result.slice(0, 120)}${stats}`
2032
+ : `${t(locale, "session_completed")}${stats}`;
2033
+ }
2034
+ else {
2035
+ body = msg.error ? msg.error.slice(0, 120) : t(locale, "session_failed");
2036
+ }
2037
+ void this.pushRelay.notify({
2038
+ eventType,
2039
+ title,
2040
+ body,
2041
+ locale,
2042
+ data,
2043
+ }).catch((err) => {
2044
+ const detail = err instanceof Error ? err.message : String(err);
2045
+ console.warn(`[ws] Failed to send push notification (${eventType}, ${locale}): ${detail}`);
2046
+ });
2047
+ }
2048
+ }
2049
+ broadcast(msg) {
2050
+ const data = JSON.stringify(msg);
2051
+ for (const client of this.wss.clients) {
2052
+ if (client.readyState === WebSocket.OPEN) {
2053
+ client.send(data);
2054
+ }
2055
+ }
2056
+ }
2057
+ send(ws, msg) {
2058
+ const sessionId = this.extractSessionIdFromServerMessage(msg);
2059
+ if (sessionId) {
2060
+ this.recordDebugEvent(sessionId, {
2061
+ direction: "outgoing",
2062
+ channel: "ws",
2063
+ type: String(msg.type ?? "unknown"),
2064
+ detail: this.summarizeOutboundMessage(msg),
2065
+ });
2066
+ }
2067
+ if (ws.readyState === WebSocket.OPEN) {
2068
+ ws.send(JSON.stringify(msg));
2069
+ }
2070
+ }
2071
+ /** Broadcast a gallery_new_image message to all connected clients. */
2072
+ broadcastGalleryNewImage(image) {
2073
+ this.broadcast({ type: "gallery_new_image", image });
2074
+ }
2075
+ collectGitDiff(cwd, callback) {
2076
+ const execOpts = { cwd, maxBuffer: 10 * 1024 * 1024 };
2077
+ // Collect untracked files so they appear in the diff.
2078
+ let untrackedFiles = [];
2079
+ try {
2080
+ const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd }).toString().trim();
2081
+ untrackedFiles = out ? out.split("\n") : [];
2082
+ }
2083
+ catch {
2084
+ // Ignore errors: non-git directories are handled by git diff callback.
2085
+ }
2086
+ // Temporarily stage untracked files with --intent-to-add.
2087
+ if (untrackedFiles.length > 0) {
2088
+ try {
2089
+ execFileSync("git", ["add", "--intent-to-add", ...untrackedFiles], { cwd });
2090
+ }
2091
+ catch {
2092
+ // Ignore staging errors.
2093
+ }
2094
+ }
2095
+ execFile("git", ["diff", "--no-color"], execOpts, (err, stdout) => {
2096
+ // Revert intent-to-add for untracked files.
2097
+ if (untrackedFiles.length > 0) {
2098
+ try {
2099
+ execFileSync("git", ["reset", "--", ...untrackedFiles], { cwd });
2100
+ }
2101
+ catch {
2102
+ // Ignore reset errors.
2103
+ }
2104
+ }
2105
+ if (err) {
2106
+ callback({ diff: "", error: err.message });
2107
+ return;
2108
+ }
2109
+ callback({ diff: stdout });
2110
+ });
2111
+ }
2112
+ // ---------------------------------------------------------------------------
2113
+ // Image diff helpers
2114
+ // ---------------------------------------------------------------------------
2115
+ static IMAGE_EXTENSIONS = new Set([
2116
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svg",
2117
+ ]);
2118
+ // Image diff thresholds (configurable via environment variables)
2119
+ // - Auto-display: images ≤ threshold are sent inline as base64
2120
+ // - Max size: images ≤ max are available for on-demand loading
2121
+ // - Images > max size show text info only
2122
+ static AUTO_DISPLAY_THRESHOLD = (() => {
2123
+ const kb = parseInt(process.env.DIFF_IMAGE_AUTO_DISPLAY_KB ?? "", 10);
2124
+ return Number.isFinite(kb) && kb > 0 ? kb * 1024 : 1024 * 1024; // default 1 MB
2125
+ })();
2126
+ static MAX_IMAGE_SIZE = (() => {
2127
+ const mb = parseInt(process.env.DIFF_IMAGE_MAX_SIZE_MB ?? "", 10);
2128
+ return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 5 * 1024 * 1024; // default 5 MB
2129
+ })();
2130
+ static mimeTypeForExt(ext) {
2131
+ const map = {
2132
+ ".png": "image/png",
2133
+ ".jpg": "image/jpeg",
2134
+ ".jpeg": "image/jpeg",
2135
+ ".gif": "image/gif",
2136
+ ".webp": "image/webp",
2137
+ ".ico": "image/x-icon",
2138
+ ".bmp": "image/bmp",
2139
+ ".svg": "image/svg+xml",
2140
+ };
2141
+ return map[ext.toLowerCase()] ?? "application/octet-stream";
2142
+ }
2143
+ /**
2144
+ * Scan diff text for image file changes and extract base64 data where appropriate.
2145
+ *
2146
+ * Detection strategy:
2147
+ * 1. Binary markers: "Binary files a/<path> and b/<path> differ"
2148
+ * 2. diff --git headers where the file extension is an image type
2149
+ *
2150
+ * For each detected image file:
2151
+ * - Old version: `git show HEAD:<path>` (committed version)
2152
+ * - New version: read from working tree
2153
+ * - Apply size thresholds for auto-display / on-demand / text-only
2154
+ */
2155
+ async collectImageChanges(cwd, diffText) {
2156
+ const entries = [];
2157
+ const processedPaths = new Set();
2158
+ const lines = diffText.split("\n");
2159
+ for (let i = 0; i < lines.length; i++) {
2160
+ const line = lines[i];
2161
+ const gitMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
2162
+ if (!gitMatch)
2163
+ continue;
2164
+ const filePath = gitMatch[2];
2165
+ const ext = extname(filePath).toLowerCase();
2166
+ if (!BridgeWebSocketServer.IMAGE_EXTENSIONS.has(ext))
2167
+ continue;
2168
+ if (processedPaths.has(filePath))
2169
+ continue;
2170
+ processedPaths.add(filePath);
2171
+ let isNew = false;
2172
+ let isDeleted = false;
2173
+ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
2174
+ if (lines[j].startsWith("diff --git "))
2175
+ break;
2176
+ if (lines[j].startsWith("new file mode"))
2177
+ isNew = true;
2178
+ if (lines[j].startsWith("deleted file mode"))
2179
+ isDeleted = true;
2180
+ }
2181
+ entries.push({
2182
+ filePath,
2183
+ isNew,
2184
+ isDeleted,
2185
+ isSvg: ext === ".svg",
2186
+ mimeType: BridgeWebSocketServer.mimeTypeForExt(ext),
2187
+ ext,
2188
+ });
2189
+ }
2190
+ if (entries.length === 0)
2191
+ return [];
2192
+ // Phase 2: Read image data asynchronously
2193
+ const execFileAsync = promisify(execFile);
2194
+ const changes = [];
2195
+ for (const entry of entries) {
2196
+ let oldBuf;
2197
+ let newBuf;
2198
+ // Read old image (committed version)
2199
+ if (!entry.isNew) {
2200
+ try {
2201
+ const result = await execFileAsync("git", ["show", `HEAD:${entry.filePath}`], {
2202
+ cwd,
2203
+ maxBuffer: BridgeWebSocketServer.MAX_IMAGE_SIZE + 1024,
2204
+ encoding: "buffer",
2205
+ });
2206
+ oldBuf = result.stdout;
2207
+ }
2208
+ catch {
2209
+ // File may not exist in HEAD (e.g. untracked)
2210
+ }
2211
+ }
2212
+ // Read new image (working tree)
2213
+ if (!entry.isDeleted) {
2214
+ try {
2215
+ const absPath = resolve(cwd, entry.filePath);
2216
+ if (existsSync(absPath)) {
2217
+ newBuf = await readFile(absPath);
2218
+ }
2219
+ }
2220
+ catch {
2221
+ // Ignore read errors
2222
+ }
2223
+ }
2224
+ const oldSize = oldBuf?.length;
2225
+ const newSize = newBuf?.length;
2226
+ const maxSize = Math.max(oldSize ?? 0, newSize ?? 0);
2227
+ const autoDisplay = maxSize <= BridgeWebSocketServer.AUTO_DISPLAY_THRESHOLD;
2228
+ const loadable = autoDisplay || maxSize <= BridgeWebSocketServer.MAX_IMAGE_SIZE;
2229
+ const change = {
2230
+ filePath: entry.filePath,
2231
+ isNew: entry.isNew,
2232
+ isDeleted: entry.isDeleted,
2233
+ isSvg: entry.isSvg,
2234
+ mimeType: entry.mimeType,
2235
+ loadable,
2236
+ autoDisplay: autoDisplay || undefined,
2237
+ };
2238
+ if (oldSize !== undefined)
2239
+ change.oldSize = oldSize;
2240
+ if (newSize !== undefined)
2241
+ change.newSize = newSize;
2242
+ // Auto-display images are no longer embedded in the initial response.
2243
+ // They are loaded on-demand when the Flutter widget becomes visible.
2244
+ changes.push(change);
2245
+ }
2246
+ return changes;
2247
+ }
2248
+ /**
2249
+ * Load a single diff image on demand (async I/O for better throughput).
2250
+ */
2251
+ async loadDiffImageAsync(cwd, filePath, version) {
2252
+ // Path traversal guard: reject paths containing '..' or absolute paths
2253
+ if (filePath.includes("..") || filePath.startsWith("/")) {
2254
+ return { error: "Invalid file path" };
2255
+ }
2256
+ const ext = extname(filePath).toLowerCase();
2257
+ if (!BridgeWebSocketServer.IMAGE_EXTENSIONS.has(ext)) {
2258
+ return { error: "Not an image file" };
2259
+ }
2260
+ const mimeType = BridgeWebSocketServer.mimeTypeForExt(ext);
2261
+ try {
2262
+ const execFileAsync = promisify(execFile);
2263
+ let buf;
2264
+ if (version === "old") {
2265
+ const result = await execFileAsync("git", ["show", `HEAD:${filePath}`], {
2266
+ cwd,
2267
+ maxBuffer: BridgeWebSocketServer.MAX_IMAGE_SIZE + 1024,
2268
+ encoding: "buffer",
2269
+ });
2270
+ buf = result.stdout;
2271
+ }
2272
+ else {
2273
+ const absPath = resolve(cwd, filePath);
2274
+ // Verify resolved path stays within cwd
2275
+ if (!absPath.startsWith(resolve(cwd) + "/")) {
2276
+ return { error: "Invalid file path" };
2277
+ }
2278
+ buf = await readFile(absPath);
2279
+ }
2280
+ if (buf.length > BridgeWebSocketServer.MAX_IMAGE_SIZE) {
2281
+ return { error: "Image too large" };
2282
+ }
2283
+ return { base64: buf.toString("base64"), mimeType };
2284
+ }
2285
+ catch (err) {
2286
+ return { error: err instanceof Error ? err.message : String(err) };
2287
+ }
2288
+ }
2289
+ extractSessionIdFromClientMessage(msg) {
2290
+ return "sessionId" in msg && typeof msg.sessionId === "string" ? msg.sessionId : undefined;
2291
+ }
2292
+ extractSessionIdFromServerMessage(msg) {
2293
+ if ("sessionId" in msg && typeof msg.sessionId === "string")
2294
+ return msg.sessionId;
2295
+ return undefined;
2296
+ }
2297
+ recordDebugEvent(sessionId, event) {
2298
+ const events = this.debugEvents.get(sessionId) ?? [];
2299
+ const fullEvent = {
2300
+ ts: new Date().toISOString(),
2301
+ sessionId,
2302
+ ...event,
2303
+ };
2304
+ events.push(fullEvent);
2305
+ if (events.length > BridgeWebSocketServer.MAX_DEBUG_EVENTS) {
2306
+ events.splice(0, events.length - BridgeWebSocketServer.MAX_DEBUG_EVENTS);
2307
+ }
2308
+ this.debugEvents.set(sessionId, events);
2309
+ this.debugTraceStore.record(fullEvent);
2310
+ }
2311
+ getDebugEvents(sessionId, limit) {
2312
+ const events = this.debugEvents.get(sessionId) ?? [];
2313
+ const capped = Math.max(0, Math.min(limit, BridgeWebSocketServer.MAX_DEBUG_EVENTS));
2314
+ if (capped === 0)
2315
+ return [];
2316
+ return events.slice(-capped);
2317
+ }
2318
+ buildHistorySummary(history) {
2319
+ const lines = history
2320
+ .map((msg, index) => {
2321
+ const num = String(index + 1).padStart(3, "0");
2322
+ return `${num}. ${this.summarizeServerMessage(msg)}`;
2323
+ });
2324
+ if (lines.length <= BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS) {
2325
+ return lines;
2326
+ }
2327
+ return lines.slice(-BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS);
2328
+ }
2329
+ summarizeClientMessage(msg) {
2330
+ switch (msg.type) {
2331
+ case "input": {
2332
+ const textPreview = msg.text.replace(/\s+/g, " ").trim().slice(0, 80);
2333
+ const hasImage = msg.imageBase64 != null || msg.imageId != null;
2334
+ return `text=\"${textPreview}\" image=${hasImage}`;
2335
+ }
2336
+ case "push_register":
2337
+ return `platform=${msg.platform} token=${msg.token.slice(0, 8)}...`;
2338
+ case "push_unregister":
2339
+ return `token=${msg.token.slice(0, 8)}...`;
2340
+ case "approve":
2341
+ case "approve_always":
2342
+ case "reject":
2343
+ return `id=${msg.id}`;
2344
+ case "answer":
2345
+ return `toolUseId=${msg.toolUseId}`;
2346
+ case "start":
2347
+ return `projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`;
2348
+ case "resume_session":
2349
+ return `sessionId=${msg.sessionId} provider=${msg.provider ?? "claude"}`;
2350
+ case "get_debug_bundle":
2351
+ return `traceLimit=${msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS} includeDiff=${msg.includeDiff ?? true}`;
2352
+ case "get_usage":
2353
+ return "get_usage";
2354
+ default:
2355
+ return msg.type;
2356
+ }
2357
+ }
2358
+ summarizeServerMessage(msg) {
2359
+ switch (msg.type) {
2360
+ case "assistant": {
2361
+ const textChunks = [];
2362
+ for (const content of msg.message.content) {
2363
+ if (content.type === "text") {
2364
+ textChunks.push(content.text);
2365
+ }
2366
+ }
2367
+ const text = textChunks
2368
+ .join(" ")
2369
+ .replace(/\s+/g, " ")
2370
+ .trim()
2371
+ .slice(0, 100);
2372
+ return text ? `assistant: ${text}` : "assistant";
2373
+ }
2374
+ case "tool_result": {
2375
+ const contentPreview = msg.content.replace(/\s+/g, " ").trim().slice(0, 100);
2376
+ return `${msg.toolName ?? "tool_result"}(${msg.toolUseId}) ${contentPreview}`;
2377
+ }
2378
+ case "permission_request":
2379
+ return `${msg.toolName}(${msg.toolUseId})`;
2380
+ case "result":
2381
+ return `${msg.subtype}${msg.error ? ` error=${msg.error}` : ""}`;
2382
+ case "status":
2383
+ return msg.status;
2384
+ case "error":
2385
+ return msg.message;
2386
+ case "stream_delta":
2387
+ case "thinking_delta":
2388
+ return `${msg.type}(${msg.text.length})`;
2389
+ default:
2390
+ return msg.type;
2391
+ }
2392
+ }
2393
+ summarizeOutboundMessage(msg) {
2394
+ if ("type" in msg && typeof msg.type === "string") {
2395
+ return msg.type;
2396
+ }
2397
+ return "message";
2398
+ }
2399
+ pruneDebugEvents() {
2400
+ const active = new Set(this.sessionManager.list().map((s) => s.id));
2401
+ for (const sessionId of this.debugEvents.keys()) {
2402
+ if (!active.has(sessionId)) {
2403
+ this.debugEvents.delete(sessionId);
2404
+ }
2405
+ }
2406
+ for (const sessionId of this.notifiedPermissionToolUses.keys()) {
2407
+ if (!active.has(sessionId)) {
2408
+ this.notifiedPermissionToolUses.delete(sessionId);
2409
+ }
2410
+ }
2411
+ }
2412
+ buildReproRecipe(session, traceLimit, includeDiff) {
2413
+ const bridgePort = process.env.BRIDGE_PORT ?? "8765";
2414
+ const wsUrlHint = `ws://localhost:${bridgePort}`;
2415
+ const notes = [
2416
+ "1) Connect with wsUrlHint and send resumeSessionMessage.",
2417
+ "2) Read session_created.sessionId from server response.",
2418
+ "3) Replace <runtime_session_id> in getHistoryMessage/getDebugBundleMessage and send them.",
2419
+ "4) Compare history/debugTrace/diff with the saved bundle snapshot.",
2420
+ ];
2421
+ if (!session.claudeSessionId) {
2422
+ notes.push("claudeSessionId is not available yet. Use list_recent_sessions to pick the right session id.");
2423
+ }
2424
+ return {
2425
+ wsUrlHint,
2426
+ startBridgeCommand: `BRIDGE_PORT=${bridgePort} npm run bridge`,
2427
+ resumeSessionMessage: this.buildResumeSessionMessage(session),
2428
+ getHistoryMessage: {
2429
+ type: "get_history",
2430
+ sessionId: "<runtime_session_id>",
2431
+ },
2432
+ getDebugBundleMessage: {
2433
+ type: "get_debug_bundle",
2434
+ sessionId: "<runtime_session_id>",
2435
+ traceLimit,
2436
+ includeDiff,
2437
+ },
2438
+ notes,
2439
+ };
2440
+ }
2441
+ buildResumeSessionMessage(session) {
2442
+ const msg = {
2443
+ type: "resume_session",
2444
+ sessionId: session.claudeSessionId ?? "<session_id_from_recent_sessions>",
2445
+ projectPath: session.projectPath,
2446
+ provider: session.provider,
2447
+ };
2448
+ if (session.provider === "codex" && session.codexSettings) {
2449
+ if (session.codexSettings.approvalPolicy !== undefined) {
2450
+ msg.approvalPolicy = session.codexSettings.approvalPolicy;
2451
+ }
2452
+ if (session.codexSettings.sandboxMode !== undefined) {
2453
+ msg.sandboxMode = session.codexSettings.sandboxMode;
2454
+ }
2455
+ if (session.codexSettings.model !== undefined) {
2456
+ msg.model = session.codexSettings.model;
2457
+ }
2458
+ if (session.codexSettings.modelReasoningEffort !== undefined) {
2459
+ msg.modelReasoningEffort = session.codexSettings.modelReasoningEffort;
2460
+ }
2461
+ if (session.codexSettings.networkAccessEnabled !== undefined) {
2462
+ msg.networkAccessEnabled = session.codexSettings.networkAccessEnabled;
2463
+ }
2464
+ if (session.codexSettings.webSearchMode !== undefined) {
2465
+ msg.webSearchMode = session.codexSettings.webSearchMode;
2466
+ }
2467
+ }
2468
+ return msg;
2469
+ }
2470
+ buildAgentPrompt(session) {
2471
+ return [
2472
+ "Use this ccpocket debug bundle to investigate a chat-screen bug.",
2473
+ `Target provider: ${session.provider}`,
2474
+ `Project path: ${session.projectPath}`,
2475
+ "Required output:",
2476
+ "1) Timeline analysis from historySummary + debugTrace.",
2477
+ "2) Top 1-3 root-cause hypotheses with confidence.",
2478
+ "3) Concrete validation steps and the minimum extra logs needed.",
2479
+ ].join("\n");
2480
+ }
2481
+ }
2482
+ //# sourceMappingURL=websocket.js.map