@ccpocket/bridge 1.30.0 → 1.31.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.
package/dist/websocket.js CHANGED
@@ -7,11 +7,13 @@ import { WebSocketServer, WebSocket } from "ws";
7
7
  import { SessionManager } from "./session.js";
8
8
  import { SdkProcess } from "./sdk-process.js";
9
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";
10
+ import { parseClientMessage, } from "./parser.js";
11
+ import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, loadCodexSessionNames, renameClaudeSession, renameCodexSession, } from "./sessions-index.js";
12
12
  import { ArchiveStore } from "./archive-store.js";
13
13
  import { WorktreeStore } from "./worktree-store.js";
14
- import { listWorktrees, removeWorktree, worktreeExists, getMainBranch } from "./worktree.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";
15
17
  import { listWindows, takeScreenshot } from "./screenshot.js";
16
18
  import { DebugTraceStore } from "./debug-trace-store.js";
17
19
  import { PushRelayClient } from "./push-relay.js";
@@ -54,9 +56,8 @@ function deriveExecutionMode(params) {
54
56
  return "default";
55
57
  }
56
58
  function derivePlanMode(params) {
57
- return params.planMode ??
58
- ((params.permissionMode === "plan") ||
59
- (params.collaborationMode === "plan"));
59
+ return (params.planMode ??
60
+ (params.permissionMode === "plan" || params.collaborationMode === "plan"));
60
61
  }
