evolcore 0.0.21 → 0.0.23

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 (112) hide show
  1. package/AGENTS.md +62 -0
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +2 -0
  4. package/bin/codex-managed-acl.ps1 +155 -0
  5. package/bin/codex-managed-hook.mjs +3 -0
  6. package/bin/install-codex-managed-hooks.mjs +376 -96
  7. package/dist/agents/baseagent-availability.js +48 -0
  8. package/dist/agents/baseagent.js +1 -1
  9. package/dist/agents/claude-runner.js +44 -226
  10. package/dist/agents/{codex-app-server-client.js → codex/app-server-client.js} +103 -17
  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 +154 -0
  14. package/dist/agents/codex-runner.js +1292 -219
  15. package/dist/agents/ecagent-runner.js +10 -31
  16. package/dist/agents/gemini-runner.js +2 -6
  17. package/dist/aun/aid/store.js +2 -0
  18. package/dist/aun/menu-client.js +272 -0
  19. package/dist/aun/msg/group.js +65 -4
  20. package/dist/aun/msg/index.js +1 -1
  21. package/dist/aun/msg/managed-operation.js +51 -4
  22. package/dist/aun/outbox.js +7 -0
  23. package/dist/aun/rpc/client.js +8 -1
  24. package/dist/channels/aun.js +240 -36
  25. package/dist/channels/wechat.js +81 -10
  26. package/dist/cli/aun-commands.js +48 -4
  27. package/dist/cli/command-log.js +21 -2
  28. package/dist/cli/daemon-commands.js +169 -51
  29. package/dist/cli/index.js +20 -0
  30. package/dist/cli/init.js +2 -10
  31. package/dist/cli/menu-client.js +95 -0
  32. package/dist/cli/model.js +23 -8
  33. package/dist/cli/task-context.js +16 -2
  34. package/dist/cli/trigger-command.js +22 -5
  35. package/dist/config/config-field-policy.js +3 -0
  36. package/dist/config/config-manager.js +32 -5
  37. package/dist/config/contact-book-store.js +25 -3
  38. package/dist/config/schema-migration.js +14 -2
  39. package/dist/core/auth/agent-delegation.js +15 -0
  40. package/dist/core/auth/auth-gateway.js +8 -0
  41. package/dist/core/auth/authorization-audit.js +70 -6
  42. package/dist/core/auth/operation-catalog.js +4 -0
  43. package/dist/core/auth/remote-access-store.js +135 -0
  44. package/dist/core/bootstrap-messages.js +8 -0
  45. package/dist/core/bootstrap-service.js +93 -25
  46. package/dist/core/command/command-handler.js +62 -5
  47. package/dist/core/command/daemon-diagnostics.js +448 -0
  48. package/dist/core/command/menu-catalog.js +3 -0
  49. package/dist/core/command/menu-handler.js +150 -33
  50. package/dist/core/command/menu-protocol.js +120 -1
  51. package/dist/core/command/slash-gate.js +3 -3
  52. package/dist/core/command/slash-handler.js +83 -40
  53. package/dist/core/data-migration.js +11 -1
  54. package/dist/core/event-catalog.js +32 -0
  55. package/dist/core/handoff/runtime.js +23 -3
  56. package/dist/core/message/im-renderer.js +7 -3
  57. package/dist/core/message/message-bridge.js +60 -2
  58. package/dist/core/message/message-log.js +33 -0
  59. package/dist/core/message/message-queue.js +41 -15
  60. package/dist/core/message/response-engine.js +246 -114
  61. package/dist/core/model/catalog-service.js +308 -0
  62. package/dist/core/model/model-catalog.js +124 -334
  63. package/dist/core/model/model-diagnostics.js +4 -5
  64. package/dist/core/model/model-fallback.js +9 -16
  65. package/dist/core/model/model-permission.js +1 -1
  66. package/dist/core/permission/approval-gateway.js +16 -1
  67. package/dist/core/permission/ec-command-parser.js +272 -70
  68. package/dist/core/permission/protected-paths.js +74 -14
  69. package/dist/core/permission/sandbox-runtime.js +4 -2
  70. package/dist/core/permission/tool-error-code.js +12 -0
  71. package/dist/core/permission/tool-policy.js +290 -30
  72. package/dist/core/runtime-lock.js +13 -8
  73. package/dist/core/session/session-manager.js +30 -0
  74. package/dist/core/session/session-renew.js +18 -1
  75. package/dist/core/session/session-turn-coordinator.js +5 -1
  76. package/dist/index.js +104 -34
  77. package/dist/ipc.js +218 -96
  78. package/dist/paths.js +18 -0
  79. package/dist/response-system/engines/v1/proactive-flow.js +7 -2
  80. package/dist/stats/price-resolver.js +4 -0
  81. package/dist/trigger/feedback.js +14 -2
  82. package/dist/trigger/manager.js +3 -1
  83. package/dist/trigger/parser.js +86 -13
  84. package/dist/trigger/patch.js +104 -1
  85. package/dist/trigger/scheduler.js +20 -3
  86. package/dist/utils/ecweb-supervisor.js +11 -2
  87. package/dist/utils/ecweb-utils.js +8 -26
  88. package/dist/utils/error-utils.js +7 -0
  89. package/dist/utils/logger.js +9 -4
  90. package/dist/utils/npm-ops.js +47 -15
  91. package/dist/utils/tool-summary.js +59 -0
  92. package/dist/utils/windows-shell-trust.js +201 -0
  93. package/kits/docs/evolcore/INDEX.md +3 -2
  94. package/kits/docs/evolcore/agent-create.md +159 -0
  95. package/kits/docs/evolcore/agent.md +6 -0
  96. package/kits/docs/evolcore/group-collaboration.md +256 -0
  97. package/kits/docs/evolcore/group-rules.md +1 -19
  98. package/kits/docs/evolcore/group.md +3 -1
  99. package/kits/docs/evolcore/menu-client.md +34 -0
  100. package/kits/docs/evolcore/trigger.md +6 -3
  101. package/kits/docs/prompt-loading-architecture.md +6 -0
  102. package/kits/eck_message_manifest.json +6 -6
  103. package/kits/migrations/migrate-contact-book-v2.mjs +9 -1
  104. package/kits/migrations/migrate-role-config-v4.mjs +11 -5
  105. package/kits/migrations/migrate-role-config-v5.mjs +8 -2
  106. package/kits/schemas/_meta.json +3 -2
  107. package/kits/schemas/agent-config.schema.12.json +427 -0
  108. package/kits/templates/message-fragments/item.md +1 -1
  109. package/kits/templates/roles/visitor.json +1 -0
  110. package/kits/templates/system-fragments/bootstrap.md +10 -2
  111. package/kits/templates/system-fragments/commands.md +2 -2
  112. package/package.json +11 -3
@@ -1,16 +1,10 @@
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';
7
+ import { CODEX_THREAD_ACTIVE_WRITER, CodexAppServerClient, } from './codex/app-server-client.js';
14
8
  import { resolveOpenaiConfig } from './baseagent.js';
15
9
  import { logger } from '../utils/logger.js';
16
10
  import { summarizeToolInputForAudit } from '../utils/tool-summary.js';
@@ -21,7 +15,7 @@ import { buildEnvelope, isInteractionSendAccepted, sendInteractionPayload } from
21
15
  import { resolveCodexCapabilityThreadConfigForProject } from '../core/capability/capability-manager.js';
22
16
  import { AGENT_DELEGATION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/auth/agent-delegation.js';
23
17
  import { sanitizeShellExecutionEnvironment } from '../core/permission/shell-environment.js';
24
- import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, hasCodexCmdCarrierEcIntent, parseCodexToolCommand, parseLiteralShellCommand, parseCodexShellCommandArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
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
20
  import { resolvePaths, resolveRoot } from '../paths.js';
27
21
  import { ensureProcessManagedTempDir, getManagedTaskTempDir } from '../cli/task-context.js';
@@ -47,6 +41,7 @@ export function formatApprovalLogSummary(summary) {
47
41
  return singleLine;
48
42
  return singleLine.slice(0, APPROVAL_LOG_SUMMARY_MAX_LENGTH - 3).trimEnd() + '...';
49
43
  }
44
+ // ── Codex 模型目录(动态获取,含 effort) ──
50
45
  class AsyncEventQueue {
51
46
  queue = [];
52
47
  done = false;
@@ -83,6 +78,18 @@ class AsyncEventQueue {
83
78
  }
84
79
  }
85
80
  }
81
+ function errorCodeForToolDenial(decisionSource, policyCode) {
82
+ if (decisionSource === 'policy')
83
+ return 'POLICY_DENIED';
84
+ if (decisionSource === 'approval')
85
+ return 'USER_DENIED';
86
+ if (policyCode && /delegation|carrier/i.test(policyCode))
87
+ return 'DELEGATION_FAILED';
88
+ if (policyCode && /argument|canonical|quote|shell/i.test(policyCode))
89
+ return 'INVALID_ARGUMENT';
90
+ return 'EXECUTION_FAILED';
91
+ }
92
+ const CODEX_THREAD_HANDOFF_TIMEOUT_MS = 2_000;
86
93
  const managedTempDirsForExitCleanup = new Map();
87
94
  let managedTempExitCleanupRegistered = false;
88
95
  function isTrustedManagedTempDirectory(directory, parent) {
@@ -98,9 +105,9 @@ function isTrustedManagedTempDirectory(directory, parent) {
98
105
  const stat = fs.lstatSync(candidate);
99
106
  if (!stat.isDirectory() || stat.isSymbolicLink())
100
107
  return false;
101
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
108
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid())
102
109
  return false;
103
- return (stat.mode & 0o077) === 0;
110
+ return process.platform === 'win32' || (stat.mode & 0o077) === 0;
104
111
  };
105
112
  if (!trustedDirectory(parentPath) || !trustedDirectory(directoryPath))
106
113
  return false;
