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

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 (105) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +29 -3
  3. package/README.zh.md +29 -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/CodexEventMapper.js +1 -0
  12. package/dist/codex/CodexEventMapper.js.map +1 -1
  13. package/dist/codex/CodexRuntime.js +247 -120
  14. package/dist/codex/CodexRuntime.js.map +1 -1
  15. package/dist/codex/ProviderSwitch.js +12 -13
  16. package/dist/codex/ProviderSwitch.js.map +1 -1
  17. package/dist/codex/TraePlanEntry.js +70 -0
  18. package/dist/codex/TraePlanEntry.js.map +1 -0
  19. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  20. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  21. package/dist/config/loadConfig.js +4 -0
  22. package/dist/config/loadConfig.js.map +1 -1
  23. package/dist/config/schema.js +3 -0
  24. package/dist/config/schema.js.map +1 -1
  25. package/dist/feishu/CardRenderer.js +92 -8
  26. package/dist/feishu/CardRenderer.js.map +1 -1
  27. package/dist/feishu/FeishuConnector.js +1 -0
  28. package/dist/feishu/FeishuConnector.js.map +1 -1
  29. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  30. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  31. package/dist/index.js +57 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-files/LocalFileViewerServer.js +51 -4
  34. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  35. package/dist/local-files/TurnPreviewPage.js +7 -2
  36. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  37. package/dist/presentation/TurnStateReducer.js +41 -6
  38. package/dist/presentation/TurnStateReducer.js.map +1 -1
  39. package/dist/proxy/ProxySessionController.js +620 -214
  40. package/dist/proxy/ProxySessionController.js.map +1 -1
  41. package/dist/proxy/SessionOperationQueue.js +14 -0
  42. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  43. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  44. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  45. package/dist/state/StateStore.js +22 -0
  46. package/dist/state/StateStore.js.map +1 -1
  47. package/dist/state/TurnPreviewJournal.js +19 -7
  48. package/dist/state/TurnPreviewJournal.js.map +1 -1
  49. package/dist/state/migrations.js +6 -0
  50. package/dist/state/migrations.js.map +1 -1
  51. package/dist/supervision/SafeRestartNotifier.js +33 -1
  52. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  53. package/dist/supervision/SafeRestartScheduler.js +22 -6
  54. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  55. package/dist/terminal/TerminalCards.js +13 -0
  56. package/dist/terminal/TerminalCards.js.map +1 -0
  57. package/dist/terminal/TerminalCommandTracker.js +74 -0
  58. package/dist/terminal/TerminalCommandTracker.js.map +1 -0
  59. package/dist/terminal/TerminalLinks.js +76 -0
  60. package/dist/terminal/TerminalLinks.js.map +1 -0
  61. package/dist/terminal/TerminalPage.js +212 -0
  62. package/dist/terminal/TerminalPage.js.map +1 -0
  63. package/dist/terminal/TerminalProcess.js +95 -0
  64. package/dist/terminal/TerminalProcess.js.map +1 -0
  65. package/dist/terminal/TerminalServer.js +676 -0
  66. package/dist/terminal/TerminalServer.js.map +1 -0
  67. package/docs/technical-reference.md +48 -0
  68. package/docs/technical-reference.zh.md +48 -0
  69. package/npm-shrinkwrap.json +55 -5
  70. package/package.json +11 -4
  71. package/skills/agent-bot/SKILL.md +11 -2
  72. package/src/cli/controlProtocol.ts +2 -1
  73. package/src/cli/serverStatus.ts +1 -0
  74. package/src/cli.ts +6 -1
  75. package/src/codex/CodexEventMapper.ts +1 -0
  76. package/src/codex/CodexRuntime.ts +244 -128
  77. package/src/codex/ProviderSwitch.ts +11 -12
  78. package/src/codex/TraePlanEntry.ts +63 -0
  79. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  80. package/src/config/loadConfig.ts +4 -0
  81. package/src/config/schema.ts +5 -1
  82. package/src/feishu/CardRenderer.ts +109 -8
  83. package/src/feishu/FeishuConnector.ts +1 -0
  84. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  85. package/src/feishu/types.ts +1 -0
  86. package/src/index.ts +56 -8
  87. package/src/local-files/LocalFileViewerServer.ts +48 -4
  88. package/src/local-files/TurnPreviewPage.ts +7 -2
  89. package/src/presentation/TurnStateReducer.ts +46 -7
  90. package/src/presentation/turnViewTypes.ts +5 -0
  91. package/src/proxy/ProxySessionController.ts +629 -211
  92. package/src/proxy/SessionOperationQueue.ts +13 -0
  93. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  94. package/src/runtime/types.ts +8 -3
  95. package/src/state/StateStore.ts +38 -1
  96. package/src/state/TurnPreviewJournal.ts +19 -7
  97. package/src/state/migrations.ts +6 -0
  98. package/src/supervision/SafeRestartNotifier.ts +35 -2
  99. package/src/supervision/SafeRestartScheduler.ts +25 -6
  100. package/src/terminal/TerminalCards.ts +18 -0
  101. package/src/terminal/TerminalCommandTracker.ts +74 -0
  102. package/src/terminal/TerminalLinks.ts +61 -0
  103. package/src/terminal/TerminalPage.ts +213 -0
  104. package/src/terminal/TerminalProcess.ts +93 -0
  105. package/src/terminal/TerminalServer.ts +551 -0
