evolcore 0.0.22 → 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 (75) hide show
  1. package/AGENTS.md +62 -0
  2. package/CHANGELOG.md +24 -14
  3. package/README.md +2 -0
  4. package/bin/codex-managed-acl.ps1 +155 -0
  5. package/bin/install-codex-managed-hooks.mjs +373 -95
  6. package/dist/agents/baseagent-availability.js +48 -0
  7. package/dist/agents/baseagent.js +1 -1
  8. package/dist/agents/claude-runner.js +30 -226
  9. package/dist/agents/{codex-app-server-client.js → codex/app-server-client.js} +73 -13
  10. package/dist/{utils/codex-app-server-registry.js → agents/codex/app-server-registry.js} +2 -2
  11. package/dist/agents/codex/catalog.js +70 -0
  12. package/dist/agents/codex/diagnostics.js +154 -0
  13. package/dist/agents/codex-runner.js +407 -139
  14. package/dist/agents/ecagent-runner.js +10 -31
  15. package/dist/agents/gemini-runner.js +2 -6
  16. package/dist/aun/aid/store.js +2 -0
  17. package/dist/aun/menu-client.js +272 -0
  18. package/dist/aun/msg/group.js +65 -4
  19. package/dist/aun/msg/index.js +1 -1
  20. package/dist/aun/msg/managed-operation.js +51 -4
  21. package/dist/aun/rpc/client.js +8 -1
  22. package/dist/channels/aun.js +31 -1
  23. package/dist/channels/wechat.js +81 -10
  24. package/dist/cli/aun-commands.js +48 -4
  25. package/dist/cli/command-log.js +21 -2
  26. package/dist/cli/daemon-commands.js +142 -45
  27. package/dist/cli/index.js +20 -0
  28. package/dist/cli/init.js +2 -10
  29. package/dist/cli/menu-client.js +95 -0
  30. package/dist/cli/model.js +23 -8
  31. package/dist/cli/task-context.js +12 -2
  32. package/dist/cli/trigger-command.js +9 -1
  33. package/dist/config/schema-migration.js +14 -2
  34. package/dist/core/auth/agent-delegation.js +10 -7
  35. package/dist/core/auth/authorization-audit.js +4 -0
  36. package/dist/core/auth/operation-catalog.js +4 -0
  37. package/dist/core/auth/remote-access-store.js +135 -0
  38. package/dist/core/command/command-handler.js +41 -5
  39. package/dist/core/command/daemon-diagnostics.js +448 -0
  40. package/dist/core/command/menu-catalog.js +3 -0
  41. package/dist/core/command/menu-handler.js +141 -33
  42. package/dist/core/command/menu-protocol.js +119 -0
  43. package/dist/core/command/slash-gate.js +3 -3
  44. package/dist/core/command/slash-handler.js +42 -22
  45. package/dist/core/message/message-queue.js +20 -15
  46. package/dist/core/message/response-engine.js +74 -73
  47. package/dist/core/model/catalog-service.js +308 -0
  48. package/dist/core/model/model-catalog.js +124 -334
  49. package/dist/core/model/model-diagnostics.js +4 -5
  50. package/dist/core/model/model-fallback.js +9 -16
  51. package/dist/core/model/model-permission.js +1 -1
  52. package/dist/core/permission/approval-gateway.js +16 -1
  53. package/dist/core/permission/protected-paths.js +63 -4
  54. package/dist/core/permission/sandbox-runtime.js +4 -2
  55. package/dist/core/permission/tool-policy.js +244 -25
  56. package/dist/core/runtime-lock.js +13 -8
  57. package/dist/index.js +40 -29
  58. package/dist/ipc.js +123 -81
  59. package/dist/trigger/manager.js +3 -1
  60. package/dist/trigger/parser.js +77 -13
  61. package/dist/trigger/patch.js +104 -1
  62. package/dist/utils/ecweb-supervisor.js +11 -2
  63. package/dist/utils/ecweb-utils.js +8 -26
  64. package/dist/utils/error-utils.js +7 -0
  65. package/dist/utils/npm-ops.js +47 -15
  66. package/kits/docs/evolcore/INDEX.md +1 -0
  67. package/kits/docs/evolcore/agent-create.md +13 -0
  68. package/kits/docs/evolcore/group-collaboration.md +7 -2
  69. package/kits/docs/evolcore/menu-client.md +34 -0
  70. package/kits/migrations/migrate-contact-book-v2.mjs +9 -1
  71. package/kits/migrations/migrate-role-config-v4.mjs +11 -5
  72. package/kits/migrations/migrate-role-config-v5.mjs +8 -2
  73. package/kits/templates/roles/visitor.json +1 -0
  74. package/kits/templates/system-fragments/bootstrap.md +8 -1
  75. 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';
@@ -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;
@@ -94,6 +89,7 @@ function errorCodeForToolDenial(decisionSource, policyCode) {
94
89
  return 'INVALID_ARGUMENT';
95
90
  return 'EXECUTION_FAILED';
96
91
  }
