@ccpocket/bridge 1.80.2 → 1.81.1

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) {
@@ -856,12 +863,16 @@ export class BridgeWebSocketServer {
856
863
  const derivedCodexSettings = provider === "codex"
857
864
  ? withDerivedCodexPermissionsMode(session?.codexSettings)
858
865
  : session?.codexSettings;
866
+ const workspace = session
867
+ ? this.workspaceForRuntimeSession(session)
868
+ : undefined;
859
869
  const msg = {
860
870
  type: "system",
861
871
  subtype: "session_created",
862
872
  sessionId,
863
873
  provider,
864
874
  projectPath,
875
+ ...(workspace ? { workspace } : {}),
865
876
  ...(permissionMode
866
877
  ? {
867
878
  permissionMode: permissionMode,
@@ -1069,6 +1080,7 @@ export class BridgeWebSocketServer {
1069
1080
  }
1070
1081
  const projectPath = session.projectPath;
1071
1082
  const codexSettings = session.codexSettings;
1083
+ const workspace = this.workspaceForRuntimeSession(session);
1072
1084
  const worktreeOpts = session.worktreePath
1073
1085
  ? {
1074
1086
  existingWorktreePath: session.worktreePath,
@@ -1086,6 +1098,7 @@ export class BridgeWebSocketServer {
1086
1098
  ...(codexSettings ?? {}),
1087
1099
  threadId,
1088
1100
  }));
1101
+ this.attachWorkspaceToRuntimeSession(newSessionId, workspace);
1089
1102
  const newSession = this.sessionManager.get(newSessionId);
1090
1103
  this.send(ws, {
1091
1104
  type: "rewind_result",
@@ -1152,6 +1165,7 @@ export class BridgeWebSocketServer {
1152
1165
  }
1153
1166
  const projectPath = session.projectPath;
1154
1167
  const codexSettings = session.codexSettings;
1168
+ const workspace = this.workspaceForRuntimeSession(session);
1155
1169
  const worktreeOpts = session.worktreePath
1156
1170
  ? {
1157
1171
  existingWorktreePath: session.worktreePath,
@@ -1174,6 +1188,7 @@ export class BridgeWebSocketServer {
1174
1188
  ...(codexSettings ?? {}),
1175
1189
  threadId: forkedThreadId,
1176
1190
  }));
1191
+ this.attachWorkspaceToRuntimeSession(newSessionId, workspace);
1177
1192
  const newSession = this.sessionManager.get(newSessionId);
1178
1193
  this.send(ws, this.buildSessionCreatedMessage({
1179
1194
  sessionId: newSessionId,
@@ -1852,7 +1867,7 @@ export class BridgeWebSocketServer {
1852
1867
  // Send session list and project history on connect
1853
1868
  this.refreshConnectionMetadata();
1854
1869
  this.sendSessionList(ws);
1855
- const projects = this.projectHistory?.getProjects() ?? [];
1870
+ const projects = this.legacyProjectHistoryProjects();
1856
1871
  this.send(ws, { type: "project_history", projects });
1857
1872
  ws.on("message", (data) => {
1858
1873
  if (this.rejectedProtocolClients.has(ws))
@@ -1954,7 +1969,32 @@ export class BridgeWebSocketServer {
1954
1969
  }
1955
1970
  switch (msg.type) {
1956
1971
  case "start": {
1957
- const projectPath = resolvePlatformPath(msg.projectPath, this.platform);
1972
+ let projectPath = resolvePlatformPath(msg.projectPath, this.platform);
1973
+ let resolvedWorkspace;
1974
+ if (msg.projectId) {
1975
+ const project = this.workspaceStore?.getProject(msg.projectId);
1976
+ if (!project) {
1977
+ this.send(ws, {
1978
+ type: "error",
1979
+ requestId: msg.requestId,
1980
+ errorCode: "project_not_found",
1981
+ message: `Project not found: ${msg.projectId}`,
1982
+ });
1983
+ break;
1984
+ }
1985
+ const normalized = this.normalizeWorkspaceRoots(project.rootPaths);
1986
+ if (normalized.deniedRoot || !normalized.roots) {
1987
+ this.send(ws, this.buildPathNotAllowedError(normalized.deniedRoot ?? project.rootPaths[0]));
1988
+ break;
1989
+ }
1990
+ projectPath = normalized.roots[0];
1991
+ resolvedWorkspace = {
1992
+ kind: "project",
1993
+ projectId: project.id,
1994
+ projectName: project.name,
1995
+ rootPaths: normalized.roots,
1996
+ };
1997
+ }
1958
1998
  if (!this.isPathAllowed(projectPath)) {
1959
1999
  this.send(ws, {
1960
2000
  ...this.buildPathNotAllowedError(msg.projectPath),
@@ -2012,9 +2052,9 @@ export class BridgeWebSocketServer {
2012
2052
  break;
2013
2053
  }
2014
2054
  }
2015
- const additionalWritableRoots = provider === "codex"
2016
- ? this.normalizeAdditionalWritableRoots(msg.additionalWritableRoots, projectPath)
2017
- : {};
2055
+ const requestedAdditionalRoots = resolvedWorkspace?.rootPaths.slice(1) ??
2056
+ msg.additionalWritableRoots;
2057
+ const additionalWritableRoots = this.normalizeAdditionalWritableRoots(requestedAdditionalRoots, projectPath);
2018
2058
  if (additionalWritableRoots.deniedRoot) {
2019
2059
  this.send(ws, {
2020
2060
  ...this.buildPathNotAllowedError(additionalWritableRoots.deniedRoot),
@@ -2041,6 +2081,7 @@ export class BridgeWebSocketServer {
2041
2081
  ...(msg.sandboxMode
2042
2082
  ? { sandboxEnabled: msg.sandboxMode === "on" }
2043
2083
  : {}),
2084
+ additionalDirectories: additionalWritableRoots.roots,
2044
2085
  },
2045
2086
  worktreeOptions: {
2046
2087
  useWorktree: msg.useWorktree,
@@ -2086,6 +2127,10 @@ export class BridgeWebSocketServer {
2086
2127
  usedFallback: false,
2087
2128
  };
2088
2129
  const createdSession = this.sessionManager.get(sessionId);
2130
+ if (resolvedWorkspace) {
2131
+ this.pendingSessionWorkspaces.set(sessionId, resolvedWorkspace);
2132
+ void this.persistPendingSessionWorkspace(sessionId);
2133
+ }
2089
2134
  const cached = this.sessionManager.getCachedCommands(provider, createdSession?.worktreePath ?? projectPath);
2090
2135
  // Load saved session name from CLI storage (for resumed sessions)
2091
2136
  void this.loadAndSetSessionName(createdSession, provider, projectPath, msg.sessionId).then(() => {
@@ -2150,9 +2195,17 @@ export class BridgeWebSocketServer {
2150
2195
  this.recordingStore?.saveMeta(sessionId, {
2151
2196
  bridgeSessionId: sessionId,
2152
2197
  projectPath,
2198
+ ...(resolvedWorkspace?.projectId
2199
+ ? { projectId: resolvedWorkspace.projectId }
2200
+ : {}),
2201
+ ...(resolvedWorkspace?.projectName
2202
+ ? { projectName: resolvedWorkspace.projectName }
2203
+ : {}),
2153
2204
  createdAt: new Date().toISOString(),
2154
2205
  });
2155
- this.projectHistory?.addProject(projectPath);
2206
+ if (!resolvedWorkspace) {
2207
+ this.projectHistory?.addProject(projectPath);
2208
+ }
2156
2209
  }
2157
2210
  catch (err) {
2158
2211
  console.error(`[ws] Failed to start session:`, err);
@@ -2805,6 +2858,7 @@ export class BridgeWebSocketServer {
2805
2858
  const worktreePath = session.worktreePath;
2806
2859
  const worktreeBranch = session.worktreeBranch;
2807
2860
  const sessionName = session.name;
2861
+ const workspace = this.workspaceForRuntimeSession(session);
2808
2862
  this.destroySession(oldSessionId);
2809
2863
  console.log(`[ws] Permission mode change: destroyed session ${oldSessionId}`);
2810
2864
  const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") ||
@@ -2822,8 +2876,10 @@ export class BridgeWebSocketServer {
2822
2876
  serviceTier: oldSettings.serviceTier,
2823
2877
  networkAccessEnabled: oldSettings.networkAccessEnabled,
2824
2878
  webSearchMode: oldSettings.webSearchMode,
2879
+ additionalWritableRoots: oldSettings.additionalWritableRoots,
2825
2880
  collaborationMode: newCollaboration,
2826
2881
  }));
2882
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
2827
2883
  const newSession = this.sessionManager.get(newId);
2828
2884
  if (newSession && sessionName)
2829
2885
  newSession.name = sessionName;
@@ -2883,8 +2939,10 @@ export class BridgeWebSocketServer {
2883
2939
  serviceTier: oldSettings.serviceTier,
2884
2940
  networkAccessEnabled: oldSettings.networkAccessEnabled,
2885
2941
  webSearchMode: oldSettings.webSearchMode,
2942
+ additionalWritableRoots: oldSettings.additionalWritableRoots,
2886
2943
  collaborationMode: newCollaboration,
2887
2944
  }));
2945
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
2888
2946
  const newSession = this.sessionManager.get(newId);
2889
2947
  if (newSession && sessionName) {
2890
2948
  newSession.name = sessionName;
@@ -3174,6 +3232,7 @@ export class BridgeWebSocketServer {
3174
3232
  const sessionName = session.name;
3175
3233
  const permissionMode = session.process.permissionMode;
3176
3234
  const model = session.process.model;
3235
+ const workspace = this.workspaceForRuntimeSession(session);
3177
3236
  this.destroySession(oldSessionId);
3178
3237
  console.log(`[ws] Claude sandbox change: destroyed session ${oldSessionId}`);
3179
3238
  const newId = this.sessionManager.create(projectPath, {
@@ -3181,9 +3240,11 @@ export class BridgeWebSocketServer {
3181
3240
  permissionMode,
3182
3241
  model,
3183
3242
  sandboxEnabled: newEnabled,
3243
+ additionalDirectories: workspace?.rootPaths.slice(1),
3184
3244
  }, undefined, worktreePath
3185
3245
  ? { existingWorktreePath: worktreePath, worktreeBranch }
3186
3246
  : undefined, "claude");
3247
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
3187
3248
  const newSession = this.sessionManager.get(newId);
3188
3249
  if (newSession && sessionName)
3189
3250
  newSession.name = sessionName;
@@ -3227,6 +3288,7 @@ export class BridgeWebSocketServer {
3227
3288
  const sessionName = session.name;
3228
3289
  const collaborationMode = session.process
3229
3290
  .collaborationMode;
3291
+ const workspace = this.workspaceForRuntimeSession(session);
3230
3292
  const executionMode = oldSettings.approvalPolicy === "never" ? "fullAccess" : "default";
3231
3293
  const planMode = collaborationMode === "plan";
3232
3294
  const legacyPermissionMode = modesToLegacyPermissionMode("codex", executionMode, planMode);
@@ -3255,8 +3317,10 @@ export class BridgeWebSocketServer {
3255
3317
  serviceTier: oldSettings.serviceTier,
3256
3318
  networkAccessEnabled: oldSettings.networkAccessEnabled,
3257
3319
  webSearchMode: oldSettings.webSearchMode,
3320
+ additionalWritableRoots: oldSettings.additionalWritableRoots,
3258
3321
  collaborationMode,
3259
3322
  }));
3323
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
3260
3324
  const newSession = this.sessionManager.get(newId);
3261
3325
  if (newSession && sessionName)
3262
3326
  newSession.name = sessionName;
@@ -3309,8 +3373,10 @@ export class BridgeWebSocketServer {
3309
3373
  serviceTier: oldSettings.serviceTier,
3310
3374
  networkAccessEnabled: oldSettings.networkAccessEnabled,
3311
3375
  webSearchMode: oldSettings.webSearchMode,
3376
+ additionalWritableRoots: oldSettings.additionalWritableRoots,
3312
3377
  collaborationMode,
3313
3378
  }));
3379
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
3314
3380
  // Restore session name
3315
3381
  const newSession = this.sessionManager.get(newId);
3316
3382
  if (newSession && sessionName) {
@@ -3377,6 +3443,7 @@ export class BridgeWebSocketServer {
3377
3443
  const permissionMode = sdkProc.permissionMode;
3378
3444
  const worktreePath = session.worktreePath;
3379
3445
  const worktreeBranch = session.worktreeBranch;
3446
+ const workspace = this.workspaceForRuntimeSession(session);
3380
3447
  this.destroySession(sessionId);
3381
3448
  console.log(`[ws] Clear context: destroyed session ${sessionId}`);
3382
3449
  const newId = this.sessionManager.create(projectPath, {
@@ -3388,9 +3455,11 @@ export class BridgeWebSocketServer {
3388
3455
  : {}),
3389
3456
  permissionMode,
3390
3457
  initialInput: planText || undefined,
3458
+ additionalDirectories: workspace?.rootPaths.slice(1),
3391
3459
  }, undefined, worktreePath
3392
3460
  ? { existingWorktreePath: worktreePath, worktreeBranch }
3393
3461
  : undefined);
3462
+ this.attachWorkspaceToRuntimeSession(newId, workspace);
3394
3463
  console.log(`[ws] Clear context: created new session ${newId} (CLI session: ${claudeSessionId ?? "new"})`);
3395
3464
  // Notify all clients. Broadcast is used so reconnecting clients also receive it.
3396
3465
  const newSession = this.sessionManager.get(newId);
@@ -3576,7 +3645,7 @@ export class BridgeWebSocketServer {
3576
3645
  this.send(ws, {
3577
3646
  type: "session_context",
3578
3647
  sessionId: msg.sessionId,
3579
- context: { ...context },
3648
+ context: { ...this.runtimeSessionWithWorkspace(context) },
3580
3649
  });
3581
3650
  }
3582
3651
  else {
@@ -3830,6 +3899,8 @@ export class BridgeWebSocketServer {
3830
3899
  limit: msg.limit,
3831
3900
  offset: msg.offset,
3832
3901
  projectPath: msg.projectPath,
3902
+ projectId: msg.projectId,
3903
+ workspaceKind: msg.workspaceKind,
3833
3904
  requestScope: msg.requestScope,
3834
3905
  requestId: msg.requestId,
3835
3906
  });
@@ -3844,6 +3915,8 @@ export class BridgeWebSocketServer {
3844
3915
  message: `Failed to list recent sessions: ${err}`,
3845
3916
  errorCode: "recent_sessions_failed",
3846
3917
  path: msg.projectPath,
3918
+ projectId: msg.projectId,
3919
+ workspaceKind: msg.workspaceKind,
3847
3920
  requestId: msg.requestId,
3848
3921
  requestScope: msg.requestScope,
3849
3922
  offset: msg.offset,
@@ -3904,9 +3977,58 @@ export class BridgeWebSocketServer {
3904
3977
  }
3905
3978
  case "resume_session": {
3906
3979
  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
3980
  const provider = msg.provider ?? "claude";
3981
+ const storedAssignment = this.workspaceStore?.getAssignment(provider, msg.sessionId);
3982
+ const requestedProject = !storedAssignment && msg.projectId
3983
+ ? this.workspaceStore?.getProject(msg.projectId)
3984
+ : undefined;
3985
+ if (!storedAssignment && msg.projectId && !requestedProject) {
3986
+ this.sendResumeFailed(ws, {
3987
+ provider,
3988
+ sourceSessionId: msg.sessionId,
3989
+ projectPath: msg.projectPath,
3990
+ resumeRequestId: msg.resumeRequestId,
3991
+ });
3992
+ this.send(ws, {
3993
+ type: "error",
3994
+ sessionId: msg.sessionId,
3995
+ requestId: msg.resumeRequestId,
3996
+ errorCode: "project_not_found",
3997
+ message: `Project not found: ${msg.projectId}`,
3998
+ });
3999
+ break;
4000
+ }
4001
+ const assignedProject = storedAssignment?.projectId
4002
+ ? this.workspaceStore?.getProject(storedAssignment.projectId)
4003
+ : undefined;
4004
+ const resolvedResumeProjectName = assignedProject?.name ?? storedAssignment?.projectName;
4005
+ const resumeRoots = storedAssignment?.rootPaths ?? requestedProject?.rootPaths;
4006
+ console.log(`[ws] resume_session: sessionId=${msg.sessionId} projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`);
4007
+ // A stored assignment supplies the root snapshot and secondary roots,
4008
+ // while the recent-session path may be a worktree cwd that must win.
4009
+ const resumeProjectPath = resolvePlatformPath(storedAssignment
4010
+ ? msg.projectPath
4011
+ : (resumeRoots?.[0] ?? msg.projectPath), this.platform);
4012
+ const resumeAdditionalRoots = resumeRoots?.slice(1) ?? msg.additionalWritableRoots;
4013
+ const resolvedResumeWorkspace = storedAssignment
4014
+ ? {
4015
+ kind: storedAssignment.kind,
4016
+ ...(storedAssignment.projectId
4017
+ ? { projectId: storedAssignment.projectId }
4018
+ : {}),
4019
+ ...(resolvedResumeProjectName
4020
+ ? { projectName: resolvedResumeProjectName }
4021
+ : {}),
4022
+ rootPaths: storedAssignment.rootPaths,
4023
+ }
4024
+ : requestedProject
4025
+ ? {
4026
+ kind: "project",
4027
+ projectId: requestedProject.id,
4028
+ projectName: requestedProject.name,
4029
+ rootPaths: requestedProject.rootPaths,
4030
+ }
4031
+ : undefined;
3910
4032
  if (!this.isPathAllowed(resumeProjectPath)) {
3911
4033
  this.sendResumeFailed(ws, {
3912
4034
  provider,
@@ -3917,6 +4039,13 @@ export class BridgeWebSocketServer {
3917
4039
  this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
3918
4040
  break;
3919
4041
  }
4042
+ if (resolvedResumeWorkspace &&
4043
+ !storedAssignment &&
4044
+ this.workspaceStore) {
4045
+ void this.workspaceStore.assignSession(provider, msg.sessionId, resolvedResumeWorkspace).catch((error) => {
4046
+ console.error("[workspace] Failed to persist resume assignment:", error);
4047
+ });
4048
+ }
3920
4049
  const normalizedCodexPermissionsMode = provider === "codex"
3921
4050
  ? normalizeCodexPermissionsMode(msg.codexPermissionsMode)
3922
4051
  : undefined;
@@ -3960,7 +4089,7 @@ export class BridgeWebSocketServer {
3960
4089
  const effectiveProfile = msg.profile
3961
4090
  ? await this.resolveCodexResumeProfile(msg.profile, sessionRefId, effectiveProjectPath)
3962
4091
  : undefined;
3963
- const additionalWritableRoots = this.normalizeAdditionalWritableRoots(msg.additionalWritableRoots, effectiveProjectPath);
4092
+ const additionalWritableRoots = this.normalizeAdditionalWritableRoots(resumeAdditionalRoots, effectiveProjectPath);
3964
4093
  if (additionalWritableRoots.deniedRoot) {
3965
4094
  this.sendResumeFailed(ws, {
3966
4095
  provider,
@@ -4098,7 +4227,9 @@ export class BridgeWebSocketServer {
4098
4227
  type: "session_resumed",
4099
4228
  detail: `provider=codex thread=${sessionRefId}`,
4100
4229
  });
4101
- this.projectHistory?.addProject(effectiveProjectPath);
4230
+ if (!resolvedResumeWorkspace) {
4231
+ this.projectHistory?.addProject(effectiveProjectPath);
4232
+ }
4102
4233
  console.info(formatResumePerformanceLog({
4103
4234
  provider: "codex",
4104
4235
  sourceSessionId: sessionRefId,
@@ -4136,6 +4267,17 @@ export class BridgeWebSocketServer {
4136
4267
  break;
4137
4268
  }
4138
4269
  const claudeSessionId = sessionRefId;
4270
+ const additionalDirectories = this.normalizeAdditionalWritableRoots(resumeAdditionalRoots, resumeProjectPath);
4271
+ if (additionalDirectories.deniedRoot) {
4272
+ this.sendResumeFailed(ws, {
4273
+ provider,
4274
+ sourceSessionId: sessionRefId,
4275
+ projectPath: resumeProjectPath,
4276
+ resumeRequestId: msg.resumeRequestId,
4277
+ });
4278
+ this.send(ws, this.buildPathNotAllowedError(additionalDirectories.deniedRoot));
4279
+ break;
4280
+ }
4139
4281
  // Look up worktree mapping for this Claude session
4140
4282
  const wtMapping = this.worktreeStore.get(claudeSessionId);
4141
4283
  let worktreeOpts;
@@ -4190,6 +4332,7 @@ export class BridgeWebSocketServer {
4190
4332
  ...(msg.sandboxMode
4191
4333
  ? { sandboxEnabled: msg.sandboxMode === "on" }
4192
4334
  : {}),
4335
+ additionalDirectories: additionalDirectories.roots,
4193
4336
  },
4194
4337
  pastMessages,
4195
4338
  worktreeOptions: worktreeOpts,
@@ -4260,7 +4403,9 @@ export class BridgeWebSocketServer {
4260
4403
  type: "session_resumed",
4261
4404
  detail: `provider=claude session=${claudeSessionId}`,
4262
4405
  });
4263
- this.projectHistory?.addProject(resumeProjectPath);
4406
+ if (!resolvedResumeWorkspace) {
4407
+ this.projectHistory?.addProject(resumeProjectPath);
4408
+ }
4264
4409
  })
4265
4410
  .catch((err) => {
4266
4411
  if (!historyLoaded) {
@@ -4347,16 +4492,104 @@ export class BridgeWebSocketServer {
4347
4492
  break;
4348
4493
  }
4349
4494
  case "list_project_history": {
4350
- const projects = this.projectHistory?.getProjects() ?? [];
4495
+ const projects = this.legacyProjectHistoryProjects();
4351
4496
  this.send(ws, { type: "project_history", projects });
4352
4497
  break;
4353
4498
  }
4354
4499
  case "remove_project_history": {
4355
4500
  this.projectHistory?.removeProject(msg.projectPath);
4356
- const projects = this.projectHistory?.getProjects() ?? [];
4501
+ const projects = this.legacyProjectHistoryProjects();
4357
4502
  this.send(ws, { type: "project_history", projects });
4358
4503
  break;
4359
4504
  }
4505
+ case "list_projects": {
4506
+ this.send(ws, this.projectsMessage(msg.requestId));
4507
+ break;
4508
+ }
4509
+ case "create_project": {
4510
+ if (!this.workspaceStore) {
4511
+ this.send(ws, {
4512
+ type: "error",
4513
+ requestId: msg.requestId,
4514
+ errorCode: "projects_unavailable",
4515
+ message: "Project storage is unavailable",
4516
+ });
4517
+ break;
4518
+ }
4519
+ const normalized = this.normalizeWorkspaceRoots(msg.rootPaths);
4520
+ if (normalized.deniedRoot || !normalized.roots) {
4521
+ this.send(ws, this.buildPathNotAllowedError(normalized.deniedRoot ?? msg.rootPaths[0]));
4522
+ break;
4523
+ }
4524
+ try {
4525
+ await this.workspaceStore.createProject(msg.name, normalized.roots);
4526
+ this.broadcast(this.projectsMessage(msg.requestId));
4527
+ }
4528
+ catch (error) {
4529
+ this.sendWorkspaceMutationError(ws, msg.requestId, "create Project", error);
4530
+ }
4531
+ break;
4532
+ }
4533
+ case "update_project": {
4534
+ if (!this.workspaceStore) {
4535
+ this.send(ws, {
4536
+ type: "error",
4537
+ requestId: msg.requestId,
4538
+ errorCode: "projects_unavailable",
4539
+ message: "Project storage is unavailable",
4540
+ });
4541
+ break;
4542
+ }
4543
+ const normalized = this.normalizeWorkspaceRoots(msg.rootPaths);
4544
+ if (normalized.deniedRoot || !normalized.roots) {
4545
+ this.send(ws, this.buildPathNotAllowedError(normalized.deniedRoot ?? msg.rootPaths[0]));
4546
+ break;
4547
+ }
4548
+ let updated;
4549
+ try {
4550
+ updated = await this.workspaceStore.updateProject(msg.projectId, msg.name, normalized.roots);
4551
+ }
4552
+ catch (error) {
4553
+ this.sendWorkspaceMutationError(ws, msg.requestId, "update Project", error);
4554
+ break;
4555
+ }
4556
+ if (!updated) {
4557
+ this.send(ws, {
4558
+ type: "error",
4559
+ requestId: msg.requestId,
4560
+ errorCode: "project_not_found",
4561
+ message: `Project not found: ${msg.projectId}`,
4562
+ });
4563
+ break;
4564
+ }
4565
+ this.broadcast(this.projectsMessage(msg.requestId));
4566
+ break;
4567
+ }
4568
+ case "remove_project": {
4569
+ if (!this.workspaceStore) {
4570
+ this.sendWorkspaceMutationError(ws, msg.requestId, "remove Project", new Error("Project storage is unavailable"));
4571
+ break;
4572
+ }
4573
+ let removed;
4574
+ try {
4575
+ removed = await this.workspaceStore.removeProject(msg.projectId);
4576
+ }
4577
+ catch (error) {
4578
+ this.sendWorkspaceMutationError(ws, msg.requestId, "remove Project", error);
4579
+ break;
4580
+ }
4581
+ if (!removed) {
4582
+ this.send(ws, {
4583
+ type: "error",
4584
+ requestId: msg.requestId,
4585
+ errorCode: "project_not_found",
4586
+ message: `Project not found: ${msg.projectId}`,
4587
+ });
4588
+ break;
4589
+ }
4590
+ this.broadcast(this.projectsMessage(msg.requestId));
4591
+ break;
4592
+ }
4360
4593
  case "list_directory": {
4361
4594
  try {
4362
4595
  const listing = await listAllowedDirectories(msg.path, this.allowedDirs, this.platform, msg.includeHidden ?? false);
@@ -4700,6 +4933,20 @@ export class BridgeWebSocketServer {
4700
4933
  }
4701
4934
  }
4702
4935
  }
4936
+ // A recording keeps its name snapshot after Project deletion, but a
4937
+ // live Project rename should be reflected when the list is read.
4938
+ for (const recording of recordings) {
4939
+ const projectId = recording.meta?.projectId;
4940
+ if (!projectId)
4941
+ continue;
4942
+ const project = this.workspaceStore?.getProject(projectId);
4943
+ if (project && recording.meta) {
4944
+ recording.meta = {
4945
+ ...recording.meta,
4946
+ projectName: project.name,
4947
+ };
4948
+ }
4949
+ }
4703
4950
  this.send(ws, { type: "recording_list", recordings });
4704
4951
  });
4705
4952
  break;
@@ -5758,9 +6005,17 @@ export class BridgeWebSocketServer {
5758
6005
  break;
5759
6006
  }
5760
6007
  try {
6008
+ const runtimeSession = msg.sessionId
6009
+ ? this.resolveSession(msg.sessionId)
6010
+ : undefined;
6011
+ const workspace = runtimeSession
6012
+ ? this.workspaceForRuntimeSession(runtimeSession)
6013
+ : undefined;
5761
6014
  const entry = await this.promptHistoryStore.record({
5762
6015
  text: msg.text,
5763
6016
  projectPath: msg.projectPath,
6017
+ projectId: workspace?.projectId ?? msg.projectId,
6018
+ projectName: workspace?.projectName ?? msg.projectName,
5764
6019
  clientId: msg.clientId,
5765
6020
  clientName: msg.clientName,
5766
6021
  sessionId: msg.sessionId,
@@ -5831,6 +6086,7 @@ export class BridgeWebSocketServer {
5831
6086
  id: msg.id,
5832
6087
  text: msg.text,
5833
6088
  projectPath: msg.projectPath,
6089
+ projectId: msg.projectId,
5834
6090
  action: msg.action,
5835
6091
  isFavorite: msg.isFavorite,
5836
6092
  updatedAt: msg.updatedAt,
@@ -6204,7 +6460,7 @@ export class BridgeWebSocketServer {
6204
6460
  }
6205
6461
  sendSessionList(ws) {
6206
6462
  this.pruneDebugEvents();
6207
- const sessions = this.sessionManager.list();
6463
+ const sessions = this.runtimeSessionsWithWorkspaces();
6208
6464
  this.send(ws, {
6209
6465
  type: "session_list",
6210
6466
  sessions,
@@ -6242,7 +6498,7 @@ export class BridgeWebSocketServer {
6242
6498
  /** Broadcast session list to all connected clients. */
6243
6499
  broadcastSessionList() {
6244
6500
  this.pruneDebugEvents();
6245
- const sessions = this.sessionManager.list();
6501
+ const sessions = this.runtimeSessionsWithWorkspaces();
6246
6502
  this.broadcast({
6247
6503
  type: "session_list",
6248
6504
  sessions,
@@ -6264,6 +6520,18 @@ export class BridgeWebSocketServer {
6264
6520
  ],
6265
6521
  });
6266
6522
  }
6523
+ runtimeSessionsWithWorkspaces() {
6524
+ return this.sessionManager
6525
+ .list()
6526
+ .map((summary) => this.runtimeSessionWithWorkspace(summary));
6527
+ }
6528
+ runtimeSessionWithWorkspace(summary) {
6529
+ const session = this.sessionManager.get(summary.id);
6530
+ const workspace = session
6531
+ ? this.workspaceForRuntimeSession(session)
6532
+ : undefined;
6533
+ return workspace ? { ...summary, workspace } : summary;
6534
+ }
6267
6535
  broadcastPromptHistoryStatus() {
6268
6536
  if (!this.promptHistoryStore)
6269
6537
  return;
@@ -6390,6 +6658,7 @@ export class BridgeWebSocketServer {
6390
6658
  }
6391
6659
  destroySession(sessionId) {
6392
6660
  this.flushSessionDeltaBatches(sessionId);
6661
+ this.pendingSessionWorkspaces.delete(sessionId);
6393
6662
  this.sessionManager.destroy(sessionId);
6394
6663
  }
6395
6664
  trackSessionMessage(sessionId, msg) {
@@ -6407,10 +6676,15 @@ export class BridgeWebSocketServer {
6407
6676
  msg.sessionId) {
6408
6677
  const session = this.sessionManager.get(sessionId);
6409
6678
  if (session) {
6679
+ const workspace = this.workspaceForRuntimeSession(session);
6410
6680
  this.recordingStore?.saveMeta(sessionId, {
6411
6681
  bridgeSessionId: sessionId,
6412
6682
  claudeSessionId: msg.sessionId,
6413
6683
  projectPath: session.projectPath,
6684
+ ...(workspace?.projectId ? { projectId: workspace.projectId } : {}),
6685
+ ...(workspace?.projectName
6686
+ ? { projectName: workspace.projectName }
6687
+ : {}),
6414
6688
  createdAt: session.createdAt.toISOString(),
6415
6689
  });
6416
6690
  }
@@ -6433,6 +6707,128 @@ export class BridgeWebSocketServer {
6433
6707
  }
6434
6708
  }
6435
6709
  async listRecentSessions(msg) {
6710
+ const primaryProjectIds = new Set(msg.projectPath === undefined
6711
+ ? []
6712
+ : (this.workspaceStore?.listProjects() ?? [])
6713
+ .filter((project) => project.rootPaths[0] === msg.projectPath)
6714
+ .map((project) => project.id));
6715
+ const workspaceFilterRequested = msg.projectId !== undefined ||
6716
+ msg.workspaceKind !== undefined ||
6717
+ primaryProjectIds.size > 0;
6718
+ if (!workspaceFilterRequested) {
6719
+ const rawResult = await this.listRecentSessionsRaw(msg);
6720
+ const enriched = rawResult.sessions.map((session) => this.enrichRecentSessionWorkspace(session));
6721
+ return { sessions: enriched, hasMore: rawResult.hasMore };
6722
+ }
6723
+ const matchesWorkspace = (session) => {
6724
+ const value = session;
6725
+ const workspace = value.workspace;
6726
+ if (msg.projectId && workspace?.projectId !== msg.projectId)
6727
+ return false;
6728
+ if (msg.workspaceKind && workspace?.kind !== msg.workspaceKind)
6729
+ return false;
6730
+ if (msg.projectPath !== undefined) {
6731
+ const matchesPrimaryProject = typeof workspace?.projectId === "string" &&
6732
+ primaryProjectIds.has(workspace.projectId);
6733
+ if (value.projectPath !== msg.projectPath &&
6734
+ !matchesPrimaryProject) {
6735
+ return false;
6736
+ }
6737
+ }
6738
+ return true;
6739
+ };
6740
+ const offset = msg.offset ?? 0;
6741
+ const limit = msg.limit ?? 20;
6742
+ const requiredMatches = offset + limit + 1;
6743
+ let filtered;
6744
+ if (msg.provider === "codex") {
6745
+ filtered = await this.listWorkspaceFilteredCodexSessions(msg, matchesWorkspace, requiredMatches);
6746
+ }
6747
+ else if (msg.provider === "claude") {
6748
+ filtered = await this.listWorkspaceFilteredIndexedSessions(msg, matchesWorkspace, requiredMatches);
6749
+ }
6750
+ else {
6751
+ // The filesystem index includes both providers and is also the fallback
6752
+ // for Codex rollouts not returned by app-server. Merge it with one
6753
+ // cursor-preserving app-server scan for the freshest Codex metadata.
6754
+ const [indexed, codex] = await Promise.all([
6755
+ this.listWorkspaceFilteredIndexedSessions(msg, matchesWorkspace, requiredMatches),
6756
+ this.listWorkspaceFilteredCodexSessions({ ...msg, provider: "codex" }, matchesWorkspace, requiredMatches),
6757
+ ]);
6758
+ filtered = mergeRecentSessionPages([...codex, ...indexed]);
6759
+ }
6760
+ return {
6761
+ sessions: filtered.slice(offset, offset + limit),
6762
+ hasMore: filtered.length > offset + limit,
6763
+ };
6764
+ }
6765
+ async listWorkspaceFilteredIndexedSessions(msg, matchesWorkspace, limit) {
6766
+ const result = await getAllRecentSessions({
6767
+ limit,
6768
+ offset: 0,
6769
+ // Project identity is authoritative. Do not pre-filter by the current
6770
+ // primary root: assignments may retain an older snapshot or worktree cwd.
6771
+ provider: msg.provider,
6772
+ namedOnly: msg.namedOnly,
6773
+ searchQuery: msg.searchQuery,
6774
+ archivedSessionIds: this.archiveStore.archivedIds(),
6775
+ sessionFilter: (session) => matchesWorkspace(this.enrichRecentSessionWorkspace(session)),
6776
+ });
6777
+ return result.sessions.map((session) => this.enrichRecentSessionWorkspace(session));
6778
+ }
6779
+ async listWorkspaceFilteredCodexSessions(msg, matchesWorkspace, limit) {
6780
+ try {
6781
+ return await this.listWorkspaceFilteredCodexThreads(msg, matchesWorkspace, limit);
6782
+ }
6783
+ catch (err) {
6784
+ console.warn(`[ws] Codex thread/list failed, falling back to rollout scan: ${err}`);
6785
+ return this.listWorkspaceFilteredIndexedSessions({ ...msg, provider: "codex" }, matchesWorkspace, limit);
6786
+ }
6787
+ }
6788
+ async listWorkspaceFilteredCodexThreads(msg, matchesWorkspace, limit) {
6789
+ const activeProcess = this.getActiveCodexProcess();
6790
+ const process = activeProcess ?? (await this.createStandaloneCodexProcess(undefined));
6791
+ const isStandalone = activeProcess === null;
6792
+ try {
6793
+ const archivedIds = this.archiveStore.archivedIds();
6794
+ const matchingThreads = [];
6795
+ let cursor;
6796
+ do {
6797
+ const request = {
6798
+ limit: 500,
6799
+ searchTerm: msg.searchQuery,
6800
+ sourceKinds: CODEX_RECENT_THREAD_SOURCE_KINDS,
6801
+ };
6802
+ if (cursor != null)
6803
+ request.cursor = cursor;
6804
+ const result = await process.listThreads(request);
6805
+ for (const thread of result.data) {
6806
+ if (archivedIds.has(thread.id))
6807
+ continue;
6808
+ if (msg.namedOnly && !thread.name)
6809
+ continue;
6810
+ const workspaceProbe = this.enrichRecentSessionWorkspace({
6811
+ provider: "codex",
6812
+ sessionId: thread.id,
6813
+ projectPath: thread.cwd,
6814
+ });
6815
+ if (!matchesWorkspace(workspaceProbe))
6816
+ continue;
6817
+ matchingThreads.push(thread);
6818
+ if (matchingThreads.length >= limit)
6819
+ break;
6820
+ }
6821
+ cursor = result.nextCursor;
6822
+ } while (matchingThreads.length < limit && cursor != null);
6823
+ const indexedById = await getCodexSessionIndexMetadata(matchingThreads.map((thread) => thread.id));
6824
+ return matchingThreads.map((thread) => this.enrichRecentSessionWorkspace(codexThreadToRecentSession(thread, indexedById.get(thread.id))));
6825
+ }
6826
+ finally {
6827
+ if (isStandalone)
6828
+ process.stop();
6829
+ }
6830
+ }
6831
+ async listRecentSessionsRaw(msg) {
6436
6832
  if (msg.provider === "codex") {
6437
6833
  return this.listRecentCodexSessions(msg);
6438
6834
  }
@@ -6449,11 +6845,106 @@ export class BridgeWebSocketServer {
6449
6845
  archivedSessionIds: this.archiveStore.archivedIds(),
6450
6846
  });
6451
6847
  }
6848
+ enrichRecentSessionWorkspace(session) {
6849
+ if (!session || typeof session !== "object")
6850
+ return session;
6851
+ const value = session;
6852
+ const provider = value.provider;
6853
+ const providerSessionId = value.sessionId;
6854
+ const projectPath = value.projectPath;
6855
+ if ((provider !== "claude" && provider !== "codex") ||
6856
+ typeof providerSessionId !== "string" ||
6857
+ typeof projectPath !== "string") {
6858
+ return session;
6859
+ }
6860
+ const workspace = this.workspaceStore?.resolveRecentWorkspace(provider, providerSessionId);
6861
+ return {
6862
+ ...value,
6863
+ workspace: workspace ?? {
6864
+ kind: "unassigned",
6865
+ rootPaths: projectPath ? [projectPath] : [],
6866
+ },
6867
+ };
6868
+ }
6869
+ normalizeWorkspaceRoots(rootPaths) {
6870
+ const roots = [];
6871
+ const seen = new Set();
6872
+ for (const rawPath of rootPaths) {
6873
+ const path = resolvePlatformPath(rawPath, this.platform);
6874
+ if (!this.isPathAllowed(path))
6875
+ return { deniedRoot: rawPath };
6876
+ if (!seen.has(path)) {
6877
+ seen.add(path);
6878
+ roots.push(path);
6879
+ }
6880
+ }
6881
+ return roots.length > 0 ? { roots } : {};
6882
+ }
6883
+ async persistPendingSessionWorkspace(sessionId) {
6884
+ const workspace = this.pendingSessionWorkspaces.get(sessionId);
6885
+ const session = this.sessionManager.get(sessionId);
6886
+ if (!workspace || !session?.claudeSessionId || !this.workspaceStore)
6887
+ return;
6888
+ try {
6889
+ await this.workspaceStore.assignSession(session.provider, session.claudeSessionId, workspace);
6890
+ this.pendingSessionWorkspaces.delete(sessionId);
6891
+ }
6892
+ catch (error) {
6893
+ console.error("[workspace] Failed to persist session assignment:", error);
6894
+ }
6895
+ }
6896
+ workspaceForRuntimeSession(session) {
6897
+ const pending = this.pendingSessionWorkspaces.get(session.id);
6898
+ if (pending) {
6899
+ return { ...pending, rootPaths: [...pending.rootPaths] };
6900
+ }
6901
+ if (!session.claudeSessionId || !this.workspaceStore)
6902
+ return undefined;
6903
+ const assignment = this.workspaceStore.getAssignment(session.provider, session.claudeSessionId);
6904
+ if (!assignment)
6905
+ return undefined;
6906
+ const project = assignment.projectId
6907
+ ? this.workspaceStore.getProject(assignment.projectId)
6908
+ : undefined;
6909
+ const projectName = project?.name ?? assignment.projectName;
6910
+ return {
6911
+ kind: assignment.kind,
6912
+ ...(assignment.projectId ? { projectId: assignment.projectId } : {}),
6913
+ ...(projectName ? { projectName } : {}),
6914
+ rootPaths: [...assignment.rootPaths],
6915
+ };
6916
+ }
6917
+ attachWorkspaceToRuntimeSession(sessionId, workspace) {
6918
+ if (!workspace)
6919
+ return;
6920
+ this.pendingSessionWorkspaces.set(sessionId, workspace);
6921
+ void this.persistPendingSessionWorkspace(sessionId);
6922
+ }
6923
+ projectsMessage(requestId) {
6924
+ return {
6925
+ type: "projects",
6926
+ projects: this.workspaceStore?.listProjects() ?? [],
6927
+ ...(requestId ? { requestId } : {}),
6928
+ };
6929
+ }
6930
+ sendWorkspaceMutationError(ws, requestId, action, error) {
6931
+ this.send(ws, {
6932
+ type: "error",
6933
+ requestId,
6934
+ errorCode: "workspace_write_failed",
6935
+ message: `Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`,
6936
+ });
6937
+ }
6938
+ legacyProjectHistoryProjects() {
6939
+ return this.projectHistory?.getProjects() ?? [];
6940
+ }
6452
6941
  listRecentSessionsCoalesced(msg) {
6453
6942
  const key = JSON.stringify({
6454
6943
  limit: msg.limit ?? null,
6455
6944
  offset: msg.offset ?? null,
6456
6945
  projectPath: msg.projectPath ?? null,
6946
+ projectId: msg.projectId ?? null,
6947
+ workspaceKind: msg.workspaceKind ?? null,
6457
6948
  provider: msg.provider ?? null,
6458
6949
  namedOnly: msg.namedOnly ?? null,
6459
6950
  searchQuery: msg.searchQuery ?? null,
@@ -6810,11 +7301,14 @@ export class BridgeWebSocketServer {
6810
7301
  throw err;
6811
7302
  }
6812
7303
  }
6813
- /** Extract a short project label from the full projectPath (last directory name). */
7304
+ /** Resolve a user-facing Project label, falling back to the cwd basename. */
6814
7305
  projectLabel(sessionId) {
6815
7306
  const session = this.sessionManager.get(sessionId);
6816
7307
  if (!session?.projectPath)
6817
7308
  return "";
7309
+ const workspace = this.workspaceForRuntimeSession(session);
7310
+ if (workspace?.projectName)
7311
+ return workspace.projectName;
6818
7312
  const parts = session.projectPath.replace(/\/+$/, "").split("/");
6819
7313
  return parts[parts.length - 1] || "";
6820
7314
  }