@ccpocket/bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +54 -0
  2. package/dist/claude-process.d.ts +108 -0
  3. package/dist/claude-process.js +471 -0
  4. package/dist/claude-process.js.map +1 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +42 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/codex-process.d.ts +46 -0
  9. package/dist/codex-process.js +420 -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/firebase-auth.d.ts +35 -0
  15. package/dist/firebase-auth.js +132 -0
  16. package/dist/firebase-auth.js.map +1 -0
  17. package/dist/gallery-store.d.ts +66 -0
  18. package/dist/gallery-store.js +310 -0
  19. package/dist/gallery-store.js.map +1 -0
  20. package/dist/image-store.d.ts +22 -0
  21. package/dist/image-store.js +113 -0
  22. package/dist/image-store.js.map +1 -0
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +153 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/mdns.d.ts +6 -0
  27. package/dist/mdns.js +42 -0
  28. package/dist/mdns.js.map +1 -0
  29. package/dist/parser.d.ts +381 -0
  30. package/dist/parser.js +218 -0
  31. package/dist/parser.js.map +1 -0
  32. package/dist/project-history.d.ts +10 -0
  33. package/dist/project-history.js +73 -0
  34. package/dist/project-history.js.map +1 -0
  35. package/dist/prompt-history-backup.d.ts +15 -0
  36. package/dist/prompt-history-backup.js +46 -0
  37. package/dist/prompt-history-backup.js.map +1 -0
  38. package/dist/push-relay.d.ts +27 -0
  39. package/dist/push-relay.js +69 -0
  40. package/dist/push-relay.js.map +1 -0
  41. package/dist/recording-store.d.ts +51 -0
  42. package/dist/recording-store.js +158 -0
  43. package/dist/recording-store.js.map +1 -0
  44. package/dist/screenshot.d.ts +28 -0
  45. package/dist/screenshot.js +98 -0
  46. package/dist/screenshot.js.map +1 -0
  47. package/dist/sdk-process.d.ts +151 -0
  48. package/dist/sdk-process.js +740 -0
  49. package/dist/sdk-process.js.map +1 -0
  50. package/dist/session.d.ts +126 -0
  51. package/dist/session.js +550 -0
  52. package/dist/session.js.map +1 -0
  53. package/dist/sessions-index.d.ts +86 -0
  54. package/dist/sessions-index.js +1027 -0
  55. package/dist/sessions-index.js.map +1 -0
  56. package/dist/setup-launchd.d.ts +8 -0
  57. package/dist/setup-launchd.js +109 -0
  58. package/dist/setup-launchd.js.map +1 -0
  59. package/dist/startup-info.d.ts +8 -0
  60. package/dist/startup-info.js +78 -0
  61. package/dist/startup-info.js.map +1 -0
  62. package/dist/usage.d.ts +17 -0
  63. package/dist/usage.js +236 -0
  64. package/dist/usage.js.map +1 -0
  65. package/dist/version.d.ts +11 -0
  66. package/dist/version.js +39 -0
  67. package/dist/version.js.map +1 -0
  68. package/dist/websocket.d.ts +71 -0
  69. package/dist/websocket.js +1487 -0
  70. package/dist/websocket.js.map +1 -0
  71. package/dist/worktree-store.d.ts +25 -0
  72. package/dist/worktree-store.js +59 -0
  73. package/dist/worktree-store.js.map +1 -0
  74. package/dist/worktree.d.ts +43 -0
  75. package/dist/worktree.js +295 -0
  76. package/dist/worktree.js.map +1 -0
  77. package/package.json +63 -0
