farming-code 2.2.16 → 2.2.17

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 (37) hide show
  1. package/README.md +2 -2
  2. package/README.zh_cn.md +2 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/backend/acp-runtime.js +221 -10
  5. package/backend/agent-manager.js +396 -47
  6. package/backend/agent-provider-session.js +15 -1
  7. package/backend/codex-rollout-follower.js +83 -0
  8. package/backend/codex-session-archive.js +12 -1
  9. package/backend/codex-session-history.js +94 -0
  10. package/backend/config-manager.js +3 -3
  11. package/backend/farming-app-cli.js +8 -0
  12. package/backend/native-pty-host-client.js +59 -2
  13. package/backend/provider-adapters.js +117 -36
  14. package/backend/provider-session-service.js +138 -34
  15. package/backend/server.js +5 -1
  16. package/backend/storage-layout.js +5 -0
  17. package/backend/usage-history-client.js +305 -0
  18. package/backend/usage-history-scanner.generated.js +2250 -0
  19. package/backend/usage-history-scanner.ts +2718 -0
  20. package/backend/usage-history-smoke.js +64 -0
  21. package/backend/usage-history-worker.js +34 -0
  22. package/backend/usage-monitor.js +188 -588
  23. package/backend/vendor/usage-parsers/CC_STATISTICS_LICENSE +21 -0
  24. package/backend/vendor/usage-parsers/CODEXBAR_LICENSE +21 -0
  25. package/backend/vendor/usage-parsers/VENDOR.json +45 -0
  26. package/dist/assets/App-6yWdwzj8.js +210 -0
  27. package/dist/assets/code-dark-5esj0bRu.css +1 -0
  28. package/dist/assets/{index-iKBvPjWS.js → index-C8ZFKye1.js} +2 -2
  29. package/dist/assets/main-DSO3i_Gs.css +1 -0
  30. package/dist/index.html +1 -1
  31. package/frontend/skins/crt/app.js +9 -3
  32. package/package.json +12 -8
  33. package/shared/backend-connection-status.d.ts +25 -0
  34. package/shared/backend-connection-status.js +40 -0
  35. package/dist/assets/App-y6p2yRar.js +0 -206
  36. package/dist/assets/code-dark-DKBGxoPX.css +0 -1
  37. package/dist/assets/main-CdkLOXYl.css +0 -1
@@ -16,9 +16,13 @@ function emptyPlan(args) {
16
16
  return {
17
17
  provider: '',
18
18
  id: '',
19
+ precreate: false,
19
20
  temporary: false,
20
21
  source: '',
21
22
  forkedFromProviderSessionId: '',
23
+ identityWorkspace: '',
24
+ resumeInsertIndex: null,
25
+ error: '',
22
26
  args,
23
27
  };
24
28
  }
@@ -44,13 +48,23 @@ function buildAgentProviderSessionPlan({ command, program, args, source } = {})
44
48
  }
45
49
 
46
50
  const plan = adapter.planSession(rawParts.slice(1), launchArgs);
47
- if (!plan?.id) return emptyPlan(launchArgs);
51
+ if (plan?.error) {
52
+ return {
53
+ ...emptyPlan(launchArgs),
54
+ provider,
55
+ error: String(plan.error),
56
+ };
57
+ }
58
+ if (!plan || (!plan.id && plan.precreate !== true)) return emptyPlan(launchArgs);
48
59
  return {
49
60
  provider,
50
61
  id: plan.id,
62
+ precreate: plan.precreate === true,
51
63
  temporary: plan.temporary === true,
52
64
  source: plan.source || '',
53
65
  forkedFromProviderSessionId: plan.forkedFromProviderSessionId || '',
66
+ identityWorkspace: plan.identityWorkspace || '',
67
+ resumeInsertIndex: Number.isInteger(plan.resumeInsertIndex) ? plan.resumeInsertIndex : null,
54
68
  args: Array.isArray(plan.args) ? plan.args : launchArgs,
55
69
  };
56
70
  }
@@ -59,6 +59,88 @@ function findCodexRolloutFile(sessionId, options = {}) {
59
59
  return '';
60
60
  }
61
61
 