@@ -1,13 +1,15 @@
1
1
  import { statSync } from "node:fs";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
3
4
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
4
5
  import { AppServerRequestError } from "./AppServerConnection.js";
5
6
  import { workspaceSandboxFromConfig, workspaceSandboxFromResponse } from "./AppServerPermissions.js";
6
7
  import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
7
8
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
9
+ import { hasCompletedTraePlanEntry } from "./TraePlanEntry.js";
8
10
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
9
11
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
10
- import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
12
+ import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, PROVIDER_SWITCH_EMPTY, ProviderSwitchError, providerSwitchFailure } from "./ProviderSwitch.js";
11
13
  import { fetchProviderModels } from "./ProviderModels.js";
12
14
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
13
15
  "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 +96,20 @@ export class CodexRuntime {
94
96
  async resumeSession(input) {
95
97
  return this.runSessionOperation(input.localSessionId, () => this.resumeSessionNow(input));
96
98
  }
99
+ async prepareSessionForSettings(input) {
100
+ return this.runSessionOperation(input.localSessionId, async () => {
101
+ const existing = this.sessions.get(input.localSessionId);
102
+ if (existing)
103
+ return existing;
104
+ const session = this.makeSession(input, {
105
+ thread: { id: input.remoteSessionId, name: input.title },
106
+ modelProvider: input.modelProvider, model: input.model,
107
+ }, input.reasoningEffort);
108
+ session.needsResume = true;
109
+ this.sessions.set(input.localSessionId, session);
110
+ return session;
111
+ });
112
+ }
97
113
  async resumeSessionNow(input) {
98
114
  const client = await this.client();
99
115
  const response = await client.request("thread/resume", {
@@ -105,8 +121,18 @@ export class CodexRuntime {
105
121
  ...threadLifecycleParams(input.cwd),
106
122
  ...permissionParams(input.permissionMode),
107
123
  }, SESSION_REQUEST_TIMEOUT_MS);
124
+ if (response.thread.id !== input.remoteSessionId)
125
+ throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
108
126
  const reasoningEffort = await this.resolveReasoningEffort(input, response);
109
127
  const session = this.makeSession(input, response, reasoningEffort);
128
+ try {
129
+ assertProviderSettingsApplied(response, { modelProvider: input.modelProvider });
130
+ }
131
+ catch (error) {
132
+ // Keep the attachment available for an explicit settings repair, but never send to the wrong Provider.
133
+ session.settingsRecoveryError = providerSwitchFailure(error);
134
+ session.needsResume = true;
135
+ }
110
136
  this.sessions.set(input.localSessionId, session);
111
137
  return session;
112
138
  }
@@ -150,13 +176,18 @@ export class CodexRuntime {
150
176
  const session = this.requireSession(sessionId);
151
177
  const client = await this.client();
152
178
  await this.ensureSessionResumed(session, client);
153
- session.canReplaceEmptyThread = false;
154
179
  const start = async () => client.request("turn/start", {
155
180
  threadId: session.remoteSessionId,
156
181
  input: codexUserInput(prompt),
157
182
  cwd: session.cwd,
158
183
  model: session.model,
159
184
  effort: session.reasoningEffort,
185
+ ...(typeof prompt !== "string" && prompt.collaborationMode ? {
186
+ collaborationMode: {
187
+ mode: prompt.collaborationMode,
188
+ settings: { model: session.model, reasoning_effort: session.reasoningEffort ?? null, developer_instructions: null },
189
+ },
190
+ } : {}),
160
191
  summary: "auto",
161
192
  approvalPolicy: session.permissionMode === "auto" ? "never" : "on-request",
162
193
  sandboxPolicy: await this.turnSandboxPolicy(session, client),
@@ -444,9 +475,10 @@ export class CodexRuntime {
444
475
  .find((session) => session.remoteSessionId === remoteSessionId);
445
476
  const detectedTurnId = activeThreads?.get(remoteSessionId);
446
477
  // A turn started in this process is evidence; a restored activeTurnId alone is not.
478
+ const observedTurnId = runtimeSession?.activeTurnStartedAt !== undefined ? runtimeSession.activeTurnId : undefined;
447
479
  const active = status === "active" || Boolean(activeThreads?.has(remoteSessionId))
448
- || Boolean(runtimeSession?.activeTurnId && runtimeSession.activeTurnStartedAt !== undefined);
449
- const activeTurnId = detectedTurnId ?? runtimeSession?.activeTurnId;
480
+ || Boolean(observedTurnId);
481
+ const activeTurnId = detectedTurnId ?? observedTurnId;
450
482
  return {
451
483
  active,
452
484
  ...(active && activeTurnId ? { activeTurnId } : {}),
@@ -479,6 +511,14 @@ export class CodexRuntime {
479
511
  await this.interruptRemoteTurn(session.remoteSessionId, turnId, sessionId);
480
512
  }
481
513
  async interruptRemoteTurn(remoteSessionId, turnId, sessionId) {
514
+ for (const session of this.sessions.values()) {
515
+ if (session.remoteSessionId === remoteSessionId)
516
+ session.planContinuationTurnId = undefined;
517
+ }
518
+ for (const pending of this.modeChanges.values()) {
519
+ if (pending.turnId === turnId)
520
+ pending.decision = "cancel";
521
+ }
482
522
  await (await this.client()).request("turn/interrupt", { threadId: remoteSessionId, turnId }, CONTROL_REQUEST_TIMEOUT_MS);
483
523
  this.logger.info({ ...(sessionId ? { sessionId } : {}), threadId: remoteSessionId, turnId }, "App Server accepted the turn interrupt request.");
484
524
  }
@@ -488,13 +528,53 @@ export class CodexRuntime {
488
528
  await this.archiveRemoteSession(session.remoteSessionId);
489
529
  });
490
530
  }
491
- async archiveRemoteSession(remoteSessionId) {
492
- await (await this.client()).request("thread/archive", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS);
531
+ async archiveRemoteSession(remoteSessionId, options = {}) {
532
+ const client = await this.client();
533
+ try {
534
+ await client.request("thread/archive", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS);
535
+ }
536
+ catch (error) {
537
+ if (!options.allowUnmaterialized || !(error instanceof AppServerRequestError)
538
+ || error.method !== "thread/archive" || error.code !== -32600
539
+ || error.serverMessage !== `no rollout found for thread id ${remoteSessionId}`
540
+ || !await this.isUnmaterializedThread(client, remoteSessionId))
541
+ throw error;
542
+ // Codex has nothing to move to its archive before the first user message.
543
+ const result = await client.request("thread/unsubscribe", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS);
544
+ if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
545
+ throw new Error(`Cannot release empty App Server task: ${result.status}`);
546
+ }
547
+ this.logger.info({ remoteSessionId }, "Released an empty App Server task without a materialized rollout.");
548
+ }
493
549
  for (const [localSessionId, session] of this.sessions) {
494
550
  if (session.remoteSessionId === remoteSessionId)
495
551
  this.sessions.delete(localSessionId);
496
552
  }
497
553
  }
554
+ async isUnmaterializedThread(client, remoteSessionId) {
555
+ const { thread } = await client.request("thread/read", { threadId: remoteSessionId, includeTurns: false }, CONTROL_REQUEST_TIMEOUT_MS);
556
+ if (thread.id !== remoteSessionId || thread.preview !== "" || thread.forkedFromId !== null
557
+ || !["idle", "notLoaded"].includes(thread.status?.type ?? "") || !thread.path
558
+ || (thread.turns?.length ?? 0) > 0)
559
+ return false;
560
+ for (const session of this.sessions.values()) {
561
+ if (session.remoteSessionId === remoteSessionId && (session.activeTurnId
562
+ || session.knownTurnIds.size > 0 || (session.turnCount ?? 0) > 0))
563
+ return false;
564
+ }
565
+ const active = await this.localActivityDetector?.activeThreads([remoteSessionId]);
566
+ if (active?.has(remoteSessionId))
567
+ return false;
568
+ try {
569
+ statSync(thread.path);
570
+ return false;
571
+ }
572
+ catch (error) {
573
+ if (error.code === "ENOENT")
574
+ return true;
575
+ throw error;
576
+ }
577
+ }
498
578
  async setTitle(sessionId, title) {
499
579
  return this.runSessionOperation(sessionId, () => this.setTitleNow(sessionId, title));
500
580
  }
@@ -523,7 +603,6 @@ export class CodexRuntime {
523
603
  const session = this.requireSession(sessionId);
524
604
  const client = await this.client();
525
605
  await this.ensureSessionResumed(session, client);
526
- session.canReplaceEmptyThread = false;
527
606
  const response = await client.request("thread/goal/set", { threadId: session.remoteSessionId, ...update }, CONTROL_REQUEST_TIMEOUT_MS);
528
607
  return response.goal;
529
608
  }
@@ -547,7 +626,18 @@ export class CodexRuntime {
547
626
  await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).permissionMode = mode; });
548
627
  }
549
628
  async setExecutionSettings(sessionId, settings, persist) {
550
- return this.runSessionOperation(sessionId, () => this.setExecutionSettingsNow(sessionId, settings, persist));
629
+ return this.runSessionOperation(sessionId, async () => {
630
+ try {
631
+ return await this.setExecutionSettingsNow(sessionId, settings, persist);
632
+ }
633
+ catch (error) {
634
+ if (error instanceof ProviderSwitchError)
635
+ throw error;
636
+ // Errors outside the mutation block are preflight failures; no settings were changed.
637
+ const session = this.sessions.get(sessionId);
638
+ throw new ProviderSwitchError(providerSwitchFailure(error), Boolean(session && !session.needsResume && !session.settingsRecoveryError), { cause: error });
639
+ }
640
+ });
551
641
  }
552
642
  async setExecutionSettingsNow(sessionId, settings, persist) {
553
643
  const session = this.requireSession(sessionId);
@@ -561,88 +651,60 @@ export class CodexRuntime {
561
651
  if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
562
652
  throw new Error(PROVIDER_SWITCH_BUSY);
563
653
  }
564
- let empty = false;
565
- if (session.canReplaceEmptyThread && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
654
+ if (session.turnCount === 0 && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
566
655
  try {
567
656
  const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
568
- empty = turns.length === 0;
569
- if (!empty)
570
- session.canReplaceEmptyThread = false;
657
+ if (turns.length === 0)
658
+ throw new Error(PROVIDER_SWITCH_EMPTY);
571
659
  }
572
660
  catch (error) {
573
661
  if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
574
662
  || !/missing source rollout|no rollout found/iu.test(error.serverMessage))
575
663
  throw error;
576
- empty = true;
664
+ throw new Error(PROVIDER_SWITCH_EMPTY, { cause: error });
577
665
  }
578
666
  }
579
667
  const previous = { ...session };
580
- let replacementId;
668
+ const restoreProvider = previous.modelProvider?.trim()
669
+ || (metadata.thread.id === previous.remoteSessionId ? metadata.thread.modelProvider?.trim() : undefined);
581
670
  let detached = false;
582
671
  try {
583
672
  const params = {
584
673
  cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
585
674
  ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
586
675
  };
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
- }
676
+ await this.detachThreadForSettings(client, session, () => { detached = true; });
677
+ const response = await client.request("thread/resume", {
678
+ ...params, threadId: session.remoteSessionId, excludeTurns: true,
679
+ }, SESSION_REQUEST_TIMEOUT_MS);
616
680
  assertProviderSettingsApplied(response, settings);
617
- if (!empty && !replacementId && response.thread.id !== session.remoteSessionId) {
681
+ if (response.thread.id !== session.remoteSessionId) {
618
682
  throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
619
683
  }
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,
684
+ const candidate = { ...session, ...settings,
625
685
  workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
626
686
  needsResume: false, settingsRecoveryError: undefined };
627
687
  await persist?.(candidate);
628
- Object.assign(session, candidate);
688
+ Object.assign(session, settings);
689
+ session.workspaceSandboxPolicy = candidate.workspaceSandboxPolicy;
690
+ session.needsResume = this.attachedClient !== client;
691
+ session.settingsRecoveryError = undefined;
629
692
  }
630
693
  catch (error) {
631
- if (replacementId)
632
- await this.discardUnusedThread(client, replacementId);
633
694
  let recovery = "原任务设置未更改。";
634
695
  if (detached) {
635
696
  try {
697
+ if (!restoreProvider)
698
+ throw new Error("无法确认切换前的 Provider,不能验证原设置已恢复。");
636
699
  await this.detachThreadForSettings(client, previous);
637
700
  const restored = await client.request("thread/resume", {
638
701
  threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
639
- modelProvider: previous.modelProvider, model: previous.model,
702
+ modelProvider: restoreProvider, model: previous.model,
640
703
  ...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
641
704
  }, SESSION_REQUEST_TIMEOUT_MS);
642
- if (previous.modelProvider && previous.model)
643
- assertProviderSettingsApplied(restored, {
644
- modelProvider: previous.modelProvider, model: previous.model,
645
- });
705
+ assertProviderSettingsApplied(restored, {
706
+ modelProvider: restoreProvider, model: previous.model,
707
+ });
646
708
  if (restored.thread.id !== previous.remoteSessionId)
647
709
  throw new Error("恢复时返回了不同的任务 ID。");
648
710
  session.needsResume = false;
@@ -656,52 +718,29 @@ export class CodexRuntime {
656
718
  session.needsResume = true;
657
719
  }
658
720
  }
659
- throw new Error(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, { cause: error });
721
+ throw new ProviderSwitchError(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, !session.needsResume && !session.settingsRecoveryError, { cause: error });
660
722
  }
661
- if (replacementId)
662
- await this.discardUnusedThread(client, previous.remoteSessionId);
663
723
  return session;
664
724
  }
665
- async detachThreadForSettings(client, session) {
725
+ async detachThreadForSettings(client, session, onDetach) {
666
726
  const current = await client.request("thread/read", {
667
727
  threadId: session.remoteSessionId, includeTurns: false,
668
728
  }, SYNC_REQUEST_TIMEOUT_MS);
669
729
  if (session.activeTurnId || current.thread.status?.type === "active")
670
730
  throw new Error(PROVIDER_SWITCH_BUSY);
731
+ // A timeout does not cancel the remote unsubscribe, so recovery is required once it is sent.
732
+ onDetach?.();
671
733
  const result = await client.request("thread/unsubscribe", {
672
734
  threadId: session.remoteSessionId,
673
735
  }, 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.");
736
+ if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
737
+ throw new Error(`App Server 返回了未知的取消订阅状态:${result.status}`);
702
738
  }
703
739
  }
704
740
  async respondToApproval(sessionId, requestId, decision) {
741
+ return this.resolveApproval(sessionId, requestId, decision);
742
+ }
743
+ async resolveApproval(sessionId, requestId, decision, automatic = false) {
705
744
  const modeChange = this.modeChanges.get(requestId);
706
745
  if (modeChange) {
707
746
  if (modeChange.sessionId !== sessionId
@@ -714,22 +753,34 @@ export class CodexRuntime {
714
753
  if (modeChange.responding)
715
754
  throw new Error("Approval response is already in progress.");
716
755
  modeChange.responding = true;
756
+ modeChange.decision = decision;
757
+ modeChange.automatic = automatic;
717
758
  try {
718
759
  const client = await this.client();
719
760
  if (this.modeChanges.get(requestId) !== modeChange)
720
761
  throw new Error("Approval request is no longer pending.");
721
- await client.request("mode/change/respond", {
722
- threadId: this.requireSession(sessionId).remoteSessionId,
723
- requestId: modeChange.requestId,
724
- decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
725
- }, CONTROL_REQUEST_TIMEOUT_MS);
762
+ try {
763
+ await client.request("mode/change/respond", {
764
+ threadId: this.requireSession(sessionId).remoteSessionId,
765
+ requestId: modeChange.requestId,
766
+ decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
767
+ }, CONTROL_REQUEST_TIMEOUT_MS);
768
+ }
769
+ catch (error) {
770
+ if (!await this.reconcileCompletedPlanEntry(requestId, modeChange, error))
771
+ throw error;
772
+ }
726
773
  if (this.modeChanges.get(requestId) === modeChange) {
774
+ this.confirmPlanContinuation(modeChange, decision);
727
775
  this.modeChanges.delete(requestId);
728
- this.emit({ type: "approval_resolved", sessionId, turnId: modeChange.turnId, requestId, decision });
776
+ this.emit({ type: "approval_resolved", sessionId, turnId: modeChange.turnId, requestId, decision,
777
+ ...(automatic ? { automatic: true } : {}) });
729
778
  }
730
779
  }
731
780
  finally {
732
781
  modeChange.responding = false;
782
+ if (modeChange.terminalEvent)
783
+ this.emit(modeChange.terminalEvent);
733
784
  }
734
785
  return;
735
786
  }
@@ -740,6 +791,36 @@ export class CodexRuntime {
740
791
  pending.resolve({ decision });
741
792
  this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
742
793
  }
794
+ async reconcileCompletedPlanEntry(requestId, pending, error) {
795
+ const session = this.sessions.get(pending.sessionId);
796
+ if (!pending.automatic || pending.decision !== "accept" || pending.targetMode !== "plan"
797
+ || this.provider.getAgentFamily?.() !== "traex" || !session?.rolloutPath
798
+ || !(error instanceof AppServerRequestError) || error.method !== "mode/change/respond" || error.code !== -32600
799
+ || error.serverMessage !== `failed to respond to mode change request: No pending mode change found for request_id: ${pending.requestId}`) {
800
+ return false;
801
+ }
802
+ const toolCallId = pending.sourceToolCallId ?? pending.requestId;
803
+ for (const waitMs of [0, 100, 200]) {
804
+ if (waitMs)
805
+ await delay(waitMs);
806
+ if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
807
+ || session.activeTurnId !== pending.turnId)
808
+ return false;
809
+ try {
810
+ if (!await hasCompletedTraePlanEntry(session.rolloutPath, pending.turnId, toolCallId))
811
+ continue;
812
+ if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
813
+ || session.activeTurnId !== pending.turnId)
814
+ return false;
815
+ this.logger.info({ sessionId: pending.sessionId, turnId: pending.turnId, requestId, toolCallId }, "Verified that TraeX already completed the automatic plan-mode entry.");
816
+ return true;
817
+ }
818
+ catch (verificationError) {
819
+ this.logger.debug({ error: verificationError, sessionId: pending.sessionId, requestId }, "Could not verify TraeX plan-mode entry from the rollout tail.");
820
+ }
821
+ }
822
+ return false;
823
+ }
743
824
  async listModels(modelProvider, fallbackModel) {
744
825
  return (await this.listModelsWithStatus(modelProvider, fallbackModel)).models;
745
826
  }
@@ -958,25 +1039,37 @@ export class CodexRuntime {
958
1039
  if (session.modeChangeRequestIds.has(requestId))
959
1040
  return;
960
1041
  session.modeChangeRequestIds.add(requestId);
961
- this.modeChanges.set(requestId, {
1042
+ const pending = {
962
1043
  sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
963
1044
  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
- });
1045
+ sourceToolCallId: mapped.sourceToolCallId ?? undefined,
1046
+ };
1047
+ this.modeChanges.set(requestId, pending);
1048
+ const automatic = session.permissionMode === "auto" && this.provider.getAgentFamily?.() === "traex";
1049
+ const request = {
1050
+ id: requestId,
1051
+ kind: "mode_change",
1052
+ ...(automatic ? { automatic: true } : {}),
1053
+ title: mapped.targetMode === "default" ? "确认方案并开始执行" : "确认进入规划模式",
1054
+ reason: [mapped.reason, ...(mapped.allowedPrompts ?? []).map((entry) => `- ${entry.tool}: ${entry.prompt}`)]
1055
+ .filter(Boolean).join("\n"),
1056
+ options: [
1057
+ { id: "accept", label: mapped.targetMode === "default" ? "Approve Plan" : "Enter Plan Mode" },
1058
+ { id: "decline", label: "Reject" },
1059
+ { id: "cancel", label: "Cancel Request" },
1060
+ ],
1061
+ };
1062
+ this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId, request });
1063
+ if (automatic) {
1064
+ void this.resolveApproval(sessionId, requestId, "accept", true).catch((error) => {
1065
+ if (this.modeChanges.get(requestId) !== pending || session.activeTurnId !== mapped.turnId)
1066
+ return;
1067
+ this.logger.warn({ error, sessionId, requestId, turnId: mapped.turnId, targetMode: mapped.targetMode,
1068
+ sourceToolCallId: pending.sourceToolCallId }, "Failed to automatically approve the TraeX mode change.");
1069
+ this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId,
1070
+ request: { ...request, automatic: false, reason: ["自动同意失败,请手动确认。", request.reason].filter(Boolean).join("\n\n") } });
1071
+ });
1072
+ }
980
1073
  }
