evolcore 0.0.22 → 0.0.24

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 (117) hide show
  1. package/CHANGELOG.md +74 -14
  2. package/README.md +4 -0
  3. package/bin/codex-managed-acl.ps1 +160 -0
  4. package/bin/codex-managed-hook.mjs +127 -10
  5. package/bin/ec-safe-output.js +85 -3
  6. package/bin/install-codex-managed-hooks.mjs +384 -95
  7. package/dist/agents/baseagent-availability.js +48 -0
  8. package/dist/agents/baseagent.js +30 -2
  9. package/dist/agents/claude-runner.js +179 -240
  10. package/dist/agents/{codex-app-server-client.js → codex/app-server-client.js} +90 -20
  11. package/dist/{utils/codex-app-server-registry.js → agents/codex/app-server-registry.js} +2 -2
  12. package/dist/agents/codex/catalog.js +70 -0
  13. package/dist/agents/codex/diagnostics.js +369 -0
  14. package/dist/agents/codex-runner.js +839 -192
  15. package/dist/agents/ecagent-runner.js +27 -34
  16. package/dist/agents/gemini-runner.js +62 -8
  17. package/dist/agents/runner-types.js +1 -1
  18. package/dist/aun/aid/managed-operation.js +28 -6
  19. package/dist/aun/aid/store.js +43 -0
  20. package/dist/aun/menu-client.js +272 -0
  21. package/dist/aun/msg/group.js +65 -4
  22. package/dist/aun/msg/index.js +1 -1
  23. package/dist/aun/msg/managed-operation.js +57 -5
  24. package/dist/aun/outbox.js +56 -18
  25. package/dist/aun/rpc/client.js +7 -1
  26. package/dist/channels/aun.js +178 -77
  27. package/dist/channels/daemon.js +7 -1
  28. package/dist/channels/dingtalk.js +1 -0
  29. package/dist/channels/feishu.js +10 -4
  30. package/dist/channels/wechat.js +81 -10
  31. package/dist/channels/wecom.js +2 -1
  32. package/dist/cli/agent-command.js +29 -3
  33. package/dist/cli/agent-create.js +538 -0
  34. package/dist/cli/agent.js +26 -675
  35. package/dist/cli/aun-commands.js +108 -24
  36. package/dist/cli/command-log.js +21 -2
  37. package/dist/cli/ctl-command.js +2 -0
  38. package/dist/cli/daemon-commands.js +289 -61
  39. package/dist/cli/index.js +22 -2
  40. package/dist/cli/init.js +2 -10
  41. package/dist/cli/managed-command-guard.js +3 -0
  42. package/dist/cli/menu-client.js +95 -0
  43. package/dist/cli/model.js +23 -8
  44. package/dist/cli/restart-monitor.js +432 -8
  45. package/dist/cli/stats.js +110 -0
  46. package/dist/cli/task-context.js +12 -2
  47. package/dist/cli/trigger-command.js +9 -1
  48. package/dist/config/builtin-roles.js +2 -0
  49. package/dist/config/schema-migration.js +14 -2
  50. package/dist/core/auth/agent-delegation.js +10 -7
  51. package/dist/core/auth/auth-gateway.js +3 -2
  52. package/dist/core/auth/authorization-audit.js +75 -18
  53. package/dist/core/auth/operation-authorizer.js +63 -3
  54. package/dist/core/auth/operation-catalog.js +14 -2
  55. package/dist/core/auth/remote-access-store.js +136 -0
  56. package/dist/core/baseagent-loader.js +4 -4
  57. package/dist/core/bootstrap-messages.js +39 -18
  58. package/dist/core/bootstrap-service.js +32 -26
  59. package/dist/core/capability/capability-manager.js +8 -4
  60. package/dist/core/capability/providers/codex-capability-provider.js +1 -1
  61. package/dist/core/command/agent-control.js +7 -2
  62. package/dist/core/command/command-handler.js +128 -30
  63. package/dist/core/command/daemon-diagnostics.js +448 -0
  64. package/dist/core/command/menu-catalog.js +6 -1
  65. package/dist/core/command/menu-handler.js +410 -98
  66. package/dist/core/command/menu-protocol.js +123 -0
  67. package/dist/core/command/slash-gate.js +4 -6
  68. package/dist/core/command/slash-handler.js +199 -70
  69. package/dist/core/data-migration.js +5 -15
  70. package/dist/core/event-catalog.js +3 -0
  71. package/dist/core/handoff/runtime.js +3 -1
  72. package/dist/core/handoff/store.js +7 -1
  73. package/dist/core/message/im-renderer.js +13 -6
  74. package/dist/core/message/message-bridge.js +5 -36
  75. package/dist/core/message/message-queue.js +20 -15
  76. package/dist/core/message/message-utils.js +6 -3
  77. package/dist/core/message/response-engine.js +147 -94
  78. package/dist/core/model/catalog-service.js +310 -0
  79. package/dist/core/model/model-catalog.js +134 -334
  80. package/dist/core/model/model-diagnostics.js +4 -5
  81. package/dist/core/model/model-fallback.js +9 -16
  82. package/dist/core/model/model-permission.js +5 -2
  83. package/dist/core/permission/approval-gateway.js +183 -32
  84. package/dist/core/permission/protected-paths.js +63 -4
  85. package/dist/core/permission/sandbox-runtime.js +4 -2
  86. package/dist/core/permission/tool-policy.js +257 -31
  87. package/dist/core/runtime-lock.js +13 -8
  88. package/dist/core/session/adapters/codex-session-file-adapter.js +71 -3
  89. package/dist/core/session/session-manager.js +49 -13
  90. package/dist/core/session/session-renew.js +1 -0
  91. package/dist/index.js +214 -57
  92. package/dist/ipc.js +191 -102
  93. package/dist/paths.js +23 -1
  94. package/dist/stats/billing.js +18 -4
  95. package/dist/trigger/manager.js +3 -1
  96. package/dist/trigger/parser.js +77 -13
  97. package/dist/trigger/patch.js +104 -1
  98. package/dist/utils/atomic-write.js +26 -2
  99. package/dist/utils/cross-platform.js +436 -1
  100. package/dist/utils/ecweb-supervisor.js +11 -2
  101. package/dist/utils/ecweb-utils.js +8 -26
  102. package/dist/utils/error-utils.js +16 -2
  103. package/dist/utils/model-prices.jsonl +23 -20
  104. package/dist/utils/npm-ops.js +70 -15
  105. package/dist/utils/windows-autostart.js +1 -1
  106. package/kits/docs/evolcore/INDEX.md +1 -0
  107. package/kits/docs/evolcore/agent-create.md +13 -0
  108. package/kits/docs/evolcore/group-collaboration.md +7 -2
  109. package/kits/docs/evolcore/menu-client.md +34 -0
  110. package/kits/migrations/migrate-contact-book-v2.mjs +9 -1
  111. package/kits/migrations/migrate-role-config-v4.mjs +11 -5
  112. package/kits/migrations/migrate-role-config-v5.mjs +8 -2
  113. package/kits/templates/roles/admin.json +2 -0
  114. package/kits/templates/roles/visitor.json +1 -0
  115. package/kits/templates/system-fragments/bootstrap.md +8 -1
  116. package/package.json +16 -4
  117. package/dist/utils/codex-cli.js +0 -43
@@ -1,20 +1,14 @@
1
- /**
2
- * Codex Agent Runner
3
- *
4
- * Integrates Codex app-server as an agent backend.
5
- * Implements the same interface surface as AgentRunner (claude-runner.ts)
6
- * so MessageProcessor and CommandHandler can work with it transparently.
7
- */
1
+ import { getRunnerCatalog, catalogEfforts, assertCatalogSelectable } from '../core/model/model-catalog.js';
8
2
  import { hasTrustedFullAccessContext } from './runner-types.js';
9
3
  import { requestDangerousCommandPermission } from '../core/permission/approval-gateway.js';
10
4
  import { checkReadonly, checkDangerousCommand, evaluateToolPreflight } from '../core/permission/tool-policy.js';
11
5
  import { normalizeExecutionPermissionMode, normalizePermissionMode, resolvePhaseOneExecutionSandbox } from '../core/permission/mode.js';
12
6
  import { buildCodexProtectedFilesystemRules, isSameOrDescendant, resolveProtectedCandidate } from '../core/permission/protected-paths.js';
13
- import { CodexAppServerClient } from './codex-app-server-client.js';
14
- import { resolveOpenaiConfig } from './baseagent.js';
7
+ import { CODEX_THREAD_ACTIVE_WRITER, CodexAppServerClient, } from './codex/app-server-client.js';
8
+ import { resolveOpenaiConfig, resolveCodexCliPath } from './baseagent.js';
15
9
  import { logger } from '../utils/logger.js';
16
10
  import { summarizeToolInputForAudit } from '../utils/tool-summary.js';
17
- import { auditCodexApprovalDecision, auditToolPreflightDenial } from '../core/auth/authorization-audit.js';
11
+ import { auditCodexApprovalDecision, auditPermissionLifecycle, auditToolPreflightDenial } from '../core/auth/authorization-audit.js';
18
12
  import { isRetryableError } from '../utils/error-utils.js';
19
13
  import { renderActionAsText } from '../core/interaction-router.js';
20
14
  import { buildEnvelope, isInteractionSendAccepted, sendInteractionPayload } from '../core/message/message-utils.js';