62
+ function recentSessionDirectories(root, now = new Date()) {
63
+ const directories = [];
64
+ for (const offsetDays of [-1, 0, 1]) {
65
+ const date = new Date(now.getTime() + offsetDays * 24 * 60 * 60 * 1000);
66
+ directories.push(
67
+ path.join(
68
+ root,
69
+ String(date.getFullYear()),
70
+ String(date.getMonth() + 1).padStart(2, '0'),
71
+ String(date.getDate()).padStart(2, '0'),
72
+ ),
73
+ path.join(
74
+ root,
75
+ String(date.getUTCFullYear()),
76
+ String(date.getUTCMonth() + 1).padStart(2, '0'),
77
+ String(date.getUTCDate()).padStart(2, '0'),
78
+ ),
79
+ );
80
+ }
81
+ return [...new Set(directories)];
82
+ }
83
+
84
+ async function findCodexRolloutFileAsync(sessionId, options = {}) {
85
+ const normalizedSessionId = String(sessionId || '').trim();
86
+ if (!normalizedSessionId) return '';
87
+ const codexHome = normalizePathValue(options.codexHome || path.join(os.homedir(), '.codex'));
88
+ const maxEntries = Number.isFinite(options.maxEntries)
89
+ ? Math.max(1, Math.floor(options.maxEntries))
90
+ : 100_000;
91
+ const deadline = Number.isFinite(options.deadline)
92
+ ? Number(options.deadline)
93
+ : Date.now() + 5_000;
94
+ const sessionsRoot = path.join(codexHome, 'sessions');
95
+ const archivedRoot = path.join(codexHome, 'archived_sessions');
96
+ const preferredDirectories = [
97
+ ...recentSessionDirectories(sessionsRoot),
98
+ ...(options.recentOnly === true ? [] : [archivedRoot]),
99
+ ];
100
+ const visited = new Set();
101
+ let inspectedEntries = 0;
102
+
103
+ async function inspectDirectory(directory, descend) {
104
+ if (Date.now() > deadline || inspectedEntries >= maxEntries || visited.has(directory)) return '';
105
+ visited.add(directory);
106
+ let entries;
107
+ try {
108
+ entries = await fsp.readdir(directory, { withFileTypes: true });
109
+ } catch {
110
+ return '';
111
+ }
112
+ const subdirectories = [];
113
+ for (const entry of entries) {
114
+ inspectedEntries += 1;
115
+ if (Date.now() > deadline || inspectedEntries > maxEntries) return '';
116
+ const fullPath = path.join(directory, entry.name);
117
+ if (entry.isFile() && entry.name.endsWith('.jsonl') && sessionIdFromFilePath(fullPath) === normalizedSessionId) {
118
+ return fullPath;
119
+ }
120
+ if (descend && entry.isDirectory()) subdirectories.push(fullPath);
121
+ }
122
+ return subdirectories;
123
+ }
124
+
125
+ for (const directory of preferredDirectories) {
126
+ const found = await inspectDirectory(directory, false);
127
+ if (typeof found === 'string' && found) return found;
128
+ }
129
+ if (options.recentOnly === true) return '';
130
+
131
+ const stack = [sessionsRoot, archivedRoot];
132
+ while (stack.length > 0 && Date.now() <= deadline && inspectedEntries < maxEntries) {
133
+ const directory = stack.pop();
134
+ const result = await inspectDirectory(directory, true);
135
+ if (typeof result === 'string') {
136
+ if (result) return result;
137
+ continue;
138
+ }
139
+ stack.push(...result);
140
+ }
141
+ return '';
142
+ }
143
+
62
144
  function normalizePreviewText(value, maxLength = 400) {
63
145
  return String(value || '')
64
146
  .replace(/\r\n/g, '\n')
@@ -239,5 +321,6 @@ class CodexRolloutFollower extends EventEmitter {
239
321
  module.exports = {
240
322
  CodexRolloutFollower,
241
323
  findCodexRolloutFile,
324
+ findCodexRolloutFileAsync,
242
325
  formatCodexRolloutEvent,
243
326
  };
@@ -1,4 +1,5 @@
1
1
  const { execFile } = require('child_process');
2
+ const fs = require('fs');
2
3
  const os = require('os');
3
4
  const { promisify } = require('util');
4
5
  const { resolveCompatibleCodexExecutable } = require('./executable-discovery');
@@ -9,6 +10,13 @@ async function runCodexSessionArchiveCommand(action, sessionId, session = {}, op
9
10
  const resolveExecutable = options.resolveCompatibleCodexExecutable || resolveCompatibleCodexExecutable;
10
11
  const runExecFile = options.execFileAsync || execFileAsync;
11
12
  const processEnv = options.processEnv || process.env;
13
+ const directoryExists = options.directoryExists || ((directory) => {
14
+ try {
15
+ return fs.statSync(directory).isDirectory();
16
+ } catch {
17
+ return false;
18
+ }
19
+ });
12
20
  const codexResolution = resolveExecutable(session.cliVersion || '', processEnv.PATH || '');
13
21
  if (!codexResolution.compatible) {
14
22
  return {
@@ -18,8 +26,11 @@ async function runCodexSessionArchiveCommand(action, sessionId, session = {}, op
18
26
  }
19
27
 
20
28
  try {
29
+ const sessionCwd = session.cwd || session.workspace || '';
21
30
  await runExecFile(codexResolution.path || 'codex', [action, sessionId], {
22
- cwd: session.cwd || session.workspace || os.homedir(),
31
+ cwd: sessionCwd && directoryExists(sessionCwd)
32
+ ? sessionCwd
33
+ : (processEnv.HOME || os.homedir()),
23
34
  env: session.providerHomePath
24
35
  ? { ...processEnv, CODEX_HOME: session.providerHomePath }
25
36
  : processEnv,
@@ -412,6 +412,98 @@ async function readSessionMetadata(filePath, maxLines = 80) {
412
412
  return metadata.id ? metadata : null;
413
413
  }
414
414
 
415
+ function codexSessionDateKeys(startedAt, windowMs) {
416
+ const center = Number(startedAt);
417
+ const radius = Math.max(0, Number(windowMs) || 0);
418
+ if (!Number.isFinite(center) || center <= 0) return [];
419
+ const cursor = new Date(center - radius);
420
+ cursor.setHours(0, 0, 0, 0);
421
+ const lastDay = new Date(center + radius);
422
+ lastDay.setHours(0, 0, 0, 0);
423
+ const keys = [];
424
+ while (cursor <= lastDay) {
425
+ keys.push([
426
+ cursor.getFullYear(),
427
+ String(cursor.getMonth() + 1).padStart(2, '0'),
428
+ String(cursor.getDate()).padStart(2, '0'),
429
+ ].join('-'));
430
+ cursor.setDate(cursor.getDate() + 1);
431
+ }
432
+ return keys;
433
+ }
434
+
435
+ async function readCodexSessionIdentity(filePath) {
436
+ let handle;
437
+ try {
438
+ handle = await fs.promises.open(filePath, 'r');
439
+ const chunk = Buffer.allocUnsafe(64 * 1024);
440
+ const chunks = [];
441
+ let bytesReadTotal = 0;
442
+ let lineEnd = -1;
443
+ while (bytesReadTotal < 1024 * 1024 && lineEnd < 0) {
444
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, bytesReadTotal);
445
+ if (bytesRead <= 0) break;
446
+ const current = Buffer.from(chunk.subarray(0, bytesRead));
447
+ chunks.push(current);
448
+ const newlineIndex = current.indexOf(0x0a);
449
+ if (newlineIndex >= 0) lineEnd = bytesReadTotal + newlineIndex;
450
+ bytesReadTotal += bytesRead;
451
+ }
452
+ const header = Buffer.concat(chunks);
453
+ const firstLine = header.subarray(0, lineEnd >= 0 ? lineEnd : header.length).toString('utf8').replace(/\r$/, '');
454
+ const fileSessionId = sessionIdFromFilePath(filePath);
455
+ const event = JSON.parse(firstLine);
456
+ if (event?.type !== 'session_meta') return null;
457
+ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
458
+ const id = typeof payload.id === 'string' ? payload.id.trim() : '';
459
+ if (!id || (fileSessionId && id !== fileSessionId)) return null;
460
+ const cwd = typeof payload.cwd === 'string' ? normalizePathValue(payload.cwd) : '';
461
+ return {
462
+ id,
463
+ createdAt: typeof event.timestamp === 'string' ? event.timestamp : '',
464
+ cwd,
465
+ workspace: cwd,
466
+ };
467
+ } catch {
468
+ return null;
469
+ } finally {
470
+ await handle?.close().catch(() => {});
471
+ }
472
+ }
473
+
474
+ async function listCodexSessionIdentities(options = {}) {
475
+ const codexHome = options.codexHome || path.join(os.homedir(), '.codex');
476
+ const startedAt = Number(options.startedAt);
477
+ const windowMs = Math.max(0, Number(options.windowMs) || 0);
478
+ if (!Number.isFinite(startedAt) || startedAt <= 0) return [];
479
+
480
+ const files = [];
481
+ for (const dateKey of codexSessionDateKeys(startedAt, windowMs)) {
482
+ const [year, month, day] = dateKey.split('-');
483
+ const directory = path.join(codexHome, 'sessions', year, month, day);
484
+ let entries;
485
+ try {
486
+ entries = fs.readdirSync(directory, { withFileTypes: true });
487
+ } catch {
488
+ continue;
489
+ }
490
+ for (const entry of entries) {
491
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
492
+ files.push(path.join(directory, entry.name));
493
+ }
494
+ }
495
+
496
+ const identities = [];
497
+ const batchSize = 32;
498
+ for (let offset = 0; offset < files.length; offset += batchSize) {
499
+ const batch = await Promise.all(
500
+ files.slice(offset, offset + batchSize).map(readCodexSessionIdentity)
501
+ );
502
+ identities.push(...batch.filter(Boolean));
503
+ }
504
+ return identities;
505
+ }
506
+
415
507
  function getGlobalState(codexHome) {
416
508
  const state = readJsonFile(path.join(codexHome, '.codex-global-state.json'), {});
417
509
  const atom = state['electron-persisted-atom-state'] || {};
@@ -540,8 +632,10 @@ async function listCodexSessions(options = {}) {
540
632
  }
541
633
 
542
634
  module.exports = {
635
+ codexSessionDateKeys,
543
636
  hasTemporaryWorkspaceReference,
544
637
  isTemporaryWorkspace,
638
+ listCodexSessionIdentities,
545
639
  listCodexSessions,
546
640
  readSessionIndex,
547
641
  readSessionMetadata,
@@ -306,7 +306,7 @@ class ConfigManager {
306
306
  projectNames: {},
307
307
  instanceName: '',
308
308
  theme: 'terminal',
309
- appearance: 'light',
309
+ appearance: 'system',
310
310
  language: 'en',
311
311
  heartbeatInterval: 1000,
312
312
  dangerouslySkipAgentPermissionsByDefault: false,
@@ -342,7 +342,7 @@ class ConfigManager {
342
342
  projectNames: {},
343
343
  instanceName: '',
344
344
  theme: 'terminal',
345
- appearance: 'light',
345
+ appearance: 'system',
346
346
  language: 'en',
347
347
  heartbeatInterval: 1000,
348
348
  dangerouslySkipAgentPermissionsByDefault: false,
@@ -498,7 +498,7 @@ class ConfigManager {
498
498
  }
499
499
 
500
500
  normalizeAppearance(appearance) {
501
- return ['light', 'dark'].includes(appearance) ? appearance : 'light';
501
+ return ['system', 'light', 'dark'].includes(appearance) ? appearance : 'system';
502
502
  }
503
503
 
504
504
  normalizeLanguage(language) {
@@ -13,6 +13,7 @@ const storageLayout = require('./storage-layout');
13
13
  const SERVER_MODE_ARG = '--farming-server';
14
14
  const SERVER_MODE_ENV = 'FARMING_RUN_SERVER';
15
15
  const NATIVE_PTY_HOST_ARG = '--native-pty-host';
16
+ const USAGE_HISTORY_SMOKE_ARG = '--farming-usage-history-smoke';
16
17
  const DEFAULT_PORT = '6694';
17
18
  const DEFAULT_BASE_PATH = '/farming';
18
19
  const DEFAULT_SERVER_START_TIMEOUT_MS = 30_000;
@@ -787,6 +788,13 @@ async function run(argv = process.argv.slice(2)) {
787
788
  return 0;
788
789
  }
789
790
 
791
+ if (argv[0] === USAGE_HISTORY_SMOKE_ARG) {
792
+ const { runUsageHistorySmoke } = require('./usage-history-smoke');
793
+ const result = await runUsageHistorySmoke();
794
+ console.log(JSON.stringify(result));
795
+ return 0;
796
+ }
797
+
790
798
  if (argv[0] === NATIVE_PTY_HOST_ARG || process.env.FARMING_RUN_NATIVE_PTY_HOST === '1') {
791
799
  runNativePtyHostInCurrentProcess();
792
800
  return 0;
@@ -14,7 +14,9 @@ const {
14
14
  allocateNativePtyControllerGeneration,
15
15
  positiveGeneration,
16
16
  } = require('./native-pty-controller-generation');
17
+ const { isTemporaryProviderSessionId } = require('./provider-session-id');
17
18
  const storageLayout = require('./storage-layout');
19
+ const { deserializeTerminalState } = require('./terminal-state-serialization');
18
20
 
19
21
  const DEFAULT_CONNECT_RETRIES = 300;
20
22
  const DEFAULT_CONNECT_RETRY_MS = 50;
@@ -463,6 +465,23 @@ class NativePtyHostClient extends EventEmitter {
463
465
  });
464
466
  }
465
467
 
468
+ async requirePreparedHostRotationResume(preparationToken, actual, primaryError) {
469
+ if (!preparationToken) return;
470
+ try {
471
+ await this.resumePreparedHostRotation(preparationToken);
472
+ } catch (resumeError) {
473
+ const recoveryError = new Error(
474
+ `Cannot confirm recovery of incompatible native PTY host (${actual}) after aborting rotation`
475
+ );
476
+ recoveryError.code = 'FARMING_NATIVE_HOST_ROTATION_RECOVERY_FAILED';
477
+ recoveryError.cause = new AggregateError(
478
+ [primaryError, resumeError].filter(Boolean),
479
+ 'Native PTY rotation failed and the prepared host could not be resumed'
480
+ );
481
+ throw recoveryError;
482
+ }
483
+ }
484
+
466
485
  async rotateMismatchedHost(hostInfo) {
467
486
  const expected = runtimeIdentityLabel(this.expectedRuntimeIdentity);
468
487
  const actual = runtimeIdentityLabel(hostInfo && hostInfo.runtimeIdentity);
@@ -513,7 +532,11 @@ class NativePtyHostClient extends EventEmitter {
513
532
  throw new Error('The native PTY host returned an invalid rotation checkpoint');
514
533
  }
515
534
  } catch (error) {
516
- await this.resumePreparedHostRotation(preparationToken).catch(() => {});
535
+ if (preparationToken) {
536
+ await this.requirePreparedHostRotationResume(preparationToken, actual, error);
537
+ } else {
538
+ await this.resumePreparedHostRotation('').catch(() => {});
539
+ }
517
540
  const recovered = await this.requestOnce('recoverSessions', {}, {
518
541
  ensureConnected: false,
519
542
  retryOnDisconnect: false,
@@ -532,6 +555,40 @@ class NativePtyHostClient extends EventEmitter {
532
555
  }
533
556
  }
534
557
 
558
+ let unresumableCodex;
559
+ try {
560
+ unresumableCodex = deserializeTerminalState(serializedTerminalState).find(entry => {
561
+ const metadata = entry.metadata || {};
562
+ const provider = String(metadata.providerSessionProvider || metadata.provider || '').trim();
563
+ return provider === 'codex'
564
+ && metadata.terminalInputReceived === true
565
+ && (
566
+ metadata.providerSessionTemporary === true
567
+ || isTemporaryProviderSessionId(metadata.providerSessionId)
568
+ );
569
+ });
570
+ } catch (error) {
571
+ await this.requirePreparedHostRotationResume(preparationToken, actual, error);
572
+ const mismatchError = new Error(
573
+ `Cannot rotate incompatible native PTY host (${actual}) with an invalid terminal checkpoint`
574
+ );
575
+ mismatchError.code = 'FARMING_NATIVE_HOST_RUNTIME_MISMATCH';
576
+ mismatchError.cause = error;
577
+ throw mismatchError;
578
+ }
579
+ if (unresumableCodex) {
580
+ const unresumableError = new Error(
581
+ `Codex session ${unresumableCodex.id} has user input but no exact resume id`
582
+ );
583
+ await this.requirePreparedHostRotationResume(preparationToken, actual, unresumableError);
584
+ const mismatchError = new Error(
585
+ `Cannot rotate incompatible native PTY host (${actual}) while Codex session ${unresumableCodex.id} has user input but no exact resume id`
586
+ );
587
+ mismatchError.code = 'FARMING_NATIVE_HOST_UNRESUMABLE_SESSION';
588
+ mismatchError.cause = unresumableError;
589
+ throw mismatchError;
590
+ }
591
+
535
592
  const socket = this.socket;
536
593
  if (socket) this.suppressedDisconnectSockets.add(socket);
537
594
  let shutdownUncertain = false;
@@ -546,7 +603,7 @@ class NativePtyHostClient extends EventEmitter {
546
603
  });
547
604
  } catch (error) {
548
605
  if (!isConnectRetryable(error)) {
549
- await this.resumePreparedHostRotation(preparationToken).catch(() => {});
606
+ await this.requirePreparedHostRotationResume(preparationToken, actual, error);
550
607
  const mismatchError = new Error(
551
608
  `Cannot rotate incompatible native PTY host (${actual}); stop the old host and restart Farming`
552
609
  );
@@ -7,45 +7,73 @@ const {
7
7
  } = require('./provider-session-id');
8
8
 
9
9
  const CODEX_VALUE_OPTIONS = new Set([
10
- '-a', '-c', '-C', '-m', '-s', '--ask-for-approval', '--cd', '--config',
11
- '--config-profile', '--model', '--profile', '--sandbox',
10
+ '-a', '-c', '-C', '-m', '-p', '-s', '--ask-for-approval', '--cd', '--config',
11
+ '--config-profile', '--model', '--profile', '--sandbox', '--add-dir',
12
+ '--enable', '--disable', '--remote', '--remote-auth-token-env',
13
+ '--local-provider', '-i', '--image',
12
14
  ]);
15
+ const OPENCODE_VALUE_OPTIONS = new Set([
16
+ '-m', '-s', '--agent', '--hostname', '--log-level', '--mdns-domain',
17
+ '--model', '--port', '--prompt', '--replay-limit', '--session',
18
+ ]);
19
+ const CODEX_SUBCOMMANDS = new Set([
20
+ 'exec', 'e', 'review', 'login', 'logout', 'mcp', 'plugin', 'mcp-server',
21
+ 'app-server', 'remote-control', 'app', 'completion', 'update', 'doctor',
22
+ 'sandbox', 'debug', 'apply', 'a', 'resume', 'archive', 'delete', 'unarchive',
23
+ 'fork', 'cloud', 'exec-server', 'features', 'help',
24
+ ]);
25
+ const OPENCODE_SUBCOMMANDS = new Set([
26
+ 'completion', 'acp', 'mcp', 'attach', 'run', 'debug', 'providers', 'auth',
27
+ 'agent', 'upgrade', 'uninstall', 'serve', 'web', 'models', 'stats', 'export',
28
+ 'import', 'github', 'pr', 'session', 'plugin', 'plug', 'db',
29
+ ]);
30
+ const CODEX_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
31
 
14
32
  function optionTakesValue(option, valueOptions) {
15
33
  return Boolean(option && !option.includes('=') && valueOptions.has(option));
16
34
  }
17
35
 
18
- function firstCodexSubcommand(args) {
36
+ function scanPositionals(args, valueOptions, options = {}) {
37
+ const positionals = [];
19
38
  for (let index = 0; index < args.length; index += 1) {
20
- const arg = args[index];
39
+ const arg = String(args[index] || '');
21
40
  if (!arg) continue;
41
+ if (arg === '--') {
42
+ for (let positionalIndex = index + 1; positionalIndex < args.length; positionalIndex += 1) {
43
+ const value = String(args[positionalIndex] || '');
44
+ if (value) positionals.push({ value, index: positionalIndex, afterDelimiter: true });
45
+ }
46
+ break;
47
+ }
22
48
  if (arg.startsWith('-')) {
23
- if (optionTakesValue(arg, CODEX_VALUE_OPTIONS)) index += 1;
49
+ if (
50
+ options.multiValueOptions?.has(arg)
51
+ && !arg.includes('=')
52
+ ) {
53
+ while (index + 1 < args.length && !String(args[index + 1] || '').startsWith('-')) index += 1;
54
+ continue;
55
+ }
56
+ if (optionTakesValue(arg, valueOptions)) index += 1;
24
57
  continue;
25
58
  }
26
- return { value: arg, index };
59
+ positionals.push({ value: arg, index, afterDelimiter: false });
27
60
  }
28
- return null;
61
+ return positionals;
29
62
  }
30
63
 
31
64
  function codexSessionIdAfterSubcommand(args, subcommandIndex) {
32
- const positional = [];
33
- for (let index = subcommandIndex + 1; index < args.length; index += 1) {
34
- const arg = args[index];
35
- if (!arg) continue;
36
- if (arg.startsWith('-')) {
37
- if (optionTakesValue(arg, CODEX_VALUE_OPTIONS)) index += 1;
38
- continue;
39
- }
40
- positional.push(arg);
41
- }
42
- const sessionId = positional.at(-1) || '';
43
- return isSafeProviderSessionId(sessionId) ? sessionId : '';
65
+ const sessionId = scanPositionals(
66
+ args.slice(subcommandIndex + 1),
67
+ CODEX_VALUE_OPTIONS,
68
+ { multiValueOptions: new Set(['-i', '--image']) },
69
+ )[0]?.value || '';
70
+ return CODEX_SESSION_ID_RE.test(sessionId) ? sessionId.toLowerCase() : '';
44
71
  }
45
72
 
46
73
  function argValue(args, names) {
47
74
  for (let index = 0; index < args.length; index += 1) {
48
75
  const arg = args[index];
76
+ if (arg === '--') break;
49
77
  for (const name of names) {
50
78
  if (arg === name) return args[index + 1] || '';
51
79
  if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1);
@@ -55,13 +83,26 @@ function argValue(args, names) {
55
83
  }
56
84
 
57
85
  function hasArg(args, names) {
58
- return args.some(arg => names.includes(arg) || names.some(name => arg.startsWith(`${name}=`)));
86
+ for (const arg of args) {
87
+ if (arg === '--') break;
88
+ if (names.includes(arg) || names.some(name => arg.startsWith(`${name}=`))) return true;
89
+ }
90
+ return false;
59
91
  }
60
92
 
61
- function codexSessionPlan(rawArgs) {
62
- const subcommand = firstCodexSubcommand(rawArgs);
93
+ function codexSessionPlan(rawArgs, _launchArgs) {
94
+ const positional = scanPositionals(rawArgs, CODEX_VALUE_OPTIONS, {
95
+ multiValueOptions: new Set(['-i', '--image']),
96
+ });
97
+ const subcommand = positional[0]
98
+ && positional[0].afterDelimiter !== true
99
+ && CODEX_SUBCOMMANDS.has(positional[0].value)
100
+ ? positional[0]
101
+ : null;
63
102
  if (subcommand?.value === 'resume') {
64
- const id = codexSessionIdAfterSubcommand(rawArgs, subcommand.index);
103
+ const id = hasArg(rawArgs.slice(subcommand.index + 1), ['--last'])
104
+ ? ''
105
+ : codexSessionIdAfterSubcommand(rawArgs, subcommand.index);
65
106
  if (id) return { id, temporary: false, source: 'resume' };
66
107
  }
67
108
  if (subcommand?.value === 'fork') {
@@ -69,11 +110,22 @@ function codexSessionPlan(rawArgs) {
69
110
  id: createTemporaryProviderSessionId(),
70
111
  temporary: true,
71
112
  source: 'codex-fork-temporary',
72
- forkedFromProviderSessionId: codexSessionIdAfterSubcommand(rawArgs, subcommand.index),
113
+ forkedFromProviderSessionId: hasArg(rawArgs.slice(subcommand.index + 1), ['--last'])
114
+ ? ''
115
+ : codexSessionIdAfterSubcommand(rawArgs, subcommand.index),
73
116
  };
74
117
  }
75
- if (subcommand?.value) return null;
76
- return { id: createTemporaryProviderSessionId(), temporary: true, source: 'codex-temporary' };
118
+ if (subcommand) return null;
119
+ if (hasArg(rawArgs, ['--remote'])) {
120
+ return {
121
+ error: 'A fresh Codex --remote Terminal cannot be correlated with a local resumable session id; resume an explicit remote session id instead',
122
+ };
123
+ }
124
+ return {
125
+ id: createTemporaryProviderSessionId(),
126
+ temporary: true,
127
+ source: 'codex-temporary',
128
+ };
77
129
  }
78
130
 
79
131
  function explicitSessionPlan(provider, rawArgs, launchArgs) {
@@ -103,17 +155,35 @@ function explicitSessionPlan(provider, rawArgs, launchArgs) {
103
155
  };
104
156
  }
105
157
 
106
- function openCodeSessionPlan(rawArgs) {
158
+ function openCodeSessionPlan(rawArgs, launchArgs) {
107
159
  const id = argValue(rawArgs, ['--session', '-s']);
108
- if (!id || !isSafeProviderSessionId(id)) return null;
109
- return hasArg(rawArgs, ['--fork'])
110
- ? {
111
- id: createTemporaryProviderSessionId(),
112
- temporary: true,
113
- source: 'opencode-fork-temporary',
114
- forkedFromProviderSessionId: id,
115
- }
116
- : { id, temporary: false, source: 'resume' };
160
+ if (id && isSafeProviderSessionId(id)) {
161
+ return hasArg(rawArgs, ['--fork'])
162
+ ? {
163
+ id: createTemporaryProviderSessionId(),
164
+ temporary: true,
165
+ source: 'opencode-fork-temporary',
166
+ forkedFromProviderSessionId: id,
167
+ }
168
+ : { id, temporary: false, source: 'resume' };
169
+ }
170
+ if (hasArg(rawArgs, ['--session', '-s', '--continue', '-c', '--fork'])) return null;
171
+ const positional = scanPositionals(rawArgs, OPENCODE_VALUE_OPTIONS, {
172
+ multiValueOptions: new Set(['--cors']),
173
+ });
174
+ if (
175
+ positional[0]
176
+ && positional[0].afterDelimiter !== true
177
+ && OPENCODE_SUBCOMMANDS.has(positional[0].value)
178
+ ) return null;
179
+ return {
180
+ id: '',
181
+ precreate: true,
182
+ temporary: false,
183
+ source: 'opencode-precreate',
184
+ identityWorkspace: positional[0]?.value || '',
185
+ resumeInsertIndex: launchArgs.length,
186
+ };
117
187
  }
118
188
 
119
189
  function codexAcpEnvironment(options = {}) {
@@ -148,6 +218,7 @@ const PROVIDER_ADAPTERS = Object.freeze([
148
218
  commands: ['codex'],
149
219
  supportedRuntimes: ['terminal', 'acp', 'json'],
150
220
  planSession: codexSessionPlan,
221
+ terminalResumeArgs: (args, sessionId) => ['resume', sessionId, ...args],
151
222
  acp: { packageName: '@agentclientprotocol/codex-acp', version: '1.1.4' },
152
223
  prepareAcpEnvironment: codexAcpEnvironment,
153
224
  capabilities: { runtimeSwitch: true, terminalProfile: true, goals: false },
@@ -175,6 +246,16 @@ const PROVIDER_ADAPTERS = Object.freeze([
175
246
  commands: ['opencode'],
176
247
  supportedRuntimes: ['terminal', 'acp', 'json'],
177
248
  planSession: openCodeSessionPlan,
249
+ terminalResumeArgs: (args, sessionId) => {
250
+ const delimiterIndex = args.indexOf('--');
251
+ const insertIndex = delimiterIndex >= 0 ? delimiterIndex : args.length;
252
+ return [
253
+ ...args.slice(0, insertIndex),
254
+ '--session',
255
+ sessionId,
256
+ ...args.slice(insertIndex),
257
+ ];
258
+ },
178
259
  acp: {
179
260
  version: 'native',
180
261
  launch: options => ({