981
1074
  else if (mapped.kind === "mode_change_resolved") {
982
1075
  const requestId = `mode:${mapped.threadId}:${mapped.requestId}`;
@@ -984,10 +1077,12 @@ export class CodexRuntime {
984
1077
  if (!pending || pending.sessionId !== sessionId || pending.turnId !== mapped.turnId
985
1078
  || pending.targetMode !== mapped.targetMode)
986
1079
  return;
1080
+ this.confirmPlanContinuation(pending, mapped.decision === "approved" ? "accept" : "decline");
987
1081
  this.modeChanges.delete(requestId);
988
1082
  this.emit({
989
1083
  type: "approval_resolved", sessionId, turnId: mapped.turnId, requestId,
990
1084
  decision: mapped.decision === "approved" ? "accept" : mapped.decision === "rejected" ? "decline" : "cancel",
1085
+ ...(pending.automatic && mapped.decision === "approved" ? { automatic: true } : {}),
991
1086
  });
992
1087
  }
993
1088
  else if (mapped.kind === "token_usage") {
@@ -1024,6 +1119,7 @@ export class CodexRuntime {
1024
1119
  session.unphasedFinalMessageId = undefined;
1025
1120
  }
1026
1121
  else if (mapped.kind === "agent_delta") {
1122
+ this.clearPlanContinuation(session, mapped.turnId);
1027
1123
  const phase = session.messagePhases.get(mapped.itemId);
1028
1124
  if (phase !== "final_answer") {
1029
1125
  // Providers may omit phase. Keep their text in sequence until a tool or completion disambiguates it.
@@ -1081,6 +1177,9 @@ export class CodexRuntime {
1081
1177
  this.emit({ type: "plan_updated", sessionId, turnId: mapped.turnId, steps: mapped.steps });
1082
1178
  }
1083
1179
  else if (mapped.kind === "tool") {
1180
+ // A backend that resumes tool execution itself needs no client handoff.
1181
+ if (mapped.phase === "started")
1182
+ this.clearPlanContinuation(session, mapped.turnId);
1084
1183
  if (mapped.phase === "started")
1085
1184
  session.unphasedFinalMessageId = undefined;
1086
1185
  if (mapped.phase === "updated"
@@ -1208,6 +1307,9 @@ export class CodexRuntime {
1208
1307
  ...threadLifecycleParams(session.cwd),
1209
1308
  ...permissionParams(session.permissionMode),
1210
1309
  }, SESSION_REQUEST_TIMEOUT_MS);
1310
+ if (response.thread.id !== session.remoteSessionId)
1311
+ throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
1312
+ assertProviderSettingsApplied(response, { modelProvider: session.modelProvider });
1211
1313
  session.workspaceSandboxPolicy = workspaceSandboxFromResponse(response.sandbox);
1212
1314
  }
1213
1315
  async turnSandboxPolicy(session, client) {
@@ -1276,7 +1378,6 @@ export class CodexRuntime {
1276
1378
  this.finishSnapshotTurn(session, latest);
1277
1379
  }
1278
1380
  adoptTurn(session, turnId, startedAt) {
1279
- session.canReplaceEmptyThread = false;
1280
1381
  if (!session.knownTurnIds.has(turnId)) {
1281
1382
  session.knownTurnIds.add(turnId);
1282
1383
  if (session.turnCount !== undefined)
@@ -1284,6 +1385,7 @@ export class CodexRuntime {
1284
1385
  }
1285
1386
  session.activeTurnId = turnId;
1286
1387
  session.activeTurnStartedAt = startedAt;
1388
+ session.planContinuationTurnId = undefined;
1287
1389
  session.modeChangeRequestIds.clear();
1288
1390
  session.unphasedMessages.clear();
1289
1391
  session.unphasedFinalMessageId = undefined;
@@ -1373,7 +1475,6 @@ export class CodexRuntime {
1373
1475
  unphasedMessages: new Map(),
1374
1476
  needsResume: false,
1375
1477
  workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
1376
- canReplaceEmptyThread: !("remoteSessionId" in input),
1377
1478
  rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1378
1479
  turnCount: "remoteSessionId" in input ? undefined : 0,
1379
1480
  knownTurnIds: new Set(),
@@ -1403,11 +1504,39 @@ export class CodexRuntime {
1403
1504
  throw new Error(`App Server turn is no longer active: ${turnId}`);
1404
1505
  return session;
1405
1506
  }
1507
+ clearPlanContinuation(session, turnId) {
1508
+ session.planContinuationTurnId = undefined;
1509
+ for (const pending of this.modeChanges.values()) {
1510
+ if (pending.sessionId === session.localSessionId && pending.turnId === turnId && pending.responding) {
1511
+ pending.continued = true;
1512
+ }
1513
+ }
1514
+ }
1515
+ confirmPlanContinuation(pending, decision) {
1516
+ const session = this.sessions.get(pending.sessionId);
1517
+ // Do not start work approved in a different client.
1518
+ if (session && pending.responding && !pending.continued && pending.decision === "accept" && decision === "accept"
1519
+ && pending.targetMode === "default")
1520
+ session.planContinuationTurnId = pending.turnId;
1521
+ }
1406
1522
  emit(event) {
1407
1523
  if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
1408
1524
  for (const [requestId, pending] of this.modeChanges) {
1409
- if (pending.sessionId === event.sessionId && pending.turnId === event.turnId)
1410
- this.modeChanges.delete(requestId);
1525
+ if (pending.sessionId !== event.sessionId || pending.turnId !== event.turnId)
1526
+ continue;
1527
+ if (event.type === "turn_completed" && pending.responding && pending.decision === "accept"
1528
+ && pending.targetMode === "default") {
1529
+ // The terminal notification may beat the approval RPC response.
1530
+ pending.terminalEvent = event;
1531
+ return;
1532
+ }
1533
+ this.modeChanges.delete(requestId);
1534
+ }
1535
+ const session = this.sessions.get(event.sessionId);
1536
+ if (session?.planContinuationTurnId === event.turnId) {
1537
+ session.planContinuationTurnId = undefined;
1538
+ if (event.type === "turn_completed")
1539
+ event = { ...event, approvedPlan: true };
1411
1540
  }
1412
1541
  }
1413
1542
  for (const listener of this.listeners)
@@ -1420,6 +1549,7 @@ export class CodexRuntime {
1420
1549
  this.modeChanges.clear();
1421
1550
  for (const session of this.sessions.values()) {
1422
1551
  session.needsResume = true;
1552
+ session.planContinuationTurnId = undefined;
1423
1553
  const turnId = session.activeTurnId;
1424
1554
  if (!turnId)
1425
1555
  continue;
@@ -1732,9 +1862,6 @@ function remoteThreadStatus(value) {
1732
1862
  return "error";
1733
1863
  return "not_loaded";
1734
1864
  }
1735
- function providerSettingsMatch(response, settings) {
1736
- return response.modelProvider === settings.modelProvider && response.model === settings.model;
1737
- }
1738
1865
  function codexSourceLabel(source) {
1739
1866
  if (typeof source === "string")
1740
1867
  return source;