@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, 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]",
|
|
@@ -110,6 +141,11 @@ const HELP_COMMAND_SECTIONS: Array<{
|
|
|
110
141
|
requiresArgument: true,
|
|
111
142
|
},
|
|
112
143
|
{ command: "/sessions", usage: "[关键词]", description: "查找本机任务" },
|
|
144
|
+
{
|
|
145
|
+
command: "/archive",
|
|
146
|
+
usage: "[序号或任务 ID]",
|
|
147
|
+
description: "归档当前或指定任务",
|
|
148
|
+
},
|
|
113
149
|
{
|
|
114
150
|
command: "/switch",
|
|
115
151
|
usage: "[序号或任务 ID]",
|
|
@@ -174,6 +210,11 @@ const HELP_COMMAND_SECTIONS: Array<{
|
|
|
174
210
|
usage: "[--force]",
|
|
175
211
|
description: "默认安全重启;--force 立即重启",
|
|
176
212
|
},
|
|
213
|
+
{
|
|
214
|
+
command: "/mute",
|
|
215
|
+
usage: "[on|off]",
|
|
216
|
+
description: "开启或关闭当前群的仅 @ 响应模式",
|
|
217
|
+
},
|
|
177
218
|
{ command: "/help", description: "显示本帮助" },
|
|
178
219
|
],
|
|
179
220
|
},
|
|
@@ -202,6 +243,11 @@ interface SessionsCardOptions {
|
|
|
202
243
|
page?: number;
|
|
203
244
|
}
|
|
204
245
|
|
|
246
|
+
interface DirectoryBrowserOptions {
|
|
247
|
+
updateMessageId?: string;
|
|
248
|
+
page?: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
205
251
|
interface StatusCardOptions {
|
|
206
252
|
updateMessageId?: string;
|
|
207
253
|
forceSwitchTaskId?: string;
|
|
@@ -301,6 +347,13 @@ export interface ProxyLifecycle {
|
|
|
301
347
|
}
|
|
302
348
|
|
|
303
349
|
export type ShellCommandExecutor = (command: string, cwd: string) => Promise<ShellCommandResult>;
|
|
350
|
+
export interface WindowsDriveInfo {
|
|
351
|
+
root: string;
|
|
352
|
+
label?: string;
|
|
353
|
+
driveType?: string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export type WindowsDriveLister = () => Promise<WindowsDriveInfo[]>;
|
|
304
357
|
|
|
305
358
|
export class ProxySessionController {
|
|
306
359
|
private readonly router = new CommandRouter();
|
|
@@ -313,7 +366,13 @@ export class ProxySessionController {
|
|
|
313
366
|
private readonly sessionResets = new Map<string, Promise<void>>();
|
|
314
367
|
private readonly lastSessionListings = new Map<string, string[]>();
|
|
315
368
|
private readonly threadInitializations = new Map<string, Promise<void>>();
|
|
369
|
+
private readonly llmRetryingSessions = new Set<string>();
|
|
370
|
+
private readonly retriedFailureTurnIds = new Set<string>();
|
|
371
|
+
private readonly recoveryRetryTimers = new Map<string, NodeJS.Timeout>();
|
|
316
372
|
private readonly unsubscribe: Array<() => void> = [];
|
|
373
|
+
private readonly recoveryActivityHeartbeat: NodeJS.Timeout;
|
|
374
|
+
private startupRecovery?: Promise<void>;
|
|
375
|
+
private queuedPromptsRestored = false;
|
|
317
376
|
|
|
318
377
|
constructor(
|
|
319
378
|
private readonly config: AppConfig,
|
|
@@ -323,18 +382,8 @@ export class ProxySessionController {
|
|
|
323
382
|
private readonly logger: Logger,
|
|
324
383
|
private readonly lifecycle?: ProxyLifecycle,
|
|
325
384
|
private readonly shellCommandExecutor: ShellCommandExecutor = executeShellCommand,
|
|
385
|
+
private readonly windowsDriveLister: WindowsDriveLister = listWindowsDriveRoots,
|
|
326
386
|
) {
|
|
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
387
|
for (const [, runtime] of this.runtimes.entries()) {
|
|
339
388
|
this.unsubscribe.push(
|
|
340
389
|
runtime.onEvent((event) => {
|
|
@@ -345,15 +394,37 @@ export class ProxySessionController {
|
|
|
345
394
|
);
|
|
346
395
|
}
|
|
347
396
|
this.restorePersistedSessionRoutes();
|
|
348
|
-
this.restorePersistedQueuedPrompts();
|
|
349
397
|
void this.restorePersistedMessageReactions().catch((error: unknown) => {
|
|
350
398
|
this.logger.warn({ error }, "Failed to restore persisted message reaction statuses.");
|
|
351
399
|
});
|
|
400
|
+
this.recoveryActivityHeartbeat = setInterval(
|
|
401
|
+
() => this.persistActiveTurnHeartbeats(),
|
|
402
|
+
RECOVERY_HEARTBEAT_INTERVAL_MS,
|
|
403
|
+
);
|
|
404
|
+
this.recoveryActivityHeartbeat.unref?.();
|
|
352
405
|
}
|
|
353
406
|
|
|
354
407
|
async onMessage(message: IncomingMessage): Promise<void> {
|
|
355
|
-
|
|
356
|
-
|
|
408
|
+
if (!message.contextKey.startsWith("console:") && !allowsFeishuUser(this.config, message.userId)) {
|
|
409
|
+
this.logger.debug(
|
|
410
|
+
{ messageId: message.messageId },
|
|
411
|
+
"Ignored a Feishu message from a non-owner before acknowledgement.",
|
|
412
|
+
);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
message.chatType === "group"
|
|
417
|
+
&& this.store.chatRequiresMention(baseChatContextKey(message.contextKey))
|
|
418
|
+
&& message.mentionedBot !== true
|
|
419
|
+
) {
|
|
420
|
+
this.logger.debug(
|
|
421
|
+
{ messageId: message.messageId, contextKey: message.contextKey },
|
|
422
|
+
"Ignored an unmentioned message from a muted Lark group.",
|
|
423
|
+
);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// For accepted messages, claim durable deduplication before acknowledgement so event
|
|
427
|
+
// retries cannot add duplicate reactions.
|
|
357
428
|
if (!this.store.claimInboundEvent(message.messageId, "message")) return;
|
|
358
429
|
try {
|
|
359
430
|
const reactionId = await this.outbound.addReaction(
|
|
@@ -473,6 +544,13 @@ export class ProxySessionController {
|
|
|
473
544
|
}
|
|
474
545
|
|
|
475
546
|
async onCardAction(action: CardAction): Promise<void> {
|
|
547
|
+
if (!allowsFeishuUser(this.config, action.userId)) {
|
|
548
|
+
this.logger.debug(
|
|
549
|
+
{ actionId: action.actionId },
|
|
550
|
+
"Ignored a Feishu card action from a non-owner.",
|
|
551
|
+
);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
476
554
|
if (!this.store.claimInboundEvent(action.actionId, "card_action")) return;
|
|
477
555
|
const contextKey = this.cardActionContextKey(action);
|
|
478
556
|
const scopedAction = contextKey === action.contextKey ? action : { ...action, contextKey };
|
|
@@ -533,6 +611,32 @@ export class ProxySessionController {
|
|
|
533
611
|
if (!cancelled) {
|
|
534
612
|
await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
|
|
535
613
|
}
|
|
614
|
+
} else if (kind === "directory_open" || kind === "directory_page") {
|
|
615
|
+
await this.openDirectoryBrowser(
|
|
616
|
+
contextKey,
|
|
617
|
+
directoryActionPath(scopedAction.value.directory),
|
|
618
|
+
{
|
|
619
|
+
updateMessageId: requiredCardMessageId(scopedAction.messageId),
|
|
620
|
+
page: kind === "directory_page" ? directoryPageValue(scopedAction.value.page) : 0,
|
|
621
|
+
},
|
|
622
|
+
);
|
|
623
|
+
} else if (kind === "directory_send_file") {
|
|
624
|
+
const filePath = directoryFileActionPath(scopedAction.value.filePath);
|
|
625
|
+
await this.assertSendableFile(filePath);
|
|
626
|
+
await this.outbound.sendFile(contextKey, filePath);
|
|
627
|
+
} else if (kind === "directory_new" || kind === "directory_new_group") {
|
|
628
|
+
const directory = directoryActionPath(scopedAction.value.directory);
|
|
629
|
+
await this.assertBrowsableDirectory(directory);
|
|
630
|
+
await this.execute(
|
|
631
|
+
contextKey,
|
|
632
|
+
kind === "directory_new"
|
|
633
|
+
? { type: "new", cwd: directory }
|
|
634
|
+
: { type: "newgroup", cwd: directory },
|
|
635
|
+
undefined,
|
|
636
|
+
replyTarget,
|
|
637
|
+
undefined,
|
|
638
|
+
scopedAction.userId,
|
|
639
|
+
);
|
|
536
640
|
} else if (kind === "session_page") {
|
|
537
641
|
await this.refreshSessionsCardFromAction(scopedAction);
|
|
538
642
|
} else if (kind === "session_switch") {
|
|
@@ -556,6 +660,13 @@ export class ProxySessionController {
|
|
|
556
660
|
const sessionId = String(scopedAction.value.sessionId ?? "");
|
|
557
661
|
await this.createFeishuGroupFromReference(contextKey, sessionId, scopedAction.userId);
|
|
558
662
|
await this.refreshSessionsCardFromAction(scopedAction);
|
|
663
|
+
} else if (kind === "session_archive") {
|
|
664
|
+
const sessionId = String(scopedAction.value.sessionId ?? "");
|
|
665
|
+
await this.archiveSessionReference(contextKey, sessionId, {
|
|
666
|
+
announce: true,
|
|
667
|
+
source: "sessions_card",
|
|
668
|
+
});
|
|
669
|
+
await this.refreshSessionsCardFromAction(scopedAction);
|
|
559
670
|
} else if (kind === "session_stop") {
|
|
560
671
|
const sessionId = String(scopedAction.value.sessionId ?? "");
|
|
561
672
|
await this.stopSessionReference(contextKey, sessionId);
|
|
@@ -692,6 +803,9 @@ export class ProxySessionController {
|
|
|
692
803
|
this.queuedPromptCards.clear();
|
|
693
804
|
this.queuedPromptCardWrites.clear();
|
|
694
805
|
this.sessionResets.clear();
|
|
806
|
+
for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
|
|
807
|
+
this.recoveryRetryTimers.clear();
|
|
808
|
+
clearInterval(this.recoveryActivityHeartbeat);
|
|
695
809
|
}
|
|
696
810
|
|
|
697
811
|
async controlStopTask(localSessionId: string): Promise<string> {
|
|
@@ -701,6 +815,18 @@ export class ProxySessionController {
|
|
|
701
815
|
return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
|
|
702
816
|
}
|
|
703
817
|
|
|
818
|
+
async controlArchiveTask(localSessionId: string): Promise<{
|
|
819
|
+
localSessionId: string;
|
|
820
|
+
remoteSessionId: string;
|
|
821
|
+
title: string;
|
|
822
|
+
}> {
|
|
823
|
+
const record = this.requireControlSession(localSessionId);
|
|
824
|
+
return this.archiveSessionReference(this.controlSessionContextKey(record), localSessionId, {
|
|
825
|
+
announce: false,
|
|
826
|
+
source: "cli",
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
704
830
|
async controlGetTaskStatus(localSessionId: string): Promise<{
|
|
705
831
|
session: SessionRecord;
|
|
706
832
|
snapshot?: TurnViewState;
|
|
@@ -882,6 +1008,387 @@ export class ProxySessionController {
|
|
|
882
1008
|
};
|
|
883
1009
|
}
|
|
884
1010
|
|
|
1011
|
+
async controlCreateTask(
|
|
1012
|
+
localSessionId: string,
|
|
1013
|
+
requestedTitle?: string,
|
|
1014
|
+
requestedProjectCwd?: string,
|
|
1015
|
+
forceProjectless = false,
|
|
1016
|
+
requestedAgentName?: string,
|
|
1017
|
+
): Promise<SessionRecord> {
|
|
1018
|
+
const source = this.requireControlSession(localSessionId);
|
|
1019
|
+
const contextKey = this.controlSessionContextKey(source);
|
|
1020
|
+
const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
|
|
1021
|
+
const agentName = requestedAgentName?.trim() || context.defaultAgent;
|
|
1022
|
+
const agent = this.ensureAgent(agentName);
|
|
1023
|
+
if (forceProjectless && agent.kind !== "app-server") {
|
|
1024
|
+
throw new Error("task new --nodir is only available for App Server agents.");
|
|
1025
|
+
}
|
|
1026
|
+
const replyTarget = this.controlSessionReplyTarget(source);
|
|
1027
|
+
const cwd = forceProjectless
|
|
1028
|
+
? undefined
|
|
1029
|
+
: requestedProjectCwd === undefined
|
|
1030
|
+
? detectProjectlessWorkspace(source.cwd)
|
|
1031
|
+
? agent.kind === "app-server" ? undefined : createProjectlessWorkspace().cwd
|
|
1032
|
+
: source.cwd
|
|
1033
|
+
: resolveUserPath(requestedProjectCwd, source.cwd);
|
|
1034
|
+
const settings: SessionExecutionSettings = source.agentName === agentName
|
|
1035
|
+
? {
|
|
1036
|
+
modelProvider: source.modelProvider,
|
|
1037
|
+
model: source.model,
|
|
1038
|
+
reasoningEffort: source.reasoningEffort,
|
|
1039
|
+
permissionMode: source.permissionMode,
|
|
1040
|
+
}
|
|
1041
|
+
: {};
|
|
1042
|
+
return this.outbound.withReplyTarget(contextKey, replyTarget, () =>
|
|
1043
|
+
this.createSession(
|
|
1044
|
+
contextKey,
|
|
1045
|
+
agentName,
|
|
1046
|
+
cwd,
|
|
1047
|
+
true,
|
|
1048
|
+
false,
|
|
1049
|
+
undefined,
|
|
1050
|
+
undefined,
|
|
1051
|
+
requestedTitle,
|
|
1052
|
+
settings,
|
|
1053
|
+
));
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async controlForkTask(localSessionId: string): Promise<{
|
|
1057
|
+
sourceLocalSessionId: string;
|
|
1058
|
+
sourceTurnId: string;
|
|
1059
|
+
task: SessionRecord;
|
|
1060
|
+
}> {
|
|
1061
|
+
const source = this.requireControlSession(localSessionId);
|
|
1062
|
+
const contextKey = this.controlSessionContextKey(source);
|
|
1063
|
+
const replyTarget = this.controlSessionReplyTarget(source);
|
|
1064
|
+
const plan = await this.prepareForkSession(contextKey, source.localSessionId);
|
|
1065
|
+
const forked = await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
|
|
1066
|
+
const result = await this.forkSessionIntoContext(contextKey, plan);
|
|
1067
|
+
await this.outbound.sendText(
|
|
1068
|
+
contextKey,
|
|
1069
|
+
`已从指定任务${plan.forkedFromHistoricalTurn ? "最近已完成轮次" : ""}创建分支并切换到新任务:${
|
|
1070
|
+
result.session.title
|
|
1071
|
+
? `${result.session.title}(${result.session.remoteSessionId})`
|
|
1072
|
+
: result.session.remoteSessionId
|
|
1073
|
+
}`,
|
|
1074
|
+
);
|
|
1075
|
+
return result;
|
|
1076
|
+
});
|
|
1077
|
+
return {
|
|
1078
|
+
sourceLocalSessionId: source.localSessionId,
|
|
1079
|
+
sourceTurnId: plan.lastTurnId,
|
|
1080
|
+
task: this.store.getSession(forked.record.localSessionId) ?? forked.record,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async controlSwitchTask(
|
|
1085
|
+
localSessionId: string,
|
|
1086
|
+
targetLocalSessionId?: string,
|
|
1087
|
+
previous = false,
|
|
1088
|
+
): Promise<SessionRecord> {
|
|
1089
|
+
const anchor = this.requireControlSession(localSessionId);
|
|
1090
|
+
const contextKey = this.controlSessionContextKey(anchor);
|
|
1091
|
+
const replyTarget = this.controlSessionReplyTarget(anchor);
|
|
1092
|
+
const target = previous ? undefined : targetLocalSessionId ?? anchor.localSessionId;
|
|
1093
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.switchSession(contextKey, target));
|
|
1094
|
+
return this.requireCurrentSession(contextKey);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async controlQueueTaskPrompt(localSessionId: string, text: string): Promise<{ promptId: string; queued: number }> {
|
|
1098
|
+
const promptText = text.trim();
|
|
1099
|
+
if (!promptText) throw new Error("The queued Prompt cannot be empty.");
|
|
1100
|
+
const record = this.requireControlSession(localSessionId);
|
|
1101
|
+
const contextKey = this.controlSessionContextKey(record);
|
|
1102
|
+
const replyTarget = this.controlSessionReplyTarget(record);
|
|
1103
|
+
const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, promptText, { replyTarget });
|
|
1104
|
+
this.store.audit(contextKey, "queued_prompt_added", {
|
|
1105
|
+
promptId: queued.promptId,
|
|
1106
|
+
localSessionId: record.localSessionId,
|
|
1107
|
+
source: "cli",
|
|
1108
|
+
});
|
|
1109
|
+
const queuedCount = this.store.countQueuedPrompts(record.localSessionId);
|
|
1110
|
+
await this.scheduleNextQueuedPrompt(record.localSessionId);
|
|
1111
|
+
return { promptId: queued.promptId, queued: queuedCount };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
controlTaskAgent(localSessionId: string, agentName?: string): {
|
|
1115
|
+
current: string;
|
|
1116
|
+
agents: Array<{ name: string; title: string }>;
|
|
1117
|
+
} {
|
|
1118
|
+
const record = this.requireControlSession(localSessionId);
|
|
1119
|
+
const contextKey = this.controlSessionContextKey(record);
|
|
1120
|
+
const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
|
|
1121
|
+
if (agentName) {
|
|
1122
|
+
this.ensureAgent(agentName);
|
|
1123
|
+
this.store.setDefaultAgent(contextKey, agentName);
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
current: agentName ?? context.defaultAgent,
|
|
1127
|
+
agents: Object.entries(this.config.agents).map(([name, agent]) => ({ name, title: agent.title })),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async controlTaskSettings(
|
|
1132
|
+
localSessionId: string,
|
|
1133
|
+
setting?: "provider" | "model" | "thinking" | "permissions",
|
|
1134
|
+
value?: string,
|
|
1135
|
+
): Promise<{
|
|
1136
|
+
session: SessionRecord;
|
|
1137
|
+
providers: Awaited<ReturnType<NonNullable<AgentRuntime["listModelProviders"]>>>;
|
|
1138
|
+
models: Awaited<ReturnType<AgentRuntime["listModels"]>>;
|
|
1139
|
+
reasoningOptions: Awaited<ReturnType<AgentRuntime["listModels"]>>[number]["supportedReasoningEfforts"];
|
|
1140
|
+
permissionModes: PermissionMode[];
|
|
1141
|
+
}> {
|
|
1142
|
+
const record = this.requireControlSession(localSessionId);
|
|
1143
|
+
const loaded = await this.loadSession(record);
|
|
1144
|
+
if (setting && !value?.trim()) throw new Error(`task ${setting} requires a value.`);
|
|
1145
|
+
const nextValue = value?.trim();
|
|
1146
|
+
if (setting === "provider" && nextValue) {
|
|
1147
|
+
await this.assertModelProvider(loaded, nextValue);
|
|
1148
|
+
const models = await loaded.runtime.listModels();
|
|
1149
|
+
const model = models.find((candidate) => candidate.id === loaded.session.model)
|
|
1150
|
+
?? models.find((candidate) => candidate.isDefault);
|
|
1151
|
+
if (!model) throw new Error("The current runtime has no model available for a Provider change.");
|
|
1152
|
+
const effort = model.supportedReasoningEfforts.some(
|
|
1153
|
+
(candidate) => candidate.value === loaded.session.reasoningEffort,
|
|
1154
|
+
)
|
|
1155
|
+
? loaded.session.reasoningEffort
|
|
1156
|
+
: model.defaultReasoningEffort ?? model.supportedReasoningEfforts[0]?.value;
|
|
1157
|
+
if (!effort) throw new Error(`Model ${model.id} has no reasoning effort available for a Provider change.`);
|
|
1158
|
+
if (!loaded.runtime.setExecutionSettings) throw new Error("The current runtime does not support Provider settings.");
|
|
1159
|
+
const updated = await loaded.runtime.setExecutionSettings(record.localSessionId, {
|
|
1160
|
+
modelProvider: nextValue,
|
|
1161
|
+
model: model.id,
|
|
1162
|
+
reasoningEffort: effort,
|
|
1163
|
+
permissionMode: loaded.session.permissionMode,
|
|
1164
|
+
});
|
|
1165
|
+
this.store.updateRuntimeSession(record.localSessionId, {
|
|
1166
|
+
modelProvider: updated.modelProvider ?? nextValue,
|
|
1167
|
+
model: updated.model ?? model.id,
|
|
1168
|
+
reasoningEffort: updated.reasoningEffort ?? effort,
|
|
1169
|
+
permissionMode: updated.permissionMode,
|
|
1170
|
+
});
|
|
1171
|
+
} else if (setting === "model" && nextValue) {
|
|
1172
|
+
const models = await loaded.runtime.listModels();
|
|
1173
|
+
const selected = models.find((candidate) => candidate.id === nextValue);
|
|
1174
|
+
if (!selected) throw new Error(`Unknown model: ${nextValue}`);
|
|
1175
|
+
const currentEffort = loaded.session.reasoningEffort;
|
|
1176
|
+
const nextEffort = currentEffort && selected.supportedReasoningEfforts.some(
|
|
1177
|
+
(candidate) => candidate.value === currentEffort,
|
|
1178
|
+
)
|
|
1179
|
+
? currentEffort
|
|
1180
|
+
: selected.defaultReasoningEffort;
|
|
1181
|
+
await loaded.runtime.setModel(record.localSessionId, nextValue);
|
|
1182
|
+
if (nextEffort && nextEffort !== currentEffort) {
|
|
1183
|
+
await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
|
|
1184
|
+
}
|
|
1185
|
+
this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
|
|
1186
|
+
} else if (setting === "thinking" && nextValue) {
|
|
1187
|
+
const models = await loaded.runtime.listModels();
|
|
1188
|
+
const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
|
|
1189
|
+
?? models.find((candidate) => candidate.isDefault);
|
|
1190
|
+
if (!currentModel) throw new Error("The current runtime has no model with configurable reasoning effort.");
|
|
1191
|
+
if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
|
|
1192
|
+
const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
|
|
1193
|
+
throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
|
|
1194
|
+
}
|
|
1195
|
+
await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
|
|
1196
|
+
this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
|
|
1197
|
+
} else if (setting === "permissions" && nextValue) {
|
|
1198
|
+
if (nextValue !== "auto" && nextValue !== "confirm") {
|
|
1199
|
+
throw new Error("task permissions accepts auto or confirm.");
|
|
1200
|
+
}
|
|
1201
|
+
await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
|
|
1202
|
+
this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
|
|
1203
|
+
}
|
|
1204
|
+
const session = this.store.getSession(record.localSessionId) ?? record;
|
|
1205
|
+
const models = await loaded.runtime.listModels();
|
|
1206
|
+
const currentModel = models.find((candidate) => candidate.id === session.model)
|
|
1207
|
+
?? models.find((candidate) => candidate.isDefault);
|
|
1208
|
+
const providers = loaded.runtime.listModelProviders
|
|
1209
|
+
? await loaded.runtime.listModelProviders()
|
|
1210
|
+
: [];
|
|
1211
|
+
return {
|
|
1212
|
+
session,
|
|
1213
|
+
providers,
|
|
1214
|
+
models,
|
|
1215
|
+
reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
|
|
1216
|
+
permissionModes: ["auto", "confirm"],
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
async controlTaskGoal(
|
|
1221
|
+
localSessionId: string,
|
|
1222
|
+
action: "show" | "set" | "edit" | "pause" | "resume" | "clear",
|
|
1223
|
+
objective?: string,
|
|
1224
|
+
): Promise<{ goal?: RuntimeGoal; cleared?: boolean }> {
|
|
1225
|
+
const record = this.requireControlSession(localSessionId);
|
|
1226
|
+
const loaded = await this.loadSession(record);
|
|
1227
|
+
if (loaded.runtime.kind !== "codex" || !loaded.runtime.getGoal || !loaded.runtime.setGoal || !loaded.runtime.clearGoal) {
|
|
1228
|
+
throw new Error("The current Agent does not support Goal mode.");
|
|
1229
|
+
}
|
|
1230
|
+
if (action === "show") return { goal: await loaded.runtime.getGoal(record.localSessionId) };
|
|
1231
|
+
if (action === "clear") return { cleared: await loaded.runtime.clearGoal(record.localSessionId) };
|
|
1232
|
+
const current = await loaded.runtime.getGoal(record.localSessionId);
|
|
1233
|
+
if (action === "pause" || action === "resume") {
|
|
1234
|
+
if (!current) throw new Error("The task has no Goal.");
|
|
1235
|
+
return { goal: await loaded.runtime.setGoal(record.localSessionId, { status: action === "pause" ? "paused" : "active" }) };
|
|
1236
|
+
}
|
|
1237
|
+
const nextObjective = objective?.trim();
|
|
1238
|
+
if (!nextObjective) throw new Error(`task goal ${action} requires an objective.`);
|
|
1239
|
+
validateGoalObjective(nextObjective);
|
|
1240
|
+
if (action === "set" && current && current.status !== "complete") {
|
|
1241
|
+
throw new Error("The task already has an unfinished Goal. Use task goal <task> edit <objective>, or clear it first.");
|
|
1242
|
+
}
|
|
1243
|
+
if (action === "edit" && !current) throw new Error("The task has no Goal to edit.");
|
|
1244
|
+
return {
|
|
1245
|
+
goal: await loaded.runtime.setGoal(record.localSessionId, {
|
|
1246
|
+
objective: nextObjective,
|
|
1247
|
+
status: action === "edit" ? current!.status : "active",
|
|
1248
|
+
...(action === "edit" ? { tokenBudget: current!.tokenBudget } : {}),
|
|
1249
|
+
}),
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
controlListTaskTurns(localSessionId: string): {
|
|
1254
|
+
session: SessionRecord;
|
|
1255
|
+
turns: Array<{
|
|
1256
|
+
sequence: number;
|
|
1257
|
+
turnId: string;
|
|
1258
|
+
parentTurnId?: string;
|
|
1259
|
+
prompt?: string;
|
|
1260
|
+
startedAt?: number;
|
|
1261
|
+
completedAt?: number;
|
|
1262
|
+
current: boolean;
|
|
1263
|
+
}>;
|
|
1264
|
+
} {
|
|
1265
|
+
const session = this.requireControlSession(localSessionId);
|
|
1266
|
+
if (!session.remoteSessionId || !this.isCodexSession(session)) {
|
|
1267
|
+
throw new Error("The task is not an App Server task that can be reset.");
|
|
1268
|
+
}
|
|
1269
|
+
const contextKey = this.controlSessionContextKey(session);
|
|
1270
|
+
const rows = this.store.listCompletedTurnGraph(session.localSessionId, contextKey);
|
|
1271
|
+
const graph = buildTurnGraphRows(rows.map((turn) => ({ turnId: turn.turnId, parentTurnId: turn.parentTurnId })));
|
|
1272
|
+
return {
|
|
1273
|
+
session,
|
|
1274
|
+
turns: rows.flatMap((turn, index) => {
|
|
1275
|
+
const snapshot = turnViewSnapshot(turn.snapshot);
|
|
1276
|
+
if (!snapshot) return [];
|
|
1277
|
+
return [{
|
|
1278
|
+
sequence: graph[index]!.sequence,
|
|
1279
|
+
turnId: turn.turnId,
|
|
1280
|
+
parentTurnId: turn.parentTurnId,
|
|
1281
|
+
prompt: snapshot.prompt ?? snapshot.taskTitle,
|
|
1282
|
+
startedAt: snapshot.startedAt,
|
|
1283
|
+
completedAt: snapshot.completedAt,
|
|
1284
|
+
current: session.lastTurnId === turn.turnId && session.lastTurnStatus === "completed",
|
|
1285
|
+
}];
|
|
1286
|
+
}),
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async controlResetTaskToTurn(localSessionId: string, turnId: string): Promise<SessionRecord> {
|
|
1291
|
+
const record = this.requireControlSession(localSessionId);
|
|
1292
|
+
const contextKey = this.controlSessionContextKey(record);
|
|
1293
|
+
const previous = this.sessionResets.get(record.localSessionId) ?? Promise.resolve();
|
|
1294
|
+
const reset = previous.catch(() => undefined).then(() =>
|
|
1295
|
+
this.performSessionReset(contextKey, record, turnId, false));
|
|
1296
|
+
this.sessionResets.set(record.localSessionId, reset);
|
|
1297
|
+
try {
|
|
1298
|
+
await reset;
|
|
1299
|
+
} finally {
|
|
1300
|
+
if (this.sessionResets.get(record.localSessionId) === reset) this.sessionResets.delete(record.localSessionId);
|
|
1301
|
+
}
|
|
1302
|
+
return this.store.getSession(record.localSessionId) ?? record;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
controlTaskMute(localSessionId: string, enabled?: boolean): { contextKey: string; enabled: boolean } {
|
|
1306
|
+
const record = this.requireControlSession(localSessionId);
|
|
1307
|
+
const contextKey = baseChatContextKey(this.controlSessionContextKey(record));
|
|
1308
|
+
const chat = this.store.getChatContext(contextKey);
|
|
1309
|
+
if (chat?.chatType !== "group") throw new Error("task mute is only available for group chats.");
|
|
1310
|
+
const next = enabled ?? Boolean(chat.requiresMention);
|
|
1311
|
+
if (enabled !== undefined) this.store.setChatRequiresMention(contextKey, enabled);
|
|
1312
|
+
return { contextKey, enabled: next };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async controlRunTaskShell(localSessionId: string, command: string): Promise<ShellCommandResult & {
|
|
1316
|
+
cwd: string;
|
|
1317
|
+
command: string;
|
|
1318
|
+
}> {
|
|
1319
|
+
const record = this.requireControlSession(localSessionId);
|
|
1320
|
+
const normalized = command.trim();
|
|
1321
|
+
if (!normalized) throw new Error("task shell requires a command.");
|
|
1322
|
+
return { ...await this.shellCommandExecutor(normalized, record.cwd), cwd: record.cwd, command: normalized };
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async controlListTaskDirectory(
|
|
1326
|
+
localSessionId: string,
|
|
1327
|
+
requestedDirectory?: string,
|
|
1328
|
+
requestedPage = 0,
|
|
1329
|
+
): Promise<{
|
|
1330
|
+
directory: string;
|
|
1331
|
+
parentDirectory?: string;
|
|
1332
|
+
page: number;
|
|
1333
|
+
totalPages: number;
|
|
1334
|
+
totalEntries: number;
|
|
1335
|
+
entries: Array<{
|
|
1336
|
+
name: string;
|
|
1337
|
+
path: string;
|
|
1338
|
+
kind: "directory" | "image" | "binary" | "file" | "drive";
|
|
1339
|
+
}>;
|
|
1340
|
+
}> {
|
|
1341
|
+
const record = this.requireControlSession(localSessionId);
|
|
1342
|
+
if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
|
|
1343
|
+
const drives = await this.windowsDriveLister();
|
|
1344
|
+
const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
|
|
1345
|
+
const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
|
|
1346
|
+
return {
|
|
1347
|
+
directory: WINDOWS_DRIVES_DIRECTORY,
|
|
1348
|
+
page,
|
|
1349
|
+
totalPages,
|
|
1350
|
+
totalEntries: drives.length,
|
|
1351
|
+
entries: drives.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE).map((drive) => ({
|
|
1352
|
+
name: windowsDriveDisplayName(drive),
|
|
1353
|
+
path: drive.root,
|
|
1354
|
+
kind: "drive" as const,
|
|
1355
|
+
})),
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
const directory = requestedDirectory === undefined
|
|
1359
|
+
? path.resolve(record.cwd)
|
|
1360
|
+
: resolveUserPath(requestedDirectory, record.cwd);
|
|
1361
|
+
await this.assertBrowsableDirectory(directory);
|
|
1362
|
+
const entries = (await fs.readdir(directory, { withFileTypes: true }))
|
|
1363
|
+
.map((entry) => ({
|
|
1364
|
+
name: entry.name,
|
|
1365
|
+
path: path.join(directory, entry.name),
|
|
1366
|
+
kind: directoryBrowserEntryKind(entry),
|
|
1367
|
+
}))
|
|
1368
|
+
.sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
|
|
1369
|
+
|| left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
1370
|
+
const totalPages = Math.max(1, Math.ceil(entries.length / DIRECTORY_PAGE_SIZE));
|
|
1371
|
+
const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
|
|
1372
|
+
const parentDirectory = isWindowsDriveRoot(directory) ? WINDOWS_DRIVES_DIRECTORY : path.dirname(directory);
|
|
1373
|
+
return {
|
|
1374
|
+
directory,
|
|
1375
|
+
...(parentDirectory !== directory ? { parentDirectory } : {}),
|
|
1376
|
+
page,
|
|
1377
|
+
totalPages,
|
|
1378
|
+
totalEntries: entries.length,
|
|
1379
|
+
entries: entries.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE),
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
async controlSendTaskFile(localSessionId: string, requestedFilePath: string): Promise<string | undefined> {
|
|
1384
|
+
const record = this.requireControlSession(localSessionId);
|
|
1385
|
+
const filePath = resolveUserPath(requestedFilePath, record.cwd);
|
|
1386
|
+
await this.assertSendableFile(filePath);
|
|
1387
|
+
const contextKey = this.controlSessionContextKey(record);
|
|
1388
|
+
const replyTarget = this.controlSessionReplyTarget(record);
|
|
1389
|
+
return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendFile(contextKey, filePath));
|
|
1390
|
+
}
|
|
1391
|
+
|
|
885
1392
|
private cardActionContextKey(action: CardAction): string {
|
|
886
1393
|
const explicit = typeof action.value.contextKey === "string" ? action.value.contextKey : undefined;
|
|
887
1394
|
if (explicit && baseChatContextKey(explicit) === baseChatContextKey(action.contextKey)) return explicit;
|
|
@@ -916,6 +1423,9 @@ export class ProxySessionController {
|
|
|
916
1423
|
case "shell":
|
|
917
1424
|
await this.runShellCommand(contextKey, command.command);
|
|
918
1425
|
return;
|
|
1426
|
+
case "dir":
|
|
1427
|
+
await this.openDirectoryBrowser(contextKey, command.directory);
|
|
1428
|
+
return;
|
|
919
1429
|
case "new":
|
|
920
1430
|
if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
|
|
921
1431
|
throw new Error("/new --nodir 仅支持 App Server Agent。");
|
|
@@ -974,6 +1484,15 @@ export class ProxySessionController {
|
|
|
974
1484
|
if (command.agent) await this.setDefaultAgent(contextKey, command.agent);
|
|
975
1485
|
else await this.openAgentSettings(contextKey);
|
|
976
1486
|
return;
|
|
1487
|
+
case "archive": {
|
|
1488
|
+
const sessionId = command.sessionId ?? context.currentSessionId;
|
|
1489
|
+
if (!sessionId) throw new Error("当前会话没有可归档的任务。请使用 /sessions 查找任务。");
|
|
1490
|
+
await this.archiveSessionReference(contextKey, sessionId, {
|
|
1491
|
+
announce: true,
|
|
1492
|
+
source: "command",
|
|
1493
|
+
});
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
977
1496
|
case "status":
|
|
978
1497
|
await this.status(contextKey, command.sessionId);
|
|
979
1498
|
return;
|
|
@@ -984,6 +1503,9 @@ export class ProxySessionController {
|
|
|
984
1503
|
if (!this.lifecycle) throw new Error("当前运行方式不支持自动重启。");
|
|
985
1504
|
await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
|
|
986
1505
|
return;
|
|
1506
|
+
case "mute":
|
|
1507
|
+
await this.setGroupMute(contextKey, command.enabled);
|
|
1508
|
+
return;
|
|
987
1509
|
case "turns":
|
|
988
1510
|
await this.openResetHistory(contextKey);
|
|
989
1511
|
return;
|
|
@@ -1016,6 +1538,7 @@ export class ProxySessionController {
|
|
|
1016
1538
|
const turnContextKey = session.lastTurnId
|
|
1017
1539
|
? this.store.getTurnContextKey(session.lastTurnId) ?? context.contextKey
|
|
1018
1540
|
: context.contextKey;
|
|
1541
|
+
if (!this.outbound.canRoute(turnContextKey)) continue;
|
|
1019
1542
|
this.outbound.registerSession(
|
|
1020
1543
|
session.localSessionId,
|
|
1021
1544
|
turnContextKey,
|
|
@@ -1028,16 +1551,386 @@ export class ProxySessionController {
|
|
|
1028
1551
|
this.logger.warn({ error, sessionId: session.localSessionId }, "Failed to restore persisted turn delivery.");
|
|
1029
1552
|
});
|
|
1030
1553
|
}
|
|
1554
|
+
for (const turn of this.store.listUndeliveredCompletedTurns()) {
|
|
1555
|
+
const contextKey = turn.contextKey ?? this.store.getSession(turn.localSessionId)?.contextKey;
|
|
1556
|
+
if (!contextKey || !this.outbound.canRoute(contextKey)) continue;
|
|
1557
|
+
void this.outbound.resumeDelivery(turn.localSessionId, contextKey, turn.turnId).catch((error: unknown) => {
|
|
1558
|
+
this.logger.warn(
|
|
1559
|
+
{ error, sessionId: turn.localSessionId, turnId: turn.turnId },
|
|
1560
|
+
"Failed to resume an undelivered completed turn.",
|
|
1561
|
+
);
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1031
1564
|
}
|
|
1032
1565
|
|
|
1033
1566
|
private restorePersistedQueuedPrompts(): void {
|
|
1567
|
+
if (this.queuedPromptsRestored) return;
|
|
1568
|
+
this.queuedPromptsRestored = true;
|
|
1034
1569
|
for (const sessionId of this.store.listQueuedPromptSessionIds()) {
|
|
1570
|
+
const firstPrompt = this.store.listQueuedPrompts(sessionId)[0];
|
|
1571
|
+
if (firstPrompt && !this.outbound.canRoute(firstPrompt.contextKey)) continue;
|
|
1035
1572
|
void this.scheduleNextQueuedPrompt(sessionId).catch((error: unknown) => {
|
|
1036
1573
|
this.logger.warn({ error, sessionId }, "Failed to resume a persisted prompt queue.");
|
|
1037
1574
|
});
|
|
1038
1575
|
}
|
|
1039
1576
|
}
|
|
1040
1577
|
|
|
1578
|
+
async recoverInterruptedTasks(): Promise<void> {
|
|
1579
|
+
if (this.startupRecovery) return this.startupRecovery;
|
|
1580
|
+
const recovery = this.runStartupRecovery();
|
|
1581
|
+
this.startupRecovery = recovery;
|
|
1582
|
+
return recovery;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
private async runStartupRecovery(): Promise<void> {
|
|
1586
|
+
this.backfillTurnAttemptsForUpgrade();
|
|
1587
|
+
const attempts = this.store.listIncompleteTurnAttempts();
|
|
1588
|
+
const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
|
|
1589
|
+
const recentAttempts = attempts.filter((attempt) => isRecoveryAttemptRecent(attempt, recoveryCutoff));
|
|
1590
|
+
if (recentAttempts.length > 0) {
|
|
1591
|
+
this.logger.warn(
|
|
1592
|
+
{ attemptIds: recentAttempts.map((attempt) => attempt.attemptId) },
|
|
1593
|
+
"Recovering unfinished Agent Bot tasks after startup.",
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
for (const attempt of attempts) {
|
|
1597
|
+
if (!isRecoveryAttemptRecent(attempt, recoveryCutoff)) {
|
|
1598
|
+
await this.expireStaleTurnAttempt(attempt);
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
if (!this.outbound.canRoute(attempt.contextKey)) continue;
|
|
1602
|
+
try {
|
|
1603
|
+
await this.recoverTurnAttempt(attempt);
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
this.logger.error(
|
|
1606
|
+
{ error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId },
|
|
1607
|
+
"Failed to recover an unfinished task; it will be retried while the server is running.",
|
|
1608
|
+
);
|
|
1609
|
+
this.scheduleRecoveryRetry(attempt.attemptId);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
this.restorePersistedQueuedPrompts();
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
private backfillTurnAttemptsForUpgrade(): void {
|
|
1616
|
+
for (const session of this.store.listAllSessions()) {
|
|
1617
|
+
if (session.status !== "starting" && session.status !== "running" && session.status !== "ready") continue;
|
|
1618
|
+
if (this.store.findIncompleteTurnAttemptForSession(session.localSessionId)) continue;
|
|
1619
|
+
const lastTurn = session.lastTurnId
|
|
1620
|
+
? {
|
|
1621
|
+
turnId: session.lastTurnId,
|
|
1622
|
+
localSessionId: session.localSessionId,
|
|
1623
|
+
contextKey: this.store.getTurnContextKey(session.lastTurnId),
|
|
1624
|
+
snapshot: this.store.getTurnSnapshot(session.lastTurnId),
|
|
1625
|
+
updatedAt: session.updatedAt,
|
|
1626
|
+
}
|
|
1627
|
+
: undefined;
|
|
1628
|
+
const lastTurnSnapshot = turnViewSnapshot(lastTurn?.snapshot);
|
|
1629
|
+
const latestPersisted = this.store.findLatestTurnSnapshotForSession(session.localSessionId);
|
|
1630
|
+
const latestPersistedSnapshot = turnViewSnapshot(latestPersisted?.snapshot);
|
|
1631
|
+
const latest = lastTurnSnapshot && !isTerminalTurnViewStatus(lastTurnSnapshot.status)
|
|
1632
|
+
? lastTurn
|
|
1633
|
+
: latestPersistedSnapshot && !isTerminalTurnViewStatus(latestPersistedSnapshot.status)
|
|
1634
|
+
? latestPersisted
|
|
1635
|
+
: session.status === "ready"
|
|
1636
|
+
? undefined
|
|
1637
|
+
: lastTurn;
|
|
1638
|
+
if (!latest && session.status === "ready") continue;
|
|
1639
|
+
const snapshot = turnViewSnapshot(latest?.snapshot);
|
|
1640
|
+
if (snapshot && isTerminalTurnViewStatus(snapshot.status)) {
|
|
1641
|
+
this.store.updateSession(session.localSessionId, {
|
|
1642
|
+
status: snapshot.status === "failed" ? "failed" : "ready",
|
|
1643
|
+
});
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
const persistedTurnId = latest?.turnId;
|
|
1647
|
+
const pendingTurnId = persistedTurnId?.startsWith("pending_") ? persistedTurnId : undefined;
|
|
1648
|
+
const turnId = pendingTurnId ? undefined : persistedTurnId ?? session.lastTurnId;
|
|
1649
|
+
const replyTarget = snapshot?.replyTarget?.replyInThread ? snapshot.replyTarget : undefined;
|
|
1650
|
+
this.store.createTurnAttempt({
|
|
1651
|
+
attemptId: createId("attempt"),
|
|
1652
|
+
localSessionId: session.localSessionId,
|
|
1653
|
+
contextKey: latest?.contextKey ?? session.contextKey,
|
|
1654
|
+
promptText: snapshot?.prompt?.trim() || "继续完成重启前尚未完成的任务。",
|
|
1655
|
+
messageId: turnId ? this.store.findMessageIdForTurn(turnId) : undefined,
|
|
1656
|
+
replyMessageId: replyTarget?.messageId,
|
|
1657
|
+
pendingTurnId,
|
|
1658
|
+
turnId,
|
|
1659
|
+
status: turnId ? "running" : "accepted",
|
|
1660
|
+
createdAt: latest?.updatedAt ?? session.updatedAt,
|
|
1661
|
+
updatedAt: latest?.updatedAt ?? session.updatedAt,
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
private async expireStaleTurnAttempt(attempt: TurnAttemptRecord): Promise<void> {
|
|
1667
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1668
|
+
const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
|
|
1669
|
+
if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
|
|
1670
|
+
await this.outbound.interruptTurnForRecovery(
|
|
1671
|
+
session.localSessionId,
|
|
1672
|
+
attempt.contextKey,
|
|
1673
|
+
oldCardTurnId,
|
|
1674
|
+
"执行中断已超过 5 分钟,未自动恢复。",
|
|
1675
|
+
).catch((error: unknown) => {
|
|
1676
|
+
this.logger.warn(
|
|
1677
|
+
{ error, attemptId: attempt.attemptId, turnId: oldCardTurnId },
|
|
1678
|
+
"Failed to close an expired interrupted thinking card.",
|
|
1679
|
+
);
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
|
|
1683
|
+
if (!session || session.status === "closed") return;
|
|
1684
|
+
if (attempt.turnId && session.lastTurnId === attempt.turnId) {
|
|
1685
|
+
this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
|
|
1686
|
+
}
|
|
1687
|
+
if (session.status === "starting" || session.status === "running") {
|
|
1688
|
+
this.store.updateSession(session.localSessionId, { status: "ready" });
|
|
1689
|
+
}
|
|
1690
|
+
if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
|
|
1691
|
+
await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error: unknown) => {
|
|
1692
|
+
this.logger.warn(
|
|
1693
|
+
{ error, attemptId: attempt.attemptId, turnId: attempt.turnId },
|
|
1694
|
+
"Failed to finalize the message reaction for an expired interrupted turn.",
|
|
1695
|
+
);
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
private persistActiveTurnHeartbeats(): void {
|
|
1701
|
+
for (const attempt of this.store.listIncompleteTurnAttempts()) {
|
|
1702
|
+
if (!attempt.turnId) continue;
|
|
1703
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1704
|
+
if (!session) continue;
|
|
1705
|
+
const activeTurnId = this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId;
|
|
1706
|
+
if (activeTurnId === attempt.turnId) this.store.touchTurnAttempt(attempt.turnId);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
private async recoverTurnAttempt(attempt: TurnAttemptRecord, announce = true): Promise<void> {
|
|
1711
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1712
|
+
if (!session || session.status === "closed") {
|
|
1713
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const contextKey = attempt.contextKey || session.contextKey;
|
|
1717
|
+
const replyTarget = attempt.replyMessageId
|
|
1718
|
+
? { messageId: attempt.replyMessageId, replyInThread: true as const }
|
|
1719
|
+
: undefined;
|
|
1720
|
+
this.outbound.registerSession(
|
|
1721
|
+
session.localSessionId,
|
|
1722
|
+
contextKey,
|
|
1723
|
+
session.title,
|
|
1724
|
+
session.cwd,
|
|
1725
|
+
this.agentLabel(session.agentName),
|
|
1726
|
+
);
|
|
1727
|
+
if (announce) {
|
|
1728
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () =>
|
|
1729
|
+
this.outbound.sendText(contextKey, "检测到任务在 Agent Bot 重启前尚未完成,正在自动恢复。"),
|
|
1730
|
+
).catch((error: unknown) => {
|
|
1731
|
+
this.logger.warn(
|
|
1732
|
+
{ error, attemptId: attempt.attemptId, contextKey },
|
|
1733
|
+
"Failed to send the task recovery notification.",
|
|
1734
|
+
);
|
|
1735
|
+
return undefined;
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
const runtime = this.runtimes.forAgent(session.agentName);
|
|
1740
|
+
const sourceTurnId = attempt.turnId;
|
|
1741
|
+
if (runtime.kind === "acp") {
|
|
1742
|
+
await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
let remote: RemoteSessionSummary | undefined;
|
|
1747
|
+
if (session.remoteSessionId && runtime.readRemoteSession) {
|
|
1748
|
+
try {
|
|
1749
|
+
remote = await runtime.readRemoteSession(session.remoteSessionId);
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
this.logger.warn(
|
|
1752
|
+
{ error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId },
|
|
1753
|
+
"Failed to read the remote task during startup recovery; treating the old local execution as interrupted.",
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
|
|
1758
|
+
if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "completed") {
|
|
1759
|
+
await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "failed") {
|
|
1763
|
+
await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
if (remote && remoteTurnBelongsToAttempt && isRemoteSessionActive(remote) && remote.lastTurnId) {
|
|
1767
|
+
await this.reattachActiveRecoveredTurn(attempt, session, remote.lastTurnId, replyTarget);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (remote && isRemoteSessionActive(remote) && !remoteTurnBelongsToAttempt) {
|
|
1771
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "recovering" });
|
|
1772
|
+
if (announce) {
|
|
1773
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () =>
|
|
1774
|
+
this.outbound.sendText(contextKey, "任务中检测到另一轮仍在执行;本次恢复将在它结束后重试。"),
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
this.scheduleRecoveryRetry(attempt.attemptId);
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
private async reconcileRecoveredRemoteTurn(
|
|
1784
|
+
attempt: TurnAttemptRecord,
|
|
1785
|
+
session: SessionRecord,
|
|
1786
|
+
remoteTurnId: string | undefined,
|
|
1787
|
+
): Promise<void> {
|
|
1788
|
+
if (remoteTurnId && !attempt.turnId) {
|
|
1789
|
+
this.store.updateTurnAttempt(attempt.attemptId, { turnId: remoteTurnId, status: "running" });
|
|
1790
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
1791
|
+
lastTurnId: remoteTurnId,
|
|
1792
|
+
lastTurnStatus: "running",
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1796
|
+
const loaded = await this.loadSession({
|
|
1797
|
+
...current,
|
|
1798
|
+
contextKey: attempt.contextKey,
|
|
1799
|
+
status: "running",
|
|
1800
|
+
lastTurnStatus: "running",
|
|
1801
|
+
});
|
|
1802
|
+
const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
|
|
1803
|
+
if (synchronized.activeTurnId) this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
private scheduleRecoveryRetry(attemptId: string, activeTurnId?: string): void {
|
|
1807
|
+
if (this.recoveryRetryTimers.has(attemptId)) return;
|
|
1808
|
+
const timer = setTimeout(() => {
|
|
1809
|
+
this.recoveryRetryTimers.delete(attemptId);
|
|
1810
|
+
const attempt = this.store.getTurnAttempt(attemptId);
|
|
1811
|
+
if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status)) return;
|
|
1812
|
+
const retry = async (): Promise<void> => {
|
|
1813
|
+
let recoveryAttempt = attempt;
|
|
1814
|
+
if (activeTurnId) {
|
|
1815
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1816
|
+
if (session) {
|
|
1817
|
+
const runtime = this.runtimes.forAgent(session.agentName);
|
|
1818
|
+
const synchronized = await runtime.synchronizeSession(session.localSessionId);
|
|
1819
|
+
if (synchronized.activeTurnId === activeTurnId) {
|
|
1820
|
+
this.scheduleRecoveryRetry(attemptId, activeTurnId);
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
const current = this.store.getTurnAttempt(attemptId);
|
|
1824
|
+
if (!current || !isIncompleteTurnAttemptStatus(current.status)) return;
|
|
1825
|
+
recoveryAttempt = current;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
await this.recoverTurnAttempt(recoveryAttempt, false);
|
|
1829
|
+
};
|
|
1830
|
+
void retry().catch((error: unknown) => {
|
|
1831
|
+
this.logger.warn(
|
|
1832
|
+
{ error, attemptId, sessionId: attempt.localSessionId },
|
|
1833
|
+
"Failed to retry an unfinished task recovery.",
|
|
1834
|
+
);
|
|
1835
|
+
this.scheduleRecoveryRetry(attemptId, activeTurnId);
|
|
1836
|
+
});
|
|
1837
|
+
}, 5_000);
|
|
1838
|
+
timer.unref?.();
|
|
1839
|
+
this.recoveryRetryTimers.set(attemptId, timer);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
private async reattachActiveRecoveredTurn(
|
|
1843
|
+
attempt: TurnAttemptRecord,
|
|
1844
|
+
session: SessionRecord,
|
|
1845
|
+
turnId: string,
|
|
1846
|
+
replyTarget?: MessageReplyTarget,
|
|
1847
|
+
): Promise<void> {
|
|
1848
|
+
const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
|
|
1849
|
+
if (oldCardTurnId) {
|
|
1850
|
+
await this.outbound.interruptTurnForRecovery(
|
|
1851
|
+
session.localSessionId,
|
|
1852
|
+
attempt.contextKey,
|
|
1853
|
+
oldCardTurnId,
|
|
1854
|
+
"Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。",
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
const pendingTurnId = await this.outbound.startPendingTurn(
|
|
1858
|
+
session.localSessionId,
|
|
1859
|
+
attempt.contextKey,
|
|
1860
|
+
session.title,
|
|
1861
|
+
replyTarget,
|
|
1862
|
+
recoveryCardPrompt(attempt.promptText),
|
|
1863
|
+
);
|
|
1864
|
+
this.store.updateTurnAttempt(attempt.attemptId, {
|
|
1865
|
+
pendingTurnId: pendingTurnId ?? null,
|
|
1866
|
+
turnId,
|
|
1867
|
+
status: "running",
|
|
1868
|
+
});
|
|
1869
|
+
await this.outbound.onEvent({
|
|
1870
|
+
type: "turn_started",
|
|
1871
|
+
sessionId: session.localSessionId,
|
|
1872
|
+
turnId,
|
|
1873
|
+
startedAt: Date.now(),
|
|
1874
|
+
});
|
|
1875
|
+
this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
|
|
1876
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1877
|
+
const loaded = await this.loadSession({
|
|
1878
|
+
...current,
|
|
1879
|
+
contextKey: attempt.contextKey,
|
|
1880
|
+
status: "running",
|
|
1881
|
+
lastTurnId: turnId,
|
|
1882
|
+
lastTurnStatus: "running",
|
|
1883
|
+
});
|
|
1884
|
+
const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
|
|
1885
|
+
if (synchronized.activeTurnId) this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
private async continueInterruptedAttempt(
|
|
1889
|
+
attempt: TurnAttemptRecord,
|
|
1890
|
+
session: SessionRecord,
|
|
1891
|
+
sourceTurnId: string | undefined,
|
|
1892
|
+
replyTarget?: MessageReplyTarget,
|
|
1893
|
+
): Promise<void> {
|
|
1894
|
+
const oldCardTurnId = attempt.pendingTurnId ?? sourceTurnId;
|
|
1895
|
+
if (oldCardTurnId) {
|
|
1896
|
+
await this.outbound.interruptTurnForRecovery(
|
|
1897
|
+
session.localSessionId,
|
|
1898
|
+
attempt.contextKey,
|
|
1899
|
+
oldCardTurnId,
|
|
1900
|
+
"Agent Bot 重启中断了这次执行,正在新的思考卡片中继续。",
|
|
1901
|
+
).catch((error: unknown) => {
|
|
1902
|
+
this.logger.warn(
|
|
1903
|
+
{ error, attemptId: attempt.attemptId, turnId: oldCardTurnId },
|
|
1904
|
+
"Failed to close the interrupted thinking card before recovery.",
|
|
1905
|
+
);
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
this.store.prepareTurnAttemptRecovery(attempt.attemptId, oldCardTurnId);
|
|
1909
|
+
this.store.updateSession(session.localSessionId, { status: "ready" });
|
|
1910
|
+
if (sourceTurnId) {
|
|
1911
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
1912
|
+
lastTurnId: sourceTurnId,
|
|
1913
|
+
lastTurnStatus: "cancelled",
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1917
|
+
const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
|
|
1918
|
+
const recoveryText = recoveryRuntimePrompt(attempt.promptText);
|
|
1919
|
+
const turnId = await this.startTurn(
|
|
1920
|
+
loaded,
|
|
1921
|
+
recoveryText,
|
|
1922
|
+
replyTarget,
|
|
1923
|
+
attempt.localImagePaths,
|
|
1924
|
+
{
|
|
1925
|
+
attemptId: attempt.attemptId,
|
|
1926
|
+
messageId: attempt.messageId,
|
|
1927
|
+
displayPrompt: recoveryCardPrompt(attempt.promptText),
|
|
1928
|
+
preserveAttemptOnFailure: true,
|
|
1929
|
+
},
|
|
1930
|
+
);
|
|
1931
|
+
if (attempt.messageId) await this.bindMessageReactionToTurn(attempt.messageId, session.localSessionId, turnId);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1041
1934
|
private async restorePersistedMessageReactions(): Promise<void> {
|
|
1042
1935
|
const pending = this.store.listPendingMessageReactions();
|
|
1043
1936
|
const terminalTurns = new Map<string, "completed" | "failed" | "cancelled">();
|
|
@@ -1086,6 +1979,19 @@ export class ProxySessionController {
|
|
|
1086
1979
|
replyTarget?: MessageReplyTarget,
|
|
1087
1980
|
localImagePaths?: string[],
|
|
1088
1981
|
): Promise<void> {
|
|
1982
|
+
if (this.llmRetryingSessions.has(record.localSessionId)) {
|
|
1983
|
+
const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
|
|
1984
|
+
localImagePaths,
|
|
1985
|
+
messageId,
|
|
1986
|
+
replyTarget,
|
|
1987
|
+
});
|
|
1988
|
+
this.store.audit(contextKey, "queued_prompt_added_during_llm_retry", {
|
|
1989
|
+
promptId: queued.promptId,
|
|
1990
|
+
localSessionId: record.localSessionId,
|
|
1991
|
+
});
|
|
1992
|
+
await this.presentPromptQueueCard(record.localSessionId, contextKey);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1089
1995
|
const configuredRuntime = this.runtimes.forAgent(record.agentName);
|
|
1090
1996
|
if (!configuredRuntime.getSession(record.localSessionId)) {
|
|
1091
1997
|
this.outbound.registerSession(
|
|
@@ -1143,7 +2049,7 @@ export class ProxySessionController {
|
|
|
1143
2049
|
this.logger.warn({ error: syncError, sessionId: record.localSessionId }, "Failed to synchronize after steering failure.");
|
|
1144
2050
|
}
|
|
1145
2051
|
if (!current?.activeTurnId) {
|
|
1146
|
-
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
|
|
2052
|
+
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
|
|
1147
2053
|
if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
|
|
1148
2054
|
return;
|
|
1149
2055
|
}
|
|
@@ -1172,7 +2078,7 @@ export class ProxySessionController {
|
|
|
1172
2078
|
return;
|
|
1173
2079
|
}
|
|
1174
2080
|
}
|
|
1175
|
-
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
|
|
2081
|
+
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
|
|
1176
2082
|
if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
|
|
1177
2083
|
}
|
|
1178
2084
|
|
|
@@ -1182,6 +2088,7 @@ export class ProxySessionController {
|
|
|
1182
2088
|
text: string,
|
|
1183
2089
|
messageId?: string,
|
|
1184
2090
|
): Promise<void> {
|
|
2091
|
+
this.store.touchTurnAttempt(turnId);
|
|
1185
2092
|
try {
|
|
1186
2093
|
await this.outbound.appendSteerMessage(localSessionId, turnId, text, messageId);
|
|
1187
2094
|
} catch (error) {
|
|
@@ -1311,6 +2218,9 @@ export class ProxySessionController {
|
|
|
1311
2218
|
if (!message.threadContext || !message.threadId || !message.chatId) return;
|
|
1312
2219
|
const current = this.store.getUserContext(message.contextKey)?.currentSessionId;
|
|
1313
2220
|
if (current && this.store.getSession(current)) return;
|
|
2221
|
+
const hasPersistedSourceTurn = threadForkAnchorMessageIds(message)
|
|
2222
|
+
.some((messageId) => this.store.findTurnAnchorByMessageId(messageId) !== undefined);
|
|
2223
|
+
if (!hasPersistedSourceTurn) return;
|
|
1314
2224
|
|
|
1315
2225
|
const existing = this.threadInitializations.get(message.contextKey);
|
|
1316
2226
|
if (existing) return existing;
|
|
@@ -1401,9 +2311,7 @@ export class ProxySessionController {
|
|
|
1401
2311
|
if (!message.threadContext || !message.threadId || !message.chatId) {
|
|
1402
2312
|
throw new Error("当前消息不属于可识别的飞书话题。");
|
|
1403
2313
|
}
|
|
1404
|
-
const
|
|
1405
|
-
.filter((messageId): messageId is string => Boolean(messageId && messageId !== message.messageId));
|
|
1406
|
-
const anchor = [...new Set(anchorMessageIds)]
|
|
2314
|
+
const anchor = threadForkAnchorMessageIds(message)
|
|
1407
2315
|
.map((messageId) => this.store.findTurnAnchorByMessageId(messageId))
|
|
1408
2316
|
.find((candidate) => candidate !== undefined);
|
|
1409
2317
|
if (!anchor) {
|
|
@@ -1785,31 +2693,60 @@ export class ProxySessionController {
|
|
|
1785
2693
|
text: string,
|
|
1786
2694
|
replyTarget?: MessageReplyTarget,
|
|
1787
2695
|
localImagePaths?: string[],
|
|
2696
|
+
options: StartTurnOptions = {},
|
|
1788
2697
|
): Promise<string> {
|
|
1789
2698
|
const currentRecord = this.store.getSession(loaded.record.localSessionId);
|
|
1790
2699
|
const title = currentRecord?.title ?? normalizeTaskTitle(text);
|
|
1791
2700
|
if (!currentRecord?.title && title) this.store.updateRuntimeSession(loaded.record.localSessionId, { title });
|
|
1792
2701
|
if (title) this.outbound.updateSessionTitle(loaded.record.localSessionId, title);
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
2702
|
+
const attemptId = options.attemptId ?? createId("attempt");
|
|
2703
|
+
if (options.attemptId) {
|
|
2704
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
2705
|
+
messageId: options.messageId ?? undefined,
|
|
2706
|
+
replyMessageId: replyTarget?.messageId ?? undefined,
|
|
2707
|
+
status: "recovering",
|
|
2708
|
+
});
|
|
2709
|
+
} else {
|
|
2710
|
+
this.store.createTurnAttempt({
|
|
2711
|
+
attemptId,
|
|
2712
|
+
localSessionId: loaded.record.localSessionId,
|
|
2713
|
+
contextKey: loaded.record.contextKey,
|
|
2714
|
+
promptText: text,
|
|
2715
|
+
localImagePaths,
|
|
2716
|
+
messageId: options.messageId,
|
|
2717
|
+
replyMessageId: replyTarget?.messageId,
|
|
2718
|
+
});
|
|
2719
|
+
}
|
|
1800
2720
|
let turnId: string;
|
|
1801
2721
|
try {
|
|
2722
|
+
const pendingTurnId = await this.outbound.startPendingTurn(
|
|
2723
|
+
loaded.record.localSessionId,
|
|
2724
|
+
loaded.record.contextKey,
|
|
2725
|
+
title,
|
|
2726
|
+
replyTarget,
|
|
2727
|
+
options.displayPrompt ?? text,
|
|
2728
|
+
);
|
|
2729
|
+
this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
|
|
1802
2730
|
turnId = await loaded.runtime.startTurn(
|
|
1803
2731
|
loaded.record.localSessionId,
|
|
1804
2732
|
runtimePrompt(text, localImagePaths),
|
|
1805
2733
|
);
|
|
1806
2734
|
} catch (error) {
|
|
2735
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
2736
|
+
status: options.preserveAttemptOnFailure ? "recovering" : "failed",
|
|
2737
|
+
});
|
|
1807
2738
|
await this.outbound.failPendingTurn(
|
|
1808
2739
|
loaded.record.localSessionId,
|
|
1809
2740
|
error instanceof Error ? error.message : String(error),
|
|
1810
2741
|
);
|
|
1811
2742
|
throw error;
|
|
1812
2743
|
}
|
|
2744
|
+
const attempt = this.store.getTurnAttempt(attemptId);
|
|
2745
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
2746
|
+
pendingTurnId: null,
|
|
2747
|
+
turnId,
|
|
2748
|
+
...(attempt && isIncompleteTurnAttemptStatus(attempt.status) ? { status: "running" as const } : {}),
|
|
2749
|
+
});
|
|
1813
2750
|
const latest = this.store.getSession(loaded.record.localSessionId);
|
|
1814
2751
|
const alreadyTerminal = latest?.lastTurnId === turnId
|
|
1815
2752
|
&& ["completed", "cancelled", "failed"].includes(latest.lastTurnStatus ?? "");
|
|
@@ -2218,6 +3155,7 @@ export class ProxySessionController {
|
|
|
2218
3155
|
|
|
2219
3156
|
private async handleRuntimeEvent(event: RuntimeEvent): Promise<void> {
|
|
2220
3157
|
if ("turnId" in event) {
|
|
3158
|
+
this.store.touchTurnAttempt(event.turnId);
|
|
2221
3159
|
const source = this.store.getSession(event.sessionId);
|
|
2222
3160
|
if (source?.remoteSessionId && this.isCodexSession(source)) {
|
|
2223
3161
|
this.store.saveTurnRuntimeOrigin(
|
|
@@ -2228,6 +3166,10 @@ export class ProxySessionController {
|
|
|
2228
3166
|
);
|
|
2229
3167
|
}
|
|
2230
3168
|
if (event.type === "turn_started") {
|
|
3169
|
+
const attempt = this.store.bindTurnAttempt(event.sessionId, event.turnId);
|
|
3170
|
+
if (attempt?.recoveredFromTurnId && attempt.recoveredFromTurnId !== event.turnId) {
|
|
3171
|
+
this.store.rebindPendingTurnMessages(event.sessionId, attempt.recoveredFromTurnId, event.turnId);
|
|
3172
|
+
}
|
|
2231
3173
|
this.store.saveTurnParent(
|
|
2232
3174
|
event.turnId,
|
|
2233
3175
|
event.sessionId,
|
|
@@ -2235,6 +3177,20 @@ export class ProxySessionController {
|
|
|
2235
3177
|
);
|
|
2236
3178
|
}
|
|
2237
3179
|
}
|
|
3180
|
+
const failedAttempt = event.type === "turn_failed"
|
|
3181
|
+
? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
|
|
3182
|
+
: undefined;
|
|
3183
|
+
if (event.type === "turn_failed") {
|
|
3184
|
+
if (this.retriedFailureTurnIds.has(event.turnId)) return;
|
|
3185
|
+
if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
|
|
3186
|
+
const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
|
|
3187
|
+
if (retryAttempt) {
|
|
3188
|
+
this.retriedFailureTurnIds.add(event.turnId);
|
|
3189
|
+
await this.retryFailedLlmTurn(event, retryAttempt);
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
2238
3194
|
if (event.type === "session_metadata_updated") {
|
|
2239
3195
|
this.store.updateRuntimeSession(event.sessionId, { title: event.title });
|
|
2240
3196
|
this.outbound.updateSessionTitle(event.sessionId, event.title);
|
|
@@ -2244,15 +3200,27 @@ export class ProxySessionController {
|
|
|
2244
3200
|
this.store.updateSession(event.sessionId, { status: "running" });
|
|
2245
3201
|
this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
|
|
2246
3202
|
} else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
|
|
3203
|
+
this.store.markTurnAttemptTerminal(
|
|
3204
|
+
event.turnId,
|
|
3205
|
+
event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
|
|
3206
|
+
);
|
|
2247
3207
|
this.store.updateRuntimeSession(event.sessionId, {
|
|
2248
3208
|
lastTurnId: event.turnId,
|
|
2249
3209
|
lastTurnStatus: event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
|
|
2250
3210
|
});
|
|
2251
3211
|
}
|
|
2252
3212
|
|
|
3213
|
+
const presentationEvent = event.type === "turn_failed"
|
|
3214
|
+
&& isRetryableLlmTurnFailure(event.message)
|
|
3215
|
+
&& (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
|
|
3216
|
+
? {
|
|
3217
|
+
...event,
|
|
3218
|
+
message: `${event.message}\n\n已自动重试 ${MAX_LLM_TURN_RETRIES} 次,仍未成功。`,
|
|
3219
|
+
}
|
|
3220
|
+
: event;
|
|
2253
3221
|
let presentationError: unknown;
|
|
2254
3222
|
try {
|
|
2255
|
-
await this.outbound.onEvent(
|
|
3223
|
+
await this.outbound.onEvent(presentationEvent);
|
|
2256
3224
|
} catch (error) {
|
|
2257
3225
|
presentationError = error;
|
|
2258
3226
|
}
|
|
@@ -2285,6 +3253,80 @@ export class ProxySessionController {
|
|
|
2285
3253
|
if (presentationError) throw presentationError;
|
|
2286
3254
|
}
|
|
2287
3255
|
|
|
3256
|
+
private async retryFailedLlmTurn(
|
|
3257
|
+
event: Extract<RuntimeEvent, { type: "turn_failed" }>,
|
|
3258
|
+
attempt: TurnAttemptRecord,
|
|
3259
|
+
): Promise<void> {
|
|
3260
|
+
const retryNumber = attempt.retryCount;
|
|
3261
|
+
const session = this.store.getSession(event.sessionId);
|
|
3262
|
+
if (!session || session.status === "closed") {
|
|
3263
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
this.llmRetryingSessions.add(session.localSessionId);
|
|
3268
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
3269
|
+
lastTurnId: event.turnId,
|
|
3270
|
+
lastTurnStatus: "failed",
|
|
3271
|
+
});
|
|
3272
|
+
this.store.audit(attempt.contextKey, "llm_turn_retry_started", {
|
|
3273
|
+
attemptId: attempt.attemptId,
|
|
3274
|
+
failedTurnId: event.turnId,
|
|
3275
|
+
retryNumber,
|
|
3276
|
+
maxRetries: MAX_LLM_TURN_RETRIES,
|
|
3277
|
+
error: event.message,
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
try {
|
|
3281
|
+
await this.outbound.onEvent({
|
|
3282
|
+
...event,
|
|
3283
|
+
message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
|
|
3284
|
+
});
|
|
3285
|
+
} catch (error) {
|
|
3286
|
+
this.logger.warn(
|
|
3287
|
+
{ error, sessionId: session.localSessionId, turnId: event.turnId },
|
|
3288
|
+
"Failed to finalize the failed thinking card before an LLM retry.",
|
|
3289
|
+
);
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
const replyTarget = attempt.replyMessageId
|
|
3293
|
+
? { messageId: attempt.replyMessageId, replyInThread: true as const }
|
|
3294
|
+
: undefined;
|
|
3295
|
+
try {
|
|
3296
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
3297
|
+
const loaded = await this.loadSession(current);
|
|
3298
|
+
const retryTurnId = await this.startTurn(
|
|
3299
|
+
loaded,
|
|
3300
|
+
llmRetryRuntimePrompt(attempt.promptText, retryNumber),
|
|
3301
|
+
replyTarget,
|
|
3302
|
+
attempt.localImagePaths,
|
|
3303
|
+
{
|
|
3304
|
+
attemptId: attempt.attemptId,
|
|
3305
|
+
messageId: attempt.messageId,
|
|
3306
|
+
displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
|
|
3307
|
+
preserveAttemptOnFailure: true,
|
|
3308
|
+
},
|
|
3309
|
+
);
|
|
3310
|
+
this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
|
|
3311
|
+
} catch (error) {
|
|
3312
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
|
|
3313
|
+
this.store.updateSession(session.localSessionId, { status: "failed" });
|
|
3314
|
+
await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError: unknown) => {
|
|
3315
|
+
this.logger.warn(
|
|
3316
|
+
{ error: reactionError, sessionId: session.localSessionId, turnId: event.turnId },
|
|
3317
|
+
"Failed to finalize reactions after an LLM retry could not start.",
|
|
3318
|
+
);
|
|
3319
|
+
});
|
|
3320
|
+
this.logger.error(
|
|
3321
|
+
{ error, sessionId: session.localSessionId, retryNumber },
|
|
3322
|
+
"Failed to start an automatic LLM turn retry.",
|
|
3323
|
+
);
|
|
3324
|
+
await this.scheduleNextQueuedPrompt(session.localSessionId);
|
|
3325
|
+
} finally {
|
|
3326
|
+
this.llmRetryingSessions.delete(session.localSessionId);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
|
|
2288
3330
|
private scheduleNextQueuedPrompt(sessionId: string): Promise<void> {
|
|
2289
3331
|
const previous = this.queuedPromptStarts.get(sessionId) ?? Promise.resolve();
|
|
2290
3332
|
const next = previous.catch(() => undefined).then(() => this.startNextQueuedPromptIfIdle(sessionId));
|
|
@@ -2317,6 +3359,7 @@ export class ProxySessionController {
|
|
|
2317
3359
|
prompt.text,
|
|
2318
3360
|
prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined,
|
|
2319
3361
|
prompt.localImagePaths,
|
|
3362
|
+
{ messageId: prompt.messageId },
|
|
2320
3363
|
);
|
|
2321
3364
|
if (prompt.messageId) await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
|
|
2322
3365
|
} catch (error) {
|
|
@@ -2333,7 +3376,9 @@ export class ProxySessionController {
|
|
|
2333
3376
|
if (!source?.lastTurnId) return undefined;
|
|
2334
3377
|
if (source.lastTurnStatus === "completed") return source.lastTurnId;
|
|
2335
3378
|
const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(source.lastTurnId));
|
|
2336
|
-
return snapshot?.status === "completed"
|
|
3379
|
+
return snapshot?.status === "completed"
|
|
3380
|
+
? source.lastTurnId
|
|
3381
|
+
: this.store.findLatestCompletedTurnId(source.localSessionId, source.contextKey);
|
|
2337
3382
|
}
|
|
2338
3383
|
|
|
2339
3384
|
private async finalizeStandaloneMessageReaction(
|
|
@@ -2476,16 +3521,25 @@ export class ProxySessionController {
|
|
|
2476
3521
|
if (current.localSessionId !== sessionId) {
|
|
2477
3522
|
throw new Error("这张思考卡片不属于当前任务。请先切换回对应任务,再点击 Reset。");
|
|
2478
3523
|
}
|
|
3524
|
+
await this.performSessionReset(contextKey, current, turnId, true);
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
private async performSessionReset(
|
|
3528
|
+
contextKey: string,
|
|
3529
|
+
current: SessionRecord,
|
|
3530
|
+
turnId: string,
|
|
3531
|
+
announce: boolean,
|
|
3532
|
+
): Promise<void> {
|
|
2479
3533
|
if (!current.remoteSessionId || !this.isCodexSession(current)) {
|
|
2480
3534
|
throw new Error("当前任务不是可 Reset 的 App Server 任务。");
|
|
2481
3535
|
}
|
|
2482
3536
|
|
|
2483
3537
|
const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
|
|
2484
|
-
if (!snapshot || snapshot.sessionId !==
|
|
3538
|
+
if (!snapshot || snapshot.sessionId !== current.localSessionId || snapshot.status !== "completed") {
|
|
2485
3539
|
throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
|
|
2486
3540
|
}
|
|
2487
3541
|
if (current.lastTurnId === turnId && current.lastTurnStatus === "completed") {
|
|
2488
|
-
await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
|
|
3542
|
+
if (announce) await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
|
|
2489
3543
|
return;
|
|
2490
3544
|
}
|
|
2491
3545
|
|
|
@@ -2502,7 +3556,7 @@ export class ProxySessionController {
|
|
|
2502
3556
|
}
|
|
2503
3557
|
|
|
2504
3558
|
const origin = this.store.getTurnRuntimeOrigin(turnId);
|
|
2505
|
-
if (origin && (origin.localSessionId !==
|
|
3559
|
+
if (origin && (origin.localSessionId !== current.localSessionId || origin.agentName !== current.agentName)) {
|
|
2506
3560
|
throw new Error("这张思考卡片与当前任务不匹配,已拒绝 Reset。");
|
|
2507
3561
|
}
|
|
2508
3562
|
const sourceRemoteSessionId = origin?.remoteSessionId ?? current.remoteSessionId;
|
|
@@ -2543,16 +3597,18 @@ export class ProxySessionController {
|
|
|
2543
3597
|
100,
|
|
2544
3598
|
) || "未记录对话内容";
|
|
2545
3599
|
const targetTime = snapshot.completedAt ?? snapshot.startedAt;
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
3600
|
+
if (announce) {
|
|
3601
|
+
await this.outbound.sendText(
|
|
3602
|
+
contextKey,
|
|
3603
|
+
[
|
|
3604
|
+
"已将当前任务重置到:",
|
|
3605
|
+
targetSummary,
|
|
3606
|
+
`完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
|
|
3607
|
+
`Turn ID:${turnId}`,
|
|
3608
|
+
"后续对话将从该轮完成后的状态继续;本地文件没有回退。",
|
|
3609
|
+
].join("\n"),
|
|
3610
|
+
);
|
|
3611
|
+
}
|
|
2556
3612
|
}
|
|
2557
3613
|
|
|
2558
3614
|
private async resolveApproval(action: CardAction): Promise<void> {
|
|
@@ -3194,6 +4250,175 @@ export class ProxySessionController {
|
|
|
3194
4250
|
}
|
|
3195
4251
|
}
|
|
3196
4252
|
|
|
4253
|
+
private async openDirectoryBrowser(
|
|
4254
|
+
contextKey: string,
|
|
4255
|
+
requestedDirectory?: string,
|
|
4256
|
+
options: DirectoryBrowserOptions = {},
|
|
4257
|
+
): Promise<void> {
|
|
4258
|
+
if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
|
|
4259
|
+
await this.openWindowsDriveBrowser(contextKey, options);
|
|
4260
|
+
return;
|
|
4261
|
+
}
|
|
4262
|
+
const baseDirectory = this.currentSession(contextKey)?.cwd
|
|
4263
|
+
?? this.store.getUserContext(contextKey)?.boundProjectCwd
|
|
4264
|
+
?? this.config.defaults.cwd;
|
|
4265
|
+
const directory = requestedDirectory === undefined
|
|
4266
|
+
? path.resolve(baseDirectory)
|
|
4267
|
+
: resolveUserPath(requestedDirectory, baseDirectory);
|
|
4268
|
+
await this.assertBrowsableDirectory(directory);
|
|
4269
|
+
|
|
4270
|
+
let directoryEntries: Dirent[];
|
|
4271
|
+
try {
|
|
4272
|
+
directoryEntries = await fs.readdir(directory, { withFileTypes: true });
|
|
4273
|
+
} catch (error) {
|
|
4274
|
+
throw new Error(`无法读取目录 ${directory}:${runtimeErrorMessage(error)}`);
|
|
4275
|
+
}
|
|
4276
|
+
const entries = directoryEntries
|
|
4277
|
+
.map((entry) => ({
|
|
4278
|
+
name: entry.name,
|
|
4279
|
+
path: path.join(directory, entry.name),
|
|
4280
|
+
kind: directoryBrowserEntryKind(entry),
|
|
4281
|
+
}))
|
|
4282
|
+
.sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
|
|
4283
|
+
|| left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
4284
|
+
const parentDirectory = isWindowsDriveRoot(directory)
|
|
4285
|
+
? WINDOWS_DRIVES_DIRECTORY
|
|
4286
|
+
: path.dirname(directory);
|
|
4287
|
+
const parentEntries: DirectoryBrowserCardEntry[] = parentDirectory === directory ? [] : [{
|
|
4288
|
+
name: "..",
|
|
4289
|
+
kind: "directory",
|
|
4290
|
+
openAction: {
|
|
4291
|
+
text: "..",
|
|
4292
|
+
value: {
|
|
4293
|
+
action: "directory_open",
|
|
4294
|
+
directory: parentDirectory,
|
|
4295
|
+
contextKey,
|
|
4296
|
+
},
|
|
4297
|
+
},
|
|
4298
|
+
}];
|
|
4299
|
+
const pageSize = DIRECTORY_PAGE_SIZE;
|
|
4300
|
+
const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
|
|
4301
|
+
const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
|
|
4302
|
+
const page = Math.min(requestedPage, totalPages - 1);
|
|
4303
|
+
const offset = page * pageSize;
|
|
4304
|
+
const visibleEntries: DirectoryBrowserCardEntry[] = [
|
|
4305
|
+
...parentEntries,
|
|
4306
|
+
...entries
|
|
4307
|
+
.slice(offset, offset + pageSize)
|
|
4308
|
+
.map((entry): DirectoryBrowserCardEntry => entry.kind === "directory"
|
|
4309
|
+
? {
|
|
4310
|
+
name: entry.name,
|
|
4311
|
+
kind: entry.kind,
|
|
4312
|
+
openAction: {
|
|
4313
|
+
text: entry.name,
|
|
4314
|
+
value: {
|
|
4315
|
+
action: "directory_open",
|
|
4316
|
+
directory: entry.path,
|
|
4317
|
+
contextKey,
|
|
4318
|
+
},
|
|
4319
|
+
},
|
|
4320
|
+
actions: directoryCreationActions(entry.path, contextKey),
|
|
4321
|
+
}
|
|
4322
|
+
: {
|
|
4323
|
+
name: entry.name,
|
|
4324
|
+
kind: entry.kind,
|
|
4325
|
+
openAction: {
|
|
4326
|
+
text: entry.name,
|
|
4327
|
+
value: {
|
|
4328
|
+
action: "directory_send_file",
|
|
4329
|
+
filePath: entry.path,
|
|
4330
|
+
contextKey,
|
|
4331
|
+
},
|
|
4332
|
+
},
|
|
4333
|
+
}),
|
|
4334
|
+
];
|
|
4335
|
+
const navigationActions = directoryPaginationActions(directory, page, totalPages, contextKey);
|
|
4336
|
+
const directoryCount = entries.filter((entry) => entry.kind === "directory").length;
|
|
4337
|
+
const card = this.cardRenderer.renderDirectoryBrowserCard({
|
|
4338
|
+
directory: abbreviateHomeDirectory(directory),
|
|
4339
|
+
entries: visibleEntries,
|
|
4340
|
+
currentActions: directoryCreationActions(directory, contextKey),
|
|
4341
|
+
navigationActions,
|
|
4342
|
+
footerLines: [
|
|
4343
|
+
`第 ${page + 1}/${totalPages} 页 · ${directoryCount} 个目录 · ${entries.length - directoryCount} 个文件`,
|
|
4344
|
+
"",
|
|
4345
|
+
"> 点击目录名称进入,点击文件名称发送到当前会话;**New** 在该目录创建任务,**NewGroup** 在该目录创建新群和任务。",
|
|
4346
|
+
],
|
|
4347
|
+
});
|
|
4348
|
+
if (options.updateMessageId) {
|
|
4349
|
+
await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
|
|
4350
|
+
} else {
|
|
4351
|
+
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
4352
|
+
}
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4355
|
+
private async openWindowsDriveBrowser(
|
|
4356
|
+
contextKey: string,
|
|
4357
|
+
options: DirectoryBrowserOptions,
|
|
4358
|
+
): Promise<void> {
|
|
4359
|
+
const drives = await this.windowsDriveLister();
|
|
4360
|
+
const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
|
|
4361
|
+
const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
|
|
4362
|
+
const page = Math.min(requestedPage, totalPages - 1);
|
|
4363
|
+
const offset = page * DIRECTORY_PAGE_SIZE;
|
|
4364
|
+
const entries: DirectoryBrowserCardEntry[] = drives
|
|
4365
|
+
.slice(offset, offset + DIRECTORY_PAGE_SIZE)
|
|
4366
|
+
.map((drive) => ({
|
|
4367
|
+
name: windowsDriveDisplayName(drive),
|
|
4368
|
+
kind: "drive",
|
|
4369
|
+
openAction: {
|
|
4370
|
+
text: windowsDriveDisplayName(drive),
|
|
4371
|
+
value: {
|
|
4372
|
+
action: "directory_open",
|
|
4373
|
+
directory: drive.root,
|
|
4374
|
+
contextKey,
|
|
4375
|
+
},
|
|
4376
|
+
},
|
|
4377
|
+
actions: directoryCreationActions(drive.root, contextKey),
|
|
4378
|
+
}));
|
|
4379
|
+
const card = this.cardRenderer.renderDirectoryBrowserCard({
|
|
4380
|
+
directory: "此电脑",
|
|
4381
|
+
entries,
|
|
4382
|
+
currentActions: [],
|
|
4383
|
+
navigationActions: directoryPaginationActions(
|
|
4384
|
+
WINDOWS_DRIVES_DIRECTORY,
|
|
4385
|
+
page,
|
|
4386
|
+
totalPages,
|
|
4387
|
+
contextKey,
|
|
4388
|
+
),
|
|
4389
|
+
footerLines: [
|
|
4390
|
+
`第 ${page + 1}/${totalPages} 页 · ${drives.length} 个磁盘`,
|
|
4391
|
+
"",
|
|
4392
|
+
"> 点击磁盘名称进入;**New** 在磁盘根目录创建任务,**NewGroup** 在磁盘根目录创建新群和任务。",
|
|
4393
|
+
],
|
|
4394
|
+
});
|
|
4395
|
+
if (options.updateMessageId) {
|
|
4396
|
+
await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
|
|
4397
|
+
} else {
|
|
4398
|
+
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4402
|
+
private async assertBrowsableDirectory(directory: string): Promise<void> {
|
|
4403
|
+
let stats;
|
|
4404
|
+
try {
|
|
4405
|
+
stats = await fs.stat(directory);
|
|
4406
|
+
} catch (error) {
|
|
4407
|
+
throw new Error(`目录不存在或无法访问:${directory}(${runtimeErrorMessage(error)})`);
|
|
4408
|
+
}
|
|
4409
|
+
if (!stats.isDirectory()) throw new Error(`这不是目录:${directory}`);
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4412
|
+
private async assertSendableFile(filePath: string): Promise<void> {
|
|
4413
|
+
let stats;
|
|
4414
|
+
try {
|
|
4415
|
+
stats = await fs.stat(filePath);
|
|
4416
|
+
} catch (error) {
|
|
4417
|
+
throw new Error(`文件不存在或无法访问:${filePath}(${runtimeErrorMessage(error)})`);
|
|
4418
|
+
}
|
|
4419
|
+
if (!stats.isFile()) throw new Error(`这不是普通文件:${filePath}`);
|
|
4420
|
+
}
|
|
4421
|
+
|
|
3197
4422
|
private async listSessions(
|
|
3198
4423
|
contextKey: string,
|
|
3199
4424
|
searchTerm?: string,
|
|
@@ -3224,23 +4449,33 @@ export class ProxySessionController {
|
|
|
3224
4449
|
? `部分 Agent 的任务读取失败:${remoteErrors.join(";")}`
|
|
3225
4450
|
: undefined;
|
|
3226
4451
|
|
|
3227
|
-
const entries =
|
|
3228
|
-
|
|
4452
|
+
const entries = orderTaskListByProject(
|
|
4453
|
+
mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
|
|
4454
|
+
.filter((entry) => !normalizedSearch
|
|
3229
4455
|
|| [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);
|
|
4456
|
+
.some((value) => value.toLowerCase().includes(normalizedSearch))),
|
|
4457
|
+
);
|
|
3234
4458
|
const activeCount = entries.filter((entry) => entry.active).length;
|
|
3235
4459
|
const lastKnownPage = Math.max(0, Math.ceil(entries.length / SESSION_PAGE_SIZE) - 1);
|
|
3236
4460
|
const page = remoteHasMore ? requestedPage : Math.min(requestedPage, lastKnownPage);
|
|
3237
4461
|
const offset = page * SESSION_PAGE_SIZE;
|
|
3238
4462
|
const visibleEntries = entries.slice(offset, offset + SESSION_PAGE_SIZE);
|
|
4463
|
+
const projectActionSources = new Map<string, UnifiedTaskListEntry>();
|
|
4464
|
+
for (const entry of entries) {
|
|
4465
|
+
const project = taskProjectInfo(entry);
|
|
4466
|
+
if (!projectActionSources.has(project.key)) projectActionSources.set(project.key, entry);
|
|
4467
|
+
}
|
|
3239
4468
|
const hasPrevious = page > 0;
|
|
3240
4469
|
const hasNext = remoteHasMore || entries.length > offset + SESSION_PAGE_SIZE;
|
|
3241
4470
|
this.lastSessionListings.set(contextKey, entries.map((entry) => entry.reference));
|
|
3242
|
-
const cardEntries = visibleEntries.map((entry, index)
|
|
3243
|
-
|
|
4471
|
+
const cardEntries = visibleEntries.map((entry, index): {
|
|
4472
|
+
project: TaskProjectInfo;
|
|
4473
|
+
projectActions: TaskListCardAction[];
|
|
4474
|
+
cardEntry: SessionTaskCardEntry;
|
|
4475
|
+
} => {
|
|
4476
|
+
const project = taskProjectInfo(entry);
|
|
4477
|
+
const projectActionSource = projectActionSources.get(project.key) ?? entry;
|
|
4478
|
+
const marker = entry.current ? "✅" : entry.active ? "🟢" : "•";
|
|
3244
4479
|
const showStop = entry.status === "外部执行中"
|
|
3245
4480
|
&& entry.reference !== options.forceSwitchTaskId
|
|
3246
4481
|
&& entry.id !== options.forceSwitchTaskId;
|
|
@@ -3255,26 +4490,25 @@ export class ProxySessionController {
|
|
|
3255
4490
|
contextKey,
|
|
3256
4491
|
},
|
|
3257
4492
|
}];
|
|
3258
|
-
|
|
4493
|
+
const projectActions: TaskListCardAction[] = [{
|
|
3259
4494
|
text: "New",
|
|
3260
4495
|
value: {
|
|
3261
4496
|
action: "session_new",
|
|
3262
|
-
sessionId:
|
|
4497
|
+
sessionId: projectActionSource.reference,
|
|
3263
4498
|
...(searchTerm ? { searchTerm } : {}),
|
|
3264
4499
|
page: String(page),
|
|
3265
4500
|
contextKey,
|
|
3266
4501
|
},
|
|
3267
|
-
}
|
|
3268
|
-
actions.push({
|
|
4502
|
+
}, {
|
|
3269
4503
|
text: "NewGroup",
|
|
3270
4504
|
value: {
|
|
3271
4505
|
action: "session_new_group",
|
|
3272
|
-
sessionId:
|
|
4506
|
+
sessionId: projectActionSource.reference,
|
|
3273
4507
|
...(searchTerm ? { searchTerm } : {}),
|
|
3274
4508
|
page: String(page),
|
|
3275
4509
|
contextKey,
|
|
3276
4510
|
},
|
|
3277
|
-
}
|
|
4511
|
+
}];
|
|
3278
4512
|
actions.push({
|
|
3279
4513
|
text: "Fork",
|
|
3280
4514
|
value: {
|
|
@@ -3303,24 +4537,58 @@ export class ProxySessionController {
|
|
|
3303
4537
|
contextKey,
|
|
3304
4538
|
},
|
|
3305
4539
|
});
|
|
4540
|
+
actions.push({
|
|
4541
|
+
text: "Archive",
|
|
4542
|
+
value: {
|
|
4543
|
+
action: "session_archive",
|
|
4544
|
+
sessionId: entry.reference,
|
|
4545
|
+
...(searchTerm ? { searchTerm } : {}),
|
|
4546
|
+
page: String(page),
|
|
4547
|
+
contextKey,
|
|
4548
|
+
},
|
|
4549
|
+
});
|
|
4550
|
+
const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
|
|
3306
4551
|
return {
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
4552
|
+
project,
|
|
4553
|
+
projectActions,
|
|
4554
|
+
cardEntry: {
|
|
4555
|
+
reference: entry.reference,
|
|
4556
|
+
summary: `${offset + index + 1}. ${marker} ${title} · ${entry.agentName}`,
|
|
4557
|
+
detailLines: [
|
|
4558
|
+
`**状态 / 更新时间**:${cardText(entry.status)} / ${cardText(entry.updatedLabel)}`,
|
|
4559
|
+
`**Agent / 任务 ID**:${cardCode(entry.agentName)} / ${cardCode(entry.id)}`,
|
|
4560
|
+
`**目录**:${cardCode(entry.cwd || "目录未知")}`,
|
|
4561
|
+
],
|
|
4562
|
+
actions,
|
|
4563
|
+
current: entry.current,
|
|
4564
|
+
},
|
|
3312
4565
|
};
|
|
3313
4566
|
});
|
|
3314
|
-
const
|
|
3315
|
-
|
|
4567
|
+
const cardGroups: SessionTaskCardGroup[] = [];
|
|
4568
|
+
let currentProjectKey: string | undefined;
|
|
4569
|
+
for (const item of cardEntries) {
|
|
4570
|
+
const currentGroup = cardGroups.at(-1);
|
|
4571
|
+
if (currentGroup && currentProjectKey === item.project.key) {
|
|
4572
|
+
currentGroup.entries.push(item.cardEntry);
|
|
4573
|
+
} else {
|
|
4574
|
+
currentProjectKey = item.project.key;
|
|
4575
|
+
cardGroups.push({
|
|
4576
|
+
title: item.project.title,
|
|
4577
|
+
entries: [item.cardEntry],
|
|
4578
|
+
actions: item.projectActions,
|
|
4579
|
+
});
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
const card = this.cardRenderer.renderSessionTaskListCard(
|
|
4583
|
+
searchTerm ? `任务列表:${searchTerm}` : "任务列表",
|
|
3316
4584
|
activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务",
|
|
3317
|
-
|
|
4585
|
+
cardGroups,
|
|
3318
4586
|
[
|
|
3319
4587
|
...(remoteHint ? [remoteHint] : []),
|
|
3320
4588
|
`第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
|
|
3321
4589
|
"",
|
|
3322
|
-
"> **
|
|
3323
|
-
"> **
|
|
4590
|
+
"> 项目行:**New** 新建任务,**NewGroup** 新建群。",
|
|
4591
|
+
"> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
|
|
3324
4592
|
],
|
|
3325
4593
|
[
|
|
3326
4594
|
...(hasPrevious ? [{
|
|
@@ -3509,6 +4777,67 @@ export class ProxySessionController {
|
|
|
3509
4777
|
await this.outbound.sendText(contextKey, `已向 Agent 发送 Interrupt 请求:${turnId}`);
|
|
3510
4778
|
}
|
|
3511
4779
|
|
|
4780
|
+
private async archiveSessionReference(
|
|
4781
|
+
contextKey: string,
|
|
4782
|
+
reference: string,
|
|
4783
|
+
options: { announce: boolean; source: "cli" | "command" | "sessions_card" },
|
|
4784
|
+
): Promise<{ localSessionId: string; remoteSessionId: string; title: string }> {
|
|
4785
|
+
const taskId = this.resolveSessionReference(contextKey, reference);
|
|
4786
|
+
const direct = this.store.getSession(taskId);
|
|
4787
|
+
if (direct?.status === "closed") throw new Error(`任务已经归档:${direct.title ?? taskId}`);
|
|
4788
|
+
const local = direct ?? this.findStoredSessionByReference(taskId);
|
|
4789
|
+
if (local && (!local.remoteSessionId || !this.isCodexSession(local))) {
|
|
4790
|
+
throw new Error("当前 Agent 不支持归档任务。");
|
|
4791
|
+
}
|
|
4792
|
+
if (local && (
|
|
4793
|
+
local.status === "running"
|
|
4794
|
+
|| Boolean(this.runtimes.forAgent(local.agentName).getSession(local.localSessionId)?.activeTurnId)
|
|
4795
|
+
)) {
|
|
4796
|
+
throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
|
|
4797
|
+
}
|
|
4798
|
+
if (local && this.store.countQueuedPrompts(local.localSessionId) > 0) {
|
|
4799
|
+
throw new Error("任务仍有排队消息,无法归档。请先处理或取消排队消息。");
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
const remoteReference = local
|
|
4803
|
+
? remoteSessionReference(local.agentName, local.remoteSessionId!)
|
|
4804
|
+
: taskId;
|
|
4805
|
+
const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
|
|
4806
|
+
if (isRemoteSessionActive(remote)) {
|
|
4807
|
+
throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
|
|
4808
|
+
}
|
|
4809
|
+
if (!runtime.archiveRemoteSession) {
|
|
4810
|
+
throw new Error(`Agent ${agentName} 不支持归档任务。`);
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
|
|
4814
|
+
await runtime.archiveRemoteSession(remote.id);
|
|
4815
|
+
const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
|
|
4816
|
+
if (local) {
|
|
4817
|
+
this.store.archiveSession(local.localSessionId);
|
|
4818
|
+
this.outbound.unregisterSession(local.localSessionId);
|
|
4819
|
+
}
|
|
4820
|
+
this.store.audit(contextKey, "session_archived", {
|
|
4821
|
+
...(local ? { localSessionId: local.localSessionId } : {}),
|
|
4822
|
+
remoteSessionId: remote.id,
|
|
4823
|
+
agentName,
|
|
4824
|
+
source: options.source,
|
|
4825
|
+
});
|
|
4826
|
+
if (options.announce) {
|
|
4827
|
+
await this.outbound.sendText(
|
|
4828
|
+
contextKey,
|
|
4829
|
+
wasCurrent
|
|
4830
|
+
? `已归档任务:${title}\n当前会话已没有绑定任务,直接发送消息即可创建新任务。`
|
|
4831
|
+
: `已归档任务:${title}`,
|
|
4832
|
+
);
|
|
4833
|
+
}
|
|
4834
|
+
return {
|
|
4835
|
+
localSessionId: local?.localSessionId ?? "",
|
|
4836
|
+
remoteSessionId: remote.id,
|
|
4837
|
+
title,
|
|
4838
|
+
};
|
|
4839
|
+
}
|
|
4840
|
+
|
|
3512
4841
|
private resolveSessionReference(contextKey: string, reference?: string): string {
|
|
3513
4842
|
if (reference === undefined) {
|
|
3514
4843
|
const previous = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!).previousSessionId;
|
|
@@ -3839,6 +5168,42 @@ export class ProxySessionController {
|
|
|
3839
5168
|
));
|
|
3840
5169
|
}
|
|
3841
5170
|
|
|
5171
|
+
private async setGroupMute(contextKey: string, enabled: boolean): Promise<void> {
|
|
5172
|
+
const chatContextKey = baseChatContextKey(contextKey);
|
|
5173
|
+
const chat = this.store.getChatContext(chatContextKey);
|
|
5174
|
+
if (chat?.chatType !== "group") {
|
|
5175
|
+
throw new Error("/mute 仅适用于群聊。");
|
|
5176
|
+
}
|
|
5177
|
+
this.store.setChatRequiresMention(chatContextKey, enabled);
|
|
5178
|
+
await this.outbound.sendText(
|
|
5179
|
+
contextKey,
|
|
5180
|
+
enabled
|
|
5181
|
+
? "已开启当前群的静音模式。之后只有 @ 机器人的消息会被处理;发送 @机器人 /mute off 可恢复自动响应。"
|
|
5182
|
+
: "已关闭当前群的静音模式。机器人将恢复自动响应群消息。",
|
|
5183
|
+
);
|
|
5184
|
+
}
|
|
5185
|
+
|
|
5186
|
+
private requireControlSession(localSessionId: string): SessionRecord {
|
|
5187
|
+
const record = this.store.getSession(localSessionId);
|
|
5188
|
+
if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
|
|
5189
|
+
return record;
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5192
|
+
private controlSessionContextKey(record: SessionRecord): string {
|
|
5193
|
+
return this.outbound.getSessionContextKey(record.localSessionId)
|
|
5194
|
+
?? (record.lastTurnId ? this.store.getTurnContextKey(record.lastTurnId) : undefined)
|
|
5195
|
+
?? record.contextKey;
|
|
5196
|
+
}
|
|
5197
|
+
|
|
5198
|
+
private controlSessionReplyTarget(record: SessionRecord): MessageReplyTarget | undefined {
|
|
5199
|
+
const routed = this.outbound.getSessionReplyTarget(record.localSessionId);
|
|
5200
|
+
if (routed) return routed;
|
|
5201
|
+
const snapshot = record.lastTurnId
|
|
5202
|
+
? turnViewSnapshot(this.store.getTurnSnapshot(record.lastTurnId))
|
|
5203
|
+
: undefined;
|
|
5204
|
+
return snapshot?.replyTarget;
|
|
5205
|
+
}
|
|
5206
|
+
|
|
3842
5207
|
private ensureAgent(agentName: string) {
|
|
3843
5208
|
const agent = this.config.agents[agentName];
|
|
3844
5209
|
if (!agent) throw new Error(`未知 agent:${agentName}`);
|
|
@@ -3903,6 +5268,11 @@ export class ProxySessionController {
|
|
|
3903
5268
|
}
|
|
3904
5269
|
}
|
|
3905
5270
|
|
|
5271
|
+
function threadForkAnchorMessageIds(message: IncomingMessage): string[] {
|
|
5272
|
+
return [...new Set([message.rootMessageId, message.parentMessageId]
|
|
5273
|
+
.filter((messageId): messageId is string => Boolean(messageId && messageId !== message.messageId)))];
|
|
5274
|
+
}
|
|
5275
|
+
|
|
3906
5276
|
function sessionStatusLabel(status: SessionRecord["status"], activeTurnId?: string): string {
|
|
3907
5277
|
if (activeTurnId || status === "running") return "执行中";
|
|
3908
5278
|
const labels: Record<SessionRecord["status"], string> = {
|
|
@@ -3957,6 +5327,55 @@ interface UnifiedTaskListEntry {
|
|
|
3957
5327
|
updatedLabel: string;
|
|
3958
5328
|
}
|
|
3959
5329
|
|
|
5330
|
+
interface TaskProjectInfo {
|
|
5331
|
+
key: string;
|
|
5332
|
+
title: string;
|
|
5333
|
+
}
|
|
5334
|
+
|
|
5335
|
+
function orderTaskListByProject(entries: UnifiedTaskListEntry[]): UnifiedTaskListEntry[] {
|
|
5336
|
+
const groups = new Map<string, { project: TaskProjectInfo; entries: UnifiedTaskListEntry[] }>();
|
|
5337
|
+
for (const entry of entries) {
|
|
5338
|
+
const project = taskProjectInfo(entry);
|
|
5339
|
+
const group = groups.get(project.key);
|
|
5340
|
+
if (group) group.entries.push(entry);
|
|
5341
|
+
else groups.set(project.key, { project, entries: [entry] });
|
|
5342
|
+
}
|
|
5343
|
+
const orderedGroups = [...groups.values()]
|
|
5344
|
+
.map((group) => ({
|
|
5345
|
+
...group,
|
|
5346
|
+
current: group.entries.some((entry) => entry.current),
|
|
5347
|
+
active: group.entries.some((entry) => entry.active),
|
|
5348
|
+
updatedAt: Math.max(...group.entries.map((entry) => entry.updatedAt)),
|
|
5349
|
+
}))
|
|
5350
|
+
.sort((left, right) => Number(right.current) - Number(left.current)
|
|
5351
|
+
|| Number(right.active) - Number(left.active)
|
|
5352
|
+
|| right.updatedAt - left.updatedAt
|
|
5353
|
+
|| left.project.title.localeCompare(right.project.title, undefined, { sensitivity: "base" }));
|
|
5354
|
+
return orderedGroups.flatMap((group) => group.entries.sort(compareTaskListEntries));
|
|
5355
|
+
}
|
|
5356
|
+
|
|
5357
|
+
function compareTaskListEntries(left: UnifiedTaskListEntry, right: UnifiedTaskListEntry): number {
|
|
5358
|
+
return Number(right.current) - Number(left.current)
|
|
5359
|
+
|| Number(right.active) - Number(left.active)
|
|
5360
|
+
|| right.updatedAt - left.updatedAt
|
|
5361
|
+
|| left.title.localeCompare(right.title, undefined, { sensitivity: "base" });
|
|
5362
|
+
}
|
|
5363
|
+
|
|
5364
|
+
function taskProjectInfo(entry: UnifiedTaskListEntry): TaskProjectInfo {
|
|
5365
|
+
if (!entry.cwd) return { key: "unknown", title: "📁 目录未知" };
|
|
5366
|
+
if (detectProjectlessWorkspace(entry.cwd)) {
|
|
5367
|
+
return { key: "projectless", title: "🗒️ 未指定项目" };
|
|
5368
|
+
}
|
|
5369
|
+
const windowsPath = isWindowsAbsolutePath(entry.cwd);
|
|
5370
|
+
const normalized = windowsPath
|
|
5371
|
+
? path.win32.normalize(entry.cwd).toLowerCase()
|
|
5372
|
+
: path.resolve(entry.cwd);
|
|
5373
|
+
return {
|
|
5374
|
+
key: `cwd:${normalized}`,
|
|
5375
|
+
title: `📁 ${abbreviateHomeDirectory(entry.cwd)}`,
|
|
5376
|
+
};
|
|
5377
|
+
}
|
|
5378
|
+
|
|
3960
5379
|
function mergeTaskList(
|
|
3961
5380
|
localSessions: SessionRecord[],
|
|
3962
5381
|
remoteSessions: AgentRemoteSessionSummary[],
|
|
@@ -4055,6 +5474,14 @@ function turnViewSnapshot(value: unknown): TurnViewState | undefined {
|
|
|
4055
5474
|
: undefined;
|
|
4056
5475
|
}
|
|
4057
5476
|
|
|
5477
|
+
function isTerminalTurnViewStatus(status: TurnViewState["status"]): boolean {
|
|
5478
|
+
return status === "completed" || status === "cancelled" || status === "failed";
|
|
5479
|
+
}
|
|
5480
|
+
|
|
5481
|
+
function isIncompleteTurnAttemptStatus(status: TurnAttemptRecord["status"]): boolean {
|
|
5482
|
+
return status === "accepted" || status === "running" || status === "recovering";
|
|
5483
|
+
}
|
|
5484
|
+
|
|
4058
5485
|
function executionDetailLines(
|
|
4059
5486
|
record: SessionRecord,
|
|
4060
5487
|
snapshot: TurnViewState | undefined,
|
|
@@ -4336,7 +5763,7 @@ function isBotOwnedActiveTurn(record: SessionRecord, remote: RemoteSessionSummar
|
|
|
4336
5763
|
}
|
|
4337
5764
|
|
|
4338
5765
|
function isQueueIndependentCommand(command: Command): boolean {
|
|
4339
|
-
if (["stop", "status", "restart", "help", "sessions", "goal", "nosteer"].includes(command.type)) return true;
|
|
5766
|
+
if (["archive", "stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type)) return true;
|
|
4340
5767
|
if (command.type === "agent") return command.agent === undefined;
|
|
4341
5768
|
if (["model", "provider", "thinking", "permissions"].includes(command.type)) return true;
|
|
4342
5769
|
return false;
|
|
@@ -4347,6 +5774,138 @@ function parseSessionPage(value: unknown): number {
|
|
|
4347
5774
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
4348
5775
|
}
|
|
4349
5776
|
|
|
5777
|
+
function directoryPageValue(value: unknown): number {
|
|
5778
|
+
const page = Number(value);
|
|
5779
|
+
if (!Number.isSafeInteger(page) || page < 0) throw new Error("文件浏览卡片页码无效,请重新发送 /dir。");
|
|
5780
|
+
return page;
|
|
5781
|
+
}
|
|
5782
|
+
|
|
5783
|
+
function isWindowsDriveRoot(directory: string): boolean {
|
|
5784
|
+
return /^[a-z]:\\$/iu.test(path.win32.normalize(directory));
|
|
5785
|
+
}
|
|
5786
|
+
|
|
5787
|
+
function directoryBrowserEntryKind(entry: Dirent): DirectoryBrowserCardEntry["kind"] {
|
|
5788
|
+
if (entry.isDirectory()) return "directory";
|
|
5789
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
5790
|
+
if (IMAGE_FILE_EXTENSIONS.has(extension)) return "image";
|
|
5791
|
+
if (BINARY_FILE_EXTENSIONS.has(extension)) return "binary";
|
|
5792
|
+
return "file";
|
|
5793
|
+
}
|
|
5794
|
+
|
|
5795
|
+
function directoryActionPath(value: unknown): string {
|
|
5796
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
5797
|
+
throw new Error("文件浏览卡片中的目录无效,请重新发送 /dir。");
|
|
5798
|
+
}
|
|
5799
|
+
return value === WINDOWS_DRIVES_DIRECTORY ? value : path.resolve(value);
|
|
5800
|
+
}
|
|
5801
|
+
|
|
5802
|
+
function directoryFileActionPath(value: unknown): string {
|
|
5803
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
5804
|
+
throw new Error("文件浏览卡片中的文件无效,请重新发送 /dir。");
|
|
5805
|
+
}
|
|
5806
|
+
return path.resolve(value);
|
|
5807
|
+
}
|
|
5808
|
+
|
|
5809
|
+
function directoryCreationActions(directory: string, contextKey: string): TaskListCardAction[] {
|
|
5810
|
+
return [
|
|
5811
|
+
{
|
|
5812
|
+
text: "New",
|
|
5813
|
+
value: {
|
|
5814
|
+
action: "directory_new",
|
|
5815
|
+
directory,
|
|
5816
|
+
contextKey,
|
|
5817
|
+
},
|
|
5818
|
+
},
|
|
5819
|
+
{
|
|
5820
|
+
text: "NewGroup",
|
|
5821
|
+
value: {
|
|
5822
|
+
action: "directory_new_group",
|
|
5823
|
+
directory,
|
|
5824
|
+
contextKey,
|
|
5825
|
+
},
|
|
5826
|
+
},
|
|
5827
|
+
];
|
|
5828
|
+
}
|
|
5829
|
+
|
|
5830
|
+
function directoryPaginationActions(
|
|
5831
|
+
directory: string,
|
|
5832
|
+
page: number,
|
|
5833
|
+
totalPages: number,
|
|
5834
|
+
contextKey: string,
|
|
5835
|
+
): TaskListCardAction[] {
|
|
5836
|
+
return [
|
|
5837
|
+
...(page > 0 ? [{
|
|
5838
|
+
text: "Previous",
|
|
5839
|
+
value: {
|
|
5840
|
+
action: "directory_page",
|
|
5841
|
+
directory,
|
|
5842
|
+
page: String(page - 1),
|
|
5843
|
+
contextKey,
|
|
5844
|
+
},
|
|
5845
|
+
}] : []),
|
|
5846
|
+
...(page < totalPages - 1 ? [{
|
|
5847
|
+
text: "Next",
|
|
5848
|
+
value: {
|
|
5849
|
+
action: "directory_page",
|
|
5850
|
+
directory,
|
|
5851
|
+
page: String(page + 1),
|
|
5852
|
+
contextKey,
|
|
5853
|
+
},
|
|
5854
|
+
}] : []),
|
|
5855
|
+
];
|
|
5856
|
+
}
|
|
5857
|
+
|
|
5858
|
+
function windowsDriveDisplayName(drive: WindowsDriveInfo): string {
|
|
5859
|
+
const driveLetter = drive.root.slice(0, 2).toUpperCase();
|
|
5860
|
+
const label = drive.label?.trim() || windowsDriveTypeLabel(drive.driveType);
|
|
5861
|
+
return `${label} (${driveLetter})`;
|
|
5862
|
+
}
|
|
5863
|
+
|
|
5864
|
+
function windowsDriveTypeLabel(driveType?: string): string {
|
|
5865
|
+
switch (driveType?.toLowerCase()) {
|
|
5866
|
+
case "fixed": return "本地磁盘";
|
|
5867
|
+
case "network": return "网络驱动器";
|
|
5868
|
+
case "removable": return "可移动磁盘";
|
|
5869
|
+
case "cdrom": return "光驱";
|
|
5870
|
+
case "ram": return "RAM 磁盘";
|
|
5871
|
+
default: return "磁盘";
|
|
5872
|
+
}
|
|
5873
|
+
}
|
|
5874
|
+
|
|
5875
|
+
async function listWindowsDriveRoots(): Promise<WindowsDriveInfo[]> {
|
|
5876
|
+
if (process.platform !== "win32") throw new Error("磁盘列表仅在 Windows 上可用。");
|
|
5877
|
+
const result = await executeShellCommand(
|
|
5878
|
+
"$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",
|
|
5879
|
+
os.homedir(),
|
|
5880
|
+
{ timeoutMs: 10_000, maxOutputBytes: 16 * 1024, platform: "win32" },
|
|
5881
|
+
);
|
|
5882
|
+
if (result.timedOut || result.exitCode !== 0) {
|
|
5883
|
+
const detail = result.stderr.trim() || `退出码 ${result.exitCode ?? "未知"}`;
|
|
5884
|
+
throw new Error(`无法读取 Windows 磁盘列表:${detail}`);
|
|
5885
|
+
}
|
|
5886
|
+
let parsed: unknown;
|
|
5887
|
+
try {
|
|
5888
|
+
parsed = JSON.parse(result.stdout.trim().replace(/^\uFEFF/u, ""));
|
|
5889
|
+
} catch (error) {
|
|
5890
|
+
throw new Error(`无法解析 Windows 磁盘列表:${runtimeErrorMessage(error)}`);
|
|
5891
|
+
}
|
|
5892
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
5893
|
+
const drives = values.flatMap((value): WindowsDriveInfo[] => {
|
|
5894
|
+
if (typeof value !== "object" || value === null) return [];
|
|
5895
|
+
const record = value as Record<string, unknown>;
|
|
5896
|
+
if (typeof record.root !== "string") return [];
|
|
5897
|
+
const root = path.win32.normalize(record.root.trim());
|
|
5898
|
+
if (!isWindowsDriveRoot(root)) return [];
|
|
5899
|
+
return [{
|
|
5900
|
+
root,
|
|
5901
|
+
...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
|
|
5902
|
+
...(typeof record.driveType === "string" ? { driveType: record.driveType } : {}),
|
|
5903
|
+
}];
|
|
5904
|
+
}).sort((left, right) => left.root.localeCompare(right.root, undefined, { sensitivity: "base" }));
|
|
5905
|
+
if (drives.length === 0) throw new Error("没有检测到可用的 Windows 磁盘。");
|
|
5906
|
+
return drives;
|
|
5907
|
+
}
|
|
5908
|
+
|
|
4350
5909
|
function isPromptCommand(command: Command): boolean {
|
|
4351
5910
|
return command.type === "prompt" || command.type === "nosteer";
|
|
4352
5911
|
}
|
|
@@ -4355,6 +5914,75 @@ function runtimePrompt(text: string, localImagePaths?: string[]): RuntimePrompt
|
|
|
4355
5914
|
return localImagePaths?.length ? { text, localImagePaths } : text;
|
|
4356
5915
|
}
|
|
4357
5916
|
|
|
5917
|
+
function recoveryCardPrompt(prompt: string): string {
|
|
5918
|
+
return `恢复重启前的任务:${prompt}`;
|
|
5919
|
+
}
|
|
5920
|
+
|
|
5921
|
+
function recoveryRuntimePrompt(prompt: string): string {
|
|
5922
|
+
return [
|
|
5923
|
+
"Agent Bot restarted while the previous turn was still running.",
|
|
5924
|
+
"Continue the unfinished task in the current task and workspace.",
|
|
5925
|
+
"Inspect the existing conversation, files, and completed tool effects first. Do not repeat completed or irreversible actions.",
|
|
5926
|
+
"Finish the original request:",
|
|
5927
|
+
"",
|
|
5928
|
+
prompt,
|
|
5929
|
+
].join("\n");
|
|
5930
|
+
}
|
|
5931
|
+
|
|
5932
|
+
function llmRetryCardPrompt(prompt: string, retryNumber: number): string {
|
|
5933
|
+
return `自动重试 ${retryNumber}/${MAX_LLM_TURN_RETRIES}:${prompt}`;
|
|
5934
|
+
}
|
|
5935
|
+
|
|
5936
|
+
function llmRetryRuntimePrompt(prompt: string, retryNumber: number): string {
|
|
5937
|
+
return [
|
|
5938
|
+
`Agent Bot is automatically retrying this request after a transient LLM service failure (${retryNumber}/${MAX_LLM_TURN_RETRIES}).`,
|
|
5939
|
+
"Continue in the current task and workspace. Inspect the conversation, files, and completed tool effects before acting.",
|
|
5940
|
+
"Do not repeat completed or irreversible actions. Finish the original request:",
|
|
5941
|
+
"",
|
|
5942
|
+
prompt,
|
|
5943
|
+
].join("\n");
|
|
5944
|
+
}
|
|
5945
|
+
|
|
5946
|
+
function isRetryableLlmTurnFailure(message: string): boolean {
|
|
5947
|
+
const normalized = message.trim().toLowerCase();
|
|
5948
|
+
if (!normalized) return false;
|
|
5949
|
+
const permanentFailure = [
|
|
5950
|
+
/auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
|
|
5951
|
+
/insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
|
|
5952
|
+
/context (?:length|window)|maximum context|too many tokens|token limit/u,
|
|
5953
|
+
/invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,
|
|
5954
|
+
/model (?:not found|does not exist)|unknown model/u,
|
|
5955
|
+
/permission|approval|sandbox|policy violation|content policy/u,
|
|
5956
|
+
].some((pattern) => pattern.test(normalized));
|
|
5957
|
+
if (permanentFailure) return false;
|
|
5958
|
+
|
|
5959
|
+
return [
|
|
5960
|
+
/(?:^|\D)429(?:\D|$)|rate[_ -]?limit|too many requests/u,
|
|
5961
|
+
/overload|high demand|at capacity|capacity limit|temporar(?:y|ily) unavailable/u,
|
|
5962
|
+
/service unavailable|internal server error|bad gateway|gateway timeout/u,
|
|
5963
|
+
/(?:^|\D)(?:500|502|503|504)(?:\D|$)|upstream (?:error|failure|timeout)/u,
|
|
5964
|
+
/deadline exceeded|request timed out|request timeout|response timeout/u,
|
|
5965
|
+
/connection (?:reset|closed|aborted)|network error|socket hang up/u,
|
|
5966
|
+
/stream (?:closed|disconnected|interrupted|error)/u,
|
|
5967
|
+
/\b(?:llm|model|provider|inference)\b.*\b(?:error|failed|failure|unavailable|timeout|timed out)\b/u,
|
|
5968
|
+
].some((pattern) => pattern.test(normalized));
|
|
5969
|
+
}
|
|
5970
|
+
|
|
5971
|
+
function remoteTurnMatchesAttempt(remote: RemoteSessionSummary | undefined, attempt: TurnAttemptRecord): boolean {
|
|
5972
|
+
if (!remote?.lastTurnId) return false;
|
|
5973
|
+
if (attempt.turnId) return remote.lastTurnId === attempt.turnId;
|
|
5974
|
+
const remoteUpdatedAt = remote.updatedAt === undefined ? undefined : normalizeRemoteTimestamp(remote.updatedAt);
|
|
5975
|
+
const acceptedAt = Date.parse(attempt.createdAt);
|
|
5976
|
+
return remoteUpdatedAt !== undefined
|
|
5977
|
+
&& Number.isFinite(acceptedAt)
|
|
5978
|
+
&& remoteUpdatedAt >= acceptedAt - 1_000;
|
|
5979
|
+
}
|
|
5980
|
+
|
|
5981
|
+
function isRecoveryAttemptRecent(attempt: TurnAttemptRecord, cutoff: number): boolean {
|
|
5982
|
+
const updatedAt = Date.parse(attempt.updatedAt);
|
|
5983
|
+
return Number.isFinite(updatedAt) && updatedAt >= cutoff;
|
|
5984
|
+
}
|
|
5985
|
+
|
|
4358
5986
|
function formatGroupDateSuffix(date: Date): string {
|
|
4359
5987
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
4360
5988
|
const day = String(date.getDate()).padStart(2, "0");
|
|
@@ -4395,10 +6023,19 @@ function formatGroupProjectDirectory(projectCwd: string): string {
|
|
|
4395
6023
|
}
|
|
4396
6024
|
|
|
4397
6025
|
function abbreviateHomeDirectory(value: string): string {
|
|
4398
|
-
const
|
|
6026
|
+
const homeDirectory = os.homedir();
|
|
6027
|
+
const valueIsWindows = isWindowsAbsolutePath(value);
|
|
6028
|
+
const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
|
|
6029
|
+
if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value))) return value;
|
|
6030
|
+
const pathApi = valueIsWindows ? path.win32 : path;
|
|
6031
|
+
const relative = pathApi.relative(homeDirectory, value);
|
|
4399
6032
|
if (relative === "") return "~";
|
|
4400
|
-
if (
|
|
4401
|
-
return `~${
|
|
6033
|
+
if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) return value;
|
|
6034
|
+
return `~${pathApi.sep}${relative}`;
|
|
6035
|
+
}
|
|
6036
|
+
|
|
6037
|
+
function isWindowsAbsolutePath(value: string): boolean {
|
|
6038
|
+
return /^[a-z]:[\\/]|^\\\\/iu.test(value);
|
|
4402
6039
|
}
|
|
4403
6040
|
|
|
4404
6041
|
function fitTrailingPathLevels(levels: string[], maxLength: number, separator: string): string {
|