pi-web-ui 0.91.0 → 0.93.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 +90 -1
- package/README.md +69 -14
- package/README.zh-CN.md +36 -10
- package/bin/pi-web-ui.mjs +39 -8
- package/dist/server/agent-service.js +938 -163
- package/dist/server/auth-cookie.js +26 -0
- package/dist/server/claim-files-tool.js +115 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +114 -6
- package/dist/server/conversation-read-tool.js +371 -28
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/delegate-task.js +10 -3
- package/dist/server/dsh/dsh-agent-service.js +50 -1
- package/dist/server/files-service.js +78 -26
- package/dist/server/http-proxy.js +72 -0
- package/dist/server/index.js +94 -11
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/patch-node-pty.js +11 -0
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +66 -10
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +125 -0
- package/dist/server/resolve-global-sdk.js +72 -0
- package/dist/server/sdk-origin.js +64 -0
- package/dist/server/serialize.js +80 -22
- package/dist/server/settings-service.js +19 -1
- package/dist/server/skill-tool.js +2 -1
- package/dist/server/subagents.js +30 -13
- package/dist/server/terminals.js +38 -18
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +32 -2
- package/dist/server/tool-info.js +89 -0
- package/dist/server/tool-manager.js +110 -11
- package/package.json +2 -2
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +20 -0
- package/themes/ayu-light.css +136 -0
- package/themes/catppuccin-latte.css +140 -0
- package/themes/catppuccin.css +144 -0
- package/themes/codex.css +136 -0
- package/themes/cyberpunk.css +4 -0
- package/themes/dazzle.css +4 -0
- package/themes/everforest-light.css +136 -0
- package/themes/geist.css +136 -0
- package/themes/gruvbox-light.css +136 -0
- package/themes/kanagawa-lotus.css +136 -0
- package/themes/md-preview.css +4 -0
- package/themes/mist.css +4 -0
- package/themes/nord.css +134 -0
- package/themes/one-dark.css +135 -0
- package/themes/paper.css +16 -12
- package/themes/rose-pine-dawn.css +136 -0
- package/themes/sakura.css +4 -0
- package/themes/solarized-light.css +135 -0
- package/themes/tokyo-night.css +144 -0
- package/themes/white.css +4 -0
- package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-C1A23aI-.js} +1 -1
- package/web/dist/assets/index-B48QWf0I.js +364 -0
- package/web/dist/assets/index-DGL5OInG.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CdsKltOS.css +0 -1
- package/web/dist/assets/index-OoULNF70.js +0 -363
|
@@ -35,19 +35,28 @@ import { MarkerService } from "./marker-service.js";
|
|
|
35
35
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
36
36
|
import { ModelAdminService } from "./model-admin.js";
|
|
37
37
|
import { FilesService, MACHINE_ROOT, desktopDirWire, workspacePath } from "./files-service.js";
|
|
38
|
-
import { isExtensionDisabled, isExtensionEnabled, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
|
|
38
|
+
import { DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS, effectiveToolWatchdogMs, isExtensionDisabled, isExtensionEnabled, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
|
|
39
39
|
import { bilingual, pick, resolveServerLang } from "./i18n.js";
|
|
40
40
|
import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
|
|
41
41
|
import { ComposerDraftsStore } from "./composer-drafts.js";
|
|
42
42
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
|
|
43
|
-
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, } from "./tool-manager.js";
|
|
43
|
+
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isAgentToolEnabled, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, PRESENT_FILES_TOOL_NAME, } from "./tool-manager.js";
|
|
44
44
|
import { WebUIContext } from "./webui-context.js";
|
|
45
45
|
import { DEFAULT_COMPACTION_RESERVE_TOKENS, effectiveSoftCap, softCapToReserve } from "./soft-cap.js";
|
|
46
46
|
import { decodeText } from "./text-sniff.js";
|
|
47
47
|
import { makeEditSoftTool } from "./edit-soft-tool.js";
|
|
48
|
+
// 覆盖 SDK 内置 read:路径是目录时列出目录条目(行为开关 readDirEnabled,默认开)。
|
|
49
|
+
import { makeReadDirTool } from "./read-tool.js";
|
|
50
|
+
// 展示文件给用户(present_files):图片/视频内联、文本开预览弹窗、本地打开按钮。
|
|
51
|
+
import { makePresentFilesTool } from "./present-files-tool.js";
|
|
52
|
+
// 工具定义说明的归一化(工具卡右键 → 「显示工具详细信息」,见 getToolInfo)。
|
|
53
|
+
import { normalizeToolInfo } from "./tool-info.js";
|
|
48
54
|
import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
49
55
|
import { makeDelegateTaskTool } from "./delegate-task.js";
|
|
50
56
|
import { makeConversationReadTool, parseTranscriptLines, toTranscriptInput, } from "./conversation-read-tool.js";
|
|
57
|
+
import { extractTouches, formatTouchesCompact, intersectTouches } from "./conversation-touches.js";
|
|
58
|
+
import { ClaimStore, matchClaims, mergeTouchSidecar, readTouchSidecar, removeTouchSidecar } from "./claim-store.js";
|
|
59
|
+
import { makeClaimFilesTool } from "./claim-files-tool.js";
|
|
51
60
|
import { makeSkillTool } from "./skill-tool.js";
|
|
52
61
|
import { makeScheduleTools } from "./schedule-agent-tool.js";
|
|
53
62
|
import { sameSessionFile } from "./scheduler-tasks.js";
|
|
@@ -73,6 +82,13 @@ const DESKTOP_WIRE = desktopDirWire(HOME_WIRE);
|
|
|
73
82
|
const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
|
|
74
83
|
/** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
|
|
75
84
|
const DELTA_ACTIVE_WINDOW_MS = 1500;
|
|
85
|
+
/**
|
|
86
|
+
* `session.getSessionStats()` 会遍历整份转写,而 `message_delta` 曾经**每一帧**都调它
|
|
87
|
+
* (只为了填 usage)。实测 6000 条转写 × 6002 帧时这一条链占了流式阶段 **27.6%** 的 CPU
|
|
88
|
+
* (2123ms),而一个「最多旧 250ms」的读数对进度条/上下文指示器来说与实时值无法区分。
|
|
89
|
+
* 加这层短缓存后实测流式 CPU 4.859s → 1.328s(3.7×),快照字节数完全不变(issue #259)。
|
|
90
|
+
*/
|
|
91
|
+
const STATS_CACHE_MS = 250;
|
|
76
92
|
const WIDGET_REFRESH_MS = 2000;
|
|
77
93
|
/** SCM「AI 生成提交信息」的单次补全超时——慢供应商不该让按钮转圈到天荒地老。 */
|
|
78
94
|
const SCM_COMMITMSG_TIMEOUT_MS = 60_000;
|
|
@@ -85,9 +101,13 @@ const STALL_NOTIFY_MS = (() => {
|
|
|
85
101
|
const v = Number(process.env.PI_WEB_STALL_NOTIFY_MS);
|
|
86
102
|
return Number.isFinite(v) && v >= 0 ? v : 180_000;
|
|
87
103
|
})();
|
|
88
|
-
/** Serialization-cache cap per conversation (see
|
|
89
|
-
* UiMessage objects are pure-function results, so
|
|
90
|
-
* recompute on
|
|
104
|
+
/** Serialization-cache soft cap per conversation (see serializeCachedFor /
|
|
105
|
+
* pruneMessageCache). Cached UiMessage objects are pure-function results, so a
|
|
106
|
+
* miss only costs a recompute — but a miss on a message that is STILL in the
|
|
107
|
+
* transcript is not free: it hands back a fresh object identity, which fails
|
|
108
|
+
* emitSnapshotNow's identity walk and degrades every checkpoint to a full
|
|
109
|
+
* snapshot (issue #259). Eviction is therefore by "no longer in the
|
|
110
|
+
* transcript", never FIFO; this constant only says when that sweep runs. */
|
|
91
111
|
const UI_MESSAGE_CACHE_CAP = 4096;
|
|
92
112
|
/** Preview panel cap: only the first 512KB of a file is ever read/sent. */
|
|
93
113
|
/** Thrown when the service is quiesced (draining) and the request is NEW work
|
|
@@ -217,7 +237,14 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
|
|
|
217
237
|
"Setting ON → runs in a visible terminal. persist=true keeps that terminal alive ('ai-bash': shell state such as cd/venv/ssh retained across calls, silent commands move to the background and notify when done); persist=false (default in terminal mode) creates a one-shot terminal that exits when the command finishes while its output stays for review.\n" +
|
|
218
238
|
"Run the bare command — do NOT pipe through head/tail/more/less (use the head/tail parameters to trim the returned output instead; piping also hides live progress in the visible terminal). For interactive commands (REPLs, prompts, installers asking y/n) set persist=true (terminal mode) and drive them with terminal_input / terminal_key.",
|
|
219
239
|
promptSnippet: "run shell commands",
|
|
220
|
-
execute: (id, params, signal, onUpdate, ctx) =>
|
|
240
|
+
execute: (id, params, signal, onUpdate, ctx) => {
|
|
241
|
+
const p = params;
|
|
242
|
+
// Windows 下 ConPTY 架构限制(MSYS2 全局控制台上限 128,且高频创建销毁容易句柄耗尽/卡顿):
|
|
243
|
+
// 一次性命令(persist !== true)走原生 spawn(基于 pipe,无需分配控制台,速度快 60 倍且免死锁,issue #269);
|
|
244
|
+
// 只有明确需要持久交互(persist === true)才进可见终端 ai-bash。
|
|
245
|
+
const shouldUseTerminal = useTerminal() && (process.platform !== "win32" || p?.persist === true);
|
|
246
|
+
return (shouldUseTerminal ? terminalBacked : killable).execute(id, params, signal, onUpdate, ctx);
|
|
247
|
+
},
|
|
221
248
|
};
|
|
222
249
|
}
|
|
223
250
|
/**
|
|
@@ -316,6 +343,10 @@ ownerId) {
|
|
|
316
343
|
},
|
|
317
344
|
};
|
|
318
345
|
}
|
|
346
|
+
/** 判定是否应当即时向前端推送问卷弹窗(只推给当前前台会话,未指定会话按全局放行)。 */
|
|
347
|
+
export function shouldPopQuestion(conversationId, activeId) {
|
|
348
|
+
return conversationId === undefined || conversationId === activeId;
|
|
349
|
+
}
|
|
319
350
|
// ---------------------------------------------------------------------------
|
|
320
351
|
// 浏览器页面工具(标准 pi 引擎的 browser_page customTool)
|
|
321
352
|
//
|
|
@@ -643,15 +674,6 @@ function previewToolResult(result) {
|
|
|
643
674
|
return "[unserializable result]";
|
|
644
675
|
}
|
|
645
676
|
}
|
|
646
|
-
/** Hard cap on how long ONE tool call may run before the watchdog aborts the
|
|
647
|
-
* session. The SDK bash tool has NO default timeout, so a command that never
|
|
648
|
-
* finishes (servers, watchers, infinite loops) would otherwise hang the whole
|
|
649
|
-
* conversation indefinitely. Override with the PI_WEB_TOOL_TIMEOUT_MS env var
|
|
650
|
-
* (milliseconds). */
|
|
651
|
-
const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
|
|
652
|
-
const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
|
|
653
|
-
return Number.isFinite(v) && v > 0 ? v : 20 * 60_000;
|
|
654
|
-
})();
|
|
655
677
|
/** Cap on simultaneously open NON-subagent conversations of ONE project (each keeps a full
|
|
656
678
|
* runtime alive; conversations of other projects keep their own lists).
|
|
657
679
|
* 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
|
|
@@ -790,14 +812,26 @@ function collectSessionAnchors(filePath, q, cap = 10) {
|
|
|
790
812
|
export function piSessionsRoot() {
|
|
791
813
|
return process.env.PI_CODING_AGENT_SESSION_DIR || undefined;
|
|
792
814
|
}
|
|
793
|
-
/** Guardrail: only transcripts under
|
|
794
|
-
*
|
|
815
|
+
/** Guardrail: only transcripts under a sessions root may be opened/deleted/renamed
|
|
816
|
+
* — never arbitrary files. Two roots count as “a sessions root”, and they must stay
|
|
817
|
+
* the **same two** the history list reads from (`loadSessionInfos` →
|
|
818
|
+
* `SessionManager.list(cwd, piSessionsRoot())`):
|
|
819
|
+
*
|
|
820
|
+
* 1. `<agentDir>/sessions/`(SDK 默认的每-cwd 子目录布局)
|
|
821
|
+
* 2. `PI_CODING_AGENT_SESSION_DIR`(扁平「额外会话根」,设了就以它为准扫盘)
|
|
822
|
+
*
|
|
823
|
+
* 只认第 1 条会让设了该变量的用户「历史列得出来、却点不开/删不掉/改不了名」
|
|
824
|
+
* (列表与打开两边口径不一致)。守卫的意图是「不许开任意文件」,不是「只许开
|
|
825
|
+
* 默认目录下的文件」,所以放宽到两个根仍然成立。
|
|
795
826
|
* Shared by deleteSession/renameSession/switchSession so the open path cannot
|
|
796
827
|
* escape the confinement the write paths already enforce. */
|
|
797
828
|
export function isInsideSessionsDir(agentDir, targetPath) {
|
|
798
829
|
const abs = resolve(targetPath);
|
|
799
|
-
const
|
|
800
|
-
|
|
830
|
+
const roots = [resolve(agentDir, "sessions")];
|
|
831
|
+
const extra = piSessionsRoot();
|
|
832
|
+
if (extra)
|
|
833
|
+
roots.push(resolve(extra));
|
|
834
|
+
return roots.some((root) => abs.startsWith(root + sep));
|
|
801
835
|
}
|
|
802
836
|
/** 会话当前模型的 "provider/id"(无模型时 null;软上限按模型覆盖用,issue #229)。 */
|
|
803
837
|
function modelKeyOf(session) {
|
|
@@ -1037,13 +1071,22 @@ export class ClientSession {
|
|
|
1037
1071
|
* `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
|
|
1038
1072
|
* 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
|
|
1039
1073
|
* runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
|
|
1040
|
-
async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
|
|
1074
|
+
async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId, persist) {
|
|
1041
1075
|
// 数量上限先行:每个子代理都是完整 runtime + TerminalManager,无上限时一次
|
|
1042
|
-
//
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1076
|
+
// 并行派发几十个会把服务进程拖垮。持久化普通对话则受项目会话上限限制。
|
|
1077
|
+
if (persist) {
|
|
1078
|
+
const baseCwdForLimit = parentId ? (this.convs.get(parentId)?.cwd ?? this.cwd) : this.cwd;
|
|
1079
|
+
const resolvedCwdForLimit = cwd ? resolve(baseCwdForLimit, cwd) : baseCwdForLimit;
|
|
1080
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === resolvedCwdForLimit && !c.isSubagent).length;
|
|
1081
|
+
if (openInProject >= MAX_OPEN_CONVERSATIONS) {
|
|
1082
|
+
throw new Error(pick(this.getLang(), `目标项目运行的普通对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),无法创建持久化对话。请先关闭不需要的对话,或以轻量子代理(persist=false)方式运行`, `The project already has max open regular conversations (${MAX_OPEN_CONVERSATIONS}). Please close some or run as a lightweight subagent (persist=false)`, "agent.conv.limit.reached", { limit: MAX_OPEN_CONVERSATIONS }));
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
const liveSubagents = [...this.convs.values()].filter((c) => c.isSubagent).length;
|
|
1087
|
+
if (liveSubagents >= MAX_SUBAGENTS) {
|
|
1088
|
+
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 }));
|
|
1089
|
+
}
|
|
1047
1090
|
}
|
|
1048
1091
|
// 真正的派发者(withSubagentOwner 按 runtime 归属填入):cwd 基准 / 跟随模型 /
|
|
1049
1092
|
// 跟随思考强度一律读它,而不是派发瞬间的 active——后台对话产出时用户可能正
|
|
@@ -1053,15 +1096,16 @@ export class ClientSession {
|
|
|
1053
1096
|
const baseCwd = spawner?.cwd ?? this.cwd;
|
|
1054
1097
|
// 相对 cwd 按派发者所在目录解析:直接透传会相对 server 进程 cwd 落到别处。
|
|
1055
1098
|
const resolvedCwd = cwd ? resolve(baseCwd, cwd) : baseCwd;
|
|
1056
|
-
const conversationId = `sa-${randomUUID().slice(0, 8)}`;
|
|
1099
|
+
const conversationId = persist ? `conv-${randomUUID().slice(0, 8)}` : `sa-${randomUUID().slice(0, 8)}`;
|
|
1057
1100
|
const terminals = this.makeTerminalManager(conversationId, resolvedCwd);
|
|
1101
|
+
const sessionManager = persist ? SessionManager.create(resolvedCwd) : SessionManager.inMemory(resolvedCwd);
|
|
1058
1102
|
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
|
|
1059
1103
|
cwd: resolvedCwd,
|
|
1060
1104
|
agentDir: this.agentDir,
|
|
1061
|
-
sessionManager
|
|
1105
|
+
sessionManager,
|
|
1062
1106
|
});
|
|
1063
1107
|
const conv = this.makeConversation(runtime, conversationId, terminals);
|
|
1064
|
-
conv.isSubagent =
|
|
1108
|
+
conv.isSubagent = !persist;
|
|
1065
1109
|
// 父对话 = 真正派发它的会话(按会话归属的 host 包装填入)。直接用 active
|
|
1066
1110
|
// 会错:后台对话运行时用户可能正看着别的项目对话,孩子会被记到无关
|
|
1067
1111
|
// 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
|
|
@@ -1167,18 +1211,28 @@ export class ClientSession {
|
|
|
1167
1211
|
textEn: `Subagent ${conversationId} failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
|
1168
1212
|
});
|
|
1169
1213
|
});
|
|
1214
|
+
if (persist) {
|
|
1215
|
+
this.pushProjects().catch(() => { });
|
|
1216
|
+
}
|
|
1170
1217
|
this.emitConversations();
|
|
1171
1218
|
return conv.id;
|
|
1172
1219
|
}
|
|
1173
1220
|
getSubagentSnapshot(convId) {
|
|
1174
1221
|
const conv = this.convs.get(convId);
|
|
1175
|
-
if (!conv?.
|
|
1222
|
+
if (!conv?.session)
|
|
1176
1223
|
return undefined;
|
|
1177
1224
|
return this.toSubagentSnapshot(conv);
|
|
1178
1225
|
}
|
|
1179
|
-
listSubagentSnapshots() {
|
|
1226
|
+
listSubagentSnapshots(scope) {
|
|
1180
1227
|
return [...this.convs.values()]
|
|
1181
|
-
.filter((c) =>
|
|
1228
|
+
.filter((c) => {
|
|
1229
|
+
const isPersisted = !c.isSubagent;
|
|
1230
|
+
if (scope === "subagent")
|
|
1231
|
+
return c.isSubagent;
|
|
1232
|
+
if (scope === "persistent")
|
|
1233
|
+
return isPersisted;
|
|
1234
|
+
return c.isSubagent || Boolean(c.parentId) || Boolean(c.subagentPrompt);
|
|
1235
|
+
})
|
|
1182
1236
|
.sort((a, b) => a.createdAt - b.createdAt)
|
|
1183
1237
|
.map((c) => this.toSubagentSnapshot(c));
|
|
1184
1238
|
}
|
|
@@ -1196,6 +1250,8 @@ export class ClientSession {
|
|
|
1196
1250
|
// session being replaced — report defaults
|
|
1197
1251
|
}
|
|
1198
1252
|
const fullPrompt = conv.subagentPrompt ?? "";
|
|
1253
|
+
const sm = conv.session.sessionManager;
|
|
1254
|
+
const isPersisted = !conv.isSubagent || (typeof sm?.isPersisted === "function" && sm.isPersisted());
|
|
1199
1255
|
return {
|
|
1200
1256
|
convId: conv.id,
|
|
1201
1257
|
type: conv.subagentType ?? "general",
|
|
@@ -1211,6 +1267,7 @@ export class ClientSession {
|
|
|
1211
1267
|
model: conv.session.model?.id,
|
|
1212
1268
|
output: conv.session.getLastAssistantText() ?? "",
|
|
1213
1269
|
parentId: conv.parentId,
|
|
1270
|
+
persisted: isPersisted,
|
|
1214
1271
|
};
|
|
1215
1272
|
}
|
|
1216
1273
|
/** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
|
|
@@ -1407,17 +1464,17 @@ export class ClientSession {
|
|
|
1407
1464
|
* 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
|
|
1408
1465
|
*/
|
|
1409
1466
|
subagentHost = {
|
|
1410
|
-
spawnSubagent: (prompt, type, cwd, templateName, model, parentId) => {
|
|
1467
|
+
spawnSubagent: (prompt, type, cwd, templateName, model, parentId, persist) => {
|
|
1411
1468
|
// 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
|
|
1412
1469
|
const tpl = templateName ? this.subagentTemplates.get(templateName) : undefined;
|
|
1413
1470
|
if (templateName && (!tpl || !tpl.enabled)) {
|
|
1414
1471
|
throw new Error(pick(this.getLang(), `子代理模板不可用:${templateName}(不存在或已停用)`, `Subagent template unavailable: ${templateName} (missing or disabled)`, "agent.subagent.template.unavailable", { templateName: templateName }));
|
|
1415
1472
|
}
|
|
1416
1473
|
// 模型优先级:显式 model 参数 > 模板自带模型 > 设置面板默认模型;都不给 = 跟随主对话。
|
|
1417
|
-
return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId);
|
|
1474
|
+
return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId, persist);
|
|
1418
1475
|
},
|
|
1419
1476
|
getSubagent: (convId) => this.getSubagentSnapshot(convId),
|
|
1420
|
-
listSubagents: () => this.listSubagentSnapshots(),
|
|
1477
|
+
listSubagents: (scope) => this.listSubagentSnapshots(scope),
|
|
1421
1478
|
steerSubagent: async (convId, message) => {
|
|
1422
1479
|
const conv = this.convs.get(convId);
|
|
1423
1480
|
if (!conv?.session)
|
|
@@ -1430,6 +1487,7 @@ export class ClientSession {
|
|
|
1430
1487
|
await this.interruptRun(conv, pick(this.getLang(), "用户停止子代理", "User stopped the subagent", "agent.subagent.stop.user"));
|
|
1431
1488
|
}
|
|
1432
1489
|
},
|
|
1490
|
+
getWatchdogTimeoutMs: () => this.getBaseToolWatchdogTimeoutMs(),
|
|
1433
1491
|
// issue #91:子代理工具返回按客户端 UI 语言出中英(英文默认)。
|
|
1434
1492
|
lang: () => this.getLang(),
|
|
1435
1493
|
// 只向 AI 暴露 enabled 的模板(停用的对 AI 不可见)。
|
|
@@ -1510,16 +1568,8 @@ export class ClientSession {
|
|
|
1510
1568
|
const c = this.convs.get(id);
|
|
1511
1569
|
if (!c)
|
|
1512
1570
|
return undefined;
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
raw = (c.session.messages ??
|
|
1516
|
-
c.session.agent.state.messages ??
|
|
1517
|
-
[]);
|
|
1518
|
-
}
|
|
1519
|
-
catch {
|
|
1520
|
-
raw = [];
|
|
1521
|
-
}
|
|
1522
|
-
return { title: c.title, cwd: c.cwd, isSubagent: !!c.isSubagent, messages: raw.map(toTranscriptInput) };
|
|
1571
|
+
// 取数与并行提醒的触碰集同一路径(convTranscript),不另起读取逻辑。
|
|
1572
|
+
return { title: c.title, cwd: c.cwd, isSubagent: !!c.isSubagent, messages: this.convTranscript(c) };
|
|
1523
1573
|
},
|
|
1524
1574
|
listHistorySessions: async (scope, cwd) => {
|
|
1525
1575
|
const infos = scope === "all"
|
|
@@ -1534,6 +1584,29 @@ export class ClientSession {
|
|
|
1534
1584
|
cwd: s.cwd,
|
|
1535
1585
|
}));
|
|
1536
1586
|
},
|
|
1587
|
+
readTouchSidecar: (id, path) => {
|
|
1588
|
+
// sidecar 让 files/status 在压缩后仍有答案(additive 可选方法:
|
|
1589
|
+
// 读不到就回 undefined,调用方回落现算转录 —— 绝不抛错阻塞工具)。
|
|
1590
|
+
try {
|
|
1591
|
+
if (id) {
|
|
1592
|
+
const c = this.convs.get(id);
|
|
1593
|
+
let file;
|
|
1594
|
+
try {
|
|
1595
|
+
file = c?.session.sessionFile ?? undefined;
|
|
1596
|
+
}
|
|
1597
|
+
catch {
|
|
1598
|
+
file = undefined;
|
|
1599
|
+
}
|
|
1600
|
+
return readTouchSidecar(file);
|
|
1601
|
+
}
|
|
1602
|
+
if (path)
|
|
1603
|
+
return readTouchSidecar(path);
|
|
1604
|
+
return undefined;
|
|
1605
|
+
}
|
|
1606
|
+
catch {
|
|
1607
|
+
return undefined;
|
|
1608
|
+
}
|
|
1609
|
+
},
|
|
1537
1610
|
readHistorySession: async (path) => {
|
|
1538
1611
|
const all = await SessionManager.listAll(piSessionsRoot());
|
|
1539
1612
|
const hit = all.find((s) => resolve(s.path) === resolve(path));
|
|
@@ -1556,6 +1629,28 @@ export class ClientSession {
|
|
|
1556
1629
|
},
|
|
1557
1630
|
};
|
|
1558
1631
|
}
|
|
1632
|
+
/** claim_files 工具的数据宿主:owner 口径同 subagent/skill(本 runtime 所属会话)。
|
|
1633
|
+
* 常驻注册、不进 AGENT_TOOL_CATALOG(例外:目录工具必须有设置页行,见
|
|
1634
|
+
* settings-tool-rows.test.ts,而 web/src 正被并行任务占用;advisory 工具常驻
|
|
1635
|
+
* 默认开可接受,目录项 + 设置行等 web/src 空出来后补)。 */
|
|
1636
|
+
claimToolHost(ownerId) {
|
|
1637
|
+
const target = () => {
|
|
1638
|
+
try {
|
|
1639
|
+
return (ownerId ?? "").trim() !== "" ? this.convs.get(ownerId.trim()) : this.convs.get(this.activeId);
|
|
1640
|
+
}
|
|
1641
|
+
catch {
|
|
1642
|
+
return undefined;
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
return {
|
|
1646
|
+
cwd: () => target()?.cwd ?? this.cwd,
|
|
1647
|
+
self: () => {
|
|
1648
|
+
const t = target();
|
|
1649
|
+
return { convId: t?.id ?? this.activeId, title: t?.title ?? "" };
|
|
1650
|
+
},
|
|
1651
|
+
store: () => this.getClaimStore?.(),
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1559
1654
|
/** skill 工具的数据宿主:读所属会话 loader 的实时技能表 + 主会话禁用集过滤
|
|
1560
1655
|
* (与 skillsOverride 主会话语义一致)。ownerId 语义同 browser_page(本
|
|
1561
1656
|
* runtime 所属会话,不是派发瞬间的 active)。失败回空目录,不抛错。 */
|
|
@@ -1605,6 +1700,9 @@ export class ClientSession {
|
|
|
1605
1700
|
/** Timestamp of the most recent message_delta push — while fresh, snapshots
|
|
1606
1701
|
* use the slower STREAMING_SNAPSHOT_INTERVAL_MS cadence. */
|
|
1607
1702
|
lastDeltaAt = 0;
|
|
1703
|
+
/** Short-lived `getSessionStats()` memo — see STATS_CACHE_MS. Keyed by the
|
|
1704
|
+
* session instance so a conversation switch never serves the previous one. */
|
|
1705
|
+
sessionStatsCache = null;
|
|
1608
1706
|
sessionsTimer = null;
|
|
1609
1707
|
version = 0;
|
|
1610
1708
|
/** Snapshot revision counter (see emitSnapshotNow / protocol snapshot_delta). */
|
|
@@ -1808,14 +1906,22 @@ export class ClientSession {
|
|
|
1808
1906
|
});
|
|
1809
1907
|
}
|
|
1810
1908
|
}
|
|
1811
|
-
// issue #145
|
|
1909
|
+
// issue #145:因别处仍持有而跳过恢复 —— 首帧即被告之(pendingNotices 随 attachSink 下发)。
|
|
1910
|
+
// 跑着/空闲都拦:直接恢复会造出第二个写者(两边轮流发送分叉历史)。
|
|
1812
1911
|
if (opts?.blank && opts?.blankTitle) {
|
|
1813
|
-
cs.pendingNotices.push(
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1912
|
+
cs.pendingNotices.push(opts.idleHeld
|
|
1913
|
+
? {
|
|
1914
|
+
type: "notice",
|
|
1915
|
+
level: "info",
|
|
1916
|
+
text: `该项目最近的对话「${opts.blankTitle}」在另一处开着(当前空闲),为你停在了空白新对话 —— 可在左栏「运行的对话」里把它过户过来继续看,或从历史对话里打开(只留一处发送消息,否则历史分叉)。`,
|
|
1917
|
+
textEn: `The most recent conversation ("${opts.blankTitle}") is still open in another window (currently idle), so you landed on a blank chat instead — take it over from Running chats (tagged "Elsewhere") or reopen it from History (send new messages from only one place, or the history will fork).`,
|
|
1918
|
+
}
|
|
1919
|
+
: {
|
|
1920
|
+
type: "notice",
|
|
1921
|
+
level: "info",
|
|
1922
|
+
text: `该项目最近的对话「${opts.blankTitle}」正在另一处运行,为你停在了新对话 —— 直接打开会造出第二个写者。左栏「运行的对话」里能看到它(标着“另一处”),等它跑完再打开。`,
|
|
1923
|
+
textEn: `The most recent conversation ("${opts.blankTitle}") is running in another window, so you landed on a new chat instead — opening it here would create a second writer. It is listed under Running chats (tagged "Elsewhere"); open it after it finishes.`,
|
|
1924
|
+
});
|
|
1819
1925
|
}
|
|
1820
1926
|
await cs.bindSession();
|
|
1821
1927
|
await cs.restoreProjectProviderKeysForCwd(cwd);
|
|
@@ -1966,6 +2072,11 @@ export class ClientSession {
|
|
|
1966
2072
|
],
|
|
1967
2073
|
},
|
|
1968
2074
|
});
|
|
2075
|
+
// 桥接工具目标(问卷 / 页面):每次调用都解析「现在谁持有这条对话」,
|
|
2076
|
+
// 而不是认这个 runtime 是在哪个 ClientSession 里建出来的(过户会换主)。
|
|
2077
|
+
// anchor 在拿到 created.session 后回填(SDK 的 runtime.session 就是它)。
|
|
2078
|
+
const bridgeAnchor = {};
|
|
2079
|
+
const bridge = this.bridgeTarget(bridgeAnchor, ownerId);
|
|
1969
2080
|
const created = await createAgentSessionFromServices({
|
|
1970
2081
|
services,
|
|
1971
2082
|
sessionManager,
|
|
@@ -1988,6 +2099,14 @@ export class ClientSession {
|
|
|
1988
2099
|
// 设置关 → 原生 bash;开 → 终端 bash。
|
|
1989
2100
|
() => this.settingsSvc.current.terminalBash),
|
|
1990
2101
|
...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang()),
|
|
2102
|
+
// 覆盖 SDK 内置 read(customTools 按 name 覆盖):路径是目录时列出目录
|
|
2103
|
+
// 条目(复用 SDK ls 的排序/`/` 后缀/截断口径),其余情况原样转发内置实现。
|
|
2104
|
+
// 开关是行为开关(read 本体不可关),每次调用实时读设置——不进
|
|
2105
|
+
// tool-manager 的 ActiveSet 目录。DSH 引擎无 customTool 注册面,不接。
|
|
2106
|
+
makeReadDirTool(effectiveCwd, {
|
|
2107
|
+
dirEnabled: () => this.settingsSvc.current.readDirEnabled !== false,
|
|
2108
|
+
getLang: () => this.getLang(),
|
|
2109
|
+
}),
|
|
1991
2110
|
// 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置)。
|
|
1992
2111
|
makeEditSoftTool(effectiveCwd, () => this.getLang()),
|
|
1993
2112
|
// 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
|
|
@@ -2017,16 +2136,38 @@ export class ClientSession {
|
|
|
2017
2136
|
// 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
|
|
2018
2137
|
// 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
|
|
2019
2138
|
// DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
|
|
2020
|
-
makeAskUserQuestionTool(
|
|
2139
|
+
makeAskUserQuestionTool(bridge, ownerId),
|
|
2021
2140
|
// 浏览器页面工具:模型调用 → page_request 给浏览器 → page-picker 扩展
|
|
2022
2141
|
// 操作用户授权的页面 → page_response 回来。ownerId 语义同上(本 runtime
|
|
2023
2142
|
// 所属会话,不是派发瞬间的 active)。
|
|
2024
|
-
makeBrowserPageTool(
|
|
2143
|
+
makeBrowserPageTool(bridge, ownerId),
|
|
2025
2144
|
// 别的对话读取(运行中含子代理 + 历史转录,只读):用户引用了别的
|
|
2026
2145
|
// 对话(引用 chip / 粘过来的 id / “看看之前那个对话”)时用。子代理
|
|
2027
2146
|
// 会话同样注册了它,可自然嵌套读取。不需要 ownerId——读的是本
|
|
2028
2147
|
// 客户端的 conversation 体系与落盘历史,与派发者无关。
|
|
2029
|
-
|
|
2148
|
+
// extras 认领表:files/status 顺带展示(AgentService 级共享)。
|
|
2149
|
+
makeConversationReadTool(this.conversationReadHost(), () => this.getLang(), {
|
|
2150
|
+
listClaims: (cwd) => (this.getClaimStore?.().list(cwd) ?? []).map((c) => ({
|
|
2151
|
+
path: c.path,
|
|
2152
|
+
ownerTitle: c.ownerTitle,
|
|
2153
|
+
...(c.note ? { note: c.note } : {}),
|
|
2154
|
+
})),
|
|
2155
|
+
}),
|
|
2156
|
+
// 文件认领(claim_files):开关走统一工具 tab(ActiveSet 门控)。
|
|
2157
|
+
// ownerId 语义同 subagent/skill(本 runtime 所属会话)。
|
|
2158
|
+
// DSH 引擎无 customTool 注册面,不接(提醒里照样能看到认领)。
|
|
2159
|
+
makeClaimFilesTool(this.claimToolHost(ownerId), () => this.getLang()),
|
|
2160
|
+
// 展示文件给用户(present_files,issue #231):模型给路径清单,服务端
|
|
2161
|
+
// 只做只读探测(stat + 未知扩展嗅探 + 文本摘录),结构化 items 走 tool
|
|
2162
|
+
// result 的 details 下发,前端渲染成图片/视频内联 + 预览/本地打开/
|
|
2163
|
+
// 在文件夹中显示/下载/复制路径的卡片。不打开任何窗口,系统级动作
|
|
2164
|
+
// 一律由用户点卡片触发(file_open_default / file_reveal)。
|
|
2165
|
+
// 开关走统一工具 tab(ActiveSet 门控;enabled 兜底只做报错文案)。
|
|
2166
|
+
// DSH 引擎无 customTool 注册面,不接。
|
|
2167
|
+
makePresentFilesTool(effectiveCwd, {
|
|
2168
|
+
enabled: () => isAgentToolEnabled(PRESENT_FILES_TOOL_NAME, effectiveDisabledAgentTools(this.settingsSvc.current)),
|
|
2169
|
+
getLang: () => this.getLang(),
|
|
2170
|
+
}),
|
|
2030
2171
|
// 技能全文按名加载(名录在 {{skills}} 段):模型不再拼路径调 read。
|
|
2031
2172
|
// 子代理会话同样注册(owner 即真正派发的父对话,读该会话 loader)。
|
|
2032
2173
|
// DSH 引擎无 customTool 注册面,不接。开关走统一工具 tab。
|
|
@@ -2038,6 +2179,9 @@ export class ClientSession {
|
|
|
2038
2179
|
...makeScheduleTools(this.scheduleToolHost(), ownerId, () => this.getLang()),
|
|
2039
2180
|
],
|
|
2040
2181
|
});
|
|
2182
|
+
// 桥接工具归属锚点:SDK 会话对象在本 runtime 生命周期内稳定,过户只搬对话
|
|
2183
|
+
// 不改它(见 ClientSession.findConversationHome)。
|
|
2184
|
+
bridgeAnchor.session = created.session;
|
|
2041
2185
|
// 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
2042
2186
|
this.applyToolGating(created.session);
|
|
2043
2187
|
return {
|
|
@@ -2203,6 +2347,9 @@ export class ClientSession {
|
|
|
2203
2347
|
// model picker needs it even before the client asks).
|
|
2204
2348
|
this.modelAdmin.listProviderKeys();
|
|
2205
2349
|
this.modelAdmin.listProviderOAuthFlows();
|
|
2350
|
+
// Reconnect: push the global default model (the picker's ★ marker +
|
|
2351
|
+
// "set as global default" button state).
|
|
2352
|
+
this.pushDefaultModel();
|
|
2206
2353
|
// PTYs are conversation-owned and survive a socket reconnect.
|
|
2207
2354
|
this.pushTerminals();
|
|
2208
2355
|
}
|
|
@@ -2286,25 +2433,48 @@ export class ClientSession {
|
|
|
2286
2433
|
}
|
|
2287
2434
|
}, 30_000);
|
|
2288
2435
|
}
|
|
2436
|
+
/** 当前生效的基础工具看门狗超时(毫秒)。0 = 禁用看门狗。
|
|
2437
|
+
* Hard cap on how long ONE tool call may run before the watchdog aborts the
|
|
2438
|
+
* session. The SDK bash tool has NO default timeout, so a command that never
|
|
2439
|
+
* finishes (servers, watchers, infinite loops) would otherwise hang the whole
|
|
2440
|
+
* conversation indefinitely. 优先取设置面板「工具」页的
|
|
2441
|
+
* ClientSettings.toolWatchdogTimeoutMs(逐 run 实时读取,无需 reload);
|
|
2442
|
+
* 未设时回落 PI_WEB_TOOL_TIMEOUT_MS 环境变量,再回落默认 20 分钟。 */
|
|
2443
|
+
getBaseToolWatchdogTimeoutMs() {
|
|
2444
|
+
const fromSettings = this.settingsSvc.current.toolWatchdogTimeoutMs;
|
|
2445
|
+
if (typeof fromSettings === "number" && Number.isFinite(fromSettings) && fromSettings >= 0) {
|
|
2446
|
+
return fromSettings;
|
|
2447
|
+
}
|
|
2448
|
+
return DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS;
|
|
2449
|
+
}
|
|
2289
2450
|
/** Arm the hang-guard for a tool call: if it is still running after
|
|
2290
2451
|
* TOOL_WATCHDOG_TIMEOUT_MS, abort the session instead of letting the
|
|
2291
|
-
* conversation hang forever (the SDK bash tool has no default timeout).
|
|
2292
|
-
|
|
2293
|
-
|
|
2452
|
+
* conversation hang forever (the SDK bash tool has no default timeout).
|
|
2453
|
+
* 若工具调用显式指定了更长超时(如 bash args.timeout),看门狗自动顺延。 */
|
|
2454
|
+
armToolWatchdog(conv, toolCallId, toolName, args) {
|
|
2455
|
+
const timeoutMs = effectiveToolWatchdogMs(this.getBaseToolWatchdogTimeoutMs(), toolName, args);
|
|
2456
|
+
// 0 = 用户显式禁用看门狗(设置面板填 0)。
|
|
2457
|
+
if (timeoutMs <= 0)
|
|
2458
|
+
return;
|
|
2459
|
+
this.rearmToolWatchdog(conv, toolCallId, timeoutMs, timeoutMs);
|
|
2294
2460
|
}
|
|
2295
2461
|
/** (重)布工具挂死看门狗:delayMs 后仍在跑则 abort 整轮。过户时用剩余时间重布
|
|
2296
2462
|
* (已逾期的立即触发,语义不变). */
|
|
2297
|
-
rearmToolWatchdog(conv, toolCallId, delayMs) {
|
|
2463
|
+
rearmToolWatchdog(conv, toolCallId, delayMs, totalTimeoutMs) {
|
|
2464
|
+
const totalMs = totalTimeoutMs ?? delayMs;
|
|
2298
2465
|
const t = setTimeout(() => {
|
|
2299
2466
|
conv.toolWatchdogs.delete(toolCallId);
|
|
2300
2467
|
// The tool finished before the deadline — nothing to do.
|
|
2301
2468
|
if (!conv.toolStartTimes.has(toolCallId))
|
|
2302
2469
|
return;
|
|
2470
|
+
const durationMin = Math.round(totalMs / 60_000);
|
|
2471
|
+
const durationDescZh = durationMin >= 1 ? `${durationMin} 分钟` : `${Math.round(totalMs / 1000)} 秒`;
|
|
2472
|
+
const durationDescEn = durationMin >= 1 ? `${durationMin} min` : `${Math.round(totalMs / 1000)}s`;
|
|
2303
2473
|
this.emit({
|
|
2304
2474
|
type: "notice",
|
|
2305
2475
|
level: "warning",
|
|
2306
|
-
text: `工具执行超过 ${
|
|
2307
|
-
textEn: `Tool ran over ${
|
|
2476
|
+
text: `工具执行超过 ${durationDescZh},已自动终止(防止挂死)。可调整超时:设置面板「工具」或环境变量 PI_WEB_TOOL_TIMEOUT_MS。`,
|
|
2477
|
+
textEn: `Tool ran over ${durationDescEn} and was auto-terminated (hang guard). Tune via Settings -> Tools or PI_WEB_TOOL_TIMEOUT_MS env var.`,
|
|
2308
2478
|
});
|
|
2309
2479
|
conv.toolStartTimes.delete(toolCallId);
|
|
2310
2480
|
// Abort the run (kills the process tree via the SDK's abort signal);
|
|
@@ -2385,9 +2555,12 @@ export class ClientSession {
|
|
|
2385
2555
|
catch {
|
|
2386
2556
|
/* 尽力而为 */
|
|
2387
2557
|
}
|
|
2558
|
+
const curModel = target.session.model;
|
|
2559
|
+
const modelId = curModel ? `${curModel.provider}/${curModel.id}` : undefined;
|
|
2388
2560
|
return {
|
|
2389
2561
|
conversationId: target.id,
|
|
2390
2562
|
title: target.title,
|
|
2563
|
+
model: modelId,
|
|
2391
2564
|
at: target.lastActiveAt,
|
|
2392
2565
|
isStreaming: target.session.isStreaming,
|
|
2393
2566
|
messages: this.messagesOf(target),
|
|
@@ -2700,7 +2873,7 @@ export class ClientSession {
|
|
|
2700
2873
|
// (默认 20 分钟会把还在思考的用户连对话一起剁掉)。它的收场自有路子:
|
|
2701
2874
|
// 用户回答/取消、会话 dispose(cancelPendingQuestions),不限时。
|
|
2702
2875
|
if (event.toolName !== ASK_USER_QUESTION_TOOL_NAME) {
|
|
2703
|
-
this.armToolWatchdog(conv, event.toolCallId);
|
|
2876
|
+
this.armToolWatchdog(conv, event.toolCallId, event.toolName, event.args);
|
|
2704
2877
|
}
|
|
2705
2878
|
// 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
|
|
2706
2879
|
this.onToolEvent?.({
|
|
@@ -2815,7 +2988,9 @@ export class ClientSession {
|
|
|
2815
2988
|
conv.compactionState = { reason: event.reason, startedAt: Date.now() };
|
|
2816
2989
|
conv.lastCompactionTokens = null;
|
|
2817
2990
|
this.markCompactionPending(conv);
|
|
2818
|
-
|
|
2991
|
+
// 进度条只有激活对话看得到(后台对话的快照没有接收方,见 onEvent 末尾)。
|
|
2992
|
+
if (conv.id === this.conv.id)
|
|
2993
|
+
this.flushSnapshot();
|
|
2819
2994
|
break;
|
|
2820
2995
|
}
|
|
2821
2996
|
case "compaction_end": {
|
|
@@ -2962,6 +3137,31 @@ export class ClientSession {
|
|
|
2962
3137
|
this.settingsSvc.consumePendingReload();
|
|
2963
3138
|
void this.applySettingsReload();
|
|
2964
3139
|
}
|
|
3140
|
+
// 触碰 sidecar 落盘:压缩后旧消息被摘要替代,files 回落现算会丢历史;
|
|
3141
|
+
// 条数没涨就不写(不在热路径);子代理 inMemory 无转录文件,merge 内 no-op。
|
|
3142
|
+
try {
|
|
3143
|
+
let count = conv.touchSidecarCount ?? -1;
|
|
3144
|
+
try {
|
|
3145
|
+
count = conv.session.getSessionStats().totalMessages;
|
|
3146
|
+
}
|
|
3147
|
+
catch {
|
|
3148
|
+
// 会话替换中 —— 按上次条数处理(多半直接跳过)
|
|
3149
|
+
}
|
|
3150
|
+
if (count !== (conv.touchSidecarCount ?? -1)) {
|
|
3151
|
+
conv.touchSidecarCount = count;
|
|
3152
|
+
let file;
|
|
3153
|
+
try {
|
|
3154
|
+
file = conv.session.sessionFile ?? undefined;
|
|
3155
|
+
}
|
|
3156
|
+
catch {
|
|
3157
|
+
file = undefined;
|
|
3158
|
+
}
|
|
3159
|
+
void mergeTouchSidecar(file, extractTouches(this.convTranscript(conv)));
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
catch {
|
|
3163
|
+
// sidecar 只是加速 + 防压缩丢失,失败了下次重算
|
|
3164
|
+
}
|
|
2965
3165
|
break;
|
|
2966
3166
|
}
|
|
2967
3167
|
case "entry_appended": {
|
|
@@ -3038,7 +3238,7 @@ export class ClientSession {
|
|
|
3038
3238
|
messageId: `stream-${m?.timestamp ?? 0}`,
|
|
3039
3239
|
usage: (() => {
|
|
3040
3240
|
try {
|
|
3041
|
-
const t = this.
|
|
3241
|
+
const t = this.sessionStats().tokens;
|
|
3042
3242
|
return t ? { input: t.input, output: t.output, total: t.total } : null;
|
|
3043
3243
|
}
|
|
3044
3244
|
catch {
|
|
@@ -3062,6 +3262,16 @@ export class ClientSession {
|
|
|
3062
3262
|
// Snapshot checkpoint policy: deltas carry live rendering during streaming;
|
|
3063
3263
|
// full snapshots are reconciliation checkpoints taken immediately at
|
|
3064
3264
|
// run/tool boundaries and on a slow timer otherwise.
|
|
3265
|
+
//
|
|
3266
|
+
// 只服务**激活对话**(口径同上面的 message_update):flushSnapshot /
|
|
3267
|
+
// scheduleSnapshot 推的都是 this.conv 的整份状态,而这里的事件可能来自后台
|
|
3268
|
+
// 对话——运行中的子代理每次 tool_execution_end / agent_end 都会走到这一点。
|
|
3269
|
+
// 不按 conv.id 分流 = 子代理的每一次工具调用都替激活对话做一次快照:issue
|
|
3270
|
+
// #259 实测 8 子代理 × 5 次 bash → 8 条全量快照共 39.5MB,而激活对话一个
|
|
3271
|
+
// 字节都没变。后台对话的内容在切过去时取(switch_session / get_state 强制
|
|
3272
|
+
// 全量),它在左栏的运行态由 emitConversations 走另一条通道。
|
|
3273
|
+
if (conv.id !== this.conv.id)
|
|
3274
|
+
return;
|
|
3065
3275
|
if (event.type === "agent_end" ||
|
|
3066
3276
|
event.type === "tool_execution_end" ||
|
|
3067
3277
|
event.type === "compaction_end" ||
|
|
@@ -3105,8 +3315,11 @@ export class ClientSession {
|
|
|
3105
3315
|
serializeCached(m) {
|
|
3106
3316
|
return this.serializeCachedFor(this.conv, m);
|
|
3107
3317
|
}
|
|
3108
|
-
/**
|
|
3109
|
-
|
|
3318
|
+
/** 稳定缓存键 + 该消息的序号种子 n(见 serializeCachedFor)。
|
|
3319
|
+
*
|
|
3320
|
+
* messagesOf 一次扫描里同时要 key(建 live 集合)与 n(算 user seq),所以
|
|
3321
|
+
* 两者一起返回、只算一次;单独调用的路径不传 key,按需现算。 */
|
|
3322
|
+
uiMessageKey(conv, m) {
|
|
3110
3323
|
// toolResult messages are keyed by toolCallId; everything else by
|
|
3111
3324
|
// role+timestamp. A single prompt can emit several same-role messages
|
|
3112
3325
|
// within the SAME millisecond (multiple attachment asides), so the
|
|
@@ -3119,8 +3332,13 @@ export class ClientSession {
|
|
|
3119
3332
|
n = conv.nextMsgId++;
|
|
3120
3333
|
conv.msgIds.set(key, n);
|
|
3121
3334
|
}
|
|
3122
|
-
|
|
3123
|
-
|
|
3335
|
+
return { cacheKey: `${key}#${n}`, n };
|
|
3336
|
+
}
|
|
3337
|
+
/** serializeCached 的按对话版本(插件快照读非活跃对话用;缓存仍按对话隔离)。
|
|
3338
|
+
* key 可由 messagesOf 预计算传入(同一次扫描里它已经算过一遍)。 */
|
|
3339
|
+
serializeCachedFor(conv, m, key) {
|
|
3340
|
+
const k = key ?? this.uiMessageKey(conv, m);
|
|
3341
|
+
const cached = conv.uiMessageCache.get(k.cacheKey);
|
|
3124
3342
|
if (cached)
|
|
3125
3343
|
return cached;
|
|
3126
3344
|
// User-message id suffix is a 1-based count of user messages sharing
|
|
@@ -3128,28 +3346,17 @@ export class ClientSession {
|
|
|
3128
3346
|
// a global per-conversation counter across ALL roles, so it can't be
|
|
3129
3347
|
// reused as the seq — otherwise editing anything but the first question
|
|
3130
3348
|
// fails to resolve ("找不到要编辑的消息").
|
|
3131
|
-
let seq = n;
|
|
3349
|
+
let seq = k.n;
|
|
3132
3350
|
if (m.role === "user") {
|
|
3133
3351
|
const ts = m.timestamp ?? 0;
|
|
3134
3352
|
seq = (conv.userSeqByTs.get(ts) ?? 0) + 1;
|
|
3135
3353
|
conv.userSeqByTs.set(ts, seq);
|
|
3136
3354
|
}
|
|
3137
3355
|
const msg = serializeMessage(m, seq);
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
// insertion order, so dropping from the front evicts the oldest —
|
|
3143
|
-
// recent messages (the ones every snapshot touches) always survive.
|
|
3144
|
-
// Safe: a miss just recomputes an identical object on next access.
|
|
3145
|
-
let excess = conv.uiMessageCache.size - UI_MESSAGE_CACHE_CAP;
|
|
3146
|
-
while (excess-- > 0) {
|
|
3147
|
-
const oldest = conv.uiMessageCache.keys().next().value;
|
|
3148
|
-
if (oldest === undefined)
|
|
3149
|
-
break;
|
|
3150
|
-
conv.uiMessageCache.delete(oldest);
|
|
3151
|
-
}
|
|
3152
|
-
}
|
|
3356
|
+
// 上界在 messagesOf 的 pruneMessageCache 里按 live 集合处理:见那里的注释
|
|
3357
|
+
// (FIFO 淘汰仍在转写里的条目会让每次快照都退化成全量,issue #259)。
|
|
3358
|
+
if (msg)
|
|
3359
|
+
conv.uiMessageCache.set(k.cacheKey, msg);
|
|
3153
3360
|
return msg;
|
|
3154
3361
|
}
|
|
3155
3362
|
/** Current messages array (with the existing sig-reuse optimization).
|
|
@@ -3160,13 +3367,21 @@ export class ClientSession {
|
|
|
3160
3367
|
}
|
|
3161
3368
|
/** currentMessages 的按对话版本(插件快照读非活跃对话用)。 */
|
|
3162
3369
|
messagesOf(conv) {
|
|
3370
|
+
// 一次扫描同时收齐「当前转写里的全部缓存键」(live 集合,供
|
|
3371
|
+
// pruneMessageCache 精确回收死条目)与各自的序列化结果。
|
|
3372
|
+
const live = new Set();
|
|
3163
3373
|
let rawMessages = conv.session.agent.state.messages
|
|
3164
|
-
.map((m) =>
|
|
3374
|
+
.map((m) => {
|
|
3375
|
+
const k = this.uiMessageKey(conv, m);
|
|
3376
|
+
live.add(k.cacheKey);
|
|
3377
|
+
return this.serializeCachedFor(conv, m, k);
|
|
3378
|
+
})
|
|
3165
3379
|
.filter((m) => m !== null);
|
|
3166
3380
|
// 自动重试等待期:SDK 暂留在 state 末尾的 error 气泡只是中间态(随后被
|
|
3167
3381
|
// 摘掉重跑),不进快照——成功则用户永远看不到,耗尽才标红。否则 agent_end
|
|
3168
3382
|
// 的立即 flush 会先画红、摘掉后又消失(红色一闪而过)。
|
|
3169
3383
|
rawMessages = stripTransientRetryErrors(rawMessages, !!conv.retryState);
|
|
3384
|
+
this.pruneMessageCache(conv, live);
|
|
3170
3385
|
// Reuse the previous array when nothing changed: the element objects are
|
|
3171
3386
|
// cached (reference-stable) anyway, and a stable array reference lets the
|
|
3172
3387
|
// frontend memoize derived maps instead of rebuilding them every 60ms.
|
|
@@ -3176,6 +3391,27 @@ export class ClientSession {
|
|
|
3176
3391
|
conv.lastMessagesArray = rawMessages;
|
|
3177
3392
|
return messages;
|
|
3178
3393
|
}
|
|
3394
|
+
/** 序列化缓存上界:**先按「还在转写里」淘汰,绝不为省内存淘汰仍在转写里的条目**。
|
|
3395
|
+
*
|
|
3396
|
+
* 为什么不能 FIFO 淘汰(issue #259 实测的机理):缓存上限一旦低于转写长度,
|
|
3397
|
+
* 每次快照扫描前缀条目全部 miss → 重新序列化出**新对象**,emitSnapshotNow 的
|
|
3398
|
+
* identity walk 在 i=0 就失配 ⇒ 每个 checkpoint 都退化成整份全量快照,且每次
|
|
3399
|
+
* 都要重算整份转写(颠簸,成本随转写线性甚至更差)。实测 6000 条转写的激活
|
|
3400
|
+
* 对话 + 8 子代理 × 5 次 bash:40 次工具调用换来 8 条全量快照共 39.5MB,
|
|
3401
|
+
* `snapshot_delta` 一条都没有。
|
|
3402
|
+
*
|
|
3403
|
+
* 按 live 集合淘汰后:仍在转写里的消息对象恒定(identity walk 命中,增量通路
|
|
3404
|
+
* 恢复),被回收的只有 fork / 压缩 / 换会话留下的死条目 —— 内存上界仍等于
|
|
3405
|
+
* 「当前转写」本身(这份数组本来就要常驻),不再随历史累积。 */
|
|
3406
|
+
pruneMessageCache(conv, live) {
|
|
3407
|
+
const cache = conv.uiMessageCache;
|
|
3408
|
+
// 只有「超上限」且「确实有死条目」时才扫一遍;转写单调增长时这里是零成本。
|
|
3409
|
+
if (cache.size <= UI_MESSAGE_CACHE_CAP || cache.size <= live.size)
|
|
3410
|
+
return;
|
|
3411
|
+
for (const key of cache.keys())
|
|
3412
|
+
if (!live.has(key))
|
|
3413
|
+
cache.delete(key);
|
|
3414
|
+
}
|
|
3179
3415
|
/** Build every UiState field EXCEPT messages (the expensive part). */
|
|
3180
3416
|
buildLightState(rev, withDraft = false) {
|
|
3181
3417
|
const conv = this.conv;
|
|
@@ -3188,7 +3424,7 @@ export class ClientSession {
|
|
|
3188
3424
|
contextUsage: { tokens: null, contextWindow: 0, percent: null },
|
|
3189
3425
|
};
|
|
3190
3426
|
try {
|
|
3191
|
-
const s = this.
|
|
3427
|
+
const s = this.sessionStats();
|
|
3192
3428
|
stats = {
|
|
3193
3429
|
totalMessages: s.totalMessages,
|
|
3194
3430
|
tokens: s.tokens,
|
|
@@ -3326,6 +3562,37 @@ export class ClientSession {
|
|
|
3326
3562
|
// -----------------------------------------------------------------------
|
|
3327
3563
|
// 用户提问桥(标准 pi 引擎 ask_user_question customTool)
|
|
3328
3564
|
// -----------------------------------------------------------------------
|
|
3565
|
+
/**
|
|
3566
|
+
* 桥接工具目标(ask_user_question / browser_page):**调用瞬间**按 runtime 身份
|
|
3567
|
+
* 解析当前持有它的会话与对话 id,而不是用建 runtime 时捕获的 `this` + ownerId
|
|
3568
|
+
* (过户会把 runtime 搬到另一个 ClientSession,闭包里的会话引用与 id 都不跟着
|
|
3569
|
+
* 搬 —— 详见 findConversationHome)。
|
|
3570
|
+
*
|
|
3571
|
+
* 工厂(makeAskUserQuestionTool / makeBrowserPageTool)是鸭子类型,只要求这几个
|
|
3572
|
+
* 方法,所以这里给的是一个按需转发的小适配器:工厂传进来的 convId 是**建时**的
|
|
3573
|
+
* 旧 id(过户撞车改名后它要么查不到、要么撞到别的对话),一律以解析结果为准。
|
|
3574
|
+
* 解析不到(未接线 / 会话被替换 / 对话已关闭)时兜底建时的会话与 id —— 与改动前
|
|
3575
|
+
* 行为一致。anchor 由 runtime 创建处在拿到 `created.session` 后回填。
|
|
3576
|
+
*/
|
|
3577
|
+
bridgeTarget(anchor, ownerId) {
|
|
3578
|
+
const home = () => {
|
|
3579
|
+
const found = anchor.session ? this.findConversationHome?.(anchor.session) : undefined;
|
|
3580
|
+
return found ?? { session: this, convId: ownerId };
|
|
3581
|
+
};
|
|
3582
|
+
return {
|
|
3583
|
+
askUser: (questions, sig) => {
|
|
3584
|
+
const h = home();
|
|
3585
|
+
return h.session.askUser(questions, sig, h.convId);
|
|
3586
|
+
},
|
|
3587
|
+
pageCall: (req, sig) => {
|
|
3588
|
+
const h = home();
|
|
3589
|
+
return h.session.pageCall(req, sig, h.convId);
|
|
3590
|
+
},
|
|
3591
|
+
// 截图「给图还是走视觉桥」按当前持有方的模型与设置判定(过户后由它驱动)。
|
|
3592
|
+
canSeeImages: () => home().session.canSeeImages(),
|
|
3593
|
+
transcribeToolImage: (image, signal) => home().session.transcribeToolImage(image, signal),
|
|
3594
|
+
};
|
|
3595
|
+
}
|
|
3329
3596
|
/** 标准引擎模型调 ask_user_question:发 question_pending 给浏览器并阻塞等待
|
|
3330
3597
|
* question_answer。sig 为工具执行信号的当前状态(aborted → 立即 reject)。
|
|
3331
3598
|
* 返回 answers(用户选中/自定义),或 null(用户取消)。
|
|
@@ -3349,11 +3616,18 @@ export class ClientSession {
|
|
|
3349
3616
|
this.pendingQuestions.set(id, { resolve, questions, conversationId });
|
|
3350
3617
|
// 运行列表的「?」角标靠 conversations 推送(问卷登记/解决不经过快照通道)。
|
|
3351
3618
|
this.emitConversations();
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3619
|
+
// 只向目标会话的当前激活端推送弹窗,避免后台问卷污染当前前台会话。
|
|
3620
|
+
if (shouldPopQuestion(conversationId, this.activeId)) {
|
|
3621
|
+
const conv = conversationId ? this.convs.get(conversationId) : this.conv;
|
|
3622
|
+
const conversationTitle = conv?.title;
|
|
3623
|
+
this.emit({
|
|
3624
|
+
type: "question_pending",
|
|
3625
|
+
id,
|
|
3626
|
+
questions,
|
|
3627
|
+
...(conversationId !== undefined ? { conversationId } : {}),
|
|
3628
|
+
...(conversationTitle ? { conversationTitle } : {}),
|
|
3629
|
+
});
|
|
3630
|
+
}
|
|
3357
3631
|
});
|
|
3358
3632
|
}
|
|
3359
3633
|
/** 前端回答模型提问(question_answer → 恢复 askUser 的 Promise)。id 需匹配
|
|
@@ -3378,7 +3652,14 @@ export class ClientSession {
|
|
|
3378
3652
|
for (const [id, p] of this.pendingQuestions) {
|
|
3379
3653
|
if (p.conversationId !== undefined && p.conversationId !== this.activeId)
|
|
3380
3654
|
continue;
|
|
3381
|
-
|
|
3655
|
+
const conv = p.conversationId !== undefined ? this.convs.get(p.conversationId) : this.conv;
|
|
3656
|
+
const conversationTitle = conv?.title;
|
|
3657
|
+
return {
|
|
3658
|
+
id,
|
|
3659
|
+
questions: p.questions,
|
|
3660
|
+
...(p.conversationId !== undefined ? { conversationId: p.conversationId } : {}),
|
|
3661
|
+
...(conversationTitle ? { conversationTitle } : {}),
|
|
3662
|
+
};
|
|
3382
3663
|
}
|
|
3383
3664
|
return null;
|
|
3384
3665
|
}
|
|
@@ -3399,14 +3680,34 @@ export class ClientSession {
|
|
|
3399
3680
|
/** 取某对话的等答复问卷原文(跨页作答的 peek 用;只读,不改变状态)。 */
|
|
3400
3681
|
peekPendingQuestion(convId) {
|
|
3401
3682
|
for (const [id, p] of this.pendingQuestions) {
|
|
3402
|
-
if (p.conversationId === convId)
|
|
3403
|
-
|
|
3683
|
+
if (p.conversationId === convId) {
|
|
3684
|
+
const conv = this.convs.get(convId);
|
|
3685
|
+
return { id, questions: p.questions, conversationTitle: conv?.title };
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
return undefined;
|
|
3689
|
+
}
|
|
3690
|
+
/** 取某对话的等答复问卷简要信息(用于会话列表与横幅展示)。 */
|
|
3691
|
+
getPendingQuestionForConv(convId) {
|
|
3692
|
+
for (const [id, p] of this.pendingQuestions) {
|
|
3693
|
+
if (p.conversationId === undefined || p.conversationId === convId) {
|
|
3694
|
+
const q0 = p.questions[0];
|
|
3695
|
+
const title = q0?.header?.trim() || q0?.question?.trim() || undefined;
|
|
3696
|
+
return { id, title };
|
|
3697
|
+
}
|
|
3404
3698
|
}
|
|
3405
3699
|
return undefined;
|
|
3406
3700
|
}
|
|
3407
3701
|
/** 跨页作答:把别处问卷的原文推给本页弹框(回答经 answerElsewhereQuestion 回去)。 */
|
|
3408
3702
|
pushElsewhereQuestion(owner, convId, q) {
|
|
3409
|
-
this.emit({
|
|
3703
|
+
this.emit({
|
|
3704
|
+
type: "elsewhere_question",
|
|
3705
|
+
owner,
|
|
3706
|
+
convId,
|
|
3707
|
+
id: q.id,
|
|
3708
|
+
questions: q.questions,
|
|
3709
|
+
...(q.conversationTitle ? { conversationTitle: q.conversationTitle } : {}),
|
|
3710
|
+
});
|
|
3410
3711
|
}
|
|
3411
3712
|
/** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
|
|
3412
3713
|
cancelPendingQuestions() {
|
|
@@ -3651,6 +3952,22 @@ export class ClientSession {
|
|
|
3651
3952
|
* attach 时由 AgentService 接到全局 onClientCwdChanged —— 编辑器等
|
|
3652
3953
|
* 工作区跟随型插件借此把根目录切到用户当前项目。 */
|
|
3653
3954
|
onCwdChanged = undefined;
|
|
3955
|
+
/** 过户后的桥接投递解析(attach 时由 AgentService 接线):给一个 SDK 会话(=
|
|
3956
|
+
* 一个 runtime 的身份),返回**当前**持有它的客户端会话 + 该对话**当前** id。
|
|
3957
|
+
*
|
|
3958
|
+
* 为什么需要它:runtime 创建时把 `this`(当时的 ClientSession)与当时的
|
|
3959
|
+
* conversationId 闭包进桥接工具(ask_user_question / browser_page),而
|
|
3960
|
+
* `take_over_conversation` 只搬对话(runtime/终端/订阅/看门狗/在途问卷与页
|
|
3961
|
+
* 调用),搬不动闭包里的会话引用。过户后模型再提问/截图,用捕获的 `this` 就会把
|
|
3962
|
+
* question_pending / page_request 推给过户前那台设备 —— 持有方收不到,问卷还会
|
|
3963
|
+
* 挂在老设备的注册表里(且不进它的快照:该 conv 已不在它名下)→ 刷新即丢,
|
|
3964
|
+
* 而 pi 引擎问卷不超时,那一轮 run 就永远挂住。
|
|
3965
|
+
*
|
|
3966
|
+
* 用 SDK 会话对象(稳定标识)而不是建时的 conversationId:过户遇到 id 撞车会把
|
|
3967
|
+
* 对话改名(c1→c2),那时旧 id 要么查不到、要么查到老会话里的另一条对话,都会
|
|
3968
|
+
* 投错。(注意不能用 runtime 对象比:SDK 会把工厂返回值包成新的 AgentSessionRuntime
|
|
3969
|
+
* 实例,而 `runtime.session` 与本工厂的 `created.session` 是同一个对象。) */
|
|
3970
|
+
findConversationHome = undefined;
|
|
3654
3971
|
/** issue #145 跨客户端同会话感知 —— attach 时由 AgentService 接线:
|
|
3655
3972
|
* - findSessionOwner:别处是否已持有同一 session 文件(查重建第二个 writer 用);
|
|
3656
3973
|
* - listProjectRunners:别处在同一 cwd 下正在跑的对话(同项目并行感知用);
|
|
@@ -3665,6 +3982,8 @@ export class ClientSession {
|
|
|
3665
3982
|
/** issue #145:除本客户端外是否有人在跑(扫目录查重前置的无 I/O 判断)。 */
|
|
3666
3983
|
hasStreamingElsewhere = undefined;
|
|
3667
3984
|
listProjectRunners = undefined;
|
|
3985
|
+
/** issue #145 同款接线:全局认领表(AgentService 级单例,attach 时由 AgentService 接线)。 */
|
|
3986
|
+
getClaimStore = undefined;
|
|
3668
3987
|
listExternalRunning = undefined;
|
|
3669
3988
|
notifyExternalClients = undefined;
|
|
3670
3989
|
onRunningChanged = undefined;
|
|
@@ -3805,6 +4124,22 @@ export class ClientSession {
|
|
|
3805
4124
|
}
|
|
3806
4125
|
this.emitSnapshotNow(forceFull);
|
|
3807
4126
|
}
|
|
4127
|
+
/**
|
|
4128
|
+
* Cached `session.getSessionStats()` — the SDK computes it by walking the whole
|
|
4129
|
+
* transcript, and the message_delta path used to call it per streaming frame
|
|
4130
|
+
* (measured: 27.6% of streaming CPU at 6000 messages, see STATS_CACHE_MS).
|
|
4131
|
+
* Callers that need the authoritative value can still call the session
|
|
4132
|
+
* directly; every cache hit here is at most STATS_CACHE_MS stale.
|
|
4133
|
+
*/
|
|
4134
|
+
sessionStats() {
|
|
4135
|
+
const now = Date.now();
|
|
4136
|
+
const hit = this.sessionStatsCache;
|
|
4137
|
+
if (hit && hit.session === this.session && now - hit.at < STATS_CACHE_MS)
|
|
4138
|
+
return hit.value;
|
|
4139
|
+
const value = this.session.getSessionStats();
|
|
4140
|
+
this.sessionStatsCache = { at: now, session: this.session, value };
|
|
4141
|
+
return value;
|
|
4142
|
+
}
|
|
3808
4143
|
scheduleSnapshot() {
|
|
3809
4144
|
if (this.snapshotTimer || this.disposed)
|
|
3810
4145
|
return;
|
|
@@ -3878,6 +4213,29 @@ export class ClientSession {
|
|
|
3878
4213
|
pushSlashCommands() {
|
|
3879
4214
|
return this.slash.push();
|
|
3880
4215
|
}
|
|
4216
|
+
/**
|
|
4217
|
+
* 取一条工具的**定义说明**(工具卡右键 → 「显示工具详细信息」)→ `tool_info`。
|
|
4218
|
+
*
|
|
4219
|
+
* 定义从活动会话现取(`getAllTools` 是目录全集,含被禁用的工具),
|
|
4220
|
+
* `getActiveToolNames` 只用来标记「当前是否启用」—— 禁用名单里的工具仍在目录里,
|
|
4221
|
+
* 用户点开看定义是合理的,只是模型看不到它。
|
|
4222
|
+
*
|
|
4223
|
+
* 失败(会话未就绪 / 引擎抛错)一律回 `found: false`:这是只读的展示请求,
|
|
4224
|
+
* 不该因为拿不到定义就在 UI 上报警。
|
|
4225
|
+
*/
|
|
4226
|
+
getToolInfo(name) {
|
|
4227
|
+
let raw;
|
|
4228
|
+
try {
|
|
4229
|
+
const defs = this.session.getAllTools();
|
|
4230
|
+
const found = defs.find((d) => d.name === name);
|
|
4231
|
+
if (found)
|
|
4232
|
+
raw = { ...found, active: this.session.getActiveToolNames().includes(name) };
|
|
4233
|
+
}
|
|
4234
|
+
catch {
|
|
4235
|
+
// Session not ready (or the engine threw) — fall through to found:false.
|
|
4236
|
+
}
|
|
4237
|
+
this.emit({ type: "tool_info", ...normalizeToolInfo(name, raw) });
|
|
4238
|
+
}
|
|
3881
4239
|
/** 模型/服务商配置管理 —— 自包含模块,见 model-admin.ts。 */
|
|
3882
4240
|
modelAdmin;
|
|
3883
4241
|
/** Persist an api-key credential for a provider (auth.json). */
|
|
@@ -3889,8 +4247,12 @@ export class ClientSession {
|
|
|
3889
4247
|
await this.modelAdmin.clearProviderApiKey(provider);
|
|
3890
4248
|
// The provider is back to unconfigured — drop its key preference in
|
|
3891
4249
|
// EVERY project, otherwise each project switch re-tries a restore.
|
|
3892
|
-
if (!usingOAuth)
|
|
4250
|
+
if (!usingOAuth) {
|
|
3893
4251
|
this.stateStore.deleteProviderEverywhere(provider.trim());
|
|
4252
|
+
const defKey = this.stateStore.getDefaultProviderKey(provider.trim());
|
|
4253
|
+
if (defKey)
|
|
4254
|
+
this.stateStore.repointDeletedKeyInDefault(provider.trim(), defKey, null);
|
|
4255
|
+
}
|
|
3894
4256
|
}
|
|
3895
4257
|
startProviderOAuth(provider) {
|
|
3896
4258
|
this.modelAdmin.startProviderOAuth(provider);
|
|
@@ -3937,6 +4299,14 @@ export class ClientSession {
|
|
|
3937
4299
|
cloneProvider(providerId, reqId) {
|
|
3938
4300
|
return this.modelAdmin.cloneProvider(providerId, reqId);
|
|
3939
4301
|
}
|
|
4302
|
+
/** Enrich custom-provider draft rows from public catalogs (enrich_models_result). */
|
|
4303
|
+
enrichModels(reqId, ids, hints) {
|
|
4304
|
+
return this.modelAdmin.enrichModels(reqId, ids, hints, () => this.getLang());
|
|
4305
|
+
}
|
|
4306
|
+
/** Abort in-flight enrich_models request. */
|
|
4307
|
+
abortEnrichModels(reqId) {
|
|
4308
|
+
this.modelAdmin.abortEnrichModels(reqId);
|
|
4309
|
+
}
|
|
3940
4310
|
saveModelConfig(providerId, config) {
|
|
3941
4311
|
return this.modelAdmin.saveModelConfig(providerId, config);
|
|
3942
4312
|
}
|
|
@@ -3968,6 +4338,7 @@ export class ClientSession {
|
|
|
3968
4338
|
// (or drop the pin when no keys remain), not just the current one.
|
|
3969
4339
|
const active = this.modelAdmin.getActiveKeyName(provider);
|
|
3970
4340
|
this.stateStore.repointDeletedKeyEverywhere(provider, keyName, active);
|
|
4341
|
+
this.stateStore.repointDeletedKeyInDefault(provider, keyName, active);
|
|
3971
4342
|
}
|
|
3972
4343
|
/** Restore per-project provider keys when entering a project. For each
|
|
3973
4344
|
* provider that has a saved key for `cwd`, activate it if it differs from
|
|
@@ -3992,25 +4363,40 @@ export class ClientSession {
|
|
|
3992
4363
|
}
|
|
3993
4364
|
}
|
|
3994
4365
|
/** When a model is set, ensure its provider's per-project key is restored.
|
|
3995
|
-
* Silent + self-healing like the bulk restore above.
|
|
4366
|
+
* Silent + self-healing like the bulk restore above. Falls back to the
|
|
4367
|
+
* GLOBAL default key when the project has no pin for this provider (new
|
|
4368
|
+
* project following the global default model). */
|
|
3996
4369
|
async restoreKeyForModel(modelId, cwd) {
|
|
3997
4370
|
const slash = modelId.indexOf("/");
|
|
3998
4371
|
if (slash <= 0)
|
|
3999
4372
|
return;
|
|
4000
4373
|
const provider = modelId.slice(0, slash);
|
|
4001
|
-
const
|
|
4002
|
-
if (
|
|
4374
|
+
const projectPin = this.stateStore.getProjectProviderKey(this.clientId, cwd, provider);
|
|
4375
|
+
if (projectPin) {
|
|
4376
|
+
const cur = this.modelAdmin.getActiveKeyName(provider);
|
|
4377
|
+
if (cur === projectPin)
|
|
4378
|
+
return;
|
|
4379
|
+
if (!this.modelAdmin.hasProviderKey(provider, projectPin)) {
|
|
4380
|
+
this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
|
|
4381
|
+
return;
|
|
4382
|
+
}
|
|
4383
|
+
const ok = await this.modelAdmin.activateProviderKey(provider, projectPin, { silent: true });
|
|
4384
|
+
if (!ok)
|
|
4385
|
+
this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
|
|
4003
4386
|
return;
|
|
4004
|
-
|
|
4005
|
-
|
|
4387
|
+
}
|
|
4388
|
+
// No project pin — follow the global default key (if any). No
|
|
4389
|
+
// self-healing deletes here: the ref belongs to the global default,
|
|
4390
|
+
// not this project (key deletions already repoint it, see
|
|
4391
|
+
// repointDeletedKeyInDefault).
|
|
4392
|
+
const globalPin = this.stateStore.getDefaultProviderKey(provider);
|
|
4393
|
+
if (!globalPin)
|
|
4006
4394
|
return;
|
|
4007
|
-
if (
|
|
4008
|
-
this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
|
|
4395
|
+
if (this.modelAdmin.getActiveKeyName(provider) === globalPin)
|
|
4009
4396
|
return;
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
|
|
4397
|
+
if (!this.modelAdmin.hasProviderKey(provider, globalPin))
|
|
4398
|
+
return;
|
|
4399
|
+
await this.modelAdmin.activateProviderKey(provider, globalPin, { silent: true });
|
|
4014
4400
|
}
|
|
4015
4401
|
/** Remember the just-selected model (and the key that was active for its
|
|
4016
4402
|
* provider) for the current project. Called IMMEDIATELY on model selection —
|
|
@@ -4034,9 +4420,10 @@ export class ClientSession {
|
|
|
4034
4420
|
* own per-session model: switching back to a RUNNING / completed chat must not
|
|
4035
4421
|
* silently overwrite its model with the project default. So a fresh chat in the
|
|
4036
4422
|
* project gets the remembered model; an in-progress one keeps what it had and
|
|
4037
|
-
* the user switches via the picker. Silent on failure (model no longer in catalog).
|
|
4423
|
+
* the user switches via the picker. Silent on failure (model no longer in catalog).
|
|
4424
|
+
* Fallback chain: project memory > GLOBAL default model > SDK default (no-op). */
|
|
4038
4425
|
async restoreProjectModelForCwd(cwd) {
|
|
4039
|
-
const savedModel = this.stateStore.getProjectModel(this.clientId, cwd);
|
|
4426
|
+
const savedModel = this.stateStore.getProjectModel(this.clientId, cwd) ?? this.stateStore.getDefaultModel();
|
|
4040
4427
|
if (!savedModel)
|
|
4041
4428
|
return;
|
|
4042
4429
|
try {
|
|
@@ -4058,8 +4445,20 @@ export class ClientSession {
|
|
|
4058
4445
|
const curId = cur ? `${cur.provider}/${cur.id}` : null;
|
|
4059
4446
|
// Restore the model's provider key first so setModel's auth check passes.
|
|
4060
4447
|
await this.restoreKeyForModel(savedModel, cwd);
|
|
4061
|
-
if (curId === savedModel)
|
|
4448
|
+
if (curId === savedModel) {
|
|
4449
|
+
// 即使模型已是目标模型,也确保恢复其专属思考强度或全局默认思考强度
|
|
4450
|
+
const sm = this.session.settingsManager;
|
|
4451
|
+
const targetThinking = sm.getModelThinkingLevel(model.provider, model.id) ?? sm.getDefaultThinkingLevel();
|
|
4452
|
+
if (targetThinking && this.session.thinkingLevel !== targetThinking) {
|
|
4453
|
+
try {
|
|
4454
|
+
this.session.setThinkingLevel(targetThinking);
|
|
4455
|
+
}
|
|
4456
|
+
catch {
|
|
4457
|
+
/* 模型可能不支持该强度 */
|
|
4458
|
+
}
|
|
4459
|
+
}
|
|
4062
4460
|
return;
|
|
4461
|
+
}
|
|
4063
4462
|
await this.session.setModel(model);
|
|
4064
4463
|
}
|
|
4065
4464
|
catch {
|
|
@@ -4338,6 +4737,21 @@ export class ClientSession {
|
|
|
4338
4737
|
return false;
|
|
4339
4738
|
}
|
|
4340
4739
|
}
|
|
4740
|
+
/** 某对话的转录最小结构(内存实时消息,含未落盘的;与 conversationReadHost
|
|
4741
|
+
* 的 readRunningConversation 同一取数逻辑 —— 并行提醒算触碰集时复用,
|
|
4742
|
+
* 不另起读取路径)。 */
|
|
4743
|
+
convTranscript(conv) {
|
|
4744
|
+
let raw = [];
|
|
4745
|
+
try {
|
|
4746
|
+
raw = (conv.session.messages ??
|
|
4747
|
+
conv.session.agent.state.messages ??
|
|
4748
|
+
[]);
|
|
4749
|
+
}
|
|
4750
|
+
catch {
|
|
4751
|
+
raw = [];
|
|
4752
|
+
}
|
|
4753
|
+
return raw.map(toTranscriptInput);
|
|
4754
|
+
}
|
|
4341
4755
|
/** issue #145:当前活动对话的 session 文件(resolved),无则 undefined。 */
|
|
4342
4756
|
activeSessionFileResolved() {
|
|
4343
4757
|
try {
|
|
@@ -4368,21 +4782,39 @@ export class ClientSession {
|
|
|
4368
4782
|
}
|
|
4369
4783
|
return out;
|
|
4370
4784
|
}
|
|
4371
|
-
/** issue #145
|
|
4372
|
-
* 手动过户的目标定位:convId + 是否有等答复问卷)。
|
|
4785
|
+
/** issue #145:本实例别处可见的对话摘要(elsewhere 列表的本机一半 +
|
|
4786
|
+
* 手动过户的目标定位:convId + 是否有等答复问卷)。
|
|
4787
|
+
*
|
|
4788
|
+
* 口径 = 运行中 + 已结束但本会话仍持有的可见对话(shownInRunningList:
|
|
4789
|
+
* listed 或当前有内容的对话;空白新对话不入列)。只推 running 时,对话
|
|
4790
|
+
* 一结束 elsewhere 行就消失,另一处想过户查看只能趁运行中动手 —— 跑完
|
|
4791
|
+
* 即失联。空闲行带 isStreaming:false + sessionFile,照样可过户(搬 runtime
|
|
4792
|
+
* 本体,单 writer 不变;takeoverBriefs 本来就含空闲对话)。子代理不单列
|
|
4793
|
+
* (随主对话一起搬)。 */
|
|
4373
4794
|
streamingSummariesAll() {
|
|
4374
4795
|
const out = [];
|
|
4375
4796
|
for (const conv of this.convs.values()) {
|
|
4376
4797
|
if (conv.isSubagent)
|
|
4377
4798
|
continue;
|
|
4378
|
-
|
|
4799
|
+
const streaming = this.conversationStreaming(conv);
|
|
4800
|
+
if (!streaming && !this.shownInRunningList(conv))
|
|
4379
4801
|
continue;
|
|
4802
|
+
const pq = this.getPendingQuestionForConv(conv.id);
|
|
4803
|
+
let sessionFile;
|
|
4804
|
+
try {
|
|
4805
|
+
sessionFile = conv.session.sessionFile ?? undefined;
|
|
4806
|
+
}
|
|
4807
|
+
catch {
|
|
4808
|
+
sessionFile = undefined;
|
|
4809
|
+
}
|
|
4380
4810
|
out.push({
|
|
4381
4811
|
title: conv.title,
|
|
4382
4812
|
cwd: conv.cwd,
|
|
4383
|
-
isStreaming:
|
|
4813
|
+
isStreaming: streaming,
|
|
4384
4814
|
convId: conv.id,
|
|
4385
|
-
hasQuestion:
|
|
4815
|
+
hasQuestion: !!pq,
|
|
4816
|
+
...(pq?.title ? { questionTitle: pq.title } : {}),
|
|
4817
|
+
...(sessionFile ? { sessionFile } : {}),
|
|
4386
4818
|
});
|
|
4387
4819
|
}
|
|
4388
4820
|
return out;
|
|
@@ -4503,32 +4935,133 @@ export class ClientSession {
|
|
|
4503
4935
|
// 通告一次(steer/排队等流式中发送不重复打扰)。
|
|
4504
4936
|
// parallelReminderEnabled=false 时整段跳过(不发 notice、不注 AI、不通知对端)。
|
|
4505
4937
|
if (!conv.isSubagent && !s.isStreaming && this.settingsSvc.current.parallelReminderEnabled !== false) {
|
|
4938
|
+
// 认领心跳:本对话发 prompt = 还活着,自己名下的认领续期(同步内存操作)。
|
|
4939
|
+
try {
|
|
4940
|
+
this.getClaimStore?.().touch(conv.id);
|
|
4941
|
+
}
|
|
4942
|
+
catch {
|
|
4943
|
+
// ignore
|
|
4944
|
+
}
|
|
4945
|
+
let projectClaims = [];
|
|
4946
|
+
try {
|
|
4947
|
+
projectClaims = this.getClaimStore?.().list(conv.cwd) ?? [];
|
|
4948
|
+
}
|
|
4949
|
+
catch {
|
|
4950
|
+
projectClaims = [];
|
|
4951
|
+
}
|
|
4952
|
+
// 本窗口正在跑的:子代理也算进来(以前 !c.isSubagent 把它们排除在外,
|
|
4953
|
+
// 对方用子代理干活时 AI 完全收不到提示),标明归属。触碰集就地从内存
|
|
4954
|
+
// 消息算,无 I/O,不阻塞发送路径。
|
|
4506
4955
|
const localRunners = [...this.convs.values()]
|
|
4507
|
-
.filter((c) => c.id !== conv.id &&
|
|
4508
|
-
.map((c) =>
|
|
4956
|
+
.filter((c) => c.id !== conv.id && c.cwd === conv.cwd && this.conversationStreaming(c))
|
|
4957
|
+
.map((c) => {
|
|
4958
|
+
let label;
|
|
4959
|
+
if (c.isSubagent) {
|
|
4960
|
+
const parent = c.parentId ? this.convs.get(c.parentId) : undefined;
|
|
4961
|
+
label = parent ? `本窗口「${parent.title}」的子代理「${c.title}」` : `本窗口子代理「${c.title}」`;
|
|
4962
|
+
}
|
|
4963
|
+
else {
|
|
4964
|
+
label = `本窗口「${c.title}」`;
|
|
4965
|
+
}
|
|
4966
|
+
return { label, touches: extractTouches(this.convTranscript(c)) };
|
|
4967
|
+
});
|
|
4509
4968
|
const externalRunners = (this.listProjectRunners?.(conv.cwd) ?? []).filter((r) => r.sessionFile === undefined || (activeFile !== undefined && resolve(r.sessionFile) !== activeFile));
|
|
4510
|
-
|
|
4511
|
-
|
|
4969
|
+
// 取舍(诚实降级):外部运行只有 title + sessionFile,触碰集要读对方
|
|
4970
|
+
// 转录文件 —— 同步文件 I/O 会阻塞发送路径,不做;提醒里如实写
|
|
4971
|
+
// 「外部运行的文件触碰未知」,不编造。
|
|
4972
|
+
// 每条 ≤60 字符(以前整串 slice(0, 600),经常从半截路径处拦腰截断)。
|
|
4973
|
+
const capItem = (st) => (st.length <= 60 ? st : `${st.slice(0, 59)}…`);
|
|
4974
|
+
const noticeTitles = [
|
|
4975
|
+
...localRunners.map((r) => r.label),
|
|
4512
4976
|
...externalRunners.map((r) => `另一处「${r.title}」`),
|
|
4513
|
-
];
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4977
|
+
].map(capItem);
|
|
4978
|
+
const aiItems = [
|
|
4979
|
+
...localRunners.map((r) => `${r.label}·${r.touches.length} files`),
|
|
4980
|
+
...externalRunners.map((r) => `另一处「${r.title}」·touches unknown`),
|
|
4981
|
+
].map(capItem);
|
|
4982
|
+
if (noticeTitles.length > 0) {
|
|
4983
|
+
const shown = noticeTitles.slice(0, 3).join("、");
|
|
4984
|
+
const more = noticeTitles.length > 3 ? `等 ${noticeTitles.length} 处` : "";
|
|
4517
4985
|
this.emit({
|
|
4518
4986
|
type: "notice",
|
|
4519
4987
|
level: "info",
|
|
4520
4988
|
text: `同项目并行提醒:${shown}${more}正在同一项目运行。你可以继续(适合改不同文件),改动同一文件前请先确认;拿不准就等它跑完。`,
|
|
4521
4989
|
textEn: `Parallel-work notice: ${shown}${more ? " and more" : ""} running in the same project. You may continue (fine for different files); confirm before touching the same files, or wait for it to finish when unsure.`,
|
|
4522
4990
|
});
|
|
4523
|
-
// 给 AI
|
|
4524
|
-
//
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4991
|
+
// 给 AI 的上下文:交集由服务端算好写明“⚠ 双方都动过 X”,AI 不用自己
|
|
4992
|
+
// 算;拿不准就 ask_user_question 让用户选(并行 / 等它跑完 / 只读围观)。
|
|
4993
|
+
// display:false —— 用户界面只看上面的 notice。分两档:无交集只给一行
|
|
4994
|
+
// (省 token),有交集才展开细节。
|
|
4995
|
+
const mine = extractTouches(this.convTranscript(conv));
|
|
4996
|
+
// 认领升级(advisory,但比触碰更强:这是对方的事前意图):
|
|
4997
|
+
// 我动过 + 对方认领 → 最强信号单独点名;其他认领只给一行汇总。
|
|
4998
|
+
const othersClaims = projectClaims.filter((c) => c.ownerConvId !== conv.id);
|
|
4999
|
+
const myClaimed = matchClaims(mine, othersClaims.map((c) => ({
|
|
5000
|
+
path: c.path,
|
|
5001
|
+
ownerConvId: c.ownerConvId,
|
|
5002
|
+
ownerTitle: c.ownerTitle,
|
|
5003
|
+
claimedAt: 0,
|
|
5004
|
+
expiresAt: 0,
|
|
5005
|
+
})), conv.cwd);
|
|
5006
|
+
const claimHitEn = myClaimed
|
|
5007
|
+
.slice(0, 3)
|
|
5008
|
+
.map((h) => `${h.touch.path} (claimed by ${h.claim.ownerTitle})`)
|
|
5009
|
+
.join("; ");
|
|
5010
|
+
const claimHitZh = myClaimed
|
|
5011
|
+
.slice(0, 3)
|
|
5012
|
+
.map((h) => `${h.touch.path}(${h.claim.ownerTitle}已认领)`)
|
|
5013
|
+
.join("、");
|
|
5014
|
+
const claimMore = myClaimed.length > 3 ? ` (+${myClaimed.length - 3})` : "";
|
|
5015
|
+
const claimsSummaryEn = othersClaims.length > 0
|
|
5016
|
+
? ` Claimed by others (steer clear): ${othersClaims
|
|
5017
|
+
.slice(0, 3)
|
|
5018
|
+
.map((c) => `${c.path} ("${c.ownerTitle}")`)
|
|
5019
|
+
.join("; ")}${othersClaims.length > 3 ? ` (+${othersClaims.length - 3})` : ""}.`
|
|
5020
|
+
: "";
|
|
5021
|
+
const claimsSummaryZh = othersClaims.length > 0
|
|
5022
|
+
? ` 对方认领(绕行):${othersClaims
|
|
5023
|
+
.slice(0, 3)
|
|
5024
|
+
.map((c) => `${c.path}(「${c.ownerTitle}」)`)
|
|
5025
|
+
.join("、")}${othersClaims.length > 3 ? `(等 ${othersClaims.length - 3} 处)` : ""}。`
|
|
5026
|
+
: "";
|
|
5027
|
+
const clashes = localRunners
|
|
5028
|
+
.map((r) => ({ label: r.label, hits: intersectTouches(r.touches, mine) }))
|
|
5029
|
+
.filter((r) => r.hits.length > 0);
|
|
5030
|
+
const extNoteEn = externalRunners.length > 0 ? ` Touched files of external run(s) are unknown.` : "";
|
|
5031
|
+
const extNoteZh = externalRunners.length > 0 ? `外部运行的文件触碰未知。` : "";
|
|
5032
|
+
let aiReminder;
|
|
5033
|
+
if (clashes.length === 0 && myClaimed.length === 0) {
|
|
5034
|
+
aiReminder =
|
|
5035
|
+
`(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
|
|
5036
|
+
`are currently running in the same project directory. No file written by both you and them was detected, ` +
|
|
5037
|
+
`so working on different files in parallel is fine; before writing the same files or running project-wide ` +
|
|
5038
|
+
`commands, assess the conflict risk first, and use ask_user_question when unsure ` +
|
|
5039
|
+
`(continue in parallel / wait / watch read-only).${extNoteEn}${claimsSummaryEn})\n` +
|
|
5040
|
+
`(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。未发现双方都写过的文件,` +
|
|
5041
|
+
`改不同文件可并行;动同一文件或跑全局命令前先评估冲突,拿不准就用 ask_user_question 让用户选择:` +
|
|
5042
|
+
`并行 / 等它跑完 / 只读围观。${extNoteZh}${claimsSummaryZh})`;
|
|
5043
|
+
}
|
|
5044
|
+
else {
|
|
5045
|
+
// 有交集档:每处 ≤3 条完整路径 + 计数(路径永不截断,见 conversation-touches)。
|
|
5046
|
+
const clashPartsEn = clashes.map((h) => `${h.label} — you both wrote: ${formatTouchesCompact(h.hits)}`);
|
|
5047
|
+
if (myClaimed.length > 0) {
|
|
5048
|
+
clashPartsEn.push(`⚠ you touched and others claimed: ${claimHitEn}${claimMore} — ask the user before touching these again`);
|
|
5049
|
+
}
|
|
5050
|
+
const clashEn = clashPartsEn.join("; ");
|
|
5051
|
+
const clashPartsZh = clashes.map((h) => `⚠ ${h.label}双方都动过:${formatTouchesCompact(h.hits)}`);
|
|
5052
|
+
if (myClaimed.length > 0) {
|
|
5053
|
+
clashPartsZh.push(`⚠ 你动过、对方已认领:${claimHitZh}${claimMore} —— 动之前先问用户`);
|
|
5054
|
+
}
|
|
5055
|
+
const clashZh = clashPartsZh.join(";");
|
|
5056
|
+
aiReminder =
|
|
5057
|
+
`(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
|
|
5058
|
+
`are currently running in the same project directory. ⚠ ${clashEn} — re-read these files before ` +
|
|
5059
|
+
`touching them again, and use ask_user_question when unsure ` +
|
|
5060
|
+
`(continue in parallel / wait / watch read-only).${extNoteEn})\n` +
|
|
5061
|
+
`(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。` +
|
|
5062
|
+
`${clashZh} —— 再动这些文件前先读最新内容,拿不准就用 ask_user_question 让用户选择:` +
|
|
5063
|
+
`并行 / 等它跑完 / 只读围观。${extNoteZh})`;
|
|
5064
|
+
}
|
|
4532
5065
|
try {
|
|
4533
5066
|
await s.sendCustomMessage({
|
|
4534
5067
|
customType: "parallel-work-reminder",
|
|
@@ -5043,6 +5576,7 @@ export class ClientSession {
|
|
|
5043
5576
|
// Carry the model chosen in the active chat over to the new chat so it
|
|
5044
5577
|
// doesn't silently revert to the ModelRuntime default model.
|
|
5045
5578
|
const prevModel = this.conv.session.agent.state.model ?? null;
|
|
5579
|
+
const prevThinking = this.conv.session.thinkingLevel ?? null;
|
|
5046
5580
|
let ready = false;
|
|
5047
5581
|
try {
|
|
5048
5582
|
const conversationId = this.nextConversationId();
|
|
@@ -5074,6 +5608,14 @@ export class ClientSession {
|
|
|
5074
5608
|
// model no longer resolvable — keep the default
|
|
5075
5609
|
}
|
|
5076
5610
|
}
|
|
5611
|
+
if (prevThinking) {
|
|
5612
|
+
try {
|
|
5613
|
+
this.session.setThinkingLevel(prevThinking);
|
|
5614
|
+
}
|
|
5615
|
+
catch {
|
|
5616
|
+
// model may not support previous thinking level
|
|
5617
|
+
}
|
|
5618
|
+
}
|
|
5077
5619
|
this.emitConversations();
|
|
5078
5620
|
this.goalSvc.emitGoalStatus();
|
|
5079
5621
|
this.pushTerminals();
|
|
@@ -5340,6 +5882,14 @@ export class ClientSession {
|
|
|
5340
5882
|
const conv = this.convs.get(id);
|
|
5341
5883
|
if (!conv || id === this.activeId)
|
|
5342
5884
|
return;
|
|
5885
|
+
// 对话真关闭(dismiss/释放)→ 放掉它的认领。过户不走这里(对话换个会话
|
|
5886
|
+
// 继续,owner 不变,认领继续有效),所以只在此处释放。
|
|
5887
|
+
try {
|
|
5888
|
+
this.getClaimStore?.().releaseByOwner(id);
|
|
5889
|
+
}
|
|
5890
|
+
catch {
|
|
5891
|
+
// ignore
|
|
5892
|
+
}
|
|
5343
5893
|
this.convs.delete(id);
|
|
5344
5894
|
this.clearAllToolWatchdogs(conv);
|
|
5345
5895
|
conv.terminals.killAll();
|
|
@@ -5347,6 +5897,15 @@ export class ClientSession {
|
|
|
5347
5897
|
conv.unsubscribe = undefined;
|
|
5348
5898
|
void conv.runtime.dispose().catch(() => { });
|
|
5349
5899
|
}
|
|
5900
|
+
/** 本会话是否持有这个 SDK 会话对应的对话;命中则给出它的**当前** id
|
|
5901
|
+
* (AgentService 按 runtime 身份解析过户后的归属方用,见 findConversationHome)。 */
|
|
5902
|
+
conversationIdOfSession(sdkSession) {
|
|
5903
|
+
for (const c of this.convs.values()) {
|
|
5904
|
+
if (c.session === sdkSession)
|
|
5905
|
+
return c.id;
|
|
5906
|
+
}
|
|
5907
|
+
return undefined;
|
|
5908
|
+
}
|
|
5350
5909
|
/** 过户用的对话摘要(AgentService 拼移动集合 + 容量检查用)。 */
|
|
5351
5910
|
takeoverBriefs() {
|
|
5352
5911
|
return [...this.convs.values()].map((c) => ({
|
|
@@ -5391,7 +5950,7 @@ export class ClientSession {
|
|
|
5391
5950
|
this.pendingQuestions.delete(qid);
|
|
5392
5951
|
questions.push({ resolve: p.resolve, questions: p.questions, conversationId: p.conversationId });
|
|
5393
5952
|
// 源页面的对话框可能是即时通道弹出的(live),快照为 null 收不掉它 ——
|
|
5394
|
-
//
|
|
5953
|
+
// 明确撤回,让源页面立即收起(目标页主对话由转入方重推 question_pending 或快照呈现)。
|
|
5395
5954
|
this.emit({ type: "question_retracted", id: qid });
|
|
5396
5955
|
}
|
|
5397
5956
|
}
|
|
@@ -5412,8 +5971,8 @@ export class ClientSession {
|
|
|
5412
5971
|
* - id 冲突(两边计数器都从 c1 开始,大概率撞上)→ 给搬入方分配新 id,move
|
|
5413
5972
|
* 集合内的 parentId/问卷归属同步改写。模型手里旧 runId 的后续子代理工具调用
|
|
5414
5973
|
* 会报 unknown(可经列表查新 id);定时唤醒的旧 id 同理回落无头执行。
|
|
5415
|
-
* -
|
|
5416
|
-
*
|
|
5974
|
+
* - 事件订阅/终端投递/问卷/页调用全部重接到本会话,迁入的主对话立即触发弹窗
|
|
5975
|
+
* (子代理待答问卷通过角标与快照呈现)。看门狗按剩余时间重布(已逾期的立即触发)。
|
|
5417
5976
|
*/
|
|
5418
5977
|
insertTakeoverConvs(payload) {
|
|
5419
5978
|
const remap = new Map();
|
|
@@ -5437,7 +5996,10 @@ export class ClientSession {
|
|
|
5437
5996
|
conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
|
|
5438
5997
|
for (const [toolCallId, start] of conv.toolStartTimes) {
|
|
5439
5998
|
if (!conv.toolWatchdogs.has(toolCallId)) {
|
|
5440
|
-
|
|
5999
|
+
const baseMs = this.getBaseToolWatchdogTimeoutMs();
|
|
6000
|
+
if (baseMs > 0) {
|
|
6001
|
+
this.rearmToolWatchdog(conv, toolCallId, baseMs - (Date.now() - start), baseMs);
|
|
6002
|
+
}
|
|
5441
6003
|
}
|
|
5442
6004
|
}
|
|
5443
6005
|
this.convs.set(conv.id, conv);
|
|
@@ -5446,12 +6008,23 @@ export class ClientSession {
|
|
|
5446
6008
|
mainId = payload.convs[0]?.id ?? "";
|
|
5447
6009
|
for (const q of payload.questions) {
|
|
5448
6010
|
const nid = `q-${++this.questionSeq}`;
|
|
6011
|
+
const qConvId = fix(q.conversationId);
|
|
5449
6012
|
this.pendingQuestions.set(nid, {
|
|
5450
6013
|
resolve: q.resolve,
|
|
5451
6014
|
questions: q.questions,
|
|
5452
|
-
conversationId:
|
|
6015
|
+
conversationId: qConvId,
|
|
5453
6016
|
});
|
|
5454
|
-
|
|
6017
|
+
// 迁入的主对话立即触发弹窗(子代理问卷留在后台,由角标与切换会话呈现)。
|
|
6018
|
+
if (qConvId === mainId) {
|
|
6019
|
+
const conv = this.convs.get(qConvId);
|
|
6020
|
+
this.emit({
|
|
6021
|
+
type: "question_pending",
|
|
6022
|
+
id: nid,
|
|
6023
|
+
questions: q.questions,
|
|
6024
|
+
conversationId: qConvId,
|
|
6025
|
+
...(conv?.title ? { conversationTitle: conv.title } : {}),
|
|
6026
|
+
});
|
|
6027
|
+
}
|
|
5455
6028
|
}
|
|
5456
6029
|
for (const p of payload.pageCalls) {
|
|
5457
6030
|
const nid = `p-${++this.pageSeq}`;
|
|
@@ -5610,16 +6183,21 @@ export class ClientSession {
|
|
|
5610
6183
|
// 子代理带 error 标记:左栏红点提示(普通对话不参与)。
|
|
5611
6184
|
...(conv.isSubagent ? this.subagentRunOutcome(conv) : {}),
|
|
5612
6185
|
// 等答复的问卷:左栏「?」角标(主对话/子代理各自挂名下,切过去即可回答)。
|
|
5613
|
-
...(
|
|
6186
|
+
...(() => {
|
|
6187
|
+
const pq = this.getPendingQuestionForConv(conv.id);
|
|
6188
|
+
return pq ? { hasQuestion: true, questionId: pq.id, questionTitle: pq.title } : {};
|
|
6189
|
+
})(),
|
|
5614
6190
|
parentId: conv.parentId,
|
|
5615
6191
|
});
|
|
5616
6192
|
}
|
|
5617
6193
|
// issue #145:流式集合签名变化 → 通知其他客户端重推(左栏「另一处正在运行」近实时)。
|
|
5618
6194
|
// 签名含等问卷态(问卷挂起/解决不改变流式集合,不带它已打开的别处页面永远看不到 `?`)。
|
|
6195
|
+
// elsewhere 口径含已结束的空闲行:签名同样覆盖它们(流式位 + 等问卷位),
|
|
6196
|
+
// 否则对方跑完(streaming→idle)或空闲行出现/消失时这边收不到重推。
|
|
5619
6197
|
try {
|
|
5620
6198
|
const sig = JSON.stringify([...this.convs.values()]
|
|
5621
|
-
.filter((c) => this.conversationStreaming(c))
|
|
5622
|
-
.map((c) => `${c.id}:${this.isWaitingOnUser(c.id) ? 1 : 0}`)
|
|
6199
|
+
.filter((c) => this.conversationStreaming(c) || (!c.isSubagent && this.shownInRunningList(c)))
|
|
6200
|
+
.map((c) => `${c.id}:${this.conversationStreaming(c) ? 1 : 0}:${this.isWaitingOnUser(c.id) ? 1 : 0}`)
|
|
5623
6201
|
.sort());
|
|
5624
6202
|
if (sig !== this.lastRunningSig) {
|
|
5625
6203
|
this.lastRunningSig = sig;
|
|
@@ -5692,8 +6270,6 @@ export class ClientSession {
|
|
|
5692
6270
|
void this.refreshSessions();
|
|
5693
6271
|
}
|
|
5694
6272
|
async pushSessions() {
|
|
5695
|
-
if (!this.sessionsRequested)
|
|
5696
|
-
return;
|
|
5697
6273
|
if (!this.sessionsRequested)
|
|
5698
6274
|
return;
|
|
5699
6275
|
try {
|
|
@@ -5701,8 +6277,23 @@ export class ClientSession {
|
|
|
5701
6277
|
// (<agentDir>/sessions/--<cwd>--/), the same files the pi CLI/TUI
|
|
5702
6278
|
// use — one listing covers every conversation of the current folder.
|
|
5703
6279
|
const infos = await this.loadSessionInfos();
|
|
6280
|
+
// 隐藏「被 fork 掉的父会话」,历史列表只保留每条 fork 链最新的链尾会话
|
|
6281
|
+
// (全文搜索 searchSessions 保留全部会话,不受此影响)。
|
|
6282
|
+
const normSessionPath = (p) => String(p).replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();
|
|
6283
|
+
const forkedParentPaths = new Set();
|
|
6284
|
+
for (const info of infos) {
|
|
6285
|
+
if (typeof info.parentSessionPath === "string" && info.parentSessionPath) {
|
|
6286
|
+
forkedParentPaths.add(normSessionPath(info.parentSessionPath));
|
|
6287
|
+
}
|
|
6288
|
+
}
|
|
6289
|
+
const visibleInfos = forkedParentPaths.size > 0
|
|
6290
|
+
? (() => {
|
|
6291
|
+
const kept = infos.filter((info) => !forkedParentPaths.has(normSessionPath(info.path)));
|
|
6292
|
+
return kept.length > 0 ? kept : infos;
|
|
6293
|
+
})()
|
|
6294
|
+
: infos;
|
|
5704
6295
|
const sessions = new Map();
|
|
5705
|
-
for (const s of
|
|
6296
|
+
for (const s of visibleInfos) {
|
|
5706
6297
|
sessions.set(s.path, {
|
|
5707
6298
|
path: s.path,
|
|
5708
6299
|
name: s.name,
|
|
@@ -5807,6 +6398,8 @@ export class ClientSession {
|
|
|
5807
6398
|
}
|
|
5808
6399
|
}
|
|
5809
6400
|
rmSync(abs, { force: true });
|
|
6401
|
+
// 转录删了,sidecar 再留着就是孤儿,一起清掉(不存在不报错)。
|
|
6402
|
+
removeTouchSidecar(abs);
|
|
5810
6403
|
// 转录删了,未发送草稿再留着就是孤儿,一起清掉。
|
|
5811
6404
|
try {
|
|
5812
6405
|
this.drafts.pruneSessionFile(abs);
|
|
@@ -5932,6 +6525,84 @@ export class ClientSession {
|
|
|
5932
6525
|
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
5933
6526
|
});
|
|
5934
6527
|
}
|
|
6528
|
+
/**
|
|
6529
|
+
* 将内存子代理(inMemory)固化为普通持久化对话:
|
|
6530
|
+
* 写入磁盘 .jsonl 会话文件,清除 isSubagent 标记,使它进入历史会话列表并长久保留。
|
|
6531
|
+
*/
|
|
6532
|
+
async persistConversation(id) {
|
|
6533
|
+
const conv = this.convs.get(id);
|
|
6534
|
+
if (!conv) {
|
|
6535
|
+
this.emit({
|
|
6536
|
+
type: "notice",
|
|
6537
|
+
level: "warning",
|
|
6538
|
+
text: "该对话不存在或已关闭",
|
|
6539
|
+
textEn: "This conversation does not exist or is already closed",
|
|
6540
|
+
});
|
|
6541
|
+
return;
|
|
6542
|
+
}
|
|
6543
|
+
const sm = conv.session.sessionManager;
|
|
6544
|
+
if (!conv.isSubagent && sm?.isPersisted?.()) {
|
|
6545
|
+
this.emit({
|
|
6546
|
+
type: "notice",
|
|
6547
|
+
level: "info",
|
|
6548
|
+
text: `对话「${conv.title}」已是持久化对话,无需固化`,
|
|
6549
|
+
textEn: `Conversation "${conv.title}" is already persistent`,
|
|
6550
|
+
});
|
|
6551
|
+
return;
|
|
6552
|
+
}
|
|
6553
|
+
try {
|
|
6554
|
+
const cwd = conv.cwd || this.cwd;
|
|
6555
|
+
const sampleSm = SessionManager.create(cwd);
|
|
6556
|
+
const sessionDir = sampleSm.getSessionDir();
|
|
6557
|
+
if (!existsSync(sessionDir)) {
|
|
6558
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
6559
|
+
}
|
|
6560
|
+
const timestamp = new Date().toISOString();
|
|
6561
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
6562
|
+
const sessionId = sm?.getSessionId?.() || randomUUID();
|
|
6563
|
+
const sessionFile = join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
|
|
6564
|
+
// 获取所有已存在的 entries 并写盘
|
|
6565
|
+
const entries = sm?.fileEntries ?? [];
|
|
6566
|
+
let entriesToWrite = entries;
|
|
6567
|
+
if (!entriesToWrite.some((e) => e?.type === "session")) {
|
|
6568
|
+
const header = {
|
|
6569
|
+
type: "session",
|
|
6570
|
+
version: 3,
|
|
6571
|
+
id: sessionId,
|
|
6572
|
+
timestamp,
|
|
6573
|
+
cwd,
|
|
6574
|
+
};
|
|
6575
|
+
entriesToWrite = [header, ...entriesToWrite];
|
|
6576
|
+
}
|
|
6577
|
+
writeFileSync(sessionFile, entriesToWrite.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8");
|
|
6578
|
+
// 切换 SessionManager 内部状态,后续消息自动追加写盘
|
|
6579
|
+
if (sm) {
|
|
6580
|
+
sm.setSessionFile(sessionFile);
|
|
6581
|
+
sm.sessionDir = sessionDir;
|
|
6582
|
+
sm.persist = true;
|
|
6583
|
+
sm.flushed = true;
|
|
6584
|
+
}
|
|
6585
|
+
conv.isSubagent = false;
|
|
6586
|
+
this.emitConversations();
|
|
6587
|
+
await this.pushProjects();
|
|
6588
|
+
this.flushSnapshot();
|
|
6589
|
+
this.emit({
|
|
6590
|
+
type: "notice",
|
|
6591
|
+
level: "info",
|
|
6592
|
+
text: `已将子代理「${conv.title}」固化为普通对话,并保存至历史记录`,
|
|
6593
|
+
textEn: `Solidified subagent "${conv.title}" into a regular conversation saved to history`,
|
|
6594
|
+
});
|
|
6595
|
+
}
|
|
6596
|
+
catch (err) {
|
|
6597
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6598
|
+
this.emit({
|
|
6599
|
+
type: "notice",
|
|
6600
|
+
level: "error",
|
|
6601
|
+
text: `固化子代理失败:${msg}`,
|
|
6602
|
+
textEn: `Failed to persist subagent: ${msg}`,
|
|
6603
|
+
});
|
|
6604
|
+
}
|
|
6605
|
+
}
|
|
5935
6606
|
/** Dismiss a running conversation from the left-panel list without deleting its
|
|
5936
6607
|
* transcript file. Only idle (non-streaming) conversations that are not
|
|
5937
6608
|
* retained by terminal/wake/review state can be dismissed. The session stays
|
|
@@ -6520,6 +7191,7 @@ export class ClientSession {
|
|
|
6520
7191
|
// Preserve the model the user had selected — fork() seeds a new
|
|
6521
7192
|
// branch with the ModelRuntime default model otherwise.
|
|
6522
7193
|
const prevModel = this.session.agent.state.model ?? null;
|
|
7194
|
+
const prevThinking = this.session.thinkingLevel ?? null;
|
|
6523
7195
|
const result = await this.runtime.fork(entryId);
|
|
6524
7196
|
if (result.cancelled) {
|
|
6525
7197
|
this.emit({
|
|
@@ -6543,6 +7215,14 @@ export class ClientSession {
|
|
|
6543
7215
|
// model no longer resolvable — keep the default
|
|
6544
7216
|
}
|
|
6545
7217
|
}
|
|
7218
|
+
if (prevThinking) {
|
|
7219
|
+
try {
|
|
7220
|
+
this.session.setThinkingLevel(prevThinking);
|
|
7221
|
+
}
|
|
7222
|
+
catch {
|
|
7223
|
+
// model no longer supports previous thinking level
|
|
7224
|
+
}
|
|
7225
|
+
}
|
|
6546
7226
|
await this.prompt(trimmed, attachments);
|
|
6547
7227
|
this.emit({
|
|
6548
7228
|
type: "notice",
|
|
@@ -6922,18 +7602,20 @@ export class ClientSession {
|
|
|
6922
7602
|
});
|
|
6923
7603
|
this.flushSnapshot();
|
|
6924
7604
|
// First visit to this project: resume its most recent session —
|
|
6925
|
-
// unless that transcript is
|
|
7605
|
+
// unless that transcript is still held on another client (#145):
|
|
6926
7606
|
// default-opening it would strand the tab on a conversation it
|
|
6927
|
-
// cannot use (the prompt guard refuses) with a
|
|
6928
|
-
// forks history once
|
|
7607
|
+
// cannot use (the prompt guard refuses while streaming) with a
|
|
7608
|
+
// stale leaf that forks history once both sides send (idle-held
|
|
7609
|
+
// files fork the same way — 第二个写者不只跑着时才危险). Land
|
|
7610
|
+
// blank instead.
|
|
6929
7611
|
let resumeSkipped = null;
|
|
6930
|
-
//
|
|
6931
|
-
if (this.
|
|
7612
|
+
// 别处无可见行时不扫目录(首访切项目的常见情形零开销)。
|
|
7613
|
+
if ((this.listExternalRunning?.() ?? []).length > 0) {
|
|
6932
7614
|
try {
|
|
6933
7615
|
const infos = await SessionManager.list(abs, piSessionsRoot());
|
|
6934
7616
|
const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
|
|
6935
7617
|
const owner = recent ? this.findSessionOwner?.(recent) : null;
|
|
6936
|
-
if (owner
|
|
7618
|
+
if (owner && (owner.connected || owner.isStreaming)) {
|
|
6937
7619
|
resumeSkipped = owner;
|
|
6938
7620
|
}
|
|
6939
7621
|
}
|
|
@@ -6967,12 +7649,19 @@ export class ClientSession {
|
|
|
6967
7649
|
}
|
|
6968
7650
|
await this.bindSession();
|
|
6969
7651
|
if (resumeSkipped) {
|
|
6970
|
-
this.emit(
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
7652
|
+
this.emit(resumeSkipped.isStreaming
|
|
7653
|
+
? {
|
|
7654
|
+
type: "notice",
|
|
7655
|
+
level: "info",
|
|
7656
|
+
text: `该项目最近的对话「${resumeSkipped.title}」正在另一处运行,为你停在了新对话 —— 直接打开会造出第二个写者。左栏「运行的对话」里能看到它(标着“另一处”),等它跑完再打开。`,
|
|
7657
|
+
textEn: `The most recent conversation ("${resumeSkipped.title}") is running in another window, so you landed on a new chat instead — opening it here would create a second writer. It is listed under Running chats (tagged "Elsewhere"); open it after it finishes.`,
|
|
7658
|
+
}
|
|
7659
|
+
: {
|
|
7660
|
+
type: "notice",
|
|
7661
|
+
level: "info",
|
|
7662
|
+
text: `该项目最近的对话「${resumeSkipped.title}」在另一处开着(当前空闲),为你停在了空白新对话 —— 可在左栏「运行的对话」里把它过户过来继续看,或从历史对话里打开(只留一处发送消息,否则历史分叉)。`,
|
|
7663
|
+
textEn: `The most recent conversation ("${resumeSkipped.title}") is still open in another window (currently idle), so you landed on a blank chat instead — take it over from Running chats (tagged "Elsewhere") or reopen it from History (send new messages from only one place, or the history will fork).`,
|
|
7664
|
+
});
|
|
6976
7665
|
}
|
|
6977
7666
|
}
|
|
6978
7667
|
this.pushTerminals();
|
|
@@ -7138,6 +7827,59 @@ export class ClientSession {
|
|
|
7138
7827
|
}
|
|
7139
7828
|
this.flushSnapshot();
|
|
7140
7829
|
}
|
|
7830
|
+
/** Set the GLOBAL default model ("provider/id"): projects with no memory
|
|
7831
|
+
* fall back to it (project memory wins). Also remembers the provider's
|
|
7832
|
+
* currently-active key globally so new projects restore the same {model,
|
|
7833
|
+
* key} pair. Shared across clients, persisted server-side. */
|
|
7834
|
+
async setDefaultModel(modelId) {
|
|
7835
|
+
try {
|
|
7836
|
+
const mr = this.runtime.services.modelRuntime;
|
|
7837
|
+
const slash = modelId.indexOf("/");
|
|
7838
|
+
if (slash <= 0 || slash === modelId.length - 1) {
|
|
7839
|
+
throw new Error(`无效的模型 ID:${modelId}`);
|
|
7840
|
+
}
|
|
7841
|
+
const provider = modelId.slice(0, slash);
|
|
7842
|
+
const id = modelId.slice(slash + 1);
|
|
7843
|
+
if (!mr.getModel(provider, id))
|
|
7844
|
+
throw new Error(`模型不存在:${modelId}`);
|
|
7845
|
+
this.stateStore.saveDefaultModel(modelId);
|
|
7846
|
+
const active = this.modelAdmin.getActiveKeyName(provider);
|
|
7847
|
+
if (active)
|
|
7848
|
+
this.stateStore.saveDefaultProviderKey(provider, active);
|
|
7849
|
+
this.pushDefaultModel();
|
|
7850
|
+
this.emit({
|
|
7851
|
+
type: "notice",
|
|
7852
|
+
level: "info",
|
|
7853
|
+
text: `🌍 已设全局默认模型 ${modelId}(新项目自动使用,项目记忆优先)`,
|
|
7854
|
+
textEn: `🌍 Global default model set to ${modelId} (new projects follow it; project memory wins)`,
|
|
7855
|
+
});
|
|
7856
|
+
}
|
|
7857
|
+
catch (err) {
|
|
7858
|
+
this.emit({
|
|
7859
|
+
type: "notice",
|
|
7860
|
+
level: "error",
|
|
7861
|
+
text: `设置全局默认模型失败:${err.message}`,
|
|
7862
|
+
textEn: `Failed to set global default model: ${err.message}`,
|
|
7863
|
+
});
|
|
7864
|
+
}
|
|
7865
|
+
this.flushSnapshot();
|
|
7866
|
+
}
|
|
7867
|
+
/** Clear the GLOBAL default model (new projects fall back to the SDK default). */
|
|
7868
|
+
clearDefaultModel() {
|
|
7869
|
+
this.stateStore.clearDefaultModel();
|
|
7870
|
+
this.pushDefaultModel();
|
|
7871
|
+
this.emit({
|
|
7872
|
+
type: "notice",
|
|
7873
|
+
level: "info",
|
|
7874
|
+
text: "🌍 已清除全局默认模型(新项目回到 SDK 默认)",
|
|
7875
|
+
textEn: "🌍 Global default model cleared (new projects use the SDK default)",
|
|
7876
|
+
});
|
|
7877
|
+
this.flushSnapshot();
|
|
7878
|
+
}
|
|
7879
|
+
/** Push the current global default model (attach + after every change). */
|
|
7880
|
+
pushDefaultModel() {
|
|
7881
|
+
this.emit({ type: "default_model", modelId: this.stateStore.getDefaultModel() ?? null });
|
|
7882
|
+
}
|
|
7141
7883
|
/** 切换模型,失败时抛出(无头路径专用:插件 host.chat / 定时任务)。
|
|
7142
7884
|
* setModel 为兼容 UI 把异常吞成 notice(面板要能看到原因、调用方是 fire-and-forget),
|
|
7143
7885
|
* 无头路径拿不到那个 notice,于是「模型 ID 打错/没配密钥」会变成静默按旧模型跑 ——
|
|
@@ -7160,7 +7902,12 @@ export class ClientSession {
|
|
|
7160
7902
|
/** Set the thinking level for future turns. */
|
|
7161
7903
|
setThinking(level) {
|
|
7162
7904
|
try {
|
|
7163
|
-
|
|
7905
|
+
const thinkingLevel = level;
|
|
7906
|
+
this.session.setThinkingLevel(thinkingLevel, { persist: true });
|
|
7907
|
+
const cur = this.session.model;
|
|
7908
|
+
if (cur) {
|
|
7909
|
+
this.session.settingsManager.setModelThinkingLevel(cur.provider, cur.id, thinkingLevel);
|
|
7910
|
+
}
|
|
7164
7911
|
}
|
|
7165
7912
|
catch (err) {
|
|
7166
7913
|
this.emit({
|
|
@@ -7174,7 +7921,11 @@ export class ClientSession {
|
|
|
7174
7921
|
}
|
|
7175
7922
|
cycleThinking() {
|
|
7176
7923
|
try {
|
|
7177
|
-
this.session.cycleThinkingLevel();
|
|
7924
|
+
const nextLevel = this.session.cycleThinkingLevel({ persist: true });
|
|
7925
|
+
const cur = this.session.model;
|
|
7926
|
+
if (cur && nextLevel) {
|
|
7927
|
+
this.session.settingsManager.setModelThinkingLevel(cur.provider, cur.id, nextLevel);
|
|
7928
|
+
}
|
|
7178
7929
|
}
|
|
7179
7930
|
catch (err) {
|
|
7180
7931
|
this.emit({
|
|
@@ -7302,6 +8053,8 @@ export class AgentService {
|
|
|
7302
8053
|
/** index.ts 注入:内置调度存储(attach 时拷贝到每个新会话,供 schedule_* 工具)。 */
|
|
7303
8054
|
schedulerStore = undefined;
|
|
7304
8055
|
clients = new Map();
|
|
8056
|
+
/** 全局认领表(跨浏览器标签页共享;<dataDir>/claims.json,best-effort 持久化)。 */
|
|
8057
|
+
claimStore;
|
|
7305
8058
|
/** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
|
|
7306
8059
|
* session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
|
|
7307
8060
|
* once existing runs finish. Controlled via the local control socket:
|
|
@@ -7322,6 +8075,7 @@ export class AgentService {
|
|
|
7322
8075
|
constructor(cwd, stateFile) {
|
|
7323
8076
|
this.cwd = cwd;
|
|
7324
8077
|
this.stateStore = new ClientStateStore(stateFile);
|
|
8078
|
+
this.claimStore = new ClaimStore(join(this.stateStore.dataDir, "claims.json"));
|
|
7325
8079
|
}
|
|
7326
8080
|
/** Get or create the session for a client, racing attach calls safely. */
|
|
7327
8081
|
/** True while the service is draining — new work is refused. */
|
|
@@ -7567,6 +8321,23 @@ export class AgentService {
|
|
|
7567
8321
|
}
|
|
7568
8322
|
return out;
|
|
7569
8323
|
}
|
|
8324
|
+
/** 按 SDK 会话(runtime 身份)找它当前归属的客户端会话与对话 id(过户后归属会变):
|
|
8325
|
+
* 桥接工具(问卷 / 页面)在调用瞬间用它投递,见 ClientSession.bridgeTarget。
|
|
8326
|
+
* 一条对话任一时刻只属于一个会话(过户先摘后插),扫一遍即可 —— 问卷/截图都是
|
|
8327
|
+
* 低频调用,不值得为此再维护一张全局索引。 */
|
|
8328
|
+
findConversationHome(sdkSession) {
|
|
8329
|
+
for (const cs of this.clients.values()) {
|
|
8330
|
+
try {
|
|
8331
|
+
const convId = cs.conversationIdOfSession(sdkSession);
|
|
8332
|
+
if (convId)
|
|
8333
|
+
return { session: cs, convId };
|
|
8334
|
+
}
|
|
8335
|
+
catch {
|
|
8336
|
+
// 单客户端坏了不影响解析
|
|
8337
|
+
}
|
|
8338
|
+
}
|
|
8339
|
+
return undefined;
|
|
8340
|
+
}
|
|
7570
8341
|
/** issue #145:别处所有正在跑的对话(左栏 elsewhere 只读感知 + 手动过户用)。
|
|
7571
8342
|
* owner/convId 标识过户目标(手动过户入口);DSH 引擎不填(不可过户)。 */
|
|
7572
8343
|
listExternalRunning(excludeClientId) {
|
|
@@ -7896,6 +8667,8 @@ export class AgentService {
|
|
|
7896
8667
|
cs.findSessionOwner = (targetPath) => this.findSessionOwner(targetPath, clientId);
|
|
7897
8668
|
cs.hasStreamingElsewhere = () => this.hasStreamingElsewhere(clientId);
|
|
7898
8669
|
cs.listProjectRunners = (cwd) => this.listProjectRunners(cwd, clientId);
|
|
8670
|
+
cs.getClaimStore = () => this.claimStore;
|
|
8671
|
+
cs.findConversationHome = (sdkSession) => this.findConversationHome(sdkSession);
|
|
7899
8672
|
cs.listExternalRunning = () => this.listExternalRunning(clientId);
|
|
7900
8673
|
cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
|
|
7901
8674
|
cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
|
|
@@ -8094,17 +8867,19 @@ export class AgentService {
|
|
|
8094
8867
|
}
|
|
8095
8868
|
}
|
|
8096
8869
|
// Sessions use the SDK default per-project dir — no per-client dir.
|
|
8097
|
-
// issue #145:新标签页默认恢复项目最近的会话 ——
|
|
8098
|
-
//
|
|
8099
|
-
//
|
|
8870
|
+
// issue #145:新标签页默认恢复项目最近的会话 —— 若那条仍被别处持有
|
|
8871
|
+
// (跑着或空闲),建之前就决定空白(第二个 writer 根本不会被打开,
|
|
8872
|
+
// 也无需事后拆 runtime)。之前只拦 running:空闲持有照样恢复出双
|
|
8873
|
+
// writer,两边轮流发送分叉历史。其他客户端不存在时不扫目录。
|
|
8100
8874
|
let createOpts;
|
|
8101
|
-
if (this.
|
|
8875
|
+
if (this.clients.size > 0) {
|
|
8102
8876
|
try {
|
|
8103
8877
|
const infos = await SessionManager.list(cwd, piSessionsRoot());
|
|
8104
8878
|
const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
|
|
8105
8879
|
const owner = recent ? this.findSessionOwner(recent, clientId) : null;
|
|
8106
|
-
if (owner
|
|
8107
|
-
createOpts = { blank: true, blankTitle: owner.title };
|
|
8880
|
+
if (owner && (owner.connected || owner.isStreaming)) {
|
|
8881
|
+
createOpts = { blank: true, blankTitle: owner.title, ...(owner.isStreaming ? {} : { idleHeld: true }) };
|
|
8882
|
+
}
|
|
8108
8883
|
}
|
|
8109
8884
|
catch {
|
|
8110
8885
|
// 列表失败不挡正常恢复
|