@keyou007/agent-bot 0.1.14-alpha.0 → 0.1.14-alpha.2
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 +82 -55
- package/README.zh.md +82 -55
- package/config.example.yaml +5 -1
- package/dist/cli/AutostartManager.js +431 -0
- package/dist/cli/AutostartManager.js.map +1 -0
- package/dist/cli/FeishuAppConfiguration.js +17 -13
- package/dist/cli/FeishuAppConfiguration.js.map +1 -1
- package/dist/cli/Initializer.js +35 -0
- package/dist/cli/Initializer.js.map +1 -1
- package/dist/cli/SelfUpdateRunner.js +552 -0
- package/dist/cli/SelfUpdateRunner.js.map +1 -0
- package/dist/cli/SelfUpdater.js +431 -0
- package/dist/cli/SelfUpdater.js.map +1 -0
- package/dist/cli/autostartOutput.js +63 -0
- package/dist/cli/autostartOutput.js.map +1 -0
- package/dist/cli/controlProtocol.js.map +1 -1
- package/dist/cli/help.js +92 -18
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/taskGroupOptions.js +14 -11
- package/dist/cli/taskGroupOptions.js.map +1 -1
- package/dist/cli/taskListOutput.js +3 -0
- package/dist/cli/taskListOutput.js.map +1 -1
- package/dist/cli/taskTarget.js +99 -0
- package/dist/cli/taskTarget.js.map +1 -0
- package/dist/cli.js +643 -58
- package/dist/cli.js.map +1 -1
- package/dist/codex/CodexRuntime.js +16 -5
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/commands/CommandRouter.js +20 -1
- package/dist/commands/CommandRouter.js.map +1 -1
- package/dist/config/loadConfig.js +2 -0
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/schema.js +2 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/console/ConsoleTurnPresenter.js +2 -0
- package/dist/console/ConsoleTurnPresenter.js.map +1 -1
- package/dist/feishu/CardRenderer.js +584 -16
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +29 -8
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuMarkdown.js +25 -1
- package/dist/feishu/FeishuMarkdown.js.map +1 -1
- package/dist/feishu/FeishuMessageClient.js +43 -0
- package/dist/feishu/FeishuMessageClient.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +29 -1
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/feishu/ownerAccess.js +7 -0
- package/dist/feishu/ownerAccess.js.map +1 -0
- package/dist/index.js +148 -19
- package/dist/index.js.map +1 -1
- package/dist/presentation/OutboundRouter.js +19 -1
- package/dist/presentation/OutboundRouter.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +1444 -66
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/startup/StartupNotifier.js +65 -16
- package/dist/startup/StartupNotifier.js.map +1 -1
- package/dist/startup/startFeishu.js +2 -2
- package/dist/startup/startFeishu.js.map +1 -1
- package/dist/state/StateStore.js +268 -67
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/migrations.js +26 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +17 -16
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +12 -7
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/supervision/replacementSupervisor.js +45 -4
- package/dist/supervision/replacementSupervisor.js.map +1 -1
- package/dist/supervisor.js +30 -18
- package/dist/supervisor.js.map +1 -1
- package/docs/technical-reference.md +33 -10
- package/docs/technical-reference.zh.md +33 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/skills/agent-bot/SKILL.md +107 -182
- package/src/cli/AutostartManager.ts +579 -0
- package/src/cli/FeishuAppConfiguration.ts +22 -7
- package/src/cli/Initializer.ts +57 -0
- package/src/cli/SelfUpdateRunner.ts +817 -0
- package/src/cli/SelfUpdater.ts +688 -0
- package/src/cli/autostartOutput.ts +75 -0
- package/src/cli/controlProtocol.ts +111 -1
- package/src/cli/help.ts +92 -18
- package/src/cli/taskGroupOptions.ts +27 -17
- package/src/cli/taskListOutput.ts +10 -0
- package/src/cli/taskTarget.ts +168 -0
- package/src/cli.ts +781 -65
- package/src/codex/CodexRuntime.ts +17 -5
- package/src/commands/CommandRouter.ts +19 -1
- package/src/commands/commandTypes.ts +3 -0
- package/src/config/loadConfig.ts +2 -0
- package/src/config/schema.ts +2 -0
- package/src/console/ConsoleTurnPresenter.ts +4 -1
- package/src/feishu/CardRenderer.ts +716 -16
- package/src/feishu/FeishuConnector.ts +44 -10
- package/src/feishu/FeishuMarkdown.ts +27 -1
- package/src/feishu/FeishuMessageClient.ts +58 -0
- package/src/feishu/FeishuTurnPresenter.ts +35 -2
- package/src/feishu/ownerAccess.ts +7 -0
- package/src/feishu/types.ts +7 -0
- package/src/index.ts +192 -22
- package/src/presentation/OutboundRouter.ts +28 -3
- package/src/proxy/ProxySessionController.ts +1707 -70
- package/src/runtime/types.ts +1 -0
- package/src/startup/StartupNotifier.ts +67 -17
- package/src/startup/startFeishu.ts +5 -3
- package/src/state/StateStore.ts +413 -65
- package/src/state/migrations.ts +26 -0
- package/src/supervision/SafeRestartNotifier.ts +17 -17
- package/src/supervision/SafeRestartScheduler.ts +13 -6
- package/src/supervision/replacementSupervisor.ts +50 -4
- package/src/supervisor.ts +35 -16
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
|
|
@@ -6,6 +7,7 @@ import { CommandRouter } from "../commands/CommandRouter.js";
|
|
|
6
7
|
import { baseChatContextKey, isThreadContextKey } from "../feishu/contextKey.js";
|
|
7
8
|
import { CardRenderer, } from "../feishu/CardRenderer.js";
|
|
8
9
|
import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
|
|
10
|
+
import { allowsFeishuUser } from "../feishu/ownerAccess.js";
|
|
9
11
|
import { buildTurnGraphRows } from "../presentation/turnGraph.js";
|
|
10
12
|
import { createId } from "../utils/id.js";
|
|
11
13
|
import { asInlineCode, codeBlock, truncateMiddle, truncateText } from "../utils/markdown.js";
|
|
@@ -16,8 +18,22 @@ const MESSAGE_COMPLETED_REACTION = "DONE";
|
|
|
16
18
|
const MESSAGE_FAILED_REACTION = "ERROR";
|
|
17
19
|
const MESSAGE_CANCELLED_REACTION = "CrossMark";
|
|
18
20
|
const SESSION_PAGE_SIZE = 5;
|
|
21
|
+
const DIRECTORY_PAGE_SIZE = 15;
|
|
19
22
|
const RESET_HISTORY_PAGE_SIZE = 10;
|
|
23
|
+
const MAX_LLM_TURN_RETRIES = 3;
|
|
24
|
+
const RECOVERY_ACTIVITY_WINDOW_MS = 5 * 60 * 1_000;
|
|
25
|
+
const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
|
|
20
26
|
const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
|
|
27
|
+
const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
|
|
28
|
+
const IMAGE_FILE_EXTENSIONS = new Set([
|
|
29
|
+
".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp",
|
|
30
|
+
]);
|
|
31
|
+
const BINARY_FILE_EXTENSIONS = new Set([
|
|
32
|
+
".7z", ".a", ".apk", ".avi", ".bin", ".bz2", ".class", ".dat", ".db", ".deb", ".dll", ".dmg", ".doc", ".docx",
|
|
33
|
+
".dylib", ".eot", ".exe", ".flac", ".gz", ".ico", ".ipa", ".iso", ".jar", ".lib", ".mdb", ".mkv", ".mov", ".mp3",
|
|
34
|
+
".mp4", ".msi", ".o", ".obj", ".ogg", ".otf", ".pdf", ".ppt", ".pptx", ".rar", ".rpm", ".so", ".sqlite", ".tar",
|
|
35
|
+
".ttf", ".wav", ".webm", ".woff", ".woff2", ".xls", ".xlsx", ".xz", ".zip", ".zst",
|
|
36
|
+
]);
|
|
21
37
|
const HELP_COMMAND_SECTIONS = [
|
|
22
38
|
{
|
|
23
39
|
title: "任务管理",
|
|
@@ -32,6 +48,11 @@ const HELP_COMMAND_SECTIONS = [
|
|
|
32
48
|
usage: "[title] [--dir <cwd> | --nodir]",
|
|
33
49
|
description: "创建飞书群和新任务",
|
|
34
50
|
},
|
|
51
|
+
{
|
|
52
|
+
command: "/dir",
|
|
53
|
+
usage: "[目录]",
|
|
54
|
+
description: "浏览、发送文件,并从选定目录创建任务或群",
|
|
55
|
+
},
|
|
35
56
|
{
|
|
36
57
|
command: "/forkgroup",
|
|
37
58
|
usage: "[title]",
|
|
@@ -50,6 +71,11 @@ const HELP_COMMAND_SECTIONS = [
|
|
|
50
71
|
requiresArgument: true,
|
|
51
72
|
},
|
|
52
73
|
{ command: "/sessions", usage: "[关键词]", description: "查找本机任务" },
|
|
74
|
+
{
|
|
75
|
+
command: "/archive",
|
|
76
|
+
usage: "[序号或任务 ID]",
|
|
77
|
+
description: "归档当前或指定任务",
|
|
78
|
+
},
|
|
53
79
|
{
|
|
54
80
|
command: "/switch",
|
|
55
81
|
usage: "[序号或任务 ID]",
|
|
@@ -114,6 +140,11 @@ const HELP_COMMAND_SECTIONS = [
|
|
|
114
140
|
usage: "[--force]",
|
|
115
141
|
description: "默认安全重启;--force 立即重启",
|
|
116
142
|
},
|
|
143
|
+
{
|
|
144
|
+
command: "/mute",
|
|
145
|
+
usage: "[on|off]",
|
|
146
|
+
description: "开启或关闭当前群的仅 @ 响应模式",
|
|
147
|
+
},
|
|
117
148
|
{ command: "/help", description: "显示本帮助" },
|
|
118
149
|
],
|
|
119
150
|
},
|
|
@@ -129,6 +160,7 @@ export class ProxySessionController {
|
|
|
129
160
|
logger;
|
|
130
161
|
lifecycle;
|
|
131
162
|
shellCommandExecutor;
|
|
163
|
+
windowsDriveLister;
|
|
132
164
|
router = new CommandRouter();
|
|
133
165
|
cardRenderer = new CardRenderer();
|
|
134
166
|
messageQueues = new Map();
|
|
@@ -139,8 +171,14 @@ export class ProxySessionController {
|
|
|
139
171
|
sessionResets = new Map();
|
|
140
172
|
lastSessionListings = new Map();
|
|
141
173
|
threadInitializations = new Map();
|
|
174
|
+
llmRetryingSessions = new Set();
|
|
175
|
+
retriedFailureTurnIds = new Set();
|
|
176
|
+
recoveryRetryTimers = new Map();
|
|
142
177
|
unsubscribe = [];
|
|
143
|
-
|
|
178
|
+
recoveryActivityHeartbeat;
|
|
179
|
+
startupRecovery;
|
|
180
|
+
queuedPromptsRestored = false;
|
|
181
|
+
constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand, windowsDriveLister = listWindowsDriveRoots) {
|
|
144
182
|
this.config = config;
|
|
145
183
|
this.store = store;
|
|
146
184
|
this.runtimes = runtimes;
|
|
@@ -148,12 +186,7 @@ export class ProxySessionController {
|
|
|
148
186
|
this.logger = logger;
|
|
149
187
|
this.lifecycle = lifecycle;
|
|
150
188
|
this.shellCommandExecutor = shellCommandExecutor;
|
|
151
|
-
|
|
152
|
-
.filter(([, agent]) => agent.kind === "acp")
|
|
153
|
-
.map(([name]) => name));
|
|
154
|
-
if (interruptedAcpSessions.length > 0) {
|
|
155
|
-
this.logger.warn({ sessionIds: interruptedAcpSessions.map((session) => session.localSessionId) }, "Marked interrupted ACP sessions as failed after process restart.");
|
|
156
|
-
}
|
|
189
|
+
this.windowsDriveLister = windowsDriveLister;
|
|
157
190
|
for (const [, runtime] of this.runtimes.entries()) {
|
|
158
191
|
this.unsubscribe.push(runtime.onEvent((event) => {
|
|
159
192
|
void this.handleRuntimeEvent(event).catch((error) => {
|
|
@@ -162,14 +195,25 @@ export class ProxySessionController {
|
|
|
162
195
|
}));
|
|
163
196
|
}
|
|
164
197
|
this.restorePersistedSessionRoutes();
|
|
165
|
-
this.restorePersistedQueuedPrompts();
|
|
166
198
|
void this.restorePersistedMessageReactions().catch((error) => {
|
|
167
199
|
this.logger.warn({ error }, "Failed to restore persisted message reaction statuses.");
|
|
168
200
|
});
|
|
201
|
+
this.recoveryActivityHeartbeat = setInterval(() => this.persistActiveTurnHeartbeats(), RECOVERY_HEARTBEAT_INTERVAL_MS);
|
|
202
|
+
this.recoveryActivityHeartbeat.unref?.();
|
|
169
203
|
}
|
|
170
204
|
async onMessage(message) {
|
|
171
|
-
|
|
172
|
-
|
|
205
|
+
if (!message.contextKey.startsWith("console:") && !allowsFeishuUser(this.config, message.userId)) {
|
|
206
|
+
this.logger.debug({ messageId: message.messageId }, "Ignored a Feishu message from a non-owner before acknowledgement.");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (message.chatType === "group"
|
|
210
|
+
&& this.store.chatRequiresMention(baseChatContextKey(message.contextKey))
|
|
211
|
+
&& message.mentionedBot !== true) {
|
|
212
|
+
this.logger.debug({ messageId: message.messageId, contextKey: message.contextKey }, "Ignored an unmentioned message from a muted Lark group.");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// For accepted messages, claim durable deduplication before acknowledgement so event
|
|
216
|
+
// retries cannot add duplicate reactions.
|
|
173
217
|
if (!this.store.claimInboundEvent(message.messageId, "message"))
|
|
174
218
|
return;
|
|
175
219
|
try {
|
|
@@ -262,6 +306,10 @@ export class ProxySessionController {
|
|
|
262
306
|
this.messageQueues.delete(message.contextKey);
|
|
263
307
|
}
|
|
264
308
|
async onCardAction(action) {
|
|
309
|
+
if (!allowsFeishuUser(this.config, action.userId)) {
|
|
310
|
+
this.logger.debug({ actionId: action.actionId }, "Ignored a Feishu card action from a non-owner.");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
265
313
|
if (!this.store.claimInboundEvent(action.actionId, "card_action"))
|
|
266
314
|
return;
|
|
267
315
|
const contextKey = this.cardActionContextKey(action);
|
|
@@ -321,6 +369,24 @@ export class ProxySessionController {
|
|
|
321
369
|
await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
|
|
322
370
|
}
|
|
323
371
|
}
|
|
372
|
+
else if (kind === "directory_open" || kind === "directory_page") {
|
|
373
|
+
await this.openDirectoryBrowser(contextKey, directoryActionPath(scopedAction.value.directory), {
|
|
374
|
+
updateMessageId: requiredCardMessageId(scopedAction.messageId),
|
|
375
|
+
page: kind === "directory_page" ? directoryPageValue(scopedAction.value.page) : 0,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
else if (kind === "directory_send_file") {
|
|
379
|
+
const filePath = directoryFileActionPath(scopedAction.value.filePath);
|
|
380
|
+
await this.assertSendableFile(filePath);
|
|
381
|
+
await this.outbound.sendFile(contextKey, filePath);
|
|
382
|
+
}
|
|
383
|
+
else if (kind === "directory_new" || kind === "directory_new_group") {
|
|
384
|
+
const directory = directoryActionPath(scopedAction.value.directory);
|
|
385
|
+
await this.assertBrowsableDirectory(directory);
|
|
386
|
+
await this.execute(contextKey, kind === "directory_new"
|
|
387
|
+
? { type: "new", cwd: directory }
|
|
388
|
+
: { type: "newgroup", cwd: directory }, undefined, replyTarget, undefined, scopedAction.userId);
|
|
389
|
+
}
|
|
324
390
|
else if (kind === "session_page") {
|
|
325
391
|
await this.refreshSessionsCardFromAction(scopedAction);
|
|
326
392
|
}
|
|
@@ -352,6 +418,14 @@ export class ProxySessionController {
|
|
|
352
418
|
await this.createFeishuGroupFromReference(contextKey, sessionId, scopedAction.userId);
|
|
353
419
|
await this.refreshSessionsCardFromAction(scopedAction);
|
|
354
420
|
}
|
|
421
|
+
else if (kind === "session_archive") {
|
|
422
|
+
const sessionId = String(scopedAction.value.sessionId ?? "");
|
|
423
|
+
await this.archiveSessionReference(contextKey, sessionId, {
|
|
424
|
+
announce: true,
|
|
425
|
+
source: "sessions_card",
|
|
426
|
+
});
|
|
427
|
+
await this.refreshSessionsCardFromAction(scopedAction);
|
|
428
|
+
}
|
|
355
429
|
else if (kind === "session_stop") {
|
|
356
430
|
const sessionId = String(scopedAction.value.sessionId ?? "");
|
|
357
431
|
await this.stopSessionReference(contextKey, sessionId);
|
|
@@ -484,6 +558,10 @@ export class ProxySessionController {
|
|
|
484
558
|
this.queuedPromptCards.clear();
|
|
485
559
|
this.queuedPromptCardWrites.clear();
|
|
486
560
|
this.sessionResets.clear();
|
|
561
|
+
for (const timer of this.recoveryRetryTimers.values())
|
|
562
|
+
clearTimeout(timer);
|
|
563
|
+
this.recoveryRetryTimers.clear();
|
|
564
|
+
clearInterval(this.recoveryActivityHeartbeat);
|
|
487
565
|
}
|
|
488
566
|
async controlStopTask(localSessionId) {
|
|
489
567
|
const record = this.store.getSession(localSessionId);
|
|
@@ -492,6 +570,13 @@ export class ProxySessionController {
|
|
|
492
570
|
await this.cancelSession(record);
|
|
493
571
|
return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
|
|
494
572
|
}
|
|
573
|
+
async controlArchiveTask(localSessionId) {
|
|
574
|
+
const record = this.requireControlSession(localSessionId);
|
|
575
|
+
return this.archiveSessionReference(this.controlSessionContextKey(record), localSessionId, {
|
|
576
|
+
announce: false,
|
|
577
|
+
source: "cli",
|
|
578
|
+
});
|
|
579
|
+
}
|
|
495
580
|
async controlGetTaskStatus(localSessionId) {
|
|
496
581
|
const record = this.store.getSession(localSessionId);
|
|
497
582
|
if (!record)
|
|
@@ -641,6 +726,314 @@ export class ProxySessionController {
|
|
|
641
726
|
task: forked.task,
|
|
642
727
|
};
|
|
643
728
|
}
|
|
729
|
+
async controlCreateTask(localSessionId, requestedTitle, requestedProjectCwd, forceProjectless = false, requestedAgentName) {
|
|
730
|
+
const source = this.requireControlSession(localSessionId);
|
|
731
|
+
const contextKey = this.controlSessionContextKey(source);
|
|
732
|
+
const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
|
|
733
|
+
const agentName = requestedAgentName?.trim() || context.defaultAgent;
|
|
734
|
+
const agent = this.ensureAgent(agentName);
|
|
735
|
+
if (forceProjectless && agent.kind !== "app-server") {
|
|
736
|
+
throw new Error("task new --nodir is only available for App Server agents.");
|
|
737
|
+
}
|
|
738
|
+
const replyTarget = this.controlSessionReplyTarget(source);
|
|
739
|
+
const cwd = forceProjectless
|
|
740
|
+
? undefined
|
|
741
|
+
: requestedProjectCwd === undefined
|
|
742
|
+
? detectProjectlessWorkspace(source.cwd)
|
|
743
|
+
? agent.kind === "app-server" ? undefined : createProjectlessWorkspace().cwd
|
|
744
|
+
: source.cwd
|
|
745
|
+
: resolveUserPath(requestedProjectCwd, source.cwd);
|
|
746
|
+
const settings = source.agentName === agentName
|
|
747
|
+
? {
|
|
748
|
+
modelProvider: source.modelProvider,
|
|
749
|
+
model: source.model,
|
|
750
|
+
reasoningEffort: source.reasoningEffort,
|
|
751
|
+
permissionMode: source.permissionMode,
|
|
752
|
+
}
|
|
753
|
+
: {};
|
|
754
|
+
return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.createSession(contextKey, agentName, cwd, true, false, undefined, undefined, requestedTitle, settings));
|
|
755
|
+
}
|
|
756
|
+
async controlForkTask(localSessionId) {
|
|
757
|
+
const source = this.requireControlSession(localSessionId);
|
|
758
|
+
const contextKey = this.controlSessionContextKey(source);
|
|
759
|
+
const replyTarget = this.controlSessionReplyTarget(source);
|
|
760
|
+
const plan = await this.prepareForkSession(contextKey, source.localSessionId);
|
|
761
|
+
const forked = await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
|
|
762
|
+
const result = await this.forkSessionIntoContext(contextKey, plan);
|
|
763
|
+
await this.outbound.sendText(contextKey, `已从指定任务${plan.forkedFromHistoricalTurn ? "最近已完成轮次" : ""}创建分支并切换到新任务:${result.session.title
|
|
764
|
+
? `${result.session.title}(${result.session.remoteSessionId})`
|
|
765
|
+
: result.session.remoteSessionId}`);
|
|
766
|
+
return result;
|
|
767
|
+
});
|
|
768
|
+
return {
|
|
769
|
+
sourceLocalSessionId: source.localSessionId,
|
|
770
|
+
sourceTurnId: plan.lastTurnId,
|
|
771
|
+
task: this.store.getSession(forked.record.localSessionId) ?? forked.record,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
async controlSwitchTask(localSessionId, targetLocalSessionId, previous = false) {
|
|
775
|
+
const anchor = this.requireControlSession(localSessionId);
|
|
776
|
+
const contextKey = this.controlSessionContextKey(anchor);
|
|
777
|
+
const replyTarget = this.controlSessionReplyTarget(anchor);
|
|
778
|
+
const target = previous ? undefined : targetLocalSessionId ?? anchor.localSessionId;
|
|
779
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.switchSession(contextKey, target));
|
|
780
|
+
return this.requireCurrentSession(contextKey);
|
|
781
|
+
}
|
|
782
|
+
async controlQueueTaskPrompt(localSessionId, text) {
|
|
783
|
+
const promptText = text.trim();
|
|
784
|
+
if (!promptText)
|
|
785
|
+
throw new Error("The queued Prompt cannot be empty.");
|
|
786
|
+
const record = this.requireControlSession(localSessionId);
|
|
787
|
+
const contextKey = this.controlSessionContextKey(record);
|
|
788
|
+
const replyTarget = this.controlSessionReplyTarget(record);
|
|
789
|
+
const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, promptText, { replyTarget });
|
|
790
|
+
this.store.audit(contextKey, "queued_prompt_added", {
|
|
791
|
+
promptId: queued.promptId,
|
|
792
|
+
localSessionId: record.localSessionId,
|
|
793
|
+
source: "cli",
|
|
794
|
+
});
|
|
795
|
+
const queuedCount = this.store.countQueuedPrompts(record.localSessionId);
|
|
796
|
+
await this.scheduleNextQueuedPrompt(record.localSessionId);
|
|
797
|
+
return { promptId: queued.promptId, queued: queuedCount };
|
|
798
|
+
}
|
|
799
|
+
controlTaskAgent(localSessionId, agentName) {
|
|
800
|
+
const record = this.requireControlSession(localSessionId);
|
|
801
|
+
const contextKey = this.controlSessionContextKey(record);
|
|
802
|
+
const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
|
|
803
|
+
if (agentName) {
|
|
804
|
+
this.ensureAgent(agentName);
|
|
805
|
+
this.store.setDefaultAgent(contextKey, agentName);
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
current: agentName ?? context.defaultAgent,
|
|
809
|
+
agents: Object.entries(this.config.agents).map(([name, agent]) => ({ name, title: agent.title })),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
async controlTaskSettings(localSessionId, setting, value) {
|
|
813
|
+
const record = this.requireControlSession(localSessionId);
|
|
814
|
+
const loaded = await this.loadSession(record);
|
|
815
|
+
if (setting && !value?.trim())
|
|
816
|
+
throw new Error(`task ${setting} requires a value.`);
|
|
817
|
+
const nextValue = value?.trim();
|
|
818
|
+
if (setting === "provider" && nextValue) {
|
|
819
|
+
await this.assertModelProvider(loaded, nextValue);
|
|
820
|
+
const models = await loaded.runtime.listModels();
|
|
821
|
+
const model = models.find((candidate) => candidate.id === loaded.session.model)
|
|
822
|
+
?? models.find((candidate) => candidate.isDefault);
|
|
823
|
+
if (!model)
|
|
824
|
+
throw new Error("The current runtime has no model available for a Provider change.");
|
|
825
|
+
const effort = model.supportedReasoningEfforts.some((candidate) => candidate.value === loaded.session.reasoningEffort)
|
|
826
|
+
? loaded.session.reasoningEffort
|
|
827
|
+
: model.defaultReasoningEffort ?? model.supportedReasoningEfforts[0]?.value;
|
|
828
|
+
if (!effort)
|
|
829
|
+
throw new Error(`Model ${model.id} has no reasoning effort available for a Provider change.`);
|
|
830
|
+
if (!loaded.runtime.setExecutionSettings)
|
|
831
|
+
throw new Error("The current runtime does not support Provider settings.");
|
|
832
|
+
const updated = await loaded.runtime.setExecutionSettings(record.localSessionId, {
|
|
833
|
+
modelProvider: nextValue,
|
|
834
|
+
model: model.id,
|
|
835
|
+
reasoningEffort: effort,
|
|
836
|
+
permissionMode: loaded.session.permissionMode,
|
|
837
|
+
});
|
|
838
|
+
this.store.updateRuntimeSession(record.localSessionId, {
|
|
839
|
+
modelProvider: updated.modelProvider ?? nextValue,
|
|
840
|
+
model: updated.model ?? model.id,
|
|
841
|
+
reasoningEffort: updated.reasoningEffort ?? effort,
|
|
842
|
+
permissionMode: updated.permissionMode,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
else if (setting === "model" && nextValue) {
|
|
846
|
+
const models = await loaded.runtime.listModels();
|
|
847
|
+
const selected = models.find((candidate) => candidate.id === nextValue);
|
|
848
|
+
if (!selected)
|
|
849
|
+
throw new Error(`Unknown model: ${nextValue}`);
|
|
850
|
+
const currentEffort = loaded.session.reasoningEffort;
|
|
851
|
+
const nextEffort = currentEffort && selected.supportedReasoningEfforts.some((candidate) => candidate.value === currentEffort)
|
|
852
|
+
? currentEffort
|
|
853
|
+
: selected.defaultReasoningEffort;
|
|
854
|
+
await loaded.runtime.setModel(record.localSessionId, nextValue);
|
|
855
|
+
if (nextEffort && nextEffort !== currentEffort) {
|
|
856
|
+
await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
|
|
857
|
+
}
|
|
858
|
+
this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
|
|
859
|
+
}
|
|
860
|
+
else if (setting === "thinking" && nextValue) {
|
|
861
|
+
const models = await loaded.runtime.listModels();
|
|
862
|
+
const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
|
|
863
|
+
?? models.find((candidate) => candidate.isDefault);
|
|
864
|
+
if (!currentModel)
|
|
865
|
+
throw new Error("The current runtime has no model with configurable reasoning effort.");
|
|
866
|
+
if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
|
|
867
|
+
const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
|
|
868
|
+
throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
|
|
869
|
+
}
|
|
870
|
+
await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
|
|
871
|
+
this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
|
|
872
|
+
}
|
|
873
|
+
else if (setting === "permissions" && nextValue) {
|
|
874
|
+
if (nextValue !== "auto" && nextValue !== "confirm") {
|
|
875
|
+
throw new Error("task permissions accepts auto or confirm.");
|
|
876
|
+
}
|
|
877
|
+
await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
|
|
878
|
+
this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
|
|
879
|
+
}
|
|
880
|
+
const session = this.store.getSession(record.localSessionId) ?? record;
|
|
881
|
+
const models = await loaded.runtime.listModels();
|
|
882
|
+
const currentModel = models.find((candidate) => candidate.id === session.model)
|
|
883
|
+
?? models.find((candidate) => candidate.isDefault);
|
|
884
|
+
const providers = loaded.runtime.listModelProviders
|
|
885
|
+
? await loaded.runtime.listModelProviders()
|
|
886
|
+
: [];
|
|
887
|
+
return {
|
|
888
|
+
session,
|
|
889
|
+
providers,
|
|
890
|
+
models,
|
|
891
|
+
reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
|
|
892
|
+
permissionModes: ["auto", "confirm"],
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
async controlTaskGoal(localSessionId, action, objective) {
|
|
896
|
+
const record = this.requireControlSession(localSessionId);
|
|
897
|
+
const loaded = await this.loadSession(record);
|
|
898
|
+
if (loaded.runtime.kind !== "codex" || !loaded.runtime.getGoal || !loaded.runtime.setGoal || !loaded.runtime.clearGoal) {
|
|
899
|
+
throw new Error("The current Agent does not support Goal mode.");
|
|
900
|
+
}
|
|
901
|
+
if (action === "show")
|
|
902
|
+
return { goal: await loaded.runtime.getGoal(record.localSessionId) };
|
|
903
|
+
if (action === "clear")
|
|
904
|
+
return { cleared: await loaded.runtime.clearGoal(record.localSessionId) };
|
|
905
|
+
const current = await loaded.runtime.getGoal(record.localSessionId);
|
|
906
|
+
if (action === "pause" || action === "resume") {
|
|
907
|
+
if (!current)
|
|
908
|
+
throw new Error("The task has no Goal.");
|
|
909
|
+
return { goal: await loaded.runtime.setGoal(record.localSessionId, { status: action === "pause" ? "paused" : "active" }) };
|
|
910
|
+
}
|
|
911
|
+
const nextObjective = objective?.trim();
|
|
912
|
+
if (!nextObjective)
|
|
913
|
+
throw new Error(`task goal ${action} requires an objective.`);
|
|
914
|
+
validateGoalObjective(nextObjective);
|
|
915
|
+
if (action === "set" && current && current.status !== "complete") {
|
|
916
|
+
throw new Error("The task already has an unfinished Goal. Use task goal <task> edit <objective>, or clear it first.");
|
|
917
|
+
}
|
|
918
|
+
if (action === "edit" && !current)
|
|
919
|
+
throw new Error("The task has no Goal to edit.");
|
|
920
|
+
return {
|
|
921
|
+
goal: await loaded.runtime.setGoal(record.localSessionId, {
|
|
922
|
+
objective: nextObjective,
|
|
923
|
+
status: action === "edit" ? current.status : "active",
|
|
924
|
+
...(action === "edit" ? { tokenBudget: current.tokenBudget } : {}),
|
|
925
|
+
}),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
controlListTaskTurns(localSessionId) {
|
|
929
|
+
const session = this.requireControlSession(localSessionId);
|
|
930
|
+
if (!session.remoteSessionId || !this.isCodexSession(session)) {
|
|
931
|
+
throw new Error("The task is not an App Server task that can be reset.");
|
|
932
|
+
}
|
|
933
|
+
const contextKey = this.controlSessionContextKey(session);
|
|
934
|
+
const rows = this.store.listCompletedTurnGraph(session.localSessionId, contextKey);
|
|
935
|
+
const graph = buildTurnGraphRows(rows.map((turn) => ({ turnId: turn.turnId, parentTurnId: turn.parentTurnId })));
|
|
936
|
+
return {
|
|
937
|
+
session,
|
|
938
|
+
turns: rows.flatMap((turn, index) => {
|
|
939
|
+
const snapshot = turnViewSnapshot(turn.snapshot);
|
|
940
|
+
if (!snapshot)
|
|
941
|
+
return [];
|
|
942
|
+
return [{
|
|
943
|
+
sequence: graph[index].sequence,
|
|
944
|
+
turnId: turn.turnId,
|
|
945
|
+
parentTurnId: turn.parentTurnId,
|
|
946
|
+
prompt: snapshot.prompt ?? snapshot.taskTitle,
|
|
947
|
+
startedAt: snapshot.startedAt,
|
|
948
|
+
completedAt: snapshot.completedAt,
|
|
949
|
+
current: session.lastTurnId === turn.turnId && session.lastTurnStatus === "completed",
|
|
950
|
+
}];
|
|
951
|
+
}),
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
async controlResetTaskToTurn(localSessionId, turnId) {
|
|
955
|
+
const record = this.requireControlSession(localSessionId);
|
|
956
|
+
const contextKey = this.controlSessionContextKey(record);
|
|
957
|
+
const previous = this.sessionResets.get(record.localSessionId) ?? Promise.resolve();
|
|
958
|
+
const reset = previous.catch(() => undefined).then(() => this.performSessionReset(contextKey, record, turnId, false));
|
|
959
|
+
this.sessionResets.set(record.localSessionId, reset);
|
|
960
|
+
try {
|
|
961
|
+
await reset;
|
|
962
|
+
}
|
|
963
|
+
finally {
|
|
964
|
+
if (this.sessionResets.get(record.localSessionId) === reset)
|
|
965
|
+
this.sessionResets.delete(record.localSessionId);
|
|
966
|
+
}
|
|
967
|
+
return this.store.getSession(record.localSessionId) ?? record;
|
|
968
|
+
}
|
|
969
|
+
controlTaskMute(localSessionId, enabled) {
|
|
970
|
+
const record = this.requireControlSession(localSessionId);
|
|
971
|
+
const contextKey = baseChatContextKey(this.controlSessionContextKey(record));
|
|
972
|
+
const chat = this.store.getChatContext(contextKey);
|
|
973
|
+
if (chat?.chatType !== "group")
|
|
974
|
+
throw new Error("task mute is only available for group chats.");
|
|
975
|
+
const next = enabled ?? Boolean(chat.requiresMention);
|
|
976
|
+
if (enabled !== undefined)
|
|
977
|
+
this.store.setChatRequiresMention(contextKey, enabled);
|
|
978
|
+
return { contextKey, enabled: next };
|
|
979
|
+
}
|
|
980
|
+
async controlRunTaskShell(localSessionId, command) {
|
|
981
|
+
const record = this.requireControlSession(localSessionId);
|
|
982
|
+
const normalized = command.trim();
|
|
983
|
+
if (!normalized)
|
|
984
|
+
throw new Error("task shell requires a command.");
|
|
985
|
+
return { ...await this.shellCommandExecutor(normalized, record.cwd), cwd: record.cwd, command: normalized };
|
|
986
|
+
}
|
|
987
|
+
async controlListTaskDirectory(localSessionId, requestedDirectory, requestedPage = 0) {
|
|
988
|
+
const record = this.requireControlSession(localSessionId);
|
|
989
|
+
if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
|
|
990
|
+
const drives = await this.windowsDriveLister();
|
|
991
|
+
const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
|
|
992
|
+
const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
|
|
993
|
+
return {
|
|
994
|
+
directory: WINDOWS_DRIVES_DIRECTORY,
|
|
995
|
+
page,
|
|
996
|
+
totalPages,
|
|
997
|
+
totalEntries: drives.length,
|
|
998
|
+
entries: drives.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE).map((drive) => ({
|
|
999
|
+
name: windowsDriveDisplayName(drive),
|
|
1000
|
+
path: drive.root,
|
|
1001
|
+
kind: "drive",
|
|
1002
|
+
})),
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
const directory = requestedDirectory === undefined
|
|
1006
|
+
? path.resolve(record.cwd)
|
|
1007
|
+
: resolveUserPath(requestedDirectory, record.cwd);
|
|
1008
|
+
await this.assertBrowsableDirectory(directory);
|
|
1009
|
+
const entries = (await fs.readdir(directory, { withFileTypes: true }))
|
|
1010
|
+
.map((entry) => ({
|
|
1011
|
+
name: entry.name,
|
|
1012
|
+
path: path.join(directory, entry.name),
|
|
1013
|
+
kind: directoryBrowserEntryKind(entry),
|
|
1014
|
+
}))
|
|
1015
|
+
.sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
|
|
1016
|
+
|| left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
1017
|
+
const totalPages = Math.max(1, Math.ceil(entries.length / DIRECTORY_PAGE_SIZE));
|
|
1018
|
+
const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
|
|
1019
|
+
const parentDirectory = isWindowsDriveRoot(directory) ? WINDOWS_DRIVES_DIRECTORY : path.dirname(directory);
|
|
1020
|
+
return {
|
|
1021
|
+
directory,
|
|
1022
|
+
...(parentDirectory !== directory ? { parentDirectory } : {}),
|
|
1023
|
+
page,
|
|
1024
|
+
totalPages,
|
|
1025
|
+
totalEntries: entries.length,
|
|
1026
|
+
entries: entries.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE),
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
async controlSendTaskFile(localSessionId, requestedFilePath) {
|
|
1030
|
+
const record = this.requireControlSession(localSessionId);
|
|
1031
|
+
const filePath = resolveUserPath(requestedFilePath, record.cwd);
|
|
1032
|
+
await this.assertSendableFile(filePath);
|
|
1033
|
+
const contextKey = this.controlSessionContextKey(record);
|
|
1034
|
+
const replyTarget = this.controlSessionReplyTarget(record);
|
|
1035
|
+
return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendFile(contextKey, filePath));
|
|
1036
|
+
}
|
|
644
1037
|
cardActionContextKey(action) {
|
|
645
1038
|
const explicit = typeof action.value.contextKey === "string" ? action.value.contextKey : undefined;
|
|
646
1039
|
if (explicit && baseChatContextKey(explicit) === baseChatContextKey(action.contextKey))
|
|
@@ -665,6 +1058,9 @@ export class ProxySessionController {
|
|
|
665
1058
|
case "shell":
|
|
666
1059
|
await this.runShellCommand(contextKey, command.command);
|
|
667
1060
|
return;
|
|
1061
|
+
case "dir":
|
|
1062
|
+
await this.openDirectoryBrowser(contextKey, command.directory);
|
|
1063
|
+
return;
|
|
668
1064
|
case "new":
|
|
669
1065
|
if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
|
|
670
1066
|
throw new Error("/new --nodir 仅支持 App Server Agent。");
|
|
@@ -708,6 +1104,16 @@ export class ProxySessionController {
|
|
|
708
1104
|
else
|
|
709
1105
|
await this.openAgentSettings(contextKey);
|
|
710
1106
|
return;
|
|
1107
|
+
case "archive": {
|
|
1108
|
+
const sessionId = command.sessionId ?? context.currentSessionId;
|
|
1109
|
+
if (!sessionId)
|
|
1110
|
+
throw new Error("当前会话没有可归档的任务。请使用 /sessions 查找任务。");
|
|
1111
|
+
await this.archiveSessionReference(contextKey, sessionId, {
|
|
1112
|
+
announce: true,
|
|
1113
|
+
source: "command",
|
|
1114
|
+
});
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
711
1117
|
case "status":
|
|
712
1118
|
await this.status(contextKey, command.sessionId);
|
|
713
1119
|
return;
|
|
@@ -719,6 +1125,9 @@ export class ProxySessionController {
|
|
|
719
1125
|
throw new Error("当前运行方式不支持自动重启。");
|
|
720
1126
|
await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
|
|
721
1127
|
return;
|
|
1128
|
+
case "mute":
|
|
1129
|
+
await this.setGroupMute(contextKey, command.enabled);
|
|
1130
|
+
return;
|
|
722
1131
|
case "turns":
|
|
723
1132
|
await this.openResetHistory(contextKey);
|
|
724
1133
|
return;
|
|
@@ -752,6 +1161,8 @@ export class ProxySessionController {
|
|
|
752
1161
|
const turnContextKey = session.lastTurnId
|
|
753
1162
|
? this.store.getTurnContextKey(session.lastTurnId) ?? context.contextKey
|
|
754
1163
|
: context.contextKey;
|
|
1164
|
+
if (!this.outbound.canRoute(turnContextKey))
|
|
1165
|
+
continue;
|
|
755
1166
|
this.outbound.registerSession(session.localSessionId, turnContextKey, session.title, session.cwd, this.agentLabel(session.agentName));
|
|
756
1167
|
if (!session.lastTurnId)
|
|
757
1168
|
continue;
|
|
@@ -759,14 +1170,314 @@ export class ProxySessionController {
|
|
|
759
1170
|
this.logger.warn({ error, sessionId: session.localSessionId }, "Failed to restore persisted turn delivery.");
|
|
760
1171
|
});
|
|
761
1172
|
}
|
|
1173
|
+
for (const turn of this.store.listUndeliveredCompletedTurns()) {
|
|
1174
|
+
const contextKey = turn.contextKey ?? this.store.getSession(turn.localSessionId)?.contextKey;
|
|
1175
|
+
if (!contextKey || !this.outbound.canRoute(contextKey))
|
|
1176
|
+
continue;
|
|
1177
|
+
void this.outbound.resumeDelivery(turn.localSessionId, contextKey, turn.turnId).catch((error) => {
|
|
1178
|
+
this.logger.warn({ error, sessionId: turn.localSessionId, turnId: turn.turnId }, "Failed to resume an undelivered completed turn.");
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
762
1181
|
}
|
|
763
1182
|
restorePersistedQueuedPrompts() {
|
|
1183
|
+
if (this.queuedPromptsRestored)
|
|
1184
|
+
return;
|
|
1185
|
+
this.queuedPromptsRestored = true;
|
|
764
1186
|
for (const sessionId of this.store.listQueuedPromptSessionIds()) {
|
|
1187
|
+
const firstPrompt = this.store.listQueuedPrompts(sessionId)[0];
|
|
1188
|
+
if (firstPrompt && !this.outbound.canRoute(firstPrompt.contextKey))
|
|
1189
|
+
continue;
|
|
765
1190
|
void this.scheduleNextQueuedPrompt(sessionId).catch((error) => {
|
|
766
1191
|
this.logger.warn({ error, sessionId }, "Failed to resume a persisted prompt queue.");
|
|
767
1192
|
});
|
|
768
1193
|
}
|
|
769
1194
|
}
|
|
1195
|
+
async recoverInterruptedTasks() {
|
|
1196
|
+
if (this.startupRecovery)
|
|
1197
|
+
return this.startupRecovery;
|
|
1198
|
+
const recovery = this.runStartupRecovery();
|
|
1199
|
+
this.startupRecovery = recovery;
|
|
1200
|
+
return recovery;
|
|
1201
|
+
}
|
|
1202
|
+
async runStartupRecovery() {
|
|
1203
|
+
this.backfillTurnAttemptsForUpgrade();
|
|
1204
|
+
const attempts = this.store.listIncompleteTurnAttempts();
|
|
1205
|
+
const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
|
|
1206
|
+
const recentAttempts = attempts.filter((attempt) => isRecoveryAttemptRecent(attempt, recoveryCutoff));
|
|
1207
|
+
if (recentAttempts.length > 0) {
|
|
1208
|
+
this.logger.warn({ attemptIds: recentAttempts.map((attempt) => attempt.attemptId) }, "Recovering unfinished Agent Bot tasks after startup.");
|
|
1209
|
+
}
|
|
1210
|
+
for (const attempt of attempts) {
|
|
1211
|
+
if (!isRecoveryAttemptRecent(attempt, recoveryCutoff)) {
|
|
1212
|
+
await this.expireStaleTurnAttempt(attempt);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (!this.outbound.canRoute(attempt.contextKey))
|
|
1216
|
+
continue;
|
|
1217
|
+
try {
|
|
1218
|
+
await this.recoverTurnAttempt(attempt);
|
|
1219
|
+
}
|
|
1220
|
+
catch (error) {
|
|
1221
|
+
this.logger.error({ error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId }, "Failed to recover an unfinished task; it will be retried while the server is running.");
|
|
1222
|
+
this.scheduleRecoveryRetry(attempt.attemptId);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
this.restorePersistedQueuedPrompts();
|
|
1226
|
+
}
|
|
1227
|
+
backfillTurnAttemptsForUpgrade() {
|
|
1228
|
+
for (const session of this.store.listAllSessions()) {
|
|
1229
|
+
if (session.status !== "starting" && session.status !== "running" && session.status !== "ready")
|
|
1230
|
+
continue;
|
|
1231
|
+
if (this.store.findIncompleteTurnAttemptForSession(session.localSessionId))
|
|
1232
|
+
continue;
|
|
1233
|
+
const lastTurn = session.lastTurnId
|
|
1234
|
+
? {
|
|
1235
|
+
turnId: session.lastTurnId,
|
|
1236
|
+
localSessionId: session.localSessionId,
|
|
1237
|
+
contextKey: this.store.getTurnContextKey(session.lastTurnId),
|
|
1238
|
+
snapshot: this.store.getTurnSnapshot(session.lastTurnId),
|
|
1239
|
+
updatedAt: session.updatedAt,
|
|
1240
|
+
}
|
|
1241
|
+
: undefined;
|
|
1242
|
+
const lastTurnSnapshot = turnViewSnapshot(lastTurn?.snapshot);
|
|
1243
|
+
const latestPersisted = this.store.findLatestTurnSnapshotForSession(session.localSessionId);
|
|
1244
|
+
const latestPersistedSnapshot = turnViewSnapshot(latestPersisted?.snapshot);
|
|
1245
|
+
const latest = lastTurnSnapshot && !isTerminalTurnViewStatus(lastTurnSnapshot.status)
|
|
1246
|
+
? lastTurn
|
|
1247
|
+
: latestPersistedSnapshot && !isTerminalTurnViewStatus(latestPersistedSnapshot.status)
|
|
1248
|
+
? latestPersisted
|
|
1249
|
+
: session.status === "ready"
|
|
1250
|
+
? undefined
|
|
1251
|
+
: lastTurn;
|
|
1252
|
+
if (!latest && session.status === "ready")
|
|
1253
|
+
continue;
|
|
1254
|
+
const snapshot = turnViewSnapshot(latest?.snapshot);
|
|
1255
|
+
if (snapshot && isTerminalTurnViewStatus(snapshot.status)) {
|
|
1256
|
+
this.store.updateSession(session.localSessionId, {
|
|
1257
|
+
status: snapshot.status === "failed" ? "failed" : "ready",
|
|
1258
|
+
});
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const persistedTurnId = latest?.turnId;
|
|
1262
|
+
const pendingTurnId = persistedTurnId?.startsWith("pending_") ? persistedTurnId : undefined;
|
|
1263
|
+
const turnId = pendingTurnId ? undefined : persistedTurnId ?? session.lastTurnId;
|
|
1264
|
+
const replyTarget = snapshot?.replyTarget?.replyInThread ? snapshot.replyTarget : undefined;
|
|
1265
|
+
this.store.createTurnAttempt({
|
|
1266
|
+
attemptId: createId("attempt"),
|
|
1267
|
+
localSessionId: session.localSessionId,
|
|
1268
|
+
contextKey: latest?.contextKey ?? session.contextKey,
|
|
1269
|
+
promptText: snapshot?.prompt?.trim() || "继续完成重启前尚未完成的任务。",
|
|
1270
|
+
messageId: turnId ? this.store.findMessageIdForTurn(turnId) : undefined,
|
|
1271
|
+
replyMessageId: replyTarget?.messageId,
|
|
1272
|
+
pendingTurnId,
|
|
1273
|
+
turnId,
|
|
1274
|
+
status: turnId ? "running" : "accepted",
|
|
1275
|
+
createdAt: latest?.updatedAt ?? session.updatedAt,
|
|
1276
|
+
updatedAt: latest?.updatedAt ?? session.updatedAt,
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
async expireStaleTurnAttempt(attempt) {
|
|
1281
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1282
|
+
const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
|
|
1283
|
+
if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
|
|
1284
|
+
await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "执行中断已超过 5 分钟,未自动恢复。").catch((error) => {
|
|
1285
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, turnId: oldCardTurnId }, "Failed to close an expired interrupted thinking card.");
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
|
|
1289
|
+
if (!session || session.status === "closed")
|
|
1290
|
+
return;
|
|
1291
|
+
if (attempt.turnId && session.lastTurnId === attempt.turnId) {
|
|
1292
|
+
this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
|
|
1293
|
+
}
|
|
1294
|
+
if (session.status === "starting" || session.status === "running") {
|
|
1295
|
+
this.store.updateSession(session.localSessionId, { status: "ready" });
|
|
1296
|
+
}
|
|
1297
|
+
if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
|
|
1298
|
+
await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error) => {
|
|
1299
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, turnId: attempt.turnId }, "Failed to finalize the message reaction for an expired interrupted turn.");
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
persistActiveTurnHeartbeats() {
|
|
1304
|
+
for (const attempt of this.store.listIncompleteTurnAttempts()) {
|
|
1305
|
+
if (!attempt.turnId)
|
|
1306
|
+
continue;
|
|
1307
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1308
|
+
if (!session)
|
|
1309
|
+
continue;
|
|
1310
|
+
const activeTurnId = this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId;
|
|
1311
|
+
if (activeTurnId === attempt.turnId)
|
|
1312
|
+
this.store.touchTurnAttempt(attempt.turnId);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
async recoverTurnAttempt(attempt, announce = true) {
|
|
1316
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1317
|
+
if (!session || session.status === "closed") {
|
|
1318
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
const contextKey = attempt.contextKey || session.contextKey;
|
|
1322
|
+
const replyTarget = attempt.replyMessageId
|
|
1323
|
+
? { messageId: attempt.replyMessageId, replyInThread: true }
|
|
1324
|
+
: undefined;
|
|
1325
|
+
this.outbound.registerSession(session.localSessionId, contextKey, session.title, session.cwd, this.agentLabel(session.agentName));
|
|
1326
|
+
if (announce) {
|
|
1327
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendText(contextKey, "检测到任务在 Agent Bot 重启前尚未完成,正在自动恢复。")).catch((error) => {
|
|
1328
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, contextKey }, "Failed to send the task recovery notification.");
|
|
1329
|
+
return undefined;
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
const runtime = this.runtimes.forAgent(session.agentName);
|
|
1333
|
+
const sourceTurnId = attempt.turnId;
|
|
1334
|
+
if (runtime.kind === "acp") {
|
|
1335
|
+
await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
let remote;
|
|
1339
|
+
if (session.remoteSessionId && runtime.readRemoteSession) {
|
|
1340
|
+
try {
|
|
1341
|
+
remote = await runtime.readRemoteSession(session.remoteSessionId);
|
|
1342
|
+
}
|
|
1343
|
+
catch (error) {
|
|
1344
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId }, "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.");
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
|
|
1348
|
+
if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "completed") {
|
|
1349
|
+
await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "failed") {
|
|
1353
|
+
await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (remote && remoteTurnBelongsToAttempt && isRemoteSessionActive(remote) && remote.lastTurnId) {
|
|
1357
|
+
await this.reattachActiveRecoveredTurn(attempt, session, remote.lastTurnId, replyTarget);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (remote && isRemoteSessionActive(remote) && !remoteTurnBelongsToAttempt) {
|
|
1361
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "recovering" });
|
|
1362
|
+
if (announce) {
|
|
1363
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendText(contextKey, "任务中检测到另一轮仍在执行;本次恢复将在它结束后重试。"));
|
|
1364
|
+
}
|
|
1365
|
+
this.scheduleRecoveryRetry(attempt.attemptId);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
|
|
1369
|
+
}
|
|
1370
|
+
async reconcileRecoveredRemoteTurn(attempt, session, remoteTurnId) {
|
|
1371
|
+
if (remoteTurnId && !attempt.turnId) {
|
|
1372
|
+
this.store.updateTurnAttempt(attempt.attemptId, { turnId: remoteTurnId, status: "running" });
|
|
1373
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
1374
|
+
lastTurnId: remoteTurnId,
|
|
1375
|
+
lastTurnStatus: "running",
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1379
|
+
const loaded = await this.loadSession({
|
|
1380
|
+
...current,
|
|
1381
|
+
contextKey: attempt.contextKey,
|
|
1382
|
+
status: "running",
|
|
1383
|
+
lastTurnStatus: "running",
|
|
1384
|
+
});
|
|
1385
|
+
const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
|
|
1386
|
+
if (synchronized.activeTurnId)
|
|
1387
|
+
this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
|
|
1388
|
+
}
|
|
1389
|
+
scheduleRecoveryRetry(attemptId, activeTurnId) {
|
|
1390
|
+
if (this.recoveryRetryTimers.has(attemptId))
|
|
1391
|
+
return;
|
|
1392
|
+
const timer = setTimeout(() => {
|
|
1393
|
+
this.recoveryRetryTimers.delete(attemptId);
|
|
1394
|
+
const attempt = this.store.getTurnAttempt(attemptId);
|
|
1395
|
+
if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status))
|
|
1396
|
+
return;
|
|
1397
|
+
const retry = async () => {
|
|
1398
|
+
let recoveryAttempt = attempt;
|
|
1399
|
+
if (activeTurnId) {
|
|
1400
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1401
|
+
if (session) {
|
|
1402
|
+
const runtime = this.runtimes.forAgent(session.agentName);
|
|
1403
|
+
const synchronized = await runtime.synchronizeSession(session.localSessionId);
|
|
1404
|
+
if (synchronized.activeTurnId === activeTurnId) {
|
|
1405
|
+
this.scheduleRecoveryRetry(attemptId, activeTurnId);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
const current = this.store.getTurnAttempt(attemptId);
|
|
1409
|
+
if (!current || !isIncompleteTurnAttemptStatus(current.status))
|
|
1410
|
+
return;
|
|
1411
|
+
recoveryAttempt = current;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
await this.recoverTurnAttempt(recoveryAttempt, false);
|
|
1415
|
+
};
|
|
1416
|
+
void retry().catch((error) => {
|
|
1417
|
+
this.logger.warn({ error, attemptId, sessionId: attempt.localSessionId }, "Failed to retry an unfinished task recovery.");
|
|
1418
|
+
this.scheduleRecoveryRetry(attemptId, activeTurnId);
|
|
1419
|
+
});
|
|
1420
|
+
}, 5_000);
|
|
1421
|
+
timer.unref?.();
|
|
1422
|
+
this.recoveryRetryTimers.set(attemptId, timer);
|
|
1423
|
+
}
|
|
1424
|
+
async reattachActiveRecoveredTurn(attempt, session, turnId, replyTarget) {
|
|
1425
|
+
const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
|
|
1426
|
+
if (oldCardTurnId) {
|
|
1427
|
+
await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。");
|
|
1428
|
+
}
|
|
1429
|
+
const pendingTurnId = await this.outbound.startPendingTurn(session.localSessionId, attempt.contextKey, session.title, replyTarget, recoveryCardPrompt(attempt.promptText));
|
|
1430
|
+
this.store.updateTurnAttempt(attempt.attemptId, {
|
|
1431
|
+
pendingTurnId: pendingTurnId ?? null,
|
|
1432
|
+
turnId,
|
|
1433
|
+
status: "running",
|
|
1434
|
+
});
|
|
1435
|
+
await this.outbound.onEvent({
|
|
1436
|
+
type: "turn_started",
|
|
1437
|
+
sessionId: session.localSessionId,
|
|
1438
|
+
turnId,
|
|
1439
|
+
startedAt: Date.now(),
|
|
1440
|
+
});
|
|
1441
|
+
this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
|
|
1442
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1443
|
+
const loaded = await this.loadSession({
|
|
1444
|
+
...current,
|
|
1445
|
+
contextKey: attempt.contextKey,
|
|
1446
|
+
status: "running",
|
|
1447
|
+
lastTurnId: turnId,
|
|
1448
|
+
lastTurnStatus: "running",
|
|
1449
|
+
});
|
|
1450
|
+
const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
|
|
1451
|
+
if (synchronized.activeTurnId)
|
|
1452
|
+
this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
|
|
1453
|
+
}
|
|
1454
|
+
async continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget) {
|
|
1455
|
+
const oldCardTurnId = attempt.pendingTurnId ?? sourceTurnId;
|
|
1456
|
+
if (oldCardTurnId) {
|
|
1457
|
+
await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 重启中断了这次执行,正在新的思考卡片中继续。").catch((error) => {
|
|
1458
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, turnId: oldCardTurnId }, "Failed to close the interrupted thinking card before recovery.");
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
this.store.prepareTurnAttemptRecovery(attempt.attemptId, oldCardTurnId);
|
|
1462
|
+
this.store.updateSession(session.localSessionId, { status: "ready" });
|
|
1463
|
+
if (sourceTurnId) {
|
|
1464
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
1465
|
+
lastTurnId: sourceTurnId,
|
|
1466
|
+
lastTurnStatus: "cancelled",
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1470
|
+
const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
|
|
1471
|
+
const recoveryText = recoveryRuntimePrompt(attempt.promptText);
|
|
1472
|
+
const turnId = await this.startTurn(loaded, recoveryText, replyTarget, attempt.localImagePaths, {
|
|
1473
|
+
attemptId: attempt.attemptId,
|
|
1474
|
+
messageId: attempt.messageId,
|
|
1475
|
+
displayPrompt: recoveryCardPrompt(attempt.promptText),
|
|
1476
|
+
preserveAttemptOnFailure: true,
|
|
1477
|
+
});
|
|
1478
|
+
if (attempt.messageId)
|
|
1479
|
+
await this.bindMessageReactionToTurn(attempt.messageId, session.localSessionId, turnId);
|
|
1480
|
+
}
|
|
770
1481
|
async restorePersistedMessageReactions() {
|
|
771
1482
|
const pending = this.store.listPendingMessageReactions();
|
|
772
1483
|
const terminalTurns = new Map();
|
|
@@ -795,6 +1506,19 @@ export class ProxySessionController {
|
|
|
795
1506
|
await this.promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths);
|
|
796
1507
|
}
|
|
797
1508
|
async promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths) {
|
|
1509
|
+
if (this.llmRetryingSessions.has(record.localSessionId)) {
|
|
1510
|
+
const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
|
|
1511
|
+
localImagePaths,
|
|
1512
|
+
messageId,
|
|
1513
|
+
replyTarget,
|
|
1514
|
+
});
|
|
1515
|
+
this.store.audit(contextKey, "queued_prompt_added_during_llm_retry", {
|
|
1516
|
+
promptId: queued.promptId,
|
|
1517
|
+
localSessionId: record.localSessionId,
|
|
1518
|
+
});
|
|
1519
|
+
await this.presentPromptQueueCard(record.localSessionId, contextKey);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
798
1522
|
const configuredRuntime = this.runtimes.forAgent(record.agentName);
|
|
799
1523
|
if (!configuredRuntime.getSession(record.localSessionId)) {
|
|
800
1524
|
this.outbound.registerSession(record.localSessionId, contextKey, record.title, record.cwd, this.agentLabel(record.agentName));
|
|
@@ -852,7 +1576,7 @@ export class ProxySessionController {
|
|
|
852
1576
|
this.logger.warn({ error: syncError, sessionId: record.localSessionId }, "Failed to synchronize after steering failure.");
|
|
853
1577
|
}
|
|
854
1578
|
if (!current?.activeTurnId) {
|
|
855
|
-
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
|
|
1579
|
+
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
|
|
856
1580
|
if (messageId)
|
|
857
1581
|
await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
|
|
858
1582
|
return;
|
|
@@ -877,11 +1601,12 @@ export class ProxySessionController {
|
|
|
877
1601
|
return;
|
|
878
1602
|
}
|
|
879
1603
|
}
|
|
880
|
-
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
|
|
1604
|
+
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
|
|
881
1605
|
if (messageId)
|
|
882
1606
|
await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
|
|
883
1607
|
}
|
|
884
1608
|
async presentSteerMessage(localSessionId, turnId, text, messageId) {
|
|
1609
|
+
this.store.touchTurnAttempt(turnId);
|
|
885
1610
|
try {
|
|
886
1611
|
await this.outbound.appendSteerMessage(localSessionId, turnId, text, messageId);
|
|
887
1612
|
}
|
|
@@ -992,6 +1717,10 @@ export class ProxySessionController {
|
|
|
992
1717
|
const current = this.store.getUserContext(message.contextKey)?.currentSessionId;
|
|
993
1718
|
if (current && this.store.getSession(current))
|
|
994
1719
|
return;
|
|
1720
|
+
const hasPersistedSourceTurn = threadForkAnchorMessageIds(message)
|
|
1721
|
+
.some((messageId) => this.store.findTurnAnchorByMessageId(messageId) !== undefined);
|
|
1722
|
+
if (!hasPersistedSourceTurn)
|
|
1723
|
+
return;
|
|
995
1724
|
const existing = this.threadInitializations.get(message.contextKey);
|
|
996
1725
|
if (existing)
|
|
997
1726
|
return existing;
|
|
@@ -1074,9 +1803,7 @@ export class ProxySessionController {
|
|
|
1074
1803
|
if (!message.threadContext || !message.threadId || !message.chatId) {
|
|
1075
1804
|
throw new Error("当前消息不属于可识别的飞书话题。");
|
|
1076
1805
|
}
|
|
1077
|
-
const
|
|
1078
|
-
.filter((messageId) => Boolean(messageId && messageId !== message.messageId));
|
|
1079
|
-
const anchor = [...new Set(anchorMessageIds)]
|
|
1806
|
+
const anchor = threadForkAnchorMessageIds(message)
|
|
1080
1807
|
.map((messageId) => this.store.findTurnAnchorByMessageId(messageId))
|
|
1081
1808
|
.find((candidate) => candidate !== undefined);
|
|
1082
1809
|
if (!anchor) {
|
|
@@ -1381,22 +2108,51 @@ export class ProxySessionController {
|
|
|
1381
2108
|
executionSettings,
|
|
1382
2109
|
};
|
|
1383
2110
|
}
|
|
1384
|
-
async startTurn(loaded, text, replyTarget, localImagePaths) {
|
|
2111
|
+
async startTurn(loaded, text, replyTarget, localImagePaths, options = {}) {
|
|
1385
2112
|
const currentRecord = this.store.getSession(loaded.record.localSessionId);
|
|
1386
2113
|
const title = currentRecord?.title ?? normalizeTaskTitle(text);
|
|
1387
2114
|
if (!currentRecord?.title && title)
|
|
1388
2115
|
this.store.updateRuntimeSession(loaded.record.localSessionId, { title });
|
|
1389
2116
|
if (title)
|
|
1390
2117
|
this.outbound.updateSessionTitle(loaded.record.localSessionId, title);
|
|
1391
|
-
|
|
2118
|
+
const attemptId = options.attemptId ?? createId("attempt");
|
|
2119
|
+
if (options.attemptId) {
|
|
2120
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
2121
|
+
messageId: options.messageId ?? undefined,
|
|
2122
|
+
replyMessageId: replyTarget?.messageId ?? undefined,
|
|
2123
|
+
status: "recovering",
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
else {
|
|
2127
|
+
this.store.createTurnAttempt({
|
|
2128
|
+
attemptId,
|
|
2129
|
+
localSessionId: loaded.record.localSessionId,
|
|
2130
|
+
contextKey: loaded.record.contextKey,
|
|
2131
|
+
promptText: text,
|
|
2132
|
+
localImagePaths,
|
|
2133
|
+
messageId: options.messageId,
|
|
2134
|
+
replyMessageId: replyTarget?.messageId,
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
1392
2137
|
let turnId;
|
|
1393
2138
|
try {
|
|
2139
|
+
const pendingTurnId = await this.outbound.startPendingTurn(loaded.record.localSessionId, loaded.record.contextKey, title, replyTarget, options.displayPrompt ?? text);
|
|
2140
|
+
this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
|
|
1394
2141
|
turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, runtimePrompt(text, localImagePaths));
|
|
1395
2142
|
}
|
|
1396
2143
|
catch (error) {
|
|
2144
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
2145
|
+
status: options.preserveAttemptOnFailure ? "recovering" : "failed",
|
|
2146
|
+
});
|
|
1397
2147
|
await this.outbound.failPendingTurn(loaded.record.localSessionId, error instanceof Error ? error.message : String(error));
|
|
1398
2148
|
throw error;
|
|
1399
2149
|
}
|
|
2150
|
+
const attempt = this.store.getTurnAttempt(attemptId);
|
|
2151
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
2152
|
+
pendingTurnId: null,
|
|
2153
|
+
turnId,
|
|
2154
|
+
...(attempt && isIncompleteTurnAttemptStatus(attempt.status) ? { status: "running" } : {}),
|
|
2155
|
+
});
|
|
1400
2156
|
const latest = this.store.getSession(loaded.record.localSessionId);
|
|
1401
2157
|
const alreadyTerminal = latest?.lastTurnId === turnId
|
|
1402
2158
|
&& ["completed", "cancelled", "failed"].includes(latest.lastTurnStatus ?? "");
|
|
@@ -1665,14 +2421,34 @@ export class ProxySessionController {
|
|
|
1665
2421
|
}
|
|
1666
2422
|
async handleRuntimeEvent(event) {
|
|
1667
2423
|
if ("turnId" in event) {
|
|
2424
|
+
this.store.touchTurnAttempt(event.turnId);
|
|
1668
2425
|
const source = this.store.getSession(event.sessionId);
|
|
1669
2426
|
if (source?.remoteSessionId && this.isCodexSession(source)) {
|
|
1670
2427
|
this.store.saveTurnRuntimeOrigin(event.turnId, source.localSessionId, source.agentName, source.remoteSessionId);
|
|
1671
2428
|
}
|
|
1672
2429
|
if (event.type === "turn_started") {
|
|
2430
|
+
const attempt = this.store.bindTurnAttempt(event.sessionId, event.turnId);
|
|
2431
|
+
if (attempt?.recoveredFromTurnId && attempt.recoveredFromTurnId !== event.turnId) {
|
|
2432
|
+
this.store.rebindPendingTurnMessages(event.sessionId, attempt.recoveredFromTurnId, event.turnId);
|
|
2433
|
+
}
|
|
1673
2434
|
this.store.saveTurnParent(event.turnId, event.sessionId, this.completedParentTurnId(source));
|
|
1674
2435
|
}
|
|
1675
2436
|
}
|
|
2437
|
+
const failedAttempt = event.type === "turn_failed"
|
|
2438
|
+
? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
|
|
2439
|
+
: undefined;
|
|
2440
|
+
if (event.type === "turn_failed") {
|
|
2441
|
+
if (this.retriedFailureTurnIds.has(event.turnId))
|
|
2442
|
+
return;
|
|
2443
|
+
if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
|
|
2444
|
+
const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
|
|
2445
|
+
if (retryAttempt) {
|
|
2446
|
+
this.retriedFailureTurnIds.add(event.turnId);
|
|
2447
|
+
await this.retryFailedLlmTurn(event, retryAttempt);
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
1676
2452
|
if (event.type === "session_metadata_updated") {
|
|
1677
2453
|
this.store.updateRuntimeSession(event.sessionId, { title: event.title });
|
|
1678
2454
|
this.outbound.updateSessionTitle(event.sessionId, event.title);
|
|
@@ -1683,14 +2459,23 @@ export class ProxySessionController {
|
|
|
1683
2459
|
this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
|
|
1684
2460
|
}
|
|
1685
2461
|
else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
|
|
2462
|
+
this.store.markTurnAttemptTerminal(event.turnId, event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed");
|
|
1686
2463
|
this.store.updateRuntimeSession(event.sessionId, {
|
|
1687
2464
|
lastTurnId: event.turnId,
|
|
1688
2465
|
lastTurnStatus: event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
|
|
1689
2466
|
});
|
|
1690
2467
|
}
|
|
2468
|
+
const presentationEvent = event.type === "turn_failed"
|
|
2469
|
+
&& isRetryableLlmTurnFailure(event.message)
|
|
2470
|
+
&& (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
|
|
2471
|
+
? {
|
|
2472
|
+
...event,
|
|
2473
|
+
message: `${event.message}\n\n已自动重试 ${MAX_LLM_TURN_RETRIES} 次,仍未成功。`,
|
|
2474
|
+
}
|
|
2475
|
+
: event;
|
|
1691
2476
|
let presentationError;
|
|
1692
2477
|
try {
|
|
1693
|
-
await this.outbound.onEvent(
|
|
2478
|
+
await this.outbound.onEvent(presentationEvent);
|
|
1694
2479
|
}
|
|
1695
2480
|
catch (error) {
|
|
1696
2481
|
presentationError = error;
|
|
@@ -1719,6 +2504,61 @@ export class ProxySessionController {
|
|
|
1719
2504
|
if (presentationError)
|
|
1720
2505
|
throw presentationError;
|
|
1721
2506
|
}
|
|
2507
|
+
async retryFailedLlmTurn(event, attempt) {
|
|
2508
|
+
const retryNumber = attempt.retryCount;
|
|
2509
|
+
const session = this.store.getSession(event.sessionId);
|
|
2510
|
+
if (!session || session.status === "closed") {
|
|
2511
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
this.llmRetryingSessions.add(session.localSessionId);
|
|
2515
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
2516
|
+
lastTurnId: event.turnId,
|
|
2517
|
+
lastTurnStatus: "failed",
|
|
2518
|
+
});
|
|
2519
|
+
this.store.audit(attempt.contextKey, "llm_turn_retry_started", {
|
|
2520
|
+
attemptId: attempt.attemptId,
|
|
2521
|
+
failedTurnId: event.turnId,
|
|
2522
|
+
retryNumber,
|
|
2523
|
+
maxRetries: MAX_LLM_TURN_RETRIES,
|
|
2524
|
+
error: event.message,
|
|
2525
|
+
});
|
|
2526
|
+
try {
|
|
2527
|
+
await this.outbound.onEvent({
|
|
2528
|
+
...event,
|
|
2529
|
+
message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
catch (error) {
|
|
2533
|
+
this.logger.warn({ error, sessionId: session.localSessionId, turnId: event.turnId }, "Failed to finalize the failed thinking card before an LLM retry.");
|
|
2534
|
+
}
|
|
2535
|
+
const replyTarget = attempt.replyMessageId
|
|
2536
|
+
? { messageId: attempt.replyMessageId, replyInThread: true }
|
|
2537
|
+
: undefined;
|
|
2538
|
+
try {
|
|
2539
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
2540
|
+
const loaded = await this.loadSession(current);
|
|
2541
|
+
const retryTurnId = await this.startTurn(loaded, llmRetryRuntimePrompt(attempt.promptText, retryNumber), replyTarget, attempt.localImagePaths, {
|
|
2542
|
+
attemptId: attempt.attemptId,
|
|
2543
|
+
messageId: attempt.messageId,
|
|
2544
|
+
displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
|
|
2545
|
+
preserveAttemptOnFailure: true,
|
|
2546
|
+
});
|
|
2547
|
+
this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
|
|
2548
|
+
}
|
|
2549
|
+
catch (error) {
|
|
2550
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
|
|
2551
|
+
this.store.updateSession(session.localSessionId, { status: "failed" });
|
|
2552
|
+
await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError) => {
|
|
2553
|
+
this.logger.warn({ error: reactionError, sessionId: session.localSessionId, turnId: event.turnId }, "Failed to finalize reactions after an LLM retry could not start.");
|
|
2554
|
+
});
|
|
2555
|
+
this.logger.error({ error, sessionId: session.localSessionId, retryNumber }, "Failed to start an automatic LLM turn retry.");
|
|
2556
|
+
await this.scheduleNextQueuedPrompt(session.localSessionId);
|
|
2557
|
+
}
|
|
2558
|
+
finally {
|
|
2559
|
+
this.llmRetryingSessions.delete(session.localSessionId);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
1722
2562
|
scheduleNextQueuedPrompt(sessionId) {
|
|
1723
2563
|
const previous = this.queuedPromptStarts.get(sessionId) ?? Promise.resolve();
|
|
1724
2564
|
const next = previous.catch(() => undefined).then(() => this.startNextQueuedPromptIfIdle(sessionId));
|
|
@@ -1749,7 +2589,7 @@ export class ProxySessionController {
|
|
|
1749
2589
|
const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
|
|
1750
2590
|
loaded = { ...loaded, record };
|
|
1751
2591
|
await this.refreshPromptQueueCards(sessionId);
|
|
1752
|
-
const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths);
|
|
2592
|
+
const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths, { messageId: prompt.messageId });
|
|
1753
2593
|
if (prompt.messageId)
|
|
1754
2594
|
await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
|
|
1755
2595
|
}
|
|
@@ -1770,7 +2610,9 @@ export class ProxySessionController {
|
|
|
1770
2610
|
if (source.lastTurnStatus === "completed")
|
|
1771
2611
|
return source.lastTurnId;
|
|
1772
2612
|
const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(source.lastTurnId));
|
|
1773
|
-
return snapshot?.status === "completed"
|
|
2613
|
+
return snapshot?.status === "completed"
|
|
2614
|
+
? source.lastTurnId
|
|
2615
|
+
: this.store.findLatestCompletedTurnId(source.localSessionId, source.contextKey);
|
|
1774
2616
|
}
|
|
1775
2617
|
async finalizeStandaloneMessageReaction(messageId, status) {
|
|
1776
2618
|
const reaction = this.store.claimMessageReaction(messageId);
|
|
@@ -1886,15 +2728,19 @@ export class ProxySessionController {
|
|
|
1886
2728
|
if (current.localSessionId !== sessionId) {
|
|
1887
2729
|
throw new Error("这张思考卡片不属于当前任务。请先切换回对应任务,再点击 Reset。");
|
|
1888
2730
|
}
|
|
2731
|
+
await this.performSessionReset(contextKey, current, turnId, true);
|
|
2732
|
+
}
|
|
2733
|
+
async performSessionReset(contextKey, current, turnId, announce) {
|
|
1889
2734
|
if (!current.remoteSessionId || !this.isCodexSession(current)) {
|
|
1890
2735
|
throw new Error("当前任务不是可 Reset 的 App Server 任务。");
|
|
1891
2736
|
}
|
|
1892
2737
|
const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
|
|
1893
|
-
if (!snapshot || snapshot.sessionId !==
|
|
2738
|
+
if (!snapshot || snapshot.sessionId !== current.localSessionId || snapshot.status !== "completed") {
|
|
1894
2739
|
throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
|
|
1895
2740
|
}
|
|
1896
2741
|
if (current.lastTurnId === turnId && current.lastTurnStatus === "completed") {
|
|
1897
|
-
|
|
2742
|
+
if (announce)
|
|
2743
|
+
await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
|
|
1898
2744
|
return;
|
|
1899
2745
|
}
|
|
1900
2746
|
const runtime = this.runtimes.forAgent(current.agentName);
|
|
@@ -1909,7 +2755,7 @@ export class ProxySessionController {
|
|
|
1909
2755
|
throw new Error("当前任务仍在执行。请等待任务完成或先停止任务,再点击 Reset。");
|
|
1910
2756
|
}
|
|
1911
2757
|
const origin = this.store.getTurnRuntimeOrigin(turnId);
|
|
1912
|
-
if (origin && (origin.localSessionId !==
|
|
2758
|
+
if (origin && (origin.localSessionId !== current.localSessionId || origin.agentName !== current.agentName)) {
|
|
1913
2759
|
throw new Error("这张思考卡片与当前任务不匹配,已拒绝 Reset。");
|
|
1914
2760
|
}
|
|
1915
2761
|
const sourceRemoteSessionId = origin?.remoteSessionId ?? current.remoteSessionId;
|
|
@@ -1941,13 +2787,15 @@ export class ProxySessionController {
|
|
|
1941
2787
|
});
|
|
1942
2788
|
const targetSummary = truncateText((snapshot.prompt ?? snapshot.taskTitle ?? "未记录对话内容").replace(/\s+/g, " ").trim(), 100) || "未记录对话内容";
|
|
1943
2789
|
const targetTime = snapshot.completedAt ?? snapshot.startedAt;
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2790
|
+
if (announce) {
|
|
2791
|
+
await this.outbound.sendText(contextKey, [
|
|
2792
|
+
"已将当前任务重置到:",
|
|
2793
|
+
targetSummary,
|
|
2794
|
+
`完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
|
|
2795
|
+
`Turn ID:${turnId}`,
|
|
2796
|
+
"后续对话将从该轮完成后的状态继续;本地文件没有回退。",
|
|
2797
|
+
].join("\n"));
|
|
2798
|
+
}
|
|
1951
2799
|
}
|
|
1952
2800
|
async resolveApproval(action) {
|
|
1953
2801
|
const sessionId = String(action.value.sessionId ?? "");
|
|
@@ -2497,6 +3345,165 @@ export class ProxySessionController {
|
|
|
2497
3345
|
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
2498
3346
|
}
|
|
2499
3347
|
}
|
|
3348
|
+
async openDirectoryBrowser(contextKey, requestedDirectory, options = {}) {
|
|
3349
|
+
if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
|
|
3350
|
+
await this.openWindowsDriveBrowser(contextKey, options);
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
const baseDirectory = this.currentSession(contextKey)?.cwd
|
|
3354
|
+
?? this.store.getUserContext(contextKey)?.boundProjectCwd
|
|
3355
|
+
?? this.config.defaults.cwd;
|
|
3356
|
+
const directory = requestedDirectory === undefined
|
|
3357
|
+
? path.resolve(baseDirectory)
|
|
3358
|
+
: resolveUserPath(requestedDirectory, baseDirectory);
|
|
3359
|
+
await this.assertBrowsableDirectory(directory);
|
|
3360
|
+
let directoryEntries;
|
|
3361
|
+
try {
|
|
3362
|
+
directoryEntries = await fs.readdir(directory, { withFileTypes: true });
|
|
3363
|
+
}
|
|
3364
|
+
catch (error) {
|
|
3365
|
+
throw new Error(`无法读取目录 ${directory}:${runtimeErrorMessage(error)}`);
|
|
3366
|
+
}
|
|
3367
|
+
const entries = directoryEntries
|
|
3368
|
+
.map((entry) => ({
|
|
3369
|
+
name: entry.name,
|
|
3370
|
+
path: path.join(directory, entry.name),
|
|
3371
|
+
kind: directoryBrowserEntryKind(entry),
|
|
3372
|
+
}))
|
|
3373
|
+
.sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
|
|
3374
|
+
|| left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
3375
|
+
const parentDirectory = isWindowsDriveRoot(directory)
|
|
3376
|
+
? WINDOWS_DRIVES_DIRECTORY
|
|
3377
|
+
: path.dirname(directory);
|
|
3378
|
+
const parentEntries = parentDirectory === directory ? [] : [{
|
|
3379
|
+
name: "..",
|
|
3380
|
+
kind: "directory",
|
|
3381
|
+
openAction: {
|
|
3382
|
+
text: "..",
|
|
3383
|
+
value: {
|
|
3384
|
+
action: "directory_open",
|
|
3385
|
+
directory: parentDirectory,
|
|
3386
|
+
contextKey,
|
|
3387
|
+
},
|
|
3388
|
+
},
|
|
3389
|
+
}];
|
|
3390
|
+
const pageSize = DIRECTORY_PAGE_SIZE;
|
|
3391
|
+
const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
|
|
3392
|
+
const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
|
|
3393
|
+
const page = Math.min(requestedPage, totalPages - 1);
|
|
3394
|
+
const offset = page * pageSize;
|
|
3395
|
+
const visibleEntries = [
|
|
3396
|
+
...parentEntries,
|
|
3397
|
+
...entries
|
|
3398
|
+
.slice(offset, offset + pageSize)
|
|
3399
|
+
.map((entry) => entry.kind === "directory"
|
|
3400
|
+
? {
|
|
3401
|
+
name: entry.name,
|
|
3402
|
+
kind: entry.kind,
|
|
3403
|
+
openAction: {
|
|
3404
|
+
text: entry.name,
|
|
3405
|
+
value: {
|
|
3406
|
+
action: "directory_open",
|
|
3407
|
+
directory: entry.path,
|
|
3408
|
+
contextKey,
|
|
3409
|
+
},
|
|
3410
|
+
},
|
|
3411
|
+
actions: directoryCreationActions(entry.path, contextKey),
|
|
3412
|
+
}
|
|
3413
|
+
: {
|
|
3414
|
+
name: entry.name,
|
|
3415
|
+
kind: entry.kind,
|
|
3416
|
+
openAction: {
|
|
3417
|
+
text: entry.name,
|
|
3418
|
+
value: {
|
|
3419
|
+
action: "directory_send_file",
|
|
3420
|
+
filePath: entry.path,
|
|
3421
|
+
contextKey,
|
|
3422
|
+
},
|
|
3423
|
+
},
|
|
3424
|
+
}),
|
|
3425
|
+
];
|
|
3426
|
+
const navigationActions = directoryPaginationActions(directory, page, totalPages, contextKey);
|
|
3427
|
+
const directoryCount = entries.filter((entry) => entry.kind === "directory").length;
|
|
3428
|
+
const card = this.cardRenderer.renderDirectoryBrowserCard({
|
|
3429
|
+
directory: abbreviateHomeDirectory(directory),
|
|
3430
|
+
entries: visibleEntries,
|
|
3431
|
+
currentActions: directoryCreationActions(directory, contextKey),
|
|
3432
|
+
navigationActions,
|
|
3433
|
+
footerLines: [
|
|
3434
|
+
`第 ${page + 1}/${totalPages} 页 · ${directoryCount} 个目录 · ${entries.length - directoryCount} 个文件`,
|
|
3435
|
+
"",
|
|
3436
|
+
"> 点击目录名称进入,点击文件名称发送到当前会话;**New** 在该目录创建任务,**NewGroup** 在该目录创建新群和任务。",
|
|
3437
|
+
],
|
|
3438
|
+
});
|
|
3439
|
+
if (options.updateMessageId) {
|
|
3440
|
+
await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
|
|
3441
|
+
}
|
|
3442
|
+
else {
|
|
3443
|
+
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
async openWindowsDriveBrowser(contextKey, options) {
|
|
3447
|
+
const drives = await this.windowsDriveLister();
|
|
3448
|
+
const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
|
|
3449
|
+
const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
|
|
3450
|
+
const page = Math.min(requestedPage, totalPages - 1);
|
|
3451
|
+
const offset = page * DIRECTORY_PAGE_SIZE;
|
|
3452
|
+
const entries = drives
|
|
3453
|
+
.slice(offset, offset + DIRECTORY_PAGE_SIZE)
|
|
3454
|
+
.map((drive) => ({
|
|
3455
|
+
name: windowsDriveDisplayName(drive),
|
|
3456
|
+
kind: "drive",
|
|
3457
|
+
openAction: {
|
|
3458
|
+
text: windowsDriveDisplayName(drive),
|
|
3459
|
+
value: {
|
|
3460
|
+
action: "directory_open",
|
|
3461
|
+
directory: drive.root,
|
|
3462
|
+
contextKey,
|
|
3463
|
+
},
|
|
3464
|
+
},
|
|
3465
|
+
actions: directoryCreationActions(drive.root, contextKey),
|
|
3466
|
+
}));
|
|
3467
|
+
const card = this.cardRenderer.renderDirectoryBrowserCard({
|
|
3468
|
+
directory: "此电脑",
|
|
3469
|
+
entries,
|
|
3470
|
+
currentActions: [],
|
|
3471
|
+
navigationActions: directoryPaginationActions(WINDOWS_DRIVES_DIRECTORY, page, totalPages, contextKey),
|
|
3472
|
+
footerLines: [
|
|
3473
|
+
`第 ${page + 1}/${totalPages} 页 · ${drives.length} 个磁盘`,
|
|
3474
|
+
"",
|
|
3475
|
+
"> 点击磁盘名称进入;**New** 在磁盘根目录创建任务,**NewGroup** 在磁盘根目录创建新群和任务。",
|
|
3476
|
+
],
|
|
3477
|
+
});
|
|
3478
|
+
if (options.updateMessageId) {
|
|
3479
|
+
await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
|
|
3480
|
+
}
|
|
3481
|
+
else {
|
|
3482
|
+
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
async assertBrowsableDirectory(directory) {
|
|
3486
|
+
let stats;
|
|
3487
|
+
try {
|
|
3488
|
+
stats = await fs.stat(directory);
|
|
3489
|
+
}
|
|
3490
|
+
catch (error) {
|
|
3491
|
+
throw new Error(`目录不存在或无法访问:${directory}(${runtimeErrorMessage(error)})`);
|
|
3492
|
+
}
|
|
3493
|
+
if (!stats.isDirectory())
|
|
3494
|
+
throw new Error(`这不是目录:${directory}`);
|
|
3495
|
+
}
|
|
3496
|
+
async assertSendableFile(filePath) {
|
|
3497
|
+
let stats;
|
|
3498
|
+
try {
|
|
3499
|
+
stats = await fs.stat(filePath);
|
|
3500
|
+
}
|
|
3501
|
+
catch (error) {
|
|
3502
|
+
throw new Error(`文件不存在或无法访问:${filePath}(${runtimeErrorMessage(error)})`);
|
|
3503
|
+
}
|
|
3504
|
+
if (!stats.isFile())
|
|
3505
|
+
throw new Error(`这不是普通文件:${filePath}`);
|
|
3506
|
+
}
|
|
2500
3507
|
async listSessions(contextKey, searchTerm, options = {}) {
|
|
2501
3508
|
const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
|
|
2502
3509
|
const normalizedSearch = searchTerm?.trim().toLowerCase();
|
|
@@ -2522,23 +3529,28 @@ export class ProxySessionController {
|
|
|
2522
3529
|
const remoteHint = remoteErrors.length > 0
|
|
2523
3530
|
? `部分 Agent 的任务读取失败:${remoteErrors.join(";")}`
|
|
2524
3531
|
: undefined;
|
|
2525
|
-
const entries = mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
|
|
3532
|
+
const entries = orderTaskListByProject(mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
|
|
2526
3533
|
.filter((entry) => !normalizedSearch
|
|
2527
3534
|
|| [entry.id, entry.title, entry.cwd, entry.agentName]
|
|
2528
|
-
.some((value) => value.toLowerCase().includes(normalizedSearch)))
|
|
2529
|
-
.sort((left, right) => Number(right.current) - Number(left.current)
|
|
2530
|
-
|| Number(right.active) - Number(left.active)
|
|
2531
|
-
|| right.updatedAt - left.updatedAt);
|
|
3535
|
+
.some((value) => value.toLowerCase().includes(normalizedSearch))));
|
|
2532
3536
|
const activeCount = entries.filter((entry) => entry.active).length;
|
|
2533
3537
|
const lastKnownPage = Math.max(0, Math.ceil(entries.length / SESSION_PAGE_SIZE) - 1);
|
|
2534
3538
|
const page = remoteHasMore ? requestedPage : Math.min(requestedPage, lastKnownPage);
|
|
2535
3539
|
const offset = page * SESSION_PAGE_SIZE;
|
|
2536
3540
|
const visibleEntries = entries.slice(offset, offset + SESSION_PAGE_SIZE);
|
|
3541
|
+
const projectActionSources = new Map();
|
|
3542
|
+
for (const entry of entries) {
|
|
3543
|
+
const project = taskProjectInfo(entry);
|
|
3544
|
+
if (!projectActionSources.has(project.key))
|
|
3545
|
+
projectActionSources.set(project.key, entry);
|
|
3546
|
+
}
|
|
2537
3547
|
const hasPrevious = page > 0;
|
|
2538
3548
|
const hasNext = remoteHasMore || entries.length > offset + SESSION_PAGE_SIZE;
|
|
2539
3549
|
this.lastSessionListings.set(contextKey, entries.map((entry) => entry.reference));
|
|
2540
3550
|
const cardEntries = visibleEntries.map((entry, index) => {
|
|
2541
|
-
const
|
|
3551
|
+
const project = taskProjectInfo(entry);
|
|
3552
|
+
const projectActionSource = projectActionSources.get(project.key) ?? entry;
|
|
3553
|
+
const marker = entry.current ? "✅" : entry.active ? "🟢" : "•";
|
|
2542
3554
|
const showStop = entry.status === "外部执行中"
|
|
2543
3555
|
&& entry.reference !== options.forceSwitchTaskId
|
|
2544
3556
|
&& entry.id !== options.forceSwitchTaskId;
|
|
@@ -2553,10 +3565,29 @@ export class ProxySessionController {
|
|
|
2553
3565
|
contextKey,
|
|
2554
3566
|
},
|
|
2555
3567
|
}];
|
|
3568
|
+
const projectActions = [{
|
|
3569
|
+
text: "New",
|
|
3570
|
+
value: {
|
|
3571
|
+
action: "session_new",
|
|
3572
|
+
sessionId: projectActionSource.reference,
|
|
3573
|
+
...(searchTerm ? { searchTerm } : {}),
|
|
3574
|
+
page: String(page),
|
|
3575
|
+
contextKey,
|
|
3576
|
+
},
|
|
3577
|
+
}, {
|
|
3578
|
+
text: "NewGroup",
|
|
3579
|
+
value: {
|
|
3580
|
+
action: "session_new_group",
|
|
3581
|
+
sessionId: projectActionSource.reference,
|
|
3582
|
+
...(searchTerm ? { searchTerm } : {}),
|
|
3583
|
+
page: String(page),
|
|
3584
|
+
contextKey,
|
|
3585
|
+
},
|
|
3586
|
+
}];
|
|
2556
3587
|
actions.push({
|
|
2557
|
-
text: "
|
|
3588
|
+
text: "Fork",
|
|
2558
3589
|
value: {
|
|
2559
|
-
action: "
|
|
3590
|
+
action: "session_fork",
|
|
2560
3591
|
sessionId: entry.reference,
|
|
2561
3592
|
...(searchTerm ? { searchTerm } : {}),
|
|
2562
3593
|
page: String(page),
|
|
@@ -2564,9 +3595,9 @@ export class ProxySessionController {
|
|
|
2564
3595
|
},
|
|
2565
3596
|
});
|
|
2566
3597
|
actions.push({
|
|
2567
|
-
text: "
|
|
3598
|
+
text: "ForkGroup",
|
|
2568
3599
|
value: {
|
|
2569
|
-
action: "
|
|
3600
|
+
action: "session_fork_group",
|
|
2570
3601
|
sessionId: entry.reference,
|
|
2571
3602
|
...(searchTerm ? { searchTerm } : {}),
|
|
2572
3603
|
page: String(page),
|
|
@@ -2574,47 +3605,62 @@ export class ProxySessionController {
|
|
|
2574
3605
|
},
|
|
2575
3606
|
});
|
|
2576
3607
|
actions.push({
|
|
2577
|
-
text: "
|
|
3608
|
+
text: "Status",
|
|
2578
3609
|
value: {
|
|
2579
|
-
action: "
|
|
3610
|
+
action: "session_status",
|
|
2580
3611
|
sessionId: entry.reference,
|
|
2581
|
-
...(searchTerm ? { searchTerm } : {}),
|
|
2582
|
-
page: String(page),
|
|
2583
3612
|
contextKey,
|
|
2584
3613
|
},
|
|
2585
3614
|
});
|
|
2586
3615
|
actions.push({
|
|
2587
|
-
text: "
|
|
3616
|
+
text: "Archive",
|
|
2588
3617
|
value: {
|
|
2589
|
-
action: "
|
|
3618
|
+
action: "session_archive",
|
|
2590
3619
|
sessionId: entry.reference,
|
|
2591
3620
|
...(searchTerm ? { searchTerm } : {}),
|
|
2592
3621
|
page: String(page),
|
|
2593
3622
|
contextKey,
|
|
2594
3623
|
},
|
|
2595
3624
|
});
|
|
2596
|
-
|
|
2597
|
-
text: "Status",
|
|
2598
|
-
value: {
|
|
2599
|
-
action: "session_status",
|
|
2600
|
-
sessionId: entry.reference,
|
|
2601
|
-
contextKey,
|
|
2602
|
-
},
|
|
2603
|
-
});
|
|
3625
|
+
const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
|
|
2604
3626
|
return {
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
3627
|
+
project,
|
|
3628
|
+
projectActions,
|
|
3629
|
+
cardEntry: {
|
|
3630
|
+
reference: entry.reference,
|
|
3631
|
+
summary: `${offset + index + 1}. ${marker} ${title} · ${entry.agentName}`,
|
|
3632
|
+
detailLines: [
|
|
3633
|
+
`**状态 / 更新时间**:${cardText(entry.status)} / ${cardText(entry.updatedLabel)}`,
|
|
3634
|
+
`**Agent / 任务 ID**:${cardCode(entry.agentName)} / ${cardCode(entry.id)}`,
|
|
3635
|
+
`**目录**:${cardCode(entry.cwd || "目录未知")}`,
|
|
3636
|
+
],
|
|
3637
|
+
actions,
|
|
3638
|
+
current: entry.current,
|
|
3639
|
+
},
|
|
2610
3640
|
};
|
|
2611
3641
|
});
|
|
2612
|
-
const
|
|
3642
|
+
const cardGroups = [];
|
|
3643
|
+
let currentProjectKey;
|
|
3644
|
+
for (const item of cardEntries) {
|
|
3645
|
+
const currentGroup = cardGroups.at(-1);
|
|
3646
|
+
if (currentGroup && currentProjectKey === item.project.key) {
|
|
3647
|
+
currentGroup.entries.push(item.cardEntry);
|
|
3648
|
+
}
|
|
3649
|
+
else {
|
|
3650
|
+
currentProjectKey = item.project.key;
|
|
3651
|
+
cardGroups.push({
|
|
3652
|
+
title: item.project.title,
|
|
3653
|
+
entries: [item.cardEntry],
|
|
3654
|
+
actions: item.projectActions,
|
|
3655
|
+
});
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
const card = this.cardRenderer.renderSessionTaskListCard(searchTerm ? `任务列表:${searchTerm}` : "任务列表", activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务", cardGroups, [
|
|
2613
3659
|
...(remoteHint ? [remoteHint] : []),
|
|
2614
3660
|
`第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
|
|
2615
3661
|
"",
|
|
2616
|
-
"> **
|
|
2617
|
-
"> **
|
|
3662
|
+
"> 项目行:**New** 新建任务,**NewGroup** 新建群。",
|
|
3663
|
+
"> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
|
|
2618
3664
|
], [
|
|
2619
3665
|
...(hasPrevious ? [{
|
|
2620
3666
|
text: "Previous",
|
|
@@ -2779,6 +3825,56 @@ export class ProxySessionController {
|
|
|
2779
3825
|
});
|
|
2780
3826
|
await this.outbound.sendText(contextKey, `已向 Agent 发送 Interrupt 请求:${turnId}`);
|
|
2781
3827
|
}
|
|
3828
|
+
async archiveSessionReference(contextKey, reference, options) {
|
|
3829
|
+
const taskId = this.resolveSessionReference(contextKey, reference);
|
|
3830
|
+
const direct = this.store.getSession(taskId);
|
|
3831
|
+
if (direct?.status === "closed")
|
|
3832
|
+
throw new Error(`任务已经归档:${direct.title ?? taskId}`);
|
|
3833
|
+
const local = direct ?? this.findStoredSessionByReference(taskId);
|
|
3834
|
+
if (local && (!local.remoteSessionId || !this.isCodexSession(local))) {
|
|
3835
|
+
throw new Error("当前 Agent 不支持归档任务。");
|
|
3836
|
+
}
|
|
3837
|
+
if (local && (local.status === "running"
|
|
3838
|
+
|| Boolean(this.runtimes.forAgent(local.agentName).getSession(local.localSessionId)?.activeTurnId))) {
|
|
3839
|
+
throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
|
|
3840
|
+
}
|
|
3841
|
+
if (local && this.store.countQueuedPrompts(local.localSessionId) > 0) {
|
|
3842
|
+
throw new Error("任务仍有排队消息,无法归档。请先处理或取消排队消息。");
|
|
3843
|
+
}
|
|
3844
|
+
const remoteReference = local
|
|
3845
|
+
? remoteSessionReference(local.agentName, local.remoteSessionId)
|
|
3846
|
+
: taskId;
|
|
3847
|
+
const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
|
|
3848
|
+
if (isRemoteSessionActive(remote)) {
|
|
3849
|
+
throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
|
|
3850
|
+
}
|
|
3851
|
+
if (!runtime.archiveRemoteSession) {
|
|
3852
|
+
throw new Error(`Agent ${agentName} 不支持归档任务。`);
|
|
3853
|
+
}
|
|
3854
|
+
const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
|
|
3855
|
+
await runtime.archiveRemoteSession(remote.id);
|
|
3856
|
+
const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
|
|
3857
|
+
if (local) {
|
|
3858
|
+
this.store.archiveSession(local.localSessionId);
|
|
3859
|
+
this.outbound.unregisterSession(local.localSessionId);
|
|
3860
|
+
}
|
|
3861
|
+
this.store.audit(contextKey, "session_archived", {
|
|
3862
|
+
...(local ? { localSessionId: local.localSessionId } : {}),
|
|
3863
|
+
remoteSessionId: remote.id,
|
|
3864
|
+
agentName,
|
|
3865
|
+
source: options.source,
|
|
3866
|
+
});
|
|
3867
|
+
if (options.announce) {
|
|
3868
|
+
await this.outbound.sendText(contextKey, wasCurrent
|
|
3869
|
+
? `已归档任务:${title}\n当前会话已没有绑定任务,直接发送消息即可创建新任务。`
|
|
3870
|
+
: `已归档任务:${title}`);
|
|
3871
|
+
}
|
|
3872
|
+
return {
|
|
3873
|
+
localSessionId: local?.localSessionId ?? "",
|
|
3874
|
+
remoteSessionId: remote.id,
|
|
3875
|
+
title,
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
2782
3878
|
resolveSessionReference(contextKey, reference) {
|
|
2783
3879
|
if (reference === undefined) {
|
|
2784
3880
|
const previous = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent).previousSessionId;
|
|
@@ -3067,6 +4163,37 @@ export class ProxySessionController {
|
|
|
3067
4163
|
"**[参数]** 可选 **<参数>** 必填",
|
|
3068
4164
|
], sections));
|
|
3069
4165
|
}
|
|
4166
|
+
async setGroupMute(contextKey, enabled) {
|
|
4167
|
+
const chatContextKey = baseChatContextKey(contextKey);
|
|
4168
|
+
const chat = this.store.getChatContext(chatContextKey);
|
|
4169
|
+
if (chat?.chatType !== "group") {
|
|
4170
|
+
throw new Error("/mute 仅适用于群聊。");
|
|
4171
|
+
}
|
|
4172
|
+
this.store.setChatRequiresMention(chatContextKey, enabled);
|
|
4173
|
+
await this.outbound.sendText(contextKey, enabled
|
|
4174
|
+
? "已开启当前群的静音模式。之后只有 @ 机器人的消息会被处理;发送 @机器人 /mute off 可恢复自动响应。"
|
|
4175
|
+
: "已关闭当前群的静音模式。机器人将恢复自动响应群消息。");
|
|
4176
|
+
}
|
|
4177
|
+
requireControlSession(localSessionId) {
|
|
4178
|
+
const record = this.store.getSession(localSessionId);
|
|
4179
|
+
if (!record || record.status === "closed")
|
|
4180
|
+
throw new Error(`Task not found: ${localSessionId}`);
|
|
4181
|
+
return record;
|
|
4182
|
+
}
|
|
4183
|
+
controlSessionContextKey(record) {
|
|
4184
|
+
return this.outbound.getSessionContextKey(record.localSessionId)
|
|
4185
|
+
?? (record.lastTurnId ? this.store.getTurnContextKey(record.lastTurnId) : undefined)
|
|
4186
|
+
?? record.contextKey;
|
|
4187
|
+
}
|
|
4188
|
+
controlSessionReplyTarget(record) {
|
|
4189
|
+
const routed = this.outbound.getSessionReplyTarget(record.localSessionId);
|
|
4190
|
+
if (routed)
|
|
4191
|
+
return routed;
|
|
4192
|
+
const snapshot = record.lastTurnId
|
|
4193
|
+
? turnViewSnapshot(this.store.getTurnSnapshot(record.lastTurnId))
|
|
4194
|
+
: undefined;
|
|
4195
|
+
return snapshot?.replyTarget;
|
|
4196
|
+
}
|
|
3070
4197
|
ensureAgent(agentName) {
|
|
3071
4198
|
const agent = this.config.agents[agentName];
|
|
3072
4199
|
if (!agent)
|
|
@@ -3127,6 +4254,10 @@ export class ProxySessionController {
|
|
|
3127
4254
|
await this.outbound.sendText(contextKey, error instanceof Error ? error.message : String(error));
|
|
3128
4255
|
}
|
|
3129
4256
|
}
|
|
4257
|
+
function threadForkAnchorMessageIds(message) {
|
|
4258
|
+
return [...new Set([message.rootMessageId, message.parentMessageId]
|
|
4259
|
+
.filter((messageId) => Boolean(messageId && messageId !== message.messageId)))];
|
|
4260
|
+
}
|
|
3130
4261
|
function sessionStatusLabel(status, activeTurnId) {
|
|
3131
4262
|
if (activeTurnId || status === "running")
|
|
3132
4263
|
return "执行中";
|
|
@@ -3157,6 +4288,50 @@ function statusRefreshAction(sessionId, contextKey) {
|
|
|
3157
4288
|
},
|
|
3158
4289
|
};
|
|
3159
4290
|
}
|
|
4291
|
+
function orderTaskListByProject(entries) {
|
|
4292
|
+
const groups = new Map();
|
|
4293
|
+
for (const entry of entries) {
|
|
4294
|
+
const project = taskProjectInfo(entry);
|
|
4295
|
+
const group = groups.get(project.key);
|
|
4296
|
+
if (group)
|
|
4297
|
+
group.entries.push(entry);
|
|
4298
|
+
else
|
|
4299
|
+
groups.set(project.key, { project, entries: [entry] });
|
|
4300
|
+
}
|
|
4301
|
+
const orderedGroups = [...groups.values()]
|
|
4302
|
+
.map((group) => ({
|
|
4303
|
+
...group,
|
|
4304
|
+
current: group.entries.some((entry) => entry.current),
|
|
4305
|
+
active: group.entries.some((entry) => entry.active),
|
|
4306
|
+
updatedAt: Math.max(...group.entries.map((entry) => entry.updatedAt)),
|
|
4307
|
+
}))
|
|
4308
|
+
.sort((left, right) => Number(right.current) - Number(left.current)
|
|
4309
|
+
|| Number(right.active) - Number(left.active)
|
|
4310
|
+
|| right.updatedAt - left.updatedAt
|
|
4311
|
+
|| left.project.title.localeCompare(right.project.title, undefined, { sensitivity: "base" }));
|
|
4312
|
+
return orderedGroups.flatMap((group) => group.entries.sort(compareTaskListEntries));
|
|
4313
|
+
}
|
|
4314
|
+
function compareTaskListEntries(left, right) {
|
|
4315
|
+
return Number(right.current) - Number(left.current)
|
|
4316
|
+
|| Number(right.active) - Number(left.active)
|
|
4317
|
+
|| right.updatedAt - left.updatedAt
|
|
4318
|
+
|| left.title.localeCompare(right.title, undefined, { sensitivity: "base" });
|
|
4319
|
+
}
|
|
4320
|
+
function taskProjectInfo(entry) {
|
|
4321
|
+
if (!entry.cwd)
|
|
4322
|
+
return { key: "unknown", title: "📁 目录未知" };
|
|
4323
|
+
if (detectProjectlessWorkspace(entry.cwd)) {
|
|
4324
|
+
return { key: "projectless", title: "🗒️ 未指定项目" };
|
|
4325
|
+
}
|
|
4326
|
+
const windowsPath = isWindowsAbsolutePath(entry.cwd);
|
|
4327
|
+
const normalized = windowsPath
|
|
4328
|
+
? path.win32.normalize(entry.cwd).toLowerCase()
|
|
4329
|
+
: path.resolve(entry.cwd);
|
|
4330
|
+
return {
|
|
4331
|
+
key: `cwd:${normalized}`,
|
|
4332
|
+
title: `📁 ${abbreviateHomeDirectory(entry.cwd)}`,
|
|
4333
|
+
};
|
|
4334
|
+
}
|
|
3160
4335
|
function mergeTaskList(localSessions, remoteSessions, currentLocalSessionId) {
|
|
3161
4336
|
const localByRemoteId = new Map(localSessions
|
|
3162
4337
|
.filter((session) => session.runtimeKind === "codex" && session.remoteSessionId)
|
|
@@ -3246,6 +4421,12 @@ function turnViewSnapshot(value) {
|
|
|
3246
4421
|
? candidate
|
|
3247
4422
|
: undefined;
|
|
3248
4423
|
}
|
|
4424
|
+
function isTerminalTurnViewStatus(status) {
|
|
4425
|
+
return status === "completed" || status === "cancelled" || status === "failed";
|
|
4426
|
+
}
|
|
4427
|
+
function isIncompleteTurnAttemptStatus(status) {
|
|
4428
|
+
return status === "accepted" || status === "running" || status === "recovering";
|
|
4429
|
+
}
|
|
3249
4430
|
function executionDetailLines(record, snapshot, remote, activeTurnId, queued) {
|
|
3250
4431
|
const remoteActive = isRemoteSessionActive(remote);
|
|
3251
4432
|
const turnId = remoteActive
|
|
@@ -3519,7 +4700,7 @@ function isBotOwnedActiveTurn(record, remote) {
|
|
|
3519
4700
|
&& record.lastTurnId === remote.lastTurnId;
|
|
3520
4701
|
}
|
|
3521
4702
|
function isQueueIndependentCommand(command) {
|
|
3522
|
-
if (["stop", "status", "restart", "help", "sessions", "goal", "nosteer"].includes(command.type))
|
|
4703
|
+
if (["archive", "stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type))
|
|
3523
4704
|
return true;
|
|
3524
4705
|
if (command.type === "agent")
|
|
3525
4706
|
return command.agent === undefined;
|
|
@@ -3531,12 +4712,200 @@ function parseSessionPage(value) {
|
|
|
3531
4712
|
const parsed = typeof value === "string" || typeof value === "number" ? Number(value) : Number.NaN;
|
|
3532
4713
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
3533
4714
|
}
|
|
4715
|
+
function directoryPageValue(value) {
|
|
4716
|
+
const page = Number(value);
|
|
4717
|
+
if (!Number.isSafeInteger(page) || page < 0)
|
|
4718
|
+
throw new Error("文件浏览卡片页码无效,请重新发送 /dir。");
|
|
4719
|
+
return page;
|
|
4720
|
+
}
|
|
4721
|
+
function isWindowsDriveRoot(directory) {
|
|
4722
|
+
return /^[a-z]:\\$/iu.test(path.win32.normalize(directory));
|
|
4723
|
+
}
|
|
4724
|
+
function directoryBrowserEntryKind(entry) {
|
|
4725
|
+
if (entry.isDirectory())
|
|
4726
|
+
return "directory";
|
|
4727
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
4728
|
+
if (IMAGE_FILE_EXTENSIONS.has(extension))
|
|
4729
|
+
return "image";
|
|
4730
|
+
if (BINARY_FILE_EXTENSIONS.has(extension))
|
|
4731
|
+
return "binary";
|
|
4732
|
+
return "file";
|
|
4733
|
+
}
|
|
4734
|
+
function directoryActionPath(value) {
|
|
4735
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
4736
|
+
throw new Error("文件浏览卡片中的目录无效,请重新发送 /dir。");
|
|
4737
|
+
}
|
|
4738
|
+
return value === WINDOWS_DRIVES_DIRECTORY ? value : path.resolve(value);
|
|
4739
|
+
}
|
|
4740
|
+
function directoryFileActionPath(value) {
|
|
4741
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
4742
|
+
throw new Error("文件浏览卡片中的文件无效,请重新发送 /dir。");
|
|
4743
|
+
}
|
|
4744
|
+
return path.resolve(value);
|
|
4745
|
+
}
|
|
4746
|
+
function directoryCreationActions(directory, contextKey) {
|
|
4747
|
+
return [
|
|
4748
|
+
{
|
|
4749
|
+
text: "New",
|
|
4750
|
+
value: {
|
|
4751
|
+
action: "directory_new",
|
|
4752
|
+
directory,
|
|
4753
|
+
contextKey,
|
|
4754
|
+
},
|
|
4755
|
+
},
|
|
4756
|
+
{
|
|
4757
|
+
text: "NewGroup",
|
|
4758
|
+
value: {
|
|
4759
|
+
action: "directory_new_group",
|
|
4760
|
+
directory,
|
|
4761
|
+
contextKey,
|
|
4762
|
+
},
|
|
4763
|
+
},
|
|
4764
|
+
];
|
|
4765
|
+
}
|
|
4766
|
+
function directoryPaginationActions(directory, page, totalPages, contextKey) {
|
|
4767
|
+
return [
|
|
4768
|
+
...(page > 0 ? [{
|
|
4769
|
+
text: "Previous",
|
|
4770
|
+
value: {
|
|
4771
|
+
action: "directory_page",
|
|
4772
|
+
directory,
|
|
4773
|
+
page: String(page - 1),
|
|
4774
|
+
contextKey,
|
|
4775
|
+
},
|
|
4776
|
+
}] : []),
|
|
4777
|
+
...(page < totalPages - 1 ? [{
|
|
4778
|
+
text: "Next",
|
|
4779
|
+
value: {
|
|
4780
|
+
action: "directory_page",
|
|
4781
|
+
directory,
|
|
4782
|
+
page: String(page + 1),
|
|
4783
|
+
contextKey,
|
|
4784
|
+
},
|
|
4785
|
+
}] : []),
|
|
4786
|
+
];
|
|
4787
|
+
}
|
|
4788
|
+
function windowsDriveDisplayName(drive) {
|
|
4789
|
+
const driveLetter = drive.root.slice(0, 2).toUpperCase();
|
|
4790
|
+
const label = drive.label?.trim() || windowsDriveTypeLabel(drive.driveType);
|
|
4791
|
+
return `${label} (${driveLetter})`;
|
|
4792
|
+
}
|
|
4793
|
+
function windowsDriveTypeLabel(driveType) {
|
|
4794
|
+
switch (driveType?.toLowerCase()) {
|
|
4795
|
+
case "fixed": return "本地磁盘";
|
|
4796
|
+
case "network": return "网络驱动器";
|
|
4797
|
+
case "removable": return "可移动磁盘";
|
|
4798
|
+
case "cdrom": return "光驱";
|
|
4799
|
+
case "ram": return "RAM 磁盘";
|
|
4800
|
+
default: return "磁盘";
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
async function listWindowsDriveRoots() {
|
|
4804
|
+
if (process.platform !== "win32")
|
|
4805
|
+
throw new Error("磁盘列表仅在 Windows 上可用。");
|
|
4806
|
+
const result = await executeShellCommand("$drives = @([System.IO.DriveInfo]::GetDrives() | ForEach-Object { $label = ''; if ($_.IsReady) { try { $label = $_.VolumeLabel } catch {} }; [PSCustomObject]@{ root = $_.Name; label = $label; driveType = $_.DriveType.ToString() } }); ConvertTo-Json -Compress -InputObject $drives", os.homedir(), { timeoutMs: 10_000, maxOutputBytes: 16 * 1024, platform: "win32" });
|
|
4807
|
+
if (result.timedOut || result.exitCode !== 0) {
|
|
4808
|
+
const detail = result.stderr.trim() || `退出码 ${result.exitCode ?? "未知"}`;
|
|
4809
|
+
throw new Error(`无法读取 Windows 磁盘列表:${detail}`);
|
|
4810
|
+
}
|
|
4811
|
+
let parsed;
|
|
4812
|
+
try {
|
|
4813
|
+
parsed = JSON.parse(result.stdout.trim().replace(/^\uFEFF/u, ""));
|
|
4814
|
+
}
|
|
4815
|
+
catch (error) {
|
|
4816
|
+
throw new Error(`无法解析 Windows 磁盘列表:${runtimeErrorMessage(error)}`);
|
|
4817
|
+
}
|
|
4818
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
4819
|
+
const drives = values.flatMap((value) => {
|
|
4820
|
+
if (typeof value !== "object" || value === null)
|
|
4821
|
+
return [];
|
|
4822
|
+
const record = value;
|
|
4823
|
+
if (typeof record.root !== "string")
|
|
4824
|
+
return [];
|
|
4825
|
+
const root = path.win32.normalize(record.root.trim());
|
|
4826
|
+
if (!isWindowsDriveRoot(root))
|
|
4827
|
+
return [];
|
|
4828
|
+
return [{
|
|
4829
|
+
root,
|
|
4830
|
+
...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
|
|
4831
|
+
...(typeof record.driveType === "string" ? { driveType: record.driveType } : {}),
|
|
4832
|
+
}];
|
|
4833
|
+
}).sort((left, right) => left.root.localeCompare(right.root, undefined, { sensitivity: "base" }));
|
|
4834
|
+
if (drives.length === 0)
|
|
4835
|
+
throw new Error("没有检测到可用的 Windows 磁盘。");
|
|
4836
|
+
return drives;
|
|
4837
|
+
}
|
|
3534
4838
|
function isPromptCommand(command) {
|
|
3535
4839
|
return command.type === "prompt" || command.type === "nosteer";
|
|
3536
4840
|
}
|
|
3537
4841
|
function runtimePrompt(text, localImagePaths) {
|
|
3538
4842
|
return localImagePaths?.length ? { text, localImagePaths } : text;
|
|
3539
4843
|
}
|
|
4844
|
+
function recoveryCardPrompt(prompt) {
|
|
4845
|
+
return `恢复重启前的任务:${prompt}`;
|
|
4846
|
+
}
|
|
4847
|
+
function recoveryRuntimePrompt(prompt) {
|
|
4848
|
+
return [
|
|
4849
|
+
"Agent Bot restarted while the previous turn was still running.",
|
|
4850
|
+
"Continue the unfinished task in the current task and workspace.",
|
|
4851
|
+
"Inspect the existing conversation, files, and completed tool effects first. Do not repeat completed or irreversible actions.",
|
|
4852
|
+
"Finish the original request:",
|
|
4853
|
+
"",
|
|
4854
|
+
prompt,
|
|
4855
|
+
].join("\n");
|
|
4856
|
+
}
|
|
4857
|
+
function llmRetryCardPrompt(prompt, retryNumber) {
|
|
4858
|
+
return `自动重试 ${retryNumber}/${MAX_LLM_TURN_RETRIES}:${prompt}`;
|
|
4859
|
+
}
|
|
4860
|
+
function llmRetryRuntimePrompt(prompt, retryNumber) {
|
|
4861
|
+
return [
|
|
4862
|
+
`Agent Bot is automatically retrying this request after a transient LLM service failure (${retryNumber}/${MAX_LLM_TURN_RETRIES}).`,
|
|
4863
|
+
"Continue in the current task and workspace. Inspect the conversation, files, and completed tool effects before acting.",
|
|
4864
|
+
"Do not repeat completed or irreversible actions. Finish the original request:",
|
|
4865
|
+
"",
|
|
4866
|
+
prompt,
|
|
4867
|
+
].join("\n");
|
|
4868
|
+
}
|
|
4869
|
+
function isRetryableLlmTurnFailure(message) {
|
|
4870
|
+
const normalized = message.trim().toLowerCase();
|
|
4871
|
+
if (!normalized)
|
|
4872
|
+
return false;
|
|
4873
|
+
const permanentFailure = [
|
|
4874
|
+
/auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
|
|
4875
|
+
/insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
|
|
4876
|
+
/context (?:length|window)|maximum context|too many tokens|token limit/u,
|
|
4877
|
+
/invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,
|
|
4878
|
+
/model (?:not found|does not exist)|unknown model/u,
|
|
4879
|
+
/permission|approval|sandbox|policy violation|content policy/u,
|
|
4880
|
+
].some((pattern) => pattern.test(normalized));
|
|
4881
|
+
if (permanentFailure)
|
|
4882
|
+
return false;
|
|
4883
|
+
return [
|
|
4884
|
+
/(?:^|\D)429(?:\D|$)|rate[_ -]?limit|too many requests/u,
|
|
4885
|
+
/overload|high demand|at capacity|capacity limit|temporar(?:y|ily) unavailable/u,
|
|
4886
|
+
/service unavailable|internal server error|bad gateway|gateway timeout/u,
|
|
4887
|
+
/(?:^|\D)(?:500|502|503|504)(?:\D|$)|upstream (?:error|failure|timeout)/u,
|
|
4888
|
+
/deadline exceeded|request timed out|request timeout|response timeout/u,
|
|
4889
|
+
/connection (?:reset|closed|aborted)|network error|socket hang up/u,
|
|
4890
|
+
/stream (?:closed|disconnected|interrupted|error)/u,
|
|
4891
|
+
/\b(?:llm|model|provider|inference)\b.*\b(?:error|failed|failure|unavailable|timeout|timed out)\b/u,
|
|
4892
|
+
].some((pattern) => pattern.test(normalized));
|
|
4893
|
+
}
|
|
4894
|
+
function remoteTurnMatchesAttempt(remote, attempt) {
|
|
4895
|
+
if (!remote?.lastTurnId)
|
|
4896
|
+
return false;
|
|
4897
|
+
if (attempt.turnId)
|
|
4898
|
+
return remote.lastTurnId === attempt.turnId;
|
|
4899
|
+
const remoteUpdatedAt = remote.updatedAt === undefined ? undefined : normalizeRemoteTimestamp(remote.updatedAt);
|
|
4900
|
+
const acceptedAt = Date.parse(attempt.createdAt);
|
|
4901
|
+
return remoteUpdatedAt !== undefined
|
|
4902
|
+
&& Number.isFinite(acceptedAt)
|
|
4903
|
+
&& remoteUpdatedAt >= acceptedAt - 1_000;
|
|
4904
|
+
}
|
|
4905
|
+
function isRecoveryAttemptRecent(attempt, cutoff) {
|
|
4906
|
+
const updatedAt = Date.parse(attempt.updatedAt);
|
|
4907
|
+
return Number.isFinite(updatedAt) && updatedAt >= cutoff;
|
|
4908
|
+
}
|
|
3540
4909
|
function formatGroupDateSuffix(date) {
|
|
3541
4910
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
3542
4911
|
const day = String(date.getDate()).padStart(2, "0");
|
|
@@ -3567,12 +4936,21 @@ function formatGroupProjectDirectory(projectCwd) {
|
|
|
3567
4936
|
return `[${fitTrailingPathLevels(levels, GROUP_PROJECT_DIRECTORY_MAX_LENGTH, path.sep)}]`;
|
|
3568
4937
|
}
|
|
3569
4938
|
function abbreviateHomeDirectory(value) {
|
|
3570
|
-
const
|
|
4939
|
+
const homeDirectory = os.homedir();
|
|
4940
|
+
const valueIsWindows = isWindowsAbsolutePath(value);
|
|
4941
|
+
const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
|
|
4942
|
+
if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value)))
|
|
4943
|
+
return value;
|
|
4944
|
+
const pathApi = valueIsWindows ? path.win32 : path;
|
|
4945
|
+
const relative = pathApi.relative(homeDirectory, value);
|
|
3571
4946
|
if (relative === "")
|
|
3572
4947
|
return "~";
|
|
3573
|
-
if (
|
|
4948
|
+
if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`))
|
|
3574
4949
|
return value;
|
|
3575
|
-
return `~${
|
|
4950
|
+
return `~${pathApi.sep}${relative}`;
|
|
4951
|
+
}
|
|
4952
|
+
function isWindowsAbsolutePath(value) {
|
|
4953
|
+
return /^[a-z]:[\\/]|^\\\\/iu.test(value);
|
|
3576
4954
|
}
|
|
3577
4955
|
function fitTrailingPathLevels(levels, maxLength, separator) {
|
|
3578
4956
|
if (levels.length === 0)
|