92
+ const CODEX_THREAD_HANDOFF_TIMEOUT_MS = 2_000;
97
93
  const managedTempDirsForExitCleanup = new Map();
98
94
  let managedTempExitCleanupRegistered = false;
99
95
  function isTrustedManagedTempDirectory(directory, parent) {
@@ -109,9 +105,9 @@ function isTrustedManagedTempDirectory(directory, parent) {
109
105
  const stat = fs.lstatSync(candidate);
110
106
  if (!stat.isDirectory() || stat.isSymbolicLink())
111
107
  return false;
112
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
108
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid())
113
109
  return false;
114
- return (stat.mode & 0o077) === 0;
110
+ return process.platform === 'win32' || (stat.mode & 0o077) === 0;
115
111
  };
116
112
  if (!trustedDirectory(parentPath) || !trustedDirectory(directoryPath))
117
113
  return false;
@@ -218,20 +214,17 @@ function outputTextForManagedHook(value) {
218
214
  }
219
215
  return String(value);
220
216
  }
221
- const CODEX_CATALOG_FALLBACK = [
222
- { slug: 'gpt-5.5', efforts: ['low', 'medium', 'high', 'xhigh'] },
223
- { slug: 'gpt-5.4', efforts: ['low', 'medium', 'high', 'xhigh'] },
224
- { slug: 'gpt-5.4-mini', efforts: ['low', 'medium', 'high', 'xhigh'] },
225
- { slug: 'gpt-5.3-codex', efforts: ['low', 'medium', 'high', 'xhigh'] },
226
- { slug: 'gpt-5.2', efforts: ['low', 'medium', 'high', 'xhigh'] },
227
- ];
228
217
  const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
229
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;
230
220
  function isCodexNoRolloutError(error) {
231
221
  return error?.code === 'CODEX_ROLLOUT_NOT_FOUND'
232
222
  || CODEX_NO_ROLLOUT_RE.test(error instanceof Error ? error.message : String(error));
233
223
  }
234
- let codexCatalogCache = null;
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
+ }
235
228
  // The permission bridge is version-locked to the audited approval schema and
236
229
  // managed PreToolUse hook behavior used by proactive first-tool enforcement.
237
230
  export const MIN_CODEX_CLI_VERSION = '0.144.1';
