pi-web-ui 0.92.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 (56) hide show
  1. package/CHANGELOG.md +58 -2
  2. package/README.md +69 -14
  3. package/README.zh-CN.md +36 -10
  4. package/bin/pi-web-ui.mjs +18 -6
  5. package/dist/server/agent-service.js +497 -120
  6. package/dist/server/auth-cookie.js +26 -0
  7. package/dist/server/claim-files-tool.js +6 -7
  8. package/dist/server/client-state.js +65 -6
  9. package/dist/server/conversation-read-tool.js +2 -1
  10. package/dist/server/delegate-task.js +10 -3
  11. package/dist/server/dsh/dsh-agent-service.js +43 -1
  12. package/dist/server/files-service.js +78 -26
  13. package/dist/server/http-proxy.js +72 -0
  14. package/dist/server/index.js +62 -7
  15. package/dist/server/patch-node-pty.js +11 -0
  16. package/dist/server/plugins.js +1 -0
  17. package/dist/server/protocol-version.js +1 -1
  18. package/dist/server/read-tool.js +37 -3
  19. package/dist/server/resolve-global-sdk.js +72 -0
  20. package/dist/server/sdk-origin.js +64 -0
  21. package/dist/server/serialize.js +59 -21
  22. package/dist/server/settings-service.js +12 -1
  23. package/dist/server/skill-tool.js +2 -1
  24. package/dist/server/subagents.js +30 -13
  25. package/dist/server/terminals.js +38 -18
  26. package/dist/server/themes.js +15 -0
  27. package/dist/server/tool-info.js +89 -0
  28. package/dist/server/tool-manager.js +107 -14
  29. package/package.json +2 -2
  30. package/plugins/catalog.json +10 -0
  31. package/themes/ayu-light.css +136 -0
  32. package/themes/catppuccin-latte.css +4 -0
  33. package/themes/catppuccin.css +4 -0
  34. package/themes/codex.css +136 -0
  35. package/themes/cyberpunk.css +4 -0
  36. package/themes/dazzle.css +4 -0
  37. package/themes/everforest-light.css +136 -0
  38. package/themes/geist.css +136 -0
  39. package/themes/gruvbox-light.css +136 -0
  40. package/themes/kanagawa-lotus.css +136 -0
  41. package/themes/md-preview.css +4 -0
  42. package/themes/mist.css +4 -0
  43. package/themes/nord.css +4 -0
  44. package/themes/one-dark.css +4 -0
  45. package/themes/paper.css +4 -0
  46. package/themes/rose-pine-dawn.css +136 -0
  47. package/themes/sakura.css +4 -0
  48. package/themes/solarized-light.css +4 -0
  49. package/themes/tokyo-night.css +4 -0
  50. package/themes/white.css +4 -0
  51. package/web/dist/assets/{TerminalPanel-RACpk6AX.js → TerminalPanel-C1A23aI-.js} +1 -1
  52. package/web/dist/assets/index-B48QWf0I.js +364 -0
  53. package/web/dist/assets/index-DGL5OInG.css +1 -0
  54. package/web/dist/index.html +2 -2
  55. package/web/dist/assets/index-BgrnP9Jz.js +0 -364
  56. package/web/dist/assets/index-Cy8dSTZt.css +0 -1
@@ -35,7 +35,7 @@ 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";
@@ -49,6 +49,8 @@ import { makeEditSoftTool } from "./edit-soft-tool.js";
49
49
  import { makeReadDirTool } from "./read-tool.js";
50
50
  // 展示文件给用户(present_files):图片/视频内联、文本开预览弹窗、本地打开按钮。
51
51
  import { makePresentFilesTool } from "./present-files-tool.js";
52
+ // 工具定义说明的归一化(工具卡右键 → 「显示工具详细信息」,见 getToolInfo)。
53
+ import { normalizeToolInfo } from "./tool-info.js";
52
54
  import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
53
55
  import { makeDelegateTaskTool } from "./delegate-task.js";
54
56
  import { makeConversationReadTool, parseTranscriptLines, toTranscriptInput, } from "./conversation-read-tool.js";
@@ -80,6 +82,13 @@ const DESKTOP_WIRE = desktopDirWire(HOME_WIRE);
80
82
  const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
81
83
  /** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
82
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;
83
92
  const WIDGET_REFRESH_MS = 2000;
84
93
  /** SCM「AI 生成提交信息」的单次补全超时——慢供应商不该让按钮转圈到天荒地老。 */
85
94
  const SCM_COMMITMSG_TIMEOUT_MS = 60_000;
@@ -92,9 +101,13 @@ const STALL_NOTIFY_MS = (() => {
92
101
  const v = Number(process.env.PI_WEB_STALL_NOTIFY_MS);
93
102
  return Number.isFinite(v) && v >= 0 ? v : 180_000;
94
103
  })();
95
- /** Serialization-cache cap per conversation (see serializeCached): cached
96
- * UiMessage objects are pure-function results, so eviction only costs a
97
- * 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. */
98
111
  const UI_MESSAGE_CACHE_CAP = 4096;
99
112
  /** Preview panel cap: only the first 512KB of a file is ever read/sent. */
