@keyou007/agent-bot 0.1.24-alpha.5 → 0.1.24-alpha.6

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 (96) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -3
  3. package/README.zh.md +27 -3
  4. package/SECURITY.md +12 -0
  5. package/config.example.yaml +5 -0
  6. package/dist/cli/controlProtocol.js.map +1 -1
  7. package/dist/cli/serverStatus.js +1 -0
  8. package/dist/cli/serverStatus.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/codex/CodexRuntime.js +202 -115
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/codex/ProviderSwitch.js +12 -13
  14. package/dist/codex/ProviderSwitch.js.map +1 -1
  15. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  16. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  17. package/dist/config/loadConfig.js +4 -0
  18. package/dist/config/loadConfig.js.map +1 -1
  19. package/dist/config/schema.js +3 -0
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/feishu/CardRenderer.js +66 -8
  22. package/dist/feishu/CardRenderer.js.map +1 -1
  23. package/dist/feishu/FeishuConnector.js +1 -0
  24. package/dist/feishu/FeishuConnector.js.map +1 -1
  25. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  26. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  27. package/dist/index.js +43 -6
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-files/LocalFileViewerServer.js +51 -4
  30. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  31. package/dist/local-files/TurnPreviewPage.js +7 -2
  32. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  33. package/dist/presentation/TurnStateReducer.js +41 -6
  34. package/dist/presentation/TurnStateReducer.js.map +1 -1
  35. package/dist/proxy/ProxySessionController.js +615 -214
  36. package/dist/proxy/ProxySessionController.js.map +1 -1
  37. package/dist/proxy/SessionOperationQueue.js +14 -0
  38. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  39. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  40. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  41. package/dist/state/StateStore.js +22 -0
  42. package/dist/state/StateStore.js.map +1 -1
  43. package/dist/state/TurnPreviewJournal.js +19 -7
  44. package/dist/state/TurnPreviewJournal.js.map +1 -1
  45. package/dist/state/migrations.js +6 -0
  46. package/dist/state/migrations.js.map +1 -1
  47. package/dist/supervision/SafeRestartNotifier.js +1 -0
  48. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  49. package/dist/supervision/SafeRestartScheduler.js +19 -6
  50. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  51. package/dist/terminal/TerminalCards.js +13 -0
  52. package/dist/terminal/TerminalCards.js.map +1 -0
  53. package/dist/terminal/TerminalLinks.js +76 -0
  54. package/dist/terminal/TerminalLinks.js.map +1 -0
  55. package/dist/terminal/TerminalPage.js +212 -0
  56. package/dist/terminal/TerminalPage.js.map +1 -0
  57. package/dist/terminal/TerminalProcess.js +71 -0
  58. package/dist/terminal/TerminalProcess.js.map +1 -0
  59. package/dist/terminal/TerminalServer.js +639 -0
  60. package/dist/terminal/TerminalServer.js.map +1 -0
  61. package/docs/technical-reference.md +48 -0
  62. package/docs/technical-reference.zh.md +48 -0
  63. package/npm-shrinkwrap.json +55 -5
  64. package/package.json +11 -4
  65. package/skills/agent-bot/SKILL.md +9 -2
  66. package/src/cli/controlProtocol.ts +2 -1
  67. package/src/cli/serverStatus.ts +1 -0
  68. package/src/cli.ts +6 -1
  69. package/src/codex/CodexRuntime.ts +200 -123
  70. package/src/codex/ProviderSwitch.ts +11 -12
  71. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  72. package/src/config/loadConfig.ts +4 -0
  73. package/src/config/schema.ts +5 -1
  74. package/src/feishu/CardRenderer.ts +81 -8
  75. package/src/feishu/FeishuConnector.ts +1 -0
  76. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  77. package/src/feishu/types.ts +1 -0
  78. package/src/index.ts +44 -7
  79. package/src/local-files/LocalFileViewerServer.ts +48 -4
  80. package/src/local-files/TurnPreviewPage.ts +7 -2
  81. package/src/presentation/TurnStateReducer.ts +46 -7
  82. package/src/presentation/turnViewTypes.ts +5 -0
  83. package/src/proxy/ProxySessionController.ts +625 -211
  84. package/src/proxy/SessionOperationQueue.ts +13 -0
  85. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  86. package/src/runtime/types.ts +8 -3
  87. package/src/state/StateStore.ts +38 -1
  88. package/src/state/TurnPreviewJournal.ts +19 -7
  89. package/src/state/migrations.ts +6 -0
  90. package/src/supervision/SafeRestartNotifier.ts +1 -0
  91. package/src/supervision/SafeRestartScheduler.ts +18 -6
  92. package/src/terminal/TerminalCards.ts +18 -0
  93. package/src/terminal/TerminalLinks.ts +61 -0
  94. package/src/terminal/TerminalPage.ts +213 -0
  95. package/src/terminal/TerminalProcess.ts +71 -0
  96. package/src/terminal/TerminalServer.ts +513 -0
