@keyou007/agent-bot 0.1.24-alpha.4 → 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 (108) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +42 -6
  3. package/README.zh.md +42 -6
  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/AppServerPermissions.js +34 -0
  12. package/dist/codex/AppServerPermissions.js.map +1 -0
  13. package/dist/codex/CodexLocalActivityDetector.js +37 -5
  14. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  15. package/dist/codex/CodexRuntime.js +238 -127
  16. package/dist/codex/CodexRuntime.js.map +1 -1
  17. package/dist/codex/ProviderSwitch.js +12 -13
  18. package/dist/codex/ProviderSwitch.js.map +1 -1
  19. package/dist/codex/ThreadWriterProcess.js +19 -5
  20. package/dist/codex/ThreadWriterProcess.js.map +1 -1
  21. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  22. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  23. package/dist/config/loadConfig.js +4 -0
  24. package/dist/config/loadConfig.js.map +1 -1
  25. package/dist/config/schema.js +3 -0
  26. package/dist/config/schema.js.map +1 -1
  27. package/dist/feishu/CardRenderer.js +146 -72
  28. package/dist/feishu/CardRenderer.js.map +1 -1
  29. package/dist/feishu/FeishuConnector.js +1 -0
  30. package/dist/feishu/FeishuConnector.js.map +1 -1
  31. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  32. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  33. package/dist/index.js +43 -6
  34. package/dist/index.js.map +1 -1
  35. package/dist/local-files/CsvPreview.js +148 -0
  36. package/dist/local-files/CsvPreview.js.map +1 -0
  37. package/dist/local-files/LocalFileViewerServer.js +104 -14
  38. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  39. package/dist/local-files/TurnPreviewPage.js +128 -12
  40. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  41. package/dist/presentation/TurnStateReducer.js +80 -6
  42. package/dist/presentation/TurnStateReducer.js.map +1 -1
  43. package/dist/proxy/ProxySessionController.js +767 -260
  44. package/dist/proxy/ProxySessionController.js.map +1 -1
  45. package/dist/proxy/SessionOperationQueue.js +14 -0
  46. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  47. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  48. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  49. package/dist/state/StateStore.js +22 -0
  50. package/dist/state/StateStore.js.map +1 -1
  51. package/dist/state/TurnPreviewJournal.js +19 -7
  52. package/dist/state/TurnPreviewJournal.js.map +1 -1
  53. package/dist/state/migrations.js +6 -0
  54. package/dist/state/migrations.js.map +1 -1
  55. package/dist/supervision/SafeRestartNotifier.js +1 -0
  56. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  57. package/dist/supervision/SafeRestartScheduler.js +19 -6
  58. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  59. package/dist/terminal/TerminalCards.js +13 -0
  60. package/dist/terminal/TerminalCards.js.map +1 -0
  61. package/dist/terminal/TerminalLinks.js +76 -0
  62. package/dist/terminal/TerminalLinks.js.map +1 -0
  63. package/dist/terminal/TerminalPage.js +212 -0
  64. package/dist/terminal/TerminalPage.js.map +1 -0
  65. package/dist/terminal/TerminalProcess.js +71 -0
  66. package/dist/terminal/TerminalProcess.js.map +1 -0
  67. package/dist/terminal/TerminalServer.js +639 -0
  68. package/dist/terminal/TerminalServer.js.map +1 -0
  69. package/docs/technical-reference.md +53 -3
  70. package/docs/technical-reference.zh.md +52 -2
  71. package/npm-shrinkwrap.json +55 -5
  72. package/package.json +11 -4
  73. package/skills/agent-bot/SKILL.md +15 -2
  74. package/src/cli/controlProtocol.ts +2 -1
  75. package/src/cli/serverStatus.ts +1 -0
  76. package/src/cli.ts +6 -1
  77. package/src/codex/AppServerPermissions.ts +39 -0
  78. package/src/codex/CodexLocalActivityDetector.ts +34 -4
  79. package/src/codex/CodexRuntime.ts +243 -139
  80. package/src/codex/ProviderSwitch.ts +11 -12
  81. package/src/codex/ThreadWriterProcess.ts +17 -9
  82. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  83. package/src/config/loadConfig.ts +4 -0
  84. package/src/config/schema.ts +5 -1
  85. package/src/feishu/CardRenderer.ts +159 -72
  86. package/src/feishu/FeishuConnector.ts +1 -0
  87. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  88. package/src/feishu/types.ts +1 -0
  89. package/src/index.ts +44 -7
  90. package/src/local-files/CsvPreview.ts +132 -0
  91. package/src/local-files/LocalFileViewerServer.ts +102 -15
  92. package/src/local-files/TurnPreviewPage.ts +131 -13
  93. package/src/presentation/TurnStateReducer.ts +87 -7
  94. package/src/presentation/turnViewTypes.ts +9 -0
  95. package/src/proxy/ProxySessionController.ts +762 -259
  96. package/src/proxy/SessionOperationQueue.ts +13 -0
  97. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  98. package/src/runtime/types.ts +10 -3
  99. package/src/state/StateStore.ts +38 -1
  100. package/src/state/TurnPreviewJournal.ts +19 -7
  101. package/src/state/migrations.ts +6 -0
  102. package/src/supervision/SafeRestartNotifier.ts +1 -0
  103. package/src/supervision/SafeRestartScheduler.ts +18 -6
  104. package/src/terminal/TerminalCards.ts +18 -0
  105. package/src/terminal/TerminalLinks.ts +61 -0
  106. package/src/terminal/TerminalPage.ts +213 -0
  107. package/src/terminal/TerminalProcess.ts +71 -0
  108. package/src/terminal/TerminalServer.ts +513 -0
