@keyou007/agent-bot 0.1.13 → 0.1.14-alpha.1
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 +17 -0
- package/README.md +41 -44
- package/README.zh.md +41 -44
- package/config.example.yaml +4 -0
- package/dist/codex/CodexRuntime.js +8 -3
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/commands/CommandRouter.js +13 -0
- 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 +428 -9
- 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/FeishuMessageClient.js +78 -8
- package/dist/feishu/FeishuMessageClient.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +31 -2
- 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 +14 -17
- package/dist/index.js.map +1 -1
- package/dist/presentation/OutboundRouter.js +19 -1
- package/dist/presentation/OutboundRouter.js.map +1 -1
- package/dist/presentation/splitMarkdown.js +16 -4
- package/dist/presentation/splitMarkdown.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +1012 -58
- 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 +250 -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 +18 -5
- package/docs/technical-reference.zh.md +18 -5
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +8 -2
- package/src/codex/CodexRuntime.ts +9 -3
- package/src/commands/CommandRouter.ts +13 -0
- package/src/commands/commandTypes.ts +2 -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 +533 -9
- package/src/feishu/FeishuConnector.ts +44 -10
- package/src/feishu/FeishuMessageClient.ts +118 -7
- package/src/feishu/FeishuTurnPresenter.ts +42 -3
- package/src/feishu/ownerAccess.ts +7 -0
- package/src/feishu/types.ts +7 -0
- package/src/index.ts +20 -20
- package/src/presentation/OutboundRouter.ts +28 -3
- package/src/presentation/splitMarkdown.ts +26 -3
- package/src/proxy/ProxySessionController.ts +1177 -57
- package/src/startup/StartupNotifier.ts +67 -17
- package/src/startup/startFeishu.ts +5 -3
- package/src/state/StateStore.ts +394 -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, type Dirent } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import type { Logger } from "pino";
|
|
@@ -16,13 +17,16 @@ import type {
|
|
|
16
17
|
import {
|
|
17
18
|
CardRenderer,
|
|
18
19
|
type CardSection,
|
|
20
|
+
type DirectoryBrowserCardEntry,
|
|
19
21
|
type ExecutionSettingsTab,
|
|
20
22
|
type HelpCardSection,
|
|
21
23
|
type ResetHistoryCardEntry,
|
|
24
|
+
type SessionTaskCardEntry,
|
|
25
|
+
type SessionTaskCardGroup,
|
|
22
26
|
type TaskListCardAction,
|
|
23
|
-
type TaskListCardEntry,
|
|
24
27
|
} from "../feishu/CardRenderer.js";
|
|
25
28
|
import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
|
|
29
|
+
import { allowsFeishuUser } from "../feishu/ownerAccess.js";
|
|
26
30
|
import type { OutboundRouter } from "../presentation/OutboundRouter.js";
|
|
27
31
|
import type { TurnActivity, TurnViewState } from "../presentation/turnViewTypes.js";
|
|
28
32
|
import { buildTurnGraphRows } from "../presentation/turnGraph.js";
|
|
@@ -44,6 +48,7 @@ import {
|
|
|
44
48
|
type MessageReactionStatus,
|
|
45
49
|
type QueuedPromptRecord,
|
|
46
50
|
type SessionRecord,
|
|
51
|
+
type TurnAttemptRecord,
|
|
47
52
|
type TurnAnchorRecord,
|
|
48
53
|
} from "../state/StateStore.js";
|
|
49
54
|
import { createId } from "../utils/id.js";
|
|
@@ -60,13 +65,34 @@ interface LoadedSession {
|
|
|
60
65
|
session: RuntimeSession;
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
interface StartTurnOptions {
|
|
69
|
+
attemptId?: string;
|
|
70
|
+
messageId?: string;
|
|
71
|
+
displayPrompt?: string;
|
|
72
|
+
preserveAttemptOnFailure?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
const MESSAGE_RECEIVED_REACTION = "OnIt";
|
|
64
76
|
const MESSAGE_COMPLETED_REACTION = "DONE";
|
|
65
77
|
const MESSAGE_FAILED_REACTION = "ERROR";
|
|
66
78
|
const MESSAGE_CANCELLED_REACTION = "CrossMark";
|
|
67
79
|
const SESSION_PAGE_SIZE = 5;
|
|
80
|
+
const DIRECTORY_PAGE_SIZE = 15;
|
|
68
81
|
const RESET_HISTORY_PAGE_SIZE = 10;
|
|
82
|
+
const MAX_LLM_TURN_RETRIES = 3;
|
|
83
|
+
const RECOVERY_ACTIVITY_WINDOW_MS = 5 * 60 * 1_000;
|
|
84
|
+
const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
|
|
69
85
|
const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
|
|
86
|
+
const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
|
|
87
|
+
const IMAGE_FILE_EXTENSIONS = new Set([
|
|
88
|
+
".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp",
|
|
89
|
+
]);
|
|
90
|
+
const BINARY_FILE_EXTENSIONS = new Set([
|
|
91
|
+
".7z", ".a", ".apk", ".avi", ".bin", ".bz2", ".class", ".dat", ".db", ".deb", ".dll", ".dmg", ".doc", ".docx",
|
|
92
|
+
".dylib", ".eot", ".exe", ".flac", ".gz", ".ico", ".ipa", ".iso", ".jar", ".lib", ".mdb", ".mkv", ".mov", ".mp3",
|
|
93
|
+
".mp4", ".msi", ".o", ".obj", ".ogg", ".otf", ".pdf", ".ppt", ".pptx", ".rar", ".rpm", ".so", ".sqlite", ".tar",
|
|
94
|
+
".ttf", ".wav", ".webm", ".woff", ".woff2", ".xls", ".xlsx", ".xz", ".zip", ".zst",
|
|
95
|
+
]);
|
|
70
96
|
|
|
71
97
|
interface HelpCommandDefinition {
|
|
72
98
|
command: string;
|
|
@@ -92,6 +118,11 @@ const HELP_COMMAND_SECTIONS: Array<{
|
|
|
92
118
|
usage: "[title] [--dir <cwd> | --nodir]",
|
|
93
119
|
description: "创建飞书群和新任务",
|
|
94
120
|
},
|
|
121
|
+
{
|
|
122
|
+
command: "/dir",
|
|
123
|
+
usage: "[目录]",
|
|
124
|
+
description: "浏览、发送文件,并从选定目录创建任务或群",
|
|
125
|
+
},
|
|
95
126
|
{
|
|
96
127
|
command: "/forkgroup",
|
|
97
128
|
usage: "[title]",
|
|
@@ -174,6 +205,11 @@ const HELP_COMMAND_SECTIONS: Array<{
|
|
|
174
205
|
usage: "[--force]",
|
|
175
206
|
description: "默认安全重启;--force 立即重启",
|
|
176
207
|
},
|
|
208
|
+
{
|
|
209
|
+
command: "/mute",
|
|
210
|
+
usage: "[on|off]",
|
|
211
|
+
description: "开启或关闭当前群的仅 @ 响应模式",
|
|
212
|
+
},
|
|
177
213
|
{ command: "/help", description: "显示本帮助" },
|
|
178
214
|
],
|
|
179
215
|
},
|
|
@@ -202,6 +238,11 @@ interface SessionsCardOptions {
|
|
|
202
238
|
page?: number;
|
|
203
239
|
}
|
|
204
240
|
|
|
241
|
+
interface DirectoryBrowserOptions {
|
|
242
|
+
updateMessageId?: string;
|
|
243
|
+
page?: number;
|
|
244
|
+
}
|
|
245
|
+
|
|
205
246
|
interface StatusCardOptions {
|
|
206
247
|
updateMessageId?: string;
|
|
207
248
|
forceSwitchTaskId?: string;
|
|
@@ -301,6 +342,13 @@ export interface ProxyLifecycle {
|
|
|
301
342
|
}
|
|
302
343
|
|
|
303
344
|
export type ShellCommandExecutor = (command: string, cwd: string) => Promise<ShellCommandResult>;
|
|
345
|
+
export interface WindowsDriveInfo {
|
|
346
|
+
root: string;
|
|
347
|
+
label?: string;
|
|
348
|
+
driveType?: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export type WindowsDriveLister = () => Promise<WindowsDriveInfo[]>;
|
|
304
352
|
|
|
305
353
|
export class ProxySessionController {
|
|
306
354
|
private readonly router = new CommandRouter();
|
|
@@ -313,7 +361,13 @@ export class ProxySessionController {
|
|
|
313
361
|
private readonly sessionResets = new Map<string, Promise<void>>();
|
|
314
362
|
private readonly lastSessionListings = new Map<string, string[]>();
|
|
315
363
|
private readonly threadInitializations = new Map<string, Promise<void>>();
|
|
364
|
+
private readonly llmRetryingSessions = new Set<string>();
|
|
365
|
+
private readonly retriedFailureTurnIds = new Set<string>();
|
|
366
|
+
private readonly recoveryRetryTimers = new Map<string, NodeJS.Timeout>();
|
|
316
367
|
private readonly unsubscribe: Array<() => void> = [];
|
|
368
|
+
private readonly recoveryActivityHeartbeat: NodeJS.Timeout;
|
|
369
|
+
private startupRecovery?: Promise<void>;
|
|
370
|
+
private queuedPromptsRestored = false;
|
|
317
371
|
|
|
318
372
|
constructor(
|
|
319
373
|
private readonly config: AppConfig,
|
|
@@ -323,18 +377,8 @@ export class ProxySessionController {
|
|
|
323
377
|
private readonly logger: Logger,
|
|
324
378
|
private readonly lifecycle?: ProxyLifecycle,
|
|
325
379
|
private readonly shellCommandExecutor: ShellCommandExecutor = executeShellCommand,
|
|
380
|
+
private readonly windowsDriveLister: WindowsDriveLister = listWindowsDriveRoots,
|
|
326
381
|
) {
|
|
327
|
-
const interruptedAcpSessions = this.store.reconcileInterruptedAcpSessions(
|
|
328
|
-
Object.entries(this.config.agents)
|
|
329
|
-
.filter(([, agent]) => agent.kind === "acp")
|
|
330
|
-
.map(([name]) => name),
|
|
331
|
-
);
|
|
332
|
-
if (interruptedAcpSessions.length > 0) {
|
|
333
|
-
this.logger.warn(
|
|
334
|
-
{ sessionIds: interruptedAcpSessions.map((session) => session.localSessionId) },
|
|
335
|
-
"Marked interrupted ACP sessions as failed after process restart.",
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
382
|
for (const [, runtime] of this.runtimes.entries()) {
|
|
339
383
|
this.unsubscribe.push(
|
|
340
384
|
runtime.onEvent((event) => {
|
|
@@ -345,15 +389,37 @@ export class ProxySessionController {
|
|
|
345
389
|
);
|
|
346
390
|
}
|
|
347
391
|
this.restorePersistedSessionRoutes();
|
|
348
|
-
this.restorePersistedQueuedPrompts();
|
|
349
392
|
void this.restorePersistedMessageReactions().catch((error: unknown) => {
|
|
350
393
|
this.logger.warn({ error }, "Failed to restore persisted message reaction statuses.");
|
|
351
394
|
});
|
|
395
|
+
this.recoveryActivityHeartbeat = setInterval(
|
|
396
|
+
() => this.persistActiveTurnHeartbeats(),
|
|
397
|
+
RECOVERY_HEARTBEAT_INTERVAL_MS,
|
|
398
|
+
);
|
|
399
|
+
this.recoveryActivityHeartbeat.unref?.();
|
|
352
400
|
}
|
|
353
401
|
|
|
354
402
|
async onMessage(message: IncomingMessage): Promise<void> {
|
|
355
|
-
|
|
356
|
-
|
|
403
|
+
if (!message.contextKey.startsWith("console:") && !allowsFeishuUser(this.config, message.userId)) {
|
|
404
|
+
this.logger.debug(
|
|
405
|
+
{ messageId: message.messageId },
|
|
406
|
+
"Ignored a Feishu message from a non-owner before acknowledgement.",
|
|
407
|
+
);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (
|
|
411
|
+
message.chatType === "group"
|
|
412
|
+
&& this.store.chatRequiresMention(baseChatContextKey(message.contextKey))
|
|
413
|
+
&& message.mentionedBot !== true
|
|
414
|
+
) {
|
|
415
|
+
this.logger.debug(
|
|
416
|
+
{ messageId: message.messageId, contextKey: message.contextKey },
|
|
417
|
+
"Ignored an unmentioned message from a muted Lark group.",
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// For accepted messages, claim durable deduplication before acknowledgement so event
|
|
422
|
+
// retries cannot add duplicate reactions.
|
|
357
423
|
if (!this.store.claimInboundEvent(message.messageId, "message")) return;
|
|
358
424
|
try {
|
|
359
425
|
const reactionId = await this.outbound.addReaction(
|
|
@@ -473,6 +539,13 @@ export class ProxySessionController {
|
|
|
473
539
|
}
|
|
474
540
|
|
|
475
541
|
async onCardAction(action: CardAction): Promise<void> {
|
|
542
|
+
if (!allowsFeishuUser(this.config, action.userId)) {
|
|
543
|
+
this.logger.debug(
|
|
544
|
+
{ actionId: action.actionId },
|
|
545
|
+
"Ignored a Feishu card action from a non-owner.",
|
|
546
|
+
);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
476
549
|
if (!this.store.claimInboundEvent(action.actionId, "card_action")) return;
|
|
477
550
|
const contextKey = this.cardActionContextKey(action);
|
|
478
551
|
const scopedAction = contextKey === action.contextKey ? action : { ...action, contextKey };
|
|
@@ -533,6 +606,32 @@ export class ProxySessionController {
|
|
|
533
606
|
if (!cancelled) {
|
|
534
607
|
await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
|
|
535
608
|
}
|
|
609
|
+
} else if (kind === "directory_open" || kind === "directory_page") {
|
|
610
|
+
await this.openDirectoryBrowser(
|
|
611
|
+
contextKey,
|
|
612
|
+
directoryActionPath(scopedAction.value.directory),
|
|
613
|
+
{
|
|
614
|
+
updateMessageId: requiredCardMessageId(scopedAction.messageId),
|
|
615
|
+
page: kind === "directory_page" ? directoryPageValue(scopedAction.value.page) : 0,
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
} else if (kind === "directory_send_file") {
|
|
619
|
+
const filePath = directoryFileActionPath(scopedAction.value.filePath);
|
|
620
|
+
await this.assertSendableFile(filePath);
|
|
621
|
+
await this.outbound.sendFile(contextKey, filePath);
|
|
622
|
+
} else if (kind === "directory_new" || kind === "directory_new_group") {
|
|
623
|
+
const directory = directoryActionPath(scopedAction.value.directory);
|
|
624
|
+
await this.assertBrowsableDirectory(directory);
|
|
625
|
+
await this.execute(
|
|
626
|
+
contextKey,
|
|
627
|
+
kind === "directory_new"
|
|
628
|
+
? { type: "new", cwd: directory }
|
|
629
|
+
: { type: "newgroup", cwd: directory },
|
|
630
|
+
undefined,
|
|
631
|
+
replyTarget,
|
|
632
|
+
undefined,
|
|
633
|
+
scopedAction.userId,
|
|
634
|
+
);
|
|
536
635
|
} else if (kind === "session_page") {
|
|
537
636
|
await this.refreshSessionsCardFromAction(scopedAction);
|
|
538
637
|
} else if (kind === "session_switch") {
|
|
@@ -692,6 +791,9 @@ export class ProxySessionController {
|
|
|
692
791
|
this.queuedPromptCards.clear();
|
|
693
792
|
this.queuedPromptCardWrites.clear();
|
|
694
793
|
this.sessionResets.clear();
|
|
794
|
+
for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
|
|
795
|
+
this.recoveryRetryTimers.clear();
|
|
796
|
+
clearInterval(this.recoveryActivityHeartbeat);
|
|
695
797
|
}
|
|
696
798
|
|
|
697
799
|
async controlStopTask(localSessionId: string): Promise<string> {
|
|
@@ -916,6 +1018,9 @@ export class ProxySessionController {
|
|
|
916
1018
|
case "shell":
|
|
917
1019
|
await this.runShellCommand(contextKey, command.command);
|
|
918
1020
|
return;
|
|
1021
|
+
case "dir":
|
|
1022
|
+
await this.openDirectoryBrowser(contextKey, command.directory);
|
|
1023
|
+
return;
|
|
919
1024
|
case "new":
|
|
920
1025
|
if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
|
|
921
1026
|
throw new Error("/new --nodir 仅支持 App Server Agent。");
|
|
@@ -984,6 +1089,9 @@ export class ProxySessionController {
|
|
|
984
1089
|
if (!this.lifecycle) throw new Error("当前运行方式不支持自动重启。");
|
|
985
1090
|
await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
|
|
986
1091
|
return;
|
|
1092
|
+
case "mute":
|
|
1093
|
+
await this.setGroupMute(contextKey, command.enabled);
|
|
1094
|
+
return;
|
|
987
1095
|
case "turns":
|
|
988
1096
|
await this.openResetHistory(contextKey);
|
|
989
1097
|
return;
|
|
@@ -1016,6 +1124,7 @@ export class ProxySessionController {
|
|
|
1016
1124
|
const turnContextKey = session.lastTurnId
|
|
1017
1125
|
? this.store.getTurnContextKey(session.lastTurnId) ?? context.contextKey
|
|
1018
1126
|
: context.contextKey;
|
|
1127
|
+
if (!this.outbound.canRoute(turnContextKey)) continue;
|
|
1019
1128
|
this.outbound.registerSession(
|
|
1020
1129
|
session.localSessionId,
|
|
1021
1130
|
turnContextKey,
|
|
@@ -1028,16 +1137,386 @@ export class ProxySessionController {
|
|
|
1028
1137
|
this.logger.warn({ error, sessionId: session.localSessionId }, "Failed to restore persisted turn delivery.");
|
|
1029
1138
|
});
|
|
1030
1139
|
}
|
|
1140
|
+
for (const turn of this.store.listUndeliveredCompletedTurns()) {
|
|
1141
|
+
const contextKey = turn.contextKey ?? this.store.getSession(turn.localSessionId)?.contextKey;
|
|
1142
|
+
if (!contextKey || !this.outbound.canRoute(contextKey)) continue;
|
|
1143
|
+
void this.outbound.resumeDelivery(turn.localSessionId, contextKey, turn.turnId).catch((error: unknown) => {
|
|
1144
|
+
this.logger.warn(
|
|
1145
|
+
{ error, sessionId: turn.localSessionId, turnId: turn.turnId },
|
|
1146
|
+
"Failed to resume an undelivered completed turn.",
|
|
1147
|
+
);
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1031
1150
|
}
|
|
1032
1151
|
|
|
1033
1152
|
private restorePersistedQueuedPrompts(): void {
|
|
1153
|
+
if (this.queuedPromptsRestored) return;
|
|
1154
|
+
this.queuedPromptsRestored = true;
|
|
1034
1155
|
for (const sessionId of this.store.listQueuedPromptSessionIds()) {
|
|
1156
|
+
const firstPrompt = this.store.listQueuedPrompts(sessionId)[0];
|
|
1157
|
+
if (firstPrompt && !this.outbound.canRoute(firstPrompt.contextKey)) continue;
|
|
1035
1158
|
void this.scheduleNextQueuedPrompt(sessionId).catch((error: unknown) => {
|
|
1036
1159
|
this.logger.warn({ error, sessionId }, "Failed to resume a persisted prompt queue.");
|
|
1037
1160
|
});
|
|
1038
1161
|
}
|
|
1039
1162
|
}
|
|
1040
1163
|
|
|
1164
|
+
async recoverInterruptedTasks(): Promise<void> {
|
|
1165
|
+
if (this.startupRecovery) return this.startupRecovery;
|
|
1166
|
+
const recovery = this.runStartupRecovery();
|
|
1167
|
+
this.startupRecovery = recovery;
|
|
1168
|
+
return recovery;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
private async runStartupRecovery(): Promise<void> {
|
|
1172
|
+
this.backfillTurnAttemptsForUpgrade();
|
|
1173
|
+
const attempts = this.store.listIncompleteTurnAttempts();
|
|
1174
|
+
const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
|
|
1175
|
+
const recentAttempts = attempts.filter((attempt) => isRecoveryAttemptRecent(attempt, recoveryCutoff));
|
|
1176
|
+
if (recentAttempts.length > 0) {
|
|
1177
|
+
this.logger.warn(
|
|
1178
|
+
{ attemptIds: recentAttempts.map((attempt) => attempt.attemptId) },
|
|
1179
|
+
"Recovering unfinished Agent Bot tasks after startup.",
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
for (const attempt of attempts) {
|
|
1183
|
+
if (!isRecoveryAttemptRecent(attempt, recoveryCutoff)) {
|
|
1184
|
+
await this.expireStaleTurnAttempt(attempt);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
if (!this.outbound.canRoute(attempt.contextKey)) continue;
|
|
1188
|
+
try {
|
|
1189
|
+
await this.recoverTurnAttempt(attempt);
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
this.logger.error(
|
|
1192
|
+
{ error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId },
|
|
1193
|
+
"Failed to recover an unfinished task; it will be retried while the server is running.",
|
|
1194
|
+
);
|
|
1195
|
+
this.scheduleRecoveryRetry(attempt.attemptId);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
this.restorePersistedQueuedPrompts();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private backfillTurnAttemptsForUpgrade(): void {
|
|
1202
|
+
for (const session of this.store.listAllSessions()) {
|
|
1203
|
+
if (session.status !== "starting" && session.status !== "running" && session.status !== "ready") continue;
|
|
1204
|
+
if (this.store.findIncompleteTurnAttemptForSession(session.localSessionId)) continue;
|
|
1205
|
+
const lastTurn = session.lastTurnId
|
|
1206
|
+
? {
|
|
1207
|
+
turnId: session.lastTurnId,
|
|
1208
|
+
localSessionId: session.localSessionId,
|
|
1209
|
+
contextKey: this.store.getTurnContextKey(session.lastTurnId),
|
|
1210
|
+
snapshot: this.store.getTurnSnapshot(session.lastTurnId),
|
|
1211
|
+
updatedAt: session.updatedAt,
|
|
1212
|
+
}
|
|
1213
|
+
: undefined;
|
|
1214
|
+
const lastTurnSnapshot = turnViewSnapshot(lastTurn?.snapshot);
|
|
1215
|
+
const latestPersisted = this.store.findLatestTurnSnapshotForSession(session.localSessionId);
|
|
1216
|
+
const latestPersistedSnapshot = turnViewSnapshot(latestPersisted?.snapshot);
|
|
1217
|
+
const latest = lastTurnSnapshot && !isTerminalTurnViewStatus(lastTurnSnapshot.status)
|
|
1218
|
+
? lastTurn
|
|
1219
|
+
: latestPersistedSnapshot && !isTerminalTurnViewStatus(latestPersistedSnapshot.status)
|
|
1220
|
+
? latestPersisted
|
|
1221
|
+
: session.status === "ready"
|
|
1222
|
+
? undefined
|
|
1223
|
+
: lastTurn;
|
|
1224
|
+
if (!latest && session.status === "ready") continue;
|
|
1225
|
+
const snapshot = turnViewSnapshot(latest?.snapshot);
|
|
1226
|
+
if (snapshot && isTerminalTurnViewStatus(snapshot.status)) {
|
|
1227
|
+
this.store.updateSession(session.localSessionId, {
|
|
1228
|
+
status: snapshot.status === "failed" ? "failed" : "ready",
|
|
1229
|
+
});
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
const persistedTurnId = latest?.turnId;
|
|
1233
|
+
const pendingTurnId = persistedTurnId?.startsWith("pending_") ? persistedTurnId : undefined;
|
|
1234
|
+
const turnId = pendingTurnId ? undefined : persistedTurnId ?? session.lastTurnId;
|
|
1235
|
+
const replyTarget = snapshot?.replyTarget?.replyInThread ? snapshot.replyTarget : undefined;
|
|
1236
|
+
this.store.createTurnAttempt({
|
|
1237
|
+
attemptId: createId("attempt"),
|
|
1238
|
+
localSessionId: session.localSessionId,
|
|
1239
|
+
contextKey: latest?.contextKey ?? session.contextKey,
|
|
1240
|
+
promptText: snapshot?.prompt?.trim() || "继续完成重启前尚未完成的任务。",
|
|
1241
|
+
messageId: turnId ? this.store.findMessageIdForTurn(turnId) : undefined,
|
|
1242
|
+
replyMessageId: replyTarget?.messageId,
|
|
1243
|
+
pendingTurnId,
|
|
1244
|
+
turnId,
|
|
1245
|
+
status: turnId ? "running" : "accepted",
|
|
1246
|
+
createdAt: latest?.updatedAt ?? session.updatedAt,
|
|
1247
|
+
updatedAt: latest?.updatedAt ?? session.updatedAt,
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
private async expireStaleTurnAttempt(attempt: TurnAttemptRecord): Promise<void> {
|
|
1253
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1254
|
+
const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
|
|
1255
|
+
if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
|
|
1256
|
+
await this.outbound.interruptTurnForRecovery(
|
|
1257
|
+
session.localSessionId,
|
|
1258
|
+
attempt.contextKey,
|
|
1259
|
+
oldCardTurnId,
|
|
1260
|
+
"执行中断已超过 5 分钟,未自动恢复。",
|
|
1261
|
+
).catch((error: unknown) => {
|
|
1262
|
+
this.logger.warn(
|
|
1263
|
+
{ error, attemptId: attempt.attemptId, turnId: oldCardTurnId },
|
|
1264
|
+
"Failed to close an expired interrupted thinking card.",
|
|
1265
|
+
);
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
|
|
1269
|
+
if (!session || session.status === "closed") return;
|
|
1270
|
+
if (attempt.turnId && session.lastTurnId === attempt.turnId) {
|
|
1271
|
+
this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
|
|
1272
|
+
}
|
|
1273
|
+
if (session.status === "starting" || session.status === "running") {
|
|
1274
|
+
this.store.updateSession(session.localSessionId, { status: "ready" });
|
|
1275
|
+
}
|
|
1276
|
+
if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
|
|
1277
|
+
await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error: unknown) => {
|
|
1278
|
+
this.logger.warn(
|
|
1279
|
+
{ error, attemptId: attempt.attemptId, turnId: attempt.turnId },
|
|
1280
|
+
"Failed to finalize the message reaction for an expired interrupted turn.",
|
|
1281
|
+
);
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
private persistActiveTurnHeartbeats(): void {
|
|
1287
|
+
for (const attempt of this.store.listIncompleteTurnAttempts()) {
|
|
1288
|
+
if (!attempt.turnId) continue;
|
|
1289
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1290
|
+
if (!session) continue;
|
|
1291
|
+
const activeTurnId = this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId;
|
|
1292
|
+
if (activeTurnId === attempt.turnId) this.store.touchTurnAttempt(attempt.turnId);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
private async recoverTurnAttempt(attempt: TurnAttemptRecord, announce = true): Promise<void> {
|
|
1297
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1298
|
+
if (!session || session.status === "closed") {
|
|
1299
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const contextKey = attempt.contextKey || session.contextKey;
|
|
1303
|
+
const replyTarget = attempt.replyMessageId
|
|
1304
|
+
? { messageId: attempt.replyMessageId, replyInThread: true as const }
|
|
1305
|
+
: undefined;
|
|
1306
|
+
this.outbound.registerSession(
|
|
1307
|
+
session.localSessionId,
|
|
1308
|
+
contextKey,
|
|
1309
|
+
session.title,
|
|
1310
|
+
session.cwd,
|
|
1311
|
+
this.agentLabel(session.agentName),
|
|
1312
|
+
);
|
|
1313
|
+
if (announce) {
|
|
1314
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () =>
|
|
1315
|
+
this.outbound.sendText(contextKey, "检测到任务在 Agent Bot 重启前尚未完成,正在自动恢复。"),
|
|
1316
|
+
).catch((error: unknown) => {
|
|
1317
|
+
this.logger.warn(
|
|
1318
|
+
{ error, attemptId: attempt.attemptId, contextKey },
|
|
1319
|
+
"Failed to send the task recovery notification.",
|
|
1320
|
+
);
|
|
1321
|
+
return undefined;
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const runtime = this.runtimes.forAgent(session.agentName);
|
|
1326
|
+
const sourceTurnId = attempt.turnId;
|
|
1327
|
+
if (runtime.kind === "acp") {
|
|
1328
|
+
await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
let remote: RemoteSessionSummary | undefined;
|
|
1333
|
+
if (session.remoteSessionId && runtime.readRemoteSession) {
|
|
1334
|
+
try {
|
|
1335
|
+
remote = await runtime.readRemoteSession(session.remoteSessionId);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
this.logger.warn(
|
|
1338
|
+
{ error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId },
|
|
1339
|
+
"Failed to read the remote task during startup recovery; treating the old local execution as interrupted.",
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
|
|
1344
|
+
if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "completed") {
|
|
1345
|
+
await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "failed") {
|
|
1349
|
+
await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (remote && remoteTurnBelongsToAttempt && isRemoteSessionActive(remote) && remote.lastTurnId) {
|
|
1353
|
+
await this.reattachActiveRecoveredTurn(attempt, session, remote.lastTurnId, replyTarget);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (remote && isRemoteSessionActive(remote) && !remoteTurnBelongsToAttempt) {
|
|
1357
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "recovering" });
|
|
1358
|
+
if (announce) {
|
|
1359
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () =>
|
|
1360
|
+
this.outbound.sendText(contextKey, "任务中检测到另一轮仍在执行;本次恢复将在它结束后重试。"),
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
this.scheduleRecoveryRetry(attempt.attemptId);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
private async reconcileRecoveredRemoteTurn(
|
|
1370
|
+
attempt: TurnAttemptRecord,
|
|
1371
|
+
session: SessionRecord,
|
|
1372
|
+
remoteTurnId: string | undefined,
|
|
1373
|
+
): Promise<void> {
|
|
1374
|
+
if (remoteTurnId && !attempt.turnId) {
|
|
1375
|
+
this.store.updateTurnAttempt(attempt.attemptId, { turnId: remoteTurnId, status: "running" });
|
|
1376
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
1377
|
+
lastTurnId: remoteTurnId,
|
|
1378
|
+
lastTurnStatus: "running",
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1382
|
+
const loaded = await this.loadSession({
|
|
1383
|
+
...current,
|
|
1384
|
+
contextKey: attempt.contextKey,
|
|
1385
|
+
status: "running",
|
|
1386
|
+
lastTurnStatus: "running",
|
|
1387
|
+
});
|
|
1388
|
+
const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
|
|
1389
|
+
if (synchronized.activeTurnId) this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
private scheduleRecoveryRetry(attemptId: string, activeTurnId?: string): void {
|
|
1393
|
+
if (this.recoveryRetryTimers.has(attemptId)) return;
|
|
1394
|
+
const timer = setTimeout(() => {
|
|
1395
|
+
this.recoveryRetryTimers.delete(attemptId);
|
|
1396
|
+
const attempt = this.store.getTurnAttempt(attemptId);
|
|
1397
|
+
if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status)) return;
|
|
1398
|
+
const retry = async (): Promise<void> => {
|
|
1399
|
+
let recoveryAttempt = attempt;
|
|
1400
|
+
if (activeTurnId) {
|
|
1401
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1402
|
+
if (session) {
|
|
1403
|
+
const runtime = this.runtimes.forAgent(session.agentName);
|
|
1404
|
+
const synchronized = await runtime.synchronizeSession(session.localSessionId);
|
|
1405
|
+
if (synchronized.activeTurnId === activeTurnId) {
|
|
1406
|
+
this.scheduleRecoveryRetry(attemptId, activeTurnId);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const current = this.store.getTurnAttempt(attemptId);
|
|
1410
|
+
if (!current || !isIncompleteTurnAttemptStatus(current.status)) return;
|
|
1411
|
+
recoveryAttempt = current;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
await this.recoverTurnAttempt(recoveryAttempt, false);
|
|
1415
|
+
};
|
|
1416
|
+
void retry().catch((error: unknown) => {
|
|
1417
|
+
this.logger.warn(
|
|
1418
|
+
{ error, attemptId, sessionId: attempt.localSessionId },
|
|
1419
|
+
"Failed to retry an unfinished task recovery.",
|
|
1420
|
+
);
|
|
1421
|
+
this.scheduleRecoveryRetry(attemptId, activeTurnId);
|
|
1422
|
+
});
|
|
1423
|
+
}, 5_000);
|
|
1424
|
+
timer.unref?.();
|
|
1425
|
+
this.recoveryRetryTimers.set(attemptId, timer);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
private async reattachActiveRecoveredTurn(
|
|
1429
|
+
attempt: TurnAttemptRecord,
|
|
1430
|
+
session: SessionRecord,
|
|
1431
|
+
turnId: string,
|
|
1432
|
+
replyTarget?: MessageReplyTarget,
|
|
1433
|
+
): Promise<void> {
|
|
1434
|
+
const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
|
|
1435
|
+
if (oldCardTurnId) {
|
|
1436
|
+
await this.outbound.interruptTurnForRecovery(
|
|
1437
|
+
session.localSessionId,
|
|
1438
|
+
attempt.contextKey,
|
|
1439
|
+
oldCardTurnId,
|
|
1440
|
+
"Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。",
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
const pendingTurnId = await this.outbound.startPendingTurn(
|
|
1444
|
+
session.localSessionId,
|
|
1445
|
+
attempt.contextKey,
|
|
1446
|
+
session.title,
|
|
1447
|
+
replyTarget,
|
|
1448
|
+
recoveryCardPrompt(attempt.promptText),
|
|
1449
|
+
);
|
|
1450
|
+
this.store.updateTurnAttempt(attempt.attemptId, {
|
|
1451
|
+
pendingTurnId: pendingTurnId ?? null,
|
|
1452
|
+
turnId,
|
|
1453
|
+
status: "running",
|
|
1454
|
+
});
|
|
1455
|
+
await this.outbound.onEvent({
|
|
1456
|
+
type: "turn_started",
|
|
1457
|
+
sessionId: session.localSessionId,
|
|
1458
|
+
turnId,
|
|
1459
|
+
startedAt: Date.now(),
|
|
1460
|
+
});
|
|
1461
|
+
this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
|
|
1462
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1463
|
+
const loaded = await this.loadSession({
|
|
1464
|
+
...current,
|
|
1465
|
+
contextKey: attempt.contextKey,
|
|
1466
|
+
status: "running",
|
|
1467
|
+
lastTurnId: turnId,
|
|
1468
|
+
lastTurnStatus: "running",
|
|
1469
|
+
});
|
|
1470
|
+
const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
|
|
1471
|
+
if (synchronized.activeTurnId) this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
private async continueInterruptedAttempt(
|
|
1475
|
+
attempt: TurnAttemptRecord,
|
|
1476
|
+
session: SessionRecord,
|
|
1477
|
+
sourceTurnId: string | undefined,
|
|
1478
|
+
replyTarget?: MessageReplyTarget,
|
|
1479
|
+
): Promise<void> {
|
|
1480
|
+
const oldCardTurnId = attempt.pendingTurnId ?? sourceTurnId;
|
|
1481
|
+
if (oldCardTurnId) {
|
|
1482
|
+
await this.outbound.interruptTurnForRecovery(
|
|
1483
|
+
session.localSessionId,
|
|
1484
|
+
attempt.contextKey,
|
|
1485
|
+
oldCardTurnId,
|
|
1486
|
+
"Agent Bot 重启中断了这次执行,正在新的思考卡片中继续。",
|
|
1487
|
+
).catch((error: unknown) => {
|
|
1488
|
+
this.logger.warn(
|
|
1489
|
+
{ error, attemptId: attempt.attemptId, turnId: oldCardTurnId },
|
|
1490
|
+
"Failed to close the interrupted thinking card before recovery.",
|
|
1491
|
+
);
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
this.store.prepareTurnAttemptRecovery(attempt.attemptId, oldCardTurnId);
|
|
1495
|
+
this.store.updateSession(session.localSessionId, { status: "ready" });
|
|
1496
|
+
if (sourceTurnId) {
|
|
1497
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
1498
|
+
lastTurnId: sourceTurnId,
|
|
1499
|
+
lastTurnStatus: "cancelled",
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1503
|
+
const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
|
|
1504
|
+
const recoveryText = recoveryRuntimePrompt(attempt.promptText);
|
|
1505
|
+
const turnId = await this.startTurn(
|
|
1506
|
+
loaded,
|
|
1507
|
+
recoveryText,
|
|
1508
|
+
replyTarget,
|
|
1509
|
+
attempt.localImagePaths,
|
|
1510
|
+
{
|
|
1511
|
+
attemptId: attempt.attemptId,
|
|
1512
|
+
messageId: attempt.messageId,
|
|
1513
|
+
displayPrompt: recoveryCardPrompt(attempt.promptText),
|
|
1514
|
+
preserveAttemptOnFailure: true,
|
|
1515
|
+
},
|
|
1516
|
+
);
|
|
1517
|
+
if (attempt.messageId) await this.bindMessageReactionToTurn(attempt.messageId, session.localSessionId, turnId);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1041
1520
|
private async restorePersistedMessageReactions(): Promise<void> {
|
|
1042
1521
|
const pending = this.store.listPendingMessageReactions();
|
|
1043
1522
|
const terminalTurns = new Map<string, "completed" | "failed" | "cancelled">();
|
|
@@ -1086,6 +1565,19 @@ export class ProxySessionController {
|
|
|
1086
1565
|
replyTarget?: MessageReplyTarget,
|
|
1087
1566
|
localImagePaths?: string[],
|
|
1088
1567
|
): Promise<void> {
|
|
1568
|
+
if (this.llmRetryingSessions.has(record.localSessionId)) {
|
|
1569
|
+
const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
|
|
1570
|
+
localImagePaths,
|
|
1571
|
+
messageId,
|
|
1572
|
+
replyTarget,
|
|
1573
|
+
});
|
|
1574
|
+
this.store.audit(contextKey, "queued_prompt_added_during_llm_retry", {
|
|
1575
|
+
promptId: queued.promptId,
|
|
1576
|
+
localSessionId: record.localSessionId,
|
|
1577
|
+
});
|
|
1578
|
+
await this.presentPromptQueueCard(record.localSessionId, contextKey);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1089
1581
|
const configuredRuntime = this.runtimes.forAgent(record.agentName);
|
|
1090
1582
|
if (!configuredRuntime.getSession(record.localSessionId)) {
|
|
1091
1583
|
this.outbound.registerSession(
|
|
@@ -1143,7 +1635,7 @@ export class ProxySessionController {
|
|
|
1143
1635
|
this.logger.warn({ error: syncError, sessionId: record.localSessionId }, "Failed to synchronize after steering failure.");
|
|
1144
1636
|
}
|
|
1145
1637
|
if (!current?.activeTurnId) {
|
|
1146
|
-
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
|
|
1638
|
+
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
|
|
1147
1639
|
if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
|
|
1148
1640
|
return;
|
|
1149
1641
|
}
|
|
@@ -1172,7 +1664,7 @@ export class ProxySessionController {
|
|
|
1172
1664
|
return;
|
|
1173
1665
|
}
|
|
1174
1666
|
}
|
|
1175
|
-
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
|
|
1667
|
+
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
|
|
1176
1668
|
if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
|
|
1177
1669
|
}
|
|
1178
1670
|
|
|
@@ -1182,6 +1674,7 @@ export class ProxySessionController {
|
|
|
1182
1674
|
text: string,
|
|
1183
1675
|
messageId?: string,
|
|
1184
1676
|
): Promise<void> {
|
|
1677
|
+
this.store.touchTurnAttempt(turnId);
|
|
1185
1678
|
try {
|
|
1186
1679
|
await this.outbound.appendSteerMessage(localSessionId, turnId, text, messageId);
|
|
1187
1680
|
} catch (error) {
|
|
@@ -1311,6 +1804,9 @@ export class ProxySessionController {
|
|
|
1311
1804
|
if (!message.threadContext || !message.threadId || !message.chatId) return;
|
|
1312
1805
|
const current = this.store.getUserContext(message.contextKey)?.currentSessionId;
|
|
1313
1806
|
if (current && this.store.getSession(current)) return;
|
|
1807
|
+
const hasPersistedSourceTurn = threadForkAnchorMessageIds(message)
|
|
1808
|
+
.some((messageId) => this.store.findTurnAnchorByMessageId(messageId) !== undefined);
|
|
1809
|
+
if (!hasPersistedSourceTurn) return;
|
|
1314
1810
|
|
|
1315
1811
|
const existing = this.threadInitializations.get(message.contextKey);
|
|
1316
1812
|
if (existing) return existing;
|
|
@@ -1401,9 +1897,7 @@ export class ProxySessionController {
|
|
|
1401
1897
|
if (!message.threadContext || !message.threadId || !message.chatId) {
|
|
1402
1898
|
throw new Error("当前消息不属于可识别的飞书话题。");
|
|
1403
1899
|
}
|
|
1404
|
-
const
|
|
1405
|
-
.filter((messageId): messageId is string => Boolean(messageId && messageId !== message.messageId));
|
|
1406
|
-
const anchor = [...new Set(anchorMessageIds)]
|
|
1900
|
+
const anchor = threadForkAnchorMessageIds(message)
|
|
1407
1901
|
.map((messageId) => this.store.findTurnAnchorByMessageId(messageId))
|
|
1408
1902
|
.find((candidate) => candidate !== undefined);
|
|
1409
1903
|
if (!anchor) {
|
|
@@ -1785,31 +2279,60 @@ export class ProxySessionController {
|
|
|
1785
2279
|
text: string,
|
|
1786
2280
|
replyTarget?: MessageReplyTarget,
|
|
1787
2281
|
localImagePaths?: string[],
|
|
2282
|
+
options: StartTurnOptions = {},
|
|
1788
2283
|
): Promise<string> {
|
|
1789
2284
|
const currentRecord = this.store.getSession(loaded.record.localSessionId);
|
|
1790
2285
|
const title = currentRecord?.title ?? normalizeTaskTitle(text);
|
|
1791
2286
|
if (!currentRecord?.title && title) this.store.updateRuntimeSession(loaded.record.localSessionId, { title });
|
|
1792
2287
|
if (title) this.outbound.updateSessionTitle(loaded.record.localSessionId, title);
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
2288
|
+
const attemptId = options.attemptId ?? createId("attempt");
|
|
2289
|
+
if (options.attemptId) {
|
|
2290
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
2291
|
+
messageId: options.messageId ?? undefined,
|
|
2292
|
+
replyMessageId: replyTarget?.messageId ?? undefined,
|
|
2293
|
+
status: "recovering",
|
|
2294
|
+
});
|
|
2295
|
+
} else {
|
|
2296
|
+
this.store.createTurnAttempt({
|
|
2297
|
+
attemptId,
|
|
2298
|
+
localSessionId: loaded.record.localSessionId,
|
|
2299
|
+
contextKey: loaded.record.contextKey,
|
|
2300
|
+
promptText: text,
|
|
2301
|
+
localImagePaths,
|
|
2302
|
+
messageId: options.messageId,
|
|
2303
|
+
replyMessageId: replyTarget?.messageId,
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
1800
2306
|
let turnId: string;
|
|
1801
2307
|
try {
|
|
2308
|
+
const pendingTurnId = await this.outbound.startPendingTurn(
|
|
2309
|
+
loaded.record.localSessionId,
|
|
2310
|
+
loaded.record.contextKey,
|
|
2311
|
+
title,
|
|
2312
|
+
replyTarget,
|
|
2313
|
+
options.displayPrompt ?? text,
|
|
2314
|
+
);
|
|
2315
|
+
this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
|
|
1802
2316
|
turnId = await loaded.runtime.startTurn(
|
|
1803
2317
|
loaded.record.localSessionId,
|
|
1804
2318
|
runtimePrompt(text, localImagePaths),
|
|
1805
2319
|
);
|
|
1806
2320
|
} catch (error) {
|
|
2321
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
2322
|
+
status: options.preserveAttemptOnFailure ? "recovering" : "failed",
|
|
2323
|
+
});
|
|
1807
2324
|
await this.outbound.failPendingTurn(
|
|
1808
2325
|
loaded.record.localSessionId,
|
|
1809
2326
|
error instanceof Error ? error.message : String(error),
|
|
1810
2327
|
);
|
|
1811
2328
|
throw error;
|
|
1812
2329
|
}
|
|
2330
|
+
const attempt = this.store.getTurnAttempt(attemptId);
|
|
2331
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
2332
|
+
pendingTurnId: null,
|
|
2333
|
+
turnId,
|
|
2334
|
+
...(attempt && isIncompleteTurnAttemptStatus(attempt.status) ? { status: "running" as const } : {}),
|
|
2335
|
+
});
|
|
1813
2336
|
const latest = this.store.getSession(loaded.record.localSessionId);
|
|
1814
2337
|
const alreadyTerminal = latest?.lastTurnId === turnId
|
|
1815
2338
|
&& ["completed", "cancelled", "failed"].includes(latest.lastTurnStatus ?? "");
|
|
@@ -2218,6 +2741,7 @@ export class ProxySessionController {
|
|
|
2218
2741
|
|
|
2219
2742
|
private async handleRuntimeEvent(event: RuntimeEvent): Promise<void> {
|
|
2220
2743
|
if ("turnId" in event) {
|
|
2744
|
+
this.store.touchTurnAttempt(event.turnId);
|
|
2221
2745
|
const source = this.store.getSession(event.sessionId);
|
|
2222
2746
|
if (source?.remoteSessionId && this.isCodexSession(source)) {
|
|
2223
2747
|
this.store.saveTurnRuntimeOrigin(
|
|
@@ -2228,6 +2752,10 @@ export class ProxySessionController {
|
|
|
2228
2752
|
);
|
|
2229
2753
|
}
|
|
2230
2754
|
if (event.type === "turn_started") {
|
|
2755
|
+
const attempt = this.store.bindTurnAttempt(event.sessionId, event.turnId);
|
|
2756
|
+
if (attempt?.recoveredFromTurnId && attempt.recoveredFromTurnId !== event.turnId) {
|
|
2757
|
+
this.store.rebindPendingTurnMessages(event.sessionId, attempt.recoveredFromTurnId, event.turnId);
|
|
2758
|
+
}
|
|
2231
2759
|
this.store.saveTurnParent(
|
|
2232
2760
|
event.turnId,
|
|
2233
2761
|
event.sessionId,
|
|
@@ -2235,6 +2763,20 @@ export class ProxySessionController {
|
|
|
2235
2763
|
);
|
|
2236
2764
|
}
|
|
2237
2765
|
}
|
|
2766
|
+
const failedAttempt = event.type === "turn_failed"
|
|
2767
|
+
? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
|
|
2768
|
+
: undefined;
|
|
2769
|
+
if (event.type === "turn_failed") {
|
|
2770
|
+
if (this.retriedFailureTurnIds.has(event.turnId)) return;
|
|
2771
|
+
if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
|
|
2772
|
+
const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
|
|
2773
|
+
if (retryAttempt) {
|
|
2774
|
+
this.retriedFailureTurnIds.add(event.turnId);
|
|
2775
|
+
await this.retryFailedLlmTurn(event, retryAttempt);
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2238
2780
|
if (event.type === "session_metadata_updated") {
|
|
2239
2781
|
this.store.updateRuntimeSession(event.sessionId, { title: event.title });
|
|
2240
2782
|
this.outbound.updateSessionTitle(event.sessionId, event.title);
|
|
@@ -2244,15 +2786,27 @@ export class ProxySessionController {
|
|
|
2244
2786
|
this.store.updateSession(event.sessionId, { status: "running" });
|
|
2245
2787
|
this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
|
|
2246
2788
|
} else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
|
|
2789
|
+
this.store.markTurnAttemptTerminal(
|
|
2790
|
+
event.turnId,
|
|
2791
|
+
event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
|
|
2792
|
+
);
|
|
2247
2793
|
this.store.updateRuntimeSession(event.sessionId, {
|
|
2248
2794
|
lastTurnId: event.turnId,
|
|
2249
2795
|
lastTurnStatus: event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
|
|
2250
2796
|
});
|
|
2251
2797
|
}
|
|
2252
2798
|
|
|
2799
|
+
const presentationEvent = event.type === "turn_failed"
|
|
2800
|
+
&& isRetryableLlmTurnFailure(event.message)
|
|
2801
|
+
&& (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
|
|
2802
|
+
? {
|
|
2803
|
+
...event,
|
|
2804
|
+
message: `${event.message}\n\n已自动重试 ${MAX_LLM_TURN_RETRIES} 次,仍未成功。`,
|
|
2805
|
+
}
|
|
2806
|
+
: event;
|
|
2253
2807
|
let presentationError: unknown;
|
|
2254
2808
|
try {
|
|
2255
|
-
await this.outbound.onEvent(
|
|
2809
|
+
await this.outbound.onEvent(presentationEvent);
|
|
2256
2810
|
} catch (error) {
|
|
2257
2811
|
presentationError = error;
|
|
2258
2812
|
}
|
|
@@ -2285,6 +2839,80 @@ export class ProxySessionController {
|
|
|
2285
2839
|
if (presentationError) throw presentationError;
|
|
2286
2840
|
}
|
|
2287
2841
|
|
|
2842
|
+
private async retryFailedLlmTurn(
|
|
2843
|
+
event: Extract<RuntimeEvent, { type: "turn_failed" }>,
|
|
2844
|
+
attempt: TurnAttemptRecord,
|
|
2845
|
+
): Promise<void> {
|
|
2846
|
+
const retryNumber = attempt.retryCount;
|
|
2847
|
+
const session = this.store.getSession(event.sessionId);
|
|
2848
|
+
if (!session || session.status === "closed") {
|
|
2849
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
this.llmRetryingSessions.add(session.localSessionId);
|
|
2854
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
2855
|
+
lastTurnId: event.turnId,
|
|
2856
|
+
lastTurnStatus: "failed",
|
|
2857
|
+
});
|
|
2858
|
+
this.store.audit(attempt.contextKey, "llm_turn_retry_started", {
|
|
2859
|
+
attemptId: attempt.attemptId,
|
|
2860
|
+
failedTurnId: event.turnId,
|
|
2861
|
+
retryNumber,
|
|
2862
|
+
maxRetries: MAX_LLM_TURN_RETRIES,
|
|
2863
|
+
error: event.message,
|
|
2864
|
+
});
|
|
2865
|
+
|
|
2866
|
+
try {
|
|
2867
|
+
await this.outbound.onEvent({
|
|
2868
|
+
...event,
|
|
2869
|
+
message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
|
|
2870
|
+
});
|
|
2871
|
+
} catch (error) {
|
|
2872
|
+
this.logger.warn(
|
|
2873
|
+
{ error, sessionId: session.localSessionId, turnId: event.turnId },
|
|
2874
|
+
"Failed to finalize the failed thinking card before an LLM retry.",
|
|
2875
|
+
);
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
const replyTarget = attempt.replyMessageId
|
|
2879
|
+
? { messageId: attempt.replyMessageId, replyInThread: true as const }
|
|
2880
|
+
: undefined;
|
|
2881
|
+
try {
|
|
2882
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
2883
|
+
const loaded = await this.loadSession(current);
|
|
2884
|
+
const retryTurnId = await this.startTurn(
|
|
2885
|
+
loaded,
|
|
2886
|
+
llmRetryRuntimePrompt(attempt.promptText, retryNumber),
|
|
2887
|
+
replyTarget,
|
|
2888
|
+
attempt.localImagePaths,
|
|
2889
|
+
{
|
|
2890
|
+
attemptId: attempt.attemptId,
|
|
2891
|
+
messageId: attempt.messageId,
|
|
2892
|
+
displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
|
|
2893
|
+
preserveAttemptOnFailure: true,
|
|
2894
|
+
},
|
|
2895
|
+
);
|
|
2896
|
+
this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
|
|
2897
|
+
} catch (error) {
|
|
2898
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
|
|
2899
|
+
this.store.updateSession(session.localSessionId, { status: "failed" });
|
|
2900
|
+
await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError: unknown) => {
|
|
2901
|
+
this.logger.warn(
|
|
2902
|
+
{ error: reactionError, sessionId: session.localSessionId, turnId: event.turnId },
|
|
2903
|
+
"Failed to finalize reactions after an LLM retry could not start.",
|
|
2904
|
+
);
|
|
2905
|
+
});
|
|
2906
|
+
this.logger.error(
|
|
2907
|
+
{ error, sessionId: session.localSessionId, retryNumber },
|
|
2908
|
+
"Failed to start an automatic LLM turn retry.",
|
|
2909
|
+
);
|
|
2910
|
+
await this.scheduleNextQueuedPrompt(session.localSessionId);
|
|
2911
|
+
} finally {
|
|
2912
|
+
this.llmRetryingSessions.delete(session.localSessionId);
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2288
2916
|
private scheduleNextQueuedPrompt(sessionId: string): Promise<void> {
|
|
2289
2917
|
const previous = this.queuedPromptStarts.get(sessionId) ?? Promise.resolve();
|
|
2290
2918
|
const next = previous.catch(() => undefined).then(() => this.startNextQueuedPromptIfIdle(sessionId));
|
|
@@ -2317,6 +2945,7 @@ export class ProxySessionController {
|
|
|
2317
2945
|
prompt.text,
|
|
2318
2946
|
prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined,
|
|
2319
2947
|
prompt.localImagePaths,
|
|
2948
|
+
{ messageId: prompt.messageId },
|
|
2320
2949
|
);
|
|
2321
2950
|
if (prompt.messageId) await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
|
|
2322
2951
|
} catch (error) {
|
|
@@ -2333,7 +2962,9 @@ export class ProxySessionController {
|
|
|
2333
2962
|
if (!source?.lastTurnId) return undefined;
|
|
2334
2963
|
if (source.lastTurnStatus === "completed") return source.lastTurnId;
|
|
2335
2964
|
const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(source.lastTurnId));
|
|
2336
|
-
return snapshot?.status === "completed"
|
|
2965
|
+
return snapshot?.status === "completed"
|
|
2966
|
+
? source.lastTurnId
|
|
2967
|
+
: this.store.findLatestCompletedTurnId(source.localSessionId, source.contextKey);
|
|
2337
2968
|
}
|
|
2338
2969
|
|
|
2339
2970
|
private async finalizeStandaloneMessageReaction(
|
|
@@ -3194,6 +3825,175 @@ export class ProxySessionController {
|
|
|
3194
3825
|
}
|
|
3195
3826
|
}
|
|
3196
3827
|
|
|
3828
|
+
private async openDirectoryBrowser(
|
|
3829
|
+
contextKey: string,
|
|
3830
|
+
requestedDirectory?: string,
|
|
3831
|
+
options: DirectoryBrowserOptions = {},
|
|
3832
|
+
): Promise<void> {
|
|
3833
|
+
if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
|
|
3834
|
+
await this.openWindowsDriveBrowser(contextKey, options);
|
|
3835
|
+
return;
|
|
3836
|
+
}
|
|
3837
|
+
const baseDirectory = this.currentSession(contextKey)?.cwd
|
|
3838
|
+
?? this.store.getUserContext(contextKey)?.boundProjectCwd
|
|
3839
|
+
?? this.config.defaults.cwd;
|
|
3840
|
+
const directory = requestedDirectory === undefined
|
|
3841
|
+
? path.resolve(baseDirectory)
|
|
3842
|
+
: resolveUserPath(requestedDirectory, baseDirectory);
|
|
3843
|
+
await this.assertBrowsableDirectory(directory);
|
|
3844
|
+
|
|
3845
|
+
let directoryEntries: Dirent[];
|
|
3846
|
+
try {
|
|
3847
|
+
directoryEntries = await fs.readdir(directory, { withFileTypes: true });
|
|
3848
|
+
} catch (error) {
|
|
3849
|
+
throw new Error(`无法读取目录 ${directory}:${runtimeErrorMessage(error)}`);
|
|
3850
|
+
}
|
|
3851
|
+
const entries = directoryEntries
|
|
3852
|
+
.map((entry) => ({
|
|
3853
|
+
name: entry.name,
|
|
3854
|
+
path: path.join(directory, entry.name),
|
|
3855
|
+
kind: directoryBrowserEntryKind(entry),
|
|
3856
|
+
}))
|
|
3857
|
+
.sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
|
|
3858
|
+
|| left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
3859
|
+
const parentDirectory = isWindowsDriveRoot(directory)
|
|
3860
|
+
? WINDOWS_DRIVES_DIRECTORY
|
|
3861
|
+
: path.dirname(directory);
|
|
3862
|
+
const parentEntries: DirectoryBrowserCardEntry[] = parentDirectory === directory ? [] : [{
|
|
3863
|
+
name: "..",
|
|
3864
|
+
kind: "directory",
|
|
3865
|
+
openAction: {
|
|
3866
|
+
text: "..",
|
|
3867
|
+
value: {
|
|
3868
|
+
action: "directory_open",
|
|
3869
|
+
directory: parentDirectory,
|
|
3870
|
+
contextKey,
|
|
3871
|
+
},
|
|
3872
|
+
},
|
|
3873
|
+
}];
|
|
3874
|
+
const pageSize = DIRECTORY_PAGE_SIZE;
|
|
3875
|
+
const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
|
|
3876
|
+
const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
|
|
3877
|
+
const page = Math.min(requestedPage, totalPages - 1);
|
|
3878
|
+
const offset = page * pageSize;
|
|
3879
|
+
const visibleEntries: DirectoryBrowserCardEntry[] = [
|
|
3880
|
+
...parentEntries,
|
|
3881
|
+
...entries
|
|
3882
|
+
.slice(offset, offset + pageSize)
|
|
3883
|
+
.map((entry): DirectoryBrowserCardEntry => entry.kind === "directory"
|
|
3884
|
+
? {
|
|
3885
|
+
name: entry.name,
|
|
3886
|
+
kind: entry.kind,
|
|
3887
|
+
openAction: {
|
|
3888
|
+
text: entry.name,
|
|
3889
|
+
value: {
|
|
3890
|
+
action: "directory_open",
|
|
3891
|
+
directory: entry.path,
|
|
3892
|
+
contextKey,
|
|
3893
|
+
},
|
|
3894
|
+
},
|
|
3895
|
+
actions: directoryCreationActions(entry.path, contextKey),
|
|
3896
|
+
}
|
|
3897
|
+
: {
|
|
3898
|
+
name: entry.name,
|
|
3899
|
+
kind: entry.kind,
|
|
3900
|
+
openAction: {
|
|
3901
|
+
text: entry.name,
|
|
3902
|
+
value: {
|
|
3903
|
+
action: "directory_send_file",
|
|
3904
|
+
filePath: entry.path,
|
|
3905
|
+
contextKey,
|
|
3906
|
+
},
|
|
3907
|
+
},
|
|
3908
|
+
}),
|
|
3909
|
+
];
|
|
3910
|
+
const navigationActions = directoryPaginationActions(directory, page, totalPages, contextKey);
|
|
3911
|
+
const directoryCount = entries.filter((entry) => entry.kind === "directory").length;
|
|
3912
|
+
const card = this.cardRenderer.renderDirectoryBrowserCard({
|
|
3913
|
+
directory: abbreviateHomeDirectory(directory),
|
|
3914
|
+
entries: visibleEntries,
|
|
3915
|
+
currentActions: directoryCreationActions(directory, contextKey),
|
|
3916
|
+
navigationActions,
|
|
3917
|
+
footerLines: [
|
|
3918
|
+
`第 ${page + 1}/${totalPages} 页 · ${directoryCount} 个目录 · ${entries.length - directoryCount} 个文件`,
|
|
3919
|
+
"",
|
|
3920
|
+
"> 点击目录名称进入,点击文件名称发送到当前会话;**New** 在该目录创建任务,**NewGroup** 在该目录创建新群和任务。",
|
|
3921
|
+
],
|
|
3922
|
+
});
|
|
3923
|
+
if (options.updateMessageId) {
|
|
3924
|
+
await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
|
|
3925
|
+
} else {
|
|
3926
|
+
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
private async openWindowsDriveBrowser(
|
|
3931
|
+
contextKey: string,
|
|
3932
|
+
options: DirectoryBrowserOptions,
|
|
3933
|
+
): Promise<void> {
|
|
3934
|
+
const drives = await this.windowsDriveLister();
|
|
3935
|
+
const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
|
|
3936
|
+
const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
|
|
3937
|
+
const page = Math.min(requestedPage, totalPages - 1);
|
|
3938
|
+
const offset = page * DIRECTORY_PAGE_SIZE;
|
|
3939
|
+
const entries: DirectoryBrowserCardEntry[] = drives
|
|
3940
|
+
.slice(offset, offset + DIRECTORY_PAGE_SIZE)
|
|
3941
|
+
.map((drive) => ({
|
|
3942
|
+
name: windowsDriveDisplayName(drive),
|
|
3943
|
+
kind: "drive",
|
|
3944
|
+
openAction: {
|
|
3945
|
+
text: windowsDriveDisplayName(drive),
|
|
3946
|
+
value: {
|
|
3947
|
+
action: "directory_open",
|
|
3948
|
+
directory: drive.root,
|
|
3949
|
+
contextKey,
|
|
3950
|
+
},
|
|
3951
|
+
},
|
|
3952
|
+
actions: directoryCreationActions(drive.root, contextKey),
|
|
3953
|
+
}));
|
|
3954
|
+
const card = this.cardRenderer.renderDirectoryBrowserCard({
|
|
3955
|
+
directory: "此电脑",
|
|
3956
|
+
entries,
|
|
3957
|
+
currentActions: [],
|
|
3958
|
+
navigationActions: directoryPaginationActions(
|
|
3959
|
+
WINDOWS_DRIVES_DIRECTORY,
|
|
3960
|
+
page,
|
|
3961
|
+
totalPages,
|
|
3962
|
+
contextKey,
|
|
3963
|
+
),
|
|
3964
|
+
footerLines: [
|
|
3965
|
+
`第 ${page + 1}/${totalPages} 页 · ${drives.length} 个磁盘`,
|
|
3966
|
+
"",
|
|
3967
|
+
"> 点击磁盘名称进入;**New** 在磁盘根目录创建任务,**NewGroup** 在磁盘根目录创建新群和任务。",
|
|
3968
|
+
],
|
|
3969
|
+
});
|
|
3970
|
+
if (options.updateMessageId) {
|
|
3971
|
+
await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
|
|
3972
|
+
} else {
|
|
3973
|
+
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
private async assertBrowsableDirectory(directory: string): Promise<void> {
|
|
3978
|
+
let stats;
|
|
3979
|
+
try {
|
|
3980
|
+
stats = await fs.stat(directory);
|
|
3981
|
+
} catch (error) {
|
|
3982
|
+
throw new Error(`目录不存在或无法访问:${directory}(${runtimeErrorMessage(error)})`);
|
|
3983
|
+
}
|
|
3984
|
+
if (!stats.isDirectory()) throw new Error(`这不是目录:${directory}`);
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
private async assertSendableFile(filePath: string): Promise<void> {
|
|
3988
|
+
let stats;
|
|
3989
|
+
try {
|
|
3990
|
+
stats = await fs.stat(filePath);
|
|
3991
|
+
} catch (error) {
|
|
3992
|
+
throw new Error(`文件不存在或无法访问:${filePath}(${runtimeErrorMessage(error)})`);
|
|
3993
|
+
}
|
|
3994
|
+
if (!stats.isFile()) throw new Error(`这不是普通文件:${filePath}`);
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3197
3997
|
private async listSessions(
|
|
3198
3998
|
contextKey: string,
|
|
3199
3999
|
searchTerm?: string,
|
|
@@ -3224,23 +4024,33 @@ export class ProxySessionController {
|
|
|
3224
4024
|
? `部分 Agent 的任务读取失败:${remoteErrors.join(";")}`
|
|
3225
4025
|
: undefined;
|
|
3226
4026
|
|
|
3227
|
-
const entries =
|
|
3228
|
-
|
|
4027
|
+
const entries = orderTaskListByProject(
|
|
4028
|
+
mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
|
|
4029
|
+
.filter((entry) => !normalizedSearch
|
|
3229
4030
|
|| [entry.id, entry.title, entry.cwd, entry.agentName]
|
|
3230
|
-
.some((value) => value.toLowerCase().includes(normalizedSearch)))
|
|
3231
|
-
|
|
3232
|
-
|| Number(right.active) - Number(left.active)
|
|
3233
|
-
|| right.updatedAt - left.updatedAt);
|
|
4031
|
+
.some((value) => value.toLowerCase().includes(normalizedSearch))),
|
|
4032
|
+
);
|
|
3234
4033
|
const activeCount = entries.filter((entry) => entry.active).length;
|
|
3235
4034
|
const lastKnownPage = Math.max(0, Math.ceil(entries.length / SESSION_PAGE_SIZE) - 1);
|
|
3236
4035
|
const page = remoteHasMore ? requestedPage : Math.min(requestedPage, lastKnownPage);
|
|
3237
4036
|
const offset = page * SESSION_PAGE_SIZE;
|
|
3238
4037
|
const visibleEntries = entries.slice(offset, offset + SESSION_PAGE_SIZE);
|
|
4038
|
+
const projectActionSources = new Map<string, UnifiedTaskListEntry>();
|
|
4039
|
+
for (const entry of entries) {
|
|
4040
|
+
const project = taskProjectInfo(entry);
|
|
4041
|
+
if (!projectActionSources.has(project.key)) projectActionSources.set(project.key, entry);
|
|
4042
|
+
}
|
|
3239
4043
|
const hasPrevious = page > 0;
|
|
3240
4044
|
const hasNext = remoteHasMore || entries.length > offset + SESSION_PAGE_SIZE;
|
|
3241
4045
|
this.lastSessionListings.set(contextKey, entries.map((entry) => entry.reference));
|
|
3242
|
-
const cardEntries = visibleEntries.map((entry, index)
|
|
3243
|
-
|
|
4046
|
+
const cardEntries = visibleEntries.map((entry, index): {
|
|
4047
|
+
project: TaskProjectInfo;
|
|
4048
|
+
projectActions: TaskListCardAction[];
|
|
4049
|
+
cardEntry: SessionTaskCardEntry;
|
|
4050
|
+
} => {
|
|
4051
|
+
const project = taskProjectInfo(entry);
|
|
4052
|
+
const projectActionSource = projectActionSources.get(project.key) ?? entry;
|
|
4053
|
+
const marker = entry.current ? "✅" : entry.active ? "🟢" : "•";
|
|
3244
4054
|
const showStop = entry.status === "外部执行中"
|
|
3245
4055
|
&& entry.reference !== options.forceSwitchTaskId
|
|
3246
4056
|
&& entry.id !== options.forceSwitchTaskId;
|
|
@@ -3255,26 +4065,25 @@ export class ProxySessionController {
|
|
|
3255
4065
|
contextKey,
|
|
3256
4066
|
},
|
|
3257
4067
|
}];
|
|
3258
|
-
|
|
4068
|
+
const projectActions: TaskListCardAction[] = [{
|
|
3259
4069
|
text: "New",
|
|
3260
4070
|
value: {
|
|
3261
4071
|
action: "session_new",
|
|
3262
|
-
sessionId:
|
|
4072
|
+
sessionId: projectActionSource.reference,
|
|
3263
4073
|
...(searchTerm ? { searchTerm } : {}),
|
|
3264
4074
|
page: String(page),
|
|
3265
4075
|
contextKey,
|
|
3266
4076
|
},
|
|
3267
|
-
}
|
|
3268
|
-
actions.push({
|
|
4077
|
+
}, {
|
|
3269
4078
|
text: "NewGroup",
|
|
3270
4079
|
value: {
|
|
3271
4080
|
action: "session_new_group",
|
|
3272
|
-
sessionId:
|
|
4081
|
+
sessionId: projectActionSource.reference,
|
|
3273
4082
|
...(searchTerm ? { searchTerm } : {}),
|
|
3274
4083
|
page: String(page),
|
|
3275
4084
|
contextKey,
|
|
3276
4085
|
},
|
|
3277
|
-
}
|
|
4086
|
+
}];
|
|
3278
4087
|
actions.push({
|
|
3279
4088
|
text: "Fork",
|
|
3280
4089
|
value: {
|
|
@@ -3303,24 +4112,48 @@ export class ProxySessionController {
|
|
|
3303
4112
|
contextKey,
|
|
3304
4113
|
},
|
|
3305
4114
|
});
|
|
4115
|
+
const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
|
|
3306
4116
|
return {
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
4117
|
+
project,
|
|
4118
|
+
projectActions,
|
|
4119
|
+
cardEntry: {
|
|
4120
|
+
reference: entry.reference,
|
|
4121
|
+
summary: `${offset + index + 1}. ${marker} ${title} · ${entry.agentName}`,
|
|
4122
|
+
detailLines: [
|
|
4123
|
+
`**状态 / 更新时间**:${cardText(entry.status)} / ${cardText(entry.updatedLabel)}`,
|
|
4124
|
+
`**Agent / 任务 ID**:${cardCode(entry.agentName)} / ${cardCode(entry.id)}`,
|
|
4125
|
+
`**目录**:${cardCode(entry.cwd || "目录未知")}`,
|
|
4126
|
+
],
|
|
4127
|
+
actions,
|
|
4128
|
+
current: entry.current,
|
|
4129
|
+
},
|
|
3312
4130
|
};
|
|
3313
4131
|
});
|
|
3314
|
-
const
|
|
3315
|
-
|
|
4132
|
+
const cardGroups: SessionTaskCardGroup[] = [];
|
|
4133
|
+
let currentProjectKey: string | undefined;
|
|
4134
|
+
for (const item of cardEntries) {
|
|
4135
|
+
const currentGroup = cardGroups.at(-1);
|
|
4136
|
+
if (currentGroup && currentProjectKey === item.project.key) {
|
|
4137
|
+
currentGroup.entries.push(item.cardEntry);
|
|
4138
|
+
} else {
|
|
4139
|
+
currentProjectKey = item.project.key;
|
|
4140
|
+
cardGroups.push({
|
|
4141
|
+
title: item.project.title,
|
|
4142
|
+
entries: [item.cardEntry],
|
|
4143
|
+
actions: item.projectActions,
|
|
4144
|
+
});
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
const card = this.cardRenderer.renderSessionTaskListCard(
|
|
4148
|
+
searchTerm ? `任务列表:${searchTerm}` : "任务列表",
|
|
3316
4149
|
activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务",
|
|
3317
|
-
|
|
4150
|
+
cardGroups,
|
|
3318
4151
|
[
|
|
3319
4152
|
...(remoteHint ? [remoteHint] : []),
|
|
3320
4153
|
`第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
|
|
3321
4154
|
"",
|
|
3322
|
-
"> **
|
|
3323
|
-
"> **
|
|
4155
|
+
"> 项目行:**New** 新建任务,**NewGroup** 新建群。",
|
|
4156
|
+
"> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态。",
|
|
3324
4157
|
],
|
|
3325
4158
|
[
|
|
3326
4159
|
...(hasPrevious ? [{
|
|
@@ -3839,6 +4672,21 @@ export class ProxySessionController {
|
|
|
3839
4672
|
));
|
|
3840
4673
|
}
|
|
3841
4674
|
|
|
4675
|
+
private async setGroupMute(contextKey: string, enabled: boolean): Promise<void> {
|
|
4676
|
+
const chatContextKey = baseChatContextKey(contextKey);
|
|
4677
|
+
const chat = this.store.getChatContext(chatContextKey);
|
|
4678
|
+
if (chat?.chatType !== "group") {
|
|
4679
|
+
throw new Error("/mute 仅适用于群聊。");
|
|
4680
|
+
}
|
|
4681
|
+
this.store.setChatRequiresMention(chatContextKey, enabled);
|
|
4682
|
+
await this.outbound.sendText(
|
|
4683
|
+
contextKey,
|
|
4684
|
+
enabled
|
|
4685
|
+
? "已开启当前群的静音模式。之后只有 @ 机器人的消息会被处理;发送 @机器人 /mute off 可恢复自动响应。"
|
|
4686
|
+
: "已关闭当前群的静音模式。机器人将恢复自动响应群消息。",
|
|
4687
|
+
);
|
|
4688
|
+
}
|
|
4689
|
+
|
|
3842
4690
|
private ensureAgent(agentName: string) {
|
|
3843
4691
|
const agent = this.config.agents[agentName];
|
|
3844
4692
|
if (!agent) throw new Error(`未知 agent:${agentName}`);
|
|
@@ -3903,6 +4751,11 @@ export class ProxySessionController {
|
|
|
3903
4751
|
}
|
|
3904
4752
|
}
|
|
3905
4753
|
|
|
4754
|
+
function threadForkAnchorMessageIds(message: IncomingMessage): string[] {
|
|
4755
|
+
return [...new Set([message.rootMessageId, message.parentMessageId]
|
|
4756
|
+
.filter((messageId): messageId is string => Boolean(messageId && messageId !== message.messageId)))];
|
|
4757
|
+
}
|
|
4758
|
+
|
|
3906
4759
|
function sessionStatusLabel(status: SessionRecord["status"], activeTurnId?: string): string {
|
|
3907
4760
|
if (activeTurnId || status === "running") return "执行中";
|
|
3908
4761
|
const labels: Record<SessionRecord["status"], string> = {
|
|
@@ -3957,6 +4810,55 @@ interface UnifiedTaskListEntry {
|
|
|
3957
4810
|
updatedLabel: string;
|
|
3958
4811
|
}
|
|
3959
4812
|
|
|
4813
|
+
interface TaskProjectInfo {
|
|
4814
|
+
key: string;
|
|
4815
|
+
title: string;
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
function orderTaskListByProject(entries: UnifiedTaskListEntry[]): UnifiedTaskListEntry[] {
|
|
4819
|
+
const groups = new Map<string, { project: TaskProjectInfo; entries: UnifiedTaskListEntry[] }>();
|
|
4820
|
+
for (const entry of entries) {
|
|
4821
|
+
const project = taskProjectInfo(entry);
|
|
4822
|
+
const group = groups.get(project.key);
|
|
4823
|
+
if (group) group.entries.push(entry);
|
|
4824
|
+
else groups.set(project.key, { project, entries: [entry] });
|
|
4825
|
+
}
|
|
4826
|
+
const orderedGroups = [...groups.values()]
|
|
4827
|
+
.map((group) => ({
|
|
4828
|
+
...group,
|
|
4829
|
+
current: group.entries.some((entry) => entry.current),
|
|
4830
|
+
active: group.entries.some((entry) => entry.active),
|
|
4831
|
+
updatedAt: Math.max(...group.entries.map((entry) => entry.updatedAt)),
|
|
4832
|
+
}))
|
|
4833
|
+
.sort((left, right) => Number(right.current) - Number(left.current)
|
|
4834
|
+
|| Number(right.active) - Number(left.active)
|
|
4835
|
+
|| right.updatedAt - left.updatedAt
|
|
4836
|
+
|| left.project.title.localeCompare(right.project.title, undefined, { sensitivity: "base" }));
|
|
4837
|
+
return orderedGroups.flatMap((group) => group.entries.sort(compareTaskListEntries));
|
|
4838
|
+
}
|
|
4839
|
+
|
|
4840
|
+
function compareTaskListEntries(left: UnifiedTaskListEntry, right: UnifiedTaskListEntry): number {
|
|
4841
|
+
return Number(right.current) - Number(left.current)
|
|
4842
|
+
|| Number(right.active) - Number(left.active)
|
|
4843
|
+
|| right.updatedAt - left.updatedAt
|
|
4844
|
+
|| left.title.localeCompare(right.title, undefined, { sensitivity: "base" });
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4847
|
+
function taskProjectInfo(entry: UnifiedTaskListEntry): TaskProjectInfo {
|
|
4848
|
+
if (!entry.cwd) return { key: "unknown", title: "📁 目录未知" };
|
|
4849
|
+
if (detectProjectlessWorkspace(entry.cwd)) {
|
|
4850
|
+
return { key: "projectless", title: "🗒️ 未指定项目" };
|
|
4851
|
+
}
|
|
4852
|
+
const windowsPath = isWindowsAbsolutePath(entry.cwd);
|
|
4853
|
+
const normalized = windowsPath
|
|
4854
|
+
? path.win32.normalize(entry.cwd).toLowerCase()
|
|
4855
|
+
: path.resolve(entry.cwd);
|
|
4856
|
+
return {
|
|
4857
|
+
key: `cwd:${normalized}`,
|
|
4858
|
+
title: `📁 ${abbreviateHomeDirectory(entry.cwd)}`,
|
|
4859
|
+
};
|
|
4860
|
+
}
|
|
4861
|
+
|
|
3960
4862
|
function mergeTaskList(
|
|
3961
4863
|
localSessions: SessionRecord[],
|
|
3962
4864
|
remoteSessions: AgentRemoteSessionSummary[],
|
|
@@ -4055,6 +4957,14 @@ function turnViewSnapshot(value: unknown): TurnViewState | undefined {
|
|
|
4055
4957
|
: undefined;
|
|
4056
4958
|
}
|
|
4057
4959
|
|
|
4960
|
+
function isTerminalTurnViewStatus(status: TurnViewState["status"]): boolean {
|
|
4961
|
+
return status === "completed" || status === "cancelled" || status === "failed";
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
function isIncompleteTurnAttemptStatus(status: TurnAttemptRecord["status"]): boolean {
|
|
4965
|
+
return status === "accepted" || status === "running" || status === "recovering";
|
|
4966
|
+
}
|
|
4967
|
+
|
|
4058
4968
|
function executionDetailLines(
|
|
4059
4969
|
record: SessionRecord,
|
|
4060
4970
|
snapshot: TurnViewState | undefined,
|
|
@@ -4336,7 +5246,7 @@ function isBotOwnedActiveTurn(record: SessionRecord, remote: RemoteSessionSummar
|
|
|
4336
5246
|
}
|
|
4337
5247
|
|
|
4338
5248
|
function isQueueIndependentCommand(command: Command): boolean {
|
|
4339
|
-
if (["stop", "status", "restart", "help", "sessions", "goal", "nosteer"].includes(command.type)) return true;
|
|
5249
|
+
if (["stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type)) return true;
|
|
4340
5250
|
if (command.type === "agent") return command.agent === undefined;
|
|
4341
5251
|
if (["model", "provider", "thinking", "permissions"].includes(command.type)) return true;
|
|
4342
5252
|
return false;
|
|
@@ -4347,6 +5257,138 @@ function parseSessionPage(value: unknown): number {
|
|
|
4347
5257
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
4348
5258
|
}
|
|
4349
5259
|
|
|
5260
|
+
function directoryPageValue(value: unknown): number {
|
|
5261
|
+
const page = Number(value);
|
|
5262
|
+
if (!Number.isSafeInteger(page) || page < 0) throw new Error("文件浏览卡片页码无效,请重新发送 /dir。");
|
|
5263
|
+
return page;
|
|
5264
|
+
}
|
|
5265
|
+
|
|
5266
|
+
function isWindowsDriveRoot(directory: string): boolean {
|
|
5267
|
+
return /^[a-z]:\\$/iu.test(path.win32.normalize(directory));
|
|
5268
|
+
}
|
|
5269
|
+
|
|
5270
|
+
function directoryBrowserEntryKind(entry: Dirent): DirectoryBrowserCardEntry["kind"] {
|
|
5271
|
+
if (entry.isDirectory()) return "directory";
|
|
5272
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
5273
|
+
if (IMAGE_FILE_EXTENSIONS.has(extension)) return "image";
|
|
5274
|
+
if (BINARY_FILE_EXTENSIONS.has(extension)) return "binary";
|
|
5275
|
+
return "file";
|
|
5276
|
+
}
|
|
5277
|
+
|
|
5278
|
+
function directoryActionPath(value: unknown): string {
|
|
5279
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
5280
|
+
throw new Error("文件浏览卡片中的目录无效,请重新发送 /dir。");
|
|
5281
|
+
}
|
|
5282
|
+
return value === WINDOWS_DRIVES_DIRECTORY ? value : path.resolve(value);
|
|
5283
|
+
}
|
|
5284
|
+
|
|
5285
|
+
function directoryFileActionPath(value: unknown): string {
|
|
5286
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
5287
|
+
throw new Error("文件浏览卡片中的文件无效,请重新发送 /dir。");
|
|
5288
|
+
}
|
|
5289
|
+
return path.resolve(value);
|
|
5290
|
+
}
|
|
5291
|
+
|
|
5292
|
+
function directoryCreationActions(directory: string, contextKey: string): TaskListCardAction[] {
|
|
5293
|
+
return [
|
|
5294
|
+
{
|
|
5295
|
+
text: "New",
|
|
5296
|
+
value: {
|
|
5297
|
+
action: "directory_new",
|
|
5298
|
+
directory,
|
|
5299
|
+
contextKey,
|
|
5300
|
+
},
|
|
5301
|
+
},
|
|
5302
|
+
{
|
|
5303
|
+
text: "NewGroup",
|
|
5304
|
+
value: {
|
|
5305
|
+
action: "directory_new_group",
|
|
5306
|
+
directory,
|
|
5307
|
+
contextKey,
|
|
5308
|
+
},
|
|
5309
|
+
},
|
|
5310
|
+
];
|
|
5311
|
+
}
|
|
5312
|
+
|
|
5313
|
+
function directoryPaginationActions(
|
|
5314
|
+
directory: string,
|
|
5315
|
+
page: number,
|
|
5316
|
+
totalPages: number,
|
|
5317
|
+
contextKey: string,
|
|
5318
|
+
): TaskListCardAction[] {
|
|
5319
|
+
return [
|
|
5320
|
+
...(page > 0 ? [{
|
|
5321
|
+
text: "Previous",
|
|
5322
|
+
value: {
|
|
5323
|
+
action: "directory_page",
|
|
5324
|
+
directory,
|
|
5325
|
+
page: String(page - 1),
|
|
5326
|
+
contextKey,
|
|
5327
|
+
},
|
|
5328
|
+
}] : []),
|
|
5329
|
+
...(page < totalPages - 1 ? [{
|
|
5330
|
+
text: "Next",
|
|
5331
|
+
value: {
|
|
5332
|
+
action: "directory_page",
|
|
5333
|
+
directory,
|
|
5334
|
+
page: String(page + 1),
|
|
5335
|
+
contextKey,
|
|
5336
|
+
},
|
|
5337
|
+
}] : []),
|
|
5338
|
+
];
|
|
5339
|
+
}
|
|
5340
|
+
|
|
5341
|
+
function windowsDriveDisplayName(drive: WindowsDriveInfo): string {
|
|
5342
|
+
const driveLetter = drive.root.slice(0, 2).toUpperCase();
|
|
5343
|
+
const label = drive.label?.trim() || windowsDriveTypeLabel(drive.driveType);
|
|
5344
|
+
return `${label} (${driveLetter})`;
|
|
5345
|
+
}
|
|
5346
|
+
|
|
5347
|
+
function windowsDriveTypeLabel(driveType?: string): string {
|
|
5348
|
+
switch (driveType?.toLowerCase()) {
|
|
5349
|
+
case "fixed": return "本地磁盘";
|
|
5350
|
+
case "network": return "网络驱动器";
|
|
5351
|
+
case "removable": return "可移动磁盘";
|
|
5352
|
+
case "cdrom": return "光驱";
|
|
5353
|
+
case "ram": return "RAM 磁盘";
|
|
5354
|
+
default: return "磁盘";
|
|
5355
|
+
}
|
|
5356
|
+
}
|
|
5357
|
+
|
|
5358
|
+
async function listWindowsDriveRoots(): Promise<WindowsDriveInfo[]> {
|
|
5359
|
+
if (process.platform !== "win32") throw new Error("磁盘列表仅在 Windows 上可用。");
|
|
5360
|
+
const result = await executeShellCommand(
|
|
5361
|
+
"$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",
|
|
5362
|
+
os.homedir(),
|
|
5363
|
+
{ timeoutMs: 10_000, maxOutputBytes: 16 * 1024, platform: "win32" },
|
|
5364
|
+
);
|
|
5365
|
+
if (result.timedOut || result.exitCode !== 0) {
|
|
5366
|
+
const detail = result.stderr.trim() || `退出码 ${result.exitCode ?? "未知"}`;
|
|
5367
|
+
throw new Error(`无法读取 Windows 磁盘列表:${detail}`);
|
|
5368
|
+
}
|
|
5369
|
+
let parsed: unknown;
|
|
5370
|
+
try {
|
|
5371
|
+
parsed = JSON.parse(result.stdout.trim().replace(/^\uFEFF/u, ""));
|
|
5372
|
+
} catch (error) {
|
|
5373
|
+
throw new Error(`无法解析 Windows 磁盘列表:${runtimeErrorMessage(error)}`);
|
|
5374
|
+
}
|
|
5375
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
5376
|
+
const drives = values.flatMap((value): WindowsDriveInfo[] => {
|
|
5377
|
+
if (typeof value !== "object" || value === null) return [];
|
|
5378
|
+
const record = value as Record<string, unknown>;
|
|
5379
|
+
if (typeof record.root !== "string") return [];
|
|
5380
|
+
const root = path.win32.normalize(record.root.trim());
|
|
5381
|
+
if (!isWindowsDriveRoot(root)) return [];
|
|
5382
|
+
return [{
|
|
5383
|
+
root,
|
|
5384
|
+
...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
|
|
5385
|
+
...(typeof record.driveType === "string" ? { driveType: record.driveType } : {}),
|
|
5386
|
+
}];
|
|
5387
|
+
}).sort((left, right) => left.root.localeCompare(right.root, undefined, { sensitivity: "base" }));
|
|
5388
|
+
if (drives.length === 0) throw new Error("没有检测到可用的 Windows 磁盘。");
|
|
5389
|
+
return drives;
|
|
5390
|
+
}
|
|
5391
|
+
|
|
4350
5392
|
function isPromptCommand(command: Command): boolean {
|
|
4351
5393
|
return command.type === "prompt" || command.type === "nosteer";
|
|
4352
5394
|
}
|
|
@@ -4355,6 +5397,75 @@ function runtimePrompt(text: string, localImagePaths?: string[]): RuntimePrompt
|
|
|
4355
5397
|
return localImagePaths?.length ? { text, localImagePaths } : text;
|
|
4356
5398
|
}
|
|
4357
5399
|
|
|
5400
|
+
function recoveryCardPrompt(prompt: string): string {
|
|
5401
|
+
return `恢复重启前的任务:${prompt}`;
|
|
5402
|
+
}
|
|
5403
|
+
|
|
5404
|
+
function recoveryRuntimePrompt(prompt: string): string {
|
|
5405
|
+
return [
|
|
5406
|
+
"Agent Bot restarted while the previous turn was still running.",
|
|
5407
|
+
"Continue the unfinished task in the current task and workspace.",
|
|
5408
|
+
"Inspect the existing conversation, files, and completed tool effects first. Do not repeat completed or irreversible actions.",
|
|
5409
|
+
"Finish the original request:",
|
|
5410
|
+
"",
|
|
5411
|
+
prompt,
|
|
5412
|
+
].join("\n");
|
|
5413
|
+
}
|
|
5414
|
+
|
|
5415
|
+
function llmRetryCardPrompt(prompt: string, retryNumber: number): string {
|
|
5416
|
+
return `自动重试 ${retryNumber}/${MAX_LLM_TURN_RETRIES}:${prompt}`;
|
|
5417
|
+
}
|
|
5418
|
+
|
|
5419
|
+
function llmRetryRuntimePrompt(prompt: string, retryNumber: number): string {
|
|
5420
|
+
return [
|
|
5421
|
+
`Agent Bot is automatically retrying this request after a transient LLM service failure (${retryNumber}/${MAX_LLM_TURN_RETRIES}).`,
|
|
5422
|
+
"Continue in the current task and workspace. Inspect the conversation, files, and completed tool effects before acting.",
|
|
5423
|
+
"Do not repeat completed or irreversible actions. Finish the original request:",
|
|
5424
|
+
"",
|
|
5425
|
+
prompt,
|
|
5426
|
+
].join("\n");
|
|
5427
|
+
}
|
|
5428
|
+
|
|
5429
|
+
function isRetryableLlmTurnFailure(message: string): boolean {
|
|
5430
|
+
const normalized = message.trim().toLowerCase();
|
|
5431
|
+
if (!normalized) return false;
|
|
5432
|
+
const permanentFailure = [
|
|
5433
|
+
/auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
|
|
5434
|
+
/insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
|
|
5435
|
+
/context (?:length|window)|maximum context|too many tokens|token limit/u,
|
|
5436
|
+
/invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,
|
|
5437
|
+
/model (?:not found|does not exist)|unknown model/u,
|
|
5438
|
+
/permission|approval|sandbox|policy violation|content policy/u,
|
|
5439
|
+
].some((pattern) => pattern.test(normalized));
|
|
5440
|
+
if (permanentFailure) return false;
|
|
5441
|
+
|
|
5442
|
+
return [
|
|
5443
|
+
/(?:^|\D)429(?:\D|$)|rate[_ -]?limit|too many requests/u,
|
|
5444
|
+
/overload|high demand|at capacity|capacity limit|temporar(?:y|ily) unavailable/u,
|
|
5445
|
+
/service unavailable|internal server error|bad gateway|gateway timeout/u,
|
|
5446
|
+
/(?:^|\D)(?:500|502|503|504)(?:\D|$)|upstream (?:error|failure|timeout)/u,
|
|
5447
|
+
/deadline exceeded|request timed out|request timeout|response timeout/u,
|
|
5448
|
+
/connection (?:reset|closed|aborted)|network error|socket hang up/u,
|
|
5449
|
+
/stream (?:closed|disconnected|interrupted|error)/u,
|
|
5450
|
+
/\b(?:llm|model|provider|inference)\b.*\b(?:error|failed|failure|unavailable|timeout|timed out)\b/u,
|
|
5451
|
+
].some((pattern) => pattern.test(normalized));
|
|
5452
|
+
}
|
|
5453
|
+
|
|
5454
|
+
function remoteTurnMatchesAttempt(remote: RemoteSessionSummary | undefined, attempt: TurnAttemptRecord): boolean {
|
|
5455
|
+
if (!remote?.lastTurnId) return false;
|
|
5456
|
+
if (attempt.turnId) return remote.lastTurnId === attempt.turnId;
|
|
5457
|
+
const remoteUpdatedAt = remote.updatedAt === undefined ? undefined : normalizeRemoteTimestamp(remote.updatedAt);
|
|
5458
|
+
const acceptedAt = Date.parse(attempt.createdAt);
|
|
5459
|
+
return remoteUpdatedAt !== undefined
|
|
5460
|
+
&& Number.isFinite(acceptedAt)
|
|
5461
|
+
&& remoteUpdatedAt >= acceptedAt - 1_000;
|
|
5462
|
+
}
|
|
5463
|
+
|
|
5464
|
+
function isRecoveryAttemptRecent(attempt: TurnAttemptRecord, cutoff: number): boolean {
|
|
5465
|
+
const updatedAt = Date.parse(attempt.updatedAt);
|
|
5466
|
+
return Number.isFinite(updatedAt) && updatedAt >= cutoff;
|
|
5467
|
+
}
|
|
5468
|
+
|
|
4358
5469
|
function formatGroupDateSuffix(date: Date): string {
|
|
4359
5470
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
4360
5471
|
const day = String(date.getDate()).padStart(2, "0");
|
|
@@ -4395,10 +5506,19 @@ function formatGroupProjectDirectory(projectCwd: string): string {
|
|
|
4395
5506
|
}
|
|
4396
5507
|
|
|
4397
5508
|
function abbreviateHomeDirectory(value: string): string {
|
|
4398
|
-
const
|
|
5509
|
+
const homeDirectory = os.homedir();
|
|
5510
|
+
const valueIsWindows = isWindowsAbsolutePath(value);
|
|
5511
|
+
const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
|
|
5512
|
+
if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value))) return value;
|
|
5513
|
+
const pathApi = valueIsWindows ? path.win32 : path;
|
|
5514
|
+
const relative = pathApi.relative(homeDirectory, value);
|
|
4399
5515
|
if (relative === "") return "~";
|
|
4400
|
-
if (
|
|
4401
|
-
return `~${
|
|
5516
|
+
if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) return value;
|
|
5517
|
+
return `~${pathApi.sep}${relative}`;
|
|
5518
|
+
}
|
|
5519
|
+
|
|
5520
|
+
function isWindowsAbsolutePath(value: string): boolean {
|
|
5521
|
+
return /^[a-z]:[\\/]|^\\\\/iu.test(value);
|
|
4402
5522
|
}
|
|
4403
5523
|
|
|
4404
5524
|
function fitTrailingPathLevels(levels: string[], maxLength: number, separator: string): string {
|