@@ -23,11 +17,12 @@ import { AGENT_DELEGATION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/au
23
17
  import { sanitizeShellExecutionEnvironment } from '../core/permission/shell-environment.js';
24
18
  import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, hasCodexCmdCarrierEcIntent, hasPowerShellDelegationAssignment, normalizeEvolcoreCommandArgv, parseCodexToolCommand, parseCodexCarrierCommand, parseLiteralShellCommand, parseCodexShellCommandArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
25
19
  import { compareVersions } from '../utils/npm-ops.js';
26
- import { resolvePaths, resolveRoot } from '../paths.js';
20
+ import { agentSessionsDir, resolvePaths, resolveRoot } from '../paths.js';
27
21
  import { ensureProcessManagedTempDir, getManagedTaskTempDir } from '../cli/task-context.js';
28
22
  import { buildSessionTurnList } from '../core/session/session-turns.js';
23
+ import { CodexSessionFileAdapter } from '../core/session/adapters/codex-session-file-adapter.js';
29
24
  import { execFileSync } from 'child_process';
30
- import { execCodexCliSync, resolveCodexCliPath } from '../utils/codex-cli.js';
25
+ import { probeCli, probeCliSync } from '../utils/cross-platform.js';
31
26
  import { createHash, randomBytes } from 'crypto';
32
27
  import { buildModelRequestHeaders } from './request-identity.js';
33
28
  import fs from 'fs';
@@ -47,6 +42,14 @@ export function formatApprovalLogSummary(summary) {
47
42
  return singleLine;
48
43
  return singleLine.slice(0, APPROVAL_LOG_SUMMARY_MAX_LENGTH - 3).trimEnd() + '...';
49
44
  }
45
+ /**
46
+ * 规范化模型 ID:去掉 Claude Code SDK 专有的上下文窗口后缀 [1m]/[200k]
47
+ * Codex/OpenAI API 不认识这些后缀,必须传入标准模型 ID
48
+ */
49
+ function stripContextWindowSuffix(modelId) {
50
+ return modelId.replace(/\[(?:1m|200k)\]$/i, '');
51
+ }
52
+ // ── Codex 模型目录(动态获取,含 effort) ──
50
53
  class AsyncEventQueue {
51
54
  queue = [];
52
55
  done = false;
@@ -94,6 +97,12 @@ function errorCodeForToolDenial(decisionSource, policyCode) {
94
97
  return 'INVALID_ARGUMENT';
95
98
  return 'EXECUTION_FAILED';
96
99
  }
100
+ function isCodexReasoningNotification(method) {
101
+ return method === 'item/reasoning/textDelta'
102
+ || method === 'item/reasoning/summaryTextDelta'
103
+ || method === 'item/reasoning/summaryPartAdded';
104
+ }
105
+ const CODEX_THREAD_HANDOFF_TIMEOUT_MS = 2_000;
97
106
  const managedTempDirsForExitCleanup = new Map();
98
107
  let managedTempExitCleanupRegistered = false;
99
108
  function isTrustedManagedTempDirectory(directory, parent) {
@@ -109,9 +118,9 @@ function isTrustedManagedTempDirectory(directory, parent) {
109
118
  const stat = fs.lstatSync(candidate);
110
119
  if (!stat.isDirectory() || stat.isSymbolicLink())
111
120
  return false;
112
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
121
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid())
113
122
  return false;
114
- return (stat.mode & 0o077) === 0;
123
+ return process.platform === 'win32' || (stat.mode & 0o077) === 0;
115
124
  };
116
125
  if (!trustedDirectory(parentPath) || !trustedDirectory(directoryPath))
117
126
  return false;
@@ -218,20 +227,17 @@ function outputTextForManagedHook(value) {
218
227
  }
219
228
  return String(value);
220
229
  }
221
- const CODEX_CATALOG_FALLBACK = [
222
- { slug: 'gpt-5.5', efforts: ['low', 'medium', 'high', 'xhigh'] },
223
- { slug: 'gpt-5.4', efforts: ['low', 'medium', 'high', 'xhigh'] },
224
- { slug: 'gpt-5.4-mini', efforts: ['low', 'medium', 'high', 'xhigh'] },
225
- { slug: 'gpt-5.3-codex', efforts: ['low', 'medium', 'high', 'xhigh'] },
226
- { slug: 'gpt-5.2', efforts: ['low', 'medium', 'high', 'xhigh'] },
227
- ];
228
230
  const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
229
231
  const CODEX_NO_ROLLOUT_RE = /no rollout found for thread id/i;
232
+ const CODEX_ACTIVE_WRITER_RE = /already has an active writer/i;
230
233
  function isCodexNoRolloutError(error) {
231
234
  return error?.code === 'CODEX_ROLLOUT_NOT_FOUND'
232
235
  || CODEX_NO_ROLLOUT_RE.test(error instanceof Error ? error.message : String(error));
233
236
  }
234
- let codexCatalogCache = null;
237
+ function isCodexActiveWriterError(error) {
238
+ return error?.code === CODEX_THREAD_ACTIVE_WRITER
239
+ || CODEX_ACTIVE_WRITER_RE.test(error instanceof Error ? error.message : String(error));
240
+ }
235
241
  // The permission bridge is version-locked to the audited approval schema and
236
242
  // managed PreToolUse hook behavior used by proactive first-tool enforcement.
237
243
  export const MIN_CODEX_CLI_VERSION = '0.144.1';
@@ -242,21 +248,59 @@ export function parseCodexCliVersion(output) {
242
248
  export function isCodexCliVersionSupported(version) {
243
249
  return compareVersions(version, MIN_CODEX_CLI_VERSION) >= 0;
244
250
  }
251
+ // Windows shim/process startup can exceed the quick probe deadline under load.
252
+ const CODEX_CLI_PROBE_TIMEOUT_MS = 3000;
253
+ const CODEX_CLI_PROBE_RETRY_TIMEOUT_MS = 15_000;
254
+ function compactCodexProbeText(value, maxLength = 600) {
255
+ if (Buffer.isBuffer(value))
256
+ value = value.toString('utf-8');
257
+ if (typeof value !== 'string')
258
+ return undefined;
259
+ const text = value.replace(/\s+/gu, ' ').trim();
260
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3).trimEnd()}...`;
261
+ }
262
+ function logCodexProbeFailure(args, startedAt, error, timeoutMs) {
263
+ const detail = error;
264
+ logger.warn(`[CodexProbe] CLI probe failed: ${JSON.stringify({
265
+ command: ['codex', ...args],
266
+ path: detail?.path ?? null,
267
+ spawnargs: detail?.spawnargs ?? null,
268
+ elapsedMs: Date.now() - startedAt,
269
+ timeoutMs,
270
+ code: detail?.code ?? null,
271
+ status: detail?.status ?? null,
272
+ signal: detail?.signal ?? null,
273
+ timedOut: detail?.code === 'ETIMEDOUT',
274
+ message: compactCodexProbeText(detail?.message ?? String(error)),
275
+ stdout: compactCodexProbeText(detail?.stdout),
276
+ stderr: compactCodexProbeText(detail?.stderr),
277
+ })}`);
278
+ }
279
+ function probeCodexCli(args) {
280
+ return probeCliSync('codex', args, { onFailure: logCodexProbeFailure });
281
+ }
245
282
  export function getCodexCliVersion() {
283
+ const startedAt = Date.now();
246
284
  try {
247
- const output = execCodexCliSync(['--version'], {
248
- encoding: 'utf-8',
249
- timeout: 3000,
250
- stdio: ['pipe', 'pipe', 'pipe'],
251
- });
252
- return parseCodexCliVersion(output);
285
+ const output = probeCodexCli(['--version']);
286
+ const version = parseCodexCliVersion(output);
287
+ if (!version) {
288
+ logCodexProbeFailure(['--version'], startedAt, {
289
+ code: 'INVALID_VERSION_OUTPUT',
290
+ message: 'Codex CLI version output could not be parsed',
291
+ status: 0,
292
+ stdout: output,
293
+ }, CODEX_CLI_PROBE_TIMEOUT_MS);
294
+ }
295
+ return version;
253
296
  }
254
297
  catch {
255
298
  return null;
256
299
  }
257
300
  }
258
301
  export function getCodexAppServerAvailability() {
259
- if (!resolveCodexCliPath()) {
302
+ const cliPath = resolveCodexCliPath();
303
+ if (!cliPath) {
260
304
  return { available: false, reason: '未检测到 Codex CLI。' };
261
305
  }
262
306
  const version = getCodexCliVersion();
@@ -272,52 +316,45 @@ export function getCodexAppServerAvailability() {
272
316
  };
273
317
  }
274
318
  try {
275
- execCodexCliSync(['app-server', '--help'], {
276
- encoding: 'utf-8',
277
- timeout: 3000,
278
- stdio: ['pipe', 'pipe', 'pipe'],
279
- });
319
+ probeCodexCli(['app-server', '--help']);
280
320
  return { available: true, version };
281
321
  }
282
- catch {
283
- return { available: false, version, reason: `Codex CLI ${version} 不支持 app-server。` };
322
+ catch (error) {
323
+ if (error?.code === 'ETIMEDOUT') {
324
+ return { available: false, version, reason: `Codex CLI ${version} 的 app-server 探测超时,请稍后重试。` };
325
+ }
326
+ return { available: false, version, reason: `Codex CLI ${version} 的 app-server 探测失败,请检查 CLI 启动日志。` };
284
327
  }
285
328
  }
286
329
  export function isCodexAppServerAvailable() {
287
330
  return getCodexAppServerAvailability().available;
288
331
  }
289
- function fetchCodexCatalog() {
290
- if (codexCatalogCache)
291
- return codexCatalogCache;
332
+ /** Daemon startup/hot reload use asynchronous probes; sync CLI callers share the cache. */
333
+ export async function getCodexAppServerAvailabilityAsync() {
334
+ const options = {
335
+ timeouts: [CODEX_CLI_PROBE_TIMEOUT_MS, CODEX_CLI_PROBE_RETRY_TIMEOUT_MS],
336
+ onFailure: logCodexProbeFailure,
337
+ };
338
+ let version;
292
339
  try {
293
- const output = execCodexCliSync(['debug', 'models'], {
294
- encoding: 'utf-8',
295
- timeout: 5000,
296
- stdio: ['pipe', 'pipe', 'pipe'],
297
- });
298
- const catalog = JSON.parse(output);
299
- const models = catalog.models
300
- .filter(m => m.visibility === 'list')
301
- .map(m => ({
302
- slug: m.slug,
303
- efforts: (m.supported_reasoning_levels || []).map(l => l.effort),
304
- }));
305
- if (models.length > 0) {
306
- codexCatalogCache = models;
307
- return models;
308
- }
340
+ version = parseCodexCliVersion(await probeCli('codex', ['--version'], options)) ?? undefined;
341
+ if (!version)
342
+ return { available: false, reason: '无法读取 Codex CLI 版本。' };
343
+ if (!isCodexCliVersionSupported(version)) {
344
+ return { available: false, version, requiresUpgrade: true,
345
+ reason: `Codex CLI ${version} 低于最低要求 ${MIN_CODEX_CLI_VERSION}。请升级 Codex CLI:npm install -g @openai/codex@latest` };
346
+ }
347
+ await probeCli('codex', ['app-server', '--help'], options);
348
+ return { available: true, version };
309
349
  }
310
- catch (e) {
311
- logger.debug(`[CodexRunner] Failed to fetch model catalog, using fallback: ${e}`);
350
+ catch (error) {
351
+ const detail = error;
352
+ return { available: false, version, reason: detail.code === 'ETIMEDOUT' || detail.killed
353
+ ? 'Codex CLI 探测超时,请稍后重试。'
354
+ : `Codex CLI 探测失败:${error instanceof Error ? error.message : String(error)}` };
312
355
  }
313
- return CODEX_CATALOG_FALLBACK;
314
356
  }
315
- export function getCodexEfforts(model) {
316
- const catalog = fetchCodexCatalog();
317
- const entry = catalog.find(m => m.slug === model);
318
- return entry?.efforts ?? catalog[0]?.efforts ?? ['low', 'medium', 'high'];
319
- }
320
- // ── Codex Runner ──
357
+ export const CODEX_SESSION_TOKEN_ENV = 'EVOLCORE_CODEX_SESSION_TOKEN';
321
358
  export class CodexRunner {
322
359
  name = 'codex';
323
360
  // turn/start persists a new user turn before runQuery resolves. App-server
@@ -329,7 +366,7 @@ export class CodexRunner {
329
366
  activeAbortControllers = new Map();
330
367
  activeStreams = new Map();
331
368
  activeSessions = new Map(); // sessionId → threadId
332
- /** Threads created/resumed for a turn but not yet durably admitted by turn/start. */
369
+ /** New threads held in memory until the exact first turn is visible in the rollout. */
333
370
  provisionalSessions = new Map();
334
371
  activeTurns = new Map();
335
372
  activeSubagentTurns = new Map();
@@ -347,14 +384,25 @@ export class CodexRunner {
347
384
  steeringTails = new Map();
348
385
  threadOperationLocks = new Map();
349
386
  activeOperationReleases = new Map();
387
+ /** Last app-server client that loaded each thread, including unsubscribed threads. */
388
+ threadSubscriptions = new Map();
389
+ /** Ordinary sessions stay behind the EvolCore H/L filesystem guard. */
350
390
  appServerClient = null;
391
+ /** Trusted fullaccess calls use a separate unmasked app-server process. */
392
+ fullAccessAppServerClient = null;
351
393
  onSessionIdUpdate;
352
394
  onCompactStart;
353
395
  permissionGateway;
354
396
  sendPromptFn;
355
397
  permissionContexts = new Map();
398
+ /** Fallback authorization lifecycle ids for app-server requests without an RPC id. */
399
+ approvalLifecycleIds = new WeakMap();
356
400
  resolvedConfig;
357
401
  pendingInterruptTtlMs = 30_000;
402
+ /** Fallback for direct runner callers that do not inject SessionManager. */
403
+ codexSessionFileAdapter = new CodexSessionFileAdapter();
404
+ /** threadId → managed-hook credential, issued by this runner's agent only. */
405
+ threadCredentials = new Map();
358
406
  constructor(config, callbacks, runtime) {
359
407
  // Compatibility for legacy direct callers that still pass the removed
360
408
  // internal fields in a synthetic config. AgentLoader uses runtime binding.
@@ -383,29 +431,208 @@ export class CodexRunner {
383
431
  fileRewind: 'git-head',
384
432
  fullaccess: true,
385
433
  };
386
- this.model = this.resolvedConfig.model;
434
+ this.model = stripContextWindowSuffix(this.resolvedConfig.model);
387
435
  if (this.resolvedConfig.effort)
388
436
  this.effort = this.resolvedConfig.effort;
389
437
  this.onSessionIdUpdate = callbacks.onSessionIdUpdate;
390
438
  }
391
- getAppServerClient() {
392
- if (!this.appServerClient) {
393
- const client = new CodexAppServerClient({
394
- apiKey: this.resolvedConfig.apiKey,
395
- baseUrl: this.resolvedConfig.baseUrl,
396
- provider: this.resolvedConfig.provider,
397
- headers: this.resolvedConfig.headers,
398
- queryParams: this.resolvedConfig.queryParams,
399
- model: this.model,
400
- effort: this.effort,
401
- enableRequestUserInput: this.resolvedConfig.enableRequestUserInput,
402
- approvalsReviewer: 'user',
403
- onServerRequest: request => this.handleAppServerRequest(request),
404
- });
405
- client.onNotification(notification => this.trackApprovalItemNotification(notification));
439
+ getAppServerClient(fullAccess = false) {
440
+ const existing = fullAccess ? this.fullAccessAppServerClient : this.appServerClient;
441
+ if (existing)
442
+ return existing;
443
+ const client = new CodexAppServerClient({
444
+ apiKey: this.resolvedConfig.apiKey,
445
+ baseUrl: this.resolvedConfig.baseUrl,
446
+ provider: this.resolvedConfig.provider,
447
+ headers: this.resolvedConfig.headers,
448
+ queryParams: this.resolvedConfig.queryParams,
449
+ model: this.model,
450
+ effort: this.effort,
451
+ enableRequestUserInput: this.resolvedConfig.enableRequestUserInput,
452
+ approvalsReviewer: 'user',
453
+ protectEvolcoreFilesystem: !fullAccess,
454
+ // A completed turn is explicitly unsubscribed below. This is only an
455
+ // optimization on newer Codex versions; old versions retain the thread
456
+ // until the cross-client archive fallback runs.
457
+ onServerRequest: request => this.handleAppServerRequest(request),
458
+ });
459
+ client.onNotification(notification => {
460
+ this.trackApprovalItemNotification(notification);
461
+ if (notification.method !== 'thread/closed')
462
+ return;
463
+ const params = (notification.params || {});
464
+ const threadId = params.threadId ?? params.thread_id;
465
+ if (typeof threadId !== 'string')
466
+ return;
467
+ const subscription = this.threadSubscriptions.get(threadId);
468
+ if (subscription?.client === client) {
469
+ // Keep the last client identity so a later same-mode turn resumes on
470
+ // the same app-server even after the runtime has fully unloaded.
471
+ this.threadSubscriptions.set(threadId, { client, state: 'unsubscribed' });
472
+ }
473
+ });
474
+ if (fullAccess) {
475
+ this.fullAccessAppServerClient = client;
476
+ }
477
+ else {
406
478
  this.appServerClient = client;
407
479
  }
408
- return this.appServerClient;
480
+ return client;
481
+ }
482
+ getAppServerClientForThread(threadId) {
483
+ const subscribed = this.threadSubscriptions.get(threadId);
484
+ if (subscribed)
485
+ return subscribed.client;
486
+ const sessionId = this.findSessionKeyByThread(threadId);
487
+ return this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
488
+ }
489
+ markThreadSubscribed(client, threadId) {
490
+ const existing = this.threadSubscriptions.get(threadId);
491
+ if (existing?.client === client && existing.state === 'subscribed' && !existing.release)
492
+ return;
493
+ this.threadSubscriptions.set(threadId, { client, state: 'subscribed' });
494
+ }
495
+ /**
496
+ * Release only one thread from one app-server connection. Unsubscribe is
497
+ * intentionally not treated as an immediate unload: current Codex builds can
498
+ * keep an unsubscribed thread loaded for a long grace period. The owner therefore
499
+ * remains in the map until a different client successfully resumes it.
500
+ */
501
+ async releaseThreadSubscription(client, threadId, reason, options = {}) {
502
+ const existing = this.threadSubscriptions.get(threadId);
503
+ if (existing && existing.client !== client)
504
+ return false;
505
+ if (existing?.release)
506
+ return await existing.release.catch(error => {
507
+ logger.warn(`[CodexRunner] Existing thread release failed: thread=${threadId} `
508
+ + `error=${error instanceof Error ? error.message : String(error)}`);
509
+ return false;
510
+ });
511
+ if (existing?.state === 'unsubscribed')
512
+ return true;
513
+ let releasePromise;
514
+ releasePromise = Promise.resolve().then(async () => {
515
+ let released = false;
516
+ try {
517
+ const unsubscribe = client.threadUnsubscribe;
518
+ if (typeof unsubscribe === 'function') {
519
+ if (options.waitForClose && typeof client.threadUnsubscribeAndWait === 'function') {
520
+ try {
521
+ await client.threadUnsubscribeAndWait.call(client, threadId, options.timeoutMs ?? CODEX_THREAD_HANDOFF_TIMEOUT_MS);
522
+ }
523
+ catch (error) {
524
+ // The unsubscribe RPC has already succeeded when this is a
525
+ // close-timeout. Keep the owner marked unsubscribed and let the
526
+ // target resume decide whether the archive fallback is needed.
527
+ if (error?.code !== 'CODEX_THREAD_CLOSE_TIMEOUT')
528
+ throw error;
529
+ logger.debug(`[CodexRunner] thread/closed not observed before handoff timeout: `
530
+ + `thread=${threadId} reason=${reason}`);
531
+ }
532
+ }
533
+ else {
534
+ await unsubscribe.call(client, threadId);
535
+ }
536
+ }
537
+ logger.debug(`[CodexRunner] Released thread subscription: thread=${threadId} reason=${reason}`);
538
+ released = true;
539
+ return true;
540
+ }
541
+ catch (error) {
542
+ logger.warn(`[CodexRunner] Failed to release thread subscription: thread=${threadId} reason=${reason} `
543
+ + `error=${error instanceof Error ? error.message : String(error)}`);
544
+ return false;
545
+ }
546
+ finally {
547
+ const current = this.threadSubscriptions.get(threadId);
548
+ if (current?.client === client && current.release === releasePromise) {
549
+ if (released) {
550
+ this.threadSubscriptions.set(threadId, { client, state: 'unsubscribed' });
551
+ }
552
+ else {
553
+ this.threadSubscriptions.set(threadId, { client, state: current.state });
554
+ }
555
+ }
556
+ }
557
+ });
558
+ this.threadSubscriptions.set(threadId, {
559
+ client,
560
+ state: existing?.state ?? 'subscribed',
561
+ release: releasePromise,
562
+ });
563
+ return await releasePromise;
564
+ }
565
+ async prepareThreadSubscription(client, threadId) {
566
+ const existing = this.threadSubscriptions.get(threadId);
567
+ if (!existing)
568
+ return undefined;
569
+ if (existing.client === client) {
570
+ if (existing.release)
571
+ await existing.release.catch(() => undefined);
572
+ return undefined;
573
+ }
574
+ if (existing.state === 'subscribed') {
575
+ const released = await this.releaseThreadSubscription(existing.client, threadId, 'mode-handoff', {
576
+ waitForClose: true,
577
+ timeoutMs: CODEX_THREAD_HANDOFF_TIMEOUT_MS,
578
+ });
579
+ if (!released) {
580
+ const error = new Error(`Codex thread ${threadId} is still owned by another app-server connection`);
581
+ error.code = CODEX_THREAD_ACTIVE_WRITER;
582
+ throw error;
583
+ }
584
+ }
585
+ return existing.client;
586
+ }
587
+ async archiveThreadForHandoff(sourceClient, targetClient, threadId) {
588
+ const existing = this.threadSubscriptions.get(threadId);
589
+ if (existing && existing.client !== sourceClient) {
590
+ const error = new Error(`Codex thread ${threadId} ownership changed during handoff`);
591
+ error.code = CODEX_THREAD_ACTIVE_WRITER;
592
+ throw error;
593
+ }
594
+ const sessionId = this.findSessionKeyByThread(threadId);
595
+ if ([...this.activeSubagentTurns.values()].some(active => active.sessionId === sessionId && active.parentThreadId === threadId)) {
596
+ const error = new Error(`Codex thread ${threadId} has an active descendant turn`);
597
+ error.code = CODEX_THREAD_ACTIVE_WRITER;
598
+ throw error;
599
+ }
600
+ // Old app-server versions can acknowledge unsubscribe while retaining the
601
+ // writer. Archive is the only per-thread operation that synchronously
602
+ // tears down that runtime; the target client restores it before resume.
603
+ const archive = sourceClient.threadArchive;
604
+ const unarchive = targetClient.threadUnarchive;
605
+ if (typeof archive !== 'function' || typeof unarchive !== 'function') {
606
+ const error = new Error(`Codex thread ${threadId} requires archive/unarchive handoff support`);
607
+ error.code = CODEX_THREAD_ACTIVE_WRITER;
608
+ throw error;
609
+ }
610
+ let archived = false;
611
+ try {
612
+ await archive.call(sourceClient, threadId);
613
+ archived = true;
614
+ await unarchive.call(targetClient, threadId);
615
+ }
616
+ catch (error) {
617
+ if (archived) {
618
+ // Do not strand the user's rollout under archived_sessions when the
619
+ // target client fails midway through the compatibility handoff.
620
+ try {
621
+ await unarchive.call(sourceClient, threadId);
622
+ this.threadSubscriptions.set(threadId, { client: sourceClient, state: 'subscribed' });
623
+ }
624
+ catch (restoreError) {
625
+ logger.error(`[CodexRunner] Failed to restore archived thread after handoff failure: thread=${threadId} `
626
+ + `error=${restoreError instanceof Error ? restoreError.message : String(restoreError)}`);
627
+ }
628
+ }
629
+ logger.warn(`[CodexRunner] Archive handoff failed: thread=${threadId} `
630
+ + `error=${error instanceof Error ? error.message : String(error)}`);
631
+ const handoffError = new Error(`Codex thread ${threadId} is still owned by another app-server connection`);
632
+ handoffError.code = CODEX_THREAD_ACTIVE_WRITER;
633
+ throw handoffError;
634
+ }
635
+ this.threadSubscriptions.set(threadId, { client: targetClient, state: 'subscribed' });
409
636
  }
410
637
  createDelegationCarrier() {
411
638
  return randomBytes(32).toString('base64url');
@@ -629,6 +856,11 @@ export class CodexRunner {
629
856
  error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
630
857
  throw error;
631
858
  }
859
+ // The binding callback can yield while a replacement run claims this
860
+ // session. Re-check the provisional owner before publishing any local
861
+ // indexes so a late first turn cannot overwrite the newer thread.
862
+ if (this.provisionalSessions.get(sessionId) !== threadId)
863
+ return false;
632
864
  this.provisionalSessions.delete(sessionId);
633
865
  this.activeSessions.set(sessionId, threadId);
634
866
  if (carrierToPublish) {
@@ -638,6 +870,30 @@ export class CodexRunner {
638
870
  this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
639
871
  return true;
640
872
  }
873
+ async waitForCodexTurnPersistence(sessionManager, projectPath, threadId, turnId, timeoutMs = 0) {
874
+ const check = () => {
875
+ try {
876
+ if (typeof sessionManager?.checkSessionTurnPersisted === 'function') {
877
+ return sessionManager.checkSessionTurnPersisted(projectPath, threadId, 'codex', turnId, this.resolvedConfig.agentAid) === true;
878
+ }
879
+ return this.codexSessionFileAdapter.checkTurnPersisted(projectPath, threadId, turnId, this.resolvedConfig.agentAid);
880
+ }
881
+ catch (error) {
882
+ logger.debug(`[CodexRunner] rollout persistence check failed: session=${threadId} turn=${turnId} `
883
+ + `error=${error instanceof Error ? error.message : String(error)}`);
884
+ return false;
885
+ }
886
+ };
887
+ const deadline = Date.now() + Math.max(0, timeoutMs);
888
+ while (true) {
889
+ if (check())
890
+ return true;
891
+ const remaining = deadline - Date.now();
892
+ if (remaining <= 0)
893
+ return false;
894
+ await new Promise(resolve => setTimeout(resolve, Math.min(10, remaining)));
895
+ }
896
+ }
641
897
  cleanupProvisionalThread(sessionId, threadId) {
642
898
  if (this.provisionalSessions.get(sessionId) !== threadId)
643
899
  return;
@@ -681,8 +937,11 @@ export class CodexRunner {
681
937
  }
682
938
  }
683
939
  resetAppServerClient() {
684
- const client = this.appServerClient;
940
+ const clients = [this.appServerClient, this.fullAccessAppServerClient]
941
+ .filter((client) => !!client);
685
942
  this.appServerClient = null;
943
+ this.fullAccessAppServerClient = null;
944
+ this.threadSubscriptions.clear();
686
945
  this.threadExternalToolFingerprints.clear();
687
946
  this.threadExternalToolBoundaries.clear();
688
947
  for (const childThreadId of this.childThreadSessions.keys())
@@ -695,9 +954,11 @@ export class CodexRunner {
695
954
  this.trackedApprovalItems.clear();
696
955
  this.pendingToolDenials.clear();
697
956
  this.threadDelegationCarriers.clear();
698
- client?.close().catch(error => {
699
- logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
700
- });
957
+ for (const client of clients) {
958
+ client.close().catch(error => {
959
+ logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
960
+ });
961
+ }
701
962
  }
702
963
  mergeThreadConfig(...configs) {
703
964
  const merged = {};
@@ -943,21 +1204,18 @@ export class CodexRunner {
943
1204
  }
944
1205
  }
945
1206
  // ── ModelSwitcher ──
946
- setModel(model) { this.model = model; this.resetAppServerClient(); }
1207
+ setModel(model) { this.model = stripContextWindowSuffix(model); this.resetAppServerClient(); }
947
1208
  getModel() { return this.model; }
1209
+ getModelCatalog() {
1210
+ return getRunnerCatalog('codex', this.resolvedConfig.agentAid, this.resolvedConfig);
1211
+ }
948
1212
  async listModels() {
949
- try {
950
- const response = await this.getAppServerClient().modelList(false);
951
- const ids = (response.data ?? [])
952
- .map(model => model.id || model.slug || model.name || model.model)
953
- .filter((id) => typeof id === 'string' && id.length > 0);
954
- if (ids.length > 0)
955
- return ids;
956
- }
957
- catch (error) {
958
- logger.debug(`[CodexRunner] app-server model/list failed, using catalog fallback: ${error}`);
959
- }
960
- return fetchCodexCatalog().map(m => m.slug);
1213
+ const catalog = await this.getModelCatalog();
1214
+ assertCatalogSelectable(catalog);
1215
+ return catalog.models.map(m => m.id);
1216
+ }
1217
+ async getAvailableEfforts(model) {
1218
+ return catalogEfforts(await this.getModelCatalog(), model, 'codex');
961
1219
  }
962
1220
  // ── Effort ──
963
1221
  setEffort(effort) { this.effort = effort; this.resetAppServerClient(); }
@@ -1014,7 +1272,9 @@ export class CodexRunner {
1014
1272
  if (!looksLikeGitMetadataDirectory(resolved))
1015
1273
  return;
1016
1274
  paths.add(resolved);
1017
- paths.add(path.join(resolved, '**'));
1275
+ // Keep exact paths native, but Codex requires POSIX separators for
1276
+ // recursive filesystem globs even on Windows.
1277
+ paths.add(`${resolved}/**`);
1018
1278
  };
1019
1279
  try {
1020
1280
  const stat = fs.statSync(marker);
@@ -1028,7 +1288,7 @@ export class CodexRunner {
1028
1288
  }
1029
1289
  }
1030
1290
  catch { }
1031
- for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith(`${path.sep}**`))) {
1291
+ for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith('/**'))) {
1032
1292
  try {
1033
1293
  const commonDirectory = fs.readFileSync(path.join(gitDirectory, 'commondir'), 'utf8').trim();
1034
1294
  if (commonDirectory)
@@ -1137,7 +1397,7 @@ export class CodexRunner {
1137
1397
  return;
1138
1398
  }
1139
1399
  const input = [{ type: 'text', text, text_elements: [] }];
1140
- const client = this.getAppServerClient();
1400
+ const client = this.getAppServerClientForThread(activeTurn.threadId);
1141
1401
  if (typeof client.turnSteer !== 'function') {
1142
1402
  logger.warn(`[CodexRunner] turn steer unavailable in app-server client: session=${sessionId}`);
1143
1403
  return;
@@ -1165,7 +1425,28 @@ export class CodexRunner {
1165
1425
  }
1166
1426
  await appServer.assertManagedHooksAvailable();
1167
1427
  }
1168
- async evaluatePreToolUse(threadId, toolName, toolInput, signal, callId) {
1428
+ /**
1429
+ * Decide whether the managed hook may proceed with a tool call.
1430
+ *
1431
+ * Credential contract (see CODEX_SESSION_TOKEN_ENV):
1432
+ * - sessionToken present and issued by this runner for this thread → the
1433
+ * call belongs to an EvolCore-managed thread; full policy evaluation.
1434
+ * - sessionToken present but unknown/mismatched → forgery or stale state;
1435
+ * fail closed with applicable=true so no other outcome allows it.
1436
+ * - sessionToken absent → unmanaged process; not this runner's concern
1437
+ * (applicable=false; the IPC layer allows such calls outright).
1438
+ */
1439
+ async evaluatePreToolUse(threadId, toolName, toolInput, signal, callId, sessionToken) {
1440
+ if (typeof sessionToken === 'string' && sessionToken) {
1441
+ if (!this.validateThreadCredential(threadId, sessionToken)) {
1442
+ return {
1443
+ ok: false,
1444
+ applicable: true,
1445
+ decision: 'deny',
1446
+ reason: 'Codex session credential was not recognized',
1447
+ };
1448
+ }
1449
+ }
1169
1450
  const sessionKey = this.findSessionKeyByThread(threadId);
1170
1451
  const activeThread = this.activeSessions.get(sessionKey) ?? this.provisionalSessions.get(sessionKey);
1171
1452
  const context = this.permissionContexts.get(sessionKey);
@@ -1213,6 +1494,16 @@ export class CodexRunner {
1213
1494
  }
1214
1495
  };
1215
1496
  if (!activeThread || !this.isThreadOwnedBySession(sessionKey, threadId)) {
1497
+ if (typeof sessionToken === 'string' && sessionToken) {
1498
+ // A credentialed thread this runner cannot place (e.g. resumed state
1499
+ // lost across a runner reload) must never fall through to allow.
1500
+ return {
1501
+ ok: false,
1502
+ applicable: true,
1503
+ decision: 'deny',
1504
+ reason: 'EvolCore-managed Codex thread is not tracked by an active session',
1505
+ };
1506
+ }
1216
1507
  return { ok: true, applicable: false };
1217
1508
  }
1218
1509
  const proactiveSession = context?.chatmode === 'proactive';
@@ -1265,6 +1556,10 @@ export class CodexRunner {
1265
1556
  const hadController = this.activeAbortControllers.has(key);
1266
1557
  const hadTurn = this.activeTurns.has(key);
1267
1558
  const provisionalThread = this.provisionalSessions.get(key);
1559
+ const subscribedThreadId = this.activeTurns.get(key)?.threadId
1560
+ ?? this.activeSessions.get(key)
1561
+ ?? provisionalThread;
1562
+ const subscription = subscribedThreadId ? this.threadSubscriptions.get(subscribedThreadId) : undefined;
1268
1563
  this.activeStreams.delete(key);
1269
1564
  this.activeAbortControllers.delete(key);
1270
1565
  this.pendingInterrupts.delete(key);
@@ -1276,9 +1571,18 @@ export class CodexRunner {
1276
1571
  }
1277
1572
  this.chatModes.delete(key);
1278
1573
  const release = this.activeOperationReleases.get(key);
1574
+ let releaseAfterSubscription;
1575
+ if (subscription) {
1576
+ releaseAfterSubscription = this.releaseThreadSubscription(subscription.client, subscribedThreadId, 'stream-cleanup');
1577
+ }
1279
1578
  if (release) {
1280
1579
  this.activeOperationReleases.delete(key);
1281
- release();
1580
+ if (releaseAfterSubscription) {
1581
+ void releaseAfterSubscription.then(release, release);
1582
+ }
1583
+ else {
1584
+ release();
1585
+ }
1282
1586
  }
1283
1587
  }
1284
1588
  hasActiveStream(key) {
@@ -1440,7 +1744,7 @@ export class CodexRunner {
1440
1744
  }
1441
1745
  }
1442
1746
  let resumingThread = !!agentSessionId;
1443
- const callModel = modelOverride?.model || this.model;
1747
+ const callModel = stripContextWindowSuffix(modelOverride?.model || this.model);
1444
1748
  const callEffort = modelOverride?.effortMode === 'model_default'
1445
1749
  ? undefined
1446
1750
  : modelOverride?.effort ?? this.effort;
@@ -1455,7 +1759,7 @@ export class CodexRunner {
1455
1759
  this.chatModes.set(sessionId, callMode);
1456
1760
  const callApprovalPolicy = this.toApprovalPolicy(callMode);
1457
1761
  const executionSandbox = this.buildExecutionSandboxOptions(sessionId, callMode, projectPath, modelOverride?.bootstrapAgentMdPath);
1458
- const appServer = this.getAppServerClient();
1762
+ const appServer = this.getAppServerClient(callMode === 'fullaccess');
1459
1763
  this.dropExpiredPendingInterrupt(sessionId);
1460
1764
  const effectiveApprovalPolicy = callApprovalPolicy;
1461
1765
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
@@ -1484,47 +1788,61 @@ export class CodexRunner {
1484
1788
  approvalPolicy: effectiveApprovalPolicy,
1485
1789
  approvalsReviewer: 'user',
1486
1790
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1487
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate), this.buildLifecycleLockdownConfig(callMode)), callMode),
1791
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate, callMode === 'fullaccess' ? runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] : undefined, agentSessionId), this.buildLifecycleLockdownConfig(callMode)), callMode),
1488
1792
  ...(developerInstructions ? { developerInstructions } : {}),