@@ -2,11 +2,12 @@ import { statSync } from "node:fs";
2
2
  import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
3
3
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
4
4
  import { AppServerRequestError } from "./AppServerConnection.js";
5
+ import { workspaceSandboxFromConfig, workspaceSandboxFromResponse } from "./AppServerPermissions.js";
5
6
  import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
6
7
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
7
8
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
8
9
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
9
- import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
10
+ import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, PROVIDER_SWITCH_EMPTY, ProviderSwitchError, providerSwitchFailure } from "./ProviderSwitch.js";
10
11
  import { fetchProviderModels } from "./ProviderModels.js";
11
12
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
12
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.",
@@ -54,6 +55,9 @@ export class CodexRuntime {
54
55
  getSession(localSessionId) {
55
56
  return this.sessions.get(localSessionId);
56
57
  }
58
+ forgetSession(localSessionId) {
59
+ this.sessions.delete(localSessionId);
60
+ }
57
61
  getProcessInfo() {
58
62
  return this.provider.getProcessInfo?.() ?? {};
59
63
  }
@@ -90,6 +94,20 @@ export class CodexRuntime {
90
94
  async resumeSession(input) {
91
95
  return this.runSessionOperation(input.localSessionId, () => this.resumeSessionNow(input));
92
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
+ }
93
111
  async resumeSessionNow(input) {
94
112
  const client = await this.client();
95
113
  const response = await client.request("thread/resume", {
@@ -101,8 +119,18 @@ export class CodexRuntime {
101
119
  ...threadLifecycleParams(input.cwd),
102
120
  ...permissionParams(input.permissionMode),
103
121
  }, SESSION_REQUEST_TIMEOUT_MS);
122
+ if (response.thread.id !== input.remoteSessionId)
123
+ throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
104
124
  const reasoningEffort = await this.resolveReasoningEffort(input, response);
105
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
+ }
106
134
  this.sessions.set(input.localSessionId, session);
107
135
  return session;
108
136
  }
@@ -146,15 +174,21 @@ export class CodexRuntime {
146
174
  const session = this.requireSession(sessionId);
147
175
  const client = await this.client();
148
176
  await this.ensureSessionResumed(session, client);
149
- session.canReplaceEmptyThread = false;
150
- const start = () => client.request("turn/start", {
177
+ const start = async () => client.request("turn/start", {
151
178
  threadId: session.remoteSessionId,
152
179
  input: codexUserInput(prompt),
153
180
  cwd: session.cwd,
154
181
  model: session.model,
155
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
+ } : {}),
156
189
  summary: "auto",
157
190
  approvalPolicy: session.permissionMode === "auto" ? "never" : "on-request",
191
+ sandboxPolicy: await this.turnSandboxPolicy(session, client),
158
192
  }, SESSION_REQUEST_TIMEOUT_MS);
159
193
  let response;
160
194
  try {
@@ -194,7 +228,7 @@ export class CodexRuntime {
194
228
  return listed;
195
229
  }
196
230
  }));
197
- const activeThreads = await this.localActivityDetector?.activeThreads(sessions.map((session) => session.id));
231
+ const activeThreads = await this.localActivityDetector?.activeThreads(sessions.filter((session) => session.status === "not_loaded").map((session) => session.id));
198
232
  return {
199
233
  sessions: activeThreads
200
234
  ? sessions.map((session) => markLocallyDetectedActive(session, activeThreads))
@@ -375,7 +409,8 @@ export class CodexRuntime {
375
409
  }));