@@ -286,37 +279,6 @@ export function getCodexAppServerAvailability() {
286
279
  export function isCodexAppServerAvailable() {
287
280
  return getCodexAppServerAvailability().available;
288
281
  }
289
- function fetchCodexCatalog() {
290
- if (codexCatalogCache)
291
- return codexCatalogCache;
292
- try {
293
- const output = execCodexCliSync(['debug', 'models'], {
294
- encoding: 'utf-8',
295
- timeout: 5000,
296
- stdio: ['pipe', 'pipe', 'pipe'],
297
- });
298
- const catalog = JSON.parse(output);
299
- const models = catalog.models
300
- .filter(m => m.visibility === 'list')
301
- .map(m => ({
302
- slug: m.slug,
303
- efforts: (m.supported_reasoning_levels || []).map(l => l.effort),
304
- }));
305
- if (models.length > 0) {
306
- codexCatalogCache = models;
307
- return models;
308
- }
309
- }
310
- catch (e) {
311
- logger.debug(`[CodexRunner] Failed to fetch model catalog, using fallback: ${e}`);
312
- }
313
- return CODEX_CATALOG_FALLBACK;
314
- }
315
- export function getCodexEfforts(model) {
316
- const catalog = fetchCodexCatalog();
317
- const entry = catalog.find(m => m.slug === model);
318
- return entry?.efforts ?? catalog[0]?.efforts ?? ['low', 'medium', 'high'];
319
- }
320
282
  // ── Codex Runner ──
321
283
  export class CodexRunner {
322
284
  name = 'codex';
@@ -347,7 +309,12 @@ export class CodexRunner {
347
309
  steeringTails = new Map();
348
310
  threadOperationLocks = new Map();
349
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. */
350
315
  appServerClient = null;
316
+ /** Trusted fullaccess calls use a separate unmasked app-server process. */
317
+ fullAccessAppServerClient = null;
351
318
  onSessionIdUpdate;
352
319
  onCompactStart;
353
320
  permissionGateway;
@@ -388,24 +355,203 @@ export class CodexRunner {
388
355
  this.effort = this.resolvedConfig.effort;
389
356
  this.onSessionIdUpdate = callbacks.onSessionIdUpdate;
390
357
  }
391
- getAppServerClient() {
392
- if (!this.appServerClient) {
393
- const client = new CodexAppServerClient({
394
- apiKey: this.resolvedConfig.apiKey,
395
- baseUrl: this.resolvedConfig.baseUrl,
396
- provider: this.resolvedConfig.provider,
397
- headers: this.resolvedConfig.headers,
398
- queryParams: this.resolvedConfig.queryParams,
399
- model: this.model,
400
- effort: this.effort,
401
- enableRequestUserInput: this.resolvedConfig.enableRequestUserInput,
402
- approvalsReviewer: 'user',
403
- onServerRequest: request => this.handleAppServerRequest(request),
404
- });
405
- client.onNotification(notification => this.trackApprovalItemNotification(notification));
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 {
406
397
  this.appServerClient = client;
407
398
  }
408
- 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' });
409
555
  }
410
556
  createDelegationCarrier() {
411
557
  return randomBytes(32).toString('base64url');
@@ -681,8 +827,11 @@ export class CodexRunner {
681
827
  }
682
828
  }
683
829
  resetAppServerClient() {
684
- const client = this.appServerClient;
830
+ const clients = [this.appServerClient, this.fullAccessAppServerClient]
831
+ .filter((client) => !!client);
685
832
  this.appServerClient = null;
833
+ this.fullAccessAppServerClient = null;
834
+ this.threadSubscriptions.clear();
686
835
  this.threadExternalToolFingerprints.clear();
687
836
  this.threadExternalToolBoundaries.clear();
688
837
  for (const childThreadId of this.childThreadSessions.keys())
@@ -695,9 +844,11 @@ export class CodexRunner {
695
844
  this.trackedApprovalItems.clear();
696
845
  this.pendingToolDenials.clear();
697
846
  this.threadDelegationCarriers.clear();
698
- client?.close().catch(error => {
699
- logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
700
- });
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
+ }
701
852
  }
702
853
  mergeThreadConfig(...configs) {
703
854
  const merged = {};
@@ -945,19 +1096,16 @@ export class CodexRunner {
945
1096
  // ── ModelSwitcher ──
946
1097
  setModel(model) { this.model = model; this.resetAppServerClient(); }
947
1098
  getModel() { return this.model; }
1099
+ getModelCatalog() {
1100
+ return getRunnerCatalog('codex', this.resolvedConfig.agentAid, this.resolvedConfig);
1101
+ }
948
1102
  async listModels() {
949
- try {
950
- const response = await this.getAppServerClient().modelList(false);
951
- const ids = (response.data ?? [])
952
- .map(model => model.id || model.slug || model.name || model.model)
953
- .filter((id) => typeof id === 'string' && id.length > 0);
954
- if (ids.length > 0)
955
- return ids;
956
- }
957
- catch (error) {
958
- logger.debug(`[CodexRunner] app-server model/list failed, using catalog fallback: ${error}`);
959
- }
960
- return fetchCodexCatalog().map(m => m.slug);
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');
961
1109
  }
962
1110
  // ── Effort ──
963
1111
  setEffort(effort) { this.effort = effort; this.resetAppServerClient(); }
@@ -1014,7 +1162,9 @@ export class CodexRunner {
1014
1162
  if (!looksLikeGitMetadataDirectory(resolved))
1015
1163
  return;
1016
1164
  paths.add(resolved);
1017
- 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}/**`);
1018
1168
  };
1019
1169
  try {
1020
1170
  const stat = fs.statSync(marker);
@@ -1028,7 +1178,7 @@ export class CodexRunner {
1028
1178
  }
1029
1179
  }
1030
1180
  catch { }
1031
- for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith(`${path.sep}**`))) {
1181
+ for (const gitDirectory of [...paths].filter(candidate => !candidate.endsWith('/**'))) {
1032
1182
  try {
1033
1183
  const commonDirectory = fs.readFileSync(path.join(gitDirectory, 'commondir'), 'utf8').trim();
1034
1184
  if (commonDirectory)
@@ -1137,7 +1287,7 @@ export class CodexRunner {
1137
1287
  return;
1138
1288
  }
1139
1289
  const input = [{ type: 'text', text, text_elements: [] }];
1140
- const client = this.getAppServerClient();
1290
+ const client = this.getAppServerClientForThread(activeTurn.threadId);
1141
1291
  if (typeof client.turnSteer !== 'function') {
1142
1292
  logger.warn(`[CodexRunner] turn steer unavailable in app-server client: session=${sessionId}`);
1143
1293
  return;
@@ -1265,6 +1415,10 @@ export class CodexRunner {
1265
1415
  const hadController = this.activeAbortControllers.has(key);
1266
1416
  const hadTurn = this.activeTurns.has(key);
1267
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;
1268
1422
  this.activeStreams.delete(key);
1269
1423
  this.activeAbortControllers.delete(key);
1270
1424
  this.pendingInterrupts.delete(key);
@@ -1276,9 +1430,18 @@ export class CodexRunner {
1276
1430
  }
1277
1431
  this.chatModes.delete(key);
1278
1432
  const release = this.activeOperationReleases.get(key);
1433
+ let releaseAfterSubscription;
1434
+ if (subscription) {
1435
+ releaseAfterSubscription = this.releaseThreadSubscription(subscription.client, subscribedThreadId, 'stream-cleanup');
1436
+ }
1279
1437
  if (release) {
1280
1438
  this.activeOperationReleases.delete(key);
1281
- release();
1439
+ if (releaseAfterSubscription) {
1440
+ void releaseAfterSubscription.then(release, release);
1441
+ }
1442
+ else {
1443
+ release();
1444
+ }
1282
1445
  }
1283
1446
  }
1284
1447
  hasActiveStream(key) {
@@ -1455,7 +1618,7 @@ export class CodexRunner {
1455
1618
  this.chatModes.set(sessionId, callMode);
1456
1619
  const callApprovalPolicy = this.toApprovalPolicy(callMode);
1457
1620
  const executionSandbox = this.buildExecutionSandboxOptions(sessionId, callMode, projectPath, modelOverride?.bootstrapAgentMdPath);
1458
- const appServer = this.getAppServerClient();
1621
+ const appServer = this.getAppServerClient(callMode === 'fullaccess');
1459
1622
  this.dropExpiredPendingInterrupt(sessionId);
1460
1623
  const effectiveApprovalPolicy = callApprovalPolicy;
1461
1624
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
@@ -1484,47 +1647,60 @@ export class CodexRunner {
1484
1647
  approvalPolicy: effectiveApprovalPolicy,
1485
1648
  approvalsReviewer: 'user',
1486
1649
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1487
- config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, runtimeEnv ? { shell_environment_policy: { set: runtimeEnv } } : undefined, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrierCandidate), this.buildLifecycleLockdownConfig(callMode)), callMode),
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),
1488
1651
  ...(developerInstructions ? { developerInstructions } : {}),
1489
1652
  };
1490
1653
  logger.info(`[CodexRunner] runQuery permMode=${requestedPermissionMode}->${callMode} ` +
1491
1654
  `role=${executionSandbox.decision.role ?? 'none'} sandbox=${executionSandbox.decision.state}`);
1492
1655
  let threadResponse;
1656
+ let previousAppServer;
1493
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);
1494
1661
  try {
1495
1662
  threadResponse = await appServer.threadResume(agentSessionId, projectPath, threadOptions);
1496
1663
  }
1497
1664
  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;
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);
1511
1703
  }
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
1704
  }
1529
1705
  }
1530
1706
  else {
@@ -1533,6 +1709,7 @@ export class CodexRunner {
1533
1709
  let threadId = threadResponse.thread?.id || agentSessionId;
1534
1710
  if (!threadId)
1535
1711
  throw new Error('Codex app-server did not return a thread id');
1712
+ this.markThreadSubscribed(appServer, threadId);
1536
1713
  if (!resumingThread) {
1537
1714
  carrierToPublish = { threadId, carrier: delegationCarrierCandidate };
1538
1715
  }
@@ -1559,6 +1736,9 @@ export class CodexRunner {
1559
1736
  const forkedThreadId = forked.thread?.id;
1560
1737
  if (!forkedThreadId)
1561
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');
1562
1742
  threadId = forkedThreadId;
1563
1743
  recoveredCarrier = delegationCarrierCandidate;
1564
1744
  }
@@ -1591,6 +1771,7 @@ export class CodexRunner {
1591
1771
  if (!bindingAccepted) {
1592
1772
  const error = new Error(`backend discovery rejected: ${bindingResult}`);
1593
1773
  error.code = 'TOPIC_BACKEND_DISCOVERY_REJECTED';
1774
+ await this.releaseThreadSubscription(appServer, threadId, 'backend-binding-rejected');
1594
1775
  throw error;
1595
1776
  }
1596
1777
  this.activeSessions.set(sessionId, threadId);
@@ -1633,7 +1814,7 @@ export class CodexRunner {
1633
1814
  && this.childThreadSessions.get(notifThreadId) === sessionId;
1634
1815
  if (notifThreadId !== undefined && notifThreadId !== threadId && !emittedByChild)
1635
1816
  return;
1636
- this.trackSubagentLifecycle(notification, state);
1817
+ this.trackSubagentLifecycle(notification, state, appServer);
1637
1818
  if (emittedByChild && notifThreadId !== threadId) {
1638
1819
  const childTurnId = this.extractTurnId(notification);
1639
1820
  if (notification.method === 'turn/started' && childTurnId) {
@@ -1649,6 +1830,7 @@ export class CodexRunner {
1649
1830
  this.activeSubagentTurns.delete(notifThreadId);
1650
1831
  }
1651
1832
  state.activeSubagentThreads.delete(notifThreadId);
1833
+ void this.releaseThreadSubscription(appServer, notifThreadId, 'subagent-turn-finished');
1652
1834
  this.unregisterChildThread(notifThreadId);
1653
1835
  this.endAppServerQueueWhenSubagentsDone(queue, state);
1654
1836
  }
@@ -1689,7 +1871,7 @@ export class CodexRunner {
1689
1871
  if (!resumingThread)
1690
1872
  this.cleanupProvisionalThread(sessionId, threadId);
1691
1873
  logger.info(`[CodexRunner] Applied pending interrupt before turn start: ${sessionId}`);
1692
- return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
1874
+ return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
1693
1875
  }
1694
1876
  try {
1695
1877
  const turnResponse = await appServer.turnStart(threadId, input, {
@@ -1721,7 +1903,7 @@ export class CodexRunner {
1721
1903
  }
1722
1904
  const status = turnResponse.turn?.status;
1723
1905
  if (status === 'completed' || status === 'failed') {
1724
- this.primeSubagentStateFromTurn(turnResponse.turn, state);
1906
+ this.primeSubagentStateFromTurn(turnResponse.turn, state, appServer);
1725
1907
  queue.push({ method: 'turn/completed', params: { threadId, turn: turnResponse.turn } });
1726
1908
  state.turnCompleted = true;
1727
1909
  this.endAppServerQueueWhenSubagentsDone(queue, state);
@@ -1729,6 +1911,7 @@ export class CodexRunner {
1729
1911
  }
1730
1912
  catch (error) {
1731
1913
  unsubscribe();
1914
+ await this.releaseThreadSubscription(appServer, threadId, 'turn-start-failed');
1732
1915
  this.activeAbortControllers.delete(sessionId);
1733
1916
  this.activeTurns.delete(sessionId);
1734
1917
  this.pendingInterrupts.delete(sessionId);
@@ -1737,7 +1920,7 @@ export class CodexRunner {
1737
1920
  this.cleanupProvisionalThread(sessionId, threadId);
1738
1921
  throw error;
1739
1922
  }
1740
- return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, tempFiles);
1923
+ return this.transformAppServerStream(queue, sessionId, state, controller, unsubscribe, appServer, tempFiles);
1741
1924
  }
1742
1925
  // ── Interrupt ──
1743
1926
  async interrupt(sessionKey) {
@@ -1789,7 +1972,12 @@ export class CodexRunner {
1789
1972
  }
1790
1973
  async interruptAppServerTurn(threadId, turnId) {
1791
1974
  try {
1792
- 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);
1793
1981
  }
1794
1982
  catch (error) {
1795
1983
  logger.debug(`[CodexRunner] app-server turn interrupt failed: ${error}`);
@@ -1853,11 +2041,16 @@ export class CodexRunner {
1853
2041
  await this.interruptSubagentTurns(sessionId);
1854
2042
  }
1855
2043
  capturedController?.abort('Session closed');
2044
+ const capturedSubscription = capturedThreadId ? this.threadSubscriptions.get(capturedThreadId) : undefined;
1856
2045
  const lifecycleReplaced = (capturedStream !== undefined && this.activeStreams.get(sessionId) !== capturedStream)
1857
2046
  || (capturedController !== undefined && this.activeAbortControllers.get(sessionId) !== capturedController)
1858
2047
  || (capturedTurn !== undefined && this.activeTurns.get(sessionId) !== capturedTurn)
1859
2048
  || (capturedPermissionContext !== undefined && this.permissionContexts.get(sessionId) !== capturedPermissionContext)
1860
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
+ }
1861
2054
  if (capturedStream !== undefined && this.activeStreams.get(sessionId) === capturedStream)
1862
2055
  this.activeStreams.delete(sessionId);
1863
2056
  if (capturedController !== undefined && this.activeAbortControllers.get(sessionId) === capturedController)
@@ -1913,6 +2106,10 @@ export class CodexRunner {
1913
2106
  async clearSession(sessionId, _agentSessionId, _projectPath) {
1914
2107
  // Codex: 清空会话 = 下次 runQuery 不传 resumeId,自动创建新 thread
1915
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
+ }
1916
2113
  this.activeSessions.delete(sessionId);
1917
2114
  this.activeTurns.delete(sessionId);
1918
2115
  this.steeringTails.delete(sessionId);
@@ -1933,8 +2130,12 @@ export class CodexRunner {
1933
2130
  return result;
1934
2131
  };
1935
2132
  const release = await this.acquireThreadOperation(this.threadOperationKey(agentSessionId, _sessionId), 'compact');
2133
+ let compactAppServer;
1936
2134
  try {
1937
- 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);
1938
2139
  this.onCompactStart?.(_sessionId);
1939
2140
  try {
1940
2141
  await this.startAndWaitForCompact(appServer, agentSessionId);
@@ -1944,7 +2145,6 @@ export class CodexRunner {
1944
2145
  if (!this.isThreadNotFoundError(error))
1945
2146
  throw error;
1946
2147
  logger.info(`[CodexRunner] Compact thread not loaded, resuming before compact: ${agentSessionId}`);
1947
- const compactMode = normalizeExecutionPermissionMode(modelOverride?.permissionMode ?? this.chatModes.get(_sessionId) ?? this.currentMode);
1948
2148
  const compactModel = modelOverride?.model || this.model;
1949
2149
  const compactEffort = modelOverride?.effortMode === 'model_default'
1950
2150
  ? undefined
@@ -1967,6 +2167,7 @@ export class CodexRunner {
1967
2167
  ...(executionSandbox.sandbox ? { sandbox: executionSandbox.sandbox } : {}),
1968
2168
  config: this.sanitizeThreadEnvironmentConfig(this.mergeThreadConfig(executionSandbox.permissionProfileConfig, capabilityConfig, externalToolConfig, this.buildEvolcoreShellEnvironmentConfig(_sessionId, compactDelegationCarrier), this.buildLifecycleLockdownConfig(compactMode)), compactMode),
1969
2169
  });
2170
+ this.markThreadSubscribed(appServer, agentSessionId);
1970
2171
  this.threadProjectPaths.set(agentSessionId, path.resolve(_projectPath));
1971
2172
  this.threadDelegationCarriers.set(agentSessionId, compactDelegationCarrier);
1972
2173
  this.bindExternalToolBoundary(agentSessionId, externalToolConfig);
@@ -1979,6 +2180,9 @@ export class CodexRunner {
1979
2180
  return finish({ ok: false, code: 'sdk_error', message, durationMs: Date.now() - startedAt });
1980
2181
  }
1981
2182
  finally {
2183
+ if (compactAppServer) {
2184
+ await this.releaseThreadSubscription(compactAppServer, agentSessionId, 'compact-finished');
2185
+ }
1982
2186
  release();
1983
2187
  }
1984
2188
  }
@@ -2119,7 +2323,8 @@ export class CodexRunner {
2119
2323
  const mode = this.chatModes.get(sessionKey) ?? this.currentMode;
2120
2324
  const executionSandbox = this.buildExecutionSandboxOptions(sessionKey, mode, projectPath);
2121
2325
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
2122
- const appServer = this.getAppServerClient();
2326
+ const appServer = this.threadSubscriptions.get(agentSessionId)?.client
2327
+ ?? this.getAppServerClient(mode === 'fullaccess');
2123
2328
  const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, mode);
2124
2329
  if (sessionKey)
2125
2330
  await this.assertProactiveHookAvailability(sessionKey, appServer);
@@ -2135,9 +2340,11 @@ export class CodexRunner {
2135
2340
  const forkedThreadId = response.thread?.id;
2136
2341
  if (!forkedThreadId)
2137
2342
  throw new Error('Codex fork did not return a thread id');
2343
+ this.markThreadSubscribed(appServer, forkedThreadId);
2138
2344
  this.threadDelegationCarriers.set(forkedThreadId, delegationCarrier);
2139
2345
  this.threadProjectPaths.set(forkedThreadId, path.resolve(projectPath));
2140
2346
  this.bindExternalToolBoundary(forkedThreadId, externalToolConfig);
2347
+ await this.releaseThreadSubscription(appServer, forkedThreadId, 'fork-finished');
2141
2348
  return forkedThreadId;
2142
2349
  }
2143
2350
  async forkSessionAt(agentSessionId, projectPath, assistantMessageId, title) {
@@ -2167,14 +2374,14 @@ export class CodexRunner {
2167
2374
  }
2168
2375
  }
2169
2376
  async setSessionName(agentSessionId, name) {
2170
- return this.getAppServerClient().threadSetName(agentSessionId, name);
2377
+ return this.getAppServerClientForThread(agentSessionId).threadSetName(agentSessionId, name);
2171
2378
  }
2172
2379
  async updateSessionMetadata(agentSessionId, metadata) {
2173
2380
  const gitInfo = metadata?.gitInfo && typeof metadata.gitInfo === 'object' ? metadata.gitInfo : undefined;
2174
- return this.getAppServerClient().threadMetadataUpdate(agentSessionId, gitInfo);
2381
+ return this.getAppServerClientForThread(agentSessionId).threadMetadataUpdate(agentSessionId, gitInfo);
2175
2382
  }
2176
2383
  async getSessionMessages(agentSessionId, projectPath) {
2177
- const response = await this.getAppServerClient().threadRead(agentSessionId, true);
2384
+ const response = await this.getAppServerClientForThread(agentSessionId).threadRead(agentSessionId, true);
2178
2385
  return this.mapThreadToSessionMessages(response, agentSessionId);
2179
2386
  }
2180
2387
  approvalItemKey(threadId, turnId, itemId) {
@@ -2406,6 +2613,10 @@ export class CodexRunner {
2406
2613
  setImmediate(() => this.steerPolicyReason(sessionKey, 'fullaccess_context_untrusted', reason));
2407
2614
  return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2408
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.
2409
2620
  const response = this.toAppServerApprovalResponse(request.method, 'allow', toolInput, false);
2410
2621
  this.logAppServerApprovalAudit(request, sessionKey, toolName, 'allow', 'approval', undefined, 'trusted fullaccess execution');
2411
2622
  return response;
@@ -2413,7 +2624,9 @@ export class CodexRunner {
2413
2624
  // Keep the policy check in the approval path as a fail-closed fallback.
2414
2625
  // Some app-server tool routes can reach approval without invoking the
2415
2626
  // command hook; allowing here would bypass the proactive gate.
2416
- const policyResult = this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
2627
+ const policyResult = (this.chatModes.get(sessionKey) ?? this.currentMode) === 'fullaccess'
2628
+ ? undefined
2629
+ : this.permissionContexts.get(sessionKey)?.policyHook?.(toolName, toolInput);
2417
2630
  if (policyResult?.block) {
2418
2631
  logger.info(`[CodexRunner] app-server approval declined by session policy: session=${sessionKey} ` +
2419
2632
  `requestId=${request.id ?? '<missing>'} code=${policyResult.policyCode ?? 'session_policy_hook'} ` +
@@ -2439,9 +2652,33 @@ export class CodexRunner {
2439
2652
  }
2440
2653
  const summary = this.summarizeAppServerRequest(request.method, params);
2441
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;
2442
2658
  const workspacePath = this.resolvePermissionWorkspacePath(params);
2443
2659
  if (!workspacePath) {
2444
2660
  logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
2661
+ try {
2662
+ await permissionContext?.recordExecutionAnomaly?.({
2663
+ code: 'operation_blocked',
2664
+ severity: 'warning',
2665
+ phase: 'execution',
2666
+ occurredAt: Date.now(),
2667
+ toolName,
2668
+ ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
2669
+ policyCode: 'approval_workspace_unknown',
2670
+ decisionSource: 'infrastructure',
2671
+ agentAid: permissionContext?.selfAid,
2672
+ agentName: permissionContext?.agentName,
2673
+ sessionId: sessionKey,
2674
+ permissionMode: normalizeExecutionPermissionMode(sessionMode),
2675
+ summary: auditSummary,
2676
+ effect: 'operation_skipped',
2677
+ });
2678
+ }
2679
+ catch (error) {
2680
+ logger.warn(`[CodexRunner] failed to record unknown-workspace approval denial: session=${sessionKey} error=${error instanceof Error ? error.message : String(error)}`);
2681
+ }
2445
2682
  const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
2446
2683
  this.rememberToolDenial(request, params, {
2447
2684
  decision: 'deny',
@@ -2454,8 +2691,7 @@ export class CodexRunner {
2454
2691
  return response;
2455
2692
  }
2456
2693
  const operationCwd = this.resolvePermissionOperationCwd(params, workspacePath, toolName);
2457
- const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
2458
- const logSummary = formatApprovalLogSummary(summary);
2694
+ const logSummary = formatApprovalLogSummary(auditSummary || summary);
2459
2695
  logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${logSummary}`);
2460
2696
  const isCommandApproval = request.method === 'item/commandExecution/requestApproval'
2461
2697
  || request.method === 'execCommandApproval';
@@ -2567,6 +2803,16 @@ export class CodexRunner {
2567
2803
  const params = (request.params || {});
2568
2804
  const rawCallId = params.itemId ?? params.item_id ?? params.callId ?? params.call_id;
2569
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;
2570
2816
  auditCodexApprovalDecision({
2571
2817
  requestId: request.id !== undefined ? String(request.id) : undefined,
2572
2818
  callId,
@@ -2581,6 +2827,10 @@ export class CodexRunner {
2581
2827
  policyCode,
2582
2828
  permissionMode: normalizeExecutionPermissionMode(this.chatModes.get(sessionKey) ?? this.currentMode),
2583
2829
  role: context?.role,
2830
+ taskId: context?.taskId,
2831
+ summary: auditSummary,
2832
+ threadId,
2833
+ cwd: rawCwd,
2584
2834
  reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
2585
2835
  parseFailure,
2586
2836
  });
@@ -2594,6 +2844,7 @@ export class CodexRunner {
2594
2844
  decisionSource,
2595
2845
  ...(policyCode ? { policyCode } : {}),
2596
2846
  ...(parseFailure ? { parseFailure } : {}),
2847
+ ...(auditSummary ? { summary: auditSummary } : {}),
2597
2848
  reason: reason ?? policyCode ?? (decision === 'allow' ? 'approval accepted' : 'approval denied'),
2598
2849
  executed: false,
2599
2850
  })}`);
@@ -3364,7 +3615,7 @@ export class CodexRunner {
3364
3615
  async rollbackSessionTurnsLocked(agentSessionId, numTurns) {
3365
3616
  if (numTurns < 1)
3366
3617
  return true;
3367
- const response = await this.getAppServerClient().threadRollback(agentSessionId, numTurns);
3618
+ const response = await this.getAppServerClientForThread(agentSessionId).threadRollback(agentSessionId, numTurns);
3368
3619
  return !!response.thread;
3369
3620
  }
3370
3621
  async rewindFiles(agentSessionId, projectPath, userMessageId) {
@@ -3611,7 +3862,7 @@ export class CodexRunner {
3611
3862
  this.unregisterChildThread(childThreadId);
3612
3863
  }
3613
3864
  }
3614
- trackSubagentLifecycle(notification, state) {
3865
+ trackSubagentLifecycle(notification, state, appServer) {
3615
3866
  const params = (notification.params || {});
3616
3867
  const item = params.item && typeof params.item === 'object' ? params.item : undefined;
3617
3868
  if (!this.isSubagentActivityItem(item))
@@ -3623,13 +3874,17 @@ export class CodexRunner {
3623
3874
  if (kind === 'started') {
3624
3875
  state.activeSubagentThreads.add(childThreadId);
3625
3876
  this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
3877
+ if (appServer)
3878
+ this.markThreadSubscribed(appServer, childThreadId);
3626
3879
  }
3627
3880
  if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3628
3881
  state.activeSubagentThreads.delete(childThreadId);
3882
+ if (appServer)
3883
+ void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
3629
3884
  this.unregisterChildThread(childThreadId);
3630
3885
  }
3631
3886
  }
3632
- primeSubagentStateFromTurn(turn, state) {
3887
+ primeSubagentStateFromTurn(turn, state, appServer) {
3633
3888
  for (const item of this.getTurnItems(turn)) {
3634
3889
  if (!this.isSubagentActivityItem(item))
3635
3890
  continue;
@@ -3640,9 +3895,13 @@ export class CodexRunner {
3640
3895
  if (kind === 'started') {
3641
3896
  state.activeSubagentThreads.add(childThreadId);
3642
3897
  this.registerChildThread(state.sessionId, childThreadId, state.projectPath);
3898
+ if (appServer)
3899
+ this.markThreadSubscribed(appServer, childThreadId);
3643
3900
  }
3644
3901
  else if (kind === 'completed' || kind === 'failed' || kind === 'stopped' || kind === 'killed') {
3645
3902
  state.activeSubagentThreads.delete(childThreadId);
3903
+ if (appServer)
3904
+ void this.releaseThreadSubscription(appServer, childThreadId, 'subagent-finished');
3646
3905
  this.unregisterChildThread(childThreadId);
3647
3906
  }
3648
3907
  }
@@ -3657,7 +3916,7 @@ export class CodexRunner {
3657
3916
  }
3658
3917
  queue.end();
3659
3918
  }
3660
- async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, tempFiles) {
3919
+ async *transformAppServerStream(notifications, sessionId, state, controller, unsubscribe, appServer, tempFiles) {
3661
3920
  try {
3662
3921
  let sessionIdEmitted = state.committed;
3663
3922
  if (sessionIdEmitted)
@@ -3686,6 +3945,7 @@ export class CodexRunner {
3686
3945
  }
3687
3946
  finally {
3688
3947
  unsubscribe();
3948
+ await this.releaseThreadSubscription(appServer, state.threadId, 'turn-finished');
3689
3949
  if (this.activeAbortControllers.get(sessionId) === controller) {
3690
3950
  this.activeAbortControllers.delete(sessionId);
3691
3951
  }
@@ -4266,10 +4526,10 @@ export class CodexRunner {
4266
4526
  if (!stat.isDirectory() || stat.isSymbolicLink()) {
4267
4527
  throw new Error(`managed temp path is not a real directory: ${directory}`);
4268
4528
  }
4269
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
4529
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
4270
4530
  throw new Error(`managed temp path is owned by another uid: ${directory}`);
4271
4531
  }
4272
- if ((stat.mode & 0o077) !== 0)
4532
+ if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0)
4273
4533
  fs.chmodSync(directory, 0o700);
4274
4534
  }
4275
4535
  ensureSessionTempDir(sessionId) {
@@ -4345,7 +4605,7 @@ export class CodexRunner {
4345
4605
  return undefined;
4346
4606
  return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 `/tmp/evolcore-codex-*` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 `$TMPDIR` 重新创建,再继续执行后续 `ec` 命令。';
4347
4607
  }
4348
- buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier) {
4608
+ buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier, taskDelegationToken) {
4349
4609
  const managedTempDir = this.ensureSessionTempDir(sessionId);
4350
4610
  return {
4351
4611
  shell_environment_policy: {
@@ -4354,7 +4614,9 @@ export class CodexRunner {
4354
4614
  EVOLCORE_HOME: resolveRoot(),
4355
4615
  ...(managedTempDir ? { EVOLCORE_SESSION_RUNTIME_DIR: managedTempDir } : {}),
4356
4616
  ...(managedTempDir ? { EVOLCORE_RUNTIME_LOCK_DIR: path.join(path.dirname(managedTempDir), 'evolcore-locks') } : {}),
4357
- ...(delegationCarrier ? { [AGENT_DELEGATION_TOKEN_ENV]: delegationCarrier } : {}),
4617
+ ...(taskDelegationToken || delegationCarrier
4618
+ ? { [AGENT_DELEGATION_TOKEN_ENV]: taskDelegationToken ?? delegationCarrier }
4619
+ : {}),
4358
4620
  ...(managedTempDir ? { TMPDIR: managedTempDir } : {}),
4359
4621
  },
4360
4622
  },
@@ -4384,6 +4646,8 @@ export class CodexRunner {
4384
4646
  controller.abort('dispose');
4385
4647
  }
4386
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();
4387
4651
  this.activeAbortControllers.clear();
4388
4652
  this.activeStreams.clear();
4389
4653
  this.activeSessions.clear();
@@ -4404,8 +4668,12 @@ export class CodexRunner {
4404
4668
  this.threadOperationLocks.clear();
4405
4669
  for (const sessionId of [...this.sessionTempDirs.keys()])
4406
4670
  this.cleanupSessionTempDir(sessionId);
4407
- await this.appServerClient?.close();
4671
+ await Promise.all([
4672
+ this.appServerClient?.close(),
4673
+ this.fullAccessAppServerClient?.close(),
4674
+ ]);
4408
4675
  this.appServerClient = null;
4676
+ this.fullAccessAppServerClient = null;
4409
4677
  }
4410
4678
  }
4411
4679
  // ── Plugin ──