1489
1793
  };
1490
1794
  logger.info(`[CodexRunner] runQuery permMode=${requestedPermissionMode}->${callMode} ` +
1491
1795
  `role=${executionSandbox.decision.role ?? 'none'} sandbox=${executionSandbox.decision.state}`);
1492
1796
  let threadResponse;
1797
+ let previousAppServer;
1493
1798
  if (agentSessionId) {
1799
+ // A thread may have been loaded by the other permission-mode client.
1800
+ // Release that one thread before asking this client to resume it.
1801
+ previousAppServer = await this.prepareThreadSubscription(appServer, agentSessionId);
1494
1802
  try {
1495
1803
  threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
1496
1804
  }
1497
1805
  catch (error) {
1498
- // A legacy main session can retain a provider ID after its rollout was
1499
- // removed externally. It is safe to recover before input submission;
1500
- // topic bindings remain authoritative and fail closed.
1501
- if (topicBinding || !isCodexNoRolloutError(error))
1502
- throw error;
1503
- if (typeof sessionManager?.clearMainSessionBackendIfMatches !== 'function')
1504
- throw error;
1505
- const missingThreadId = agentSessionId;
1506
- const cleared = await sessionManager.clearMainSessionBackendIfMatches(sessionId, missingThreadId, [CODEX_DELEGATION_CARRIER_METADATA_KEY]);
1507
- if (cleared !== 'cleared' && cleared !== 'already_clear') {
1508
- const recoveryError = new Error(`Codex missing-rollout recovery rejected: ${cleared}`);
1509
- recoveryError.code = 'CODEX_MISSING_ROLLOUT_RECOVERY_REJECTED';
1510
- throw recoveryError;
1806
+ if (isCodexActiveWriterError(error) && previousAppServer && previousAppServer !== appServer) {
1807
+ // Some Codex builds acknowledge unsubscribe before unloading the
1808
+ // runtime. Archive/unarchive is a synchronous per-thread teardown
1809
+ // and preserves the same persisted thread id for the retry.
1810
+ await this.archiveThreadForHandoff(previousAppServer, appServer, agentSessionId);
1811
+ threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
1812
+ }
1813
+ else {
1814
+ // A legacy main session can retain a provider ID after its rollout was
1815
+ // removed externally. It is safe to recover before input submission;
1816
+ // topic bindings remain authoritative and fail closed.
1817
+ if (topicBinding || !isCodexNoRolloutError(error))
1818
+ throw error;
1819
+ if (typeof sessionManager?.clearMainSessionBackendIfMatches !== 'function')
1820
+ throw error;
1821
+ const missingThreadId = agentSessionId;
1822
+ const cleared = await sessionManager.clearMainSessionBackendIfMatches(sessionId, missingThreadId, [CODEX_DELEGATION_CARRIER_METADATA_KEY]);
1823
+ if (cleared !== 'cleared' && cleared !== 'already_clear') {
1824
+ const recoveryError = new Error(`Codex missing-rollout recovery rejected: ${cleared}`);
1825
+ recoveryError.code = 'CODEX_MISSING_ROLLOUT_RECOVERY_REJECTED';
1826
+ throw recoveryError;
1827
+ }
1828
+ if (this.activeSessions.get(sessionId) === missingThreadId)
1829
+ this.activeSessions.delete(sessionId);
1830
+ this.threadProjectPaths.delete(missingThreadId);
1831
+ this.threadDelegationCarriers.delete(missingThreadId);
1832
+ this.unbindExternalToolBoundary(missingThreadId);
1833
+ this.clearTrackedApprovalItemsForThread(missingThreadId);
1834
+ this.dropThreadCredential(missingThreadId);
1835
+ agentSessionId = undefined;
1836
+ resumingThread = false;
1837
+ knownDelegationCarrier = undefined;
1838
+ delegationCarrierCandidate = this.createDelegationCarrier();
1839
+ threadOptions = {
1840
+ ...threadOptions,
1841
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate, callMode === 'fullaccess' ? runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] : undefined), this.buildLifecycleLockdownConfig(callMode)), callMode),
1842
+ };
1843
+ logger.warn(`[CodexRunner] recovered missing rollout before input submission: session=${sessionId} thread=${missingThreadId}`);
1844
+ threadResponse = await appServer.threadStart(projectPath, threadOptions);
1511
1845
  }