376
410
  }
377
411
  async decorateRemoteSession(remoteSessionId, summary) {
378
- const activeThreads = await this.localActivityDetector?.activeThreads([remoteSessionId]);
412
+ const activeThreads = summary.status === "not_loaded"
413
+ ? await this.localActivityDetector?.activeThreads([remoteSessionId]) : undefined;
379
414
  const activeSummary = activeThreads ? markLocallyDetectedActive(summary, activeThreads) : summary;
380
415
  const settings = await this.localActivityDetector?.threadSettings([remoteSessionId]);
381
416
  return {
@@ -430,20 +465,21 @@ export class CodexRuntime {
430
465
  }
431
466
  }
432
467
  async inspectRemoteSessionActivity(remoteSessionId) {
433
- const [response, activeThreads] = await Promise.all([
434
- (await this.client()).request("thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS),
435
- this.localActivityDetector?.activeThreads([remoteSessionId]),
436
- ]);
468
+ const response = await (await this.client()).request("thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS);
469
+ const status = remoteThreadStatus(response.thread.status?.type);
470
+ const activeThreads = status === "not_loaded"
471
+ ? await this.localActivityDetector?.activeThreads([remoteSessionId]) : undefined;
437
472
  const runtimeSession = [...this.sessions.values()]
438
473
  .find((session) => session.remoteSessionId === remoteSessionId);
439
474
  const detectedTurnId = activeThreads?.get(remoteSessionId);
440
- const active = remoteThreadStatus(response.thread.status?.type) === "active"
441
- || Boolean(runtimeSession?.activeTurnId)
442
- || Boolean(activeThreads?.has(remoteSessionId));
443
- const activeTurnId = detectedTurnId ?? runtimeSession?.activeTurnId;
475
+ // A turn started in this process is evidence; a restored activeTurnId alone is not.
476
+ const observedTurnId = runtimeSession?.activeTurnStartedAt !== undefined ? runtimeSession.activeTurnId : undefined;
477
+ const active = status === "active" || Boolean(activeThreads?.has(remoteSessionId))
478
+ || Boolean(observedTurnId);
479
+ const activeTurnId = detectedTurnId ?? observedTurnId;
444
480
  return {
445
481
  active,
446
- ...(activeTurnId ? { activeTurnId } : {}),
482
+ ...(active && activeTurnId ? { activeTurnId } : {}),
447
483
  };
448
484
  }
449
485
  async synchronizeSession(sessionId) {
@@ -473,6 +509,14 @@ export class CodexRuntime {
473
509
  await this.interruptRemoteTurn(session.remoteSessionId, turnId, sessionId);
474
510
  }
475
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
+ }
476
520
  await (await this.client()).request("turn/interrupt", { threadId: remoteSessionId, turnId }, CONTROL_REQUEST_TIMEOUT_MS);
477
521
  this.logger.info({ ...(sessionId ? { sessionId } : {}), threadId: remoteSessionId, turnId }, "App Server accepted the turn interrupt request.");
478
522
  }
@@ -482,13 +526,53 @@ export class CodexRuntime {
482
526
  await this.archiveRemoteSession(session.remoteSessionId);
483
527
  });
484
528
  }
485
- async archiveRemoteSession(remoteSessionId) {
486
- 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
+ }
487
547
  for (const [localSessionId, session] of this.sessions) {
488
548
  if (session.remoteSessionId === remoteSessionId)
489
549
  this.sessions.delete(localSessionId);
490
550
  }
491
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
+ }
492
576
  async setTitle(sessionId, title) {
493
577
  return this.runSessionOperation(sessionId, () => this.setTitleNow(sessionId, title));
494
578
  }
@@ -517,7 +601,6 @@ export class CodexRuntime {
517
601
  const session = this.requireSession(sessionId);
518
602
  const client = await this.client();
519
603
  await this.ensureSessionResumed(session, client);
520
- session.canReplaceEmptyThread = false;
521
604
  const response = await client.request("thread/goal/set", { threadId: session.remoteSessionId, ...update }, CONTROL_REQUEST_TIMEOUT_MS);
522
605
  return response.goal;
523
606
  }
