@keyou007/agent-bot 0.1.14-alpha.0 → 0.1.14-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +41 -44
- package/README.zh.md +41 -44
- package/config.example.yaml +4 -0
- package/dist/codex/CodexRuntime.js +8 -3
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/commands/CommandRouter.js +13 -0
- package/dist/commands/CommandRouter.js.map +1 -1
- package/dist/config/loadConfig.js +2 -0
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/schema.js +2 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/console/ConsoleTurnPresenter.js +2 -0
- package/dist/console/ConsoleTurnPresenter.js.map +1 -1
- package/dist/feishu/CardRenderer.js +428 -9
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +29 -8
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuMessageClient.js +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 +14 -17
- package/dist/index.js.map +1 -1
- package/dist/presentation/OutboundRouter.js +19 -1
- package/dist/presentation/OutboundRouter.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +1012 -58
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/startup/StartupNotifier.js +65 -16
- package/dist/startup/StartupNotifier.js.map +1 -1
- package/dist/startup/startFeishu.js +2 -2
- package/dist/startup/startFeishu.js.map +1 -1
- package/dist/state/StateStore.js +250 -67
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/migrations.js +26 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +17 -16
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +12 -7
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/supervision/replacementSupervisor.js +45 -4
- package/dist/supervision/replacementSupervisor.js.map +1 -1
- package/dist/supervisor.js +30 -18
- package/dist/supervisor.js.map +1 -1
- package/docs/technical-reference.md +18 -5
- package/docs/technical-reference.zh.md +18 -5
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +8 -2
- package/src/codex/CodexRuntime.ts +9 -3
- package/src/commands/CommandRouter.ts +13 -0
- package/src/commands/commandTypes.ts +2 -0
- package/src/config/loadConfig.ts +2 -0
- package/src/config/schema.ts +2 -0
- package/src/console/ConsoleTurnPresenter.ts +4 -1
- package/src/feishu/CardRenderer.ts +533 -9
- package/src/feishu/FeishuConnector.ts +44 -10
- package/src/feishu/FeishuMessageClient.ts +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 +20 -20
- package/src/presentation/OutboundRouter.ts +28 -3
- package/src/proxy/ProxySessionController.ts +1177 -57
- package/src/startup/StartupNotifier.ts +67 -17
- package/src/startup/startFeishu.ts +5 -3
- package/src/state/StateStore.ts +394 -65
- package/src/state/migrations.ts +26 -0
- package/src/supervision/SafeRestartNotifier.ts +17 -17
- package/src/supervision/SafeRestartScheduler.ts +13 -6
- package/src/supervision/replacementSupervisor.ts +50 -4
- package/src/supervisor.ts +35 -16
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
|
|
@@ -6,6 +7,7 @@ import { CommandRouter } from "../commands/CommandRouter.js";
|
|
|
6
7
|
import { baseChatContextKey, isThreadContextKey } from "../feishu/contextKey.js";
|
|
7
8
|
import { CardRenderer, } from "../feishu/CardRenderer.js";
|
|
8
9
|
import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
|
|
10
|
+
import { allowsFeishuUser } from "../feishu/ownerAccess.js";
|
|
9
11
|
import { buildTurnGraphRows } from "../presentation/turnGraph.js";
|
|
10
12
|
import { createId } from "../utils/id.js";
|
|
11
13
|
import { asInlineCode, codeBlock, truncateMiddle, truncateText } from "../utils/markdown.js";
|
|
@@ -16,8 +18,22 @@ const MESSAGE_COMPLETED_REACTION = "DONE";
|
|
|
16
18
|
const MESSAGE_FAILED_REACTION = "ERROR";
|
|
17
19
|
const MESSAGE_CANCELLED_REACTION = "CrossMark";
|
|
18
20
|
const SESSION_PAGE_SIZE = 5;
|
|
21
|
+
const DIRECTORY_PAGE_SIZE = 15;
|
|
19
22
|
const RESET_HISTORY_PAGE_SIZE = 10;
|
|
23
|
+
const MAX_LLM_TURN_RETRIES = 3;
|
|
24
|
+
const RECOVERY_ACTIVITY_WINDOW_MS = 5 * 60 * 1_000;
|
|
25
|
+
const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
|
|
20
26
|
const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
|
|
27
|
+
const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
|
|
28
|
+
const IMAGE_FILE_EXTENSIONS = new Set([
|
|
29
|
+
".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp",
|
|
30
|
+
]);
|
|
31
|
+
const BINARY_FILE_EXTENSIONS = new Set([
|
|
32
|
+
".7z", ".a", ".apk", ".avi", ".bin", ".bz2", ".class", ".dat", ".db", ".deb", ".dll", ".dmg", ".doc", ".docx",
|
|
33
|
+
".dylib", ".eot", ".exe", ".flac", ".gz", ".ico", ".ipa", ".iso", ".jar", ".lib", ".mdb", ".mkv", ".mov", ".mp3",
|
|
34
|
+
".mp4", ".msi", ".o", ".obj", ".ogg", ".otf", ".pdf", ".ppt", ".pptx", ".rar", ".rpm", ".so", ".sqlite", ".tar",
|
|
35
|
+
".ttf", ".wav", ".webm", ".woff", ".woff2", ".xls", ".xlsx", ".xz", ".zip", ".zst",
|
|
36
|
+
]);
|
|
21
37
|
const HELP_COMMAND_SECTIONS = [
|
|
22
38
|
{
|
|
23
39
|
title: "任务管理",
|
|
@@ -32,6 +48,11 @@ const HELP_COMMAND_SECTIONS = [
|
|
|
32
48
|
usage: "[title] [--dir <cwd> | --nodir]",
|
|
33
49
|
description: "创建飞书群和新任务",
|
|
34
50
|
},
|
|
51
|
+
{
|
|
52
|
+
command: "/dir",
|
|
53
|
+
usage: "[目录]",
|
|
54
|
+
description: "浏览、发送文件,并从选定目录创建任务或群",
|
|
55
|
+
},
|
|
35
56
|
{
|
|
36
57
|
command: "/forkgroup",
|
|
37
58
|
usage: "[title]",
|
|
@@ -114,6 +135,11 @@ const HELP_COMMAND_SECTIONS = [
|
|
|
114
135
|
usage: "[--force]",
|
|
115
136
|
description: "默认安全重启;--force 立即重启",
|
|
116
137
|
},
|
|
138
|
+
{
|
|
139
|
+
command: "/mute",
|
|
140
|
+
usage: "[on|off]",
|
|
141
|
+
description: "开启或关闭当前群的仅 @ 响应模式",
|
|
142
|
+
},
|
|
117
143
|
{ command: "/help", description: "显示本帮助" },
|
|
118
144
|
],
|
|
119
145
|
},
|
|
@@ -129,6 +155,7 @@ export class ProxySessionController {
|
|
|
129
155
|
logger;
|
|
130
156
|
lifecycle;
|
|
131
157
|
shellCommandExecutor;
|
|
158
|
+
windowsDriveLister;
|
|
132
159
|
router = new CommandRouter();
|
|
133
160
|
cardRenderer = new CardRenderer();
|
|
134
161
|
messageQueues = new Map();
|
|
@@ -139,8 +166,14 @@ export class ProxySessionController {
|
|
|
139
166
|
sessionResets = new Map();
|
|
140
167
|
lastSessionListings = new Map();
|
|
141
168
|
threadInitializations = new Map();
|
|
169
|
+
llmRetryingSessions = new Set();
|
|
170
|
+
retriedFailureTurnIds = new Set();
|
|
171
|
+
recoveryRetryTimers = new Map();
|
|
142
172
|
unsubscribe = [];
|
|
143
|
-
|
|
173
|
+
recoveryActivityHeartbeat;
|
|
174
|
+
startupRecovery;
|
|
175
|
+
queuedPromptsRestored = false;
|
|
176
|
+
constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand, windowsDriveLister = listWindowsDriveRoots) {
|
|
144
177
|
this.config = config;
|
|
145
178
|
this.store = store;
|
|
146
179
|
this.runtimes = runtimes;
|
|
@@ -148,12 +181,7 @@ export class ProxySessionController {
|
|
|
148
181
|
this.logger = logger;
|
|
149
182
|
this.lifecycle = lifecycle;
|
|
150
183
|
this.shellCommandExecutor = shellCommandExecutor;
|
|
151
|
-
|
|
152
|
-
.filter(([, agent]) => agent.kind === "acp")
|
|
153
|
-
.map(([name]) => name));
|
|
154
|
-
if (interruptedAcpSessions.length > 0) {
|
|
155
|
-
this.logger.warn({ sessionIds: interruptedAcpSessions.map((session) => session.localSessionId) }, "Marked interrupted ACP sessions as failed after process restart.");
|
|
156
|
-
}
|
|
184
|
+
this.windowsDriveLister = windowsDriveLister;
|
|
157
185
|
for (const [, runtime] of this.runtimes.entries()) {
|
|
158
186
|
this.unsubscribe.push(runtime.onEvent((event) => {
|
|
159
187
|
void this.handleRuntimeEvent(event).catch((error) => {
|
|
@@ -162,14 +190,25 @@ export class ProxySessionController {
|
|
|
162
190
|
}));
|
|
163
191
|
}
|
|
164
192
|
this.restorePersistedSessionRoutes();
|
|
165
|
-
this.restorePersistedQueuedPrompts();
|
|
166
193
|
void this.restorePersistedMessageReactions().catch((error) => {
|
|
167
194
|
this.logger.warn({ error }, "Failed to restore persisted message reaction statuses.");
|
|
168
195
|
});
|
|
196
|
+
this.recoveryActivityHeartbeat = setInterval(() => this.persistActiveTurnHeartbeats(), RECOVERY_HEARTBEAT_INTERVAL_MS);
|
|
197
|
+
this.recoveryActivityHeartbeat.unref?.();
|
|
169
198
|
}
|
|
170
199
|
async onMessage(message) {
|
|
171
|
-
|
|
172
|
-
|
|
200
|
+
if (!message.contextKey.startsWith("console:") && !allowsFeishuUser(this.config, message.userId)) {
|
|
201
|
+
this.logger.debug({ messageId: message.messageId }, "Ignored a Feishu message from a non-owner before acknowledgement.");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (message.chatType === "group"
|
|
205
|
+
&& this.store.chatRequiresMention(baseChatContextKey(message.contextKey))
|
|
206
|
+
&& message.mentionedBot !== true) {
|
|
207
|
+
this.logger.debug({ messageId: message.messageId, contextKey: message.contextKey }, "Ignored an unmentioned message from a muted Lark group.");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// For accepted messages, claim durable deduplication before acknowledgement so event
|
|
211
|
+
// retries cannot add duplicate reactions.
|
|
173
212
|
if (!this.store.claimInboundEvent(message.messageId, "message"))
|
|
174
213
|
return;
|
|
175
214
|
try {
|
|
@@ -262,6 +301,10 @@ export class ProxySessionController {
|
|
|
262
301
|
this.messageQueues.delete(message.contextKey);
|
|
263
302
|
}
|
|
264
303
|
async onCardAction(action) {
|
|
304
|
+
if (!allowsFeishuUser(this.config, action.userId)) {
|
|
305
|
+
this.logger.debug({ actionId: action.actionId }, "Ignored a Feishu card action from a non-owner.");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
265
308
|
if (!this.store.claimInboundEvent(action.actionId, "card_action"))
|
|
266
309
|
return;
|
|
267
310
|
const contextKey = this.cardActionContextKey(action);
|
|
@@ -321,6 +364,24 @@ export class ProxySessionController {
|
|
|
321
364
|
await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
|
|
322
365
|
}
|
|
323
366
|
}
|
|
367
|
+
else if (kind === "directory_open" || kind === "directory_page") {
|
|
368
|
+
await this.openDirectoryBrowser(contextKey, directoryActionPath(scopedAction.value.directory), {
|
|
369
|
+
updateMessageId: requiredCardMessageId(scopedAction.messageId),
|
|
370
|
+
page: kind === "directory_page" ? directoryPageValue(scopedAction.value.page) : 0,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
else if (kind === "directory_send_file") {
|
|
374
|
+
const filePath = directoryFileActionPath(scopedAction.value.filePath);
|
|
375
|
+
await this.assertSendableFile(filePath);
|
|
376
|
+
await this.outbound.sendFile(contextKey, filePath);
|
|
377
|
+
}
|
|
378
|
+
else if (kind === "directory_new" || kind === "directory_new_group") {
|
|
379
|
+
const directory = directoryActionPath(scopedAction.value.directory);
|
|
380
|
+
await this.assertBrowsableDirectory(directory);
|
|
381
|
+
await this.execute(contextKey, kind === "directory_new"
|
|
382
|
+
? { type: "new", cwd: directory }
|
|
383
|
+
: { type: "newgroup", cwd: directory }, undefined, replyTarget, undefined, scopedAction.userId);
|
|
384
|
+
}
|
|
324
385
|
else if (kind === "session_page") {
|
|
325
386
|
await this.refreshSessionsCardFromAction(scopedAction);
|
|
326
387
|
}
|
|
@@ -484,6 +545,10 @@ export class ProxySessionController {
|
|
|
484
545
|
this.queuedPromptCards.clear();
|
|
485
546
|
this.queuedPromptCardWrites.clear();
|
|
486
547
|
this.sessionResets.clear();
|
|
548
|
+
for (const timer of this.recoveryRetryTimers.values())
|
|
549
|
+
clearTimeout(timer);
|
|
550
|
+
this.recoveryRetryTimers.clear();
|
|
551
|
+
clearInterval(this.recoveryActivityHeartbeat);
|
|
487
552
|
}
|
|
488
553
|
async controlStopTask(localSessionId) {
|
|
489
554
|
const record = this.store.getSession(localSessionId);
|
|
@@ -665,6 +730,9 @@ export class ProxySessionController {
|
|
|
665
730
|
case "shell":
|
|
666
731
|
await this.runShellCommand(contextKey, command.command);
|
|
667
732
|
return;
|
|
733
|
+
case "dir":
|
|
734
|
+
await this.openDirectoryBrowser(contextKey, command.directory);
|
|
735
|
+
return;
|
|
668
736
|
case "new":
|
|
669
737
|
if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
|
|
670
738
|
throw new Error("/new --nodir 仅支持 App Server Agent。");
|
|
@@ -719,6 +787,9 @@ export class ProxySessionController {
|
|
|
719
787
|
throw new Error("当前运行方式不支持自动重启。");
|
|
720
788
|
await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
|
|
721
789
|
return;
|
|
790
|
+
case "mute":
|
|
791
|
+
await this.setGroupMute(contextKey, command.enabled);
|
|
792
|
+
return;
|
|
722
793
|
case "turns":
|
|
723
794
|
await this.openResetHistory(contextKey);
|
|
724
795
|
return;
|
|
@@ -752,6 +823,8 @@ export class ProxySessionController {
|
|
|
752
823
|
const turnContextKey = session.lastTurnId
|
|
753
824
|
? this.store.getTurnContextKey(session.lastTurnId) ?? context.contextKey
|
|
754
825
|
: context.contextKey;
|
|
826
|
+
if (!this.outbound.canRoute(turnContextKey))
|
|
827
|
+
continue;
|
|
755
828
|
this.outbound.registerSession(session.localSessionId, turnContextKey, session.title, session.cwd, this.agentLabel(session.agentName));
|
|
756
829
|
if (!session.lastTurnId)
|
|
757
830
|
continue;
|
|
@@ -759,14 +832,314 @@ export class ProxySessionController {
|
|
|
759
832
|
this.logger.warn({ error, sessionId: session.localSessionId }, "Failed to restore persisted turn delivery.");
|
|
760
833
|
});
|
|
761
834
|
}
|
|
835
|
+
for (const turn of this.store.listUndeliveredCompletedTurns()) {
|
|
836
|
+
const contextKey = turn.contextKey ?? this.store.getSession(turn.localSessionId)?.contextKey;
|
|
837
|
+
if (!contextKey || !this.outbound.canRoute(contextKey))
|
|
838
|
+
continue;
|
|
839
|
+
void this.outbound.resumeDelivery(turn.localSessionId, contextKey, turn.turnId).catch((error) => {
|
|
840
|
+
this.logger.warn({ error, sessionId: turn.localSessionId, turnId: turn.turnId }, "Failed to resume an undelivered completed turn.");
|
|
841
|
+
});
|
|
842
|
+
}
|
|
762
843
|
}
|
|
763
844
|
restorePersistedQueuedPrompts() {
|
|
845
|
+
if (this.queuedPromptsRestored)
|
|
846
|
+
return;
|
|
847
|
+
this.queuedPromptsRestored = true;
|
|
764
848
|
for (const sessionId of this.store.listQueuedPromptSessionIds()) {
|
|
849
|
+
const firstPrompt = this.store.listQueuedPrompts(sessionId)[0];
|
|
850
|
+
if (firstPrompt && !this.outbound.canRoute(firstPrompt.contextKey))
|
|
851
|
+
continue;
|
|
765
852
|
void this.scheduleNextQueuedPrompt(sessionId).catch((error) => {
|
|
766
853
|
this.logger.warn({ error, sessionId }, "Failed to resume a persisted prompt queue.");
|
|
767
854
|
});
|
|
768
855
|
}
|
|
769
856
|
}
|
|
857
|
+
async recoverInterruptedTasks() {
|
|
858
|
+
if (this.startupRecovery)
|
|
859
|
+
return this.startupRecovery;
|
|
860
|
+
const recovery = this.runStartupRecovery();
|
|
861
|
+
this.startupRecovery = recovery;
|
|
862
|
+
return recovery;
|
|
863
|
+
}
|
|
864
|
+
async runStartupRecovery() {
|
|
865
|
+
this.backfillTurnAttemptsForUpgrade();
|
|
866
|
+
const attempts = this.store.listIncompleteTurnAttempts();
|
|
867
|
+
const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
|
|
868
|
+
const recentAttempts = attempts.filter((attempt) => isRecoveryAttemptRecent(attempt, recoveryCutoff));
|
|
869
|
+
if (recentAttempts.length > 0) {
|
|
870
|
+
this.logger.warn({ attemptIds: recentAttempts.map((attempt) => attempt.attemptId) }, "Recovering unfinished Agent Bot tasks after startup.");
|
|
871
|
+
}
|
|
872
|
+
for (const attempt of attempts) {
|
|
873
|
+
if (!isRecoveryAttemptRecent(attempt, recoveryCutoff)) {
|
|
874
|
+
await this.expireStaleTurnAttempt(attempt);
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (!this.outbound.canRoute(attempt.contextKey))
|
|
878
|
+
continue;
|
|
879
|
+
try {
|
|
880
|
+
await this.recoverTurnAttempt(attempt);
|
|
881
|
+
}
|
|
882
|
+
catch (error) {
|
|
883
|
+
this.logger.error({ error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId }, "Failed to recover an unfinished task; it will be retried while the server is running.");
|
|
884
|
+
this.scheduleRecoveryRetry(attempt.attemptId);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
this.restorePersistedQueuedPrompts();
|
|
888
|
+
}
|
|
889
|
+
backfillTurnAttemptsForUpgrade() {
|
|
890
|
+
for (const session of this.store.listAllSessions()) {
|
|
891
|
+
if (session.status !== "starting" && session.status !== "running" && session.status !== "ready")
|
|
892
|
+
continue;
|
|
893
|
+
if (this.store.findIncompleteTurnAttemptForSession(session.localSessionId))
|
|
894
|
+
continue;
|
|
895
|
+
const lastTurn = session.lastTurnId
|
|
896
|
+
? {
|
|
897
|
+
turnId: session.lastTurnId,
|
|
898
|
+
localSessionId: session.localSessionId,
|
|
899
|
+
contextKey: this.store.getTurnContextKey(session.lastTurnId),
|
|
900
|
+
snapshot: this.store.getTurnSnapshot(session.lastTurnId),
|
|
901
|
+
updatedAt: session.updatedAt,
|
|
902
|
+
}
|
|
903
|
+
: undefined;
|
|
904
|
+
const lastTurnSnapshot = turnViewSnapshot(lastTurn?.snapshot);
|
|
905
|
+
const latestPersisted = this.store.findLatestTurnSnapshotForSession(session.localSessionId);
|
|
906
|
+
const latestPersistedSnapshot = turnViewSnapshot(latestPersisted?.snapshot);
|
|
907
|
+
const latest = lastTurnSnapshot && !isTerminalTurnViewStatus(lastTurnSnapshot.status)
|
|
908
|
+
? lastTurn
|
|
909
|
+
: latestPersistedSnapshot && !isTerminalTurnViewStatus(latestPersistedSnapshot.status)
|
|
910
|
+
? latestPersisted
|
|
911
|
+
: session.status === "ready"
|
|
912
|
+
? undefined
|
|
913
|
+
: lastTurn;
|
|
914
|
+
if (!latest && session.status === "ready")
|
|
915
|
+
continue;
|
|
916
|
+
const snapshot = turnViewSnapshot(latest?.snapshot);
|
|
917
|
+
if (snapshot && isTerminalTurnViewStatus(snapshot.status)) {
|
|
918
|
+
this.store.updateSession(session.localSessionId, {
|
|
919
|
+
status: snapshot.status === "failed" ? "failed" : "ready",
|
|
920
|
+
});
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
const persistedTurnId = latest?.turnId;
|
|
924
|
+
const pendingTurnId = persistedTurnId?.startsWith("pending_") ? persistedTurnId : undefined;
|
|
925
|
+
const turnId = pendingTurnId ? undefined : persistedTurnId ?? session.lastTurnId;
|
|
926
|
+
const replyTarget = snapshot?.replyTarget?.replyInThread ? snapshot.replyTarget : undefined;
|
|
927
|
+
this.store.createTurnAttempt({
|
|
928
|
+
attemptId: createId("attempt"),
|
|
929
|
+
localSessionId: session.localSessionId,
|
|
930
|
+
contextKey: latest?.contextKey ?? session.contextKey,
|
|
931
|
+
promptText: snapshot?.prompt?.trim() || "继续完成重启前尚未完成的任务。",
|
|
932
|
+
messageId: turnId ? this.store.findMessageIdForTurn(turnId) : undefined,
|
|
933
|
+
replyMessageId: replyTarget?.messageId,
|
|
934
|
+
pendingTurnId,
|
|
935
|
+
turnId,
|
|
936
|
+
status: turnId ? "running" : "accepted",
|
|
937
|
+
createdAt: latest?.updatedAt ?? session.updatedAt,
|
|
938
|
+
updatedAt: latest?.updatedAt ?? session.updatedAt,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
async expireStaleTurnAttempt(attempt) {
|
|
943
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
944
|
+
const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
|
|
945
|
+
if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
|
|
946
|
+
await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "执行中断已超过 5 分钟,未自动恢复。").catch((error) => {
|
|
947
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, turnId: oldCardTurnId }, "Failed to close an expired interrupted thinking card.");
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
|
|
951
|
+
if (!session || session.status === "closed")
|
|
952
|
+
return;
|
|
953
|
+
if (attempt.turnId && session.lastTurnId === attempt.turnId) {
|
|
954
|
+
this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
|
|
955
|
+
}
|
|
956
|
+
if (session.status === "starting" || session.status === "running") {
|
|
957
|
+
this.store.updateSession(session.localSessionId, { status: "ready" });
|
|
958
|
+
}
|
|
959
|
+
if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
|
|
960
|
+
await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error) => {
|
|
961
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, turnId: attempt.turnId }, "Failed to finalize the message reaction for an expired interrupted turn.");
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
persistActiveTurnHeartbeats() {
|
|
966
|
+
for (const attempt of this.store.listIncompleteTurnAttempts()) {
|
|
967
|
+
if (!attempt.turnId)
|
|
968
|
+
continue;
|
|
969
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
970
|
+
if (!session)
|
|
971
|
+
continue;
|
|
972
|
+
const activeTurnId = this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId;
|
|
973
|
+
if (activeTurnId === attempt.turnId)
|
|
974
|
+
this.store.touchTurnAttempt(attempt.turnId);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
async recoverTurnAttempt(attempt, announce = true) {
|
|
978
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
979
|
+
if (!session || session.status === "closed") {
|
|
980
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const contextKey = attempt.contextKey || session.contextKey;
|
|
984
|
+
const replyTarget = attempt.replyMessageId
|
|
985
|
+
? { messageId: attempt.replyMessageId, replyInThread: true }
|
|
986
|
+
: undefined;
|
|
987
|
+
this.outbound.registerSession(session.localSessionId, contextKey, session.title, session.cwd, this.agentLabel(session.agentName));
|
|
988
|
+
if (announce) {
|
|
989
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendText(contextKey, "检测到任务在 Agent Bot 重启前尚未完成,正在自动恢复。")).catch((error) => {
|
|
990
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, contextKey }, "Failed to send the task recovery notification.");
|
|
991
|
+
return undefined;
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
const runtime = this.runtimes.forAgent(session.agentName);
|
|
995
|
+
const sourceTurnId = attempt.turnId;
|
|
996
|
+
if (runtime.kind === "acp") {
|
|
997
|
+
await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
let remote;
|
|
1001
|
+
if (session.remoteSessionId && runtime.readRemoteSession) {
|
|
1002
|
+
try {
|
|
1003
|
+
remote = await runtime.readRemoteSession(session.remoteSessionId);
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId }, "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.");
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
|
|
1010
|
+
if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "completed") {
|
|
1011
|
+
await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "failed") {
|
|
1015
|
+
await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (remote && remoteTurnBelongsToAttempt && isRemoteSessionActive(remote) && remote.lastTurnId) {
|
|
1019
|
+
await this.reattachActiveRecoveredTurn(attempt, session, remote.lastTurnId, replyTarget);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (remote && isRemoteSessionActive(remote) && !remoteTurnBelongsToAttempt) {
|
|
1023
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "recovering" });
|
|
1024
|
+
if (announce) {
|
|
1025
|
+
await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendText(contextKey, "任务中检测到另一轮仍在执行;本次恢复将在它结束后重试。"));
|
|
1026
|
+
}
|
|
1027
|
+
this.scheduleRecoveryRetry(attempt.attemptId);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
|
|
1031
|
+
}
|
|
1032
|
+
async reconcileRecoveredRemoteTurn(attempt, session, remoteTurnId) {
|
|
1033
|
+
if (remoteTurnId && !attempt.turnId) {
|
|
1034
|
+
this.store.updateTurnAttempt(attempt.attemptId, { turnId: remoteTurnId, status: "running" });
|
|
1035
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
1036
|
+
lastTurnId: remoteTurnId,
|
|
1037
|
+
lastTurnStatus: "running",
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1041
|
+
const loaded = await this.loadSession({
|
|
1042
|
+
...current,
|
|
1043
|
+
contextKey: attempt.contextKey,
|
|
1044
|
+
status: "running",
|
|
1045
|
+
lastTurnStatus: "running",
|
|
1046
|
+
});
|
|
1047
|
+
const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
|
|
1048
|
+
if (synchronized.activeTurnId)
|
|
1049
|
+
this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
|
|
1050
|
+
}
|
|
1051
|
+
scheduleRecoveryRetry(attemptId, activeTurnId) {
|
|
1052
|
+
if (this.recoveryRetryTimers.has(attemptId))
|
|
1053
|
+
return;
|
|
1054
|
+
const timer = setTimeout(() => {
|
|
1055
|
+
this.recoveryRetryTimers.delete(attemptId);
|
|
1056
|
+
const attempt = this.store.getTurnAttempt(attemptId);
|
|
1057
|
+
if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status))
|
|
1058
|
+
return;
|
|
1059
|
+
const retry = async () => {
|
|
1060
|
+
let recoveryAttempt = attempt;
|
|
1061
|
+
if (activeTurnId) {
|
|
1062
|
+
const session = this.store.getSession(attempt.localSessionId);
|
|
1063
|
+
if (session) {
|
|
1064
|
+
const runtime = this.runtimes.forAgent(session.agentName);
|
|
1065
|
+
const synchronized = await runtime.synchronizeSession(session.localSessionId);
|
|
1066
|
+
if (synchronized.activeTurnId === activeTurnId) {
|
|
1067
|
+
this.scheduleRecoveryRetry(attemptId, activeTurnId);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const current = this.store.getTurnAttempt(attemptId);
|
|
1071
|
+
if (!current || !isIncompleteTurnAttemptStatus(current.status))
|
|
1072
|
+
return;
|
|
1073
|
+
recoveryAttempt = current;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
await this.recoverTurnAttempt(recoveryAttempt, false);
|
|
1077
|
+
};
|
|
1078
|
+
void retry().catch((error) => {
|
|
1079
|
+
this.logger.warn({ error, attemptId, sessionId: attempt.localSessionId }, "Failed to retry an unfinished task recovery.");
|
|
1080
|
+
this.scheduleRecoveryRetry(attemptId, activeTurnId);
|
|
1081
|
+
});
|
|
1082
|
+
}, 5_000);
|
|
1083
|
+
timer.unref?.();
|
|
1084
|
+
this.recoveryRetryTimers.set(attemptId, timer);
|
|
1085
|
+
}
|
|
1086
|
+
async reattachActiveRecoveredTurn(attempt, session, turnId, replyTarget) {
|
|
1087
|
+
const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
|
|
1088
|
+
if (oldCardTurnId) {
|
|
1089
|
+
await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。");
|
|
1090
|
+
}
|
|
1091
|
+
const pendingTurnId = await this.outbound.startPendingTurn(session.localSessionId, attempt.contextKey, session.title, replyTarget, recoveryCardPrompt(attempt.promptText));
|
|
1092
|
+
this.store.updateTurnAttempt(attempt.attemptId, {
|
|
1093
|
+
pendingTurnId: pendingTurnId ?? null,
|
|
1094
|
+
turnId,
|
|
1095
|
+
status: "running",
|
|
1096
|
+
});
|
|
1097
|
+
await this.outbound.onEvent({
|
|
1098
|
+
type: "turn_started",
|
|
1099
|
+
sessionId: session.localSessionId,
|
|
1100
|
+
turnId,
|
|
1101
|
+
startedAt: Date.now(),
|
|
1102
|
+
});
|
|
1103
|
+
this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
|
|
1104
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1105
|
+
const loaded = await this.loadSession({
|
|
1106
|
+
...current,
|
|
1107
|
+
contextKey: attempt.contextKey,
|
|
1108
|
+
status: "running",
|
|
1109
|
+
lastTurnId: turnId,
|
|
1110
|
+
lastTurnStatus: "running",
|
|
1111
|
+
});
|
|
1112
|
+
const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
|
|
1113
|
+
if (synchronized.activeTurnId)
|
|
1114
|
+
this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
|
|
1115
|
+
}
|
|
1116
|
+
async continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget) {
|
|
1117
|
+
const oldCardTurnId = attempt.pendingTurnId ?? sourceTurnId;
|
|
1118
|
+
if (oldCardTurnId) {
|
|
1119
|
+
await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 重启中断了这次执行,正在新的思考卡片中继续。").catch((error) => {
|
|
1120
|
+
this.logger.warn({ error, attemptId: attempt.attemptId, turnId: oldCardTurnId }, "Failed to close the interrupted thinking card before recovery.");
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
this.store.prepareTurnAttemptRecovery(attempt.attemptId, oldCardTurnId);
|
|
1124
|
+
this.store.updateSession(session.localSessionId, { status: "ready" });
|
|
1125
|
+
if (sourceTurnId) {
|
|
1126
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
1127
|
+
lastTurnId: sourceTurnId,
|
|
1128
|
+
lastTurnStatus: "cancelled",
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
1132
|
+
const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
|
|
1133
|
+
const recoveryText = recoveryRuntimePrompt(attempt.promptText);
|
|
1134
|
+
const turnId = await this.startTurn(loaded, recoveryText, replyTarget, attempt.localImagePaths, {
|
|
1135
|
+
attemptId: attempt.attemptId,
|
|
1136
|
+
messageId: attempt.messageId,
|
|
1137
|
+
displayPrompt: recoveryCardPrompt(attempt.promptText),
|
|
1138
|
+
preserveAttemptOnFailure: true,
|
|
1139
|
+
});
|
|
1140
|
+
if (attempt.messageId)
|
|
1141
|
+
await this.bindMessageReactionToTurn(attempt.messageId, session.localSessionId, turnId);
|
|
1142
|
+
}
|
|
770
1143
|
async restorePersistedMessageReactions() {
|
|
771
1144
|
const pending = this.store.listPendingMessageReactions();
|
|
772
1145
|
const terminalTurns = new Map();
|
|
@@ -795,6 +1168,19 @@ export class ProxySessionController {
|
|
|
795
1168
|
await this.promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths);
|
|
796
1169
|
}
|
|
797
1170
|
async promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths) {
|
|
1171
|
+
if (this.llmRetryingSessions.has(record.localSessionId)) {
|
|
1172
|
+
const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
|
|
1173
|
+
localImagePaths,
|
|
1174
|
+
messageId,
|
|
1175
|
+
replyTarget,
|
|
1176
|
+
});
|
|
1177
|
+
this.store.audit(contextKey, "queued_prompt_added_during_llm_retry", {
|
|
1178
|
+
promptId: queued.promptId,
|
|
1179
|
+
localSessionId: record.localSessionId,
|
|
1180
|
+
});
|
|
1181
|
+
await this.presentPromptQueueCard(record.localSessionId, contextKey);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
798
1184
|
const configuredRuntime = this.runtimes.forAgent(record.agentName);
|
|
799
1185
|
if (!configuredRuntime.getSession(record.localSessionId)) {
|
|
800
1186
|
this.outbound.registerSession(record.localSessionId, contextKey, record.title, record.cwd, this.agentLabel(record.agentName));
|
|
@@ -852,7 +1238,7 @@ export class ProxySessionController {
|
|
|
852
1238
|
this.logger.warn({ error: syncError, sessionId: record.localSessionId }, "Failed to synchronize after steering failure.");
|
|
853
1239
|
}
|
|
854
1240
|
if (!current?.activeTurnId) {
|
|
855
|
-
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
|
|
1241
|
+
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
|
|
856
1242
|
if (messageId)
|
|
857
1243
|
await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
|
|
858
1244
|
return;
|
|
@@ -877,11 +1263,12 @@ export class ProxySessionController {
|
|
|
877
1263
|
return;
|
|
878
1264
|
}
|
|
879
1265
|
}
|
|
880
|
-
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
|
|
1266
|
+
const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
|
|
881
1267
|
if (messageId)
|
|
882
1268
|
await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
|
|
883
1269
|
}
|
|
884
1270
|
async presentSteerMessage(localSessionId, turnId, text, messageId) {
|
|
1271
|
+
this.store.touchTurnAttempt(turnId);
|
|
885
1272
|
try {
|
|
886
1273
|
await this.outbound.appendSteerMessage(localSessionId, turnId, text, messageId);
|
|
887
1274
|
}
|
|
@@ -992,6 +1379,10 @@ export class ProxySessionController {
|
|
|
992
1379
|
const current = this.store.getUserContext(message.contextKey)?.currentSessionId;
|
|
993
1380
|
if (current && this.store.getSession(current))
|
|
994
1381
|
return;
|
|
1382
|
+
const hasPersistedSourceTurn = threadForkAnchorMessageIds(message)
|
|
1383
|
+
.some((messageId) => this.store.findTurnAnchorByMessageId(messageId) !== undefined);
|
|
1384
|
+
if (!hasPersistedSourceTurn)
|
|
1385
|
+
return;
|
|
995
1386
|
const existing = this.threadInitializations.get(message.contextKey);
|
|
996
1387
|
if (existing)
|
|
997
1388
|
return existing;
|
|
@@ -1074,9 +1465,7 @@ export class ProxySessionController {
|
|
|
1074
1465
|
if (!message.threadContext || !message.threadId || !message.chatId) {
|
|
1075
1466
|
throw new Error("当前消息不属于可识别的飞书话题。");
|
|
1076
1467
|
}
|
|
1077
|
-
const
|
|
1078
|
-
.filter((messageId) => Boolean(messageId && messageId !== message.messageId));
|
|
1079
|
-
const anchor = [...new Set(anchorMessageIds)]
|
|
1468
|
+
const anchor = threadForkAnchorMessageIds(message)
|
|
1080
1469
|
.map((messageId) => this.store.findTurnAnchorByMessageId(messageId))
|
|
1081
1470
|
.find((candidate) => candidate !== undefined);
|
|
1082
1471
|
if (!anchor) {
|
|
@@ -1381,22 +1770,51 @@ export class ProxySessionController {
|
|
|
1381
1770
|
executionSettings,
|
|
1382
1771
|
};
|
|
1383
1772
|
}
|
|
1384
|
-
async startTurn(loaded, text, replyTarget, localImagePaths) {
|
|
1773
|
+
async startTurn(loaded, text, replyTarget, localImagePaths, options = {}) {
|
|
1385
1774
|
const currentRecord = this.store.getSession(loaded.record.localSessionId);
|
|
1386
1775
|
const title = currentRecord?.title ?? normalizeTaskTitle(text);
|
|
1387
1776
|
if (!currentRecord?.title && title)
|
|
1388
1777
|
this.store.updateRuntimeSession(loaded.record.localSessionId, { title });
|
|
1389
1778
|
if (title)
|
|
1390
1779
|
this.outbound.updateSessionTitle(loaded.record.localSessionId, title);
|
|
1391
|
-
|
|
1780
|
+
const attemptId = options.attemptId ?? createId("attempt");
|
|
1781
|
+
if (options.attemptId) {
|
|
1782
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
1783
|
+
messageId: options.messageId ?? undefined,
|
|
1784
|
+
replyMessageId: replyTarget?.messageId ?? undefined,
|
|
1785
|
+
status: "recovering",
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
else {
|
|
1789
|
+
this.store.createTurnAttempt({
|
|
1790
|
+
attemptId,
|
|
1791
|
+
localSessionId: loaded.record.localSessionId,
|
|
1792
|
+
contextKey: loaded.record.contextKey,
|
|
1793
|
+
promptText: text,
|
|
1794
|
+
localImagePaths,
|
|
1795
|
+
messageId: options.messageId,
|
|
1796
|
+
replyMessageId: replyTarget?.messageId,
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1392
1799
|
let turnId;
|
|
1393
1800
|
try {
|
|
1801
|
+
const pendingTurnId = await this.outbound.startPendingTurn(loaded.record.localSessionId, loaded.record.contextKey, title, replyTarget, options.displayPrompt ?? text);
|
|
1802
|
+
this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
|
|
1394
1803
|
turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, runtimePrompt(text, localImagePaths));
|
|
1395
1804
|
}
|
|
1396
1805
|
catch (error) {
|
|
1806
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
1807
|
+
status: options.preserveAttemptOnFailure ? "recovering" : "failed",
|
|
1808
|
+
});
|
|
1397
1809
|
await this.outbound.failPendingTurn(loaded.record.localSessionId, error instanceof Error ? error.message : String(error));
|
|
1398
1810
|
throw error;
|
|
1399
1811
|
}
|
|
1812
|
+
const attempt = this.store.getTurnAttempt(attemptId);
|
|
1813
|
+
this.store.updateTurnAttempt(attemptId, {
|
|
1814
|
+
pendingTurnId: null,
|
|
1815
|
+
turnId,
|
|
1816
|
+
...(attempt && isIncompleteTurnAttemptStatus(attempt.status) ? { status: "running" } : {}),
|
|
1817
|
+
});
|
|
1400
1818
|
const latest = this.store.getSession(loaded.record.localSessionId);
|
|
1401
1819
|
const alreadyTerminal = latest?.lastTurnId === turnId
|
|
1402
1820
|
&& ["completed", "cancelled", "failed"].includes(latest.lastTurnStatus ?? "");
|
|
@@ -1665,14 +2083,34 @@ export class ProxySessionController {
|
|
|
1665
2083
|
}
|
|
1666
2084
|
async handleRuntimeEvent(event) {
|
|
1667
2085
|
if ("turnId" in event) {
|
|
2086
|
+
this.store.touchTurnAttempt(event.turnId);
|
|
1668
2087
|
const source = this.store.getSession(event.sessionId);
|
|
1669
2088
|
if (source?.remoteSessionId && this.isCodexSession(source)) {
|
|
1670
2089
|
this.store.saveTurnRuntimeOrigin(event.turnId, source.localSessionId, source.agentName, source.remoteSessionId);
|
|
1671
2090
|
}
|
|
1672
2091
|
if (event.type === "turn_started") {
|
|
2092
|
+
const attempt = this.store.bindTurnAttempt(event.sessionId, event.turnId);
|
|
2093
|
+
if (attempt?.recoveredFromTurnId && attempt.recoveredFromTurnId !== event.turnId) {
|
|
2094
|
+
this.store.rebindPendingTurnMessages(event.sessionId, attempt.recoveredFromTurnId, event.turnId);
|
|
2095
|
+
}
|
|
1673
2096
|
this.store.saveTurnParent(event.turnId, event.sessionId, this.completedParentTurnId(source));
|
|
1674
2097
|
}
|
|
1675
2098
|
}
|
|
2099
|
+
const failedAttempt = event.type === "turn_failed"
|
|
2100
|
+
? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
|
|
2101
|
+
: undefined;
|
|
2102
|
+
if (event.type === "turn_failed") {
|
|
2103
|
+
if (this.retriedFailureTurnIds.has(event.turnId))
|
|
2104
|
+
return;
|
|
2105
|
+
if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
|
|
2106
|
+
const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
|
|
2107
|
+
if (retryAttempt) {
|
|
2108
|
+
this.retriedFailureTurnIds.add(event.turnId);
|
|
2109
|
+
await this.retryFailedLlmTurn(event, retryAttempt);
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
1676
2114
|
if (event.type === "session_metadata_updated") {
|
|
1677
2115
|
this.store.updateRuntimeSession(event.sessionId, { title: event.title });
|
|
1678
2116
|
this.outbound.updateSessionTitle(event.sessionId, event.title);
|
|
@@ -1683,14 +2121,23 @@ export class ProxySessionController {
|
|
|
1683
2121
|
this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
|
|
1684
2122
|
}
|
|
1685
2123
|
else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
|
|
2124
|
+
this.store.markTurnAttemptTerminal(event.turnId, event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed");
|
|
1686
2125
|
this.store.updateRuntimeSession(event.sessionId, {
|
|
1687
2126
|
lastTurnId: event.turnId,
|
|
1688
2127
|
lastTurnStatus: event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
|
|
1689
2128
|
});
|
|
1690
2129
|
}
|
|
2130
|
+
const presentationEvent = event.type === "turn_failed"
|
|
2131
|
+
&& isRetryableLlmTurnFailure(event.message)
|
|
2132
|
+
&& (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
|
|
2133
|
+
? {
|
|
2134
|
+
...event,
|
|
2135
|
+
message: `${event.message}\n\n已自动重试 ${MAX_LLM_TURN_RETRIES} 次,仍未成功。`,
|
|
2136
|
+
}
|
|
2137
|
+
: event;
|
|
1691
2138
|
let presentationError;
|
|
1692
2139
|
try {
|
|
1693
|
-
await this.outbound.onEvent(
|
|
2140
|
+
await this.outbound.onEvent(presentationEvent);
|
|
1694
2141
|
}
|
|
1695
2142
|
catch (error) {
|
|
1696
2143
|
presentationError = error;
|
|
@@ -1719,6 +2166,61 @@ export class ProxySessionController {
|
|
|
1719
2166
|
if (presentationError)
|
|
1720
2167
|
throw presentationError;
|
|
1721
2168
|
}
|
|
2169
|
+
async retryFailedLlmTurn(event, attempt) {
|
|
2170
|
+
const retryNumber = attempt.retryCount;
|
|
2171
|
+
const session = this.store.getSession(event.sessionId);
|
|
2172
|
+
if (!session || session.status === "closed") {
|
|
2173
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
this.llmRetryingSessions.add(session.localSessionId);
|
|
2177
|
+
this.store.updateRuntimeSession(session.localSessionId, {
|
|
2178
|
+
lastTurnId: event.turnId,
|
|
2179
|
+
lastTurnStatus: "failed",
|
|
2180
|
+
});
|
|
2181
|
+
this.store.audit(attempt.contextKey, "llm_turn_retry_started", {
|
|
2182
|
+
attemptId: attempt.attemptId,
|
|
2183
|
+
failedTurnId: event.turnId,
|
|
2184
|
+
retryNumber,
|
|
2185
|
+
maxRetries: MAX_LLM_TURN_RETRIES,
|
|
2186
|
+
error: event.message,
|
|
2187
|
+
});
|
|
2188
|
+
try {
|
|
2189
|
+
await this.outbound.onEvent({
|
|
2190
|
+
...event,
|
|
2191
|
+
message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
catch (error) {
|
|
2195
|
+
this.logger.warn({ error, sessionId: session.localSessionId, turnId: event.turnId }, "Failed to finalize the failed thinking card before an LLM retry.");
|
|
2196
|
+
}
|
|
2197
|
+
const replyTarget = attempt.replyMessageId
|
|
2198
|
+
? { messageId: attempt.replyMessageId, replyInThread: true }
|
|
2199
|
+
: undefined;
|
|
2200
|
+
try {
|
|
2201
|
+
const current = this.store.getSession(session.localSessionId) ?? session;
|
|
2202
|
+
const loaded = await this.loadSession(current);
|
|
2203
|
+
const retryTurnId = await this.startTurn(loaded, llmRetryRuntimePrompt(attempt.promptText, retryNumber), replyTarget, attempt.localImagePaths, {
|
|
2204
|
+
attemptId: attempt.attemptId,
|
|
2205
|
+
messageId: attempt.messageId,
|
|
2206
|
+
displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
|
|
2207
|
+
preserveAttemptOnFailure: true,
|
|
2208
|
+
});
|
|
2209
|
+
this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
|
|
2210
|
+
}
|
|
2211
|
+
catch (error) {
|
|
2212
|
+
this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
|
|
2213
|
+
this.store.updateSession(session.localSessionId, { status: "failed" });
|
|
2214
|
+
await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError) => {
|
|
2215
|
+
this.logger.warn({ error: reactionError, sessionId: session.localSessionId, turnId: event.turnId }, "Failed to finalize reactions after an LLM retry could not start.");
|
|
2216
|
+
});
|
|
2217
|
+
this.logger.error({ error, sessionId: session.localSessionId, retryNumber }, "Failed to start an automatic LLM turn retry.");
|
|
2218
|
+
await this.scheduleNextQueuedPrompt(session.localSessionId);
|
|
2219
|
+
}
|
|
2220
|
+
finally {
|
|
2221
|
+
this.llmRetryingSessions.delete(session.localSessionId);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
1722
2224
|
scheduleNextQueuedPrompt(sessionId) {
|
|
1723
2225
|
const previous = this.queuedPromptStarts.get(sessionId) ?? Promise.resolve();
|
|
1724
2226
|
const next = previous.catch(() => undefined).then(() => this.startNextQueuedPromptIfIdle(sessionId));
|
|
@@ -1749,7 +2251,7 @@ export class ProxySessionController {
|
|
|
1749
2251
|
const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
|
|
1750
2252
|
loaded = { ...loaded, record };
|
|
1751
2253
|
await this.refreshPromptQueueCards(sessionId);
|
|
1752
|
-
const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths);
|
|
2254
|
+
const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths, { messageId: prompt.messageId });
|
|
1753
2255
|
if (prompt.messageId)
|
|
1754
2256
|
await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
|
|
1755
2257
|
}
|
|
@@ -1770,7 +2272,9 @@ export class ProxySessionController {
|
|
|
1770
2272
|
if (source.lastTurnStatus === "completed")
|
|
1771
2273
|
return source.lastTurnId;
|
|
1772
2274
|
const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(source.lastTurnId));
|
|
1773
|
-
return snapshot?.status === "completed"
|
|
2275
|
+
return snapshot?.status === "completed"
|
|
2276
|
+
? source.lastTurnId
|
|
2277
|
+
: this.store.findLatestCompletedTurnId(source.localSessionId, source.contextKey);
|
|
1774
2278
|
}
|
|
1775
2279
|
async finalizeStandaloneMessageReaction(messageId, status) {
|
|
1776
2280
|
const reaction = this.store.claimMessageReaction(messageId);
|
|
@@ -2497,6 +3001,165 @@ export class ProxySessionController {
|
|
|
2497
3001
|
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
2498
3002
|
}
|
|
2499
3003
|
}
|
|
3004
|
+
async openDirectoryBrowser(contextKey, requestedDirectory, options = {}) {
|
|
3005
|
+
if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
|
|
3006
|
+
await this.openWindowsDriveBrowser(contextKey, options);
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
const baseDirectory = this.currentSession(contextKey)?.cwd
|
|
3010
|
+
?? this.store.getUserContext(contextKey)?.boundProjectCwd
|
|
3011
|
+
?? this.config.defaults.cwd;
|
|
3012
|
+
const directory = requestedDirectory === undefined
|
|
3013
|
+
? path.resolve(baseDirectory)
|
|
3014
|
+
: resolveUserPath(requestedDirectory, baseDirectory);
|
|
3015
|
+
await this.assertBrowsableDirectory(directory);
|
|
3016
|
+
let directoryEntries;
|
|
3017
|
+
try {
|
|
3018
|
+
directoryEntries = await fs.readdir(directory, { withFileTypes: true });
|
|
3019
|
+
}
|
|
3020
|
+
catch (error) {
|
|
3021
|
+
throw new Error(`无法读取目录 ${directory}:${runtimeErrorMessage(error)}`);
|
|
3022
|
+
}
|
|
3023
|
+
const entries = directoryEntries
|
|
3024
|
+
.map((entry) => ({
|
|
3025
|
+
name: entry.name,
|
|
3026
|
+
path: path.join(directory, entry.name),
|
|
3027
|
+
kind: directoryBrowserEntryKind(entry),
|
|
3028
|
+
}))
|
|
3029
|
+
.sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
|
|
3030
|
+
|| left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
3031
|
+
const parentDirectory = isWindowsDriveRoot(directory)
|
|
3032
|
+
? WINDOWS_DRIVES_DIRECTORY
|
|
3033
|
+
: path.dirname(directory);
|
|
3034
|
+
const parentEntries = parentDirectory === directory ? [] : [{
|
|
3035
|
+
name: "..",
|
|
3036
|
+
kind: "directory",
|
|
3037
|
+
openAction: {
|
|
3038
|
+
text: "..",
|
|
3039
|
+
value: {
|
|
3040
|
+
action: "directory_open",
|
|
3041
|
+
directory: parentDirectory,
|
|
3042
|
+
contextKey,
|
|
3043
|
+
},
|
|
3044
|
+
},
|
|
3045
|
+
}];
|
|
3046
|
+
const pageSize = DIRECTORY_PAGE_SIZE;
|
|
3047
|
+
const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
|
|
3048
|
+
const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
|
|
3049
|
+
const page = Math.min(requestedPage, totalPages - 1);
|
|
3050
|
+
const offset = page * pageSize;
|
|
3051
|
+
const visibleEntries = [
|
|
3052
|
+
...parentEntries,
|
|
3053
|
+
...entries
|
|
3054
|
+
.slice(offset, offset + pageSize)
|
|
3055
|
+
.map((entry) => entry.kind === "directory"
|
|
3056
|
+
? {
|
|
3057
|
+
name: entry.name,
|
|
3058
|
+
kind: entry.kind,
|
|
3059
|
+
openAction: {
|
|
3060
|
+
text: entry.name,
|
|
3061
|
+
value: {
|
|
3062
|
+
action: "directory_open",
|
|
3063
|
+
directory: entry.path,
|
|
3064
|
+
contextKey,
|
|
3065
|
+
},
|
|
3066
|
+
},
|
|
3067
|
+
actions: directoryCreationActions(entry.path, contextKey),
|
|
3068
|
+
}
|
|
3069
|
+
: {
|
|
3070
|
+
name: entry.name,
|
|
3071
|
+
kind: entry.kind,
|
|
3072
|
+
openAction: {
|
|
3073
|
+
text: entry.name,
|
|
3074
|
+
value: {
|
|
3075
|
+
action: "directory_send_file",
|
|
3076
|
+
filePath: entry.path,
|
|
3077
|
+
contextKey,
|
|
3078
|
+
},
|
|
3079
|
+
},
|
|
3080
|
+
}),
|
|
3081
|
+
];
|
|
3082
|
+
const navigationActions = directoryPaginationActions(directory, page, totalPages, contextKey);
|
|
3083
|
+
const directoryCount = entries.filter((entry) => entry.kind === "directory").length;
|
|
3084
|
+
const card = this.cardRenderer.renderDirectoryBrowserCard({
|
|
3085
|
+
directory: abbreviateHomeDirectory(directory),
|
|
3086
|
+
entries: visibleEntries,
|
|
3087
|
+
currentActions: directoryCreationActions(directory, contextKey),
|
|
3088
|
+
navigationActions,
|
|
3089
|
+
footerLines: [
|
|
3090
|
+
`第 ${page + 1}/${totalPages} 页 · ${directoryCount} 个目录 · ${entries.length - directoryCount} 个文件`,
|
|
3091
|
+
"",
|
|
3092
|
+
"> 点击目录名称进入,点击文件名称发送到当前会话;**New** 在该目录创建任务,**NewGroup** 在该目录创建新群和任务。",
|
|
3093
|
+
],
|
|
3094
|
+
});
|
|
3095
|
+
if (options.updateMessageId) {
|
|
3096
|
+
await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
|
|
3097
|
+
}
|
|
3098
|
+
else {
|
|
3099
|
+
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
async openWindowsDriveBrowser(contextKey, options) {
|
|
3103
|
+
const drives = await this.windowsDriveLister();
|
|
3104
|
+
const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
|
|
3105
|
+
const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
|
|
3106
|
+
const page = Math.min(requestedPage, totalPages - 1);
|
|
3107
|
+
const offset = page * DIRECTORY_PAGE_SIZE;
|
|
3108
|
+
const entries = drives
|
|
3109
|
+
.slice(offset, offset + DIRECTORY_PAGE_SIZE)
|
|
3110
|
+
.map((drive) => ({
|
|
3111
|
+
name: windowsDriveDisplayName(drive),
|
|
3112
|
+
kind: "drive",
|
|
3113
|
+
openAction: {
|
|
3114
|
+
text: windowsDriveDisplayName(drive),
|
|
3115
|
+
value: {
|
|
3116
|
+
action: "directory_open",
|
|
3117
|
+
directory: drive.root,
|
|
3118
|
+
contextKey,
|
|
3119
|
+
},
|
|
3120
|
+
},
|
|
3121
|
+
actions: directoryCreationActions(drive.root, contextKey),
|
|
3122
|
+
}));
|
|
3123
|
+
const card = this.cardRenderer.renderDirectoryBrowserCard({
|
|
3124
|
+
directory: "此电脑",
|
|
3125
|
+
entries,
|
|
3126
|
+
currentActions: [],
|
|
3127
|
+
navigationActions: directoryPaginationActions(WINDOWS_DRIVES_DIRECTORY, page, totalPages, contextKey),
|
|
3128
|
+
footerLines: [
|
|
3129
|
+
`第 ${page + 1}/${totalPages} 页 · ${drives.length} 个磁盘`,
|
|
3130
|
+
"",
|
|
3131
|
+
"> 点击磁盘名称进入;**New** 在磁盘根目录创建任务,**NewGroup** 在磁盘根目录创建新群和任务。",
|
|
3132
|
+
],
|
|
3133
|
+
});
|
|
3134
|
+
if (options.updateMessageId) {
|
|
3135
|
+
await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
|
|
3136
|
+
}
|
|
3137
|
+
else {
|
|
3138
|
+
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
async assertBrowsableDirectory(directory) {
|
|
3142
|
+
let stats;
|
|
3143
|
+
try {
|
|
3144
|
+
stats = await fs.stat(directory);
|
|
3145
|
+
}
|
|
3146
|
+
catch (error) {
|
|
3147
|
+
throw new Error(`目录不存在或无法访问:${directory}(${runtimeErrorMessage(error)})`);
|
|
3148
|
+
}
|
|
3149
|
+
if (!stats.isDirectory())
|
|
3150
|
+
throw new Error(`这不是目录:${directory}`);
|
|
3151
|
+
}
|
|
3152
|
+
async assertSendableFile(filePath) {
|
|
3153
|
+
let stats;
|
|
3154
|
+
try {
|
|
3155
|
+
stats = await fs.stat(filePath);
|
|
3156
|
+
}
|
|
3157
|
+
catch (error) {
|
|
3158
|
+
throw new Error(`文件不存在或无法访问:${filePath}(${runtimeErrorMessage(error)})`);
|
|
3159
|
+
}
|
|
3160
|
+
if (!stats.isFile())
|
|
3161
|
+
throw new Error(`这不是普通文件:${filePath}`);
|
|
3162
|
+
}
|
|
2500
3163
|
async listSessions(contextKey, searchTerm, options = {}) {
|
|
2501
3164
|
const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
|
|
2502
3165
|
const normalizedSearch = searchTerm?.trim().toLowerCase();
|
|
@@ -2522,23 +3185,28 @@ export class ProxySessionController {
|
|
|
2522
3185
|
const remoteHint = remoteErrors.length > 0
|
|
2523
3186
|
? `部分 Agent 的任务读取失败:${remoteErrors.join(";")}`
|
|
2524
3187
|
: undefined;
|
|
2525
|
-
const entries = mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
|
|
3188
|
+
const entries = orderTaskListByProject(mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
|
|
2526
3189
|
.filter((entry) => !normalizedSearch
|
|
2527
3190
|
|| [entry.id, entry.title, entry.cwd, entry.agentName]
|
|
2528
|
-
.some((value) => value.toLowerCase().includes(normalizedSearch)))
|
|
2529
|
-
.sort((left, right) => Number(right.current) - Number(left.current)
|
|
2530
|
-
|| Number(right.active) - Number(left.active)
|
|
2531
|
-
|| right.updatedAt - left.updatedAt);
|
|
3191
|
+
.some((value) => value.toLowerCase().includes(normalizedSearch))));
|
|
2532
3192
|
const activeCount = entries.filter((entry) => entry.active).length;
|
|
2533
3193
|
const lastKnownPage = Math.max(0, Math.ceil(entries.length / SESSION_PAGE_SIZE) - 1);
|
|
2534
3194
|
const page = remoteHasMore ? requestedPage : Math.min(requestedPage, lastKnownPage);
|
|
2535
3195
|
const offset = page * SESSION_PAGE_SIZE;
|
|
2536
3196
|
const visibleEntries = entries.slice(offset, offset + SESSION_PAGE_SIZE);
|
|
3197
|
+
const projectActionSources = new Map();
|
|
3198
|
+
for (const entry of entries) {
|
|
3199
|
+
const project = taskProjectInfo(entry);
|
|
3200
|
+
if (!projectActionSources.has(project.key))
|
|
3201
|
+
projectActionSources.set(project.key, entry);
|
|
3202
|
+
}
|
|
2537
3203
|
const hasPrevious = page > 0;
|
|
2538
3204
|
const hasNext = remoteHasMore || entries.length > offset + SESSION_PAGE_SIZE;
|
|
2539
3205
|
this.lastSessionListings.set(contextKey, entries.map((entry) => entry.reference));
|
|
2540
3206
|
const cardEntries = visibleEntries.map((entry, index) => {
|
|
2541
|
-
const
|
|
3207
|
+
const project = taskProjectInfo(entry);
|
|
3208
|
+
const projectActionSource = projectActionSources.get(project.key) ?? entry;
|
|
3209
|
+
const marker = entry.current ? "✅" : entry.active ? "🟢" : "•";
|
|
2542
3210
|
const showStop = entry.status === "外部执行中"
|
|
2543
3211
|
&& entry.reference !== options.forceSwitchTaskId
|
|
2544
3212
|
&& entry.id !== options.forceSwitchTaskId;
|
|
@@ -2553,26 +3221,25 @@ export class ProxySessionController {
|
|
|
2553
3221
|
contextKey,
|
|
2554
3222
|
},
|
|
2555
3223
|
}];
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
}
|
|
2575
|
-
});
|
|
3224
|
+
const projectActions = [{
|
|
3225
|
+
text: "New",
|
|
3226
|
+
value: {
|
|
3227
|
+
action: "session_new",
|
|
3228
|
+
sessionId: projectActionSource.reference,
|
|
3229
|
+
...(searchTerm ? { searchTerm } : {}),
|
|
3230
|
+
page: String(page),
|
|
3231
|
+
contextKey,
|
|
3232
|
+
},
|
|
3233
|
+
}, {
|
|
3234
|
+
text: "NewGroup",
|
|
3235
|
+
value: {
|
|
3236
|
+
action: "session_new_group",
|
|
3237
|
+
sessionId: projectActionSource.reference,
|
|
3238
|
+
...(searchTerm ? { searchTerm } : {}),
|
|
3239
|
+
page: String(page),
|
|
3240
|
+
contextKey,
|
|
3241
|
+
},
|
|
3242
|
+
}];
|
|
2576
3243
|
actions.push({
|
|
2577
3244
|
text: "Fork",
|
|
2578
3245
|
value: {
|
|
@@ -2601,20 +3268,45 @@ export class ProxySessionController {
|
|
|
2601
3268
|
contextKey,
|
|
2602
3269
|
},
|
|
2603
3270
|
});
|
|
3271
|
+
const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
|
|
2604
3272
|
return {
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
3273
|
+
project,
|
|
3274
|
+
projectActions,
|
|
3275
|
+
cardEntry: {
|
|
3276
|
+
reference: entry.reference,
|
|
3277
|
+
summary: `${offset + index + 1}. ${marker} ${title} · ${entry.agentName}`,
|
|
3278
|
+
detailLines: [
|
|
3279
|
+
`**状态 / 更新时间**:${cardText(entry.status)} / ${cardText(entry.updatedLabel)}`,
|
|
3280
|
+
`**Agent / 任务 ID**:${cardCode(entry.agentName)} / ${cardCode(entry.id)}`,
|
|
3281
|
+
`**目录**:${cardCode(entry.cwd || "目录未知")}`,
|
|
3282
|
+
],
|
|
3283
|
+
actions,
|
|
3284
|
+
current: entry.current,
|
|
3285
|
+
},
|
|
2610
3286
|
};
|
|
2611
3287
|
});
|
|
2612
|
-
const
|
|
3288
|
+
const cardGroups = [];
|
|
3289
|
+
let currentProjectKey;
|
|
3290
|
+
for (const item of cardEntries) {
|
|
3291
|
+
const currentGroup = cardGroups.at(-1);
|
|
3292
|
+
if (currentGroup && currentProjectKey === item.project.key) {
|
|
3293
|
+
currentGroup.entries.push(item.cardEntry);
|
|
3294
|
+
}
|
|
3295
|
+
else {
|
|
3296
|
+
currentProjectKey = item.project.key;
|
|
3297
|
+
cardGroups.push({
|
|
3298
|
+
title: item.project.title,
|
|
3299
|
+
entries: [item.cardEntry],
|
|
3300
|
+
actions: item.projectActions,
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
const card = this.cardRenderer.renderSessionTaskListCard(searchTerm ? `任务列表:${searchTerm}` : "任务列表", activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务", cardGroups, [
|
|
2613
3305
|
...(remoteHint ? [remoteHint] : []),
|
|
2614
3306
|
`第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
|
|
2615
3307
|
"",
|
|
2616
|
-
"> **
|
|
2617
|
-
"> **
|
|
3308
|
+
"> 项目行:**New** 新建任务,**NewGroup** 新建群。",
|
|
3309
|
+
"> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态。",
|
|
2618
3310
|
], [
|
|
2619
3311
|
...(hasPrevious ? [{
|
|
2620
3312
|
text: "Previous",
|
|
@@ -3067,6 +3759,17 @@ export class ProxySessionController {
|
|
|
3067
3759
|
"**[参数]** 可选 **<参数>** 必填",
|
|
3068
3760
|
], sections));
|
|
3069
3761
|
}
|
|
3762
|
+
async setGroupMute(contextKey, enabled) {
|
|
3763
|
+
const chatContextKey = baseChatContextKey(contextKey);
|
|
3764
|
+
const chat = this.store.getChatContext(chatContextKey);
|
|
3765
|
+
if (chat?.chatType !== "group") {
|
|
3766
|
+
throw new Error("/mute 仅适用于群聊。");
|
|
3767
|
+
}
|
|
3768
|
+
this.store.setChatRequiresMention(chatContextKey, enabled);
|
|
3769
|
+
await this.outbound.sendText(contextKey, enabled
|
|
3770
|
+
? "已开启当前群的静音模式。之后只有 @ 机器人的消息会被处理;发送 @机器人 /mute off 可恢复自动响应。"
|
|
3771
|
+
: "已关闭当前群的静音模式。机器人将恢复自动响应群消息。");
|
|
3772
|
+
}
|
|
3070
3773
|
ensureAgent(agentName) {
|
|
3071
3774
|
const agent = this.config.agents[agentName];
|
|
3072
3775
|
if (!agent)
|
|
@@ -3127,6 +3830,10 @@ export class ProxySessionController {
|
|
|
3127
3830
|
await this.outbound.sendText(contextKey, error instanceof Error ? error.message : String(error));
|
|
3128
3831
|
}
|
|
3129
3832
|
}
|
|
3833
|
+
function threadForkAnchorMessageIds(message) {
|
|
3834
|
+
return [...new Set([message.rootMessageId, message.parentMessageId]
|
|
3835
|
+
.filter((messageId) => Boolean(messageId && messageId !== message.messageId)))];
|
|
3836
|
+
}
|
|
3130
3837
|
function sessionStatusLabel(status, activeTurnId) {
|
|
3131
3838
|
if (activeTurnId || status === "running")
|
|
3132
3839
|
return "执行中";
|
|
@@ -3157,6 +3864,50 @@ function statusRefreshAction(sessionId, contextKey) {
|
|
|
3157
3864
|
},
|
|
3158
3865
|
};
|
|
3159
3866
|
}
|
|
3867
|
+
function orderTaskListByProject(entries) {
|
|
3868
|
+
const groups = new Map();
|
|
3869
|
+
for (const entry of entries) {
|
|
3870
|
+
const project = taskProjectInfo(entry);
|
|
3871
|
+
const group = groups.get(project.key);
|
|
3872
|
+
if (group)
|
|
3873
|
+
group.entries.push(entry);
|
|
3874
|
+
else
|
|
3875
|
+
groups.set(project.key, { project, entries: [entry] });
|
|
3876
|
+
}
|
|
3877
|
+
const orderedGroups = [...groups.values()]
|
|
3878
|
+
.map((group) => ({
|
|
3879
|
+
...group,
|
|
3880
|
+
current: group.entries.some((entry) => entry.current),
|
|
3881
|
+
active: group.entries.some((entry) => entry.active),
|
|
3882
|
+
updatedAt: Math.max(...group.entries.map((entry) => entry.updatedAt)),
|
|
3883
|
+
}))
|
|
3884
|
+
.sort((left, right) => Number(right.current) - Number(left.current)
|
|
3885
|
+
|| Number(right.active) - Number(left.active)
|
|
3886
|
+
|| right.updatedAt - left.updatedAt
|
|
3887
|
+
|| left.project.title.localeCompare(right.project.title, undefined, { sensitivity: "base" }));
|
|
3888
|
+
return orderedGroups.flatMap((group) => group.entries.sort(compareTaskListEntries));
|
|
3889
|
+
}
|
|
3890
|
+
function compareTaskListEntries(left, right) {
|
|
3891
|
+
return Number(right.current) - Number(left.current)
|
|
3892
|
+
|| Number(right.active) - Number(left.active)
|
|
3893
|
+
|| right.updatedAt - left.updatedAt
|
|
3894
|
+
|| left.title.localeCompare(right.title, undefined, { sensitivity: "base" });
|
|
3895
|
+
}
|
|
3896
|
+
function taskProjectInfo(entry) {
|
|
3897
|
+
if (!entry.cwd)
|
|
3898
|
+
return { key: "unknown", title: "📁 目录未知" };
|
|
3899
|
+
if (detectProjectlessWorkspace(entry.cwd)) {
|
|
3900
|
+
return { key: "projectless", title: "🗒️ 未指定项目" };
|
|
3901
|
+
}
|
|
3902
|
+
const windowsPath = isWindowsAbsolutePath(entry.cwd);
|
|
3903
|
+
const normalized = windowsPath
|
|
3904
|
+
? path.win32.normalize(entry.cwd).toLowerCase()
|
|
3905
|
+
: path.resolve(entry.cwd);
|
|
3906
|
+
return {
|
|
3907
|
+
key: `cwd:${normalized}`,
|
|
3908
|
+
title: `📁 ${abbreviateHomeDirectory(entry.cwd)}`,
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3160
3911
|
function mergeTaskList(localSessions, remoteSessions, currentLocalSessionId) {
|
|
3161
3912
|
const localByRemoteId = new Map(localSessions
|
|
3162
3913
|
.filter((session) => session.runtimeKind === "codex" && session.remoteSessionId)
|
|
@@ -3246,6 +3997,12 @@ function turnViewSnapshot(value) {
|
|
|
3246
3997
|
? candidate
|
|
3247
3998
|
: undefined;
|
|
3248
3999
|
}
|
|
4000
|
+
function isTerminalTurnViewStatus(status) {
|
|
4001
|
+
return status === "completed" || status === "cancelled" || status === "failed";
|
|
4002
|
+
}
|
|
4003
|
+
function isIncompleteTurnAttemptStatus(status) {
|
|
4004
|
+
return status === "accepted" || status === "running" || status === "recovering";
|
|
4005
|
+
}
|
|
3249
4006
|
function executionDetailLines(record, snapshot, remote, activeTurnId, queued) {
|
|
3250
4007
|
const remoteActive = isRemoteSessionActive(remote);
|
|
3251
4008
|
const turnId = remoteActive
|
|
@@ -3519,7 +4276,7 @@ function isBotOwnedActiveTurn(record, remote) {
|
|
|
3519
4276
|
&& record.lastTurnId === remote.lastTurnId;
|
|
3520
4277
|
}
|
|
3521
4278
|
function isQueueIndependentCommand(command) {
|
|
3522
|
-
if (["stop", "status", "restart", "help", "sessions", "goal", "nosteer"].includes(command.type))
|
|
4279
|
+
if (["stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type))
|
|
3523
4280
|
return true;
|
|
3524
4281
|
if (command.type === "agent")
|
|
3525
4282
|
return command.agent === undefined;
|
|
@@ -3531,12 +4288,200 @@ function parseSessionPage(value) {
|
|
|
3531
4288
|
const parsed = typeof value === "string" || typeof value === "number" ? Number(value) : Number.NaN;
|
|
3532
4289
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
3533
4290
|
}
|
|
4291
|
+
function directoryPageValue(value) {
|
|
4292
|
+
const page = Number(value);
|
|
4293
|
+
if (!Number.isSafeInteger(page) || page < 0)
|
|
4294
|
+
throw new Error("文件浏览卡片页码无效,请重新发送 /dir。");
|
|
4295
|
+
return page;
|
|
4296
|
+
}
|
|
4297
|
+
function isWindowsDriveRoot(directory) {
|
|
4298
|
+
return /^[a-z]:\\$/iu.test(path.win32.normalize(directory));
|
|
4299
|
+
}
|
|
4300
|
+
function directoryBrowserEntryKind(entry) {
|
|
4301
|
+
if (entry.isDirectory())
|
|
4302
|
+
return "directory";
|
|
4303
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
4304
|
+
if (IMAGE_FILE_EXTENSIONS.has(extension))
|
|
4305
|
+
return "image";
|
|
4306
|
+
if (BINARY_FILE_EXTENSIONS.has(extension))
|
|
4307
|
+
return "binary";
|
|
4308
|
+
return "file";
|
|
4309
|
+
}
|
|
4310
|
+
function directoryActionPath(value) {
|
|
4311
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
4312
|
+
throw new Error("文件浏览卡片中的目录无效,请重新发送 /dir。");
|
|
4313
|
+
}
|
|
4314
|
+
return value === WINDOWS_DRIVES_DIRECTORY ? value : path.resolve(value);
|
|
4315
|
+
}
|
|
4316
|
+
function directoryFileActionPath(value) {
|
|
4317
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
4318
|
+
throw new Error("文件浏览卡片中的文件无效,请重新发送 /dir。");
|
|
4319
|
+
}
|
|
4320
|
+
return path.resolve(value);
|
|
4321
|
+
}
|
|
4322
|
+
function directoryCreationActions(directory, contextKey) {
|
|
4323
|
+
return [
|
|
4324
|
+
{
|
|
4325
|
+
text: "New",
|
|
4326
|
+
value: {
|
|
4327
|
+
action: "directory_new",
|
|
4328
|
+
directory,
|
|
4329
|
+
contextKey,
|
|
4330
|
+
},
|
|
4331
|
+
},
|
|
4332
|
+
{
|
|
4333
|
+
text: "NewGroup",
|
|
4334
|
+
value: {
|
|
4335
|
+
action: "directory_new_group",
|
|
4336
|
+
directory,
|
|
4337
|
+
contextKey,
|
|
4338
|
+
},
|
|
4339
|
+
},
|
|
4340
|
+
];
|
|
4341
|
+
}
|
|
4342
|
+
function directoryPaginationActions(directory, page, totalPages, contextKey) {
|
|
4343
|
+
return [
|
|
4344
|
+
...(page > 0 ? [{
|
|
4345
|
+
text: "Previous",
|
|
4346
|
+
value: {
|
|
4347
|
+
action: "directory_page",
|
|
4348
|
+
directory,
|
|
4349
|
+
page: String(page - 1),
|
|
4350
|
+
contextKey,
|
|
4351
|
+
},
|
|
4352
|
+
}] : []),
|
|
4353
|
+
...(page < totalPages - 1 ? [{
|
|
4354
|
+
text: "Next",
|
|
4355
|
+
value: {
|
|
4356
|
+
action: "directory_page",
|
|
4357
|
+
directory,
|
|
4358
|
+
page: String(page + 1),
|
|
4359
|
+
contextKey,
|
|
4360
|
+
},
|
|
4361
|
+
}] : []),
|
|
4362
|
+
];
|
|
4363
|
+
}
|
|
4364
|
+
function windowsDriveDisplayName(drive) {
|
|
4365
|
+
const driveLetter = drive.root.slice(0, 2).toUpperCase();
|
|
4366
|
+
const label = drive.label?.trim() || windowsDriveTypeLabel(drive.driveType);
|
|
4367
|
+
return `${label} (${driveLetter})`;
|
|
4368
|
+
}
|
|
4369
|
+
function windowsDriveTypeLabel(driveType) {
|
|
4370
|
+
switch (driveType?.toLowerCase()) {
|
|
4371
|
+
case "fixed": return "本地磁盘";
|
|
4372
|
+
case "network": return "网络驱动器";
|
|
4373
|
+
case "removable": return "可移动磁盘";
|
|
4374
|
+
case "cdrom": return "光驱";
|
|
4375
|
+
case "ram": return "RAM 磁盘";
|
|
4376
|
+
default: return "磁盘";
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
async function listWindowsDriveRoots() {
|
|
4380
|
+
if (process.platform !== "win32")
|
|
4381
|
+
throw new Error("磁盘列表仅在 Windows 上可用。");
|
|
4382
|
+
const result = await executeShellCommand("$drives = @([System.IO.DriveInfo]::GetDrives() | ForEach-Object { $label = ''; if ($_.IsReady) { try { $label = $_.VolumeLabel } catch {} }; [PSCustomObject]@{ root = $_.Name; label = $label; driveType = $_.DriveType.ToString() } }); ConvertTo-Json -Compress -InputObject $drives", os.homedir(), { timeoutMs: 10_000, maxOutputBytes: 16 * 1024, platform: "win32" });
|
|
4383
|
+
if (result.timedOut || result.exitCode !== 0) {
|
|
4384
|
+
const detail = result.stderr.trim() || `退出码 ${result.exitCode ?? "未知"}`;
|
|
4385
|
+
throw new Error(`无法读取 Windows 磁盘列表:${detail}`);
|
|
4386
|
+
}
|
|
4387
|
+
let parsed;
|
|
4388
|
+
try {
|
|
4389
|
+
parsed = JSON.parse(result.stdout.trim().replace(/^\uFEFF/u, ""));
|
|
4390
|
+
}
|
|
4391
|
+
catch (error) {
|
|
4392
|
+
throw new Error(`无法解析 Windows 磁盘列表:${runtimeErrorMessage(error)}`);
|
|
4393
|
+
}
|
|
4394
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
4395
|
+
const drives = values.flatMap((value) => {
|
|
4396
|
+
if (typeof value !== "object" || value === null)
|
|
4397
|
+
return [];
|
|
4398
|
+
const record = value;
|
|
4399
|
+
if (typeof record.root !== "string")
|
|
4400
|
+
return [];
|
|
4401
|
+
const root = path.win32.normalize(record.root.trim());
|
|
4402
|
+
if (!isWindowsDriveRoot(root))
|
|
4403
|
+
return [];
|
|
4404
|
+
return [{
|
|
4405
|
+
root,
|
|
4406
|
+
...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
|
|
4407
|
+
...(typeof record.driveType === "string" ? { driveType: record.driveType } : {}),
|
|
4408
|
+
}];
|
|
4409
|
+
}).sort((left, right) => left.root.localeCompare(right.root, undefined, { sensitivity: "base" }));
|
|
4410
|
+
if (drives.length === 0)
|
|
4411
|
+
throw new Error("没有检测到可用的 Windows 磁盘。");
|
|
4412
|
+
return drives;
|
|
4413
|
+
}
|
|
3534
4414
|
function isPromptCommand(command) {
|
|
3535
4415
|
return command.type === "prompt" || command.type === "nosteer";
|
|
3536
4416
|
}
|
|
3537
4417
|
function runtimePrompt(text, localImagePaths) {
|
|
3538
4418
|
return localImagePaths?.length ? { text, localImagePaths } : text;
|
|
3539
4419
|
}
|
|
4420
|
+
function recoveryCardPrompt(prompt) {
|
|
4421
|
+
return `恢复重启前的任务:${prompt}`;
|
|
4422
|
+
}
|
|
4423
|
+
function recoveryRuntimePrompt(prompt) {
|
|
4424
|
+
return [
|
|
4425
|
+
"Agent Bot restarted while the previous turn was still running.",
|
|
4426
|
+
"Continue the unfinished task in the current task and workspace.",
|
|
4427
|
+
"Inspect the existing conversation, files, and completed tool effects first. Do not repeat completed or irreversible actions.",
|
|
4428
|
+
"Finish the original request:",
|
|
4429
|
+
"",
|
|
4430
|
+
prompt,
|
|
4431
|
+
].join("\n");
|
|
4432
|
+
}
|
|
4433
|
+
function llmRetryCardPrompt(prompt, retryNumber) {
|
|
4434
|
+
return `自动重试 ${retryNumber}/${MAX_LLM_TURN_RETRIES}:${prompt}`;
|
|
4435
|
+
}
|
|
4436
|
+
function llmRetryRuntimePrompt(prompt, retryNumber) {
|
|
4437
|
+
return [
|
|
4438
|
+
`Agent Bot is automatically retrying this request after a transient LLM service failure (${retryNumber}/${MAX_LLM_TURN_RETRIES}).`,
|
|
4439
|
+
"Continue in the current task and workspace. Inspect the conversation, files, and completed tool effects before acting.",
|
|
4440
|
+
"Do not repeat completed or irreversible actions. Finish the original request:",
|
|
4441
|
+
"",
|
|
4442
|
+
prompt,
|
|
4443
|
+
].join("\n");
|
|
4444
|
+
}
|
|
4445
|
+
function isRetryableLlmTurnFailure(message) {
|
|
4446
|
+
const normalized = message.trim().toLowerCase();
|
|
4447
|
+
if (!normalized)
|
|
4448
|
+
return false;
|
|
4449
|
+
const permanentFailure = [
|
|
4450
|
+
/auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
|
|
4451
|
+
/insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
|
|
4452
|
+
/context (?:length|window)|maximum context|too many tokens|token limit/u,
|
|
4453
|
+
/invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,
|
|
4454
|
+
/model (?:not found|does not exist)|unknown model/u,
|
|
4455
|
+
/permission|approval|sandbox|policy violation|content policy/u,
|
|
4456
|
+
].some((pattern) => pattern.test(normalized));
|
|
4457
|
+
if (permanentFailure)
|
|
4458
|
+
return false;
|
|
4459
|
+
return [
|
|
4460
|
+
/(?:^|\D)429(?:\D|$)|rate[_ -]?limit|too many requests/u,
|
|
4461
|
+
/overload|high demand|at capacity|capacity limit|temporar(?:y|ily) unavailable/u,
|
|
4462
|
+
/service unavailable|internal server error|bad gateway|gateway timeout/u,
|
|
4463
|
+
/(?:^|\D)(?:500|502|503|504)(?:\D|$)|upstream (?:error|failure|timeout)/u,
|
|
4464
|
+
/deadline exceeded|request timed out|request timeout|response timeout/u,
|
|
4465
|
+
/connection (?:reset|closed|aborted)|network error|socket hang up/u,
|
|
4466
|
+
/stream (?:closed|disconnected|interrupted|error)/u,
|
|
4467
|
+
/\b(?:llm|model|provider|inference)\b.*\b(?:error|failed|failure|unavailable|timeout|timed out)\b/u,
|
|
4468
|
+
].some((pattern) => pattern.test(normalized));
|
|
4469
|
+
}
|
|
4470
|
+
function remoteTurnMatchesAttempt(remote, attempt) {
|
|
4471
|
+
if (!remote?.lastTurnId)
|
|
4472
|
+
return false;
|
|
4473
|
+
if (attempt.turnId)
|
|
4474
|
+
return remote.lastTurnId === attempt.turnId;
|
|
4475
|
+
const remoteUpdatedAt = remote.updatedAt === undefined ? undefined : normalizeRemoteTimestamp(remote.updatedAt);
|
|
4476
|
+
const acceptedAt = Date.parse(attempt.createdAt);
|
|
4477
|
+
return remoteUpdatedAt !== undefined
|
|
4478
|
+
&& Number.isFinite(acceptedAt)
|
|
4479
|
+
&& remoteUpdatedAt >= acceptedAt - 1_000;
|
|
4480
|
+
}
|
|
4481
|
+
function isRecoveryAttemptRecent(attempt, cutoff) {
|
|
4482
|
+
const updatedAt = Date.parse(attempt.updatedAt);
|
|
4483
|
+
return Number.isFinite(updatedAt) && updatedAt >= cutoff;
|
|
4484
|
+
}
|
|
3540
4485
|
function formatGroupDateSuffix(date) {
|
|
3541
4486
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
3542
4487
|
const day = String(date.getDate()).padStart(2, "0");
|
|
@@ -3567,12 +4512,21 @@ function formatGroupProjectDirectory(projectCwd) {
|
|
|
3567
4512
|
return `[${fitTrailingPathLevels(levels, GROUP_PROJECT_DIRECTORY_MAX_LENGTH, path.sep)}]`;
|
|
3568
4513
|
}
|
|
3569
4514
|
function abbreviateHomeDirectory(value) {
|
|
3570
|
-
const
|
|
4515
|
+
const homeDirectory = os.homedir();
|
|
4516
|
+
const valueIsWindows = isWindowsAbsolutePath(value);
|
|
4517
|
+
const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
|
|
4518
|
+
if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value)))
|
|
4519
|
+
return value;
|
|
4520
|
+
const pathApi = valueIsWindows ? path.win32 : path;
|
|
4521
|
+
const relative = pathApi.relative(homeDirectory, value);
|
|
3571
4522
|
if (relative === "")
|
|
3572
4523
|
return "~";
|
|
3573
|
-
if (
|
|
4524
|
+
if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`))
|
|
3574
4525
|
return value;
|
|
3575
|
-
return `~${
|
|
4526
|
+
return `~${pathApi.sep}${relative}`;
|
|
4527
|
+
}
|
|
4528
|
+
function isWindowsAbsolutePath(value) {
|
|
4529
|
+
return /^[a-z]:[\\/]|^\\\\/iu.test(value);
|
|
3576
4530
|
}
|
|
3577
4531
|
function fitTrailingPathLevels(levels, maxLength, separator) {
|
|
3578
4532
|
if (levels.length === 0)
|