@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.
- package/CHANGELOG.md +40 -0
- package/README.md +29 -3
- package/README.zh.md +29 -3
- package/SECURITY.md +12 -0
- package/config.example.yaml +5 -0
- package/dist/cli/controlProtocol.js.map +1 -1
- package/dist/cli/serverStatus.js +1 -0
- package/dist/cli/serverStatus.js.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/codex/CodexEventMapper.js +1 -0
- package/dist/codex/CodexEventMapper.js.map +1 -1
- package/dist/codex/CodexRuntime.js +247 -120
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/codex/ProviderSwitch.js +12 -13
- package/dist/codex/ProviderSwitch.js.map +1 -1
- package/dist/codex/TraePlanEntry.js +70 -0
- package/dist/codex/TraePlanEntry.js.map +1 -0
- package/dist/config/AgentExecutionDefaultsStore.js +4 -0
- package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
- package/dist/config/loadConfig.js +4 -0
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/schema.js +3 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/feishu/CardRenderer.js +92 -8
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +1 -0
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +121 -17
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/index.js +57 -7
- package/dist/index.js.map +1 -1
- package/dist/local-files/LocalFileViewerServer.js +51 -4
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/local-files/TurnPreviewPage.js +7 -2
- package/dist/local-files/TurnPreviewPage.js.map +1 -1
- package/dist/presentation/TurnStateReducer.js +41 -6
- package/dist/presentation/TurnStateReducer.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +620 -214
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/proxy/SessionOperationQueue.js +14 -0
- package/dist/proxy/SessionOperationQueue.js.map +1 -0
- package/dist/runtime/AcpRuntimeAdapter.js +4 -2
- package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
- package/dist/state/StateStore.js +22 -0
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/TurnPreviewJournal.js +19 -7
- package/dist/state/TurnPreviewJournal.js.map +1 -1
- package/dist/state/migrations.js +6 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +33 -1
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +22 -6
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/terminal/TerminalCards.js +13 -0
- package/dist/terminal/TerminalCards.js.map +1 -0
- package/dist/terminal/TerminalCommandTracker.js +74 -0
- package/dist/terminal/TerminalCommandTracker.js.map +1 -0
- package/dist/terminal/TerminalLinks.js +76 -0
- package/dist/terminal/TerminalLinks.js.map +1 -0
- package/dist/terminal/TerminalPage.js +212 -0
- package/dist/terminal/TerminalPage.js.map +1 -0
- package/dist/terminal/TerminalProcess.js +95 -0
- package/dist/terminal/TerminalProcess.js.map +1 -0
- package/dist/terminal/TerminalServer.js +676 -0
- package/dist/terminal/TerminalServer.js.map +1 -0
- package/docs/technical-reference.md +48 -0
- package/docs/technical-reference.zh.md +48 -0
- package/npm-shrinkwrap.json +55 -5
- package/package.json +11 -4
- package/skills/agent-bot/SKILL.md +11 -2
- package/src/cli/controlProtocol.ts +2 -1
- package/src/cli/serverStatus.ts +1 -0
- package/src/cli.ts +6 -1
- package/src/codex/CodexEventMapper.ts +1 -0
- package/src/codex/CodexRuntime.ts +244 -128
- package/src/codex/ProviderSwitch.ts +11 -12
- package/src/codex/TraePlanEntry.ts +63 -0
- package/src/config/AgentExecutionDefaultsStore.ts +2 -0
- package/src/config/loadConfig.ts +4 -0
- package/src/config/schema.ts +5 -1
- package/src/feishu/CardRenderer.ts +109 -8
- package/src/feishu/FeishuConnector.ts +1 -0
- package/src/feishu/FeishuTurnPresenter.ts +108 -16
- package/src/feishu/types.ts +1 -0
- package/src/index.ts +56 -8
- package/src/local-files/LocalFileViewerServer.ts +48 -4
- package/src/local-files/TurnPreviewPage.ts +7 -2
- package/src/presentation/TurnStateReducer.ts +46 -7
- package/src/presentation/turnViewTypes.ts +5 -0
- package/src/proxy/ProxySessionController.ts +629 -211
- package/src/proxy/SessionOperationQueue.ts +13 -0
- package/src/runtime/AcpRuntimeAdapter.ts +5 -3
- package/src/runtime/types.ts +8 -3
- package/src/state/StateStore.ts +38 -1
- package/src/state/TurnPreviewJournal.ts +19 -7
- package/src/state/migrations.ts +6 -0
- package/src/supervision/SafeRestartNotifier.ts +35 -2
- package/src/supervision/SafeRestartScheduler.ts +25 -6
- package/src/terminal/TerminalCards.ts +18 -0
- package/src/terminal/TerminalCommandTracker.ts +74 -0
- package/src/terminal/TerminalLinks.ts +61 -0
- package/src/terminal/TerminalPage.ts +213 -0
- package/src/terminal/TerminalProcess.ts +93 -0
- 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(
|
|
449
|
-
const activeTurnId = detectedTurnId ??
|
|
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
|
-
|
|
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, () =>
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
664
|
+
throw new Error(PROVIDER_SWITCH_EMPTY, { cause: error });
|
|
577
665
|
}
|
|
578
666
|
}
|
|
579
667
|
const previous = { ...session };
|
|
580
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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 (
|
|
681
|
+
if (response.thread.id !== session.remoteSessionId) {
|
|
618
682
|
throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
|
|
619
683
|
}
|
|
620
|
-
|
|
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,
|
|
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:
|
|
702
|
+
modelProvider: restoreProvider, model: previous.model,
|
|
640
703
|
...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
|
|
641
704
|
}, SESSION_REQUEST_TIMEOUT_MS);
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
|
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 (
|
|
675
|
-
throw new Error(
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
1042
|
+
const pending = {
|
|
962
1043
|
sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
|
|
963
1044
|
targetMode: mapped.targetMode, responding: false,
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
|
1410
|
-
|
|
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;
|