evolcore 0.0.21 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/codex-managed-hook.mjs +3 -0
  3. package/bin/install-codex-managed-hooks.mjs +3 -1
  4. package/dist/agents/claude-runner.js +14 -0
  5. package/dist/agents/codex-app-server-client.js +31 -5
  6. package/dist/agents/codex-runner.js +926 -121
  7. package/dist/aun/outbox.js +7 -0
  8. package/dist/channels/aun.js +209 -35
  9. package/dist/cli/daemon-commands.js +29 -8
  10. package/dist/cli/task-context.js +4 -0
  11. package/dist/cli/trigger-command.js +13 -4
  12. package/dist/config/config-field-policy.js +3 -0
  13. package/dist/config/config-manager.js +32 -5
  14. package/dist/config/contact-book-store.js +25 -3
  15. package/dist/core/auth/agent-delegation.js +12 -0
  16. package/dist/core/auth/auth-gateway.js +8 -0
  17. package/dist/core/auth/authorization-audit.js +66 -6
  18. package/dist/core/bootstrap-messages.js +8 -0
  19. package/dist/core/bootstrap-service.js +93 -25
  20. package/dist/core/command/command-handler.js +21 -0
  21. package/dist/core/command/menu-handler.js +9 -0
  22. package/dist/core/command/menu-protocol.js +1 -1
  23. package/dist/core/command/slash-handler.js +41 -18
  24. package/dist/core/data-migration.js +11 -1
  25. package/dist/core/event-catalog.js +32 -0
  26. package/dist/core/handoff/runtime.js +23 -3
  27. package/dist/core/message/im-renderer.js +7 -3
  28. package/dist/core/message/message-bridge.js +60 -2
  29. package/dist/core/message/message-log.js +33 -0
  30. package/dist/core/message/message-queue.js +21 -0
  31. package/dist/core/message/response-engine.js +172 -41
  32. package/dist/core/permission/ec-command-parser.js +272 -70
  33. package/dist/core/permission/protected-paths.js +11 -10
  34. package/dist/core/permission/tool-error-code.js +12 -0
  35. package/dist/core/permission/tool-policy.js +46 -5
  36. package/dist/core/session/session-manager.js +30 -0
  37. package/dist/core/session/session-renew.js +18 -1
  38. package/dist/core/session/session-turn-coordinator.js +5 -1
  39. package/dist/index.js +64 -5
  40. package/dist/ipc.js +97 -17
  41. package/dist/paths.js +18 -0
  42. package/dist/response-system/engines/v1/proactive-flow.js +7 -2
  43. package/dist/stats/price-resolver.js +4 -0
  44. package/dist/trigger/feedback.js +14 -2
  45. package/dist/trigger/parser.js +10 -1
  46. package/dist/trigger/scheduler.js +20 -3
  47. package/dist/utils/logger.js +9 -4
  48. package/dist/utils/tool-summary.js +59 -0
  49. package/dist/utils/windows-shell-trust.js +201 -0
  50. package/kits/docs/evolcore/INDEX.md +2 -2
  51. package/kits/docs/evolcore/agent-create.md +146 -0
  52. package/kits/docs/evolcore/agent.md +6 -0
  53. package/kits/docs/evolcore/group-collaboration.md +251 -0
  54. package/kits/docs/evolcore/group-rules.md +1 -19
  55. package/kits/docs/evolcore/group.md +3 -1
  56. package/kits/docs/evolcore/trigger.md +6 -3
  57. package/kits/docs/prompt-loading-architecture.md +6 -0
  58. package/kits/eck_message_manifest.json +6 -6
  59. package/kits/schemas/_meta.json +3 -2
  60. package/kits/schemas/agent-config.schema.12.json +427 -0
  61. package/kits/templates/message-fragments/item.md +1 -1
  62. package/kits/templates/system-fragments/bootstrap.md +2 -1
  63. package/kits/templates/system-fragments/commands.md +2 -2
  64. package/package.json +2 -2
@@ -21,7 +21,7 @@ import { buildEnvelope, isInteractionSendAccepted, sendInteractionPayload } from
21
21
  import { resolveCodexCapabilityThreadConfigForProject } from '../core/capability/capability-manager.js';
22
22
  import { AGENT_DELEGATION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/auth/agent-delegation.js';
23
23
  import { sanitizeShellExecutionEnvironment } from '../core/permission/shell-environment.js';
24
- import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, hasCodexCmdCarrierEcIntent, parseCodexToolCommand, parseLiteralShellCommand, parseCodexShellCommandArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
24
+ import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, hasCodexCmdCarrierEcIntent, hasPowerShellDelegationAssignment, normalizeEvolcoreCommandArgv, parseCodexToolCommand, parseCodexCarrierCommand, parseLiteralShellCommand, parseCodexShellCommandArgv, resolveCodexShellCarrierArgv, } from '../core/permission/ec-command-parser.js';
25
25
  import { compareVersions } from '../utils/npm-ops.js';
26
26
  import { resolvePaths, resolveRoot } from '../paths.js';
27
27
  import { ensureProcessManagedTempDir, getManagedTaskTempDir } from '../cli/task-context.js';
@@ -83,6 +83,17 @@ class AsyncEventQueue {
83
83
  }
84
84
  }
85
85
  }
86
+ function errorCodeForToolDenial(decisionSource, policyCode) {
87
+ if (decisionSource === 'policy')
88
+ return 'POLICY_DENIED';
89
+ if (decisionSource === 'approval')
90
+ return 'USER_DENIED';
91
+ if (policyCode && /delegation|carrier/i.test(policyCode))
92
+ return 'DELEGATION_FAILED';
93
+ if (policyCode && /argument|canonical|quote|shell/i.test(policyCode))
94
+ return 'INVALID_ARGUMENT';
95
+ return 'EXECUTION_FAILED';
96
+ }
86
97
  const managedTempDirsForExitCleanup = new Map();
87
98
  let managedTempExitCleanupRegistered = false;
