@ccpocket/bridge 1.80.2 → 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
@@ -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,
@@ -4098,7 +4200,9 @@ export class BridgeWebSocketServer {
4098
4200
  type: "session_resumed",
4099
4201
  detail: `provider=codex thread=${sessionRefId}`,
4100
4202
  });
4101
- this.projectHistory?.addProject(effectiveProjectPath);
4203
+ if (!resolvedResumeWorkspace) {
4204
+ this.projectHistory?.addProject(effectiveProjectPath);
4205
+ }
4102
4206
  console.info(formatResumePerformanceLog({
4103
4207
  provider: "codex",
4104
4208
  sourceSessionId: sessionRefId,
@@ -4136,6 +4240,17 @@ export class BridgeWebSocketServer {
4136
4240
  break;
4137
4241
  }
4138
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
+ }
4139
4254
  // Look up worktree mapping for this Claude session
4140
4255
  const wtMapping = this.worktreeStore.get(claudeSessionId);
4141
4256
  let worktreeOpts;
@@ -4190,6 +4305,7 @@ export class BridgeWebSocketServer {
4190
4305
  ...(msg.sandboxMode
4191
4306
  ? { sandboxEnabled: msg.sandboxMode === "on" }
4192
4307
  : {}),
4308
+ additionalDirectories: additionalDirectories.roots,
4193
4309
  },
4194
4310
  pastMessages,
4195
4311
  worktreeOptions: worktreeOpts,
@@ -4260,7 +4376,9 @@ export class BridgeWebSocketServer {
4260
4376
  type: "session_resumed",
4261
4377
  detail: `provider=claude session=${claudeSessionId}`,
4262
4378
  });
4263
- this.projectHistory?.addProject(resumeProjectPath);
4379
+ if (!resolvedResumeWorkspace) {
4380
+ this.projectHistory?.addProject(resumeProjectPath);
4381
+ }
4264
4382
  })
4265
4383
  .catch((err) => {
4266
4384
  if (!historyLoaded) {
@@ -4347,16 +4465,104 @@ export class BridgeWebSocketServer {
4347
4465
  break;
4348
4466
  }
4349
4467
  case "list_project_history": {
4350
- const projects = this.projectHistory?.getProjects() ?? [];
4468
+ const projects = this.legacyProjectHistoryProjects();
4351
4469
  this.send(ws, { type: "project_history", projects });
4352
4470
  break;
4353
4471
  }
4354
4472
  case "remove_project_history": {
4355
4473
  this.projectHistory?.removeProject(msg.projectPath);
4356
- const projects = this.projectHistory?.getProjects() ?? [];
4474
+ const projects = this.legacyProjectHistoryProjects();
4357
4475
  this.send(ws, { type: "project_history", projects });
4358
4476
  break;
4359
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
+ }
4360
4566
  case "list_directory": {
4361
4567
  try {
4362
4568
  const listing = await listAllowedDirectories(msg.path, this.allowedDirs, this.platform, msg.includeHidden ?? false);
@@ -6204,7 +6410,7 @@ export class BridgeWebSocketServer {
6204
6410
  }
6205
6411
  sendSessionList(ws) {
6206
6412
  this.pruneDebugEvents();
6207
- const sessions = this.sessionManager.list();
6413
+ const sessions = this.runtimeSessionsWithWorkspaces();
6208
6414
  this.send(ws, {
6209
6415
  type: "session_list",
6210
6416
  sessions,
@@ -6242,7 +6448,7 @@ export class BridgeWebSocketServer {
6242
6448
  /** Broadcast session list to all connected clients. */
6243
6449
  broadcastSessionList() {
6244
6450
  this.pruneDebugEvents();
6245
- const sessions = this.sessionManager.list();
6451
+ const sessions = this.runtimeSessionsWithWorkspaces();
6246
6452
  this.broadcast({
6247
6453
  type: "session_list",
6248
6454
  sessions,
@@ -6264,6 +6470,18 @@ export class BridgeWebSocketServer {
6264
6470
  ],
6265
6471
  });
6266
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
+ }
6267
6485
  broadcastPromptHistoryStatus() {
6268
6486
  if (!this.promptHistoryStore)
6269
6487
  return;
@@ -6390,6 +6608,7 @@ export class BridgeWebSocketServer {
6390
6608
  }
6391
6609
  destroySession(sessionId) {
6392
6610
  this.flushSessionDeltaBatches(sessionId);
6611
+ this.pendingSessionWorkspaces.delete(sessionId);
6393
6612
  this.sessionManager.destroy(sessionId);
6394
6613
  }
6395
6614
  trackSessionMessage(sessionId, msg) {
@@ -6433,6 +6652,128 @@ export class BridgeWebSocketServer {
6433
6652
  }
6434
6653
  }
6435
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) {
6436
6777
  if (msg.provider === "codex") {
6437
6778
  return this.listRecentCodexSessions(msg);
6438
6779
  }
@@ -6449,11 +6790,105 @@ export class BridgeWebSocketServer {
6449
6790
  archivedSessionIds: this.archiveStore.archivedIds(),
6450
6791
  });
6451
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
+ }
6452
6885
  listRecentSessionsCoalesced(msg) {
6453
6886
  const key = JSON.stringify({
6454
6887
  limit: msg.limit ?? null,
6455
6888
  offset: msg.offset ?? null,
6456
6889
  projectPath: msg.projectPath ?? null,
6890
+ projectId: msg.projectId ?? null,
6891
+ workspaceKind: msg.workspaceKind ?? null,
6457
6892
  provider: msg.provider ?? null,
6458
6893
  namedOnly: msg.namedOnly ?? null,
6459
6894
  searchQuery: msg.searchQuery ?? null,