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