100
113
  /** Thrown when the service is quiesced (draining) and the request is NEW work
@@ -224,7 +237,14 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
224
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" +
225
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.",
226
239
  promptSnippet: "run shell commands",
227
- 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
+ },
228
248
  };
229
249
  }
230
250
  /**
@@ -323,6 +343,10 @@ ownerId) {
323
343
  },
324
344
  };
325
345
  }
346
+ /** 判定是否应当即时向前端推送问卷弹窗(只推给当前前台会话,未指定会话按全局放行)。 */
347
+ export function shouldPopQuestion(conversationId, activeId) {
348
+ return conversationId === undefined || conversationId === activeId;
349
+ }
326
350
  // ---------------------------------------------------------------------------
327
351
  // 浏览器页面工具(标准 pi 引擎的 browser_page customTool)
328
352
  //
@@ -650,15 +674,6 @@ function previewToolResult(result) {
650
674
  return "[unserializable result]";
651
675
  }
652
676
  }
653
- /** Hard cap on how long ONE tool call may run before the watchdog aborts the
654
- * session. The SDK bash tool has NO default timeout, so a command that never
655
- * finishes (servers, watchers, infinite loops) would otherwise hang the whole
656
- * conversation indefinitely. Override with the PI_WEB_TOOL_TIMEOUT_MS env var
657
- * (milliseconds). */
658
- const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
659
- const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
660
- return Number.isFinite(v) && v > 0 ? v : 20 * 60_000;
661
- })();
662
677
  /** Cap on simultaneously open NON-subagent conversations of ONE project (each keeps a full
663
678
  * runtime alive; conversations of other projects keep their own lists).
664
679
  * 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
@@ -797,14 +812,26 @@ function collectSessionAnchors(filePath, q, cap = 10) {
797
812
  export function piSessionsRoot() {
798
813
  return process.env.PI_CODING_AGENT_SESSION_DIR || undefined;
799
814
  }
800
- /** Guardrail: only transcripts under the shared sessions root
801
- * (<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
+ * 默认目录下的文件」,所以放宽到两个根仍然成立。
802
826
  * Shared by deleteSession/renameSession/switchSession so the open path cannot
803
827
  * escape the confinement the write paths already enforce. */
804
828
  export function isInsideSessionsDir(agentDir, targetPath) {
805
829
  const abs = resolve(targetPath);
806
- const sessionsRoot = resolve(agentDir, "sessions");
807
- 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));
808
835
  }
809
836
  /** 会话当前模型的 "provider/id"(无模型时 null;软上限按模型覆盖用,issue #229)。 */