61
62
  function modesToLegacyPermissionMode(provider, executionMode, planMode) {
62
63
  if (planMode)
@@ -138,7 +139,7 @@ export class BridgeWebSocketServer {
138
139
  failSetPermissionMode = envFlagEnabled("BRIDGE_FAIL_SET_PERMISSION_MODE");
139
140
  failSetSandboxMode = envFlagEnabled("BRIDGE_FAIL_SET_SANDBOX_MODE");
140
141
  constructor(options) {
141
- const { server, apiKey, allowedDirs, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup } = options;
142
+ const { server, apiKey, allowedDirs, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup, } = options;
142
143
  this.apiKey = apiKey ?? null;
143
144
  this.allowedDirs = allowedDirs ?? [];
144
145
  this.imageStore = imageStore ?? null;
@@ -223,30 +224,36 @@ export class BridgeWebSocketServer {
223
224
  sessionId,
224
225
  provider,
225
226
  projectPath,
226
- ...(permissionMode ? { permissionMode: permissionMode } : {}),
227
- ...((executionMode ?? (session?.process instanceof SdkProcess
228
- ? session.process.permissionMode === "bypassPermissions"
229
- ? "fullAccess"
230
- : session.process.permissionMode === "acceptEdits"
231
- ? "acceptEdits"
232
- : "default"
233
- : session?.process instanceof CodexProcess
234
- ? session.process.approvalPolicy === "never"
235
- ? "fullAccess"
236
- : "default"
237
- : undefined))
227
+ ...(permissionMode
238
228
  ? {
239
- executionMode: (executionMode ?? (session?.process instanceof SdkProcess
240
- ? session.process.permissionMode === "bypassPermissions"
229
+ permissionMode: permissionMode,
230
+ }
231
+ : {}),
232
+ ...((executionMode ??
233
+ (session?.process instanceof SdkProcess
234
+ ? session.process.permissionMode === "bypassPermissions"
235
+ ? "fullAccess"
236
+ : session.process.permissionMode === "acceptEdits"
237
+ ? "acceptEdits"
238
+ : "default"
239
+ : session?.process instanceof CodexProcess
240
+ ? session.process.approvalPolicy === "never"
241
241
  ? "fullAccess"
242
- : session.process.permissionMode === "acceptEdits"
243
- ? "acceptEdits"
244
- : "default"
245
- : session?.process instanceof CodexProcess
246
- ? session.process.approvalPolicy === "never"
242
+ : "default"
243
+ : undefined))
244
+ ? {
245
+ executionMode: (executionMode ??
246
+ (session?.process instanceof SdkProcess
247
+ ? session.process.permissionMode === "bypassPermissions"
247
248
  ? "fullAccess"
248
- : "default"
249
- : undefined)),
249
+ : session.process.permissionMode === "acceptEdits"
250
+ ? "acceptEdits"
251
+ : "default"
252
+ : session?.process instanceof CodexProcess
253
+ ? session.process.approvalPolicy === "never"
254
+ ? "fullAccess"
255
+ : "default"
256
+ : undefined)),
250
257
  }
251
258
  : {}),
252
259
  ...((planMode ??
@@ -254,8 +261,7 @@ export class BridgeWebSocketServer {
254
261
  ? session.process.permissionMode === "plan"
255
262
  : session?.process instanceof CodexProcess
256
263
  ? session.process.collaborationMode === "plan"
257
- : undefined)) !=
258
- null
264
+ : undefined)) != null
259
265
  ? {
260
266
  planMode: planMode ??
261
267
  (session?.process instanceof SdkProcess
@@ -327,9 +333,12 @@ export class BridgeWebSocketServer {
327
333
  // handle the unsupported message (suppress vs show update hint).
328
334
  let rawType;
329
335
  try {
330
- rawType = JSON.parse(raw)?.type;
336
+ rawType = JSON.parse(raw)
337
+ ?.type;
338
+ }
339
+ catch {
340
+ /* ignore */
331
341
  }
332
- catch { /* ignore */ }
333
342
  console.error("[ws] Unsupported message:", rawType ?? raw.slice(0, 200));
334
343
  this.send(ws, {
335
344
  type: "error",
@@ -350,7 +359,8 @@ export class BridgeWebSocketServer {
350
359
  }
351
360
  async handleClientMessage(msg, ws) {
352
361
  const incomingSessionId = this.extractSessionIdFromClientMessage(msg);
353
- const isActiveRuntimeSession = incomingSessionId != null && this.sessionManager.get(incomingSessionId) != null;
362
+ const isActiveRuntimeSession = incomingSessionId != null &&
363
+ this.sessionManager.get(incomingSessionId) != null;
354
364
  if (incomingSessionId && isActiveRuntimeSession) {
355
365
  this.recordDebugEvent(incomingSessionId, {
356
366
  direction: "incoming",
@@ -381,7 +391,9 @@ export class BridgeWebSocketServer {
381
391
  if (provider === "codex") {
382
392
  console.log(`[ws] start(codex): execution=${executionMode} plan=${planMode}`);
383
393
  }
384
- const cached = provider === "claude" ? this.sessionManager.getCachedCommands(msg.projectPath) : undefined;
394
+ const cached = provider === "claude"
395
+ ? this.sessionManager.getCachedCommands(msg.projectPath)
396
+ : undefined;
385
397
  const sessionId = this.sessionManager.create(msg.projectPath, {
386
398
  sessionId: msg.sessionId,
387
399
  continueMode: msg.continue,
@@ -408,9 +420,12 @@ export class BridgeWebSocketServer {
408
420
  model: msg.model,
409
421
  modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
410
422
  networkAccessEnabled: msg.networkAccessEnabled,
411
- webSearchMode: msg.webSearchMode ?? undefined,
423
+ webSearchMode: msg.webSearchMode ??
424
+ undefined,
412
425
  threadId: msg.sessionId,
413
- collaborationMode: planMode ? "plan" : "default",
426
+ collaborationMode: planMode
427
+ ? "plan"
428
+ : "default",
414
429
  }
415
430
  : undefined);
416
431
  const createdSession = this.sessionManager.get(sessionId);
@@ -464,20 +479,31 @@ export class BridgeWebSocketServer {
464
479
  }
465
480
  catch (err) {
466
481
  console.error(`[ws] Failed to start session:`, err);
467
- this.send(ws, { type: "error", message: `Failed to start session: ${err.message}` });
482
+ this.send(ws, {
483
+ type: "error",
484
+ message: `Failed to start session: ${err.message}`,
485
+ });
468
486
  }
469
487
  break;
470
488
  }
471
489
  case "input": {
472
490
  const session = this.resolveSession(msg.sessionId);
473
491
  if (!session) {
474
- this.send(ws, { type: "error", message: "No active session. Send 'start' first." });
492
+ this.send(ws, {
493
+ type: "error",
494
+ message: "No active session. Send 'start' first.",
495
+ });
475
496
  return;
476
497
  }
477
498
  const text = msg.text;
478
499
  // Codex: reject if the process is not waiting for input (turn-based, no internal queue)
479
- if (session.provider === "codex" && !session.process.isWaitingForInput) {
480
- this.send(ws, { type: "input_rejected", sessionId: session.id, reason: "Process is busy" });
500
+ if (session.provider === "codex" &&
501
+ !session.process.isWaitingForInput) {
502
+ this.send(ws, {
503
+ type: "input_rejected",
504
+ sessionId: session.id,
505
+ reason: "Process is busy",
506
+ });
481
507
  break;
482
508
  }
483
509
  // Snapshot busy state before dispatch. We prefer the actual enqueue
@@ -522,20 +548,28 @@ export class BridgeWebSocketServer {
522
548
  // Persist images to Gallery Store asynchronously (fire-and-forget)
523
549
  if (images.length > 0 && this.galleryStore && session.projectPath) {
524
550
  for (const img of images) {
525
- this.galleryStore.addImageFromBase64(img.base64, img.mimeType, session.projectPath, msg.sessionId).catch((err) => {
551
+ this.galleryStore
552
+ .addImageFromBase64(img.base64, img.mimeType, session.projectPath, msg.sessionId)
553
+ .catch((err) => {
526
554
  console.warn(`[ws] Failed to persist image to gallery: ${err}`);
527
555
  });
528
556
  }
529
557
  }
530
558
  // Codex input path
531
559
  if (session.provider === "codex") {
532
- this.send(ws, { type: "input_ack", sessionId: session.id, queued: false });
560
+ this.send(ws, {
561
+ type: "input_ack",
562
+ sessionId: session.id,
563
+ queued: false,
564
+ });
533
565
  const codexProc = session.process;
534
566
  if (images.length > 0) {
535
567
  codexProc.sendInputWithImages(text, images);
536
568
  }
537
569
  else if (msg.imageId && this.galleryStore) {
538
- this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
570
+ this.galleryStore
571
+ .getImageAsBase64(msg.imageId)
572
+ .then((imageData) => {
539
573
  if (imageData) {
540
574
  codexProc.sendInputWithImages(text, [imageData]);
541
575
  }
@@ -543,7 +577,8 @@ export class BridgeWebSocketServer {
543
577
  console.warn(`[ws] Image not found: ${msg.imageId}`);
544
578
  codexProc.sendInput(text);
545
579
  }
546
- }).catch((err) => {
580
+ })
581
+ .catch((err) => {
547
582
  console.error(`[ws] Failed to load image: ${err}`);
548
583
  codexProc.sendInput(text);
549
584
  });
@@ -562,7 +597,8 @@ export class BridgeWebSocketServer {
562
597
  if (images.length > 0) {
563
598
  console.log(`[ws] Sending message with ${images.length} inline Base64 image(s)`);
564
599
  const result = claudeProc.sendInputWithImages(text, images);
565
- wasQueued = typeof result === "boolean" ? result : isAgentBusySnapshot;
600
+ wasQueued =
601
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
566
602
  }
567
603
  // Legacy imageId mode (backward compatibility)
568
604
  else if (msg.imageId && this.galleryStore) {
@@ -571,22 +607,29 @@ export class BridgeWebSocketServer {
571
607
  sessionId: session.id,
572
608
  queued: isAgentBusySnapshot,
573
609
  });
574
- this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
610
+ this.galleryStore
611
+ .getImageAsBase64(msg.imageId)
612
+ .then((imageData) => {
575
613
  let queuedAfterResolve = false;
576
614
  if (imageData) {
577
- const result = claudeProc.sendInputWithImages(text, [imageData]);
578
- queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
615
+ const result = claudeProc.sendInputWithImages(text, [
616
+ imageData,
617
+ ]);
618
+ queuedAfterResolve =
619
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
579
620
  }
580
621
  else {
581
622
  console.warn(`[ws] Image not found: ${msg.imageId}`);
582
623
  const result = session.process.sendInput(text);
583
- queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
624
+ queuedAfterResolve =
625
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
584
626
  }
585
627
  if (queuedAfterResolve) {
586
628
  console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
587
629
  claudeProc.interrupt();
588
630
  }
589
- }).catch((err) => {
631
+ })
632
+ .catch((err) => {
590
633
  console.error(`[ws] Failed to load image: ${err}`);
591
634
  const result = session.process.sendInput(text);
592
635
  const queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
@@ -600,12 +643,17 @@ export class BridgeWebSocketServer {
600
643
  // Text-only message
601
644
  else {
602
645
  const result = session.process.sendInput(text);
603
- wasQueued = typeof result === "boolean" ? result : isAgentBusySnapshot;
646
+ wasQueued =
647
+ typeof result === "boolean" ? result : isAgentBusySnapshot;
604
648
  }
605
649
  // Acknowledge receipt so the client can mark the message state.
606
650
  // queued=true means the input was enqueued instead of being consumed
607
651
  // immediately by the SDK stream.
608
- this.send(ws, { type: "input_ack", sessionId: session.id, queued: wasQueued });
652
+ this.send(ws, {
653
+ type: "input_ack",
654
+ sessionId: session.id,
655
+ queued: wasQueued,
656
+ });
609
657
  if (wasQueued) {
610
658
  console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
611
659
  claudeProc.interrupt();
@@ -617,34 +665,52 @@ export class BridgeWebSocketServer {
617
665
  const privacyMode = msg.privacyMode === true;
618
666
  console.log(`[ws] push_register received (platform: ${msg.platform}, locale: ${locale}, privacy: ${privacyMode}, configured: ${this.pushRelay.isConfigured})`);
619
667
  if (!this.pushRelay.isConfigured) {
620
- this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
668
+ this.send(ws, {
669
+ type: "error",
670
+ message: "Push relay is not configured on bridge",
671
+ });
621
672
  return;
622
673
  }
623
674
  this.tokenLocales.set(msg.token, locale);
624
675
  this.tokenPrivacyMode.set(msg.token, privacyMode);
625
- this.pushRelay.registerToken(msg.token, msg.platform, locale).then(() => {
676
+ this.pushRelay
677
+ .registerToken(msg.token, msg.platform, locale)
678
+ .then(() => {
626
679
  console.log("[ws] push_register: token registered successfully");
627
- }).catch((err) => {
680
+ })
681
+ .catch((err) => {
628
682
  const detail = err instanceof Error ? err.message : String(err);
629
683
  console.error(`[ws] push_register failed: ${detail}`);
630
- this.send(ws, { type: "error", message: `Failed to register push token: ${detail}` });
684
+ this.send(ws, {
685
+ type: "error",
686
+ message: `Failed to register push token: ${detail}`,
687
+ });
631
688
  });
632
689
  break;
633
690
  }
634
691
  case "push_unregister": {
635
692
  console.log("[ws] push_unregister received");
636
693
  if (!this.pushRelay.isConfigured) {
637
- this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
694
+ this.send(ws, {
695
+ type: "error",
696
+ message: "Push relay is not configured on bridge",
697
+ });
638
698
  return;
639
699
  }
640
700
  this.tokenLocales.delete(msg.token);
641
701
  this.tokenPrivacyMode.delete(msg.token);
642
- this.pushRelay.unregisterToken(msg.token).then(() => {
702
+ this.pushRelay
703
+ .unregisterToken(msg.token)
704
+ .then(() => {
643
705
  console.log("[ws] push_unregister: token unregistered successfully");
644
- }).catch((err) => {
706
+ })
707
+ .catch((err) => {
645
708
  const detail = err instanceof Error ? err.message : String(err);
646
709
  console.error(`[ws] push_unregister failed: ${detail}`);
647
- this.send(ws, { type: "error", message: `Failed to unregister push token: ${detail}` });
710
+ this.send(ws, {
711
+ type: "error",
712
+ message: `Failed to unregister push token: ${detail}`,
713
+ });
648
714
  });
649
715
  break;
650
716
  }
@@ -677,10 +743,15 @@ export class BridgeWebSocketServer {
677
743
  });
678
744
  const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
679
745
  const newApproval = executionMode === "fullAccess" ? "never" : "on-request";
680
- const newCollaboration = planMode ? "plan" : "default";
681
- const currentApproval = session.process.approvalPolicy;
682
- const currentCollaboration = session.process.collaborationMode;
683
- if (newApproval === currentApproval && newCollaboration === currentCollaboration) {
746
+ const newCollaboration = planMode
747
+ ? "plan"
748
+ : "default";
749
+ const currentApproval = session.process
750
+ .approvalPolicy;
751
+ const currentCollaboration = session.process
752
+ .collaborationMode;
753
+ if (newApproval === currentApproval &&
754
+ newCollaboration === currentCollaboration) {
684
755
  break; // No change needed
685
756
  }
686
757
  const canApplyModeInPlace = session.status === "idle";
@@ -721,9 +792,12 @@ export class BridgeWebSocketServer {
721
792
  const sessionName = session.name;
722
793
  this.sessionManager.destroy(oldSessionId);
723
794
  console.log(`[ws] Permission mode change: destroyed session ${oldSessionId}`);
724
- const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") || (session.pastMessages && session.pastMessages.length > 0);
795
+ const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") ||
796
+ (session.pastMessages && session.pastMessages.length > 0);
725
797
  if (!threadId || !hasUserMessages) {
726
- const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined, "codex", {
798
+ const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath
799
+ ? { existingWorktreePath: worktreePath, worktreeBranch }
800
+ : undefined, "codex", {
727
801
  approvalPolicy: newApproval,
728
802
  sandboxMode: oldSettings.sandboxMode,
729
803
  model: oldSettings.model,
@@ -758,16 +832,26 @@ export class BridgeWebSocketServer {
758
832
  let worktreeOpts;
759
833
  if (wtMapping) {
760
834
  if (worktreeExists(wtMapping.worktreePath)) {
761
- worktreeOpts = { existingWorktreePath: wtMapping.worktreePath, worktreeBranch: wtMapping.worktreeBranch };
835
+ worktreeOpts = {
836
+ existingWorktreePath: wtMapping.worktreePath,
837
+ worktreeBranch: wtMapping.worktreeBranch,
838
+ };
762
839
  }
763
840
  else {
764
- worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
841
+ worktreeOpts = {
842
+ useWorktree: true,
843
+ worktreeBranch: wtMapping.worktreeBranch,
844
+ };
765
845
  }
766
846
  }
767
847
  else if (worktreePath) {
768
- worktreeOpts = { existingWorktreePath: worktreePath, worktreeBranch };
848
+ worktreeOpts = {
849
+ existingWorktreePath: worktreePath,
850
+ worktreeBranch,
851
+ };
769
852
  }
770
- getCodexSessionHistory(threadId).then((pastMessages) => {
853
+ getCodexSessionHistory(threadId)
854
+ .then((pastMessages) => {
771
855
  const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
772
856
  threadId,
773
857
  approvalPolicy: newApproval,
@@ -806,12 +890,18 @@ export class BridgeWebSocketServer {
806
890
  detail: `mode=${msg.mode} approval=${newApproval} collaboration=${newCollaboration} thread=${threadId} oldSession=${oldSessionId}`,
807
891
  });
808
892
  console.log(`[ws] Permission mode change: created new session ${newId} (thread=${threadId}, mode=${msg.mode})`);
809
- }).catch((err) => {
810
- this.send(ws, { type: "error", message: `Failed to restart session for permission mode change: ${err}` });
893
+ })
894
+ .catch((err) => {
895
+ this.send(ws, {
896
+ type: "error",
897
+ message: `Failed to restart session for permission mode change: ${err}`,
898
+ });
811
899
  });
812
900
  break;
813
901
  }
814
- session.process.setPermissionMode(msg.mode).catch((err) => {
902
+ session.process
903
+ .setPermissionMode(msg.mode)
904
+ .catch((err) => {
815
905
  this.send(ws, {
816
906
  type: "error",
817
907
  message: `Failed to set permission mode: ${err instanceof Error ? err.message : String(err)}`,
@@ -834,7 +924,10 @@ export class BridgeWebSocketServer {
834
924
  return;
835
925
  }
836
926
  if (msg.sandboxMode !== "on" && msg.sandboxMode !== "off") {
837
- this.send(ws, { type: "error", message: `Invalid sandbox mode: ${msg.sandboxMode}` });
927
+ this.send(ws, {
928
+ type: "error",
929
+ message: `Invalid sandbox mode: ${msg.sandboxMode}`,
930
+ });
838
931
  return;
839
932
  }
840
933
  // ---- Claude sandbox toggle ----
@@ -859,7 +952,9 @@ export class BridgeWebSocketServer {
859
952
  permissionMode,
860
953
  model,
861
954
  sandboxEnabled: newEnabled,
862
- }, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined, "claude");
955
+ }, undefined, worktreePath
956
+ ? { existingWorktreePath: worktreePath, worktreeBranch }
957
+ : undefined, "claude");
863
958
  const newSession = this.sessionManager.get(newId);
864
959
  if (newSession && sessionName)
865
960
  newSession.name = sessionName;
@@ -901,7 +996,8 @@ export class BridgeWebSocketServer {
901
996
  const worktreePath = session.worktreePath;
902
997
  const worktreeBranch = session.worktreeBranch;
903
998
  const sessionName = session.name;
904
- const collaborationMode = session.process.collaborationMode;
999
+ const collaborationMode = session.process
1000
+ .collaborationMode;
905
1001
  const executionMode = oldSettings.approvalPolicy === "never" ? "fullAccess" : "default";
906
1002
  const planMode = collaborationMode === "plan";
907
1003
  const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
@@ -911,13 +1007,16 @@ export class BridgeWebSocketServer {
911
1007
  // session.history always contains system events (init, status, etc.)
912
1008
  // even before the first user turn, so we check for user_input/assistant
913
1009
  // messages specifically.
914
- const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") || (session.pastMessages && session.pastMessages.length > 0);
1010
+ const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") ||
1011
+ (session.pastMessages && session.pastMessages.length > 0);
915
1012
  if (!threadId || !hasUserMessages) {
916
1013
  // Session has no thread yet, or has a thread but no messages exchanged.
917
1014
  // Create a fresh session with the new sandbox — no resume needed.
918
1015
  // (A thread with no messages cannot be resumed — Codex returns
919
1016
  // "no rollout found for thread id".)
920
- const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined, "codex", {
1017
+ const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath
1018
+ ? { existingWorktreePath: worktreePath, worktreeBranch }
1019
+ : undefined, "codex", {
921
1020
  approvalPolicy: oldSettings.approvalPolicy,
922
1021
  sandboxMode: newSandboxMode,
923
1022
  model: oldSettings.model,
@@ -950,16 +1049,23 @@ export class BridgeWebSocketServer {
950
1049
  let worktreeOpts;
951
1050
  if (wtMapping) {
952
1051
  if (worktreeExists(wtMapping.worktreePath)) {
953
- worktreeOpts = { existingWorktreePath: wtMapping.worktreePath, worktreeBranch: wtMapping.worktreeBranch };
1052
+ worktreeOpts = {
1053
+ existingWorktreePath: wtMapping.worktreePath,
1054
+ worktreeBranch: wtMapping.worktreeBranch,
1055
+ };
954
1056
  }
955
1057
  else {
956
- worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
1058
+ worktreeOpts = {
1059
+ useWorktree: true,
1060
+ worktreeBranch: wtMapping.worktreeBranch,
1061
+ };
957
1062
  }
958
1063
  }
959
1064
  else if (worktreePath) {
960
1065
  worktreeOpts = { existingWorktreePath: worktreePath, worktreeBranch };
961
1066
  }
962
- getCodexSessionHistory(threadId).then((pastMessages) => {
1067
+ getCodexSessionHistory(threadId)
1068
+ .then((pastMessages) => {
963
1069
  const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
964
1070
  threadId,
965
1071
  approvalPolicy: oldSettings.approvalPolicy,
@@ -997,8 +1103,12 @@ export class BridgeWebSocketServer {
997
1103
  detail: `sandbox=${newSandboxMode} thread=${threadId} oldSession=${oldSessionId}`,
998
1104
  });
999
1105
  console.log(`[ws] Sandbox mode change: created new session ${newId} (thread=${threadId}, sandbox=${newSandboxMode})`);
1000
- }).catch((err) => {
1001
- this.send(ws, { type: "error", message: `Failed to restart session for sandbox mode change: ${err}` });
1106
+ })
1107
+ .catch((err) => {
1108
+ this.send(ws, {
1109
+ type: "error",
1110
+ message: `Failed to restart session for sandbox mode change: ${err}`,
1111
+ });
1002
1112
  });
1003
1113
  break;
1004
1114
  }
@@ -1043,7 +1153,9 @@ export class BridgeWebSocketServer {
1043
1153
  : {}),
1044
1154
  permissionMode,
1045
1155
  initialInput: planText || undefined,
1046
- }, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined);
1156
+ }, undefined, worktreePath
1157
+ ? { existingWorktreePath: worktreePath, worktreeBranch }
1158
+ : undefined);
1047
1159
  console.log(`[ws] Clear context: created new session ${newId} (CLI session: ${claudeSessionId ?? "new"})`);
1048
1160
  // Notify all clients. Broadcast is used so reconnecting clients also receive it.
1049
1161
  const newSession = this.sessionManager.get(newId);
@@ -1126,7 +1238,10 @@ export class BridgeWebSocketServer {
1126
1238
  this.sendSessionList(ws);
1127
1239
  }
1128
1240
  else {
1129
- this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
1241
+ this.send(ws, {
1242
+ type: "error",
1243
+ message: `Session ${msg.sessionId} not found`,
1244
+ });
1130
1245
  }
1131
1246
  break;
1132
1247
  }
@@ -1142,8 +1257,16 @@ export class BridgeWebSocketServer {
1142
1257
  messages: session.pastMessages,
1143
1258
  });
1144
1259
  }
1145
- this.send(ws, { type: "history", messages: session.history, sessionId: msg.sessionId });
1146
- this.send(ws, { type: "status", status: session.status, sessionId: msg.sessionId });
1260
+ this.send(ws, {
1261
+ type: "history",
1262
+ messages: session.history,
1263
+ sessionId: msg.sessionId,
1264
+ });
1265
+ this.send(ws, {
1266
+ type: "status",
1267
+ status: session.status,
1268
+ sessionId: msg.sessionId,
1269
+ });
1147
1270
  // Send cached slash commands so the client can restore them even when
1148
1271
  // the original init/supported_commands message was evicted from the
1149
1272
  // in-memory history (MAX_HISTORY_PER_SESSION overflow).
@@ -1155,12 +1278,17 @@ export class BridgeWebSocketServer {
1155
1278
  sessionId: msg.sessionId,
1156
1279
  slashCommands: cached.slashCommands,
1157
1280
  skills: cached.skills,
1158
- ...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}),
1281
+ ...(cached.skillMetadata
1282
+ ? { skillMetadata: cached.skillMetadata }
1283
+ : {}),
1159
1284
  });
1160
1285
  }
1161
1286
  }
1162
1287
  else {
1163
- this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
1288
+ this.send(ws, {
1289
+ type: "error",
1290
+ message: `Session ${msg.sessionId} not found`,
1291
+ });
1164
1292
  }
1165
1293
  break;
1166
1294
  }
@@ -1171,10 +1299,13 @@ export class BridgeWebSocketServer {
1171
1299
  let branch = "";
1172
1300
  try {
1173
1301
  branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
1174
- cwd, encoding: "utf-8",
1302
+ cwd,
1303
+ encoding: "utf-8",
1175
1304
  }).trim();
1176
1305
  }
1177
- catch { /* not a git repo */ }
1306
+ catch {
1307
+ /* not a git repo */
1308
+ }
1178
1309
  // Update stored branch so future session_list responses are also current
1179
1310
  session.gitBranch = branch;
1180
1311
  this.send(ws, {
@@ -1184,14 +1315,20 @@ export class BridgeWebSocketServer {
1184
1315
  });
1185
1316
  }
1186
1317
  else {
1187
- this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
1318
+ this.send(ws, {
1319
+ type: "error",
1320
+ message: `Session ${msg.sessionId} not found`,
1321
+ });
1188
1322
  }
1189
1323
  break;
1190
1324
  }
1191
1325
  case "get_debug_bundle": {
1192
1326
  const session = this.sessionManager.get(msg.sessionId);
1193
1327
  if (!session) {
1194
- this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
1328
+ this.send(ws, {
1329
+ type: "error",
1330
+ message: `Session ${msg.sessionId} not found`,
1331
+ });
1195
1332
  return;
1196
1333
  }
1197
1334
  const emitBundle = (diff, diffError) => {
@@ -1239,30 +1376,46 @@ export class BridgeWebSocketServer {
1239
1376
  break;
1240
1377
  }
1241
1378
  case "get_usage": {
1242
- fetchAllUsage().then((providers) => {
1379
+ fetchAllUsage()
1380
+ .then((providers) => {
1243
1381
  this.send(ws, { type: "usage_result", providers });
1244
- }).catch((err) => {
1245
- this.send(ws, { type: "error", message: `Failed to fetch usage: ${err}` });
1382
+ })
1383
+ .catch((err) => {
1384
+ this.send(ws, {
1385
+ type: "error",
1386
+ message: `Failed to fetch usage: ${err}`,
1387
+ });
1246
1388
  });
1247
1389
  break;
1248
1390
  }
1249
1391
  case "list_recent_sessions": {
1250
1392
  const requestId = ++this.recentSessionsRequestId;
1251
- this.listRecentSessions(msg).then(({ sessions, hasMore }) => {
1393
+ this.listRecentSessions(msg)
1394
+ .then(({ sessions, hasMore }) => {
1252
1395
  // Drop stale responses when rapid filter switches cause out-of-order completion
1253
1396
  if (requestId !== this.recentSessionsRequestId)
1254
1397
  return;
1255
- this.send(ws, { type: "recent_sessions", sessions, hasMore });
1256
- }).catch((err) => {
1398
+ this.send(ws, {
1399
+ type: "recent_sessions",
1400
+ sessions,
1401
+ hasMore,
1402
+ });
1403
+ })
1404
+ .catch((err) => {
1257
1405
  if (requestId !== this.recentSessionsRequestId)
1258
1406
  return;
1259
- this.send(ws, { type: "error", message: `Failed to list recent sessions: ${err}` });
1407
+ this.send(ws, {
1408
+ type: "error",
1409
+ message: `Failed to list recent sessions: ${err}`,
1410
+ });
1260
1411
  });
1261
1412
  break;
1262
1413
  }
1263
1414
  case "archive_session": {
1264
1415
  const { sessionId, provider, projectPath } = msg;
1265
- this.archiveStore.archive(sessionId, provider, projectPath).then(() => {
1416
+ this.archiveStore
1417
+ .archive(sessionId, provider, projectPath)
1418
+ .then(() => {
1266
1419
  // For Codex sessions, also call thread/archive RPC (best-effort).
1267
1420
  // Requires a running Codex app-server process; skip if none active.
1268
1421
  if (provider === "codex") {
@@ -1271,7 +1424,9 @@ export class BridgeWebSocketServer {
1271
1424
  if (codexSession) {
1272
1425
  const session = this.sessionManager.get(codexSession.id);
1273
1426
  if (session) {
1274
- session.process.archiveThread(sessionId).catch((err) => {
1427
+ session.process
1428
+ .archiveThread(sessionId)
1429
+ .catch((err) => {
1275
1430
  console.warn(`[ws] Codex thread/archive failed (non-fatal): ${err}`);
1276
1431
  });
1277
1432
  }
@@ -1282,7 +1437,8 @@ export class BridgeWebSocketServer {
1282
1437
  sessionId,
1283
1438
  success: true,
1284
1439
  });
1285
- }).catch((err) => {
1440
+ })
1441
+ .catch((err) => {
1286
1442
  this.send(ws, {
1287
1443
  type: "archive_result",
1288
1444
  sessionId,
@@ -1330,7 +1486,8 @@ export class BridgeWebSocketServer {
1330
1486
  };
1331
1487
  }
1332
1488
  }
1333
- getCodexSessionHistory(sessionRefId).then((pastMessages) => {
1489
+ getCodexSessionHistory(sessionRefId)
1490
+ .then((pastMessages) => {
1334
1491
  const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
1335
1492
  threadId: sessionRefId,
1336
1493
  approvalPolicy: executionMode === "fullAccess" ? "never" : "on-request",
@@ -1338,8 +1495,11 @@ export class BridgeWebSocketServer {
1338
1495
  model: msg.model,
1339
1496
  modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
1340
1497
  networkAccessEnabled: msg.networkAccessEnabled,
1341
- webSearchMode: msg.webSearchMode ?? undefined,
1342
- collaborationMode: planMode ? "plan" : "default",
1498
+ webSearchMode: msg.webSearchMode ??
1499
+ undefined,
1500
+ collaborationMode: planMode
1501
+ ? "plan"
1502
+ : "default",
1343
1503
  });
1344
1504
  const createdSession = this.sessionManager.get(sessionId);
1345
1505
  void this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId).then(() => {
@@ -1365,8 +1525,12 @@ export class BridgeWebSocketServer {
1365
1525
  detail: `provider=codex thread=${sessionRefId}`,
1366
1526
  });
1367
1527
  this.projectHistory?.addProject(effectiveProjectPath);
1368
- }).catch((err) => {
1369
- this.send(ws, { type: "error", message: `Failed to load Codex session history: ${err}` });
1528
+ })
1529
+ .catch((err) => {
1530
+ this.send(ws, {
1531
+ type: "error",
1532
+ message: `Failed to load Codex session history: ${err}`,
1533
+ });
1370
1534
  });
1371
1535
  break;
1372
1536
  }
@@ -1385,10 +1549,14 @@ export class BridgeWebSocketServer {
1385
1549
  }
1386
1550
  else {
1387
1551
  // Worktree was deleted — recreate on the same branch
1388
- worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
1552
+ worktreeOpts = {
1553
+ useWorktree: true,
1554
+ worktreeBranch: wtMapping.worktreeBranch,
1555
+ };
1389
1556
  }
1390
1557
  }
1391
- getSessionHistory(claudeSessionId).then((pastMessages) => {
1558
+ getSessionHistory(claudeSessionId)
1559
+ .then((pastMessages) => {
1392
1560
  const sessionId = this.sessionManager.create(msg.projectPath, {
1393
1561
  sessionId: claudeSessionId,
1394
1562
  permissionMode: legacyPermissionMode,
@@ -1399,7 +1567,9 @@ export class BridgeWebSocketServer {
1399
1567
  fallbackModel: msg.fallbackModel,
1400
1568
  forkSession: msg.forkSession,
1401
1569
  persistSession: msg.persistSession,
1402
- ...(msg.sandboxMode ? { sandboxEnabled: msg.sandboxMode === "on" } : {}),
1570
+ ...(msg.sandboxMode
1571
+ ? { sandboxEnabled: msg.sandboxMode === "on" }
1572
+ : {}),
1403
1573
  }, pastMessages, worktreeOpts);
1404
1574
  const createdSession = this.sessionManager.get(sessionId);
1405
1575
  void this.loadAndSetSessionName(createdSession, "claude", msg.projectPath, claudeSessionId).then(() => {
@@ -1435,8 +1605,12 @@ export class BridgeWebSocketServer {
1435
1605
  detail: `provider=claude session=${claudeSessionId}`,
1436
1606
  });
1437
1607
  this.projectHistory?.addProject(msg.projectPath);
1438
- }).catch((err) => {
1439
- this.send(ws, { type: "error", message: `Failed to load session history: ${err}` });
1608
+ })
1609
+ .catch((err) => {
1610
+ this.send(ws, {
1611
+ type: "error",
1612
+ message: `Failed to load session history: ${err}`,
1613
+ });
1440
1614
  });
1441
1615
  break;
1442
1616
  }
@@ -1454,7 +1628,8 @@ export class BridgeWebSocketServer {
1454
1628
  break;
1455
1629
  }
1456
1630
  case "get_message_images": {
1457
- void extractMessageImages(msg.claudeSessionId, msg.messageUuid).then((images) => {
1631
+ void extractMessageImages(msg.claudeSessionId, msg.messageUuid)
1632
+ .then((images) => {
1458
1633
  const refs = [];
1459
1634
  if (this.imageStore) {
1460
1635
  for (const img of images) {
@@ -1463,10 +1638,19 @@ export class BridgeWebSocketServer {
1463
1638
  refs.push(ref);
1464
1639
  }
1465
1640
  }
1466
- this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: refs });
1467
- }).catch((err) => {
1641
+ this.send(ws, {
1642
+ type: "message_images_result",
1643
+ messageUuid: msg.messageUuid,
1644
+ images: refs,
1645
+ });
1646
+ })
1647
+ .catch((err) => {
1468
1648
  console.error("[ws] Failed to extract message images:", err);
1469
- this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: [] });
1649
+ this.send(ws, {
1650
+ type: "message_images_result",
1651
+ messageUuid: msg.messageUuid,
1652
+ images: [],
1653
+ });
1470
1654
  });
1471
1655
  break;
1472
1656
  }
@@ -1493,32 +1677,70 @@ export class BridgeWebSocketServer {
1493
1677
  case "read_file": {
1494
1678
  const absPath = resolve(msg.projectPath, msg.filePath);
1495
1679
  if (!this.isPathAllowed(absPath)) {
1496
- this.send(ws, { type: "file_content", filePath: msg.filePath, content: "", error: "Path not allowed" });
1680
+ this.send(ws, {
1681
+ type: "file_content",
1682
+ filePath: msg.filePath,
1683
+ content: "",
1684
+ error: "Path not allowed",
1685
+ });
1497
1686
  break;
1498
1687
  }
1499
1688
  void (async () => {
1500
1689
  try {
1501
1690
  if (!existsSync(absPath)) {
1502
- this.send(ws, { type: "file_content", filePath: msg.filePath, content: "", error: "File not found" });
1691
+ this.send(ws, {
1692
+ type: "file_content",
1693
+ filePath: msg.filePath,
1694
+ content: "",
1695
+ error: "File not found",
1696
+ });
1503
1697
  return;
1504
1698
  }
1505
- const maxLines = typeof msg.maxLines === "number" && msg.maxLines > 0 ? msg.maxLines : 5000;
1699
+ const maxLines = typeof msg.maxLines === "number" && msg.maxLines > 0
1700
+ ? msg.maxLines
1701
+ : 5000;
1506
1702
  const raw = await readFile(absPath, "utf-8");
1507
1703
  const ext = extname(absPath).replace(/^\./, "").toLowerCase();
1508
1704
  const languageMap = {
1509
- ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
1510
- py: "python", rb: "ruby", rs: "rust", go: "go", java: "java",
1511
- kt: "kotlin", swift: "swift", dart: "dart", c: "c", cpp: "cpp",
1512
- h: "c", hpp: "cpp", cs: "csharp", sh: "bash", zsh: "bash",
1513
- yml: "yaml", yaml: "yaml", json: "json", toml: "toml",
1514
- md: "markdown", html: "html", css: "css", scss: "css",
1515
- sql: "sql", xml: "xml", dockerfile: "dockerfile",
1516
- makefile: "makefile", gradle: "groovy",
1705
+ ts: "typescript",
1706
+ tsx: "typescript",
1707
+ js: "javascript",
1708
+ jsx: "javascript",
1709
+ py: "python",
1710
+ rb: "ruby",
1711
+ rs: "rust",
1712
+ go: "go",
1713
+ java: "java",
1714
+ kt: "kotlin",
1715
+ swift: "swift",
1716
+ dart: "dart",
1717
+ c: "c",
1718
+ cpp: "cpp",
1719
+ h: "c",
1720
+ hpp: "cpp",
1721
+ cs: "csharp",
1722
+ sh: "bash",
1723
+ zsh: "bash",
1724
+ yml: "yaml",
1725
+ yaml: "yaml",
1726
+ json: "json",
1727
+ toml: "toml",
1728
+ md: "markdown",
1729
+ html: "html",
1730
+ css: "css",
1731
+ scss: "css",
1732
+ sql: "sql",
1733
+ xml: "xml",
1734
+ dockerfile: "dockerfile",
1735
+ makefile: "makefile",
1736
+ gradle: "groovy",
1517
1737
  };
1518
1738
  const language = languageMap[ext] ?? (ext || undefined);
1519
1739
  const lines = raw.split("\n");
1520
1740
  const truncated = lines.length > maxLines;
1521
- const content = truncated ? lines.slice(0, maxLines).join("\n") : raw;
1741
+ const content = truncated
1742
+ ? lines.slice(0, maxLines).join("\n")
1743
+ : raw;
1522
1744
  this.send(ws, {
1523
1745
  type: "file_content",
1524
1746
  filePath: msg.filePath,
@@ -1529,7 +1751,12 @@ export class BridgeWebSocketServer {
1529
1751
  });
1530
1752
  }
1531
1753
  catch (err) {
1532
- this.send(ws, { type: "file_content", filePath: msg.filePath, content: "", error: `Failed to read file: ${err}` });
1754
+ this.send(ws, {
1755
+ type: "file_content",
1756
+ filePath: msg.filePath,
1757
+ content: "",
1758
+ error: `Failed to read file: ${err}`,
1759
+ });
1533
1760
  }
1534
1761
  })();
1535
1762
  break;
@@ -1546,7 +1773,10 @@ export class BridgeWebSocketServer {
1546
1773
  this.send(ws, { type: "file_list", files: [] });
1547
1774
  }
1548
1775
  else {
1549
- this.send(ws, { type: "error", message: `Failed to list files: ${err.message}` });
1776
+ this.send(ws, {
1777
+ type: "error",
1778
+ message: `Failed to list files: ${err.message}`,
1779
+ });
1550
1780
  }
1551
1781
  return;
1552
1782
  }
@@ -1612,15 +1842,27 @@ export class BridgeWebSocketServer {
1612
1842
  }
1613
1843
  case "get_recording": {
1614
1844
  if (!this.recordingStore) {
1615
- this.send(ws, { type: "error", message: "Recording is not enabled on this server" });
1845
+ this.send(ws, {
1846
+ type: "error",
1847
+ message: "Recording is not enabled on this server",
1848
+ });
1616
1849
  break;
1617
1850
  }
1618
- void this.recordingStore.getRecordingContent(msg.sessionId).then((content) => {
1851
+ void this.recordingStore
1852
+ .getRecordingContent(msg.sessionId)
1853
+ .then((content) => {
1619
1854
  if (content !== null) {
1620
- this.send(ws, { type: "recording_content", sessionId: msg.sessionId, content });
1855
+ this.send(ws, {
1856
+ type: "recording_content",
1857
+ sessionId: msg.sessionId,
1858
+ content,
1859
+ });
1621
1860
  }
1622
1861
  else {
1623
- this.send(ws, { type: "error", message: `Recording ${msg.sessionId} not found` });
1862
+ this.send(ws, {
1863
+ type: "error",
1864
+ message: `Recording ${msg.sessionId} not found`,
1865
+ });
1624
1866
  }
1625
1867
  });
1626
1868
  break;
@@ -1641,7 +1883,11 @@ export class BridgeWebSocketServer {
1641
1883
  });
1642
1884
  }
1643
1885
  else {
1644
- this.send(ws, { type: "diff_result", diff: "", error: `Failed to get diff: ${error}` });
1886
+ this.send(ws, {
1887
+ type: "diff_result",
1888
+ diff: "",
1889
+ error: `Failed to get diff: ${error}`,
1890
+ });
1645
1891
  }
1646
1892
  return;
1647
1893
  }
@@ -1653,11 +1899,16 @@ export class BridgeWebSocketServer {
1653
1899
  this.send(ws, { type: "diff_result", diff });
1654
1900
  }
1655
1901
  });
1656
- });
1902
+ }, msg.staged === true
1903
+ ? { staged: true }
1904
+ : msg.staged === false
1905
+ ? { unstaged: true }
1906
+ : undefined);
1657
1907
  break;
1658
1908
  }
1659
1909
  case "get_diff_image": {
1660
- if (!this.isPathAllowed(msg.projectPath) || !this.isPathAllowed(resolve(msg.projectPath, msg.filePath))) {
1910
+ if (!this.isPathAllowed(msg.projectPath) ||
1911
+ !this.isPathAllowed(resolve(msg.projectPath, msg.filePath))) {
1661
1912
  this.send(ws, { type: "error", message: `Path not allowed` });
1662
1913
  break;
1663
1914
  }
@@ -1689,7 +1940,12 @@ export class BridgeWebSocketServer {
1689
1940
  void (async () => {
1690
1941
  try {
1691
1942
  const result = await this.loadDiffImageAsync(msg.projectPath, msg.filePath, version);
1692
- this.send(ws, { type: "diff_image_result", filePath: msg.filePath, version, ...result });
1943
+ this.send(ws, {
1944
+ type: "diff_image_result",
1945
+ filePath: msg.filePath,
1946
+ version,
1947
+ ...result,
1948
+ });
1693
1949
  }
1694
1950
  catch {
1695
1951
  // WebSocket may have closed; ignore send errors.
@@ -1709,7 +1965,10 @@ export class BridgeWebSocketServer {
1709
1965
  this.send(ws, { type: "worktree_list", worktrees, mainBranch });
1710
1966
  }
1711
1967
  catch (err) {
1712
- this.send(ws, { type: "error", message: `Failed to list worktrees: ${err}` });
1968
+ this.send(ws, {
1969
+ type: "error",
1970
+ message: `Failed to list worktrees: ${err}`,
1971
+ });
1713
1972
  }
1714
1973
  break;
1715
1974
  }
@@ -1721,20 +1980,334 @@ export class BridgeWebSocketServer {
1721
1980
  try {
1722
1981
  removeWorktree(msg.projectPath, msg.worktreePath);
1723
1982
  this.worktreeStore.deleteByWorktreePath(msg.worktreePath);
1724
- this.send(ws, { type: "worktree_removed", worktreePath: msg.worktreePath });
1983
+ this.send(ws, {
1984
+ type: "worktree_removed",
1985
+ worktreePath: msg.worktreePath,
1986
+ });
1725
1987
  }
1726
1988
  catch (err) {
1727
- this.send(ws, { type: "error", message: `Failed to remove worktree: ${err}` });
1989
+ this.send(ws, {
1990
+ type: "error",
1991
+ message: `Failed to remove worktree: ${err}`,
1992
+ });
1993
+ }
1994
+ break;
1995
+ }
1996
+ // ---- Git Operations (Phase 1-3) ----
1997
+ case "git_stage": {
1998
+ if (!this.isPathAllowed(msg.projectPath)) {
1999
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2000
+ break;
2001
+ }
2002
+ try {
2003
+ if (msg.files?.length)
2004
+ stageFiles(msg.projectPath, msg.files);
2005
+ if (msg.hunks?.length)
2006
+ stageHunks(msg.projectPath, msg.hunks);
2007
+ this.send(ws, { type: "git_stage_result", success: true });
2008
+ }
2009
+ catch (err) {
2010
+ this.send(ws, {
2011
+ type: "git_stage_result",
2012
+ success: false,
2013
+ error: String(err),
2014
+ });
2015
+ }
2016
+ break;
2017
+ }
2018
+ case "git_unstage": {
2019
+ if (!this.isPathAllowed(msg.projectPath)) {
2020
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2021
+ break;
2022
+ }
2023
+ try {
2024
+ unstageFiles(msg.projectPath, msg.files ?? []);
2025
+ this.send(ws, { type: "git_unstage_result", success: true });
2026
+ }
2027
+ catch (err) {
2028
+ this.send(ws, {
2029
+ type: "git_unstage_result",
2030
+ success: false,
2031
+ error: String(err),
2032
+ });
2033
+ }
2034
+ break;
2035
+ }
2036
+ case "git_unstage_hunks": {
2037
+ if (!this.isPathAllowed(msg.projectPath)) {
2038
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2039
+ break;
2040
+ }
2041
+ try {
2042
+ unstageHunks(msg.projectPath, msg.hunks);
2043
+ this.send(ws, { type: "git_unstage_hunks_result", success: true });
2044
+ }
2045
+ catch (err) {
2046
+ this.send(ws, {
2047
+ type: "git_unstage_hunks_result",
2048
+ success: false,
2049
+ error: String(err),
2050
+ });
2051
+ }
2052
+ break;
2053
+ }
2054
+ case "git_commit": {
2055
+ if (!this.isPathAllowed(msg.projectPath)) {
2056
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2057
+ break;
2058
+ }
2059
+ const session = msg.sessionId
2060
+ ? this.sessionManager.get(msg.sessionId)
2061
+ : undefined;
2062
+ try {
2063
+ const message = msg.autoGenerate === true
2064
+ ? (() => {
2065
+ if (!msg.sessionId) {
2066
+ throw new Error("git_commit with autoGenerate=true requires sessionId");
2067
+ }
2068
+ if (!session) {
2069
+ throw new Error(`Session ${msg.sessionId} not found`);
2070
+ }
2071
+ const expectedPath = resolve(session.worktreePath ?? session.projectPath);
2072
+ const requestedPath = resolve(msg.projectPath);
2073
+ if (requestedPath !== expectedPath) {
2074
+ throw new Error("git_commit projectPath must match the active session cwd");
2075
+ }
2076
+ return generateCommitMessage({
2077
+ provider: session.provider,
2078
+ projectPath: msg.projectPath,
2079
+ model: session.provider === "claude"
2080
+ ? session.process instanceof SdkProcess
2081
+ ? session.process.model
2082
+ : undefined
2083
+ : session.codexSettings?.model,
2084
+ });
2085
+ })()
2086
+ : msg.message ?? "";
2087
+ const result = gitCommit(msg.projectPath, message);
2088
+ this.send(ws, {
2089
+ type: "git_commit_result",
2090
+ success: true,
2091
+ commitHash: result.hash,
2092
+ message: result.message,
2093
+ });
2094
+ }
2095
+ catch (err) {
2096
+ this.send(ws, {
2097
+ type: "git_commit_result",
2098
+ success: false,
2099
+ error: err instanceof Error ? err.message : String(err),
2100
+ });
2101
+ }
2102
+ break;
2103
+ }
2104
+ case "git_push": {
2105
+ if (!this.isPathAllowed(msg.projectPath)) {
2106
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2107
+ break;
2108
+ }
2109
+ try {
2110
+ gitPush(msg.projectPath);
2111
+ this.send(ws, {
2112
+ type: "git_push_result",
2113
+ success: true,
2114
+ });
2115
+ }
2116
+ catch (err) {
2117
+ this.send(ws, {
2118
+ type: "git_push_result",
2119
+ success: false,
2120
+ error: String(err),
2121
+ });
2122
+ }
2123
+ break;
2124
+ }
2125
+ case "git_branches": {
2126
+ if (!this.isPathAllowed(msg.projectPath)) {
2127
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2128
+ break;
2129
+ }
2130
+ try {
2131
+ const result = listBranches(msg.projectPath);
2132
+ this.send(ws, {
2133
+ type: "git_branches_result",
2134
+ current: result.current,
2135
+ branches: result.branches,
2136
+ checkedOutBranches: result.checkedOutBranches,
2137
+ remoteStatusByBranch: result.remoteStatusByBranch,
2138
+ });
2139
+ }
2140
+ catch (err) {
2141
+ this.send(ws, {
2142
+ type: "git_branches_result",
2143
+ current: "",
2144
+ branches: [],
2145
+ remoteStatusByBranch: {},
2146
+ error: String(err),
2147
+ });
2148
+ }
2149
+ break;
2150
+ }
2151
+ case "git_create_branch": {
2152
+ if (!this.isPathAllowed(msg.projectPath)) {
2153
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2154
+ break;
2155
+ }
2156
+ try {
2157
+ createBranch(msg.projectPath, msg.name, msg.checkout);
2158
+ this.send(ws, { type: "git_create_branch_result", success: true });
2159
+ }
2160
+ catch (err) {
2161
+ this.send(ws, {
2162
+ type: "git_create_branch_result",
2163
+ success: false,
2164
+ error: String(err),
2165
+ });
2166
+ }
2167
+ break;
2168
+ }
2169
+ case "git_checkout_branch": {
2170
+ if (!this.isPathAllowed(msg.projectPath)) {
2171
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2172
+ break;
2173
+ }
2174
+ try {
2175
+ checkoutBranch(msg.projectPath, msg.branch);
2176
+ this.send(ws, { type: "git_checkout_branch_result", success: true });
2177
+ }
2178
+ catch (err) {
2179
+ this.send(ws, {
2180
+ type: "git_checkout_branch_result",
2181
+ success: false,
2182
+ error: String(err),
2183
+ });
2184
+ }
2185
+ break;
2186
+ }
2187
+ case "git_revert_file": {
2188
+ if (!this.isPathAllowed(msg.projectPath)) {
2189
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2190
+ break;
2191
+ }
2192
+ try {
2193
+ revertFiles(msg.projectPath, msg.files);
2194
+ this.send(ws, { type: "git_revert_file_result", success: true });
2195
+ }
2196
+ catch (err) {
2197
+ this.send(ws, {
2198
+ type: "git_revert_file_result",
2199
+ success: false,
2200
+ error: String(err),
2201
+ });
2202
+ }
2203
+ break;
2204
+ }
2205
+ case "git_revert_hunks": {
2206
+ if (!this.isPathAllowed(msg.projectPath)) {
2207
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2208
+ break;
2209
+ }
2210
+ try {
2211
+ revertHunks(msg.projectPath, msg.hunks);
2212
+ this.send(ws, { type: "git_revert_hunks_result", success: true });
2213
+ }
2214
+ catch (err) {
2215
+ this.send(ws, {
2216
+ type: "git_revert_hunks_result",
2217
+ success: false,
2218
+ error: String(err),
2219
+ });
2220
+ }
2221
+ break;
2222
+ }
2223
+ case "git_fetch": {
2224
+ if (!this.isPathAllowed(msg.projectPath)) {
2225
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2226
+ break;
2227
+ }
2228
+ try {
2229
+ gitFetch(msg.projectPath);
2230
+ this.send(ws, { type: "git_fetch_result", success: true });
2231
+ }
2232
+ catch (err) {
2233
+ this.send(ws, {
2234
+ type: "git_fetch_result",
2235
+ success: false,
2236
+ error: String(err),
2237
+ });
2238
+ }
2239
+ break;
2240
+ }
2241
+ case "git_pull": {
2242
+ if (!this.isPathAllowed(msg.projectPath)) {
2243
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2244
+ break;
2245
+ }
2246
+ try {
2247
+ const result = gitPull(msg.projectPath);
2248
+ if (result.success) {
2249
+ this.send(ws, {
2250
+ type: "git_pull_result",
2251
+ success: true,
2252
+ message: result.message,
2253
+ });
2254
+ }
2255
+ else {
2256
+ this.send(ws, {
2257
+ type: "git_pull_result",
2258
+ success: false,
2259
+ error: result.message,
2260
+ });
2261
+ }
2262
+ }
2263
+ catch (err) {
2264
+ this.send(ws, {
2265
+ type: "git_pull_result",
2266
+ success: false,
2267
+ error: String(err),
2268
+ });
2269
+ }
2270
+ break;
2271
+ }
2272
+ case "git_remote_status": {
2273
+ if (!this.isPathAllowed(msg.projectPath)) {
2274
+ this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
2275
+ break;
2276
+ }
2277
+ try {
2278
+ const result = gitRemoteStatus(msg.projectPath);
2279
+ this.send(ws, {
2280
+ type: "git_remote_status_result",
2281
+ ahead: result.ahead,
2282
+ behind: result.behind,
2283
+ branch: result.branch,
2284
+ hasUpstream: result.hasUpstream,
2285
+ });
2286
+ }
2287
+ catch (err) {
2288
+ this.send(ws, {
2289
+ type: "git_remote_status_result",
2290
+ ahead: 0,
2291
+ behind: 0,
2292
+ branch: "",
2293
+ hasUpstream: false,
2294
+ });
1728
2295
  }
1729
2296
  break;
1730
2297
  }
1731
2298
  case "rewind_dry_run": {
1732
2299
  const session = this.sessionManager.get(msg.sessionId);
1733
2300
  if (!session) {
1734
- this.send(ws, { type: "rewind_preview", canRewind: false, error: `Session ${msg.sessionId} not found` });
2301
+ this.send(ws, {
2302
+ type: "rewind_preview",
2303
+ canRewind: false,
2304
+ error: `Session ${msg.sessionId} not found`,
2305
+ });
1735
2306
  return;
1736
2307
  }
1737
- this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid, true).then((result) => {
2308
+ this.sessionManager
2309
+ .rewindFiles(msg.sessionId, msg.targetUuid, true)
2310
+ .then((result) => {
1738
2311
  this.send(ws, {
1739
2312
  type: "rewind_preview",
1740
2313
  canRewind: result.canRewind,
@@ -1743,40 +2316,73 @@ export class BridgeWebSocketServer {
1743
2316
  deletions: result.deletions,
1744
2317
  error: result.error,
1745
2318
  });
1746
- }).catch((err) => {
1747
- this.send(ws, { type: "rewind_preview", canRewind: false, error: `Dry run failed: ${err}` });
2319
+ })
2320
+ .catch((err) => {
2321
+ this.send(ws, {
2322
+ type: "rewind_preview",
2323
+ canRewind: false,
2324
+ error: `Dry run failed: ${err}`,
2325
+ });
1748
2326
  });
1749
2327
  break;
1750
2328
  }
1751
2329
  case "rewind": {
1752
2330
  const session = this.sessionManager.get(msg.sessionId);
1753
2331
  if (!session) {
1754
- this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: `Session ${msg.sessionId} not found` });
2332
+ this.send(ws, {
2333
+ type: "rewind_result",
2334
+ success: false,
2335
+ mode: msg.mode,
2336
+ error: `Session ${msg.sessionId} not found`,
2337
+ });
1755
2338
  return;
1756
2339
  }
1757
2340
  const handleError = (err) => {
1758
2341
  const errMsg = err instanceof Error ? err.message : String(err);
1759
- this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: errMsg });
2342
+ this.send(ws, {
2343
+ type: "rewind_result",
2344
+ success: false,
2345
+ mode: msg.mode,
2346
+ error: errMsg,
2347
+ });
1760
2348
  };
1761
2349
  if (msg.mode === "code") {
1762
2350
  // Code-only rewind: rewind files without restarting the conversation
1763
- this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
2351
+ this.sessionManager
2352
+ .rewindFiles(msg.sessionId, msg.targetUuid)
2353
+ .then((result) => {
1764
2354
  if (result.canRewind) {
1765
- this.send(ws, { type: "rewind_result", success: true, mode: "code" });
2355
+ this.send(ws, {
2356
+ type: "rewind_result",
2357
+ success: true,
2358
+ mode: "code",
2359
+ });
1766
2360
  }
1767
2361
  else {
1768
- this.send(ws, { type: "rewind_result", success: false, mode: "code", error: result.error ?? "Cannot rewind files" });
2362
+ this.send(ws, {
2363
+ type: "rewind_result",
2364
+ success: false,
2365
+ mode: "code",
2366
+ error: result.error ?? "Cannot rewind files",
2367
+ });
1769
2368
  }
1770
- }).catch(handleError);
2369
+ })
2370
+ .catch(handleError);
1771
2371
  }
1772
2372
  else if (msg.mode === "conversation") {
1773
2373
  // Conversation-only rewind: restart session at the target UUID
1774
2374
  try {
1775
2375
  this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
1776
- this.send(ws, { type: "rewind_result", success: true, mode: "conversation" });
2376
+ this.send(ws, {
2377
+ type: "rewind_result",
2378
+ success: true,
2379
+ mode: "conversation",
2380
+ });
1777
2381
  // Notify the new session ID
1778
2382
  const newSession = this.sessionManager.get(newSessionId);
1779
- const rewindPermMode = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
2383
+ const rewindPermMode = newSession?.process instanceof SdkProcess
2384
+ ? newSession.process.permissionMode
2385
+ : undefined;
1780
2386
  this.send(ws, this.buildSessionCreatedMessage({
1781
2387
  sessionId: newSessionId,
1782
2388
  provider: newSession?.provider ?? "claude",
@@ -1794,16 +2400,29 @@ export class BridgeWebSocketServer {
1794
2400
  }
1795
2401
  else {
1796
2402
  // Both: rewind files first, then rewind conversation
1797
- this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
2403
+ this.sessionManager
2404
+ .rewindFiles(msg.sessionId, msg.targetUuid)
2405
+ .then((result) => {
1798
2406
  if (!result.canRewind) {
1799
- this.send(ws, { type: "rewind_result", success: false, mode: "both", error: result.error ?? "Cannot rewind files" });
2407
+ this.send(ws, {
2408
+ type: "rewind_result",
2409
+ success: false,
2410
+ mode: "both",
2411
+ error: result.error ?? "Cannot rewind files",
2412
+ });
1800
2413
  return;
1801
2414
  }
1802
2415
  try {
1803
2416
  this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
1804
- this.send(ws, { type: "rewind_result", success: true, mode: "both" });
2417
+ this.send(ws, {
2418
+ type: "rewind_result",
2419
+ success: true,
2420
+ mode: "both",
2421
+ });
1805
2422
  const newSession = this.sessionManager.get(newSessionId);
1806
- const rewindPermMode2 = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
2423
+ const rewindPermMode2 = newSession?.process instanceof SdkProcess
2424
+ ? newSession.process.permissionMode
2425
+ : undefined;
1807
2426
  this.send(ws, this.buildSessionCreatedMessage({
1808
2427
  sessionId: newSessionId,
1809
2428
  provider: newSession?.provider ?? "claude",
@@ -1818,7 +2437,8 @@ export class BridgeWebSocketServer {
1818
2437
  catch (err) {
1819
2438
  handleError(err);
1820
2439
  }
1821
- }).catch(handleError);
2440
+ })
2441
+ .catch(handleError);
1822
2442
  }
1823
2443
  break;
1824
2444
  }
@@ -1859,7 +2479,11 @@ export class BridgeWebSocketServer {
1859
2479
  const meta = await this.galleryStore.addImage(result.filePath, msg.projectPath, msg.sessionId);
1860
2480
  if (meta) {
1861
2481
  const info = this.galleryStore.metaToInfo(meta);
1862
- this.send(ws, { type: "screenshot_result", success: true, image: info });
2482
+ this.send(ws, {
2483
+ type: "screenshot_result",
2484
+ success: true,
2485
+ image: info,
2486
+ });
1863
2487
  this.broadcast({ type: "gallery_new_image", image: info });
1864
2488
  return;
1865
2489
  }
@@ -1886,23 +2510,44 @@ export class BridgeWebSocketServer {
1886
2510
  }
1887
2511
  case "backup_prompt_history": {
1888
2512
  if (!this.promptHistoryBackup) {
1889
- this.send(ws, { type: "prompt_history_backup_result", success: false, error: "Backup store not available" });
2513
+ this.send(ws, {
2514
+ type: "prompt_history_backup_result",
2515
+ success: false,
2516
+ error: "Backup store not available",
2517
+ });
1890
2518
  break;
1891
2519
  }
1892
2520
  const buf = Buffer.from(msg.data, "base64");
1893
- this.promptHistoryBackup.save(buf, msg.appVersion, msg.dbVersion).then((meta) => {
1894
- this.send(ws, { type: "prompt_history_backup_result", success: true, backedUpAt: meta.backedUpAt });
1895
- }).catch((err) => {
1896
- this.send(ws, { type: "prompt_history_backup_result", success: false, error: err instanceof Error ? err.message : String(err) });
2521
+ this.promptHistoryBackup
2522
+ .save(buf, msg.appVersion, msg.dbVersion)
2523
+ .then((meta) => {
2524
+ this.send(ws, {
2525
+ type: "prompt_history_backup_result",
2526
+ success: true,
2527
+ backedUpAt: meta.backedUpAt,
2528
+ });
2529
+ })
2530
+ .catch((err) => {
2531
+ this.send(ws, {
2532
+ type: "prompt_history_backup_result",
2533
+ success: false,
2534
+ error: err instanceof Error ? err.message : String(err),
2535
+ });
1897
2536
  });
1898
2537
  break;
1899
2538
  }
1900
2539
  case "restore_prompt_history": {
1901
2540
  if (!this.promptHistoryBackup) {
1902
- this.send(ws, { type: "prompt_history_restore_result", success: false, error: "Backup store not available" });
2541
+ this.send(ws, {
2542
+ type: "prompt_history_restore_result",
2543
+ success: false,
2544
+ error: "Backup store not available",
2545
+ });
1903
2546
  break;
1904
2547
  }
1905
- this.promptHistoryBackup.load().then((result) => {
2548
+ this.promptHistoryBackup
2549
+ .load()
2550
+ .then((result) => {
1906
2551
  if (result) {
1907
2552
  this.send(ws, {
1908
2553
  type: "prompt_history_restore_result",
@@ -1914,10 +2559,19 @@ export class BridgeWebSocketServer {
1914
2559
  });
1915
2560
  }
1916
2561
  else {
1917
- this.send(ws, { type: "prompt_history_restore_result", success: false, error: "No backup found" });
2562
+ this.send(ws, {
2563
+ type: "prompt_history_restore_result",
2564
+ success: false,
2565
+ error: "No backup found",
2566
+ });
1918
2567
  }
1919
- }).catch((err) => {
1920
- this.send(ws, { type: "prompt_history_restore_result", success: false, error: err instanceof Error ? err.message : String(err) });
2568
+ })
2569
+ .catch((err) => {
2570
+ this.send(ws, {
2571
+ type: "prompt_history_restore_result",
2572
+ success: false,
2573
+ error: err instanceof Error ? err.message : String(err),
2574
+ });
1921
2575
  });
1922
2576
  break;
1923
2577
  }
@@ -1926,15 +2580,28 @@ export class BridgeWebSocketServer {
1926
2580
  this.send(ws, { type: "prompt_history_backup_info", exists: false });
1927
2581
  break;
1928
2582
  }
1929
- this.promptHistoryBackup.getMeta().then((meta) => {
2583
+ this.promptHistoryBackup
2584
+ .getMeta()
2585
+ .then((meta) => {
1930
2586
  if (meta) {
1931
- this.send(ws, { type: "prompt_history_backup_info", exists: true, ...meta });
2587
+ this.send(ws, {
2588
+ type: "prompt_history_backup_info",
2589
+ exists: true,
2590
+ ...meta,
2591
+ });
1932
2592
  }
1933
2593
  else {
1934
- this.send(ws, { type: "prompt_history_backup_info", exists: false });
2594
+ this.send(ws, {
2595
+ type: "prompt_history_backup_info",
2596
+ exists: false,
2597
+ });
1935
2598
  }
1936
- }).catch(() => {
1937
- this.send(ws, { type: "prompt_history_backup_info", exists: false });
2599
+ })
2600
+ .catch(() => {
2601
+ this.send(ws, {
2602
+ type: "prompt_history_backup_info",
2603
+ exists: false,
2604
+ });
1938
2605
  });
1939
2606
  break;
1940
2607
  }
@@ -1981,10 +2648,12 @@ export class BridgeWebSocketServer {
1981
2648
  if (runningSession) {
1982
2649
  this.sessionManager.renameSession(sessionId, name);
1983
2650
  // Persist to provider storage
1984
- if (runningSession.provider === "claude" && runningSession.claudeSessionId) {
2651
+ if (runningSession.provider === "claude" &&
2652
+ runningSession.claudeSessionId) {
1985
2653
  await renameClaudeSession(runningSession.worktreePath ?? runningSession.projectPath, runningSession.claudeSessionId, name);
1986
2654
  }
1987
- else if (runningSession.provider === "codex" && runningSession.process) {
2655
+ else if (runningSession.provider === "codex" &&
2656
+ runningSession.process) {
1988
2657
  try {
1989
2658
  await runningSession.process.renameThread(name ?? "");
1990
2659
  }
@@ -2028,13 +2697,27 @@ export class BridgeWebSocketServer {
2028
2697
  sendSessionList(ws) {
2029
2698
  this.pruneDebugEvents();
2030
2699
  const sessions = this.sessionManager.list();
2031
- this.send(ws, { type: "session_list", sessions, allowedDirs: this.allowedDirs, claudeModels: CLAUDE_MODELS, codexModels: CODEX_MODELS, bridgeVersion: getPackageVersion() });
2700
+ this.send(ws, {
2701
+ type: "session_list",
2702
+ sessions,
2703
+ allowedDirs: this.allowedDirs,
2704
+ claudeModels: CLAUDE_MODELS,
2705
+ codexModels: CODEX_MODELS,
2706
+ bridgeVersion: getPackageVersion(),
2707
+ });
2032
2708
  }
2033
2709
  /** Broadcast session list to all connected clients. */
2034
2710
  broadcastSessionList() {
2035
2711
  this.pruneDebugEvents();
2036
2712
  const sessions = this.sessionManager.list();
2037
- this.broadcast({ type: "session_list", sessions, allowedDirs: this.allowedDirs, claudeModels: CLAUDE_MODELS, codexModels: CODEX_MODELS, bridgeVersion: getPackageVersion() });
2713
+ this.broadcast({
2714
+ type: "session_list",
2715
+ sessions,
2716
+ allowedDirs: this.allowedDirs,
2717
+ claudeModels: CLAUDE_MODELS,
2718
+ codexModels: CODEX_MODELS,
2719
+ bridgeVersion: getPackageVersion(),
2720
+ });
2038
2721
  }
2039
2722
  broadcastSessionMessage(sessionId, msg) {
2040
2723
  this.maybeSendPushNotification(sessionId, msg);
@@ -2046,7 +2729,9 @@ export class BridgeWebSocketServer {
2046
2729
  });
2047
2730
  this.recordingStore?.record(sessionId, "outgoing", msg);
2048
2731
  // Update recording meta with claudeSessionId when it becomes available
2049
- if ((msg.type === "system" || msg.type === "result") && "sessionId" in msg && msg.sessionId) {
2732
+ if ((msg.type === "system" || msg.type === "result") &&
2733
+ "sessionId" in msg &&
2734
+ msg.sessionId) {
2050
2735
  const session = this.sessionManager.get(sessionId);
2051
2736
  if (session) {
2052
2737
  this.recordingStore?.saveMeta(sessionId, {
@@ -2085,16 +2770,21 @@ export class BridgeWebSocketServer {
2085
2770
  });
2086
2771
  }
2087
2772
  getActiveCodexProcess() {
2088
- const summary = this.sessionManager.list().find((session) => session.provider === "codex");
2773
+ const summary = this.sessionManager
2774
+ .list()
2775
+ .find((session) => session.provider === "codex");
2089
2776
  if (!summary)
2090
2777
  return null;
2091
2778
  const session = this.sessionManager.get(summary.id);
2092
- return session?.provider === "codex" ? session.process : null;
2779
+ return session?.provider === "codex"
2780
+ ? session.process
2781
+ : null;
2093
2782
  }
2094
2783
  async listRecentCodexThreads(msg) {
2095
2784
  const limit = msg.limit ?? 20;
2096
2785
  const offset = msg.offset ?? 0;
2097
- const process = this.getActiveCodexProcess() ?? await this.createStandaloneCodexProcess(msg.projectPath);
2786
+ const process = this.getActiveCodexProcess() ??
2787
+ (await this.createStandaloneCodexProcess(msg.projectPath));
2098
2788
  const isStandalone = process !== this.getActiveCodexProcess();
2099
2789
  try {
2100
2790
  const result = await process.listThreads({
@@ -2179,7 +2869,9 @@ export class BridgeWebSocketServer {
2179
2869
  this.notifiedPermissionToolUses.set(sessionId, seen);
2180
2870
  const isAskUserQuestion = msg.toolName === "AskUserQuestion";
2181
2871
  const isExitPlanMode = msg.toolName === "ExitPlanMode";
2182
- const eventType = isAskUserQuestion ? "ask_user_question" : "approval_required";
2872
+ const eventType = isAskUserQuestion
2873
+ ? "ask_user_question"
2874
+ : "approval_required";
2183
2875
  // Extract question text for AskUserQuestion (standard mode only)
2184
2876
  let questionText;
2185
2877
  if (!privacy && isAskUserQuestion) {
@@ -2202,30 +2894,38 @@ export class BridgeWebSocketServer {
2202
2894
  let body;
2203
2895
  if (isExitPlanMode) {
2204
2896
  const titleKey = "plan_ready_title";
2205
- title = label ? `${t(locale, titleKey)} - ${label}` : t(locale, titleKey);
2897
+ title = label
2898
+ ? `${t(locale, titleKey)} - ${label}`
2899
+ : t(locale, titleKey);
2206
2900
  body = t(locale, "plan_ready_body");
2207
2901
  }
2208
2902
  else if (isAskUserQuestion) {
2209
2903
  const titleKey = "ask_title";
2210
- title = label ? `${t(locale, titleKey)} - ${label}` : t(locale, titleKey);
2904
+ title = label
2905
+ ? `${t(locale, titleKey)} - ${label}`
2906
+ : t(locale, titleKey);
2211
2907
  body = privacy
2212
2908
  ? t(locale, "ask_body_private")
2213
2909
  : (questionText ?? t(locale, "ask_default_body"));
2214
2910
  }
2215
2911
  else {
2216
2912
  const titleKey = "approval_title";
2217
- title = label ? `${t(locale, titleKey)} - ${label}` : t(locale, titleKey);
2913
+ title = label
2914
+ ? `${t(locale, titleKey)} - ${label}`
2915
+ : t(locale, titleKey);
2218
2916
  body = privacy
2219
2917
  ? t(locale, "approval_body_private")
2220
2918
  : t(locale, "approval_body", { toolName: msg.toolName });
2221
2919
  }
2222
- void this.pushRelay.notify({
2920
+ void this.pushRelay
2921
+ .notify({
2223
2922
  eventType,
2224
2923
  title,
2225
2924
  body,
2226
2925
  locale,
2227
2926
  data,
2228
- }).catch((err) => {
2927
+ })
2928
+ .catch((err) => {
2229
2929
  const detail = err instanceof Error ? err.message : String(err);
2230
2930
  console.warn(`[ws] Failed to send push notification (${eventType}, ${locale}): ${detail}`);
2231
2931
  });
@@ -2260,12 +2960,18 @@ export class BridgeWebSocketServer {
2260
2960
  for (const locale of this.getRegisteredLocales()) {
2261
2961
  let title;
2262
2962
  if (privacy) {
2263
- title = isSuccess ? t(locale, "task_completed") : t(locale, "error_occurred");
2963
+ title = isSuccess
2964
+ ? t(locale, "task_completed")
2965
+ : t(locale, "error_occurred");
2264
2966
  }
2265
2967
  else {
2266
2968
  title = label
2267
- ? (isSuccess ? `✅ ${label}` : `❌ ${label}`)
2268
- : (isSuccess ? t(locale, "task_completed") : t(locale, "error_occurred"));
2969
+ ? isSuccess
2970
+ ? `✅ ${label}`
2971
+ : `❌ ${label}`
2972
+ : isSuccess
2973
+ ? t(locale, "task_completed")
2974
+ : t(locale, "error_occurred");
2269
2975
  }
2270
2976
  let body;
2271
2977
  if (privacy) {
@@ -2280,15 +2986,19 @@ export class BridgeWebSocketServer {
2280
2986
  : `${t(locale, "session_completed")}${stats}`;
2281
2987
  }
2282
2988
  else {
2283
- body = msg.error ? msg.error.slice(0, 120) : t(locale, "session_failed");
2989
+ body = msg.error
2990
+ ? msg.error.slice(0, 120)
2991
+ : t(locale, "session_failed");
2284
2992
  }
2285
- void this.pushRelay.notify({
2993
+ void this.pushRelay
2994
+ .notify({
2286
2995
  eventType,
2287
2996
  title,
2288
2997
  body,
2289
2998
  locale,
2290
2999
  data,
2291
- }).catch((err) => {
3000
+ })
3001
+ .catch((err) => {
2292
3002
  const detail = err instanceof Error ? err.message : String(err);
2293
3003
  console.warn(`[ws] Failed to send push notification (${eventType}, ${locale}): ${detail}`);
2294
3004
  });
@@ -2320,34 +3030,89 @@ export class BridgeWebSocketServer {
2320
3030
  broadcastGalleryNewImage(image) {
2321
3031
  this.broadcast({ type: "gallery_new_image", image });
2322
3032
  }
2323
- collectGitDiff(cwd, callback) {
3033
+ collectGitDiff(cwd, callback, options) {
2324
3034
  const execOpts = { cwd, maxBuffer: 10 * 1024 * 1024 };
2325
- // Collect untracked files so they appear in the diff.
2326
- let untrackedFiles = [];
3035
+ // Staged only: git diff --cached
3036
+ if (options?.staged) {
3037
+ execFile("git", ["diff", "--cached", "--no-color"], execOpts, (err, stdout) => {
3038
+ if (err) {
3039
+ callback({ diff: "", error: err.message });
3040
+ return;
3041
+ }
3042
+ callback({ diff: stdout });
3043
+ });
3044
+ return;
3045
+ }
3046
+ // Unstaged only: git diff (working tree vs index) — original behavior
3047
+ if (options?.unstaged) {
3048
+ // Collect untracked files so they appear in the diff.
3049
+ let untrackedFiles = [];
3050
+ try {
3051
+ const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd })
3052
+ .toString()
3053
+ .trim();
3054
+ untrackedFiles = out ? out.split("\n") : [];
3055
+ }
3056
+ catch {
3057
+ // Ignore errors: non-git directories are handled by git diff callback.
3058
+ }
3059
+ // Temporarily stage untracked files with --intent-to-add.
3060
+ if (untrackedFiles.length > 0) {
3061
+ try {
3062
+ execFileSync("git", ["add", "--intent-to-add", ...untrackedFiles], {
3063
+ cwd,
3064
+ });
3065
+ }
3066
+ catch {
3067
+ // Ignore staging errors.
3068
+ }
3069
+ }
3070
+ execFile("git", ["diff", "--no-color"], execOpts, (err, stdout) => {
3071
+ // Revert intent-to-add for untracked files.
3072
+ if (untrackedFiles.length > 0) {
3073
+ try {
3074
+ execFileSync("git", ["reset", "--", ...untrackedFiles], { cwd });
3075
+ }
3076
+ catch {
3077
+ // Ignore reset errors.
3078
+ }
3079
+ }
3080
+ if (err) {
3081
+ callback({ diff: "", error: err.message });
3082
+ return;
3083
+ }
3084
+ callback({ diff: stdout });
3085
+ });
3086
+ return;
3087
+ }
3088
+ // All mode (no options): git diff HEAD — shows both staged and unstaged vs HEAD
3089
+ let untrackedFilesAll = [];
2327
3090
  try {
2328
- const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd }).toString().trim();
2329
- untrackedFiles = out ? out.split("\n") : [];
3091
+ const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd })
3092
+ .toString()
3093
+ .trim();
3094
+ untrackedFilesAll = out ? out.split("\n") : [];
2330
3095
  }
2331
3096
  catch {
2332
- // Ignore errors: non-git directories are handled by git diff callback.
3097
+ // Ignore
2333
3098
  }
2334
- // Temporarily stage untracked files with --intent-to-add.
2335
- if (untrackedFiles.length > 0) {
3099
+ if (untrackedFilesAll.length > 0) {
2336
3100
  try {
2337
- execFileSync("git", ["add", "--intent-to-add", ...untrackedFiles], { cwd });
3101
+ execFileSync("git", ["add", "--intent-to-add", ...untrackedFilesAll], {
3102
+ cwd,
3103
+ });
2338
3104
  }
2339
3105
  catch {
2340
- // Ignore staging errors.
3106
+ // Ignore
2341
3107
  }
2342
3108
  }
2343
- execFile("git", ["diff", "--no-color"], execOpts, (err, stdout) => {
2344
- // Revert intent-to-add for untracked files.
2345
- if (untrackedFiles.length > 0) {
3109
+ execFile("git", ["diff", "HEAD", "--no-color"], execOpts, (err, stdout) => {
3110
+ if (untrackedFilesAll.length > 0) {
2346
3111
  try {
2347
- execFileSync("git", ["reset", "--", ...untrackedFiles], { cwd });
3112
+ execFileSync("git", ["reset", "--", ...untrackedFilesAll], { cwd });
2348
3113
  }
2349
3114
  catch {
2350
- // Ignore reset errors.
3115
+ // Ignore
2351
3116
  }
2352
3117
  }
2353
3118
  if (err) {
@@ -2361,7 +3126,14 @@ export class BridgeWebSocketServer {
2361
3126
  // Image diff helpers
2362
3127
  // ---------------------------------------------------------------------------
2363
3128
  static IMAGE_EXTENSIONS = new Set([
2364
- ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svg",
3129
+ ".png",
3130
+ ".jpg",
3131
+ ".jpeg",
3132
+ ".gif",
3133
+ ".webp",
3134
+ ".ico",
3135
+ ".bmp",
3136
+ ".svg",
2365
3137
  ]);
2366
3138
  // Image diff thresholds (configurable via environment variables)
2367
3139
  // - Auto-display: images ≤ threshold are sent inline as base64
@@ -2535,7 +3307,9 @@ export class BridgeWebSocketServer {
2535
3307
  }
2536
3308
  }
2537
3309
  extractSessionIdFromClientMessage(msg) {
2538
- return "sessionId" in msg && typeof msg.sessionId === "string" ? msg.sessionId : undefined;
3310
+ return "sessionId" in msg && typeof msg.sessionId === "string"
3311
+ ? msg.sessionId
3312
+ : undefined;
2539
3313
  }
2540
3314
  extractSessionIdFromServerMessage(msg) {
2541
3315
  if ("sessionId" in msg && typeof msg.sessionId === "string")
@@ -2564,8 +3338,7 @@ export class BridgeWebSocketServer {
2564
3338
  return events.slice(-capped);
2565
3339
  }
2566
3340
  buildHistorySummary(history) {
2567
- const lines = history
2568
- .map((msg, index) => {
3341
+ const lines = history.map((msg, index) => {
2569
3342
  const num = String(index + 1).padStart(3, "0");
2570
3343
  return `${num}. ${this.summarizeServerMessage(msg)}`;
2571
3344
  });
@@ -2620,7 +3393,10 @@ export class BridgeWebSocketServer {
2620
3393
  return text ? `assistant: ${text}` : "assistant";
2621
3394
  }
2622
3395
  case "tool_result": {
2623
- const contentPreview = msg.content.replace(/\s+/g, " ").trim().slice(0, 100);
3396
+ const contentPreview = msg.content
3397
+ .replace(/\s+/g, " ")
3398
+ .trim()
3399
+ .slice(0, 100);
2624
3400
  return `${msg.toolName ?? "tool_result"}(${msg.toolUseId}) ${contentPreview}`;
2625
3401
  }
2626
3402
  case "permission_request":