88
99
  function isTrustedManagedTempDirectory(directory, parent) {
@@ -150,6 +161,63 @@ function stableSecurityValue(value) {
150
161
  function externalToolConfigFingerprint(config) {
151
162
  return createHash('sha256').update(stableSecurityValue(config)).digest('hex');
152
163
  }
164
+ /**
165
+ * Codex can surface a managed hook rejection as item output instead of an
166
+ * app-server approval request. Preserve the policy boundary in that path too
167
+ * so downstream audit/event consumers never infer a successful execution.
168
+ */
169
+ function parseManagedPreToolUseDenial(value) {
170
+ let text;
171
+ if (typeof value === 'string')
172
+ text = value;
173
+ else if (value == null)
174
+ return undefined;
175
+ else {
176
+ try {
177
+ text = JSON.stringify(value) ?? '';
178
+ }
179
+ catch {
180
+ text = String(value);
181
+ }
182
+ }
183
+ const marker = /(?:Command|Tool call) blocked by PreToolUse hook\s*:\s*/i.exec(text);
184
+ if (!marker)
185
+ return undefined;
186
+ let reason = text.slice((marker.index ?? 0) + marker[0].length)
187
+ .replace(/\s*\.\s*(?:Command|Tool):[\s\S]*$/i, '')
188
+ .replace(/(?:^|\s)(?:Command|Tool):[\s\S]*$/i, '')
189
+ .trim();
190
+ if (!reason)
191
+ reason = 'EvolCore PreToolUse policy denied this tool call';
192
+ const policyCode = /工具调用已达到\s*\d+\s*次/.test(reason)
193
+ ? 'proactive_tool_report_required'
194
+ : /请先用\s+ec\s+(?:group\s+send|msg\s+send)/.test(reason)
195
+ ? 'proactive_first_send_required'
196
+ : 'managed_pre_tool_use_denied';
197
+ return { policyCode, reason };
198
+ }
199
+ function outputTextForManagedHook(value) {
200
+ if (typeof value === 'string')
201
+ return value;
202
+ if (value == null)
203
+ return '';
204
+ if (Array.isArray(value))
205
+ return value.map(outputTextForManagedHook).filter(Boolean).join('\n');
206
+ if (typeof value === 'object') {
207
+ const record = value;
208
+ if (typeof record.text === 'string')
209
+ return record.text;
210
+ if (typeof record.message === 'string')
211
+ return record.message;
212
+ try {
213
+ return JSON.stringify(value) ?? '';
214
+ }
215
+ catch {
216
+ return String(value);
217
+ }
218
+ }
219
+ return String(value);
220
+ }
153
221
  const CODEX_CATALOG_FALLBACK = [
154
222
  { slug: 'gpt-5.5', efforts: ['low', 'medium', 'high', 'xhigh'] },
155
223
  { slug: 'gpt-5.4', efforts: ['low', 'medium', 'high', 'xhigh'] },
@@ -158,6 +226,11 @@ const CODEX_CATALOG_FALLBACK = [
158
226
  { slug: 'gpt-5.2', efforts: ['low', 'medium', 'high', 'xhigh'] },
159
227
  ];
160
228
  const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
229
+ const CODEX_NO_ROLLOUT_RE = /no rollout found for thread id/i;
230
+ function isCodexNoRolloutError(error) {
231
+ return error?.code === 'CODEX_ROLLOUT_NOT_FOUND'
232
+ || CODEX_NO_ROLLOUT_RE.test(error instanceof Error ? error.message : String(error));
233
+ }
161
234
  let codexCatalogCache = null;
162
235
  // The permission bridge is version-locked to the audited approval schema and
163
236
  // managed PreToolUse hook behavior used by proactive first-tool enforcement.
@@ -256,15 +329,22 @@ export class CodexRunner {
256
329
  activeAbortControllers = new Map();
257
330
  activeStreams = new Map();
258
331
  activeSessions = new Map(); // sessionId → threadId
332
+ /** Threads created/resumed for a turn but not yet durably admitted by turn/start. */
333
+ provisionalSessions = new Map();
259
334
  activeTurns = new Map();
335
+ activeSubagentTurns = new Map();
260
336
  threadProjectPaths = new Map();
337
+ /** Child Codex threads inherit the EvolCore session that started them. */
338
+ childThreadSessions = new Map();
261
339
  sessionTempDirs = new Map();
262
340
  managedTempNonce = randomBytes(16).toString('hex');
263
341
  threadExternalToolFingerprints = new Map();
264
342
  threadExternalToolBoundaries = new Map();
265
343
  trackedApprovalItems = new Map();
344
+ pendingToolDenials = new Map();
266
345
  threadDelegationCarriers = new Map();
267
346
  pendingInterrupts = new Map();
347
+ steeringTails = new Map();
268
348
  threadOperationLocks = new Map();
269
349
  activeOperationReleases = new Map();
270
350
  appServerClient = null;
@@ -330,21 +410,27 @@ export class CodexRunner {
330
410
  createDelegationCarrier() {
331
411
  return randomBytes(32).toString('base64url');
332
412
  }
333
- approvedLiteralEvolcoreCommand(command, managedTempDir, dialect = 'posix') {
413
+ approvedLiteralEvolcoreCommand(command, managedTempDir, dialect = 'posix', expectedDelegationToken, executable) {
334
414
  const parseOptions = {
335
415
  allowManagedTmpDir: true,
336
416
  dialect,
417
+ verifyShellExecutable: process.platform === 'win32',
337
418
  ...(managedTempDir ? { managedTempDir } : {}),
419
+ ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
338
420
  };
339
421
  const classification = classifyEvolcoreShellCommand(command, parseOptions);
340
422
  if (classification.kind === 'bounded-output')
341
423
  return { argv: classification.command.argv };
342
424
  // Keep the approval/delegation parser aligned with the preflight parser:
343
425
  // the session-managed `$TMPDIR` token is a permitted EC path reference.
344
- const parsed = parseLiteralShellCommand(command, parseOptions);
426
+ const parsed = executable
427
+ ? parseCodexCarrierCommand({ command, dialect, executable }, parseOptions)
428
+ : dialect === 'powershell'
429
+ ? parseCodexCarrierCommand({ command, dialect }, parseOptions)
430
+ : parseLiteralShellCommand(command, parseOptions);
345
431
  if (!parsed.ok)
346
432
  return { parseFailure: parsed };
347
- if (parsed.argv[0] !== 'ec')
433
+ if (normalizeEvolcoreCommandArgv(parsed.argv)[0] !== 'ec')
348
434
  return {};
349
435
  if (containsLiteralManagedTmpDirOutsideSendContent(parsed.argv)) {
350
436
  return { parseFailure: {
@@ -355,14 +441,17 @@ export class CodexRunner {
355
441
  inputLength: command.length,
356
442
  } };
357
443
  }
358
- return { argv: parsed.argv };
444
+ return {
445
+ argv: normalizeEvolcoreCommandArgv(parsed.argv),
446
+ ...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
447
+ };
359
448
  }
360
- resolveCanonicalEcApproval(toolInput, managedTempDir) {
449
+ resolveCanonicalEcApproval(toolInput, managedTempDir, expectedDelegationToken) {
361
450
  const explicitArgv = Array.isArray(toolInput.commandArgv)
362
451
  && toolInput.commandArgv.every(value => typeof value === 'string')
363
452
  ? toolInput.commandArgv
364
453
  : undefined;
365
- if (explicitArgv?.[0] === 'ec') {
454
+ if (explicitArgv && normalizeEvolcoreCommandArgv(explicitArgv)[0] === 'ec') {
366
455
  if (explicitArgv.some(value => value.includes('\0'))) {
367
456
  return { parseFailure: {
368
457
  issue: 'invalid-control-char', offset: 0,
@@ -372,16 +461,19 @@ export class CodexRunner {
372
461
  }
373
462
  return containsLiteralManagedTmpDirOutsideSendContent(explicitArgv)
374
463
  ? { parseFailure: { issue: 'unsafe-expansion', offset: 0, tokenIndex: 0, dialect: 'posix', inputLength: 0 } }
375
- : { argv: explicitArgv };
464
+ : { argv: normalizeEvolcoreCommandArgv(explicitArgv) };
376
465
  }
377
466
  const explicitCarrier = explicitArgv
378
467
  ? resolveCodexShellCarrierArgv(explicitArgv)
379
468
  : undefined;
380
469
  if (explicitCarrier !== undefined) {
381
- return this.approvedLiteralEvolcoreCommand(explicitCarrier.command, managedTempDir, explicitCarrier.dialect);
470
+ return this.approvedLiteralEvolcoreCommand(explicitCarrier.command, managedTempDir, explicitCarrier.dialect, expectedDelegationToken, explicitCarrier.executable);
382
471
  }
383
472
  if (hasCodexCmdCarrierEcIntent(toolInput)) {
384
- const parsed = parseCodexToolCommand(toolInput);
473
+ const parsed = parseCodexToolCommand(toolInput, {
474
+ verifyShellExecutable: process.platform === 'win32',
475
+ ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
476
+ });
385
477
  if (!parsed.ok)
386
478
  return { parseFailure: parsed };
387
479
  if (parsed.argv[0] === 'ec') {
@@ -390,7 +482,10 @@ export class CodexRunner {
390
482
  issue: 'unsafe-expansion', offset: 0, tokenIndex: 0,
391
483
  dialect: 'cmd', inputLength: 0,
392
484
  } }
393
- : { argv: parsed.argv };
485
+ : {
486
+ argv: normalizeEvolcoreCommandArgv(parsed.argv),
487
+ ...(parsed.delegationToken ? { delegationToken: parsed.delegationToken } : {}),
488
+ };
394
489
  }
395
490
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
396
491
  const classification = classifyEvolcoreShellCommand(command, { dialect: 'cmd' });
@@ -407,7 +502,9 @@ export class CodexRunner {
407
502
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
408
503
  const canonicalCommand = parseCodexShellCommandArgv(command, {
409
504
  allowManagedTmpDir: true,
505
+ verifyShellExecutable: process.platform === 'win32',
410
506
  ...(managedTempDir ? { managedTempDir } : {}),
507
+ ...(expectedDelegationToken !== undefined ? { expectedDelegationToken } : {}),
411
508
  });
412
509
  if (canonicalCommand?.[0] === 'ec') {
413
510
  return containsLiteralManagedTmpDirOutsideSendContent(canonicalCommand)
@@ -436,11 +533,14 @@ export class CodexRunner {
436
533
  isManagedEvolcoreCommandIntent(toolInput) {
437
534
  if (Array.isArray(toolInput.commandArgv)) {
438
535
  const rawArgv = toolInput.commandArgv;
439
- if (rawArgv[0] === 'ec')
440
- return true;
441
536
  if (!rawArgv.every(value => typeof value === 'string'))
442
537
  return false;
538
+ if (normalizeEvolcoreCommandArgv(rawArgv)[0] === 'ec')
539
+ return true;
443
540
  const shellCarrier = resolveCodexShellCarrierArgv(rawArgv);
541
+ if (shellCarrier?.dialect === 'powershell'
542
+ && hasPowerShellDelegationAssignment(shellCarrier.command))
543
+ return true;
444
544
  return hasCodexCmdCarrierEcIntent(toolInput)
445
545
  || shellCarrier !== undefined
446
546
  && classifyEvolcoreShellCommand(shellCarrier.command, {
@@ -451,7 +551,7 @@ export class CodexRunner {
451
551
  return classifyEvolcoreShellCommand(command).kind !== 'none';
452
552
  }
453
553
  armApprovedDelegationCommand(sessionId, argv) {
454
- const threadId = this.activeSessions.get(sessionId);
554
+ const threadId = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
455
555
  const carrierToken = threadId ? this.threadDelegationCarriers.get(threadId) : undefined;
456
556
  const commandHash = hashDelegatedCommandArgv(argv);
457
557
  const arm = this.permissionContexts.get(sessionId)?.armApprovedDelegationCommand;
@@ -465,6 +565,21 @@ export class CodexRunner {
465
565
  return false;
466
566
  }
467
567
  }
568
+ delegationCarrierForSession(sessionId, threadId) {
569
+ const candidates = [
570
+ threadId,
571
+ this.activeSessions.get(sessionId),
572
+ this.provisionalSessions.get(sessionId),
573
+ ];
574
+ for (const candidate of candidates) {
575
+ if (!candidate)
576
+ continue;
577
+ const carrier = this.threadDelegationCarriers.get(candidate);
578
+ if (carrier)
579
+ return carrier;
580
+ }
581
+ return undefined;
582
+ }
468
583
  async readThreadDelegationCarrier(appServer, threadId) {
469
584
  try {
470
585
  const result = await appServer.threadShellCommand(threadId, `node -e "process.stdout.write(process.env.${AGENT_DELEGATION_TOKEN_ENV}||String())"`);
@@ -497,6 +612,49 @@ export class CodexRunner {
497
612
  return undefined;
498
613
  }
499
614
  }
615
+ async commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn) {
616
+ if (this.provisionalSessions.get(sessionId) !== threadId)
617
+ return false;
618
+ const bindingResult = this.onSessionIdUpdate
619
+ ? ((turn === undefined
620
+ ? await this.onSessionIdUpdate(sessionId, threadId)
621
+ : await this.onSessionIdUpdate(sessionId, threadId, { turn }))
622
+ ?? 'legacy_updated')
623
+ : 'legacy_updated';
624
+ const bindingAccepted = bindingResult === 'activated'
625
+ || bindingResult === 'already_active'
626
+ || bindingResult === 'legacy_updated';
627
+ if (!bindingAccepted) {
628
+ const error = new Error(`backend discovery rejected: ${bindingResult}`);
629
+ error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
630
+ throw error;
631
+ }
632
+ this.provisionalSessions.delete(sessionId);
633
+ this.activeSessions.set(sessionId, threadId);
634
+ if (carrierToPublish) {
635
+ this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
636
+ await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
637
+ }
638
+ this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
639
+ return true;
640
+ }
641
+ cleanupProvisionalThread(sessionId, threadId) {
642
+ if (this.provisionalSessions.get(sessionId) !== threadId)
643
+ return;
644
+ this.provisionalSessions.delete(sessionId);
645
+ this.threadProjectPaths.delete(threadId);
646
+ this.threadDelegationCarriers.delete(threadId);
647
+ this.unbindExternalToolBoundary(threadId);
648
+ this.clearTrackedApprovalItemsForThread(threadId);
649
+ // A stale provisional start can finish after a replacement run has
650
+ // already activated another parent thread for this session. Do not tear
651
+ // down the replacement run's child mappings in that race.
652
+ const activeParentThread = this.activeSessions.get(sessionId);
653
+ if (!activeParentThread || activeParentThread === threadId) {
654
+ this.cleanupChildThreadsForSession(sessionId);
655
+ }
656
+ this.pruneThreadOperationLocks();
657
+ }
500
658
  async persistDelegationCarrier(sessionManager, sessionId, threadId, carrier) {
501
659
  if (typeof sessionManager?.getSessionById !== 'function')
502
660
  return;
@@ -527,7 +685,15 @@ export class CodexRunner {
527
685
  this.appServerClient = null;
528
686
  this.threadExternalToolFingerprints.clear();
529
687
  this.threadExternalToolBoundaries.clear();
688
+ for (const childThreadId of this.childThreadSessions.keys())
689
+ this.threadProjectPaths.delete(childThreadId);
690
+ // Child turn ids belong to the discarded app-server connection. Keeping
691
+ // them would make a later session interrupt attempt target stale threads
692
+ // through the replacement client.
693
+ this.activeSubagentTurns.clear();
694
+ this.childThreadSessions.clear();
530
695
  this.trackedApprovalItems.clear();
696
+ this.pendingToolDenials.clear();
531
697
  this.threadDelegationCarriers.clear();
532
698
  client?.close().catch(error => {
533
699
  logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
@@ -900,9 +1066,9 @@ export class CodexRunner {
900
1066
  } : {}),
901
1067
  ...(mode !== 'readonly' && managedTempDir ? {
902
1068
  [managedTempDir]: 'write',
903
- [path.join(managedTempDir, '**')]: 'write',
1069
+ [managedTempDir + '/**']: 'write',
904
1070
  [path.join(path.dirname(managedTempDir), 'evolcore-locks')]: 'write',
905
- [path.join(path.dirname(managedTempDir), 'evolcore-locks', '**')]: 'write',
1071
+ [path.join(path.dirname(managedTempDir), 'evolcore-locks') + '/**']: 'write',
906
1072
  } : {}),
907
1073
  },
908
1074
  network,
@@ -949,55 +1115,145 @@ export class CodexRunner {
949
1115
  }
950
1116
  setSendPrompt(fn) { this.sendPromptFn = fn; }
951
1117
  setPermissionContext(sessionId, context) { this.permissionContexts.set(sessionId, context); }
1118
+ injectUserMessage(sessionId, text) {
1119
+ // Bind queued reminders to the turn that generated them. A completed
1120
+ // turn may be replaced before the serialized steer reaches app-server;
1121
+ // injecting the old reminder into the new turn would corrupt its context.
1122
+ const requestedTurn = this.activeTurns.get(sessionId);
1123
+ if (!requestedTurn) {
1124
+ logger.warn(`[CodexRunner] turn steer skipped because no active turn is registered: session=${sessionId}`);
1125
+ return;
1126
+ }
1127
+ const previous = this.steeringTails.get(sessionId) ?? Promise.resolve();
1128
+ let current;
1129
+ current = previous.catch(() => undefined).then(async () => {
1130
+ const activeTurn = this.activeTurns.get(sessionId);
1131
+ const sameTurn = !!activeTurn
1132
+ && activeTurn.threadId === requestedTurn.threadId
1133
+ && activeTurn.turnId === requestedTurn.turnId;
1134
+ if (!sameTurn) {
1135
+ logger.warn(`[CodexRunner] turn steer skipped because the originating turn is no longer active: `
1136
+ + `session=${sessionId} thread=${requestedTurn.threadId} turn=${requestedTurn.turnId}`);
1137
+ return;
1138
+ }
1139
+ const input = [{ type: 'text', text, text_elements: [] }];
1140
+ const client = this.getAppServerClient();
1141
+ if (typeof client.turnSteer !== 'function') {
1142
+ logger.warn(`[CodexRunner] turn steer unavailable in app-server client: session=${sessionId}`);
1143
+ return;
1144
+ }
1145
+ try {
1146
+ const response = await client.turnSteer(activeTurn.threadId, activeTurn.turnId, input);
1147
+ logger.info(`[CodexRunner] turn steer delivered: session=${sessionId} thread=${activeTurn.threadId} ` +
1148
+ `turn=${response.turnId} chars=${text.length}`);
1149
+ }
1150
+ catch (error) {
1151
+ logger.warn(`[CodexRunner] turn steer failed: session=${sessionId} thread=${activeTurn.threadId} ` +
1152
+ `turn=${activeTurn.turnId} error=${error instanceof Error ? error.message : String(error)}`);
1153
+ }
1154
+ }).finally(() => {
1155
+ if (this.steeringTails.get(sessionId) === current)
1156
+ this.steeringTails.delete(sessionId);
1157
+ });
1158
+ this.steeringTails.set(sessionId, current);
1159
+ }
952
1160
  async assertProactiveHookAvailability(sessionId, appServer) {
953
1161
  if (this.permissionContexts.get(sessionId)?.chatmode !== 'proactive')
954
1162
  return;
955
- if (process.platform === 'win32') {
956
- await appServer.assertManagedHooksAvailable();
957
- return;
958
- }
959
- if (process.platform !== 'linux') {
1163
+ if (process.platform !== 'linux' && process.platform !== 'win32') {
960
1164
  throw new Error('Codex proactive managed PreToolUse enforcement requires Linux or an installed Windows managed hook');
961
1165
  }
1166
+ await appServer.assertManagedHooksAvailable();
962
1167
  }
963
- async evaluatePreToolUse(threadId, toolName, toolInput, signal) {
1168
+ async evaluatePreToolUse(threadId, toolName, toolInput, signal, callId) {
964
1169
  const sessionKey = this.findSessionKeyByThread(threadId);
965
- const activeThread = this.activeSessions.get(sessionKey);
1170
+ const activeThread = this.activeSessions.get(sessionKey) ?? this.provisionalSessions.get(sessionKey);
966
1171
  const context = this.permissionContexts.get(sessionKey);
967
- if (!activeThread || activeThread !== threadId) {
1172
+ const normalizedInput = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
1173
+ ? toolInput
1174
+ : {};
1175
+ const recordPreToolUseDenial = async (policyCode, reason) => {
1176
+ const summary = summarizeToolInputForAudit(toolName, normalizedInput).slice(0, 512);
1177
+ auditToolPreflightDenial({
1178
+ toolName,
1179
+ policyCode,
1180
+ reason,
1181
+ summary,
1182
+ sessionId: sessionKey,
1183
+ agentAid: context?.selfAid,
1184
+ agentName: context?.agentName,
1185
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1186
+ channel: context?.channel,
1187
+ actorId: context?.userId,
1188
+ role: context?.role,
1189
+ selfAid: context?.selfAid,
1190
+ callId,
1191
+ correlationId: callId,
1192
+ taskId: context?.taskId,
1193
+ });
1194
+ try {
1195
+ await context?.recordExecutionAnomaly?.({
1196
+ code: 'operation_blocked',
1197
+ severity: 'warning',
1198
+ phase: 'execution',
1199
+ occurredAt: Date.now(),
1200
+ toolName,
1201
+ policyCode,
1202
+ decisionSource: 'policy',
1203
+ agentAid: context?.selfAid,
1204
+ agentName: context?.agentName,
1205
+ sessionId: sessionKey,
1206
+ permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1207
+ summary,
1208
+ effect: 'operation_skipped',
1209
+ });
1210
+ }
1211
+ catch (error) {
1212
+ logger.warn(`[CodexRunner] failed to record managed-hook denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
1213
+ }
1214
+ };
1215
+ if (!activeThread || !this.isThreadOwnedBySession(sessionKey, threadId)) {
968
1216
  return { ok: true, applicable: false };
969
1217
  }
970
1218
  const proactiveSession = context?.chatmode === 'proactive';
971
1219
  if (!context || (proactiveSession && !context.preToolUsePolicyHook)) {
972
- return { ok: false, applicable: true, decision: 'deny', reason: 'EvolCore proactive session policy context is unavailable' };
1220
+ const reason = 'EvolCore proactive session policy context is unavailable';
1221
+ await recordPreToolUseDenial('pre_tool_use_context_unavailable', reason);
1222
+ return { ok: false, applicable: true, decision: 'deny', reason };
973
1223
  }
974
1224
  if (context.pauseController) {
975
1225
  const pauseResult = await context.pauseController.waitAtToolBoundary(signal);
976
1226
  if (pauseResult === 'cancelled') {
977
- return { ok: true, applicable: true, decision: 'deny', reason: '工具调用已取消' };
1227
+ const reason = '工具调用已取消';
1228
+ await recordPreToolUseDenial('tool_call_cancelled', reason);
1229
+ return { ok: true, applicable: true, decision: 'deny', reason };
978
1230
  }
979
1231
  }
980
1232
  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' };
1233
+ if (this.isTrustedFullAccessSession(sessionKey))
1234
+ return { ok: true, applicable: true, decision: 'allow' };
1235
+ const reason = 'Untrusted fullaccess execution context';
1236
+ await recordPreToolUseDenial('fullaccess_context_untrusted', reason);
1237
+ return { ok: false, applicable: true, decision: 'deny', reason };
984
1238
  }
985
1239
  // A tracked interactive thread still needs an authenticated, explicit
986
1240
  // allow so the managed hook can fail closed when no runner matches.
987
1241
  if (!context.preToolUsePolicyHook)
988
1242
  return { ok: true, applicable: true, decision: 'allow' };
989
- const normalizedInput = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
990
- ? toolInput
991
- : {};
992
1243
  try {
993
1244
  const result = context.preToolUsePolicyHook(toolName, normalizedInput);
994
1245
  if (result?.block) {
995
- return { ok: true, applicable: true, decision: 'deny', reason: result.reason };
1246
+ const policyCode = result.policyCode ?? 'session_policy_hook';
1247
+ const reason = result.reason ?? 'session policy denied this tool call';
1248
+ await recordPreToolUseDenial(policyCode, reason);
1249
+ return { ok: true, applicable: true, decision: 'deny', reason };
996
1250
  }
997
1251
  return { ok: true, applicable: true, decision: 'allow' };
998
1252
  }
999
1253
  catch (error) {
1000
- return { ok: false, applicable: true, decision: 'deny', reason: `policy hook failed: ${error instanceof Error ? error.message : String(error)}` };
1254
+ const reason = `policy hook failed: ${error instanceof Error ? error.message : String(error)}`;
1255
+ await recordPreToolUseDenial('pre_tool_use_hook_failed', reason);
1256
+ return { ok: false, applicable: true, decision: 'deny', reason };
1001
1257
  }
1002
1258
  }
1003
1259
  setPermissionGateway(gw) { this.permissionGateway = gw; }
@@ -1006,9 +1262,18 @@ export class CodexRunner {
1006
1262
  this.activeStreams.set(key, stream);
1007
1263
  }
1008
1264
  cleanupStream(key) {
1265
+ const hadController = this.activeAbortControllers.has(key);
1266
+ const hadTurn = this.activeTurns.has(key);
1267
+ const provisionalThread = this.provisionalSessions.get(key);
1009
1268
  this.activeStreams.delete(key);
1010
1269
  this.activeAbortControllers.delete(key);
1011
1270
  this.pendingInterrupts.delete(key);
1271
+ // A stale runner-start path may dispose the stream without ever entering
1272
+ // transformAppServerStream. Reclaim its provisional thread, but preserve
1273
+ // a replacement run that has already installed a new controller/turn.
1274
+ if (provisionalThread && !hadController && !hadTurn) {
1275
+ this.cleanupProvisionalThread(key, provisionalThread);
1276
+ }
1012
1277
  this.chatModes.delete(key);
1013
1278
  const release = this.activeOperationReleases.get(key);
1014
1279
  if (release) {
@@ -1064,14 +1329,20 @@ export class CodexRunner {
1064
1329
  }
1065
1330
  }
1066
1331
  isActiveThreadOperationAlias(key) {
1067
- if (key.startsWith('session:'))
1068
- return this.activeSessions.has(key.slice('session:'.length));
1332
+ if (key.startsWith('session:')) {
1333
+ const sessionId = key.slice('session:'.length);
1334
+ return this.activeSessions.has(sessionId) || this.provisionalSessions.has(sessionId);
1335
+ }
1069
1336
  if (key.startsWith('thread:')) {
1070
1337
  const threadId = key.slice('thread:'.length);
1071
1338
  for (const activeThreadId of this.activeSessions.values()) {
1072
1339
  if (activeThreadId === threadId)
1073
1340
  return true;
1074
1341
  }
1342
+ for (const provisionalThreadId of this.provisionalSessions.values()) {
1343
+ if (provisionalThreadId === threadId)
1344
+ return true;
1345
+ }
1075
1346
  return false;
1076
1347
  }
1077
1348
  return true;
@@ -1157,7 +1428,18 @@ export class CodexRunner {
1157
1428
  let agentSessionId = topicBinding
1158
1429
  ? (topicAgentSessionId || undefined)
1159
1430
  : initialAgentSessionId || this.activeSessions.get(sessionId);
1160
- const resumingThread = !!agentSessionId;
1431
+ if (!topicBinding && !initialAgentSessionId && typeof sessionManager?.getSessionById === 'function') {
1432
+ try {
1433
+ const persisted = await sessionManager.getSessionById(sessionId);
1434
+ agentSessionId = typeof persisted?.agentSessionId === 'string' && persisted.agentSessionId.trim()
1435
+ ? persisted.agentSessionId.trim()
1436
+ : undefined;
1437
+ }
1438
+ catch (error) {
1439
+ logger.debug(`[CodexRunner] persisted main backend lookup failed; using local cache: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
1440
+ }
1441
+ }
1442
+ let resumingThread = !!agentSessionId;
1161
1443
  const callModel = modelOverride?.model || this.model;
1162
1444
  const callEffort = modelOverride?.effortMode === 'model_default'
1163
1445
  ? undefined
@@ -1190,13 +1472,13 @@ export class CodexRunner {
1190
1472
  if (agentSessionId && !knownDelegationCarrier) {
1191
1473
  knownDelegationCarrier = await this.loadPersistedDelegationCarrier(sessionManager, sessionId, agentSessionId);
1192
1474
  }
1193
- const delegationCarrierCandidate = knownDelegationCarrier ?? this.createDelegationCarrier();
1475
+ let delegationCarrierCandidate = knownDelegationCarrier ?? this.createDelegationCarrier();
1194
1476
  // Keep newly discovered carrier state local until generation-aware
1195
1477
  // activation accepts the backend. A stale run must not publish a carrier
1196
1478
  // that a later generation could mistake for its own thread.
1197
1479
  let carrierToPublish;
1198
1480
  const hasTaskDelegation = typeof runtimeEnv?.[AGENT_DELEGATION_TOKEN_ENV] === 'string';
1199
- const threadOptions = {
1481
+ let threadOptions = {
1200
1482
  model: callModel,
1201
1483
  effort: callEffort,
1202
1484
  approvalPolicy: effectiveApprovalPolicy,
@@ -1207,9 +1489,47 @@ export class CodexRunner {
1207
1489
  };
1208
1490
  logger.info(`[CodexRunner] runQuery permMode=${requestedPermissionMode}->${callMode} ` +
1209
1491
  `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);
1492
+ let threadResponse;
1493
+ if (agentSessionId) {
1494
+ try {
1495
+ threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
1496
+ }
1497
+ catch (error) {
1498
+ // A legacy main session can retain a provider ID after its rollout was
1499
+ // removed externally. It is safe to recover before input submission;
1500
+ // topic bindings remain authoritative and fail closed.
1501
+ if (topicBinding || !isCodexNoRolloutError(error))
1502
+ throw error;
1503
+ if (typeof sessionManager?.clearMainSessionBackendIfMatches !== 'function')
1504
+ throw error;
1505
+ const missingThreadId = agentSessionId;
1506
+ const cleared = await sessionManager.clearMainSessionBackendIfMatches(sessionId, missingThreadId, [CODEX_DELEGATION_CARRIER_METADATA_KEY]);
1507
+ if (cleared !== 'cleared' && cleared !== 'already_clear') {
1508
+ const recoveryError = new Error(`Codex missing-rollout recovery rejected: ${cleared}`);
1509
+ recoveryError.code = 'CODEX_MISSING_ROLLOUT_RECOVERY_REJECTED';
1510
+ throw recoveryError;
1511
+ }
1512
+ if (this.activeSessions.get(sessionId) === missingThreadId)
1513
+ this.activeSessions.delete(sessionId);
1514
+ this.threadProjectPaths.delete(missingThreadId);
1515
+ this.threadDelegationCarriers.delete(missingThreadId);
1516
+ this.unbindExternalToolBoundary(missingThreadId);
1517
+ this.clearTrackedApprovalItemsForThread(missingThreadId);
1518
+ agentSessionId = undefined;
1519
+ resumingThread = false;
1520
+ knownDelegationCarrier = undefined;
1521
+ delegationCarrierCandidate = this.createDelegationCarrier();
1522
+ threadOptions = {
1523
+ ...threadOptions,
1524
+ config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate), this.buildLifecycleLockdownConfig(callMode)), callMode),
1525
+ };
1526
+ logger.warn(`[CodexRunner] recovered missing rollout before input submission: session=${sessionId} thread=${missingThreadId}`);
1527
+ threadResponse = await appServer.threadStart(projectPath, threadOptions);
1528
+ }
1529
+ }
1530
+ else {
1531
+ threadResponse = await appServer.threadStart(projectPath, threadOptions);
1532
+ }
1213
1533
  let threadId = threadResponse.thread?.id || agentSessionId;
1214
1534
  if (!threadId)
1215
1535
  throw new Error('Codex app-server did not return a thread id');
@@ -1247,28 +1567,39 @@ export class CodexRunner {
1247
1567
  else if (knownDelegationCarrier) {
1248
1568
  carrierToPublish = { threadId, carrier: knownDelegationCarrier };
1249
1569
  }
1250
- agentSessionId = threadId;
1251
1570
  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;
1571
+ if (!resumingThread) {
1572
+ // Keep a newly-created backend private to this runner until turn/start
1573
+ // has been accepted. In particular, a pending interrupt must not leave
1574
+ // a durable ID for a thread that never produced a rollout.
1575
+ this.provisionalSessions.set(sessionId, threadId);
1576
+ if (carrierToPublish) {
1577
+ this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
1578
+ }
1265
1579
  }
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);
1580
+ else {
1581
+ agentSessionId = threadId;
1582
+ const bindingResult = this.onSessionIdUpdate
1583
+ ? ((turn === undefined
1584
+ ? await this.onSessionIdUpdate(sessionId, threadId)
1585
+ : await this.onSessionIdUpdate(sessionId, threadId, { turn }))
1586
+ ?? 'legacy_updated')
1587
+ : 'legacy_updated';
1588
+ const bindingAccepted = bindingResult === 'activated'
1589
+ || bindingResult === 'already_active'
1590
+ || bindingResult === 'legacy_updated';
1591
+ if (!bindingAccepted) {
1592
+ const error = new Error(`backend discovery rejected: ${bindingResult}`);
1593
+ error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
1594
+ throw error;
1595
+ }
1596
+ this.activeSessions.set(sessionId, threadId);
1597
+ if (carrierToPublish) {
1598
+ this.threadDelegationCarriers.set(carrierToPublish.threadId, carrierToPublish.carrier);
1599
+ await this.persistDelegationCarrier(sessionManager, sessionId, carrierToPublish.threadId, carrierToPublish.carrier);
1600
+ }
1601
+ this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
1270
1602
  }
1271
- this.bindThreadOperationKeys(`session:${sessionId}`, `thread:${threadId}`);
1272
1603
  this.threadProjectPaths.set(threadId, path.resolve(projectPath));
1273
1604
  this.bindExternalToolBoundary(threadId, externalToolConfig);
1274
1605
  const controller = new AbortController();
@@ -1279,21 +1610,55 @@ export class CodexRunner {
1279
1610
  controller.signal.addEventListener('abort', () => queue.end(), { once: true });
1280
1611
  const state = {
1281
1612
  threadId,
1613
+ sessionId,
1614
+ projectPath,
1282
1615
  model: callModel,
1616
+ committed: resumingThread,
1283
1617
  streamedAgentMessageIds: new Set(),
1284
1618
  agentMessageDeltaText: new Map(),
1285
1619
  completedItemIds: new Set(),
1286
1620
  emittedEditCallIds: new Set(),
1287
1621
  completedTurnIds: new Set(),
1288
1622
  openToolCalls: new Map(),
1623
+ activeSubagentThreads: new Set(),
1624
+ turnCompleted: false,
1289
1625
  };
1290
1626
  const unsubscribe = appServer.onNotification(notification => {
1291
1627
  // 仅从 turn/started 锁定权威 turnId — resume 时会有上一轮 turn 的残留通知
1292
1628
  // (如 thread/tokenUsage/updated)先于新 turn 到达,不能用它们 latch turnId
1293
1629
  const params = notification.params || {};
1294
1630
  const notifThreadId = params.threadId ?? params.thread_id;
1295
- if (notifThreadId !== undefined && notifThreadId !== threadId)
1631
+ const item = params.item && typeof params.item === 'object' ? params.item : undefined;
1632
+ const emittedByChild = typeof notifThreadId === 'string'
1633
+ && this.childThreadSessions.get(notifThreadId) === sessionId;
1634
+ if (notifThreadId !== undefined && notifThreadId !== threadId && !emittedByChild)
1635
+ return;
1636
+ this.trackSubagentLifecycle(notification, state);
1637
+ if (emittedByChild && notifThreadId !== threadId) {
1638
+ const childTurnId = this.extractTurnId(notification);
1639
+ if (notification.method === 'turn/started' && childTurnId) {
1640
+ this.activeSubagentTurns.set(notifThreadId, {
1641
+ sessionId,
1642
+ parentThreadId: threadId,
1643
+ turnId: childTurnId,
1644
+ });
1645
+ }
1646
+ else if (notification.method === 'turn/completed') {
1647
+ const activeChildTurn = this.activeSubagentTurns.get(notifThreadId);
1648
+ if (!childTurnId || activeChildTurn?.turnId === childTurnId) {
1649
+ this.activeSubagentTurns.delete(notifThreadId);
1650
+ }
1651
+ state.activeSubagentThreads.delete(notifThreadId);
1652
+ this.unregisterChildThread(notifThreadId);
1653
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1654
+ }
1655
+ if (this.isSubagentActivityItem(item))
1656
+ queue.push(notification);
1657
+ if (notification.method === 'item/completed' && state.turnCompleted) {
1658
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1659
+ }
1296
1660
  return;
1661
+ }
1297
1662
  if (notification.method === 'turn/started') {
1298
1663
  const startedTurnId = this.extractTurnId(notification);
1299
1664
  if (startedTurnId && !state.turnId) {
@@ -1309,13 +1674,20 @@ export class CodexRunner {
1309
1674
  return;
1310
1675
  queue.push(notification);
1311
1676
  // 仅在已锁定 turnId 后才允许 turn/completed 结束队列,避免残留的旧 turn/completed 误关
1312
- if (notification.method === 'turn/completed' && state.turnId)
1313
- queue.end();
1677
+ if (notification.method === 'turn/completed' && state.turnId) {
1678
+ state.turnCompleted = true;
1679
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1680
+ }
1681
+ else if (notification.method === 'item/completed' && state.turnCompleted) {
1682
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1683
+ }
1314
1684
  });
1315
1685
  if (this.consumePendingInterrupt(sessionId)) {
1316
1686
  controller.abort('User interrupt');
1317
1687
  this.activeAbortControllers.delete(sessionId);
1318
1688
  this.activeStreams.delete(sessionId);
1689
+ if (!resumingThread)
1690
+ this.cleanupProvisionalThread(sessionId, threadId);
1319
1691
  logger.info(`[CodexRunner] Applied pending interrupt before turn start: ${sessionId}`);
1320
1692
  return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
1321
1693
  }
@@ -1327,6 +1699,14 @@ export class CodexRunner {
1327
1699
  approvalPolicy: effectiveApprovalPolicy,
1328
1700
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1329
1701
  });
1702
+ if (!resumingThread) {
1703
+ state.commitProvisional = async () => {
1704
+ const committed = await this.commitProvisionalThread(sessionId, threadId, carrierToPublish, sessionManager, turn);
1705
+ if (committed)
1706
+ agentSessionId = threadId;
1707
+ return committed;
1708
+ };
1709
+ }
1330
1710
  const turnId = turnResponse.turn?.id;
1331
1711
  if (turnId && !state.turnId) {
1332
1712
  state.turnId = turnId;
@@ -1341,8 +1721,10 @@ export class CodexRunner {
1341
1721
  }
1342
1722
  const status = turnResponse.turn?.status;
1343
1723
  if (status === 'completed' || status === 'failed') {
1724
+ this.primeSubagentStateFromTurn(turnResponse.turn, state);
1344
1725
  queue.push({ method: 'turn/completed', params: { threadId, turn: turnResponse.turn } });
1345
- queue.end();
1726
+ state.turnCompleted = true;
1727
+ this.endAppServerQueueWhenSubagentsDone(queue, state);
1346
1728
  }
1347
1729
  }
1348
1730
  catch (error) {
@@ -1351,6 +1733,8 @@ export class CodexRunner {
1351
1733
  this.activeTurns.delete(sessionId);
1352
1734
  this.pendingInterrupts.delete(sessionId);
1353
1735
  this.cleanupTempFiles(tempFiles);
1736
+ if (!resumingThread)
1737
+ this.cleanupProvisionalThread(sessionId, threadId);
1354
1738
  throw error;
1355
1739
  }
1356
1740
  return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
@@ -1364,6 +1748,7 @@ export class CodexRunner {
1364
1748
  const interruptTurn = activeTurn
1365
1749
  ? this.interruptAppServerTurn(activeTurn.threadId, activeTurn.turnId)
1366
1750
  : Promise.resolve();
1751
+ const interruptSubagents = this.interruptSubagentTurns(sessionKey);
1367
1752
  if (!activeTurn)
1368
1753
  this.rememberPendingInterrupt(sessionKey);
1369
1754
  if (controller)
@@ -1374,7 +1759,17 @@ export class CodexRunner {
1374
1759
  this.activeTurns.delete(sessionKey);
1375
1760
  logger.info(`[CodexRunner] Interrupted session: ${sessionKey}`);
1376
1761
  }
1377
- await interruptTurn;
1762
+ await Promise.all([interruptTurn, interruptSubagents]);
1763
+ }
1764
+ async interruptSubagentTurns(sessionId, parentThreadId) {
1765
+ const entries = [...this.activeSubagentTurns.entries()]
1766
+ .filter(([, active]) => active.sessionId === sessionId && (!parentThreadId || active.parentThreadId === parentThreadId));
1767
+ await Promise.all(entries.map(async ([childThreadId, active]) => {
1768
+ await this.interruptAppServerTurn(childThreadId, active.turnId).catch(() => { });
1769
+ const current = this.activeSubagentTurns.get(childThreadId);
1770
+ if (current === active)
1771
+ this.activeSubagentTurns.delete(childThreadId);
1772
+ }));
1378
1773
  }
1379
1774
  rememberPendingInterrupt(sessionId) {
1380
1775
  this.pendingInterrupts.set(sessionId, Date.now());
@@ -1437,10 +1832,12 @@ export class CodexRunner {
1437
1832
  }
1438
1833
  async closeSession(sessionId) {
1439
1834
  const capturedThreadId = this.activeSessions.get(sessionId);
1835
+ const capturedProvisionalThreadId = this.provisionalSessions.get(sessionId);
1440
1836
  const capturedPermissionContext = this.permissionContexts.get(sessionId);
1441
1837
  const capturedStream = this.activeStreams.get(sessionId);
1442
1838
  const capturedController = this.activeAbortControllers.get(sessionId);
1443
1839
  const capturedTurn = this.activeTurns.get(sessionId);
1840
+ const capturedSteeringTail = this.steeringTails.get(sessionId);
1444
1841
  this.permissionContexts.get(sessionId)?.pauseController?.cancel();
1445
1842
  // Do not call interrupt(sessionId) here: a new run may have replaced the
1446
1843
  // session-scoped maps while an older close was waiting. Interrupt and
@@ -1448,6 +1845,13 @@ export class CodexRunner {
1448
1845
  if (capturedTurn) {
1449
1846
  await this.interruptAppServerTurn(capturedTurn.threadId, capturedTurn.turnId).catch(() => { });
1450
1847
  }
1848
+ const capturedParentThreadId = capturedThreadId ?? capturedProvisionalThreadId;
1849
+ if (capturedParentThreadId) {
1850
+ await this.interruptSubagentTurns(sessionId, capturedParentThreadId);
1851
+ }
1852
+ else if (!this.activeSessions.has(sessionId) && !this.provisionalSessions.has(sessionId)) {
1853
+ await this.interruptSubagentTurns(sessionId);
1854
+ }
1451
1855
  capturedController?.abort('Session closed');
1452
1856
  const lifecycleReplaced = (capturedStream !== undefined && this.activeStreams.get(sessionId) !== capturedStream)
1453
1857
  || (capturedController !== undefined && this.activeAbortControllers.get(sessionId) !== capturedController)
@@ -1464,6 +1868,10 @@ export class CodexRunner {
1464
1868
  this.pendingInterrupts.delete(sessionId);
1465
1869
  if (this.permissionContexts.get(sessionId) === capturedPermissionContext)
1466
1870
  this.permissionContexts.delete(sessionId);
1871
+ if (this.steeringTails.get(sessionId) === capturedSteeringTail)
1872
+ this.steeringTails.delete(sessionId);
1873
+ if (capturedProvisionalThreadId)
1874
+ this.cleanupProvisionalThread(sessionId, capturedProvisionalThreadId);
1467
1875
  // Session-level configuration and thread indexes are shared by later
1468
1876
  // runs. Only remove them when the captured lifecycle still owns the key.
1469
1877
  if (!lifecycleReplaced) {
@@ -1477,6 +1885,7 @@ export class CodexRunner {
1477
1885
  this.unbindExternalToolBoundary(capturedThreadId);
1478
1886
  }
1479
1887
  this.clearTrackedApprovalItemsForThread(capturedThreadId);
1888
+ this.cleanupChildThreadsForSession(sessionId);
1480
1889
  this.pruneThreadOperationLocks();
1481
1890
  }
1482
1891
  }
@@ -1505,6 +1914,8 @@ export class CodexRunner {
1505
1914
  // Codex: 清空会话 = 下次 runQuery 不传 resumeId,自动创建新 thread
1506
1915
  const threadId = this.activeSessions.get(sessionId) ?? _agentSessionId;
1507
1916
  this.activeSessions.delete(sessionId);
1917
+ this.activeTurns.delete(sessionId);
1918
+ this.steeringTails.delete(sessionId);
1508
1919
  this.chatModes.delete(sessionId);
1509
1920
  this.cleanupSessionTempDir(sessionId);
1510
1921
  this.threadProjectPaths.delete(threadId);
@@ -1774,13 +2185,14 @@ export class CodexRunner {
1774
2185
  trackApprovalItemNotification(notification) {
1775
2186
  const params = (notification.params || {});
1776
2187
  const item = params.item && typeof params.item === 'object' ? params.item : undefined;
1777
- const itemId = item?.id ?? params.itemId;
2188
+ const itemId = item?.id ?? params.itemId ?? params.item_id;
1778
2189
  const threadId = this.requestThreadId(params);
1779
- const key = this.approvalItemKey(threadId, params.turnId, itemId);
2190
+ const turnId = params.turnId ?? params.turn_id;
2191
+ const key = this.approvalItemKey(threadId, turnId, itemId);
1780
2192
  if (notification.method === 'item/started' && key && item && threadId) {
1781
2193
  this.trackedApprovalItems.set(key, {
1782
2194
  threadId,
1783
- turnId: params.turnId,
2195
+ turnId,
1784
2196
  itemId,
1785
2197
  item: { ...item },
1786
2198
  });
@@ -1790,7 +2202,7 @@ export class CodexRunner {
1790
2202
  const tracked = this.trackedApprovalItems.get(key);
1791
2203
  this.trackedApprovalItems.set(key, {
1792
2204
  threadId,
1793
- turnId: params.turnId,
2205
+ turnId,
1794
2206
  itemId,
1795
2207
  item: {
1796
2208
  ...(tracked?.item ?? { id: itemId, type: 'fileChange' }),
@@ -1814,22 +2226,40 @@ export class CodexRunner {
1814
2226
  this.trackedApprovalItems.delete(trackedKey);
1815
2227
  }
1816
2228
  }
2229
+ this.clearPendingToolDenialsForTurn(threadId, completedTurnId);
1817
2230
  }
1818
2231
  }
1819
2232
  findTrackedApprovalItem(params) {
1820
- const key = this.approvalItemKey(this.requestThreadId(params), params.turnId, params.itemId);
2233
+ const key = this.approvalItemKey(this.requestThreadId(params), params.turnId ?? params.turn_id, params.itemId ?? params.item_id);
1821
2234
  return key ? this.trackedApprovalItems.get(key)?.item : undefined;
1822
2235
  }
1823
2236
  requestThreadId(params) {
1824
- const threadId = typeof params.threadId === 'string' && params.threadId.length > 0
1825
- ? params.threadId
2237
+ const threadIdValue = params.threadId ?? params.thread_id;
2238
+ const threadId = typeof threadIdValue === 'string' && threadIdValue.length > 0
2239
+ ? threadIdValue
1826
2240
  : undefined;
1827
2241
  if (threadId)
1828
2242
  return threadId;
1829
- return typeof params.conversationId === 'string' && params.conversationId.length > 0
1830
- ? params.conversationId
2243
+ const conversationIdValue = params.conversationId ?? params.conversation_id;
2244
+ return typeof conversationIdValue === 'string' && conversationIdValue.length > 0
2245
+ ? conversationIdValue
1831
2246
  : undefined;
1832
2247
  }
2248
+ clearPendingToolDenialsForTurn(threadId, turnId) {
2249
+ for (const key of this.pendingToolDenials.keys()) {
2250
+ let parsed;
2251
+ try {
2252
+ parsed = JSON.parse(key);
2253
+ }
2254
+ catch {
2255
+ continue;
2256
+ }
2257
+ if (!Array.isArray(parsed) || parsed[0] !== threadId)
2258
+ continue;
2259
+ if (!turnId || parsed[1] === turnId)
2260
+ this.pendingToolDenials.delete(key);
2261
+ }
2262
+ }
1833
2263
  clearTrackedApprovalItemsForThread(threadId) {
1834
2264
  if (!threadId)
1835
2265
  return;
@@ -1837,6 +2267,73 @@ export class CodexRunner {
1837
2267
  if (tracked.threadId === threadId)
1838
2268
  this.trackedApprovalItems.delete(key);
1839
2269
  }
2270
+ for (const key of this.pendingToolDenials.keys()) {
2271
+ let parsed;
2272
+ try {
2273
+ parsed = JSON.parse(key);
2274
+ }
2275
+ catch {
2276
+ continue;
2277
+ }
2278
+ if (Array.isArray(parsed) && parsed[0] === threadId)
2279
+ this.pendingToolDenials.delete(key);
2280
+ }
2281
+ }
2282
+ rememberToolDenial(request, params, denial) {
2283
+ const itemId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
2284
+ const threadId = this.requestThreadId(params);
2285
+ const rawTurnId = params.turnId ?? params.turn_id;
2286
+ const turnId = typeof rawTurnId === 'string' ? rawTurnId : this.findActiveTurnId(threadId);
2287
+ const key = this.approvalItemKey(threadId, turnId, itemId);
2288
+ if (!key)
2289
+ return;
2290
+ this.pendingToolDenials.set(key, {
2291
+ ...denial,
2292
+ ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
2293
+ method: request.method,
2294
+ });
2295
+ }
2296
+ steerPolicyReason(sessionId, policyCode, reason) {
2297
+ const prefix = policyCode ? `策略拒绝(${policyCode})` : '策略拒绝';
2298
+ this.injectUserMessage(sessionId, `⚠️ ${prefix}:${reason}。命令未执行,请按提示重新执行正确的命令。`);
2299
+ }
2300
+ findActiveTurnId(threadId) {
2301
+ if (!threadId)
2302
+ return undefined;
2303
+ for (const active of this.activeTurns.values()) {
2304
+ if (active.threadId === threadId)
2305
+ return active.turnId;
2306
+ }
2307
+ return undefined;
2308
+ }
2309
+ takeToolDenial(state, itemId) {
2310
+ const exactKey = this.approvalItemKey(state.threadId, state.turnId, itemId);
2311
+ if (exactKey) {
2312
+ const exact = this.pendingToolDenials.get(exactKey);
2313
+ if (exact) {
2314
+ this.pendingToolDenials.delete(exactKey);
2315
+ return exact;
2316
+ }
2317
+ }
2318
+ // Legacy approval callbacks may not carry turnId. Consume the unique
2319
+ // matching item for this thread rather than losing the structured reason.
2320
+ const candidates = [];
2321
+ for (const [key, denial] of this.pendingToolDenials) {
2322
+ let parsed;
2323
+ try {
2324
+ parsed = JSON.parse(key);
2325
+ }
2326
+ catch {
2327
+ continue;
2328
+ }
2329
+ if (!Array.isArray(parsed) || parsed[0] !== state.threadId || String(parsed[2]) !== String(itemId))
2330
+ continue;
2331
+ candidates.push([key, denial]);
2332
+ }
2333
+ if (candidates.length !== 1)
2334
+ return undefined;
2335
+ this.pendingToolDenials.delete(candidates[0][0]);
2336
+ return candidates[0][1];
1840
2337
  }
1841
2338
  async handleAppServerRequest(request) {
1842
2339
  const params = (request.params || {});
@@ -1898,41 +2395,46 @@ export class CodexRunner {
1898
2395
  // workspace, or unattended-trigger policy checks.
1899
2396
  if ((this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess') {
1900
2397
  if (!this.isTrustedFullAccessSession(sessionKey)) {
2398
+ const reason = 'Untrusted fullaccess execution context';
2399
+ this.rememberToolDenial(request, params, {
2400
+ decision: 'deny',
2401
+ decisionSource: 'policy',
2402
+ policyCode: 'fullaccess_context_untrusted',
2403
+ reason,
2404
+ });
2405
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', 'fullaccess_context_untrusted', reason);
2406
+ setImmediate(() => this.steerPolicyReason(sessionKey, 'fullaccess_context_untrusted', reason));
1901
2407
  return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1902
2408
  }
1903
2409
  const response = this.toAppServerApprovalResponse(request.method, 'allow', toolInput, false);
1904
2410
  this.logAppServerApprovalAudit(request, sessionKey, toolName, 'allow', 'approval', undefined, 'trusted fullaccess execution');
1905
2411
  return response;
1906
2412
  }
1907
- // proactive 模式行为策略(首次工具调用必须是 ec msg send)
2413
+ // Keep the policy check in the approval path as a fail-closed fallback.
2414
+ // Some app-server tool routes can reach approval without invoking the
2415
+ // command hook; allowing here would bypass the proactive gate.
1908
2416
  const policyResult = this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
1909
2417
  if (policyResult?.block) {
1910
2418
  logger.info(`[CodexRunner] app-server approval declined by session policy: session=${sessionKey} ` +
1911
2419
  `requestId=${request.id ?? '<missing>'} code=${policyResult.policyCode ?? 'session_policy_hook'} ` +
1912
2420
  `reason=${policyResult.reason ?? 'policy denied'} executed=false`);
1913
- try {
1914
- await permissionContext?.recordExecutionAnomaly?.({
1915
- code: 'operation_blocked',
1916
- severity: 'warning',
1917
- phase: 'execution',
1918
- occurredAt: Date.now(),
1919
- toolName,
1920
- ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
1921
- policyCode: policyResult.policyCode ?? 'session_policy_hook',
1922
- decisionSource: 'policy',
1923
- agentAid: permissionContext?.selfAid,
1924
- agentName: permissionContext?.agentName,
1925
- sessionId: sessionKey,
1926
- permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
1927
- summary: summarizeToolInputForAudit(toolName, toolInput).slice(0, 512),
1928
- effect: 'operation_skipped',
1929
- });
1930
- }
1931
- catch (error) {
1932
- logger.warn(`[CodexRunner] failed to record policy-hook denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
2421
+ const policyCode = policyResult.policyCode ?? 'session_policy_hook';
2422
+ const policyReason = policyResult.reason ?? 'session policy denied approval';
2423
+ this.rememberToolDenial(request, params, {
2424
+ decision: 'deny',
2425
+ decisionSource: 'policy',
2426
+ policyCode,
2427
+ reason: policyReason,
2428
+ });
2429
+ // ResponseEngine's wrapped policy hook already steers Codex sessions
2430
+ // that have the pure managed-hook policy installed. Standalone callers
2431
+ // may only provide policyHook, so keep this fallback for them without
2432
+ // duplicating the same user message in production.
2433
+ if (!permissionContext?.preToolUsePolicyHook) {
2434
+ setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, policyReason));
1933
2435
  }
1934
2436
  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');
2437
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', policyCode, policyReason);
1936
2438
  return response;
1937
2439
  }
1938
2440
  const summary = this.summarizeAppServerRequest(request.method, params);
@@ -1941,7 +2443,14 @@ export class CodexRunner {
1941
2443
  if (!workspacePath) {
1942
2444
  logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
1943
2445
  const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2446
+ this.rememberToolDenial(request, params, {
2447
+ decision: 'deny',
2448
+ decisionSource: 'infrastructure',
2449
+ policyCode: 'approval_workspace_unknown',
2450
+ reason: 'thread workspace is unknown',
2451
+ });
1944
2452
  this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', 'approval_workspace_unknown', 'thread workspace is unknown');
2453
+ setImmediate(() => this.steerPolicyReason(sessionKey, 'approval_workspace_unknown', 'thread workspace is unknown'));
1945
2454
  return response;
1946
2455
  }
1947
2456
  const operationCwd = this.resolvePermissionOperationCwd(params, workspacePath, toolName);
@@ -1951,8 +2460,9 @@ export class CodexRunner {
1951
2460
  const isCommandApproval = request.method === 'item/commandExecution/requestApproval'
1952
2461
  || request.method === 'execCommandApproval';
1953
2462
  const managedEcIntent = isCommandApproval && this.isManagedEvolcoreCommandIntent(toolInput);
2463
+ const delegationCarrier = this.delegationCarrierForSession(sessionKey, typeof params.threadId === 'string' ? params.threadId : undefined);
1954
2464
  const canonicalEcApproval = isCommandApproval
1955
- ? this.resolveCanonicalEcApproval(toolInput, this.getManagedTempDir(sessionKey))
2465
+ ? this.resolveCanonicalEcApproval(toolInput, this.getManagedTempDir(sessionKey), delegationCarrier)
1956
2466
  : undefined;
1957
2467
  const approvedCommand = canonicalEcApproval?.argv;
1958
2468
  const denyInfrastructure = async (policyCode, message) => {
@@ -1979,19 +2489,32 @@ export class CodexRunner {
1979
2489
  logger.warn(`[CodexRunner] failed to record infrastructure approval denial: session=${sessionKey} code=${policyCode} error=${error instanceof Error ? error.message : String(error)}`);
1980
2490
  }
1981
2491
  const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2492
+ this.rememberToolDenial(request, params, {
2493
+ decision: 'deny',
2494
+ decisionSource: 'infrastructure',
2495
+ policyCode,
2496
+ reason: message,
2497
+ });
1982
2498
  this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', policyCode, message, canonicalEcApproval?.parseFailure);
2499
+ setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, message));
1983
2500
  return response;
1984
2501
  };
1985
2502
  if (managedEcIntent && !approvedCommand) {
2503
+ if (canonicalEcApproval?.parseFailure?.issue === 'untrusted-shell') {
2504
+ return await denyInfrastructure('ec_shell_untrusted_executable', 'EC command approval denied because the shell executable failed Windows Authenticode trust validation');
2505
+ }
1986
2506
  return await denyInfrastructure('ec_command_not_canonical', canonicalEcApproval?.parseFailure
1987
2507
  ? `EC command approval denied because canonical argv parsing failed (${canonicalEcApproval.parseFailure.issue})`
1988
2508
  : 'EC command approval denied because no canonical argv could be derived');
1989
2509
  }
1990
2510
  try {
1991
- const decision = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd, request.id);
2511
+ 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);
2512
+ const decision = typeof resolution === 'string' ? resolution : resolution.decision;
2513
+ const resolutionMeta = typeof resolution === 'string' ? undefined : resolution;
1992
2514
  if (decision !== 'deny' && managedEcIntent && approvedCommand) {
1993
2515
  const carrierThreadIds = [
1994
2516
  this.activeSessions.get(sessionKey),
2517
+ this.provisionalSessions.get(sessionKey),
1995
2518
  typeof params.threadId === 'string' ? params.threadId : undefined,
1996
2519
  typeof params.conversationId === 'string' ? params.conversationId : undefined,
1997
2520
  ];
@@ -2000,18 +2523,35 @@ export class CodexRunner {
2000
2523
  // A carrier-bearing thread is managed even if its permission context
2001
2524
  // was not wired with an arm callback; it must fail closed here rather
2002
2525
  // than reaching daemon IPC with an unarmed carrier.
2526
+ if ((hasDelegationCarrier || canArmDelegation)
2527
+ && canonicalEcApproval?.delegationToken
2528
+ && canonicalEcApproval.delegationToken !== delegationCarrier) {
2529
+ return await denyInfrastructure('delegation_token_mismatch', 'EC command approval contains a delegation token different from the active thread carrier');
2530
+ }
2003
2531
  if ((hasDelegationCarrier || canArmDelegation)
2004
2532
  && !this.armApprovedDelegationCommand(sessionKey, approvedCommand)) {
2005
2533
  return await denyInfrastructure('delegation_not_armed', 'EC command approval failed to arm delegation');
2006
2534
  }
2007
2535
  }
2008
2536
  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'))
2537
+ const decisionSource = resolutionMeta?.decisionSource
2538
+ ?? (sessionMode === 'auto' || sessionMode === 'readonly' ? 'policy' : 'approval');
2539
+ const policyCode = resolutionMeta?.policyCode
2540
+ ?? (decisionSource === 'policy' ? 'permission_mode_denied' : undefined);
2541
+ const denialReason = resolutionMeta?.reason || reason || (policyCode
2542
+ ? `permission mode ${sessionMode} denied approval`
2543
+ : 'user approval denied');
2544
+ if (decision === 'deny') {
2545
+ this.rememberToolDenial(request, params, {
2546
+ decision: 'deny',
2547
+ decisionSource,
2548
+ ...(policyCode ? { policyCode } : {}),
2549
+ reason: denialReason,
2550
+ });
2551
+ setImmediate(() => this.steerPolicyReason(sessionKey, policyCode, denialReason));
2552
+ }
2553
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, decision === 'deny' ? 'deny' : 'allow', decision === 'deny' ? decisionSource : 'approval', decision === 'deny' ? policyCode : undefined, decision === 'deny'
2554
+ ? denialReason
2015
2555
  : 'approval accepted');
2016
2556
  logger.info(`[CodexRunner] app-server approval response id=${request.id} method=${request.method} decision=${decision} response=${JSON.stringify(response)}`);
2017
2557
  return response;
@@ -2024,8 +2564,13 @@ export class CodexRunner {
2024
2564
  }
2025
2565
  logAppServerApprovalAudit(request, sessionKey, toolName, decision, decisionSource, policyCode, reason, parseFailure) {
2026
2566
  const context = this.permissionContexts.get(sessionKey);
2567
+ const params = (request.params || {});
2568
+ const rawCallId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
2569
+ const callId = typeof rawCallId === 'string' && rawCallId ? rawCallId : undefined;
2027
2570
  auditCodexApprovalDecision({
2028
2571
  requestId: request.id !== undefined ? String(request.id) : undefined,
2572
+ callId,
2573
+ correlationId: callId,
2029
2574
  method: request.method,
2030
2575
  sessionId: sessionKey,
2031
2576
  agentAid: context?.selfAid,
@@ -2041,6 +2586,7 @@ export class CodexRunner {
2041
2586
  });
2042
2587
  logger.info(`[CodexRunner] app-server approval audit ${JSON.stringify({
2043
2588
  requestId: request.id !== undefined ? String(request.id) : undefined,
2589
+ ...(callId ? { callId, correlationId: callId } : {}),
2044
2590
  method: request.method,
2045
2591
  sessionId: sessionKey,
2046
2592
  toolName,
@@ -2401,9 +2947,20 @@ export class CodexRunner {
2401
2947
  if (activeThreadId === candidate)
2402
2948
  return sessionKey;
2403
2949
  }
2950
+ for (const [sessionKey, provisionalThreadId] of this.provisionalSessions.entries()) {
2951
+ if (provisionalThreadId === candidate)
2952
+ return sessionKey;
2953
+ }
2954
+ const childSessionKey = this.childThreadSessions.get(candidate);
2955
+ if (childSessionKey)
2956
+ return childSessionKey;
2404
2957
  }
2405
2958
  return candidates[0] || 'codex-app-server';
2406
2959
  }
2960
+ isThreadOwnedBySession(sessionId, threadId) {
2961
+ const activeThread = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
2962
+ return activeThread === threadId || this.childThreadSessions.get(threadId) === sessionId;
2963
+ }
2407
2964
  buildPermissionInput(method, params) {
2408
2965
  if (method === 'item/permissions/requestApproval') {
2409
2966
  return { permissions: params.permissions, cwd: params.cwd, reason: params.reason };
@@ -2465,6 +3022,12 @@ export class CodexRunner {
2465
3022
  const threadProjectPath = this.threadProjectPaths.get(threadId);
2466
3023
  if (threadProjectPath)
2467
3024
  return threadProjectPath;
3025
+ const parentSession = this.childThreadSessions.get(threadId);
3026
+ const parentThread = parentSession
3027
+ ? this.activeSessions.get(parentSession) ?? this.provisionalSessions.get(parentSession)
3028
+ : undefined;
3029
+ if (parentThread)
3030
+ return this.threadProjectPaths.get(parentThread);
2468
3031
  }
2469
3032
  return undefined;
2470
3033
  }
@@ -2595,7 +3158,7 @@ export class CodexRunner {
2595
3158
  return true;
2596
3159
  });
2597
3160
  }
2598
- async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath, requestId) {
3161
+ async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath = process.cwd(), operationCwd = workspacePath, requestId, callId) {
2599
3162
  const permissionContext = this.permissionContexts.get(sessionKey);
2600
3163
  const permissionPrompt = permissionContext?.sendPrompt ?? this.sendPromptFn;
2601
3164
  const hasFilesystemPermissions = (toolName === 'Bash' || toolName === 'PermissionGrant')
@@ -2632,6 +3195,8 @@ export class CodexRunner {
2632
3195
  role: permissionContext?.role,
2633
3196
  selfAid: permissionContext?.selfAid,
2634
3197
  requestId: typeof requestId === 'string' || typeof requestId === 'number' ? String(requestId) : undefined,
3198
+ callId,
3199
+ correlationId: callId,
2635
3200
  taskId: permissionContext?.taskId,
2636
3201
  matchedPath,
2637
3202
  });
@@ -2662,6 +3227,7 @@ export class CodexRunner {
2662
3227
  // distinguishes this explicit empty value from runners without a
2663
3228
  // session-temp capability.
2664
3229
  managedTempDir: permissionContext?.managedTempDir ?? this.getManagedTempDir(sessionKey) ?? '',
3230
+ delegationCarrier: this.delegationCarrierForSession(sessionKey),
2665
3231
  allowProtectedMetadata: false,
2666
3232
  selfAid: permissionContext?.selfAid,
2667
3233
  channel: permissionContext?.channel,
@@ -2678,7 +3244,12 @@ export class CodexRunner {
2678
3244
  if (preflight.policyCode) {
2679
3245
  await recordBlockedOperation(preflight.policyCode, toolInput, preflight.message ?? 'tool preflight denied', preflight.matchedPath);
2680
3246
  }
2681
- return 'deny';
3247
+ return {
3248
+ decision: 'deny',
3249
+ decisionSource: 'policy',
3250
+ policyCode: preflight.policyCode ?? 'tool_preflight_denied',
3251
+ reason: preflight.message ?? 'tool preflight denied',
3252
+ };
2682
3253
  }
2683
3254
  if (preflight.behavior === 'allow')
2684
3255
  return 'allow';
@@ -2992,12 +3563,124 @@ export class CodexRunner {
2992
3563
  }
2993
3564
  return !state.turnId || !turnId || turnId === state.turnId;
2994
3565
  }
3566
+ isSubagentActivityItem(item) {
3567
+ return typeof item?.type === 'string' && item.type.toLowerCase() === 'subagentactivity';
3568
+ }
3569
+ subagentThreadId(item) {
3570
+ const value = item?.agentThreadId ?? item?.agent_thread_id;
3571
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
3572
+ }
3573
+ subagentPath(item) {
3574
+ const value = item?.agentPath ?? item?.agent_path;
3575
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
3576
+ }
3577
+ subagentType(item) {
3578
+ const value = item?.agentType ?? item?.agent_type;
3579
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
3580
+ }
3581
+ registerChildThread(sessionId, childThreadId, projectPath) {
3582
+ const parentThreadId = this.activeSessions.get(sessionId) ?? this.provisionalSessions.get(sessionId);
3583
+ if (childThreadId === parentThreadId)
3584
+ return;
3585
+ this.childThreadSessions.set(childThreadId, sessionId);
3586
+ this.threadProjectPaths.set(childThreadId, path.resolve(projectPath));
3587
+ // Child approvals use the parent's capability boundary and delegation
3588
+ // carrier. This is inheritance, not a second permission decision.
3589
+ const parentBoundary = parentThreadId ? this.threadExternalToolBoundaries.get(parentThreadId) : undefined;
3590
+ const parentFingerprint = parentThreadId ? this.threadExternalToolFingerprints.get(parentThreadId) : undefined;
3591
+ if (parentBoundary)
3592
+ this.threadExternalToolBoundaries.set(childThreadId, parentBoundary);
3593
+ if (parentFingerprint)
3594
+ this.threadExternalToolFingerprints.set(childThreadId, parentFingerprint);
3595
+ const parentCarrier = parentThreadId ? this.threadDelegationCarriers.get(parentThreadId) : undefined;
3596
+ if (parentCarrier)
3597
+ this.threadDelegationCarriers.set(childThreadId, parentCarrier);
3598
+ }
3599
+ unregisterChildThread(childThreadId) {
3600
+ this.childThreadSessions.delete(childThreadId);
3601
+ this.activeSubagentTurns.delete(childThreadId);
3602
+ this.threadProjectPaths.delete(childThreadId);
3603
+ this.threadExternalToolBoundaries.delete(childThreadId);
3604
+ this.threadExternalToolFingerprints.delete(childThreadId);
3605
+ this.threadDelegationCarriers.delete(childThreadId);
3606
+ this.clearTrackedApprovalItemsForThread(childThreadId);
3607
+ }
3608
+ cleanupChildThreadsForSession(sessionId) {
3609
+ for (const [childThreadId, ownerSessionId] of this.childThreadSessions) {
3610
+ if (ownerSessionId === sessionId)
3611
+ this.unregisterChildThread(childThreadId);
3612
+ }
3613
+ }
3614
+ trackSubagentLifecycle(notification, state) {
3615
+ const params = (notification.params || {});
3616
+ const item = params.item && typeof params.item === 'object' ? params.item : undefined;
3617
+ if (!this.isSubagentActivityItem(item))
3618
+ return;
3619
+ const childThreadId = this.subagentThreadId(item);
3620
+ if (!childThreadId)
3621
+ return;
3622
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
3623
+ if (kind === 'started') {
3624
+ state.activeSubagentThreads.add(childThreadId);
3625
+ this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
3626
+ }
3627
+ if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3628
+ state.activeSubagentThreads.delete(childThreadId);
3629
+ this.unregisterChildThread(childThreadId);
3630
+ }
3631
+ }
3632
+ primeSubagentStateFromTurn(turn, state) {
3633
+ for (const item of this.getTurnItems(turn)) {
3634
+ if (!this.isSubagentActivityItem(item))
3635
+ continue;
3636
+ const childThreadId = this.subagentThreadId(item);
3637
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
3638
+ if (!childThreadId)
3639
+ continue;
3640
+ if (kind === 'started') {
3641
+ state.activeSubagentThreads.add(childThreadId);
3642
+ this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
3643
+ }
3644
+ else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3645
+ state.activeSubagentThreads.delete(childThreadId);
3646
+ this.unregisterChildThread(childThreadId);
3647
+ }
3648
+ }
3649
+ }
3650
+ endAppServerQueueWhenSubagentsDone(queue, state) {
3651
+ if (!state.turnCompleted)
3652
+ return;
3653
+ if (state.activeSubagentThreads.size > 0) {
3654
+ logger.info(`[CodexRunner] parent turn completed; waiting for subagent thread(s): `
3655
+ + `${[...state.activeSubagentThreads].join(',')}`);
3656
+ return;
3657
+ }
3658
+ queue.end();
3659
+ }
2995
3660
  async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, tempFiles) {
2996
3661
  try {
2997
- yield { type: 'session_id', sessionId: state.threadId };
3662
+ let sessionIdEmitted = state.committed;
3663
+ if (sessionIdEmitted)
3664
+ yield { type: 'session_id', sessionId: state.threadId };
2998
3665
  for await (const notification of notifications) {
2999
3666
  if (!this.activeAbortControllers.has(sessionId))
3000
3667
  break;
3668
+ if (!state.committed && notification.method === 'turn/completed') {
3669
+ const turn = notification.params?.turn ?? {};
3670
+ if (turn.status === 'completed') {
3671
+ const committed = await state.commitProvisional?.();
3672
+ if (!committed) {
3673
+ const staleError = new Error('Codex provisional thread was superseded before commit');
3674
+ staleError.code = 'RUNNER_START_SUPERSEDED';
3675
+ throw staleError;
3676
+ }
3677
+ state.committed = true;
3678
+ if (!sessionIdEmitted) {
3679
+ sessionIdEmitted = true;
3680
+ yield { type: 'session_id', sessionId: state.threadId };
3681
+ }
3682
+ }
3683
+ }
3001
3684
  yield* this.mapAppServerNotification(notification, sessionId, state);
3002
3685
  }
3003
3686
  }
@@ -3011,6 +3694,11 @@ export class CodexRunner {
3011
3694
  && (!state.turnId || activeTurn.turnId === state.turnId)) {
3012
3695
  this.activeTurns.delete(sessionId);
3013
3696
  }
3697
+ if (!state.committed)
3698
+ this.cleanupProvisionalThread(sessionId, state.threadId);
3699
+ for (const childThreadId of state.activeSubagentThreads)
3700
+ this.unregisterChildThread(childThreadId);
3701
+ state.activeSubagentThreads.clear();
3014
3702
  this.cleanupTempFiles(tempFiles);
3015
3703
  }
3016
3704
  }
@@ -3078,12 +3766,16 @@ export class CodexRunner {
3078
3766
  }
3079
3767
  case 'turn/completed': {
3080
3768
  const turn = params.turn || {};
3081
- const turnId = turn.id || params.turnId;
3769
+ const turnId = turn.id || params.turnId || state.turnId;
3082
3770
  if (turnId && state.completedTurnIds.has(turnId))
3083
3771
  break;
3084
3772
  if (turnId)
3085
3773
  state.completedTurnIds.add(turnId);
3086
- this.activeTurns.delete(sessionId);
3774
+ const activeTurn = this.activeTurns.get(sessionId);
3775
+ if (activeTurn?.threadId === state.threadId && activeTurn.turnId === turnId) {
3776
+ this.activeTurns.delete(sessionId);
3777
+ }
3778
+ this.clearPendingToolDenialsForTurn(state.threadId, turnId);
3087
3779
  yield* this.reconcileOpenAppServerToolCalls(state, turnId);
3088
3780
  if (turn.status === 'failed' && turn.error?.message) {
3089
3781
  if (isRetryableError(new Error(turn.error.message))) {
@@ -3204,11 +3896,36 @@ export class CodexRunner {
3204
3896
  };
3205
3897
  break;
3206
3898
  case 'subAgentActivity':
3207
- yield {
3208
- type: 'task_progress',
3209
- summary: `Subagent ${item.kind}: ${item.agentPath || item.agentThreadId}`,
3210
- };
3899
+ case 'SubAgentActivity': {
3900
+ const childThreadId = this.subagentThreadId(item);
3901
+ if (!childThreadId)
3902
+ break;
3903
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
3904
+ const description = this.subagentPath(item) ?? childThreadId;
3905
+ if (kind === 'started') {
3906
+ yield {
3907
+ type: 'task_started',
3908
+ taskId: childThreadId,
3909
+ taskKind: 'agent',
3910
+ description,
3911
+ subagentType: this.subagentType(item),
3912
+ };
3913
+ }
3914
+ else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3915
+ yield {
3916
+ type: 'task_notification',
3917
+ taskId: childThreadId,
3918
+ taskKind: 'agent',
3919
+ description,
3920
+ status: kind === 'completed' ? 'completed' : kind,
3921
+ summary: description,
3922
+ };
3923
+ }
3924
+ else {
3925
+ yield { type: 'task_progress', taskId: childThreadId, taskKind: 'agent', description, summary: `Subagent ${item.kind}: ${description}` };
3926
+ }
3211
3927
  break;
3928
+ }
3212
3929
  case 'enteredReviewMode':
3213
3930
  yield { type: 'task_progress', summary: item.review || 'Review mode entered' };
3214
3931
  break;
@@ -3223,6 +3940,59 @@ export class CodexRunner {
3223
3940
  *mapAppServerItemCompleted(item, state) {
3224
3941
  if (!item)
3225
3942
  return;
3943
+ const denial = this.takeToolDenial(state, item.id);
3944
+ const denialFields = denial
3945
+ ? {
3946
+ isError: true,
3947
+ error: denial.reason,
3948
+ errorCode: errorCodeForToolDenial(denial.decisionSource, denial.policyCode),
3949
+ decision: 'deny',
3950
+ decisionSource: denial.decisionSource,
3951
+ policyCode: denial.policyCode,
3952
+ reason: denial.reason,
3953
+ requestId: denial.requestId,
3954
+ executed: false,
3955
+ executionState: 'blocked',
3956
+ }
3957
+ : undefined;
3958
+ const itemErrorText = outputTextForManagedHook(typeof item.error === 'string' ? item.error : item.error?.message);
3959
+ const hookErrorText = /(?:Command|Tool call) blocked by PreToolUse hook\s*:/i.test(itemErrorText)
3960
+ ? itemErrorText
3961
+ : undefined;
3962
+ const managedHookDenial = (item.status === 'failed' || item.status === 'declined' || hookErrorText)
3963
+ ? parseManagedPreToolUseDenial([
3964
+ item.aggregatedOutput,
3965
+ item.output,
3966
+ item.result,
3967
+ hookErrorText,
3968
+ ].filter(value => value !== undefined && value !== null).map(outputTextForManagedHook).join('\n'))
3969
+ : undefined;
3970
+ const managedHookFields = !denial && managedHookDenial
3971
+ ? {
3972
+ isError: true,
3973
+ error: managedHookDenial.reason,
3974
+ errorCode: 'POLICY_DENIED',
3975
+ decision: 'deny',
3976
+ decisionSource: 'policy',
3977
+ policyCode: managedHookDenial.policyCode,
3978
+ reason: managedHookDenial.reason,
3979
+ executed: false,
3980
+ executionState: 'blocked',
3981
+ }
3982
+ : undefined;
3983
+ const genericDeclineFields = !denial && !managedHookFields && item.status === 'declined'
3984
+ ? {
3985
+ isError: true,
3986
+ error: 'Approval declined',
3987
+ errorCode: 'USER_DENIED',
3988
+ decision: 'deny',
3989
+ decisionSource: 'approval',
3990
+ reason: 'Approval declined',
3991
+ executed: false,
3992
+ executionState: 'blocked',
3993
+ }
3994
+ : undefined;
3995
+ const blockedFields = denialFields ?? managedHookFields ?? genericDeclineFields;
3226
3996
  switch (item.type) {
3227
3997
  case 'agentMessage':
3228
3998
  {
@@ -3239,8 +4009,10 @@ export class CodexRunner {
3239
4009
  type: 'tool_result',
3240
4010
  name: 'Shell',
3241
4011
  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) } : {}),
4012
+ ...(blockedFields ?? {
4013
+ isError: item.exitCode !== null && item.exitCode !== undefined ? item.exitCode !== 0 : item.status === 'failed' || item.status === 'declined',
4014
+ ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
4015
+ }),
3244
4016
  callId: item.id,
3245
4017
  durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
3246
4018
  };
@@ -3250,8 +4022,8 @@ export class CodexRunner {
3250
4022
  type: 'tool_result',
3251
4023
  name: `MCP:${item.server}/${item.tool}`,
3252
4024
  result: item.result,
3253
- isError: item.status === 'failed',
3254
- error: item.error?.message,
4025
+ ...(blockedFields ?? { isError: item.status === 'failed' }),
4026
+ ...(blockedFields ? {} : { error: item.error?.message }),
3255
4027
  ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
3256
4028
  callId: item.id,
3257
4029
  durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
@@ -3262,7 +4034,7 @@ export class CodexRunner {
3262
4034
  type: 'tool_result',
3263
4035
  name: item.namespace ? `${item.namespace}:${item.tool}` : item.tool,
3264
4036
  result: item.contentItems,
3265
- isError: item.success === false || item.status === 'failed',
4037
+ ...(blockedFields ?? { isError: item.success === false || item.status === 'failed' }),
3266
4038
  ...(item.error?.code || item.error?.errorCode ? { errorCode: String(item.error.code ?? item.error.errorCode) } : {}),
3267
4039
  callId: item.id,
3268
4040
  durationMs: typeof item.durationMs === 'number' ? item.durationMs : undefined,
@@ -3277,12 +4049,12 @@ export class CodexRunner {
3277
4049
  yield editEvent;
3278
4050
  }
3279
4051
  }
3280
- yield { type: 'tool_result', name: 'Edit', result: item.changes, isError: item.status === 'failed', callId: item.id };
4052
+ yield { type: 'tool_result', name: 'Edit', result: item.changes, ...(blockedFields ?? { isError: item.status === 'failed' }), callId: item.id };
3281
4053
  }
3282
4054
  else {
3283
4055
  const desc = this.normalizeFileChanges(item.changes).map((change) => this.describeFileChange(change)).join(', ');
3284
4056
  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 };
4057
+ yield { type: 'tool_result', name: 'FileChange', result: item.changes, ...(blockedFields ?? { isError: item.status === 'failed' }), callId: item.id };
3286
4058
  }
3287
4059
  break;
3288
4060
  case 'webSearch':
@@ -3336,6 +4108,34 @@ export class CodexRunner {
3336
4108
  callId: item.id,
3337
4109
  };
3338
4110
  break;
4111
+ case 'subAgentActivity':
4112
+ case 'SubAgentActivity': {
4113
+ const childThreadId = this.subagentThreadId(item);
4114
+ if (!childThreadId)
4115
+ break;
4116
+ const kind = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
4117
+ const description = this.subagentPath(item) ?? childThreadId;
4118
+ if (kind === 'started') {
4119
+ yield {
4120
+ type: 'task_started',
4121
+ taskId: childThreadId,
4122
+ taskKind: 'agent',
4123
+ description,
4124
+ subagentType: this.subagentType(item),
4125
+ };
4126
+ }
4127
+ else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
4128
+ yield {
4129
+ type: 'task_notification',
4130
+ taskId: childThreadId,
4131
+ taskKind: 'agent',
4132
+ description,
4133
+ status: kind === 'completed' ? 'completed' : kind,
4134
+ summary: description,
4135
+ };
4136
+ }
4137
+ break;
4138
+ }
3339
4139
  }
3340
4140
  }
3341
4141
  *mapAppServerFileChangePatchUpdated(params, state) {
@@ -3583,16 +4383,21 @@ export class CodexRunner {
3583
4383
  for (const [key, controller] of this.activeAbortControllers) {
3584
4384
  controller.abort('dispose');
3585
4385
  }
4386
+ await Promise.all([...this.activeSubagentTurns.entries()].map(([threadId, active]) => this.interruptAppServerTurn(threadId, active.turnId).catch(() => { })));
3586
4387
  this.activeAbortControllers.clear();
3587
4388
  this.activeStreams.clear();
3588
4389
  this.activeSessions.clear();
4390
+ this.provisionalSessions.clear();
3589
4391
  this.activeTurns.clear();
4392
+ this.activeSubagentTurns.clear();
4393
+ this.childThreadSessions.clear();
3590
4394
  this.threadProjectPaths.clear();
3591
4395
  this.threadExternalToolFingerprints.clear();
3592
4396
  this.threadExternalToolBoundaries.clear();
3593
4397
  this.trackedApprovalItems.clear();
3594
4398
  this.threadDelegationCarriers.clear();
3595
4399
  this.pendingInterrupts.clear();
4400
+ this.steeringTails.clear();
3596
4401
  this.permissionContexts.clear();
3597
4402
  this.chatModes.clear();
3598
4403
  this.pruneThreadOperationLocks();