farming-code 2.2.19 → 2.2.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +5 -5
  2. package/README.zh_cn.md +5 -5
  3. package/THIRD_PARTY_NOTICES.md +5 -8
  4. package/backend/acp/client-services.js +37 -5
  5. package/backend/acp/patch-decisions.js +19 -3
  6. package/backend/acp-runtime.js +1201 -198
  7. package/backend/acp-transcript.js +66 -0
  8. package/backend/agent-lifecycle-journal.js +133 -0
  9. package/backend/agent-manager.js +2715 -354
  10. package/backend/agent-order.js +8 -4
  11. package/backend/agent-runtime-binding.js +1 -0
  12. package/backend/bundle-update-helper.js +5 -1
  13. package/backend/config-manager.js +16 -0
  14. package/backend/control-api.js +146 -20
  15. package/backend/farming-app-cli.js +335 -10
  16. package/backend/farming-session-store.js +283 -36
  17. package/backend/json-cli-runtime.js +120 -6
  18. package/backend/npm-update-helper.js +97 -18
  19. package/backend/provider-adapters.js +18 -6
  20. package/backend/review-session-service.js +31 -17
  21. package/backend/server.js +337 -103
  22. package/backend/storage-layout.js +10 -0
  23. package/backend/terminal-screen-worker-pool.js +15 -6
  24. package/backend/theme-manager.js +23 -5
  25. package/backend/update-service.js +239 -27
  26. package/backend/usage-monitor.js +11 -0
  27. package/backend/workspace-file-router.js +9 -3
  28. package/backend/workspace-file-service.js +292 -62
  29. package/dist/acp/codex-acp-1.1.4.js +32 -1
  30. package/dist/assets/App-CmZdNavW.js +210 -0
  31. package/dist/assets/{FileEditorMarkdownPreview-R0jwwLME.js → FileEditorMarkdownPreview-KbnE8VC8.js} +1 -1
  32. package/dist/assets/FileEditorPane-5_HUejjd.js +2 -0
  33. package/dist/assets/IconGlyphs-C2WFcoJS.js +1 -0
  34. package/dist/assets/ProjectFilesSection-C1RAJWOE.js +12 -0
  35. package/dist/assets/{ReviewPage-DBKNOWsJ.js → ReviewPage-L1vh1Ggs.js} +3 -3
  36. package/dist/assets/code-dark-BKtvf0Xc.css +1 -0
  37. package/dist/assets/file-icons-CHWt5nSy.js +1 -0
  38. package/dist/assets/{index-DQdtPfCk.js → index-RvUiMpGP.js} +2 -2
  39. package/dist/assets/main-XMXgyJJt.css +1 -0
  40. package/dist/assets/workspace-view-state-BqB4HM7r.js +7 -0
  41. package/dist/index.html +1 -1
  42. package/frontend/skins/crt/app.js +365 -107
  43. package/frontend/skins/crt/index.html +5 -10
  44. package/frontend/skins/crt/styles/billing.css +6 -1
  45. package/package.json +9 -3
  46. package/dist/assets/App-CbXYBccJ.js +0 -210
  47. package/dist/assets/FileEditorPane-CJs6kfZO.js +0 -2
  48. package/dist/assets/IconGlyphs-CZzDr5o2.js +0 -1
  49. package/dist/assets/ProjectFilesSection-DWCV07lA.js +0 -12
  50. package/dist/assets/code-dark-qIBiLBWV.css +0 -1
  51. package/dist/assets/file-icons-DpbrD5ZP.js +0 -1
  52. package/dist/assets/main-yzZEfgft.css +0 -1
  53. package/dist/assets/workspace-view-state-BpPINu84.js +0 -7
@@ -404,6 +404,17 @@ function metadataFileVersion(relativePath, stat) {
404
404
  return sha1(Buffer.from(`${relativePath}:${stat.size}:${stat.mtimeMs}`, 'utf8'));
405
405
  }
406
406
 
