remodex-cli 1.0.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 (99) hide show
  1. package/LICENSE +12 -0
  2. package/README.md +105 -0
  3. package/dist/archive-store.d.ts +28 -0
  4. package/dist/archive-store.js +68 -0
  5. package/dist/archive-store.js.map +1 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +88 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/codex-process.d.ts +186 -0
  10. package/dist/codex-process.js +2111 -0
  11. package/dist/codex-process.js.map +1 -0
  12. package/dist/debug-trace-store.d.ts +15 -0
  13. package/dist/debug-trace-store.js +78 -0
  14. package/dist/debug-trace-store.js.map +1 -0
  15. package/dist/doctor.d.ts +58 -0
  16. package/dist/doctor.js +670 -0
  17. package/dist/doctor.js.map +1 -0
  18. package/dist/firebase-auth.d.ts +35 -0
  19. package/dist/firebase-auth.js +132 -0
  20. package/dist/firebase-auth.js.map +1 -0
  21. package/dist/gallery-store.d.ts +67 -0
  22. package/dist/gallery-store.js +333 -0
  23. package/dist/gallery-store.js.map +1 -0
  24. package/dist/git-assist.d.ts +7 -0
  25. package/dist/git-assist.js +51 -0
  26. package/dist/git-assist.js.map +1 -0
  27. package/dist/git-operations.d.ts +63 -0
  28. package/dist/git-operations.js +292 -0
  29. package/dist/git-operations.js.map +1 -0
  30. package/dist/image-store.d.ts +23 -0
  31. package/dist/image-store.js +142 -0
  32. package/dist/image-store.js.map +1 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +198 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/mdns.d.ts +7 -0
  37. package/dist/mdns.js +49 -0
  38. package/dist/mdns.js.map +1 -0
  39. package/dist/parser.d.ts +620 -0
  40. package/dist/parser.js +423 -0
  41. package/dist/parser.js.map +1 -0
  42. package/dist/path-utils.d.ts +4 -0
  43. package/dist/path-utils.js +34 -0
  44. package/dist/path-utils.js.map +1 -0
  45. package/dist/project-history.d.ts +10 -0
  46. package/dist/project-history.js +73 -0
  47. package/dist/project-history.js.map +1 -0
  48. package/dist/prompt-history-backup.d.ts +15 -0
  49. package/dist/prompt-history-backup.js +46 -0
  50. package/dist/prompt-history-backup.js.map +1 -0
  51. package/dist/proxy.d.ts +15 -0
  52. package/dist/proxy.js +95 -0
  53. package/dist/proxy.js.map +1 -0
  54. package/dist/push-i18n.d.ts +7 -0
  55. package/dist/push-i18n.js +75 -0
  56. package/dist/push-i18n.js.map +1 -0
  57. package/dist/push-relay.d.ts +29 -0
  58. package/dist/push-relay.js +70 -0
  59. package/dist/push-relay.js.map +1 -0
  60. package/dist/recording-store.d.ts +51 -0
  61. package/dist/recording-store.js +158 -0
  62. package/dist/recording-store.js.map +1 -0
  63. package/dist/screenshot.d.ts +28 -0
  64. package/dist/screenshot.js +98 -0
  65. package/dist/screenshot.js.map +1 -0
  66. package/dist/sdk-process.d.ts +180 -0
  67. package/dist/sdk-process.js +960 -0
  68. package/dist/sdk-process.js.map +1 -0
  69. package/dist/session.d.ts +144 -0
  70. package/dist/session.js +687 -0
  71. package/dist/session.js.map +1 -0
  72. package/dist/sessions-index.d.ts +130 -0
  73. package/dist/sessions-index.js +1817 -0
  74. package/dist/sessions-index.js.map +1 -0
  75. package/dist/setup-launchd.d.ts +9 -0
  76. package/dist/setup-launchd.js +115 -0
  77. package/dist/setup-launchd.js.map +1 -0
  78. package/dist/setup-systemd.d.ts +9 -0
  79. package/dist/setup-systemd.js +122 -0
  80. package/dist/setup-systemd.js.map +1 -0
  81. package/dist/startup-info.d.ts +9 -0
  82. package/dist/startup-info.js +116 -0
  83. package/dist/startup-info.js.map +1 -0
  84. package/dist/usage.d.ts +69 -0
  85. package/dist/usage.js +545 -0
  86. package/dist/usage.js.map +1 -0
  87. package/dist/version.d.ts +13 -0
  88. package/dist/version.js +43 -0
  89. package/dist/version.js.map +1 -0
  90. package/dist/websocket.d.ts +132 -0
  91. package/dist/websocket.js +3551 -0
  92. package/dist/websocket.js.map +1 -0
  93. package/dist/worktree-store.d.ts +26 -0
  94. package/dist/worktree-store.js +61 -0
  95. package/dist/worktree-store.js.map +1 -0
  96. package/dist/worktree.d.ts +47 -0
  97. package/dist/worktree.js +330 -0
  98. package/dist/worktree.js.map +1 -0
  99. package/package.json +62 -0
