@ccpocket/bridge 1.80.1 → 1.81.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,7 +7,7 @@ import { promisify } from "node:util";
7
7
  import { WebSocketServer, WebSocket } from "ws";
8
8
  import { SessionManager, MAX_HISTORY_PER_SESSION, } from "./session.js";
9
9
  import { SdkProcess, listAvailableClaudeModels, } from "./sdk-process.js";
10
- import { codexErrorMessage, CodexRpcError, CodexProcess, } from "./codex-process.js";
10
+ import { codexErrorMessage, isCodexThreadWriterConflict, CodexRpcError, CodexProcess, } from "./codex-process.js";
11
11
  import { stopManagedCodexAppServers } from "./codex-transport.js";
12
12
  import { parseClientMessage, } from "./parser.js";
13
13
  import { BRIDGE_PROTOCOL_MAX_VERSION, BRIDGE_PROTOCOL_MIN_VERSION, clientProtocolRange, negotiateProtocolVersion, } from "./protocol-version.js";
@@ -104,6 +104,7 @@ const OPT_IN_SERVER_MESSAGES = new Set([
104
104
  "goal_state",
105
105
  "guardian_approval",
106
106
  "prompt_history_status",
107
+ "projects",
107
108
  "push_registration_result",
108
109
  ]);
109
110
  function isRecord(value) {
@@ -517,6 +518,8 @@ export class BridgeWebSocketServer {
517
518
  uploadStore;
518
519
  galleryStore;
519
520
  projectHistory;
521
+ workspaceStore;
522
+ pendingSessionWorkspaces = new Map();
520
523
  debugTraceStore;
521
524
  recordingStore;
522
525
  worktreeStore;
@@ -570,7 +573,7 @@ export class BridgeWebSocketServer {
570
573
  pendingClaudeResumeInputs = new WeakMap();
571
574
  resumeOperations = new Map();
572
575
  constructor(options) {
573
- const { server, apiKey, allowedDirs, imageStore, mediaStore, uploadStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup, promptHistoryStore, platform, fileListMaxEntries, fileListMaxBytes, fileDownloadMaxBytes, fileUploadMaxBytes, deltaBatchMs, deltaBatchMaxChars, } = options;
576
+ const { server, apiKey, allowedDirs, imageStore, mediaStore, uploadStore, galleryStore, projectHistory, workspaceStore, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup, promptHistoryStore, platform, fileListMaxEntries, fileListMaxBytes, fileDownloadMaxBytes, fileUploadMaxBytes, deltaBatchMs, deltaBatchMaxChars, } = options;
574
577
  this.apiKey = apiKey ?? null;
575
578
  this.allowedDirs = allowedDirs ?? [];
576
579
  this.imageStore = imageStore ?? null;
@@ -578,6 +581,7 @@ export class BridgeWebSocketServer {
578
581
  this.uploadStore = uploadStore ?? null;
579
582
  this.galleryStore = galleryStore ?? null;
580
583
  this.projectHistory = projectHistory ?? null;
584
+ this.workspaceStore = workspaceStore ?? null;
581
585
  this.debugTraceStore = debugTraceStore ?? new DebugTraceStore();
582
586
  this.recordingStore = recordingStore ?? null;
583
587
  this.worktreeStore = new WorktreeStore();
@@ -619,7 +623,10 @@ export class BridgeWebSocketServer {
619
623
  const info = this.galleryStore.metaToInfo(meta);
620
624
  this.broadcast({ type: "gallery_new_image", image: info });
621
625
  }
622
- }, this.worktreeStore, () => this.broadcastSessionList());
626
+ }, this.worktreeStore, (sessionId) => {
627
+ void this.persistPendingSessionWorkspace(sessionId);
628
+ this.broadcastSessionList();
629
+ });
623
630
  this.wss.on("connection", (ws, req) => {
624
631
  // API key authentication
625
632
  if (this.apiKey) {
@@ -1069,6 +1076,7 @@ export class BridgeWebSocketServer {
1069
1076
  }
1070
1077
  const projectPath = session.projectPath;
1071
1078
  const codexSettings = session.codexSettings;
1079
+ const workspace = this.workspaceForRuntimeSession(session);
1072
1080
  const worktreeOpts = session.worktreePath
1073
1081
  ? {
1074
1082
  existingWorktreePath: session.worktreePath,
@@ -1086,6 +1094,7 @@ export class BridgeWebSocketServer {
1086
1094
  ...(codexSettings ?? {}),
1087
1095
  threadId,
1088
1096
  }));
1097
+ this.attachWorkspaceToRuntimeSession(newSessionId, workspace);
1089
1098
  const newSession = this.sessionManager.get(newSessionId);
1090
1099
  this.send(ws, {
1091
1100
  type: "rewind_result",
@@ -1152,6 +1161,7 @@ export class BridgeWebSocketServer {
1152
1161
  }
1153
1162
  const projectPath = session.projectPath;
1154
1163
  const codexSettings = session.codexSettings;
1164
+ const workspace = this.workspaceForRuntimeSession(session);
1155
1165
  const worktreeOpts = session.worktreePath
1156
1166
  ? {
1157
1167
  existingWorktreePath: session.worktreePath,
@@ -1174,6 +1184,7 @@ export class BridgeWebSocketServer {
1174
1184
  ...(codexSettings ?? {}),
1175
1185
  threadId: forkedThreadId,
1176
1186
  }));
1187
+ this.attachWorkspaceToRuntimeSession(newSessionId, workspace);
1177
1188
  const newSession = this.sessionManager.get(newSessionId);
1178
1189
  this.send(ws, this.buildSessionCreatedMessage({
1179
1190
  sessionId: newSessionId,
@@ -1852,7 +1863,7 @@ export class BridgeWebSocketServer {
1852
1863
  // Send session list and project history on connect
1853
1864
  this.refreshConnectionMetadata();
1854
1865
  this.sendSessionList(ws);
1855
- const projects = this.projectHistory?.getProjects() ?? [];
1866
+ const projects = this.legacyProjectHistoryProjects();
1856
1867
  this.send(ws, { type: "project_history", projects });
1857
1868
  ws.on("message", (data) => {
1858
1869
  if (this.rejectedProtocolClients.has(ws))
@@ -1954,7 +1965,32 @@ export class BridgeWebSocketServer {
1954
1965
  }
1955
1966
  switch (msg.type) {
1956
1967
  case "start": {
1957
- const projectPath = resolvePlatformPath(msg.projectPath, this.platform);
1968
+ let projectPath = resolvePlatformPath(msg.projectPath, this.platform);
1969
+ let resolvedWorkspace;
1970
+ if (msg.projectId) {
1971
+ const project = this.workspaceStore?.getProject(msg.projectId);
1972
+ if (!project) {
1973
+ this.send(ws, {
1974
+ type: "error",
1975
+ requestId: msg.requestId,
1976
+ errorCode: "project_not_found",
1977
+ message: `Project not found: ${msg.projectId}`,
1978
+ });
1979
+ break;
1980
+ }
1981
+ const normalized = this.normalizeWorkspaceRoots(project.rootPaths);
1982
+ if (normalized.deniedRoot || !normalized.roots) {
1983
+ this.send(ws, this.buildPathNotAllowedError(normalized.deniedRoot ?? project.rootPaths[0]));
1984
+ break;
1985
+ }
1986
+ projectPath = normalized.roots[0];
1987
+ resolvedWorkspace = {
1988
+ kind: "project",
1989
+ projectId: project.id,
1990
+ projectName: project.name,
1991
+ rootPaths: normalized.roots,
1992
+ };
1993
+ }
1958
1994
  if (!this.isPathAllowed(projectPath)) {
1959
1995
  this.send(ws, {
1960
1996
  ...this.buildPathNotAllowedError(msg.projectPath),
@@ -2012,9 +2048,9 @@ export class BridgeWebSocketServer {
2012
2048
  break;
2013
2049
  }
2014
2050
  }
2015
- const additionalWritableRoots = provider === "codex"
2016
- ? this.normalizeAdditionalWritableRoots(msg.additionalWritableRoots, projectPath)
2017
- : {};
2051
+ const requestedAdditionalRoots = resolvedWorkspace?.rootPaths.slice(1) ??
2052
+ msg.additionalWritableRoots;
2053
+ const additionalWritableRoots = this.normalizeAdditionalWritableRoots(requestedAdditionalRoots, projectPath);
2018
2054
  if (additionalWritableRoots.deniedRoot) {
2019
2055
  this.send(ws, {
2020
2056
  ...this.buildPathNotAllowedError(additionalWritableRoots.deniedRoot),
@@ -2041,6 +2077,7 @@ export class BridgeWebSocketServer {
2041
2077
  ...(msg.sandboxMode
2042
2078
  ? { sandboxEnabled: msg.sandboxMode === "on" }
2043
2079
  : {}),
2080
+ additionalDirectories: additionalWritableRoots.roots,
2044
2081
  },
2045
2082
  worktreeOptions: {
2046
2083
  useWorktree: msg.useWorktree,
@@ -2086,6 +2123,10 @@ export class BridgeWebSocketServer {
2086
2123
  usedFallback: false,
2087
2124
  };
2088
2125
  const createdSession = this.sessionManager.get(sessionId);
2126
+ if (resolvedWorkspace) {
2127
+ this.pendingSessionWorkspaces.set(sessionId, resolvedWorkspace);
2128
+ void this.persistPendingSessionWorkspace(sessionId);
2129
+ }
2089
2130
  const cached = this.sessionManager.getCachedCommands(provider, createdSession?.worktreePath ?? projectPath);
2090
2131
  // Load saved session name from CLI storage (for resumed sessions)
2091
2132
  void this.loadAndSetSessionName(createdSession, provider, projectPath, msg.sessionId).then(() => {
@@ -2152,7 +2193,9 @@ export class BridgeWebSocketServer {
2152
2193
  projectPath,
2153
2194
  createdAt: new Date().toISOString(),
2154
2195
  });
2155
- this.projectHistory?.addProject(projectPath);
2196
+ if (!resolvedWorkspace) {
2197
+ this.projectHistory?.addProject(projectPath);
2198
+ }
2156
2199
  }
2157
2200
  catch (err) {
2158
2201
  console.error(`[ws] Failed to start session:`, err);
@@ -2805,6 +2848,7 @@ export class BridgeWebSocketServer {
2805
2848
  const worktreePath = session.worktreePath;
2806
2849
  const worktreeBranch = session.worktreeBranch;
2807
2850
  const sessionName = session.name;
2851
+ const workspace = this.workspaceForRuntimeSession(session);
2808
2852
  this.destroySession(oldSessionId);
2809
2853
  console.log(`[ws] Permission mode change: destroyed session ${oldSessionId}`);
2810
2854
  const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") ||
@@ -2822,8 +2866,10 @@ export class BridgeWebSocketServer {
2822
2866
  serviceTier: oldSettings.serviceTier,
2823
2867
  networkAccessEnabled: oldSettings.networkAccessEnabled,
2824
2868
  webSearchMode: oldSettings.webSearchMode,
2869
+ additionalWritableRoots: oldSettings.additionalWritableRoots,
2825
2870
  collaborationMode: newCollaboration,
2826
2871
  }));
2872
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
2827
2873
  const newSession = this.sessionManager.get(newId);
2828
2874
  if (newSession && sessionName)
2829
2875
  newSession.name = sessionName;
@@ -2883,8 +2929,10 @@ export class BridgeWebSocketServer {
2883
2929
  serviceTier: oldSettings.serviceTier,
2884
2930
  networkAccessEnabled: oldSettings.networkAccessEnabled,
2885
2931
  webSearchMode: oldSettings.webSearchMode,
2932
+ additionalWritableRoots: oldSettings.additionalWritableRoots,
2886
2933
  collaborationMode: newCollaboration,
2887
2934
  }));
2935
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
2888
2936
  const newSession = this.sessionManager.get(newId);
2889
2937
  if (newSession && sessionName) {
2890
2938
  newSession.name = sessionName;
@@ -3174,6 +3222,7 @@ export class BridgeWebSocketServer {
3174
3222
  const sessionName = session.name;
3175
3223
  const permissionMode = session.process.permissionMode;
3176
3224
  const model = session.process.model;
3225
+ const workspace = this.workspaceForRuntimeSession(session);
3177
3226
  this.destroySession(oldSessionId);
3178
3227
  console.log(`[ws] Claude sandbox change: destroyed session ${oldSessionId}`);
3179
3228
  const newId = this.sessionManager.create(projectPath, {
@@ -3181,9 +3230,11 @@ export class BridgeWebSocketServer {
3181
3230
  permissionMode,
3182
3231
  model,
3183
3232
  sandboxEnabled: newEnabled,
3233
+ additionalDirectories: workspace?.rootPaths.slice(1),
3184
3234
  }, undefined, worktreePath
3185
3235
  ? { existingWorktreePath: worktreePath, worktreeBranch }
3186
3236
  : undefined, "claude");
3237
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
3187
3238
  const newSession = this.sessionManager.get(newId);
3188
3239
  if (newSession && sessionName)
3189
3240
  newSession.name = sessionName;
@@ -3227,6 +3278,7 @@ export class BridgeWebSocketServer {
3227
3278
  const sessionName = session.name;
3228
3279
  const collaborationMode = session.process
3229
3280
  .collaborationMode;
3281
+ const workspace = this.workspaceForRuntimeSession(session);
3230
3282
  const executionMode = oldSettings.approvalPolicy === "never" ? "fullAccess" : "default";
3231
3283
  const planMode = collaborationMode === "plan";
3232
3284
  const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
@@ -3255,8 +3307,10 @@ export class BridgeWebSocketServer {
3255
3307
  serviceTier: oldSettings.serviceTier,
3256
3308
  networkAccessEnabled: oldSettings.networkAccessEnabled,
3257
3309
  webSearchMode: oldSettings.webSearchMode,
3310
+ additionalWritableRoots: oldSettings.additionalWritableRoots,
3258
3311
  collaborationMode,
3259
3312
  }));
3313
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
3260
3314
  const newSession = this.sessionManager.get(newId);
3261
3315
  if (newSession && sessionName)
3262
3316
  newSession.name = sessionName;
@@ -3309,8 +3363,10 @@ export class BridgeWebSocketServer {
3309
3363
  serviceTier: oldSettings.serviceTier,
3310
3364
  networkAccessEnabled: oldSettings.networkAccessEnabled,
3311
3365
  webSearchMode: oldSettings.webSearchMode,
3366
+ additionalWritableRoots: oldSettings.additionalWritableRoots,
3312
3367
  collaborationMode,
3313
3368
  }));
3369
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
3314
3370
  // Restore session name
3315
3371
  const newSession = this.sessionManager.get(newId);
3316
3372
  if (newSession && sessionName) {
@@ -3377,6 +3433,7 @@ export class BridgeWebSocketServer {
3377
3433
  const permissionMode = sdkProc.permissionMode;
3378
3434
  const worktreePath = session.worktreePath;
3379
3435
  const worktreeBranch = session.worktreeBranch;
3436
+ const workspace = this.workspaceForRuntimeSession(session);
3380
3437
  this.destroySession(sessionId);
3381
3438
  console.log(`[ws] Clear context: destroyed session ${sessionId}`);
3382
3439
  const newId = this.sessionManager.create(projectPath, {
@@ -3388,9 +3445,11 @@ export class BridgeWebSocketServer {
3388
3445
  : {}),
3389
3446
  permissionMode,
3390
3447
  initialInput: planText || undefined,
3448
+ additionalDirectories: workspace?.rootPaths.slice(1),
3391
3449
  }, undefined, worktreePath
3392
3450
  ? { existingWorktreePath: worktreePath, worktreeBranch }
3393
3451
  : undefined);
3452
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
3394
3453
  console.log(`[ws] Clear context: created new session ${newId} (CLI session: ${claudeSessionId ?? "new"})`);
3395
3454
  // Notify all clients. Broadcast is used so reconnecting clients also receive it.
3396
3455
  const newSession = this.sessionManager.get(newId);
@@ -3576,7 +3635,7 @@ export class BridgeWebSocketServer {
3576
3635
  this.send(ws, {
3577
3636
  type: "session_context",
3578
3637
  sessionId: msg.sessionId,
3579
- context: { ...context },
3638
+ context: { ...this.runtimeSessionWithWorkspace(context) },
3580
3639
  });
3581
3640
  }
3582
3641
  else {
@@ -3830,6 +3889,8 @@ export class BridgeWebSocketServer {
3830
3889
  limit: msg.limit,
3831
3890
  offset: msg.offset,
3832
3891
  projectPath: msg.projectPath,
3892
+ projectId: msg.projectId,
3893
+ workspaceKind: msg.workspaceKind,
3833
3894
  requestScope: msg.requestScope,
3834
3895
  requestId: msg.requestId,
3835
3896
  });
@@ -3844,6 +3905,8 @@ export class BridgeWebSocketServer {
3844
3905
  message: `Failed to list recent sessions: ${err}`,
3845
3906
  errorCode: "recent_sessions_failed",
3846
3907
  path: msg.projectPath,
3908
+ projectId: msg.projectId,
3909
+ workspaceKind: msg.workspaceKind,
3847
3910
  requestId: msg.requestId,
3848
3911
  requestScope: msg.requestScope,
3849
3912
  offset: msg.offset,
@@ -3904,9 +3967,41 @@ export class BridgeWebSocketServer {
3904
3967
  }
3905
3968
  case "resume_session": {
3906
3969
  const resumeStartedAt = Date.now();
3907
- console.log(`[ws] resume_session: sessionId=${msg.sessionId} projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`);
3908
- const resumeProjectPath = resolvePlatformPath(msg.projectPath, this.platform);
3909
3970
  const provider = msg.provider ?? "claude";
3971
+ const storedAssignment = this.workspaceStore?.getAssignment(provider, msg.sessionId);
3972
+ const requestedProject = !storedAssignment && msg.projectId
3973
+ ? this.workspaceStore?.getProject(msg.projectId)
3974
+ : undefined;
3975
+ const assignedProject = storedAssignment?.projectId
3976
+ ? this.workspaceStore?.getProject(storedAssignment.projectId)
3977
+ : undefined;
3978
+ const resumeRoots = storedAssignment?.rootPaths ?? requestedProject?.rootPaths;
3979
+ console.log(`[ws] resume_session: sessionId=${msg.sessionId} projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`);
3980
+ // A stored assignment supplies the root snapshot and secondary roots,
3981
+ // while the recent-session path may be a worktree cwd that must win.
3982
+ const resumeProjectPath = resolvePlatformPath(storedAssignment
3983
+ ? msg.projectPath
3984
+ : (resumeRoots?.[0] ?? msg.projectPath), this.platform);
3985
+ const resumeAdditionalRoots = resumeRoots?.slice(1) ?? msg.additionalWritableRoots;
3986
+ const resolvedResumeWorkspace = storedAssignment
3987
+ ? {
3988
+ kind: storedAssignment.kind,
3989
+ ...(storedAssignment.projectId
3990
+ ? { projectId: storedAssignment.projectId }
3991
+ : {}),
3992
+ ...(assignedProject
3993
+ ? { projectName: assignedProject.name }
3994
+ : {}),
3995
+ rootPaths: storedAssignment.rootPaths,
3996
+ }
3997
+ : requestedProject
3998
+ ? {
3999
+ kind: "project",
4000
+ projectId: requestedProject.id,
4001
+ projectName: requestedProject.name,
4002
+ rootPaths: requestedProject.rootPaths,
4003
+ }
4004
+ : undefined;
3910
4005
  if (!this.isPathAllowed(resumeProjectPath)) {
3911
4006
  this.sendResumeFailed(ws, {
3912
4007
  provider,
@@ -3917,6 +4012,13 @@ export class BridgeWebSocketServer {
3917
4012
  this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
3918
4013
  break;
3919
4014
  }
4015
+ if (resolvedResumeWorkspace &&
4016
+ !storedAssignment &&
4017
+ this.workspaceStore) {
4018
+ void this.workspaceStore.assignSession(provider, msg.sessionId, resolvedResumeWorkspace).catch((error) => {
4019
+ console.error("[workspace] Failed to persist resume assignment:", error);
4020
+ });
4021
+ }
3920
4022
  const normalizedCodexPermissionsMode = provider === "codex"
3921
4023
  ? normalizeCodexPermissionsMode(msg.codexPermissionsMode)
3922
4024
  : undefined;
@@ -3960,7 +4062,7 @@ export class BridgeWebSocketServer {
3960
4062
  const effectiveProfile = msg.profile
3961
4063
  ? await this.resolveCodexResumeProfile(msg.profile, sessionRefId, effectiveProjectPath)
3962
4064
  : undefined;
3963
- const additionalWritableRoots = this.normalizeAdditionalWritableRoots(msg.additionalWritableRoots, effectiveProjectPath);
4065
+ const additionalWritableRoots = this.normalizeAdditionalWritableRoots(resumeAdditionalRoots, effectiveProjectPath);
3964
4066
  if (additionalWritableRoots.deniedRoot) {
3965
4067
  this.sendResumeFailed(ws, {
3966
4068
  provider,
@@ -4031,9 +4133,18 @@ export class BridgeWebSocketServer {
4031
4133
  collaborationMode: planMode
4032
4134
  ? "plan"
4033
4135
  : "default",
4034
- }));
4136
+ }), { deferProcessMessages: true });
4137
+ if (!this.attachProvisionalResumeSession(resumeOperation.key, resumeOperation.operationId, sessionId)) {
4138
+ this.sessionManager.destroy(sessionId);
4139
+ break;
4140
+ }
4035
4141
  sessionCreateMs = Date.now() - createStartedAt;
4036
4142
  const createdSession = this.sessionManager.get(sessionId);
4143
+ const waitUntilReady = createdSession?.process?.waitUntilReady;
4144
+ if (!waitUntilReady) {
4145
+ throw new Error("Codex session readiness is unavailable");
4146
+ }
4147
+ await waitUntilReady.call(createdSession?.process);
4037
4148
  if (createdSession) {
4038
4149
  // get_history immediately follows session_created on the app.
4039
4150
  // Reuse the canonical history loaded above instead of issuing a
@@ -4080,6 +4191,7 @@ export class BridgeWebSocketServer {
4080
4191
  this.sessionManager.destroy(sessionId);
4081
4192
  break;
4082
4193
  }
4194
+ this.sessionManager.releaseDeferredProcessMessages(sessionId);
4083
4195
  this.broadcastSessionList();
4084
4196
  this.debugEvents.set(sessionId, []);
4085
4197
  this.recordDebugEvent(sessionId, {
@@ -4088,7 +4200,9 @@ export class BridgeWebSocketServer {
4088
4200
  type: "session_resumed",
4089
4201
  detail: `provider=codex thread=${sessionRefId}`,
4090
4202
  });
4091
- this.projectHistory?.addProject(effectiveProjectPath);
4203
+ if (!resolvedResumeWorkspace) {
4204
+ this.projectHistory?.addProject(effectiveProjectPath);
4205
+ }
4092
4206
  console.info(formatResumePerformanceLog({
4093
4207
  provider: "codex",
4094
4208
  sourceSessionId: sessionRefId,
@@ -4114,11 +4228,29 @@ export class BridgeWebSocketServer {
4114
4228
  nameLoadMs,
4115
4229
  totalMs: Date.now() - resumeStartedAt,
4116
4230
  }));
4117
- this.failResumeOperation(resumeOperation.key, resumeOperation.operationId, `Failed to load Codex session history: ${err}`);
4231
+ const activeWriterConflict = isCodexThreadWriterConflict(err);
4232
+ this.failResumeOperation(resumeOperation.key, resumeOperation.operationId, activeWriterConflict
4233
+ ? codexErrorMessage(err)
4234
+ : historyLoaded
4235
+ ? `Failed to restore Codex session: ${err}`
4236
+ : `Failed to load Codex session history: ${err}`, activeWriterConflict
4237
+ ? "codex_thread_writer_conflict"
4238
+ : undefined);
4118
4239
  }
4119
4240
  break;
4120
4241
  }
4121
4242
  const claudeSessionId = sessionRefId;
4243
+ const additionalDirectories = this.normalizeAdditionalWritableRoots(resumeAdditionalRoots, resumeProjectPath);
4244
+ if (additionalDirectories.deniedRoot) {
4245
+ this.sendResumeFailed(ws, {
4246
+ provider,
4247
+ sourceSessionId: sessionRefId,
4248
+ projectPath: resumeProjectPath,
4249
+ resumeRequestId: msg.resumeRequestId,
4250
+ });
4251
+ this.send(ws, this.buildPathNotAllowedError(additionalDirectories.deniedRoot));
4252
+ break;
4253
+ }
4122
4254
  // Look up worktree mapping for this Claude session
4123
4255
  const wtMapping = this.worktreeStore.get(claudeSessionId);
4124
4256
  let worktreeOpts;
@@ -4173,6 +4305,7 @@ export class BridgeWebSocketServer {
4173
4305
  ...(msg.sandboxMode
4174
4306
  ? { sandboxEnabled: msg.sandboxMode === "on" }
4175
4307
  : {}),
4308
+ additionalDirectories: additionalDirectories.roots,
4176
4309
  },
4177
4310
  pastMessages,
4178
4311
  worktreeOptions: worktreeOpts,
@@ -4243,7 +4376,9 @@ export class BridgeWebSocketServer {
4243
4376
  type: "session_resumed",
4244
4377
  detail: `provider=claude session=${claudeSessionId}`,
4245
4378
  });
4246
- this.projectHistory?.addProject(resumeProjectPath);
4379
+ if (!resolvedResumeWorkspace) {
4380
+ this.projectHistory?.addProject(resumeProjectPath);
4381
+ }
4247
4382
  })
4248
4383
  .catch((err) => {
4249
4384
  if (!historyLoaded) {
@@ -4330,16 +4465,104 @@ export class BridgeWebSocketServer {
4330
4465
  break;
4331
4466
  }
4332
4467
  case "list_project_history": {
4333
- const projects = this.projectHistory?.getProjects() ?? [];
4468
+ const projects = this.legacyProjectHistoryProjects();
4334
4469
  this.send(ws, { type: "project_history", projects });
4335
4470
  break;
4336
4471
  }
4337
4472
  case "remove_project_history": {
4338
4473
  this.projectHistory?.removeProject(msg.projectPath);
4339
- const projects = this.projectHistory?.getProjects() ?? [];
4474
+ const projects = this.legacyProjectHistoryProjects();
4340
4475
  this.send(ws, { type: "project_history", projects });
4341
4476
  break;
4342
4477
  }
4478
+ case "list_projects": {
4479
+ this.send(ws, this.projectsMessage(msg.requestId));
4480
+ break;
4481
+ }
4482
+ case "create_project": {
4483
+ if (!this.workspaceStore) {
4484
+ this.send(ws, {
4485
+ type: "error",
4486
+ requestId: msg.requestId,
4487
+ errorCode: "projects_unavailable",
4488
+ message: "Project storage is unavailable",
4489
+ });
4490
+ break;
4491
+ }
4492
+ const normalized = this.normalizeWorkspaceRoots(msg.rootPaths);
4493
+ if (normalized.deniedRoot || !normalized.roots) {
4494
+ this.send(ws, this.buildPathNotAllowedError(normalized.deniedRoot ?? msg.rootPaths[0]));
4495
+ break;
4496
+ }
4497
+ try {
4498
+ await this.workspaceStore.createProject(msg.name, normalized.roots);
4499
+ this.broadcast(this.projectsMessage(msg.requestId));
4500
+ }
4501
+ catch (error) {
4502
+ this.sendWorkspaceMutationError(ws, msg.requestId, "create Project", error);
4503
+ }
4504
+ break;
4505
+ }
4506
+ case "update_project": {
4507
+ if (!this.workspaceStore) {
4508
+ this.send(ws, {
4509
+ type: "error",
4510
+ requestId: msg.requestId,
4511
+ errorCode: "projects_unavailable",
4512
+ message: "Project storage is unavailable",
4513
+ });
4514
+ break;
4515
+ }
4516
+ const normalized = this.normalizeWorkspaceRoots(msg.rootPaths);
4517
+ if (normalized.deniedRoot || !normalized.roots) {
4518
+ this.send(ws, this.buildPathNotAllowedError(normalized.deniedRoot ?? msg.rootPaths[0]));
4519
+ break;
4520
+ }
4521
+ let updated;
4522
+ try {
4523
+ updated = await this.workspaceStore.updateProject(msg.projectId, msg.name, normalized.roots);
4524
+ }
4525
+ catch (error) {
4526
+ this.sendWorkspaceMutationError(ws, msg.requestId, "update Project", error);
4527
+ break;
4528
+ }
4529
+ if (!updated) {
4530
+ this.send(ws, {
4531
+ type: "error",
4532
+ requestId: msg.requestId,
4533
+ errorCode: "project_not_found",
4534
+ message: `Project not found: ${msg.projectId}`,
4535
+ });
4536
+ break;
4537
+ }
4538
+ this.broadcast(this.projectsMessage(msg.requestId));
4539
+ break;
4540
+ }
4541
+ case "remove_project": {
4542
+ if (!this.workspaceStore) {
4543
+ this.sendWorkspaceMutationError(ws, msg.requestId, "remove Project", new Error("Project storage is unavailable"));
4544
+ break;
4545
+ }
4546
+ let removed;
4547
+ try {
4548
+ removed = await this.workspaceStore.removeProject(msg.projectId);
4549
+ }
4550
+ catch (error) {
4551
+ this.sendWorkspaceMutationError(ws, msg.requestId, "remove Project", error);
4552
+ break;
4553
+ }
4554
+ if (!removed) {
4555
+ this.send(ws, {
4556
+ type: "error",
4557
+ requestId: msg.requestId,
4558
+ errorCode: "project_not_found",
4559
+ message: `Project not found: ${msg.projectId}`,
4560
+ });
4561
+ break;
4562
+ }
4563
+ this.broadcast(this.projectsMessage(msg.requestId));
4564
+ break;
4565
+ }
4343
4566
  case "list_directory": {
4344
4567
  try {
4345
4568
  const listing = await listAllowedDirectories(msg.path, this.allowedDirs, this.platform, msg.includeHidden ?? false);
@@ -6007,6 +6230,7 @@ export class BridgeWebSocketServer {
6007
6230
  return false;
6008
6231
  if (operation.timeout)
6009
6232
  clearTimeout(operation.timeout);
6233
+ operation.provisionalSessionId = undefined;
6010
6234
  operation.completed = {
6011
6235
  sessionId,
6012
6236
  message,
@@ -6025,16 +6249,36 @@ export class BridgeWebSocketServer {
6025
6249
  this.pruneCompletedResumeOperations();
6026
6250
  return true;
6027
6251
  }
6028
- failResumeOperation(key, operationId, message) {
6252
+ failResumeOperation(key, operationId, message, errorCode) {
6029
6253
  const operation = this.resumeOperations.get(key);
6030
6254
  if (!operation || operation.id !== operationId)
6031
6255
  return;
6256
+ if (operation.provisionalSessionId) {
6257
+ this.sessionManager.destroy(operation.provisionalSessionId);
6258
+ operation.provisionalSessionId = undefined;
6259
+ }
6032
6260
  this.clearResumeOperation(key, operation);
6033
6261
  for (const waiter of operation.waiters) {
6034
6262
  this.rejectPendingClaudeResumeInputs(waiter, operation.sourceSessionId);
6035
6263
  this.sendResumeFailed(waiter, operation);
6036
- this.send(waiter, { type: "error", message });
6264
+ this.send(waiter, {
6265
+ type: "error",
6266
+ message,
6267
+ sessionId: operation.sourceSessionId,
6268
+ ...(operation.resumeRequestId
6269
+ ? { requestId: operation.resumeRequestId }
6270
+ : {}),
6271
+ ...(errorCode ? { errorCode } : {}),
6272
+ });
6273
+ }
6274
+ }
6275
+ attachProvisionalResumeSession(key, operationId, sessionId) {
6276
+ const operation = this.resumeOperations.get(key);
6277
+ if (!operation || operation.id !== operationId || operation.completed) {
6278
+ return false;
6037
6279
  }
6280
+ operation.provisionalSessionId = sessionId;
6281
+ return true;
6038
6282
  }
6039
6283
  sendResumeFailed(ws, resume) {
6040
6284
  this.send(ws, {
@@ -6166,7 +6410,7 @@ export class BridgeWebSocketServer {
6166
6410
  }
6167
6411
  sendSessionList(ws) {
6168
6412
  this.pruneDebugEvents();
6169
- const sessions = this.sessionManager.list();
6413
+ const sessions = this.runtimeSessionsWithWorkspaces();
6170
6414
  this.send(ws, {
6171
6415
  type: "session_list",
6172
6416
  sessions,
@@ -6204,7 +6448,7 @@ export class BridgeWebSocketServer {
6204
6448
  /** Broadcast session list to all connected clients. */
6205
6449
  broadcastSessionList() {
6206
6450
  this.pruneDebugEvents();
6207
- const sessions = this.sessionManager.list();
6451
+ const sessions = this.runtimeSessionsWithWorkspaces();
6208
6452
  this.broadcast({
6209
6453
  type: "session_list",
6210
6454
  sessions,
@@ -6226,6 +6470,18 @@ export class BridgeWebSocketServer {
6226
6470
  ],
6227
6471
  });
6228
6472
  }
6473
+ runtimeSessionsWithWorkspaces() {
6474
+ return this.sessionManager
6475
+ .list()
6476
+ .map((summary) => this.runtimeSessionWithWorkspace(summary));
6477
+ }
6478
+ runtimeSessionWithWorkspace(summary) {
6479
+ const session = this.sessionManager.get(summary.id);
6480
+ const workspace = session
6481
+ ? this.workspaceForRuntimeSession(session)
6482
+ : undefined;
6483
+ return workspace ? { ...summary, workspace } : summary;
6484
+ }
6229
6485
  broadcastPromptHistoryStatus() {
6230
6486
  if (!this.promptHistoryStore)
6231
6487
  return;
@@ -6352,6 +6608,7 @@ export class BridgeWebSocketServer {
6352
6608
  }
6353
6609
  destroySession(sessionId) {
6354
6610
  this.flushSessionDeltaBatches(sessionId);
6611
+ this.pendingSessionWorkspaces.delete(sessionId);
6355
6612
  this.sessionManager.destroy(sessionId);
6356
6613
  }
6357
6614
  trackSessionMessage(sessionId, msg) {
@@ -6395,6 +6652,128 @@ export class BridgeWebSocketServer {
6395
6652
  }
6396
6653
  }
6397
6654
  async listRecentSessions(msg) {
6655
+ const primaryProjectIds = new Set(msg.projectPath === undefined
6656
+ ? []
6657
+ : (this.workspaceStore?.listProjects() ?? [])
6658
+ .filter((project) => project.rootPaths[0] === msg.projectPath)
6659
+ .map((project) => project.id));
6660
+ const workspaceFilterRequested = msg.projectId !== undefined ||
6661
+ msg.workspaceKind !== undefined ||
6662
+ primaryProjectIds.size > 0;
6663
+ if (!workspaceFilterRequested) {
6664
+ const rawResult = await this.listRecentSessionsRaw(msg);
6665
+ const enriched = rawResult.sessions.map((session) => this.enrichRecentSessionWorkspace(session));
6666
+ return { sessions: enriched, hasMore: rawResult.hasMore };
6667
+ }
6668
+ const matchesWorkspace = (session) => {
6669
+ const value = session;
6670
+ const workspace = value.workspace;
6671
+ if (msg.projectId && workspace?.projectId !== msg.projectId)
6672
+ return false;
6673
+ if (msg.workspaceKind && workspace?.kind !== msg.workspaceKind)
6674
+ return false;
6675
+ if (msg.projectPath !== undefined) {
6676
+ const matchesPrimaryProject = typeof workspace?.projectId === "string" &&
6677
+ primaryProjectIds.has(workspace.projectId);
6678
+ if (value.projectPath !== msg.projectPath &&
6679
+ !matchesPrimaryProject) {
6680
+ return false;
6681
+ }
6682
+ }
6683
+ return true;
6684
+ };
6685
+ const offset = msg.offset ?? 0;
6686
+ const limit = msg.limit ?? 20;
6687
+ const requiredMatches = offset + limit + 1;
6688
+ let filtered;
6689
+ if (msg.provider === "codex") {
6690
+ filtered = await this.listWorkspaceFilteredCodexSessions(msg, matchesWorkspace, requiredMatches);
6691
+ }
6692
+ else if (msg.provider === "claude") {
6693
+ filtered = await this.listWorkspaceFilteredIndexedSessions(msg, matchesWorkspace, requiredMatches);
6694
+ }
6695
+ else {
6696
+ // The filesystem index includes both providers and is also the fallback
6697
+ // for Codex rollouts not returned by app-server. Merge it with one
6698
+ // cursor-preserving app-server scan for the freshest Codex metadata.
6699
+ const [indexed, codex] = await Promise.all([
6700
+ this.listWorkspaceFilteredIndexedSessions(msg, matchesWorkspace, requiredMatches),
6701
+ this.listWorkspaceFilteredCodexSessions({ ...msg, provider: "codex" }, matchesWorkspace, requiredMatches),
6702
+ ]);
6703
+ filtered = mergeRecentSessionPages([...codex, ...indexed]);
6704
+ }
6705
+ return {
6706
+ sessions: filtered.slice(offset, offset + limit),
6707
+ hasMore: filtered.length > offset + limit,
6708
+ };
6709
+ }
6710
+ async listWorkspaceFilteredIndexedSessions(msg, matchesWorkspace, limit) {
6711
+ const result = await getAllRecentSessions({
6712
+ limit,
6713
+ offset: 0,
6714
+ // Project identity is authoritative. Do not pre-filter by the current
6715
+ // primary root: assignments may retain an older snapshot or worktree cwd.
6716
+ provider: msg.provider,
6717
+ namedOnly: msg.namedOnly,
6718
+ searchQuery: msg.searchQuery,
6719
+ archivedSessionIds: this.archiveStore.archivedIds(),
6720
+ sessionFilter: (session) => matchesWorkspace(this.enrichRecentSessionWorkspace(session)),
6721
+ });
6722
+ return result.sessions.map((session) => this.enrichRecentSessionWorkspace(session));
6723
+ }
6724
+ async listWorkspaceFilteredCodexSessions(msg, matchesWorkspace, limit) {
6725
+ try {
6726
+ return await this.listWorkspaceFilteredCodexThreads(msg, matchesWorkspace, limit);
6727
+ }
6728
+ catch (err) {
6729
+ console.warn(`[ws] Codex thread/list failed, falling back to rollout scan: ${err}`);
6730
+ return this.listWorkspaceFilteredIndexedSessions({ ...msg, provider: "codex" }, matchesWorkspace, limit);
6731
+ }
6732
+ }
6733
+ async listWorkspaceFilteredCodexThreads(msg, matchesWorkspace, limit) {
6734
+ const activeProcess = this.getActiveCodexProcess();
6735
+ const process = activeProcess ?? (await this.createStandaloneCodexProcess(undefined));
6736
+ const isStandalone = activeProcess === null;
6737
+ try {
6738
+ const archivedIds = this.archiveStore.archivedIds();
6739
+ const matchingThreads = [];
6740
+ let cursor;
6741
+ do {
6742
+ const request = {
6743
+ limit: 500,
6744
+ searchTerm: msg.searchQuery,
6745
+ sourceKinds: CODEX_RECENT_THREAD_SOURCE_KINDS,
6746
+ };
6747
+ if (cursor != null)
6748
+ request.cursor = cursor;
6749
+ const result = await process.listThreads(request);
6750
+ for (const thread of result.data) {
6751
+ if (archivedIds.has(thread.id))
6752
+ continue;
6753
+ if (msg.namedOnly && !thread.name)
6754
+ continue;
6755
+ const workspaceProbe = this.enrichRecentSessionWorkspace({
6756
+ provider: "codex",
6757
+ sessionId: thread.id,
6758
+ projectPath: thread.cwd,
6759
+ });
6760
+ if (!matchesWorkspace(workspaceProbe))
6761
+ continue;
6762
+ matchingThreads.push(thread);
6763
+ if (matchingThreads.length >= limit)
6764
+ break;
6765
+ }
6766
+ cursor = result.nextCursor;
6767
+ } while (matchingThreads.length < limit && cursor != null);
6768
+ const indexedById = await getCodexSessionIndexMetadata(matchingThreads.map((thread) => thread.id));
6769
+ return matchingThreads.map((thread) => this.enrichRecentSessionWorkspace(codexThreadToRecentSession(thread, indexedById.get(thread.id))));
6770
+ }
6771
+ finally {
6772
+ if (isStandalone)
6773
+ process.stop();
6774
+ }
6775
+ }
6776
+ async listRecentSessionsRaw(msg) {
6398
6777
  if (msg.provider === "codex") {
6399
6778
  return this.listRecentCodexSessions(msg);
6400
6779
  }
@@ -6411,11 +6790,105 @@ export class BridgeWebSocketServer {
6411
6790
  archivedSessionIds: this.archiveStore.archivedIds(),
6412
6791
  });
6413
6792
  }
6793
+ enrichRecentSessionWorkspace(session) {
6794
+ if (!session || typeof session !== "object")
6795
+ return session;
6796
+ const value = session;
6797
+ const provider = value.provider;
6798
+ const providerSessionId = value.sessionId;
6799
+ const projectPath = value.projectPath;
6800
+ if ((provider !== "claude" && provider !== "codex") ||
6801
+ typeof providerSessionId !== "string" ||
6802
+ typeof projectPath !== "string") {
6803
+ return session;
6804
+ }
6805
+ const workspace = this.workspaceStore?.resolveRecentWorkspace(provider, providerSessionId);
6806
+ return {
6807
+ ...value,
6808
+ workspace: workspace ?? {
6809
+ kind: "unassigned",
6810
+ rootPaths: projectPath ? [projectPath] : [],
6811
+ },
6812
+ };
6813
+ }
6814
+ normalizeWorkspaceRoots(rootPaths) {
6815
+ const roots = [];
6816
+ const seen = new Set();
6817
+ for (const rawPath of rootPaths) {
6818
+ const path = resolvePlatformPath(rawPath, this.platform);
6819
+ if (!this.isPathAllowed(path))
6820
+ return { deniedRoot: rawPath };
6821
+ if (!seen.has(path)) {
6822
+ seen.add(path);
6823
+ roots.push(path);
6824
+ }
6825
+ }
6826
+ return roots.length > 0 ? { roots } : {};
6827
+ }
6828
+ async persistPendingSessionWorkspace(sessionId) {
6829
+ const workspace = this.pendingSessionWorkspaces.get(sessionId);
6830
+ const session = this.sessionManager.get(sessionId);
6831
+ if (!workspace || !session?.claudeSessionId || !this.workspaceStore)
6832
+ return;
6833
+ try {
6834
+ await this.workspaceStore.assignSession(session.provider, session.claudeSessionId, workspace);
6835
+ this.pendingSessionWorkspaces.delete(sessionId);
6836
+ }
6837
+ catch (error) {
6838
+ console.error("[workspace] Failed to persist session assignment:", error);
6839
+ }
6840
+ }
6841
+ workspaceForRuntimeSession(session) {
6842
+ const pending = this.pendingSessionWorkspaces.get(session.id);
6843
+ if (pending) {
6844
+ return { ...pending, rootPaths: [...pending.rootPaths] };
6845
+ }
6846
+ if (!session.claudeSessionId || !this.workspaceStore)
6847
+ return undefined;
6848
+ const assignment = this.workspaceStore.getAssignment(session.provider, session.claudeSessionId);
6849
+ if (!assignment)
6850
+ return undefined;
6851
+ const project = assignment.projectId
6852
+ ? this.workspaceStore.getProject(assignment.projectId)
6853
+ : undefined;
6854
+ return {
6855
+ kind: assignment.kind,
6856
+ ...(assignment.projectId ? { projectId: assignment.projectId } : {}),
6857
+ ...(project ? { projectName: project.name } : {}),
6858
+ rootPaths: [...assignment.rootPaths],
6859
+ };
6860
+ }
6861
+ attachWorkspaceToRuntimeSession(sessionId, workspace) {
6862
+ if (!workspace)
6863
+ return;
6864
+ this.pendingSessionWorkspaces.set(sessionId, workspace);
6865
+ void this.persistPendingSessionWorkspace(sessionId);
6866
+ }
6867
+ projectsMessage(requestId) {
6868
+ return {
6869
+ type: "projects",
6870
+ projects: this.workspaceStore?.listProjects() ?? [],
6871
+ ...(requestId ? { requestId } : {}),
6872
+ };
6873
+ }
6874
+ sendWorkspaceMutationError(ws, requestId, action, error) {
6875
+ this.send(ws, {
6876
+ type: "error",
6877
+ requestId,
6878
+ errorCode: "workspace_write_failed",
6879
+ message: `Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`,
6880
+ });
6881
+ }
6882
+ legacyProjectHistoryProjects() {
6883
+ return this.projectHistory?.getProjects() ?? [];
6884
+ }
6414
6885
  listRecentSessionsCoalesced(msg) {
6415
6886
  const key = JSON.stringify({
6416
6887
  limit: msg.limit ?? null,
6417
6888
  offset: msg.offset ?? null,
6418
6889
  projectPath: msg.projectPath ?? null,
6890
+ projectId: msg.projectId ?? null,
6891
+ workspaceKind: msg.workspaceKind ?? null,
6419
6892
  provider: msg.provider ?? null,
6420
6893
  namedOnly: msg.namedOnly ?? null,
6421
6894
  searchQuery: msg.searchQuery ?? null,