407
+ function workspaceEntryVersion(stat) {
408
+ return sha1(Buffer.from([
409
+ stat.dev,
410
+ stat.ino,
411
+ stat.mode,
412
+ stat.size,
413
+ stat.mtimeMs,
414
+ stat.ctimeMs,
415
+ ].join(':'), 'utf8'));
416
+ }
417
+
407
418
  async function readTextPrefix(target, maxBytes) {
408
419
  const handle = await fsp.open(target, 'r');
409
420
  try {
@@ -1122,7 +1133,12 @@ class WorkspaceFileService {
1122
1133
  this.gitStatusInlineTimeoutMs = options.gitStatusInlineTimeoutMs ?? DEFAULT_GIT_STATUS_INLINE_TIMEOUT_MS;
1123
1134
  this.gitStatusTimeoutMs = options.gitStatusTimeoutMs ?? DEFAULT_GIT_STATUS_TIMEOUT_MS;
1124
1135
  this.gitStatusCache = new Map();
1136
+ this.mutationQueues = new Map();
1137
+ this.flushWorkspaceWrites = options.flushWorkspaceWrites !== false;
1125
1138
  this.watchers = new Map();
1139
+ this.watcherSubscriptionGeneration = 0;
1140
+ this.watcherLifecycleGeneration = 0;
1141
+ this.disposed = false;
1126
1142
  this.watchOptions = options.watchOptions || {};
1127
1143
  this.watchDepth = Number.isFinite(options.watchDepth) ? Math.max(0, options.watchDepth) : DEFAULT_WATCH_DEPTH;
1128
1144
  this.commandRunner = options.commandRunner || new CommandRunner(options.commandRunnerOptions);
@@ -1136,6 +1152,33 @@ class WorkspaceFileService {
1136
1152
  });
1137
1153
  }
1138
1154
 
1155
+ async runWorkspaceMutation(workspaceRoot, operation) {
1156
+ const root = await this.resolveRoot(workspaceRoot);
1157
+ const previous = this.mutationQueues.get(root) || Promise.resolve();
1158
+ let release;
1159
+ const current = new Promise(resolve => {
1160
+ release = resolve;
1161
+ });
1162
+ const tail = previous.then(() => current);
1163
+ this.mutationQueues.set(root, tail);
1164
+ await previous;
1165
+ try {
1166
+ return await operation(root);
1167
+ } finally {
1168
+ release();
1169
+ if (this.mutationQueues.get(root) === tail) {
1170
+ this.mutationQueues.delete(root);
1171
+ }
1172
+ }
1173
+ }
1174
+
1175
+ async waitForWorkspaceMutations(workspaceRoot) {
1176
+ const root = await this.resolveRoot(workspaceRoot);
1177
+ const pending = this.mutationQueues.get(root);
1178
+ if (pending) await pending;
1179
+ return root;
1180
+ }
1181
+
1139
1182
  async execRipgrep(args, options = {}) {
1140
1183
  try {
1141
1184
  return await this.execFile(this.rgPath, args, options);
@@ -1618,7 +1661,8 @@ class WorkspaceFileService {
1618
1661
  }
1619
1662
 
1620
1663
  async listTree(workspaceRoot, userPath = '', options = {}) {
1621
- const { root, target, relativePath, external: parentExternal = false } = await this.resolvePath(workspaceRoot, userPath, options);
1664
+ const root = await this.waitForWorkspaceMutations(workspaceRoot);
1665
+ const { target, relativePath, external: parentExternal = false } = await this.resolvePath(root, userPath, options);
1622
1666
  const stat = await fsp.stat(target);
1623
1667
  if (!stat.isDirectory()) {
1624
1668
  throw new WorkspaceFileError('path must be a directory', 400);
@@ -1679,6 +1723,7 @@ class WorkspaceFileService {
1679
1723
  type,
1680
1724
  size: targetStat.size,
1681
1725
  mtimeMs: targetStat.mtimeMs,
1726
+ version: workspaceEntryVersion(entryStat),
1682
1727
  ...(symbolicLink ? { symbolicLink: true } : {}),
1683
1728
  ...(external ? { external: true } : {}),
1684
1729
  ...(readOnly ? { readOnly: true } : {}),
@@ -2198,6 +2243,25 @@ class WorkspaceFileService {
2198
2243
  }
2199
2244
 
2200
2245
  async writeFile(workspaceRoot, userPath, content, options = {}) {
2246
+ return this.runWorkspaceMutation(workspaceRoot, root => (
2247
+ this.writeFileUnlocked(root, userPath, content, options)
2248
+ ));
2249
+ }
2250
+
2251
+ async flushWorkspaceFileHandle(handle) {
2252
+ if (!this.flushWorkspaceWrites) return;
2253
+ try {
2254
+ await handle.datasync();
2255
+ } catch (error) {
2256
+ if (!['EINVAL', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP'].includes(error?.code)) {
2257
+ throw error;
2258
+ }
2259
+ this.flushWorkspaceWrites = false;
2260
+ console.warn('Workspace file datasync is unsupported; disabling it for this server process:', error);
2261
+ }
2262
+ }
2263
+
2264
+ async writeFileUnlocked(workspaceRoot, userPath, content, options = {}) {
2201
2265
  if (typeof content !== 'string') {
2202
2266
  throw new WorkspaceFileError('content must be a string', 400);
2203
2267
  }
@@ -2211,9 +2275,13 @@ class WorkspaceFileService {
2211
2275
  const overwrite = options.overwrite === true;
2212
2276
  let currentMode = 0o666;
2213
2277
  let currentSha1 = '';
2278
+ let currentStat = null;
2214
2279
  let exists = false;
2280
+ let symbolicLink = false;
2215
2281
 
2216
2282
  try {
2283
+ const requestedStat = await fsp.lstat(target);
2284
+ symbolicLink = requestedStat.isSymbolicLink();
2217
2285
  const realTarget = await fsp.realpath(target);
2218
2286
  if (!isInside(root, realTarget)) {
2219
2287
  throw new WorkspaceFileError('symlinks outside the workspace are not allowed', 403);
@@ -2225,26 +2293,37 @@ class WorkspaceFileService {
2225
2293
  }
2226
2294
 
2227
2295
  try {
2228
- const stat = await fsp.stat(writeTarget);
2229
- if (!stat.isFile()) {
2296
+ currentStat = await fsp.stat(writeTarget);
2297
+ if (!currentStat.isFile()) {
2230
2298
  throw new WorkspaceFileError('path must be a file', 400);
2231
2299
  }
2232
- if (stat.size > this.maxFileSize) {
2233
- throw new WorkspaceFileError('existing file is too large to overwrite safely', 413, { size: stat.size });
2300
+ if (currentStat.size > this.maxFileSize) {
2301
+ throw new WorkspaceFileError('existing file is too large to overwrite safely', 413, { size: currentStat.size });
2234
2302
  }
2235
2303
  if (await isProbablyBinaryFile(writeTarget)) {
2236
2304
  throw new WorkspaceFileError('binary files cannot be overwritten as text', 415);
2237
2305
  }
2238
2306
  const currentBuffer = await fsp.readFile(writeTarget);
2239
2307
  currentSha1 = sha1(currentBuffer);
2240
- currentMode = stat.mode;
2308
+ currentMode = currentStat.mode;
2241
2309
  exists = true;
2242
2310
  } catch (error) {
2243
2311
  if (error instanceof WorkspaceFileError) throw error;
2244
2312
  if (error.code !== 'ENOENT') throw error;
2245
2313
  }
2246
2314
 
2315
+ const desiredSha1 = sha1(Buffer.from(content, 'utf8'));
2247
2316
  if (baseSha1 && currentSha1 && baseSha1 !== currentSha1 && !overwrite) {
2317
+ if (currentSha1 === desiredSha1) {
2318
+ return {
2319
+ path: relativePath,
2320
+ content,
2321
+ size: Buffer.byteLength(content, 'utf8'),
2322
+ mtimeMs: currentStat.mtimeMs,
2323
+ sha1: desiredSha1,
2324
+ ...(symbolicLink ? { symbolicLink: true } : {}),
2325
+ };
2326
+ }
2248
2327
  throw new WorkspaceFileError('file changed on disk', 409, {
2249
2328
  path: relativePath,
2250
2329
  currentSha1,
@@ -2256,16 +2335,48 @@ class WorkspaceFileService {
2256
2335
 
2257
2336
  const tempPath = path.join(
2258
2337
  path.dirname(writeTarget),
2259
- `.${path.basename(writeTarget)}.farming-${process.pid}-${Date.now()}.tmp`
2338
+ `.${path.basename(writeTarget)}.farming-${process.pid}-${crypto.randomUUID()}.tmp`
2260
2339
  );
2261
- await fsp.writeFile(tempPath, content, { mode: currentMode });
2262
- await fsp.rename(tempPath, writeTarget);
2340
+ let tempHandle = null;
2341
+ let committedStat = null;
2342
+ try {
2343
+ tempHandle = await fsp.open(tempPath, 'wx', currentMode);
2344
+ await tempHandle.writeFile(content);
2345
+ await this.flushWorkspaceFileHandle(tempHandle);
2346
+ committedStat = await tempHandle.stat();
2347
+ await tempHandle.close();
2348
+ tempHandle = null;
2349
+ await fsp.rename(tempPath, writeTarget);
2350
+ } catch (error) {
2351
+ if (tempHandle) {
2352
+ await tempHandle.close().catch(() => {});
2353
+ }
2354
+ await fsp.unlink(tempPath).catch(unlinkError => {
2355
+ if (unlinkError.code !== 'ENOENT') {
2356
+ console.error('Failed to clean up workspace file temporary:', unlinkError);
2357
+ }
2358
+ });
2359
+ throw error;
2360
+ }
2263
2361
  this.invalidateGitStatus(root);
2264
2362
 
2265
- return this.readFile(workspaceRoot, relativePath);
2363
+ return {
2364
+ path: relativePath,
2365
+ content,
2366
+ size: Buffer.byteLength(content, 'utf8'),
2367
+ mtimeMs: committedStat.mtimeMs,
2368
+ sha1: desiredSha1,
2369
+ ...(symbolicLink ? { symbolicLink: true } : {}),
2370
+ };
2266
2371
  }
2267
2372
 
2268
- async moveEntry(workspaceRoot, sourcePath, targetDirectory = '') {
2373
+ async moveEntry(workspaceRoot, sourcePath, targetDirectory = '', options = {}) {
2374
+ return this.runWorkspaceMutation(workspaceRoot, root => (
2375
+ this.moveEntryUnlocked(root, sourcePath, targetDirectory, options)
2376
+ ));
2377
+ }
2378
+
2379
+ async moveEntryUnlocked(workspaceRoot, sourcePath, targetDirectory = '', options = {}) {
2269
2380
  const root = await this.resolveRoot(workspaceRoot);
2270
2381
  const normalizedSource = normalizeUserPath(sourcePath);
2271
2382
  const normalizedTargetDirectory = normalizeUserPath(targetDirectory);
@@ -2310,6 +2421,13 @@ class WorkspaceFileService {
2310
2421
  }
2311
2422
 
2312
2423
  const sourceStat = await fsp.lstat(source.target);
2424
+ const sourceVersion = workspaceEntryVersion(sourceStat);
2425
+ if (options.expectedVersion && options.expectedVersion !== sourceVersion) {
2426
+ throw new WorkspaceFileError('source changed on disk', 409, {
2427
+ path: source.relativePath,
2428
+ currentVersion: sourceVersion,
2429
+ });
2430
+ }
2313
2431
  if (sourceStat.isDirectory() && isInside(source.target, target)) {
2314
2432
  throw new WorkspaceFileError('directory cannot be moved into itself', 400);
2315
2433
  }
@@ -2323,6 +2441,7 @@ class WorkspaceFileService {
2323
2441
  }
2324
2442
 
2325
2443
  await fsp.rename(source.target, target);
2444
+ const targetStat = await fsp.lstat(target);
2326
2445
  this.invalidateGitStatus(root);
2327
2446
 
2328
2447
  return {
@@ -2330,10 +2449,18 @@ class WorkspaceFileService {
2330
2449
  targetPath,
2331
2450
  sourceDirectory: parentDirectory(source.relativePath),
2332
2451
  targetDirectory: normalizedTargetDirectory,
2452
+ sourceVersion,
2453
+ targetVersion: workspaceEntryVersion(targetStat),
2333
2454
  };
2334
2455
  }
2335
2456
 
2336
2457
  async createEntry(workspaceRoot, parentPath = '', name, type = 'file', content = '') {
2458
+ return this.runWorkspaceMutation(workspaceRoot, root => (
2459
+ this.createEntryUnlocked(root, parentPath, name, type, content)
2460
+ ));
2461
+ }
2462
+
2463
+ async createEntryUnlocked(workspaceRoot, parentPath = '', name, type = 'file', content = '') {
2337
2464
  const root = await this.resolveRoot(workspaceRoot);
2338
2465
  const normalizedParentPath = normalizeUserPath(parentPath);
2339
2466
  const entryName = normalizeEntryName(name);
@@ -2368,43 +2495,59 @@ class WorkspaceFileService {
2368
2495
  throw new WorkspaceFileError('ignored paths cannot be changed', 403);
2369
2496
  }
2370
2497
 
2371
- try {
2372
- await fsp.lstat(target);
2373
- throw new WorkspaceFileError('target path already exists', 409, { path: targetPath });
2374
- } catch (error) {
2375
- if (error instanceof WorkspaceFileError) throw error;
2376
- if (error.code !== 'ENOENT') throw error;
2377
- }
2378
-
2379
2498
  if (entryType === 'directory') {
2380
- await fsp.mkdir(target);
2499
+ try {
2500
+ await fsp.mkdir(target);
2501
+ } catch (error) {
2502
+ if (error.code === 'EEXIST') {
2503
+ throw new WorkspaceFileError('target path already exists', 409, { path: targetPath });
2504
+ }
2505
+ throw error;
2506
+ }
2507
+ const stat = await fsp.lstat(target);
2381
2508
  this.invalidateGitStatus(root);
2382
2509
  return {
2383
2510
  entry: {
2384
2511
  name: entryName,
2385
2512
  path: targetPath,
2386
2513
  type: 'directory',
2387
- size: 0,
2388
- mtimeMs: Date.now(),
2514
+ size: stat.size,
2515
+ mtimeMs: stat.mtimeMs,
2516
+ version: workspaceEntryVersion(stat),
2389
2517
  },
2390
2518
  };
2391
2519
  }
2392
2520
 
2393
- await fsp.writeFile(target, String(content || ''));
2521
+ try {
2522
+ await fsp.writeFile(target, String(content || ''), { flag: 'wx' });
2523
+ } catch (error) {
2524
+ if (error.code === 'EEXIST') {
2525
+ throw new WorkspaceFileError('target path already exists', 409, { path: targetPath });
2526
+ }
2527
+ throw error;
2528
+ }
2529
+ const stat = await fsp.lstat(target);
2394
2530
  this.invalidateGitStatus(root);
2395
2531
  return {
2396
2532
  entry: {
2397
2533
  name: entryName,
2398
2534
  path: targetPath,
2399
2535
  type: 'file',
2400
- size: Buffer.byteLength(String(content || ''), 'utf8'),
2401
- mtimeMs: Date.now(),
2536
+ size: stat.size,
2537
+ mtimeMs: stat.mtimeMs,
2538
+ version: workspaceEntryVersion(stat),
2402
2539
  },
2403
2540
  file: await this.readFile(workspaceRoot, targetPath),
2404
2541
  };
2405
2542
  }
2406
2543
 
2407
- async renameEntry(workspaceRoot, sourcePath, name) {
2544
+ async renameEntry(workspaceRoot, sourcePath, name, options = {}) {
2545
+ return this.runWorkspaceMutation(workspaceRoot, root => (
2546
+ this.renameEntryUnlocked(root, sourcePath, name, options)
2547
+ ));
2548
+ }
2549
+
2550
+ async renameEntryUnlocked(workspaceRoot, sourcePath, name, options = {}) {
2408
2551
  const root = await this.resolveRoot(workspaceRoot);
2409
2552
  const normalizedSource = normalizeUserPath(sourcePath);
2410
2553
  const entryName = normalizeEntryName(name);
@@ -2432,6 +2575,15 @@ class WorkspaceFileService {
2432
2575
  };
2433
2576
  }
2434
2577
 
2578
+ const sourceStat = await fsp.lstat(source.target);
2579
+ const sourceVersion = workspaceEntryVersion(sourceStat);
2580
+ if (options.expectedVersion && options.expectedVersion !== sourceVersion) {
2581
+ throw new WorkspaceFileError('source changed on disk', 409, {
2582
+ path: source.relativePath,
2583
+ currentVersion: sourceVersion,
2584
+ });
2585
+ }
2586
+
2435
2587
  try {
2436
2588
  await fsp.lstat(target);
2437
2589
  throw new WorkspaceFileError('target path already exists', 409, { path: targetPath });
@@ -2441,6 +2593,7 @@ class WorkspaceFileService {
2441
2593
  }
2442
2594
 
2443
2595
  await fsp.rename(source.target, target);
2596
+ const targetStat = await fsp.lstat(target);
2444
2597
  this.invalidateGitStatus(root);
2445
2598
 
2446
2599
  return {
@@ -2448,10 +2601,18 @@ class WorkspaceFileService {
2448
2601
  targetPath,
2449
2602
  sourceDirectory: parentDirectory(source.relativePath),
2450
2603
  targetDirectory: parentDirectory(targetPath),
2604
+ sourceVersion,
2605
+ targetVersion: workspaceEntryVersion(targetStat),
2451
2606
  };
2452
2607
  }
2453
2608
 
2454
- async deleteEntry(workspaceRoot, userPath) {
2609
+ async deleteEntry(workspaceRoot, userPath, options = {}) {
2610
+ return this.runWorkspaceMutation(workspaceRoot, root => (
2611
+ this.deleteEntryUnlocked(root, userPath, options)
2612
+ ));
2613
+ }
2614
+
2615
+ async deleteEntryUnlocked(workspaceRoot, userPath, options = {}) {
2455
2616
  const { root, target, relativePath } = await this.resolveEntryPath(workspaceRoot, userPath);
2456
2617
  if (!relativePath) {
2457
2618
  throw new WorkspaceFileError('workspace root cannot be deleted', 400);
@@ -2461,6 +2622,13 @@ class WorkspaceFileService {
2461
2622
  }
2462
2623
 
2463
2624
  const stat = await fsp.lstat(target);
2625
+ const version = workspaceEntryVersion(stat);
2626
+ if (options.expectedVersion && options.expectedVersion !== version) {
2627
+ throw new WorkspaceFileError('entry changed on disk', 409, {
2628
+ path: relativePath,
2629
+ currentVersion: version,
2630
+ });
2631
+ }
2464
2632
  const type = stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : stat.isSymbolicLink() ? 'symlink' : 'other';
2465
2633
  if (stat.isDirectory()) {
2466
2634
  await fsp.rm(target, { recursive: true, force: false });
@@ -2473,6 +2641,7 @@ class WorkspaceFileService {
2473
2641
  path: relativePath,
2474
2642
  parentDirectory: parentDirectory(relativePath),
2475
2643
  type,
2644
+ version,
2476
2645
  };
2477
2646
  }
2478
2647
 
@@ -2975,12 +3144,17 @@ class WorkspaceFileService {
2975
3144
  }
2976
3145
 
2977
3146
  async subscribe(workspaceRoot, callback) {
3147
+ if (this.disposed) return async () => {};
3148
+ const lifecycleGeneration = this.watcherLifecycleGeneration;
2978
3149
  const root = await this.resolveRoot(workspaceRoot);
3150
+ if (this.disposed || this.watcherLifecycleGeneration !== lifecycleGeneration) return async () => {};
2979
3151
  const watchRoot = path.resolve(workspaceRoot);
2980
3152
  let record = this.watchers.get(root);
2981
3153
 
2982
3154
  if (!record) {
2983
3155
  const subscribers = new Set();
3156
+ const generation = this.watcherSubscriptionGeneration + 1;
3157
+ this.watcherSubscriptionGeneration = generation;
2984
3158
 
2985
3159
  const emit = (eventType, absolutePath) => {
2986
3160
  const relative = relativeFromRoot(watchRoot, absolutePath);
@@ -3003,56 +3177,112 @@ class WorkspaceFileService {
3003
3177
  });
3004
3178
  };
3005
3179
 
3006
- const configuredIgnored = this.watchOptions.ignored;
3007
- const ignored = (candidatePath) => {
3008
- const relative = path.relative(watchRoot, candidatePath);
3009
- if (shouldHidePath(relative)) return true;
3010
- if (typeof configuredIgnored === 'function') return configuredIgnored(candidatePath);
3011
- if (configuredIgnored instanceof RegExp) return configuredIgnored.test(candidatePath);
3012
- if (Array.isArray(configuredIgnored)) {
3013
- return configuredIgnored.some(pattern => (
3014
- pattern instanceof RegExp ? pattern.test(candidatePath) : String(candidatePath).includes(String(pattern))
3015
- ));
3016
- }
3017
- return false;
3180
+ record = {
3181
+ watcher: null,
3182
+ subscribers,
3183
+ generation,
3184
+ cancelled: false,
3185
+ cancelInitialization: null,
3186
+ closePromise: null,
3187
+ ready: null,
3018
3188
  };
3019
- const chokidar = await loadChokidar();
3020
- const watcher = chokidar.watch(watchRoot, {
3021
- ignoreInitial: true,
3022
- depth: this.watchDepth,
3023
- ...this.watchOptions,
3024
- ignored,
3025
- });
3026
-
3027
- ['add', 'change', 'unlink', 'addDir', 'unlinkDir'].forEach((eventType) => {
3028
- watcher.on(eventType, filePath => emit(eventType, filePath));
3029
- });
3030
- watcher.on('error', emitError);
3031
- const ready = new Promise(resolve => watcher.once('ready', resolve));
3032
-
3033
- record = { watcher, subscribers, ready };
3034
3189
  this.watchers.set(root, record);
3190
+ record.ready = (async () => {
3191
+ const configuredIgnored = this.watchOptions.ignored;
3192
+ const ignored = (candidatePath) => {
3193
+ const relative = path.relative(watchRoot, candidatePath);
3194
+ if (shouldHidePath(relative)) return true;
3195
+ if (typeof configuredIgnored === 'function') return configuredIgnored(candidatePath);
3196
+ if (configuredIgnored instanceof RegExp) return configuredIgnored.test(candidatePath);
3197
+ if (Array.isArray(configuredIgnored)) {
3198
+ return configuredIgnored.some(pattern => (
3199
+ pattern instanceof RegExp ? pattern.test(candidatePath) : String(candidatePath).includes(String(pattern))
3200
+ ));
3201
+ }
3202
+ return false;
3203
+ };
3204
+ const chokidar = await loadChokidar();
3205
+ if (
3206
+ record.cancelled
3207
+ || this.watchers.get(root)?.generation !== generation
3208
+ ) return false;
3209
+ const watcher = chokidar.watch(watchRoot, {
3210
+ ignoreInitial: true,
3211
+ depth: this.watchDepth,
3212
+ ...this.watchOptions,
3213
+ ignored,
3214
+ });
3215
+ record.watcher = watcher;
3216
+
3217
+ ['add', 'change', 'unlink', 'addDir', 'unlinkDir'].forEach((eventType) => {
3218
+ watcher.on(eventType, filePath => emit(eventType, filePath));
3219
+ });
3220
+ watcher.on('error', emitError);
3221
+ const initialized = await new Promise(resolve => {
3222
+ let settled = false;
3223
+ const finish = value => {
3224
+ if (settled) return;
3225
+ settled = true;
3226
+ resolve(value);
3227
+ };
3228
+ watcher.once('ready', () => finish(true));
3229
+ record.cancelInitialization = () => finish(false);
3230
+ });
3231
+ record.cancelInitialization = null;
3232
+ return initialized
3233
+ && !record.cancelled
3234
+ && this.watchers.get(root)?.generation === generation;
3235
+ })();
3035
3236
  }
3036
3237
 
3037
- await record.ready;
3038
3238
  record.subscribers.add(callback);
3039
- return async () => {
3239
+ let initialized = false;
3240
+ try {
3241
+ initialized = await record.ready;
3242
+ } catch (error) {
3040
3243
  record.subscribers.delete(callback);
3041
- if (record.subscribers.size === 0) {
3244
+ if (record.subscribers.size === 0 && this.watchers.get(root)?.generation === record.generation) {
3042
3245
  this.watchers.delete(root);
3043
- const closeResult = record.watcher.close();
3044
- if (closeResult && typeof closeResult.then === 'function') {
3045
- await closeResult;
3046
- }
3246
+ await this.closeWorkspaceWatcherRecord(record);
3247
+ }
3248
+ throw error;
3249
+ }
3250
+ if (!initialized) {
3251
+ record.subscribers.delete(callback);
3252
+ return async () => {};
3253
+ }
3254
+ return async () => {
3255
+ if (!record.subscribers.delete(callback)) return;
3256
+ if (
3257
+ record.subscribers.size === 0
3258
+ && this.watchers.get(root)?.generation === record.generation
3259
+ ) {
3260
+ this.watchers.delete(root);
3261
+ await this.closeWorkspaceWatcherRecord(record);
3047
3262
  }
3048
3263
  };
3049
3264
  }
3050
3265
 
3266
+ async closeWorkspaceWatcherRecord(record) {
3267
+ if (record.closePromise) return record.closePromise;
3268
+ record.cancelled = true;
3269
+ record.cancelInitialization?.();
3270
+ record.closePromise = (async () => {
3271
+ await record.ready?.catch(() => false);
3272
+ if (!record.watcher) return;
3273
+ const closeResult = record.watcher.close();
3274
+ if (closeResult && typeof closeResult.then === 'function') await closeResult;
3275
+ })();
3276
+ return record.closePromise;
3277
+ }
3278
+
3051
3279
  async dispose() {
3052
3280
  const watchers = Array.from(this.watchers.values());
3053
3281
  this.watchers.clear();
3282
+ this.disposed = true;
3283
+ this.watcherLifecycleGeneration += 1;
3054
3284
  this.gitStatusCache.clear();
3055
- await Promise.all(watchers.map(record => record.watcher.close()));
3285
+ await Promise.all(watchers.map(record => this.closeWorkspaceWatcherRecord(record)));
3056
3286
  if (this.ownsCommandRunner) {
3057
3287
  this.commandRunner.dispose();
3058
3288
  }
@@ -26066,6 +26066,17 @@ var CodexAcpClient = class {
26066
26066
  additionalDirectories
26067
26067
  };
26068
26068
  }
26069
+ async forkSession(request) {
26070
+ const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
26071
+ await this.refreshSkills(request.cwd, additionalDirectories);
26072
+ const response = await this.codexClient.threadFork({
26073
+ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
26074
+ cwd: request.cwd,
26075
+ modelProvider: await this.getResumeModelProvider(),
26076
+ threadId: request.sessionId
26077
+ });
26078
+ return { sessionId: response.thread.id };
26079
+ }
26069
26080
  async newSession(request) {
26070
26081
  const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
26071
26082
  await this.refreshSkills(request.cwd, additionalDirectories);
@@ -28211,6 +28222,7 @@ var CodexAcpServer = class _CodexAcpServer {
28211
28222
  sessionCapabilities: {
28212
28223
  resume: {},
28213
28224
  list: {},
28225
+ fork: {},
28214
28226
  close: {},
28215
28227
  delete: {},
28216
28228
  additionalDirectories: {}
@@ -28512,6 +28524,22 @@ You have been logged out. Please try again.`);
28512
28524
  ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId))
28513
28525
  };
28514
28526
  }
28527
+ async unstable_forkSession(params) {
28528
+ logger.log("Forking session...", { sessionId: params.sessionId });
28529
+ const sessionState = this.sessions.get(params.sessionId);
28530
+ if (!sessionState) {
28531
+ throw RequestError.invalidParams(void 0, `Unknown session: ${params.sessionId}`);
28532
+ }
28533
+ if (this.activePrompts.has(sessionState.sessionId)) {
28534
+ throw RequestError.invalidRequest({ details: "Cannot fork an active Codex turn" });
28535
+ }
28536
+ const response = await this.runWithProcessCheck(() => this.codexAcpClient.forkSession(params));
28537
+ logger.log("Session forked", {
28538
+ sourceSessionId: params.sessionId,
28539
+ sessionId: response.sessionId
28540
+ });
28541
+ return response;
28542
+ }
28515
28543
  async resumeSession(params) {
28516
28544
  logger.log("Resuming session...", { sessionId: params.sessionId });
28517
28545
  const [sessionId, modelState, modeState] = await this.getOrCreateSession(params);
@@ -30074,6 +30102,9 @@ var CodexAppServerClient = class {
30074
30102
  async threadResume(params) {
30075
30103
  return await this.sendRequest({ method: "thread/resume", params });
30076
30104
  }
30105
+ async threadFork(params) {
30106
+ return await this.sendRequest({ method: "thread/fork", params });
30107
+ }
30077
30108
  getThreadSettings(threadId) {
30078
30109
  return this.threadSettings.get(threadId);
30079
30110
  }
@@ -30714,5 +30745,5 @@ function startAcpServer() {
30714
30745
  codexAcpServer = null;
30715
30746
  }
30716
30747
  });
30717
- }).onRequest(methods.agent.initialize, (ctx) => getAgent().initialize(ctx.params)).onRequest(methods.agent.session.new, (ctx) => getAgent().newSession(ctx.params)).onRequest(methods.agent.session.load, (ctx) => getAgent().loadSession(ctx.params)).onRequest(methods.agent.session.list, (ctx) => getAgent().listSessions(ctx.params)).onRequest(methods.agent.session.delete, (ctx) => getAgent().deleteSession(ctx.params)).onRequest(methods.agent.session.resume, (ctx) => getAgent().resumeSession(ctx.params)).onRequest(methods.agent.session.close, (ctx) => getAgent().closeSession(ctx.params)).onRequest(methods.agent.session.setMode, (ctx) => getAgent().setSessionMode(ctx.params)).onRequest(methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOption(ctx.params)).onRequest(methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params)).onRequest(methods.agent.logout, (ctx) => getAgent().logout(ctx.params)).onRequest(methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params)).onRequest(methods.agent.providers.set, (ctx) => getAgent().setProvider(ctx.params)).onRequest(methods.agent.providers.disable, (ctx) => getAgent().disableProvider(ctx.params)).onRequest(methods.agent.session.prompt, (ctx) => getAgent().prompt(ctx.params, ctx.signal)).onNotification(methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params)).onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)).onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)).onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)).onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)).onRequest(STEER_METHOD, steerParamsParser, (ctx) => getAgent().extMethod(STEER_METHOD, ctx.params)).connect(acpJsonStream);
30748
+ }).onRequest(methods.agent.initialize, (ctx) => getAgent().initialize(ctx.params)).onRequest(methods.agent.session.new, (ctx) => getAgent().newSession(ctx.params)).onRequest(methods.agent.session.load, (ctx) => getAgent().loadSession(ctx.params)).onRequest(methods.agent.session.fork, (ctx) => getAgent().unstable_forkSession(ctx.params)).onRequest(methods.agent.session.list, (ctx) => getAgent().listSessions(ctx.params)).onRequest(methods.agent.session.delete, (ctx) => getAgent().deleteSession(ctx.params)).onRequest(methods.agent.session.resume, (ctx) => getAgent().resumeSession(ctx.params)).onRequest(methods.agent.session.close, (ctx) => getAgent().closeSession(ctx.params)).onRequest(methods.agent.session.setMode, (ctx) => getAgent().setSessionMode(ctx.params)).onRequest(methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOption(ctx.params)).onRequest(methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params)).onRequest(methods.agent.logout, (ctx) => getAgent().logout(ctx.params)).onRequest(methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params)).onRequest(methods.agent.providers.set, (ctx) => getAgent().setProvider(ctx.params)).onRequest(methods.agent.providers.disable, (ctx) => getAgent().disableProvider(ctx.params)).onRequest(methods.agent.session.prompt, (ctx) => getAgent().prompt(ctx.params, ctx.signal)).onNotification(methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params)).onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)).onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)).onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)).onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)).onRequest(STEER_METHOD, steerParamsParser, (ctx) => getAgent().extMethod(STEER_METHOD, ctx.params)).connect(acpJsonStream);
30718
30749
  }