pi-web-ui 0.97.0 → 0.99.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/bin/pi-web-ui.mjs +64 -23
  5. package/dist/server/agent-service.js +645 -147
  6. package/dist/server/approval-rules.js +5 -10
  7. package/dist/server/atomic-file.js +40 -0
  8. package/dist/server/attachments.js +22 -0
  9. package/dist/server/claim-store.js +5 -11
  10. package/dist/server/client-state.js +220 -23
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +7 -5
  13. package/dist/server/composer-drafts.js +4 -6
  14. package/dist/server/control-socket.js +0 -40
  15. package/dist/server/delegate-mode.js +6 -12
  16. package/dist/server/dsh/dsh-agent-service.js +32 -30
  17. package/dist/server/dsh/dsh-client.js +45 -9
  18. package/dist/server/dsh/dsh-serialize.js +12 -10
  19. package/dist/server/dsh/dsh-sessions.js +20 -6
  20. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  21. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  22. package/dist/server/ensure-bash.js +3 -5
  23. package/dist/server/eval-tool.js +30 -5
  24. package/dist/server/goal-review-gate.js +7 -20
  25. package/dist/server/goal-service.js +77 -10
  26. package/dist/server/index.js +83 -15
  27. package/dist/server/launch-origin.js +0 -4
  28. package/dist/server/locales.js +3 -5
  29. package/dist/server/lsp-tool.js +110 -14
  30. package/dist/server/markers/builtins/notify.js +4 -2
  31. package/dist/server/markers/builtins/rename.js +0 -1
  32. package/dist/server/markers/builtins/todo.js +6 -26
  33. package/dist/server/markers/marker.js +0 -15
  34. package/dist/server/markers/registry.js +0 -3
  35. package/dist/server/markers/store.js +0 -3
  36. package/dist/server/mcp-hot-reload.js +12 -5
  37. package/dist/server/model-admin.js +19 -18
  38. package/dist/server/model-enrich.js +2 -3
  39. package/dist/server/patch-tool.js +78 -28
  40. package/dist/server/permission-preset.js +25 -0
  41. package/dist/server/plan-manager.js +196 -10
  42. package/dist/server/plan-mode.js +58 -20
  43. package/dist/server/plugin-api-catalog.js +2 -0
  44. package/dist/server/plugin-catalog.js +6 -6
  45. package/dist/server/plugin-dom.js +3 -4
  46. package/dist/server/plugin-facilities.js +3 -5
  47. package/dist/server/plugin-grants.js +5 -8
  48. package/dist/server/plugin-permissions.js +4 -6
  49. package/dist/server/plugin-schedule.js +4 -7
  50. package/dist/server/plugin-updater.js +151 -38
  51. package/dist/server/plugins.js +16 -2
  52. package/dist/server/prompt-composer.js +12 -3
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/resolve-global-sdk.js +8 -0
  55. package/dist/server/schedule-agent-tool.js +5 -1
  56. package/dist/server/scheduler-tasks.js +46 -27
  57. package/dist/server/sdk-origin.js +27 -0
  58. package/dist/server/serialize.js +22 -9
  59. package/dist/server/slash-commands.js +16 -0
  60. package/dist/server/subagent-templates.js +4 -10
  61. package/dist/server/subagents.js +22 -11
  62. package/dist/server/terminals.js +24 -12
  63. package/dist/server/text-quote.js +59 -0
  64. package/dist/server/tool-manager.js +12 -3
  65. package/dist/server/tool-overrides.js +24 -0
  66. package/dist/server/update-check.js +69 -40
  67. package/extensions/webui.ts +15 -1
  68. package/package.json +31 -19
  69. package/plugin-sdk/client-utils.mjs +92 -0
  70. package/plugin-sdk/index.d.ts +21 -0
  71. package/plugin-sdk/index.mjs +2 -0
  72. package/plugins/catalog.json +7 -0
  73. package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-NerrJlTS.js} +1 -1
  74. package/web/dist/assets/index-BdMaGCcs.css +1 -0
  75. package/web/dist/assets/index-ByCBN4Kt.js +368 -0
  76. package/web/dist/index.html +2 -2
  77. package/web/dist/sw.js +2 -1
  78. package/web/public/sw.js +2 -1
  79. package/web/dist/assets/index-DwToli0J.css +0 -1
  80. 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, renameSync, rmSync, statSync, mkdirSync, watch, writeFileSync, } from "node:fs";
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, getActiveConvId, emit, flushSnapshot) {
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 convId = getActiveConvId();
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。转录是 append-only
1263
- * 的唯一事实源,compaction 收尾/清理的整文件重写若中途被打断(进程被杀/断电),
1264
- * 原地 writeFileSync 会留下截断的 JSONL;同目录 rename 是原子的,最坏情况是
1265
- * 旧文件原样保留,绝不会出现半份转录。Windows 下目标被外部占用(阅读器/杀软
1266
- * 锁)时 rename 可能 EPERM —— 退回原地写保住功能(旧行为),不因小概率锁失败丢更新。
1303
+ * 转录整文件重写的原子落盘:先写同目录临时文件再 rename。
1267
1304
  */
1268
1305
  function atomicWriteFileSync(file, data) {
1269
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
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
- * (SDK 的 allMessagesText 包含每一段 user 与 assistant 消息,AI 输出也在内)。 */
1344
- function sessionMatchesSearch(q, s) {
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
- if (!existsSync(root))
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
- if (existsSync(cwd))
1515
- projects.set(key, { path: cwd, lastUsed: s.mtime });
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
- if (existsSync(r.path)) {
1584
- projects.set(key, r);
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/allMessagesText)。
1601
- * 仅用于会话列表发现与搜索,避免调用 SDK 重型的全量树解析。
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.push(text);
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: allMessages.join(" "),
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 norm(resolve(x)) === norm(resolve(y));
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 = new 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 = event.systemPrompt.indexOf("\n\nAvailable tools:");
3550
+ const boundary = core.indexOf("\n\nAvailable tools:");
3454
3551
  // 边界串是 SDK 提示词的内部格式:版本一变就可能对不上。
3455
3552
  // 对不上时不再静默回退默认 persona(模板等于没生效),而是把模板
3456
3553
  // 提示词前置拼接——角色约束仍在,只是灵魂段没被精确替换。
3457
- if (boundary === -1) {
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
- return rendered ? { systemPrompt: rendered } : undefined;
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, () => ownerId ?? this.activeId, (msg) => this.emit(msg), () => this.flushSnapshot()),
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
- return {
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 the ACTIVE conversation's session. */
3984
- async bindSession() {
3985
- const conv = this.conv;
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 的 sessionMatchesSearch 判定(含转录全文),只读不 emit。 */
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
- for (const s of infos) {
4237
- if (!sessionMatchesSearch(q, s))
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 rawMessages = conv.session.agent.state.messages
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);
@@ -5177,7 +5352,9 @@ export class ClientSession {
5177
5352
  compaction: conv.compactionState ?? null,
5178
5353
  pendingQuestion: this.pendingQuestionForSnapshot(),
5179
5354
  pendingApproval: this.pendingApprovalForSnapshot(),
5180
- plan: this.planManager.getPlan(this.activeId),
5355
+ // 任务计划看板状态同样是会话级:快照恒给 PlanState 或 null(不用 undefined),
5356
+ // 否则 snapshot_delta 里 key 缺席 → 前端 spread 浅合并会残留上一对话的 plan。
5357
+ plan: this.planManager.getPlan(this.activeId) ?? null,
5181
5358
  // 计划模式是**会话级**开关:快照恒给布尔(不用 undefined),否则
5182
5359
  // snapshot_delta 里 key 缺席 → 前端 spread 合并会残留上一对话的 true。
5183
5360
  planMode: conv?.planMode === true,
@@ -5621,7 +5798,36 @@ export class ClientSession {
5621
5798
  }
5622
5799
  updatePlan(steps, activeStepId, conversationId) {
5623
5800
  const convId = conversationId ?? this.activeId;
5624
- const plan = this.planManager.setPlan(convId, steps, activeStepId);
5801
+ const conv = this.convs.get(convId);
5802
+ const sessionId = conv?.session?.sessionId;
5803
+ const plan = this.planManager.setPlan(convId, steps, activeStepId, sessionId);
5804
+ try {
5805
+ const sm = conv?.session?.sessionManager;
5806
+ sm?.appendCustomEntry?.("plan/update", { plan });
5807
+ }
5808
+ catch {
5809
+ // ignore
5810
+ }
5811
+ this.emit({
5812
+ type: "plan_updated",
5813
+ conversationId: convId,
5814
+ plan,
5815
+ });
5816
+ this.flushSnapshot();
5817
+ }
5818
+ updatePlanStep(stepId, patch, conversationId) {
5819
+ const convId = conversationId ?? this.activeId;
5820
+ const plan = this.planManager.updateStep(convId, stepId, patch);
5821
+ this.emit({
5822
+ type: "plan_updated",
5823
+ conversationId: convId,
5824
+ plan,
5825
+ });
5826
+ this.flushSnapshot();
5827
+ }
5828
+ deletePlanStep(stepId, conversationId) {
5829
+ const convId = conversationId ?? this.activeId;
5830
+ const plan = this.planManager.deleteStep(convId, stepId);
5625
5831
  this.emit({
5626
5832
  type: "plan_updated",
5627
5833
  conversationId: convId,
@@ -5629,6 +5835,38 @@ export class ClientSession {
5629
5835
  });
5630
5836
  this.flushSnapshot();
5631
5837
  }
5838
+ addPlanStep(step, afterStepId, conversationId) {
5839
+ const convId = conversationId ?? this.activeId;
5840
+ const plan = this.planManager.addStep(convId, step, afterStepId);
5841
+ this.emit({
5842
+ type: "plan_updated",
5843
+ conversationId: convId,
5844
+ plan,
5845
+ });
5846
+ this.flushSnapshot();
5847
+ }
5848
+ /** 「✨ 净室执行(Clean-session Handoff)」:
5849
+ * 关闭源会话的计划闸门,新建隔离会话,在新会话中原子设置计划步骤、可选迁移目标,并触发实施轮 prompt。 */
5850
+ async planCleanHandoff(steps, promptText) {
5851
+ const sourceConv = this.conv;
5852
+ if (sourceConv?.planMode) {
5853
+ await this.setPlanMode(false, sourceConv.id);
5854
+ }
5855
+ const sourceGoal = sourceConv?.goal?.goal;
5856
+ await this.newChat();
5857
+ const targetConv = this.conv;
5858
+ this.planManager.setPlan(targetConv.id, steps, undefined, targetConv?.session?.sessionId);
5859
+ if (sourceGoal) {
5860
+ try {
5861
+ await this.goalSvc.setGoal(sourceGoal, { targetConvId: targetConv.id, autoStart: false });
5862
+ }
5863
+ catch {
5864
+ // 目标迁移失败不阻塞实施
5865
+ }
5866
+ }
5867
+ this.flushSnapshot();
5868
+ await this.prompt(promptText);
5869
+ }
5632
5870
  /** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
5633
5871
  cancelPendingQuestions() {
5634
5872
  for (const [, p] of this.pendingQuestions) {
@@ -5636,6 +5874,35 @@ export class ClientSession {
5636
5874
  }
5637
5875
  this.pendingQuestions.clear();
5638
5876
  }
5877
+ /** #487:结算指定会话的挂起提问/审批(按 conversationId 精确匹配)。
5878
+ * abort/移除/重建会话后残留的条目会让 isWaitingOnUser 恒真——stall 失联
5879
+ * 检测被永久豁免,且问卷/审批弹窗跨轮残留。dispose 口径范本:提问以
5880
+ * 「取消」解析、审批以「拒绝」解析并广播 tool_approval_resolved。 */
5881
+ settlePendingsForConv(convId) {
5882
+ for (const [id, q] of this.pendingQuestions) {
5883
+ if (q.conversationId !== convId)
5884
+ continue;
5885
+ this.pendingQuestions.delete(id);
5886
+ try {
5887
+ q.resolve(null);
5888
+ }
5889
+ catch {
5890
+ // 单个结算异常不影响其余清理
5891
+ }
5892
+ }
5893
+ for (const [id, a] of this.pendingApprovals) {
5894
+ if (a.conversationId !== convId)
5895
+ continue;
5896
+ this.pendingApprovals.delete(id);
5897
+ try {
5898
+ a.resolve({ decision: "deny", reason: "运行已停止" });
5899
+ }
5900
+ catch {
5901
+ // 单个结算异常不影响其余清理
5902
+ }
5903
+ this.emit({ type: "tool_approval_resolved", id });
5904
+ }
5905
+ }
5639
5906
  /** 关闭所有挂起审批(dispose 时清理):以「拒绝」解析,避免等答复的
5640
5907
  * Promise 与对应 runtime 泄漏(会话没了,审批弹窗永远不会有人点)。 */
5641
5908
  cancelPendingApprovals() {
@@ -5796,6 +6063,8 @@ export class ClientSession {
5796
6063
  * the forked child deadlocks between fork and exec. This applies to
5797
6064
  * asynchronous spawns too — the previous async probe reproduced the hang.
5798
6065
  */
6066
+ // 手写 TTL 缓存而非通用 memoize:安装 pi 后 invalidatePiCliProbe() 要立即
6067
+ // 失效重探,通用 TTL memoize 不带失效通道(issue #470 处置说明)。
5799
6068
  static piCliProbe = null;
5800
6069
  static PI_CLI_PROBE_TTL_MS = 10_000;
5801
6070
  isPiCliInstalled() {
@@ -5993,6 +6262,7 @@ export class ClientSession {
5993
6262
  * 主动检查已安装界面插件(<dataDir>/plugins)的更新状态并向客户端推送。
5994
6263
  */
5995
6264
  async checkPluginUpdates(manual = false) {
6265
+ this.updatesAllCache = null;
5996
6266
  const lang = () => this.getLang();
5997
6267
  try {
5998
6268
  const updates = await checkPluginUpdates(this.stateStore.dataDir, undefined, lang);
@@ -6120,11 +6390,12 @@ export class ClientSession {
6120
6390
  }));
6121
6391
  this.lastPluginUpdates = list;
6122
6392
  this.emit({ type: "plugin_updates", updates: list });
6393
+ const fmtVer = (v) => (v ? (/^[vV]/.test(v) ? v : `v${v}`) : null);
6123
6394
  pluginItems = pluginUpdates.map((p) => ({
6124
6395
  name: p.name ? `${p.name} (${p.id})` : p.id,
6125
6396
  kind: "plugin",
6126
- current: p.version ? `v${p.version}` : (p.localSha ?? "unknown"),
6127
- latest: p.latestVersion ? `v${p.latestVersion}` : (p.remoteSha ?? null),
6397
+ current: fmtVer(p.version) ?? p.localSha ?? "unknown",
6398
+ latest: fmtVer(p.latestVersion) ?? p.remoteSha ?? null,
6128
6399
  latestPublishedAt: null,
6129
6400
  upToDate: !p.updatable,
6130
6401
  error: p.error,
@@ -6287,6 +6558,8 @@ export class ClientSession {
6287
6558
  this.flushSnapshot();
6288
6559
  },
6289
6560
  refreshSessions: () => this.refreshSessions(),
6561
+ setPlanMode: (enabled) => this.setPlanMode(enabled),
6562
+ getPlanMode: () => this.conv?.planMode === true,
6290
6563
  afterReload: () => {
6291
6564
  // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 软上限覆盖 + 终端门控。
6292
6565
  this.applyRetryOverrides();
@@ -6833,6 +7106,10 @@ export class ClientSession {
6833
7106
  }
6834
7107
  }
6835
7108
  applyAgentToolsGating(session, [...disabled], targetPreset);
7109
+ const ownerId = conv?.id;
7110
+ syncSubagentOverride(session, disabled.has("subagent"), () => ownerId
7111
+ ? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId), undefined, ownerId)[0]
7112
+ : makeSubagentTools(this.subagentHost)[0]);
6836
7113
  this.syncPluginTools(session, targetPreset);
6837
7114
  this.sessionStatsCache = null;
6838
7115
  this.cachedBaseTokens = null;
@@ -7848,6 +8125,13 @@ export class ClientSession {
7848
8125
  conv.promptInFlight = true;
7849
8126
  // Attach files as independent nextTurn context messages (asides) so the
7850
8127
  // user message stays clean; they render as separate attachment cards.
8128
+ // SDK 的流式队列只消费用户消息;引用须与对应队列项一起入队和撤回。
8129
+ const streamingQuotes = s.isStreaming
8130
+ ? (attachments ?? [])
8131
+ .filter((a) => a.mode === "quote")
8132
+ .map((a) => readTextQuote(a.quote))
8133
+ .filter((q) => q !== null)
8134
+ : [];
7851
8135
  const asides = await buildAttachmentMessages({
7852
8136
  cwd: this.cwd,
7853
8137
  clientId: this.clientId,
@@ -7856,9 +8140,17 @@ export class ClientSession {
7856
8140
  session: this.session,
7857
8141
  // issue #91:附件/视觉桥文案按客户端 UI 语言出中英(英文默认)。
7858
8142
  getLang: () => this.getLang(),
7859
- }, attachments);
8143
+ }, streamingQuotes.length ? attachments?.filter((a) => a.mode !== "quote") : attachments);
7860
8144
  if (promptAc.signal.aborted) {
7861
8145
  conv.activePromptAc = undefined;
8146
+ // #492:displacement(新对话顶替/会话被移出)在 prompt 前置阶段 abort 时
8147
+ // 原本静默 return——消息凭空消失、草稿已清,用户毫无回执。
8148
+ this.emit({
8149
+ type: "notice",
8150
+ level: "warning",
8151
+ text: `消息未送达:该对话已被关闭或运行已被停止`,
8152
+ textEn: `Message not delivered: the conversation was closed or the run was stopped`,
8153
+ });
7862
8154
  return;
7863
8155
  }
7864
8156
  for (const aside of asides) {
@@ -7883,6 +8175,13 @@ export class ClientSession {
7883
8175
  }
7884
8176
  if (promptAc.signal.aborted) {
7885
8177
  conv.activePromptAc = undefined;
8178
+ // #492:同上——影子快照阶段的 abort 也必须给回执,不能静默黑洞。
8179
+ this.emit({
8180
+ type: "notice",
8181
+ level: "warning",
8182
+ text: `消息未送达:该对话已被关闭或运行已被停止`,
8183
+ textEn: `Message not delivered: the conversation was closed or the run was stopped`,
8184
+ });
7886
8185
  return;
7887
8186
  }
7888
8187
  conv.activePromptAc = undefined;
@@ -7907,12 +8206,12 @@ export class ClientSession {
7907
8206
  // Enter-during-streaming semantic (docs/usage: Enter queues a
7908
8207
  // steering message); followUp would wait for the whole run
7909
8208
  // to finish, which users perceive as ordinary queueing.
7910
- await s.prompt(text, {
8209
+ await s.prompt(formatQuotedPrompt(text, streamingQuotes), {
7911
8210
  streamingBehavior: queue ? "followUp" : "steer",
7912
8211
  });
7913
8212
  }
7914
8213
  else {
7915
- await s.prompt(text);
8214
+ await s.prompt(formatQuotedPrompt(text, streamingQuotes));
7916
8215
  }
7917
8216
  // 关联快照与本次 prompt 产生的用户消息 entry
7918
8217
  if (snapshotRef) {
@@ -8106,7 +8405,23 @@ export class ClientSession {
8106
8405
  this.flushSnapshot();
8107
8406
  return;
8108
8407
  }
8109
- const { steering, followUp } = s.clearQueue();
8408
+ let steering;
8409
+ let followUp;
8410
+ try {
8411
+ ({ steering, followUp } = s.clearQueue());
8412
+ }
8413
+ catch (err) {
8414
+ // #491:runtime 被 forceReset 置换等半死状态下 clearQueue 可能抛错——
8415
+ // 回退到本地显示镜像移除,等 queue_update 对账;绝不把 reject 的 Promise
8416
+ // 扔回给 dispatch(无兜底 handler 时 unhandledRejection 直接崩进程)。
8417
+ console.error("[agent-service] clearQueue failed while removing a queued message:", err);
8418
+ const next = removeQueuedByIndexOrText(local, text, index);
8419
+ if (next.length !== local.length) {
8420
+ local.splice(0, local.length, ...next);
8421
+ }
8422
+ this.flushSnapshot();
8423
+ return;
8424
+ }
8110
8425
  // 气泡 ✕ 对应的是「第几个气泡」(index),不是「哪段文本」:同一文本排队两次时
8111
8426
  // 按文本只会删掉第一条,点第二个气泡却删掉第一个。用 index 定位,位置对不上
8112
8427
  // (队列在点击与执行之间变化)或旧客户端没发 index 时回落到第一处文本匹配。
@@ -8244,6 +8559,9 @@ export class ClientSession {
8244
8559
  }
8245
8560
  /** Interrupt a run: abort, with a force-reset fallback on timeout. */
8246
8561
  async interruptRun(conv, reason) {
8562
+ // #487:停下就结算该会话的挂起提问/审批——残留条目让 isWaitingOnUser 恒真
8563
+ // (stall 失联检测被永久豁免),弹窗也会跨轮残留;dispose 语义已范本化。
8564
+ this.settlePendingsForConv(conv.id);
8247
8565
  if (!this.conversationStreaming(conv) && conv.session.isIdle) {
8248
8566
  return;
8249
8567
  }
@@ -8317,6 +8635,11 @@ export class ClientSession {
8317
8635
  conv.toolStartTimes.clear();
8318
8636
  disposeEvalSession(conv.id);
8319
8637
  await conv.runtime.dispose();
8638
+ // #485:dispose 挂起期间会话可能已被强行关闭移出(forceDismiss 走
8639
+ // removeConversation)——此时绝不能幽灵重建,否则新 runtime 挂在已脱离
8640
+ // this.convs 的 conv 对象上,无人持有、永不回收。
8641
+ if (!this.convs.has(conv.id))
8642
+ return;
8320
8643
  // #280:dispose 丢弃了内存里的在飞状态(未落盘的工具结果蒸发),
8321
8644
  // 文件尾可能留下一个悬空 toolCall——先补合成 toolResult 再重建,
8322
8645
  // 否则重建后的 prompt 会把非法转录链喂给 provider(零落盘黑洞)。
@@ -8348,6 +8671,17 @@ export class ClientSession {
8348
8671
  sessionManager: m,
8349
8672
  }), async () => (ownFile && existsSync(ownFile) ? ownFile : undefined));
8350
8673
  const runtime = opened.runtime;
8674
+ // #485:openManagerAndRuntime 的长 await 期间会话被移出的同一守卫——
8675
+ // 刚建好的 runtime 无人认领,就地 dispose 防止扩展宿主子进程泄漏。
8676
+ if (!this.convs.has(conv.id)) {
8677
+ try {
8678
+ await runtime.dispose();
8679
+ }
8680
+ catch {
8681
+ // 已被移除的会话:dispose 失败无处上报,best-effort。
8682
+ }
8683
+ return;
8684
+ }
8351
8685
  if (opened.repair) {
8352
8686
  for (const n of this.transcriptRepairNotices(opened.repair))
8353
8687
  this.emit(n);
@@ -8381,7 +8715,10 @@ export class ClientSession {
8381
8715
  text: reason,
8382
8716
  textEn: `${reason} (forced reset: run did not terminate)`,
8383
8717
  });
8384
- await this.bindSession();
8718
+ // #484:把订阅重挂到**被重建的那个对话**上。非活跃对话(子代理/角色
8719
+ // 对话)此前永远走 bindSession() → 只给活跃对话挂订阅,重建后该对话
8720
+ // 的所有 SDK 事件失聪:快照冻结、看门狗不再布防、turnEndWaiters 死等。
8721
+ await this.bindSession(conv);
8385
8722
  this.emitConversations();
8386
8723
  void this.pushSlashCommands();
8387
8724
  }
@@ -8408,7 +8745,8 @@ export class ClientSession {
8408
8745
  // this branch normally can't exist — kept as a safety net).
8409
8746
  const isBlank = (c) => {
8410
8747
  try {
8411
- return c.session.getSessionStats().totalMessages === 0 && c.terminals.list().length === 0;
8748
+ const hasPlan = (this.planManager.getPlan(c.id)?.steps.length ?? 0) > 0;
8749
+ return c.session.getSessionStats().totalMessages === 0 && c.terminals.list().length === 0 && !hasPlan;
8412
8750
  }
8413
8751
  catch {
8414
8752
  // session being replaced — treat as used so we don't switch onto it
@@ -8784,6 +9122,9 @@ export class ClientSession {
8784
9122
  }
8785
9123
  displaceActive() {
8786
9124
  const conv = this.conv;
9125
+ // 正在过户给其他会话:绝不就地释放(runtime/终端等整体搬迁给 target)。
9126
+ if (conv.transferring)
9127
+ return null;
8787
9128
  // 子代理不受切换关闭影响(见上)。
8788
9129
  if (conv.isSubagent) {
8789
9130
  conv.listed = true;
@@ -8847,6 +9188,8 @@ export class ClientSession {
8847
9188
  const conv = this.convs.get(id);
8848
9189
  if (!conv || id === this.activeId)
8849
9190
  return;
9191
+ // #487:会话没了,挂起的提问/审批永远等不到人点——按 dispose 口径就地结算。
9192
+ this.settlePendingsForConv(id);
8850
9193
  // 正在投递的消息(附件构建 / 影子快照阶段)必须先打断:否则 prompt() 醒来
8851
9194
  // 后会把用户消息写进已销毁的 runtime,静默丢失。
8852
9195
  if (conv.activePromptAc) {
@@ -8870,6 +9213,7 @@ export class ClientSession {
8870
9213
  // ignore
8871
9214
  }
8872
9215
  this.convs.delete(id);
9216
+ this.planManager.unbindConversation(id);
8873
9217
  this.clearAllToolWatchdogs(conv);
8874
9218
  // 关对话 → 连它的 eval 内核(Python/Node 子进程 + 临时沙箱目录)一起回收:
8875
9219
  // 这些进程是 detached 进程组,父进程退出不会自动带走它们。
@@ -8925,21 +9269,37 @@ export class ClientSession {
8925
9269
  const convs = [...this.convs.values()].filter((c) => set.has(c.id));
8926
9270
  if (convs.length === 0)
8927
9271
  return { ok: false, reason: "missing" };
8928
- if (set.has(this.activeId)) {
8929
- const remaining = [...this.convs.values()].find((c) => !set.has(c.id) && !c.isSubagent) ??
8930
- [...this.convs.values()].find((c) => !set.has(c.id));
8931
- if (remaining) {
8932
- await this.switchConversation(remaining.id);
8933
- }
8934
- else if (!(await this.newChat())) {
8935
- return { ok: false, reason: "empty" };
9272
+ for (const conv of convs)
9273
+ conv.transferring = true;
9274
+ try {
9275
+ if (set.has(this.activeId)) {
9276
+ const remaining = [...this.convs.values()].find((c) => !set.has(c.id) && !c.isSubagent) ??
9277
+ [...this.convs.values()].find((c) => !set.has(c.id));
9278
+ if (remaining) {
9279
+ await this.switchConversation(remaining.id);
9280
+ }
9281
+ else if (!(await this.newChat())) {
9282
+ return { ok: false, reason: "empty" };
9283
+ }
8936
9284
  }
8937
9285
  }
9286
+ finally {
9287
+ for (const conv of convs)
9288
+ delete conv.transferring;
9289
+ }
8938
9290
  for (const conv of convs) {
8939
9291
  this.convs.delete(conv.id);
8940
9292
  this.clearAllToolWatchdogs(conv);
8941
9293
  conv.unsubscribe?.();
8942
9294
  conv.unsubscribe = undefined;
9295
+ // issue #457: 唤醒角色轮等待者与目标审查等待者,避免会话搬走后原会话循环永久卡死
9296
+ const waiters = this.turnEndWaiters.get(conv.id);
9297
+ if (waiters) {
9298
+ this.turnEndWaiters.delete(conv.id);
9299
+ for (const fn of waiters)
9300
+ fn("gone");
9301
+ }
9302
+ this.goalSvc.notifyTakeover(conv);
8943
9303
  }
8944
9304
  const questions = [];
8945
9305
  for (const [qid, p] of this.pendingQuestions) {
@@ -9304,8 +9664,118 @@ export class ClientSession {
9304
9664
  static projectsCache = null;
9305
9665
  static PROJECTS_CACHE_TTL = 60_000;
9306
9666
  static projectsInFlight = null;
9307
- /** 会话单文件 stat 缓存(path -> { mtime, size, info }):避免对未修改的会话文件重复读取与 JSON 解析。 */
9667
+ /**
9668
+ * 会话单文件元信息缓存(path -> { mtime, size, info }):避免对未修改的会话文件
9669
+ * 重复读取与 JSON 解析。LRU 上限(issue #440):静态 Map 跨项目共享,原先没有任何
9670
+ * 上限/TTL —— 删除、更名、被挤出 200 名额的文件只要曾进过缓存就永久驻留,长驻
9671
+ * 服务 + 多项目重度使用下 RSS 单调上涨。
9672
+ *
9673
+ * 上限 512 的依据:单项目单轮扫描只取最新 200 个候选,512 ≈ 2.5 倍余量,足够容纳
9674
+ * 多个最近活跃项目的热集;且拆分后(issue #440)条目只含元信息不含转录全文
9675
+ * (单条 KB 级),总占用恒定且极小 —— 上限防的是「无界累积」,不是单条重量。
9676
+ *
9677
+ * LRU 淘汰语义:把 Map 当 LRU 用(Map 迭代序 = 插入序)—— 命中即 delete+set 重插
9678
+ * 到最新端,写入超上限从最旧端淘汰。mtime/size 未变的条目命中即复用(行为与原先
9679
+ * 一致),变化的条目由调用方重新解析后经 store 覆盖(也落到最新端)。
9680
+ */
9308
9681
  static sessionFileCache = new Map();
9682
+ static SESSION_FILE_CACHE_MAX = 512;
9683
+ /**
9684
+ * 转录全文缓存(path -> { text, at }):仅供 searchSessions / searchForPlugins 的
9685
+ * 全文命中判定按需加载(issue #440 拆分)。与列表缓存分离的原因:列表路径
9686
+ * (pushSessions / 插件列表 / 删除后切换)只需要元信息,而全文单条可达 MB 级,
9687
+ * 随列表常驻会让每个曾进过列表的文件都占住内存。
9688
+ *
9689
+ * 容量依据:256 ≥ 单项目候选上限 200,键入查询的其余按键在 TTL 窗口内全程免读盘;
9690
+ * 单条在 parseSessionInfoFast 里按 MAX_SEARCH_TEXT_CHARS(256K 字符)封顶。
9691
+ * TTL 只在访问时判断(无定时器),每次载入顺手清扫过期项 —— 搜索空闲后不长期
9692
+ * 占内存。删除/更名经 invalidateSessionInfos 同步清理。
9693
+ */
9694
+ static sessionTextCache = new Map();
9695
+ static SESSION_TEXT_CACHE_MAX = 256;
9696
+ static SESSION_TEXT_CACHE_TTL = 30_000;
9697
+ /** sessionFileCache LRU 读取:命中即重插到最新端(续期),未命中返回 undefined。 */
9698
+ static sessionFileCacheLookup(key) {
9699
+ const entry = ClientSession.sessionFileCache.get(key);
9700
+ if (!entry)
9701
+ return undefined;
9702
+ ClientSession.sessionFileCache.delete(key);
9703
+ ClientSession.sessionFileCache.set(key, entry);
9704
+ return entry;
9705
+ }
9706
+ /** sessionFileCache LRU 写入:新/更新条目放最新端,超上限从最旧端淘汰。 */
9707
+ static sessionFileCacheStore(key, entry) {
9708
+ ClientSession.sessionFileCache.delete(key);
9709
+ ClientSession.sessionFileCache.set(key, entry);
9710
+ while (ClientSession.sessionFileCache.size > ClientSession.SESSION_FILE_CACHE_MAX) {
9711
+ const oldest = ClientSession.sessionFileCache.keys().next();
9712
+ if (oldest.done)
9713
+ break;
9714
+ ClientSession.sessionFileCache.delete(oldest.value);
9715
+ }
9716
+ }
9717
+ /** sessionTextCache 读取:TTL 内命中即重插续期;过期即清并按未命中处理。 */
9718
+ static sessionTextCacheLookup(key, now) {
9719
+ const entry = ClientSession.sessionTextCache.get(key);
9720
+ if (!entry)
9721
+ return undefined;
9722
+ ClientSession.sessionTextCache.delete(key);
9723
+ if (now - entry.at >= ClientSession.SESSION_TEXT_CACHE_TTL)
9724
+ return undefined;
9725
+ ClientSession.sessionTextCache.set(key, entry);
9726
+ return entry.text;
9727
+ }
9728
+ /** sessionTextCache 写入:放最新端,超上限从最旧端淘汰。 */
9729
+ static sessionTextCacheStore(key, text, at) {
9730
+ ClientSession.sessionTextCache.delete(key);
9731
+ ClientSession.sessionTextCache.set(key, { text, at });
9732
+ while (ClientSession.sessionTextCache.size > ClientSession.SESSION_TEXT_CACHE_MAX) {
9733
+ const oldest = ClientSession.sessionTextCache.keys().next();
9734
+ if (oldest.done)
9735
+ break;
9736
+ ClientSession.sessionTextCache.delete(oldest.value);
9737
+ }
9738
+ }
9739
+ /** 惰性清扫 sessionTextCache 过期项:TTL 只在访问时判断,没有定时器兜底,
9740
+ * 靠每次全文载入顺手扫一遍,保证搜索空闲后全文不会无限期滞留内存。 */
9741
+ static sessionTextCacheSweep(now) {
9742
+ for (const [key, entry] of ClientSession.sessionTextCache) {
9743
+ if (now - entry.at >= ClientSession.SESSION_TEXT_CACHE_TTL)
9744
+ ClientSession.sessionTextCache.delete(key);
9745
+ }
9746
+ }
9747
+ /**
9748
+ * 转录全文按需加载(搜索专用):TTL 缓存命中直接回;未命中读盘提取全文
9749
+ * (与 parseSessionInfoFast 同口径:user/assistant 消息文本,MAX_SEARCH_TEXT_CHARS
9750
+ * 截断)。解析失败回空串且不缓存 —— 与列表路径一样下次重试。
9751
+ */
9752
+ static async loadSessionSearchText(filePath) {
9753
+ const now = Date.now();
9754
+ ClientSession.sessionTextCacheSweep(now);
9755
+ const hit = ClientSession.sessionTextCacheLookup(filePath, now);
9756
+ if (hit !== undefined)
9757
+ return hit;
9758
+ const info = await parseSessionInfoFast(filePath, 0, true);
9759
+ const text = info?.allMessagesText ?? "";
9760
+ if (text)
9761
+ ClientSession.sessionTextCacheStore(filePath, text, now);
9762
+ return text;
9763
+ }
9764
+ /**
9765
+ * searchSessions / searchForPlugins 共用的全文匹配:先比元信息(零 IO,覆盖绝大多数
9766
+ * 按标题/文件名/首条消息的查询),元信息未命中的会话再按需加载转录全文判定
9767
+ * (小容量短 TTL 缓存,见 sessionTextCache)。保序返回命中子集。
9768
+ * issue #440:全文不再随列表缓存常驻,改在这里按需加载。
9769
+ */
9770
+ static async filterSessionsForSearch(q, infos) {
9771
+ const flags = await Promise.all(infos.map(async (s) => {
9772
+ if (sessionMatchesMetadata(q, s))
9773
+ return true;
9774
+ const text = await ClientSession.loadSessionSearchText(s.path);
9775
+ return text.toLowerCase().includes(q);
9776
+ }));
9777
+ return infos.filter((_, i) => flags[i]);
9778
+ }
9309
9779
  /** 会话列表缓存(按 cwdKey 隔离,30s TTL)。 */
9310
9780
  static sessionInfosCache = new Map();
9311
9781
  static sessionInfosInFlight = new Map();
@@ -9344,15 +9814,15 @@ export class ClientSession {
9344
9814
  }));
9345
9815
  const validStats = stats.filter((s) => s !== null);
9346
9816
  validStats.sort((a, b) => b.mtime - a.mtime);
9347
- const candidates = validStats.slice(0, 200);
9348
- const results = await Promise.all(candidates.map(async (file) => {
9349
- const cached = ClientSession.sessionFileCache.get(file.path);
9817
+ const results = await Promise.all(validStats.map(async (file) => {
9818
+ const cached = ClientSession.sessionFileCacheLookup(file.path);
9350
9819
  if (cached && cached.mtime === file.mtime && cached.size === file.size) {
9351
9820
  return cached.info;
9352
9821
  }
9822
+ // 列表路径不收集转录全文(issue #440)——全文只在搜索时按需加载
9353
9823
  const info = await parseSessionInfoFast(file.path, file.mtime);
9354
9824
  if (info) {
9355
- ClientSession.sessionFileCache.set(file.path, {
9825
+ ClientSession.sessionFileCacheStore(file.path, {
9356
9826
  mtime: file.mtime,
9357
9827
  size: file.size,
9358
9828
  info,
@@ -9361,15 +9831,28 @@ export class ClientSession {
9361
9831
  return info;
9362
9832
  }));
9363
9833
  const validInfos = results.filter((info) => info !== null);
9364
- validInfos.sort((a, b) => b.modified.getTime() - a.modified.getTime());
9365
- ClientSession.sessionInfosCache.set(cwdKey, { infos: validInfos, at: Date.now() });
9366
- return validInfos;
9834
+ // issue #438:扁平布局(PI_CODING_AGENT_SESSION_DIR)下根目录顶层直接是**所有项目**
9835
+ // 共享的 .jsonl,所属 cwd 是文件内字段;不过滤的话其他项目的会话会混进本项目
9836
+ // 「历史会话」列表,点开即把整个工作区切走。与 SessionManager.list(cwd, ...) 的
9837
+ // fallback 及 discoverRecentProjectsFromDisk 同口径:normalizePathKey(Windows
9838
+ // 大小写/斜杠归一)。空 cwd 的损坏文件须排除——normalizePathKey("") 会 resolve 到
9839
+ // process.cwd(),恰好等于本项目时会把垃圾文件误收进来。
9840
+ const ownInfos = validInfos.filter((info) => info.cwd !== "" && normalizePathKey(info.cwd) === cwdKey);
9841
+ // 200 名额只在 cwd 过滤**之后**分配:扁平布局下若先截断,本项目会话可能被
9842
+ // 其他项目的文件挤出列表。截断仍按上面的 mtime 降序序取(Promise.all 保序),
9843
+ // 与原有选集口径一致;非扁平布局每-cwd 子目录内文件全属本项目,过滤幂等。
9844
+ const candidates = ownInfos.slice(0, 200);
9845
+ candidates.sort((a, b) => b.modified.getTime() - a.modified.getTime());
9846
+ ClientSession.sessionInfosCache.set(cwdKey, { infos: candidates, at: Date.now() });
9847
+ return candidates;
9367
9848
  }
9368
9849
  catch {
9369
9850
  try {
9370
9851
  const fallback = await SessionManager.list(this.cwd, piSessionsRoot());
9371
- ClientSession.sessionInfosCache.set(cwdKey, { infos: fallback, at: Date.now() });
9372
- return fallback;
9852
+ // 统一口径:列表缓存一律不带转录全文(issue #440),搜索按需另行加载
9853
+ const stripped = fallback.map((s) => ({ ...s, allMessagesText: "" }));
9854
+ ClientSession.sessionInfosCache.set(cwdKey, { infos: stripped, at: Date.now() });
9855
+ return stripped;
9373
9856
  }
9374
9857
  catch {
9375
9858
  return [];
@@ -9389,7 +9872,10 @@ export class ClientSession {
9389
9872
  invalidateSessionInfos(filePath) {
9390
9873
  ClientSession.sessionInfosCache.clear();
9391
9874
  if (filePath) {
9392
- ClientSession.sessionFileCache.delete(resolve(filePath));
9875
+ const abs = resolve(filePath);
9876
+ ClientSession.sessionFileCache.delete(abs);
9877
+ // 全文缓存一并清(issue #440):文件已删/更名,正文不该再被搜索命中
9878
+ ClientSession.sessionTextCache.delete(abs);
9393
9879
  }
9394
9880
  }
9395
9881
  /** Push the persisted session list to the client (client-requested). */
@@ -9414,7 +9900,7 @@ export class ClientSession {
9414
9900
  const infos = await this.loadSessionInfos();
9415
9901
  // 隐藏「被 fork 掉的父会话」,历史列表只保留每条 fork 链最新的链尾会话
9416
9902
  // (全文搜索 searchSessions 保留全部会话,不受此影响)。
9417
- const normSessionPath = (p) => String(p).replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();
9903
+ const normSessionPath = (p) => normalizePathKey(p);
9418
9904
  const forkedParentPaths = new Set();
9419
9905
  for (const info of infos) {
9420
9906
  if (typeof info.parentSessionPath === "string" && info.parentSessionPath) {
@@ -9429,6 +9915,7 @@ export class ClientSession {
9429
9915
  : infos;
9430
9916
  const sessions = new Map();
9431
9917
  for (const s of visibleInfos) {
9918
+ const isPinned = this.stateStore?.isSessionPinned(this.cwd, s.path);
9432
9919
  sessions.set(s.path, {
9433
9920
  path: s.path,
9434
9921
  name: s.name,
@@ -9436,6 +9923,7 @@ export class ClientSession {
9436
9923
  messageCount: s.messageCount,
9437
9924
  modified: s.modified.getTime(),
9438
9925
  source: "web",
9926
+ ...(isPinned ? { pinned: true } : {}),
9439
9927
  });
9440
9928
  }
9441
9929
  const sorted = [...sessions.values()].sort((a, b) => b.modified - a.modified).slice(0, 200); // newest first — the panel shows recent history
@@ -9550,6 +10038,8 @@ export class ClientSession {
9550
10038
  return;
9551
10039
  }
9552
10040
  rmSync(abs, { force: true });
10041
+ // 转录删了,钉住记录一起清理(issue #433)。
10042
+ this.stateStore?.cleanPinnedSession(abs);
9553
10043
  // 转录删了,sidecar 再留着就是孤儿,一起清掉(不存在不报错)。
9554
10044
  removeTouchSidecar(abs);
9555
10045
  // 转录删了,未发送草稿再留着就是孤儿,一起清掉。
@@ -9639,9 +10129,15 @@ export class ClientSession {
9639
10129
  conv.pinned = true;
9640
10130
  // 立即进入运行列表(含空白对话):listed 一旦置位,shownInRunningList 即放行。
9641
10131
  conv.listed = true;
10132
+ if (conv.session.sessionFile) {
10133
+ this.stateStore?.setSessionPinned(conv.cwd, conv.session.sessionFile, true);
10134
+ }
9642
10135
  }
9643
10136
  else {
9644
10137
  delete conv.pinned;
10138
+ if (conv.session.sessionFile) {
10139
+ this.stateStore?.setSessionPinned(conv.cwd, conv.session.sessionFile, false);
10140
+ }
9645
10141
  }
9646
10142
  this.emitConversations();
9647
10143
  this.flushSnapshot();
@@ -9656,6 +10152,27 @@ export class ClientSession {
9656
10152
  : `Unpinned "${conv.title}" — it will be handled by the usual rules on the next switch`,
9657
10153
  });
9658
10154
  }
10155
+ /** 钉住 / 取消钉住历史会话(持久化并在运行中/历史列表生效)。 */
10156
+ async pinSession(path, pinned) {
10157
+ const targetPath = resolve(path);
10158
+ this.stateStore?.setSessionPinned(this.cwd, targetPath, pinned);
10159
+ // 如果当前已在运行列表中,同步其 pinned 状态
10160
+ for (const conv of this.convs.values()) {
10161
+ if (conv.session.sessionFile && resolve(conv.session.sessionFile) === targetPath) {
10162
+ if (pinned) {
10163
+ conv.pinned = true;
10164
+ conv.listed = true;
10165
+ }
10166
+ else {
10167
+ delete conv.pinned;
10168
+ }
10169
+ break;
10170
+ }
10171
+ }
10172
+ this.emitConversations();
10173
+ this.flushSnapshot();
10174
+ await this.refreshSessions();
10175
+ }
9659
10176
  /** Rename a live conversation by id: retitle in memory AND persist a
9660
10177
  * session_info entry to its transcript so History matches immediately. */
9661
10178
  async renameConversation(id, name) {
@@ -10307,6 +10824,10 @@ export class ClientSession {
10307
10824
  // Deliberately resumed — must not be dismissed when the user later
10308
10825
  // switches away without sending a new message.
10309
10826
  conv.promptedSinceActive = true;
10827
+ if (this.stateStore?.isSessionPinned(targetCwd, targetPath)) {
10828
+ conv.pinned = true;
10829
+ conv.listed = true;
10830
+ }
10310
10831
  this.noticeInterruptedCompaction(conv);
10311
10832
  this.convs.set(conv.id, conv);
10312
10833
  this.activeId = conv.id;
@@ -10315,6 +10836,10 @@ export class ClientSession {
10315
10836
  if (displaced)
10316
10837
  this.removeConversation(displaced.id);
10317
10838
  await this.bindSession();
10839
+ // #436:转录回放恢复的 planMode 在 runtime 工厂的 applyToolGating 时还读不到
10840
+ //(conv 尚未进 this.convs,planModeOf 落空)——注册后统一补一次门控,让
10841
+ // 计划模式的写类/旁路工具剥离对重开的会话同样生效。下同(forkSession/setCwd)。
10842
+ this.applyToolGating(conv.session, conv.agentPreset);
10318
10843
  this.cwd = targetCwd;
10319
10844
  // 打开的历史会话可能属于另一个项目 —— 工作区跟随面(roots/文件树/
10320
10845
  // 历史列表/命令目录/最近项目)必须跟着切,否则 UI 停在旧项目。
@@ -10509,6 +11034,9 @@ export class ClientSession {
10509
11034
  if (displaced)
10510
11035
  this.removeConversation(displaced.id);
10511
11036
  await this.bindSession();
11037
+ // #436:同 switchSession —— 派生出的会话若从转录回放恢复了 planMode,
11038
+ // 工厂里的门控当时读不到它,注册后补一次。
11039
+ this.applyToolGating(newConv.session, newConv.agentPreset);
10512
11040
  if (prevModel && this.sharedModelRuntime) {
10513
11041
  try {
10514
11042
  const pm = prevModel;
@@ -10677,7 +11205,8 @@ export class ClientSession {
10677
11205
  if (this.quiesceBlocked())
10678
11206
  return;
10679
11207
  const trimmed = text.trim();
10680
- if (!trimmed) {
11208
+ const hasAttachments = Boolean(attachments && attachments.length > 0);
11209
+ if (!trimmed && !hasAttachments) {
10681
11210
  this.emit({
10682
11211
  type: "notice",
10683
11212
  level: "warning",
@@ -10787,14 +11316,14 @@ export class ClientSession {
10787
11316
  try {
10788
11317
  const discovered = await discoverRecentProjectsFromDisk(sessionRoots, 30);
10789
11318
  this.stateStore.mergeDiscoveredProjects(discovered);
10790
- const merged = this.stateStore.getRecentProjects(this.clientId);
11319
+ const merged = await this.stateStore.getRecentProjects(this.clientId);
10791
11320
  ClientSession.projectsCache = { at: Date.now(), projects: merged };
10792
11321
  const result = this.withCurrentCwd(merged, Date.now());
10793
11322
  this.emit({ type: "projects", projects: result });
10794
11323
  return merged;
10795
11324
  }
10796
11325
  catch {
10797
- const fallback = this.withCurrentCwd(this.stateStore.getRecentProjects(this.clientId), Date.now());
11326
+ const fallback = this.withCurrentCwd(await this.stateStore.getRecentProjects(this.clientId), Date.now());
10798
11327
  this.emit({ type: "projects", projects: fallback });
10799
11328
  return fallback;
10800
11329
  }
@@ -10835,8 +11364,9 @@ export class ClientSession {
10835
11364
  }
10836
11365
  /** 全局搜索:在当前工作区的会话转录全文里做大小写不敏感匹配 ——
10837
11366
  * 不止首条消息,而是每一段 user 与 assistant 文本(AI 输出也在内)。
10838
- * 结果经 session_search_results 回推(reqId 匹配);复用 loadSessionInfos()
10839
- * 缓存,避免每个按键都重新解析全部转录文件。 */
11367
+ * 结果经 session_search_results 回推(reqId 匹配);元信息复用 loadSessionInfos()
11368
+ * 缓存,转录全文按需加载(issue #440,小容量短 TTL 缓存),避免每个按键都重新
11369
+ * 解析全部转录文件、也不让全文长期驻留内存。 */
10840
11370
  async searchSessions(query, reqId) {
10841
11371
  const q = query.trim().toLowerCase();
10842
11372
  if (!q) {
@@ -10845,11 +11375,12 @@ export class ClientSession {
10845
11375
  }
10846
11376
  try {
10847
11377
  const infos = await this.loadSessionInfos();
10848
- const results = infos
10849
- .filter((s) => sessionMatchesSearch(q, s))
11378
+ const matched = await ClientSession.filterSessionsForSearch(q, infos);
11379
+ const results = matched
10850
11380
  .sort((a, b) => b.modified.getTime() - a.modified.getTime())
10851
11381
  .slice(0, 50)
10852
11382
  .map((s) => {
11383
+ const isPinned = this.stateStore?.isSessionPinned(this.cwd, s.path);
10853
11384
  const base = {
10854
11385
  path: s.path,
10855
11386
  name: s.name,
@@ -10857,6 +11388,7 @@ export class ClientSession {
10857
11388
  messageCount: s.messageCount,
10858
11389
  modified: s.modified.getTime(),
10859
11390
  source: "web",
11391
+ ...(isPinned ? { pinned: true } : {}),
10860
11392
  };
10861
11393
  // 命中会话里再定位具体消息(供点击跳转);仅元数据命中则无锚点
10862
11394
  return { ...base, anchors: collectSessionAnchors(s.path, q) };
@@ -10985,26 +11517,6 @@ export class ClientSession {
10985
11517
  await this.setCwd(created);
10986
11518
  }
10987
11519
  }
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
11520
  /**
11009
11521
  * Path completion for the cwd input: expand ~/relative paths, list the parent
11010
11522
  * directory, and return prefix matches (dirs first, capped).
@@ -11152,6 +11664,9 @@ export class ClientSession {
11152
11664
  }
11153
11665
  }
11154
11666
  await this.bindSession();
11667
+ // #436:同 switchSession —— 续聊最近会话若从转录回放恢复了 planMode,
11668
+ // 工厂里的门控当时读不到它,注册后补一次。
11669
+ this.applyToolGating(conv.session, conv.agentPreset);
11155
11670
  if (resumeSkipped) {
11156
11671
  this.emit(resumeSkipped.isStreaming
11157
11672
  ? {
@@ -11440,24 +11955,6 @@ export class ClientSession {
11440
11955
  }
11441
11956
  this.flushSnapshot();
11442
11957
  }
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
11958
  /** Push the user command list (.pi/commands.json) to the client. */
11462
11959
  async listCommands() {
11463
11960
  const { commands, path, warning, warningEn } = await loadCommands(this.cwd);
@@ -12318,6 +12815,7 @@ export class AgentService {
12318
12815
  textEn: `"${main.title}" was moved to this page — pick up right where it left off.`,
12319
12816
  });
12320
12817
  await target.switchConversation(newMainId);
12818
+ await target.bindSession();
12321
12819
  }
12322
12820
  catch (err) {
12323
12821
  fail(`过户失败:${err.message}`, `Takeover failed: ${err.message}`);