pi-web-ui 0.91.0 → 0.93.0

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