pi-web-ui 0.90.1 → 0.91.0
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 +48 -1
- package/dist/server/agent-service.js +763 -61
- package/dist/server/client-state.js +70 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +21 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +120 -30
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugins.js +47 -8
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/schedule-agent-tool.js +23 -5
- package/dist/server/scheduler-tasks.js +60 -1
- package/dist/server/scm-commitmsg.js +140 -0
- package/dist/server/scm.js +42 -0
- package/dist/server/settings-service.js +40 -0
- package/dist/server/skill-tool.js +120 -0
- package/dist/server/soft-cap.js +68 -0
- package/dist/server/subagent-templates.js +6 -0
- package/dist/server/subagents.js +157 -16
- package/dist/server/tool-manager.js +7 -1
- package/package.json +2 -1
- package/plugins/catalog.json +10 -0
- package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-GtDQ1h85.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- package/web/dist/assets/index-CdsKltOS.css +1 -0
- package/web/dist/assets/index-OoULNF70.js +363 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJIV4A-t.js +0 -355
- package/web/dist/assets/index-DLdcRBs_.css +0 -1
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// wholesale (no union merge / no stale built-in leftovers).
|
|
16
16
|
import "./patch-remote-catalog.js";
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
|
-
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
import { appendFileSync, existsSync, readFileSync, rmSync, statSync, mkdirSync, watch, writeFileSync } from "node:fs";
|
|
21
21
|
import { homedir } from "node:os";
|
|
@@ -26,6 +26,7 @@ import { Type } from "typebox";
|
|
|
26
26
|
import { BgServerTracker } from "./bg-servers.js";
|
|
27
27
|
import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
|
|
28
28
|
import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
|
|
29
|
+
import { COMPACTION_PENDING_TYPE, looksLikeChainCorruption, makeCompactionMarkerId, repairSessionFile, } from "./compaction-markers.js";
|
|
29
30
|
import { removeQueuedByIndexOrText } from "./queue-utils.js";
|
|
30
31
|
import { syncPluginToolsIntoSession } from "./plugins.js";
|
|
31
32
|
import { SettingsService } from "./settings-service.js";
|
|
@@ -41,14 +42,19 @@ import { ComposerDraftsStore } from "./composer-drafts.js";
|
|
|
41
42
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
|
|
42
43
|
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, } from "./tool-manager.js";
|
|
43
44
|
import { WebUIContext } from "./webui-context.js";
|
|
45
|
+
import { DEFAULT_COMPACTION_RESERVE_TOKENS, effectiveSoftCap, softCapToReserve } from "./soft-cap.js";
|
|
44
46
|
import { decodeText } from "./text-sniff.js";
|
|
45
47
|
import { makeEditSoftTool } from "./edit-soft-tool.js";
|
|
46
48
|
import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
47
49
|
import { makeDelegateTaskTool } from "./delegate-task.js";
|
|
48
50
|
import { makeConversationReadTool, parseTranscriptLines, toTranscriptInput, } from "./conversation-read-tool.js";
|
|
51
|
+
import { makeSkillTool } from "./skill-tool.js";
|
|
49
52
|
import { makeScheduleTools } from "./schedule-agent-tool.js";
|
|
53
|
+
import { sameSessionFile } from "./scheduler-tasks.js";
|
|
50
54
|
import { buildAttachmentMessages, parseModelSpec } from "./attachments.js";
|
|
51
55
|
import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
|
|
56
|
+
import { isNotRepoError, scmCommitContext } from "./scm.js";
|
|
57
|
+
import { buildCommitMsgInput, buildCommitMsgPrompt, sanitizeCommitMessage } from "./scm-commitmsg.js";
|
|
52
58
|
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
53
59
|
import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
54
60
|
import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
@@ -68,6 +74,8 @@ const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
|
|
|
68
74
|
/** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
|
|
69
75
|
const DELTA_ACTIVE_WINDOW_MS = 1500;
|
|
70
76
|
const WIDGET_REFRESH_MS = 2000;
|
|
77
|
+
/** SCM「AI 生成提交信息」的单次补全超时——慢供应商不该让按钮转圈到天荒地老。 */
|
|
78
|
+
const SCM_COMMITMSG_TIMEOUT_MS = 60_000;
|
|
71
79
|
/** Model-stall watchdog: warn (don't abort — deep thinking can be legitimately
|
|
72
80
|
* quiet for minutes) when a streaming run produced NO SDK events for this long.
|
|
73
81
|
* Covers the failure class the per-tool watchdog cannot see: half-open API
|
|
@@ -648,6 +656,13 @@ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
|
|
|
648
656
|
* runtime alive; conversations of other projects keep their own lists).
|
|
649
657
|
* 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
|
|
650
658
|
const MAX_OPEN_CONVERSATIONS = 8;
|
|
659
|
+
/** 同时存活的子代理上限(按客户端计,含嵌套派生的孙子辈)。每个子代理都是一个完整
|
|
660
|
+
* runtime + TerminalManager,无上限时 AI 一次并行派发几十个会把服务进程拖垮。
|
|
661
|
+
* 主对话的 8 个上限是按项目计的,子代理按客户端全局计(wait_all 本来就是全局口径)。 */
|
|
662
|
+
const MAX_SUBAGENTS = 16;
|
|
663
|
+
/** SubagentSnapshot.prompt 下发上限:存的是全量 prompt,快照里只带前 N 字符,
|
|
664
|
+
* 避免 subagent_list 一次把几个长 prompt 全推给模型烧 token。 */
|
|
665
|
+
const SUBAGENT_PROMPT_SNAPSHOT_CAP = 2000;
|
|
651
666
|
const DEFAULT_CONV_TITLE = "新对话";
|
|
652
667
|
/** First user text in a session, truncated for the conversation list. */
|
|
653
668
|
function conversationTitle(session) {
|
|
@@ -784,6 +799,26 @@ export function isInsideSessionsDir(agentDir, targetPath) {
|
|
|
784
799
|
const sessionsRoot = resolve(agentDir, "sessions");
|
|
785
800
|
return abs.startsWith(sessionsRoot + sep);
|
|
786
801
|
}
|
|
802
|
+
/** 会话当前模型的 "provider/id"(无模型时 null;软上限按模型覆盖用,issue #229)。 */
|
|
803
|
+
function modelKeyOf(session) {
|
|
804
|
+
const m = session?.model;
|
|
805
|
+
if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
|
|
806
|
+
return null;
|
|
807
|
+
return `${m.provider}/${m.id}`;
|
|
808
|
+
}
|
|
809
|
+
/** 会话当前模型的上下文窗口(未知时 0):live 统计优先,模型定义回落。 */
|
|
810
|
+
function contextWindowOf(session) {
|
|
811
|
+
try {
|
|
812
|
+
const live = session?.getSessionStats?.()?.contextUsage?.contextWindow;
|
|
813
|
+
if (typeof live === "number" && live > 0)
|
|
814
|
+
return Math.floor(live);
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
// 会话未就绪 → 回落模型定义。
|
|
818
|
+
}
|
|
819
|
+
const def = session?.model?.contextWindow;
|
|
820
|
+
return typeof def === "number" && def > 0 ? Math.floor(def) : 0;
|
|
821
|
+
}
|
|
787
822
|
/**
|
|
788
823
|
* 选一个断开的残留会话给新标签认领(纯函数):
|
|
789
824
|
* - 还有别的在线浏览器(非伪客户端且 live)→ 不认领(新标签是第二块屏,
|
|
@@ -804,6 +839,23 @@ export function pickAdoptableOrphan(cands) {
|
|
|
804
839
|
}
|
|
805
840
|
return best?.id ?? null;
|
|
806
841
|
}
|
|
842
|
+
/** 同项目判定(纯函数):调度视口回退与 id 唤醒的 cwd 护栏用。
|
|
843
|
+
* Windows 大小写/分隔符差异归一,空串永不相等。 */
|
|
844
|
+
export function sameCwd(a, b) {
|
|
845
|
+
const x = String(a ?? "").trim();
|
|
846
|
+
const y = String(b ?? "").trim();
|
|
847
|
+
if (!x || !y)
|
|
848
|
+
return false;
|
|
849
|
+
const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
850
|
+
if (norm(x) === norm(y))
|
|
851
|
+
return true;
|
|
852
|
+
try {
|
|
853
|
+
return norm(resolve(x)) === norm(resolve(y));
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
807
859
|
export class ClientSession {
|
|
808
860
|
clientId;
|
|
809
861
|
/** Set by AgentService.attach: reflects the SERVICE-wide quiesce flag
|
|
@@ -986,12 +1038,27 @@ export class ClientSession {
|
|
|
986
1038
|
* 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
|
|
987
1039
|
* runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
|
|
988
1040
|
async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
|
|
1041
|
+
// 数量上限先行:每个子代理都是完整 runtime + TerminalManager,无上限时一次
|
|
1042
|
+
// 并行派发几十个会把服务进程拖垮。抛错由 subagent_spawn / delegate_task 转成
|
|
1043
|
+
// 返回文本,AI 读到后可改串行 / 等待收口后重试,而不是看到工具异常。
|
|
1044
|
+
const liveSubagents = [...this.convs.values()].filter((c) => c.isSubagent).length;
|
|
1045
|
+
if (liveSubagents >= MAX_SUBAGENTS) {
|
|
1046
|
+
throw new Error(pick(this.getLang(), `子代理数量已达上限(${MAX_SUBAGENTS} 个),请先用 subagent_wait_all 等一部分完成、或用 subagent_stop 停掉不需要的再派发`, `Subagent limit reached (${MAX_SUBAGENTS} live). Wait for some with subagent_wait_all or stop unneeded ones with subagent_stop before spawning more`, "agent.subagent.limit.reached", { limit: MAX_SUBAGENTS }));
|
|
1047
|
+
}
|
|
1048
|
+
// 真正的派发者(withSubagentOwner 按 runtime 归属填入):cwd 基准 / 跟随模型 /
|
|
1049
|
+
// 跟随思考强度一律读它,而不是派发瞬间的 active——后台对话产出时用户可能正
|
|
1050
|
+
// 看着别的项目,读 active 会跟错模型、把相对 cwd 解析到错误的项目下。
|
|
1051
|
+
const spawner = parentId ? this.convs.get(parentId) : undefined;
|
|
1052
|
+
const spawnerSession = spawner?.session ?? this.session;
|
|
1053
|
+
const baseCwd = spawner?.cwd ?? this.cwd;
|
|
1054
|
+
// 相对 cwd 按派发者所在目录解析:直接透传会相对 server 进程 cwd 落到别处。
|
|
1055
|
+
const resolvedCwd = cwd ? resolve(baseCwd, cwd) : baseCwd;
|
|
989
1056
|
const conversationId = `sa-${randomUUID().slice(0, 8)}`;
|
|
990
|
-
const terminals = this.makeTerminalManager(conversationId,
|
|
1057
|
+
const terminals = this.makeTerminalManager(conversationId, resolvedCwd);
|
|
991
1058
|
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
|
|
992
|
-
cwd,
|
|
1059
|
+
cwd: resolvedCwd,
|
|
993
1060
|
agentDir: this.agentDir,
|
|
994
|
-
sessionManager: SessionManager.inMemory(
|
|
1061
|
+
sessionManager: SessionManager.inMemory(resolvedCwd),
|
|
995
1062
|
});
|
|
996
1063
|
const conv = this.makeConversation(runtime, conversationId, terminals);
|
|
997
1064
|
conv.isSubagent = true;
|
|
@@ -1000,6 +1067,11 @@ export class ClientSession {
|
|
|
1000
1067
|
// 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
|
|
1001
1068
|
conv.parentId = parentId ?? this.activeId ?? undefined;
|
|
1002
1069
|
conv.subagentType = type;
|
|
1070
|
+
conv.subagentPrompt = prompt;
|
|
1071
|
+
// 插件工具门与模板扩展白名单对齐:白名单非空时插件/MCP 工具(无 SDK
|
|
1072
|
+
// extensionKey 身份)不进该会话。工厂期 customTools 不注册 + 下面的
|
|
1073
|
+
// syncPluginTools 不回补,模板热改不影响已运行的子代理(与 prompt/技能一致)。
|
|
1074
|
+
conv.subagentBarsPluginTools = !!apply && apply.enabledExtensions.length > 0;
|
|
1003
1075
|
conv.listed = true;
|
|
1004
1076
|
conv.title = subagentTitle(prompt);
|
|
1005
1077
|
this.convs.set(conv.id, conv);
|
|
@@ -1009,6 +1081,8 @@ export class ClientSession {
|
|
|
1009
1081
|
conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
|
|
1010
1082
|
// 子代理不走 bindSession——这里同样注入面板的重试次数覆盖。
|
|
1011
1083
|
this.applyRetryOverrides();
|
|
1084
|
+
// 软上限覆盖同样重放(子代理跟随主对话的压缩阈值,issue #229)。
|
|
1085
|
+
this.applyCompactionOverrides();
|
|
1012
1086
|
// 扩展绑定(rpc 模式);用 headless 的 Web UI context:
|
|
1013
1087
|
// 扩展绑定时不会因缺方法崩,UI 输出也不下发(不会与主对话的 widget/status 冲突)。
|
|
1014
1088
|
try {
|
|
@@ -1030,8 +1104,8 @@ export class ClientSession {
|
|
|
1030
1104
|
const resolvedModel = model ?? (apply?.model?.trim() || null) ?? (this.settingsSvc.current.subagentDefaultModel || null);
|
|
1031
1105
|
const followModel = resolvedModel
|
|
1032
1106
|
? resolvedModel
|
|
1033
|
-
:
|
|
1034
|
-
? `${
|
|
1107
|
+
: spawnerSession.model
|
|
1108
|
+
? `${spawnerSession.model.provider}/${spawnerSession.model.id}`
|
|
1035
1109
|
: null;
|
|
1036
1110
|
if (followModel) {
|
|
1037
1111
|
const slash = followModel.indexOf("/");
|
|
@@ -1041,7 +1115,8 @@ export class ClientSession {
|
|
|
1041
1115
|
if (m) {
|
|
1042
1116
|
try {
|
|
1043
1117
|
// 先恢复该 provider 的项目密钥(setModel 的鉴权检查要用),再换模型。
|
|
1044
|
-
|
|
1118
|
+
// 按子代理自己的目录恢复(跨目录派发时派发者的密钥不一定适用)。
|
|
1119
|
+
await this.restoreKeyForModel(followModel, resolvedCwd);
|
|
1045
1120
|
await conv.session.setModel(m);
|
|
1046
1121
|
}
|
|
1047
1122
|
catch (err) {
|
|
@@ -1063,12 +1138,12 @@ export class ClientSession {
|
|
|
1063
1138
|
});
|
|
1064
1139
|
}
|
|
1065
1140
|
}
|
|
1066
|
-
//
|
|
1067
|
-
// 同一取数源:
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
1071
|
-
const thinkingLevel = apply?.thinkingLevel?.trim() ||
|
|
1141
|
+
// 思考强度:模板指定则固定用它,否则跟随派发者当前强度(与「跟随派发者模型」
|
|
1142
|
+
// 同一取数源:spawnerSession)。所以子代理默认与派发者一致,而不是默默回到
|
|
1143
|
+
// SDK 默认档位。放在换模型之后:setModel 会按模型能力重算强度,我们先让它
|
|
1144
|
+
// 算完再覆盖。不传 persist:只影响这个子代理会话,不动全局默认强度;模型不
|
|
1145
|
+
// 支持的档位由 SDK 自动收敛(reasoning:false 的模型只能是 off)。
|
|
1146
|
+
const thinkingLevel = apply?.thinkingLevel?.trim() || spawnerSession.thinkingLevel;
|
|
1072
1147
|
if (thinkingLevel) {
|
|
1073
1148
|
try {
|
|
1074
1149
|
conv.session.setThinkingLevel(thinkingLevel);
|
|
@@ -1109,7 +1184,10 @@ export class ClientSession {
|
|
|
1109
1184
|
}
|
|
1110
1185
|
toSubagentSnapshot(conv) {
|
|
1111
1186
|
const streaming = conv.session.isStreaming;
|
|
1112
|
-
const
|
|
1187
|
+
const { error, canceled } = this.subagentRunOutcome(conv);
|
|
1188
|
+
// state 如实反映终态:之前 canceled 的子代理报的也是 done,只能靠独立 flag
|
|
1189
|
+
// 分辨。"queued" 保留给未来(排队调度),当前 spawn 即运行,无排队态。
|
|
1190
|
+
const state = streaming ? "running" : canceled ? "canceled" : "done";
|
|
1113
1191
|
let messageCount = 0;
|
|
1114
1192
|
try {
|
|
1115
1193
|
messageCount = conv.session.getSessionStats().totalMessages;
|
|
@@ -1117,12 +1195,14 @@ export class ClientSession {
|
|
|
1117
1195
|
catch {
|
|
1118
1196
|
// session being replaced — report defaults
|
|
1119
1197
|
}
|
|
1120
|
-
const
|
|
1198
|
+
const fullPrompt = conv.subagentPrompt ?? "";
|
|
1121
1199
|
return {
|
|
1122
1200
|
convId: conv.id,
|
|
1123
1201
|
type: conv.subagentType ?? "general",
|
|
1124
1202
|
title: conv.title,
|
|
1125
|
-
prompt:
|
|
1203
|
+
prompt: fullPrompt.length > SUBAGENT_PROMPT_SNAPSHOT_CAP
|
|
1204
|
+
? `${fullPrompt.slice(0, SUBAGENT_PROMPT_SNAPSHOT_CAP)}\n… [truncated]`
|
|
1205
|
+
: fullPrompt,
|
|
1126
1206
|
state,
|
|
1127
1207
|
streaming,
|
|
1128
1208
|
error,
|
|
@@ -1130,6 +1210,7 @@ export class ClientSession {
|
|
|
1130
1210
|
messageCount,
|
|
1131
1211
|
model: conv.session.model?.id,
|
|
1132
1212
|
output: conv.session.getLastAssistantText() ?? "",
|
|
1213
|
+
parentId: conv.parentId,
|
|
1133
1214
|
};
|
|
1134
1215
|
}
|
|
1135
1216
|
/** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
|
|
@@ -1200,6 +1281,9 @@ export class ClientSession {
|
|
|
1200
1281
|
lastSdkAppendFiles = [];
|
|
1201
1282
|
/** 当前活动会话的工具/资源快照 → composer 输入。cwd 取活动对话的。 */
|
|
1202
1283
|
composeInputs(src) {
|
|
1284
|
+
// 技能名录指纹观测(只打日志,不干预组装;预览与 run 共用此入口,
|
|
1285
|
+
// 变化才记一行,首轮静默)。用未注文的目录(fill 前),全文注入不影响指纹。
|
|
1286
|
+
this.noteSkillCatalogDigest(src.skills);
|
|
1203
1287
|
return {
|
|
1204
1288
|
cwd: src.cwd,
|
|
1205
1289
|
systemPromptFile: this.lastBaseSystemPrompt || undefined,
|
|
@@ -1364,12 +1448,32 @@ export class ClientSession {
|
|
|
1364
1448
|
return !!t && t.enabled;
|
|
1365
1449
|
},
|
|
1366
1450
|
};
|
|
1367
|
-
/** schedule_* 工具的数据宿主:全局调度存储+创建时刻 live 的 cwd/活动对话。
|
|
1451
|
+
/** schedule_* 工具的数据宿主:全局调度存储+创建时刻 live 的 cwd/活动对话。
|
|
1452
|
+
* issue #231:同时快照 owner 对话的落盘会话文件(压缩/重启后稳定),触发时
|
|
1453
|
+
* 先按 sessionFile 认同一会话(内存对话 id 重启即失效,不可单独做持久键)。 */
|
|
1368
1454
|
scheduleToolHost() {
|
|
1369
1455
|
return {
|
|
1370
1456
|
store: () => this.schedulerStore,
|
|
1371
1457
|
cwd: () => this.cwd,
|
|
1372
1458
|
activeConversationId: () => this.activeId,
|
|
1459
|
+
conversationInfo: (id) => {
|
|
1460
|
+
try {
|
|
1461
|
+
const target = (id ?? "").trim() ? this.convs.get((id ?? "").trim()) : this.convs.get(this.activeId);
|
|
1462
|
+
if (!target)
|
|
1463
|
+
return undefined;
|
|
1464
|
+
let sessionFile = "";
|
|
1465
|
+
try {
|
|
1466
|
+
sessionFile = String(target.session.sessionFile ?? "");
|
|
1467
|
+
}
|
|
1468
|
+
catch {
|
|
1469
|
+
sessionFile = "";
|
|
1470
|
+
}
|
|
1471
|
+
return { cwd: target.cwd ?? this.cwd, sessionFile };
|
|
1472
|
+
}
|
|
1473
|
+
catch {
|
|
1474
|
+
return undefined;
|
|
1475
|
+
}
|
|
1476
|
+
},
|
|
1373
1477
|
};
|
|
1374
1478
|
}
|
|
1375
1479
|
/** conversation_read 工具的数据宿主:读本客户端的 conversation 体系 +
|
|
@@ -1452,6 +1556,45 @@ export class ClientSession {
|
|
|
1452
1556
|
},
|
|
1453
1557
|
};
|
|
1454
1558
|
}
|
|
1559
|
+
/** skill 工具的数据宿主:读所属会话 loader 的实时技能表 + 主会话禁用集过滤
|
|
1560
|
+
* (与 skillsOverride 主会话语义一致)。ownerId 语义同 browser_page(本
|
|
1561
|
+
* runtime 所属会话,不是派发瞬间的 active)。失败回空目录,不抛错。 */
|
|
1562
|
+
skillToolHost(ownerId) {
|
|
1563
|
+
return {
|
|
1564
|
+
listSkills: () => {
|
|
1565
|
+
try {
|
|
1566
|
+
const target = (ownerId ?? "").trim() !== "" ? this.convs.get(ownerId.trim()) : this.convs.get(this.activeId);
|
|
1567
|
+
const all = target?.session.resourceLoader.getSkills().skills ?? [];
|
|
1568
|
+
const disabled = new Set(this.settingsSvc.current.disabledSkills);
|
|
1569
|
+
return all
|
|
1570
|
+
.filter((s) => !disabled.has(s.name))
|
|
1571
|
+
.map((s) => ({
|
|
1572
|
+
name: s.name,
|
|
1573
|
+
description: s.description ?? "",
|
|
1574
|
+
filePath: s.filePath ?? "",
|
|
1575
|
+
}));
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
return [];
|
|
1579
|
+
}
|
|
1580
|
+
},
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
/** 技能名录指纹(sha256 over name+description,非正文):变化才打一行日志,
|
|
1584
|
+
* 首轮静默。只观测不干预组装(digest 第一步:日志;复用以后再说)。 */
|
|
1585
|
+
lastSkillCatalogDigest = "";
|
|
1586
|
+
noteSkillCatalogDigest(skills) {
|
|
1587
|
+
const d = createHash("sha256")
|
|
1588
|
+
.update(skills.map((s) => `${s.name}\n${s.description}`).join("\n"))
|
|
1589
|
+
.digest("hex")
|
|
1590
|
+
.slice(0, 16);
|
|
1591
|
+
if (d === this.lastSkillCatalogDigest)
|
|
1592
|
+
return;
|
|
1593
|
+
const prev = this.lastSkillCatalogDigest;
|
|
1594
|
+
this.lastSkillCatalogDigest = d;
|
|
1595
|
+
if (prev)
|
|
1596
|
+
console.log(`[skills] catalog digest ${prev}→${d} (${skills.length} skills)`);
|
|
1597
|
+
}
|
|
1455
1598
|
widgetsTimer = null;
|
|
1456
1599
|
/** Model-stall watchdog interval (see startStallTimer). */
|
|
1457
1600
|
stallTimer = null;
|
|
@@ -1575,13 +1718,15 @@ export class ClientSession {
|
|
|
1575
1718
|
reloadSession: async () => {
|
|
1576
1719
|
await this.session.reload();
|
|
1577
1720
|
// reload() 重读磁盘 settings.json,会丢掉内存 applyOverrides
|
|
1578
|
-
// (含重试次数覆盖)——依次重放:重试覆盖 → 终端门控。
|
|
1721
|
+
// (含重试次数覆盖)——依次重放:重试覆盖 → 软上限覆盖 → 终端门控。
|
|
1579
1722
|
this.applyRetryOverrides();
|
|
1723
|
+
this.applyCompactionOverrides();
|
|
1580
1724
|
// reload() 会把 custom 工具重新加回活跃集——重放终端开关。
|
|
1581
1725
|
this.applyToolGating(this.session);
|
|
1582
1726
|
await this.pushSlashCommands();
|
|
1583
1727
|
},
|
|
1584
1728
|
applyRetryOverrides: () => this.applyRetryOverrides(),
|
|
1729
|
+
applyCompactionOverrides: () => this.applyCompactionOverrides(),
|
|
1585
1730
|
applyToolGating: () => this.applyToolGating(this.session),
|
|
1586
1731
|
promptSnapshot: () => this.promptSnapshot(),
|
|
1587
1732
|
getMarkerState: () => ({
|
|
@@ -1631,15 +1776,22 @@ export class ClientSession {
|
|
|
1631
1776
|
const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
|
|
1632
1777
|
const conversationId = cs.nextConversationId();
|
|
1633
1778
|
const terminals = cs.makeTerminalManager(conversationId, cwd);
|
|
1634
|
-
|
|
1779
|
+
// Resume the most recent session for this project — the SDK default
|
|
1780
|
+
// per-project dir (<agentDir>/sessions/--<cwd>--/, shared with the
|
|
1781
|
+
// pi CLI/TUI) — or start a fresh one on first visit.
|
|
1782
|
+
// issue #145: opts.blank = 跳过恢复(最近那条在别处跑着),直接空白新对话。
|
|
1783
|
+
// issue #235:坏转录(重复压缩标记成环)修一次再试,否则整项目首屏
|
|
1784
|
+
// "Failed to initialize session"。
|
|
1785
|
+
const opened = await cs.openManagerAndRuntime(() => (opts?.blank ? SessionManager.create(cwd) : SessionManager.continueRecent(cwd)), (m) => createAgentSessionRuntime(cs.makeRuntimeFactory(terminals, undefined, conversationId), {
|
|
1635
1786
|
cwd,
|
|
1636
1787
|
agentDir,
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1788
|
+
sessionManager: m,
|
|
1789
|
+
}), async () => (await SessionManager.list(cwd))[0]?.path);
|
|
1790
|
+
const runtime = opened.runtime;
|
|
1791
|
+
if (opened.repair) {
|
|
1792
|
+
for (const n of cs.transcriptRepairNotices(opened.repair))
|
|
1793
|
+
cs.pendingNotices.push(n);
|
|
1794
|
+
}
|
|
1643
1795
|
// First conversation = the resumed session; it also seeds the shared
|
|
1644
1796
|
// ModelRuntime that every later conversation reuses.
|
|
1645
1797
|
cs.sharedModelRuntime = runtime.services.modelRuntime;
|
|
@@ -1779,12 +1931,18 @@ export class ClientSession {
|
|
|
1779
1931
|
// 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
|
|
1780
1932
|
// systemPromptOverride 整体替换,此处边界不存在会自然跳过。
|
|
1781
1933
|
if (apply) {
|
|
1782
|
-
|
|
1934
|
+
const tplPrompt = pickTemplatePrompt(apply, this.getLang()).trim();
|
|
1935
|
+
if (apply.promptMode !== "replace" || !tplPrompt)
|
|
1783
1936
|
return undefined;
|
|
1784
1937
|
const boundary = event.systemPrompt.indexOf("\n\nAvailable tools:");
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1938
|
+
// 边界串是 SDK 提示词的内部格式:版本一变就可能对不上。
|
|
1939
|
+
// 对不上时不再静默回退默认 persona(模板等于没生效),而是把模板
|
|
1940
|
+
// 提示词前置拼接——角色约束仍在,只是灵魂段没被精确替换。
|
|
1941
|
+
if (boundary === -1) {
|
|
1942
|
+
const fallback = `${tplPrompt}\n\n${event.systemPrompt}`;
|
|
1943
|
+
return fallback === event.systemPrompt ? undefined : { systemPrompt: fallback };
|
|
1944
|
+
}
|
|
1945
|
+
const swapped = tplPrompt + event.systemPrompt.slice(boundary);
|
|
1788
1946
|
return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
|
|
1789
1947
|
}
|
|
1790
1948
|
// 主会话:组合模板渲染(模板为空且无覆盖时返回 undefined = 用 SDK 默认)。
|
|
@@ -1834,7 +1992,10 @@ export class ClientSession {
|
|
|
1834
1992
|
makeEditSoftTool(effectiveCwd, () => this.getLang()),
|
|
1835
1993
|
// 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
|
|
1836
1994
|
// 后续注册经 refreshPluginTools 动态补入已有会话)。
|
|
1837
|
-
|
|
1995
|
+
// 子代理模板带非空扩展白名单时不注入:插件/MCP 工具没有 SDK extensionKey
|
|
1996
|
+
// 身份、无法参与白名单匹配,全放行等于白名单没关门,全收编才符合「只加载这些」。
|
|
1997
|
+
// 空白名单 = 跟随主会话(插件工具照常进入子代理)。
|
|
1998
|
+
...(apply && apply.enabledExtensions.length > 0 ? [] : this.enabledPluginToolDefs()),
|
|
1838
1999
|
// 第一方子代理工具(spawn/get_result/steer/list/stop)。子代理会话
|
|
1839
2000
|
// 也注册了它们,因此可自然嵌套派发。host 按 ownerId 包装:子代理的
|
|
1840
2001
|
// 父对话 = 真正调用 spawn 的那个会话(本 runtime 所属会话),而不是
|
|
@@ -1866,6 +2027,10 @@ export class ClientSession {
|
|
|
1866
2027
|
// 会话同样注册了它,可自然嵌套读取。不需要 ownerId——读的是本
|
|
1867
2028
|
// 客户端的 conversation 体系与落盘历史,与派发者无关。
|
|
1868
2029
|
makeConversationReadTool(this.conversationReadHost(), () => this.getLang()),
|
|
2030
|
+
// 技能全文按名加载(名录在 {{skills}} 段):模型不再拼路径调 read。
|
|
2031
|
+
// 子代理会话同样注册(owner 即真正派发的父对话,读该会话 loader)。
|
|
2032
|
+
// DSH 引擎无 customTool 注册面,不接。开关走统一工具 tab。
|
|
2033
|
+
makeSkillTool(this.skillToolHost(ownerId), () => this.getLang()),
|
|
1869
2034
|
// 定时唤醒三件套(schedule_task/list/cancel,issue #193):默认绑定
|
|
1870
2035
|
// 发起对话(ownerId,无则活动对话),到期 steer 语义唤醒它;子代理
|
|
1871
2036
|
// 会话同样注册(owner 即真正派发的父对话)。开关走统一工具 tab。
|
|
@@ -2075,6 +2240,8 @@ export class ClientSession {
|
|
|
2075
2240
|
// 默认重试 3 次——这里把面板的 retryMaxAttempts 覆盖注入,否则“设了 6
|
|
2076
2241
|
// 次还是按 3 次重试”。已存在会话重复注入是幂等的(同值覆盖)。
|
|
2077
2242
|
this.applyRetryOverrides();
|
|
2243
|
+
// 软上限覆盖同路重放(新 runtime 的 SettingsManager 是干净的,issue #229)。
|
|
2244
|
+
this.applyCompactionOverrides();
|
|
2078
2245
|
this.scheduleSnapshot();
|
|
2079
2246
|
this.webUi.refresh();
|
|
2080
2247
|
this.startWidgetsTimer();
|
|
@@ -2343,6 +2510,94 @@ export class ClientSession {
|
|
|
2343
2510
|
await conv.session.sendUserMessage(text, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
|
|
2344
2511
|
return { ok: true };
|
|
2345
2512
|
}
|
|
2513
|
+
/** issue #231:本客户端内按稳定键定位调度唤醒目标。
|
|
2514
|
+
* sessionFile 优先(压缩/重启后内存对话 id 已变,落盘会话文件才是同一会话);
|
|
2515
|
+
* 只有没给 sessionFile 时才按内存 id 找,且 id 必须附带 cwd 一致才认 ——
|
|
2516
|
+
* 各客户端计数器都从 c1 开始,跨项目同 id 必然撞车,不校验 cwd 会把巡检
|
|
2517
|
+
* 报告投进完全无关的项目对话。
|
|
2518
|
+
* excludeIds 用于视口回退时跳过已知的忙对话。 */
|
|
2519
|
+
resolveSchedulerTarget(opts) {
|
|
2520
|
+
const wantFile = String(opts.sessionFile ?? "").trim();
|
|
2521
|
+
const wantId = String(opts.conversationId ?? "").trim();
|
|
2522
|
+
const wantCwd = String(opts.cwd ?? "").trim();
|
|
2523
|
+
const excluded = opts.excludeIds;
|
|
2524
|
+
if (wantFile) {
|
|
2525
|
+
let best = null;
|
|
2526
|
+
for (const c of this.convs.values()) {
|
|
2527
|
+
if (!c?.session || (excluded && excluded.has(c.id)))
|
|
2528
|
+
continue;
|
|
2529
|
+
let f = "";
|
|
2530
|
+
try {
|
|
2531
|
+
f = String(c.session.sessionFile ?? "");
|
|
2532
|
+
}
|
|
2533
|
+
catch {
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
if (!sameSessionFile(f, wantFile))
|
|
2537
|
+
continue;
|
|
2538
|
+
if (!best || c.lastActiveAt > best.lastActiveAt)
|
|
2539
|
+
best = c;
|
|
2540
|
+
}
|
|
2541
|
+
if (best)
|
|
2542
|
+
return best;
|
|
2543
|
+
}
|
|
2544
|
+
if (wantId) {
|
|
2545
|
+
const c = this.convs.get(wantId);
|
|
2546
|
+
if (!c?.session || (excluded && excluded.has(c.id)))
|
|
2547
|
+
return null;
|
|
2548
|
+
if (wantCwd && !sameCwd(c.cwd, wantCwd))
|
|
2549
|
+
return null;
|
|
2550
|
+
return c;
|
|
2551
|
+
}
|
|
2552
|
+
return null;
|
|
2553
|
+
}
|
|
2554
|
+
/** issue #231:本客户端内同项目的最近活跃对话(视口回退目标)。
|
|
2555
|
+
* 主对话优先(用户正看着的面),没有主对话时才考虑子代理行;
|
|
2556
|
+
* 最近活跃者即用户当前的视口。 */
|
|
2557
|
+
findViewportInCwd(cwd, excludeIds) {
|
|
2558
|
+
const want = String(cwd ?? "").trim();
|
|
2559
|
+
if (!want)
|
|
2560
|
+
return null;
|
|
2561
|
+
let best = null;
|
|
2562
|
+
let bestSub = null;
|
|
2563
|
+
for (const c of this.convs.values()) {
|
|
2564
|
+
if (!c?.session || (excludeIds && excludeIds.has(c.id)))
|
|
2565
|
+
continue;
|
|
2566
|
+
if (!sameCwd(c.cwd, want))
|
|
2567
|
+
continue;
|
|
2568
|
+
if (c.isSubagent) {
|
|
2569
|
+
if (!bestSub || c.lastActiveAt > bestSub.lastActiveAt)
|
|
2570
|
+
bestSub = c;
|
|
2571
|
+
}
|
|
2572
|
+
else if (!best || c.lastActiveAt > best.lastActiveAt) {
|
|
2573
|
+
best = c;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
return best ?? bestSub;
|
|
2577
|
+
}
|
|
2578
|
+
/** issue #231:带压缩忙检测的调度 steer。压缩进行中时 SDK 直接抛错
|
|
2579
|
+
* (Cannot submit a prompt while compaction is in progress),调用方据 busy
|
|
2580
|
+
* 另寻视口兄弟或稍后重试,而不是当成“对话不在”静默转无头。 */
|
|
2581
|
+
async trySteerScheduler(conv, text) {
|
|
2582
|
+
try {
|
|
2583
|
+
try {
|
|
2584
|
+
if (conv.session.isCompacting === true) {
|
|
2585
|
+
return { ok: false, busy: true, error: "上下文压缩进行中,稍后重试" };
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
catch {
|
|
2589
|
+
/* 读不到压缩态就直接投递,失败按异常走 */
|
|
2590
|
+
}
|
|
2591
|
+
await conv.session.sendUserMessage(text, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
|
|
2592
|
+
return { ok: true };
|
|
2593
|
+
}
|
|
2594
|
+
catch (err) {
|
|
2595
|
+
const msg = String(err?.message ?? err);
|
|
2596
|
+
if (/compaction is in progress/i.test(msg))
|
|
2597
|
+
return { ok: false, busy: true, error: msg };
|
|
2598
|
+
return { ok: false, busy: false, error: msg };
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2346
2601
|
/** 插件扩展点(供 runSteerer):向指定对话插队一条用户消息,复用子代理 steer 的
|
|
2347
2602
|
* sendUserMessage + deliverAs:'steer' 路径(运行时插队;未跑时按普通消息投递)。
|
|
2348
2603
|
* 先找本客户端 conversations,找不到再经 steerConversationElsewhere 问其他客户端;
|
|
@@ -2949,12 +3204,14 @@ export class ClientSession {
|
|
|
2949
3204
|
contextWindow: cu.contextWindow,
|
|
2950
3205
|
percent: (conv.lastCompactionTokens / cu.contextWindow) * 100,
|
|
2951
3206
|
estimated: true,
|
|
3207
|
+
softCap: this.activeSoftCap(cu.contextWindow),
|
|
2952
3208
|
};
|
|
2953
3209
|
}
|
|
2954
3210
|
return {
|
|
2955
3211
|
tokens: cu.tokens,
|
|
2956
3212
|
contextWindow: cu.contextWindow,
|
|
2957
3213
|
percent: cu.percent,
|
|
3214
|
+
softCap: this.activeSoftCap(cu.contextWindow),
|
|
2958
3215
|
};
|
|
2959
3216
|
})(),
|
|
2960
3217
|
};
|
|
@@ -3588,8 +3845,9 @@ export class ClientSession {
|
|
|
3588
3845
|
},
|
|
3589
3846
|
refreshSessions: () => this.refreshSessions(),
|
|
3590
3847
|
afterReload: () => {
|
|
3591
|
-
// /reload 同样重读磁盘 settings.json——重放重试覆盖 + 终端门控。
|
|
3848
|
+
// /reload 同样重读磁盘 settings.json——重放重试覆盖 + 软上限覆盖 + 终端门控。
|
|
3592
3849
|
this.applyRetryOverrides();
|
|
3850
|
+
this.applyCompactionOverrides();
|
|
3593
3851
|
this.applyToolGating(this.session);
|
|
3594
3852
|
},
|
|
3595
3853
|
pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
|
|
@@ -3833,6 +4091,40 @@ export class ClientSession {
|
|
|
3833
4091
|
}
|
|
3834
4092
|
}
|
|
3835
4093
|
}
|
|
4094
|
+
/** 把压缩软上限换算成各存活会话的 compaction reserveTokens 覆盖
|
|
4095
|
+
* (issue #229)。与重试覆盖同一 live 机制:applyOverrides 只改内存
|
|
4096
|
+
* 合并视图,SDK 每次自动压缩检查前都重读 getCompactionSettings(),
|
|
4097
|
+
* 无需 reload;软上限关闭时回填 SDK 默认 reserve(不让旧覆盖泄漏)。
|
|
4098
|
+
* 窗口未知(会话未就绪/无模型)的会话跳过——创建/就绪/换模型路径
|
|
4099
|
+
* 会重放(见各 applyRetryOverrides 调用点)。 */
|
|
4100
|
+
applyCompactionOverrides() {
|
|
4101
|
+
const s = this.settingsSvc.current;
|
|
4102
|
+
for (const c of this.convs.values()) {
|
|
4103
|
+
try {
|
|
4104
|
+
const modelId = modelKeyOf(c.session);
|
|
4105
|
+
const contextWindow = contextWindowOf(c.session);
|
|
4106
|
+
const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelId);
|
|
4107
|
+
const reserve = softCapToReserve(contextWindow, cap);
|
|
4108
|
+
c.session.settingsManager.applyOverrides({
|
|
4109
|
+
compaction: { reserveTokens: reserve ?? DEFAULT_COMPACTION_RESERVE_TOKENS },
|
|
4110
|
+
});
|
|
4111
|
+
}
|
|
4112
|
+
catch {
|
|
4113
|
+
// 会话未就绪或已释放 → 其 runtime 创建时统一注入。
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
/** 当前活动对话的生效软上限(快照底栏标记线用;null = 关闭/未知)。 */
|
|
4118
|
+
activeSoftCap(contextWindow) {
|
|
4119
|
+
try {
|
|
4120
|
+
const s = this.settingsSvc.current;
|
|
4121
|
+
const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelKeyOf(this.session));
|
|
4122
|
+
return softCapToReserve(contextWindow, cap) === null ? null : cap;
|
|
4123
|
+
}
|
|
4124
|
+
catch {
|
|
4125
|
+
return null;
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
3836
4128
|
/** Extensions/skills changed externally (e.g. `pi remove` finished in the
|
|
3837
4129
|
* terminal): re-run session.reload() and re-push state. Streaming-safe —
|
|
3838
4130
|
* deferred to agent_end, same as settings reloads. */
|
|
@@ -3867,6 +4159,7 @@ export class ClientSession {
|
|
|
3867
4159
|
try {
|
|
3868
4160
|
await this.session.reload();
|
|
3869
4161
|
this.applyRetryOverrides();
|
|
4162
|
+
this.applyCompactionOverrides();
|
|
3870
4163
|
this.applyToolGating(this.session);
|
|
3871
4164
|
await this.pushSlashCommands();
|
|
3872
4165
|
this.pushSettings();
|
|
@@ -3929,8 +4222,14 @@ export class ClientSession {
|
|
|
3929
4222
|
return (this.pluginToolsProvider?.() ?? []).filter((t) => !off.has(t.name)).map(pluginToolToDefinition);
|
|
3930
4223
|
}
|
|
3931
4224
|
/** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除;禁用工具同步移除)。
|
|
3932
|
-
* 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。
|
|
4225
|
+
* 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。
|
|
4226
|
+
* 模板白名单的子代理(subagentBarsPluginTools)跳过:工厂期就没注册,这里
|
|
4227
|
+
* 不回补,否则白名单等于没关门。 */
|
|
3933
4228
|
syncPluginTools(session) {
|
|
4229
|
+
for (const conv of this.convs.values()) {
|
|
4230
|
+
if (conv.session === session && conv.subagentBarsPluginTools)
|
|
4231
|
+
return;
|
|
4232
|
+
}
|
|
3934
4233
|
try {
|
|
3935
4234
|
const defs = this.enabledPluginToolDefs();
|
|
3936
4235
|
const next = syncPluginToolsIntoSession(session, defs, this.appliedPluginToolNames);
|
|
@@ -4202,7 +4501,8 @@ export class ClientSession {
|
|
|
4202
4501
|
// issue #145:同项目并行感知 —— 同一 cwd 下别处(或其他对话)正在跑时,
|
|
4203
4502
|
// 允许并行(可以同时改不同部分),但用户与 AI 都必须知道。只在新一轮启动时
|
|
4204
4503
|
// 通告一次(steer/排队等流式中发送不重复打扰)。
|
|
4205
|
-
|
|
4504
|
+
// parallelReminderEnabled=false 时整段跳过(不发 notice、不注 AI、不通知对端)。
|
|
4505
|
+
if (!conv.isSubagent && !s.isStreaming && this.settingsSvc.current.parallelReminderEnabled !== false) {
|
|
4206
4506
|
const localRunners = [...this.convs.values()]
|
|
4207
4507
|
.filter((c) => c.id !== conv.id && !c.isSubagent && c.cwd === conv.cwd && this.conversationStreaming(c))
|
|
4208
4508
|
.map((c) => ({ title: c.title }));
|
|
@@ -4655,11 +4955,17 @@ export class ClientSession {
|
|
|
4655
4955
|
this.clearAllToolWatchdogs(conv);
|
|
4656
4956
|
conv.toolStartTimes.clear();
|
|
4657
4957
|
await conv.runtime.dispose();
|
|
4658
|
-
|
|
4958
|
+
// #235:转录链损坏时修一次再试(见 openManagerAndRuntime)。
|
|
4959
|
+
const opened = await this.openManagerAndRuntime(() => SessionManager.continueRecent(conv.cwd), (m) => createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
|
|
4659
4960
|
cwd: conv.cwd,
|
|
4660
4961
|
agentDir: this.agentDir,
|
|
4661
|
-
sessionManager:
|
|
4662
|
-
});
|
|
4962
|
+
sessionManager: m,
|
|
4963
|
+
}), async () => (await SessionManager.list(conv.cwd))[0]?.path);
|
|
4964
|
+
const runtime = opened.runtime;
|
|
4965
|
+
if (opened.repair) {
|
|
4966
|
+
for (const n of this.transcriptRepairNotices(opened.repair))
|
|
4967
|
+
this.emit(n);
|
|
4968
|
+
}
|
|
4663
4969
|
conv.runtime = runtime;
|
|
4664
4970
|
conv.session = runtime.session;
|
|
4665
4971
|
this.emit({
|
|
@@ -4814,9 +5120,11 @@ export class ClientSession {
|
|
|
4814
5120
|
return;
|
|
4815
5121
|
// parentId joins the live chain: a null-parent marker would become a
|
|
4816
5122
|
// second root and hijack the leaf, corrupting the transcript.
|
|
5123
|
+
// #235: id 必须唯一——硬编码共享 id + SDK byId last-wins + 标记恰为末行
|
|
5124
|
+
// 时新消息 parent 记成共享 id = 下次 open 回溯成环,整个会话打不开。
|
|
4817
5125
|
const marker = {
|
|
4818
5126
|
type: "custom",
|
|
4819
|
-
id: "
|
|
5127
|
+
id: makeCompactionMarkerId("pending"),
|
|
4820
5128
|
parentId: conv.session.sessionManager.getLeafId(),
|
|
4821
5129
|
timestamp: new Date().toISOString(),
|
|
4822
5130
|
customType: "pi-web-ui/compaction-pending",
|
|
@@ -4843,10 +5151,18 @@ export class ClientSession {
|
|
|
4843
5151
|
const file = conv.session.sessionFile;
|
|
4844
5152
|
if (!file || !existsSync(file))
|
|
4845
5153
|
return;
|
|
4846
|
-
const id = "pi-web-ui-compaction-pending";
|
|
4847
5154
|
const raw = readFileSync(file, "utf8");
|
|
4848
5155
|
const lines = raw.split("\n");
|
|
4849
|
-
|
|
5156
|
+
// #235:按 customType 定位(id 自本 fix 起唯一,老文件仍是硬编码 id,
|
|
5157
|
+
// 按 id 找已不可靠)。取最后一个:重叠压缩时它属于本次,旧残留留给
|
|
5158
|
+
// 下次 open 的 repair/notice 处理。
|
|
5159
|
+
let idx = -1;
|
|
5160
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
5161
|
+
if (lines[i].includes(`"${COMPACTION_PENDING_TYPE}"`)) {
|
|
5162
|
+
idx = i;
|
|
5163
|
+
break;
|
|
5164
|
+
}
|
|
5165
|
+
}
|
|
4850
5166
|
if (idx < 0)
|
|
4851
5167
|
return;
|
|
4852
5168
|
// ponytail: full-file rewrite on compaction end — compactions are rare
|
|
@@ -4864,7 +5180,7 @@ export class ClientSession {
|
|
|
4864
5180
|
}
|
|
4865
5181
|
const done = {
|
|
4866
5182
|
type: "custom",
|
|
4867
|
-
id: "
|
|
5183
|
+
id: makeCompactionMarkerId("done"),
|
|
4868
5184
|
parentId,
|
|
4869
5185
|
timestamp: new Date().toISOString(),
|
|
4870
5186
|
customType: "pi-web-ui/compaction-done",
|
|
@@ -4894,15 +5210,80 @@ export class ClientSession {
|
|
|
4894
5210
|
return;
|
|
4895
5211
|
const kept = raw.split("\n").filter((line) => !line.includes("pi-web-ui/compaction-pending"));
|
|
4896
5212
|
writeFileSync(file, kept.join("\n"));
|
|
4897
|
-
this.
|
|
5213
|
+
this.emitCompactionInterruptedNotice();
|
|
5214
|
+
}
|
|
5215
|
+
catch {
|
|
5216
|
+
// Best-effort, same as the write path.
|
|
5217
|
+
}
|
|
5218
|
+
}
|
|
5219
|
+
emitCompactionInterruptedNotice() {
|
|
5220
|
+
this.emit({
|
|
5221
|
+
type: "notice",
|
|
5222
|
+
level: "warning",
|
|
5223
|
+
text: "上次压缩上下文被服务端重启打断,未完成。可发送 /compact 重试。",
|
|
5224
|
+
textEn: "The last context compaction was interrupted by a server restart and did not finish. Send /compact to retry.",
|
|
5225
|
+
});
|
|
5226
|
+
}
|
|
5227
|
+
/** #235 修复产生的提示(调用方决定 emit 还是进 pendingNotices)。 */
|
|
5228
|
+
transcriptRepairNotices(repair) {
|
|
5229
|
+
const out = [];
|
|
5230
|
+
if (repair.interrupted) {
|
|
5231
|
+
out.push({
|
|
4898
5232
|
type: "notice",
|
|
4899
5233
|
level: "warning",
|
|
4900
5234
|
text: "上次压缩上下文被服务端重启打断,未完成。可发送 /compact 重试。",
|
|
4901
5235
|
textEn: "The last context compaction was interrupted by a server restart and did not finish. Send /compact to retry.",
|
|
4902
5236
|
});
|
|
4903
5237
|
}
|
|
5238
|
+
if (repair.renamedIds > 0 || repair.rewiredParents > 0 || repair.cyclesBroken > 0) {
|
|
5239
|
+
out.push({
|
|
5240
|
+
type: "notice",
|
|
5241
|
+
level: "warning",
|
|
5242
|
+
text: `对话记录链损坏已自动修复(重复的压缩标记),原文件备份在 ${repair.backup ?? "同目录 .bak 文件"}。如内容异常可手动恢复。`,
|
|
5243
|
+
textEn: `The conversation transcript had a corrupted parent chain (duplicate compaction markers) and was auto-repaired. The original file is backed up at ${repair.backup ?? "a .bak file next to it"}; restore it manually if anything looks off.`,
|
|
5244
|
+
});
|
|
5245
|
+
}
|
|
5246
|
+
return out;
|
|
5247
|
+
}
|
|
5248
|
+
/**
|
|
5249
|
+
* #235:已知路径先修后开(openConversation 走这条——单文件预扫描零负担)。
|
|
5250
|
+
* 返回 null = 文件健康或无需处理;返回 repair = 修过,调用方弹提示。
|
|
5251
|
+
*/
|
|
5252
|
+
repairTranscriptFileBeforeOpen(filePath) {
|
|
5253
|
+
let repair = null;
|
|
5254
|
+
try {
|
|
5255
|
+
repair = repairSessionFile(filePath);
|
|
5256
|
+
}
|
|
4904
5257
|
catch {
|
|
4905
|
-
|
|
5258
|
+
return null;
|
|
5259
|
+
}
|
|
5260
|
+
if (!repair?.changed)
|
|
5261
|
+
return null;
|
|
5262
|
+
for (const n of this.transcriptRepairNotices(repair))
|
|
5263
|
+
this.emit(n);
|
|
5264
|
+
return repair;
|
|
5265
|
+
}
|
|
5266
|
+
/**
|
|
5267
|
+
* #235:manager+runtime 一起建,链损坏报错则修最近文件后重试一次。
|
|
5268
|
+
* SessionManager.open 本身不走 parent 链(真正死循环的是 runtime 初始化里的
|
|
5269
|
+
* getBranch),所以重试必须把两步都包进来;修完用全新 manager 重读。
|
|
5270
|
+
*/
|
|
5271
|
+
async openManagerAndRuntime(makeManager, makeRuntime, locateFile) {
|
|
5272
|
+
try {
|
|
5273
|
+
const manager = makeManager();
|
|
5274
|
+
const runtime = await makeRuntime(manager);
|
|
5275
|
+
return { manager, runtime, repair: null };
|
|
5276
|
+
}
|
|
5277
|
+
catch (err) {
|
|
5278
|
+
if (!looksLikeChainCorruption(err))
|
|
5279
|
+
throw err;
|
|
5280
|
+
const file = await locateFile().catch(() => undefined);
|
|
5281
|
+
const repair = file ? repairSessionFile(file) : null;
|
|
5282
|
+
if (!repair?.changed)
|
|
5283
|
+
throw err;
|
|
5284
|
+
const manager = makeManager();
|
|
5285
|
+
const runtime = await makeRuntime(manager);
|
|
5286
|
+
return { manager, runtime, repair };
|
|
4906
5287
|
}
|
|
4907
5288
|
}
|
|
4908
5289
|
displaceActive() {
|
|
@@ -5997,6 +6378,9 @@ export class ClientSession {
|
|
|
5997
6378
|
});
|
|
5998
6379
|
}
|
|
5999
6380
|
}
|
|
6381
|
+
// #235:先修后开——坏转录到 open 后的 getBranch 会死循环,修完再读。
|
|
6382
|
+
// 单文件预扫描,健康文件只多一次小读;修过即弹提示(含压缩被打断)。
|
|
6383
|
+
this.repairTranscriptFileBeforeOpen(targetPath);
|
|
6000
6384
|
const sessionManager = SessionManager.open(targetPath);
|
|
6001
6385
|
const targetCwd = sessionManager.getCwd();
|
|
6002
6386
|
const conversationId = this.nextConversationId();
|
|
@@ -6301,6 +6685,79 @@ export class ClientSession {
|
|
|
6301
6685
|
async scmQuery(kind, reqId, arg) {
|
|
6302
6686
|
return this.files.scmQuery(kind, reqId, arg);
|
|
6303
6687
|
}
|
|
6688
|
+
/**
|
|
6689
|
+
* SCM「AI 生成提交信息」:用当前对话模型做一次 completeSimple 一次性补全
|
|
6690
|
+
* (与视觉桥同一条通路)——不进对话上下文、不打断正在流式的回复。
|
|
6691
|
+
* 恰好应答一次:任何失败都以 ok:false 的 scm_data(kind "commitmsg")收尾,
|
|
6692
|
+
* 前端按钮不会卡在转圈。
|
|
6693
|
+
*/
|
|
6694
|
+
async scmGenCommitMessage(reqId) {
|
|
6695
|
+
const lang = this.getLang();
|
|
6696
|
+
const cwd = this.conv?.cwd ?? this.cwd;
|
|
6697
|
+
const reply = (ok, extra) => {
|
|
6698
|
+
this.emit({ type: "scm_data", reqId, kind: "commitmsg", ok, ...extra });
|
|
6699
|
+
};
|
|
6700
|
+
const fail = (err) => {
|
|
6701
|
+
reply(false, { error: err instanceof Error ? err.message : String(err) });
|
|
6702
|
+
};
|
|
6703
|
+
try {
|
|
6704
|
+
const runtime = this.runtime.services.modelRuntime;
|
|
6705
|
+
const model = this.session?.model;
|
|
6706
|
+
if (!model) {
|
|
6707
|
+
throw new Error(pick(lang, "当前没有可用模型——先在顶栏选择一个模型再生成", "No model available — pick one in the top bar first", "scm.commitmsg.no.model"));
|
|
6708
|
+
}
|
|
6709
|
+
const ctx = await scmCommitContext(cwd, () => lang);
|
|
6710
|
+
const input = buildCommitMsgInput(ctx, lang === "zh" ? "zh" : "en");
|
|
6711
|
+
if (!input) {
|
|
6712
|
+
throw new Error(pick(lang, "没有可描述的更改(工作区干净)", "Nothing to describe (working tree clean)", "scm.commitmsg.no.changes"));
|
|
6713
|
+
}
|
|
6714
|
+
const ac = new AbortController();
|
|
6715
|
+
const timer = setTimeout(() => ac.abort(), SCM_COMMITMSG_TIMEOUT_MS);
|
|
6716
|
+
// 提示词可配置(设置 → 提示词 → AI 提交信息):追加/替换内置默认。
|
|
6717
|
+
const commitSettings = this.settingsSvc.current;
|
|
6718
|
+
const systemPrompt = buildCommitMsgPrompt(commitSettings.scmCommitMsgPromptMode === "replace" ? "replace" : "append", commitSettings.scmCommitMsgPrompt ?? "");
|
|
6719
|
+
let msg;
|
|
6720
|
+
try {
|
|
6721
|
+
msg = await runtime.completeSimple(model, {
|
|
6722
|
+
systemPrompt,
|
|
6723
|
+
messages: [
|
|
6724
|
+
{
|
|
6725
|
+
role: "user",
|
|
6726
|
+
timestamp: Date.now(),
|
|
6727
|
+
content: [{ type: "text", text: input }],
|
|
6728
|
+
},
|
|
6729
|
+
],
|
|
6730
|
+
}, { signal: ac.signal, maxTokens: 400 });
|
|
6731
|
+
}
|
|
6732
|
+
finally {
|
|
6733
|
+
clearTimeout(timer);
|
|
6734
|
+
}
|
|
6735
|
+
if (msg.stopReason === "error" || msg.stopReason === "aborted") {
|
|
6736
|
+
throw new Error(msg.errorMessage ||
|
|
6737
|
+
pick(lang, `模型异常终止(${msg.stopReason})`, `Model terminated abnormally (${msg.stopReason})`, "scm.commitmsg.model.terminated"));
|
|
6738
|
+
}
|
|
6739
|
+
const raw = msg.content
|
|
6740
|
+
.filter((b) => b.type === "text")
|
|
6741
|
+
.map((b) => b.text ?? "")
|
|
6742
|
+
.join("\n");
|
|
6743
|
+
const text = sanitizeCommitMessage(raw);
|
|
6744
|
+
if (!text) {
|
|
6745
|
+
throw new Error(pick(lang, "模型返回了空的提交信息", "The model returned an empty commit message", "scm.commitmsg.empty"));
|
|
6746
|
+
}
|
|
6747
|
+
reply(true, { text });
|
|
6748
|
+
}
|
|
6749
|
+
catch (err) {
|
|
6750
|
+
if (isNotRepoError(err)) {
|
|
6751
|
+
fail(new Error(pick(lang, "当前目录不是 Git 仓库", "Current directory is not a Git repository", "scm.commitmsg.not.repo")));
|
|
6752
|
+
return;
|
|
6753
|
+
}
|
|
6754
|
+
if (err instanceof Error && /abort/i.test(`${err.name} ${err.message}`)) {
|
|
6755
|
+
fail(new Error(pick(lang, `生成提交信息超时(${Math.round(SCM_COMMITMSG_TIMEOUT_MS / 1000)} 秒)`, `Commit-message generation timed out (${Math.round(SCM_COMMITMSG_TIMEOUT_MS / 1000)}s)`, "scm.commitmsg.timeout")));
|
|
6756
|
+
return;
|
|
6757
|
+
}
|
|
6758
|
+
fail(err);
|
|
6759
|
+
}
|
|
6760
|
+
}
|
|
6304
6761
|
/** Read a workspace file for the preview panel (size-capped, binary-safe). */
|
|
6305
6762
|
async readFile(relPath) {
|
|
6306
6763
|
return this.files.readFile(relPath);
|
|
@@ -6469,7 +6926,6 @@ export class ClientSession {
|
|
|
6469
6926
|
// default-opening it would strand the tab on a conversation it
|
|
6470
6927
|
// cannot use (the prompt guard refuses) with a stale leaf that
|
|
6471
6928
|
// forks history once the owner finishes. Land blank instead.
|
|
6472
|
-
let sessionManager = SessionManager.continueRecent(abs);
|
|
6473
6929
|
let resumeSkipped = null;
|
|
6474
6930
|
// 无人在跑时不扫目录(首访切项目的常见情形零开销)。
|
|
6475
6931
|
if (this.hasStreamingElsewhere?.() ?? false) {
|
|
@@ -6478,7 +6934,6 @@ export class ClientSession {
|
|
|
6478
6934
|
const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
|
|
6479
6935
|
const owner = recent ? this.findSessionOwner?.(recent) : null;
|
|
6480
6936
|
if (owner?.isStreaming) {
|
|
6481
|
-
sessionManager = SessionManager.create(abs);
|
|
6482
6937
|
resumeSkipped = owner;
|
|
6483
6938
|
}
|
|
6484
6939
|
}
|
|
@@ -6488,11 +6943,18 @@ export class ClientSession {
|
|
|
6488
6943
|
}
|
|
6489
6944
|
const conversationId = this.nextConversationId();
|
|
6490
6945
|
const terminals = this.makeTerminalManager(conversationId, abs);
|
|
6491
|
-
|
|
6946
|
+
// #235:manager+runtime 一起建,转录链损坏时修最近文件后重试一次
|
|
6947
|
+
// (见 openManagerAndRuntime)。blank(别处在跑)是全新空会话,不会坏。
|
|
6948
|
+
const opened = await this.openManagerAndRuntime(() => (resumeSkipped ? SessionManager.create(abs) : SessionManager.continueRecent(abs)), (m) => createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
|
|
6492
6949
|
cwd: abs,
|
|
6493
6950
|
agentDir: this.agentDir,
|
|
6494
|
-
sessionManager,
|
|
6495
|
-
});
|
|
6951
|
+
sessionManager: m,
|
|
6952
|
+
}), async () => (await SessionManager.list(abs))[0]?.path);
|
|
6953
|
+
const newRuntime = opened.runtime;
|
|
6954
|
+
if (opened.repair) {
|
|
6955
|
+
for (const n of this.transcriptRepairNotices(opened.repair))
|
|
6956
|
+
this.emit(n);
|
|
6957
|
+
}
|
|
6496
6958
|
const conv = this.makeConversation(newRuntime, conversationId, terminals);
|
|
6497
6959
|
this.convs.set(conv.id, conv);
|
|
6498
6960
|
this.activeId = conv.id;
|
|
@@ -6642,7 +7104,9 @@ export class ClientSession {
|
|
|
6642
7104
|
return "";
|
|
6643
7105
|
}
|
|
6644
7106
|
}
|
|
6645
|
-
/** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5").
|
|
7107
|
+
/** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5").
|
|
7108
|
+
* 失败时只发 notice 不抛错(UI 路径靠 notice 提示,见 cycleModel 等调用方)。
|
|
7109
|
+
* 需要「失败即拒绝」的无头路径(插件/定时任务)用 switchModelOrThrow。 */
|
|
6646
7110
|
async setModel(modelId) {
|
|
6647
7111
|
try {
|
|
6648
7112
|
const mr = this.runtime.services.modelRuntime;
|
|
@@ -6661,6 +7125,8 @@ export class ClientSession {
|
|
|
6661
7125
|
// project (not only after a turn). This is what makes project switching
|
|
6662
7126
|
// restore both the model and the provider key.
|
|
6663
7127
|
this.rememberProjectModel(modelId);
|
|
7128
|
+
// 换模型后按新模型的窗口重算软上限覆盖(按模型覆盖可能不同,issue #229)。
|
|
7129
|
+
this.applyCompactionOverrides();
|
|
6664
7130
|
}
|
|
6665
7131
|
catch (err) {
|
|
6666
7132
|
this.emit({
|
|
@@ -6672,6 +7138,25 @@ export class ClientSession {
|
|
|
6672
7138
|
}
|
|
6673
7139
|
this.flushSnapshot();
|
|
6674
7140
|
}
|
|
7141
|
+
/** 切换模型,失败时抛出(无头路径专用:插件 host.chat / 定时任务)。
|
|
7142
|
+
* setModel 为兼容 UI 把异常吞成 notice(面板要能看到原因、调用方是 fire-and-forget),
|
|
7143
|
+
* 无头路径拿不到那个 notice,于是「模型 ID 打错/没配密钥」会变成静默按旧模型跑 ——
|
|
7144
|
+
* 账单与效果都和用户预期不符。这里统一改成响亮失败。 */
|
|
7145
|
+
async switchModelOrThrow(modelId) {
|
|
7146
|
+
await this.setModel(modelId);
|
|
7147
|
+
// 复核结果:读不到(无活跃对话)不阻断,读得到且不符才拒绝。
|
|
7148
|
+
// 注意 session 是 getter,无活跃对话时会抛,不能用 `?.` 兜底。
|
|
7149
|
+
let curId = "";
|
|
7150
|
+
try {
|
|
7151
|
+
const cur = this.session?.model;
|
|
7152
|
+
curId = cur ? `${cur.provider}/${cur.id}` : "";
|
|
7153
|
+
}
|
|
7154
|
+
catch {
|
|
7155
|
+
curId = "";
|
|
7156
|
+
}
|
|
7157
|
+
if (curId && curId !== modelId)
|
|
7158
|
+
throw new Error(`切换模型失败(${modelId}),当前仍是 ${curId} —— 请检查模型 ID 与供应商密钥`);
|
|
7159
|
+
}
|
|
6675
7160
|
/** Set the thinking level for future turns. */
|
|
6676
7161
|
setThinking(level) {
|
|
6677
7162
|
try {
|
|
@@ -6762,6 +7247,42 @@ export class ClientSession {
|
|
|
6762
7247
|
}
|
|
6763
7248
|
}
|
|
6764
7249
|
}
|
|
7250
|
+
/** issue #226:插件无头调用的工作目录校验(纯函数,可单测)。
|
|
7251
|
+
* 存在性语义与定时任务一致(须存在且为目录,不默默跑错目录);另在
|
|
7252
|
+
* Windows 下拒绝 SystemRoot 及其子树(如 C:\Windows\System32)——后台服务/
|
|
7253
|
+
* 快捷方式启动时宿主 cwd 常飘到 system32,直接跑就是高危误操作。 */
|
|
7254
|
+
export function checkPluginCwd(cwd) {
|
|
7255
|
+
const trimmed = String(cwd ?? "").trim();
|
|
7256
|
+
if (!trimmed)
|
|
7257
|
+
return { ok: false, error: "工作目录为空" };
|
|
7258
|
+
let abs;
|
|
7259
|
+
try {
|
|
7260
|
+
abs =
|
|
7261
|
+
process.platform === "win32" && /^[A-Za-z]:$/.test(trimmed) ? `${trimmed.toUpperCase()}${sep}` : resolve(trimmed);
|
|
7262
|
+
}
|
|
7263
|
+
catch {
|
|
7264
|
+
return { ok: false, error: `工作目录非法:${trimmed}` };
|
|
7265
|
+
}
|
|
7266
|
+
try {
|
|
7267
|
+
if (!statSync(abs).isDirectory())
|
|
7268
|
+
throw new Error("not-a-dir");
|
|
7269
|
+
}
|
|
7270
|
+
catch {
|
|
7271
|
+
return { ok: false, error: `目标项目不存在或不是目录:${trimmed}` };
|
|
7272
|
+
}
|
|
7273
|
+
if (process.platform === "win32") {
|
|
7274
|
+
const sysRoot = (process.env.SystemRoot || process.env.windir || "C:\\Windows")
|
|
7275
|
+
.replace(/\//g, "\\")
|
|
7276
|
+
.replace(/\\+$/, "");
|
|
7277
|
+
const norm = abs.replace(/\//g, "\\").replace(/\\+$/, "");
|
|
7278
|
+
const low = norm.toLowerCase();
|
|
7279
|
+
const rootLow = sysRoot.toLowerCase();
|
|
7280
|
+
if (low === rootLow || low.startsWith(`${rootLow}\\`)) {
|
|
7281
|
+
return { ok: false, error: `拒绝在系统目录执行:${abs}(请在插件设置里指定项目工作目录)` };
|
|
7282
|
+
}
|
|
7283
|
+
}
|
|
7284
|
+
return { ok: true, abs };
|
|
7285
|
+
}
|
|
6765
7286
|
export class AgentService {
|
|
6766
7287
|
cwd;
|
|
6767
7288
|
/** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
|
|
@@ -6875,30 +7396,165 @@ export class AgentService {
|
|
|
6875
7396
|
}
|
|
6876
7397
|
/** issue #193:定时任务唤醒发起对话。逐个客户端找持有方,用 steer 语义投递
|
|
6877
7398
|
* (运行时插队、未跑时普通投递,不切用户当前对话);都找不到回 ok:false,
|
|
6878
|
-
* 调用方(index.ts executor
|
|
6879
|
-
|
|
6880
|
-
|
|
7399
|
+
* 调用方(index.ts executor)回落视口/无头执行。quiesced 时直接拒绝。
|
|
7400
|
+
* issue #226:成功时带回持有方 clientId(插件绑定网页会话时原样回执)。
|
|
7401
|
+
* issue #231:opts.sessionFile 是跨压缩/重启的稳定键 —— 优先按它认同一会话
|
|
7402
|
+
* (内存对话 id 重启即失效,压缩后同文件对话可能已换新 id,成功时带回**实际**
|
|
7403
|
+
* 投递的 conversationId + sessionFile,调用方据此重绑定任务);id 相位带 cwd
|
|
7404
|
+
* 护栏(各客户端计数器都从 c1 开始,不校验会把报告投进无关项目)。压缩进行中
|
|
7405
|
+
* 的持有方回 busy:true(调用方另寻视口兄弟,而不是当成“不在”静默转无头)。 */
|
|
7406
|
+
async wakeConversation(id, text, opts) {
|
|
7407
|
+
const wantFile = String(opts?.sessionFile ?? "").trim();
|
|
7408
|
+
const wantCwd = String(opts?.cwd ?? "").trim();
|
|
7409
|
+
if ((!id && !wantFile) || !text.trim())
|
|
6881
7410
|
return { ok: false, error: "唤醒目标或文本为空" };
|
|
6882
7411
|
if (this.quiesced)
|
|
6883
7412
|
return { ok: false, error: "服务器正忙(quiesced),请稍后重试" };
|
|
6884
|
-
|
|
7413
|
+
const liveFile = (c) => {
|
|
6885
7414
|
try {
|
|
6886
|
-
|
|
6887
|
-
|
|
7415
|
+
return String(c.session.sessionFile ?? "");
|
|
7416
|
+
}
|
|
7417
|
+
catch {
|
|
7418
|
+
return "";
|
|
7419
|
+
}
|
|
7420
|
+
};
|
|
7421
|
+
const flush = (cs) => {
|
|
7422
|
+
try {
|
|
7423
|
+
cs.flushSnapshot();
|
|
7424
|
+
}
|
|
7425
|
+
catch {
|
|
7426
|
+
// 推送失败不影响已投递的唤醒
|
|
7427
|
+
}
|
|
7428
|
+
};
|
|
7429
|
+
// 相位一:落盘会话文件(稳定键)。同文件可能在多处打开,取最近活跃者;
|
|
7430
|
+
// 全部忙(压缩中)则报 busy,调用方去找视口兄弟。
|
|
7431
|
+
if (wantFile) {
|
|
7432
|
+
const hits = [];
|
|
7433
|
+
for (const [clientId, cs] of this.clients) {
|
|
7434
|
+
try {
|
|
7435
|
+
const conv = cs.resolveSchedulerTarget({ sessionFile: wantFile });
|
|
7436
|
+
if (conv)
|
|
7437
|
+
hits.push({ cs, clientId, conv });
|
|
7438
|
+
}
|
|
7439
|
+
catch {
|
|
7440
|
+
// 单客户端坏了继续找下一个
|
|
7441
|
+
}
|
|
7442
|
+
}
|
|
7443
|
+
hits.sort((a, b) => b.conv.lastActiveAt - a.conv.lastActiveAt);
|
|
7444
|
+
let busyError;
|
|
7445
|
+
for (const h of hits) {
|
|
7446
|
+
try {
|
|
7447
|
+
const r = await h.cs.trySteerScheduler(h.conv, text);
|
|
7448
|
+
if (r.ok) {
|
|
7449
|
+
flush(h.cs);
|
|
7450
|
+
return { ok: true, conversationId: h.conv.id, sessionFile: liveFile(h.conv), clientId: h.clientId };
|
|
7451
|
+
}
|
|
7452
|
+
if (r.busy)
|
|
7453
|
+
busyError = r.error;
|
|
7454
|
+
// 非忙失败(投递异常)试下一个同文件持有方
|
|
7455
|
+
}
|
|
7456
|
+
catch {
|
|
7457
|
+
// 单客户端坏了继续找下一个
|
|
7458
|
+
}
|
|
7459
|
+
}
|
|
7460
|
+
if (hits.length > 0) {
|
|
7461
|
+
if (busyError)
|
|
7462
|
+
return { ok: false, busy: true, error: busyError };
|
|
7463
|
+
// 同文件持有方都在但都投递失败 —— id 相位大概率指向同一批,无需再试
|
|
7464
|
+
return { ok: false, error: "目标对话投递失败(持有方异常)" };
|
|
7465
|
+
}
|
|
7466
|
+
// 无同文件持有方 —— 老任务只有 id,继续相位二
|
|
7467
|
+
}
|
|
7468
|
+
// 相位二:内存对话 id(易失键,必须配 cwd 护栏防跨项目串台)。
|
|
7469
|
+
if (id) {
|
|
7470
|
+
const hits = [];
|
|
7471
|
+
for (const [clientId, cs] of this.clients) {
|
|
7472
|
+
try {
|
|
7473
|
+
const conv = cs.resolveSchedulerTarget({
|
|
7474
|
+
conversationId: id,
|
|
7475
|
+
...(wantCwd ? { cwd: wantCwd } : {}),
|
|
7476
|
+
});
|
|
7477
|
+
if (conv)
|
|
7478
|
+
hits.push({ cs, clientId, conv });
|
|
7479
|
+
}
|
|
7480
|
+
catch {
|
|
7481
|
+
// 单客户端坏了继续找下一个
|
|
7482
|
+
}
|
|
7483
|
+
}
|
|
7484
|
+
hits.sort((a, b) => b.conv.lastActiveAt - a.conv.lastActiveAt);
|
|
7485
|
+
let busyError;
|
|
7486
|
+
for (const h of hits) {
|
|
7487
|
+
try {
|
|
7488
|
+
const r = await h.cs.trySteerScheduler(h.conv, text);
|
|
7489
|
+
if (r.ok) {
|
|
7490
|
+
flush(h.cs);
|
|
7491
|
+
return { ok: true, conversationId: h.conv.id, sessionFile: liveFile(h.conv), clientId: h.clientId };
|
|
7492
|
+
}
|
|
7493
|
+
if (r.busy)
|
|
7494
|
+
busyError = r.error;
|
|
7495
|
+
}
|
|
7496
|
+
catch {
|
|
7497
|
+
// 单客户端坏了继续找下一个
|
|
7498
|
+
}
|
|
7499
|
+
}
|
|
7500
|
+
if (busyError)
|
|
7501
|
+
return { ok: false, busy: true, error: busyError };
|
|
7502
|
+
}
|
|
7503
|
+
return { ok: false, error: "目标对话不在运行中(已关闭或服务重启过)" };
|
|
7504
|
+
}
|
|
7505
|
+
/** issue #231:同项目视口回退 —— 原绑定对话不在时,把唤醒投给该项目最近活跃
|
|
7506
|
+
* 的对话(用户当前正看着的面),而不是静默转无头。excludeIds 跳过已知忙对话;
|
|
7507
|
+
* 候选全部忙回 busy:true;无候选回 ok:false。成功带回实际投递方(调用方重绑定)。 */
|
|
7508
|
+
async wakeViewportInCwd(cwd, text, excludeIds) {
|
|
7509
|
+
const want = String(cwd ?? "").trim();
|
|
7510
|
+
if (!want || !text.trim())
|
|
7511
|
+
return { ok: false, error: "回退目标或文本为空" };
|
|
7512
|
+
if (this.quiesced)
|
|
7513
|
+
return { ok: false, error: "服务器正忙(quiesced),请稍后重试" };
|
|
7514
|
+
const cands = [];
|
|
7515
|
+
for (const [clientId, cs] of this.clients) {
|
|
7516
|
+
try {
|
|
7517
|
+
const conv = cs.findViewportInCwd(want, excludeIds);
|
|
7518
|
+
if (conv)
|
|
7519
|
+
cands.push({ cs, clientId, conv });
|
|
7520
|
+
}
|
|
7521
|
+
catch {
|
|
7522
|
+
// 单客户端坏了继续找下一个
|
|
7523
|
+
}
|
|
7524
|
+
}
|
|
7525
|
+
cands.sort((a, b) => b.conv.lastActiveAt - a.conv.lastActiveAt);
|
|
7526
|
+
if (cands.length === 0)
|
|
7527
|
+
return { ok: false, error: "同项目无存活对话" };
|
|
7528
|
+
let busyError;
|
|
7529
|
+
for (const c of cands) {
|
|
7530
|
+
try {
|
|
7531
|
+
const r = await c.cs.trySteerScheduler(c.conv, text);
|
|
7532
|
+
if (r.ok) {
|
|
6888
7533
|
try {
|
|
6889
|
-
cs.flushSnapshot();
|
|
7534
|
+
c.cs.flushSnapshot();
|
|
6890
7535
|
}
|
|
6891
7536
|
catch {
|
|
6892
7537
|
// 推送失败不影响已投递的唤醒
|
|
6893
7538
|
}
|
|
6894
|
-
|
|
7539
|
+
let f = "";
|
|
7540
|
+
try {
|
|
7541
|
+
f = String(c.conv.session.sessionFile ?? "");
|
|
7542
|
+
}
|
|
7543
|
+
catch {
|
|
7544
|
+
f = "";
|
|
7545
|
+
}
|
|
7546
|
+
return { ok: true, conversationId: c.conv.id, sessionFile: f, clientId: c.clientId };
|
|
6895
7547
|
}
|
|
7548
|
+
if (r.busy)
|
|
7549
|
+
busyError = r.error;
|
|
6896
7550
|
}
|
|
6897
7551
|
catch {
|
|
6898
7552
|
// 单客户端坏了继续找下一个
|
|
6899
7553
|
}
|
|
6900
7554
|
}
|
|
6901
|
-
|
|
7555
|
+
if (busyError)
|
|
7556
|
+
return { ok: false, busy: true, error: busyError };
|
|
7557
|
+
return { ok: false, error: "同项目对话投递失败" };
|
|
6902
7558
|
}
|
|
6903
7559
|
/** issue #145:别处在某 cwd 下正在跑的对话(同项目并行感知用,不含请求方)。 */
|
|
6904
7560
|
listProjectRunners(cwd, excludeClientId) {
|
|
@@ -6985,7 +7641,11 @@ export class AgentService {
|
|
|
6985
7641
|
* 快照/notice 发了即丢,不攒内存。fire-and-forget:prompt 投递即返回,
|
|
6986
7642
|
* 运行结果经 onRunEvent(run_end) 按 conversationId 关联。
|
|
6987
7643
|
* v1 语义:与该服务 cwd 下最近会话共享(单用户视角连续);peer 名由插件
|
|
6988
|
-
* 拼进文本前缀,per-peer 会话隔离以后再加。
|
|
7644
|
+
* 拼进文本前缀,per-peer 会话隔离以后再加。
|
|
7645
|
+
* issue #226:对齐定时任务的四件套——conversationId 命中时走 steer 语义
|
|
7646
|
+
* 投递(网页端实时可见,miss 则回落无头);cwd 显式 pin 住(不存在/系统
|
|
7647
|
+
* 目录即拒绝,不默默跑错目录);model/thinkingLevel 投递前应用(失败即
|
|
7648
|
+
* 拒绝,不回落,避免账单/效果与预期不符)。 */
|
|
6989
7649
|
async chatFromPlugin(pluginId, req) {
|
|
6990
7650
|
const safe = String(pluginId ?? "plugin").replace(/[^A-Za-z0-9_-]/g, "") || "plugin";
|
|
6991
7651
|
const acct = String(req?.accountId ?? "default").replace(/[^A-Za-z0-9_-]/g, "") || "default";
|
|
@@ -6995,10 +7655,52 @@ export class AgentService {
|
|
|
6995
7655
|
throw new Error("chatFromPlugin: text 为空");
|
|
6996
7656
|
if (this.quiesced)
|
|
6997
7657
|
throw new QuiesceRejectedError("插件无头调用被拒绝,请等服务器恢复后重试");
|
|
7658
|
+
// 1. 绑定已有会话:steer 语义投递,网页端实时可见(微信当远程遥控器用)。
|
|
7659
|
+
// miss/已回收时不抛错,回落无头伪客户端(浏览器关着时微信照常可用)。
|
|
7660
|
+
const target = String(req?.conversationId ?? "").trim();
|
|
7661
|
+
if (target) {
|
|
7662
|
+
const w = await this.wakeConversation(target, text);
|
|
7663
|
+
if (w.ok)
|
|
7664
|
+
return { conversationId: target, clientId: w.clientId ?? clientId };
|
|
7665
|
+
}
|
|
7666
|
+
// 2. 工作空间:不传回落伪客户端当前目录;传了必须存在且非系统目录。
|
|
7667
|
+
const cwdReq = String(req?.cwd ?? "").trim();
|
|
7668
|
+
let cwdAbs = "";
|
|
7669
|
+
if (cwdReq) {
|
|
7670
|
+
const chk = checkPluginCwd(cwdReq);
|
|
7671
|
+
if (!chk.ok)
|
|
7672
|
+
throw new Error(`chatFromPlugin: ${chk.error}`);
|
|
7673
|
+
cwdAbs = chk.abs ?? "";
|
|
7674
|
+
}
|
|
6998
7675
|
const cs = await this.attach(clientId, () => { });
|
|
6999
|
-
|
|
7676
|
+
try {
|
|
7677
|
+
if (cwdAbs && cs.cwd !== cwdAbs)
|
|
7678
|
+
await cs.setCwd(cwdAbs);
|
|
7679
|
+
}
|
|
7680
|
+
catch (err) {
|
|
7681
|
+
throw new Error(`chatFromPlugin: 切换工作目录失败(${cwdAbs}):${err.message}`);
|
|
7682
|
+
}
|
|
7683
|
+
const model = String(req?.model ?? "").trim();
|
|
7684
|
+
if (model) {
|
|
7685
|
+
try {
|
|
7686
|
+
await cs.switchModelOrThrow(model);
|
|
7687
|
+
}
|
|
7688
|
+
catch (err) {
|
|
7689
|
+
throw new Error(`chatFromPlugin: 切换模型失败(${model}):${err.message}`);
|
|
7690
|
+
}
|
|
7691
|
+
}
|
|
7692
|
+
const thinking = String(req?.thinkingLevel ?? "").trim();
|
|
7693
|
+
if (thinking) {
|
|
7694
|
+
try {
|
|
7695
|
+
cs.setThinking(thinking);
|
|
7696
|
+
}
|
|
7697
|
+
catch (err) {
|
|
7698
|
+
throw new Error(`chatFromPlugin: 切换思考强度失败(${thinking}):${err.message}`);
|
|
7699
|
+
}
|
|
7700
|
+
}
|
|
7701
|
+
const conversationId = cs.readConversationForPlugins()?.conversationId ?? "";
|
|
7000
7702
|
void cs.prompt(text);
|
|
7001
|
-
return { conversationId
|
|
7703
|
+
return { conversationId, clientId };
|
|
7002
7704
|
}
|
|
7003
7705
|
/** 内置定时任务的无头执行(issue #184,server/scheduler-tasks.ts 的 executor)。
|
|
7004
7706
|
* 每个任务独立伪客户端 `scheduler:<taskId>`(专属会话连续、无浏览器也能跑);
|
|
@@ -7030,7 +7732,7 @@ export class AgentService {
|
|
|
7030
7732
|
const model = String(task.model ?? "").trim();
|
|
7031
7733
|
if (model) {
|
|
7032
7734
|
try {
|
|
7033
|
-
await cs.
|
|
7735
|
+
await cs.switchModelOrThrow(model);
|
|
7034
7736
|
}
|
|
7035
7737
|
catch (err) {
|
|
7036
7738
|
return { ok: false, error: `切换模型失败(${model}):${err.message}` };
|