@@ -0,0 +1,1487 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { unlink } from "node:fs/promises";
3
+ import { WebSocketServer, WebSocket } from "ws";
4
+ import { SessionManager } from "./session.js";
5
+ import { SdkProcess } from "./sdk-process.js";
6
+ import { parseClientMessage } from "./parser.js";
7
+ import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages } from "./sessions-index.js";
8
+ import { WorktreeStore } from "./worktree-store.js";
9
+ import { listWorktrees, removeWorktree, worktreeExists } from "./worktree.js";
10
+ import { listWindows, takeScreenshot } from "./screenshot.js";
11
+ import { DebugTraceStore } from "./debug-trace-store.js";
12
+ import { RecordingStore } from "./recording-store.js";
13
+ import { PushRelayClient } from "./push-relay.js";
14
+ import { fetchAllUsage } from "./usage.js";
15
+ export class BridgeWebSocketServer {
16
+ static MAX_DEBUG_EVENTS = 800;
17
+ static MAX_HISTORY_SUMMARY_ITEMS = 300;
18
+ wss;
19
+ sessionManager;
20
+ apiKey;
21
+ imageStore;
22
+ galleryStore;
23
+ projectHistory;
24
+ debugTraceStore;
25
+ recordingStore;
26
+ worktreeStore;
27
+ pushRelay;
28
+ promptHistoryBackup;
29
+ recentSessionsRequestId = 0;
30
+ debugEvents = new Map();
31
+ notifiedPermissionToolUses = new Map();
32
+ constructor(options) {
33
+ const { server, apiKey, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup } = options;
34
+ this.apiKey = apiKey ?? null;
35
+ this.imageStore = imageStore ?? null;
36
+ this.galleryStore = galleryStore ?? null;
37
+ this.projectHistory = projectHistory ?? null;
38
+ this.debugTraceStore = debugTraceStore ?? new DebugTraceStore();
39
+ this.recordingStore = recordingStore ?? new RecordingStore();
40
+ this.worktreeStore = new WorktreeStore();
41
+ this.pushRelay = new PushRelayClient({ firebaseAuth });
42
+ this.promptHistoryBackup = promptHistoryBackup ?? null;
43
+ void this.debugTraceStore.init().catch((err) => {
44
+ console.error("[ws] Failed to initialize debug trace store:", err);
45
+ });
46
+ void this.recordingStore.init().catch((err) => {
47
+ console.error("[ws] Failed to initialize recording store:", err);
48
+ });
49
+ if (!this.pushRelay.isConfigured) {
50
+ console.log("[ws] Push relay disabled (Firebase auth not available)");
51
+ }
52
+ else {
53
+ console.log("[ws] Push relay enabled (Firebase Anonymous Auth)");
54
+ }
55
+ this.wss = new WebSocketServer({ server });
56
+ this.sessionManager = new SessionManager((sessionId, msg) => {
57
+ this.broadcastSessionMessage(sessionId, msg);
58
+ }, imageStore, galleryStore,
59
+ // Broadcast gallery_new_image when a new image is added
60
+ (meta) => {
61
+ if (this.galleryStore) {
62
+ const info = this.galleryStore.metaToInfo(meta);
63
+ this.broadcast({ type: "gallery_new_image", image: info });
64
+ }
65
+ }, this.worktreeStore);
66
+ this.wss.on("connection", (ws, req) => {
67
+ // API key authentication
68
+ if (this.apiKey) {
69
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
70
+ const token = url.searchParams.get("token");
71
+ if (token !== this.apiKey) {
72
+ console.log("[ws] Client rejected: invalid token");
73
+ ws.close(4001, "Unauthorized");
74
+ return;
75
+ }
76
+ }
77
+ console.log("[ws] Client connected");
78
+ this.handleConnection(ws);
79
+ });
80
+ this.wss.on("error", (err) => {
81
+ console.error("[ws] Server error:", err.message);
82
+ });
83
+ console.log(`[ws] WebSocket server attached to HTTP server`);
84
+ }
85
+ close() {
86
+ console.log("[ws] Shutting down...");
87
+ this.sessionManager.destroyAll();
88
+ this.debugEvents.clear();
89
+ this.wss.close();
90
+ }
91
+ /** Return session count for /health endpoint. */
92
+ get sessionCount() {
93
+ return this.sessionManager.list().length;
94
+ }
95
+ /** Return connected WebSocket client count. */
96
+ get clientCount() {
97
+ return this.wss.clients.size;
98
+ }
99
+ handleConnection(ws) {
100
+ // Send session list on connect
101
+ this.sendSessionList(ws);
102
+ ws.on("message", (data) => {
103
+ const raw = data.toString();
104
+ const msg = parseClientMessage(raw);
105
+ if (!msg) {
106
+ console.error("[ws] Invalid message:", raw.slice(0, 200));
107
+ this.send(ws, { type: "error", message: "Invalid message format" });
108
+ return;
109
+ }
110
+ console.log(`[ws] Received: ${msg.type}`);
111
+ this.handleClientMessage(msg, ws);
112
+ });
113
+ ws.on("close", () => {
114
+ console.log("[ws] Client disconnected");
115
+ });
116
+ ws.on("error", (err) => {
117
+ console.error("[ws] Client error:", err.message);
118
+ });
119
+ }
120
+ handleClientMessage(msg, ws) {
121
+ const incomingSessionId = this.extractSessionIdFromClientMessage(msg);
122
+ const isActiveRuntimeSession = incomingSessionId != null && this.sessionManager.get(incomingSessionId) != null;
123
+ if (incomingSessionId && isActiveRuntimeSession) {
124
+ this.recordDebugEvent(incomingSessionId, {
125
+ direction: "incoming",
126
+ channel: "ws",
127
+ type: msg.type,
128
+ detail: this.summarizeClientMessage(msg),
129
+ });
130
+ this.recordingStore.record(incomingSessionId, "incoming", msg);
131
+ }
132
+ switch (msg.type) {
133
+ case "start": {
134
+ const provider = msg.provider ?? "claude";
135
+ const cached = provider === "claude" ? this.sessionManager.getCachedCommands(msg.projectPath) : undefined;
136
+ const sessionId = this.sessionManager.create(msg.projectPath, {
137
+ sessionId: msg.sessionId,
138
+ continueMode: msg.continue,
139
+ permissionMode: msg.permissionMode,
140
+ model: msg.model,
141
+ effort: msg.effort,
142
+ maxTurns: msg.maxTurns,
143
+ maxBudgetUsd: msg.maxBudgetUsd,
144
+ fallbackModel: msg.fallbackModel,
145
+ forkSession: msg.forkSession,
146
+ persistSession: msg.persistSession,
147
+ }, undefined, {
148
+ useWorktree: msg.useWorktree,
149
+ worktreeBranch: msg.worktreeBranch,
150
+ existingWorktreePath: msg.existingWorktreePath,
151
+ }, provider, provider === "codex"
152
+ ? {
153
+ approvalPolicy: msg.approvalPolicy ?? undefined,
154
+ sandboxMode: msg.sandboxMode ?? undefined,
155
+ model: msg.model,
156
+ modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
157
+ networkAccessEnabled: msg.networkAccessEnabled,
158
+ webSearchMode: msg.webSearchMode ?? undefined,
159
+ threadId: msg.sessionId,
160
+ }
161
+ : undefined);
162
+ const createdSession = this.sessionManager.get(sessionId);
163
+ this.send(ws, {
164
+ type: "system",
165
+ subtype: "session_created",
166
+ sessionId,
167
+ provider,
168
+ projectPath: msg.projectPath,
169
+ ...(provider === "claude" && msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
170
+ ...(provider === "codex" && msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
171
+ ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
172
+ ...(createdSession?.worktreePath ? {
173
+ worktreePath: createdSession.worktreePath,
174
+ worktreeBranch: createdSession.worktreeBranch,
175
+ } : {}),
176
+ });
177
+ this.debugEvents.set(sessionId, []);
178
+ this.recordDebugEvent(sessionId, {
179
+ direction: "internal",
180
+ channel: "bridge",
181
+ type: "session_created",
182
+ detail: `provider=${provider} projectPath=${msg.projectPath}`,
183
+ });
184
+ this.recordingStore.saveMeta(sessionId, {
185
+ bridgeSessionId: sessionId,
186
+ projectPath: msg.projectPath,
187
+ createdAt: new Date().toISOString(),
188
+ });
189
+ this.projectHistory?.addProject(msg.projectPath);
190
+ break;
191
+ }
192
+ case "input": {
193
+ const session = this.resolveSession(msg.sessionId);
194
+ if (!session) {
195
+ this.send(ws, { type: "error", message: "No active session. Send 'start' first." });
196
+ return;
197
+ }
198
+ const text = msg.text;
199
+ // Codex: reject if the process is not waiting for input (turn-based, no internal queue)
200
+ // SDK (Claude Code): always accept — the async generator keeps the resolver set during processing
201
+ if (session.provider === "codex" && !session.process.isWaitingForInput) {
202
+ this.send(ws, { type: "input_rejected", sessionId: session.id, reason: "Process is busy" });
203
+ break;
204
+ }
205
+ // Acknowledge receipt immediately so the client can mark the message as sent
206
+ this.send(ws, { type: "input_ack", sessionId: session.id });
207
+ // Add user_input to in-memory history.
208
+ // The SDK stream does NOT emit user messages, so session.history would
209
+ // otherwise lack them. This ensures get_history responses include user
210
+ // messages and replaceEntries on the client side preserves them.
211
+ // We do NOT broadcast this back — Flutter already shows it via sendMessage().
212
+ const hasImage = !!(msg.imageBase64 || msg.imageId);
213
+ session.history.push({
214
+ type: "user_input",
215
+ text,
216
+ ...(hasImage ? { imageCount: 1 } : {}),
217
+ });
218
+ // Codex input path (text + optional image)
219
+ if (session.provider === "codex") {
220
+ const codexProc = session.process;
221
+ if (msg.imageBase64 && msg.mimeType) {
222
+ codexProc.sendInputWithImage(text, {
223
+ base64: msg.imageBase64,
224
+ mimeType: msg.mimeType,
225
+ });
226
+ if (this.galleryStore && session.projectPath) {
227
+ this.galleryStore.addImageFromBase64(msg.imageBase64, msg.mimeType, session.projectPath, msg.sessionId).catch((err) => {
228
+ console.warn(`[ws] Failed to persist image to gallery: ${err}`);
229
+ });
230
+ }
231
+ }
232
+ else if (msg.imageId && this.galleryStore) {
233
+ this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
234
+ if (imageData) {
235
+ codexProc.sendInputWithImage(text, imageData);
236
+ }
237
+ else {
238
+ console.warn(`[ws] Image not found: ${msg.imageId}`);
239
+ codexProc.sendInput(text);
240
+ }
241
+ }).catch((err) => {
242
+ console.error(`[ws] Failed to load image: ${err}`);
243
+ codexProc.sendInput(text);
244
+ });
245
+ }
246
+ else {
247
+ codexProc.sendInput(text);
248
+ }
249
+ break;
250
+ }
251
+ // Priority 1: Direct Base64 image (simplified flow)
252
+ const claudeProc = session.process;
253
+ if (msg.imageBase64 && msg.mimeType) {
254
+ console.log(`[ws] Sending message with inline Base64 image (${msg.mimeType})`);
255
+ claudeProc.sendInputWithImage(text, {
256
+ base64: msg.imageBase64,
257
+ mimeType: msg.mimeType,
258
+ });
259
+ // Persist to Gallery Store asynchronously (fire-and-forget)
260
+ if (this.galleryStore && session.projectPath) {
261
+ this.galleryStore.addImageFromBase64(msg.imageBase64, msg.mimeType, session.projectPath, msg.sessionId).catch((err) => {
262
+ console.warn(`[ws] Failed to persist image to gallery: ${err}`);
263
+ });
264
+ }
265
+ }
266
+ // Priority 2: Legacy imageId mode (backward compatibility)
267
+ else if (msg.imageId && this.galleryStore) {
268
+ this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
269
+ if (imageData) {
270
+ claudeProc.sendInputWithImage(text, imageData);
271
+ }
272
+ else {
273
+ console.warn(`[ws] Image not found: ${msg.imageId}`);
274
+ session.process.sendInput(text);
275
+ }
276
+ }).catch((err) => {
277
+ console.error(`[ws] Failed to load image: ${err}`);
278
+ session.process.sendInput(text);
279
+ });
280
+ }
281
+ // Priority 3: Text-only message
282
+ else {
283
+ session.process.sendInput(text);
284
+ }
285
+ break;
286
+ }
287
+ case "push_register": {
288
+ console.log(`[ws] push_register received (platform: ${msg.platform}, configured: ${this.pushRelay.isConfigured})`);
289
+ if (!this.pushRelay.isConfigured) {
290
+ this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
291
+ return;
292
+ }
293
+ this.pushRelay.registerToken(msg.token, msg.platform).then(() => {
294
+ console.log("[ws] push_register: token registered successfully");
295
+ }).catch((err) => {
296
+ const detail = err instanceof Error ? err.message : String(err);
297
+ console.error(`[ws] push_register failed: ${detail}`);
298
+ this.send(ws, { type: "error", message: `Failed to register push token: ${detail}` });
299
+ });
300
+ break;
301
+ }
302
+ case "push_unregister": {
303
+ console.log("[ws] push_unregister received");
304
+ if (!this.pushRelay.isConfigured) {
305
+ this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
306
+ return;
307
+ }
308
+ this.pushRelay.unregisterToken(msg.token).then(() => {
309
+ console.log("[ws] push_unregister: token unregistered successfully");
310
+ }).catch((err) => {
311
+ const detail = err instanceof Error ? err.message : String(err);
312
+ console.error(`[ws] push_unregister failed: ${detail}`);
313
+ this.send(ws, { type: "error", message: `Failed to unregister push token: ${detail}` });
314
+ });
315
+ break;
316
+ }
317
+ case "set_permission_mode": {
318
+ const session = this.resolveSession(msg.sessionId);
319
+ if (!session) {
320
+ this.send(ws, { type: "error", message: "No active session." });
321
+ return;
322
+ }
323
+ if (session.provider === "codex") {
324
+ this.send(ws, {
325
+ type: "error",
326
+ message: "Codex sessions do not support runtime permission mode changes",
327
+ });
328
+ return;
329
+ }
330
+ session.process.setPermissionMode(msg.mode).catch((err) => {
331
+ this.send(ws, {
332
+ type: "error",
333
+ message: `Failed to set permission mode: ${err instanceof Error ? err.message : String(err)}`,
334
+ });
335
+ });
336
+ break;
337
+ }
338
+ case "set_sandbox_mode": {
339
+ const session = this.resolveSession(msg.sessionId);
340
+ if (!session) {
341
+ this.send(ws, { type: "error", message: "No active session." });
342
+ return;
343
+ }
344
+ if (session.provider !== "codex") {
345
+ this.send(ws, { type: "error", message: "Only Codex sessions support sandbox mode changes" });
346
+ return;
347
+ }
348
+ const validModes = ["read-only", "workspace-write", "danger-full-access"];
349
+ if (!validModes.includes(msg.sandboxMode)) {
350
+ this.send(ws, { type: "error", message: `Invalid sandbox mode: ${msg.sandboxMode}` });
351
+ return;
352
+ }
353
+ const newSandboxMode = msg.sandboxMode;
354
+ // Update stored settings
355
+ if (!session.codexSettings) {
356
+ session.codexSettings = {};
357
+ }
358
+ session.codexSettings.sandboxMode = newSandboxMode;
359
+ // Restart Codex process with new sandboxMode (required: sandboxMode is set at thread start)
360
+ const codexProc = session.process;
361
+ const threadId = session.claudeSessionId ?? undefined;
362
+ const effectiveCwd = session.worktreePath ?? session.projectPath;
363
+ codexProc.stop();
364
+ codexProc.start(effectiveCwd, {
365
+ approvalPolicy: session.codexSettings.approvalPolicy ?? undefined,
366
+ sandboxMode: newSandboxMode,
367
+ model: session.codexSettings.model,
368
+ modelReasoningEffort: session.codexSettings.modelReasoningEffort ?? undefined,
369
+ networkAccessEnabled: session.codexSettings.networkAccessEnabled,
370
+ webSearchMode: session.codexSettings.webSearchMode ?? undefined,
371
+ threadId,
372
+ });
373
+ console.log(`[ws] Sandbox mode changed to ${newSandboxMode} for session ${session.id} (thread restart)`);
374
+ break;
375
+ }
376
+ case "approve": {
377
+ const session = this.resolveSession(msg.sessionId);
378
+ if (!session) {
379
+ this.send(ws, { type: "error", message: "No active session." });
380
+ return;
381
+ }
382
+ if (session.provider === "codex") {
383
+ this.send(ws, { type: "error", message: "Codex sessions do not support approval" });
384
+ return;
385
+ }
386
+ const sdkProc = session.process;
387
+ if (msg.clearContext) {
388
+ // Clear & Accept: immediately destroy this runtime session and
389
+ // create a fresh one that continues the same Claude conversation.
390
+ // This guarantees chat history is cleared in the mobile UI without
391
+ // waiting for additional in-turn tool approvals.
392
+ const pending = sdkProc.getPendingPermission(msg.id);
393
+ const mergedInput = {
394
+ ...(pending?.input ?? {}),
395
+ ...(msg.updatedInput ?? {}),
396
+ };
397
+ const planText = typeof mergedInput.plan === "string" ? mergedInput.plan : "";
398
+ // Use session.id (always present) instead of msg.sessionId.
399
+ const sessionId = session.id;
400
+ // Capture session properties before destroy.
401
+ const claudeSessionId = session.claudeSessionId;
402
+ const projectPath = session.projectPath;
403
+ const permissionMode = sdkProc.permissionMode;
404
+ const worktreePath = session.worktreePath;
405
+ const worktreeBranch = session.worktreeBranch;
406
+ this.sessionManager.destroy(sessionId);
407
+ console.log(`[ws] Clear context: destroyed session ${sessionId}`);
408
+ const newId = this.sessionManager.create(projectPath, {
409
+ ...(claudeSessionId
410
+ ? {
411
+ sessionId: claudeSessionId,
412
+ continueMode: true,
413
+ }
414
+ : {}),
415
+ permissionMode,
416
+ initialInput: planText || undefined,
417
+ }, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined);
418
+ console.log(`[ws] Clear context: created new session ${newId} (CLI session: ${claudeSessionId ?? "new"})`);
419
+ // Notify all clients. Broadcast is used so reconnecting clients also receive it.
420
+ const newSession = this.sessionManager.get(newId);
421
+ this.broadcast({
422
+ type: "system",
423
+ subtype: "session_created",
424
+ sessionId: newId,
425
+ provider: newSession?.provider ?? "claude",
426
+ projectPath,
427
+ ...(permissionMode ? { permissionMode } : {}),
428
+ clearContext: true,
429
+ });
430
+ this.broadcastSessionList();
431
+ }
432
+ else {
433
+ sdkProc.approve(msg.id, msg.updatedInput);
434
+ }
435
+ break;
436
+ }
437
+ case "approve_always": {
438
+ const session = this.resolveSession(msg.sessionId);
439
+ if (!session) {
440
+ this.send(ws, { type: "error", message: "No active session." });
441
+ return;
442
+ }
443
+ if (session.provider === "codex") {
444
+ this.send(ws, { type: "error", message: "Codex sessions do not support approval" });
445
+ return;
446
+ }
447
+ session.process.approveAlways(msg.id);
448
+ break;
449
+ }
450
+ case "reject": {
451
+ const session = this.resolveSession(msg.sessionId);
452
+ if (!session) {
453
+ this.send(ws, { type: "error", message: "No active session." });
454
+ return;
455
+ }
456
+ if (session.provider === "codex") {
457
+ this.send(ws, { type: "error", message: "Codex sessions do not support rejection" });
458
+ return;
459
+ }
460
+ session.process.reject(msg.id, msg.message);
461
+ break;
462
+ }
463
+ case "answer": {
464
+ const session = this.resolveSession(msg.sessionId);
465
+ if (!session) {
466
+ this.send(ws, { type: "error", message: "No active session." });
467
+ return;
468
+ }
469
+ if (session.provider === "codex") {
470
+ this.send(ws, { type: "error", message: "Codex sessions do not support answer" });
471
+ return;
472
+ }
473
+ session.process.answer(msg.toolUseId, msg.result);
474
+ break;
475
+ }
476
+ case "list_sessions": {
477
+ this.sendSessionList(ws);
478
+ break;
479
+ }
480
+ case "stop_session": {
481
+ const session = this.sessionManager.get(msg.sessionId);
482
+ if (session) {
483
+ // Notify clients before destroying (destroy removes listeners)
484
+ this.broadcastSessionMessage(msg.sessionId, {
485
+ type: "result",
486
+ subtype: "stopped",
487
+ sessionId: session.claudeSessionId,
488
+ });
489
+ this.sessionManager.destroy(msg.sessionId);
490
+ this.recordDebugEvent(msg.sessionId, {
491
+ direction: "internal",
492
+ channel: "bridge",
493
+ type: "session_stopped",
494
+ });
495
+ this.debugEvents.delete(msg.sessionId);
496
+ this.notifiedPermissionToolUses.delete(msg.sessionId);
497
+ this.sendSessionList(ws);
498
+ }
499
+ else {
500
+ this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
501
+ }
502
+ break;
503
+ }
504
+ case "get_history": {
505
+ const session = this.sessionManager.get(msg.sessionId);
506
+ if (session) {
507
+ // Send past conversation from disk (resume) before in-memory history
508
+ if (session.pastMessages && session.pastMessages.length > 0) {
509
+ this.send(ws, {
510
+ type: "past_history",
511
+ claudeSessionId: session.claudeSessionId ?? msg.sessionId,
512
+ sessionId: msg.sessionId,
513
+ messages: session.pastMessages,
514
+ });
515
+ }
516
+ this.send(ws, { type: "history", messages: session.history, sessionId: msg.sessionId });
517
+ this.send(ws, { type: "status", status: session.status, sessionId: msg.sessionId });
518
+ }
519
+ else {
520
+ this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
521
+ }
522
+ break;
523
+ }
524
+ case "get_debug_bundle": {
525
+ const session = this.sessionManager.get(msg.sessionId);
526
+ if (!session) {
527
+ this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
528
+ return;
529
+ }
530
+ const emitBundle = (diff, diffError) => {
531
+ const traceLimit = msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS;
532
+ const trace = this.getDebugEvents(msg.sessionId, traceLimit);
533
+ const generatedAt = new Date().toISOString();
534
+ const includeDiff = msg.includeDiff !== false;
535
+ const bundlePayload = {
536
+ type: "debug_bundle",
537
+ sessionId: msg.sessionId,
538
+ generatedAt,
539
+ session: {
540
+ id: session.id,
541
+ provider: session.provider,
542
+ status: session.status,
543
+ projectPath: session.projectPath,
544
+ worktreePath: session.worktreePath,
545
+ worktreeBranch: session.worktreeBranch,
546
+ claudeSessionId: session.claudeSessionId,
547
+ createdAt: session.createdAt.toISOString(),
548
+ lastActivityAt: session.lastActivityAt.toISOString(),
549
+ },
550
+ pastMessageCount: session.pastMessages?.length ?? 0,
551
+ historySummary: this.buildHistorySummary(session.history),
552
+ debugTrace: trace,
553
+ traceFilePath: this.debugTraceStore.getTraceFilePath(msg.sessionId),
554
+ reproRecipe: this.buildReproRecipe(session, traceLimit, includeDiff),
555
+ agentPrompt: this.buildAgentPrompt(session),
556
+ diff,
557
+ diffError,
558
+ };
559
+ const savedBundlePath = this.debugTraceStore.getBundleFilePath(msg.sessionId, generatedAt);
560
+ bundlePayload.savedBundlePath = savedBundlePath;
561
+ this.debugTraceStore.saveBundleAtPath(savedBundlePath, bundlePayload);
562
+ this.send(ws, bundlePayload);
563
+ };
564
+ if (msg.includeDiff === false) {
565
+ emitBundle("");
566
+ break;
567
+ }
568
+ const cwd = session.worktreePath ?? session.projectPath;
569
+ this.collectGitDiff(cwd, ({ diff, error }) => {
570
+ emitBundle(diff, error);
571
+ });
572
+ break;
573
+ }
574
+ case "get_usage": {
575
+ fetchAllUsage().then((providers) => {
576
+ this.send(ws, { type: "usage_result", providers });
577
+ }).catch((err) => {
578
+ this.send(ws, { type: "error", message: `Failed to fetch usage: ${err}` });
579
+ });
580
+ break;
581
+ }
582
+ case "list_recent_sessions": {
583
+ const requestId = ++this.recentSessionsRequestId;
584
+ getAllRecentSessions({
585
+ limit: msg.limit,
586
+ offset: msg.offset,
587
+ projectPath: msg.projectPath,
588
+ }).then(({ sessions, hasMore }) => {
589
+ // Drop stale responses when rapid filter switches cause out-of-order completion
590
+ if (requestId !== this.recentSessionsRequestId)
591
+ return;
592
+ this.send(ws, { type: "recent_sessions", sessions, hasMore });
593
+ }).catch((err) => {
594
+ if (requestId !== this.recentSessionsRequestId)
595
+ return;
596
+ this.send(ws, { type: "error", message: `Failed to list recent sessions: ${err}` });
597
+ });
598
+ break;
599
+ }
600
+ case "resume_session": {
601
+ const provider = msg.provider ?? "claude";
602
+ const sessionRefId = msg.sessionId;
603
+ // Resume flow: keep past history in SessionInfo and deliver it only
604
+ // via get_history(sessionId) to avoid duplicate/missed replay races.
605
+ if (provider === "codex") {
606
+ const wtMapping = this.worktreeStore.get(sessionRefId);
607
+ const effectiveProjectPath = wtMapping?.projectPath ?? msg.projectPath;
608
+ let worktreeOpts;
609
+ if (wtMapping) {
610
+ if (worktreeExists(wtMapping.worktreePath)) {
611
+ worktreeOpts = {
612
+ existingWorktreePath: wtMapping.worktreePath,
613
+ worktreeBranch: wtMapping.worktreeBranch,
614
+ };
615
+ }
616
+ else {
617
+ worktreeOpts = {
618
+ useWorktree: true,
619
+ worktreeBranch: wtMapping.worktreeBranch,
620
+ };
621
+ }
622
+ }
623
+ getCodexSessionHistory(sessionRefId).then((pastMessages) => {
624
+ const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
625
+ threadId: sessionRefId,
626
+ approvalPolicy: msg.approvalPolicy ?? undefined,
627
+ sandboxMode: msg.sandboxMode ?? undefined,
628
+ model: msg.model,
629
+ modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
630
+ networkAccessEnabled: msg.networkAccessEnabled,
631
+ webSearchMode: msg.webSearchMode ?? undefined,
632
+ });
633
+ const createdSession = this.sessionManager.get(sessionId);
634
+ this.send(ws, {
635
+ type: "system",
636
+ subtype: "session_created",
637
+ sessionId,
638
+ provider: "codex",
639
+ projectPath: effectiveProjectPath,
640
+ ...(createdSession?.codexSettings?.sandboxMode ? { sandboxMode: createdSession.codexSettings.sandboxMode } : {}),
641
+ ...(createdSession?.worktreePath ? {
642
+ worktreePath: createdSession.worktreePath,
643
+ worktreeBranch: createdSession.worktreeBranch,
644
+ } : {}),
645
+ });
646
+ this.debugEvents.set(sessionId, []);
647
+ this.recordDebugEvent(sessionId, {
648
+ direction: "internal",
649
+ channel: "bridge",
650
+ type: "session_resumed",
651
+ detail: `provider=codex thread=${sessionRefId}`,
652
+ });
653
+ this.projectHistory?.addProject(effectiveProjectPath);
654
+ }).catch((err) => {
655
+ this.send(ws, { type: "error", message: `Failed to load Codex session history: ${err}` });
656
+ });
657
+ break;
658
+ }
659
+ const claudeSessionId = sessionRefId;
660
+ const cached = this.sessionManager.getCachedCommands(msg.projectPath);
661
+ // Look up worktree mapping for this Claude session
662
+ const wtMapping = this.worktreeStore.get(claudeSessionId);
663
+ let worktreeOpts;
664
+ if (wtMapping) {
665
+ if (worktreeExists(wtMapping.worktreePath)) {
666
+ // Worktree exists — reuse it directly
667
+ worktreeOpts = {
668
+ existingWorktreePath: wtMapping.worktreePath,
669
+ worktreeBranch: wtMapping.worktreeBranch,
670
+ };
671
+ }
672
+ else {
673
+ // Worktree was deleted — recreate on the same branch
674
+ worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
675
+ }
676
+ }
677
+ getSessionHistory(claudeSessionId).then((pastMessages) => {
678
+ const sessionId = this.sessionManager.create(msg.projectPath, {
679
+ sessionId: claudeSessionId,
680
+ permissionMode: msg.permissionMode,
681
+ model: msg.model,
682
+ effort: msg.effort,
683
+ maxTurns: msg.maxTurns,
684
+ maxBudgetUsd: msg.maxBudgetUsd,
685
+ fallbackModel: msg.fallbackModel,
686
+ forkSession: msg.forkSession,
687
+ persistSession: msg.persistSession,
688
+ }, pastMessages, worktreeOpts);
689
+ const createdSession = this.sessionManager.get(sessionId);
690
+ this.send(ws, {
691
+ type: "system",
692
+ subtype: "session_created",
693
+ sessionId,
694
+ claudeSessionId,
695
+ provider: "claude",
696
+ projectPath: msg.projectPath,
697
+ ...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
698
+ ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
699
+ ...(createdSession?.worktreePath ? {
700
+ worktreePath: createdSession.worktreePath,
701
+ worktreeBranch: createdSession.worktreeBranch,
702
+ } : {}),
703
+ });
704
+ this.debugEvents.set(sessionId, []);
705
+ this.recordDebugEvent(sessionId, {
706
+ direction: "internal",
707
+ channel: "bridge",
708
+ type: "session_resumed",
709
+ detail: `provider=claude session=${claudeSessionId}`,
710
+ });
711
+ this.projectHistory?.addProject(msg.projectPath);
712
+ }).catch((err) => {
713
+ this.send(ws, { type: "error", message: `Failed to load session history: ${err}` });
714
+ });
715
+ break;
716
+ }
717
+ case "list_gallery": {
718
+ if (this.galleryStore) {
719
+ const images = this.galleryStore.list({
720
+ projectPath: msg.project,
721
+ sessionId: msg.sessionId,
722
+ });
723
+ this.send(ws, { type: "gallery_list", images });
724
+ }
725
+ else {
726
+ this.send(ws, { type: "gallery_list", images: [] });
727
+ }
728
+ break;
729
+ }
730
+ case "get_message_images": {
731
+ void extractMessageImages(msg.claudeSessionId, msg.messageUuid).then((images) => {
732
+ const refs = [];
733
+ if (this.imageStore) {
734
+ for (const img of images) {
735
+ const ref = this.imageStore.registerFromBase64(img.base64, img.mimeType);
736
+ if (ref)
737
+ refs.push(ref);
738
+ }
739
+ }
740
+ this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: refs });
741
+ }).catch((err) => {
742
+ console.error("[ws] Failed to extract message images:", err);
743
+ this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: [] });
744
+ });
745
+ break;
746
+ }
747
+ case "interrupt": {
748
+ const session = this.resolveSession(msg.sessionId);
749
+ if (!session) {
750
+ this.send(ws, { type: "error", message: "No active session." });
751
+ return;
752
+ }
753
+ session.process.interrupt();
754
+ break;
755
+ }
756
+ case "list_project_history": {
757
+ const projects = this.projectHistory?.getProjects() ?? [];
758
+ this.send(ws, { type: "project_history", projects });
759
+ break;
760
+ }
761
+ case "remove_project_history": {
762
+ this.projectHistory?.removeProject(msg.projectPath);
763
+ const projects = this.projectHistory?.getProjects() ?? [];
764
+ this.send(ws, { type: "project_history", projects });
765
+ break;
766
+ }
767
+ case "list_files": {
768
+ execFile("git", ["ls-files"], { cwd: msg.projectPath, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
769
+ if (err) {
770
+ this.send(ws, { type: "error", message: `Failed to list files: ${err.message}` });
771
+ return;
772
+ }
773
+ const files = stdout.trim().split("\n").filter(Boolean);
774
+ this.send(ws, { type: "file_list", files });
775
+ });
776
+ break;
777
+ }
778
+ case "list_recordings": {
779
+ void this.recordingStore.listRecordings().then(async (recordings) => {
780
+ // First pass: extract info from JSONL for recordings missing firstPrompt
781
+ // This covers both meta-less legacy recordings and new ones where sessions-index hasn't indexed yet
782
+ await Promise.all(recordings.map(async (rec) => {
783
+ const info = await this.recordingStore.extractInfoFromJsonl(rec.name);
784
+ if (info.firstPrompt && !rec.firstPrompt)
785
+ rec.firstPrompt = info.firstPrompt;
786
+ if (info.lastPrompt && !rec.lastPrompt)
787
+ rec.lastPrompt = info.lastPrompt;
788
+ // Backfill meta for legacy recordings
789
+ if (!rec.meta && (info.claudeSessionId || info.projectPath)) {
790
+ rec.meta = {
791
+ bridgeSessionId: rec.name,
792
+ claudeSessionId: info.claudeSessionId,
793
+ projectPath: info.projectPath ?? "",
794
+ createdAt: rec.modified,
795
+ };
796
+ }
797
+ }));
798
+ // Second pass: look up sessions-index for summaries (if claudeSessionIds available)
799
+ const claudeIds = new Set();
800
+ const idToIdx = new Map();
801
+ for (let i = 0; i < recordings.length; i++) {
802
+ const cid = recordings[i].meta?.claudeSessionId;
803
+ if (cid) {
804
+ claudeIds.add(cid);
805
+ const arr = idToIdx.get(cid) ?? [];
806
+ arr.push(i);
807
+ idToIdx.set(cid, arr);
808
+ }
809
+ }
810
+ if (claudeIds.size > 0) {
811
+ const sessionInfo = await findSessionsByClaudeIds(claudeIds);
812
+ for (const [cid, info] of sessionInfo) {
813
+ const indices = idToIdx.get(cid) ?? [];
814
+ for (const idx of indices) {
815
+ if (info.summary)
816
+ recordings[idx].summary = info.summary;
817
+ if (info.firstPrompt)
818
+ recordings[idx].firstPrompt = info.firstPrompt;
819
+ if (info.lastPrompt)
820
+ recordings[idx].lastPrompt = info.lastPrompt;
821
+ }
822
+ }
823
+ }
824
+ this.send(ws, { type: "recording_list", recordings });
825
+ });
826
+ break;
827
+ }
828
+ case "get_recording": {
829
+ void this.recordingStore.getRecordingContent(msg.sessionId).then((content) => {
830
+ if (content !== null) {
831
+ this.send(ws, { type: "recording_content", sessionId: msg.sessionId, content });
832
+ }
833
+ else {
834
+ this.send(ws, { type: "error", message: `Recording ${msg.sessionId} not found` });
835
+ }
836
+ });
837
+ break;
838
+ }
839
+ case "get_diff": {
840
+ this.collectGitDiff(msg.projectPath, ({ diff, error }) => {
841
+ if (error) {
842
+ this.send(ws, { type: "diff_result", diff: "", error: `Failed to get diff: ${error}` });
843
+ return;
844
+ }
845
+ this.send(ws, { type: "diff_result", diff });
846
+ });
847
+ break;
848
+ }
849
+ case "list_worktrees": {
850
+ try {
851
+ const worktrees = listWorktrees(msg.projectPath);
852
+ this.send(ws, { type: "worktree_list", worktrees });
853
+ }
854
+ catch (err) {
855
+ this.send(ws, { type: "error", message: `Failed to list worktrees: ${err}` });
856
+ }
857
+ break;
858
+ }
859
+ case "remove_worktree": {
860
+ try {
861
+ removeWorktree(msg.projectPath, msg.worktreePath);
862
+ this.worktreeStore.deleteByWorktreePath(msg.worktreePath);
863
+ this.send(ws, { type: "worktree_removed", worktreePath: msg.worktreePath });
864
+ }
865
+ catch (err) {
866
+ this.send(ws, { type: "error", message: `Failed to remove worktree: ${err}` });
867
+ }
868
+ break;
869
+ }
870
+ case "rewind_dry_run": {
871
+ const session = this.sessionManager.get(msg.sessionId);
872
+ if (!session) {
873
+ this.send(ws, { type: "rewind_preview", canRewind: false, error: `Session ${msg.sessionId} not found` });
874
+ return;
875
+ }
876
+ this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid, true).then((result) => {
877
+ this.send(ws, {
878
+ type: "rewind_preview",
879
+ canRewind: result.canRewind,
880
+ filesChanged: result.filesChanged,
881
+ insertions: result.insertions,
882
+ deletions: result.deletions,
883
+ error: result.error,
884
+ });
885
+ }).catch((err) => {
886
+ this.send(ws, { type: "rewind_preview", canRewind: false, error: `Dry run failed: ${err}` });
887
+ });
888
+ break;
889
+ }
890
+ case "rewind": {
891
+ const session = this.sessionManager.get(msg.sessionId);
892
+ if (!session) {
893
+ this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: `Session ${msg.sessionId} not found` });
894
+ return;
895
+ }
896
+ const handleError = (err) => {
897
+ const errMsg = err instanceof Error ? err.message : String(err);
898
+ this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: errMsg });
899
+ };
900
+ if (msg.mode === "code") {
901
+ // Code-only rewind: rewind files without restarting the conversation
902
+ this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
903
+ if (result.canRewind) {
904
+ this.send(ws, { type: "rewind_result", success: true, mode: "code" });
905
+ }
906
+ else {
907
+ this.send(ws, { type: "rewind_result", success: false, mode: "code", error: result.error ?? "Cannot rewind files" });
908
+ }
909
+ }).catch(handleError);
910
+ }
911
+ else if (msg.mode === "conversation") {
912
+ // Conversation-only rewind: restart session at the target UUID
913
+ try {
914
+ this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
915
+ this.send(ws, { type: "rewind_result", success: true, mode: "conversation" });
916
+ // Notify the new session ID
917
+ const newSession = this.sessionManager.get(newSessionId);
918
+ const rewindPermMode = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
919
+ this.send(ws, {
920
+ type: "system",
921
+ subtype: "session_created",
922
+ sessionId: newSessionId,
923
+ provider: newSession?.provider ?? "claude",
924
+ projectPath: newSession?.projectPath ?? "",
925
+ ...(rewindPermMode ? { permissionMode: rewindPermMode } : {}),
926
+ });
927
+ this.sendSessionList(ws);
928
+ });
929
+ }
930
+ catch (err) {
931
+ handleError(err);
932
+ }
933
+ }
934
+ else {
935
+ // Both: rewind files first, then rewind conversation
936
+ this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
937
+ if (!result.canRewind) {
938
+ this.send(ws, { type: "rewind_result", success: false, mode: "both", error: result.error ?? "Cannot rewind files" });
939
+ return;
940
+ }
941
+ try {
942
+ this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
943
+ this.send(ws, { type: "rewind_result", success: true, mode: "both" });
944
+ const newSession = this.sessionManager.get(newSessionId);
945
+ const rewindPermMode2 = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
946
+ this.send(ws, {
947
+ type: "system",
948
+ subtype: "session_created",
949
+ sessionId: newSessionId,
950
+ provider: newSession?.provider ?? "claude",
951
+ projectPath: newSession?.projectPath ?? "",
952
+ ...(rewindPermMode2 ? { permissionMode: rewindPermMode2 } : {}),
953
+ });
954
+ this.sendSessionList(ws);
955
+ });
956
+ }
957
+ catch (err) {
958
+ handleError(err);
959
+ }
960
+ }).catch(handleError);
961
+ }
962
+ break;
963
+ }
964
+ case "list_windows": {
965
+ listWindows()
966
+ .then((windows) => {
967
+ this.send(ws, { type: "window_list", windows });
968
+ })
969
+ .catch((err) => {
970
+ this.send(ws, {
971
+ type: "error",
972
+ message: `Failed to list windows: ${err instanceof Error ? err.message : String(err)}`,
973
+ });
974
+ });
975
+ break;
976
+ }
977
+ case "take_screenshot": {
978
+ // For window mode, verify the window ID is still valid.
979
+ // The user may have fetched the window list minutes ago and the
980
+ // window could have been closed since then.
981
+ const doCapture = async () => {
982
+ if (msg.mode !== "window" || msg.windowId == null) {
983
+ return { mode: msg.mode };
984
+ }
985
+ const current = await listWindows();
986
+ if (current.some((w) => w.windowId === msg.windowId)) {
987
+ return { mode: "window", windowId: msg.windowId };
988
+ }
989
+ // Window ID is stale — fall back to fullscreen and notify
990
+ console.warn(`[screenshot] Window ID ${msg.windowId} no longer exists, falling back to fullscreen`);
991
+ return { mode: "fullscreen" };
992
+ };
993
+ doCapture()
994
+ .then((opts) => takeScreenshot(opts))
995
+ .then(async (result) => {
996
+ try {
997
+ if (this.galleryStore) {
998
+ const meta = await this.galleryStore.addImage(result.filePath, msg.projectPath, msg.sessionId);
999
+ if (meta) {
1000
+ const info = this.galleryStore.metaToInfo(meta);
1001
+ this.send(ws, { type: "screenshot_result", success: true, image: info });
1002
+ this.broadcast({ type: "gallery_new_image", image: info });
1003
+ return;
1004
+ }
1005
+ }
1006
+ this.send(ws, {
1007
+ type: "screenshot_result",
1008
+ success: false,
1009
+ error: "Failed to save screenshot to gallery",
1010
+ });
1011
+ }
1012
+ finally {
1013
+ // Always clean up temp file
1014
+ unlink(result.filePath).catch(() => { });
1015
+ }
1016
+ })
1017
+ .catch((err) => {
1018
+ this.send(ws, {
1019
+ type: "screenshot_result",
1020
+ success: false,
1021
+ error: err instanceof Error ? err.message : String(err),
1022
+ });
1023
+ });
1024
+ break;
1025
+ }
1026
+ case "backup_prompt_history": {
1027
+ if (!this.promptHistoryBackup) {
1028
+ this.send(ws, { type: "prompt_history_backup_result", success: false, error: "Backup store not available" });
1029
+ break;
1030
+ }
1031
+ const buf = Buffer.from(msg.data, "base64");
1032
+ this.promptHistoryBackup.save(buf, msg.appVersion, msg.dbVersion).then((meta) => {
1033
+ this.send(ws, { type: "prompt_history_backup_result", success: true, backedUpAt: meta.backedUpAt });
1034
+ }).catch((err) => {
1035
+ this.send(ws, { type: "prompt_history_backup_result", success: false, error: err instanceof Error ? err.message : String(err) });
1036
+ });
1037
+ break;
1038
+ }
1039
+ case "restore_prompt_history": {
1040
+ if (!this.promptHistoryBackup) {
1041
+ this.send(ws, { type: "prompt_history_restore_result", success: false, error: "Backup store not available" });
1042
+ break;
1043
+ }
1044
+ this.promptHistoryBackup.load().then((result) => {
1045
+ if (result) {
1046
+ this.send(ws, {
1047
+ type: "prompt_history_restore_result",
1048
+ success: true,
1049
+ data: result.data.toString("base64"),
1050
+ appVersion: result.meta.appVersion,
1051
+ dbVersion: result.meta.dbVersion,
1052
+ backedUpAt: result.meta.backedUpAt,
1053
+ });
1054
+ }
1055
+ else {
1056
+ this.send(ws, { type: "prompt_history_restore_result", success: false, error: "No backup found" });
1057
+ }
1058
+ }).catch((err) => {
1059
+ this.send(ws, { type: "prompt_history_restore_result", success: false, error: err instanceof Error ? err.message : String(err) });
1060
+ });
1061
+ break;
1062
+ }
1063
+ case "get_prompt_history_backup_info": {
1064
+ if (!this.promptHistoryBackup) {
1065
+ this.send(ws, { type: "prompt_history_backup_info", exists: false });
1066
+ break;
1067
+ }
1068
+ this.promptHistoryBackup.getMeta().then((meta) => {
1069
+ if (meta) {
1070
+ this.send(ws, { type: "prompt_history_backup_info", exists: true, ...meta });
1071
+ }
1072
+ else {
1073
+ this.send(ws, { type: "prompt_history_backup_info", exists: false });
1074
+ }
1075
+ }).catch(() => {
1076
+ this.send(ws, { type: "prompt_history_backup_info", exists: false });
1077
+ });
1078
+ break;
1079
+ }
1080
+ }
1081
+ }
1082
+ resolveSession(sessionId) {
1083
+ if (sessionId)
1084
+ return this.sessionManager.get(sessionId);
1085
+ return this.getFirstSession();
1086
+ }
1087
+ getFirstSession() {
1088
+ const sessions = this.sessionManager.list();
1089
+ if (sessions.length === 0)
1090
+ return undefined;
1091
+ return this.sessionManager.get(sessions[sessions.length - 1].id);
1092
+ }
1093
+ sendSessionList(ws) {
1094
+ this.pruneDebugEvents();
1095
+ const sessions = this.sessionManager.list();
1096
+ this.send(ws, { type: "session_list", sessions });
1097
+ }
1098
+ /** Broadcast session list to all connected clients. */
1099
+ broadcastSessionList() {
1100
+ this.pruneDebugEvents();
1101
+ const sessions = this.sessionManager.list();
1102
+ this.broadcast({ type: "session_list", sessions });
1103
+ }
1104
+ broadcastSessionMessage(sessionId, msg) {
1105
+ this.maybeSendPushNotification(sessionId, msg);
1106
+ this.recordDebugEvent(sessionId, {
1107
+ direction: "outgoing",
1108
+ channel: "session",
1109
+ type: msg.type,
1110
+ detail: this.summarizeServerMessage(msg),
1111
+ });
1112
+ this.recordingStore.record(sessionId, "outgoing", msg);
1113
+ // Update recording meta with claudeSessionId when it becomes available
1114
+ if ((msg.type === "system" || msg.type === "result") && "sessionId" in msg && msg.sessionId) {
1115
+ const session = this.sessionManager.get(sessionId);
1116
+ if (session) {
1117
+ this.recordingStore.saveMeta(sessionId, {
1118
+ bridgeSessionId: sessionId,
1119
+ claudeSessionId: msg.sessionId,
1120
+ projectPath: session.projectPath,
1121
+ createdAt: session.createdAt.toISOString(),
1122
+ });
1123
+ }
1124
+ }
1125
+ // Wrap the message with sessionId
1126
+ const data = JSON.stringify({ ...msg, sessionId });
1127
+ for (const client of this.wss.clients) {
1128
+ if (client.readyState === WebSocket.OPEN) {
1129
+ client.send(data);
1130
+ }
1131
+ }
1132
+ }
1133
+ /** Extract a short project label from the full projectPath (last directory name). */
1134
+ projectLabel(sessionId) {
1135
+ const session = this.sessionManager.get(sessionId);
1136
+ if (!session?.projectPath)
1137
+ return "";
1138
+ const parts = session.projectPath.replace(/\/+$/, "").split("/");
1139
+ return parts[parts.length - 1] || "";
1140
+ }
1141
+ maybeSendPushNotification(sessionId, msg) {
1142
+ if (!this.pushRelay.isConfigured)
1143
+ return;
1144
+ const project = this.projectLabel(sessionId);
1145
+ if (msg.type === "permission_request") {
1146
+ const seen = this.notifiedPermissionToolUses.get(sessionId) ?? new Set();
1147
+ if (seen.has(msg.toolUseId))
1148
+ return;
1149
+ seen.add(msg.toolUseId);
1150
+ this.notifiedPermissionToolUses.set(sessionId, seen);
1151
+ const isAskUserQuestion = msg.toolName === "AskUserQuestion";
1152
+ const eventType = isAskUserQuestion ? "ask_user_question" : "approval_required";
1153
+ const title = project
1154
+ ? (isAskUserQuestion ? `回答待ち - ${project}` : `承認待ち - ${project}`)
1155
+ : (isAskUserQuestion ? "回答待ち" : "承認待ち");
1156
+ let body;
1157
+ if (isAskUserQuestion) {
1158
+ // Extract question text from input.questions[0].question
1159
+ const questions = msg.input?.questions;
1160
+ const firstQuestion = Array.isArray(questions) && questions.length > 0
1161
+ ? questions[0]?.question
1162
+ : undefined;
1163
+ body = typeof firstQuestion === "string" && firstQuestion.length > 0
1164
+ ? firstQuestion.slice(0, 120)
1165
+ : "Claude が質問しています";
1166
+ }
1167
+ else {
1168
+ body = `${msg.toolName} の実行を承認してください`;
1169
+ }
1170
+ void this.pushRelay.notify({
1171
+ eventType,
1172
+ title,
1173
+ body,
1174
+ data: {
1175
+ sessionId,
1176
+ provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
1177
+ toolUseId: msg.toolUseId,
1178
+ toolName: msg.toolName,
1179
+ },
1180
+ }).catch((err) => {
1181
+ const detail = err instanceof Error ? err.message : String(err);
1182
+ console.warn(`[ws] Failed to send push notification (${eventType}): ${detail}`);
1183
+ });
1184
+ return;
1185
+ }
1186
+ if (msg.type !== "result")
1187
+ return;
1188
+ if (msg.subtype === "stopped")
1189
+ return;
1190
+ if (msg.subtype !== "success" && msg.subtype !== "error")
1191
+ return;
1192
+ const isSuccess = msg.subtype === "success";
1193
+ const eventType = isSuccess ? "session_completed" : "session_failed";
1194
+ const title = project
1195
+ ? (isSuccess ? `✅ ${project}` : `❌ ${project}`)
1196
+ : (isSuccess ? "タスク完了" : "エラー発生");
1197
+ let body;
1198
+ if (isSuccess) {
1199
+ const pieces = [];
1200
+ if (msg.duration != null)
1201
+ pieces.push(`${msg.duration.toFixed(1)}s`);
1202
+ if (msg.cost != null)
1203
+ pieces.push(`$${msg.cost.toFixed(4)}`);
1204
+ const stats = pieces.length > 0 ? ` (${pieces.join(", ")})` : "";
1205
+ body = msg.result
1206
+ ? `${msg.result.slice(0, 120)}${stats}`
1207
+ : `セッション完了${stats}`;
1208
+ }
1209
+ else {
1210
+ body = msg.error ? msg.error.slice(0, 120) : "セッションが失敗しました";
1211
+ }
1212
+ const data = {
1213
+ sessionId,
1214
+ provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
1215
+ subtype: msg.subtype,
1216
+ };
1217
+ if (msg.stopReason)
1218
+ data.stopReason = msg.stopReason;
1219
+ if (msg.sessionId)
1220
+ data.providerSessionId = msg.sessionId;
1221
+ void this.pushRelay.notify({
1222
+ eventType,
1223
+ title,
1224
+ body,
1225
+ data,
1226
+ }).catch((err) => {
1227
+ const detail = err instanceof Error ? err.message : String(err);
1228
+ console.warn(`[ws] Failed to send push notification (${eventType}): ${detail}`);
1229
+ });
1230
+ }
1231
+ broadcast(msg) {
1232
+ const data = JSON.stringify(msg);
1233
+ for (const client of this.wss.clients) {
1234
+ if (client.readyState === WebSocket.OPEN) {
1235
+ client.send(data);
1236
+ }
1237
+ }
1238
+ }
1239
+ send(ws, msg) {
1240
+ const sessionId = this.extractSessionIdFromServerMessage(msg);
1241
+ if (sessionId) {
1242
+ this.recordDebugEvent(sessionId, {
1243
+ direction: "outgoing",
1244
+ channel: "ws",
1245
+ type: String(msg.type ?? "unknown"),
1246
+ detail: this.summarizeOutboundMessage(msg),
1247
+ });
1248
+ }
1249
+ if (ws.readyState === WebSocket.OPEN) {
1250
+ ws.send(JSON.stringify(msg));
1251
+ }
1252
+ }
1253
+ /** Broadcast a gallery_new_image message to all connected clients. */
1254
+ broadcastGalleryNewImage(image) {
1255
+ this.broadcast({ type: "gallery_new_image", image });
1256
+ }
1257
+ collectGitDiff(cwd, callback) {
1258
+ const execOpts = { cwd, maxBuffer: 10 * 1024 * 1024 };
1259
+ // Collect untracked files so they appear in the diff.
1260
+ let untrackedFiles = [];
1261
+ try {
1262
+ const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd }).toString().trim();
1263
+ untrackedFiles = out ? out.split("\n") : [];
1264
+ }
1265
+ catch {
1266
+ // Ignore errors: non-git directories are handled by git diff callback.
1267
+ }
1268
+ // Temporarily stage untracked files with --intent-to-add.
1269
+ if (untrackedFiles.length > 0) {
1270
+ try {
1271
+ execFileSync("git", ["add", "--intent-to-add", ...untrackedFiles], { cwd });
1272
+ }
1273
+ catch {
1274
+ // Ignore staging errors.
1275
+ }
1276
+ }
1277
+ execFile("git", ["diff", "--no-color"], execOpts, (err, stdout) => {
1278
+ // Revert intent-to-add for untracked files.
1279
+ if (untrackedFiles.length > 0) {
1280
+ try {
1281
+ execFileSync("git", ["reset", "--", ...untrackedFiles], { cwd });
1282
+ }
1283
+ catch {
1284
+ // Ignore reset errors.
1285
+ }
1286
+ }
1287
+ if (err) {
1288
+ callback({ diff: "", error: err.message });
1289
+ return;
1290
+ }
1291
+ callback({ diff: stdout });
1292
+ });
1293
+ }
1294
+ extractSessionIdFromClientMessage(msg) {
1295
+ return "sessionId" in msg && typeof msg.sessionId === "string" ? msg.sessionId : undefined;
1296
+ }
1297
+ extractSessionIdFromServerMessage(msg) {
1298
+ if ("sessionId" in msg && typeof msg.sessionId === "string")
1299
+ return msg.sessionId;
1300
+ return undefined;
1301
+ }
1302
+ recordDebugEvent(sessionId, event) {
1303
+ const events = this.debugEvents.get(sessionId) ?? [];
1304
+ const fullEvent = {
1305
+ ts: new Date().toISOString(),
1306
+ sessionId,
1307
+ ...event,
1308
+ };
1309
+ events.push(fullEvent);
1310
+ if (events.length > BridgeWebSocketServer.MAX_DEBUG_EVENTS) {
1311
+ events.splice(0, events.length - BridgeWebSocketServer.MAX_DEBUG_EVENTS);
1312
+ }
1313
+ this.debugEvents.set(sessionId, events);
1314
+ this.debugTraceStore.record(fullEvent);
1315
+ }
1316
+ getDebugEvents(sessionId, limit) {
1317
+ const events = this.debugEvents.get(sessionId) ?? [];
1318
+ const capped = Math.max(0, Math.min(limit, BridgeWebSocketServer.MAX_DEBUG_EVENTS));
1319
+ if (capped === 0)
1320
+ return [];
1321
+ return events.slice(-capped);
1322
+ }
1323
+ buildHistorySummary(history) {
1324
+ const lines = history
1325
+ .map((msg, index) => {
1326
+ const num = String(index + 1).padStart(3, "0");
1327
+ return `${num}. ${this.summarizeServerMessage(msg)}`;
1328
+ });
1329
+ if (lines.length <= BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS) {
1330
+ return lines;
1331
+ }
1332
+ return lines.slice(-BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS);
1333
+ }
1334
+ summarizeClientMessage(msg) {
1335
+ switch (msg.type) {
1336
+ case "input": {
1337
+ const textPreview = msg.text.replace(/\s+/g, " ").trim().slice(0, 80);
1338
+ const hasImage = msg.imageBase64 != null || msg.imageId != null;
1339
+ return `text=\"${textPreview}\" image=${hasImage}`;
1340
+ }
1341
+ case "push_register":
1342
+ return `platform=${msg.platform} token=${msg.token.slice(0, 8)}...`;
1343
+ case "push_unregister":
1344
+ return `token=${msg.token.slice(0, 8)}...`;
1345
+ case "approve":
1346
+ case "approve_always":
1347
+ case "reject":
1348
+ return `id=${msg.id}`;
1349
+ case "answer":
1350
+ return `toolUseId=${msg.toolUseId}`;
1351
+ case "start":
1352
+ return `projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`;
1353
+ case "resume_session":
1354
+ return `sessionId=${msg.sessionId} provider=${msg.provider ?? "claude"}`;
1355
+ case "get_debug_bundle":
1356
+ return `traceLimit=${msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS} includeDiff=${msg.includeDiff ?? true}`;
1357
+ case "get_usage":
1358
+ return "get_usage";
1359
+ default:
1360
+ return msg.type;
1361
+ }
1362
+ }
1363
+ summarizeServerMessage(msg) {
1364
+ switch (msg.type) {
1365
+ case "assistant": {
1366
+ const textChunks = [];
1367
+ for (const content of msg.message.content) {
1368
+ if (content.type === "text") {
1369
+ textChunks.push(content.text);
1370
+ }
1371
+ }
1372
+ const text = textChunks
1373
+ .join(" ")
1374
+ .replace(/\s+/g, " ")
1375
+ .trim()
1376
+ .slice(0, 100);
1377
+ return text ? `assistant: ${text}` : "assistant";
1378
+ }
1379
+ case "tool_result": {
1380
+ const contentPreview = msg.content.replace(/\s+/g, " ").trim().slice(0, 100);
1381
+ return `${msg.toolName ?? "tool_result"}(${msg.toolUseId}) ${contentPreview}`;
1382
+ }
1383
+ case "permission_request":
1384
+ return `${msg.toolName}(${msg.toolUseId})`;
1385
+ case "result":
1386
+ return `${msg.subtype}${msg.error ? ` error=${msg.error}` : ""}`;
1387
+ case "status":
1388
+ return msg.status;
1389
+ case "error":
1390
+ return msg.message;
1391
+ case "stream_delta":
1392
+ case "thinking_delta":
1393
+ return `${msg.type}(${msg.text.length})`;
1394
+ default:
1395
+ return msg.type;
1396
+ }
1397
+ }
1398
+ summarizeOutboundMessage(msg) {
1399
+ if ("type" in msg && typeof msg.type === "string") {
1400
+ return msg.type;
1401
+ }
1402
+ return "message";
1403
+ }
1404
+ pruneDebugEvents() {
1405
+ const active = new Set(this.sessionManager.list().map((s) => s.id));
1406
+ for (const sessionId of this.debugEvents.keys()) {
1407
+ if (!active.has(sessionId)) {
1408
+ this.debugEvents.delete(sessionId);
1409
+ }
1410
+ }
1411
+ for (const sessionId of this.notifiedPermissionToolUses.keys()) {
1412
+ if (!active.has(sessionId)) {
1413
+ this.notifiedPermissionToolUses.delete(sessionId);
1414
+ }
1415
+ }
1416
+ }
1417
+ buildReproRecipe(session, traceLimit, includeDiff) {
1418
+ const bridgePort = process.env.BRIDGE_PORT ?? "8765";
1419
+ const wsUrlHint = `ws://localhost:${bridgePort}`;
1420
+ const notes = [
1421
+ "1) Connect with wsUrlHint and send resumeSessionMessage.",
1422
+ "2) Read session_created.sessionId from server response.",
1423
+ "3) Replace <runtime_session_id> in getHistoryMessage/getDebugBundleMessage and send them.",
1424
+ "4) Compare history/debugTrace/diff with the saved bundle snapshot.",
1425
+ ];
1426
+ if (!session.claudeSessionId) {
1427
+ notes.push("claudeSessionId is not available yet. Use list_recent_sessions to pick the right session id.");
1428
+ }
1429
+ return {
1430
+ wsUrlHint,
1431
+ startBridgeCommand: `BRIDGE_PORT=${bridgePort} npm run bridge`,
1432
+ resumeSessionMessage: this.buildResumeSessionMessage(session),
1433
+ getHistoryMessage: {
1434
+ type: "get_history",
1435
+ sessionId: "<runtime_session_id>",
1436
+ },
1437
+ getDebugBundleMessage: {
1438
+ type: "get_debug_bundle",
1439
+ sessionId: "<runtime_session_id>",
1440
+ traceLimit,
1441
+ includeDiff,
1442
+ },
1443
+ notes,
1444
+ };
1445
+ }
1446
+ buildResumeSessionMessage(session) {
1447
+ const msg = {
1448
+ type: "resume_session",
1449
+ sessionId: session.claudeSessionId ?? "<session_id_from_recent_sessions>",
1450
+ projectPath: session.projectPath,
1451
+ provider: session.provider,
1452
+ };
1453
+ if (session.provider === "codex" && session.codexSettings) {
1454
+ if (session.codexSettings.approvalPolicy !== undefined) {
1455
+ msg.approvalPolicy = session.codexSettings.approvalPolicy;
1456
+ }
1457
+ if (session.codexSettings.sandboxMode !== undefined) {
1458
+ msg.sandboxMode = session.codexSettings.sandboxMode;
1459
+ }
1460
+ if (session.codexSettings.model !== undefined) {
1461
+ msg.model = session.codexSettings.model;
1462
+ }
1463
+ if (session.codexSettings.modelReasoningEffort !== undefined) {
1464
+ msg.modelReasoningEffort = session.codexSettings.modelReasoningEffort;
1465
+ }
1466
+ if (session.codexSettings.networkAccessEnabled !== undefined) {
1467
+ msg.networkAccessEnabled = session.codexSettings.networkAccessEnabled;
1468
+ }
1469
+ if (session.codexSettings.webSearchMode !== undefined) {
1470
+ msg.webSearchMode = session.codexSettings.webSearchMode;
1471
+ }
1472
+ }
1473
+ return msg;
1474
+ }
1475
+ buildAgentPrompt(session) {
1476
+ return [
1477
+ "Use this ccpocket debug bundle to investigate a chat-screen bug.",
1478
+ `Target provider: ${session.provider}`,
1479
+ `Project path: ${session.projectPath}`,
1480
+ "Required output:",
1481
+ "1) Timeline analysis from historySummary + debugTrace.",
1482
+ "2) Top 1-3 root-cause hypotheses with confidence.",
1483
+ "3) Concrete validation steps and the minimum extra logs needed.",
1484
+ ].join("\n");
1485
+ }
1486
+ }
1487
+ //# sourceMappingURL=websocket.js.map