810
837
  function modelKeyOf(session) {
@@ -1044,13 +1071,22 @@ export class ClientSession {
1044
1071
  * `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
1045
1072
  * 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
1046
1073
  * runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
1047
- async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
1074
+ async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId, persist) {
1048
1075
  // 数量上限先行:每个子代理都是完整 runtime + TerminalManager,无上限时一次
1049
- // 并行派发几十个会把服务进程拖垮。抛错由 subagent_spawn / delegate_task 转成
1050
- // 返回文本,AI 读到后可改串行 / 等待收口后重试,而不是看到工具异常。
1051
- const liveSubagents = [...this.convs.values()].filter((c) => c.isSubagent).length;
1052
- if (liveSubagents >= MAX_SUBAGENTS) {
1053
- 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
+ }
1054
1090
  }
1055
1091
  // 真正的派发者(withSubagentOwner 按 runtime 归属填入):cwd 基准 / 跟随模型 /
1056
1092
  // 跟随思考强度一律读它,而不是派发瞬间的 active——后台对话产出时用户可能正
@@ -1060,15 +1096,16 @@ export class ClientSession {
1060
1096
  const baseCwd = spawner?.cwd ?? this.cwd;
1061
1097
  // 相对 cwd 按派发者所在目录解析:直接透传会相对 server 进程 cwd 落到别处。
1062
1098
  const resolvedCwd = cwd ? resolve(baseCwd, cwd) : baseCwd;
1063
- const conversationId = `sa-${randomUUID().slice(0, 8)}`;
1099
+ const conversationId = persist ? `conv-${randomUUID().slice(0, 8)}` : `sa-${randomUUID().slice(0, 8)}`;
1064
1100
  const terminals = this.makeTerminalManager(conversationId, resolvedCwd);
1101
+ const sessionManager = persist ? SessionManager.create(resolvedCwd) : SessionManager.inMemory(resolvedCwd);
1065
1102
  const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
1066
1103
  cwd: resolvedCwd,
1067
1104
  agentDir: this.agentDir,
1068
- sessionManager: SessionManager.inMemory(resolvedCwd),
1105
+ sessionManager,
1069
1106
  });
1070
1107
  const conv = this.makeConversation(runtime, conversationId, terminals);
1071
- conv.isSubagent = true;
1108
+ conv.isSubagent = !persist;
1072
1109
  // 父对话 = 真正派发它的会话(按会话归属的 host 包装填入)。直接用 active
1073
1110
  // 会错:后台对话运行时用户可能正看着别的项目对话,孩子会被记到无关
1074
1111
  // 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
@@ -1174,18 +1211,28 @@ export class ClientSession {
1174
1211
  textEn: `Subagent ${conversationId} failed to start: ${err instanceof Error ? err.message : String(err)}`,
1175
1212
  });
1176
1213
  });
1214
+ if (persist) {
1215
+ this.pushProjects().catch(() => { });
1216
+ }
1177
1217
  this.emitConversations();
1178
1218
  return conv.id;
1179
1219
  }
1180
1220
  getSubagentSnapshot(convId) {
1181
1221
  const conv = this.convs.get(convId);
1182
- if (!conv?.isSubagent || !conv.session)
1222
+ if (!conv?.session)
1183
1223
  return undefined;
1184
1224
  return this.toSubagentSnapshot(conv);
1185
1225
  }
1186
- listSubagentSnapshots() {
1226
+ listSubagentSnapshots(scope) {
1187
1227
  return [...this.convs.values()]
1188
- .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
+ })
1189
1236
  .sort((a, b) => a.createdAt - b.createdAt)
1190
1237
  .map((c) => this.toSubagentSnapshot(c));
1191
1238
  }
@@ -1203,6 +1250,8 @@ export class ClientSession {
1203
1250
  // session being replaced — report defaults
1204
1251
  }
1205
1252
  const fullPrompt = conv.subagentPrompt ?? "";
1253
+ const sm = conv.session.sessionManager;
1254
+ const isPersisted = !conv.isSubagent || (typeof sm?.isPersisted === "function" && sm.isPersisted());
1206
1255
  return {
1207
1256
  convId: conv.id,
1208
1257
  type: conv.subagentType ?? "general",
@@ -1218,6 +1267,7 @@ export class ClientSession {
1218
1267
  model: conv.session.model?.id,
1219
1268
  output: conv.session.getLastAssistantText() ?? "",
1220
1269
  parentId: conv.parentId,
1270
+ persisted: isPersisted,
1221
1271
  };
1222
1272
  }
1223
1273
  /** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
@@ -1414,17 +1464,17 @@ export class ClientSession {
1414
1464
  * 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
1415
1465
  */
1416
1466
  subagentHost = {
1417
- spawnSubagent: (prompt, type, cwd, templateName, model, parentId) => {
1467
+ spawnSubagent: (prompt, type, cwd, templateName, model, parentId, persist) => {
1418
1468
  // 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
1419
1469
  const tpl = templateName ? this.subagentTemplates.get(templateName) : undefined;
1420
1470
  if (templateName && (!tpl || !tpl.enabled)) {
1421
1471
  throw new Error(pick(this.getLang(), `子代理模板不可用:${templateName}(不存在或已停用)`, `Subagent template unavailable: ${templateName} (missing or disabled)`, "agent.subagent.template.unavailable", { templateName: templateName }));
1422
1472
  }
1423
1473
  // 模型优先级:显式 model 参数 > 模板自带模型 > 设置面板默认模型;都不给 = 跟随主对话。
1424
- return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId);
1474
+ return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId, persist);
1425
1475
  },
1426
1476
  getSubagent: (convId) => this.getSubagentSnapshot(convId),
1427
- listSubagents: () => this.listSubagentSnapshots(),
1477
+ listSubagents: (scope) => this.listSubagentSnapshots(scope),
1428
1478
  steerSubagent: async (convId, message) => {
1429
1479
  const conv = this.convs.get(convId);
1430
1480
  if (!conv?.session)
@@ -1437,6 +1487,7 @@ export class ClientSession {
1437
1487
  await this.interruptRun(conv, pick(this.getLang(), "用户停止子代理", "User stopped the subagent", "agent.subagent.stop.user"));
1438
1488
  }
1439
1489
  },
1490
+ getWatchdogTimeoutMs: () => this.getBaseToolWatchdogTimeoutMs(),
1440
1491
  // issue #91:子代理工具返回按客户端 UI 语言出中英(英文默认)。
1441
1492
  lang: () => this.getLang(),
1442
1493
  // 只向 AI 暴露 enabled 的模板(停用的对 AI 不可见)。
@@ -1649,6 +1700,9 @@ export class ClientSession {
1649
1700
  /** Timestamp of the most recent message_delta push — while fresh, snapshots
1650
1701
  * use the slower STREAMING_SNAPSHOT_INTERVAL_MS cadence. */
1651
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;
1652
1706
  sessionsTimer = null;
1653
1707
  version = 0;
1654
1708
  /** Snapshot revision counter (see emitSnapshotNow / protocol snapshot_delta). */
@@ -1852,14 +1906,22 @@ export class ClientSession {
1852
1906
  });
1853
1907
  }
1854
1908
  }
1855
- // issue #145:因别处正在跑而跳过恢复 —— 首帧即被告之(pendingNotices 随 attachSink 下发)。
1909
+ // issue #145:因别处仍持有而跳过恢复 —— 首帧即被告之(pendingNotices 随 attachSink 下发)。
1910
+ // 跑着/空闲都拦:直接恢复会造出第二个写者(两边轮流发送分叉历史)。
1856
1911
  if (opts?.blank && opts?.blankTitle) {
1857
- cs.pendingNotices.push({
1858
- type: "notice",
1859
- level: "info",
1860
- text: `该项目最近的对话「${opts.blankTitle}」正在另一处运行,为你停在了新对话 —— 直接打开会造出第二个写者。左栏「运行的对话」里能看到它(标着“另一处”),等它跑完再打开。`,
1861
- 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.`,
1862
- });
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
+ });
1863
1925
  }
1864
1926
  await cs.bindSession();
1865
1927
  await cs.restoreProjectProviderKeysForCwd(cwd);
@@ -2091,8 +2153,8 @@ export class ClientSession {
2091
2153
  ...(c.note ? { note: c.note } : {}),
2092
2154
  })),
2093
2155
  }),