@@ -541,7 +624,18 @@ export class CodexRuntime {
541
624
  await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).permissionMode = mode; });
542
625
  }
543
626
  async setExecutionSettings(sessionId, settings, persist) {
544
- 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
+ });
545
639
  }
546
640
  async setExecutionSettingsNow(sessionId, settings, persist) {
547
641
  const session = this.requireSession(sessionId);
@@ -555,90 +649,64 @@ export class CodexRuntime {
555
649
  if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
556
650
  throw new Error(PROVIDER_SWITCH_BUSY);
557
651
  }
558
- let empty = false;
559
- if (session.canReplaceEmptyThread && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
652
+ if (session.turnCount === 0 && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
560
653
  try {
561
654
  const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
562
- empty = turns.length === 0;
563
- if (!empty)
564
- session.canReplaceEmptyThread = false;
655
+ if (turns.length === 0)
656
+ throw new Error(PROVIDER_SWITCH_EMPTY);
565
657
  }
566
658
  catch (error) {
567
659
  if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
568
660
  || !/missing source rollout|no rollout found/iu.test(error.serverMessage))
569
661
  throw error;
570
- empty = true;
662
+ throw new Error(PROVIDER_SWITCH_EMPTY, { cause: error });
571
663
  }
572
664
  }
573
665
  const previous = { ...session };
574
- let replacementId;
666
+ const restoreProvider = previous.modelProvider?.trim()
667
+ || (metadata.thread.id === previous.remoteSessionId ? metadata.thread.modelProvider?.trim() : undefined);
575
668
  let detached = false;
576
669
  try {
577
670
  const params = {
578
671
  cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
579
672
  ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
580
673
  };
581
- if (!empty) {
582
- await this.detachThreadForSettings(client, session);
583
- detached = true;
584
- }
585
- let response;
586
- if (empty) {
587
- response = await client.request("thread/start", {
588
- ...params, threadSource: "user", allowProviderModelFallback: false,
589
- }, SESSION_REQUEST_TIMEOUT_MS);
590
- replacementId = response.thread.id;
591
- }
592
- else {
593
- response = await client.request("thread/resume", {
594
- ...params, threadId: session.remoteSessionId, excludeTurns: true,
595
- }, SESSION_REQUEST_TIMEOUT_MS);
596
- if (!providerSettingsMatch(response, settings)) {
597
- this.logger.warn({
598
- sessionId,
599
- threadId: session.remoteSessionId,
600
- requestedProvider: settings.modelProvider,
601
- actualProvider: response.modelProvider,
602
- requestedModel: settings.model,
603
- actualModel: response.model,
604
- }, "App Server did not apply Provider settings while resuming; forking the latest completed Turn.");
605
- await this.detachThreadForSettings(client, session);
606
- response = await this.forkThreadForSettings(client, session, settings);
607
- replacementId = response.thread.id;
608
- }
609
- }
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);
610
678
  assertProviderSettingsApplied(response, settings);
611
- if (!empty && !replacementId && response.thread.id !== session.remoteSessionId) {
679
+ if (response.thread.id !== session.remoteSessionId) {
612
680
  throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
613
681
  }
614
- if (session.title && (empty || replacementId))
615
- await client.request("thread/name/set", {
616
- threadId: response.thread.id, name: session.title,
617
- }, SESSION_REQUEST_TIMEOUT_MS);
618
- const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
682
+ const candidate = { ...session, ...settings,
683
+ workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
619
684
  needsResume: false, settingsRecoveryError: undefined };
620
685
  await persist?.(candidate);
621
- Object.assign(session, candidate);
686
+ Object.assign(session, settings);
687
+ session.workspaceSandboxPolicy = candidate.workspaceSandboxPolicy;
688
+ session.needsResume = this.attachedClient !== client;
689
+ session.settingsRecoveryError = undefined;
622
690
  }
623
691
  catch (error) {
624
- if (replacementId)
625
- await this.discardUnusedThread(client, replacementId);
626
692
  let recovery = "原任务设置未更改。";
627
693
  if (detached) {
628
694
  try {
695
+ if (!restoreProvider)
696
+ throw new Error("无法确认切换前的 Provider,不能验证原设置已恢复。");
629
697
  await this.detachThreadForSettings(client, previous);
630
698
  const restored = await client.request("thread/resume", {
631
699
  threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
632
- modelProvider: previous.modelProvider, model: previous.model,
700
+ modelProvider: restoreProvider, model: previous.model,
633
701
  ...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
634
702
  }, SESSION_REQUEST_TIMEOUT_MS);
635
- if (previous.modelProvider && previous.model)
636
- assertProviderSettingsApplied(restored, {
637
- modelProvider: previous.modelProvider, model: previous.model,
638
- });
703
+ assertProviderSettingsApplied(restored, {
704
+ modelProvider: restoreProvider, model: previous.model,
705
+ });
639
706
  if (restored.thread.id !== previous.remoteSessionId)
640
707
  throw new Error("恢复时返回了不同的任务 ID。");
641
708
  session.needsResume = false;
709
+ session.workspaceSandboxPolicy = workspaceSandboxFromResponse(restored.sandbox);
642
710
  session.settingsRecoveryError = undefined;
643
711
  }
644
712
  catch (restoreError) {
@@ -648,52 +716,29 @@ export class CodexRuntime {
648
716
  session.needsResume = true;
649
717
  }
650
718
  }
651
- throw new Error(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, { cause: error });
719
+ throw new ProviderSwitchError(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, !session.needsResume && !session.settingsRecoveryError, { cause: error });
652
720
  }
653
- if (replacementId)
654
- await this.discardUnusedThread(client, previous.remoteSessionId);
655
721
  return session;
656
722
  }
657
- async detachThreadForSettings(client, session) {
723
+ async detachThreadForSettings(client, session, onDetach) {
658
724
  const current = await client.request("thread/read", {
659
725
  threadId: session.remoteSessionId, includeTurns: false,
660
726
  }, SYNC_REQUEST_TIMEOUT_MS);
661
727
  if (session.activeTurnId || current.thread.status?.type === "active")
662
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?.();
663
731
  const result = await client.request("thread/unsubscribe", {
664
732
  threadId: session.remoteSessionId,
665
733
  }, CONTROL_REQUEST_TIMEOUT_MS);
666
- if (result.status !== "unsubscribed" && result.status !== "notLoaded") {
667
- throw new Error("当前任务仍由其他客户端加载,无法安全切换 Provider。请先在原客户端释放任务。");
668
- }
669
- }
670
- async forkThreadForSettings(client, session, settings) {
671
- const turns = await this.listRecentThreadTurnsThroughCompleted(client, session.remoteSessionId);
672
- const lastTurnId = turns.find((turn) => turn.status === "completed")?.id;
673
- if (!lastTurnId) {
674
- throw new Error("无法为 Provider 切换找到已完成的 Turn。请先完成一轮任务后重试。");
675
- }
676
- return client.request("thread/fork", {
677
- threadId: session.remoteSessionId,
678
- lastTurnId,
679
- cwd: session.cwd,
680
- model: settings.model,
681
- modelProvider: settings.modelProvider,
682
- threadSource: "user",
683
- excludeTurns: true,
684
- ...threadLifecycleParams(session.cwd),
685
- ...permissionParams(settings.permissionMode),
686
- }, FORK_REQUEST_TIMEOUT_MS);
687
- }
688
- async discardUnusedThread(client, threadId) {
689
- try {
690
- await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
691
- }
692
- catch (error) {
693
- 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}`);
694
736
  }
695
737
  }
696
738
  async respondToApproval(sessionId, requestId, decision) {
739
+ return this.resolveApproval(sessionId, requestId, decision);
740
+ }
741
+ async resolveApproval(sessionId, requestId, decision, automatic = false) {
697
742
  const modeChange = this.modeChanges.get(requestId);
698
743
  if (modeChange) {
699
744
  if (modeChange.sessionId !== sessionId
@@ -706,6 +751,8 @@ export class CodexRuntime {
706
751
  if (modeChange.responding)
707
752
  throw new Error("Approval response is already in progress.");
708
753
  modeChange.responding = true;
754
+ modeChange.decision = decision;
755
+ modeChange.automatic = automatic;
709
756
  try {
710
757
  const client = await this.client();
711
758
  if (this.modeChanges.get(requestId) !== modeChange)
@@ -716,12 +763,16 @@ export class CodexRuntime {
716
763
  decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
717
764
  }, CONTROL_REQUEST_TIMEOUT_MS);
718
765
  if (this.modeChanges.get(requestId) === modeChange) {
766
+ this.confirmPlanContinuation(modeChange, decision);
719
767
  this.modeChanges.delete(requestId);
720
- 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 } : {}) });
721
770
  }
722
771
  }
723
772
  finally {
724
773
  modeChange.responding = false;
774
+ if (modeChange.terminalEvent)
775
+ this.emit(modeChange.terminalEvent);
725
776
  }
726
777
  return;
727
778
  }
@@ -950,25 +1001,35 @@ export class CodexRuntime {
950
1001
  if (session.modeChangeRequestIds.has(requestId))
951
1002
  return;
952
1003
  session.modeChangeRequestIds.add(requestId);
953
- this.modeChanges.set(requestId, {
1004
+ const pending = {
954
1005
  sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
955
1006
  targetMode: mapped.targetMode, responding: false,
956
- });
957
- this.emit({
958
- type: "approval_requested", sessionId, turnId: mapped.turnId,
959
- request: {
960
- id: requestId,
961
- kind: "mode_change",
962
- title: mapped.targetMode === "default" ? "确认方案并开始执行" : "确认进入规划模式",
963
- reason: [mapped.reason, ...(mapped.allowedPrompts ?? []).map((entry) => `- ${entry.tool}: ${entry.prompt}`)]
964
- .filter(Boolean).join("\n"),
965
- options: [
966
- { id: "accept", label: mapped.targetMode === "default" ? "Approve Plan" : "Enter Plan Mode" },
967
- { id: "decline", label: "Reject" },
968
- { id: "cancel", label: "Cancel Request" },
969
- ],
970
- },
971
- });
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
+ }
972
1033
  }
973
1034
  else if (mapped.kind === "mode_change_resolved") {
974
1035
  const requestId = `mode:${mapped.threadId}:${mapped.requestId}`;
@@ -976,10 +1037,12 @@ export class CodexRuntime {
976
1037
  if (!pending || pending.sessionId !== sessionId || pending.turnId !== mapped.turnId
977
1038
  || pending.targetMode !== mapped.targetMode)
978
1039
  return;
1040
+ this.confirmPlanContinuation(pending, mapped.decision === "approved" ? "accept" : "decline");
979
1041
  this.modeChanges.delete(requestId);
980
1042
  this.emit({
981
1043
  type: "approval_resolved", sessionId, turnId: mapped.turnId, requestId,
982
1044
  decision: mapped.decision === "approved" ? "accept" : mapped.decision === "rejected" ? "decline" : "cancel",
1045
+ ...(pending.automatic && mapped.decision === "approved" ? { automatic: true } : {}),
983
1046
  });
984
1047
  }
985
1048
  else if (mapped.kind === "token_usage") {
@@ -1016,6 +1079,7 @@ export class CodexRuntime {
1016
1079
  session.unphasedFinalMessageId = undefined;
1017
1080
  }
1018
1081
  else if (mapped.kind === "agent_delta") {
1082
+ this.clearPlanContinuation(session, mapped.turnId);
1019
1083
  const phase = session.messagePhases.get(mapped.itemId);
1020
1084
  if (phase !== "final_answer") {
1021
1085
  // Providers may omit phase. Keep their text in sequence until a tool or completion disambiguates it.
@@ -1073,6 +1137,9 @@ export class CodexRuntime {
1073
1137
  this.emit({ type: "plan_updated", sessionId, turnId: mapped.turnId, steps: mapped.steps });
1074
1138
  }
1075
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);
1076
1143
  if (mapped.phase === "started")
1077
1144
  session.unphasedFinalMessageId = undefined;
1078
1145
  if (mapped.phase === "updated"
@@ -1191,7 +1258,7 @@ export class CodexRuntime {
1191
1258
  session.needsResume = false;
1192
1259
  }
1193
1260
  async resumeAppServerSession(session, client) {
1194
- await client.request("thread/resume", {
1261
+ const response = await client.request("thread/resume", {
1195
1262
  threadId: session.remoteSessionId,
1196
1263
  excludeTurns: true,
1197
1264
  cwd: session.cwd,
@@ -1200,6 +1267,22 @@ export class CodexRuntime {
1200
1267
  ...threadLifecycleParams(session.cwd),
1201
1268
  ...permissionParams(session.permissionMode),
1202
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 });
1273
+ session.workspaceSandboxPolicy = workspaceSandboxFromResponse(response.sandbox);
1274
+ }
1275
+ async turnSandboxPolicy(session, client) {
1276
+ if (session.permissionMode === "auto")
1277
+ return { type: "dangerFullAccess" };
1278
+ if (!session.workspaceSandboxPolicy) {
1279
+ // Resolve project-specific options once, without resuming or scanning history.
1280
+ const response = await client.request("config/read", {
1281
+ cwd: session.cwd, includeLayers: false,
1282
+ }, CONTROL_REQUEST_TIMEOUT_MS);
1283
+ session.workspaceSandboxPolicy = workspaceSandboxFromConfig(response.config);
1284
+ }
1285
+ return session.workspaceSandboxPolicy;
1203
1286
  }
1204
1287
  async synchronizeSessionNow(sessionId) {
1205
1288
  const session = this.requireSession(sessionId);
@@ -1229,7 +1312,9 @@ export class CodexRuntime {
1229
1312
  }
1230
1313
  if (latestInProgress) {
1231
1314
  this.supersedeTurn(session, activeTurnId);
1232
- this.adoptTurn(session, latestInProgress.id, turnStartedAt(latestInProgress));
1315
+ if (thread.status?.type === "active") {
1316
+ this.adoptTurn(session, latestInProgress.id, turnStartedAt(latestInProgress));
1317
+ }
1233
1318
  return;
1234
1319
  }
1235
1320
  if (!latest) {
@@ -1253,7 +1338,6 @@ export class CodexRuntime {
1253
1338
  this.finishSnapshotTurn(session, latest);
1254
1339
  }
1255
1340
  adoptTurn(session, turnId, startedAt) {
1256
- session.canReplaceEmptyThread = false;
1257
1341
  if (!session.knownTurnIds.has(turnId)) {
1258
1342
  session.knownTurnIds.add(turnId);
1259
1343
  if (session.turnCount !== undefined)
@@ -1261,6 +1345,7 @@ export class CodexRuntime {
1261
1345
  }
1262
1346
  session.activeTurnId = turnId;
1263
1347
  session.activeTurnStartedAt = startedAt;
1348
+ session.planContinuationTurnId = undefined;
1264
1349
  session.modeChangeRequestIds.clear();
1265
1350
  session.unphasedMessages.clear();
1266
1351
  session.unphasedFinalMessageId = undefined;
@@ -1349,7 +1434,7 @@ export class CodexRuntime {
1349
1434
  messagePhases: new Map(),
1350
1435
  unphasedMessages: new Map(),
1351
1436
  needsResume: false,
1352
- canReplaceEmptyThread: !("remoteSessionId" in input),
1437
+ workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
1353
1438
  rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1354
1439
  turnCount: "remoteSessionId" in input ? undefined : 0,
1355
1440
  knownTurnIds: new Set(),
@@ -1379,11 +1464,39 @@ export class CodexRuntime {
1379
1464
  throw new Error(`App Server turn is no longer active: ${turnId}`);
1380
1465
  return session;
1381
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
+ }
1382
1482
  emit(event) {
1383
1483
  if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
1384
1484
  for (const [requestId, pending] of this.modeChanges) {
1385
- if (pending.sessionId === event.sessionId && pending.turnId === event.turnId)
1386
- 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 };
1387
1500
  }
1388
1501
  }
1389
1502
  for (const listener of this.listeners)
@@ -1396,6 +1509,7 @@ export class CodexRuntime {
1396
1509
  this.modeChanges.clear();
1397
1510
  for (const session of this.sessions.values()) {
1398
1511
  session.needsResume = true;
1512
+ session.planContinuationTurnId = undefined;
1399
1513
  const turnId = session.activeTurnId;
1400
1514
  if (!turnId)
1401
1515
  continue;
@@ -1708,9 +1822,6 @@ function remoteThreadStatus(value) {
1708
1822
  return "error";
1709
1823
  return "not_loaded";
1710
1824
  }
1711
- function providerSettingsMatch(response, settings) {
1712
- return response.modelProvider === settings.modelProvider && response.model === settings.model;
1713
- }
1714
1825
  function codexSourceLabel(source) {
1715
1826
  if (typeof source === "string")
1716
1827
  return source;