pi-web-ui 0.97.0 → 0.98.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 +97 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/pi-web-ui.mjs +64 -23
- package/dist/server/agent-service.js +637 -145
- package/dist/server/approval-rules.js +5 -10
- package/dist/server/atomic-file.js +40 -0
- package/dist/server/attachments.js +22 -0
- package/dist/server/claim-store.js +5 -11
- package/dist/server/client-state.js +220 -23
- package/dist/server/compact-context-tool.js +3 -1
- package/dist/server/compacted-history.js +7 -5
- package/dist/server/composer-drafts.js +4 -6
- package/dist/server/control-socket.js +0 -40
- package/dist/server/delegate-mode.js +6 -12
- package/dist/server/dsh/dsh-agent-service.js +32 -30
- package/dist/server/dsh/dsh-client.js +45 -9
- package/dist/server/dsh/dsh-serialize.js +12 -10
- package/dist/server/dsh/dsh-sessions.js +20 -6
- package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
- package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
- package/dist/server/ensure-bash.js +3 -5
- package/dist/server/eval-tool.js +8 -4
- package/dist/server/goal-review-gate.js +7 -20
- package/dist/server/goal-service.js +49 -5
- package/dist/server/index.js +83 -15
- package/dist/server/launch-origin.js +0 -4
- package/dist/server/locales.js +3 -5
- package/dist/server/lsp-tool.js +110 -14
- package/dist/server/markers/builtins/notify.js +4 -2
- package/dist/server/markers/builtins/rename.js +0 -1
- package/dist/server/markers/builtins/todo.js +6 -26
- package/dist/server/markers/marker.js +0 -15
- package/dist/server/markers/registry.js +0 -3
- package/dist/server/markers/store.js +0 -3
- package/dist/server/mcp-hot-reload.js +12 -5
- package/dist/server/model-admin.js +19 -18
- package/dist/server/model-enrich.js +0 -3
- package/dist/server/patch-tool.js +21 -6
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +150 -5
- package/dist/server/plan-mode.js +58 -20
- package/dist/server/plugin-api-catalog.js +2 -0
- package/dist/server/plugin-catalog.js +6 -6
- package/dist/server/plugin-dom.js +3 -4
- package/dist/server/plugin-facilities.js +3 -5
- package/dist/server/plugin-grants.js +5 -8
- package/dist/server/plugin-permissions.js +4 -6
- package/dist/server/plugin-schedule.js +4 -7
- package/dist/server/plugin-updater.js +151 -38
- package/dist/server/plugins.js +16 -2
- package/dist/server/prompt-composer.js +12 -3
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/resolve-global-sdk.js +8 -0
- package/dist/server/schedule-agent-tool.js +5 -1
- package/dist/server/scheduler-tasks.js +46 -27
- package/dist/server/sdk-origin.js +27 -0
- package/dist/server/serialize.js +22 -9
- package/dist/server/slash-commands.js +16 -0
- package/dist/server/subagent-templates.js +4 -10
- package/dist/server/subagents.js +22 -11
- package/dist/server/terminals.js +11 -6
- package/dist/server/text-quote.js +59 -0
- package/dist/server/tool-manager.js +12 -3
- package/dist/server/tool-overrides.js +24 -0
- package/dist/server/update-check.js +69 -25
- package/extensions/webui.ts +15 -1
- package/package.json +31 -19
- package/plugin-sdk/client-utils.mjs +92 -0
- package/plugin-sdk/index.d.ts +21 -0
- package/plugin-sdk/index.mjs +2 -0
- package/plugins/catalog.json +7 -0
- package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-C88r3r3Q.js} +1 -1
- package/web/dist/assets/index-2jDtjHvW.css +1 -0
- package/web/dist/assets/index-BjdK7s1M.js +368 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -1
- package/web/public/sw.js +2 -1
- package/web/dist/assets/index-DwToli0J.css +0 -1
- package/web/dist/assets/index-oI7UKOqJ.js +0 -368
|
@@ -19,10 +19,11 @@ import { spawn } from "node:child_process";
|
|
|
19
19
|
import { createHash, randomUUID } from "node:crypto";
|
|
20
20
|
import { createRequire } from "node:module";
|
|
21
21
|
import * as fsPromises from "node:fs/promises";
|
|
22
|
-
import { appendFileSync, existsSync, readFileSync,
|
|
22
|
+
import { appendFileSync, existsSync, readFileSync, rmSync, statSync, mkdirSync, watch, writeFileSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { writeAtomicSync } from "./atomic-file.js";
|
|
26
27
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createEditToolDefinition, createLocalBashOperations, createWriteToolDefinition, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
27
28
|
import { Type } from "typebox";
|
|
28
29
|
import { BgServerTracker } from "./bg-servers.js";
|
|
@@ -54,7 +55,7 @@ import { PlanManager } from "./plan-manager.js";
|
|
|
54
55
|
import { buildPlanModePrompt, planModeDenial, planModeNoticeText, PLAN_MODE_BLOCKED_TOOL_NAMES } from "./plan-mode.js";
|
|
55
56
|
import { DELEGATION_SYSTEM_PROMPT, delegationDenial, delegateNoticeText } from "./delegate-mode.js";
|
|
56
57
|
import { goalReviewDenial, shouldDeferPromptForReview } from "./goal-review-gate.js";
|
|
57
|
-
import { readPlanModeFromSession } from "./permission-preset.js";
|
|
58
|
+
import { readPlanModeFromSession, readPlanFromSession } from "./permission-preset.js";
|
|
58
59
|
import { readDelegateModeFromSession } from "./permission-preset.js";
|
|
59
60
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
|
|
60
61
|
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isAgentToolEnabled, isTerminalGuidanceOn, localizedName, MARKERS_LIST_TOOL_NAME, PI_AGENT_PRESETS, PI_PERMISSION_OPTIONS, PLAN_UPDATE_TOOL_NAME, PRESENT_FILES_TOOL_NAME, presetAllowsPluginTools, presetHasQuestionnaire, presetShowsSkillCatalog, } from "./tool-manager.js";
|
|
@@ -66,7 +67,7 @@ import { makeEditSoftTool } from "./edit-soft-tool.js";
|
|
|
66
67
|
// 覆盖 SDK 内置 read:路径是目录时列出目录条目(行为开关 readDirEnabled,默认开)。
|
|
67
68
|
// 覆盖定义与「与扩展同名工具共存」的注入辅助分在两个文件(后者的依据见 tool-overrides.ts)。
|
|
68
69
|
import { makeReadDirTool, withReadDirSupport } from "./read-tool.js";
|
|
69
|
-
import { installToolOverrides, } from "./tool-overrides.js";
|
|
70
|
+
import { installToolOverrides, syncSubagentOverride, } from "./tool-overrides.js";
|
|
70
71
|
// 展示文件给用户(present_files):图片/视频内联、文本开预览弹窗、本地打开按钮。
|
|
71
72
|
import { makePresentFilesTool } from "./present-files-tool.js";
|
|
72
73
|
// 主动压缩上下文工具(compact_context):AI 主动根据当前问题精简上下文并自主控制范围。
|
|
@@ -88,10 +89,11 @@ import { makePatchTool } from "./patch-tool.js";
|
|
|
88
89
|
import { makeLspTool } from "./lsp-tool.js";
|
|
89
90
|
import { sameSessionFile } from "./scheduler-tasks.js";
|
|
90
91
|
import { buildAttachmentMessages, parseModelSpec } from "./attachments.js";
|
|
92
|
+
import { formatQuotedPrompt, readTextQuote } from "./text-quote.js";
|
|
91
93
|
import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
|
|
92
94
|
import { isNotRepoError, scmCommitContext } from "./scm.js";
|
|
93
95
|
import { buildCommitMsgInput, buildCommitMsgPrompt, sanitizeCommitMessage } from "./scm-commitmsg.js";
|
|
94
|
-
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, estimatePromptTokens, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
96
|
+
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, estimatePromptTokens, renderPromptTemplate, resolveSectionTexts, splitExtensionWrap, } from "./prompt-composer.js";
|
|
95
97
|
import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
96
98
|
import { findEntryByUiId, serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
97
99
|
import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
|
|
@@ -159,6 +161,25 @@ export class QuiesceRejectedError extends Error {
|
|
|
159
161
|
/** 自家内联扩展名(组合模板渲染,见 prompt-composer.ts)。SDK 以其
|
|
160
162
|
* "<inline:<name>>" 作为 path;扩展白名单/禁用过滤必须放行它。 */
|
|
161
163
|
const INLINE_PERSONA_EXT = "<inline:pi-webui-persona>";
|
|
164
|
+
/** 本次 run 的 SDK 原始提示词 + 早前扩展的首尾增补(见 splitExtensionWrap)。
|
|
165
|
+
* 导出仅为单测(tests/unit/persona-prompt-chain.test.ts)可达。
|
|
166
|
+
* systemPromptOptions 就是 runner 里那个共享可变对象,forceSystemPrompt 有值即
|
|
167
|
+
* 说明前面有扩展替换过提示词;临时清掉再读 event.systemPrompt 就拿到原始基线
|
|
168
|
+
* (公开字段,不碰 SDK 内部)。没人动过时零开销,直接返回当前文本。 */
|
|
169
|
+
export function splitAgentStartPrompt(event) {
|
|
170
|
+
const current = event.systemPrompt;
|
|
171
|
+
const opts = event.systemPromptOptions;
|
|
172
|
+
const forced = opts?.forceSystemPrompt;
|
|
173
|
+
if (!opts || typeof forced !== "string")
|
|
174
|
+
return { pre: "", core: current, post: "" };
|
|
175
|
+
try {
|
|
176
|
+
opts.forceSystemPrompt = undefined;
|
|
177
|
+
return splitExtensionWrap(event.systemPrompt, current);
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
opts.forceSystemPrompt = forced;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
162
183
|
/** Pi 包文档路径(composer 的 {{pi_docs}} 自动内容用)。随安装位置解析一次。 */
|
|
163
184
|
const PI_DOC_PATHS = (() => {
|
|
164
185
|
try {
|
|
@@ -738,7 +759,7 @@ function wrapEditSoftToolWithPermission(tool, cwd, getPermission, getRoots, getL
|
|
|
738
759
|
/**
|
|
739
760
|
* 结构化任务执行计划更新工具(plan_update)— Plan Mode / Step State Machine。
|
|
740
761
|
*/
|
|
741
|
-
export function makePlanUpdateTool(planManager,
|
|
762
|
+
export function makePlanUpdateTool(planManager, getActiveConvTarget, emit, flushSnapshot, onPersist) {
|
|
742
763
|
return {
|
|
743
764
|
name: PLAN_UPDATE_TOOL_NAME,
|
|
744
765
|
label: "plan_update",
|
|
@@ -765,9 +786,21 @@ export function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnap
|
|
|
765
786
|
activeStepId: Type.Optional(Type.String({ description: "ID of the step currently being executed" })),
|
|
766
787
|
}),
|
|
767
788
|
execute: async (toolCallId, params) => {
|
|
768
|
-
const
|
|
789
|
+
const target = getActiveConvTarget();
|
|
790
|
+
const convId = typeof target === "string" ? target : target.id;
|
|
791
|
+
const sessionId = typeof target === "string" ? undefined : target.sessionId;
|
|
769
792
|
const p = params;
|
|
770
|
-
const plan = planManager.setPlan(convId, p.steps, p.activeStepId);
|
|
793
|
+
const plan = planManager.setPlan(convId, p.steps, p.activeStepId, sessionId);
|
|
794
|
+
if (typeof target !== "string" && target.sessionManager) {
|
|
795
|
+
try {
|
|
796
|
+
const sm = target.sessionManager;
|
|
797
|
+
sm?.appendCustomEntry?.("plan/update", { plan });
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
// 转录追加失败不影响主流程
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
onPersist?.(convId, plan);
|
|
771
804
|
emit({
|
|
772
805
|
type: "plan_updated",
|
|
773
806
|
conversationId: convId,
|
|
@@ -1185,6 +1218,14 @@ function pluginToolToDefinition(tool) {
|
|
|
1185
1218
|
* unlikely to matter).
|
|
1186
1219
|
*/
|
|
1187
1220
|
function contentFingerprint(m) {
|
|
1221
|
+
const summary = m.summary;
|
|
1222
|
+
if (typeof summary === "string" && summary.length > 0) {
|
|
1223
|
+
let h = 5381;
|
|
1224
|
+
for (let i = 0; i < summary.length && i < 512; i++) {
|
|
1225
|
+
h = ((h << 5) + h + summary.charCodeAt(i)) >>> 0;
|
|
1226
|
+
}
|
|
1227
|
+
return `sum:${h.toString(36)}:${summary.length}`;
|
|
1228
|
+
}
|
|
1188
1229
|
const content = m.content;
|
|
1189
1230
|
if (!Array.isArray(content) || content.length === 0)
|
|
1190
1231
|
return "empty";
|
|
@@ -1259,31 +1300,10 @@ function previewToolResult(result) {
|
|
|
1259
1300
|
}
|
|
1260
1301
|
}
|
|
1261
1302
|
/**
|
|
1262
|
-
* 转录整文件重写的原子落盘:先写同目录临时文件再 rename
|
|
1263
|
-
* 的唯一事实源,compaction 收尾/清理的整文件重写若中途被打断(进程被杀/断电),
|
|
1264
|
-
* 原地 writeFileSync 会留下截断的 JSONL;同目录 rename 是原子的,最坏情况是
|
|
1265
|
-
* 旧文件原样保留,绝不会出现半份转录。Windows 下目标被外部占用(阅读器/杀软
|
|
1266
|
-
* 锁)时 rename 可能 EPERM —— 退回原地写保住功能(旧行为),不因小概率锁失败丢更新。
|
|
1303
|
+
* 转录整文件重写的原子落盘:先写同目录临时文件再 rename。
|
|
1267
1304
|
*/
|
|
1268
1305
|
function atomicWriteFileSync(file, data) {
|
|
1269
|
-
|
|
1270
|
-
try {
|
|
1271
|
-
writeFileSync(tmp, data);
|
|
1272
|
-
try {
|
|
1273
|
-
renameSync(tmp, file);
|
|
1274
|
-
}
|
|
1275
|
-
catch {
|
|
1276
|
-
writeFileSync(file, data);
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
finally {
|
|
1280
|
-
try {
|
|
1281
|
-
rmSync(tmp, { force: true });
|
|
1282
|
-
}
|
|
1283
|
-
catch {
|
|
1284
|
-
// 临时文件清不掉只能留给下次(同目录 .tmp,不影响转录本身)
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1306
|
+
writeAtomicSync(file, data);
|
|
1287
1307
|
}
|
|
1288
1308
|
/** Cap on simultaneously open NON-subagent conversations of ONE project (each keeps a full
|
|
1289
1309
|
* runtime alive; conversations of other projects keep their own lists).
|
|
@@ -1338,18 +1358,17 @@ function conversationTitle(session) {
|
|
|
1338
1358
|
}
|
|
1339
1359
|
return DEFAULT_CONV_TITLE;
|
|
1340
1360
|
}
|
|
1341
|
-
/**
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
1344
|
-
|
|
1361
|
+
/** 全局搜索的会话元信息匹配:大小写不敏感,命中任一项即算 ——
|
|
1362
|
+
* 显示名、当前项目内的文件名片段、首条消息。
|
|
1363
|
+
* 转录全文匹配不在这里:全文单条可达 MB 级,不再随列表缓存常驻(issue #440),
|
|
1364
|
+
* 由 filterSessionsForSearch 按需加载后判定(口径不变:user 与 assistant 消息文本)。 */
|
|
1365
|
+
function sessionMatchesMetadata(q, s) {
|
|
1345
1366
|
if (s.name && s.name.toLowerCase().includes(q))
|
|
1346
1367
|
return true;
|
|
1347
1368
|
if (basename(s.path).toLowerCase().includes(q))
|
|
1348
1369
|
return true;
|
|
1349
1370
|
if (s.firstMessage.toLowerCase().includes(q))
|
|
1350
1371
|
return true;
|
|
1351
|
-
if (s.allMessagesText.toLowerCase().includes(q))
|
|
1352
|
-
return true;
|
|
1353
1372
|
return false;
|
|
1354
1373
|
}
|
|
1355
1374
|
/** 抽取一条 AgentMessage 的可搜索文本(user/assistant 的 text 块;
|
|
@@ -1373,6 +1392,12 @@ function messageSearchText(m) {
|
|
|
1373
1392
|
/** 转录全文扫描/整读的大小上限(16MB,与 readHistorySession 一致):
|
|
1374
1393
|
* 超限文件同步 readFileSync 会卡事件循环数百毫秒起,搜索不值得。 */
|
|
1375
1394
|
const MAX_TRANSCRIPT_SCAN_BYTES = 16 * 1024 * 1024;
|
|
1395
|
+
/** 转录全文进入内存/缓存的字符上限(256K 字符):全文只服务搜索的命中判定
|
|
1396
|
+
* (issue #440 拆分后按需加载),封顶保证单条 worst case 内存可控(缓存 256 条
|
|
1397
|
+
* × 256K 字符 ≈ 64MB 上界,且只在搜索活动期间存在)。
|
|
1398
|
+
* 影响面:超长转录在截断点之后的内容不参与「是否命中」判定;命中后的消息级
|
|
1399
|
+
* 定位(collectSessionAnchors)仍扫全文件,不受此上限影响。 */
|
|
1400
|
+
const MAX_SEARCH_TEXT_CHARS = 256 * 1024;
|
|
1376
1401
|
/** 扫描一个会话转录文件,收集文本命中查询的消息锚点(role + timestamp,
|
|
1377
1402
|
* 按转录顺序,最多 cap 个)。仅 user/assistant 消息参与,与搜索范围一致。
|
|
1378
1403
|
* 超过大小上限的转录直接跳过(stat 先行,不整读)—— 全局搜索逐会话同步
|
|
@@ -1486,8 +1511,13 @@ async function discoverRecentProjectsFromDisk(sessionRoots, limit = 30) {
|
|
|
1486
1511
|
if (projects.size >= limit)
|
|
1487
1512
|
break;
|
|
1488
1513
|
try {
|
|
1489
|
-
|
|
1514
|
+
// 异步探测(issue #441):会话根可能在已断连的网络盘上,同步 existsSync 会冻结事件循环。
|
|
1515
|
+
try {
|
|
1516
|
+
await fsPromises.access(root);
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1490
1519
|
continue;
|
|
1520
|
+
}
|
|
1491
1521
|
const entries = await fsPromises.readdir(root, { withFileTypes: true });
|
|
1492
1522
|
// 1. 扁平布局(PI_CODING_AGENT_SESSION_DIR):根目录下直接是 .jsonl
|
|
1493
1523
|
const flatFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
@@ -1511,8 +1541,9 @@ async function discoverRecentProjectsFromDisk(sessionRoots, limit = 30) {
|
|
|
1511
1541
|
const key = normalizePathKey(cwd);
|
|
1512
1542
|
if (!projects.has(key)) {
|
|
1513
1543
|
try {
|
|
1514
|
-
|
|
1515
|
-
|
|
1544
|
+
// 异步探测(issue #441):cwd 可能在已断连的网络盘上。
|
|
1545
|
+
await fsPromises.access(cwd);
|
|
1546
|
+
projects.set(key, { path: cwd, lastUsed: s.mtime });
|
|
1516
1547
|
}
|
|
1517
1548
|
catch { }
|
|
1518
1549
|
}
|
|
@@ -1580,9 +1611,9 @@ async function discoverRecentProjectsFromDisk(sessionRoots, limit = 30) {
|
|
|
1580
1611
|
const key = normalizePathKey(r.path);
|
|
1581
1612
|
if (!projects.has(key)) {
|
|
1582
1613
|
try {
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1614
|
+
// 异步探测(issue #441):cwd 可能在已断连的网络盘上。
|
|
1615
|
+
await fsPromises.access(r.path);
|
|
1616
|
+
projects.set(key, r);
|
|
1586
1617
|
}
|
|
1587
1618
|
catch { }
|
|
1588
1619
|
}
|
|
@@ -1597,10 +1628,14 @@ async function discoverRecentProjectsFromDisk(sessionRoots, limit = 30) {
|
|
|
1597
1628
|
return [...projects.values()].sort((a, b) => b.lastUsed - a.lastUsed);
|
|
1598
1629
|
}
|
|
1599
1630
|
/**
|
|
1600
|
-
* 快速解析单个会话文件(提取 id/cwd/name/parentSessionPath/firstMessage/messageCount/modified
|
|
1601
|
-
*
|
|
1631
|
+
* 快速解析单个会话文件(提取 id/cwd/name/parentSessionPath/firstMessage/messageCount/modified,
|
|
1632
|
+
* 以及可选的转录全文 allMessagesText)。仅用于会话列表发现与搜索,避免调用 SDK 重型的全量树解析。
|
|
1633
|
+
*
|
|
1634
|
+
* withAllMessagesText=false(默认,列表路径)时不收集转录全文,allMessagesText 返回空串 ——
|
|
1635
|
+
* 全文单条可达 MB 级,绝不随列表进任何缓存(issue #440);true(搜索路径)时收集全文并在
|
|
1636
|
+
* MAX_SEARCH_TEXT_CHARS 处截断。
|
|
1602
1637
|
*/
|
|
1603
|
-
async function parseSessionInfoFast(filePath, fileMtime) {
|
|
1638
|
+
async function parseSessionInfoFast(filePath, fileMtime, withAllMessagesText = false) {
|
|
1604
1639
|
try {
|
|
1605
1640
|
const content = await fsPromises.readFile(filePath, "utf8");
|
|
1606
1641
|
const lines = content.split("\n");
|
|
@@ -1612,7 +1647,7 @@ async function parseSessionInfoFast(filePath, fileMtime) {
|
|
|
1612
1647
|
let messageCount = 0;
|
|
1613
1648
|
let lastActivityTime = 0;
|
|
1614
1649
|
let headerTime = 0;
|
|
1615
|
-
const allMessages = [];
|
|
1650
|
+
const allMessages = withAllMessagesText ? [] : null;
|
|
1616
1651
|
for (const line of lines) {
|
|
1617
1652
|
if (!line.trim())
|
|
1618
1653
|
continue;
|
|
@@ -1658,7 +1693,7 @@ async function parseSessionInfoFast(filePath, fileMtime) {
|
|
|
1658
1693
|
if (text) {
|
|
1659
1694
|
if (!firstMessage && msg.role === "user")
|
|
1660
1695
|
firstMessage = text;
|
|
1661
|
-
allMessages
|
|
1696
|
+
allMessages?.push(text);
|
|
1662
1697
|
}
|
|
1663
1698
|
}
|
|
1664
1699
|
}
|
|
@@ -1666,6 +1701,11 @@ async function parseSessionInfoFast(filePath, fileMtime) {
|
|
|
1666
1701
|
if (!id)
|
|
1667
1702
|
return null;
|
|
1668
1703
|
const modified = lastActivityTime > 0 ? lastActivityTime : headerTime > 0 ? headerTime : fileMtime;
|
|
1704
|
+
let allMessagesText = "";
|
|
1705
|
+
if (allMessages) {
|
|
1706
|
+
const joined = allMessages.join(" ");
|
|
1707
|
+
allMessagesText = joined.length > MAX_SEARCH_TEXT_CHARS ? joined.slice(0, MAX_SEARCH_TEXT_CHARS) : joined;
|
|
1708
|
+
}
|
|
1669
1709
|
return {
|
|
1670
1710
|
path: filePath,
|
|
1671
1711
|
id,
|
|
@@ -1676,7 +1716,7 @@ async function parseSessionInfoFast(filePath, fileMtime) {
|
|
|
1676
1716
|
modified: new Date(modified),
|
|
1677
1717
|
messageCount,
|
|
1678
1718
|
firstMessage: firstMessage || "(no messages)",
|
|
1679
|
-
allMessagesText
|
|
1719
|
+
allMessagesText,
|
|
1680
1720
|
};
|
|
1681
1721
|
}
|
|
1682
1722
|
catch {
|
|
@@ -1730,11 +1770,8 @@ export function sameCwd(a, b) {
|
|
|
1730
1770
|
const y = String(b ?? "").trim();
|
|
1731
1771
|
if (!x || !y)
|
|
1732
1772
|
return false;
|
|
1733
|
-
const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
1734
|
-
if (norm(x) === norm(y))
|
|
1735
|
-
return true;
|
|
1736
1773
|
try {
|
|
1737
|
-
return
|
|
1774
|
+
return normalizePathKey(x) === normalizePathKey(y);
|
|
1738
1775
|
}
|
|
1739
1776
|
catch {
|
|
1740
1777
|
return false;
|
|
@@ -2508,6 +2545,9 @@ export class ClientSession {
|
|
|
2508
2545
|
const handoffMessage = `[Peer Hand-off from ${fromType} subagent (${fromRunId.slice(0, 8)})]:\n\n${payload}`;
|
|
2509
2546
|
const record = { fromRunId, toRunId, timestamp: Date.now() };
|
|
2510
2547
|
this.subagentHandoffs.push(record);
|
|
2548
|
+
if (this.subagentHandoffs.length > 50) {
|
|
2549
|
+
this.subagentHandoffs = this.subagentHandoffs.slice(-50);
|
|
2550
|
+
}
|
|
2511
2551
|
if (!fromConv?.peerHandoffTo) {
|
|
2512
2552
|
if (fromConv)
|
|
2513
2553
|
fromConv.peerHandoffTo = [];
|
|
@@ -2999,7 +3039,7 @@ export class ClientSession {
|
|
|
2999
3039
|
* conversationId 记录谁问的:看门狗豁免、快照恢复都靠它。 */
|
|
3000
3040
|
pendingQuestions = new Map();
|
|
3001
3041
|
/** 任务计划管理器(Plan Mode / Step State Machine)。 */
|
|
3002
|
-
planManager
|
|
3042
|
+
planManager;
|
|
3003
3043
|
approvalSeq = 0;
|
|
3004
3044
|
/** 待审批高危工具调用(Human-in-the-Loop: Edit & Run)。 */
|
|
3005
3045
|
pendingApprovals = new Map();
|
|
@@ -3026,6 +3066,7 @@ export class ClientSession {
|
|
|
3026
3066
|
this.subagentTemplates = new SubagentTemplatesStore(join(stateStore.dataDir, "subagent-templates.json"));
|
|
3027
3067
|
this.approvalRules = new ApprovalRulesStore(join(stateStore.dataDir, "approval-rules.json"));
|
|
3028
3068
|
this.drafts = new ComposerDraftsStore(join(stateStore.dataDir, "composer-drafts.json"));
|
|
3069
|
+
this.planManager = new PlanManager(join(stateStore.dataDir, "plans.json"));
|
|
3029
3070
|
this.markerSvc = new MarkerService({
|
|
3030
3071
|
clientId,
|
|
3031
3072
|
stateStore,
|
|
@@ -3314,6 +3355,57 @@ export class ClientSession {
|
|
|
3314
3355
|
}
|
|
3315
3356
|
await cs.restoreProjectProviderKeysForCwd(cwd);
|
|
3316
3357
|
await cs.restoreProjectModelForCwd(cwd);
|
|
3358
|
+
if (conv.session.sessionFile && cs.stateStore?.isSessionPinned(cwd, conv.session.sessionFile)) {
|
|
3359
|
+
conv.pinned = true;
|
|
3360
|
+
conv.listed = true;
|
|
3361
|
+
}
|
|
3362
|
+
// 恢复本项目其他已被钉住的会话(重启后常驻运行列表,issue #433)
|
|
3363
|
+
const pinnedPaths = cs.stateStore?.getPinnedSessions(cwd) ?? [];
|
|
3364
|
+
for (const p of pinnedPaths) {
|
|
3365
|
+
if (cs.convs.size >= MAX_OPEN_CONVERSATIONS)
|
|
3366
|
+
break;
|
|
3367
|
+
const normP = normalizePathKey(p);
|
|
3368
|
+
const alreadyOpen = [...cs.convs.values()].some((c) => c.session.sessionFile && normalizePathKey(c.session.sessionFile) === normP);
|
|
3369
|
+
if (alreadyOpen)
|
|
3370
|
+
continue;
|
|
3371
|
+
try {
|
|
3372
|
+
const targetPath = resolve(p);
|
|
3373
|
+
if (!existsSync(targetPath))
|
|
3374
|
+
continue;
|
|
3375
|
+
cs.repairTranscriptFileBeforeOpen(targetPath);
|
|
3376
|
+
const sm = SessionManager.open(targetPath);
|
|
3377
|
+
const convId = cs.nextConversationId();
|
|
3378
|
+
const terms = cs.makeTerminalManager(convId, cwd);
|
|
3379
|
+
const rt = await createAgentSessionRuntime(cs.makeRuntimeFactory(terms, undefined, convId), {
|
|
3380
|
+
cwd,
|
|
3381
|
+
agentDir,
|
|
3382
|
+
sessionManager: sm,
|
|
3383
|
+
});
|
|
3384
|
+
const pinnedConv = cs.makeConversation(rt, convId, terms);
|
|
3385
|
+
pinnedConv.pinned = true;
|
|
3386
|
+
pinnedConv.listed = true;
|
|
3387
|
+
pinnedConv.promptedSinceActive = true;
|
|
3388
|
+
cs.convs.set(pinnedConv.id, pinnedConv);
|
|
3389
|
+
pinnedConv.unsubscribe = pinnedConv.session.subscribe((event) => cs.onEvent(pinnedConv, event));
|
|
3390
|
+
try {
|
|
3391
|
+
await pinnedConv.session.bindExtensions({
|
|
3392
|
+
mode: "rpc",
|
|
3393
|
+
uiContext: WebUIContext.headless(),
|
|
3394
|
+
onError: cs.makeExtensionErrorReporter({
|
|
3395
|
+
text: `会话 ${convId}:`,
|
|
3396
|
+
textEn: `Conversation ${convId}: `,
|
|
3397
|
+
}),
|
|
3398
|
+
});
|
|
3399
|
+
}
|
|
3400
|
+
catch {
|
|
3401
|
+
// 忽略扩展绑定失败
|
|
3402
|
+
}
|
|
3403
|
+
cs.applyToolGating(pinnedConv.session, pinnedConv.agentPreset);
|
|
3404
|
+
}
|
|
3405
|
+
catch {
|
|
3406
|
+
// best effort
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3317
3409
|
return cs;
|
|
3318
3410
|
}
|
|
3319
3411
|
/**
|
|
@@ -3443,6 +3535,11 @@ export class ClientSession {
|
|
|
3443
3535
|
hidden: true,
|
|
3444
3536
|
factory: (pi) => {
|
|
3445
3537
|
pi.on("before_agent_start", (event) => {
|
|
3538
|
+
// 我们是链上最后一个 handler:先把早前扩展的首尾增补摘出来(pre/post),
|
|
3539
|
+
// 只在 SDK 原始提示词 core 上做事,最后原样套回去——否则返回 systemPrompt
|
|
3540
|
+
// 会整体替换,把别的扩展注入的内容(如 <invoked_skill>)一起丢掉。
|
|
3541
|
+
const { pre, core, post } = splitAgentStartPrompt(event);
|
|
3542
|
+
const rewrap = (next) => pre + next + post;
|
|
3446
3543
|
// 子代理模板 replace(无 SYSTEM.md 时):默认分支拼好的提示词里
|
|
3447
3544
|
// 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
|
|
3448
3545
|
// systemPromptOverride 整体替换,此处边界不存在会自然跳过。
|
|
@@ -3450,15 +3547,11 @@ export class ClientSession {
|
|
|
3450
3547
|
const tplPrompt = pickTemplatePrompt(apply, this.getLang()).trim();
|
|
3451
3548
|
if (apply.promptMode !== "replace" || !tplPrompt)
|
|
3452
3549
|
return undefined;
|
|
3453
|
-
const boundary =
|
|
3550
|
+
const boundary = core.indexOf("\n\nAvailable tools:");
|
|
3454
3551
|
// 边界串是 SDK 提示词的内部格式:版本一变就可能对不上。
|
|
3455
3552
|
// 对不上时不再静默回退默认 persona(模板等于没生效),而是把模板
|
|
3456
3553
|
// 提示词前置拼接——角色约束仍在,只是灵魂段没被精确替换。
|
|
3457
|
-
|
|
3458
|
-
const fallback = `${tplPrompt}\n\n${event.systemPrompt}`;
|
|
3459
|
-
return fallback === event.systemPrompt ? undefined : { systemPrompt: fallback };
|
|
3460
|
-
}
|
|
3461
|
-
const swapped = tplPrompt + event.systemPrompt.slice(boundary);
|
|
3554
|
+
const swapped = rewrap(boundary === -1 ? `${tplPrompt}\n\n${core}` : tplPrompt + core.slice(boundary));
|
|
3462
3555
|
return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
|
|
3463
3556
|
}
|
|
3464
3557
|
// 主会话:按当前会话归属预设与真正活跃的工具列表组装系统提示词
|
|
@@ -3496,7 +3589,8 @@ export class ClientSession {
|
|
|
3496
3589
|
})),
|
|
3497
3590
|
preset: currentPreset,
|
|
3498
3591
|
});
|
|
3499
|
-
|
|
3592
|
+
// 不自定义时 rendered 为 undefined:不返回提示词,前面扩展的改动原样保留。
|
|
3593
|
+
return rendered ? { systemPrompt: rewrap(rendered) } : undefined;
|
|
3500
3594
|
});
|
|
3501
3595
|
},
|
|
3502
3596
|
},
|
|
@@ -3638,7 +3732,15 @@ export class ClientSession {
|
|
|
3638
3732
|
// 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
|
|
3639
3733
|
makeMarkersListTool(() => this.activeId, this.markerSvc),
|
|
3640
3734
|
// 结构化任务计划更新(Plan Mode / Step State Machine)。
|
|
3641
|
-
makePlanUpdateTool(this.planManager, () =>
|
|
3735
|
+
makePlanUpdateTool(this.planManager, () => {
|
|
3736
|
+
const id = ownerId ?? this.activeId;
|
|
3737
|
+
const c = this.convs.get(id);
|
|
3738
|
+
return {
|
|
3739
|
+
id,
|
|
3740
|
+
sessionId: c?.session?.sessionId,
|
|
3741
|
+
sessionManager: c?.session?.sessionManager,
|
|
3742
|
+
};
|
|
3743
|
+
}, (msg) => this.emit(msg), () => this.flushSnapshot()),
|
|
3642
3744
|
// 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
|
|
3643
3745
|
// 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
|
|
3644
3746
|
// DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
|
|
@@ -3694,9 +3796,9 @@ export class ClientSession {
|
|
|
3694
3796
|
lang: () => this.getLang(),
|
|
3695
3797
|
}),
|
|
3696
3798
|
// 高可靠行补丁工具(patch,基于内容哈希与语法块级替换)。
|
|
3697
|
-
makePatchTool({ cwd: effectiveCwd, ownerId }),
|
|
3799
|
+
makePatchTool({ cwd: effectiveCwd, ownerId, lang: () => this.getLang() }),
|
|
3698
3800
|
// 原生语言服务器工具(lsp,定义跳转/引用/悬停/诊断)。
|
|
3699
|
-
makeLspTool({ cwd: effectiveCwd, ownerId }),
|
|
3801
|
+
makeLspTool({ cwd: effectiveCwd, ownerId, lang: () => this.getLang() }),
|
|
3700
3802
|
].map((t) =>
|
|
3701
3803
|
// 目标审查闸门在最外层(理由最贴合此刻);插件工具也在这个数组里,
|
|
3702
3804
|
// 创建时注册的同样被闸门覆盖(后续动态补入的走 syncPluginTools,与
|
|
@@ -3730,7 +3832,7 @@ export class ClientSession {
|
|
|
3730
3832
|
}
|
|
3731
3833
|
/** Wrap a fresh runtime as a new conversation record. */
|
|
3732
3834
|
makeConversation(runtime, id, terminals) {
|
|
3733
|
-
|
|
3835
|
+
const conv = {
|
|
3734
3836
|
id,
|
|
3735
3837
|
title: conversationTitle(runtime.session),
|
|
3736
3838
|
isSubagent: false,
|
|
@@ -3773,6 +3875,42 @@ export class ClientSession {
|
|
|
3773
3875
|
toolWatchdogs: new Map(),
|
|
3774
3876
|
workspaceSnapshots: [],
|
|
3775
3877
|
};
|
|
3878
|
+
this.restorePlan(conv);
|
|
3879
|
+
return conv;
|
|
3880
|
+
}
|
|
3881
|
+
/** 会话计划回放恢复:持久化文件(sessionId)→ 转录 customType → 历史消息三重兜底。 */
|
|
3882
|
+
restorePlan(conv) {
|
|
3883
|
+
const sessionId = conv.session.sessionId;
|
|
3884
|
+
if (sessionId) {
|
|
3885
|
+
const existing = this.planManager.bindSession(conv.id, sessionId);
|
|
3886
|
+
if (existing)
|
|
3887
|
+
return;
|
|
3888
|
+
}
|
|
3889
|
+
// 2. 从 sessionManager entries 回放
|
|
3890
|
+
const smPlan = readPlanFromSession(conv.session.sessionManager);
|
|
3891
|
+
if (smPlan && Array.isArray(smPlan.steps) && smPlan.steps.length > 0) {
|
|
3892
|
+
this.planManager.setPlan(conv.id, smPlan.steps, smPlan.activeStepId, sessionId);
|
|
3893
|
+
return;
|
|
3894
|
+
}
|
|
3895
|
+
// 3. 从已有历史消息中回放最后一次成功的 plan_update 工具调用
|
|
3896
|
+
const msgs = conv.session.agent?.state?.messages;
|
|
3897
|
+
if (Array.isArray(msgs)) {
|
|
3898
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
3899
|
+
const msg = msgs[i];
|
|
3900
|
+
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
|
|
3901
|
+
for (const part of msg.content) {
|
|
3902
|
+
const p = part;
|
|
3903
|
+
if (p?.type === "tool_use" &&
|
|
3904
|
+
p.name === "plan_update" &&
|
|
3905
|
+
Array.isArray(p.input?.steps) &&
|
|
3906
|
+
p.input.steps.length > 0) {
|
|
3907
|
+
this.planManager.setPlan(conv.id, p.input.steps, p.input.activeStepId ?? null, sessionId);
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3776
3914
|
}
|
|
3777
3915
|
/** Summaries of conversations currently streaming — captured at shutdown
|
|
3778
3916
|
* so the next attach can reopen them and continue their runs. */
|
|
@@ -3980,9 +4118,13 @@ export class ClientSession {
|
|
|
3980
4118
|
});
|
|
3981
4119
|
};
|
|
3982
4120
|
}
|
|
3983
|
-
/** (Re)attach event plumbing to
|
|
3984
|
-
|
|
3985
|
-
|
|
4121
|
+
/** (Re)attach event plumbing to a conversation's session. 默认绑当前活跃对话;
|
|
4122
|
+
* forceReset 重建非活跃对话(子代理/角色对话)时必须显式传入该对话,
|
|
4123
|
+
* 否则重建后的会话永远拿不回事件订阅(issue #484)。
|
|
4124
|
+
* **public**:过户(take_over_conversation)后由 ClientSessionPool 对 **其它页面** 的
|
|
4125
|
+
* ClientSession 调用(切会话只重建 runtime,订阅得重新挂上,见 idle-takeover-test)。 */
|
|
4126
|
+
async bindSession(target) {
|
|
4127
|
+
const conv = target ?? this.conv;
|
|
3986
4128
|
conv.unsubscribe?.();
|
|
3987
4129
|
conv.session = conv.runtime.session;
|
|
3988
4130
|
await conv.session.bindExtensions({
|
|
@@ -4219,7 +4361,7 @@ export class ClientSession {
|
|
|
4219
4361
|
}
|
|
4220
4362
|
}
|
|
4221
4363
|
/** 插件扩展点 v2(供 conversationSearcher):运行中对话标题 + 历史会话全文匹配,返回前 N 个 {id,title}。
|
|
4222
|
-
* 复用 searchSessions
|
|
4364
|
+
* 复用 searchSessions 的匹配口径(元信息 + 按需加载的转录全文),只读不 emit。 */
|
|
4223
4365
|
async searchForPlugins(query, limit = 20) {
|
|
4224
4366
|
const q = query.trim().toLowerCase();
|
|
4225
4367
|
if (!q)
|
|
@@ -4233,9 +4375,8 @@ export class ClientSession {
|
|
|
4233
4375
|
}
|
|
4234
4376
|
try {
|
|
4235
4377
|
const infos = await this.loadSessionInfos();
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
continue;
|
|
4378
|
+
const matched = await ClientSession.filterSessionsForSearch(q, infos);
|
|
4379
|
+
for (const s of matched) {
|
|
4239
4380
|
out.push({
|
|
4240
4381
|
id: s.path,
|
|
4241
4382
|
title: (s.name?.trim() || s.firstMessage.trim() || basename(s.path)).slice(0, 60),
|
|
@@ -5019,7 +5160,41 @@ export class ClientSession {
|
|
|
5019
5160
|
// 一次扫描同时收齐「当前转写里的全部缓存键」(live 集合,供
|
|
5020
5161
|
// pruneMessageCache 精确回收死条目)与各自的序列化结果。
|
|
5021
5162
|
const live = new Set();
|
|
5022
|
-
let
|
|
5163
|
+
let agentMessages = conv.session.agent.state.messages;
|
|
5164
|
+
// issue #448: 多次压缩后 SDK state.messages 只保留最后一次压缩卡片。
|
|
5165
|
+
// 从当前分支祖先链中提取前序未展示的历史 compaction 并正序置前,
|
|
5166
|
+
// 使前端能逐段回溯展开历史折叠内容。
|
|
5167
|
+
if (conv.session.sessionManager?.getBranch) {
|
|
5168
|
+
try {
|
|
5169
|
+
const branch = conv.session.sessionManager.getBranch();
|
|
5170
|
+
const compEntries = branch.filter((e) => e.type === "compaction");
|
|
5171
|
+
if (compEntries.length > 1) {
|
|
5172
|
+
const existingCompKeys = new Set(agentMessages
|
|
5173
|
+
.filter((m) => m.role === "compactionSummary")
|
|
5174
|
+
.map((m) => `${m.timestamp}:${m.summary ?? ""}`));
|
|
5175
|
+
const missingComps = [];
|
|
5176
|
+
for (const ce of compEntries) {
|
|
5177
|
+
const ts = ce.timestamp ? new Date(ce.timestamp).getTime() : 0;
|
|
5178
|
+
const key = `${ts}:${ce.summary ?? ""}`;
|
|
5179
|
+
if (!existingCompKeys.has(key)) {
|
|
5180
|
+
missingComps.push({
|
|
5181
|
+
role: "compactionSummary",
|
|
5182
|
+
summary: ce.summary ?? "",
|
|
5183
|
+
tokensBefore: ce.tokensBefore,
|
|
5184
|
+
timestamp: ts,
|
|
5185
|
+
});
|
|
5186
|
+
}
|
|
5187
|
+
}
|
|
5188
|
+
if (missingComps.length > 0) {
|
|
5189
|
+
agentMessages = [...missingComps, ...agentMessages];
|
|
5190
|
+
}
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5193
|
+
catch {
|
|
5194
|
+
// 获取分支异常不影响当前渲染
|
|
5195
|
+
}
|
|
5196
|
+
}
|
|
5197
|
+
let rawMessages = agentMessages
|
|
5023
5198
|
.map((m) => {
|
|
5024
5199
|
const k = this.uiMessageKey(conv, m);
|
|
5025
5200
|
live.add(k.cacheKey);
|
|
@@ -5621,7 +5796,36 @@ export class ClientSession {
|
|
|
5621
5796
|
}
|
|
5622
5797
|
updatePlan(steps, activeStepId, conversationId) {
|
|
5623
5798
|
const convId = conversationId ?? this.activeId;
|
|
5624
|
-
const
|
|
5799
|
+
const conv = this.convs.get(convId);
|
|
5800
|
+
const sessionId = conv?.session?.sessionId;
|
|
5801
|
+
const plan = this.planManager.setPlan(convId, steps, activeStepId, sessionId);
|
|
5802
|
+
try {
|
|
5803
|
+
const sm = conv?.session?.sessionManager;
|
|
5804
|
+
sm?.appendCustomEntry?.("plan/update", { plan });
|
|
5805
|
+
}
|
|
5806
|
+
catch {
|
|
5807
|
+
// ignore
|
|
5808
|
+
}
|
|
5809
|
+
this.emit({
|
|
5810
|
+
type: "plan_updated",
|
|
5811
|
+
conversationId: convId,
|
|
5812
|
+
plan,
|
|
5813
|
+
});
|
|
5814
|
+
this.flushSnapshot();
|
|
5815
|
+
}
|
|
5816
|
+
updatePlanStep(stepId, patch, conversationId) {
|
|
5817
|
+
const convId = conversationId ?? this.activeId;
|
|
5818
|
+
const plan = this.planManager.updateStep(convId, stepId, patch);
|
|
5819
|
+
this.emit({
|
|
5820
|
+
type: "plan_updated",
|
|
5821
|
+
conversationId: convId,
|
|
5822
|
+
plan,
|
|
5823
|
+
});
|
|
5824
|
+
this.flushSnapshot();
|
|
5825
|
+
}
|
|
5826
|
+
deletePlanStep(stepId, conversationId) {
|
|
5827
|
+
const convId = conversationId ?? this.activeId;
|
|
5828
|
+
const plan = this.planManager.deleteStep(convId, stepId);
|
|
5625
5829
|
this.emit({
|
|
5626
5830
|
type: "plan_updated",
|
|
5627
5831
|
conversationId: convId,
|
|
@@ -5629,6 +5833,38 @@ export class ClientSession {
|
|
|
5629
5833
|
});
|
|
5630
5834
|
this.flushSnapshot();
|
|
5631
5835
|
}
|
|
5836
|
+
addPlanStep(step, afterStepId, conversationId) {
|
|
5837
|
+
const convId = conversationId ?? this.activeId;
|
|
5838
|
+
const plan = this.planManager.addStep(convId, step, afterStepId);
|
|
5839
|
+
this.emit({
|
|
5840
|
+
type: "plan_updated",
|
|
5841
|
+
conversationId: convId,
|
|
5842
|
+
plan,
|
|
5843
|
+
});
|
|
5844
|
+
this.flushSnapshot();
|
|
5845
|
+
}
|
|
5846
|
+
/** 「✨ 净室执行(Clean-session Handoff)」:
|
|
5847
|
+
* 关闭源会话的计划闸门,新建隔离会话,在新会话中原子设置计划步骤、可选迁移目标,并触发实施轮 prompt。 */
|
|
5848
|
+
async planCleanHandoff(steps, promptText) {
|
|
5849
|
+
const sourceConv = this.conv;
|
|
5850
|
+
if (sourceConv?.planMode) {
|
|
5851
|
+
await this.setPlanMode(false, sourceConv.id);
|
|
5852
|
+
}
|
|
5853
|
+
const sourceGoal = sourceConv?.goal?.goal;
|
|
5854
|
+
await this.newChat();
|
|
5855
|
+
const targetConv = this.conv;
|
|
5856
|
+
this.planManager.setPlan(targetConv.id, steps);
|
|
5857
|
+
if (sourceGoal) {
|
|
5858
|
+
try {
|
|
5859
|
+
await this.goalSvc.setGoal(sourceGoal, { targetConvId: targetConv.id, autoStart: false });
|
|
5860
|
+
}
|
|
5861
|
+
catch {
|
|
5862
|
+
// 目标迁移失败不阻塞实施
|
|
5863
|
+
}
|
|
5864
|
+
}
|
|
5865
|
+
this.flushSnapshot();
|
|
5866
|
+
await this.prompt(promptText);
|
|
5867
|
+
}
|
|
5632
5868
|
/** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
|
|
5633
5869
|
cancelPendingQuestions() {
|
|
5634
5870
|
for (const [, p] of this.pendingQuestions) {
|
|
@@ -5636,6 +5872,35 @@ export class ClientSession {
|
|
|
5636
5872
|
}
|
|
5637
5873
|
this.pendingQuestions.clear();
|
|
5638
5874
|
}
|
|
5875
|
+
/** #487:结算指定会话的挂起提问/审批(按 conversationId 精确匹配)。
|
|
5876
|
+
* abort/移除/重建会话后残留的条目会让 isWaitingOnUser 恒真——stall 失联
|
|
5877
|
+
* 检测被永久豁免,且问卷/审批弹窗跨轮残留。dispose 口径范本:提问以
|
|
5878
|
+
* 「取消」解析、审批以「拒绝」解析并广播 tool_approval_resolved。 */
|
|
5879
|
+
settlePendingsForConv(convId) {
|
|
5880
|
+
for (const [id, q] of this.pendingQuestions) {
|
|
5881
|
+
if (q.conversationId !== convId)
|
|
5882
|
+
continue;
|
|
5883
|
+
this.pendingQuestions.delete(id);
|
|
5884
|
+
try {
|
|
5885
|
+
q.resolve(null);
|
|
5886
|
+
}
|
|
5887
|
+
catch {
|
|
5888
|
+
// 单个结算异常不影响其余清理
|
|
5889
|
+
}
|
|
5890
|
+
}
|
|
5891
|
+
for (const [id, a] of this.pendingApprovals) {
|
|
5892
|
+
if (a.conversationId !== convId)
|
|
5893
|
+
continue;
|
|
5894
|
+
this.pendingApprovals.delete(id);
|
|
5895
|
+
try {
|
|
5896
|
+
a.resolve({ decision: "deny", reason: "运行已停止" });
|
|
5897
|
+
}
|
|
5898
|
+
catch {
|
|
5899
|
+
// 单个结算异常不影响其余清理
|
|
5900
|
+
}
|
|
5901
|
+
this.emit({ type: "tool_approval_resolved", id });
|
|
5902
|
+
}
|
|
5903
|
+
}
|
|
5639
5904
|
/** 关闭所有挂起审批(dispose 时清理):以「拒绝」解析,避免等答复的
|
|
5640
5905
|
* Promise 与对应 runtime 泄漏(会话没了,审批弹窗永远不会有人点)。 */
|
|
5641
5906
|
cancelPendingApprovals() {
|
|
@@ -5993,6 +6258,7 @@ export class ClientSession {
|
|
|
5993
6258
|
* 主动检查已安装界面插件(<dataDir>/plugins)的更新状态并向客户端推送。
|
|
5994
6259
|
*/
|
|
5995
6260
|
async checkPluginUpdates(manual = false) {
|
|
6261
|
+
this.updatesAllCache = null;
|
|
5996
6262
|
const lang = () => this.getLang();
|
|
5997
6263
|
try {
|
|
5998
6264
|
const updates = await checkPluginUpdates(this.stateStore.dataDir, undefined, lang);
|
|
@@ -6120,11 +6386,12 @@ export class ClientSession {
|
|
|
6120
6386
|
}));
|
|
6121
6387
|
this.lastPluginUpdates = list;
|
|
6122
6388
|
this.emit({ type: "plugin_updates", updates: list });
|
|
6389
|
+
const fmtVer = (v) => (v ? (/^[vV]/.test(v) ? v : `v${v}`) : null);
|
|
6123
6390
|
pluginItems = pluginUpdates.map((p) => ({
|
|
6124
6391
|
name: p.name ? `${p.name} (${p.id})` : p.id,
|
|
6125
6392
|
kind: "plugin",
|
|
6126
|
-
current: p.version
|
|
6127
|
-
latest: p.latestVersion
|
|
6393
|
+
current: fmtVer(p.version) ?? p.localSha ?? "unknown",
|
|
6394
|
+
latest: fmtVer(p.latestVersion) ?? p.remoteSha ?? null,
|
|
6128
6395
|
latestPublishedAt: null,
|
|
6129
6396
|
upToDate: !p.updatable,
|
|
6130
6397
|
error: p.error,
|
|
@@ -6287,6 +6554,8 @@ export class ClientSession {
|
|
|
6287
6554
|
this.flushSnapshot();
|
|
6288
6555
|
},
|
|
6289
6556
|
refreshSessions: () => this.refreshSessions(),
|
|
6557
|
+
setPlanMode: (enabled) => this.setPlanMode(enabled),
|
|
6558
|
+
getPlanMode: () => this.conv?.planMode === true,
|
|
6290
6559
|
afterReload: () => {
|
|
6291
6560
|
// /reload 同样重读磁盘 settings.json——重放重试覆盖 + 软上限覆盖 + 终端门控。
|
|
6292
6561
|
this.applyRetryOverrides();
|
|
@@ -6833,6 +7102,10 @@ export class ClientSession {
|
|
|
6833
7102
|
}
|
|
6834
7103
|
}
|
|
6835
7104
|
applyAgentToolsGating(session, [...disabled], targetPreset);
|
|
7105
|
+
const ownerId = conv?.id;
|
|
7106
|
+
syncSubagentOverride(session, disabled.has("subagent"), () => ownerId
|
|
7107
|
+
? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId), undefined, ownerId)[0]
|
|
7108
|
+
: makeSubagentTools(this.subagentHost)[0]);
|
|
6836
7109
|
this.syncPluginTools(session, targetPreset);
|
|
6837
7110
|
this.sessionStatsCache = null;
|
|
6838
7111
|
this.cachedBaseTokens = null;
|
|
@@ -7848,6 +8121,13 @@ export class ClientSession {
|
|
|
7848
8121
|
conv.promptInFlight = true;
|
|
7849
8122
|
// Attach files as independent nextTurn context messages (asides) so the
|
|
7850
8123
|
// user message stays clean; they render as separate attachment cards.
|
|
8124
|
+
// SDK 的流式队列只消费用户消息;引用须与对应队列项一起入队和撤回。
|
|
8125
|
+
const streamingQuotes = s.isStreaming
|
|
8126
|
+
? (attachments ?? [])
|
|
8127
|
+
.filter((a) => a.mode === "quote")
|
|
8128
|
+
.map((a) => readTextQuote(a.quote))
|
|
8129
|
+
.filter((q) => q !== null)
|
|
8130
|
+
: [];
|
|
7851
8131
|
const asides = await buildAttachmentMessages({
|
|
7852
8132
|
cwd: this.cwd,
|
|
7853
8133
|
clientId: this.clientId,
|
|
@@ -7856,9 +8136,17 @@ export class ClientSession {
|
|
|
7856
8136
|
session: this.session,
|
|
7857
8137
|
// issue #91:附件/视觉桥文案按客户端 UI 语言出中英(英文默认)。
|
|
7858
8138
|
getLang: () => this.getLang(),
|
|
7859
|
-
}, attachments);
|
|
8139
|
+
}, streamingQuotes.length ? attachments?.filter((a) => a.mode !== "quote") : attachments);
|
|
7860
8140
|
if (promptAc.signal.aborted) {
|
|
7861
8141
|
conv.activePromptAc = undefined;
|
|
8142
|
+
// #492:displacement(新对话顶替/会话被移出)在 prompt 前置阶段 abort 时
|
|
8143
|
+
// 原本静默 return——消息凭空消失、草稿已清,用户毫无回执。
|
|
8144
|
+
this.emit({
|
|
8145
|
+
type: "notice",
|
|
8146
|
+
level: "warning",
|
|
8147
|
+
text: `消息未送达:该对话已被关闭或运行已被停止`,
|
|
8148
|
+
textEn: `Message not delivered: the conversation was closed or the run was stopped`,
|
|
8149
|
+
});
|
|
7862
8150
|
return;
|
|
7863
8151
|
}
|
|
7864
8152
|
for (const aside of asides) {
|
|
@@ -7883,6 +8171,13 @@ export class ClientSession {
|
|
|
7883
8171
|
}
|
|
7884
8172
|
if (promptAc.signal.aborted) {
|
|
7885
8173
|
conv.activePromptAc = undefined;
|
|
8174
|
+
// #492:同上——影子快照阶段的 abort 也必须给回执,不能静默黑洞。
|
|
8175
|
+
this.emit({
|
|
8176
|
+
type: "notice",
|
|
8177
|
+
level: "warning",
|
|
8178
|
+
text: `消息未送达:该对话已被关闭或运行已被停止`,
|
|
8179
|
+
textEn: `Message not delivered: the conversation was closed or the run was stopped`,
|
|
8180
|
+
});
|
|
7886
8181
|
return;
|
|
7887
8182
|
}
|
|
7888
8183
|
conv.activePromptAc = undefined;
|
|
@@ -7907,12 +8202,12 @@ export class ClientSession {
|
|
|
7907
8202
|
// Enter-during-streaming semantic (docs/usage: Enter queues a
|
|
7908
8203
|
// steering message); followUp would wait for the whole run
|
|
7909
8204
|
// to finish, which users perceive as ordinary queueing.
|
|
7910
|
-
await s.prompt(text, {
|
|
8205
|
+
await s.prompt(formatQuotedPrompt(text, streamingQuotes), {
|
|
7911
8206
|
streamingBehavior: queue ? "followUp" : "steer",
|
|
7912
8207
|
});
|
|
7913
8208
|
}
|
|
7914
8209
|
else {
|
|
7915
|
-
await s.prompt(text);
|
|
8210
|
+
await s.prompt(formatQuotedPrompt(text, streamingQuotes));
|
|
7916
8211
|
}
|
|
7917
8212
|
// 关联快照与本次 prompt 产生的用户消息 entry
|
|
7918
8213
|
if (snapshotRef) {
|
|
@@ -8106,7 +8401,23 @@ export class ClientSession {
|
|
|
8106
8401
|
this.flushSnapshot();
|
|
8107
8402
|
return;
|
|
8108
8403
|
}
|
|
8109
|
-
|
|
8404
|
+
let steering;
|
|
8405
|
+
let followUp;
|
|
8406
|
+
try {
|
|
8407
|
+
({ steering, followUp } = s.clearQueue());
|
|
8408
|
+
}
|
|
8409
|
+
catch (err) {
|
|
8410
|
+
// #491:runtime 被 forceReset 置换等半死状态下 clearQueue 可能抛错——
|
|
8411
|
+
// 回退到本地显示镜像移除,等 queue_update 对账;绝不把 reject 的 Promise
|
|
8412
|
+
// 扔回给 dispatch(无兜底 handler 时 unhandledRejection 直接崩进程)。
|
|
8413
|
+
console.error("[agent-service] clearQueue failed while removing a queued message:", err);
|
|
8414
|
+
const next = removeQueuedByIndexOrText(local, text, index);
|
|
8415
|
+
if (next.length !== local.length) {
|
|
8416
|
+
local.splice(0, local.length, ...next);
|
|
8417
|
+
}
|
|
8418
|
+
this.flushSnapshot();
|
|
8419
|
+
return;
|
|
8420
|
+
}
|
|
8110
8421
|
// 气泡 ✕ 对应的是「第几个气泡」(index),不是「哪段文本」:同一文本排队两次时
|
|
8111
8422
|
// 按文本只会删掉第一条,点第二个气泡却删掉第一个。用 index 定位,位置对不上
|
|
8112
8423
|
// (队列在点击与执行之间变化)或旧客户端没发 index 时回落到第一处文本匹配。
|
|
@@ -8244,6 +8555,9 @@ export class ClientSession {
|
|
|
8244
8555
|
}
|
|
8245
8556
|
/** Interrupt a run: abort, with a force-reset fallback on timeout. */
|
|
8246
8557
|
async interruptRun(conv, reason) {
|
|
8558
|
+
// #487:停下就结算该会话的挂起提问/审批——残留条目让 isWaitingOnUser 恒真
|
|
8559
|
+
// (stall 失联检测被永久豁免),弹窗也会跨轮残留;dispose 语义已范本化。
|
|
8560
|
+
this.settlePendingsForConv(conv.id);
|
|
8247
8561
|
if (!this.conversationStreaming(conv) && conv.session.isIdle) {
|
|
8248
8562
|
return;
|
|
8249
8563
|
}
|
|
@@ -8317,6 +8631,11 @@ export class ClientSession {
|
|
|
8317
8631
|
conv.toolStartTimes.clear();
|
|
8318
8632
|
disposeEvalSession(conv.id);
|
|
8319
8633
|
await conv.runtime.dispose();
|
|
8634
|
+
// #485:dispose 挂起期间会话可能已被强行关闭移出(forceDismiss 走
|
|
8635
|
+
// removeConversation)——此时绝不能幽灵重建,否则新 runtime 挂在已脱离
|
|
8636
|
+
// this.convs 的 conv 对象上,无人持有、永不回收。
|
|
8637
|
+
if (!this.convs.has(conv.id))
|
|
8638
|
+
return;
|
|
8320
8639
|
// #280:dispose 丢弃了内存里的在飞状态(未落盘的工具结果蒸发),
|
|
8321
8640
|
// 文件尾可能留下一个悬空 toolCall——先补合成 toolResult 再重建,
|
|
8322
8641
|
// 否则重建后的 prompt 会把非法转录链喂给 provider(零落盘黑洞)。
|
|
@@ -8348,6 +8667,17 @@ export class ClientSession {
|
|
|
8348
8667
|
sessionManager: m,
|
|
8349
8668
|
}), async () => (ownFile && existsSync(ownFile) ? ownFile : undefined));
|
|
8350
8669
|
const runtime = opened.runtime;
|
|
8670
|
+
// #485:openManagerAndRuntime 的长 await 期间会话被移出的同一守卫——
|
|
8671
|
+
// 刚建好的 runtime 无人认领,就地 dispose 防止扩展宿主子进程泄漏。
|
|
8672
|
+
if (!this.convs.has(conv.id)) {
|
|
8673
|
+
try {
|
|
8674
|
+
await runtime.dispose();
|
|
8675
|
+
}
|
|
8676
|
+
catch {
|
|
8677
|
+
// 已被移除的会话:dispose 失败无处上报,best-effort。
|
|
8678
|
+
}
|
|
8679
|
+
return;
|
|
8680
|
+
}
|
|
8351
8681
|
if (opened.repair) {
|
|
8352
8682
|
for (const n of this.transcriptRepairNotices(opened.repair))
|
|
8353
8683
|
this.emit(n);
|
|
@@ -8381,7 +8711,10 @@ export class ClientSession {
|
|
|
8381
8711
|
text: reason,
|
|
8382
8712
|
textEn: `${reason} (forced reset: run did not terminate)`,
|
|
8383
8713
|
});
|
|
8384
|
-
|
|
8714
|
+
// #484:把订阅重挂到**被重建的那个对话**上。非活跃对话(子代理/角色
|
|
8715
|
+
// 对话)此前永远走 bindSession() → 只给活跃对话挂订阅,重建后该对话
|
|
8716
|
+
// 的所有 SDK 事件失聪:快照冻结、看门狗不再布防、turnEndWaiters 死等。
|
|
8717
|
+
await this.bindSession(conv);
|
|
8385
8718
|
this.emitConversations();
|
|
8386
8719
|
void this.pushSlashCommands();
|
|
8387
8720
|
}
|
|
@@ -8784,6 +9117,9 @@ export class ClientSession {
|
|
|
8784
9117
|
}
|
|
8785
9118
|
displaceActive() {
|
|
8786
9119
|
const conv = this.conv;
|
|
9120
|
+
// 正在过户给其他会话:绝不就地释放(runtime/终端等整体搬迁给 target)。
|
|
9121
|
+
if (conv.transferring)
|
|
9122
|
+
return null;
|
|
8787
9123
|
// 子代理不受切换关闭影响(见上)。
|
|
8788
9124
|
if (conv.isSubagent) {
|
|
8789
9125
|
conv.listed = true;
|
|
@@ -8847,6 +9183,8 @@ export class ClientSession {
|
|
|
8847
9183
|
const conv = this.convs.get(id);
|
|
8848
9184
|
if (!conv || id === this.activeId)
|
|
8849
9185
|
return;
|
|
9186
|
+
// #487:会话没了,挂起的提问/审批永远等不到人点——按 dispose 口径就地结算。
|
|
9187
|
+
this.settlePendingsForConv(id);
|
|
8850
9188
|
// 正在投递的消息(附件构建 / 影子快照阶段)必须先打断:否则 prompt() 醒来
|
|
8851
9189
|
// 后会把用户消息写进已销毁的 runtime,静默丢失。
|
|
8852
9190
|
if (conv.activePromptAc) {
|
|
@@ -8925,21 +9263,37 @@ export class ClientSession {
|
|
|
8925
9263
|
const convs = [...this.convs.values()].filter((c) => set.has(c.id));
|
|
8926
9264
|
if (convs.length === 0)
|
|
8927
9265
|
return { ok: false, reason: "missing" };
|
|
8928
|
-
|
|
8929
|
-
|
|
8930
|
-
|
|
8931
|
-
if (
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
|
|
9266
|
+
for (const conv of convs)
|
|
9267
|
+
conv.transferring = true;
|
|
9268
|
+
try {
|
|
9269
|
+
if (set.has(this.activeId)) {
|
|
9270
|
+
const remaining = [...this.convs.values()].find((c) => !set.has(c.id) && !c.isSubagent) ??
|
|
9271
|
+
[...this.convs.values()].find((c) => !set.has(c.id));
|
|
9272
|
+
if (remaining) {
|
|
9273
|
+
await this.switchConversation(remaining.id);
|
|
9274
|
+
}
|
|
9275
|
+
else if (!(await this.newChat())) {
|
|
9276
|
+
return { ok: false, reason: "empty" };
|
|
9277
|
+
}
|
|
8936
9278
|
}
|
|
8937
9279
|
}
|
|
9280
|
+
finally {
|
|
9281
|
+
for (const conv of convs)
|
|
9282
|
+
delete conv.transferring;
|
|
9283
|
+
}
|
|
8938
9284
|
for (const conv of convs) {
|
|
8939
9285
|
this.convs.delete(conv.id);
|
|
8940
9286
|
this.clearAllToolWatchdogs(conv);
|
|
8941
9287
|
conv.unsubscribe?.();
|
|
8942
9288
|
conv.unsubscribe = undefined;
|
|
9289
|
+
// issue #457: 唤醒角色轮等待者与目标审查等待者,避免会话搬走后原会话循环永久卡死
|
|
9290
|
+
const waiters = this.turnEndWaiters.get(conv.id);
|
|
9291
|
+
if (waiters) {
|
|
9292
|
+
this.turnEndWaiters.delete(conv.id);
|
|
9293
|
+
for (const fn of waiters)
|
|
9294
|
+
fn("gone");
|
|
9295
|
+
}
|
|
9296
|
+
this.goalSvc.notifyTakeover(conv);
|
|
8943
9297
|
}
|
|
8944
9298
|
const questions = [];
|
|
8945
9299
|
for (const [qid, p] of this.pendingQuestions) {
|
|
@@ -9304,8 +9658,118 @@ export class ClientSession {
|
|
|
9304
9658
|
static projectsCache = null;
|
|
9305
9659
|
static PROJECTS_CACHE_TTL = 60_000;
|
|
9306
9660
|
static projectsInFlight = null;
|
|
9307
|
-
/**
|
|
9661
|
+
/**
|
|
9662
|
+
* 会话单文件元信息缓存(path -> { mtime, size, info }):避免对未修改的会话文件
|
|
9663
|
+
* 重复读取与 JSON 解析。LRU 上限(issue #440):静态 Map 跨项目共享,原先没有任何
|
|
9664
|
+
* 上限/TTL —— 删除、更名、被挤出 200 名额的文件只要曾进过缓存就永久驻留,长驻
|
|
9665
|
+
* 服务 + 多项目重度使用下 RSS 单调上涨。
|
|
9666
|
+
*
|
|
9667
|
+
* 上限 512 的依据:单项目单轮扫描只取最新 200 个候选,512 ≈ 2.5 倍余量,足够容纳
|
|
9668
|
+
* 多个最近活跃项目的热集;且拆分后(issue #440)条目只含元信息不含转录全文
|
|
9669
|
+
* (单条 KB 级),总占用恒定且极小 —— 上限防的是「无界累积」,不是单条重量。
|
|
9670
|
+
*
|
|
9671
|
+
* LRU 淘汰语义:把 Map 当 LRU 用(Map 迭代序 = 插入序)—— 命中即 delete+set 重插
|
|
9672
|
+
* 到最新端,写入超上限从最旧端淘汰。mtime/size 未变的条目命中即复用(行为与原先
|
|
9673
|
+
* 一致),变化的条目由调用方重新解析后经 store 覆盖(也落到最新端)。
|
|
9674
|
+
*/
|
|
9308
9675
|
static sessionFileCache = new Map();
|
|
9676
|
+
static SESSION_FILE_CACHE_MAX = 512;
|
|
9677
|
+
/**
|
|
9678
|
+
* 转录全文缓存(path -> { text, at }):仅供 searchSessions / searchForPlugins 的
|
|
9679
|
+
* 全文命中判定按需加载(issue #440 拆分)。与列表缓存分离的原因:列表路径
|
|
9680
|
+
* (pushSessions / 插件列表 / 删除后切换)只需要元信息,而全文单条可达 MB 级,
|
|
9681
|
+
* 随列表常驻会让每个曾进过列表的文件都占住内存。
|
|
9682
|
+
*
|
|
9683
|
+
* 容量依据:256 ≥ 单项目候选上限 200,键入查询的其余按键在 TTL 窗口内全程免读盘;
|
|
9684
|
+
* 单条在 parseSessionInfoFast 里按 MAX_SEARCH_TEXT_CHARS(256K 字符)封顶。
|
|
9685
|
+
* TTL 只在访问时判断(无定时器),每次载入顺手清扫过期项 —— 搜索空闲后不长期
|
|
9686
|
+
* 占内存。删除/更名经 invalidateSessionInfos 同步清理。
|
|
9687
|
+
*/
|
|
9688
|
+
static sessionTextCache = new Map();
|
|
9689
|
+
static SESSION_TEXT_CACHE_MAX = 256;
|
|
9690
|
+
static SESSION_TEXT_CACHE_TTL = 30_000;
|
|
9691
|
+
/** sessionFileCache LRU 读取:命中即重插到最新端(续期),未命中返回 undefined。 */
|
|
9692
|
+
static sessionFileCacheLookup(key) {
|
|
9693
|
+
const entry = ClientSession.sessionFileCache.get(key);
|
|
9694
|
+
if (!entry)
|
|
9695
|
+
return undefined;
|
|
9696
|
+
ClientSession.sessionFileCache.delete(key);
|
|
9697
|
+
ClientSession.sessionFileCache.set(key, entry);
|
|
9698
|
+
return entry;
|
|
9699
|
+
}
|
|
9700
|
+
/** sessionFileCache LRU 写入:新/更新条目放最新端,超上限从最旧端淘汰。 */
|
|
9701
|
+
static sessionFileCacheStore(key, entry) {
|
|
9702
|
+
ClientSession.sessionFileCache.delete(key);
|
|
9703
|
+
ClientSession.sessionFileCache.set(key, entry);
|
|
9704
|
+
while (ClientSession.sessionFileCache.size > ClientSession.SESSION_FILE_CACHE_MAX) {
|
|
9705
|
+
const oldest = ClientSession.sessionFileCache.keys().next();
|
|
9706
|
+
if (oldest.done)
|
|
9707
|
+
break;
|
|
9708
|
+
ClientSession.sessionFileCache.delete(oldest.value);
|
|
9709
|
+
}
|
|
9710
|
+
}
|
|
9711
|
+
/** sessionTextCache 读取:TTL 内命中即重插续期;过期即清并按未命中处理。 */
|
|
9712
|
+
static sessionTextCacheLookup(key, now) {
|
|
9713
|
+
const entry = ClientSession.sessionTextCache.get(key);
|
|
9714
|
+
if (!entry)
|
|
9715
|
+
return undefined;
|
|
9716
|
+
ClientSession.sessionTextCache.delete(key);
|
|
9717
|
+
if (now - entry.at >= ClientSession.SESSION_TEXT_CACHE_TTL)
|
|
9718
|
+
return undefined;
|
|
9719
|
+
ClientSession.sessionTextCache.set(key, entry);
|
|
9720
|
+
return entry.text;
|
|
9721
|
+
}
|
|
9722
|
+
/** sessionTextCache 写入:放最新端,超上限从最旧端淘汰。 */
|
|
9723
|
+
static sessionTextCacheStore(key, text, at) {
|
|
9724
|
+
ClientSession.sessionTextCache.delete(key);
|
|
9725
|
+
ClientSession.sessionTextCache.set(key, { text, at });
|
|
9726
|
+
while (ClientSession.sessionTextCache.size > ClientSession.SESSION_TEXT_CACHE_MAX) {
|
|
9727
|
+
const oldest = ClientSession.sessionTextCache.keys().next();
|
|
9728
|
+
if (oldest.done)
|
|
9729
|
+
break;
|
|
9730
|
+
ClientSession.sessionTextCache.delete(oldest.value);
|
|
9731
|
+
}
|
|
9732
|
+
}
|
|
9733
|
+
/** 惰性清扫 sessionTextCache 过期项:TTL 只在访问时判断,没有定时器兜底,
|
|
9734
|
+
* 靠每次全文载入顺手扫一遍,保证搜索空闲后全文不会无限期滞留内存。 */
|
|
9735
|
+
static sessionTextCacheSweep(now) {
|
|
9736
|
+
for (const [key, entry] of ClientSession.sessionTextCache) {
|
|
9737
|
+
if (now - entry.at >= ClientSession.SESSION_TEXT_CACHE_TTL)
|
|
9738
|
+
ClientSession.sessionTextCache.delete(key);
|
|
9739
|
+
}
|
|
9740
|
+
}
|
|
9741
|
+
/**
|
|
9742
|
+
* 转录全文按需加载(搜索专用):TTL 缓存命中直接回;未命中读盘提取全文
|
|
9743
|
+
* (与 parseSessionInfoFast 同口径:user/assistant 消息文本,MAX_SEARCH_TEXT_CHARS
|
|
9744
|
+
* 截断)。解析失败回空串且不缓存 —— 与列表路径一样下次重试。
|
|
9745
|
+
*/
|
|
9746
|
+
static async loadSessionSearchText(filePath) {
|
|
9747
|
+
const now = Date.now();
|
|
9748
|
+
ClientSession.sessionTextCacheSweep(now);
|
|
9749
|
+
const hit = ClientSession.sessionTextCacheLookup(filePath, now);
|
|
9750
|
+
if (hit !== undefined)
|
|
9751
|
+
return hit;
|
|
9752
|
+
const info = await parseSessionInfoFast(filePath, 0, true);
|
|
9753
|
+
const text = info?.allMessagesText ?? "";
|
|
9754
|
+
if (text)
|
|
9755
|
+
ClientSession.sessionTextCacheStore(filePath, text, now);
|
|
9756
|
+
return text;
|
|
9757
|
+
}
|
|
9758
|
+
/**
|
|
9759
|
+
* searchSessions / searchForPlugins 共用的全文匹配:先比元信息(零 IO,覆盖绝大多数
|
|
9760
|
+
* 按标题/文件名/首条消息的查询),元信息未命中的会话再按需加载转录全文判定
|
|
9761
|
+
* (小容量短 TTL 缓存,见 sessionTextCache)。保序返回命中子集。
|
|
9762
|
+
* issue #440:全文不再随列表缓存常驻,改在这里按需加载。
|
|
9763
|
+
*/
|
|
9764
|
+
static async filterSessionsForSearch(q, infos) {
|
|
9765
|
+
const flags = await Promise.all(infos.map(async (s) => {
|
|
9766
|
+
if (sessionMatchesMetadata(q, s))
|
|
9767
|
+
return true;
|
|
9768
|
+
const text = await ClientSession.loadSessionSearchText(s.path);
|
|
9769
|
+
return text.toLowerCase().includes(q);
|
|
9770
|
+
}));
|
|
9771
|
+
return infos.filter((_, i) => flags[i]);
|
|
9772
|
+
}
|
|
9309
9773
|
/** 会话列表缓存(按 cwdKey 隔离,30s TTL)。 */
|
|
9310
9774
|
static sessionInfosCache = new Map();
|
|
9311
9775
|
static sessionInfosInFlight = new Map();
|
|
@@ -9344,15 +9808,15 @@ export class ClientSession {
|
|
|
9344
9808
|
}));
|
|
9345
9809
|
const validStats = stats.filter((s) => s !== null);
|
|
9346
9810
|
validStats.sort((a, b) => b.mtime - a.mtime);
|
|
9347
|
-
const
|
|
9348
|
-
|
|
9349
|
-
const cached = ClientSession.sessionFileCache.get(file.path);
|
|
9811
|
+
const results = await Promise.all(validStats.map(async (file) => {
|
|
9812
|
+
const cached = ClientSession.sessionFileCacheLookup(file.path);
|
|
9350
9813
|
if (cached && cached.mtime === file.mtime && cached.size === file.size) {
|
|
9351
9814
|
return cached.info;
|
|
9352
9815
|
}
|
|
9816
|
+
// 列表路径不收集转录全文(issue #440)——全文只在搜索时按需加载
|
|
9353
9817
|
const info = await parseSessionInfoFast(file.path, file.mtime);
|
|
9354
9818
|
if (info) {
|
|
9355
|
-
ClientSession.
|
|
9819
|
+
ClientSession.sessionFileCacheStore(file.path, {
|
|
9356
9820
|
mtime: file.mtime,
|
|
9357
9821
|
size: file.size,
|
|
9358
9822
|
info,
|
|
@@ -9361,15 +9825,28 @@ export class ClientSession {
|
|
|
9361
9825
|
return info;
|
|
9362
9826
|
}));
|
|
9363
9827
|
const validInfos = results.filter((info) => info !== null);
|
|
9364
|
-
|
|
9365
|
-
|
|
9366
|
-
|
|
9828
|
+
// issue #438:扁平布局(PI_CODING_AGENT_SESSION_DIR)下根目录顶层直接是**所有项目**
|
|
9829
|
+
// 共享的 .jsonl,所属 cwd 是文件内字段;不过滤的话其他项目的会话会混进本项目
|
|
9830
|
+
// 「历史会话」列表,点开即把整个工作区切走。与 SessionManager.list(cwd, ...) 的
|
|
9831
|
+
// fallback 及 discoverRecentProjectsFromDisk 同口径:normalizePathKey(Windows
|
|
9832
|
+
// 大小写/斜杠归一)。空 cwd 的损坏文件须排除——normalizePathKey("") 会 resolve 到
|
|
9833
|
+
// process.cwd(),恰好等于本项目时会把垃圾文件误收进来。
|
|
9834
|
+
const ownInfos = validInfos.filter((info) => info.cwd !== "" && normalizePathKey(info.cwd) === cwdKey);
|
|
9835
|
+
// 200 名额只在 cwd 过滤**之后**分配:扁平布局下若先截断,本项目会话可能被
|
|
9836
|
+
// 其他项目的文件挤出列表。截断仍按上面的 mtime 降序序取(Promise.all 保序),
|
|
9837
|
+
// 与原有选集口径一致;非扁平布局每-cwd 子目录内文件全属本项目,过滤幂等。
|
|
9838
|
+
const candidates = ownInfos.slice(0, 200);
|
|
9839
|
+
candidates.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
9840
|
+
ClientSession.sessionInfosCache.set(cwdKey, { infos: candidates, at: Date.now() });
|
|
9841
|
+
return candidates;
|
|
9367
9842
|
}
|
|
9368
9843
|
catch {
|
|
9369
9844
|
try {
|
|
9370
9845
|
const fallback = await SessionManager.list(this.cwd, piSessionsRoot());
|
|
9371
|
-
|
|
9372
|
-
|
|
9846
|
+
// 统一口径:列表缓存一律不带转录全文(issue #440),搜索按需另行加载
|
|
9847
|
+
const stripped = fallback.map((s) => ({ ...s, allMessagesText: "" }));
|
|
9848
|
+
ClientSession.sessionInfosCache.set(cwdKey, { infos: stripped, at: Date.now() });
|
|
9849
|
+
return stripped;
|
|
9373
9850
|
}
|
|
9374
9851
|
catch {
|
|
9375
9852
|
return [];
|
|
@@ -9389,7 +9866,10 @@ export class ClientSession {
|
|
|
9389
9866
|
invalidateSessionInfos(filePath) {
|
|
9390
9867
|
ClientSession.sessionInfosCache.clear();
|
|
9391
9868
|
if (filePath) {
|
|
9392
|
-
|
|
9869
|
+
const abs = resolve(filePath);
|
|
9870
|
+
ClientSession.sessionFileCache.delete(abs);
|
|
9871
|
+
// 全文缓存一并清(issue #440):文件已删/更名,正文不该再被搜索命中
|
|
9872
|
+
ClientSession.sessionTextCache.delete(abs);
|
|
9393
9873
|
}
|
|
9394
9874
|
}
|
|
9395
9875
|
/** Push the persisted session list to the client (client-requested). */
|
|
@@ -9414,7 +9894,7 @@ export class ClientSession {
|
|
|
9414
9894
|
const infos = await this.loadSessionInfos();
|
|
9415
9895
|
// 隐藏「被 fork 掉的父会话」,历史列表只保留每条 fork 链最新的链尾会话
|
|
9416
9896
|
// (全文搜索 searchSessions 保留全部会话,不受此影响)。
|
|
9417
|
-
const normSessionPath = (p) =>
|
|
9897
|
+
const normSessionPath = (p) => normalizePathKey(p);
|
|
9418
9898
|
const forkedParentPaths = new Set();
|
|
9419
9899
|
for (const info of infos) {
|
|
9420
9900
|
if (typeof info.parentSessionPath === "string" && info.parentSessionPath) {
|
|
@@ -9429,6 +9909,7 @@ export class ClientSession {
|
|
|
9429
9909
|
: infos;
|
|
9430
9910
|
const sessions = new Map();
|
|
9431
9911
|
for (const s of visibleInfos) {
|
|
9912
|
+
const isPinned = this.stateStore?.isSessionPinned(this.cwd, s.path);
|
|
9432
9913
|
sessions.set(s.path, {
|
|
9433
9914
|
path: s.path,
|
|
9434
9915
|
name: s.name,
|
|
@@ -9436,6 +9917,7 @@ export class ClientSession {
|
|
|
9436
9917
|
messageCount: s.messageCount,
|
|
9437
9918
|
modified: s.modified.getTime(),
|
|
9438
9919
|
source: "web",
|
|
9920
|
+
...(isPinned ? { pinned: true } : {}),
|
|
9439
9921
|
});
|
|
9440
9922
|
}
|
|
9441
9923
|
const sorted = [...sessions.values()].sort((a, b) => b.modified - a.modified).slice(0, 200); // newest first — the panel shows recent history
|
|
@@ -9550,6 +10032,8 @@ export class ClientSession {
|
|
|
9550
10032
|
return;
|
|
9551
10033
|
}
|
|
9552
10034
|
rmSync(abs, { force: true });
|
|
10035
|
+
// 转录删了,钉住记录一起清理(issue #433)。
|
|
10036
|
+
this.stateStore?.cleanPinnedSession(abs);
|
|
9553
10037
|
// 转录删了,sidecar 再留着就是孤儿,一起清掉(不存在不报错)。
|
|
9554
10038
|
removeTouchSidecar(abs);
|
|
9555
10039
|
// 转录删了,未发送草稿再留着就是孤儿,一起清掉。
|
|
@@ -9639,9 +10123,15 @@ export class ClientSession {
|
|
|
9639
10123
|
conv.pinned = true;
|
|
9640
10124
|
// 立即进入运行列表(含空白对话):listed 一旦置位,shownInRunningList 即放行。
|
|
9641
10125
|
conv.listed = true;
|
|
10126
|
+
if (conv.session.sessionFile) {
|
|
10127
|
+
this.stateStore?.setSessionPinned(conv.cwd, conv.session.sessionFile, true);
|
|
10128
|
+
}
|
|
9642
10129
|
}
|
|
9643
10130
|
else {
|
|
9644
10131
|
delete conv.pinned;
|
|
10132
|
+
if (conv.session.sessionFile) {
|
|
10133
|
+
this.stateStore?.setSessionPinned(conv.cwd, conv.session.sessionFile, false);
|
|
10134
|
+
}
|
|
9645
10135
|
}
|
|
9646
10136
|
this.emitConversations();
|
|
9647
10137
|
this.flushSnapshot();
|
|
@@ -9656,6 +10146,27 @@ export class ClientSession {
|
|
|
9656
10146
|
: `Unpinned "${conv.title}" — it will be handled by the usual rules on the next switch`,
|
|
9657
10147
|
});
|
|
9658
10148
|
}
|
|
10149
|
+
/** 钉住 / 取消钉住历史会话(持久化并在运行中/历史列表生效)。 */
|
|
10150
|
+
async pinSession(path, pinned) {
|
|
10151
|
+
const targetPath = resolve(path);
|
|
10152
|
+
this.stateStore?.setSessionPinned(this.cwd, targetPath, pinned);
|
|
10153
|
+
// 如果当前已在运行列表中,同步其 pinned 状态
|
|
10154
|
+
for (const conv of this.convs.values()) {
|
|
10155
|
+
if (conv.session.sessionFile && resolve(conv.session.sessionFile) === targetPath) {
|
|
10156
|
+
if (pinned) {
|
|
10157
|
+
conv.pinned = true;
|
|
10158
|
+
conv.listed = true;
|
|
10159
|
+
}
|
|
10160
|
+
else {
|
|
10161
|
+
delete conv.pinned;
|
|
10162
|
+
}
|
|
10163
|
+
break;
|
|
10164
|
+
}
|
|
10165
|
+
}
|
|
10166
|
+
this.emitConversations();
|
|
10167
|
+
this.flushSnapshot();
|
|
10168
|
+
await this.refreshSessions();
|
|
10169
|
+
}
|
|
9659
10170
|
/** Rename a live conversation by id: retitle in memory AND persist a
|
|
9660
10171
|
* session_info entry to its transcript so History matches immediately. */
|
|
9661
10172
|
async renameConversation(id, name) {
|
|
@@ -10307,6 +10818,10 @@ export class ClientSession {
|
|
|
10307
10818
|
// Deliberately resumed — must not be dismissed when the user later
|
|
10308
10819
|
// switches away without sending a new message.
|
|
10309
10820
|
conv.promptedSinceActive = true;
|
|
10821
|
+
if (this.stateStore?.isSessionPinned(targetCwd, targetPath)) {
|
|
10822
|
+
conv.pinned = true;
|
|
10823
|
+
conv.listed = true;
|
|
10824
|
+
}
|
|
10310
10825
|
this.noticeInterruptedCompaction(conv);
|
|
10311
10826
|
this.convs.set(conv.id, conv);
|
|
10312
10827
|
this.activeId = conv.id;
|
|
@@ -10315,6 +10830,10 @@ export class ClientSession {
|
|
|
10315
10830
|
if (displaced)
|
|
10316
10831
|
this.removeConversation(displaced.id);
|
|
10317
10832
|
await this.bindSession();
|
|
10833
|
+
// #436:转录回放恢复的 planMode 在 runtime 工厂的 applyToolGating 时还读不到
|
|
10834
|
+
//(conv 尚未进 this.convs,planModeOf 落空)——注册后统一补一次门控,让
|
|
10835
|
+
// 计划模式的写类/旁路工具剥离对重开的会话同样生效。下同(forkSession/setCwd)。
|
|
10836
|
+
this.applyToolGating(conv.session, conv.agentPreset);
|
|
10318
10837
|
this.cwd = targetCwd;
|
|
10319
10838
|
// 打开的历史会话可能属于另一个项目 —— 工作区跟随面(roots/文件树/
|
|
10320
10839
|
// 历史列表/命令目录/最近项目)必须跟着切,否则 UI 停在旧项目。
|
|
@@ -10509,6 +11028,9 @@ export class ClientSession {
|
|
|
10509
11028
|
if (displaced)
|
|
10510
11029
|
this.removeConversation(displaced.id);
|
|
10511
11030
|
await this.bindSession();
|
|
11031
|
+
// #436:同 switchSession —— 派生出的会话若从转录回放恢复了 planMode,
|
|
11032
|
+
// 工厂里的门控当时读不到它,注册后补一次。
|
|
11033
|
+
this.applyToolGating(newConv.session, newConv.agentPreset);
|
|
10512
11034
|
if (prevModel && this.sharedModelRuntime) {
|
|
10513
11035
|
try {
|
|
10514
11036
|
const pm = prevModel;
|
|
@@ -10677,7 +11199,8 @@ export class ClientSession {
|
|
|
10677
11199
|
if (this.quiesceBlocked())
|
|
10678
11200
|
return;
|
|
10679
11201
|
const trimmed = text.trim();
|
|
10680
|
-
|
|
11202
|
+
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
|
11203
|
+
if (!trimmed && !hasAttachments) {
|
|
10681
11204
|
this.emit({
|
|
10682
11205
|
type: "notice",
|
|
10683
11206
|
level: "warning",
|
|
@@ -10787,14 +11310,14 @@ export class ClientSession {
|
|
|
10787
11310
|
try {
|
|
10788
11311
|
const discovered = await discoverRecentProjectsFromDisk(sessionRoots, 30);
|
|
10789
11312
|
this.stateStore.mergeDiscoveredProjects(discovered);
|
|
10790
|
-
const merged = this.stateStore.getRecentProjects(this.clientId);
|
|
11313
|
+
const merged = await this.stateStore.getRecentProjects(this.clientId);
|
|
10791
11314
|
ClientSession.projectsCache = { at: Date.now(), projects: merged };
|
|
10792
11315
|
const result = this.withCurrentCwd(merged, Date.now());
|
|
10793
11316
|
this.emit({ type: "projects", projects: result });
|
|
10794
11317
|
return merged;
|
|
10795
11318
|
}
|
|
10796
11319
|
catch {
|
|
10797
|
-
const fallback = this.withCurrentCwd(this.stateStore.getRecentProjects(this.clientId), Date.now());
|
|
11320
|
+
const fallback = this.withCurrentCwd(await this.stateStore.getRecentProjects(this.clientId), Date.now());
|
|
10798
11321
|
this.emit({ type: "projects", projects: fallback });
|
|
10799
11322
|
return fallback;
|
|
10800
11323
|
}
|
|
@@ -10835,8 +11358,9 @@ export class ClientSession {
|
|
|
10835
11358
|
}
|
|
10836
11359
|
/** 全局搜索:在当前工作区的会话转录全文里做大小写不敏感匹配 ——
|
|
10837
11360
|
* 不止首条消息,而是每一段 user 与 assistant 文本(AI 输出也在内)。
|
|
10838
|
-
* 结果经 session_search_results 回推(reqId
|
|
10839
|
-
*
|
|
11361
|
+
* 结果经 session_search_results 回推(reqId 匹配);元信息复用 loadSessionInfos()
|
|
11362
|
+
* 缓存,转录全文按需加载(issue #440,小容量短 TTL 缓存),避免每个按键都重新
|
|
11363
|
+
* 解析全部转录文件、也不让全文长期驻留内存。 */
|
|
10840
11364
|
async searchSessions(query, reqId) {
|
|
10841
11365
|
const q = query.trim().toLowerCase();
|
|
10842
11366
|
if (!q) {
|
|
@@ -10845,11 +11369,12 @@ export class ClientSession {
|
|
|
10845
11369
|
}
|
|
10846
11370
|
try {
|
|
10847
11371
|
const infos = await this.loadSessionInfos();
|
|
10848
|
-
const
|
|
10849
|
-
|
|
11372
|
+
const matched = await ClientSession.filterSessionsForSearch(q, infos);
|
|
11373
|
+
const results = matched
|
|
10850
11374
|
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
|
|
10851
11375
|
.slice(0, 50)
|
|
10852
11376
|
.map((s) => {
|
|
11377
|
+
const isPinned = this.stateStore?.isSessionPinned(this.cwd, s.path);
|
|
10853
11378
|
const base = {
|
|
10854
11379
|
path: s.path,
|
|
10855
11380
|
name: s.name,
|
|
@@ -10857,6 +11382,7 @@ export class ClientSession {
|
|
|
10857
11382
|
messageCount: s.messageCount,
|
|
10858
11383
|
modified: s.modified.getTime(),
|
|
10859
11384
|
source: "web",
|
|
11385
|
+
...(isPinned ? { pinned: true } : {}),
|
|
10860
11386
|
};
|
|
10861
11387
|
// 命中会话里再定位具体消息(供点击跳转);仅元数据命中则无锚点
|
|
10862
11388
|
return { ...base, anchors: collectSessionAnchors(s.path, q) };
|
|
@@ -10985,26 +11511,6 @@ export class ClientSession {
|
|
|
10985
11511
|
await this.setCwd(created);
|
|
10986
11512
|
}
|
|
10987
11513
|
}
|
|
10988
|
-
async cycleModel() {
|
|
10989
|
-
try {
|
|
10990
|
-
const result = await this.session.cycleModel();
|
|
10991
|
-
if (result?.model) {
|
|
10992
|
-
const mid = `${result.model.provider}/${result.model.id}`;
|
|
10993
|
-
await this.restoreKeyForModel(mid, this.cwd);
|
|
10994
|
-
// Remember per-project like setModel — cycling is also a model switch.
|
|
10995
|
-
this.rememberProjectModel(mid);
|
|
10996
|
-
}
|
|
10997
|
-
}
|
|
10998
|
-
catch (err) {
|
|
10999
|
-
this.emit({
|
|
11000
|
-
type: "notice",
|
|
11001
|
-
level: "error",
|
|
11002
|
-
text: `切换模型失败:${err.message}`,
|
|
11003
|
-
textEn: `Failed to switch model: ${err.message}`,
|
|
11004
|
-
});
|
|
11005
|
-
}
|
|
11006
|
-
this.flushSnapshot();
|
|
11007
|
-
}
|
|
11008
11514
|
/**
|
|
11009
11515
|
* Path completion for the cwd input: expand ~/relative paths, list the parent
|
|
11010
11516
|
* directory, and return prefix matches (dirs first, capped).
|
|
@@ -11152,6 +11658,9 @@ export class ClientSession {
|
|
|
11152
11658
|
}
|
|
11153
11659
|
}
|
|
11154
11660
|
await this.bindSession();
|
|
11661
|
+
// #436:同 switchSession —— 续聊最近会话若从转录回放恢复了 planMode,
|
|
11662
|
+
// 工厂里的门控当时读不到它,注册后补一次。
|
|
11663
|
+
this.applyToolGating(conv.session, conv.agentPreset);
|
|
11155
11664
|
if (resumeSkipped) {
|
|
11156
11665
|
this.emit(resumeSkipped.isStreaming
|
|
11157
11666
|
? {
|
|
@@ -11440,24 +11949,6 @@ export class ClientSession {
|
|
|
11440
11949
|
}
|
|
11441
11950
|
this.flushSnapshot();
|
|
11442
11951
|
}
|
|
11443
|
-
cycleThinking() {
|
|
11444
|
-
try {
|
|
11445
|
-
const nextLevel = this.session.cycleThinkingLevel({ persist: true });
|
|
11446
|
-
const cur = this.session.model;
|
|
11447
|
-
if (cur && nextLevel) {
|
|
11448
|
-
this.session.settingsManager.setModelThinkingLevel(cur.provider, cur.id, nextLevel);
|
|
11449
|
-
}
|
|
11450
|
-
}
|
|
11451
|
-
catch (err) {
|
|
11452
|
-
this.emit({
|
|
11453
|
-
type: "notice",
|
|
11454
|
-
level: "error",
|
|
11455
|
-
text: `切换思考强度失败:${err.message}`,
|
|
11456
|
-
textEn: `Failed to switch thinking level: ${err.message}`,
|
|
11457
|
-
});
|
|
11458
|
-
}
|
|
11459
|
-
this.flushSnapshot();
|
|
11460
|
-
}
|
|
11461
11952
|
/** Push the user command list (.pi/commands.json) to the client. */
|
|
11462
11953
|
async listCommands() {
|
|
11463
11954
|
const { commands, path, warning, warningEn } = await loadCommands(this.cwd);
|
|
@@ -12318,6 +12809,7 @@ export class AgentService {
|
|
|
12318
12809
|
textEn: `"${main.title}" was moved to this page — pick up right where it left off.`,
|
|
12319
12810
|
});
|
|
12320
12811
|
await target.switchConversation(newMainId);
|
|
12812
|
+
await target.bindSession();
|
|
12321
12813
|
}
|
|
12322
12814
|
catch (err) {
|
|
12323
12815
|
fail(`过户失败:${err.message}`, `Takeover failed: ${err.message}`);
|