@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.
- package/CHANGELOG.md +59 -0
- package/README.md +42 -6
- package/README.zh.md +42 -6
- 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/AppServerPermissions.js +34 -0
- package/dist/codex/AppServerPermissions.js.map +1 -0
- package/dist/codex/CodexLocalActivityDetector.js +37 -5
- package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
- package/dist/codex/CodexRuntime.js +238 -127
- 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/ThreadWriterProcess.js +19 -5
- package/dist/codex/ThreadWriterProcess.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 +146 -72
- 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/CsvPreview.js +148 -0
- package/dist/local-files/CsvPreview.js.map +1 -0
- package/dist/local-files/LocalFileViewerServer.js +104 -14
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/local-files/TurnPreviewPage.js +128 -12
- package/dist/local-files/TurnPreviewPage.js.map +1 -1
- package/dist/presentation/TurnStateReducer.js +80 -6
- package/dist/presentation/TurnStateReducer.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +767 -260
- 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 +53 -3
- package/docs/technical-reference.zh.md +52 -2
- package/npm-shrinkwrap.json +55 -5
- package/package.json +11 -4
- package/skills/agent-bot/SKILL.md +15 -2
- package/src/cli/controlProtocol.ts +2 -1
- package/src/cli/serverStatus.ts +1 -0
- package/src/cli.ts +6 -1
- package/src/codex/AppServerPermissions.ts +39 -0
- package/src/codex/CodexLocalActivityDetector.ts +34 -4
- package/src/codex/CodexRuntime.ts +243 -139
- package/src/codex/ProviderSwitch.ts +11 -12
- package/src/codex/ThreadWriterProcess.ts +17 -9
- 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 +159 -72
- 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/CsvPreview.ts +132 -0
- package/src/local-files/LocalFileViewerServer.ts +102 -15
- package/src/local-files/TurnPreviewPage.ts +131 -13
- package/src/presentation/TurnStateReducer.ts +87 -7
- package/src/presentation/turnViewTypes.ts +9 -0
- package/src/proxy/ProxySessionController.ts +762 -259
- package/src/proxy/SessionOperationQueue.ts +13 -0
- package/src/runtime/AcpRuntimeAdapter.ts +5 -3
- package/src/runtime/types.ts +10 -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
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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, () =>
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
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
|
-
|
|
662
|
+
throw new Error(PROVIDER_SWITCH_EMPTY, { cause: error });
|
|
571
663
|
}
|
|
572
664
|
}
|
|
573
665
|
const previous = { ...session };
|
|
574
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 (
|
|
679
|
+
if (response.thread.id !== session.remoteSessionId) {
|
|
612
680
|
throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
|
|
613
681
|
}
|
|
614
|
-
|
|
615
|
-
|
|
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,
|
|
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:
|
|
700
|
+
modelProvider: restoreProvider, model: previous.model,
|
|
633
701
|
...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
|
|
634
702
|
}, SESSION_REQUEST_TIMEOUT_MS);
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
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 (
|
|
667
|
-
throw new Error(
|
|
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
|
-
|
|
1004
|
+
const pending = {
|
|
954
1005
|
sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
|
|
955
1006
|
targetMode: mapped.targetMode, responding: false,
|
|
956
|
-
}
|
|
957
|
-
this.
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1386
|
-
|
|
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;
|