@@ -7,7 +7,7 @@ import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
7
7
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
8
8
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
9
9
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
10
- import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
10
+ import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, PROVIDER_SWITCH_EMPTY, ProviderSwitchError, providerSwitchFailure } from "./ProviderSwitch.js";
11
11
  import { fetchProviderModels } from "./ProviderModels.js";
12
12
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
13
13
  "When capturing any screenshot on Windows, use one fresh process and make it Per-Monitor DPI Aware V2 before loading System.Windows.Forms, System.Drawing, or UI Automation, and before calling any screen, window, or bounds API.",
@@ -94,6 +94,20 @@ export class CodexRuntime {
94
94
  async resumeSession(input) {
95
95
  return this.runSessionOperation(input.localSessionId, () => this.resumeSessionNow(input));
96
96
  }
97
+ async prepareSessionForSettings(input) {
98
+ return this.runSessionOperation(input.localSessionId, async () => {
99
+ const existing = this.sessions.get(input.localSessionId);
100
+ if (existing)
101
+ return existing;
102
+ const session = this.makeSession(input, {
103
+ thread: { id: input.remoteSessionId, name: input.title },
104
+ modelProvider: input.modelProvider, model: input.model,
105
+ }, input.reasoningEffort);
106
+ session.needsResume = true;
107
+ this.sessions.set(input.localSessionId, session);
108
+ return session;
109
+ });
110
+ }
97
111
  async resumeSessionNow(input) {
98
112
  const client = await this.client();
99
113
  const response = await client.request("thread/resume", {
@@ -105,8 +119,18 @@ export class CodexRuntime {
105
119
  ...threadLifecycleParams(input.cwd),
106
120
  ...permissionParams(input.permissionMode),
107
121
  }, SESSION_REQUEST_TIMEOUT_MS);
122
+ if (response.thread.id !== input.remoteSessionId)
123
+ throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
108
124
  const reasoningEffort = await this.resolveReasoningEffort(input, response);
109
125
  const session = this.makeSession(input, response, reasoningEffort);
126
+ try {
127
+ assertProviderSettingsApplied(response, { modelProvider: input.modelProvider });
128
+ }
129
+ catch (error) {
130
+ // Keep the attachment available for an explicit settings repair, but never send to the wrong Provider.
131
+ session.settingsRecoveryError = providerSwitchFailure(error);
132
+ session.needsResume = true;
133
+ }
110
134
  this.sessions.set(input.localSessionId, session);
111
135
  return session;
112
136
  }
@@ -150,13 +174,18 @@ export class CodexRuntime {
150
174
  const session = this.requireSession(sessionId);
151
175
  const client = await this.client();
152
176
  await this.ensureSessionResumed(session, client);
153
- session.canReplaceEmptyThread = false;
154
177
  const start = async () => client.request("turn/start", {
155
178
  threadId: session.remoteSessionId,
156
179
  input: codexUserInput(prompt),
157
180
  cwd: session.cwd,
158
181
  model: session.model,
159
182
  effort: session.reasoningEffort,
183
+ ...(typeof prompt !== "string" && prompt.collaborationMode ? {
184
+ collaborationMode: {
185
+ mode: prompt.collaborationMode,
186
+ settings: { model: session.model, reasoning_effort: session.reasoningEffort ?? null, developer_instructions: null },
187
+ },
188
+ } : {}),
160
189
  summary: "auto",
161
190
  approvalPolicy: session.permissionMode === "auto" ? "never" : "on-request",
162
191
  sandboxPolicy: await this.turnSandboxPolicy(session, client),
@@ -444,9 +473,10 @@ export class CodexRuntime {
444
473
  .find((session) => session.remoteSessionId === remoteSessionId);
445
474
  const detectedTurnId = activeThreads?.get(remoteSessionId);
446
475
  // A turn started in this process is evidence; a restored activeTurnId alone is not.
476
+ const observedTurnId = runtimeSession?.activeTurnStartedAt !== undefined ? runtimeSession.activeTurnId : undefined;
447
477
  const active = status === "active" || Boolean(activeThreads?.has(remoteSessionId))
448
- || Boolean(runtimeSession?.activeTurnId && runtimeSession.activeTurnStartedAt !== undefined);
449
- const activeTurnId = detectedTurnId ?? runtimeSession?.activeTurnId;
478
+ || Boolean(observedTurnId);
479
+ const activeTurnId = detectedTurnId ?? observedTurnId;
450
480
  return {
451
481
  active,
452
482
  ...(active && activeTurnId ? { activeTurnId } : {}),
@@ -479,6 +509,14 @@ export class CodexRuntime {
479
509
  await this.interruptRemoteTurn(session.remoteSessionId, turnId, sessionId);
480
510
  }
481
511
  async interruptRemoteTurn(remoteSessionId, turnId, sessionId) {
512
+ for (const session of this.sessions.values()) {
513
+ if (session.remoteSessionId === remoteSessionId)
514
+ session.planContinuationTurnId = undefined;
515
+ }
516
+ for (const pending of this.modeChanges.values()) {
517
+ if (pending.turnId === turnId)
518
+ pending.decision = "cancel";
519
+ }
482
520
  await (await this.client()).request("turn/interrupt", { threadId: remoteSessionId, turnId }, CONTROL_REQUEST_TIMEOUT_MS);
483
521
  this.logger.info({ ...(sessionId ? { sessionId } : {}), threadId: remoteSessionId, turnId }, "App Server accepted the turn interrupt request.");
484
522
  }
@@ -488,13 +526,53 @@ export class CodexRuntime {
488
526
  await this.archiveRemoteSession(session.remoteSessionId);
489
527
  });
490
528
  }
491
- async archiveRemoteSession(remoteSessionId) {
492
- await (await this.client()).request("thread/archive", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS);
529
+ async archiveRemoteSession(remoteSessionId, options = {}) {
530
+ const client = await this.client();
531
+ try {
532
+ await client.request("thread/archive", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS);
533
+ }
534
+ catch (error) {
535
+ if (!options.allowUnmaterialized || !(error instanceof AppServerRequestError)
536
+ || error.method !== "thread/archive" || error.code !== -32600
537
+ || error.serverMessage !== `no rollout found for thread id ${remoteSessionId}`
538
+ || !await this.isUnmaterializedThread(client, remoteSessionId))
539
+ throw error;
540
+ // Codex has nothing to move to its archive before the first user message.
541
+ const result = await client.request("thread/unsubscribe", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS);
542
+ if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
543
+ throw new Error(`Cannot release empty App Server task: ${result.status}`);
544
+ }
545
+ this.logger.info({ remoteSessionId }, "Released an empty App Server task without a materialized rollout.");
546
+ }
493
547
  for (const [localSessionId, session] of this.sessions) {
494
548
  if (session.remoteSessionId === remoteSessionId)
495
549
  this.sessions.delete(localSessionId);
496
550
  }
497
551
  }
552
+ async isUnmaterializedThread(client, remoteSessionId) {
553
+ const { thread } = await client.request("thread/read", { threadId: remoteSessionId, includeTurns: false }, CONTROL_REQUEST_TIMEOUT_MS);
554
+ if (thread.id !== remoteSessionId || thread.preview !== "" || thread.forkedFromId !== null
555
+ || !["idle", "notLoaded"].includes(thread.status?.type ?? "") || !thread.path
556
+ || (thread.turns?.length ?? 0) > 0)
557
+ return false;
558
+ for (const session of this.sessions.values()) {
559
+ if (session.remoteSessionId === remoteSessionId && (session.activeTurnId
560
+ || session.knownTurnIds.size > 0 || (session.turnCount ?? 0) > 0))
561
+ return false;
562
+ }
563
+ const active = await this.localActivityDetector?.activeThreads([remoteSessionId]);
564
+ if (active?.has(remoteSessionId))
565
+ return false;
566
+ try {
567
+ statSync(thread.path);
568
+ return false;
569
+ }
570
+ catch (error) {
571
+ if (error.code === "ENOENT")
572
+ return true;
573
+ throw error;
574
+ }
575
+ }
498
576
  async setTitle(sessionId, title) {
499
577
  return this.runSessionOperation(sessionId, () => this.setTitleNow(sessionId, title));
500
578
  }
@@ -523,7 +601,6 @@ export class CodexRuntime {
523
601
  const session = this.requireSession(sessionId);
524
602
  const client = await this.client();
525
603
  await this.ensureSessionResumed(session, client);
526
- session.canReplaceEmptyThread = false;
527
604
  const response = await client.request("thread/goal/set", { threadId: session.remoteSessionId, ...update }, CONTROL_REQUEST_TIMEOUT_MS);
528
605
  return response.goal;
529
606
  }
@@ -547,7 +624,18 @@ export class CodexRuntime {
547
624
  await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).permissionMode = mode; });
548
625
  }
549
626
  async setExecutionSettings(sessionId, settings, persist) {
550
- return this.runSessionOperation(sessionId, () => this.setExecutionSettingsNow(sessionId, settings, persist));
627
+ return this.runSessionOperation(sessionId, async () => {
628
+ try {
629
+ return await this.setExecutionSettingsNow(sessionId, settings, persist);
630
+ }
631
+ catch (error) {
632
+ if (error instanceof ProviderSwitchError)
633
+ throw error;
634
+ // Errors outside the mutation block are preflight failures; no settings were changed.
635
+ const session = this.sessions.get(sessionId);
636
+ throw new ProviderSwitchError(providerSwitchFailure(error), Boolean(session && !session.needsResume && !session.settingsRecoveryError), { cause: error });
637
+ }
638
+ });
551
639
  }
552
640
  async setExecutionSettingsNow(sessionId, settings, persist) {
553
641
  const session = this.requireSession(sessionId);
@@ -561,88 +649,60 @@ export class CodexRuntime {
561
649
  if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
562
650
  throw new Error(PROVIDER_SWITCH_BUSY);
563
651
  }
564
- let empty = false;
565
- if (session.canReplaceEmptyThread && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
652
+ if (session.turnCount === 0 && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
566
653
  try {
567
654
  const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
568
- empty = turns.length === 0;
569
- if (!empty)
570
- session.canReplaceEmptyThread = false;
655
+ if (turns.length === 0)
656
+ throw new Error(PROVIDER_SWITCH_EMPTY);
571
657
  }
572
658
  catch (error) {
573
659
  if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
574
660
  || !/missing source rollout|no rollout found/iu.test(error.serverMessage))
575
661
  throw error;
576
- empty = true;
662
+ throw new Error(PROVIDER_SWITCH_EMPTY, { cause: error });
577
663
  }
578
664
  }
579
665
  const previous = { ...session };
580
- let replacementId;
666
+ const restoreProvider = previous.modelProvider?.trim()
667
+ || (metadata.thread.id === previous.remoteSessionId ? metadata.thread.modelProvider?.trim() : undefined);
581
668
  let detached = false;
582
669
  try {
583
670
  const params = {
584
671
  cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
585
672
  ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
586
673
  };
587
- if (!empty) {
588
- await this.detachThreadForSettings(client, session);
589
- detached = true;
590
- }
591
- let response;
592
- if (empty) {
593
- response = await client.request("thread/start", {
594
- ...params, threadSource: "user", allowProviderModelFallback: false,
595
- }, SESSION_REQUEST_TIMEOUT_MS);
596
- replacementId = response.thread.id;
597
- }
598
- else {
599
- response = await client.request("thread/resume", {
600
- ...params, threadId: session.remoteSessionId, excludeTurns: true,
601
- }, SESSION_REQUEST_TIMEOUT_MS);
602
- if (!providerSettingsMatch(response, settings)) {
603
- this.logger.warn({
604
- sessionId,
605
- threadId: session.remoteSessionId,
606
- requestedProvider: settings.modelProvider,
607
- actualProvider: response.modelProvider,
608
- requestedModel: settings.model,
609
- actualModel: response.model,
610
- }, "App Server did not apply Provider settings while resuming; forking the latest completed Turn.");
611
- await this.detachThreadForSettings(client, session);
612
- response = await this.forkThreadForSettings(client, session, settings);
613
- replacementId = response.thread.id;
614
- }
615
- }
674
+ await this.detachThreadForSettings(client, session, () => { detached = true; });
675
+ const response = await client.request("thread/resume", {
676
+ ...params, threadId: session.remoteSessionId, excludeTurns: true,
677
+ }, SESSION_REQUEST_TIMEOUT_MS);
616
678
  assertProviderSettingsApplied(response, settings);
617
- if (!empty && !replacementId && response.thread.id !== session.remoteSessionId) {
679
+ if (response.thread.id !== session.remoteSessionId) {
618
680
  throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
619
681
  }
620
- if (session.title && (empty || replacementId))
621
- await client.request("thread/name/set", {
622
- threadId: response.thread.id, name: session.title,
623
- }, SESSION_REQUEST_TIMEOUT_MS);
624
- const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
682
+ const candidate = { ...session, ...settings,
625
683
  workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
626
684
  needsResume: false, settingsRecoveryError: undefined };
627
685
  await persist?.(candidate);
628
- Object.assign(session, candidate);
686
+ Object.assign(session, settings);
687
+ session.workspaceSandboxPolicy = candidate.workspaceSandboxPolicy;
688
+ session.needsResume = this.attachedClient !== client;
689
+ session.settingsRecoveryError = undefined;
629
690
  }
630
691
  catch (error) {
631
- if (replacementId)
632
- await this.discardUnusedThread(client, replacementId);
633
692
  let recovery = "原任务设置未更改。";
634
693
  if (detached) {
635
694
  try {
695
+ if (!restoreProvider)
696
+ throw new Error("无法确认切换前的 Provider,不能验证原设置已恢复。");
636
697
  await this.detachThreadForSettings(client, previous);
637
698
  const restored = await client.request("thread/resume", {
638
699
  threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
639
- modelProvider: previous.modelProvider, model: previous.model,
700
+ modelProvider: restoreProvider, model: previous.model,
640
701
  ...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
641
702
  }, SESSION_REQUEST_TIMEOUT_MS);
642
- if (previous.modelProvider && previous.model)
643
- assertProviderSettingsApplied(restored, {
644
- modelProvider: previous.modelProvider, model: previous.model,
645
- });
703
+ assertProviderSettingsApplied(restored, {
704
+ modelProvider: restoreProvider, model: previous.model,
705
+ });
646
706
  if (restored.thread.id !== previous.remoteSessionId)
647
707
  throw new Error("恢复时返回了不同的任务 ID。");
648
708
  session.needsResume = false;
@@ -656,52 +716,29 @@ export class CodexRuntime {
656
716
  session.needsResume = true;
657
717
  }
658
718
  }
659
- throw new Error(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, { cause: error });
719
+ throw new ProviderSwitchError(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, !session.needsResume && !session.settingsRecoveryError, { cause: error });
660
720
  }
661
- if (replacementId)
662
- await this.discardUnusedThread(client, previous.remoteSessionId);
663
721
  return session;
664
722
  }
665
- async detachThreadForSettings(client, session) {
723
+ async detachThreadForSettings(client, session, onDetach) {
666
724
  const current = await client.request("thread/read", {
667
725
  threadId: session.remoteSessionId, includeTurns: false,
668
726
  }, SYNC_REQUEST_TIMEOUT_MS);
669
727
  if (session.activeTurnId || current.thread.status?.type === "active")
670
728
  throw new Error(PROVIDER_SWITCH_BUSY);
729
+ // A timeout does not cancel the remote unsubscribe, so recovery is required once it is sent.
730
+ onDetach?.();
671
731
  const result = await client.request("thread/unsubscribe", {
672
732
  threadId: session.remoteSessionId,
673
733
  }, CONTROL_REQUEST_TIMEOUT_MS);
674
- if (result.status !== "unsubscribed" && result.status !== "notLoaded") {
675
- throw new Error("当前任务仍由其他客户端加载,无法安全切换 Provider。请先在原客户端释放任务。");
676
- }
677
- }
678
- async forkThreadForSettings(client, session, settings) {
679
- const turns = await this.listRecentThreadTurnsThroughCompleted(client, session.remoteSessionId);
680
- const lastTurnId = turns.find((turn) => turn.status === "completed")?.id;
681
- if (!lastTurnId) {
682
- throw new Error("无法为 Provider 切换找到已完成的 Turn。请先完成一轮任务后重试。");
683
- }
684
- return client.request("thread/fork", {
685
- threadId: session.remoteSessionId,
686
- lastTurnId,
687
- cwd: session.cwd,
688
- model: settings.model,
689
- modelProvider: settings.modelProvider,
690
- threadSource: "user",
691
- excludeTurns: true,
692
- ...threadLifecycleParams(session.cwd),
693
- ...permissionParams(settings.permissionMode),
694
- }, FORK_REQUEST_TIMEOUT_MS);
695
- }
696
- async discardUnusedThread(client, threadId) {
697
- try {
698
- await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
699
- }
700
- catch (error) {
701
- this.logger.warn({ error, threadId }, "Failed to release an unused empty Thread after changing Provider.");
734
+ if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
735
+ throw new Error(`App Server 返回了未知的取消订阅状态:${result.status}`);
702
736
  }
703
737
  }
704
738
  async respondToApproval(sessionId, requestId, decision) {
739
+ return this.resolveApproval(sessionId, requestId, decision);
740
+ }
741
+ async resolveApproval(sessionId, requestId, decision, automatic = false) {
705
742
  const modeChange = this.modeChanges.get(requestId);
706
743
  if (modeChange) {
707
744
  if (modeChange.sessionId !== sessionId
@@ -714,6 +751,8 @@ export class CodexRuntime {
714
751
  if (modeChange.responding)
715
752
  throw new Error("Approval response is already in progress.");
716
753
  modeChange.responding = true;
754
+ modeChange.decision = decision;
755
+ modeChange.automatic = automatic;
717
756
  try {
718
757
  const client = await this.client();
719
758
  if (this.modeChanges.get(requestId) !== modeChange)
@@ -724,12 +763,16 @@ export class CodexRuntime {
724
763
  decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
725
764
  }, CONTROL_REQUEST_TIMEOUT_MS);
726
765
  if (this.modeChanges.get(requestId) === modeChange) {
766
+ this.confirmPlanContinuation(modeChange, decision);
727
767
  this.modeChanges.delete(requestId);
728
- this.emit({ type: "approval_resolved", sessionId, turnId: modeChange.turnId, requestId, decision });
768
+ this.emit({ type: "approval_resolved", sessionId, turnId: modeChange.turnId, requestId, decision,
769
+ ...(automatic ? { automatic: true } : {}) });
729
770
  }
730
771
  }
731
772
  finally {
732
773
  modeChange.responding = false;
774
+ if (modeChange.terminalEvent)
775
+ this.emit(modeChange.terminalEvent);
733
776
  }
734
777
  return;
735
778
  }
@@ -958,25 +1001,35 @@ export class CodexRuntime {
958
1001
  if (session.modeChangeRequestIds.has(requestId))
959
1002
  return;
960
1003
  session.modeChangeRequestIds.add(requestId);
961
- this.modeChanges.set(requestId, {
1004
+ const pending = {
962
1005
  sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
963
1006
  targetMode: mapped.targetMode, responding: false,
964
- });
965
- this.emit({
966
- type: "approval_requested", sessionId, turnId: mapped.turnId,
967
- request: {
968
- id: requestId,
969
- kind: "mode_change",
970
- title: mapped.targetMode === "default" ? "确认方案并开始执行" : "确认进入规划模式",
971
- reason: [mapped.reason, ...(mapped.allowedPrompts ?? []).map((entry) => `- ${entry.tool}: ${entry.prompt}`)]
972
- .filter(Boolean).join("\n"),
973
- options: [
974
- { id: "accept", label: mapped.targetMode === "default" ? "Approve Plan" : "Enter Plan Mode" },
975
- { id: "decline", label: "Reject" },
976
- { id: "cancel", label: "Cancel Request" },
977
- ],
978
- },
979
- });
1007
+ };
1008
+ this.modeChanges.set(requestId, pending);
1009
+ const automatic = session.permissionMode === "auto" && this.provider.getAgentFamily?.() === "traex";
1010
+ const request = {
1011
+ id: requestId,
1012
+ kind: "mode_change",
1013
+ ...(automatic ? { automatic: true } : {}),
1014
+ title: mapped.targetMode === "default" ? "确认方案并开始执行" : "确认进入规划模式",
1015
+ reason: [mapped.reason, ...(mapped.allowedPrompts ?? []).map((entry) => `- ${entry.tool}: ${entry.prompt}`)]
1016
+ .filter(Boolean).join("\n"),
1017
+ options: [
1018
+ { id: "accept", label: mapped.targetMode === "default" ? "Approve Plan" : "Enter Plan Mode" },
1019
+ { id: "decline", label: "Reject" },
1020
+ { id: "cancel", label: "Cancel Request" },
1021
+ ],
1022
+ };
1023
+ this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId, request });
1024
+ if (automatic) {
1025
+ void this.resolveApproval(sessionId, requestId, "accept", true).catch((error) => {
1026
+ this.logger.warn({ error, sessionId, requestId }, "Failed to automatically approve the TraeX mode change.");
1027
+ if (this.modeChanges.get(requestId) !== pending || session.activeTurnId !== mapped.turnId)
1028
+ return;
1029
+ this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId,
1030
+ request: { ...request, automatic: false, reason: ["自动同意失败,请手动确认。", request.reason].filter(Boolean).join("\n\n") } });
1031
+ });
1032
+ }
980
1033
  }
981
1034
  else if (mapped.kind === "mode_change_resolved") {
982
1035
  const requestId = `mode:${mapped.threadId}:${mapped.requestId}`;
@@ -984,10 +1037,12 @@ export class CodexRuntime {
984
1037
  if (!pending || pending.sessionId !== sessionId || pending.turnId !== mapped.turnId
985
1038
  || pending.targetMode !== mapped.targetMode)
986
1039
  return;
1040
+ this.confirmPlanContinuation(pending, mapped.decision === "approved" ? "accept" : "decline");
987
1041
  this.modeChanges.delete(requestId);
988
1042
  this.emit({
989
1043
  type: "approval_resolved", sessionId, turnId: mapped.turnId, requestId,
990
1044
  decision: mapped.decision === "approved" ? "accept" : mapped.decision === "rejected" ? "decline" : "cancel",
1045
+ ...(pending.automatic && mapped.decision === "approved" ? { automatic: true } : {}),
991
1046
  });
992
1047
  }
993
1048
  else if (mapped.kind === "token_usage") {
@@ -1024,6 +1079,7 @@ export class CodexRuntime {
1024
1079
  session.unphasedFinalMessageId = undefined;
1025
1080
  }
1026
1081
  else if (mapped.kind === "agent_delta") {
1082
+ this.clearPlanContinuation(session, mapped.turnId);
1027
1083
  const phase = session.messagePhases.get(mapped.itemId);
1028
1084
  if (phase !== "final_answer") {
1029
1085
  // Providers may omit phase. Keep their text in sequence until a tool or completion disambiguates it.
@@ -1081,6 +1137,9 @@ export class CodexRuntime {
1081
1137
  this.emit({ type: "plan_updated", sessionId, turnId: mapped.turnId, steps: mapped.steps });
1082
1138
  }
1083
1139
  else if (mapped.kind === "tool") {
1140
+ // A backend that resumes tool execution itself needs no client handoff.
1141
+ if (mapped.phase === "started")
1142
+ this.clearPlanContinuation(session, mapped.turnId);
1084
1143
  if (mapped.phase === "started")
1085
1144
  session.unphasedFinalMessageId = undefined;
1086
1145
  if (mapped.phase === "updated"
@@ -1208,6 +1267,9 @@ export class CodexRuntime {
1208
1267
  ...threadLifecycleParams(session.cwd),
1209
1268
  ...permissionParams(session.permissionMode),
1210
1269
  }, SESSION_REQUEST_TIMEOUT_MS);
1270
+ if (response.thread.id !== session.remoteSessionId)
1271
+ throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
1272
+ assertProviderSettingsApplied(response, { modelProvider: session.modelProvider });
1211
1273
  session.workspaceSandboxPolicy = workspaceSandboxFromResponse(response.sandbox);
1212
1274
  }
1213
1275
  async turnSandboxPolicy(session, client) {
@@ -1276,7 +1338,6 @@ export class CodexRuntime {
1276
1338
  this.finishSnapshotTurn(session, latest);
1277
1339
  }
1278
1340
  adoptTurn(session, turnId, startedAt) {
1279
- session.canReplaceEmptyThread = false;
1280
1341
  if (!session.knownTurnIds.has(turnId)) {
1281
1342
  session.knownTurnIds.add(turnId);
1282
1343
  if (session.turnCount !== undefined)
@@ -1284,6 +1345,7 @@ export class CodexRuntime {
1284
1345
  }
1285
1346
  session.activeTurnId = turnId;
1286
1347
  session.activeTurnStartedAt = startedAt;
1348
+ session.planContinuationTurnId = undefined;
1287
1349
  session.modeChangeRequestIds.clear();
1288
1350
  session.unphasedMessages.clear();
1289
1351
  session.unphasedFinalMessageId = undefined;
@@ -1373,7 +1435,6 @@ export class CodexRuntime {
1373
1435
  unphasedMessages: new Map(),
1374
1436
  needsResume: false,
1375
1437
  workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
1376
- canReplaceEmptyThread: !("remoteSessionId" in input),
1377
1438
  rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1378
1439
  turnCount: "remoteSessionId" in input ? undefined : 0,
1379
1440
  knownTurnIds: new Set(),
@@ -1403,11 +1464,39 @@ export class CodexRuntime {
1403
1464
  throw new Error(`App Server turn is no longer active: ${turnId}`);
1404
1465
  return session;
1405
1466
  }
1467
+ clearPlanContinuation(session, turnId) {
1468
+ session.planContinuationTurnId = undefined;
1469
+ for (const pending of this.modeChanges.values()) {
1470
+ if (pending.sessionId === session.localSessionId && pending.turnId === turnId && pending.responding) {
1471
+ pending.continued = true;
1472
+ }
1473
+ }
1474
+ }
1475
+ confirmPlanContinuation(pending, decision) {
1476
+ const session = this.sessions.get(pending.sessionId);
1477
+ // Do not start work approved in a different client.
1478
+ if (session && pending.responding && !pending.continued && pending.decision === "accept" && decision === "accept"
1479
+ && pending.targetMode === "default")
1480
+ session.planContinuationTurnId = pending.turnId;
1481
+ }
1406
1482
  emit(event) {
1407
1483
  if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
1408
1484
  for (const [requestId, pending] of this.modeChanges) {
1409
- if (pending.sessionId === event.sessionId && pending.turnId === event.turnId)
1410
- this.modeChanges.delete(requestId);
1485
+ if (pending.sessionId !== event.sessionId || pending.turnId !== event.turnId)
1486
+ continue;
1487
+ if (event.type === "turn_completed" && pending.responding && pending.decision === "accept"
1488
+ && pending.targetMode === "default") {
1489
+ // The terminal notification may beat the approval RPC response.
1490
+ pending.terminalEvent = event;
1491
+ return;
1492
+ }
1493
+ this.modeChanges.delete(requestId);
1494
+ }
1495
+ const session = this.sessions.get(event.sessionId);
1496
+ if (session?.planContinuationTurnId === event.turnId) {
1497
+ session.planContinuationTurnId = undefined;
1498
+ if (event.type === "turn_completed")
1499
+ event = { ...event, approvedPlan: true };
1411
1500
  }
1412
1501
  }
1413
1502
  for (const listener of this.listeners)
@@ -1420,6 +1509,7 @@ export class CodexRuntime {
1420
1509
  this.modeChanges.clear();
1421
1510
  for (const session of this.sessions.values()) {
1422
1511
  session.needsResume = true;
1512
+ session.planContinuationTurnId = undefined;
1423
1513
  const turnId = session.activeTurnId;
1424
1514
  if (!turnId)
1425
1515
  continue;
@@ -1732,9 +1822,6 @@ function remoteThreadStatus(value) {
1732
1822
  return "error";
1733
1823
  return "not_loaded";
1734
1824
  }
1735
- function providerSettingsMatch(response, settings) {
1736
- return response.modelProvider === settings.modelProvider && response.model === settings.model;
1737
- }
1738
1825
  function codexSourceLabel(source) {
1739
1826
  if (typeof source === "string")
1740
1827
  return source;