@@ -0,0 +1,3551 @@
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 { stageFiles, stageHunks, unstageFiles, unstageHunks, gitCommit, gitPush, listBranches, createBranch, checkoutBranch, revertFiles, revertHunks, gitFetch, gitPull, gitRemoteStatus, } from "./git-operations.js";
16
+ import { generateCommitMessage } from "./git-assist.js";
17
+ import { listWindows, takeScreenshot } from "./screenshot.js";
18
+ import { DebugTraceStore } from "./debug-trace-store.js";
19
+ import { PushRelayClient } from "./push-relay.js";
20
+ import { normalizePushLocale, t } from "./push-i18n.js";
21
+ import { fetchAllUsage } from "./usage.js";
22
+ import { getPackageVersion } from "./version.js";
23
+ import { isPathWithinAllowedDirectory, resolvePlatformPath, } from "./path-utils.js";
24
+ // ---- Available model lists (delivered to clients via session_list) ----
25
+ const CLAUDE_MODELS = [
26
+ "claude-opus-4-6",
27
+ "claude-opus-4-6[1m]",
28
+ "claude-sonnet-4-6",
29
+ "claude-haiku-4-6",
30
+ ];
31
+ const CODEX_MODELS = [
32
+ "gpt-5.4",
33
+ "gpt-5.4-mini",
34
+ "gpt-5.3-codex",
35
+ "gpt-5.3-codex-spark",
36
+ "gpt-5.2-codex",
37
+ ];
38
+ // ---- Codex mode mapping helpers ----
39
+ /** Map unified PermissionMode to Codex approval_policy.
40
+ * Only "bypassPermissions" maps to "never"; all others use "on-request". */
41
+ function permissionModeToApprovalPolicy(mode) {
42
+ return mode === "bypassPermissions" ? "never" : "on-request";
43
+ }
44
+ function normalizeCodexApprovalPolicy(value) {
45
+ switch (value) {
46
+ case "untrusted":
47
+ return "untrusted";
48
+ case "on-failure":
49
+ return "on-failure";
50
+ case "never":
51
+ return "never";
52
+ case "on-request":
53
+ default:
54
+ return "on-request";
55
+ }
56
+ }
57
+ function deriveExecutionMode(params) {
58
+ if (params.executionMode === "default" ||
59
+ params.executionMode === "acceptEdits" ||
60
+ params.executionMode === "fullAccess") {
61
+ return params.executionMode;
62
+ }
63
+ if (params.permissionMode === "bypassPermissions" ||
64
+ params.approvalPolicy === "never") {
65
+ return "fullAccess";
66
+ }
67
+ if (params.permissionMode === "acceptEdits") {
68
+ return params.provider === "codex" ? "default" : "acceptEdits";
69
+ }
70
+ return "default";
71
+ }
72
+ function derivePlanMode(params) {
73
+ return (params.planMode ??
74
+ (params.permissionMode === "plan" || params.collaborationMode === "plan"));
75
+ }
76
+ function modesToLegacyPermissionMode(provider, executionMode, planMode) {
77
+ if (planMode)
78
+ return "plan";
79
+ switch (executionMode) {
80
+ case "fullAccess":
81
+ return "bypassPermissions";
82
+ case "acceptEdits":
83
+ return "acceptEdits";
84
+ case "default":
85
+ default:
86
+ return provider === "codex" ? "acceptEdits" : "default";
87
+ }
88
+ }
89
+ /** Map simplified SandboxMode (on/off) to Codex internal sandbox mode. */
90
+ function sandboxModeToInternal(mode) {
91
+ switch (mode) {
92
+ case "danger-full-access":
93
+ case "workspace-write":
94
+ case "read-only":
95
+ return mode;
96
+ case "off":
97
+ return "danger-full-access";
98
+ default:
99
+ return "workspace-write";
100
+ }
101
+ }
102
+ /** Map Codex internal sandbox mode back to simplified on/off for clients. */
103
+ function sandboxModeToExternal(mode) {
104
+ return mode === "danger-full-access" ? "off" : "on";
105
+ }
106
+ function threadTimestampToIso(value) {
107
+ return value > 0 ? new Date(value * 1000).toISOString() : "";
108
+ }
109
+ function envFlagEnabled(name) {
110
+ const value = process.env[name]?.trim().toLowerCase();
111
+ return value === "1" || value === "true" || value === "yes" || value === "on";
112
+ }
113
+ function codexThreadToRecentSession(thread, indexed) {
114
+ return {
115
+ sessionId: thread.id,
116
+ provider: "codex",
117
+ ...(thread.name ? { name: thread.name } : {}),
118
+ ...(thread.agentNickname ? { agentNickname: thread.agentNickname } : {}),
119
+ ...(thread.agentRole ? { agentRole: thread.agentRole } : {}),
120
+ summary: thread.preview || undefined,
121
+ firstPrompt: thread.preview || "",
122
+ created: threadTimestampToIso(thread.createdAt),
123
+ modified: threadTimestampToIso(thread.updatedAt),
124
+ gitBranch: thread.gitBranch ?? "",
125
+ projectPath: thread.cwd,
126
+ ...(indexed?.resumeCwd ? { resumeCwd: indexed.resumeCwd } : {}),
127
+ isSidechain: false,
128
+ ...(indexed?.codexSettings ? { codexSettings: indexed.codexSettings } : {}),
129
+ };
130
+ }
131
+ export class BridgeWebSocketServer {
132
+ static MAX_DEBUG_EVENTS = 800;
133
+ static MAX_HISTORY_SUMMARY_ITEMS = 300;
134
+ wss;
135
+ sessionManager;
136
+ apiKey;
137
+ allowedDirs;
138
+ imageStore;
139
+ galleryStore;
140
+ projectHistory;
141
+ debugTraceStore;
142
+ recordingStore;
143
+ worktreeStore;
144
+ pushRelay;
145
+ promptHistoryBackup;
146
+ recentSessionsRequestId = 0;
147
+ debugEvents = new Map();
148
+ notifiedPermissionToolUses = new Map();
149
+ archiveStore;
150
+ /** FCM token → push notification locale */
151
+ tokenLocales = new Map();
152
+ tokenPrivacyMode = new Map();
153
+ failSetPermissionMode = envFlagEnabled("BRIDGE_FAIL_SET_PERMISSION_MODE");
154
+ failSetSandboxMode = envFlagEnabled("BRIDGE_FAIL_SET_SANDBOX_MODE");
155
+ platform;
156
+ constructor(options) {
157
+ const { server, apiKey, allowedDirs, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup, platform, } = options;
158
+ this.apiKey = apiKey ?? null;
159
+ this.allowedDirs = allowedDirs ?? [];
160
+ this.imageStore = imageStore ?? null;
161
+ this.galleryStore = galleryStore ?? null;
162
+ this.projectHistory = projectHistory ?? null;
163
+ this.debugTraceStore = debugTraceStore ?? new DebugTraceStore();
164
+ this.recordingStore = recordingStore ?? null;
165
+ this.worktreeStore = new WorktreeStore();
166
+ this.pushRelay = new PushRelayClient({ firebaseAuth });
167
+ this.promptHistoryBackup = promptHistoryBackup ?? null;
168
+ this.platform = platform ?? process.platform;
169
+ this.archiveStore = new ArchiveStore();
170
+ void this.debugTraceStore.init().catch((err) => {
171
+ console.error("[ws] Failed to initialize debug trace store:", err);
172
+ });
173
+ if (this.recordingStore) {
174
+ void this.recordingStore.init().catch((err) => {
175
+ console.error("[ws] Failed to initialize recording store:", err);
176
+ });
177
+ }
178
+ void this.archiveStore.init().catch((err) => {
179
+ console.error("[ws] Failed to initialize archive store:", err);
180
+ });
181
+ if (!this.pushRelay.isConfigured) {
182
+ console.log("[ws] Push relay disabled (Firebase auth not available)");
183
+ }
184
+ else {
185
+ console.log("[ws] Push relay enabled (Firebase Anonymous Auth)");
186
+ }
187
+ this.wss = new WebSocketServer({ server });
188
+ this.sessionManager = new SessionManager((sessionId, msg) => {
189
+ this.broadcastSessionMessage(sessionId, msg);
190
+ }, imageStore, galleryStore,
191
+ // Broadcast gallery_new_image when a new image is added
192
+ (meta) => {
193
+ if (this.galleryStore) {
194
+ const info = this.galleryStore.metaToInfo(meta);
195
+ this.broadcast({ type: "gallery_new_image", image: info });
196
+ }
197
+ }, this.worktreeStore);
198
+ this.wss.on("connection", (ws, req) => {
199
+ // API key authentication
200
+ if (this.apiKey) {
201
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
202
+ const token = url.searchParams.get("token");
203
+ if (token !== this.apiKey) {
204
+ console.log("[ws] Client rejected: invalid token");
205
+ ws.close(4001, "Unauthorized");
206
+ return;
207
+ }
208
+ }
209
+ console.log("[ws] Client connected");
210
+ this.handleConnection(ws);
211
+ });
212
+ this.wss.on("error", (err) => {
213
+ console.error("[ws] Server error:", err.message);
214
+ });
215
+ console.log(`[ws] WebSocket server attached to HTTP server`);
216
+ }
217
+ /**
218
+ * Validate that a project path is within the allowed directories.
219
+ * Returns true if the path is allowed, false otherwise.
220
+ */
221
+ isPathAllowed(path) {
222
+ if (this.allowedDirs.length === 0)
223
+ return true;
224
+ return this.allowedDirs.some((dir) => isPathWithinAllowedDirectory(path, dir, this.platform));
225
+ }
226
+ /** Build a user-friendly error for disallowed project paths. */
227
+ buildPathNotAllowedError(projectPath) {
228
+ return {
229
+ type: "error",
230
+ 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.`,
231
+ errorCode: "path_not_allowed",
232
+ };
233
+ }
234
+ buildSessionCreatedMessage(params) {
235
+ const { sessionId, provider, projectPath, session, permissionMode, executionMode, planMode, sandboxMode, slashCommands, skills, skillMetadata, sourceSessionId, } = params;
236
+ const msg = {
237
+ type: "system",
238
+ subtype: "session_created",
239
+ sessionId,
240
+ provider,
241
+ projectPath,
242
+ ...(permissionMode
243
+ ? {
244
+ permissionMode: permissionMode,
245
+ }
246
+ : {}),
247
+ ...((executionMode ??
248
+ (session?.process instanceof SdkProcess
249
+ ? session.process.permissionMode === "bypassPermissions"
250
+ ? "fullAccess"
251
+ : session.process.permissionMode === "acceptEdits"
252
+ ? "acceptEdits"
253
+ : "default"
254
+ : session?.process instanceof CodexProcess
255
+ ? session.process.approvalPolicy === "never"
256
+ ? "fullAccess"
257
+ : "default"
258
+ : undefined))
259
+ ? {
260
+ executionMode: (executionMode ??
261
+ (session?.process instanceof SdkProcess
262
+ ? session.process.permissionMode === "bypassPermissions"
263
+ ? "fullAccess"
264
+ : session.process.permissionMode === "acceptEdits"
265
+ ? "acceptEdits"
266
+ : "default"
267
+ : session?.process instanceof CodexProcess
268
+ ? session.process.approvalPolicy === "never"
269
+ ? "fullAccess"
270
+ : "default"
271
+ : undefined)),
272
+ }
273
+ : {}),
274
+ ...((planMode ??
275
+ (session?.process instanceof SdkProcess
276
+ ? session.process.permissionMode === "plan"
277
+ : session?.process instanceof CodexProcess
278
+ ? session.process.collaborationMode === "plan"
279
+ : undefined)) != null
280
+ ? {
281
+ planMode: planMode ??
282
+ (session?.process instanceof SdkProcess
283
+ ? session.process.permissionMode === "plan"
284
+ : session?.process instanceof CodexProcess
285
+ ? session.process.collaborationMode === "plan"
286
+ : false),
287
+ }
288
+ : {}),
289
+ ...(sandboxMode ? { sandboxMode } : {}),
290
+ ...(slashCommands ? { slashCommands } : {}),
291
+ ...(skills ? { skills } : {}),
292
+ ...(skillMetadata
293
+ ? {
294
+ skillMetadata: skillMetadata,
295
+ }
296
+ : {}),
297
+ ...(session?.worktreePath
298
+ ? {
299
+ worktreePath: session.worktreePath,
300
+ worktreeBranch: session.worktreeBranch,
301
+ }
302
+ : {}),
303
+ ...(sourceSessionId ? { sourceSessionId } : {}),
304
+ };
305
+ if (provider === "codex" && session?.codexSettings) {
306
+ if (session.codexSettings.model !== undefined) {
307
+ msg.model = session.codexSettings.model;
308
+ }
309
+ if (session.codexSettings.approvalPolicy !== undefined) {
310
+ msg.approvalPolicy = session.codexSettings.approvalPolicy;
311
+ }
312
+ if (session.codexSettings.modelReasoningEffort !== undefined) {
313
+ msg.modelReasoningEffort = session.codexSettings.modelReasoningEffort;
314
+ }
315
+ if (session.codexSettings.networkAccessEnabled !== undefined) {
316
+ msg.networkAccessEnabled = session.codexSettings.networkAccessEnabled;
317
+ }
318
+ if (session.codexSettings.webSearchMode !== undefined) {
319
+ msg.webSearchMode = session.codexSettings.webSearchMode;
320
+ }
321
+ }
322
+ return msg;
323
+ }
324
+ close() {
325
+ console.log("[ws] Shutting down...");
326
+ this.sessionManager.destroyAll();
327
+ this.debugEvents.clear();
328
+ this.wss.close();
329
+ }
330
+ /** Return session count for /health endpoint. */
331
+ get sessionCount() {
332
+ return this.sessionManager.list().length;
333
+ }
334
+ /** Return connected WebSocket client count. */
335
+ get clientCount() {
336
+ return this.wss.clients.size;
337
+ }
338
+ handleConnection(ws) {
339
+ // Send session list and project history on connect
340
+ this.sendSessionList(ws);
341
+ const projects = this.projectHistory?.getProjects() ?? [];
342
+ this.send(ws, { type: "project_history", projects });
343
+ ws.on("message", (data) => {
344
+ const raw = data.toString();
345
+ const msg = parseClientMessage(raw);
346
+ if (!msg) {
347
+ // Try to extract the message type so the client can decide how to
348
+ // handle the unsupported message (suppress vs show update hint).
349
+ let rawType;
350
+ try {
351
+ rawType = JSON.parse(raw)
352
+ ?.type;
353
+ }
354
+ catch {
355
+ /* ignore */
356
+ }
357
+ console.error("[ws] Unsupported message:", rawType ?? raw.slice(0, 200));
358
+ this.send(ws, {
359
+ type: "error",
360
+ errorCode: "unsupported_message",
361
+ message: rawType ?? "unknown",
362
+ });
363
+ return;
364
+ }
365
+ console.log(`[ws] Received: ${msg.type}`);
366
+ this.handleClientMessage(msg, ws);
367
+ });
368
+ ws.on("close", () => {
369
+ console.log("[ws] Client disconnected");
370
+ });
371
+ ws.on("error", (err) => {
372
+ console.error("[ws] Client error:", err.message);
373
+ });
374
+ }
375
+ async handleClientMessage(msg, ws) {
376
+ const incomingSessionId = this.extractSessionIdFromClientMessage(msg);
377
+ const isActiveRuntimeSession = incomingSessionId != null &&
378
+ this.sessionManager.get(incomingSessionId) != null;
379
+ if (incomingSessionId && isActiveRuntimeSession) {
380
+ this.recordDebugEvent(incomingSessionId, {
381
+ direction: "incoming",
382
+ channel: "ws",
383
+ type: msg.type,
384
+ detail: this.summarizeClientMessage(msg),
385
+ });
386
+ this.recordingStore?.record(incomingSessionId, "incoming", msg);
387
+ }
388
+ switch (msg.type) {
389
+ case "start": {
390
+ const projectPath = resolvePlatformPath(msg.projectPath, this.platform);
391
+ if (!this.isPathAllowed(projectPath)) {
392
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
393
+ break;
394
+ }
395
+ try {
396
+ const provider = msg.provider ?? "claude";
397
+ const codexApprovalPolicy = provider === "codex"
398
+ ? normalizeCodexApprovalPolicy(msg.approvalPolicy ??
399
+ (msg.executionMode == null
400
+ ? undefined
401
+ : msg.executionMode === "fullAccess"
402
+ ? "never"
403
+ : "on-request"))
404
+ : undefined;
405
+ const executionMode = deriveExecutionMode({
406
+ provider,
407
+ permissionMode: msg.permissionMode,
408
+ executionMode: msg.executionMode,
409
+ approvalPolicy: codexApprovalPolicy,
410
+ });
411
+ const planMode = derivePlanMode({
412
+ permissionMode: msg.permissionMode,
413
+ planMode: msg.planMode,
414
+ });
415
+ const legacyPermissionMode = modesToLegacyPermissionMode(provider, executionMode, planMode);
416
+ if (provider === "codex") {
417
+ console.log(`[ws] start(codex): execution=${executionMode} plan=${planMode}`);
418
+ }
419
+ const cached = provider === "claude"
420
+ ? this.sessionManager.getCachedCommands(projectPath)
421
+ : undefined;
422
+ const sessionId = this.sessionManager.create(projectPath, {
423
+ sessionId: msg.sessionId,
424
+ continueMode: msg.continue,
425
+ permissionMode: legacyPermissionMode,
426
+ model: msg.model,
427
+ effort: msg.effort,
428
+ maxTurns: msg.maxTurns,
429
+ maxBudgetUsd: msg.maxBudgetUsd,
430
+ fallbackModel: msg.fallbackModel,
431
+ forkSession: msg.forkSession,
432
+ persistSession: msg.persistSession,
433
+ // Claude sandbox: map "on"/"off" to boolean
434
+ ...(provider === "claude" && msg.sandboxMode
435
+ ? { sandboxEnabled: msg.sandboxMode === "on" }
436
+ : {}),
437
+ }, undefined, {
438
+ useWorktree: msg.useWorktree,
439
+ worktreeBranch: msg.worktreeBranch,
440
+ existingWorktreePath: msg.existingWorktreePath,
441
+ }, provider, provider === "codex"
442
+ ? {
443
+ approvalPolicy: codexApprovalPolicy ??
444
+ normalizeCodexApprovalPolicy(executionMode === "fullAccess" ? "never" : "on-request"),
445
+ sandboxMode: sandboxModeToInternal(msg.sandboxMode),
446
+ model: msg.model,
447
+ modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
448
+ networkAccessEnabled: msg.networkAccessEnabled,
449
+ webSearchMode: msg.webSearchMode ??
450
+ undefined,
451
+ threadId: msg.sessionId,
452
+ collaborationMode: planMode
453
+ ? "plan"
454
+ : "default",
455
+ }
456
+ : undefined);
457
+ const createdSession = this.sessionManager.get(sessionId);
458
+ // Load saved session name from CLI storage (for resumed sessions)
459
+ void this.loadAndSetSessionName(createdSession, provider, projectPath, msg.sessionId).then(() => {
460
+ this.send(ws, this.buildSessionCreatedMessage({
461
+ sessionId,
462
+ provider,
463
+ projectPath,
464
+ session: createdSession,
465
+ permissionMode: legacyPermissionMode,
466
+ executionMode,
467
+ planMode,
468
+ sandboxMode: msg.sandboxMode,
469
+ ...(cached
470
+ ? {
471
+ slashCommands: cached.slashCommands,
472
+ skills: cached.skills,
473
+ ...(cached.skillMetadata
474
+ ? { skillMetadata: cached.skillMetadata }
475
+ : {}),
476
+ }
477
+ : {}),
478
+ }));
479
+ this.broadcastSessionList();
480
+ // Send a gentle tip when the project is not a git repository
481
+ if (createdSession && !createdSession.gitBranch) {
482
+ const tipMsg = {
483
+ type: "system",
484
+ subtype: "tip",
485
+ tipCode: "git_not_available",
486
+ sessionId,
487
+ };
488
+ createdSession.history.push(tipMsg);
489
+ this.send(ws, tipMsg);
490
+ }
491
+ });
492
+ this.debugEvents.set(sessionId, []);
493
+ this.recordDebugEvent(sessionId, {
494
+ direction: "internal",
495
+ channel: "bridge",
496
+ type: "session_created",
497
+ detail: `provider=${provider} projectPath=${projectPath}`,
498
+ });
499
+ this.recordingStore?.saveMeta(sessionId, {
500
+ bridgeSessionId: sessionId,
501
+ projectPath,
502
+ createdAt: new Date().toISOString(),
503
+ });
504
+ this.projectHistory?.addProject(projectPath);
505
+ }
506
+ catch (err) {
507
+ console.error(`[ws] Failed to start session:`, err);
508
+ this.send(ws, {
509
+ type: "error",
510
+ message: `Failed to start session: ${err.message}`,
511
+ });
512
+ }
513
+ break;
514
+ }
515
+ case "input": {
516
+ const session = this.resolveSession(msg.sessionId);
517
+ if (!session) {
518
+ this.send(ws, {
519
+ type: "error",
520
+ message: "No active session. Send 'start' first.",
521
+ });
522
+ return;
523
+ }
524
+ const text = msg.text;
525
+ // Codex: reject if the process is not waiting for input (turn-based, no internal queue)
526
+ if (session.provider === "codex" &&
527
+ !session.process.isWaitingForInput) {
528
+ this.send(ws, {
529
+ type: "input_rejected",
530
+ sessionId: session.id,
531
+ reason: "Process is busy",
532
+ });
533
+ break;
534
+ }
535
+ // Snapshot busy state before dispatch. We prefer the actual enqueue
536
+ // result returned by SdkProcess sendInput* below, but keep this as a
537
+ // fallback for test doubles and async paths.
538
+ const isAgentBusySnapshot = session.provider === "claude" && !session.process.isWaitingForInput;
539
+ // Normalize images: support new `images` array and legacy single-image fields
540
+ let images = [];
541
+ if (msg.images && msg.images.length > 0) {
542
+ images = msg.images;
543
+ }
544
+ else if (msg.imageBase64 && msg.mimeType) {
545
+ // Legacy single-image fallback
546
+ images = [{ base64: msg.imageBase64, mimeType: msg.mimeType }];
547
+ }
548
+ // Add user_input to in-memory history.
549
+ // The SDK stream does NOT emit user messages, so session.history would
550
+ // otherwise lack them. This ensures get_history responses include user
551
+ // messages and replaceEntries on the client side preserves them.
552
+ // We do NOT broadcast this back — Flutter already shows it via sendMessage().
553
+ //
554
+ // Register images in the image store so they can be served via HTTP
555
+ // when the client re-enters the session and loads history.
556
+ let imageRefs;
557
+ if (images.length > 0 && this.imageStore) {
558
+ imageRefs = [];
559
+ for (const img of images) {
560
+ const ref = this.imageStore.registerFromBase64(img.base64, img.mimeType);
561
+ if (ref)
562
+ imageRefs.push(ref);
563
+ }
564
+ if (imageRefs.length === 0)
565
+ imageRefs = undefined;
566
+ }
567
+ session.history.push({
568
+ type: "user_input",
569
+ text,
570
+ timestamp: new Date().toISOString(),
571
+ ...(images.length > 0 ? { imageCount: images.length } : {}),
572
+ ...(imageRefs ? { images: imageRefs } : {}),
573
+ });
574
+ // Persist images to Gallery Store asynchronously (fire-and-forget)
575
+ if (images.length > 0 && this.galleryStore && session.projectPath) {
576
+ for (const img of images) {
577
+ this.galleryStore
578
+ .addImageFromBase64(img.base64, img.mimeType, session.projectPath, msg.sessionId)
579
+ .catch((err) => {
580
+ console.warn(`[ws] Failed to persist image to gallery: ${err}`);
581
+ });
582
+ }
583
+ }
584
+ // Codex input path
585
+ if (session.provider === "codex") {
586
+ this.send(ws, {
587
+ type: "input_ack",
588
+ sessionId: session.id,
589
+ queued: false,
590
+ });
591
+ const codexProc = session.process;
592
+ if (images.length > 0) {
593
+ codexProc.sendInputWithImages(text, images);
594
+ }
595
+ else if (msg.imageId && this.galleryStore) {
596
+ this.galleryStore
597
+ .getImageAsBase64(msg.imageId)
598
+ .then((imageData) => {
599
+ if (imageData) {
600
+ codexProc.sendInputWithImages(text, [imageData]);
601
+ }
602
+ else {
603
+ console.warn(`[ws] Image not found: ${msg.imageId}`);
604
+ codexProc.sendInput(text);
605
+ }
606
+ })
607
+ .catch((err) => {
608
+ console.error(`[ws] Failed to load image: ${err}`);
609
+ codexProc.sendInput(text);
610
+ });
611
+ }
612
+ else if (msg.skill) {
613
+ codexProc.sendInputWithSkill(text, msg.skill);
614
+ }
615
+ else {
616
+ codexProc.sendInput(text);
617
+ }
618
+ break;
619
+ }
620
+ // Claude Code input path — enqueue first, then interrupt if busy
621
+ const claudeProc = session.process;
622
+ let wasQueued = false;
623
+ if (images.length > 0) {
624
+ console.log(`[ws] Sending message with ${images.length} inline Base64 image(s)`);
625
+ const result = claudeProc.sendInputWithImages(text, images);
626
+ wasQueued =
627
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
628
+ }
629
+ // Legacy imageId mode (backward compatibility)
630
+ else if (msg.imageId && this.galleryStore) {
631
+ this.send(ws, {
632
+ type: "input_ack",
633
+ sessionId: session.id,
634
+ queued: isAgentBusySnapshot,
635
+ });
636
+ this.galleryStore
637
+ .getImageAsBase64(msg.imageId)
638
+ .then((imageData) => {
639
+ let queuedAfterResolve = false;
640
+ if (imageData) {
641
+ const result = claudeProc.sendInputWithImages(text, [
642
+ imageData,
643
+ ]);
644
+ queuedAfterResolve =
645
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
646
+ }
647
+ else {
648
+ console.warn(`[ws] Image not found: ${msg.imageId}`);
649
+ const result = session.process.sendInput(text);
650
+ queuedAfterResolve =
651
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
652
+ }
653
+ if (queuedAfterResolve) {
654
+ console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
655
+ claudeProc.interrupt();
656
+ }
657
+ })
658
+ .catch((err) => {
659
+ console.error(`[ws] Failed to load image: ${err}`);
660
+ const result = session.process.sendInput(text);
661
+ const queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
662
+ if (queuedAfterResolve) {
663
+ console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
664
+ claudeProc.interrupt();
665
+ }
666
+ });
667
+ break;
668
+ }
669
+ // Text-only message
670
+ else {
671
+ const result = session.process.sendInput(text);
672
+ wasQueued =
673
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
674
+ }
675
+ // Acknowledge receipt so the client can mark the message state.
676
+ // queued=true means the input was enqueued instead of being consumed
677
+ // immediately by the SDK stream.
678
+ this.send(ws, {
679
+ type: "input_ack",
680
+ sessionId: session.id,
681
+ queued: wasQueued,
682
+ });
683
+ if (wasQueued) {
684
+ console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
685
+ claudeProc.interrupt();
686
+ }
687
+ break;
688
+ }
689
+ case "push_register": {
690
+ const locale = normalizePushLocale(msg.locale);
691
+ const privacyMode = msg.privacyMode === true;
692
+ console.log(`[ws] push_register received (platform: ${msg.platform}, locale: ${locale}, privacy: ${privacyMode}, configured: ${this.pushRelay.isConfigured})`);
693
+ if (!this.pushRelay.isConfigured) {
694
+ this.send(ws, {
695
+ type: "error",
696
+ message: "Push relay is not configured on bridge",
697
+ });
698
+ return;
699
+ }
700
+ this.tokenLocales.set(msg.token, locale);
701
+ this.tokenPrivacyMode.set(msg.token, privacyMode);
702
+ this.pushRelay
703
+ .registerToken(msg.token, msg.platform, locale)
704
+ .then(() => {
705
+ console.log("[ws] push_register: token registered successfully");
706
+ })
707
+ .catch((err) => {
708
+ const detail = err instanceof Error ? err.message : String(err);
709
+ console.error(`[ws] push_register failed: ${detail}`);
710
+ this.send(ws, {
711
+ type: "error",
712
+ message: `Failed to register push token: ${detail}`,
713
+ });
714
+ });
715
+ break;
716
+ }
717
+ case "push_unregister": {
718
+ console.log("[ws] push_unregister received");
719
+ if (!this.pushRelay.isConfigured) {
720
+ this.send(ws, {
721
+ type: "error",
722
+ message: "Push relay is not configured on bridge",
723
+ });
724
+ return;
725
+ }
726
+ this.tokenLocales.delete(msg.token);
727
+ this.tokenPrivacyMode.delete(msg.token);
728
+ this.pushRelay
729
+ .unregisterToken(msg.token)
730
+ .then(() => {
731
+ console.log("[ws] push_unregister: token unregistered successfully");
732
+ })
733
+ .catch((err) => {
734
+ const detail = err instanceof Error ? err.message : String(err);
735
+ console.error(`[ws] push_unregister failed: ${detail}`);
736
+ this.send(ws, {
737
+ type: "error",
738
+ message: `Failed to unregister push token: ${detail}`,
739
+ });
740
+ });
741
+ break;
742
+ }
743
+ case "set_permission_mode": {
744
+ if (this.failSetPermissionMode) {
745
+ this.send(ws, {
746
+ type: "error",
747
+ message: "Failed to set permission mode: forced test failure",
748
+ errorCode: "set_permission_mode_rejected",
749
+ });
750
+ break;
751
+ }
752
+ const session = this.resolveSession(msg.sessionId);
753
+ if (!session) {
754
+ this.send(ws, { type: "error", message: "No active session." });
755
+ return;
756
+ }
757
+ if (session.provider === "codex") {
758
+ // Permission mode for Codex requires a session restart (like sandbox mode).
759
+ // approvalPolicy and collaborationMode are thread-level settings that
760
+ // only take effect reliably at thread/start or thread/resume time.
761
+ const explicitApproval = normalizeCodexApprovalPolicy(msg.approvalPolicy ??
762
+ (msg.executionMode == null
763
+ ? undefined
764
+ : msg.executionMode === "fullAccess"
765
+ ? "never"
766
+ : "on-request"));
767
+ const executionMode = deriveExecutionMode({
768
+ provider: "codex",
769
+ permissionMode: msg.mode,
770
+ executionMode: msg.executionMode,
771
+ approvalPolicy: explicitApproval,
772
+ });
773
+ const planMode = derivePlanMode({
774
+ permissionMode: msg.mode,
775
+ planMode: msg.planMode,
776
+ });
777
+ const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
778
+ const newApproval = explicitApproval;
779
+ const newCollaboration = planMode
780
+ ? "plan"
781
+ : "default";
782
+ const currentApproval = session.process
783
+ .approvalPolicy;
784
+ const currentCollaboration = session.process
785
+ .collaborationMode;
786
+ if (newApproval === currentApproval &&
787
+ newCollaboration === currentCollaboration) {
788
+ break; // No change needed
789
+ }
790
+ const canApplyModeInPlace = session.status === "idle";
791
+ if (canApplyModeInPlace) {
792
+ const process = session.process;
793
+ if (newApproval !== currentApproval) {
794
+ process.setApprovalPolicy(newApproval);
795
+ }
796
+ if (newCollaboration !== currentCollaboration) {
797
+ process.setCollaborationMode(newCollaboration);
798
+ }
799
+ session.lastActivityAt = new Date();
800
+ this.broadcast({
801
+ type: "system",
802
+ subtype: "set_permission_mode",
803
+ sessionId: session.id,
804
+ permissionMode: legacyPermissionMode,
805
+ executionMode,
806
+ approvalPolicy: newApproval,
807
+ planMode,
808
+ });
809
+ this.broadcastSessionList();
810
+ this.recordDebugEvent(session.id, {
811
+ direction: "internal",
812
+ channel: "bridge",
813
+ type: "permission_mode_changed",
814
+ detail: `mode=${msg.mode} approval=${newApproval} collaboration=${newCollaboration} applied=in-place`,
815
+ });
816
+ console.log(`[ws] set_permission_mode(codex): execution=${executionMode} plan=${planMode} → approval=${newApproval}, collaboration=${newCollaboration} (in-place)`);
817
+ break;
818
+ }
819
+ console.log(`[ws] set_permission_mode(codex): execution=${executionMode} plan=${planMode} → approval=${newApproval}, collaboration=${newCollaboration} (restart)`);
820
+ const oldSessionId = session.id;
821
+ const threadId = session.claudeSessionId;
822
+ const projectPath = session.projectPath;
823
+ const oldSettings = session.codexSettings ?? {};
824
+ const worktreePath = session.worktreePath;
825
+ const worktreeBranch = session.worktreeBranch;
826
+ const sessionName = session.name;
827
+ this.sessionManager.destroy(oldSessionId);
828
+ console.log(`[ws] Permission mode change: destroyed session ${oldSessionId}`);
829
+ const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") ||
830
+ (session.pastMessages && session.pastMessages.length > 0);
831
+ if (!threadId || !hasUserMessages) {
832
+ const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath
833
+ ? { existingWorktreePath: worktreePath, worktreeBranch }
834
+ : undefined, "codex", {
835
+ approvalPolicy: newApproval,
836
+ sandboxMode: oldSettings.sandboxMode,
837
+ model: oldSettings.model,
838
+ modelReasoningEffort: oldSettings.modelReasoningEffort,
839
+ networkAccessEnabled: oldSettings.networkAccessEnabled,
840
+ webSearchMode: oldSettings.webSearchMode,
841
+ collaborationMode: newCollaboration,
842
+ });
843
+ const newSession = this.sessionManager.get(newId);
844
+ if (newSession && sessionName)
845
+ newSession.name = sessionName;
846
+ this.broadcast(this.buildSessionCreatedMessage({
847
+ sessionId: newId,
848
+ provider: "codex",
849
+ projectPath,
850
+ session: newSession,
851
+ permissionMode: legacyPermissionMode,
852
+ executionMode,
853
+ planMode,
854
+ sandboxMode: oldSettings.sandboxMode
855
+ ? sandboxModeToExternal(oldSettings.sandboxMode)
856
+ : undefined,
857
+ sourceSessionId: oldSessionId,
858
+ }));
859
+ this.broadcastSessionList();
860
+ console.log(`[ws] Permission mode change (no thread): created new session ${newId} (mode=${msg.mode})`);
861
+ break;
862
+ }
863
+ // Worktree resolution
864
+ const wtMapping = this.worktreeStore.get(threadId);
865
+ const effectiveProjectPath = wtMapping?.projectPath ?? projectPath;
866
+ let worktreeOpts;
867
+ if (wtMapping) {
868
+ if (worktreeExists(wtMapping.worktreePath)) {
869
+ worktreeOpts = {
870
+ existingWorktreePath: wtMapping.worktreePath,
871
+ worktreeBranch: wtMapping.worktreeBranch,
872
+ };
873
+ }
874
+ else {
875
+ worktreeOpts = {
876
+ useWorktree: true,
877
+ worktreeBranch: wtMapping.worktreeBranch,
878
+ };
879
+ }
880
+ }
881
+ else if (worktreePath) {
882
+ worktreeOpts = {
883
+ existingWorktreePath: worktreePath,
884
+ worktreeBranch,
885
+ };
886
+ }
887
+ getCodexSessionHistory(threadId)
888
+ .then((pastMessages) => {
889
+ const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
890
+ threadId,
891
+ approvalPolicy: newApproval,
892
+ sandboxMode: oldSettings.sandboxMode,
893
+ model: oldSettings.model,
894
+ modelReasoningEffort: oldSettings.modelReasoningEffort,
895
+ networkAccessEnabled: oldSettings.networkAccessEnabled,
896
+ webSearchMode: oldSettings.webSearchMode,
897
+ collaborationMode: newCollaboration,
898
+ });
899
+ const newSession = this.sessionManager.get(newId);
900
+ if (newSession && sessionName) {
901
+ newSession.name = sessionName;
902
+ }
903
+ void this.loadAndSetSessionName(newSession, "codex", effectiveProjectPath, threadId).then(() => {
904
+ this.broadcast(this.buildSessionCreatedMessage({
905
+ sessionId: newId,
906
+ provider: "codex",
907
+ projectPath: effectiveProjectPath,
908
+ session: newSession,
909
+ permissionMode: legacyPermissionMode,
910
+ executionMode,
911
+ planMode,
912
+ sandboxMode: oldSettings.sandboxMode
913
+ ? sandboxModeToExternal(oldSettings.sandboxMode)
914
+ : undefined,
915
+ sourceSessionId: oldSessionId,
916
+ }));
917
+ this.broadcastSessionList();
918
+ });
919
+ this.debugEvents.set(newId, []);
920
+ this.recordDebugEvent(newId, {
921
+ direction: "internal",
922
+ channel: "bridge",
923
+ type: "permission_mode_changed",
924
+ detail: `mode=${msg.mode} approval=${newApproval} collaboration=${newCollaboration} thread=${threadId} oldSession=${oldSessionId}`,
925
+ });
926
+ console.log(`[ws] Permission mode change: created new session ${newId} (thread=${threadId}, mode=${msg.mode})`);
927
+ })
928
+ .catch((err) => {
929
+ this.send(ws, {
930
+ type: "error",
931
+ message: `Failed to restart session for permission mode change: ${err}`,
932
+ });
933
+ });
934
+ break;
935
+ }
936
+ session.process
937
+ .setPermissionMode(msg.mode)
938
+ .catch((err) => {
939
+ this.send(ws, {
940
+ type: "error",
941
+ message: `Failed to set permission mode: ${err instanceof Error ? err.message : String(err)}`,
942
+ });
943
+ });
944
+ break;
945
+ }
946
+ case "set_sandbox_mode": {
947
+ if (this.failSetSandboxMode) {
948
+ this.send(ws, {
949
+ type: "error",
950
+ message: "Failed to set sandbox mode: forced test failure",
951
+ errorCode: "set_sandbox_mode_rejected",
952
+ });
953
+ break;
954
+ }
955
+ const session = this.resolveSession(msg.sessionId);
956
+ if (!session) {
957
+ this.send(ws, { type: "error", message: "No active session." });
958
+ return;
959
+ }
960
+ if (msg.sandboxMode !== "on" && msg.sandboxMode !== "off") {
961
+ this.send(ws, {
962
+ type: "error",
963
+ message: `Invalid sandbox mode: ${msg.sandboxMode}`,
964
+ });
965
+ return;
966
+ }
967
+ // ---- Claude sandbox toggle ----
968
+ if (session.provider === "claude") {
969
+ const newEnabled = msg.sandboxMode === "on";
970
+ if (session.sandboxEnabled === newEnabled) {
971
+ break; // No change needed
972
+ }
973
+ // Sandbox is a query-level setting — requires session restart.
974
+ const oldSessionId = session.id;
975
+ const claudeSessionId = session.claudeSessionId;
976
+ const projectPath = session.projectPath;
977
+ const worktreePath = session.worktreePath;
978
+ const worktreeBranch = session.worktreeBranch;
979
+ const sessionName = session.name;
980
+ const permissionMode = session.process.permissionMode;
981
+ const model = session.process.model;
982
+ this.sessionManager.destroy(oldSessionId);
983
+ console.log(`[ws] Claude sandbox change: destroyed session ${oldSessionId}`);
984
+ const newId = this.sessionManager.create(projectPath, {
985
+ sessionId: claudeSessionId,
986
+ permissionMode,
987
+ model,
988
+ sandboxEnabled: newEnabled,
989
+ }, undefined, worktreePath
990
+ ? { existingWorktreePath: worktreePath, worktreeBranch }
991
+ : undefined, "claude");
992
+ const newSession = this.sessionManager.get(newId);
993
+ if (newSession && sessionName)
994
+ newSession.name = sessionName;
995
+ void this.loadAndSetSessionName(newSession, "claude", projectPath, claudeSessionId).then(() => {
996
+ this.broadcast(this.buildSessionCreatedMessage({
997
+ sessionId: newId,
998
+ provider: "claude",
999
+ projectPath,
1000
+ session: newSession,
1001
+ sandboxMode: msg.sandboxMode,
1002
+ sourceSessionId: oldSessionId,
1003
+ }));
1004
+ this.broadcastSessionList();
1005
+ });
1006
+ this.debugEvents.set(newId, []);
1007
+ this.recordDebugEvent(newId, {
1008
+ direction: "internal",
1009
+ channel: "bridge",
1010
+ type: "sandbox_mode_changed",
1011
+ detail: `sandbox=${newEnabled} claude=${claudeSessionId} oldSession=${oldSessionId}`,
1012
+ });
1013
+ console.log(`[ws] Claude sandbox change: created new session ${newId} (sandbox=${newEnabled})`);
1014
+ break;
1015
+ }
1016
+ // ---- Codex sandbox toggle ----
1017
+ const newSandboxMode = sandboxModeToInternal(msg.sandboxMode);
1018
+ const currentSandboxMode = session.codexSettings?.sandboxMode ?? "workspace-write";
1019
+ if (newSandboxMode === currentSandboxMode) {
1020
+ break; // No change needed
1021
+ }
1022
+ // Sandbox mode is a thread-level setting — it can only be applied at
1023
+ // thread/start or thread/resume time, not per-turn. To apply the new
1024
+ // mode we destroy the current session and resume the same Codex thread
1025
+ // with the updated sandbox parameter (same pattern as clearContext).
1026
+ const oldSessionId = session.id;
1027
+ const threadId = session.claudeSessionId;
1028
+ const projectPath = session.projectPath;
1029
+ const oldSettings = session.codexSettings ?? {};
1030
+ const worktreePath = session.worktreePath;
1031
+ const worktreeBranch = session.worktreeBranch;
1032
+ const sessionName = session.name;
1033
+ const collaborationMode = session.process
1034
+ .collaborationMode;
1035
+ const executionMode = oldSettings.approvalPolicy === "never" ? "fullAccess" : "default";
1036
+ const planMode = collaborationMode === "plan";
1037
+ const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
1038
+ this.sessionManager.destroy(oldSessionId);
1039
+ console.log(`[ws] Sandbox mode change: destroyed session ${oldSessionId}`);
1040
+ // Check if the user actually exchanged messages in this session.
1041
+ // session.history always contains system events (init, status, etc.)
1042
+ // even before the first user turn, so we check for user_input/assistant
1043
+ // messages specifically.
1044
+ const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") ||
1045
+ (session.pastMessages && session.pastMessages.length > 0);
1046
+ if (!threadId || !hasUserMessages) {
1047
+ // Session has no thread yet, or has a thread but no messages exchanged.
1048
+ // Create a fresh session with the new sandbox — no resume needed.
1049
+ // (A thread with no messages cannot be resumed — Codex returns
1050
+ // "no rollout found for thread id".)
1051
+ const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath
1052
+ ? { existingWorktreePath: worktreePath, worktreeBranch }
1053
+ : undefined, "codex", {
1054
+ approvalPolicy: oldSettings.approvalPolicy,
1055
+ sandboxMode: newSandboxMode,
1056
+ model: oldSettings.model,
1057
+ modelReasoningEffort: oldSettings.modelReasoningEffort,
1058
+ networkAccessEnabled: oldSettings.networkAccessEnabled,
1059
+ webSearchMode: oldSettings.webSearchMode,
1060
+ collaborationMode,
1061
+ });
1062
+ const newSession = this.sessionManager.get(newId);
1063
+ if (newSession && sessionName)
1064
+ newSession.name = sessionName;
1065
+ this.broadcast(this.buildSessionCreatedMessage({
1066
+ sessionId: newId,
1067
+ provider: "codex",
1068
+ projectPath,
1069
+ session: newSession,
1070
+ permissionMode: legacyPermissionMode,
1071
+ executionMode,
1072
+ planMode,
1073
+ sandboxMode: sandboxModeToExternal(newSandboxMode),
1074
+ sourceSessionId: oldSessionId,
1075
+ }));
1076
+ this.broadcastSessionList();
1077
+ console.log(`[ws] Sandbox mode change (no thread): created new session ${newId} (sandbox=${newSandboxMode})`);
1078
+ break;
1079
+ }
1080
+ // Worktree resolution (same as resume_session)
1081
+ const wtMapping = this.worktreeStore.get(threadId);
1082
+ const effectiveProjectPath = wtMapping?.projectPath ?? projectPath;
1083
+ let worktreeOpts;
1084
+ if (wtMapping) {
1085
+ if (worktreeExists(wtMapping.worktreePath)) {
1086
+ worktreeOpts = {
1087
+ existingWorktreePath: wtMapping.worktreePath,
1088
+ worktreeBranch: wtMapping.worktreeBranch,
1089
+ };
1090
+ }
1091
+ else {
1092
+ worktreeOpts = {
1093
+ useWorktree: true,
1094
+ worktreeBranch: wtMapping.worktreeBranch,
1095
+ };
1096
+ }
1097
+ }
1098
+ else if (worktreePath) {
1099
+ worktreeOpts = { existingWorktreePath: worktreePath, worktreeBranch };
1100
+ }
1101
+ getCodexSessionHistory(threadId)
1102
+ .then((pastMessages) => {
1103
+ const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
1104
+ threadId,
1105
+ approvalPolicy: oldSettings.approvalPolicy,
1106
+ sandboxMode: newSandboxMode,
1107
+ model: oldSettings.model,
1108
+ modelReasoningEffort: oldSettings.modelReasoningEffort,
1109
+ networkAccessEnabled: oldSettings.networkAccessEnabled,
1110
+ webSearchMode: oldSettings.webSearchMode,
1111
+ collaborationMode,
1112
+ });
1113
+ // Restore session name
1114
+ const newSession = this.sessionManager.get(newId);
1115
+ if (newSession && sessionName) {
1116
+ newSession.name = sessionName;
1117
+ }
1118
+ void this.loadAndSetSessionName(newSession, "codex", effectiveProjectPath, threadId).then(() => {
1119
+ this.broadcast(this.buildSessionCreatedMessage({
1120
+ sessionId: newId,
1121
+ provider: "codex",
1122
+ projectPath: effectiveProjectPath,
1123
+ session: newSession,
1124
+ permissionMode: legacyPermissionMode,
1125
+ executionMode,
1126
+ planMode,
1127
+ sandboxMode: sandboxModeToExternal(newSandboxMode),
1128
+ sourceSessionId: oldSessionId,
1129
+ }));
1130
+ this.broadcastSessionList();
1131
+ });
1132
+ this.debugEvents.set(newId, []);
1133
+ this.recordDebugEvent(newId, {
1134
+ direction: "internal",
1135
+ channel: "bridge",
1136
+ type: "sandbox_mode_changed",
1137
+ detail: `sandbox=${newSandboxMode} thread=${threadId} oldSession=${oldSessionId}`,
1138
+ });
1139
+ console.log(`[ws] Sandbox mode change: created new session ${newId} (thread=${threadId}, sandbox=${newSandboxMode})`);
1140
+ })
1141
+ .catch((err) => {
1142
+ this.send(ws, {
1143
+ type: "error",
1144
+ message: `Failed to restart session for sandbox mode change: ${err}`,
1145
+ });
1146
+ });
1147
+ break;
1148
+ }
1149
+ case "approve": {
1150
+ const session = this.resolveSession(msg.sessionId);
1151
+ if (!session) {
1152
+ this.send(ws, { type: "error", message: "No active session." });
1153
+ return;
1154
+ }
1155
+ if (session.provider === "codex") {
1156
+ session.process.approve(msg.id, msg.updatedInput);
1157
+ break;
1158
+ }
1159
+ const sdkProc = session.process;
1160
+ if (msg.clearContext) {
1161
+ // Clear & Accept: immediately destroy this runtime session and
1162
+ // create a fresh one that continues the same Claude conversation.
1163
+ // This guarantees chat history is cleared in the mobile UI without
1164
+ // waiting for additional in-turn tool approvals.
1165
+ const pending = sdkProc.getPendingPermission(msg.id);
1166
+ const mergedInput = {
1167
+ ...(pending?.input ?? {}),
1168
+ ...(msg.updatedInput ?? {}),
1169
+ };
1170
+ const planText = typeof mergedInput.plan === "string" ? mergedInput.plan : "";
1171
+ // Use session.id (always present) instead of msg.sessionId.
1172
+ const sessionId = session.id;
1173
+ // Capture session properties before destroy.
1174
+ const claudeSessionId = session.claudeSessionId;
1175
+ const projectPath = session.projectPath;
1176
+ const permissionMode = sdkProc.permissionMode;
1177
+ const worktreePath = session.worktreePath;
1178
+ const worktreeBranch = session.worktreeBranch;
1179
+ this.sessionManager.destroy(sessionId);
1180
+ console.log(`[ws] Clear context: destroyed session ${sessionId}`);
1181
+ const newId = this.sessionManager.create(projectPath, {
1182
+ ...(claudeSessionId
1183
+ ? {
1184
+ sessionId: claudeSessionId,
1185
+ continueMode: true,
1186
+ }
1187
+ : {}),
1188
+ permissionMode,
1189
+ initialInput: planText || undefined,
1190
+ }, undefined, worktreePath
1191
+ ? { existingWorktreePath: worktreePath, worktreeBranch }
1192
+ : undefined);
1193
+ console.log(`[ws] Clear context: created new session ${newId} (CLI session: ${claudeSessionId ?? "new"})`);
1194
+ // Notify all clients. Broadcast is used so reconnecting clients also receive it.
1195
+ const newSession = this.sessionManager.get(newId);
1196
+ const createdMsg = this.buildSessionCreatedMessage({
1197
+ sessionId: newId,
1198
+ provider: newSession?.provider ?? "claude",
1199
+ projectPath,
1200
+ session: newSession,
1201
+ permissionMode,
1202
+ sourceSessionId: sessionId,
1203
+ });
1204
+ this.broadcast({ ...createdMsg, clearContext: true });
1205
+ this.broadcastSessionList();
1206
+ }
1207
+ else {
1208
+ sdkProc.approve(msg.id, msg.updatedInput);
1209
+ }
1210
+ break;
1211
+ }
1212
+ case "approve_always": {
1213
+ const session = this.resolveSession(msg.sessionId);
1214
+ if (!session) {
1215
+ this.send(ws, { type: "error", message: "No active session." });
1216
+ return;
1217
+ }
1218
+ if (session.provider === "codex") {
1219
+ session.process.approveAlways(msg.id);
1220
+ break;
1221
+ }
1222
+ session.process.approveAlways(msg.id);
1223
+ break;
1224
+ }
1225
+ case "reject": {
1226
+ const session = this.resolveSession(msg.sessionId);
1227
+ if (!session) {
1228
+ this.send(ws, { type: "error", message: "No active session." });
1229
+ return;
1230
+ }
1231
+ if (session.provider === "codex") {
1232
+ session.process.reject(msg.id, msg.message);
1233
+ break;
1234
+ }
1235
+ session.process.reject(msg.id, msg.message);
1236
+ break;
1237
+ }
1238
+ case "answer": {
1239
+ const session = this.resolveSession(msg.sessionId);
1240
+ if (!session) {
1241
+ this.send(ws, { type: "error", message: "No active session." });
1242
+ return;
1243
+ }
1244
+ if (session.provider === "codex") {
1245
+ session.process.answer(msg.toolUseId, msg.result);
1246
+ break;
1247
+ }
1248
+ session.process.answer(msg.toolUseId, msg.result);
1249
+ break;
1250
+ }
1251
+ case "list_sessions": {
1252
+ this.sendSessionList(ws);
1253
+ break;
1254
+ }
1255
+ case "stop_session": {
1256
+ const session = this.sessionManager.get(msg.sessionId);
1257
+ if (session) {
1258
+ // Notify clients before destroying (destroy removes listeners)
1259
+ this.broadcastSessionMessage(msg.sessionId, {
1260
+ type: "result",
1261
+ subtype: "stopped",
1262
+ sessionId: session.claudeSessionId,
1263
+ });
1264
+ this.sessionManager.destroy(msg.sessionId);
1265
+ this.recordDebugEvent(msg.sessionId, {
1266
+ direction: "internal",
1267
+ channel: "bridge",
1268
+ type: "session_stopped",
1269
+ });
1270
+ this.debugEvents.delete(msg.sessionId);
1271
+ this.notifiedPermissionToolUses.delete(msg.sessionId);
1272
+ this.sendSessionList(ws);
1273
+ }
1274
+ else {
1275
+ this.send(ws, {
1276
+ type: "error",
1277
+ message: `Session ${msg.sessionId} not found`,
1278
+ });
1279
+ }
1280
+ break;
1281
+ }
1282
+ case "get_history": {
1283
+ const session = this.sessionManager.get(msg.sessionId);
1284
+ if (session) {
1285
+ // Send past conversation from disk (resume) before in-memory history
1286
+ if (session.pastMessages && session.pastMessages.length > 0) {
1287
+ this.send(ws, {
1288
+ type: "past_history",
1289
+ claudeSessionId: session.claudeSessionId ?? msg.sessionId,
1290
+ sessionId: msg.sessionId,
1291
+ messages: session.pastMessages,
1292
+ });
1293
+ }
1294
+ this.send(ws, {
1295
+ type: "history",
1296
+ messages: session.history,
1297
+ sessionId: msg.sessionId,
1298
+ });
1299
+ this.send(ws, {
1300
+ type: "status",
1301
+ status: session.status,
1302
+ sessionId: msg.sessionId,
1303
+ });
1304
+ // Send cached slash commands so the client can restore them even when
1305
+ // the original init/supported_commands message was evicted from the
1306
+ // in-memory history (MAX_HISTORY_PER_SESSION overflow).
1307
+ const cached = this.sessionManager.getCachedCommands(session.projectPath);
1308
+ if (cached && cached.slashCommands.length > 0) {
1309
+ this.send(ws, {
1310
+ type: "system",
1311
+ subtype: "supported_commands",
1312
+ sessionId: msg.sessionId,
1313
+ slashCommands: cached.slashCommands,
1314
+ skills: cached.skills,
1315
+ ...(cached.skillMetadata
1316
+ ? { skillMetadata: cached.skillMetadata }
1317
+ : {}),
1318
+ });
1319
+ }
1320
+ }
1321
+ else {
1322
+ this.send(ws, {
1323
+ type: "error",
1324
+ message: `Session ${msg.sessionId} not found`,
1325
+ });
1326
+ }
1327
+ break;
1328
+ }
1329
+ case "refresh_branch": {
1330
+ const session = this.sessionManager.get(msg.sessionId);
1331
+ if (session) {
1332
+ const cwd = session.worktreePath ?? session.projectPath;
1333
+ let branch = "";
1334
+ try {
1335
+ branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
1336
+ cwd,
1337
+ encoding: "utf-8",
1338
+ }).trim();
1339
+ }
1340
+ catch {
1341
+ /* not a git repo */
1342
+ }
1343
+ // Update stored branch so future session_list responses are also current
1344
+ session.gitBranch = branch;
1345
+ this.send(ws, {
1346
+ type: "branch_update",
1347
+ sessionId: msg.sessionId,
1348
+ branch,
1349
+ });
1350
+ }
1351
+ else {
1352
+ this.send(ws, {
1353
+ type: "error",
1354
+ message: `Session ${msg.sessionId} not found`,
1355
+ });
1356
+ }
1357
+ break;
1358
+ }
1359
+ case "get_debug_bundle": {
1360
+ const session = this.sessionManager.get(msg.sessionId);
1361
+ if (!session) {
1362
+ this.send(ws, {
1363
+ type: "error",
1364
+ message: `Session ${msg.sessionId} not found`,
1365
+ });
1366
+ return;
1367
+ }
1368
+ const emitBundle = (diff, diffError) => {
1369
+ const traceLimit = msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS;
1370
+ const trace = this.getDebugEvents(msg.sessionId, traceLimit);
1371
+ const generatedAt = new Date().toISOString();
1372
+ const includeDiff = msg.includeDiff !== false;
1373
+ const bundlePayload = {
1374
+ type: "debug_bundle",
1375
+ sessionId: msg.sessionId,
1376
+ generatedAt,
1377
+ session: {
1378
+ id: session.id,
1379
+ provider: session.provider,
1380
+ status: session.status,
1381
+ projectPath: session.projectPath,
1382
+ worktreePath: session.worktreePath,
1383
+ worktreeBranch: session.worktreeBranch,
1384
+ claudeSessionId: session.claudeSessionId,
1385
+ createdAt: session.createdAt.toISOString(),
1386
+ lastActivityAt: session.lastActivityAt.toISOString(),
1387
+ },
1388
+ pastMessageCount: session.pastMessages?.length ?? 0,
1389
+ historySummary: this.buildHistorySummary(session.history),
1390
+ debugTrace: trace,
1391
+ traceFilePath: this.debugTraceStore.getTraceFilePath(msg.sessionId),
1392
+ reproRecipe: this.buildReproRecipe(session, traceLimit, includeDiff),
1393
+ agentPrompt: this.buildAgentPrompt(session),
1394
+ diff,
1395
+ diffError,
1396
+ };
1397
+ const savedBundlePath = this.debugTraceStore.getBundleFilePath(msg.sessionId, generatedAt);
1398
+ bundlePayload.savedBundlePath = savedBundlePath;
1399
+ this.debugTraceStore.saveBundleAtPath(savedBundlePath, bundlePayload);
1400
+ this.send(ws, bundlePayload);
1401
+ };
1402
+ if (msg.includeDiff === false) {
1403
+ emitBundle("");
1404
+ break;
1405
+ }
1406
+ const cwd = session.worktreePath ?? session.projectPath;
1407
+ this.collectGitDiff(cwd, ({ diff, error }) => {
1408
+ emitBundle(diff, error);
1409
+ });
1410
+ break;
1411
+ }
1412
+ case "get_usage": {
1413
+ fetchAllUsage()
1414
+ .then((providers) => {
1415
+ this.send(ws, { type: "usage_result", providers });
1416
+ })
1417
+ .catch((err) => {
1418
+ this.send(ws, {
1419
+ type: "error",
1420
+ message: `Failed to fetch usage: ${err}`,
1421
+ });
1422
+ });
1423
+ break;
1424
+ }
1425
+ case "list_recent_sessions": {
1426
+ const requestId = ++this.recentSessionsRequestId;
1427
+ this.listRecentSessions(msg)
1428
+ .then(({ sessions, hasMore }) => {
1429
+ // Drop stale responses when rapid filter switches cause out-of-order completion
1430
+ if (requestId !== this.recentSessionsRequestId)
1431
+ return;
1432
+ this.send(ws, {
1433
+ type: "recent_sessions",
1434
+ sessions,
1435
+ hasMore,
1436
+ });
1437
+ })
1438
+ .catch((err) => {
1439
+ if (requestId !== this.recentSessionsRequestId)
1440
+ return;
1441
+ this.send(ws, {
1442
+ type: "error",
1443
+ message: `Failed to list recent sessions: ${err}`,
1444
+ });
1445
+ });
1446
+ break;
1447
+ }
1448
+ case "archive_session": {
1449
+ const { sessionId, provider, projectPath } = msg;
1450
+ this.archiveStore
1451
+ .archive(sessionId, provider, projectPath)
1452
+ .then(() => {
1453
+ // For Codex sessions, also call thread/archive RPC (best-effort).
1454
+ // Requires a running Codex app-server process; skip if none active.
1455
+ if (provider === "codex") {
1456
+ const activeSessions = this.sessionManager.list();
1457
+ const codexSession = activeSessions.find((s) => s.provider === "codex");
1458
+ if (codexSession) {
1459
+ const session = this.sessionManager.get(codexSession.id);
1460
+ if (session) {
1461
+ session.process
1462
+ .archiveThread(sessionId)
1463
+ .catch((err) => {
1464
+ console.warn(`[ws] Codex thread/archive failed (non-fatal): ${err}`);
1465
+ });
1466
+ }
1467
+ }
1468
+ }
1469
+ this.send(ws, {
1470
+ type: "archive_result",
1471
+ sessionId,
1472
+ success: true,
1473
+ });
1474
+ })
1475
+ .catch((err) => {
1476
+ this.send(ws, {
1477
+ type: "archive_result",
1478
+ sessionId,
1479
+ success: false,
1480
+ error: String(err),
1481
+ });
1482
+ });
1483
+ break;
1484
+ }
1485
+ case "resume_session": {
1486
+ console.log(`[ws] resume_session: sessionId=${msg.sessionId} projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`);
1487
+ const resumeProjectPath = resolvePlatformPath(msg.projectPath, this.platform);
1488
+ if (!this.isPathAllowed(resumeProjectPath)) {
1489
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
1490
+ break;
1491
+ }
1492
+ const provider = msg.provider ?? "claude";
1493
+ const codexApprovalPolicy = provider === "codex"
1494
+ ? normalizeCodexApprovalPolicy(msg.approvalPolicy ??
1495
+ (msg.executionMode == null
1496
+ ? undefined
1497
+ : msg.executionMode === "fullAccess"
1498
+ ? "never"
1499
+ : "on-request"))
1500
+ : undefined;
1501
+ const executionMode = deriveExecutionMode({
1502
+ provider,
1503
+ permissionMode: msg.permissionMode,
1504
+ executionMode: msg.executionMode,
1505
+ approvalPolicy: codexApprovalPolicy,
1506
+ });
1507
+ const planMode = derivePlanMode({
1508
+ permissionMode: msg.permissionMode,
1509
+ planMode: msg.planMode,
1510
+ });
1511
+ const legacyPermissionMode = modesToLegacyPermissionMode(provider, executionMode, planMode);
1512
+ const sessionRefId = msg.sessionId;
1513
+ // Resume flow: keep past history in SessionInfo and deliver it only
1514
+ // via get_history(sessionId) to avoid duplicate/missed replay races.
1515
+ if (provider === "codex") {
1516
+ const wtMapping = this.worktreeStore.get(sessionRefId);
1517
+ const effectiveProjectPath = resolvePlatformPath(wtMapping?.projectPath ?? resumeProjectPath, this.platform);
1518
+ let worktreeOpts;
1519
+ if (wtMapping) {
1520
+ if (worktreeExists(wtMapping.worktreePath)) {
1521
+ worktreeOpts = {
1522
+ existingWorktreePath: wtMapping.worktreePath,
1523
+ worktreeBranch: wtMapping.worktreeBranch,
1524
+ };
1525
+ }
1526
+ else {
1527
+ worktreeOpts = {
1528
+ useWorktree: true,
1529
+ worktreeBranch: wtMapping.worktreeBranch,
1530
+ };
1531
+ }
1532
+ }
1533
+ getCodexSessionHistory(sessionRefId)
1534
+ .then((pastMessages) => {
1535
+ const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
1536
+ threadId: sessionRefId,
1537
+ approvalPolicy: codexApprovalPolicy ??
1538
+ normalizeCodexApprovalPolicy(executionMode === "fullAccess" ? "never" : "on-request"),
1539
+ sandboxMode: sandboxModeToInternal(msg.sandboxMode),
1540
+ model: msg.model,
1541
+ modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
1542
+ networkAccessEnabled: msg.networkAccessEnabled,
1543
+ webSearchMode: msg.webSearchMode ??
1544
+ undefined,
1545
+ collaborationMode: planMode
1546
+ ? "plan"
1547
+ : "default",
1548
+ });
1549
+ const createdSession = this.sessionManager.get(sessionId);
1550
+ void this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId).then(() => {
1551
+ this.send(ws, this.buildSessionCreatedMessage({
1552
+ sessionId,
1553
+ provider: "codex",
1554
+ projectPath: effectiveProjectPath,
1555
+ session: createdSession,
1556
+ sandboxMode: createdSession?.codexSettings?.sandboxMode
1557
+ ? sandboxModeToExternal(createdSession.codexSettings.sandboxMode)
1558
+ : undefined,
1559
+ permissionMode: legacyPermissionMode,
1560
+ executionMode,
1561
+ planMode,
1562
+ }));
1563
+ this.broadcastSessionList();
1564
+ });
1565
+ this.debugEvents.set(sessionId, []);
1566
+ this.recordDebugEvent(sessionId, {
1567
+ direction: "internal",
1568
+ channel: "bridge",
1569
+ type: "session_resumed",
1570
+ detail: `provider=codex thread=${sessionRefId}`,
1571
+ });
1572
+ this.projectHistory?.addProject(effectiveProjectPath);
1573
+ })
1574
+ .catch((err) => {
1575
+ this.send(ws, {
1576
+ type: "error",
1577
+ message: `Failed to load Codex session history: ${err}`,
1578
+ });
1579
+ });
1580
+ break;
1581
+ }
1582
+ const claudeSessionId = sessionRefId;
1583
+ const cached = this.sessionManager.getCachedCommands(resumeProjectPath);
1584
+ // Look up worktree mapping for this Claude session
1585
+ const wtMapping = this.worktreeStore.get(claudeSessionId);
1586
+ let worktreeOpts;
1587
+ if (wtMapping) {
1588
+ if (worktreeExists(wtMapping.worktreePath)) {
1589
+ // Worktree exists — reuse it directly
1590
+ worktreeOpts = {
1591
+ existingWorktreePath: wtMapping.worktreePath,
1592
+ worktreeBranch: wtMapping.worktreeBranch,
1593
+ };
1594
+ }
1595
+ else {
1596
+ // Worktree was deleted — recreate on the same branch
1597
+ worktreeOpts = {
1598
+ useWorktree: true,
1599
+ worktreeBranch: wtMapping.worktreeBranch,
1600
+ };
1601
+ }
1602
+ }
1603
+ getSessionHistory(claudeSessionId)
1604
+ .then((pastMessages) => {
1605
+ const sessionId = this.sessionManager.create(resumeProjectPath, {
1606
+ sessionId: claudeSessionId,
1607
+ permissionMode: legacyPermissionMode,
1608
+ model: msg.model,
1609
+ effort: msg.effort,
1610
+ maxTurns: msg.maxTurns,
1611
+ maxBudgetUsd: msg.maxBudgetUsd,
1612
+ fallbackModel: msg.fallbackModel,
1613
+ forkSession: msg.forkSession,
1614
+ persistSession: msg.persistSession,
1615
+ ...(msg.sandboxMode
1616
+ ? { sandboxEnabled: msg.sandboxMode === "on" }
1617
+ : {}),
1618
+ }, pastMessages, worktreeOpts);
1619
+ const createdSession = this.sessionManager.get(sessionId);
1620
+ void this.loadAndSetSessionName(createdSession, "claude", resumeProjectPath, claudeSessionId).then(() => {
1621
+ this.send(ws, {
1622
+ ...this.buildSessionCreatedMessage({
1623
+ sessionId,
1624
+ provider: "claude",
1625
+ projectPath: resumeProjectPath,
1626
+ session: createdSession,
1627
+ permissionMode: legacyPermissionMode,
1628
+ executionMode,
1629
+ planMode,
1630
+ sandboxMode: msg.sandboxMode,
1631
+ ...(cached
1632
+ ? {
1633
+ slashCommands: cached.slashCommands,
1634
+ skills: cached.skills,
1635
+ ...(cached.skillMetadata
1636
+ ? { skillMetadata: cached.skillMetadata }
1637
+ : {}),
1638
+ }
1639
+ : {}),
1640
+ }),
1641
+ claudeSessionId,
1642
+ });
1643
+ this.broadcastSessionList();
1644
+ });
1645
+ this.debugEvents.set(sessionId, []);
1646
+ this.recordDebugEvent(sessionId, {
1647
+ direction: "internal",
1648
+ channel: "bridge",
1649
+ type: "session_resumed",
1650
+ detail: `provider=claude session=${claudeSessionId}`,
1651
+ });
1652
+ this.projectHistory?.addProject(resumeProjectPath);
1653
+ })
1654
+ .catch((err) => {
1655
+ this.send(ws, {
1656
+ type: "error",
1657
+ message: `Failed to load session history: ${err}`,
1658
+ });
1659
+ });
1660
+ break;
1661
+ }
1662
+ case "list_gallery": {
1663
+ if (this.galleryStore) {
1664
+ const images = this.galleryStore.list({
1665
+ projectPath: msg.project,
1666
+ sessionId: msg.sessionId,
1667
+ });
1668
+ this.send(ws, { type: "gallery_list", images });
1669
+ }
1670
+ else {
1671
+ this.send(ws, { type: "gallery_list", images: [] });
1672
+ }
1673
+ break;
1674
+ }
1675
+ case "get_message_images": {
1676
+ void extractMessageImages(msg.claudeSessionId, msg.messageUuid)
1677
+ .then((images) => {
1678
+ const refs = [];
1679
+ if (this.imageStore) {
1680
+ for (const img of images) {
1681
+ const ref = this.imageStore.registerFromBase64(img.base64, img.mimeType);
1682
+ if (ref)
1683
+ refs.push(ref);
1684
+ }
1685
+ }
1686
+ this.send(ws, {
1687
+ type: "message_images_result",
1688
+ messageUuid: msg.messageUuid,
1689
+ images: refs,
1690
+ });
1691
+ })
1692
+ .catch((err) => {
1693
+ console.error("[ws] Failed to extract message images:", err);
1694
+ this.send(ws, {
1695
+ type: "message_images_result",
1696
+ messageUuid: msg.messageUuid,
1697
+ images: [],
1698
+ });
1699
+ });
1700
+ break;
1701
+ }
1702
+ case "interrupt": {
1703
+ const session = this.resolveSession(msg.sessionId);
1704
+ if (!session) {
1705
+ this.send(ws, { type: "error", message: "No active session." });
1706
+ return;
1707
+ }
1708
+ session.process.interrupt();
1709
+ break;
1710
+ }
1711
+ case "list_project_history": {
1712
+ const projects = this.projectHistory?.getProjects() ?? [];
1713
+ this.send(ws, { type: "project_history", projects });
1714
+ break;
1715
+ }
1716
+ case "remove_project_history": {
1717
+ this.projectHistory?.removeProject(msg.projectPath);
1718
+ const projects = this.projectHistory?.getProjects() ?? [];
1719
+ this.send(ws, { type: "project_history", projects });
1720
+ break;
1721
+ }
1722
+ case "read_file": {
1723
+ const absPath = resolve(msg.projectPath, msg.filePath);
1724
+ if (!this.isPathAllowed(absPath)) {
1725
+ this.send(ws, {
1726
+ type: "file_content",
1727
+ filePath: msg.filePath,
1728
+ content: "",
1729
+ error: "Path not allowed",
1730
+ });
1731
+ break;
1732
+ }
1733
+ void (async () => {
1734
+ try {
1735
+ if (!existsSync(absPath)) {
1736
+ this.send(ws, {
1737
+ type: "file_content",
1738
+ filePath: msg.filePath,
1739
+ content: "",
1740
+ error: "File not found",
1741
+ });
1742
+ return;
1743
+ }
1744
+ const maxLines = typeof msg.maxLines === "number" && msg.maxLines > 0
1745
+ ? msg.maxLines
1746
+ : 5000;
1747
+ const raw = await readFile(absPath, "utf-8");
1748
+ const ext = extname(absPath).replace(/^\./, "").toLowerCase();
1749
+ const languageMap = {
1750
+ ts: "typescript",
1751
+ tsx: "typescript",
1752
+ js: "javascript",
1753
+ jsx: "javascript",
1754
+ py: "python",
1755
+ rb: "ruby",
1756
+ rs: "rust",
1757
+ go: "go",
1758
+ java: "java",
1759
+ kt: "kotlin",
1760
+ swift: "swift",
1761
+ dart: "dart",
1762
+ c: "c",
1763
+ cpp: "cpp",
1764
+ h: "c",
1765
+ hpp: "cpp",
1766
+ cs: "csharp",
1767
+ sh: "bash",
1768
+ zsh: "bash",
1769
+ yml: "yaml",
1770
+ yaml: "yaml",
1771
+ json: "json",
1772
+ toml: "toml",
1773
+ md: "markdown",
1774
+ html: "html",
1775
+ css: "css",
1776
+ scss: "css",
1777
+ sql: "sql",
1778
+ xml: "xml",
1779
+ dockerfile: "dockerfile",
1780
+ makefile: "makefile",
1781
+ gradle: "groovy",
1782
+ };
1783
+ const language = languageMap[ext] ?? (ext || undefined);
1784
+ const lines = raw.split("\n");
1785
+ const truncated = lines.length > maxLines;
1786
+ const content = truncated
1787
+ ? lines.slice(0, maxLines).join("\n")
1788
+ : raw;
1789
+ this.send(ws, {
1790
+ type: "file_content",
1791
+ filePath: msg.filePath,
1792
+ content,
1793
+ language,
1794
+ totalLines: lines.length,
1795
+ truncated,
1796
+ });
1797
+ }
1798
+ catch (err) {
1799
+ this.send(ws, {
1800
+ type: "file_content",
1801
+ filePath: msg.filePath,
1802
+ content: "",
1803
+ error: `Failed to read file: ${err}`,
1804
+ });
1805
+ }
1806
+ })();
1807
+ break;
1808
+ }
1809
+ case "list_files": {
1810
+ if (!this.isPathAllowed(msg.projectPath)) {
1811
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
1812
+ break;
1813
+ }
1814
+ execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { cwd: msg.projectPath, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
1815
+ if (err) {
1816
+ if (/not a git repository/i.test(err.message)) {
1817
+ // Non-git project: silently return empty list (file listing is auxiliary)
1818
+ this.send(ws, { type: "file_list", files: [] });
1819
+ }
1820
+ else {
1821
+ this.send(ws, {
1822
+ type: "error",
1823
+ message: `Failed to list files: ${err.message}`,
1824
+ });
1825
+ }
1826
+ return;
1827
+ }
1828
+ const files = stdout.trim().split("\n").filter(Boolean);
1829
+ this.send(ws, { type: "file_list", files });
1830
+ });
1831
+ break;
1832
+ }
1833
+ case "list_recordings": {
1834
+ if (!this.recordingStore) {
1835
+ this.send(ws, { type: "recording_list", recordings: [] });
1836
+ break;
1837
+ }
1838
+ const store = this.recordingStore;
1839
+ void store.listRecordings().then(async (recordings) => {
1840
+ // First pass: extract info from JSONL for recordings missing firstPrompt
1841
+ // This covers both meta-less legacy recordings and new ones where sessions-index hasn't indexed yet
1842
+ await Promise.all(recordings.map(async (rec) => {
1843
+ const info = await store.extractInfoFromJsonl(rec.name);
1844
+ if (info.firstPrompt && !rec.firstPrompt)
1845
+ rec.firstPrompt = info.firstPrompt;
1846
+ if (info.lastPrompt && !rec.lastPrompt)
1847
+ rec.lastPrompt = info.lastPrompt;
1848
+ // Backfill meta for legacy recordings
1849
+ if (!rec.meta && (info.claudeSessionId || info.projectPath)) {
1850
+ rec.meta = {
1851
+ bridgeSessionId: rec.name,
1852
+ claudeSessionId: info.claudeSessionId,
1853
+ projectPath: info.projectPath ?? "",
1854
+ createdAt: rec.modified,
1855
+ };
1856
+ }
1857
+ }));
1858
+ // Second pass: look up sessions-index for summaries (if claudeSessionIds available)
1859
+ const claudeIds = new Set();
1860
+ const idToIdx = new Map();
1861
+ for (let i = 0; i < recordings.length; i++) {
1862
+ const cid = recordings[i].meta?.claudeSessionId;
1863
+ if (cid) {
1864
+ claudeIds.add(cid);
1865
+ const arr = idToIdx.get(cid) ?? [];
1866
+ arr.push(i);
1867
+ idToIdx.set(cid, arr);
1868
+ }
1869
+ }
1870
+ if (claudeIds.size > 0) {
1871
+ const sessionInfo = await findSessionsByClaudeIds(claudeIds);
1872
+ for (const [cid, info] of sessionInfo) {
1873
+ const indices = idToIdx.get(cid) ?? [];
1874
+ for (const idx of indices) {
1875
+ if (info.summary)
1876
+ recordings[idx].summary = info.summary;
1877
+ if (info.firstPrompt)
1878
+ recordings[idx].firstPrompt = info.firstPrompt;
1879
+ if (info.lastPrompt)
1880
+ recordings[idx].lastPrompt = info.lastPrompt;
1881
+ }
1882
+ }
1883
+ }
1884
+ this.send(ws, { type: "recording_list", recordings });
1885
+ });
1886
+ break;
1887
+ }
1888
+ case "get_recording": {
1889
+ if (!this.recordingStore) {
1890
+ this.send(ws, {
1891
+ type: "error",
1892
+ message: "Recording is not enabled on this server",
1893
+ });
1894
+ break;
1895
+ }
1896
+ void this.recordingStore
1897
+ .getRecordingContent(msg.sessionId)
1898
+ .then((content) => {
1899
+ if (content !== null) {
1900
+ this.send(ws, {
1901
+ type: "recording_content",
1902
+ sessionId: msg.sessionId,
1903
+ content,
1904
+ });
1905
+ }
1906
+ else {
1907
+ this.send(ws, {
1908
+ type: "error",
1909
+ message: `Recording ${msg.sessionId} not found`,
1910
+ });
1911
+ }
1912
+ });
1913
+ break;
1914
+ }
1915
+ case "get_diff": {
1916
+ if (!this.isPathAllowed(msg.projectPath)) {
1917
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
1918
+ break;
1919
+ }
1920
+ this.collectGitDiff(msg.projectPath, ({ diff, error }) => {
1921
+ if (error) {
1922
+ if (/not a git repository/i.test(error)) {
1923
+ this.send(ws, {
1924
+ type: "diff_result",
1925
+ diff: "",
1926
+ error: "This project is not a git repository",
1927
+ errorCode: "git_not_available",
1928
+ });
1929
+ }
1930
+ else {
1931
+ this.send(ws, {
1932
+ type: "diff_result",
1933
+ diff: "",
1934
+ error: `Failed to get diff: ${error}`,
1935
+ });
1936
+ }
1937
+ return;
1938
+ }
1939
+ void this.collectImageChanges(msg.projectPath, diff).then((imageChanges) => {
1940
+ if (imageChanges.length > 0) {
1941
+ this.send(ws, { type: "diff_result", diff, imageChanges });
1942
+ }
1943
+ else {
1944
+ this.send(ws, { type: "diff_result", diff });
1945
+ }
1946
+ });
1947
+ }, msg.staged === true
1948
+ ? { staged: true }
1949
+ : msg.staged === false
1950
+ ? { unstaged: true }
1951
+ : undefined);
1952
+ break;
1953
+ }
1954
+ case "get_diff_image": {
1955
+ if (!this.isPathAllowed(msg.projectPath) ||
1956
+ !this.isPathAllowed(resolve(msg.projectPath, msg.filePath))) {
1957
+ this.send(ws, { type: "error", message: `Path not allowed` });
1958
+ break;
1959
+ }
1960
+ if (msg.version === "both") {
1961
+ void (async () => {
1962
+ try {
1963
+ const [oldResult, newResult] = await Promise.all([
1964
+ this.loadDiffImageAsync(msg.projectPath, msg.filePath, "old"),
1965
+ this.loadDiffImageAsync(msg.projectPath, msg.filePath, "new"),
1966
+ ]);
1967
+ const errors = [oldResult.error, newResult.error].filter(Boolean);
1968
+ this.send(ws, {
1969
+ type: "diff_image_result",
1970
+ filePath: msg.filePath,
1971
+ version: "both",
1972
+ oldBase64: oldResult.base64,
1973
+ newBase64: newResult.base64,
1974
+ mimeType: oldResult.mimeType ?? newResult.mimeType,
1975
+ ...(errors.length > 0 ? { error: errors.join("; ") } : {}),
1976
+ });
1977
+ }
1978
+ catch {
1979
+ // WebSocket may have closed; ignore send errors.
1980
+ }
1981
+ })();
1982
+ }
1983
+ else {
1984
+ const version = msg.version;
1985
+ void (async () => {
1986
+ try {
1987
+ const result = await this.loadDiffImageAsync(msg.projectPath, msg.filePath, version);
1988
+ this.send(ws, {
1989
+ type: "diff_image_result",
1990
+ filePath: msg.filePath,
1991
+ version,
1992
+ ...result,
1993
+ });
1994
+ }
1995
+ catch {
1996
+ // WebSocket may have closed; ignore send errors.
1997
+ }
1998
+ })();
1999
+ }
2000
+ break;
2001
+ }
2002
+ case "list_worktrees": {
2003
+ if (!this.isPathAllowed(msg.projectPath)) {
2004
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2005
+ break;
2006
+ }
2007
+ try {
2008
+ const worktrees = listWorktrees(msg.projectPath);
2009
+ const mainBranch = getMainBranch(msg.projectPath);
2010
+ this.send(ws, { type: "worktree_list", worktrees, mainBranch });
2011
+ }
2012
+ catch (err) {
2013
+ this.send(ws, {
2014
+ type: "error",
2015
+ message: `Failed to list worktrees: ${err}`,
2016
+ });
2017
+ }
2018
+ break;
2019
+ }
2020
+ case "remove_worktree": {
2021
+ if (!this.isPathAllowed(msg.projectPath)) {
2022
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2023
+ break;
2024
+ }
2025
+ try {
2026
+ removeWorktree(msg.projectPath, msg.worktreePath);
2027
+ this.worktreeStore.deleteByWorktreePath(msg.worktreePath);
2028
+ this.send(ws, {
2029
+ type: "worktree_removed",
2030
+ worktreePath: msg.worktreePath,
2031
+ });
2032
+ }
2033
+ catch (err) {
2034
+ this.send(ws, {
2035
+ type: "error",
2036
+ message: `Failed to remove worktree: ${err}`,
2037
+ });
2038
+ }
2039
+ break;
2040
+ }
2041
+ // ---- Git Operations (Phase 1-3) ----
2042
+ case "git_stage": {
2043
+ if (!this.isPathAllowed(msg.projectPath)) {
2044
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2045
+ break;
2046
+ }
2047
+ try {
2048
+ if (msg.files?.length)
2049
+ stageFiles(msg.projectPath, msg.files);
2050
+ if (msg.hunks?.length)
2051
+ stageHunks(msg.projectPath, msg.hunks);
2052
+ this.send(ws, { type: "git_stage_result", success: true });
2053
+ }
2054
+ catch (err) {
2055
+ this.send(ws, {
2056
+ type: "git_stage_result",
2057
+ success: false,
2058
+ error: String(err),
2059
+ });
2060
+ }
2061
+ break;
2062
+ }
2063
+ case "git_unstage": {
2064
+ if (!this.isPathAllowed(msg.projectPath)) {
2065
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2066
+ break;
2067
+ }
2068
+ try {
2069
+ unstageFiles(msg.projectPath, msg.files ?? []);
2070
+ this.send(ws, { type: "git_unstage_result", success: true });
2071
+ }
2072
+ catch (err) {
2073
+ this.send(ws, {
2074
+ type: "git_unstage_result",
2075
+ success: false,
2076
+ error: String(err),
2077
+ });
2078
+ }
2079
+ break;
2080
+ }
2081
+ case "git_unstage_hunks": {
2082
+ if (!this.isPathAllowed(msg.projectPath)) {
2083
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2084
+ break;
2085
+ }
2086
+ try {
2087
+ unstageHunks(msg.projectPath, msg.hunks);
2088
+ this.send(ws, { type: "git_unstage_hunks_result", success: true });
2089
+ }
2090
+ catch (err) {
2091
+ this.send(ws, {
2092
+ type: "git_unstage_hunks_result",
2093
+ success: false,
2094
+ error: String(err),
2095
+ });
2096
+ }
2097
+ break;
2098
+ }
2099
+ case "git_commit": {
2100
+ if (!this.isPathAllowed(msg.projectPath)) {
2101
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2102
+ break;
2103
+ }
2104
+ const session = msg.sessionId
2105
+ ? this.sessionManager.get(msg.sessionId)
2106
+ : undefined;
2107
+ try {
2108
+ const message = msg.autoGenerate === true
2109
+ ? (() => {
2110
+ if (!msg.sessionId) {
2111
+ throw new Error("git_commit with autoGenerate=true requires sessionId");
2112
+ }
2113
+ if (!session) {
2114
+ throw new Error(`Session ${msg.sessionId} not found`);
2115
+ }
2116
+ const expectedPath = resolve(session.worktreePath ?? session.projectPath);
2117
+ const requestedPath = resolve(msg.projectPath);
2118
+ if (requestedPath !== expectedPath) {
2119
+ throw new Error("git_commit projectPath must match the active session cwd");
2120
+ }
2121
+ return generateCommitMessage({
2122
+ provider: session.provider,
2123
+ projectPath: msg.projectPath,
2124
+ model: session.provider === "claude"
2125
+ ? session.process instanceof SdkProcess
2126
+ ? session.process.model
2127
+ : undefined
2128
+ : session.codexSettings?.model,
2129
+ });
2130
+ })()
2131
+ : msg.message ?? "";
2132
+ const result = gitCommit(msg.projectPath, message);
2133
+ this.send(ws, {
2134
+ type: "git_commit_result",
2135
+ success: true,
2136
+ commitHash: result.hash,
2137
+ message: result.message,
2138
+ });
2139
+ }
2140
+ catch (err) {
2141
+ this.send(ws, {
2142
+ type: "git_commit_result",
2143
+ success: false,
2144
+ error: err instanceof Error ? err.message : String(err),
2145
+ });
2146
+ }
2147
+ break;
2148
+ }
2149
+ case "git_push": {
2150
+ if (!this.isPathAllowed(msg.projectPath)) {
2151
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2152
+ break;
2153
+ }
2154
+ try {
2155
+ gitPush(msg.projectPath);
2156
+ this.send(ws, {
2157
+ type: "git_push_result",
2158
+ success: true,
2159
+ });
2160
+ }
2161
+ catch (err) {
2162
+ this.send(ws, {
2163
+ type: "git_push_result",
2164
+ success: false,
2165
+ error: String(err),
2166
+ });
2167
+ }
2168
+ break;
2169
+ }
2170
+ case "git_branches": {
2171
+ if (!this.isPathAllowed(msg.projectPath)) {
2172
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2173
+ break;
2174
+ }
2175
+ try {
2176
+ const result = listBranches(msg.projectPath);
2177
+ this.send(ws, {
2178
+ type: "git_branches_result",
2179
+ current: result.current,
2180
+ branches: result.branches,
2181
+ checkedOutBranches: result.checkedOutBranches,
2182
+ remoteStatusByBranch: result.remoteStatusByBranch,
2183
+ });
2184
+ }
2185
+ catch (err) {
2186
+ this.send(ws, {
2187
+ type: "git_branches_result",
2188
+ current: "",
2189
+ branches: [],
2190
+ remoteStatusByBranch: {},
2191
+ error: String(err),
2192
+ });
2193
+ }
2194
+ break;
2195
+ }
2196
+ case "git_create_branch": {
2197
+ if (!this.isPathAllowed(msg.projectPath)) {
2198
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2199
+ break;
2200
+ }
2201
+ try {
2202
+ createBranch(msg.projectPath, msg.name, msg.checkout);
2203
+ this.send(ws, { type: "git_create_branch_result", success: true });
2204
+ }
2205
+ catch (err) {
2206
+ this.send(ws, {
2207
+ type: "git_create_branch_result",
2208
+ success: false,
2209
+ error: String(err),
2210
+ });
2211
+ }
2212
+ break;
2213
+ }
2214
+ case "git_checkout_branch": {
2215
+ if (!this.isPathAllowed(msg.projectPath)) {
2216
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2217
+ break;
2218
+ }
2219
+ try {
2220
+ checkoutBranch(msg.projectPath, msg.branch);
2221
+ this.send(ws, { type: "git_checkout_branch_result", success: true });
2222
+ }
2223
+ catch (err) {
2224
+ this.send(ws, {
2225
+ type: "git_checkout_branch_result",
2226
+ success: false,
2227
+ error: String(err),
2228
+ });
2229
+ }
2230
+ break;
2231
+ }
2232
+ case "git_revert_file": {
2233
+ if (!this.isPathAllowed(msg.projectPath)) {
2234
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2235
+ break;
2236
+ }
2237
+ try {
2238
+ revertFiles(msg.projectPath, msg.files);
2239
+ this.send(ws, { type: "git_revert_file_result", success: true });
2240
+ }
2241
+ catch (err) {
2242
+ this.send(ws, {
2243
+ type: "git_revert_file_result",
2244
+ success: false,
2245
+ error: String(err),
2246
+ });
2247
+ }
2248
+ break;
2249
+ }
2250
+ case "git_revert_hunks": {
2251
+ if (!this.isPathAllowed(msg.projectPath)) {
2252
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2253
+ break;
2254
+ }
2255
+ try {
2256
+ revertHunks(msg.projectPath, msg.hunks);
2257
+ this.send(ws, { type: "git_revert_hunks_result", success: true });
2258
+ }
2259
+ catch (err) {
2260
+ this.send(ws, {
2261
+ type: "git_revert_hunks_result",
2262
+ success: false,
2263
+ error: String(err),
2264
+ });
2265
+ }
2266
+ break;
2267
+ }
2268
+ case "git_fetch": {
2269
+ if (!this.isPathAllowed(msg.projectPath)) {
2270
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2271
+ break;
2272
+ }
2273
+ try {
2274
+ gitFetch(msg.projectPath);
2275
+ this.send(ws, { type: "git_fetch_result", success: true });
2276
+ }
2277
+ catch (err) {
2278
+ this.send(ws, {
2279
+ type: "git_fetch_result",
2280
+ success: false,
2281
+ error: String(err),
2282
+ });
2283
+ }
2284
+ break;
2285
+ }
2286
+ case "git_pull": {
2287
+ if (!this.isPathAllowed(msg.projectPath)) {
2288
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2289
+ break;
2290
+ }
2291
+ try {
2292
+ const result = gitPull(msg.projectPath);
2293
+ if (result.success) {
2294
+ this.send(ws, {
2295
+ type: "git_pull_result",
2296
+ success: true,
2297
+ message: result.message,
2298
+ });
2299
+ }
2300
+ else {
2301
+ this.send(ws, {
2302
+ type: "git_pull_result",
2303
+ success: false,
2304
+ error: result.message,
2305
+ });
2306
+ }
2307
+ }
2308
+ catch (err) {
2309
+ this.send(ws, {
2310
+ type: "git_pull_result",
2311
+ success: false,
2312
+ error: String(err),
2313
+ });
2314
+ }
2315
+ break;
2316
+ }
2317
+ case "git_remote_status": {
2318
+ if (!this.isPathAllowed(msg.projectPath)) {
2319
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2320
+ break;
2321
+ }
2322
+ try {
2323
+ const result = gitRemoteStatus(msg.projectPath);
2324
+ this.send(ws, {
2325
+ type: "git_remote_status_result",
2326
+ ahead: result.ahead,
2327
+ behind: result.behind,
2328
+ branch: result.branch,
2329
+ hasUpstream: result.hasUpstream,
2330
+ });
2331
+ }
2332
+ catch (err) {
2333
+ this.send(ws, {
2334
+ type: "git_remote_status_result",
2335
+ ahead: 0,
2336
+ behind: 0,
2337
+ branch: "",
2338
+ hasUpstream: false,
2339
+ });
2340
+ }
2341
+ break;
2342
+ }
2343
+ case "rewind_dry_run": {
2344
+ const session = this.sessionManager.get(msg.sessionId);
2345
+ if (!session) {
2346
+ this.send(ws, {
2347
+ type: "rewind_preview",
2348
+ canRewind: false,
2349
+ error: `Session ${msg.sessionId} not found`,
2350
+ });
2351
+ return;
2352
+ }
2353
+ this.sessionManager
2354
+ .rewindFiles(msg.sessionId, msg.targetUuid, true)
2355
+ .then((result) => {
2356
+ this.send(ws, {
2357
+ type: "rewind_preview",
2358
+ canRewind: result.canRewind,
2359
+ filesChanged: result.filesChanged,
2360
+ insertions: result.insertions,
2361
+ deletions: result.deletions,
2362
+ error: result.error,
2363
+ });
2364
+ })
2365
+ .catch((err) => {
2366
+ this.send(ws, {
2367
+ type: "rewind_preview",
2368
+ canRewind: false,
2369
+ error: `Dry run failed: ${err}`,
2370
+ });
2371
+ });
2372
+ break;
2373
+ }
2374
+ case "rewind": {
2375
+ const session = this.sessionManager.get(msg.sessionId);
2376
+ if (!session) {
2377
+ this.send(ws, {
2378
+ type: "rewind_result",
2379
+ success: false,
2380
+ mode: msg.mode,
2381
+ error: `Session ${msg.sessionId} not found`,
2382
+ });
2383
+ return;
2384
+ }
2385
+ const handleError = (err) => {
2386
+ const errMsg = err instanceof Error ? err.message : String(err);
2387
+ this.send(ws, {
2388
+ type: "rewind_result",
2389
+ success: false,
2390
+ mode: msg.mode,
2391
+ error: errMsg,
2392
+ });
2393
+ };
2394
+ if (msg.mode === "code") {
2395
+ // Code-only rewind: rewind files without restarting the conversation
2396
+ this.sessionManager
2397
+ .rewindFiles(msg.sessionId, msg.targetUuid)
2398
+ .then((result) => {
2399
+ if (result.canRewind) {
2400
+ this.send(ws, {
2401
+ type: "rewind_result",
2402
+ success: true,
2403
+ mode: "code",
2404
+ });
2405
+ }
2406
+ else {
2407
+ this.send(ws, {
2408
+ type: "rewind_result",
2409
+ success: false,
2410
+ mode: "code",
2411
+ error: result.error ?? "Cannot rewind files",
2412
+ });
2413
+ }
2414
+ })
2415
+ .catch(handleError);
2416
+ }
2417
+ else if (msg.mode === "conversation") {
2418
+ // Conversation-only rewind: restart session at the target UUID
2419
+ try {
2420
+ this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
2421
+ this.send(ws, {
2422
+ type: "rewind_result",
2423
+ success: true,
2424
+ mode: "conversation",
2425
+ });
2426
+ // Notify the new session ID
2427
+ const newSession = this.sessionManager.get(newSessionId);
2428
+ const rewindPermMode = newSession?.process instanceof SdkProcess
2429
+ ? newSession.process.permissionMode
2430
+ : undefined;
2431
+ this.send(ws, this.buildSessionCreatedMessage({
2432
+ sessionId: newSessionId,
2433
+ provider: newSession?.provider ?? "claude",
2434
+ projectPath: newSession?.projectPath ?? "",
2435
+ session: newSession,
2436
+ permissionMode: rewindPermMode,
2437
+ sourceSessionId: msg.sessionId,
2438
+ }));
2439
+ this.sendSessionList(ws);
2440
+ });
2441
+ }
2442
+ catch (err) {
2443
+ handleError(err);
2444
+ }
2445
+ }
2446
+ else {
2447
+ // Both: rewind files first, then rewind conversation
2448
+ this.sessionManager
2449
+ .rewindFiles(msg.sessionId, msg.targetUuid)
2450
+ .then((result) => {
2451
+ if (!result.canRewind) {
2452
+ this.send(ws, {
2453
+ type: "rewind_result",
2454
+ success: false,
2455
+ mode: "both",
2456
+ error: result.error ?? "Cannot rewind files",
2457
+ });
2458
+ return;
2459
+ }
2460
+ try {
2461
+ this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
2462
+ this.send(ws, {
2463
+ type: "rewind_result",
2464
+ success: true,
2465
+ mode: "both",
2466
+ });
2467
+ const newSession = this.sessionManager.get(newSessionId);
2468
+ const rewindPermMode2 = newSession?.process instanceof SdkProcess
2469
+ ? newSession.process.permissionMode
2470
+ : undefined;
2471
+ this.send(ws, this.buildSessionCreatedMessage({
2472
+ sessionId: newSessionId,
2473
+ provider: newSession?.provider ?? "claude",
2474
+ projectPath: newSession?.projectPath ?? "",
2475
+ session: newSession,
2476
+ permissionMode: rewindPermMode2,
2477
+ sourceSessionId: msg.sessionId,
2478
+ }));
2479
+ this.sendSessionList(ws);
2480
+ });
2481
+ }
2482
+ catch (err) {
2483
+ handleError(err);
2484
+ }
2485
+ })
2486
+ .catch(handleError);
2487
+ }
2488
+ break;
2489
+ }
2490
+ case "list_windows": {
2491
+ listWindows()
2492
+ .then((windows) => {
2493
+ this.send(ws, { type: "window_list", windows });
2494
+ })
2495
+ .catch((err) => {
2496
+ this.send(ws, {
2497
+ type: "error",
2498
+ message: `Failed to list windows: ${err instanceof Error ? err.message : String(err)}`,
2499
+ });
2500
+ });
2501
+ break;
2502
+ }
2503
+ case "take_screenshot": {
2504
+ // For window mode, verify the window ID is still valid.
2505
+ // The user may have fetched the window list minutes ago and the
2506
+ // window could have been closed since then.
2507
+ const doCapture = async () => {
2508
+ if (msg.mode !== "window" || msg.windowId == null) {
2509
+ return { mode: msg.mode };
2510
+ }
2511
+ const current = await listWindows();
2512
+ if (current.some((w) => w.windowId === msg.windowId)) {
2513
+ return { mode: "window", windowId: msg.windowId };
2514
+ }
2515
+ // Window ID is stale — fall back to fullscreen and notify
2516
+ console.warn(`[screenshot] Window ID ${msg.windowId} no longer exists, falling back to fullscreen`);
2517
+ return { mode: "fullscreen" };
2518
+ };
2519
+ doCapture()
2520
+ .then((opts) => takeScreenshot(opts))
2521
+ .then(async (result) => {
2522
+ try {
2523
+ if (this.galleryStore) {
2524
+ const meta = await this.galleryStore.addImage(result.filePath, msg.projectPath, msg.sessionId);
2525
+ if (meta) {
2526
+ const info = this.galleryStore.metaToInfo(meta);
2527
+ this.send(ws, {
2528
+ type: "screenshot_result",
2529
+ success: true,
2530
+ image: info,
2531
+ });
2532
+ this.broadcast({ type: "gallery_new_image", image: info });
2533
+ return;
2534
+ }
2535
+ }
2536
+ this.send(ws, {
2537
+ type: "screenshot_result",
2538
+ success: false,
2539
+ error: "Failed to save screenshot to gallery",
2540
+ });
2541
+ }
2542
+ finally {
2543
+ // Always clean up temp file
2544
+ unlink(result.filePath).catch(() => { });
2545
+ }
2546
+ })
2547
+ .catch((err) => {
2548
+ this.send(ws, {
2549
+ type: "screenshot_result",
2550
+ success: false,
2551
+ error: err instanceof Error ? err.message : String(err),
2552
+ });
2553
+ });
2554
+ break;
2555
+ }
2556
+ case "backup_prompt_history": {
2557
+ if (!this.promptHistoryBackup) {
2558
+ this.send(ws, {
2559
+ type: "prompt_history_backup_result",
2560
+ success: false,
2561
+ error: "Backup store not available",
2562
+ });
2563
+ break;
2564
+ }
2565
+ const buf = Buffer.from(msg.data, "base64");
2566
+ this.promptHistoryBackup
2567
+ .save(buf, msg.appVersion, msg.dbVersion)
2568
+ .then((meta) => {
2569
+ this.send(ws, {
2570
+ type: "prompt_history_backup_result",
2571
+ success: true,
2572
+ backedUpAt: meta.backedUpAt,
2573
+ });
2574
+ })
2575
+ .catch((err) => {
2576
+ this.send(ws, {
2577
+ type: "prompt_history_backup_result",
2578
+ success: false,
2579
+ error: err instanceof Error ? err.message : String(err),
2580
+ });
2581
+ });
2582
+ break;
2583
+ }
2584
+ case "restore_prompt_history": {
2585
+ if (!this.promptHistoryBackup) {
2586
+ this.send(ws, {
2587
+ type: "prompt_history_restore_result",
2588
+ success: false,
2589
+ error: "Backup store not available",
2590
+ });
2591
+ break;
2592
+ }
2593
+ this.promptHistoryBackup
2594
+ .load()
2595
+ .then((result) => {
2596
+ if (result) {
2597
+ this.send(ws, {
2598
+ type: "prompt_history_restore_result",
2599
+ success: true,
2600
+ data: result.data.toString("base64"),
2601
+ appVersion: result.meta.appVersion,
2602
+ dbVersion: result.meta.dbVersion,
2603
+ backedUpAt: result.meta.backedUpAt,
2604
+ });
2605
+ }
2606
+ else {
2607
+ this.send(ws, {
2608
+ type: "prompt_history_restore_result",
2609
+ success: false,
2610
+ error: "No backup found",
2611
+ });
2612
+ }
2613
+ })
2614
+ .catch((err) => {
2615
+ this.send(ws, {
2616
+ type: "prompt_history_restore_result",
2617
+ success: false,
2618
+ error: err instanceof Error ? err.message : String(err),
2619
+ });
2620
+ });
2621
+ break;
2622
+ }
2623
+ case "get_prompt_history_backup_info": {
2624
+ if (!this.promptHistoryBackup) {
2625
+ this.send(ws, { type: "prompt_history_backup_info", exists: false });
2626
+ break;
2627
+ }
2628
+ this.promptHistoryBackup
2629
+ .getMeta()
2630
+ .then((meta) => {
2631
+ if (meta) {
2632
+ this.send(ws, {
2633
+ type: "prompt_history_backup_info",
2634
+ exists: true,
2635
+ ...meta,
2636
+ });
2637
+ }
2638
+ else {
2639
+ this.send(ws, {
2640
+ type: "prompt_history_backup_info",
2641
+ exists: false,
2642
+ });
2643
+ }
2644
+ })
2645
+ .catch(() => {
2646
+ this.send(ws, {
2647
+ type: "prompt_history_backup_info",
2648
+ exists: false,
2649
+ });
2650
+ });
2651
+ break;
2652
+ }
2653
+ case "rename_session": {
2654
+ const name = msg.name || null;
2655
+ await this.handleRenameSession(ws, msg.sessionId, name, msg);
2656
+ break;
2657
+ }
2658
+ }
2659
+ }
2660
+ /**
2661
+ * Load the saved session name from CLI storage and set it on the SessionInfo.
2662
+ * Called after SessionManager.create() so that session_created carries the name.
2663
+ */
2664
+ async loadAndSetSessionName(session, provider, projectPath, cliSessionId) {
2665
+ if (!session || !cliSessionId)
2666
+ return;
2667
+ try {
2668
+ if (provider === "claude") {
2669
+ const name = await getClaudeSessionName(projectPath, cliSessionId);
2670
+ if (name)
2671
+ session.name = name;
2672
+ }
2673
+ else if (provider === "codex") {
2674
+ const names = await loadCodexSessionNames();
2675
+ const name = names.get(cliSessionId);
2676
+ if (name)
2677
+ session.name = name;
2678
+ }
2679
+ }
2680
+ catch {
2681
+ // Non-critical: session works without name
2682
+ }
2683
+ }
2684
+ /**
2685
+ * Handle rename_session: update in-memory name and persist to CLI storage.
2686
+ *
2687
+ * Supports both running sessions (by bridge session id) and recent sessions
2688
+ * (by provider session id, i.e. claudeSessionId or codex threadId).
2689
+ */
2690
+ async handleRenameSession(ws, sessionId, name, msg) {
2691
+ // 1. Try running session first
2692
+ const runningSession = this.sessionManager.get(sessionId);
2693
+ if (runningSession) {
2694
+ this.sessionManager.renameSession(sessionId, name);
2695
+ // Persist to provider storage
2696
+ if (runningSession.provider === "claude" &&
2697
+ runningSession.claudeSessionId) {
2698
+ await renameClaudeSession(runningSession.worktreePath ?? runningSession.projectPath, runningSession.claudeSessionId, name);
2699
+ }
2700
+ else if (runningSession.provider === "codex" &&
2701
+ runningSession.process) {
2702
+ try {
2703
+ await runningSession.process.renameThread(name ?? "");
2704
+ }
2705
+ catch (err) {
2706
+ console.warn(`[websocket] Failed to rename Codex thread:`, err);
2707
+ }
2708
+ }
2709
+ this.broadcastSessionList();
2710
+ this.send(ws, { type: "rename_result", sessionId, name, success: true });
2711
+ return;
2712
+ }
2713
+ // 2. Recent session (not running) — use provider + providerSessionId + projectPath from message
2714
+ const renameMsg = msg;
2715
+ const provider = renameMsg.provider;
2716
+ const providerSessionId = renameMsg.providerSessionId;
2717
+ const projectPath = renameMsg.projectPath;
2718
+ if (provider === "claude" && providerSessionId && projectPath) {
2719
+ const success = await renameClaudeSession(projectPath, providerSessionId, name);
2720
+ this.send(ws, { type: "rename_result", sessionId, name, success });
2721
+ return;
2722
+ }
2723
+ // For Codex recent sessions, write directly to session_index.jsonl.
2724
+ if (provider === "codex" && providerSessionId) {
2725
+ const success = await renameCodexSession(providerSessionId, name);
2726
+ this.send(ws, { type: "rename_result", sessionId, name, success });
2727
+ return;
2728
+ }
2729
+ this.send(ws, { type: "rename_result", sessionId, name, success: false });
2730
+ }
2731
+ resolveSession(sessionId) {
2732
+ if (sessionId)
2733
+ return this.sessionManager.get(sessionId);
2734
+ return this.getFirstSession();
2735
+ }
2736
+ getFirstSession() {
2737
+ const sessions = this.sessionManager.list();
2738
+ if (sessions.length === 0)
2739
+ return undefined;
2740
+ return this.sessionManager.get(sessions[sessions.length - 1].id);
2741
+ }
2742
+ sendSessionList(ws) {
2743
+ this.pruneDebugEvents();
2744
+ const sessions = this.sessionManager.list();
2745
+ this.send(ws, {
2746
+ type: "session_list",
2747
+ sessions,
2748
+ allowedDirs: this.allowedDirs,
2749
+ claudeModels: CLAUDE_MODELS,
2750
+ codexModels: CODEX_MODELS,
2751
+ bridgeVersion: getPackageVersion(),
2752
+ });
2753
+ }
2754
+ /** Broadcast session list to all connected clients. */
2755
+ broadcastSessionList() {
2756
+ this.pruneDebugEvents();
2757
+ const sessions = this.sessionManager.list();
2758
+ this.broadcast({
2759
+ type: "session_list",
2760
+ sessions,
2761
+ allowedDirs: this.allowedDirs,
2762
+ claudeModels: CLAUDE_MODELS,
2763
+ codexModels: CODEX_MODELS,
2764
+ bridgeVersion: getPackageVersion(),
2765
+ });
2766
+ }
2767
+ broadcastSessionMessage(sessionId, msg) {
2768
+ this.maybeSendPushNotification(sessionId, msg);
2769
+ this.recordDebugEvent(sessionId, {
2770
+ direction: "outgoing",
2771
+ channel: "session",
2772
+ type: msg.type,
2773
+ detail: this.summarizeServerMessage(msg),
2774
+ });
2775
+ this.recordingStore?.record(sessionId, "outgoing", msg);
2776
+ // Update recording meta with claudeSessionId when it becomes available
2777
+ if ((msg.type === "system" || msg.type === "result") &&
2778
+ "sessionId" in msg &&
2779
+ msg.sessionId) {
2780
+ const session = this.sessionManager.get(sessionId);
2781
+ if (session) {
2782
+ this.recordingStore?.saveMeta(sessionId, {
2783
+ bridgeSessionId: sessionId,
2784
+ claudeSessionId: msg.sessionId,
2785
+ projectPath: session.projectPath,
2786
+ createdAt: session.createdAt.toISOString(),
2787
+ });
2788
+ }
2789
+ }
2790
+ // Wrap the message with sessionId
2791
+ const data = JSON.stringify({ ...msg, sessionId });
2792
+ for (const client of this.wss.clients) {
2793
+ if (client.readyState === WebSocket.OPEN) {
2794
+ client.send(data);
2795
+ }
2796
+ }
2797
+ }
2798
+ async listRecentSessions(msg) {
2799
+ if (msg.provider === "codex") {
2800
+ try {
2801
+ return await this.listRecentCodexThreads(msg);
2802
+ }
2803
+ catch (err) {
2804
+ console.warn(`[ws] Codex thread/list failed, falling back to rollout scan: ${err}`);
2805
+ }
2806
+ }
2807
+ return getAllRecentSessions({
2808
+ limit: msg.limit,
2809
+ offset: msg.offset,
2810
+ projectPath: msg.projectPath,
2811
+ provider: msg.provider,
2812
+ namedOnly: msg.namedOnly,
2813
+ searchQuery: msg.searchQuery,
2814
+ archivedSessionIds: this.archiveStore.archivedIds(),
2815
+ });
2816
+ }
2817
+ getActiveCodexProcess() {
2818
+ const summary = this.sessionManager
2819
+ .list()
2820
+ .find((session) => session.provider === "codex");
2821
+ if (!summary)
2822
+ return null;
2823
+ const session = this.sessionManager.get(summary.id);
2824
+ return session?.provider === "codex"
2825
+ ? session.process
2826
+ : null;
2827
+ }
2828
+ async listRecentCodexThreads(msg) {
2829
+ const limit = msg.limit ?? 20;
2830
+ const offset = msg.offset ?? 0;
2831
+ const process = this.getActiveCodexProcess() ??
2832
+ (await this.createStandaloneCodexProcess(msg.projectPath));
2833
+ const isStandalone = process !== this.getActiveCodexProcess();
2834
+ try {
2835
+ const result = await process.listThreads({
2836
+ limit: limit + offset,
2837
+ cwd: msg.projectPath,
2838
+ searchTerm: msg.searchQuery,
2839
+ });
2840
+ const archivedIds = this.archiveStore.archivedIds();
2841
+ const indexedSessions = await getAllRecentSessions({
2842
+ provider: "codex",
2843
+ projectPath: msg.projectPath,
2844
+ archivedSessionIds: archivedIds,
2845
+ });
2846
+ const indexedById = new Map(indexedSessions.sessions.map((session) => [
2847
+ session.sessionId,
2848
+ {
2849
+ codexSettings: session.codexSettings,
2850
+ resumeCwd: session.resumeCwd,
2851
+ },
2852
+ ]));
2853
+ const sessions = result.data
2854
+ .filter((thread) => !archivedIds.has(thread.id))
2855
+ .filter((thread) => !msg.namedOnly || !!thread.name)
2856
+ .slice(offset, offset + limit)
2857
+ .map((thread) => codexThreadToRecentSession(thread, indexedById.get(thread.id)));
2858
+ return {
2859
+ sessions,
2860
+ hasMore: result.nextCursor != null,
2861
+ };
2862
+ }
2863
+ finally {
2864
+ if (isStandalone) {
2865
+ process.stop();
2866
+ }
2867
+ }
2868
+ }
2869
+ async createStandaloneCodexProcess(projectPath) {
2870
+ const proc = new CodexProcess();
2871
+ await proc.initializeOnly(projectPath ?? process.cwd());
2872
+ return proc;
2873
+ }
2874
+ /** Extract a short project label from the full projectPath (last directory name). */
2875
+ projectLabel(sessionId) {
2876
+ const session = this.sessionManager.get(sessionId);
2877
+ if (!session?.projectPath)
2878
+ return "";
2879
+ const parts = session.projectPath.replace(/\/+$/, "").split("/");
2880
+ return parts[parts.length - 1] || "";
2881
+ }
2882
+ /** Get unique locales from registered tokens. Falls back to ["en"] if none registered. */
2883
+ getRegisteredLocales() {
2884
+ const locales = new Set(this.tokenLocales.values());
2885
+ return locales.size > 0 ? [...locales] : ["en"];
2886
+ }
2887
+ /** Whether any registered token has privacy mode enabled (conservative: privacy wins). */
2888
+ isPrivacyMode() {
2889
+ for (const privacy of this.tokenPrivacyMode.values()) {
2890
+ if (privacy)
2891
+ return true;
2892
+ }
2893
+ return false;
2894
+ }
2895
+ /** Get a display label for push notification title: "name (project)" or just project. */
2896
+ sessionLabel(sessionId) {
2897
+ const session = this.sessionManager.get(sessionId);
2898
+ const project = this.projectLabel(sessionId);
2899
+ if (session?.name) {
2900
+ return project ? `${session.name} (${project})` : session.name;
2901
+ }
2902
+ return project;
2903
+ }
2904
+ maybeSendPushNotification(sessionId, msg) {
2905
+ if (!this.pushRelay.isConfigured)
2906
+ return;
2907
+ const privacy = this.isPrivacyMode();
2908
+ const label = privacy ? "" : this.sessionLabel(sessionId);
2909
+ if (msg.type === "permission_request") {
2910
+ const seen = this.notifiedPermissionToolUses.get(sessionId) ?? new Set();
2911
+ if (seen.has(msg.toolUseId))
2912
+ return;
2913
+ seen.add(msg.toolUseId);
2914
+ this.notifiedPermissionToolUses.set(sessionId, seen);
2915
+ const isAskUserQuestion = msg.toolName === "AskUserQuestion";
2916
+ const isExitPlanMode = msg.toolName === "ExitPlanMode";
2917
+ const eventType = isAskUserQuestion
2918
+ ? "ask_user_question"
2919
+ : "approval_required";
2920
+ // Extract question text for AskUserQuestion (standard mode only)
2921
+ let questionText;
2922
+ if (!privacy && isAskUserQuestion) {
2923
+ const questions = msg.input?.questions;
2924
+ const firstQuestion = Array.isArray(questions) && questions.length > 0
2925
+ ? questions[0]?.question
2926
+ : undefined;
2927
+ if (typeof firstQuestion === "string" && firstQuestion.length > 0) {
2928
+ questionText = firstQuestion.slice(0, 120);
2929
+ }
2930
+ }
2931
+ const data = {
2932
+ sessionId,
2933
+ provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
2934
+ toolUseId: msg.toolUseId,
2935
+ toolName: msg.toolName,
2936
+ };
2937
+ for (const locale of this.getRegisteredLocales()) {
2938
+ let title;
2939
+ let body;
2940
+ if (isExitPlanMode) {
2941
+ const titleKey = "plan_ready_title";
2942
+ title = label
2943
+ ? `${t(locale, titleKey)} - ${label}`
2944
+ : t(locale, titleKey);
2945
+ body = t(locale, "plan_ready_body");
2946
+ }
2947
+ else if (isAskUserQuestion) {
2948
+ const titleKey = "ask_title";
2949
+ title = label
2950
+ ? `${t(locale, titleKey)} - ${label}`
2951
+ : t(locale, titleKey);
2952
+ body = privacy
2953
+ ? t(locale, "ask_body_private")
2954
+ : (questionText ?? t(locale, "ask_default_body"));
2955
+ }
2956
+ else {
2957
+ const titleKey = "approval_title";
2958
+ title = label
2959
+ ? `${t(locale, titleKey)} - ${label}`
2960
+ : t(locale, titleKey);
2961
+ body = privacy
2962
+ ? t(locale, "approval_body_private")
2963
+ : t(locale, "approval_body", { toolName: msg.toolName });
2964
+ }
2965
+ void this.pushRelay
2966
+ .notify({
2967
+ eventType,
2968
+ title,
2969
+ body,
2970
+ locale,
2971
+ data,
2972
+ })
2973
+ .catch((err) => {
2974
+ const detail = err instanceof Error ? err.message : String(err);
2975
+ console.warn(`[ws] Failed to send push notification (${eventType}, ${locale}): ${detail}`);
2976
+ });
2977
+ }
2978
+ return;
2979
+ }
2980
+ if (msg.type !== "result")
2981
+ return;
2982
+ if (msg.subtype === "stopped")
2983
+ return;
2984
+ if (msg.subtype !== "success" && msg.subtype !== "error")
2985
+ return;
2986
+ const isSuccess = msg.subtype === "success";
2987
+ const eventType = isSuccess ? "session_completed" : "session_failed";
2988
+ const pieces = [];
2989
+ if (isSuccess) {
2990
+ if (msg.duration != null)
2991
+ pieces.push(`${msg.duration.toFixed(1)}s`);
2992
+ if (msg.cost != null)
2993
+ pieces.push(`$${msg.cost.toFixed(4)}`);
2994
+ }
2995
+ const stats = pieces.length > 0 ? ` (${pieces.join(", ")})` : "";
2996
+ const data = {
2997
+ sessionId,
2998
+ provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
2999
+ subtype: msg.subtype,
3000
+ };
3001
+ if (msg.stopReason)
3002
+ data.stopReason = msg.stopReason;
3003
+ if (msg.sessionId)
3004
+ data.providerSessionId = msg.sessionId;
3005
+ for (const locale of this.getRegisteredLocales()) {
3006
+ let title;
3007
+ if (privacy) {
3008
+ title = isSuccess
3009
+ ? t(locale, "task_completed")
3010
+ : t(locale, "error_occurred");
3011
+ }
3012
+ else {
3013
+ title = label
3014
+ ? isSuccess
3015
+ ? `✅ ${label}`
3016
+ : `❌ ${label}`
3017
+ : isSuccess
3018
+ ? t(locale, "task_completed")
3019
+ : t(locale, "error_occurred");
3020
+ }
3021
+ let body;
3022
+ if (privacy) {
3023
+ const privateBody = isSuccess
3024
+ ? t(locale, "result_success_body_private")
3025
+ : t(locale, "result_error_body_private");
3026
+ body = isSuccess ? `${privateBody}${stats}` : privateBody;
3027
+ }
3028
+ else if (isSuccess) {
3029
+ body = msg.result
3030
+ ? `${msg.result.slice(0, 120)}${stats}`
3031
+ : `${t(locale, "session_completed")}${stats}`;
3032
+ }
3033
+ else {
3034
+ body = msg.error
3035
+ ? msg.error.slice(0, 120)
3036
+ : t(locale, "session_failed");
3037
+ }
3038
+ void this.pushRelay
3039
+ .notify({
3040
+ eventType,
3041
+ title,
3042
+ body,
3043
+ locale,
3044
+ data,
3045
+ })
3046
+ .catch((err) => {
3047
+ const detail = err instanceof Error ? err.message : String(err);
3048
+ console.warn(`[ws] Failed to send push notification (${eventType}, ${locale}): ${detail}`);
3049
+ });
3050
+ }
3051
+ }
3052
+ broadcast(msg) {
3053
+ const data = JSON.stringify(msg);
3054
+ for (const client of this.wss.clients) {
3055
+ if (client.readyState === WebSocket.OPEN) {
3056
+ client.send(data);
3057
+ }
3058
+ }
3059
+ }
3060
+ send(ws, msg) {
3061
+ const sessionId = this.extractSessionIdFromServerMessage(msg);
3062
+ if (sessionId) {
3063
+ this.recordDebugEvent(sessionId, {
3064
+ direction: "outgoing",
3065
+ channel: "ws",
3066
+ type: String(msg.type ?? "unknown"),
3067
+ detail: this.summarizeOutboundMessage(msg),
3068
+ });
3069
+ }
3070
+ if (ws.readyState === WebSocket.OPEN) {
3071
+ ws.send(JSON.stringify(msg));
3072
+ }
3073
+ }
3074
+ /** Broadcast a gallery_new_image message to all connected clients. */
3075
+ broadcastGalleryNewImage(image) {
3076
+ this.broadcast({ type: "gallery_new_image", image });
3077
+ }
3078
+ collectGitDiff(cwd, callback, options) {
3079
+ const execOpts = { cwd, maxBuffer: 10 * 1024 * 1024 };
3080
+ // Staged only: git diff --cached
3081
+ if (options?.staged) {
3082
+ execFile("git", ["diff", "--cached", "--no-color"], execOpts, (err, stdout) => {
3083
+ if (err) {
3084
+ callback({ diff: "", error: err.message });
3085
+ return;
3086
+ }
3087
+ callback({ diff: stdout });
3088
+ });
3089
+ return;
3090
+ }
3091
+ // Unstaged only: git diff (working tree vs index) — original behavior
3092
+ if (options?.unstaged) {
3093
+ // Collect untracked files so they appear in the diff.
3094
+ let untrackedFiles = [];
3095
+ try {
3096
+ const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd })
3097
+ .toString()
3098
+ .trim();
3099
+ untrackedFiles = out ? out.split("\n") : [];
3100
+ }
3101
+ catch {
3102
+ // Ignore errors: non-git directories are handled by git diff callback.
3103
+ }
3104
+ // Temporarily stage untracked files with --intent-to-add.
3105
+ if (untrackedFiles.length > 0) {
3106
+ try {
3107
+ execFileSync("git", ["add", "--intent-to-add", ...untrackedFiles], {
3108
+ cwd,
3109
+ });
3110
+ }
3111
+ catch {
3112
+ // Ignore staging errors.
3113
+ }
3114
+ }
3115
+ execFile("git", ["diff", "--no-color"], execOpts, (err, stdout) => {
3116
+ // Revert intent-to-add for untracked files.
3117
+ if (untrackedFiles.length > 0) {
3118
+ try {
3119
+ execFileSync("git", ["reset", "--", ...untrackedFiles], { cwd });
3120
+ }
3121
+ catch {
3122
+ // Ignore reset errors.
3123
+ }
3124
+ }
3125
+ if (err) {
3126
+ callback({ diff: "", error: err.message });
3127
+ return;
3128
+ }
3129
+ callback({ diff: stdout });
3130
+ });
3131
+ return;
3132
+ }
3133
+ // All mode (no options): git diff HEAD — shows both staged and unstaged vs HEAD
3134
+ let untrackedFilesAll = [];
3135
+ try {
3136
+ const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd })
3137
+ .toString()
3138
+ .trim();
3139
+ untrackedFilesAll = out ? out.split("\n") : [];
3140
+ }
3141
+ catch {
3142
+ // Ignore
3143
+ }
3144
+ if (untrackedFilesAll.length > 0) {
3145
+ try {
3146
+ execFileSync("git", ["add", "--intent-to-add", ...untrackedFilesAll], {
3147
+ cwd,
3148
+ });
3149
+ }
3150
+ catch {
3151
+ // Ignore
3152
+ }
3153
+ }
3154
+ execFile("git", ["diff", "HEAD", "--no-color"], execOpts, (err, stdout) => {
3155
+ if (untrackedFilesAll.length > 0) {
3156
+ try {
3157
+ execFileSync("git", ["reset", "--", ...untrackedFilesAll], { cwd });
3158
+ }
3159
+ catch {
3160
+ // Ignore
3161
+ }
3162
+ }
3163
+ if (err) {
3164
+ callback({ diff: "", error: err.message });
3165
+ return;
3166
+ }
3167
+ callback({ diff: stdout });
3168
+ });
3169
+ }
3170
+ // ---------------------------------------------------------------------------
3171
+ // Image diff helpers
3172
+ // ---------------------------------------------------------------------------
3173
+ static IMAGE_EXTENSIONS = new Set([
3174
+ ".png",
3175
+ ".jpg",
3176
+ ".jpeg",
3177
+ ".gif",
3178
+ ".webp",
3179
+ ".ico",
3180
+ ".bmp",
3181
+ ".svg",
3182
+ ]);
3183
+ // Image diff thresholds (configurable via environment variables)
3184
+ // - Auto-display: images ≤ threshold are sent inline as base64
3185
+ // - Max size: images ≤ max are available for on-demand loading
3186
+ // - Images > max size show text info only
3187
+ static AUTO_DISPLAY_THRESHOLD = (() => {
3188
+ const kb = parseInt(process.env.DIFF_IMAGE_AUTO_DISPLAY_KB ?? "", 10);
3189
+ return Number.isFinite(kb) && kb > 0 ? kb * 1024 : 1024 * 1024; // default 1 MB
3190
+ })();
3191
+ static MAX_IMAGE_SIZE = (() => {
3192
+ const mb = parseInt(process.env.DIFF_IMAGE_MAX_SIZE_MB ?? "", 10);
3193
+ return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 5 * 1024 * 1024; // default 5 MB
3194
+ })();
3195
+ static mimeTypeForExt(ext) {
3196
+ const map = {
3197
+ ".png": "image/png",
3198
+ ".jpg": "image/jpeg",
3199
+ ".jpeg": "image/jpeg",
3200
+ ".gif": "image/gif",
3201
+ ".webp": "image/webp",
3202
+ ".ico": "image/x-icon",
3203
+ ".bmp": "image/bmp",
3204
+ ".svg": "image/svg+xml",
3205
+ };
3206
+ return map[ext.toLowerCase()] ?? "application/octet-stream";
3207
+ }
3208
+ /**
3209
+ * Scan diff text for image file changes and extract base64 data where appropriate.
3210
+ *
3211
+ * Detection strategy:
3212
+ * 1. Binary markers: "Binary files a/<path> and b/<path> differ"
3213
+ * 2. diff --git headers where the file extension is an image type
3214
+ *
3215
+ * For each detected image file:
3216
+ * - Old version: `git show HEAD:<path>` (committed version)
3217
+ * - New version: read from working tree
3218
+ * - Apply size thresholds for auto-display / on-demand / text-only
3219
+ */
3220
+ async collectImageChanges(cwd, diffText) {
3221
+ const entries = [];
3222
+ const processedPaths = new Set();
3223
+ const lines = diffText.split("\n");
3224
+ for (let i = 0; i < lines.length; i++) {
3225
+ const line = lines[i];
3226
+ const gitMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
3227
+ if (!gitMatch)
3228
+ continue;
3229
+ const filePath = gitMatch[2];
3230
+ const ext = extname(filePath).toLowerCase();
3231
+ if (!BridgeWebSocketServer.IMAGE_EXTENSIONS.has(ext))
3232
+ continue;
3233
+ if (processedPaths.has(filePath))
3234
+ continue;
3235
+ processedPaths.add(filePath);
3236
+ let isNew = false;
3237
+ let isDeleted = false;
3238
+ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
3239
+ if (lines[j].startsWith("diff --git "))
3240
+ break;
3241
+ if (lines[j].startsWith("new file mode"))
3242
+ isNew = true;
3243
+ if (lines[j].startsWith("deleted file mode"))
3244
+ isDeleted = true;
3245
+ }
3246
+ entries.push({
3247
+ filePath,
3248
+ isNew,
3249
+ isDeleted,
3250
+ isSvg: ext === ".svg",
3251
+ mimeType: BridgeWebSocketServer.mimeTypeForExt(ext),
3252
+ ext,
3253
+ });
3254
+ }
3255
+ if (entries.length === 0)
3256
+ return [];
3257
+ // Phase 2: Read image data asynchronously
3258
+ const execFileAsync = promisify(execFile);
3259
+ const changes = [];
3260
+ for (const entry of entries) {
3261
+ let oldBuf;
3262
+ let newBuf;
3263
+ // Read old image (committed version)
3264
+ if (!entry.isNew) {
3265
+ try {
3266
+ const result = await execFileAsync("git", ["show", `HEAD:${entry.filePath}`], {
3267
+ cwd,
3268
+ maxBuffer: BridgeWebSocketServer.MAX_IMAGE_SIZE + 1024,
3269
+ encoding: "buffer",
3270
+ });
3271
+ oldBuf = result.stdout;
3272
+ }
3273
+ catch {
3274
+ // File may not exist in HEAD (e.g. untracked)
3275
+ }
3276
+ }
3277
+ // Read new image (working tree)
3278
+ if (!entry.isDeleted) {
3279
+ try {
3280
+ const absPath = resolve(cwd, entry.filePath);
3281
+ if (existsSync(absPath)) {
3282
+ newBuf = await readFile(absPath);
3283
+ }
3284
+ }
3285
+ catch {
3286
+ // Ignore read errors
3287
+ }
3288
+ }
3289
+ const oldSize = oldBuf?.length;
3290
+ const newSize = newBuf?.length;
3291
+ const maxSize = Math.max(oldSize ?? 0, newSize ?? 0);
3292
+ const autoDisplay = maxSize <= BridgeWebSocketServer.AUTO_DISPLAY_THRESHOLD;
3293
+ const loadable = autoDisplay || maxSize <= BridgeWebSocketServer.MAX_IMAGE_SIZE;
3294
+ const change = {
3295
+ filePath: entry.filePath,
3296
+ isNew: entry.isNew,
3297
+ isDeleted: entry.isDeleted,
3298
+ isSvg: entry.isSvg,
3299
+ mimeType: entry.mimeType,
3300
+ loadable,
3301
+ autoDisplay: autoDisplay || undefined,
3302
+ };
3303
+ if (oldSize !== undefined)
3304
+ change.oldSize = oldSize;
3305
+ if (newSize !== undefined)
3306
+ change.newSize = newSize;
3307
+ // Auto-display images are no longer embedded in the initial response.
3308
+ // They are loaded on-demand when the Flutter widget becomes visible.
3309
+ changes.push(change);
3310
+ }
3311
+ return changes;
3312
+ }
3313
+ /**
3314
+ * Load a single diff image on demand (async I/O for better throughput).
3315
+ */
3316
+ async loadDiffImageAsync(cwd, filePath, version) {
3317
+ // Path traversal guard: reject paths containing '..' or absolute paths
3318
+ if (filePath.includes("..") || filePath.startsWith("/")) {
3319
+ return { error: "Invalid file path" };
3320
+ }
3321
+ const ext = extname(filePath).toLowerCase();
3322
+ if (!BridgeWebSocketServer.IMAGE_EXTENSIONS.has(ext)) {
3323
+ return { error: "Not an image file" };
3324
+ }
3325
+ const mimeType = BridgeWebSocketServer.mimeTypeForExt(ext);
3326
+ try {
3327
+ const execFileAsync = promisify(execFile);
3328
+ let buf;
3329
+ if (version === "old") {
3330
+ const result = await execFileAsync("git", ["show", `HEAD:${filePath}`], {
3331
+ cwd,
3332
+ maxBuffer: BridgeWebSocketServer.MAX_IMAGE_SIZE + 1024,
3333
+ encoding: "buffer",
3334
+ });
3335
+ buf = result.stdout;
3336
+ }
3337
+ else {
3338
+ const absPath = resolve(cwd, filePath);
3339
+ // Verify resolved path stays within cwd
3340
+ if (!isPathWithinAllowedDirectory(absPath, cwd, this.platform)) {
3341
+ return { error: "Invalid file path" };
3342
+ }
3343
+ buf = await readFile(absPath);
3344
+ }
3345
+ if (buf.length > BridgeWebSocketServer.MAX_IMAGE_SIZE) {
3346
+ return { error: "Image too large" };
3347
+ }
3348
+ return { base64: buf.toString("base64"), mimeType };
3349
+ }
3350
+ catch (err) {
3351
+ return { error: err instanceof Error ? err.message : String(err) };
3352
+ }
3353
+ }
3354
+ extractSessionIdFromClientMessage(msg) {
3355
+ return "sessionId" in msg && typeof msg.sessionId === "string"
3356
+ ? msg.sessionId
3357
+ : undefined;
3358
+ }
3359
+ extractSessionIdFromServerMessage(msg) {
3360
+ if ("sessionId" in msg && typeof msg.sessionId === "string")
3361
+ return msg.sessionId;
3362
+ return undefined;
3363
+ }
3364
+ recordDebugEvent(sessionId, event) {
3365
+ const events = this.debugEvents.get(sessionId) ?? [];
3366
+ const fullEvent = {
3367
+ ts: new Date().toISOString(),
3368
+ sessionId,
3369
+ ...event,
3370
+ };
3371
+ events.push(fullEvent);
3372
+ if (events.length > BridgeWebSocketServer.MAX_DEBUG_EVENTS) {
3373
+ events.splice(0, events.length - BridgeWebSocketServer.MAX_DEBUG_EVENTS);
3374
+ }
3375
+ this.debugEvents.set(sessionId, events);
3376
+ this.debugTraceStore.record(fullEvent);
3377
+ }
3378
+ getDebugEvents(sessionId, limit) {
3379
+ const events = this.debugEvents.get(sessionId) ?? [];
3380
+ const capped = Math.max(0, Math.min(limit, BridgeWebSocketServer.MAX_DEBUG_EVENTS));
3381
+ if (capped === 0)
3382
+ return [];
3383
+ return events.slice(-capped);
3384
+ }
3385
+ buildHistorySummary(history) {
3386
+ const lines = history.map((msg, index) => {
3387
+ const num = String(index + 1).padStart(3, "0");
3388
+ return `${num}. ${this.summarizeServerMessage(msg)}`;
3389
+ });
3390
+ if (lines.length <= BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS) {
3391
+ return lines;
3392
+ }
3393
+ return lines.slice(-BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS);
3394
+ }
3395
+ summarizeClientMessage(msg) {
3396
+ switch (msg.type) {
3397
+ case "input": {
3398
+ const textPreview = msg.text.replace(/\s+/g, " ").trim().slice(0, 80);
3399
+ const hasImage = msg.imageBase64 != null || msg.imageId != null;
3400
+ return `text=\"${textPreview}\" image=${hasImage}`;
3401
+ }
3402
+ case "push_register":
3403
+ return `platform=${msg.platform} token=${msg.token.slice(0, 8)}...`;
3404
+ case "push_unregister":
3405
+ return `token=${msg.token.slice(0, 8)}...`;
3406
+ case "approve":
3407
+ case "approve_always":
3408
+ case "reject":
3409
+ return `id=${msg.id}`;
3410
+ case "answer":
3411
+ return `toolUseId=${msg.toolUseId}`;
3412
+ case "start":
3413
+ return `projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`;
3414
+ case "resume_session":
3415
+ return `sessionId=${msg.sessionId} provider=${msg.provider ?? "claude"}`;
3416
+ case "get_debug_bundle":
3417
+ return `traceLimit=${msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS} includeDiff=${msg.includeDiff ?? true}`;
3418
+ case "get_usage":
3419
+ return "get_usage";
3420
+ default:
3421
+ return msg.type;
3422
+ }
3423
+ }
3424
+ summarizeServerMessage(msg) {
3425
+ switch (msg.type) {
3426
+ case "assistant": {
3427
+ const textChunks = [];
3428
+ for (const content of msg.message.content) {
3429
+ if (content.type === "text") {
3430
+ textChunks.push(content.text);
3431
+ }
3432
+ }
3433
+ const text = textChunks
3434
+ .join(" ")
3435
+ .replace(/\s+/g, " ")
3436
+ .trim()
3437
+ .slice(0, 100);
3438
+ return text ? `assistant: ${text}` : "assistant";
3439
+ }
3440
+ case "tool_result": {
3441
+ const contentPreview = msg.content
3442
+ .replace(/\s+/g, " ")
3443
+ .trim()
3444
+ .slice(0, 100);
3445
+ return `${msg.toolName ?? "tool_result"}(${msg.toolUseId}) ${contentPreview}`;
3446
+ }
3447
+ case "permission_request":
3448
+ return `${msg.toolName}(${msg.toolUseId})`;
3449
+ case "result":
3450
+ return `${msg.subtype}${msg.error ? ` error=${msg.error}` : ""}`;
3451
+ case "status":
3452
+ return msg.status;
3453
+ case "error":
3454
+ return msg.message;
3455
+ case "stream_delta":
3456
+ case "thinking_delta":
3457
+ return `${msg.type}(${msg.text.length})`;
3458
+ default:
3459
+ return msg.type;
3460
+ }
3461
+ }
3462
+ summarizeOutboundMessage(msg) {
3463
+ if ("type" in msg && typeof msg.type === "string") {
3464
+ return msg.type;
3465
+ }
3466
+ return "message";
3467
+ }
3468
+ pruneDebugEvents() {
3469
+ const active = new Set(this.sessionManager.list().map((s) => s.id));
3470
+ for (const sessionId of this.debugEvents.keys()) {
3471
+ if (!active.has(sessionId)) {
3472
+ this.debugEvents.delete(sessionId);
3473
+ }
3474
+ }
3475
+ for (const sessionId of this.notifiedPermissionToolUses.keys()) {
3476
+ if (!active.has(sessionId)) {
3477
+ this.notifiedPermissionToolUses.delete(sessionId);
3478
+ }
3479
+ }
3480
+ }
3481
+ buildReproRecipe(session, traceLimit, includeDiff) {
3482
+ const bridgePort = process.env.BRIDGE_PORT ?? "8765";
3483
+ const wsUrlHint = `ws://localhost:${bridgePort}`;
3484
+ const notes = [
3485
+ "1) Connect with wsUrlHint and send resumeSessionMessage.",
3486
+ "2) Read session_created.sessionId from server response.",
3487
+ "3) Replace <runtime_session_id> in getHistoryMessage/getDebugBundleMessage and send them.",
3488
+ "4) Compare history/debugTrace/diff with the saved bundle snapshot.",
3489
+ ];
3490
+ if (!session.claudeSessionId) {
3491
+ notes.push("claudeSessionId is not available yet. Use list_recent_sessions to pick the right session id.");
3492
+ }
3493
+ return {
3494
+ wsUrlHint,
3495
+ startBridgeCommand: `BRIDGE_PORT=${bridgePort} npm run bridge`,
3496
+ resumeSessionMessage: this.buildResumeSessionMessage(session),
3497
+ getHistoryMessage: {
3498
+ type: "get_history",
3499
+ sessionId: "<runtime_session_id>",
3500
+ },
3501
+ getDebugBundleMessage: {
3502
+ type: "get_debug_bundle",
3503
+ sessionId: "<runtime_session_id>",
3504
+ traceLimit,
3505
+ includeDiff,
3506
+ },
3507
+ notes,
3508
+ };
3509
+ }
3510
+ buildResumeSessionMessage(session) {
3511
+ const msg = {
3512
+ type: "resume_session",
3513
+ sessionId: session.claudeSessionId ?? "<session_id_from_recent_sessions>",
3514
+ projectPath: session.projectPath,
3515
+ provider: session.provider,
3516
+ };
3517
+ if (session.provider === "codex" && session.codexSettings) {
3518
+ if (session.codexSettings.approvalPolicy !== undefined) {
3519
+ msg.approvalPolicy = session.codexSettings.approvalPolicy;
3520
+ }
3521
+ if (session.codexSettings.sandboxMode !== undefined) {
3522
+ msg.sandboxMode = session.codexSettings.sandboxMode;
3523
+ }
3524
+ if (session.codexSettings.model !== undefined) {
3525
+ msg.model = session.codexSettings.model;
3526
+ }
3527
+ if (session.codexSettings.modelReasoningEffort !== undefined) {
3528
+ msg.modelReasoningEffort = session.codexSettings.modelReasoningEffort;
3529
+ }
3530
+ if (session.codexSettings.networkAccessEnabled !== undefined) {
3531
+ msg.networkAccessEnabled = session.codexSettings.networkAccessEnabled;
3532
+ }
3533
+ if (session.codexSettings.webSearchMode !== undefined) {
3534
+ msg.webSearchMode = session.codexSettings.webSearchMode;
3535
+ }
3536
+ }
3537
+ return msg;
3538
+ }
3539
+ buildAgentPrompt(session) {
3540
+ return [
3541
+ "Use this remodex debug bundle to investigate a chat-screen bug.",
3542
+ `Target provider: ${session.provider}`,
3543
+ `Project path: ${session.projectPath}`,
3544
+ "Required output:",
3545
+ "1) Timeline analysis from historySummary + debugTrace.",
3546
+ "2) Top 1-3 root-cause hypotheses with confidence.",
3547
+ "3) Concrete validation steps and the minimum extra logs needed.",
3548
+ ].join("\n");
3549
+ }
3550
+ }
3551
+ //# sourceMappingURL=websocket.js.map