1512
- if (this.activeSessions.get(sessionId) === missingThreadId)
1513
- this.activeSessions.delete(sessionId);
1514
- this.threadProjectPaths.delete(missingThreadId);
1515
- this.threadDelegationCarriers.delete(missingThreadId);
1516
- this.unbindExternalToolBoundary(missingThreadId);
1517
- this.clearTrackedApprovalItemsForThread(missingThreadId);
1518
- agentSessionId = undefined;
1519
- resumingThread = false;
1520
- knownDelegationCarrier = undefined;
1521
- delegationCarrierCandidate = this.createDelegationCarrier();
1522
- threadOptions = {
1523
- ...threadOptions,
1524
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate), this.buildLifecycleLockdownConfig(callMode)), callMode),
1525
- };
1526
- logger.warn(`[CodexRunner] recovered missing rollout before input submission: session=${sessionId} thread=${missingThreadId}`);
1527
- threadResponse = await appServer.threadStart(projectPath, threadOptions);
1528
1846
  }
1529
1847
  }
1530
1848
  else {
@@ -1533,6 +1851,7 @@ export class CodexRunner {
1533
1851
  let threadId = threadResponse.thread?.id || agentSessionId;
1534
1852
  if (!threadId)
1535
1853
  throw new Error('Codex app-server did not return a thread id');
1854
+ this.markThreadSubscribed(appServer, threadId);
1536
1855
  if (!resumingThread) {
1537
1856
  carrierToPublish = { threadId, carrier: delegationCarrierCandidate };
1538
1857
  }
@@ -1559,6 +1878,9 @@ export class CodexRunner {
1559
1878
  const forkedThreadId = forked.thread?.id;
1560
1879
  if (!forkedThreadId)
1561
1880
  throw new Error('Codex delegation-carrier recovery fork did not return a thread id');
1881
+ this.markThreadSubscribed(appServer, forkedThreadId);
1882
+ await this.releaseThreadSubscription(appServer, staleThreadId, 'carrier-recovery-fork-source');
1883
+ await this.releaseThreadSubscription(appServer, forkedThreadId, 'carrier-recovery-fork');
1562
1884
  threadId = forkedThreadId;
1563
1885
  recoveredCarrier = delegationCarrierCandidate;
1564
1886
  }
@@ -1591,6 +1913,7 @@ export class CodexRunner {
1591
1913
  if (!bindingAccepted) {
1592
1914
  const error = new Error(`backend discovery rejected: ${bindingResult}`);
1593
1915
  error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
1916
+ await this.releaseThreadSubscription(appServer, threadId, 'backend-binding-rejected');
1594
1917
  throw error;
1595
1918
  }
1596
1919
  this.activeSessions.set(sessionId, threadId);
@@ -1614,6 +1937,7 @@ export class CodexRunner {
1614
1937
  projectPath,
1615
1938
  model: callModel,
1616
1939
  committed: resumingThread,
1940
+ persistenceFinalized: resumingThread,
1617
1941
  streamedAgentMessageIds: new Set(),
1618
1942
  agentMessageDeltaText: new Map(),
1619
1943
  completedItemIds: new Set(),
@@ -1633,7 +1957,7 @@ export class CodexRunner {
1633
1957
  && this.childThreadSessions.get(notifThreadId) === sessionId;
1634
1958
  if (notifThreadId !== undefined && notifThreadId !== threadId && !emittedByChild)
1635
1959
  return;
1636
- this.trackSubagentLifecycle(notification, state);
1960
+ this.trackSubagentLifecycle(notification, state, appServer);
1637
1961
  if (emittedByChild && notifThreadId !== threadId) {
1638
1962
  const childTurnId = this.extractTurnId(notification);
1639
1963
  if (notification.method === 'turn/started' && childTurnId) {
@@ -1649,11 +1973,13 @@ export class CodexRunner {
1649
1973
  this.activeSubagentTurns.delete(notifThreadId);
1650
1974
  }
1651
1975
  state.activeSubagentThreads.delete(notifThreadId);
1976
+ void this.releaseThreadSubscription(appServer, notifThreadId, 'subagent-turn-finished');
1652
1977
  this.unregisterChildThread(notifThreadId);
1653
1978
  this.endAppServerQueueWhenSubagentsDone(queue, state);
1654
1979
  }
1655
- if (this.isSubagentActivityItem(item))
1980
+ if (this.isSubagentActivityItem(item) || isCodexReasoningNotification(notification.method)) {
1656
1981
  queue.push(notification);
1982
+ }
1657
1983
  if (notification.method === 'item/completed' && state.turnCompleted) {
1658
1984
  this.endAppServerQueueWhenSubagentsDone(queue, state);
1659
1985
  }
@@ -1689,7 +2015,7 @@ export class CodexRunner {
1689
2015
  if (!resumingThread)
1690
2016
  this.cleanupProvisionalThread(sessionId, threadId);
1691
2017
  logger.info(`[CodexRunner] Applied pending interrupt before turn start: ${sessionId}`);
1692
- return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
2018
+ return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
1693
2019
  }
1694
2020
  try {
1695
2021
  const turnResponse = await appServer.turnStart(threadId, input, {
@@ -1700,11 +2026,43 @@ export class CodexRunner {
1700
2026
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1701
2027
  });
1702
2028
  if (!resumingThread) {
1703
- state.commitProvisional = async () => {
1704
- const committed = await this.commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn);
1705
- if (committed)
1706
- agentSessionId = threadId;
1707
- return committed;
2029
+ let confirmationInFlight;
2030
+ state.confirmProvisional = (waitMs = 0) => {
2031
+ if (state.committed)
2032
+ return Promise.resolve(true);
2033
+ if (state.persistenceFinalized)
2034
+ return Promise.resolve(false);
2035
+ if (confirmationInFlight)
2036
+ return confirmationInFlight;
2037
+ const providerTurnId = state.turnId;
2038
+ if (!providerTurnId)
2039
+ return Promise.resolve(false);
2040
+ const confirmation = (async () => {
2041
+ const persisted = await this.waitForCodexTurnPersistence(sessionManager, projectPath, threadId, providerTurnId, waitMs);
2042
+ if (!persisted)
2043
+ return false;
2044
+ try {
2045
+ const committed = await this.commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn);
2046
+ if (!committed) {
2047
+ const staleError = new Error('Codex provisional thread was superseded before persistence confirmation');
2048
+ staleError.code = 'RUNNER_START_SUPERSEDED';
2049
+ throw staleError;
2050
+ }
2051
+ agentSessionId = threadId;
2052
+ state.committed = true;
2053
+ return true;
2054
+ }
2055
+ catch (error) {
2056
+ state.persistenceFinalized = true;
2057
+ throw error;
2058
+ }
2059
+ })();
2060
+ confirmationInFlight = confirmation;
2061
+ void confirmation.finally(() => {
2062
+ if (confirmationInFlight === confirmation)
2063
+ confirmationInFlight = undefined;
2064
+ }).catch(() => undefined);
2065
+ return confirmation;
1708
2066
  };
1709
2067
  }
1710
2068
  const turnId = turnResponse.turn?.id;
@@ -1712,6 +2070,12 @@ export class CodexRunner {
1712
2070
  state.turnId = turnId;
1713
2071
  this.activeTurns.set(sessionId, { threadId, turnId });
1714
2072
  }
2073
+ if (!resumingThread && turnId) {
2074
+ // Check immediately, but leave a just-created rollout as a candidate.
2075
+ // Subsequent notifications recheck it and the terminal boundary gets
2076
+ // the short settle window below.
2077
+ await state.confirmProvisional?.(0);
2078
+ }
1715
2079
  if (turnId && this.consumePendingInterrupt(sessionId)) {
1716
2080
  await this.interruptAppServerTurn(threadId, turnId);
1717
2081
  controller.abort('User interrupt');
@@ -1721,7 +2085,7 @@ export class CodexRunner {
1721
2085
  }
1722
2086
  const status = turnResponse.turn?.status;
1723
2087
  if (status === 'completed' || status === 'failed') {
1724
- this.primeSubagentStateFromTurn(turnResponse.turn, state);
2088
+ this.primeSubagentStateFromTurn(turnResponse.turn, state, appServer);
1725
2089
  queue.push({ method: 'turn/completed', params: { threadId, turn: turnResponse.turn } });
1726
2090
  state.turnCompleted = true;
1727
2091
  this.endAppServerQueueWhenSubagentsDone(queue, state);
@@ -1729,6 +2093,7 @@ export class CodexRunner {
1729
2093
  }
1730
2094
  catch (error) {
1731
2095
  unsubscribe();
2096
+ await this.releaseThreadSubscription(appServer, threadId, 'turn-start-failed');
1732
2097
  this.activeAbortControllers.delete(sessionId);
1733
2098
  this.activeTurns.delete(sessionId);
1734
2099
  this.pendingInterrupts.delete(sessionId);
@@ -1737,7 +2102,7 @@ export class CodexRunner {
1737
2102
  this.cleanupProvisionalThread(sessionId, threadId);
1738
2103
  throw error;
1739
2104
  }
1740
- return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
2105
+ return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
1741
2106
  }
1742
2107
  // ── Interrupt ──
1743
2108
  async interrupt(sessionKey) {
@@ -1789,7 +2154,12 @@ export class CodexRunner {
1789
2154
  }
1790
2155
  async interruptAppServerTurn(threadId, turnId) {
1791
2156
  try {
1792
- await this.getAppServerClient().turnInterrupt(threadId, turnId);
2157
+ const sessionId = this.findSessionKeyByThread(threadId);
2158
+ const subscribed = this.threadSubscriptions.get(threadId);
2159
+ const client = subscribed
2160
+ ? subscribed.client
2161
+ : this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
2162
+ await client.turnInterrupt(threadId, turnId);
1793
2163
  }
1794
2164
  catch (error) {
1795
2165
  logger.debug(`[CodexRunner] app-server turn interrupt failed: ${error}`);
@@ -1853,11 +2223,21 @@ export class CodexRunner {
1853
2223
  await this.interruptSubagentTurns(sessionId);
1854
2224
  }
1855
2225
  capturedController?.abort('Session closed');
2226
+ const capturedSubscription = capturedThreadId ? this.threadSubscriptions.get(capturedThreadId) : undefined;
2227
+ const currentActiveThreadId = this.activeSessions.get(sessionId);
2228
+ const currentProvisionalThreadId = this.provisionalSessions.get(sessionId);
2229
+ const capturedThreadIds = new Set([capturedThreadId, capturedProvisionalThreadId].filter((value) => value !== undefined));
2230
+ const threadLifecycleReplaced = (currentActiveThreadId !== undefined && !capturedThreadIds.has(currentActiveThreadId))
2231
+ || (currentProvisionalThreadId !== undefined && !capturedThreadIds.has(currentProvisionalThreadId));
1856
2232
  const lifecycleReplaced = (capturedStream !== undefined && this.activeStreams.get(sessionId) !== capturedStream)
1857
2233
  || (capturedController !== undefined && this.activeAbortControllers.get(sessionId) !== capturedController)
1858
2234
  || (capturedTurn !== undefined && this.activeTurns.get(sessionId) !== capturedTurn)
1859
2235
  || (capturedPermissionContext !== undefined && this.permissionContexts.get(sessionId) !== capturedPermissionContext)
1860
- || (capturedThreadId !== undefined && this.activeSessions.get(sessionId) !== capturedThreadId);
2236
+ || threadLifecycleReplaced;
2237
+ if (capturedSubscription && !lifecycleReplaced
2238
+ && this.threadSubscriptions.get(capturedThreadId) === capturedSubscription) {
2239
+ await this.releaseThreadSubscription(capturedSubscription.client, capturedThreadId, 'session-closed');
2240
+ }
1861
2241
  if (capturedStream !== undefined && this.activeStreams.get(sessionId) === capturedStream)
1862
2242
  this.activeStreams.delete(sessionId);
1863
2243
  if (capturedController !== undefined && this.activeAbortControllers.get(sessionId) === capturedController)
@@ -1890,7 +2270,7 @@ export class CodexRunner {
1890
2270
  }
1891
2271
  }
1892
2272
  resolveSessionFile(agentSessionId, _projectPath) {
1893
- // Codex session 文件: ~/.codex/sessions/YYYY/MM/DD/rollout-*-{threadId}.jsonl
2273
+ // Codex sessions are shared with the local Codex Desktop installation.
1894
2274
  const sessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1895
2275
  if (!fs.existsSync(sessionsDir))
1896
2276
  return null;
@@ -1913,6 +2293,10 @@ export class CodexRunner {
1913
2293
  async clearSession(sessionId, _agentSessionId, _projectPath) {
1914
2294
  // Codex: 清空会话 = 下次 runQuery 不传 resumeId,自动创建新 thread
1915
2295
  const threadId = this.activeSessions.get(sessionId) ?? _agentSessionId;
2296
+ const subscription = this.threadSubscriptions.get(threadId);
2297
+ if (subscription) {
2298
+ await this.releaseThreadSubscription(subscription.client, threadId, 'session-cleared');
2299
+ }
1916
2300
  this.activeSessions.delete(sessionId);
1917
2301
  this.activeTurns.delete(sessionId);
1918
2302
  this.steeringTails.delete(sessionId);
@@ -1933,8 +2317,12 @@ export class CodexRunner {
1933
2317
  return result;
1934
2318
  };
1935
2319
  const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId, _sessionId), 'compact');
2320
+ let compactAppServer;
1936
2321
  try {
1937
- const appServer = this.getAppServerClient();
2322
+ const compactMode = normalizeExecutionPermissionMode(modelOverride?.permissionMode ?? this.chatModes.get(_sessionId) ?? this.currentMode);
2323
+ const appServer = this.getAppServerClient(compactMode === 'fullaccess');
2324
+ compactAppServer = appServer;
2325
+ await this.prepareThreadSubscription(appServer, agentSessionId);
1938
2326
  this.onCompactStart?.(_sessionId);
1939
2327
  try {
1940
2328
  await this.startAndWaitForCompact(appServer, agentSessionId);
@@ -1944,8 +2332,7 @@ export class CodexRunner {
1944
2332
  if (!this.isThreadNotFoundError(error))
1945
2333
  throw error;
1946
2334
  logger.info(`[CodexRunner] Compact thread not loaded, resuming before compact: ${agentSessionId}`);
1947
- const compactMode = normalizeExecutionPermissionMode(modelOverride?.permissionMode ?? this.chatModes.get(_sessionId) ?? this.currentMode);
1948
- const compactModel = modelOverride?.model || this.model;
2335
+ const compactModel = stripContextWindowSuffix(modelOverride?.model || this.model);
1949
2336
  const compactEffort = modelOverride?.effortMode === 'model_default'
1950
2337
  ? undefined
1951
2338
  : modelOverride?.effort ?? this.effort;
@@ -1965,8 +2352,9 @@ export class CodexRunner {
1965
2352
  approvalPolicy: compactPolicy,
1966
2353
  approvalsReviewer: 'user',
1967
2354
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1968
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
2355
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier, undefined, agentSessionId), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
1969
2356
  });
2357
+ this.markThreadSubscribed(appServer, agentSessionId);
1970
2358
  this.threadProjectPaths.set(agentSessionId, path.resolve(_projectPath));
1971
2359
  this.threadDelegationCarriers.set(agentSessionId, compactDelegationCarrier);
1972
2360
  this.bindExternalToolBoundary(agentSessionId, externalToolConfig);
@@ -1979,6 +2367,9 @@ export class CodexRunner {
1979
2367
  return finish({ ok: false, code: 'sdk_error', message, durationMs: Date.now() - startedAt });
1980
2368
  }
1981
2369
  finally {
2370
+ if (compactAppServer) {
2371
+ await this.releaseThreadSubscription(compactAppServer, agentSessionId, 'compact-finished');
2372
+ }
1982
2373
  release();
1983
2374
  }
1984
2375
  }
@@ -2070,10 +2461,14 @@ export class CodexRunner {
2070
2461
  }
2071
2462
  return false;
2072
2463
  }
2464
+ codexHomeDir() {
2465
+ return process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex');
2466
+ }
2073
2467
  findCodexSessionFile(threadId) {
2074
- const root = process.env.CODEX_HOME
2075
- ? path.join(process.env.CODEX_HOME, 'sessions')
2076
- : path.join(process.env.HOME || os.homedir(), '.codex', 'sessions');
2468
+ return this.findRolloutFile(path.join(this.codexHomeDir(), 'sessions'), threadId)
2469
+ ?? this.findRolloutFile(path.join(this.codexHomeDir(), 'archived_sessions'), threadId);
2470
+ }
2471
+ findRolloutFile(root, threadId) {
2077
2472
  const stack = [root];
2078
2473
  let newest;
2079
2474
  while (stack.length > 0) {
@@ -2105,6 +2500,101 @@ export class CodexRunner {
2105
2500
  }
2106
2501
  return newest?.path;
2107
2502
  }
2503
+ // ── Managed-hook thread credentials ─────────────────────────────────
2504
+ // The Windows managed hook is machine-wide, so unmanaged Codex processes
2505
+ // also reach it. Credentials let the hook tell EvolCore-owned threads
2506
+ // (policy enforced) apart from unmanaged ones (allowed unconditionally).
2507
+ // They follow the thread (not the daemon process) so a daemon restart does
2508
+ // not strand a resumable thread: the token file is re-read on resume, and
2509
+ // it is removed lazily once the rollout file itself disappears.
2510
+ threadCredentialsDir() {
2511
+ const aid = this.resolvedConfig.agentAid;
2512
+ if (!aid)
2513
+ return undefined;
2514
+ return path.join(agentSessionsDir(aid), 'codex-tokens');
2515
+ }
2516
+ threadCredentialPath(threadId) {
2517
+ const dir = this.threadCredentialsDir();
2518
+ return dir ? path.join(dir, `${encodeURIComponent(threadId)}.json`) : undefined;
2519
+ }
2520
+ /** Issue or re-read this agent's credential for a thread. */
2521
+ ensureThreadCredential(threadId) {
2522
+ const cached = this.threadCredentials.get(threadId);
2523
+ if (cached)
2524
+ return cached;
2525
+ const file = this.threadCredentialPath(threadId);
2526
+ if (!file)
2527
+ return undefined;
2528
+ try {
2529
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
2530
+ if (record && typeof record.token === 'string' && record.token && record.threadId === threadId) {
2531
+ this.threadCredentials.set(threadId, record.token);
2532
+ return record.token;
2533
+ }
2534
+ }
2535
+ catch { /* missing or unreadable: fall through to issuance */ }
2536
+ const token = randomBytes(32).toString('hex');
2537
+ const record = {
2538
+ token,
2539
+ threadId,
2540
+ createdAt: new Date().toISOString(),
2541
+ rolloutPath: this.findCodexSessionFile(threadId),
2542
+ };
2543
+ try {
2544
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
2545
+ fs.writeFileSync(file, JSON.stringify(record, null, 2), { mode: 0o600 });
2546
+ this.threadCredentials.set(threadId, token);
2547
+ return token;
2548
+ }
2549
+ catch (error) {
2550
+ logger.warn(`[CodexRunner] failed to persist thread credential: thread=${threadId} error=${error instanceof Error ? error.message : String(error)}`);
2551
+ return undefined;
2552
+ }
2553
+ }
2554
+ /**
2555
+ * Validate a credential presented by the managed hook. Returns the matched
2556
+ * threadId when this runner issued the token; undefined otherwise. Token
2557
+ * records whose rollout file has disappeared are pruned lazily.
2558
+ */
2559
+ validateThreadCredential(threadId, token) {
2560
+ const expected = this.ensureThreadCredentialLoaded(threadId);
2561
+ if (!expected || expected !== token)
2562
+ return false;
2563
+ const rollout = this.findCodexSessionFile(threadId);
2564
+ if (!rollout) {
2565
+ // The thread's rollout is gone: nothing left to protect, drop the record.
2566
+ this.dropThreadCredential(threadId);
2567
+ return false;
2568
+ }
2569
+ return true;
2570
+ }
2571
+ ensureThreadCredentialLoaded(threadId) {
2572
+ const cached = this.threadCredentials.get(threadId);
2573
+ if (cached)
2574
+ return cached;
2575
+ const file = this.threadCredentialPath(threadId);
2576
+ if (!file)
2577
+ return undefined;
2578
+ try {
2579
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
2580
+ if (record && typeof record.token === 'string' && record.token && record.threadId === threadId) {
2581
+ this.threadCredentials.set(threadId, record.token);
2582
+ return record.token;
2583
+ }
2584
+ }
2585
+ catch { /* absent or unreadable */ }
2586
+ return undefined;
2587
+ }
2588
+ dropThreadCredential(threadId) {
2589
+ this.threadCredentials.delete(threadId);
2590
+ const file = this.threadCredentialPath(threadId);
2591
+ if (!file)
2592
+ return;
2593
+ try {
2594
+ fs.rmSync(file, { force: true });
2595
+ }
2596
+ catch { /* best effort */ }
2597
+ }
2108
2598
  async forkSession(agentSessionId, projectPath, title) {
2109
2599
  const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId), 'fork');
2110
2600
  try {
@@ -2119,7 +2609,8 @@ export class CodexRunner {
2119
2609
  const mode = this.chatModes.get(sessionKey) ?? this.currentMode;
2120
2610
  const executionSandbox = this.buildExecutionSandboxOptions(sessionKey, mode, projectPath);
2121
2611
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
2122
- const appServer = this.getAppServerClient();
2612
+ const appServer = this.threadSubscriptions.get(agentSessionId)?.client
2613
+ ?? this.getAppServerClient(mode === 'fullaccess');
2123
2614
  const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, mode);
2124
2615
  if (sessionKey)
2125
2616
  await this.assertProactiveHookAvailability(sessionKey, appServer);
@@ -2135,9 +2626,11 @@ export class CodexRunner {
2135
2626
  const forkedThreadId = response.thread?.id;
2136
2627
  if (!forkedThreadId)
2137
2628
  throw new Error('Codex fork did not return a thread id');
2629
+ this.markThreadSubscribed(appServer, forkedThreadId);
2138
2630
  this.threadDelegationCarriers.set(forkedThreadId, delegationCarrier);
2139
2631
  this.threadProjectPaths.set(forkedThreadId, path.resolve(projectPath));
2140
2632
  this.bindExternalToolBoundary(forkedThreadId, externalToolConfig);
2633
+ await this.releaseThreadSubscription(appServer, forkedThreadId, 'fork-finished');
2141
2634
  return forkedThreadId;
2142
2635
  }
2143
2636
  async forkSessionAt(agentSessionId, projectPath, assistantMessageId, title) {
@@ -2167,14 +2660,14 @@ export class CodexRunner {
2167
2660
  }
2168
2661
  }
2169
2662
  async setSessionName(agentSessionId, name) {
2170
- return this.getAppServerClient().threadSetName(agentSessionId, name);
2663
+ return this.getAppServerClientForThread(agentSessionId).threadSetName(agentSessionId, name);
2171
2664
  }
2172
2665
  async updateSessionMetadata(agentSessionId, metadata) {
2173
2666
  const gitInfo = metadata?.gitInfo && typeof metadata.gitInfo === 'object' ? metadata.gitInfo : undefined;
2174
- return this.getAppServerClient().threadMetadataUpdate(agentSessionId, gitInfo);
2667
+ return this.getAppServerClientForThread(agentSessionId).threadMetadataUpdate(agentSessionId, gitInfo);
2175
2668
  }
2176
2669
  async getSessionMessages(agentSessionId, projectPath) {
2177
- const response = await this.getAppServerClient().threadRead(agentSessionId, true);
2670
+ const response = await this.getAppServerClientForThread(agentSessionId).threadRead(agentSessionId, true);
2178
2671
  return this.mapThreadToSessionMessages(response, agentSessionId);
2179
2672
  }
2180
2673
  approvalItemKey(threadId, turnId, itemId) {
@@ -2390,6 +2883,31 @@ export class CodexRunner {
2390
2883
  ? 'FileChange'
2391
2884
  : 'Bash';
2392
2885
  const toolInput = this.buildPermissionInput(request.method, params);
2886
+ const approvalCallId = typeof (params.itemId ?? params.item_id ?? params.callId ?? params.call_id) === 'string'
2887
+ ? String(params.itemId ?? params.item_id ?? params.callId ?? params.call_id)
2888
+ : undefined;
2889
+ const approvalRequestId = request.id !== undefined
2890
+ ? String(request.id)
2891
+ : approvalCallId ?? `codex-approval-${crypto.randomUUID()}`;
2892
+ this.approvalLifecycleIds.set(request, approvalRequestId);
2893
+ const approvalContext = this.permissionContexts.get(sessionKey);
2894
+ auditPermissionLifecycle({
2895
+ phase: 'request',
2896
+ requestId: approvalRequestId,
2897
+ callId: approvalCallId,
2898
+ correlationId: approvalCallId,
2899
+ sessionId: sessionKey,
2900
+ agentAid: approvalContext?.selfAid,
2901
+ agentName: approvalContext?.agentName,
2902
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
2903
+ toolName,
2904
+ decision: 'pending',
2905
+ decisionSource: 'approval',
2906
+ role: approvalContext?.role,
2907
+ taskId: approvalContext?.taskId,
2908
+ summary: summarizeToolInputForAudit(toolName, toolInput).slice(0, 512),
2909
+ approvalMethod: request.method,
2910
+ });
2393
2911
  // A trusted fullaccess run has already been explicitly authorized by the
2394
2912
  // daemon owner. Do not route its approvals through ordinary capability,
2395
2913
  // workspace, or unattended-trigger policy checks.
@@ -2406,6 +2924,10 @@ export class CodexRunner {
2406
2924
  setImmediate(() => this.steerPolicyReason(sessionKey, 'fullaccess_context_untrusted', reason));
2407
2925
  return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2408
2926
  }
2927
+ // Fullaccess uses the current task delegation token in its shell
2928
+ // environment, so it must not enter the ordinary workspace/carrier
2929
+ // approval path even if the app-server asks for an infrastructure
2930
+ // approval despite approvalPolicy=never.
2409
2931
  const response = this.toAppServerApprovalResponse(request.method, 'allow', toolInput, false);
2410
2932
  this.logAppServerApprovalAudit(request, sessionKey, toolName, 'allow', 'approval', undefined, 'trusted fullaccess execution');
2411
2933
  return response;
@@ -2413,7 +2935,9 @@ export class CodexRunner {
2413
2935
  // Keep the policy check in the approval path as a fail-closed fallback.
2414
2936
  // Some app-server tool routes can reach approval without invoking the
2415
2937
  // command hook; allowing here would bypass the proactive gate.
2416
- const policyResult = this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
2938
+ const policyResult = (this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess'
2939
+ ? undefined
2940
+ : this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
2417
2941
  if (policyResult?.block) {
2418
2942
  logger.info(`[CodexRunner] app-server approval declined by session policy: session=${sessionKey} ` +
2419
2943
  `requestId=${request.id ?? '<missing>'} code=${policyResult.policyCode ?? 'session_policy_hook'} ` +
@@ -2439,9 +2963,33 @@ export class CodexRunner {
2439
2963
  }
2440
2964
  const summary = this.summarizeAppServerRequest(request.method, params);
2441
2965
  const reason = params.reason || params.decisionReason || undefined;
2966
+ const auditSummary = (summarizeToolInputForAudit(toolName, toolInput)
2967
+ || summarizeToolInputForAudit('Bash', { command: summary })).slice(0, 512);
2968
+ const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
2442
2969
  const workspacePath = this.resolvePermissionWorkspacePath(params);
2443
2970
  if (!workspacePath) {
2444
2971
  logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
2972
+ try {
2973
+ await permissionContext?.recordExecutionAnomaly?.({
2974
+ code: 'operation_blocked',
2975
+ severity: 'warning',
2976
+ phase: 'execution',
2977
+ occurredAt: Date.now(),
2978
+ toolName,
2979
+ ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
2980
+ policyCode: 'approval_workspace_unknown',
2981
+ decisionSource: 'infrastructure',
2982
+ agentAid: permissionContext?.selfAid,
2983
+ agentName: permissionContext?.agentName,
2984
+ sessionId: sessionKey,
2985
+ permissionMode: normalizeExecutionPermissionMode(sessionMode),
2986
+ summary: auditSummary,
2987
+ effect: 'operation_skipped',
2988
+ });
2989
+ }
2990
+ catch (error) {
2991
+ logger.warn(`[CodexRunner] failed to record unknown-workspace approval denial: session=${sessionKey} error=${error instanceof Error ? error.message : String(error)}`);
2992
+ }
2445
2993
  const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2446
2994
  this.rememberToolDenial(request, params, {
2447
2995
  decision: 'deny',
@@ -2454,8 +3002,7 @@ export class CodexRunner {
2454
3002
  return response;
2455
3003
  }
2456
3004
  const operationCwd = this.resolvePermissionOperationCwd(params, workspacePath, toolName);
2457
- const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
2458
- const logSummary = formatApprovalLogSummary(summary);
3005
+ const logSummary = formatApprovalLogSummary(auditSummary || summary);
2459
3006
  logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${logSummary}`);
2460
3007
  const isCommandApproval = request.method === 'item/commandExecution/requestApproval'
2461
3008
  || request.method === 'execCommandApproval';
@@ -2567,6 +3114,16 @@ export class CodexRunner {
2567
3114
  const params = (request.params || {});
2568
3115
  const rawCallId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
2569
3116
  const callId = typeof rawCallId === 'string' && rawCallId ? rawCallId : undefined;
3117
+ const toolInput = this.buildPermissionInput(request.method, params);
3118
+ const rawSummary = summarizeToolInputForAudit(toolName, toolInput);
3119
+ const auditSummary = (rawSummary
3120
+ || summarizeToolInputForAudit('Bash', { command: this.summarizeAppServerRequest(request.method, params) })).slice(0, 512);
3121
+ const threadId = this.requestThreadId(params);
3122
+ const rawCwd = typeof toolInput.cwd === 'string' && toolInput.cwd
3123
+ ? toolInput.cwd
3124
+ : typeof params.cwd === 'string' && params.cwd
3125
+ ? params.cwd
3126
+ : undefined;
2570
3127
  auditCodexApprovalDecision({
2571
3128
  requestId: request.id !== undefined ? String(request.id) : undefined,
2572
3129
  callId,
@@ -2581,9 +3138,56 @@ export class CodexRunner {
2581
3138
  policyCode,
2582
3139
  permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
2583
3140
  role: context?.role,
3141
+ taskId: context?.taskId,
3142
+ summary: auditSummary,
3143
+ threadId,
3144
+ cwd: rawCwd,
2584
3145
  reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
2585
3146
  parseFailure,
2586
3147
  });
3148
+ const lifecycleRequestId = request.id !== undefined
3149
+ ? String(request.id)
3150
+ : this.approvalLifecycleIds.get(request)
3151
+ ?? callId
3152
+ ?? `codex-approval-${crypto.randomUUID()}`;
3153
+ auditPermissionLifecycle({
3154
+ phase: 'response',
3155
+ requestId: lifecycleRequestId,
3156
+ callId,
3157
+ correlationId: callId,
3158
+ sessionId: sessionKey,
3159
+ agentAid: context?.selfAid,
3160
+ agentName: context?.agentName,
3161
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
3162
+ toolName,
3163
+ decision,
3164
+ decisionSource,
3165
+ policyCode,
3166
+ reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
3167
+ role: context?.role,
3168
+ taskId: context?.taskId,
3169
+ summary: auditSummary,
3170
+ approvalMethod: request.method,
3171
+ });
3172
+ auditPermissionLifecycle({
3173
+ phase: 'final',
3174
+ requestId: lifecycleRequestId,
3175
+ callId,
3176
+ correlationId: callId,
3177
+ sessionId: sessionKey,
3178
+ agentAid: context?.selfAid,
3179
+ agentName: context?.agentName,
3180
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
3181
+ toolName,
3182
+ decision,
3183
+ decisionSource,
3184
+ policyCode,
3185
+ reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
3186
+ role: context?.role,
3187
+ taskId: context?.taskId,
3188
+ summary: auditSummary,
3189
+ approvalMethod: request.method,
3190
+ });
2587
3191
  logger.info(`[CodexRunner] app-server approval audit ${JSON.stringify({
2588
3192
  requestId: request.id !== undefined ? String(request.id) : undefined,
2589
3193
  ...(callId ? { callId, correlationId: callId } : {}),
@@ -2594,6 +3198,7 @@ export class CodexRunner {
2594
3198
  decisionSource,
2595
3199
  ...(policyCode ? { policyCode } : {}),
2596
3200
  ...(parseFailure ? { parseFailure } : {}),
3201
+ ...(auditSummary ? { summary: auditSummary } : {}),
2597
3202
  reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
2598
3203
  executed: false,
2599
3204
  })}`);
@@ -3364,7 +3969,7 @@ export class CodexRunner {
3364
3969
  async rollbackSessionTurnsLocked(agentSessionId, numTurns) {
3365
3970
  if (numTurns < 1)
3366
3971
  return true;
3367
- const response = await this.getAppServerClient().threadRollback(agentSessionId, numTurns);
3972
+ const response = await this.getAppServerClientForThread(agentSessionId).threadRollback(agentSessionId, numTurns);
3368
3973
  return !!response.thread;
3369
3974
  }
3370
3975
  async rewindFiles(agentSessionId, projectPath, userMessageId) {
@@ -3611,7 +4216,7 @@ export class CodexRunner {
3611
4216
  this.unregisterChildThread(childThreadId);
3612
4217
  }
3613
4218
  }
3614
- trackSubagentLifecycle(notification, state) {
4219
+ trackSubagentLifecycle(notification, state, appServer) {
3615
4220
  const params = (notification.params || {});
3616
4221
  const item = params.item && typeof params.item === 'object' ? params.item : undefined;
3617
4222
  if (!this.isSubagentActivityItem(item))
@@ -3623,13 +4228,17 @@ export class CodexRunner {
3623
4228
  if (kind === 'started') {
3624
4229
  state.activeSubagentThreads.add(childThreadId);
3625
4230
  this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
4231
+ if (appServer)
4232
+ this.markThreadSubscribed(appServer, childThreadId);
3626
4233
  }
3627
4234
  if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3628
4235
  state.activeSubagentThreads.delete(childThreadId);
4236
+ if (appServer)
4237
+ void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
3629
4238
  this.unregisterChildThread(childThreadId);
3630
4239
  }
3631
4240
  }
3632
- primeSubagentStateFromTurn(turn, state) {
4241
+ primeSubagentStateFromTurn(turn, state, appServer) {
3633
4242
  for (const item of this.getTurnItems(turn)) {
3634
4243
  if (!this.isSubagentActivityItem(item))
3635
4244
  continue;
@@ -3640,9 +4249,13 @@ export class CodexRunner {
3640
4249
  if (kind === 'started') {
3641
4250
  state.activeSubagentThreads.add(childThreadId);
3642
4251
  this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
4252
+ if (appServer)
4253
+ this.markThreadSubscribed(appServer, childThreadId);
3643
4254
  }
3644
4255
  else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3645
4256
  state.activeSubagentThreads.delete(childThreadId);
4257
+ if (appServer)
4258
+ void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
3646
4259
  this.unregisterChildThread(childThreadId);
3647
4260
  }
3648
4261
  }
@@ -3657,7 +4270,7 @@ export class CodexRunner {
3657
4270
  }
3658
4271
  queue.end();
3659
4272
  }
3660
- async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, tempFiles) {
4273
+ async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, appServer, tempFiles) {
3661
4274
  try {
3662
4275
  let sessionIdEmitted = state.committed;
3663
4276
  if (sessionIdEmitted)
@@ -3665,27 +4278,39 @@ export class CodexRunner {
3665
4278
  for await (const notification of notifications) {
3666
4279
  if (!this.activeAbortControllers.has(sessionId))
3667
4280
  break;
3668
- if (!state.committed && notification.method === 'turn/completed') {
3669
- const turn = notification.params?.turn ?? {};
3670
- if (turn.status === 'completed') {
3671
- const committed = await state.commitProvisional?.();
3672
- if (!committed) {
3673
- const staleError = new Error('Codex provisional thread was superseded before commit');
3674
- staleError.code = 'RUNNER_START_SUPERSEDED';
3675
- throw staleError;
3676
- }
3677
- state.committed = true;
3678
- if (!sessionIdEmitted) {
3679
- sessionIdEmitted = true;
3680
- yield { type: 'session_id', sessionId: state.threadId };
3681
- }
4281
+ if (!state.committed && !state.persistenceFinalized) {
4282
+ const terminal = notification.method === 'turn/completed';
4283
+ const committed = await state.confirmProvisional?.(terminal ? 250 : 0) ?? false;
4284
+ if (committed && !sessionIdEmitted) {
4285
+ sessionIdEmitted = true;
4286
+ yield { type: 'session_id', sessionId: state.threadId };
4287
+ }
4288
+ else if (terminal && !committed) {
4289
+ state.persistenceFinalized = true;
4290
+ const persistenceError = new Error(`Codex turn completed before its rollout became recoverable: thread=${state.threadId} turn=${state.turnId ?? '<unknown>'}`);
4291
+ persistenceError.code = 'CODEX_TURN_PERSISTENCE_UNCONFIRMED';
4292
+ throw persistenceError;
3682
4293
  }
3683
4294
  }
3684
4295
  yield* this.mapAppServerNotification(notification, sessionId, state);
3685
4296
  }
3686
4297
  }
3687
4298
  finally {
4299
+ if (!state.committed && !state.persistenceFinalized && state.confirmProvisional) {
4300
+ try {
4301
+ // Do not let an interrupt wait for persistence that did not exist at
4302
+ // cancellation time. A zero-wait check still captures a rollout
4303
+ // that was already durable before the stream was closed.
4304
+ await state.confirmProvisional(0);
4305
+ }
4306
+ catch (error) {
4307
+ logger.debug(`[CodexRunner] provisional thread rejected during final persistence check: `
4308
+ + `session=${sessionId} thread=${state.threadId} error=${error instanceof Error ? error.message : String(error)}`);
4309
+ }
4310
+ state.persistenceFinalized = true;
4311
+ }
3688
4312
  unsubscribe();
4313
+ await this.releaseThreadSubscription(appServer, state.threadId, 'turn-finished');
3689
4314
  if (this.activeAbortControllers.get(sessionId) === controller) {
3690
4315
  this.activeAbortControllers.delete(sessionId);
3691
4316
  }
@@ -3730,6 +4355,15 @@ export class CodexRunner {
3730
4355
  }
3731
4356
  break;
3732
4357
  }
4358
+ // Reasoning notifications are provider-internal liveness signals.
4359
+ // Do not expose their content; the ResponseEngine consumes the heartbeat
4360
+ // solely to reset the idle monitor.
4361
+ case 'item/reasoning/textDelta':
4362
+ case 'item/reasoning/summaryTextDelta':
4363
+ case 'item/reasoning/summaryPartAdded': {
4364
+ yield { type: 'heartbeat', source: 'reasoning' };
4365
+ break;
4366
+ }
3733
4367
  case 'item/completed': {
3734
4368
  const item = params.item;
3735
4369
  if (item?.id)
@@ -4266,10 +4900,10 @@ export class CodexRunner {
4266
4900
  if (!stat.isDirectory() || stat.isSymbolicLink()) {
4267
4901
  throw new Error(`managed temp path is not a real directory: ${directory}`);
4268
4902
  }
4269
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
4903
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
4270
4904
  throw new Error(`managed temp path is owned by another uid: ${directory}`);
4271
4905
  }
4272
- if ((stat.mode & 0o077) !== 0)
4906
+ if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0)
4273
4907
  fs.chmodSync(directory, 0o700);
4274
4908
  }
4275
4909
  ensureSessionTempDir(sessionId) {
@@ -4341,12 +4975,17 @@ export class CodexRunner {
4341
4975
  }
4342
4976
  }
4343
4977
  managedTempInstruction(sessionId) {
4344
- if (!this.ensureSessionTempDir(sessionId))
4978
+ const managedTempDir = this.ensureSessionTempDir(sessionId);
4979
+ if (!managedTempDir)
4345
4980
  return undefined;
4346
- return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 `/tmp/evolcore-codex-*` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 `$TMPDIR` 重新创建,再继续执行后续 `ec` 命令。';
4981
+ return `临时文件必须使用环境变量 \`$TMPDIR\`。当前值是 \`${managedTempDir}\`,它是当前 EvolCore 会话唯一允许的临时工作区;不要直接使用 \`/tmp\`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 \`/tmp/evolcore-codex-*\` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 \`$TMPDIR\` 重新创建,再继续执行后续 \`ec\` 命令。`;
4347
4982
  }
4348
- buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier) {
4983
+ buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier, taskDelegationToken, threadId) {
4349
4984
  const managedTempDir = this.ensureSessionTempDir(sessionId);
4985
+ // The managed-hook credential rides the thread's shell environment so the
4986
+ // PreToolUse hook can prove the call belongs to an EvolCore-owned thread.
4987
+ // It follows the thread (start AND resume) rather than the daemon process.
4988
+ const sessionToken = threadId ? this.ensureThreadCredential(threadId) : undefined;
4350
4989
  return {
4351
4990
  shell_environment_policy: {
4352
4991
  set: {
@@ -4354,7 +4993,10 @@ export class CodexRunner {
4354
4993
  EVOLCORE_HOME: resolveRoot(),
4355
4994
  ...(managedTempDir ? { EVOLCORE_SESSION_RUNTIME_DIR: managedTempDir } : {}),
4356
4995
  ...(managedTempDir ? { EVOLCORE_RUNTIME_LOCK_DIR: path.join(path.dirname(managedTempDir), 'evolcore-locks') } : {}),
4357
- ...(delegationCarrier ? { [AGENT_DELEGATION_TOKEN_ENV]: delegationCarrier } : {}),
4996
+ ...(taskDelegationToken || delegationCarrier
4997
+ ? { [AGENT_DELEGATION_TOKEN_ENV]: taskDelegationToken ?? delegationCarrier }
4998
+ : {}),
4999
+ ...(sessionToken ? { [CODEX_SESSION_TOKEN_ENV]: sessionToken } : {}),
4358
5000
  ...(managedTempDir ? { TMPDIR: managedTempDir } : {}),
4359
5001
  },
4360
5002
  },
@@ -4384,6 +5026,8 @@ export class CodexRunner {
4384
5026
  controller.abort('dispose');
4385
5027
  }
4386
5028
  await Promise.all([...this.activeSubagentTurns.entries()].map(([threadId, active]) => this.interruptAppServerTurn(threadId, active.turnId).catch(() => { })));
5029
+ await Promise.all([...this.threadSubscriptions.entries()].map(([threadId, subscription]) => this.releaseThreadSubscription(subscription.client, threadId, 'runner-dispose')));
5030
+ this.threadSubscriptions.clear();
4387
5031
  this.activeAbortControllers.clear();
4388
5032
  this.activeStreams.clear();
4389
5033
  this.activeSessions.clear();
@@ -4404,8 +5048,13 @@ export class CodexRunner {
4404
5048
  this.threadOperationLocks.clear();
4405
5049
  for (const sessionId of [...this.sessionTempDirs.keys()])
4406
5050
  this.cleanupSessionTempDir(sessionId);
4407
- await this.appServerClient?.close();
5051
+ await Promise.all([
5052
+ this.appServerClient?.close(),
5053
+ this.fullAccessAppServerClient?.close(),
5054
+ ]);
5055
+ this.codexSessionFileAdapter.close();
4408
5056
  this.appServerClient = null;
5057
+ this.fullAccessAppServerClient = null;
4409
5058
  }
4410
5059
  }
4411
5060
  // ── Plugin ──
@@ -4414,8 +5063,6 @@ export class CodexAgentPlugin {
4414
5063
  isEnabled(agent) {
4415
5064
  if (!agent.config.baseagents?.codex)
4416
5065
  return false;
4417
- if (!isCodexAppServerAvailable())
4418
- return false;
4419
5066
  try {
4420
5067
  const override = agent.config.baseagents.codex;
4421
5068
  const syntheticConfig = { agents: { codex: override } };
@@ -4426,8 +5073,8 @@ export class CodexAgentPlugin {
4426
5073
  return false;
4427
5074
  }
4428
5075
  }
4429
- createAgent(agent, callbacks) {
4430
- const availability = getCodexAppServerAvailability();
5076
+ async createAgent(agent, callbacks) {
5077
+ const availability = await getCodexAppServerAvailabilityAsync();
4431
5078
  if (!availability.available) {
4432
5079
  throw new Error(availability.reason || 'Missing codex CLI with app-server');
4433
5080
  }