@@ -150,15 +157,74 @@ function stableSecurityValue(value) {
150
157
  function externalToolConfigFingerprint(config) {
151
158
  return createHash('sha256').update(stableSecurityValue(config)).digest('hex');
152
159
  }
153
- const CODEX_CATALOG_FALLBACK = [
154
- { slug: 'gpt-5.5', efforts: ['low', 'medium', 'high', 'xhigh'] },
155
- { slug: 'gpt-5.4', efforts: ['low', 'medium', 'high', 'xhigh'] },
156
- { slug: 'gpt-5.4-mini', efforts: ['low', 'medium', 'high', 'xhigh'] },
157
- { slug: 'gpt-5.3-codex', efforts: ['low', 'medium', 'high', 'xhigh'] },
158
- { slug: 'gpt-5.2', efforts: ['low', 'medium', 'high', 'xhigh'] },
159
- ];
160
+ /**
161
+ * Codex can surface a managed hook rejection as item output instead of an
162
+ * app-server approval request. Preserve the policy boundary in that path too
163
+ * so downstream audit/event consumers never infer a successful execution.
164
+ */
165
+ function parseManagedPreToolUseDenial(value) {
166
+ let text;
167
+ if (typeof value === 'string')
168
+ text = value;
169
+ else if (value == null)
170
+ return undefined;
171
+ else {
172
+ try {
173
+ text = JSON.stringify(value) ?? '';
174
+ }
175
+ catch {
176
+ text = String(value);
177
+ }
178
+ }
179
+ const marker = /(?:Command|Tool call) blocked by PreToolUse hook\s*:\s*/i.exec(text);
180
+ if (!marker)
181
+ return undefined;
182
+ let reason = text.slice((marker.index ?? 0) + marker[0].length)
183
+ .replace(/\s*\.\s*(?:Command|Tool):[\s\S]*$/i, '')
184
+ .replace(/(?:^|\s)(?:Command|Tool):[\s\S]*$/i, '')
185
+ .trim();
186
+ if (!reason)
187
+ reason = 'EvolCore PreToolUse policy denied this tool call';
188
+ const policyCode = /工具调用已达到\s*\d+\s*次/.test(reason)
189
+ ? 'proactive_tool_report_required'
190
+ : /请先用\s+ec\s+(?:group\s+send|msg\s+send)/.test(reason)
191
+ ? 'proactive_first_send_required'
192
+ : 'managed_pre_tool_use_denied';
193
+ return { policyCode, reason };
194
+ }
195
+ function outputTextForManagedHook(value) {
196
+ if (typeof value === 'string')
197
+ return value;
198
+ if (value == null)
199
+ return '';
200
+ if (Array.isArray(value))
201
+ return value.map(outputTextForManagedHook).filter(Boolean).join('\n');
202
+ if (typeof value === 'object') {
203
+ const record = value;
204
+ if (typeof record.text === 'string')
205
+ return record.text;
206
+ if (typeof record.message === 'string')
207
+ return record.message;
208
+ try {
209
+ return JSON.stringify(value) ?? '';
210
+ }
211
+ catch {
212
+ return String(value);
213
+ }
214
+ }
215
+ return String(value);
216
+ }
160
217
  const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
161
- let codexCatalogCache = null;
218
+ const CODEX_NO_ROLLOUT_RE = /no rollout found for thread id/i;
219
+ const CODEX_ACTIVE_WRITER_RE = /already has an active writer/i;
220
+ function isCodexNoRolloutError(error) {
221
+ return error?.code === 'CODEX_ROLLOUT_NOT_FOUND'
222
+ || CODEX_NO_ROLLOUT_RE.test(error instanceof Error ? error.message : String(error));
223
+ }
224
+ function isCodexActiveWriterError(error) {
225
+ return error?.code === CODEX_THREAD_ACTIVE_WRITER
226
+ || CODEX_ACTIVE_WRITER_RE.test(error instanceof Error ? error.message : String(error));
227
+ }
162
228
  // The permission bridge is version-locked to the audited approval schema and
163
229
  // managed PreToolUse hook behavior used by proactive first-tool enforcement.
164
230
  export const MIN_CODEX_CLI_VERSION = '0.144.1';
@@ -213,37 +279,6 @@ export function getCodexAppServerAvailability() {
213
279
  export function isCodexAppServerAvailable() {
214
280
  return getCodexAppServerAvailability().available;
215
281
  }
216
- function fetchCodexCatalog() {
217
- if (codexCatalogCache)
218
- return codexCatalogCache;
219
- try {
220
- const output = execCodexCliSync(['debug', 'models'], {
221
- encoding: 'utf-8',
222
- timeout: 5000,
223
- stdio: ['pipe', 'pipe', 'pipe'],
224
- });
225
- const catalog = JSON.parse(output);
226
- const models = catalog.models
227
- .filter(m => m.visibility === 'list')
228
- .map(m => ({
229
- slug: m.slug,
230
- efforts: (m.supported_reasoning_levels || []).map(l => l.effort),
231
- }));
232
- if (models.length > 0) {
233
- codexCatalogCache = models;
234
- return models;
235
- }
236
- }
237
- catch (e) {
238
- logger.debug(`[CodexRunner] Failed to fetch model catalog, using fallback: ${e}`);
239
- }
240
- return CODEX_CATALOG_FALLBACK;
241
- }
242
- export function getCodexEfforts(model) {
243
- const catalog = fetchCodexCatalog();
244
- const entry = catalog.find(m => m.slug === model);
245
- return entry?.efforts ?? catalog[0]?.efforts ?? ['low', 'medium', 'high'];
246
- }
247
282
  // ── Codex Runner ──
248
283
  export class CodexRunner {
249
284
  name = 'codex';
@@ -256,18 +291,30 @@ export class CodexRunner {
256
291
  activeAbortControllers = new Map();
257
292
  activeStreams = new Map();
258
293
  activeSessions = new Map(); // sessionId → threadId
294
+ /** Threads created/resumed for a turn but not yet durably admitted by turn/start. */
295
+ provisionalSessions = new Map();
259
296
  activeTurns = new Map();
297
+ activeSubagentTurns = new Map();
260
298
  threadProjectPaths = new Map();
299
+ /** Child Codex threads inherit the EvolCore session that started them. */
300
+ childThreadSessions = new Map();
261
301
  sessionTempDirs = new Map();
262
302
  managedTempNonce = randomBytes(16).toString('hex');
263
303
  threadExternalToolFingerprints = new Map();
264
304
  threadExternalToolBoundaries = new Map();
265
305
  trackedApprovalItems = new Map();
306
+ pendingToolDenials = new Map();
266
307
  threadDelegationCarriers = new Map();
267
308
  pendingInterrupts = new Map();
309
+ steeringTails = new Map();
268
310
  threadOperationLocks = new Map();
269
311
  activeOperationReleases = new Map();
312
+ /** Last app-server client that loaded each thread, including unsubscribed threads. */
313
+ threadSubscriptions = new Map();
314
+ /** Ordinary sessions stay behind the EvolCore H/L filesystem guard. */
270
315
  appServerClient = null;
316
+ /** Trusted fullaccess calls use a separate unmasked app-server process. */
317
+ fullAccessAppServerClient = null;
271
318
  onSessionIdUpdate;
272
319
  onCompactStart;
273
320
  permissionGateway;
@@ -308,43 +355,228 @@ export class CodexRunner {
308
355
  this.effort = this.resolvedConfig.effort;
309
356
  this.onSessionIdUpdate = callbacks.onSessionIdUpdate;
310
357
  }
311
- getAppServerClient() {
312
- if (!this.appServerClient) {
313
- const client = new CodexAppServerClient({
314
- apiKey: this.resolvedConfig.apiKey,
315
- baseUrl: this.resolvedConfig.baseUrl,
316
- provider: this.resolvedConfig.provider,
317
- headers: this.resolvedConfig.headers,
318
- queryParams: this.resolvedConfig.queryParams,
319
- model: this.model,
320
- effort: this.effort,
321
- enableRequestUserInput: this.resolvedConfig.enableRequestUserInput,
322
- approvalsReviewer: 'user',
323
- onServerRequest: request => this.handleAppServerRequest(request),
324
- });
325
- client.onNotification(notification => this.trackApprovalItemNotification(notification));
358
+ getAppServerClient(fullAccess = false) {
359
+ const existing = fullAccess ? this.fullAccessAppServerClient : this.appServerClient;
360
+ if (existing)
361
+ return existing;
362
+ const client = new CodexAppServerClient({
363
+ apiKey: this.resolvedConfig.apiKey,
364
+ baseUrl: this.resolvedConfig.baseUrl,
365
+ provider: this.resolvedConfig.provider,
366
+ headers: this.resolvedConfig.headers,
367
+ queryParams: this.resolvedConfig.queryParams,
368
+ model: this.model,
369
+ effort: this.effort,
370
+ enableRequestUserInput: this.resolvedConfig.enableRequestUserInput,
371
+ approvalsReviewer: 'user',
372
+ protectEvolcoreFilesystem: !fullAccess,
373
+ // A completed turn is explicitly unsubscribed below. This is only an
374
+ // optimization on newer Codex versions; old versions retain the thread
375
+ // until the cross-client archive fallback runs.
376
+ onServerRequest: request => this.handleAppServerRequest(request),
377
+ });
378
+ client.onNotification(notification => {
379
+ this.trackApprovalItemNotification(notification);
380
+ if (notification.method !== 'thread/closed')
381
+ return;
382
+ const params = (notification.params || {});
383
+ const threadId = params.threadId ?? params.thread_id;
384
+ if (typeof threadId !== 'string')
385
+ return;
386
+ const subscription = this.threadSubscriptions.get(threadId);
387
+ if (subscription?.client === client) {
388
+ // Keep the last client identity so a later same-mode turn resumes on
389
+ // the same app-server even after the runtime has fully unloaded.
390
+ this.threadSubscriptions.set(threadId, { client, state: 'unsubscribed' });
391
+ }
392
+ });
393
+ if (fullAccess) {
394
+ this.fullAccessAppServerClient = client;
395
+ }
396
+ else {
326
397
  this.appServerClient = client;
327
398
  }
328
- return this.appServerClient;
399
+ return client;
400
+ }
401
+ getAppServerClientForThread(threadId) {
402
+ const subscribed = this.threadSubscriptions.get(threadId);
403
+ if (subscribed)
404
+ return subscribed.client;
405
+ const sessionId = this.findSessionKeyByThread(threadId);
406
+ return this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
407
+ }
408
+ markThreadSubscribed(client, threadId) {
409
+ const existing = this.threadSubscriptions.get(threadId);
410
+ if (existing?.client === client && existing.state === 'subscribed' && !existing.release)
411
+ return;
412
+ this.threadSubscriptions.set(threadId, { client, state: 'subscribed' });
413
+ }
414
+ /**
415
+ * Release only one thread from one app-server connection. Unsubscribe is
416
+ * intentionally not treated as an immediate unload: current Codex builds can
417
+ * keep an unsubscribed thread loaded for a long grace period. The owner therefore
418
+ * remains in the map until a different client successfully resumes it.
419
+ */
420
+ async releaseThreadSubscription(client, threadId, reason, options = {}) {
421
+ const existing = this.threadSubscriptions.get(threadId);
422
+ if (existing && existing.client !== client)
423
+ return false;
424
+ if (existing?.release)
425
+ return await existing.release.catch(error => {
426
+ logger.warn(`[CodexRunner] Existing thread release failed: thread=${threadId} `
427
+ + `error=${error instanceof Error ? error.message : String(error)}`);
428
+ return false;
429
+ });
430
+ if (existing?.state === 'unsubscribed')
431
+ return true;
432
+ let releasePromise;
433
+ releasePromise = Promise.resolve().then(async () => {
434
+ let released = false;
435
+ try {
436
+ const unsubscribe = client.threadUnsubscribe;
437
+ if (typeof unsubscribe === 'function') {
438
+ if (options.waitForClose && typeof client.threadUnsubscribeAndWait === 'function') {
439
+ try {
440
+ await client.threadUnsubscribeAndWait.call(client, threadId, options.timeoutMs ?? CODEX_THREAD_HANDOFF_TIMEOUT_MS);
441
+ }
442
+ catch (error) {
443
+ // The unsubscribe RPC has already succeeded when this is a
444
+ // close-timeout. Keep the owner marked unsubscribed and let the
445
+ // target resume decide whether the archive fallback is needed.
446
+ if (error?.code !== 'CODEX_THREAD_CLOSE_TIMEOUT')
447
+ throw error;
448
+ logger.debug(`[CodexRunner] thread/closed not observed before handoff timeout: `
449
+ + `thread=${threadId} reason=${reason}`);
450
+ }
451
+ }
452
+ else {
453
+ await unsubscribe.call(client, threadId);
454
+ }
455
+ }
456
+ logger.debug(`[CodexRunner] Released thread subscription: thread=${threadId} reason=${reason}`);
457
+ released = true;
458
+ return true;
459
+ }
460
+ catch (error) {
461
+ logger.warn(`[CodexRunner] Failed to release thread subscription: thread=${threadId} reason=${reason} `
462
+ + `error=${error instanceof Error ? error.message : String(error)}`);
463
+ return false;
464
+ }
465
+ finally {
466
+ const current = this.threadSubscriptions.get(threadId);
467
+ if (current?.client === client && current.release === releasePromise) {
468
+ if (released) {
469
+ this.threadSubscriptions.set(threadId, { client, state: 'unsubscribed' });
470
+ }
471
+ else {
472
+ this.threadSubscriptions.set(threadId, { client, state: current.state });
473
+ }
474
+ }
475
+ }
476
+ });
477
+ this.threadSubscriptions.set(threadId, {
478
+ client,
479
+ state: existing?.state ?? 'subscribed',
480
+ release: releasePromise,
481
+ });
482
+ return await releasePromise;
483
+ }
484
+ async prepareThreadSubscription(client, threadId) {
485
+ const existing = this.threadSubscriptions.get(threadId);
486
+ if (!existing)
487
+ return undefined;
488
+ if (existing.client === client) {
489
+ if (existing.release)
490
+ await existing.release.catch(() => undefined);
491
+ return undefined;
492
+ }
493
+ if (existing.state === 'subscribed') {
494
+ const released = await this.releaseThreadSubscription(existing.client, threadId, 'mode-handoff', {
495
+ waitForClose: true,
496
+ timeoutMs: CODEX_THREAD_HANDOFF_TIMEOUT_MS,
497
+ });
498
+ if (!released) {
499
+ const error = new Error(`Codex thread ${threadId} is still owned by another app-server connection`);
500
+ error.code = CODEX_THREAD_ACTIVE_WRITER;
501
+ throw error;
502
+ }
503
+ }
504
+ return existing.client;
505
+ }
506
+ async archiveThreadForHandoff(sourceClient, targetClient, threadId) {
507
+ const existing = this.threadSubscriptions.get(threadId);
508
+ if (existing && existing.client !== sourceClient) {
509
+ const error = new Error(`Codex thread ${threadId} ownership changed during handoff`);
510
+ error.code = CODEX_THREAD_ACTIVE_WRITER;
511
+ throw error;
512
+ }
513
+ const sessionId = this.findSessionKeyByThread(threadId);
514
+ if ([...this.activeSubagentTurns.values()].some(active => active.sessionId === sessionId && active.parentThreadId === threadId)) {
515
+ const error = new Error(`Codex thread ${threadId} has an active descendant turn`);
516
+ error.code = CODEX_THREAD_ACTIVE_WRITER;
517
+ throw error;
518
+ }
519
+ // Old app-server versions can acknowledge unsubscribe while retaining the
520
+ // writer. Archive is the only per-thread operation that synchronously
521
+ // tears down that runtime; the target client restores it before resume.
522
+ const archive = sourceClient.threadArchive;
523
+ const unarchive = targetClient.threadUnarchive;
524
+ if (typeof archive !== 'function' || typeof unarchive !== 'function') {
525
+ const error = new Error(`Codex thread ${threadId} requires archive/unarchive handoff support`);
526
+ error.code = CODEX_THREAD_ACTIVE_WRITER;
527
+ throw error;
528
+ }
529
+ let archived = false;
530
+ try {
531
+ await archive.call(sourceClient, threadId);
532
+ archived = true;
533
+ await unarchive.call(targetClient, threadId);
534
+ }
535
+ catch (error) {
536
+ if (archived) {
537
+ // Do not strand the user's rollout under archived_sessions when the
538
+ // target client fails midway through the compatibility handoff.
539
+ try {
540
+ await unarchive.call(sourceClient, threadId);
541
+ this.threadSubscriptions.set(threadId, { client: sourceClient, state: 'subscribed' });
542
+ }
543
+ catch (restoreError) {
544
+ logger.error(`[CodexRunner] Failed to restore archived thread after handoff failure: thread=${threadId} `
545
+ + `error=${restoreError instanceof Error ? restoreError.message : String(restoreError)}`);
546
+ }
547
+ }
548
+ logger.warn(`[CodexRunner] Archive handoff failed: thread=${threadId} `
549
+ + `error=${error instanceof Error ? error.message : String(error)}`);
550
+ const handoffError = new Error(`Codex thread ${threadId} is still owned by another app-server connection`);
551
+ handoffError.code = CODEX_THREAD_ACTIVE_WRITER;
552
+ throw handoffError;
553
+ }
554
+ this.threadSubscriptions.set(threadId, { client: targetClient, state: 'subscribed' });
329
555
  }
330
556
  createDelegationCarrier() {
331
557
  return randomBytes(32).toString('base64url');
332
558
  }
333
- approvedLiteralEvolcoreCommand(command, managedTempDir, dialect = 'posix') {
559
+ approvedLiteralEvolcoreCommand(command, managedTempDir, dialect = 'posix', expectedDelegationToken, executable) {
334
560
  const parseOptions = {
335
561
  allowManagedTmpDir: true,
336
562
  dialect,
563
+ verifyShellExecutable: process.platform === 'win32',
337
564
  ...(managedTempDir ? { managedTempDir } : {}),
565
+ ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
338
566
  };
339
567
  const classification = classifyEvolcoreShellCommand(command, parseOptions);
340
568
  if (classification.kind === 'bounded-output')
341
569
  return { argv: classification.command.argv };
342
570
  // Keep the approval/delegation parser aligned with the preflight parser:
343
571
  // the session-managed `$TMPDIR` token is a permitted EC path reference.
344
- const parsed = parseLiteralShellCommand(command, parseOptions);
572
+ const parsed = executable
573
+ ? parseCodexCarrierCommand({ command, dialect, executable }, parseOptions)
574
+ : dialect === 'powershell'
575
+ ? parseCodexCarrierCommand({ command, dialect }, parseOptions)
576
+ : parseLiteralShellCommand(command, parseOptions);
345
577
  if (!parsed.ok)
346
578
  return { parseFailure: parsed };
347
- if (parsed.argv[0] !== 'ec')
579
+ if (normalizeEvolcoreCommandArgv(parsed.argv)[0] !== 'ec')
348
580
  return {};
349
581
  if (containsLiteralManagedTmpDirOutsideSendContent(parsed.argv)) {
350
582
  return { parseFailure: {
@@ -355,14 +587,17 @@ export class CodexRunner {
355
587
  inputLength: command.length,
356
588
  } };
357
589
  }
358
- return { argv: parsed.argv };
590
+ return {
591
+ argv: normalizeEvolcoreCommandArgv(parsed.argv),
592
+ ...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
593
+ };
359
594
  }
360
- resolveCanonicalEcApproval(toolInput, managedTempDir) {
595
+ resolveCanonicalEcApproval(toolInput, managedTempDir, expectedDelegationToken) {
361
596
  const explicitArgv = Array.isArray(toolInput.commandArgv)
362
597
  && toolInput.commandArgv.every(value => typeof value === 'string')
363
598
  ? toolInput.commandArgv
364
599
  : undefined;
365
- if (explicitArgv?.[0] === 'ec') {
600
+ if (explicitArgv && normalizeEvolcoreCommandArgv(explicitArgv)[0] === 'ec') {
366
601
  if (explicitArgv.some(value => value.includes('\0'))) {
367
602
  return { parseFailure: {
368
603
  issue: 'invalid-control-char', offset: 0,
@@ -372,16 +607,19 @@ export class CodexRunner {
372
607
  }
373
608
  return containsLiteralManagedTmpDirOutsideSendContent(explicitArgv)
374
609
  ? { parseFailure: { issue: 'unsafe-expansion', offset: 0, tokenIndex: 0, dialect: 'posix', inputLength: 0 } }
375
- : { argv: explicitArgv };
610
+ : { argv: normalizeEvolcoreCommandArgv(explicitArgv) };
376
611
  }
377
612
  const explicitCarrier = explicitArgv
378
613
  ? resolveCodexShellCarrierArgv(explicitArgv)
379
614
  : undefined;
380
615
  if (explicitCarrier !== undefined) {
381
- return this.approvedLiteralEvolcoreCommand(explicitCarrier.command, managedTempDir, explicitCarrier.dialect);
616
+ return this.approvedLiteralEvolcoreCommand(explicitCarrier.command, managedTempDir, explicitCarrier.dialect, expectedDelegationToken, explicitCarrier.executable);
382
617
  }
383
618
  if (hasCodexCmdCarrierEcIntent(toolInput)) {
384
- const parsed = parseCodexToolCommand(toolInput);
619
+ const parsed = parseCodexToolCommand(toolInput, {
620
+ verifyShellExecutable: process.platform === 'win32',
621
+ ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
622
+ });
385
623
  if (!parsed.ok)
386
624
  return { parseFailure: parsed };
387
625
  if (parsed.argv[0] === 'ec') {
@@ -390,7 +628,10 @@ export class CodexRunner {
390
628
  issue: 'unsafe-expansion', offset: 0, tokenIndex: 0,
391
629
  dialect: 'cmd', inputLength: 0,
392
630
  } }
393
- : { argv: parsed.argv };
631
+ : {
632
+ argv: normalizeEvolcoreCommandArgv(parsed.argv),
633
+ ...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
634
+ };
394
635
  }
395
636
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
396
637
  const classification = classifyEvolcoreShellCommand(command, { dialect: 'cmd' });
@@ -407,7 +648,9 @@ export class CodexRunner {
407
648
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
408
649
  const canonicalCommand = parseCodexShellCommandArgv(command, {
409
650
  allowManagedTmpDir: true,
651
+ verifyShellExecutable: process.platform === 'win32',
410
652
  ...(managedTempDir ? { managedTempDir } : {}),
653
+ ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
411
654
  });
412
655
  if (canonicalCommand?.[0] === 'ec') {
413
656
  return containsLiteralManagedTmpDirOutsideSendContent(canonicalCommand)
@@ -436,11 +679,14 @@ export class CodexRunner {
436
679
  isManagedEvolcoreCommandIntent(toolInput) {
437
680
  if (Array.isArray(toolInput.commandArgv)) {
438
681
  const rawArgv = toolInput.commandArgv;
439
- if (rawArgv[0] === 'ec')
440
- return true;
441
682
  if (!rawArgv.every(value => typeof value === 'string'))
442
683
  return false;
684
+ if (normalizeEvolcoreCommandArgv(rawArgv)[0] === 'ec')
685
+ return true;
443
686
  const shellCarrier = resolveCodexShellCarrierArgv(rawArgv);
687
+ if (shellCarrier?.dialect === 'powershell'
688
+ && hasPowerShellDelegationAssignment(shellCarrier.command))
689
+ return true;
444
690
  return hasCodexCmdCarrierEcIntent(toolInput)
445
691
  || shellCarrier !== undefined
446
692
  && classifyEvolcoreShellCommand(shellCarrier.command, {
@@ -451,7 +697,7 @@ export class CodexRunner {
451
697
  return classifyEvolcoreShellCommand(command).kind !== 'none';
452
698
  }
453
699
  armApprovedDelegationCommand(sessionId, argv) {
454
- const threadId = this.activeSessions.get(sessionId);
700
+ const threadId = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
455
701
  const carrierToken = threadId ? this.threadDelegationCarriers.get(threadId) : undefined;
456
702
  const commandHash = hashDelegatedCommandArgv(argv);
457
703
  const arm = this.permissionContexts.get(sessionId)?.armApprovedDelegationCommand;
@@ -465,6 +711,21 @@ export class CodexRunner {
465
711
  return false;
466
712
  }
467
713
  }
714
+ delegationCarrierForSession(sessionId, threadId) {
715
+ const candidates = [
716
+ threadId,
717
+ this.activeSessions.get(sessionId),
718
+ this.provisionalSessions.get(sessionId),
719
+ ];
720
+ for (const candidate of candidates) {
721
+ if (!candidate)
722
+ continue;
723
+ const carrier = this.threadDelegationCarriers.get(candidate);
724
+ if (carrier)
725
+ return carrier;
726
+ }
727
+ return undefined;
728
+ }
468
729
  async readThreadDelegationCarrier(appServer, threadId) {
469
730
  try {
470
731
  const result = await appServer.threadShellCommand(threadId, `node -e "process.stdout.write(process.env.${AGENT_DELEGATION_TOKEN_ENV}||String())"`);
@@ -497,6 +758,49 @@ export class CodexRunner {
497
758
  return undefined;
498
759
  }
499
760
  }
761
+ async commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn) {
762
+ if (this.provisionalSessions.get(sessionId) !== threadId)
763
+ return false;
764
+ const bindingResult = this.onSessionIdUpdate
765
+ ? ((turn === undefined
766
+ ? await this.onSessionIdUpdate(sessionId, threadId)
767
+ : await this.onSessionIdUpdate(sessionId, threadId, { turn }))
768
+ ?? 'legacy_updated')
769
+ : 'legacy_updated';
770
+ const bindingAccepted = bindingResult === 'activated'
771
+ || bindingResult === 'already_active'
772
+ || bindingResult === 'legacy_updated';
773
+ if (!bindingAccepted) {
774
+ const error = new Error(`backend discovery rejected: ${bindingResult}`);
775
+ error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
776
+ throw error;
777
+ }
778
+ this.provisionalSessions.delete(sessionId);
779
+ this.activeSessions.set(sessionId, threadId);
780
+ if (carrierToPublish) {
781
+ this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
782
+ await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
783
+ }
784
+ this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
785
+ return true;
786
+ }
787
+ cleanupProvisionalThread(sessionId, threadId) {
788
+ if (this.provisionalSessions.get(sessionId) !== threadId)
789
+ return;
790
+ this.provisionalSessions.delete(sessionId);
791
+ this.threadProjectPaths.delete(threadId);
792
+ this.threadDelegationCarriers.delete(threadId);
793
+ this.unbindExternalToolBoundary(threadId);
794
+ this.clearTrackedApprovalItemsForThread(threadId);
795
+ // A stale provisional start can finish after a replacement run has
796
+ // already activated another parent thread for this session. Do not tear
797
+ // down the replacement run's child mappings in that race.
798
+ const activeParentThread = this.activeSessions.get(sessionId);
799
+ if (!activeParentThread || activeParentThread === threadId) {
800
+ this.cleanupChildThreadsForSession(sessionId);
801
+ }
802
+ this.pruneThreadOperationLocks();
803
+ }
500
804
  async persistDelegationCarrier(sessionManager, sessionId, threadId, carrier) {
501
805
  if (typeof sessionManager?.getSessionById !== 'function')
502
806
  return;
@@ -523,15 +827,28 @@ export class CodexRunner {
523
827
  }
524
828
  }
525
829
  resetAppServerClient() {
526
- const client = this.appServerClient;
830
+ const clients = [this.appServerClient, this.fullAccessAppServerClient]
831
+ .filter((client) => !!client);
527
832
  this.appServerClient = null;
833
+ this.fullAccessAppServerClient = null;
834
+ this.threadSubscriptions.clear();
528
835
  this.threadExternalToolFingerprints.clear();
529
836
  this.threadExternalToolBoundaries.clear();
837
+ for (const childThreadId of this.childThreadSessions.keys())
838
+ this.threadProjectPaths.delete(childThreadId);
839
+ // Child turn ids belong to the discarded app-server connection. Keeping
840
+ // them would make a later session interrupt attempt target stale threads
841
+ // through the replacement client.
842
+ this.activeSubagentTurns.clear();
843
+ this.childThreadSessions.clear();
530
844
  this.trackedApprovalItems.clear();
845
+ this.pendingToolDenials.clear();
531
846
  this.threadDelegationCarriers.clear();
532
- client?.close().catch(error => {
533
- logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
534
- });
847
+ for (const client of clients) {
848
+ client.close().catch(error => {
849
+ logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
850
+ });
851
+ }
535
852
  }
536
853
  mergeThreadConfig(...configs) {
537
854
  const merged = {};
@@ -779,19 +1096,16 @@ export class CodexRunner {
779
1096
  // ── ModelSwitcher ──
780
1097
  setModel(model) { this.model = model; this.resetAppServerClient(); }
781
1098
  getModel() { return this.model; }
1099
+ getModelCatalog() {
1100
+ return getRunnerCatalog('codex', this.resolvedConfig.agentAid, this.resolvedConfig);
1101
+ }
782
1102
  async listModels() {
783
- try {
784
- const response = await this.getAppServerClient().modelList(false);
785
- const ids = (response.data ?? [])
786
- .map(model => model.id || model.slug || model.name || model.model)
787
- .filter((id) => typeof id === 'string' && id.length > 0);
788
- if (ids.length > 0)
789
- return ids;
790
- }
791
- catch (error) {
792
- logger.debug(`[CodexRunner] app-server model/list failed, using catalog fallback: ${error}`);
793
- }
794
- return fetchCodexCatalog().map(m => m.slug);
1103
+ const catalog = await this.getModelCatalog();
1104
+ assertCatalogSelectable(catalog);
1105
+ return catalog.models.map(m => m.id);
1106
+ }
1107
+ async getAvailableEfforts(model) {
1108
+ return catalogEfforts(await this.getModelCatalog(), model, 'codex');
795
1109
  }
796
1110
  // ── Effort ──
797
1111
  setEffort(effort) { this.effort = effort; this.resetAppServerClient(); }
@@ -848,7 +1162,9 @@ export class CodexRunner {
848
1162
  if (!looksLikeGitMetadataDirectory(resolved))
849
1163
  return;
850
1164
  paths.add(resolved);
851
- paths.add(path.join(resolved, '**'));
1165
+ // Keep exact paths native, but Codex requires POSIX separators for
1166
+ // recursive filesystem globs even on Windows.
1167
+ paths.add(`${resolved}/**`);
852
1168
  };
853
1169
  try {
854
1170
  const stat = fs.statSync(marker);
@@ -862,7 +1178,7 @@ export class CodexRunner {
862
1178
  }
863
1179
  }
864
1180
  catch { }
865
- for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith(`${path.sep}**`))) {
1181
+ for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith('/**'))) {
866
1182
  try {
867
1183
  const commonDirectory = fs.readFileSync(path.join(gitDirectory, 'commondir'), 'utf8').trim();
868
1184
  if (commonDirectory)
@@ -900,9 +1216,9 @@ export class CodexRunner {
900
1216
  } : {}),
901
1217
  ...(mode !== 'readonly' && managedTempDir ? {
902
1218
  [managedTempDir]: 'write',
903
- [path.join(managedTempDir, '**')]: 'write',
1219
+ [managedTempDir + '/**']: 'write',
904
1220
  [path.join(path.dirname(managedTempDir), 'evolcore-locks')]: 'write',
905
- [path.join(path.dirname(managedTempDir), 'evolcore-locks', '**')]: 'write',
1221
+ [path.join(path.dirname(managedTempDir), 'evolcore-locks') + '/**']: 'write',
906
1222
  } : {}),
907
1223
  },
908
1224
  network,
@@ -949,55 +1265,145 @@ export class CodexRunner {
949
1265
  }
950
1266
  setSendPrompt(fn) { this.sendPromptFn = fn; }
951
1267
  setPermissionContext(sessionId, context) { this.permissionContexts.set(sessionId, context); }
1268
+ injectUserMessage(sessionId, text) {
1269
+ // Bind queued reminders to the turn that generated them. A completed
1270
+ // turn may be replaced before the serialized steer reaches app-server;
1271
+ // injecting the old reminder into the new turn would corrupt its context.
1272
+ const requestedTurn = this.activeTurns.get(sessionId);
1273
+ if (!requestedTurn) {
1274
+ logger.warn(`[CodexRunner] turn steer skipped because no active turn is registered: session=${sessionId}`);
1275
+ return;
1276
+ }
1277
+ const previous = this.steeringTails.get(sessionId) ?? Promise.resolve();
1278
+ let current;
1279
+ current = previous.catch(() => undefined).then(async () => {
1280
+ const activeTurn = this.activeTurns.get(sessionId);
1281
+ const sameTurn = !!activeTurn
1282
+ && activeTurn.threadId === requestedTurn.threadId
1283
+ && activeTurn.turnId === requestedTurn.turnId;
1284
+ if (!sameTurn) {
1285
+ logger.warn(`[CodexRunner] turn steer skipped because the originating turn is no longer active: `
1286
+ + `session=${sessionId} thread=${requestedTurn.threadId} turn=${requestedTurn.turnId}`);
1287
+ return;
1288
+ }
1289
+ const input = [{ type: 'text', text, text_elements: [] }];
1290
+ const client = this.getAppServerClientForThread(activeTurn.threadId);
1291
+ if (typeof client.turnSteer !== 'function') {
1292
+ logger.warn(`[CodexRunner] turn steer unavailable in app-server client: session=${sessionId}`);
1293
+ return;
1294
+ }
1295
+ try {
1296
+ const response = await client.turnSteer(activeTurn.threadId, activeTurn.turnId, input);
1297
+ logger.info(`[CodexRunner] turn steer delivered: session=${sessionId} thread=${activeTurn.threadId} ` +
1298
+ `turn=${response.turnId} chars=${text.length}`);
1299
+ }
1300
+ catch (error) {
1301
+ logger.warn(`[CodexRunner] turn steer failed: session=${sessionId} thread=${activeTurn.threadId} ` +
1302
+ `turn=${activeTurn.turnId} error=${error instanceof Error ? error.message : String(error)}`);
1303
+ }
1304
+ }).finally(() => {
1305
+ if (this.steeringTails.get(sessionId) === current)
1306
+ this.steeringTails.delete(sessionId);
1307
+ });
1308
+ this.steeringTails.set(sessionId, current);
1309
+ }
952
1310
  async assertProactiveHookAvailability(sessionId, appServer) {
953
1311
  if (this.permissionContexts.get(sessionId)?.chatmode !== 'proactive')
954
1312
  return;
955
- if (process.platform === 'win32') {
956
- await appServer.assertManagedHooksAvailable();
957
- return;
958
- }
959
- if (process.platform !== 'linux') {
1313
+ if (process.platform !== 'linux' && process.platform !== 'win32') {
960
1314
  throw new Error('Codex proactive managed PreToolUse enforcement requires Linux or an installed Windows managed hook');
961
1315
  }
1316
+ await appServer.assertManagedHooksAvailable();
962
1317
  }
963
- async evaluatePreToolUse(threadId, toolName, toolInput, signal) {
1318
+ async evaluatePreToolUse(threadId, toolName, toolInput, signal, callId) {
964
1319
  const sessionKey = this.findSessionKeyByThread(threadId);
965
- const activeThread = this.activeSessions.get(sessionKey);
1320
+ const activeThread = this.activeSessions.get(sessionKey) ?? this.provisionalSessions.get(sessionKey);
966
1321
  const context = this.permissionContexts.get(sessionKey);
967
- if (!activeThread || activeThread !== threadId) {
1322
+ const normalizedInput = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
1323
+ ? toolInput
1324
+ : {};
1325
+ const recordPreToolUseDenial = async (policyCode, reason) => {
1326
+ const summary = summarizeToolInputForAudit(toolName, normalizedInput).slice(0, 512);
1327
+ auditToolPreflightDenial({
1328
+ toolName,
1329
+ policyCode,
1330
+ reason,
1331
+ summary,
1332
+ sessionId: sessionKey,
1333
+ agentAid: context?.selfAid,
1334
+ agentName: context?.agentName,
1335
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1336
+ channel: context?.channel,
1337
+ actorId: context?.userId,
1338
+ role: context?.role,
1339
+ selfAid: context?.selfAid,
1340
+ callId,
1341
+ correlationId: callId,
1342
+ taskId: context?.taskId,
1343
+ });
1344
+ try {
1345
+ await context?.recordExecutionAnomaly?.({
1346
+ code: 'operation_blocked',
1347
+ severity: 'warning',
1348
+ phase: 'execution',
1349
+ occurredAt: Date.now(),
1350
+ toolName,
1351
+ policyCode,
1352
+ decisionSource: 'policy',
1353
+ agentAid: context?.selfAid,
1354
+ agentName: context?.agentName,
1355
+ sessionId: sessionKey,
1356
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1357
+ summary,
1358
+ effect: 'operation_skipped',
1359
+ });
1360
+ }
1361
+ catch (error) {
1362
+ logger.warn(`[CodexRunner] failed to record managed-hook denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
1363
+ }
1364
+ };
1365
+ if (!activeThread || !this.isThreadOwnedBySession(sessionKey, threadId)) {
968
1366
  return { ok: true, applicable: false };
969
1367
  }
970
1368
  const proactiveSession = context?.chatmode === 'proactive';
971
1369
  if (!context || (proactiveSession && !context.preToolUsePolicyHook)) {
972
- return { ok: false, applicable: true, decision: 'deny', reason: 'EvolCore proactive session policy context is unavailable' };
1370
+ const reason = 'EvolCore proactive session policy context is unavailable';
1371
+ await recordPreToolUseDenial('pre_tool_use_context_unavailable', reason);
1372
+ return { ok: false, applicable: true, decision: 'deny', reason };
973
1373
  }
974
1374
  if (context.pauseController) {
975
1375
  const pauseResult = await context.pauseController.waitAtToolBoundary(signal);
976
1376
  if (pauseResult === 'cancelled') {
977
- return { ok: true, applicable: true, decision: 'deny', reason: '工具调用已取消' };
1377
+ const reason = '工具调用已取消';
1378
+ await recordPreToolUseDenial('tool_call_cancelled', reason);
1379
+ return { ok: true, applicable: true, decision: 'deny', reason };
978
1380
  }
979
1381
  }
980
1382
  if ((this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess') {
981
- return this.isTrustedFullAccessSession(sessionKey)
982
- ? { ok: true, applicable: true, decision: 'allow' }
983
- : { ok: false, applicable: true, decision: 'deny', reason: 'Untrusted fullaccess execution context' };
1383
+ if (this.isTrustedFullAccessSession(sessionKey))
1384
+ return { ok: true, applicable: true, decision: 'allow' };
1385
+ const reason = 'Untrusted fullaccess execution context';
1386
+ await recordPreToolUseDenial('fullaccess_context_untrusted', reason);
1387
+ return { ok: false, applicable: true, decision: 'deny', reason };
984
1388
  }
985
1389
  // A tracked interactive thread still needs an authenticated, explicit
986
1390
  // allow so the managed hook can fail closed when no runner matches.
987
1391
  if (!context.preToolUsePolicyHook)
988
1392
  return { ok: true, applicable: true, decision: 'allow' };
989
- const normalizedInput = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
990
- ? toolInput
991
- : {};
992
1393
  try {
993
1394
  const result = context.preToolUsePolicyHook(toolName, normalizedInput);
994
1395
  if (result?.block) {
995
- return { ok: true, applicable: true, decision: 'deny', reason: result.reason };
1396
+ const policyCode = result.policyCode ?? 'session_policy_hook';
1397
+ const reason = result.reason ?? 'session policy denied this tool call';
1398
+ await recordPreToolUseDenial(policyCode, reason);
1399
+ return { ok: true, applicable: true, decision: 'deny', reason };
996
1400
  }
997
1401
  return { ok: true, applicable: true, decision: 'allow' };
998
1402
  }
999
1403
  catch (error) {
1000
- return { ok: false, applicable: true, decision: 'deny', reason: `policy hook failed: ${error instanceof Error ? error.message : String(error)}` };
1404
+ const reason = `policy hook failed: ${error instanceof Error ? error.message : String(error)}`;
1405
+ await recordPreToolUseDenial('pre_tool_use_hook_failed', reason);
1406
+ return { ok: false, applicable: true, decision: 'deny', reason };
1001
1407
  }
1002
1408
  }
1003
1409
  setPermissionGateway(gw) { this.permissionGateway = gw; }
@@ -1006,14 +1412,36 @@ export class CodexRunner {
1006
1412
  this.activeStreams.set(key, stream);
1007
1413
  }
1008
1414
  cleanupStream(key) {
1415
+ const hadController = this.activeAbortControllers.has(key);
1416
+ const hadTurn = this.activeTurns.has(key);
1417
+ const provisionalThread = this.provisionalSessions.get(key);
1418
+ const subscribedThreadId = this.activeTurns.get(key)?.threadId
1419
+ ?? this.activeSessions.get(key)
1420
+ ?? provisionalThread;
1421
+ const subscription = subscribedThreadId ? this.threadSubscriptions.get(subscribedThreadId) : undefined;
1009
1422
  this.activeStreams.delete(key);
1010
1423
  this.activeAbortControllers.delete(key);
1011
1424
  this.pendingInterrupts.delete(key);
1425
+ // A stale runner-start path may dispose the stream without ever entering
1426
+ // transformAppServerStream. Reclaim its provisional thread, but preserve
1427
+ // a replacement run that has already installed a new controller/turn.
1428
+ if (provisionalThread && !hadController && !hadTurn) {
1429
+ this.cleanupProvisionalThread(key, provisionalThread);
1430
+ }
1012
1431
  this.chatModes.delete(key);
1013
1432
  const release = this.activeOperationReleases.get(key);
1433
+ let releaseAfterSubscription;
1434
+ if (subscription) {
1435
+ releaseAfterSubscription = this.releaseThreadSubscription(subscription.client, subscribedThreadId, 'stream-cleanup');
1436
+ }
1014
1437
  if (release) {
1015
1438
  this.activeOperationReleases.delete(key);
1016
- release();
1439
+ if (releaseAfterSubscription) {
1440
+ void releaseAfterSubscription.then(release, release);
1441
+ }
1442
+ else {
1443
+ release();
1444
+ }
1017
1445
  }
1018
1446
  }
1019
1447
  hasActiveStream(key) {
@@ -1064,14 +1492,20 @@ export class CodexRunner {
1064
1492
  }
1065
1493
  }
1066
1494
  isActiveThreadOperationAlias(key) {
1067
- if (key.startsWith('session:'))
1068
- return this.activeSessions.has(key.slice('session:'.length));
1495
+ if (key.startsWith('session:')) {
1496
+ const sessionId = key.slice('session:'.length);
1497
+ return this.activeSessions.has(sessionId) || this.provisionalSessions.has(sessionId);
1498
+ }
1069
1499
  if (key.startsWith('thread:')) {
1070
1500
  const threadId = key.slice('thread:'.length);
1071
1501
  for (const activeThreadId of this.activeSessions.values()) {
1072
1502
  if (activeThreadId === threadId)
1073
1503
  return true;
1074
1504
  }
1505
+ for (const provisionalThreadId of this.provisionalSessions.values()) {
1506
+ if (provisionalThreadId === threadId)
1507
+ return true;
1508
+ }
1075
1509
  return false;
1076
1510
  }
1077
1511
  return true;
@@ -1157,7 +1591,18 @@ export class CodexRunner {
1157
1591
  let agentSessionId = topicBinding
1158
1592
  ? (topicAgentSessionId || undefined)
1159
1593
  : initialAgentSessionId || this.activeSessions.get(sessionId);
1160
- const resumingThread = !!agentSessionId;
1594
+ if (!topicBinding && !initialAgentSessionId && typeof sessionManager?.getSessionById === 'function') {
1595
+ try {
1596
+ const persisted = await sessionManager.getSessionById(sessionId);
1597
+ agentSessionId = typeof persisted?.agentSessionId === 'string' && persisted.agentSessionId.trim()
1598
+ ? persisted.agentSessionId.trim()
1599
+ : undefined;
1600
+ }
1601
+ catch (error) {
1602
+ logger.debug(`[CodexRunner] persisted main backend lookup failed; using local cache: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
1603
+ }
1604
+ }
1605
+ let resumingThread = !!agentSessionId;
1161
1606
  const callModel = modelOverride?.model || this.model;
1162
1607
  const callEffort = modelOverride?.effortMode === 'model_default'
1163
1608
  ? undefined
@@ -1173,7 +1618,7 @@ export class CodexRunner {
1173
1618
  this.chatModes.set(sessionId, callMode);
1174
1619
  const callApprovalPolicy = this.toApprovalPolicy(callMode);
1175
1620
  const executionSandbox = this.buildExecutionSandboxOptions(sessionId, callMode, projectPath, modelOverride?.bootstrapAgentMdPath);
1176
- const appServer = this.getAppServerClient();
1621
+ const appServer = this.getAppServerClient(callMode === 'fullaccess');
1177
1622
  this.dropExpiredPendingInterrupt(sessionId);
1178
1623
  const effectiveApprovalPolicy = callApprovalPolicy;
1179
1624
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
@@ -1190,29 +1635,81 @@ export class CodexRunner {
1190
1635
  if (agentSessionId && !knownDelegationCarrier) {
1191
1636
  knownDelegationCarrier = await this.loadPersistedDelegationCarrier(sessionManager, sessionId, agentSessionId);
1192
1637
  }
1193
- const delegationCarrierCandidate = knownDelegationCarrier ?? this.createDelegationCarrier();
1638
+ let delegationCarrierCandidate = knownDelegationCarrier ?? this.createDelegationCarrier();
1194
1639
  // Keep newly discovered carrier state local until generation-aware
1195
1640
  // activation accepts the backend. A stale run must not publish a carrier
1196
1641
  // that a later generation could mistake for its own thread.
1197
1642
  let carrierToPublish;
1198
1643
  const hasTaskDelegation = typeof runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] === 'string';
1199
- const threadOptions = {
1644
+ let threadOptions = {
1200
1645
  model: callModel,
1201
1646
  effort: callEffort,
1202
1647
  approvalPolicy: effectiveApprovalPolicy,
1203
1648
  approvalsReviewer: 'user',
1204
1649
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1205
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate), this.buildLifecycleLockdownConfig(callMode)), callMode),
1650
+ 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),
1206
1651
  ...(developerInstructions ? { developerInstructions } : {}),
1207
1652
  };
1208
1653
  logger.info(`[CodexRunner] runQuery permMode=${requestedPermissionMode}->${callMode} ` +
1209
1654
  `role=${executionSandbox.decision.role ?? 'none'} sandbox=${executionSandbox.decision.state}`);
1210
- const threadResponse = agentSessionId
1211
- ? await appServer.threadResume(agentSessionId, projectPath, threadOptions)
1212
- : await appServer.threadStart(projectPath, threadOptions);
1655
+ let threadResponse;
1656
+ let previousAppServer;
1657
+ if (agentSessionId) {
1658
+ // A thread may have been loaded by the other permission-mode client.
1659
+ // Release that one thread before asking this client to resume it.
1660
+ previousAppServer = await this.prepareThreadSubscription(appServer, agentSessionId);
1661
+ try {
1662
+ threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
1663
+ }
1664
+ catch (error) {
1665
+ if (isCodexActiveWriterError(error) && previousAppServer && previousAppServer !== appServer) {
1666
+ // Some Codex builds acknowledge unsubscribe before unloading the
1667
+ // runtime. Archive/unarchive is a synchronous per-thread teardown
1668
+ // and preserves the same persisted thread id for the retry.
1669
+ await this.archiveThreadForHandoff(previousAppServer, appServer, agentSessionId);
1670
+ threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
1671
+ }
1672
+ else {
1673
+ // A legacy main session can retain a provider ID after its rollout was
1674
+ // removed externally. It is safe to recover before input submission;
1675
+ // topic bindings remain authoritative and fail closed.
1676
+ if (topicBinding || !isCodexNoRolloutError(error))
1677
+ throw error;
1678
+ if (typeof sessionManager?.clearMainSessionBackendIfMatches !== 'function')
1679
+ throw error;
1680
+ const missingThreadId = agentSessionId;
1681
+ const cleared = await sessionManager.clearMainSessionBackendIfMatches(sessionId, missingThreadId, [CODEX_DELEGATION_CARRIER_METADATA_KEY]);
1682
+ if (cleared !== 'cleared' && cleared !== 'already_clear') {
1683
+ const recoveryError = new Error(`Codex missing-rollout recovery rejected: ${cleared}`);
1684
+ recoveryError.code = 'CODEX_MISSING_ROLLOUT_RECOVERY_REJECTED';
1685
+ throw recoveryError;
1686
+ }
1687
+ if (this.activeSessions.get(sessionId) === missingThreadId)
1688
+ this.activeSessions.delete(sessionId);
1689
+ this.threadProjectPaths.delete(missingThreadId);
1690
+ this.threadDelegationCarriers.delete(missingThreadId);
1691
+ this.unbindExternalToolBoundary(missingThreadId);
1692
+ this.clearTrackedApprovalItemsForThread(missingThreadId);
1693
+ agentSessionId = undefined;
1694
+ resumingThread = false;
1695
+ knownDelegationCarrier = undefined;
1696
+ delegationCarrierCandidate = this.createDelegationCarrier();
1697
+ threadOptions = {
1698
+ ...threadOptions,
1699
+ 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),
1700
+ };
1701
+ logger.warn(`[CodexRunner] recovered missing rollout before input submission: session=${sessionId} thread=${missingThreadId}`);
1702
+ threadResponse = await appServer.threadStart(projectPath, threadOptions);
1703
+ }
1704
+ }
1705
+ }
1706
+ else {
1707
+ threadResponse = await appServer.threadStart(projectPath, threadOptions);
1708
+ }
1213
1709
  let threadId = threadResponse.thread?.id || agentSessionId;
1214
1710
  if (!threadId)
1215
1711
  throw new Error('Codex app-server did not return a thread id');
1712
+ this.markThreadSubscribed(appServer, threadId);
1216
1713
  if (!resumingThread) {
1217
1714
  carrierToPublish = { threadId, carrier: delegationCarrierCandidate };
1218
1715
  }
@@ -1239,6 +1736,9 @@ export class CodexRunner {
1239
1736
  const forkedThreadId = forked.thread?.id;
1240
1737
  if (!forkedThreadId)
1241
1738
  throw new Error('Codex delegation-carrier recovery fork did not return a thread id');
1739
+ this.markThreadSubscribed(appServer, forkedThreadId);
1740
+ await this.releaseThreadSubscription(appServer, staleThreadId, 'carrier-recovery-fork-source');
1741
+ await this.releaseThreadSubscription(appServer, forkedThreadId, 'carrier-recovery-fork');
1242
1742
  threadId = forkedThreadId;
1243
1743
  recoveredCarrier = delegationCarrierCandidate;
1244
1744
  }
@@ -1247,28 +1747,40 @@ export class CodexRunner {
1247
1747
  else if (knownDelegationCarrier) {
1248
1748
  carrierToPublish = { threadId, carrier: knownDelegationCarrier };
1249
1749
  }
1250
- agentSessionId = threadId;
1251
1750
  const turn = modelOverride?.turn;
1252
- const bindingResult = this.onSessionIdUpdate
1253
- ? ((turn === undefined
1254
- ? await this.onSessionIdUpdate(sessionId, threadId)
1255
- : await this.onSessionIdUpdate(sessionId, threadId, { turn }))
1256
- ?? 'legacy_updated')
1257
- : 'legacy_updated';
1258
- const bindingAccepted = bindingResult === 'activated'
1259
- || bindingResult === 'already_active'
1260
- || bindingResult === 'legacy_updated';
1261
- if (!bindingAccepted) {
1262
- const error = new Error(`backend discovery rejected: ${bindingResult}`);
1263
- error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
1264
- throw error;
1751
+ if (!resumingThread) {
1752
+ // Keep a newly-created backend private to this runner until turn/start
1753
+ // has been accepted. In particular, a pending interrupt must not leave
1754
+ // a durable ID for a thread that never produced a rollout.
1755
+ this.provisionalSessions.set(sessionId, threadId);
1756
+ if (carrierToPublish) {
1757
+ this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
1758
+ }
1265
1759
  }
1266
- this.activeSessions.set(sessionId, threadId);
1267
- if (carrierToPublish) {
1268
- this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
1269
- await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
1760
+ else {
1761
+ agentSessionId = threadId;
1762
+ const bindingResult = this.onSessionIdUpdate
1763
+ ? ((turn === undefined
1764
+ ? await this.onSessionIdUpdate(sessionId, threadId)
1765
+ : await this.onSessionIdUpdate(sessionId, threadId, { turn }))
1766
+ ?? 'legacy_updated')
1767
+ : 'legacy_updated';
1768
+ const bindingAccepted = bindingResult === 'activated'
1769
+ || bindingResult === 'already_active'
1770
+ || bindingResult === 'legacy_updated';
1771
+ if (!bindingAccepted) {
1772
+ const error = new Error(`backend discovery rejected: ${bindingResult}`);
1773
+ error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
1774
+ await this.releaseThreadSubscription(appServer, threadId, 'backend-binding-rejected');
1775
+ throw error;
1776
+ }
1777
+ this.activeSessions.set(sessionId, threadId);
1778
+ if (carrierToPublish) {
1779
+ this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
1780
+ await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
1781
+ }
1782
+ this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
1270
1783
  }
1271
- this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
1272
1784
  this.threadProjectPaths.set(threadId, path.resolve(projectPath));
1273
1785
  this.bindExternalToolBoundary(threadId, externalToolConfig);
1274
1786
  const controller = new AbortController();
@@ -1279,21 +1791,56 @@ export class CodexRunner {
1279
1791
  controller.signal.addEventListener('abort', () => queue.end(), { once: true });
1280
1792
  const state = {
1281
1793
  threadId,
1794
+ sessionId,
1795
+ projectPath,
1282
1796
  model: callModel,
1797
+ committed: resumingThread,
1283
1798
  streamedAgentMessageIds: new Set(),
1284
1799
  agentMessageDeltaText: new Map(),
1285
1800
  completedItemIds: new Set(),
1286
1801
  emittedEditCallIds: new Set(),
1287
1802
  completedTurnIds: new Set(),
1288
1803
  openToolCalls: new Map(),
1804
+ activeSubagentThreads: new Set(),
1805
+ turnCompleted: false,
1289
1806
  };
1290
1807
  const unsubscribe = appServer.onNotification(notification => {
1291
1808
  // 仅从 turn/started 锁定权威 turnId — resume 时会有上一轮 turn 的残留通知
1292
1809
  // (如 thread/tokenUsage/updated)先于新 turn 到达,不能用它们 latch turnId
1293
1810
  const params = notification.params || {};
1294
1811
  const notifThreadId = params.threadId ?? params.thread_id;
1295
- if (notifThreadId !== undefined && notifThreadId !== threadId)
1812
+ const item = params.item && typeof params.item === 'object' ? params.item : undefined;
1813
+ const emittedByChild = typeof notifThreadId === 'string'
1814
+ && this.childThreadSessions.get(notifThreadId) === sessionId;
1815
+ if (notifThreadId !== undefined && notifThreadId !== threadId && !emittedByChild)
1816
+ return;
1817
+ this.trackSubagentLifecycle(notification, state, appServer);
1818
+ if (emittedByChild && notifThreadId !== threadId) {
1819
+ const childTurnId = this.extractTurnId(notification);
1820
+ if (notification.method === 'turn/started' && childTurnId) {
1821
+ this.activeSubagentTurns.set(notifThreadId, {
1822
+ sessionId,
1823
+ parentThreadId: threadId,
1824
+ turnId: childTurnId,
1825
+ });
1826
+ }
1827
+ else if (notification.method === 'turn/completed') {
1828
+ const activeChildTurn = this.activeSubagentTurns.get(notifThreadId);
1829
+ if (!childTurnId || activeChildTurn?.turnId === childTurnId) {
1830
+ this.activeSubagentTurns.delete(notifThreadId);
1831
+ }
1832
+ state.activeSubagentThreads.delete(notifThreadId);
1833
+ void this.releaseThreadSubscription(appServer, notifThreadId, 'subagent-turn-finished');
1834
+ this.unregisterChildThread(notifThreadId);
1835
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1836
+ }
1837
+ if (this.isSubagentActivityItem(item))
1838
+ queue.push(notification);
1839
+ if (notification.method === 'item/completed' && state.turnCompleted) {
1840
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1841
+ }
1296
1842
  return;
1843
+ }
1297
1844
  if (notification.method === 'turn/started') {
1298
1845
  const startedTurnId = this.extractTurnId(notification);
1299
1846
  if (startedTurnId && !state.turnId) {
@@ -1309,15 +1856,22 @@ export class CodexRunner {
1309
1856
  return;
1310
1857
  queue.push(notification);
1311
1858
  // 仅在已锁定 turnId 后才允许 turn/completed 结束队列,避免残留的旧 turn/completed 误关
1312
- if (notification.method === 'turn/completed' && state.turnId)
1313
- queue.end();
1859
+ if (notification.method === 'turn/completed' && state.turnId) {
1860
+ state.turnCompleted = true;
1861
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1862
+ }
1863
+ else if (notification.method === 'item/completed' && state.turnCompleted) {
1864
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1865
+ }
1314
1866
  });
1315
1867
  if (this.consumePendingInterrupt(sessionId)) {
1316
1868
  controller.abort('User interrupt');
1317
1869
  this.activeAbortControllers.delete(sessionId);
1318
1870
  this.activeStreams.delete(sessionId);
1871
+ if (!resumingThread)
1872
+ this.cleanupProvisionalThread(sessionId, threadId);
1319
1873
  logger.info(`[CodexRunner] Applied pending interrupt before turn start: ${sessionId}`);
1320
- return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
1874
+ return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
1321
1875
  }
1322
1876
  try {
1323
1877
  const turnResponse = await appServer.turnStart(threadId, input, {
@@ -1327,6 +1881,14 @@ export class CodexRunner {
1327
1881
  approvalPolicy: effectiveApprovalPolicy,
1328
1882
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1329
1883
  });
1884
+ if (!resumingThread) {
1885
+ state.commitProvisional = async () => {
1886
+ const committed = await this.commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn);
1887
+ if (committed)
1888
+ agentSessionId = threadId;
1889
+ return committed;
1890
+ };
1891
+ }
1330
1892
  const turnId = turnResponse.turn?.id;
1331
1893
  if (turnId && !state.turnId) {
1332
1894
  state.turnId = turnId;
@@ -1341,19 +1903,24 @@ export class CodexRunner {
1341
1903
  }
1342
1904
  const status = turnResponse.turn?.status;
1343
1905
  if (status === 'completed' || status === 'failed') {
1906
+ this.primeSubagentStateFromTurn(turnResponse.turn, state, appServer);
1344
1907
  queue.push({ method: 'turn/completed', params: { threadId, turn: turnResponse.turn } });
1345
- queue.end();
1908
+ state.turnCompleted = true;
1909
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1346
1910
  }
1347
1911
  }
1348
1912
  catch (error) {
1349
1913
  unsubscribe();
1914
+ await this.releaseThreadSubscription(appServer, threadId, 'turn-start-failed');
1350
1915
  this.activeAbortControllers.delete(sessionId);
1351
1916
  this.activeTurns.delete(sessionId);
1352
1917
  this.pendingInterrupts.delete(sessionId);
1353
1918
  this.cleanupTempFiles(tempFiles);
1919
+ if (!resumingThread)
1920
+ this.cleanupProvisionalThread(sessionId, threadId);
1354
1921
  throw error;
1355
1922
  }
1356
- return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
1923
+ return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
1357
1924
  }
1358
1925
  // ── Interrupt ──
1359
1926
  async interrupt(sessionKey) {
@@ -1364,6 +1931,7 @@ export class CodexRunner {
1364
1931
  const interruptTurn = activeTurn
1365
1932
  ? this.interruptAppServerTurn(activeTurn.threadId, activeTurn.turnId)
1366
1933
  : Promise.resolve();
1934
+ const interruptSubagents = this.interruptSubagentTurns(sessionKey);
1367
1935
  if (!activeTurn)
1368
1936
  this.rememberPendingInterrupt(sessionKey);
1369
1937
  if (controller)
@@ -1374,7 +1942,17 @@ export class CodexRunner {
1374
1942
  this.activeTurns.delete(sessionKey);
1375
1943
  logger.info(`[CodexRunner] Interrupted session: ${sessionKey}`);
1376
1944
  }
1377
- await interruptTurn;
1945
+ await Promise.all([interruptTurn, interruptSubagents]);
1946
+ }
1947
+ async interruptSubagentTurns(sessionId, parentThreadId) {
1948
+ const entries = [...this.activeSubagentTurns.entries()]
1949
+ .filter(([, active]) => active.sessionId === sessionId && (!parentThreadId || active.parentThreadId === parentThreadId));
1950
+ await Promise.all(entries.map(async ([childThreadId, active]) => {
1951
+ await this.interruptAppServerTurn(childThreadId, active.turnId).catch(() => { });
1952
+ const current = this.activeSubagentTurns.get(childThreadId);
1953
+ if (current === active)
1954
+ this.activeSubagentTurns.delete(childThreadId);
1955
+ }));
1378
1956
  }
1379
1957
  rememberPendingInterrupt(sessionId) {
1380
1958
  this.pendingInterrupts.set(sessionId, Date.now());
@@ -1394,7 +1972,12 @@ export class CodexRunner {
1394
1972
  }
1395
1973
  async interruptAppServerTurn(threadId, turnId) {
1396
1974
  try {
1397
- await this.getAppServerClient().turnInterrupt(threadId, turnId);
1975
+ const sessionId = this.findSessionKeyByThread(threadId);
1976
+ const subscribed = this.threadSubscriptions.get(threadId);
1977
+ const client = subscribed
1978
+ ? subscribed.client
1979
+ : this.getAppServerClient(sessionId ? this.chatModes.get(sessionId) === 'fullaccess' : false);
1980
+ await client.turnInterrupt(threadId, turnId);
1398
1981
  }
1399
1982
  catch (error) {
1400
1983
  logger.debug(`[CodexRunner] app-server turn interrupt failed: ${error}`);
@@ -1437,10 +2020,12 @@ export class CodexRunner {
1437
2020
  }
1438
2021
  async closeSession(sessionId) {
1439
2022
  const capturedThreadId = this.activeSessions.get(sessionId);
2023
+ const capturedProvisionalThreadId = this.provisionalSessions.get(sessionId);
1440
2024
  const capturedPermissionContext = this.permissionContexts.get(sessionId);
1441
2025
  const capturedStream = this.activeStreams.get(sessionId);
1442
2026
  const capturedController = this.activeAbortControllers.get(sessionId);
1443
2027
  const capturedTurn = this.activeTurns.get(sessionId);
2028
+ const capturedSteeringTail = this.steeringTails.get(sessionId);
1444
2029
  this.permissionContexts.get(sessionId)?.pauseController?.cancel();
1445
2030
  // Do not call interrupt(sessionId) here: a new run may have replaced the
1446
2031
  // session-scoped maps while an older close was waiting. Interrupt and
@@ -1448,12 +2033,24 @@ export class CodexRunner {
1448
2033
  if (capturedTurn) {
1449
2034
  await this.interruptAppServerTurn(capturedTurn.threadId, capturedTurn.turnId).catch(() => { });
1450
2035
  }
2036
+ const capturedParentThreadId = capturedThreadId ?? capturedProvisionalThreadId;
2037
+ if (capturedParentThreadId) {
2038
+ await this.interruptSubagentTurns(sessionId, capturedParentThreadId);
2039
+ }
2040
+ else if (!this.activeSessions.has(sessionId) && !this.provisionalSessions.has(sessionId)) {
2041
+ await this.interruptSubagentTurns(sessionId);
2042
+ }
1451
2043
  capturedController?.abort('Session closed');
2044
+ const capturedSubscription = capturedThreadId ? this.threadSubscriptions.get(capturedThreadId) : undefined;
1452
2045
  const lifecycleReplaced = (capturedStream !== undefined && this.activeStreams.get(sessionId) !== capturedStream)
1453
2046
  || (capturedController !== undefined && this.activeAbortControllers.get(sessionId) !== capturedController)
1454
2047
  || (capturedTurn !== undefined && this.activeTurns.get(sessionId) !== capturedTurn)
1455
2048
  || (capturedPermissionContext !== undefined && this.permissionContexts.get(sessionId) !== capturedPermissionContext)
1456
2049
  || (capturedThreadId !== undefined && this.activeSessions.get(sessionId) !== capturedThreadId);
2050
+ if (capturedSubscription && !lifecycleReplaced
2051
+ && this.threadSubscriptions.get(capturedThreadId) === capturedSubscription) {
2052
+ await this.releaseThreadSubscription(capturedSubscription.client, capturedThreadId, 'session-closed');
2053
+ }
1457
2054
  if (capturedStream !== undefined && this.activeStreams.get(sessionId) === capturedStream)
1458
2055
  this.activeStreams.delete(sessionId);
1459
2056
  if (capturedController !== undefined && this.activeAbortControllers.get(sessionId) === capturedController)
@@ -1464,6 +2061,10 @@ export class CodexRunner {
1464
2061
  this.pendingInterrupts.delete(sessionId);
1465
2062
  if (this.permissionContexts.get(sessionId) === capturedPermissionContext)
1466
2063
  this.permissionContexts.delete(sessionId);
2064
+ if (this.steeringTails.get(sessionId) === capturedSteeringTail)
2065
+ this.steeringTails.delete(sessionId);
2066
+ if (capturedProvisionalThreadId)
2067
+ this.cleanupProvisionalThread(sessionId, capturedProvisionalThreadId);
1467
2068
  // Session-level configuration and thread indexes are shared by later
1468
2069
  // runs. Only remove them when the captured lifecycle still owns the key.
1469
2070
  if (!lifecycleReplaced) {
@@ -1477,6 +2078,7 @@ export class CodexRunner {
1477
2078
  this.unbindExternalToolBoundary(capturedThreadId);
1478
2079
  }
1479
2080
  this.clearTrackedApprovalItemsForThread(capturedThreadId);
2081
+ this.cleanupChildThreadsForSession(sessionId);
1480
2082
  this.pruneThreadOperationLocks();
1481
2083
  }
1482
2084
  }
@@ -1504,7 +2106,13 @@ export class CodexRunner {
1504
2106
  async clearSession(sessionId, _agentSessionId, _projectPath) {
1505
2107
  // Codex: 清空会话 = 下次 runQuery 不传 resumeId,自动创建新 thread
1506
2108
  const threadId = this.activeSessions.get(sessionId) ?? _agentSessionId;
2109
+ const subscription = this.threadSubscriptions.get(threadId);
2110
+ if (subscription) {
2111
+ await this.releaseThreadSubscription(subscription.client, threadId, 'session-cleared');
2112
+ }
1507
2113
  this.activeSessions.delete(sessionId);
2114
+ this.activeTurns.delete(sessionId);
2115
+ this.steeringTails.delete(sessionId);
1508
2116
  this.chatModes.delete(sessionId);
1509
2117
  this.cleanupSessionTempDir(sessionId);
1510
2118
  this.threadProjectPaths.delete(threadId);
@@ -1522,8 +2130,12 @@ export class CodexRunner {
1522
2130
  return result;
1523
2131
  };
1524
2132
  const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId, _sessionId), 'compact');
2133
+ let compactAppServer;
1525
2134
  try {
1526
- const appServer = this.getAppServerClient();
2135
+ const compactMode = normalizeExecutionPermissionMode(modelOverride?.permissionMode ?? this.chatModes.get(_sessionId) ?? this.currentMode);
2136
+ const appServer = this.getAppServerClient(compactMode === 'fullaccess');
2137
+ compactAppServer = appServer;
2138
+ await this.prepareThreadSubscription(appServer, agentSessionId);
1527
2139
  this.onCompactStart?.(_sessionId);
1528
2140
  try {
1529
2141
  await this.startAndWaitForCompact(appServer, agentSessionId);
@@ -1533,7 +2145,6 @@ export class CodexRunner {
1533
2145
  if (!this.isThreadNotFoundError(error))
1534
2146
  throw error;
1535
2147
  logger.info(`[CodexRunner] Compact thread not loaded, resuming before compact: ${agentSessionId}`);
1536
- const compactMode = normalizeExecutionPermissionMode(modelOverride?.permissionMode ?? this.chatModes.get(_sessionId) ?? this.currentMode);
1537
2148
  const compactModel = modelOverride?.model || this.model;
1538
2149
  const compactEffort = modelOverride?.effortMode === 'model_default'
1539
2150
  ? undefined
@@ -1556,6 +2167,7 @@ export class CodexRunner {
1556
2167
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1557
2168
  config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
1558
2169
  });
2170
+ this.markThreadSubscribed(appServer, agentSessionId);
1559
2171
  this.threadProjectPaths.set(agentSessionId, path.resolve(_projectPath));
1560
2172
  this.threadDelegationCarriers.set(agentSessionId, compactDelegationCarrier);
1561
2173
  this.bindExternalToolBoundary(agentSessionId, externalToolConfig);
@@ -1568,6 +2180,9 @@ export class CodexRunner {
1568
2180
  return finish({ ok: false, code: 'sdk_error', message, durationMs: Date.now() - startedAt });
1569
2181
  }
1570
2182
  finally {
2183
+ if (compactAppServer) {
2184
+ await this.releaseThreadSubscription(compactAppServer, agentSessionId, 'compact-finished');
2185
+ }
1571
2186
  release();
1572
2187
  }
1573
2188
  }
@@ -1708,7 +2323,8 @@ export class CodexRunner {
1708
2323
  const mode = this.chatModes.get(sessionKey) ?? this.currentMode;
1709
2324
  const executionSandbox = this.buildExecutionSandboxOptions(sessionKey, mode, projectPath);
1710
2325
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
1711
- const appServer = this.getAppServerClient();
2326
+ const appServer = this.threadSubscriptions.get(agentSessionId)?.client
2327
+ ?? this.getAppServerClient(mode === 'fullaccess');
1712
2328
  const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, mode);
1713
2329
  if (sessionKey)
1714
2330
  await this.assertProactiveHookAvailability(sessionKey, appServer);
@@ -1724,9 +2340,11 @@ export class CodexRunner {
1724
2340
  const forkedThreadId = response.thread?.id;
1725
2341
  if (!forkedThreadId)
1726
2342
  throw new Error('Codex fork did not return a thread id');
2343
+ this.markThreadSubscribed(appServer, forkedThreadId);
1727
2344
  this.threadDelegationCarriers.set(forkedThreadId, delegationCarrier);
1728
2345
  this.threadProjectPaths.set(forkedThreadId, path.resolve(projectPath));
1729
2346
  this.bindExternalToolBoundary(forkedThreadId, externalToolConfig);
2347
+ await this.releaseThreadSubscription(appServer, forkedThreadId, 'fork-finished');
1730
2348
  return forkedThreadId;
1731
2349
  }
1732
2350
  async forkSessionAt(agentSessionId, projectPath, assistantMessageId, title) {
@@ -1756,14 +2374,14 @@ export class CodexRunner {
1756
2374
  }
1757
2375
  }
1758
2376
  async setSessionName(agentSessionId, name) {
1759
- return this.getAppServerClient().threadSetName(agentSessionId, name);
2377
+ return this.getAppServerClientForThread(agentSessionId).threadSetName(agentSessionId, name);
1760
2378
  }
1761
2379
  async updateSessionMetadata(agentSessionId, metadata) {
1762
2380
  const gitInfo = metadata?.gitInfo && typeof metadata.gitInfo === 'object' ? metadata.gitInfo : undefined;
1763
- return this.getAppServerClient().threadMetadataUpdate(agentSessionId, gitInfo);
2381
+ return this.getAppServerClientForThread(agentSessionId).threadMetadataUpdate(agentSessionId, gitInfo);
1764
2382
  }
1765
2383
  async getSessionMessages(agentSessionId, projectPath) {
1766
- const response = await this.getAppServerClient().threadRead(agentSessionId, true);
2384
+ const response = await this.getAppServerClientForThread(agentSessionId).threadRead(agentSessionId, true);
1767
2385
  return this.mapThreadToSessionMessages(response, agentSessionId);
1768
2386
  }
1769
2387
  approvalItemKey(threadId, turnId, itemId) {
@@ -1774,13 +2392,14 @@ export class CodexRunner {
1774
2392
  trackApprovalItemNotification(notification) {
1775
2393
  const params = (notification.params || {});
1776
2394
  const item = params.item && typeof params.item === 'object' ? params.item : undefined;
1777
- const itemId = item?.id ?? params.itemId;
2395
+ const itemId = item?.id ?? params.itemId ?? params.item_id;
1778
2396
  const threadId = this.requestThreadId(params);
1779
- const key = this.approvalItemKey(threadId, params.turnId, itemId);
2397
+ const turnId = params.turnId ?? params.turn_id;
2398
+ const key = this.approvalItemKey(threadId, turnId, itemId);
1780
2399
  if (notification.method === 'item/started' && key && item && threadId) {
1781
2400
  this.trackedApprovalItems.set(key, {
1782
2401
  threadId,
1783
- turnId: params.turnId,
2402
+ turnId,
1784
2403
  itemId,
1785
2404
  item: { ...item },
1786
2405
  });
@@ -1790,7 +2409,7 @@ export class CodexRunner {
1790
2409
  const tracked = this.trackedApprovalItems.get(key);
1791
2410
  this.trackedApprovalItems.set(key, {
1792
2411
  threadId,
1793
- turnId: params.turnId,
2412
+ turnId,
1794
2413
  itemId,
1795
2414
  item: {
1796
2415
  ...(tracked?.item ?? { id: itemId, type: 'fileChange' }),
@@ -1814,22 +2433,40 @@ export class CodexRunner {
1814
2433
  this.trackedApprovalItems.delete(trackedKey);
1815
2434
  }
1816
2435
  }
2436
+ this.clearPendingToolDenialsForTurn(threadId, completedTurnId);
1817
2437
  }
1818
2438
  }
1819
2439
  findTrackedApprovalItem(params) {
1820
- const key = this.approvalItemKey(this.requestThreadId(params), params.turnId, params.itemId);
2440
+ const key = this.approvalItemKey(this.requestThreadId(params), params.turnId ?? params.turn_id, params.itemId ?? params.item_id);
1821
2441
  return key ? this.trackedApprovalItems.get(key)?.item : undefined;
1822
2442
  }
1823
2443
  requestThreadId(params) {
1824
- const threadId = typeof params.threadId === 'string' && params.threadId.length > 0
1825
- ? params.threadId
2444
+ const threadIdValue = params.threadId ?? params.thread_id;
2445
+ const threadId = typeof threadIdValue === 'string' && threadIdValue.length > 0
2446
+ ? threadIdValue
1826
2447
  : undefined;
1827
2448
  if (threadId)
1828
2449
  return threadId;
1829
- return typeof params.conversationId === 'string' && params.conversationId.length > 0
1830
- ? params.conversationId
2450
+ const conversationIdValue = params.conversationId ?? params.conversation_id;
2451
+ return typeof conversationIdValue === 'string' && conversationIdValue.length > 0
2452
+ ? conversationIdValue
1831
2453
  : undefined;
1832
2454
  }
2455
+ clearPendingToolDenialsForTurn(threadId, turnId) {
2456
+ for (const key of this.pendingToolDenials.keys()) {
2457
+ let parsed;
2458
+ try {
2459
+ parsed = JSON.parse(key);
2460
+ }
2461
+ catch {
2462
+ continue;
2463
+ }
2464
+ if (!Array.isArray(parsed) || parsed[0] !== threadId)
2465
+ continue;
2466
+ if (!turnId || parsed[1] === turnId)
2467
+ this.pendingToolDenials.delete(key);
2468
+ }
2469
+ }
1833
2470
  clearTrackedApprovalItemsForThread(threadId) {
1834
2471
  if (!threadId)
1835
2472
  return;
@@ -1837,6 +2474,73 @@ export class CodexRunner {
1837
2474
  if (tracked.threadId === threadId)
1838
2475
  this.trackedApprovalItems.delete(key);
1839
2476
  }
2477
+ for (const key of this.pendingToolDenials.keys()) {
2478
+ let parsed;
2479
+ try {
2480
+ parsed = JSON.parse(key);
2481
+ }
2482
+ catch {
2483
+ continue;
2484
+ }
2485
+ if (Array.isArray(parsed) && parsed[0] === threadId)
2486
+ this.pendingToolDenials.delete(key);
2487
+ }
2488
+ }
2489
+ rememberToolDenial(request, params, denial) {
2490
+ const itemId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
2491
+ const threadId = this.requestThreadId(params);
2492
+ const rawTurnId = params.turnId ?? params.turn_id;
2493
+ const turnId = typeof rawTurnId === 'string' ? rawTurnId : this.findActiveTurnId(threadId);
2494
+ const key = this.approvalItemKey(threadId, turnId, itemId);
2495
+ if (!key)
2496
+ return;
2497
+ this.pendingToolDenials.set(key, {
2498
+ ...denial,
2499
+ ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
2500
+ method: request.method,
2501
+ });
2502
+ }
2503
+ steerPolicyReason(sessionId, policyCode, reason) {
2504
+ const prefix = policyCode ? `策略拒绝(${policyCode})` : '策略拒绝';
2505
+ this.injectUserMessage(sessionId, `⚠️ ${prefix}:${reason}。命令未执行,请按提示重新执行正确的命令。`);
2506
+ }
2507
+ findActiveTurnId(threadId) {
2508
+ if (!threadId)
2509
+ return undefined;
2510
+ for (const active of this.activeTurns.values()) {
2511
+ if (active.threadId === threadId)
2512
+ return active.turnId;
2513
+ }
2514
+ return undefined;
2515
+ }
2516
+ takeToolDenial(state, itemId) {
2517
+ const exactKey = this.approvalItemKey(state.threadId, state.turnId, itemId);
2518
+ if (exactKey) {
2519
+ const exact = this.pendingToolDenials.get(exactKey);
2520
+ if (exact) {
2521
+ this.pendingToolDenials.delete(exactKey);
2522
+ return exact;
2523
+ }
2524
+ }
2525
+ // Legacy approval callbacks may not carry turnId. Consume the unique
2526
+ // matching item for this thread rather than losing the structured reason.
2527
+ const candidates = [];
2528
+ for (const [key, denial] of this.pendingToolDenials) {
2529
+ let parsed;
2530
+ try {
2531
+ parsed = JSON.parse(key);
2532
+ }
2533
+ catch {
2534
+ continue;
2535
+ }
2536
+ if (!Array.isArray(parsed) || parsed[0] !== state.threadId || String(parsed[2]) !== String(itemId))
2537
+ continue;
2538
+ candidates.push([key, denial]);
2539
+ }
2540
+ if (candidates.length !== 1)
2541
+ return undefined;
2542
+ this.pendingToolDenials.delete(candidates[0][0]);
2543
+ return candidates[0][1];
1840
2544
  }
1841
2545
  async handleAppServerRequest(request) {
1842
2546
  const params = (request.params || {});
@@ -1898,18 +2602,62 @@ export class CodexRunner {
1898
2602
  // workspace, or unattended-trigger policy checks.
1899
2603
  if ((this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess') {
1900
2604
  if (!this.isTrustedFullAccessSession(sessionKey)) {
2605
+ const reason = 'Untrusted fullaccess execution context';
2606
+ this.rememberToolDenial(request, params, {
2607
+ decision: 'deny',
2608
+ decisionSource: 'policy',
2609
+ policyCode: 'fullaccess_context_untrusted',
2610
+ reason,
2611
+ });
2612
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', 'fullaccess_context_untrusted', reason);
2613
+ setImmediate(() => this.steerPolicyReason(sessionKey, 'fullaccess_context_untrusted', reason));
1901
2614
  return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1902
2615
  }
2616
+ // Fullaccess uses the current task delegation token in its shell
2617
+ // environment, so it must not enter the ordinary workspace/carrier
2618
+ // approval path even if the app-server asks for an infrastructure
2619
+ // approval despite approvalPolicy=never.
1903
2620
  const response = this.toAppServerApprovalResponse(request.method, 'allow', toolInput, false);
1904
2621
  this.logAppServerApprovalAudit(request, sessionKey, toolName, 'allow', 'approval', undefined, 'trusted fullaccess execution');
1905
2622
  return response;
1906
2623
  }
1907
- // proactive 模式行为策略(首次工具调用必须是 ec msg send)
1908
- const policyResult = this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
2624
+ // Keep the policy check in the approval path as a fail-closed fallback.
2625
+ // Some app-server tool routes can reach approval without invoking the
2626
+ // command hook; allowing here would bypass the proactive gate.
2627
+ const policyResult = (this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess'
2628
+ ? undefined
2629
+ : this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
1909
2630
  if (policyResult?.block) {
1910
2631
  logger.info(`[CodexRunner] app-server approval declined by session policy: session=${sessionKey} ` +
1911
2632
  `requestId=${request.id ?? '<missing>'} code=${policyResult.policyCode ?? 'session_policy_hook'} ` +
1912
2633
  `reason=${policyResult.reason ?? 'policy denied'} executed=false`);
2634
+ const policyCode = policyResult.policyCode ?? 'session_policy_hook';
2635
+ const policyReason = policyResult.reason ?? 'session policy denied approval';
2636
+ this.rememberToolDenial(request, params, {
2637
+ decision: 'deny',
2638
+ decisionSource: 'policy',
2639
+ policyCode,
2640
+ reason: policyReason,
2641
+ });
2642
+ // ResponseEngine's wrapped policy hook already steers Codex sessions
2643
+ // that have the pure managed-hook policy installed. Standalone callers
2644
+ // may only provide policyHook, so keep this fallback for them without
2645
+ // duplicating the same user message in production.
2646
+ if (!permissionContext?.preToolUsePolicyHook) {
2647
+ setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, policyReason));
2648
+ }
2649
+ const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2650
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', policyCode, policyReason);
2651
+ return response;
2652
+ }
2653
+ const summary = this.summarizeAppServerRequest(request.method, params);
2654
+ const reason = params.reason || params.decisionReason || undefined;
2655
+ const auditSummary = (summarizeToolInputForAudit(toolName, toolInput)
2656
+ || summarizeToolInputForAudit('Bash', { command: summary })).slice(0, 512);
2657
+ const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
2658
+ const workspacePath = this.resolvePermissionWorkspacePath(params);
2659
+ if (!workspacePath) {
2660
+ logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
1913
2661
  try {
1914
2662
  await permissionContext?.recordExecutionAnomaly?.({
1915
2663
  code: 'operation_blocked',
@@ -1918,41 +2666,39 @@ export class CodexRunner {
1918
2666
  occurredAt: Date.now(),
1919
2667
  toolName,
1920
2668
  ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
1921
- policyCode: policyResult.policyCode ?? 'session_policy_hook',
1922
- decisionSource: 'policy',
2669
+ policyCode: 'approval_workspace_unknown',
2670
+ decisionSource: 'infrastructure',
1923
2671
  agentAid: permissionContext?.selfAid,
1924
2672
  agentName: permissionContext?.agentName,
1925
2673
  sessionId: sessionKey,
1926
- permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1927
- summary: summarizeToolInputForAudit(toolName, toolInput).slice(0, 512),
2674
+ permissionMode: normalizeExecutionPermissionMode(sessionMode),
2675
+ summary: auditSummary,
1928
2676
  effect: 'operation_skipped',
1929
2677
  });
1930
2678
  }
1931
2679
  catch (error) {
1932
- logger.warn(`[CodexRunner] failed to record policy-hook denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
2680
+ logger.warn(`[CodexRunner] failed to record unknown-workspace approval denial: session=${sessionKey} error=${error instanceof Error ? error.message : String(error)}`);
1933
2681
  }
1934
2682
  const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1935
- this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', policyResult.policyCode ?? 'session_policy_hook', policyResult.reason ?? 'session policy denied approval');
1936
- return response;
1937
- }
1938
- const summary = this.summarizeAppServerRequest(request.method, params);
1939
- const reason = params.reason || params.decisionReason || undefined;
1940
- const workspacePath = this.resolvePermissionWorkspacePath(params);
1941
- if (!workspacePath) {
1942
- logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
1943
- const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2683
+ this.rememberToolDenial(request, params, {
2684
+ decision: 'deny',
2685
+ decisionSource: 'infrastructure',
2686
+ policyCode: 'approval_workspace_unknown',
2687
+ reason: 'thread workspace is unknown',
2688
+ });
1944
2689
  this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', 'approval_workspace_unknown', 'thread workspace is unknown');
2690
+ setImmediate(() => this.steerPolicyReason(sessionKey, 'approval_workspace_unknown', 'thread workspace is unknown'));
1945
2691
  return response;
1946
2692
  }
1947
2693
  const operationCwd = this.resolvePermissionOperationCwd(params, workspacePath, toolName);
1948
- const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
1949
- const logSummary = formatApprovalLogSummary(summary);
2694
+ const logSummary = formatApprovalLogSummary(auditSummary || summary);
1950
2695
  logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${logSummary}`);
1951
2696
  const isCommandApproval = request.method === 'item/commandExecution/requestApproval'
1952
2697
  || request.method === 'execCommandApproval';
1953
2698
  const managedEcIntent = isCommandApproval && this.isManagedEvolcoreCommandIntent(toolInput);
2699
+ const delegationCarrier = this.delegationCarrierForSession(sessionKey, typeof params.threadId === 'string' ? params.threadId : undefined);
1954
2700
  const canonicalEcApproval = isCommandApproval
1955
- ? this.resolveCanonicalEcApproval(toolInput, this.getManagedTempDir(sessionKey))
2701
+ ? this.resolveCanonicalEcApproval(toolInput, this.getManagedTempDir(sessionKey), delegationCarrier)
1956
2702
  : undefined;
1957
2703
  const approvedCommand = canonicalEcApproval?.argv;
1958
2704
  const denyInfrastructure = async (policyCode, message) => {
@@ -1979,19 +2725,32 @@ export class CodexRunner {
1979
2725
  logger.warn(`[CodexRunner] failed to record infrastructure approval denial: session=${sessionKey} code=${policyCode} error=${error instanceof Error ? error.message : String(error)}`);
1980
2726
  }
1981
2727
  const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2728
+ this.rememberToolDenial(request, params, {
2729
+ decision: 'deny',
2730
+ decisionSource: 'infrastructure',
2731
+ policyCode,
2732
+ reason: message,
2733
+ });
1982
2734
  this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', policyCode, message, canonicalEcApproval?.parseFailure);
2735
+ setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, message));
1983
2736
  return response;
1984
2737
  };
1985
2738
  if (managedEcIntent && !approvedCommand) {
2739
+ if (canonicalEcApproval?.parseFailure?.issue === 'untrusted-shell') {
2740
+ return await denyInfrastructure('ec_shell_untrusted_executable', 'EC command approval denied because the shell executable failed Windows Authenticode trust validation');
2741
+ }
1986
2742
  return await denyInfrastructure('ec_command_not_canonical', canonicalEcApproval?.parseFailure
1987
2743
  ? `EC command approval denied because canonical argv parsing failed (${canonicalEcApproval.parseFailure.issue})`
1988
2744
  : 'EC command approval denied because no canonical argv could be derived');
1989
2745
  }
1990
2746
  try {
1991
- const decision = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd, request.id);
2747
+ const resolution = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd, request.id, typeof params.itemId === 'string' ? params.itemId : typeof params.item_id === 'string' ? params.item_id : undefined);
2748
+ const decision = typeof resolution === 'string' ? resolution : resolution.decision;
2749
+ const resolutionMeta = typeof resolution === 'string' ? undefined : resolution;
1992
2750
  if (decision !== 'deny' && managedEcIntent && approvedCommand) {
1993
2751
  const carrierThreadIds = [
1994
2752
  this.activeSessions.get(sessionKey),
2753
+ this.provisionalSessions.get(sessionKey),
1995
2754
  typeof params.threadId === 'string' ? params.threadId : undefined,
1996
2755
  typeof params.conversationId === 'string' ? params.conversationId : undefined,
1997
2756
  ];
@@ -2000,18 +2759,35 @@ export class CodexRunner {
2000
2759
  // A carrier-bearing thread is managed even if its permission context
2001
2760
  // was not wired with an arm callback; it must fail closed here rather
2002
2761
  // than reaching daemon IPC with an unarmed carrier.
2762
+ if ((hasDelegationCarrier || canArmDelegation)
2763
+ && canonicalEcApproval?.delegationToken
2764
+ && canonicalEcApproval.delegationToken !== delegationCarrier) {
2765
+ return await denyInfrastructure('delegation_token_mismatch', 'EC command approval contains a delegation token different from the active thread carrier');
2766
+ }
2003
2767
  if ((hasDelegationCarrier || canArmDelegation)
2004
2768
  && !this.armApprovedDelegationCommand(sessionKey, approvedCommand)) {
2005
2769
  return await denyInfrastructure('delegation_not_armed', 'EC command approval failed to arm delegation');
2006
2770
  }
2007
2771
  }
2008
2772
  const response = this.toAppServerApprovalResponse(request.method, decision, toolInput, unattended);
2009
- this.logAppServerApprovalAudit(request, sessionKey, toolName, decision === 'deny' ? 'deny' : 'allow', decision === 'deny' ? (sessionMode === 'auto' || sessionMode === 'readonly' ? 'policy' : 'approval') : 'approval', decision === 'deny' && (sessionMode === 'auto' || sessionMode === 'readonly')
2010
- ? 'permission_mode_denied'
2011
- : undefined, decision === 'deny'
2012
- ? (reason || (sessionMode === 'auto' || sessionMode === 'readonly'
2013
- ? `permission mode ${sessionMode} denied approval`
2014
- : 'user approval denied'))
2773
+ const decisionSource = resolutionMeta?.decisionSource
2774
+ ?? (sessionMode === 'auto' || sessionMode === 'readonly' ? 'policy' : 'approval');
2775
+ const policyCode = resolutionMeta?.policyCode
2776
+ ?? (decisionSource === 'policy' ? 'permission_mode_denied' : undefined);
2777
+ const denialReason = resolutionMeta?.reason || reason || (policyCode
2778
+ ? `permission mode ${sessionMode} denied approval`
2779
+ : 'user approval denied');
2780
+ if (decision === 'deny') {
2781
+ this.rememberToolDenial(request, params, {
2782
+ decision: 'deny',
2783
+ decisionSource,
2784
+ ...(policyCode ? { policyCode } : {}),
2785
+ reason: denialReason,
2786
+ });
2787
+ setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, denialReason));
2788
+ }
2789
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, decision === 'deny' ? 'deny' : 'allow', decision === 'deny' ? decisionSource : 'approval', decision === 'deny' ? policyCode : undefined, decision === 'deny'
2790
+ ? denialReason
2015
2791
  : 'approval accepted');
2016
2792
  logger.info(`[CodexRunner] app-server approval response id=${request.id} method=${request.method} decision=${decision} response=${JSON.stringify(response)}`);
2017
2793
  return response;
@@ -2024,8 +2800,23 @@ export class CodexRunner {
2024
2800
  }
2025
2801
  logAppServerApprovalAudit(request, sessionKey, toolName, decision, decisionSource, policyCode, reason, parseFailure) {
2026
2802
  const context = this.permissionContexts.get(sessionKey);
2803
+ const params = (request.params || {});
2804
+ const rawCallId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
2805
+ const callId = typeof rawCallId === 'string' && rawCallId ? rawCallId : undefined;
2806
+ const toolInput = this.buildPermissionInput(request.method, params);
2807
+ const rawSummary = summarizeToolInputForAudit(toolName, toolInput);
2808
+ const auditSummary = (rawSummary
2809
+ || summarizeToolInputForAudit('Bash', { command: this.summarizeAppServerRequest(request.method, params) })).slice(0, 512);
2810
+ const threadId = this.requestThreadId(params);
2811
+ const rawCwd = typeof toolInput.cwd === 'string' && toolInput.cwd
2812
+ ? toolInput.cwd
2813
+ : typeof params.cwd === 'string' && params.cwd
2814
+ ? params.cwd
2815
+ : undefined;
2027
2816
  auditCodexApprovalDecision({
2028
2817
  requestId: request.id !== undefined ? String(request.id) : undefined,
2818
+ callId,
2819
+ correlationId: callId,
2029
2820
  method: request.method,
2030
2821
  sessionId: sessionKey,
2031
2822
  agentAid: context?.selfAid,
@@ -2036,11 +2827,16 @@ export class CodexRunner {
2036
2827
  policyCode,
2037
2828
  permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
2038
2829
  role: context?.role,
2830
+ taskId: context?.taskId,
2831
+ summary: auditSummary,
2832
+ threadId,
2833
+ cwd: rawCwd,
2039
2834
  reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
2040
2835
  parseFailure,
2041
2836
  });
2042
2837
  logger.info(`[CodexRunner] app-server approval audit ${JSON.stringify({
2043
2838
  requestId: request.id !== undefined ? String(request.id) : undefined,
2839
+ ...(callId ? { callId, correlationId: callId } : {}),
2044
2840
  method: request.method,
2045
2841
  sessionId: sessionKey,
2046
2842
  toolName,
@@ -2048,6 +2844,7 @@ export class CodexRunner {
2048
2844
  decisionSource,
2049
2845
  ...(policyCode ? { policyCode } : {}),
2050
2846
  ...(parseFailure ? { parseFailure } : {}),
2847
+ ...(auditSummary ? { summary: auditSummary } : {}),
2051
2848
  reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
2052
2849
  executed: false,
2053
2850
  })}`);
@@ -2401,9 +3198,20 @@ export class CodexRunner {
2401
3198
  if (activeThreadId === candidate)
2402
3199
  return sessionKey;
2403
3200
  }
3201
+ for (const [sessionKey, provisionalThreadId] of this.provisionalSessions.entries()) {
3202
+ if (provisionalThreadId === candidate)
3203
+ return sessionKey;
3204
+ }
3205
+ const childSessionKey = this.childThreadSessions.get(candidate);
3206
+ if (childSessionKey)
3207
+ return childSessionKey;
2404
3208
  }
2405
3209
  return candidates[0] || 'codex-app-server';
2406
3210
  }
3211
+ isThreadOwnedBySession(sessionId, threadId) {
3212
+ const activeThread = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
3213
+ return activeThread === threadId || this.childThreadSessions.get(threadId) === sessionId;
3214
+ }
2407
3215
  buildPermissionInput(method, params) {
2408
3216
  if (method === 'item/permissions/requestApproval') {
2409
3217
  return { permissions: params.permissions, cwd: params.cwd, reason: params.reason };
@@ -2465,6 +3273,12 @@ export class CodexRunner {
2465
3273
  const threadProjectPath = this.threadProjectPaths.get(threadId);
2466
3274
  if (threadProjectPath)
2467
3275
  return threadProjectPath;
3276
+ const parentSession = this.childThreadSessions.get(threadId);
3277
+ const parentThread = parentSession
3278
+ ? this.activeSessions.get(parentSession) ?? this.provisionalSessions.get(parentSession)
3279
+ : undefined;
3280
+ if (parentThread)
3281
+ return this.threadProjectPaths.get(parentThread);
2468
3282
  }
2469
3283
  return undefined;
2470
3284
  }
@@ -2595,7 +3409,7 @@ export class CodexRunner {
2595
3409
  return true;
2596
3410
  });
2597
3411
  }
2598
- async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath, requestId) {
3412
+ async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath, requestId, callId) {
2599
3413
  const permissionContext = this.permissionContexts.get(sessionKey);
2600
3414
  const permissionPrompt = permissionContext?.sendPrompt ?? this.sendPromptFn;
2601
3415
  const hasFilesystemPermissions = (toolName === 'Bash' || toolName === 'PermissionGrant')
@@ -2632,6 +3446,8 @@ export class CodexRunner {
2632
3446
  role: permissionContext?.role,
2633
3447
  selfAid: permissionContext?.selfAid,
2634
3448
  requestId: typeof requestId === 'string' || typeof requestId === 'number' ? String(requestId) : undefined,
3449
+ callId,
3450
+ correlationId: callId,
2635
3451
  taskId: permissionContext?.taskId,
2636
3452
  matchedPath,
2637
3453
  });
@@ -2662,6 +3478,7 @@ export class CodexRunner {
2662
3478
  // distinguishes this explicit empty value from runners without a
2663
3479
  // session-temp capability.
2664
3480
  managedTempDir: permissionContext?.managedTempDir ?? this.getManagedTempDir(sessionKey) ?? '',
3481
+ delegationCarrier: this.delegationCarrierForSession(sessionKey),
2665
3482
  allowProtectedMetadata: false,
2666
3483
  selfAid: permissionContext?.selfAid,
2667
3484
  channel: permissionContext?.channel,
@@ -2678,7 +3495,12 @@ export class CodexRunner {
2678
3495
  if (preflight.policyCode) {
2679
3496
  await recordBlockedOperation(preflight.policyCode, toolInput, preflight.message ?? 'tool preflight denied', preflight.matchedPath);
2680
3497
  }
2681
- return 'deny';
3498
+ return {
3499
+ decision: 'deny',
3500
+ decisionSource: 'policy',
3501
+ policyCode: preflight.policyCode ?? 'tool_preflight_denied',
3502
+ reason: preflight.message ?? 'tool preflight denied',
3503
+ };
2682
3504
  }
2683
3505
  if (preflight.behavior === 'allow')
2684
3506
  return 'allow';
@@ -2793,7 +3615,7 @@ export class CodexRunner {
2793
3615
  async rollbackSessionTurnsLocked(agentSessionId, numTurns) {
2794
3616
  if (numTurns < 1)
2795
3617
  return true;
2796
- const response = await this.getAppServerClient().threadRollback(agentSessionId, numTurns);
3618
+ const response = await this.getAppServerClientForThread(agentSessionId).threadRollback(agentSessionId, numTurns);
2797
3619
  return !!response.thread;
2798
3620
  }
2799
3621
  async rewindFiles(agentSessionId, projectPath, userMessageId) {
@@ -2992,17 +3814,138 @@ export class CodexRunner {
2992
3814
  }
2993
3815
  return !state.turnId || !turnId || turnId === state.turnId;
2994
3816
  }
2995
- async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, tempFiles) {
3817
+ isSubagentActivityItem(item) {
3818
+ return typeof item?.type === 'string' && item.type.toLowerCase() === 'subagentactivity';
3819
+ }
3820
+ subagentThreadId(item) {
3821
+ const value = item?.agentThreadId ?? item?.agent_thread_id;
3822
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
3823
+ }
3824
+ subagentPath(item) {
3825
+ const value = item?.agentPath ?? item?.agent_path;
3826
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
3827
+ }
3828
+ subagentType(item) {
3829
+ const value = item?.agentType ?? item?.agent_type;
3830
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
3831
+ }
3832
+ registerChildThread(sessionId, childThreadId, projectPath) {
3833
+ const parentThreadId = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
3834
+ if (childThreadId === parentThreadId)
3835
+ return;
3836
+ this.childThreadSessions.set(childThreadId, sessionId);
3837
+ this.threadProjectPaths.set(childThreadId, path.resolve(projectPath));
3838
+ // Child approvals use the parent's capability boundary and delegation
3839
+ // carrier. This is inheritance, not a second permission decision.
3840
+ const parentBoundary = parentThreadId ? this.threadExternalToolBoundaries.get(parentThreadId) : undefined;
3841
+ const parentFingerprint = parentThreadId ? this.threadExternalToolFingerprints.get(parentThreadId) : undefined;
3842
+ if (parentBoundary)
3843
+ this.threadExternalToolBoundaries.set(childThreadId, parentBoundary);
3844
+ if (parentFingerprint)
3845
+ this.threadExternalToolFingerprints.set(childThreadId, parentFingerprint);
3846
+ const parentCarrier = parentThreadId ? this.threadDelegationCarriers.get(parentThreadId) : undefined;
3847
+ if (parentCarrier)
3848
+ this.threadDelegationCarriers.set(childThreadId, parentCarrier);
3849
+ }
3850
+ unregisterChildThread(childThreadId) {
3851
+ this.childThreadSessions.delete(childThreadId);
3852
+ this.activeSubagentTurns.delete(childThreadId);
3853
+ this.threadProjectPaths.delete(childThreadId);
3854
+ this.threadExternalToolBoundaries.delete(childThreadId);
3855
+ this.threadExternalToolFingerprints.delete(childThreadId);
3856
+ this.threadDelegationCarriers.delete(childThreadId);
3857
+ this.clearTrackedApprovalItemsForThread(childThreadId);
3858
+ }
3859
+ cleanupChildThreadsForSession(sessionId) {
3860
+ for (const [childThreadId, ownerSessionId] of this.childThreadSessions) {
3861
+ if (ownerSessionId === sessionId)
3862
+ this.unregisterChildThread(childThreadId);
3863
+ }
3864
+ }
3865
+ trackSubagentLifecycle(notification, state, appServer) {
3866
+ const params = (notification.params || {});
3867
+ const item = params.item && typeof params.item === 'object' ? params.item : undefined;
3868
+ if (!this.isSubagentActivityItem(item))
3869
+ return;
3870
+ const childThreadId = this.subagentThreadId(item);
3871
+ if (!childThreadId)
3872
+ return;
3873
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
3874
+ if (kind === 'started') {
3875
+ state.activeSubagentThreads.add(childThreadId);
3876
+ this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
3877
+ if (appServer)
3878
+ this.markThreadSubscribed(appServer, childThreadId);
3879
+ }
3880
+ if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3881
+ state.activeSubagentThreads.delete(childThreadId);
3882
+ if (appServer)
3883
+ void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
3884
+ this.unregisterChildThread(childThreadId);
3885
+ }
3886
+ }
3887
+ primeSubagentStateFromTurn(turn, state, appServer) {
3888
+ for (const item of this.getTurnItems(turn)) {
3889
+ if (!this.isSubagentActivityItem(item))
3890
+ continue;
3891
+ const childThreadId = this.subagentThreadId(item);
3892
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
3893
+ if (!childThreadId)
3894
+ continue;
3895
+ if (kind === 'started') {
3896
+ state.activeSubagentThreads.add(childThreadId);
3897
+ this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
3898
+ if (appServer)
3899
+ this.markThreadSubscribed(appServer, childThreadId);
3900
+ }
3901
+ else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3902
+ state.activeSubagentThreads.delete(childThreadId);
3903
+ if (appServer)
3904
+ void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
3905
+ this.unregisterChildThread(childThreadId);
3906
+ }
3907
+ }
3908
+ }
3909
+ endAppServerQueueWhenSubagentsDone(queue, state) {
3910
+ if (!state.turnCompleted)
3911
+ return;
3912
+ if (state.activeSubagentThreads.size > 0) {
3913
+ logger.info(`[CodexRunner] parent turn completed; waiting for subagent thread(s): `
3914
+ + `${[...state.activeSubagentThreads].join(',')}`);
3915
+ return;
3916
+ }
3917
+ queue.end();
3918
+ }
3919
+ async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, appServer, tempFiles) {
2996
3920
  try {
2997
- yield { type: 'session_id', sessionId: state.threadId };
3921
+ let sessionIdEmitted = state.committed;
3922
+ if (sessionIdEmitted)
3923
+ yield { type: 'session_id', sessionId: state.threadId };
2998
3924
  for await (const notification of notifications) {
2999
3925
  if (!this.activeAbortControllers.has(sessionId))
3000
3926
  break;
3927
+ if (!state.committed && notification.method === 'turn/completed') {
3928
+ const turn = notification.params?.turn ?? {};
3929
+ if (turn.status === 'completed') {
3930
+ const committed = await state.commitProvisional?.();
3931
+ if (!committed) {
3932
+ const staleError = new Error('Codex provisional thread was superseded before commit');
3933
+ staleError.code = 'RUNNER_START_SUPERSEDED';
3934
+ throw staleError;
3935
+ }
3936
+ state.committed = true;
3937
+ if (!sessionIdEmitted) {
3938
+ sessionIdEmitted = true;
3939
+ yield { type: 'session_id', sessionId: state.threadId };
3940
+ }
3941
+ }
3942
+ }
3001
3943
  yield* this.mapAppServerNotification(notification, sessionId, state);
3002
3944
  }
3003
3945
  }
3004
3946
  finally {
3005
3947
  unsubscribe();
3948
+ await this.releaseThreadSubscription(appServer, state.threadId, 'turn-finished');
3006
3949
  if (this.activeAbortControllers.get(sessionId) === controller) {
3007
3950
  this.activeAbortControllers.delete(sessionId);
3008
3951
  }
@@ -3011,6 +3954,11 @@ export class CodexRunner {
3011
3954
  && (!state.turnId || activeTurn.turnId === state.turnId)) {
3012
3955
  this.activeTurns.delete(sessionId);
3013
3956
  }
3957
+ if (!state.committed)
3958
+ this.cleanupProvisionalThread(sessionId, state.threadId);
3959
+ for (const childThreadId of state.activeSubagentThreads)
3960
+ this.unregisterChildThread(childThreadId);
3961
+ state.activeSubagentThreads.clear();
3014
3962
  this.cleanupTempFiles(tempFiles);
3015
3963
  }
3016
3964
  }
@@ -3078,12 +4026,16 @@ export class CodexRunner {
3078
4026
  }
3079
4027
  case 'turn/completed': {
3080
4028
  const turn = params.turn || {};
3081
- const turnId = turn.id || params.turnId;
4029
+ const turnId = turn.id || params.turnId || state.turnId;
3082
4030
  if (turnId && state.completedTurnIds.has(turnId))
3083
4031
  break;
3084
4032
  if (turnId)
3085
4033
  state.completedTurnIds.add(turnId);
3086
- this.activeTurns.delete(sessionId);
4034
+ const activeTurn = this.activeTurns.get(sessionId);
4035
+ if (activeTurn?.threadId === state.threadId && activeTurn.turnId === turnId) {
4036
+ this.activeTurns.delete(sessionId);
4037
+ }
4038
+ this.clearPendingToolDenialsForTurn(state.threadId, turnId);
3087
4039
  yield* this.reconcileOpenAppServerToolCalls(state, turnId);
3088
4040
  if (turn.status === 'failed' && turn.error?.message) {
3089
4041
  if (isRetryableError(new Error(turn.error.message))) {
@@ -3204,11 +4156,36 @@ export class CodexRunner {
3204
4156
  };
3205
4157
  break;
3206
4158
  case 'subAgentActivity':
3207
- yield {
3208
- type: 'task_progress',
3209
- summary: `Subagent ${item.kind}: ${item.agentPath || item.agentThreadId}`,
3210
- };
4159
+ case 'SubAgentActivity': {
4160
+ const childThreadId = this.subagentThreadId(item);
4161
+ if (!childThreadId)
4162
+ break;
4163
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
4164
+ const description = this.subagentPath(item) ?? childThreadId;
4165
+ if (kind === 'started') {
4166
+ yield {
4167
+ type: 'task_started',
4168
+ taskId: childThreadId,
4169
+ taskKind: 'agent',
4170
+ description,
4171
+ subagentType: this.subagentType(item),
4172
+ };
4173
+ }
4174
+ else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
4175
+ yield {
4176
+ type: 'task_notification',
4177
+ taskId: childThreadId,
4178
+ taskKind: 'agent',
4179
+ description,
4180
+ status: kind === 'completed' ? 'completed' : kind,
4181
+ summary: description,
4182
+ };
4183
+ }
4184
+ else {
4185
+ yield { type: 'task_progress', taskId: childThreadId, taskKind: 'agent', description, summary: `Subagent ${item.kind}: ${description}` };
4186
+ }
3211
4187
  break;
4188
+ }
3212
4189
  case 'enteredReviewMode':
3213
4190
  yield { type: 'task_progress', summary: item.review || 'Review mode entered' };
3214
4191
  break;
@@ -3223,6 +4200,59 @@ export class CodexRunner {
3223
4200
  *mapAppServerItemCompleted(item, state) {
3224
4201
  if (!item)
3225
4202
  return;
4203
+ const denial = this.takeToolDenial(state, item.id);
4204
+ const denialFields = denial
4205
+ ? {
4206
+ isError: true,
4207
+ error: denial.reason,
4208
+ errorCode: errorCodeForToolDenial(denial.decisionSource, denial.policyCode),
4209
+ decision: 'deny',
4210
+ decisionSource: denial.decisionSource,
4211
+ policyCode: denial.policyCode,
4212
+ reason: denial.reason,
4213
+ requestId: denial.requestId,
4214
+ executed: false,
4215
+ executionState: 'blocked',
4216
+ }
4217
+ : undefined;
4218
+ const itemErrorText = outputTextForManagedHook(typeof item.error === 'string' ? item.error : item.error?.message);
4219
+ const hookErrorText = /(?:Command|Tool call) blocked by PreToolUse hook\s*:/i.test(itemErrorText)
4220
+ ? itemErrorText
4221
+ : undefined;
4222
+ const managedHookDenial = (item.status === 'failed' || item.status === 'declined' || hookErrorText)
4223
+ ? parseManagedPreToolUseDenial([
4224
+ item.aggregatedOutput,
4225
+ item.output,
4226
+ item.result,
4227
+ hookErrorText,
4228
+ ].filter(value => value !== undefined && value !== null).map(outputTextForManagedHook).join('\n'))
4229
+ : undefined;
4230
+ const managedHookFields = !denial && managedHookDenial
4231
+ ? {
4232
+ isError: true,
4233
+ error: managedHookDenial.reason,
4234
+ errorCode: 'POLICY_DENIED',
4235
+ decision: 'deny',
4236
+ decisionSource: 'policy',
4237
+ policyCode: managedHookDenial.policyCode,
4238
+ reason: managedHookDenial.reason,
4239
+ executed: false,
4240
+ executionState: 'blocked',
4241
+ }
4242
+ : undefined;
4243
+ const genericDeclineFields = !denial && !managedHookFields && item.status === 'declined'
4244
+ ? {
4245
+ isError: true,
4246
+ error: 'Approval declined',
4247
+ errorCode: 'USER_DENIED',
4248
+ decision: 'deny',
4249
+ decisionSource: 'approval',
4250
+ reason: 'Approval declined',
4251
+ executed: false,
4252
+ executionState: 'blocked',
4253
+ }
4254
+ : undefined;
4255
+ const blockedFields = denialFields ?? managedHookFields ?? genericDeclineFields;
3226
4256
  switch (item.type) {
3227
4257
  case 'agentMessage':
3228
4258
  {
@@ -3239,8 +4269,10 @@ export class CodexRunner {
3239
4269
  type: 'tool_result',
3240
4270
  name: 'Shell',
3241
4271
  result: item.aggregatedOutput ?? '',
3242
- isError: item.exitCode !== null && item.exitCode !== undefined ? item.exitCode !== 0 : item.status === 'failed',
3243
- ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
4272
+ ...(blockedFields ?? {
4273
+ isError: item.exitCode !== null && item.exitCode !== undefined ? item.exitCode !== 0 : item.status === 'failed' || item.status === 'declined',
4274
+ ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
4275
+ }),
3244
4276
  callId: item.id,
3245
4277
  durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
3246
4278
  };
@@ -3250,8 +4282,8 @@ export class CodexRunner {
3250
4282
  type: 'tool_result',
3251
4283
  name: `MCP:${item.server}/${item.tool}`,
3252
4284
  result: item.result,
3253
- isError: item.status === 'failed',
3254
- error: item.error?.message,
4285
+ ...(blockedFields ?? { isError: item.status === 'failed' }),
4286
+ ...(blockedFields ? {} : { error: item.error?.message }),
3255
4287
  ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
3256
4288
  callId: item.id,
3257
4289
  durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
@@ -3262,7 +4294,7 @@ export class CodexRunner {
3262
4294
  type: 'tool_result',
3263
4295
  name: item.namespace ? `${item.namespace}:${item.tool}` : item.tool,
3264
4296
  result: item.contentItems,
3265
- isError: item.success === false || item.status === 'failed',
4297
+ ...(blockedFields ?? { isError: item.success === false || item.status === 'failed' }),
3266
4298
  ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
3267
4299
  callId: item.id,
3268
4300
  durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
@@ -3277,12 +4309,12 @@ export class CodexRunner {
3277
4309
  yield editEvent;
3278
4310
  }
3279
4311
  }
3280
- yield { type: 'tool_result', name: 'Edit', result: item.changes, isError: item.status === 'failed', callId: item.id };
4312
+ yield { type: 'tool_result', name: 'Edit', result: item.changes, ...(blockedFields ?? { isError: item.status === 'failed' }), callId: item.id };
3281
4313
  }
3282
4314
  else {
3283
4315
  const desc = this.normalizeFileChanges(item.changes).map((change) => this.describeFileChange(change)).join(', ');
3284
4316
  yield { type: 'tool_use', name: 'FileChange', input: { description: desc }, callId: item.id };
3285
- yield { type: 'tool_result', name: 'FileChange', result: item.changes, isError: item.status === 'failed', callId: item.id };
4317
+ yield { type: 'tool_result', name: 'FileChange', result: item.changes, ...(blockedFields ?? { isError: item.status === 'failed' }), callId: item.id };
3286
4318
  }
3287
4319
  break;
3288
4320
  case 'webSearch':
@@ -3336,6 +4368,34 @@ export class CodexRunner {
3336
4368
  callId: item.id,
3337
4369
  };
3338
4370
  break;
4371
+ case 'subAgentActivity':
4372
+ case 'SubAgentActivity': {
4373
+ const childThreadId = this.subagentThreadId(item);
4374
+ if (!childThreadId)
4375
+ break;
4376
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
4377
+ const description = this.subagentPath(item) ?? childThreadId;
4378
+ if (kind === 'started') {
4379
+ yield {
4380
+ type: 'task_started',
4381
+ taskId: childThreadId,
4382
+ taskKind: 'agent',
4383
+ description,
4384
+ subagentType: this.subagentType(item),
4385
+ };
4386
+ }
4387
+ else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
4388
+ yield {
4389
+ type: 'task_notification',
4390
+ taskId: childThreadId,
4391
+ taskKind: 'agent',
4392
+ description,
4393
+ status: kind === 'completed' ? 'completed' : kind,
4394
+ summary: description,
4395
+ };
4396
+ }
4397
+ break;
4398
+ }
3339
4399
  }
3340
4400
  }
3341
4401
  *mapAppServerFileChangePatchUpdated(params, state) {
@@ -3466,10 +4526,10 @@ export class CodexRunner {
3466
4526
  if (!stat.isDirectory() || stat.isSymbolicLink()) {
3467
4527
  throw new Error(`managed temp path is not a real directory: ${directory}`);
3468
4528
  }
3469
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
4529
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
3470
4530
  throw new Error(`managed temp path is owned by another uid: ${directory}`);
3471
4531
  }
3472
- if ((stat.mode & 0o077) !== 0)
4532
+ if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0)
3473
4533
  fs.chmodSync(directory, 0o700);
3474
4534
  }
3475
4535
  ensureSessionTempDir(sessionId) {
@@ -3545,7 +4605,7 @@ export class CodexRunner {
3545
4605
  return undefined;
3546
4606
  return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 `/tmp/evolcore-codex-*` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 `$TMPDIR` 重新创建,再继续执行后续 `ec` 命令。';
3547
4607
  }
3548
- buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier) {
4608
+ buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier, taskDelegationToken) {
3549
4609
  const managedTempDir = this.ensureSessionTempDir(sessionId);
3550
4610
  return {
3551
4611
  shell_environment_policy: {
@@ -3554,7 +4614,9 @@ export class CodexRunner {
3554
4614
  EVOLCORE_HOME: resolveRoot(),
3555
4615
  ...(managedTempDir ? { EVOLCORE_SESSION_RUNTIME_DIR: managedTempDir } : {}),
3556
4616
  ...(managedTempDir ? { EVOLCORE_RUNTIME_LOCK_DIR: path.join(path.dirname(managedTempDir), 'evolcore-locks') } : {}),
3557
- ...(delegationCarrier ? { [AGENT_DELEGATION_TOKEN_ENV]: delegationCarrier } : {}),
4617
+ ...(taskDelegationToken || delegationCarrier
4618
+ ? { [AGENT_DELEGATION_TOKEN_ENV]: taskDelegationToken ?? delegationCarrier }
4619
+ : {}),
3558
4620
  ...(managedTempDir ? { TMPDIR: managedTempDir } : {}),
3559
4621
  },
3560
4622
  },
@@ -3583,24 +4645,35 @@ export class CodexRunner {
3583
4645
  for (const [key, controller] of this.activeAbortControllers) {
3584
4646
  controller.abort('dispose');
3585
4647
  }
4648
+ await Promise.all([...this.activeSubagentTurns.entries()].map(([threadId, active]) => this.interruptAppServerTurn(threadId, active.turnId).catch(() => { })));
4649
+ await Promise.all([...this.threadSubscriptions.entries()].map(([threadId, subscription]) => this.releaseThreadSubscription(subscription.client, threadId, 'runner-dispose')));
4650
+ this.threadSubscriptions.clear();
3586
4651
  this.activeAbortControllers.clear();
3587
4652
  this.activeStreams.clear();
3588
4653
  this.activeSessions.clear();
4654
+ this.provisionalSessions.clear();
3589
4655
  this.activeTurns.clear();
4656
+ this.activeSubagentTurns.clear();
4657
+ this.childThreadSessions.clear();
3590
4658
  this.threadProjectPaths.clear();
3591
4659
  this.threadExternalToolFingerprints.clear();
3592
4660
  this.threadExternalToolBoundaries.clear();
3593
4661
  this.trackedApprovalItems.clear();
3594
4662
  this.threadDelegationCarriers.clear();
3595
4663
  this.pendingInterrupts.clear();
4664
+ this.steeringTails.clear();
3596
4665
  this.permissionContexts.clear();
3597
4666
  this.chatModes.clear();
3598
4667
  this.pruneThreadOperationLocks();
3599
4668
  this.threadOperationLocks.clear();
3600
4669
  for (const sessionId of [...this.sessionTempDirs.keys()])
3601
4670
  this.cleanupSessionTempDir(sessionId);
3602
- await this.appServerClient?.close();
4671
+ await Promise.all([
4672
+ this.appServerClient?.close(),
4673
+ this.fullAccessAppServerClient?.close(),
4674
+ ]);
3603
4675
  this.appServerClient = null;
4676
+ this.fullAccessAppServerClient = null;
3604
4677
  }
3605
4678
  }
3606
4679
  // ── Plugin ──