2094
- // 文件认领(claim_files):常驻注册、不进工具目录(见 claimToolHost
2095
- // 注释)。ownerId 语义同 subagent/skill(本 runtime 所属会话)。
2156
+ // 文件认领(claim_files):开关走统一工具 tab(ActiveSet 门控)。
2157
+ // ownerId 语义同 subagent/skill(本 runtime 所属会话)。
2096
2158
  // DSH 引擎无 customTool 注册面,不接(提醒里照样能看到认领)。
2097
2159
  makeClaimFilesTool(this.claimToolHost(ownerId), () => this.getLang()),
2098
2160
  // 展示文件给用户(present_files,issue #231):模型给路径清单,服务端
@@ -2371,25 +2433,48 @@ export class ClientSession {
2371
2433
  }
2372
2434
  }, 30_000);
2373
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
+ }
2374
2450
  /** Arm the hang-guard for a tool call: if it is still running after
2375
2451
  * TOOL_WATCHDOG_TIMEOUT_MS, abort the session instead of letting the
2376
- * conversation hang forever (the SDK bash tool has no default timeout). */
2377
- armToolWatchdog(conv, toolCallId) {
2378
- 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);
2379
2460
  }
2380
2461
  /** (重)布工具挂死看门狗:delayMs 后仍在跑则 abort 整轮。过户时用剩余时间重布
2381
2462
  * (已逾期的立即触发,语义不变). */
2382
- rearmToolWatchdog(conv, toolCallId, delayMs) {
2463
+ rearmToolWatchdog(conv, toolCallId, delayMs, totalTimeoutMs) {
2464
+ const totalMs = totalTimeoutMs ?? delayMs;
2383
2465
  const t = setTimeout(() => {
2384
2466
  conv.toolWatchdogs.delete(toolCallId);
2385
2467
  // The tool finished before the deadline — nothing to do.
2386
2468
  if (!conv.toolStartTimes.has(toolCallId))
2387
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`;
2388
2473
  this.emit({
2389
2474
  type: "notice",
2390
2475
  level: "warning",
2391
- text: `工具执行超过 ${Math.round(TOOL_WATCHDOG_TIMEOUT_MS / 60_000)} 分钟,已自动终止(防止挂死)。可调整超时:环境变量 PI_WEB_TOOL_TIMEOUT_MS(毫秒)。`,
2392
- 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.`,
2393
2478
  });
2394
2479
  conv.toolStartTimes.delete(toolCallId);
2395
2480
  // Abort the run (kills the process tree via the SDK's abort signal);
@@ -2470,9 +2555,12 @@ export class ClientSession {
2470
2555
  catch {
2471
2556
  /* 尽力而为 */
2472
2557
  }
2558
+ const curModel = target.session.model;
2559
+ const modelId = curModel ? `${curModel.provider}/${curModel.id}` : undefined;
2473
2560
  return {
2474
2561
  conversationId: target.id,
2475
2562
  title: target.title,
2563
+ model: modelId,
2476
2564
  at: target.lastActiveAt,
2477
2565
  isStreaming: target.session.isStreaming,
2478
2566
  messages: this.messagesOf(target),
@@ -2785,7 +2873,7 @@ export class ClientSession {
2785
2873
  // (默认 20 分钟会把还在思考的用户连对话一起剁掉)。它的收场自有路子:
2786
2874
  // 用户回答/取消、会话 dispose(cancelPendingQuestions),不限时。
2787
2875
  if (event.toolName !== ASK_USER_QUESTION_TOOL_NAME) {
2788
- this.armToolWatchdog(conv, event.toolCallId);
2876
+ this.armToolWatchdog(conv, event.toolCallId, event.toolName, event.args);
2789
2877
  }
2790
2878
  // 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
2791
2879
  this.onToolEvent?.({
@@ -2900,7 +2988,9 @@ export class ClientSession {
2900
2988
  conv.compactionState = { reason: event.reason, startedAt: Date.now() };
2901
2989
  conv.lastCompactionTokens = null;
2902
2990
  this.markCompactionPending(conv);
2903
- this.flushSnapshot();
2991
+ // 进度条只有激活对话看得到(后台对话的快照没有接收方,见 onEvent 末尾)。
2992
+ if (conv.id === this.conv.id)
2993
+ this.flushSnapshot();
2904
2994
  break;
2905
2995
  }
2906
2996
  case "compaction_end": {
@@ -3148,7 +3238,7 @@ export class ClientSession {
3148
3238
  messageId: `stream-${m?.timestamp ?? 0}`,
3149
3239
  usage: (() => {
3150
3240
  try {
3151
- const t = this.session.getSessionStats().tokens;
3241
+ const t = this.sessionStats().tokens;
3152
3242
  return t ? { input: t.input, output: t.output, total: t.total } : null;
3153
3243
  }
3154
3244
  catch {
@@ -3172,6 +3262,16 @@ export class ClientSession {
3172
3262
  // Snapshot checkpoint policy: deltas carry live rendering during streaming;
3173
3263
  // full snapshots are reconciliation checkpoints taken immediately at
3174
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;
3175
3275
  if (event.type === "agent_end" ||
3176
3276
  event.type === "tool_execution_end" ||
3177
3277
  event.type === "compaction_end" ||
@@ -3215,8 +3315,11 @@ export class ClientSession {
3215
3315
  serializeCached(m) {
3216
3316
  return this.serializeCachedFor(this.conv, m);
3217
3317
  }
3218
- /** serializeCached 的按对话版本(插件快照读非活跃对话用;缓存仍按对话隔离)。 */
3219
- serializeCachedFor(conv, m) {
3318
+ /** 稳定缓存键 + 该消息的序号种子 n(见 serializeCachedFor)。
3319
+ *
3320
+ * messagesOf 一次扫描里同时要 key(建 live 集合)与 n(算 user seq),所以
3321
+ * 两者一起返回、只算一次;单独调用的路径不传 key,按需现算。 */
3322
+ uiMessageKey(conv, m) {
3220
3323
  // toolResult messages are keyed by toolCallId; everything else by
3221
3324
  // role+timestamp. A single prompt can emit several same-role messages
3222
3325
  // within the SAME millisecond (multiple attachment asides), so the
@@ -3229,8 +3332,13 @@ export class ClientSession {
3229
3332
  n = conv.nextMsgId++;
3230
3333
  conv.msgIds.set(key, n);
3231
3334
  }
3232
- const cacheKey = `${key}#${n}`;
3233
- 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);
3234
3342
  if (cached)
3235
3343
  return cached;
3236
3344
  // User-message id suffix is a 1-based count of user messages sharing
@@ -3238,28 +3346,17 @@ export class ClientSession {
3238
3346
  // a global per-conversation counter across ALL roles, so it can't be
3239
3347
  // reused as the seq — otherwise editing anything but the first question
3240
3348
  // fails to resolve ("找不到要编辑的消息").
3241
- let seq = n;
3349
+ let seq = k.n;
3242
3350
  if (m.role === "user") {
3243
3351
  const ts = m.timestamp ?? 0;
3244
3352
  seq = (conv.userSeqByTs.get(ts) ?? 0) + 1;
3245
3353
  conv.userSeqByTs.set(ts, seq);
3246
3354
  }
3247
3355
  const msg = serializeMessage(m, seq);
3248
- if (msg) {
3249
- conv.uiMessageCache.set(cacheKey, msg);
3250
- // Bound the cache (marathon sessions otherwise grow without limit;
3251
- // single messages can reach TEXT_CAP = 200K chars). Map iteration is
3252
- // insertion order, so dropping from the front evicts the oldest —
3253
- // recent messages (the ones every snapshot touches) always survive.
3254
- // Safe: a miss just recomputes an identical object on next access.
3255
- let excess = conv.uiMessageCache.size - UI_MESSAGE_CACHE_CAP;
3256
- while (excess-- > 0) {
3257
- const oldest = conv.uiMessageCache.keys().next().value;
3258
- if (oldest === undefined)
3259
- break;
3260
- conv.uiMessageCache.delete(oldest);
3261
- }
3262
- }
3356
+ // 上界在 messagesOf 的 pruneMessageCache 里按 live 集合处理:见那里的注释
3357
+ // (FIFO 淘汰仍在转写里的条目会让每次快照都退化成全量,issue #259)。
3358
+ if (msg)
3359
+ conv.uiMessageCache.set(k.cacheKey, msg);
3263
3360
  return msg;
3264
3361
  }
3265
3362
  /** Current messages array (with the existing sig-reuse optimization).
@@ -3270,13 +3367,21 @@ export class ClientSession {
3270
3367
  }
3271
3368
  /** currentMessages 的按对话版本(插件快照读非活跃对话用)。 */
3272
3369
  messagesOf(conv) {
3370
+ // 一次扫描同时收齐「当前转写里的全部缓存键」(live 集合,供
3371
+ // pruneMessageCache 精确回收死条目)与各自的序列化结果。
3372
+ const live = new Set();
3273
3373
  let rawMessages = conv.session.agent.state.messages
3274
- .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
+ })
3275
3379
  .filter((m) => m !== null);
3276
3380
  // 自动重试等待期:SDK 暂留在 state 末尾的 error 气泡只是中间态(随后被
3277
3381
  // 摘掉重跑),不进快照——成功则用户永远看不到,耗尽才标红。否则 agent_end
3278
3382
  // 的立即 flush 会先画红、摘掉后又消失(红色一闪而过)。
3279
3383
  rawMessages = stripTransientRetryErrors(rawMessages, !!conv.retryState);
3384
+ this.pruneMessageCache(conv, live);
3280
3385
  // Reuse the previous array when nothing changed: the element objects are
3281
3386
  // cached (reference-stable) anyway, and a stable array reference lets the
3282
3387
  // frontend memoize derived maps instead of rebuilding them every 60ms.
@@ -3286,6 +3391,27 @@ export class ClientSession {
3286
3391
  conv.lastMessagesArray = rawMessages;
3287
3392
  return messages;
3288
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
+ }
3289
3415
  /** Build every UiState field EXCEPT messages (the expensive part). */
3290
3416
  buildLightState(rev, withDraft = false) {
3291
3417
  const conv = this.conv;
@@ -3298,7 +3424,7 @@ export class ClientSession {
3298
3424
  contextUsage: { tokens: null, contextWindow: 0, percent: null },
3299
3425
  };
3300
3426
  try {
3301
- const s = this.session.getSessionStats();
3427
+ const s = this.sessionStats();
3302
3428
  stats = {
3303
3429
  totalMessages: s.totalMessages,
3304
3430
  tokens: s.tokens,
@@ -3490,11 +3616,18 @@ export class ClientSession {
3490
3616
  this.pendingQuestions.set(id, { resolve, questions, conversationId });
3491
3617
  // 运行列表的「?」角标靠 conversations 推送(问卷登记/解决不经过快照通道)。
3492
3618
  this.emitConversations();
3493
- this.emit({
3494
- type: "question_pending",
3495
- id,
3496
- questions,
3497
- });
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
+ }
3498
3631
  });
3499
3632
  }
3500
3633
  /** 前端回答模型提问(question_answer → 恢复 askUser 的 Promise)。id 需匹配
@@ -3519,7 +3652,14 @@ export class ClientSession {
3519
3652
  for (const [id, p] of this.pendingQuestions) {
3520
3653
  if (p.conversationId !== undefined && p.conversationId !== this.activeId)
3521
3654
  continue;
3522
- 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
+ };
3523
3663
  }
3524
3664
  return null;
3525
3665
  }
@@ -3540,14 +3680,34 @@ export class ClientSession {
3540
3680
  /** 取某对话的等答复问卷原文(跨页作答的 peek 用;只读,不改变状态)。 */
3541
3681
  peekPendingQuestion(convId) {
3542
3682
  for (const [id, p] of this.pendingQuestions) {
3543
- if (p.conversationId === convId)
3544
- 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
+ }
3545
3698
  }
3546
3699
  return undefined;
3547
3700
  }
3548
3701
  /** 跨页作答:把别处问卷的原文推给本页弹框(回答经 answerElsewhereQuestion 回去)。 */
3549
3702
  pushElsewhereQuestion(owner, convId, q) {
3550
- 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
+ });
3551
3711
  }
3552
3712
  /** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
3553
3713
  cancelPendingQuestions() {
@@ -3964,6 +4124,22 @@ export class ClientSession {
3964
4124
  }
3965
4125
  this.emitSnapshotNow(forceFull);
3966
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
+ }
3967
4143
  scheduleSnapshot() {
3968
4144
  if (this.snapshotTimer || this.disposed)
3969
4145
  return;
@@ -4037,6 +4213,29 @@ export class ClientSession {
4037
4213
  pushSlashCommands() {
4038
4214
  return this.slash.push();
4039
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
+ }
4040
4239
  /** 模型/服务商配置管理 —— 自包含模块,见 model-admin.ts。 */
4041
4240
  modelAdmin;
4042
4241
  /** Persist an api-key credential for a provider (auth.json). */
@@ -4246,8 +4445,20 @@ export class ClientSession {
4246
4445
  const curId = cur ? `${cur.provider}/${cur.id}` : null;
4247
4446
  // Restore the model's provider key first so setModel's auth check passes.
4248
4447
  await this.restoreKeyForModel(savedModel, cwd);
4249
- 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
+ }
4250
4460
  return;
4461
+ }
4251
4462
  await this.session.setModel(model);
4252
4463
  }
4253
4464
  catch {
@@ -4571,21 +4782,39 @@ export class ClientSession {
4571
4782
  }
4572
4783
  return out;
4573
4784
  }
4574
- /** issue #145:本实例所有正在跑的对话摘要(elsewhere 列表的本机一半 +
4575
- * 手动过户的目标定位:convId + 是否有等答复问卷)。 */
4785
+ /** issue #145:本实例别处可见的对话摘要(elsewhere 列表的本机一半 +
4786
+ * 手动过户的目标定位:convId + 是否有等答复问卷)。
4787
+ *
4788
+ * 口径 = 运行中 + 已结束但本会话仍持有的可见对话(shownInRunningList:
4789
+ * listed 或当前有内容的对话;空白新对话不入列)。只推 running 时,对话
4790
+ * 一结束 elsewhere 行就消失,另一处想过户查看只能趁运行中动手 —— 跑完
4791
+ * 即失联。空闲行带 isStreaming:false + sessionFile,照样可过户(搬 runtime
4792
+ * 本体,单 writer 不变;takeoverBriefs 本来就含空闲对话)。子代理不单列
4793
+ * (随主对话一起搬)。 */
4576
4794
  streamingSummariesAll() {
4577
4795
  const out = [];
4578
4796
  for (const conv of this.convs.values()) {
4579
4797
  if (conv.isSubagent)
4580
4798
  continue;
4581
- if (!this.conversationStreaming(conv))
4799
+ const streaming = this.conversationStreaming(conv);
4800
+ if (!streaming && !this.shownInRunningList(conv))
4582
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
+ }
4583
4810
  out.push({
4584
4811
  title: conv.title,
4585
4812
  cwd: conv.cwd,
4586
- isStreaming: true,
4813
+ isStreaming: streaming,
4587
4814
  convId: conv.id,
4588
- hasQuestion: this.isWaitingOnUser(conv.id),
4815
+ hasQuestion: !!pq,
4816
+ ...(pq?.title ? { questionTitle: pq.title } : {}),
4817
+ ...(sessionFile ? { sessionFile } : {}),
4589
4818
  });
4590
4819
  }
4591
4820
  return out;
@@ -5347,6 +5576,7 @@ export class ClientSession {
5347
5576
  // Carry the model chosen in the active chat over to the new chat so it
5348
5577
  // doesn't silently revert to the ModelRuntime default model.
5349
5578
  const prevModel = this.conv.session.agent.state.model ?? null;
5579
+ const prevThinking = this.conv.session.thinkingLevel ?? null;
5350
5580
  let ready = false;
5351
5581
  try {
5352
5582
  const conversationId = this.nextConversationId();
@@ -5378,6 +5608,14 @@ export class ClientSession {
5378
5608
  // model no longer resolvable — keep the default
5379
5609
  }
5380
5610
  }
5611
+ if (prevThinking) {
5612
+ try {
5613
+ this.session.setThinkingLevel(prevThinking);
5614
+ }
5615
+ catch {
5616
+ // model may not support previous thinking level
5617
+ }
5618
+ }
5381
5619
  this.emitConversations();
5382
5620
  this.goalSvc.emitGoalStatus();
5383
5621
  this.pushTerminals();
@@ -5712,7 +5950,7 @@ export class ClientSession {
5712
5950
  this.pendingQuestions.delete(qid);
5713
5951
  questions.push({ resolve: p.resolve, questions: p.questions, conversationId: p.conversationId });
5714
5952
  // 源页面的对话框可能是即时通道弹出的(live),快照为 null 收不掉它 ——
5715
- // 明确撤回,让源页面立即收起(目标页由转入方重推 question_pending)。
5953
+ // 明确撤回,让源页面立即收起(目标页主对话由转入方重推 question_pending 或快照呈现)。
5716
5954
  this.emit({ type: "question_retracted", id: qid });
5717
5955
  }
5718
5956
  }
@@ -5733,8 +5971,8 @@ export class ClientSession {
5733
5971
  * - id 冲突(两边计数器都从 c1 开始,大概率撞上)→ 给搬入方分配新 id,move
5734
5972
  * 集合内的 parentId/问卷归属同步改写。模型手里旧 runId 的后续子代理工具调用
5735
5973
  * 会报 unknown(可经列表查新 id);定时唤醒的旧 id 同理回落无头执行。
5736
- * - 事件订阅/终端投递/问卷/页调用全部重接到本会话并立即重推(问卷对话框在新
5737
- * 页面直接弹出来)。看门狗按剩余时间重布(已逾期的立即触发)。
5974
+ * - 事件订阅/终端投递/问卷/页调用全部重接到本会话,迁入的主对话立即触发弹窗
5975
+ * (子代理待答问卷通过角标与快照呈现)。看门狗按剩余时间重布(已逾期的立即触发)。
5738
5976
  */
5739
5977
  insertTakeoverConvs(payload) {
5740
5978
  const remap = new Map();
@@ -5758,7 +5996,10 @@ export class ClientSession {
5758
5996
  conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
5759
5997
  for (const [toolCallId, start] of conv.toolStartTimes) {
5760
5998
  if (!conv.toolWatchdogs.has(toolCallId)) {
5761
- 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
+ }
5762
6003
  }
5763
6004
  }
5764
6005
  this.convs.set(conv.id, conv);
@@ -5767,12 +6008,23 @@ export class ClientSession {
5767
6008
  mainId = payload.convs[0]?.id ?? "";
5768
6009
  for (const q of payload.questions) {
5769
6010
  const nid = `q-${++this.questionSeq}`;
6011
+ const qConvId = fix(q.conversationId);
5770
6012
  this.pendingQuestions.set(nid, {
5771
6013
  resolve: q.resolve,
5772
6014
  questions: q.questions,
5773
- conversationId: fix(q.conversationId),
6015
+ conversationId: qConvId,
5774
6016
  });
5775
- 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
+ }
5776
6028
  }
5777
6029
  for (const p of payload.pageCalls) {
5778
6030
  const nid = `p-${++this.pageSeq}`;
@@ -5931,16 +6183,21 @@ export class ClientSession {
5931
6183
  // 子代理带 error 标记:左栏红点提示(普通对话不参与)。
5932
6184
  ...(conv.isSubagent ? this.subagentRunOutcome(conv) : {}),
5933
6185
  // 等答复的问卷:左栏「?」角标(主对话/子代理各自挂名下,切过去即可回答)。
5934
- ...(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
+ })(),
5935
6190
  parentId: conv.parentId,
5936
6191
  });
5937
6192
  }
5938
6193
  // issue #145:流式集合签名变化 → 通知其他客户端重推(左栏「另一处正在运行」近实时)。
5939
6194
  // 签名含等问卷态(问卷挂起/解决不改变流式集合,不带它已打开的别处页面永远看不到 `?`)。
6195
+ // elsewhere 口径含已结束的空闲行:签名同样覆盖它们(流式位 + 等问卷位),
6196
+ // 否则对方跑完(streaming→idle)或空闲行出现/消失时这边收不到重推。
5940
6197
  try {
5941
6198
  const sig = JSON.stringify([...this.convs.values()]
5942
- .filter((c) => this.conversationStreaming(c))
5943
- .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}`)
5944
6201
  .sort());
5945
6202
  if (sig !== this.lastRunningSig) {
5946
6203
  this.lastRunningSig = sig;
@@ -6013,8 +6270,6 @@ export class ClientSession {
6013
6270
  void this.refreshSessions();
6014
6271
  }
6015
6272
  async pushSessions() {
6016
- if (!this.sessionsRequested)
6017
- return;
6018
6273
  if (!this.sessionsRequested)
6019
6274
  return;
6020
6275
  try {
@@ -6022,8 +6277,23 @@ export class ClientSession {
6022
6277
  // (<agentDir>/sessions/--<cwd>--/), the same files the pi CLI/TUI
6023
6278
  // use — one listing covers every conversation of the current folder.
6024
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;
6025
6295
  const sessions = new Map();
6026
- for (const s of infos) {
6296
+ for (const s of visibleInfos) {
6027
6297
  sessions.set(s.path, {
6028
6298
  path: s.path,
6029
6299
  name: s.name,
@@ -6255,6 +6525,84 @@ export class ClientSession {
6255
6525
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
6256
6526
  });
6257
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
+ }
6258
6606
  /** Dismiss a running conversation from the left-panel list without deleting its
6259
6607
  * transcript file. Only idle (non-streaming) conversations that are not
6260
6608
  * retained by terminal/wake/review state can be dismissed. The session stays
@@ -6843,6 +7191,7 @@ export class ClientSession {
6843
7191
  // Preserve the model the user had selected — fork() seeds a new
6844
7192
  // branch with the ModelRuntime default model otherwise.
6845
7193
  const prevModel = this.session.agent.state.model ?? null;
7194
+ const prevThinking = this.session.thinkingLevel ?? null;
6846
7195
  const result = await this.runtime.fork(entryId);
6847
7196
  if (result.cancelled) {
6848
7197
  this.emit({
@@ -6866,6 +7215,14 @@ export class ClientSession {
6866
7215
  // model no longer resolvable — keep the default
6867
7216
  }
6868
7217
  }
7218
+ if (prevThinking) {
7219
+ try {
7220
+ this.session.setThinkingLevel(prevThinking);
7221
+ }
7222
+ catch {
7223
+ // model no longer supports previous thinking level
7224
+ }
7225
+ }
6869
7226
  await this.prompt(trimmed, attachments);
6870
7227
  this.emit({
6871
7228
  type: "notice",
@@ -7245,18 +7602,20 @@ export class ClientSession {
7245
7602
  });
7246
7603
  this.flushSnapshot();
7247
7604
  // First visit to this project: resume its most recent session —
7248
- // unless that transcript is streaming on another client (#145):
7605
+ // unless that transcript is still held on another client (#145):
7249
7606
  // default-opening it would strand the tab on a conversation it
7250
- // cannot use (the prompt guard refuses) with a stale leaf that
7251
- // 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.
7252
7611
  let resumeSkipped = null;
7253
- // 无人在跑时不扫目录(首访切项目的常见情形零开销)。
7254
- if (this.hasStreamingElsewhere?.() ?? false) {
7612
+ // 别处无可见行时不扫目录(首访切项目的常见情形零开销)。
7613
+ if ((this.listExternalRunning?.() ?? []).length > 0) {
7255
7614
  try {
7256
7615
  const infos = await SessionManager.list(abs, piSessionsRoot());
7257
7616
  const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
7258
7617
  const owner = recent ? this.findSessionOwner?.(recent) : null;
7259
- if (owner?.isStreaming) {
7618
+ if (owner && (owner.connected || owner.isStreaming)) {
7260
7619
  resumeSkipped = owner;
7261
7620
  }
7262
7621
  }
@@ -7290,12 +7649,19 @@ export class ClientSession {
7290
7649
  }
7291
7650
  await this.bindSession();
7292
7651
  if (resumeSkipped) {
7293
- this.emit({
7294
- type: "notice",
7295
- level: "info",
7296
- text: `该项目最近的对话「${resumeSkipped.title}」正在另一处运行,为你停在了新对话 —— 直接打开会造出第二个写者。左栏「运行的对话」里能看到它(标着“另一处”),等它跑完再打开。`,
7297
- 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.`,
7298
- });
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
+ });
7299
7665
  }
7300
7666
  }
7301
7667
  this.pushTerminals();
@@ -7536,7 +7902,12 @@ export class ClientSession {
7536
7902
  /** Set the thinking level for future turns. */
7537
7903
  setThinking(level) {
7538
7904
  try {
7539
- 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
+ }
7540
7911
  }
7541
7912
  catch (err) {
7542
7913
  this.emit({
@@ -7550,7 +7921,11 @@ export class ClientSession {
7550
7921
  }
7551
7922
  cycleThinking() {
7552
7923
  try {
7553
- 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
+ }
7554
7929
  }
7555
7930
  catch (err) {
7556
7931
  this.emit({
@@ -8492,17 +8867,19 @@ export class AgentService {
8492
8867
  }
8493
8868
  }
8494
8869
  // Sessions use the SDK default per-project dir — no per-client dir.
8495
- // issue #145:新标签页默认恢复项目最近的会话 —— 若那条在别处跑着,
8496
- // 建之前就决定空白(第二个 writer 根本不会被打开,也无需事后拆 runtime)。
8497
- // 先做无 I/O 的便宜判断,无人在跑时不扫目录。
8870
+ // issue #145:新标签页默认恢复项目最近的会话 —— 若那条仍被别处持有
8871
+ // (跑着或空闲),建之前就决定空白(第二个 writer 根本不会被打开,
8872
+ // 也无需事后拆 runtime)。之前只拦 running:空闲持有照样恢复出双
8873
+ // writer,两边轮流发送分叉历史。其他客户端不存在时不扫目录。
8498
8874
  let createOpts;
8499
- if (this.hasStreamingElsewhere(clientId)) {
8875
+ if (this.clients.size > 0) {
8500
8876
  try {
8501
8877
  const infos = await SessionManager.list(cwd, piSessionsRoot());
8502
8878
  const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
8503
8879
  const owner = recent ? this.findSessionOwner(recent, clientId) : null;
8504
- if (owner?.isStreaming)
8505
- 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
+ }
8506
8883
  }
8507
8884
  catch {
8508
8885
  // 列表失败不挡正常恢复