pi-web-ui 0.90.1 → 0.92.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 (54) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/bin/pi-web-ui.mjs +21 -2
  3. package/dist/server/agent-service.js +1206 -106
  4. package/dist/server/claim-files-tool.js +116 -0
  5. package/dist/server/claim-store.js +301 -0
  6. package/dist/server/client-state.js +119 -5
  7. package/dist/server/compaction-markers.js +287 -0
  8. package/dist/server/conversation-read-tool.js +369 -27
  9. package/dist/server/conversation-touches.js +357 -0
  10. package/dist/server/delegate-task.js +12 -1
  11. package/dist/server/dsh/dsh-agent-service.js +28 -1
  12. package/dist/server/file-transfer-routes.js +4 -1
  13. package/dist/server/icon-svg.js +35 -0
  14. package/dist/server/index.js +152 -34
  15. package/dist/server/model-admin.js +124 -2
  16. package/dist/server/model-enrich.js +478 -0
  17. package/dist/server/plugin-catalog.js +5 -0
  18. package/dist/server/plugin-facilities.js +42 -11
  19. package/dist/server/plugin-schedule.js +27 -2
  20. package/dist/server/plugins.js +112 -18
  21. package/dist/server/present-files-tool.js +303 -0
  22. package/dist/server/prompt-composer.js +1 -1
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +91 -0
  25. package/dist/server/schedule-agent-tool.js +23 -5
  26. package/dist/server/scheduler-tasks.js +60 -1
  27. package/dist/server/scm-commitmsg.js +140 -0
  28. package/dist/server/scm.js +42 -0
  29. package/dist/server/serialize.js +21 -1
  30. package/dist/server/settings-service.js +47 -0
  31. package/dist/server/skill-tool.js +120 -0
  32. package/dist/server/soft-cap.js +68 -0
  33. package/dist/server/subagent-templates.js +6 -0
  34. package/dist/server/subagents.js +157 -16
  35. package/dist/server/text-sniff.js +15 -0
  36. package/dist/server/themes.js +17 -2
  37. package/dist/server/tool-manager.js +13 -1
  38. package/package.json +2 -1
  39. package/plugin-sdk/README.md +1 -0
  40. package/plugins/catalog.json +20 -0
  41. package/themes/catppuccin-latte.css +136 -0
  42. package/themes/catppuccin.css +140 -0
  43. package/themes/nord.css +130 -0
  44. package/themes/one-dark.css +131 -0
  45. package/themes/paper.css +12 -12
  46. package/themes/solarized-light.css +131 -0
  47. package/themes/tokyo-night.css +140 -0
  48. package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
  49. package/web/dist/assets/es-DIDZppIF.js +2 -0
  50. package/web/dist/assets/index-BgrnP9Jz.js +364 -0
  51. package/web/dist/assets/index-Cy8dSTZt.css +1 -0
  52. package/web/dist/index.html +2 -2
  53. package/web/dist/assets/index-DJIV4A-t.js +0 -355
  54. package/web/dist/assets/index-DLdcRBs_.css +0 -1
@@ -15,7 +15,7 @@
15
15
  // wholesale (no union merge / no stale built-in leftovers).
16
16
  import "./patch-remote-catalog.js";
17
17
  import { spawn } from "node:child_process";
18
- import { randomUUID } from "node:crypto";
18
+ import { createHash, randomUUID } from "node:crypto";
19
19
  import { createRequire } from "node:module";
20
20
  import { appendFileSync, existsSync, readFileSync, rmSync, statSync, mkdirSync, watch, writeFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
@@ -26,6 +26,7 @@ import { Type } from "typebox";
26
26
  import { BgServerTracker } from "./bg-servers.js";
27
27
  import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
28
28
  import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
29
+ import { COMPACTION_PENDING_TYPE, looksLikeChainCorruption, makeCompactionMarkerId, repairSessionFile, } from "./compaction-markers.js";
29
30
  import { removeQueuedByIndexOrText } from "./queue-utils.js";
30
31
  import { syncPluginToolsIntoSession } from "./plugins.js";
31
32
  import { SettingsService } from "./settings-service.js";
@@ -39,16 +40,28 @@ import { bilingual, pick, resolveServerLang } from "./i18n.js";
39
40
  import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
40
41
  import { ComposerDraftsStore } from "./composer-drafts.js";
41
42
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
42
- 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";
43
44
  import { WebUIContext } from "./webui-context.js";
45
+ import { DEFAULT_COMPACTION_RESERVE_TOKENS, effectiveSoftCap, softCapToReserve } from "./soft-cap.js";
44
46
  import { decodeText } from "./text-sniff.js";
45
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";
46
52
  import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
47
53
  import { makeDelegateTaskTool } from "./delegate-task.js";
48
54
  import { makeConversationReadTool, parseTranscriptLines, toTranscriptInput, } from "./conversation-read-tool.js";
55
+ import { extractTouches, formatTouchesCompact, intersectTouches } from "./conversation-touches.js";
56
+ import { ClaimStore, matchClaims, mergeTouchSidecar, readTouchSidecar, removeTouchSidecar } from "./claim-store.js";
57
+ import { makeClaimFilesTool } from "./claim-files-tool.js";
58
+ import { makeSkillTool } from "./skill-tool.js";
49
59
  import { makeScheduleTools } from "./schedule-agent-tool.js";
60
+ import { sameSessionFile } from "./scheduler-tasks.js";
50
61
  import { buildAttachmentMessages, parseModelSpec } from "./attachments.js";
51
62
  import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
63
+ import { isNotRepoError, scmCommitContext } from "./scm.js";
64
+ import { buildCommitMsgInput, buildCommitMsgPrompt, sanitizeCommitMessage } from "./scm-commitmsg.js";
52
65
  import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
53
66
  import { launchOrigin, toServiceInfo } from "./launch-origin.js";
54
67
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
@@ -68,6 +81,8 @@ const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
68
81
  /** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
69
82
  const DELTA_ACTIVE_WINDOW_MS = 1500;
70
83
  const WIDGET_REFRESH_MS = 2000;
84
+ /** SCM「AI 生成提交信息」的单次补全超时——慢供应商不该让按钮转圈到天荒地老。 */
85
+ const SCM_COMMITMSG_TIMEOUT_MS = 60_000;
71
86
  /** Model-stall watchdog: warn (don't abort — deep thinking can be legitimately
72
87
  * quiet for minutes) when a streaming run produced NO SDK events for this long.
73
88
  * Covers the failure class the per-tool watchdog cannot see: half-open API
@@ -648,6 +663,13 @@ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
648
663
  * runtime alive; conversations of other projects keep their own lists).
649
664
  * 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
650
665
  const MAX_OPEN_CONVERSATIONS = 8;
666
+ /** 同时存活的子代理上限(按客户端计,含嵌套派生的孙子辈)。每个子代理都是一个完整
667
+ * runtime + TerminalManager,无上限时 AI 一次并行派发几十个会把服务进程拖垮。
668
+ * 主对话的 8 个上限是按项目计的,子代理按客户端全局计(wait_all 本来就是全局口径)。 */
669
+ const MAX_SUBAGENTS = 16;
670
+ /** SubagentSnapshot.prompt 下发上限:存的是全量 prompt,快照里只带前 N 字符,
671
+ * 避免 subagent_list 一次把几个长 prompt 全推给模型烧 token。 */
672
+ const SUBAGENT_PROMPT_SNAPSHOT_CAP = 2000;
651
673
  const DEFAULT_CONV_TITLE = "新对话";
652
674
  /** First user text in a session, truncated for the conversation list. */
653
675
  function conversationTitle(session) {
@@ -784,6 +806,26 @@ export function isInsideSessionsDir(agentDir, targetPath) {
784
806
  const sessionsRoot = resolve(agentDir, "sessions");
785
807
  return abs.startsWith(sessionsRoot + sep);
786
808
  }
809
+ /** 会话当前模型的 "provider/id"(无模型时 null;软上限按模型覆盖用,issue #229)。 */
810
+ function modelKeyOf(session) {
811
+ const m = session?.model;
812
+ if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
813
+ return null;
814
+ return `${m.provider}/${m.id}`;
815
+ }
816
+ /** 会话当前模型的上下文窗口(未知时 0):live 统计优先,模型定义回落。 */
817
+ function contextWindowOf(session) {
818
+ try {
819
+ const live = session?.getSessionStats?.()?.contextUsage?.contextWindow;
820
+ if (typeof live === "number" && live > 0)
821
+ return Math.floor(live);
822
+ }
823
+ catch {
824
+ // 会话未就绪 → 回落模型定义。
825
+ }
826
+ const def = session?.model?.contextWindow;
827
+ return typeof def === "number" && def > 0 ? Math.floor(def) : 0;
828
+ }
787
829
  /**
788
830
  * 选一个断开的残留会话给新标签认领(纯函数):
789
831
  * - 还有别的在线浏览器(非伪客户端且 live)→ 不认领(新标签是第二块屏,
@@ -804,6 +846,23 @@ export function pickAdoptableOrphan(cands) {
804
846
  }
805
847
  return best?.id ?? null;
806
848
  }
849
+ /** 同项目判定(纯函数):调度视口回退与 id 唤醒的 cwd 护栏用。
850
+ * Windows 大小写/分隔符差异归一,空串永不相等。 */
851
+ export function sameCwd(a, b) {
852
+ const x = String(a ?? "").trim();
853
+ const y = String(b ?? "").trim();
854
+ if (!x || !y)
855
+ return false;
856
+ const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
857
+ if (norm(x) === norm(y))
858
+ return true;
859
+ try {
860
+ return norm(resolve(x)) === norm(resolve(y));
861
+ }
862
+ catch {
863
+ return false;
864
+ }
865
+ }
807
866
  export class ClientSession {
808
867
  clientId;
809
868
  /** Set by AgentService.attach: reflects the SERVICE-wide quiesce flag
@@ -986,12 +1045,27 @@ export class ClientSession {
986
1045
  * 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
987
1046
  * runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
988
1047
  async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
1048
+ // 数量上限先行:每个子代理都是完整 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 }));
1054
+ }
1055
+ // 真正的派发者(withSubagentOwner 按 runtime 归属填入):cwd 基准 / 跟随模型 /
1056
+ // 跟随思考强度一律读它,而不是派发瞬间的 active——后台对话产出时用户可能正
1057
+ // 看着别的项目,读 active 会跟错模型、把相对 cwd 解析到错误的项目下。
1058
+ const spawner = parentId ? this.convs.get(parentId) : undefined;
1059
+ const spawnerSession = spawner?.session ?? this.session;
1060
+ const baseCwd = spawner?.cwd ?? this.cwd;
1061
+ // 相对 cwd 按派发者所在目录解析:直接透传会相对 server 进程 cwd 落到别处。
1062
+ const resolvedCwd = cwd ? resolve(baseCwd, cwd) : baseCwd;
989
1063
  const conversationId = `sa-${randomUUID().slice(0, 8)}`;
990
- const terminals = this.makeTerminalManager(conversationId, cwd);
1064
+ const terminals = this.makeTerminalManager(conversationId, resolvedCwd);
991
1065
  const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
992
- cwd,
1066
+ cwd: resolvedCwd,
993
1067
  agentDir: this.agentDir,
994
- sessionManager: SessionManager.inMemory(cwd),
1068
+ sessionManager: SessionManager.inMemory(resolvedCwd),
995
1069
  });
996
1070
  const conv = this.makeConversation(runtime, conversationId, terminals);
997
1071
  conv.isSubagent = true;
@@ -1000,6 +1074,11 @@ export class ClientSession {
1000
1074
  // 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
1001
1075
  conv.parentId = parentId ?? this.activeId ?? undefined;
1002
1076
  conv.subagentType = type;
1077
+ conv.subagentPrompt = prompt;
1078
+ // 插件工具门与模板扩展白名单对齐:白名单非空时插件/MCP 工具(无 SDK
1079
+ // extensionKey 身份)不进该会话。工厂期 customTools 不注册 + 下面的
1080
+ // syncPluginTools 不回补,模板热改不影响已运行的子代理(与 prompt/技能一致)。
1081
+ conv.subagentBarsPluginTools = !!apply && apply.enabledExtensions.length > 0;
1003
1082
  conv.listed = true;
1004
1083
  conv.title = subagentTitle(prompt);
1005
1084
  this.convs.set(conv.id, conv);
@@ -1009,6 +1088,8 @@ export class ClientSession {
1009
1088
  conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
1010
1089
  // 子代理不走 bindSession——这里同样注入面板的重试次数覆盖。
1011
1090
  this.applyRetryOverrides();
1091
+ // 软上限覆盖同样重放(子代理跟随主对话的压缩阈值,issue #229)。
1092
+ this.applyCompactionOverrides();
1012
1093
  // 扩展绑定(rpc 模式);用 headless 的 Web UI context:
1013
1094
  // 扩展绑定时不会因缺方法崩,UI 输出也不下发(不会与主对话的 widget/status 冲突)。
1014
1095
  try {
@@ -1030,8 +1111,8 @@ export class ClientSession {
1030
1111
  const resolvedModel = model ?? (apply?.model?.trim() || null) ?? (this.settingsSvc.current.subagentDefaultModel || null);
1031
1112
  const followModel = resolvedModel
1032
1113
  ? resolvedModel
1033
- : this.session.model
1034
- ? `${this.session.model.provider}/${this.session.model.id}`
1114
+ : spawnerSession.model
1115
+ ? `${spawnerSession.model.provider}/${spawnerSession.model.id}`
1035
1116
  : null;
1036
1117
  if (followModel) {
1037
1118
  const slash = followModel.indexOf("/");
@@ -1041,7 +1122,8 @@ export class ClientSession {
1041
1122
  if (m) {
1042
1123
  try {
1043
1124
  // 先恢复该 provider 的项目密钥(setModel 的鉴权检查要用),再换模型。
1044
- await this.restoreKeyForModel(followModel, cwd);
1125
+ // 按子代理自己的目录恢复(跨目录派发时派发者的密钥不一定适用)。
1126
+ await this.restoreKeyForModel(followModel, resolvedCwd);
1045
1127
  await conv.session.setModel(m);
1046
1128
  }
1047
1129
  catch (err) {
@@ -1063,12 +1145,12 @@ export class ClientSession {
1063
1145
  });
1064
1146
  }
1065
1147
  }
1066
- // 思考强度:模板指定则固定用它,否则跟随主对话当前强度(与「跟随主对话模型」
1067
- // 同一取数源:this.session,即共享 ModelRuntime 的当前活动会话)。所以子代理默认
1068
- // 与主对话一致,而不是默默回到 SDK 默认档位。放在换模型之后:setModel 会按模型
1069
- // 能力重算强度,我们先让它算完再覆盖。不传 persist:只影响这个子代理会话,不动
1070
- // 全局默认强度;模型不支持的档位由 SDK 自动收敛(reasoning:false 的模型只能是 off)。
1071
- const thinkingLevel = apply?.thinkingLevel?.trim() || this.session.thinkingLevel;
1148
+ // 思考强度:模板指定则固定用它,否则跟随派发者当前强度(与「跟随派发者模型」
1149
+ // 同一取数源:spawnerSession)。所以子代理默认与派发者一致,而不是默默回到
1150
+ // SDK 默认档位。放在换模型之后:setModel 会按模型能力重算强度,我们先让它
1151
+ // 算完再覆盖。不传 persist:只影响这个子代理会话,不动全局默认强度;模型不
1152
+ // 支持的档位由 SDK 自动收敛(reasoning:false 的模型只能是 off)。
1153
+ const thinkingLevel = apply?.thinkingLevel?.trim() || spawnerSession.thinkingLevel;
1072
1154
  if (thinkingLevel) {
1073
1155
  try {
1074
1156
  conv.session.setThinkingLevel(thinkingLevel);
@@ -1109,7 +1191,10 @@ export class ClientSession {
1109
1191
  }
1110
1192
  toSubagentSnapshot(conv) {
1111
1193
  const streaming = conv.session.isStreaming;
1112
- const state = streaming ? "running" : "done";
1194
+ const { error, canceled } = this.subagentRunOutcome(conv);
1195
+ // state 如实反映终态:之前 canceled 的子代理报的也是 done,只能靠独立 flag
1196
+ // 分辨。"queued" 保留给未来(排队调度),当前 spawn 即运行,无排队态。
1197
+ const state = streaming ? "running" : canceled ? "canceled" : "done";
1113
1198
  let messageCount = 0;
1114
1199
  try {
1115
1200
  messageCount = conv.session.getSessionStats().totalMessages;
@@ -1117,12 +1202,14 @@ export class ClientSession {
1117
1202
  catch {
1118
1203
  // session being replaced — report defaults
1119
1204
  }
1120
- const { error, canceled } = this.subagentRunOutcome(conv);
1205
+ const fullPrompt = conv.subagentPrompt ?? "";
1121
1206
  return {
1122
1207
  convId: conv.id,
1123
1208
  type: conv.subagentType ?? "general",
1124
1209
  title: conv.title,
1125
- prompt: "",
1210
+ prompt: fullPrompt.length > SUBAGENT_PROMPT_SNAPSHOT_CAP
1211
+ ? `${fullPrompt.slice(0, SUBAGENT_PROMPT_SNAPSHOT_CAP)}\n… [truncated]`
1212
+ : fullPrompt,
1126
1213
  state,
1127
1214
  streaming,
1128
1215
  error,
@@ -1130,6 +1217,7 @@ export class ClientSession {
1130
1217
  messageCount,
1131
1218
  model: conv.session.model?.id,
1132
1219
  output: conv.session.getLastAssistantText() ?? "",
1220
+ parentId: conv.parentId,
1133
1221
  };
1134
1222
  }
1135
1223
  /** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
@@ -1200,6 +1288,9 @@ export class ClientSession {
1200
1288
  lastSdkAppendFiles = [];
1201
1289
  /** 当前活动会话的工具/资源快照 → composer 输入。cwd 取活动对话的。 */
1202
1290
  composeInputs(src) {
1291
+ // 技能名录指纹观测(只打日志,不干预组装;预览与 run 共用此入口,
1292
+ // 变化才记一行,首轮静默)。用未注文的目录(fill 前),全文注入不影响指纹。
1293
+ this.noteSkillCatalogDigest(src.skills);
1203
1294
  return {
1204
1295
  cwd: src.cwd,
1205
1296
  systemPromptFile: this.lastBaseSystemPrompt || undefined,
@@ -1364,12 +1455,32 @@ export class ClientSession {
1364
1455
  return !!t && t.enabled;
1365
1456
  },
1366
1457
  };
1367
- /** schedule_* 工具的数据宿主:全局调度存储+创建时刻 live 的 cwd/活动对话。 */
1458
+ /** schedule_* 工具的数据宿主:全局调度存储+创建时刻 live 的 cwd/活动对话。
1459
+ * issue #231:同时快照 owner 对话的落盘会话文件(压缩/重启后稳定),触发时
1460
+ * 先按 sessionFile 认同一会话(内存对话 id 重启即失效,不可单独做持久键)。 */
1368
1461
  scheduleToolHost() {
1369
1462
  return {
1370
1463
  store: () => this.schedulerStore,
1371
1464
  cwd: () => this.cwd,
1372
1465
  activeConversationId: () => this.activeId,
1466
+ conversationInfo: (id) => {
1467
+ try {
1468
+ const target = (id ?? "").trim() ? this.convs.get((id ?? "").trim()) : this.convs.get(this.activeId);
1469
+ if (!target)
1470
+ return undefined;
1471
+ let sessionFile = "";
1472
+ try {
1473
+ sessionFile = String(target.session.sessionFile ?? "");
1474
+ }
1475
+ catch {
1476
+ sessionFile = "";
1477
+ }
1478
+ return { cwd: target.cwd ?? this.cwd, sessionFile };
1479
+ }
1480
+ catch {
1481
+ return undefined;
1482
+ }
1483
+ },
1373
1484
  };
1374
1485
  }
1375
1486
  /** conversation_read 工具的数据宿主:读本客户端的 conversation 体系 +
@@ -1406,16 +1517,8 @@ export class ClientSession {
1406
1517
  const c = this.convs.get(id);
1407
1518
  if (!c)
1408
1519
  return undefined;
1409
- let raw = [];
1410
- try {
1411
- raw = (c.session.messages ??
1412
- c.session.agent.state.messages ??
1413
- []);
1414
- }
1415
- catch {
1416
- raw = [];
1417
- }
1418
- return { title: c.title, cwd: c.cwd, isSubagent: !!c.isSubagent, messages: raw.map(toTranscriptInput) };
1520
+ // 取数与并行提醒的触碰集同一路径(convTranscript),不另起读取逻辑。
1521
+ return { title: c.title, cwd: c.cwd, isSubagent: !!c.isSubagent, messages: this.convTranscript(c) };
1419
1522
  },
1420
1523
  listHistorySessions: async (scope, cwd) => {
1421
1524
  const infos = scope === "all"
@@ -1430,6 +1533,29 @@ export class ClientSession {
1430
1533
  cwd: s.cwd,
1431
1534
  }));
1432
1535
  },
1536
+ readTouchSidecar: (id, path) => {
1537
+ // sidecar 让 files/status 在压缩后仍有答案(additive 可选方法:
1538
+ // 读不到就回 undefined,调用方回落现算转录 —— 绝不抛错阻塞工具)。
1539
+ try {
1540
+ if (id) {
1541
+ const c = this.convs.get(id);
1542
+ let file;
1543
+ try {
1544
+ file = c?.session.sessionFile ?? undefined;
1545
+ }
1546
+ catch {
1547
+ file = undefined;
1548
+ }
1549
+ return readTouchSidecar(file);
1550
+ }
1551
+ if (path)
1552
+ return readTouchSidecar(path);
1553
+ return undefined;
1554
+ }
1555
+ catch {
1556
+ return undefined;
1557
+ }
1558
+ },
1433
1559
  readHistorySession: async (path) => {
1434
1560
  const all = await SessionManager.listAll(piSessionsRoot());
1435
1561
  const hit = all.find((s) => resolve(s.path) === resolve(path));
@@ -1452,6 +1578,67 @@ export class ClientSession {
1452
1578
  },
1453
1579
  };
1454
1580
  }
1581
+ /** claim_files 工具的数据宿主:owner 口径同 subagent/skill(本 runtime 所属会话)。
1582
+ * 常驻注册、不进 AGENT_TOOL_CATALOG(例外:目录工具必须有设置页行,见
1583
+ * settings-tool-rows.test.ts,而 web/src 正被并行任务占用;advisory 工具常驻
1584
+ * 默认开可接受,目录项 + 设置行等 web/src 空出来后补)。 */
1585
+ claimToolHost(ownerId) {
1586
+ const target = () => {
1587
+ try {
1588
+ return (ownerId ?? "").trim() !== "" ? this.convs.get(ownerId.trim()) : this.convs.get(this.activeId);
1589
+ }
1590
+ catch {
1591
+ return undefined;
1592
+ }
1593
+ };
1594
+ return {
1595
+ cwd: () => target()?.cwd ?? this.cwd,
1596
+ self: () => {
1597
+ const t = target();
1598
+ return { convId: t?.id ?? this.activeId, title: t?.title ?? "" };
1599
+ },
1600
+ store: () => this.getClaimStore?.(),
1601
+ };
1602
+ }
1603
+ /** skill 工具的数据宿主:读所属会话 loader 的实时技能表 + 主会话禁用集过滤
1604
+ * (与 skillsOverride 主会话语义一致)。ownerId 语义同 browser_page(本
1605
+ * runtime 所属会话,不是派发瞬间的 active)。失败回空目录,不抛错。 */
1606
+ skillToolHost(ownerId) {
1607
+ return {
1608
+ listSkills: () => {
1609
+ try {
1610
+ const target = (ownerId ?? "").trim() !== "" ? this.convs.get(ownerId.trim()) : this.convs.get(this.activeId);
1611
+ const all = target?.session.resourceLoader.getSkills().skills ?? [];
1612
+ const disabled = new Set(this.settingsSvc.current.disabledSkills);
1613
+ return all
1614
+ .filter((s) => !disabled.has(s.name))
1615
+ .map((s) => ({
1616
+ name: s.name,
1617
+ description: s.description ?? "",
1618
+ filePath: s.filePath ?? "",
1619
+ }));
1620
+ }
1621
+ catch {
1622
+ return [];
1623
+ }
1624
+ },
1625
+ };
1626
+ }
1627
+ /** 技能名录指纹(sha256 over name+description,非正文):变化才打一行日志,
1628
+ * 首轮静默。只观测不干预组装(digest 第一步:日志;复用以后再说)。 */
1629
+ lastSkillCatalogDigest = "";
1630
+ noteSkillCatalogDigest(skills) {
1631
+ const d = createHash("sha256")
1632
+ .update(skills.map((s) => `${s.name}\n${s.description}`).join("\n"))
1633
+ .digest("hex")
1634
+ .slice(0, 16);
1635
+ if (d === this.lastSkillCatalogDigest)
1636
+ return;
1637
+ const prev = this.lastSkillCatalogDigest;
1638
+ this.lastSkillCatalogDigest = d;
1639
+ if (prev)
1640
+ console.log(`[skills] catalog digest ${prev}→${d} (${skills.length} skills)`);
1641
+ }
1455
1642
  widgetsTimer = null;
1456
1643
  /** Model-stall watchdog interval (see startStallTimer). */
1457
1644
  stallTimer = null;
@@ -1575,13 +1762,15 @@ export class ClientSession {
1575
1762
  reloadSession: async () => {
1576
1763
  await this.session.reload();
1577
1764
  // reload() 重读磁盘 settings.json,会丢掉内存 applyOverrides
1578
- // (含重试次数覆盖)——依次重放:重试覆盖 → 终端门控。
1765
+ // (含重试次数覆盖)——依次重放:重试覆盖 → 软上限覆盖 → 终端门控。
1579
1766
  this.applyRetryOverrides();
1767
+ this.applyCompactionOverrides();
1580
1768
  // reload() 会把 custom 工具重新加回活跃集——重放终端开关。
1581
1769
  this.applyToolGating(this.session);
1582
1770
  await this.pushSlashCommands();
1583
1771
  },
1584
1772
  applyRetryOverrides: () => this.applyRetryOverrides(),
1773
+ applyCompactionOverrides: () => this.applyCompactionOverrides(),
1585
1774
  applyToolGating: () => this.applyToolGating(this.session),
1586
1775
  promptSnapshot: () => this.promptSnapshot(),
1587
1776
  getMarkerState: () => ({
@@ -1631,15 +1820,22 @@ export class ClientSession {
1631
1820
  const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
1632
1821
  const conversationId = cs.nextConversationId();
1633
1822
  const terminals = cs.makeTerminalManager(conversationId, cwd);
1634
- const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals, undefined, conversationId), {
1823
+ // Resume the most recent session for this project — the SDK default
1824
+ // per-project dir (<agentDir>/sessions/--<cwd>--/, shared with the
1825
+ // pi CLI/TUI) — or start a fresh one on first visit.
1826
+ // issue #145: opts.blank = 跳过恢复(最近那条在别处跑着),直接空白新对话。
1827
+ // issue #235:坏转录(重复压缩标记成环)修一次再试,否则整项目首屏
1828
+ // "Failed to initialize session"。
1829
+ const opened = await cs.openManagerAndRuntime(() => (opts?.blank ? SessionManager.create(cwd) : SessionManager.continueRecent(cwd)), (m) => createAgentSessionRuntime(cs.makeRuntimeFactory(terminals, undefined, conversationId), {
1635
1830
  cwd,
1636
1831
  agentDir,
1637
- // Resume the most recent session for this project — the SDK default
1638
- // per-project dir (<agentDir>/sessions/--<cwd>--/, shared with the
1639
- // pi CLI/TUI) — or start a fresh one on first visit.
1640
- // issue #145: opts.blank = 跳过恢复(最近那条在别处跑着),直接空白新对话。
1641
- sessionManager: opts?.blank ? SessionManager.create(cwd) : SessionManager.continueRecent(cwd),
1642
- });
1832
+ sessionManager: m,
1833
+ }), async () => (await SessionManager.list(cwd))[0]?.path);
1834
+ const runtime = opened.runtime;
1835
+ if (opened.repair) {
1836
+ for (const n of cs.transcriptRepairNotices(opened.repair))
1837
+ cs.pendingNotices.push(n);
1838
+ }
1643
1839
  // First conversation = the resumed session; it also seeds the shared
1644
1840
  // ModelRuntime that every later conversation reuses.
1645
1841
  cs.sharedModelRuntime = runtime.services.modelRuntime;
@@ -1779,12 +1975,18 @@ export class ClientSession {
1779
1975
  // 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
1780
1976
  // systemPromptOverride 整体替换,此处边界不存在会自然跳过。
1781
1977
  if (apply) {
1782
- if (apply.promptMode !== "replace" || !pickTemplatePrompt(apply, this.getLang()).trim())
1978
+ const tplPrompt = pickTemplatePrompt(apply, this.getLang()).trim();
1979
+ if (apply.promptMode !== "replace" || !tplPrompt)
1783
1980
  return undefined;
1784
1981
  const boundary = event.systemPrompt.indexOf("\n\nAvailable tools:");
1785
- if (boundary === -1)
1786
- return undefined;
1787
- const swapped = pickTemplatePrompt(apply, this.getLang()).trimEnd() + event.systemPrompt.slice(boundary);
1982
+ // 边界串是 SDK 提示词的内部格式:版本一变就可能对不上。
1983
+ // 对不上时不再静默回退默认 persona(模板等于没生效),而是把模板
1984
+ // 提示词前置拼接——角色约束仍在,只是灵魂段没被精确替换。
1985
+ if (boundary === -1) {
1986
+ const fallback = `${tplPrompt}\n\n${event.systemPrompt}`;
1987
+ return fallback === event.systemPrompt ? undefined : { systemPrompt: fallback };
1988
+ }
1989
+ const swapped = tplPrompt + event.systemPrompt.slice(boundary);
1788
1990
  return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
1789
1991
  }
1790
1992
  // 主会话:组合模板渲染(模板为空且无覆盖时返回 undefined = 用 SDK 默认)。
@@ -1808,6 +2010,11 @@ export class ClientSession {
1808
2010
  ],
1809
2011
  },
1810
2012
  });
2013
+ // 桥接工具目标(问卷 / 页面):每次调用都解析「现在谁持有这条对话」,
2014
+ // 而不是认这个 runtime 是在哪个 ClientSession 里建出来的(过户会换主)。
2015
+ // anchor 在拿到 created.session 后回填(SDK 的 runtime.session 就是它)。
2016
+ const bridgeAnchor = {};
2017
+ const bridge = this.bridgeTarget(bridgeAnchor, ownerId);
1811
2018
  const created = await createAgentSessionFromServices({
1812
2019
  services,
1813
2020
  sessionManager,
@@ -1830,11 +2037,22 @@ export class ClientSession {
1830
2037
  // 设置关 → 原生 bash;开 → 终端 bash。
1831
2038
  () => this.settingsSvc.current.terminalBash),
1832
2039
  ...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang()),
2040
+ // 覆盖 SDK 内置 read(customTools 按 name 覆盖):路径是目录时列出目录
2041
+ // 条目(复用 SDK ls 的排序/`/` 后缀/截断口径),其余情况原样转发内置实现。
2042
+ // 开关是行为开关(read 本体不可关),每次调用实时读设置——不进
2043
+ // tool-manager 的 ActiveSet 目录。DSH 引擎无 customTool 注册面,不接。
2044
+ makeReadDirTool(effectiveCwd, {
2045
+ dirEnabled: () => this.settingsSvc.current.readDirEnabled !== false,
2046
+ getLang: () => this.getLang(),
2047
+ }),
1833
2048
  // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置)。
1834
2049
  makeEditSoftTool(effectiveCwd, () => this.getLang()),
1835
2050
  // 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
1836
2051
  // 后续注册经 refreshPluginTools 动态补入已有会话)。
1837
- ...this.enabledPluginToolDefs(),
2052
+ // 子代理模板带非空扩展白名单时不注入:插件/MCP 工具没有 SDK extensionKey
2053
+ // 身份、无法参与白名单匹配,全放行等于白名单没关门,全收编才符合「只加载这些」。
2054
+ // 空白名单 = 跟随主会话(插件工具照常进入子代理)。
2055
+ ...(apply && apply.enabledExtensions.length > 0 ? [] : this.enabledPluginToolDefs()),
1838
2056
  // 第一方子代理工具(spawn/get_result/steer/list/stop)。子代理会话
1839
2057
  // 也注册了它们,因此可自然嵌套派发。host 按 ownerId 包装:子代理的
1840
2058
  // 父对话 = 真正调用 spawn 的那个会话(本 runtime 所属会话),而不是
@@ -1856,16 +2074,42 @@ export class ClientSession {
1856
2074
  // 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
1857
2075
  // 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
1858
2076
  // DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
1859
- makeAskUserQuestionTool(this, ownerId),
2077
+ makeAskUserQuestionTool(bridge, ownerId),
1860
2078
  // 浏览器页面工具:模型调用 → page_request 给浏览器 → page-picker 扩展
1861
2079
  // 操作用户授权的页面 → page_response 回来。ownerId 语义同上(本 runtime
1862
2080
  // 所属会话,不是派发瞬间的 active)。
1863
- makeBrowserPageTool(this, ownerId),
2081
+ makeBrowserPageTool(bridge, ownerId),
1864
2082
  // 别的对话读取(运行中含子代理 + 历史转录,只读):用户引用了别的
1865
2083
  // 对话(引用 chip / 粘过来的 id / “看看之前那个对话”)时用。子代理
1866
2084
  // 会话同样注册了它,可自然嵌套读取。不需要 ownerId——读的是本
1867
2085
  // 客户端的 conversation 体系与落盘历史,与派发者无关。
1868
- makeConversationReadTool(this.conversationReadHost(), () => this.getLang()),
2086
+ // extras 认领表:files/status 顺带展示(AgentService 级共享)。
2087
+ makeConversationReadTool(this.conversationReadHost(), () => this.getLang(), {
2088
+ listClaims: (cwd) => (this.getClaimStore?.().list(cwd) ?? []).map((c) => ({
2089
+ path: c.path,
2090
+ ownerTitle: c.ownerTitle,
2091
+ ...(c.note ? { note: c.note } : {}),
2092
+ })),
2093
+ }),
2094
+ // 文件认领(claim_files):常驻注册、不进工具目录(见 claimToolHost
2095
+ // 注释)。ownerId 语义同 subagent/skill(本 runtime 所属会话)。
2096
+ // DSH 引擎无 customTool 注册面,不接(提醒里照样能看到认领)。
2097
+ makeClaimFilesTool(this.claimToolHost(ownerId), () => this.getLang()),
2098
+ // 展示文件给用户(present_files,issue #231):模型给路径清单,服务端
2099
+ // 只做只读探测(stat + 未知扩展嗅探 + 文本摘录),结构化 items 走 tool
2100
+ // result 的 details 下发,前端渲染成图片/视频内联 + 预览/本地打开/
2101
+ // 在文件夹中显示/下载/复制路径的卡片。不打开任何窗口,系统级动作
2102
+ // 一律由用户点卡片触发(file_open_default / file_reveal)。
2103
+ // 开关走统一工具 tab(ActiveSet 门控;enabled 兜底只做报错文案)。
2104
+ // DSH 引擎无 customTool 注册面,不接。
2105
+ makePresentFilesTool(effectiveCwd, {
2106
+ enabled: () => isAgentToolEnabled(PRESENT_FILES_TOOL_NAME, effectiveDisabledAgentTools(this.settingsSvc.current)),
2107
+ getLang: () => this.getLang(),
2108
+ }),
2109
+ // 技能全文按名加载(名录在 {{skills}} 段):模型不再拼路径调 read。
2110
+ // 子代理会话同样注册(owner 即真正派发的父对话,读该会话 loader)。
2111
+ // DSH 引擎无 customTool 注册面,不接。开关走统一工具 tab。
2112
+ makeSkillTool(this.skillToolHost(ownerId), () => this.getLang()),
1869
2113
  // 定时唤醒三件套(schedule_task/list/cancel,issue #193):默认绑定
1870
2114
  // 发起对话(ownerId,无则活动对话),到期 steer 语义唤醒它;子代理
1871
2115
  // 会话同样注册(owner 即真正派发的父对话)。开关走统一工具 tab。
@@ -1873,6 +2117,9 @@ export class ClientSession {
1873
2117
  ...makeScheduleTools(this.scheduleToolHost(), ownerId, () => this.getLang()),
1874
2118
  ],
1875
2119
  });
2120
+ // 桥接工具归属锚点:SDK 会话对象在本 runtime 生命周期内稳定,过户只搬对话
2121
+ // 不改它(见 ClientSession.findConversationHome)。
2122
+ bridgeAnchor.session = created.session;
1876
2123
  // 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
1877
2124
  this.applyToolGating(created.session);
1878
2125
  return {
@@ -2038,6 +2285,9 @@ export class ClientSession {
2038
2285
  // model picker needs it even before the client asks).
2039
2286
  this.modelAdmin.listProviderKeys();
2040
2287
  this.modelAdmin.listProviderOAuthFlows();
2288
+ // Reconnect: push the global default model (the picker's ★ marker +
2289
+ // "set as global default" button state).
2290
+ this.pushDefaultModel();
2041
2291
  // PTYs are conversation-owned and survive a socket reconnect.
2042
2292
  this.pushTerminals();
2043
2293
  }
@@ -2075,6 +2325,8 @@ export class ClientSession {
2075
2325
  // 默认重试 3 次——这里把面板的 retryMaxAttempts 覆盖注入,否则“设了 6
2076
2326
  // 次还是按 3 次重试”。已存在会话重复注入是幂等的(同值覆盖)。
2077
2327
  this.applyRetryOverrides();
2328
+ // 软上限覆盖同路重放(新 runtime 的 SettingsManager 是干净的,issue #229)。
2329
+ this.applyCompactionOverrides();
2078
2330
  this.scheduleSnapshot();
2079
2331
  this.webUi.refresh();
2080
2332
  this.startWidgetsTimer();
@@ -2343,6 +2595,94 @@ export class ClientSession {
2343
2595
  await conv.session.sendUserMessage(text, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
2344
2596
  return { ok: true };
2345
2597
  }
2598
+ /** issue #231:本客户端内按稳定键定位调度唤醒目标。
2599
+ * sessionFile 优先(压缩/重启后内存对话 id 已变,落盘会话文件才是同一会话);
2600
+ * 只有没给 sessionFile 时才按内存 id 找,且 id 必须附带 cwd 一致才认 ——
2601
+ * 各客户端计数器都从 c1 开始,跨项目同 id 必然撞车,不校验 cwd 会把巡检
2602
+ * 报告投进完全无关的项目对话。
2603
+ * excludeIds 用于视口回退时跳过已知的忙对话。 */
2604
+ resolveSchedulerTarget(opts) {
2605
+ const wantFile = String(opts.sessionFile ?? "").trim();
2606
+ const wantId = String(opts.conversationId ?? "").trim();
2607
+ const wantCwd = String(opts.cwd ?? "").trim();
2608
+ const excluded = opts.excludeIds;
2609
+ if (wantFile) {
2610
+ let best = null;
2611
+ for (const c of this.convs.values()) {
2612
+ if (!c?.session || (excluded && excluded.has(c.id)))
2613
+ continue;
2614
+ let f = "";
2615
+ try {
2616
+ f = String(c.session.sessionFile ?? "");
2617
+ }
2618
+ catch {
2619
+ continue;
2620
+ }
2621
+ if (!sameSessionFile(f, wantFile))
2622
+ continue;
2623
+ if (!best || c.lastActiveAt > best.lastActiveAt)
2624
+ best = c;
2625
+ }
2626
+ if (best)
2627
+ return best;
2628
+ }
2629
+ if (wantId) {
2630
+ const c = this.convs.get(wantId);
2631
+ if (!c?.session || (excluded && excluded.has(c.id)))
2632
+ return null;
2633
+ if (wantCwd && !sameCwd(c.cwd, wantCwd))
2634
+ return null;
2635
+ return c;
2636
+ }
2637
+ return null;
2638
+ }
2639
+ /** issue #231:本客户端内同项目的最近活跃对话(视口回退目标)。
2640
+ * 主对话优先(用户正看着的面),没有主对话时才考虑子代理行;
2641
+ * 最近活跃者即用户当前的视口。 */
2642
+ findViewportInCwd(cwd, excludeIds) {
2643
+ const want = String(cwd ?? "").trim();
2644
+ if (!want)
2645
+ return null;
2646
+ let best = null;
2647
+ let bestSub = null;
2648
+ for (const c of this.convs.values()) {
2649
+ if (!c?.session || (excludeIds && excludeIds.has(c.id)))
2650
+ continue;
2651
+ if (!sameCwd(c.cwd, want))
2652
+ continue;
2653
+ if (c.isSubagent) {
2654
+ if (!bestSub || c.lastActiveAt > bestSub.lastActiveAt)
2655
+ bestSub = c;
2656
+ }
2657
+ else if (!best || c.lastActiveAt > best.lastActiveAt) {
2658
+ best = c;
2659
+ }
2660
+ }
2661
+ return best ?? bestSub;
2662
+ }
2663
+ /** issue #231:带压缩忙检测的调度 steer。压缩进行中时 SDK 直接抛错
2664
+ * (Cannot submit a prompt while compaction is in progress),调用方据 busy
2665
+ * 另寻视口兄弟或稍后重试,而不是当成“对话不在”静默转无头。 */
2666
+ async trySteerScheduler(conv, text) {
2667
+ try {
2668
+ try {
2669
+ if (conv.session.isCompacting === true) {
2670
+ return { ok: false, busy: true, error: "上下文压缩进行中,稍后重试" };
2671
+ }
2672
+ }
2673
+ catch {
2674
+ /* 读不到压缩态就直接投递,失败按异常走 */
2675
+ }
2676
+ await conv.session.sendUserMessage(text, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
2677
+ return { ok: true };
2678
+ }
2679
+ catch (err) {
2680
+ const msg = String(err?.message ?? err);
2681
+ if (/compaction is in progress/i.test(msg))
2682
+ return { ok: false, busy: true, error: msg };
2683
+ return { ok: false, busy: false, error: msg };
2684
+ }
2685
+ }
2346
2686
  /** 插件扩展点(供 runSteerer):向指定对话插队一条用户消息,复用子代理 steer 的
2347
2687
  * sendUserMessage + deliverAs:'steer' 路径(运行时插队;未跑时按普通消息投递)。
2348
2688
  * 先找本客户端 conversations,找不到再经 steerConversationElsewhere 问其他客户端;
@@ -2707,6 +3047,31 @@ export class ClientSession {
2707
3047
  this.settingsSvc.consumePendingReload();
2708
3048
  void this.applySettingsReload();
2709
3049
  }
3050
+ // 触碰 sidecar 落盘:压缩后旧消息被摘要替代,files 回落现算会丢历史;
3051
+ // 条数没涨就不写(不在热路径);子代理 inMemory 无转录文件,merge 内 no-op。
3052
+ try {
3053
+ let count = conv.touchSidecarCount ?? -1;
3054
+ try {
3055
+ count = conv.session.getSessionStats().totalMessages;
3056
+ }
3057
+ catch {
3058
+ // 会话替换中 —— 按上次条数处理(多半直接跳过)
3059
+ }
3060
+ if (count !== (conv.touchSidecarCount ?? -1)) {
3061
+ conv.touchSidecarCount = count;
3062
+ let file;
3063
+ try {
3064
+ file = conv.session.sessionFile ?? undefined;
3065
+ }
3066
+ catch {
3067
+ file = undefined;
3068
+ }
3069
+ void mergeTouchSidecar(file, extractTouches(this.convTranscript(conv)));
3070
+ }
3071
+ }
3072
+ catch {
3073
+ // sidecar 只是加速 + 防压缩丢失,失败了下次重算
3074
+ }
2710
3075
  break;
2711
3076
  }
2712
3077
  case "entry_appended": {
@@ -2949,12 +3314,14 @@ export class ClientSession {
2949
3314
  contextWindow: cu.contextWindow,
2950
3315
  percent: (conv.lastCompactionTokens / cu.contextWindow) * 100,
2951
3316
  estimated: true,
3317
+ softCap: this.activeSoftCap(cu.contextWindow),
2952
3318
  };
2953
3319
  }
2954
3320
  return {
2955
3321
  tokens: cu.tokens,
2956
3322
  contextWindow: cu.contextWindow,
2957
3323
  percent: cu.percent,
3324
+ softCap: this.activeSoftCap(cu.contextWindow),
2958
3325
  };
2959
3326
  })(),
2960
3327
  };
@@ -3069,6 +3436,37 @@ export class ClientSession {
3069
3436
  // -----------------------------------------------------------------------
3070
3437
  // 用户提问桥(标准 pi 引擎 ask_user_question customTool)
3071
3438
  // -----------------------------------------------------------------------
3439
+ /**
3440
+ * 桥接工具目标(ask_user_question / browser_page):**调用瞬间**按 runtime 身份
3441
+ * 解析当前持有它的会话与对话 id,而不是用建 runtime 时捕获的 `this` + ownerId
3442
+ * (过户会把 runtime 搬到另一个 ClientSession,闭包里的会话引用与 id 都不跟着
3443
+ * 搬 —— 详见 findConversationHome)。
3444
+ *
3445
+ * 工厂(makeAskUserQuestionTool / makeBrowserPageTool)是鸭子类型,只要求这几个
3446
+ * 方法,所以这里给的是一个按需转发的小适配器:工厂传进来的 convId 是**建时**的
3447
+ * 旧 id(过户撞车改名后它要么查不到、要么撞到别的对话),一律以解析结果为准。
3448
+ * 解析不到(未接线 / 会话被替换 / 对话已关闭)时兜底建时的会话与 id —— 与改动前
3449
+ * 行为一致。anchor 由 runtime 创建处在拿到 `created.session` 后回填。
3450
+ */
3451
+ bridgeTarget(anchor, ownerId) {
3452
+ const home = () => {
3453
+ const found = anchor.session ? this.findConversationHome?.(anchor.session) : undefined;
3454
+ return found ?? { session: this, convId: ownerId };
3455
+ };
3456
+ return {
3457
+ askUser: (questions, sig) => {
3458
+ const h = home();
3459
+ return h.session.askUser(questions, sig, h.convId);
3460
+ },
3461
+ pageCall: (req, sig) => {
3462
+ const h = home();
3463
+ return h.session.pageCall(req, sig, h.convId);
3464
+ },
3465
+ // 截图「给图还是走视觉桥」按当前持有方的模型与设置判定(过户后由它驱动)。
3466
+ canSeeImages: () => home().session.canSeeImages(),
3467
+ transcribeToolImage: (image, signal) => home().session.transcribeToolImage(image, signal),
3468
+ };
3469
+ }
3072
3470
  /** 标准引擎模型调 ask_user_question:发 question_pending 给浏览器并阻塞等待
3073
3471
  * question_answer。sig 为工具执行信号的当前状态(aborted → 立即 reject)。
3074
3472
  * 返回 answers(用户选中/自定义),或 null(用户取消)。
@@ -3394,6 +3792,22 @@ export class ClientSession {
3394
3792
  * attach 时由 AgentService 接到全局 onClientCwdChanged —— 编辑器等
3395
3793
  * 工作区跟随型插件借此把根目录切到用户当前项目。 */
3396
3794
  onCwdChanged = undefined;
3795
+ /** 过户后的桥接投递解析(attach 时由 AgentService 接线):给一个 SDK 会话(=
3796
+ * 一个 runtime 的身份),返回**当前**持有它的客户端会话 + 该对话**当前** id。
3797
+ *
3798
+ * 为什么需要它:runtime 创建时把 `this`(当时的 ClientSession)与当时的
3799
+ * conversationId 闭包进桥接工具(ask_user_question / browser_page),而
3800
+ * `take_over_conversation` 只搬对话(runtime/终端/订阅/看门狗/在途问卷与页
3801
+ * 调用),搬不动闭包里的会话引用。过户后模型再提问/截图,用捕获的 `this` 就会把
3802
+ * question_pending / page_request 推给过户前那台设备 —— 持有方收不到,问卷还会
3803
+ * 挂在老设备的注册表里(且不进它的快照:该 conv 已不在它名下)→ 刷新即丢,
3804
+ * 而 pi 引擎问卷不超时,那一轮 run 就永远挂住。
3805
+ *
3806
+ * 用 SDK 会话对象(稳定标识)而不是建时的 conversationId:过户遇到 id 撞车会把
3807
+ * 对话改名(c1→c2),那时旧 id 要么查不到、要么查到老会话里的另一条对话,都会
3808
+ * 投错。(注意不能用 runtime 对象比:SDK 会把工厂返回值包成新的 AgentSessionRuntime
3809
+ * 实例,而 `runtime.session` 与本工厂的 `created.session` 是同一个对象。) */
3810
+ findConversationHome = undefined;
3397
3811
  /** issue #145 跨客户端同会话感知 —— attach 时由 AgentService 接线:
3398
3812
  * - findSessionOwner:别处是否已持有同一 session 文件(查重建第二个 writer 用);
3399
3813
  * - listProjectRunners:别处在同一 cwd 下正在跑的对话(同项目并行感知用);
@@ -3408,6 +3822,8 @@ export class ClientSession {
3408
3822
  /** issue #145:除本客户端外是否有人在跑(扫目录查重前置的无 I/O 判断)。 */
3409
3823
  hasStreamingElsewhere = undefined;
3410
3824
  listProjectRunners = undefined;
3825
+ /** issue #145 同款接线:全局认领表(AgentService 级单例,attach 时由 AgentService 接线)。 */
3826
+ getClaimStore = undefined;
3411
3827
  listExternalRunning = undefined;
3412
3828
  notifyExternalClients = undefined;
3413
3829
  onRunningChanged = undefined;
@@ -3588,8 +4004,9 @@ export class ClientSession {
3588
4004
  },
3589
4005
  refreshSessions: () => this.refreshSessions(),
3590
4006
  afterReload: () => {
3591
- // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 终端门控。
4007
+ // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 软上限覆盖 + 终端门控。
3592
4008
  this.applyRetryOverrides();
4009
+ this.applyCompactionOverrides();
3593
4010
  this.applyToolGating(this.session);
3594
4011
  },
3595
4012
  pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
@@ -3631,8 +4048,12 @@ export class ClientSession {
3631
4048
  await this.modelAdmin.clearProviderApiKey(provider);
3632
4049
  // The provider is back to unconfigured — drop its key preference in
3633
4050
  // EVERY project, otherwise each project switch re-tries a restore.
3634
- if (!usingOAuth)
4051
+ if (!usingOAuth) {
3635
4052
  this.stateStore.deleteProviderEverywhere(provider.trim());
4053
+ const defKey = this.stateStore.getDefaultProviderKey(provider.trim());
4054
+ if (defKey)
4055
+ this.stateStore.repointDeletedKeyInDefault(provider.trim(), defKey, null);
4056
+ }
3636
4057
  }
3637
4058
  startProviderOAuth(provider) {
3638
4059
  this.modelAdmin.startProviderOAuth(provider);
@@ -3679,6 +4100,14 @@ export class ClientSession {
3679
4100
  cloneProvider(providerId, reqId) {
3680
4101
  return this.modelAdmin.cloneProvider(providerId, reqId);
3681
4102
  }
4103
+ /** Enrich custom-provider draft rows from public catalogs (enrich_models_result). */
4104
+ enrichModels(reqId, ids, hints) {
4105
+ return this.modelAdmin.enrichModels(reqId, ids, hints, () => this.getLang());
4106
+ }
4107
+ /** Abort in-flight enrich_models request. */
4108
+ abortEnrichModels(reqId) {
4109
+ this.modelAdmin.abortEnrichModels(reqId);
4110
+ }
3682
4111
  saveModelConfig(providerId, config) {
3683
4112
  return this.modelAdmin.saveModelConfig(providerId, config);
3684
4113
  }
@@ -3710,6 +4139,7 @@ export class ClientSession {
3710
4139
  // (or drop the pin when no keys remain), not just the current one.
3711
4140
  const active = this.modelAdmin.getActiveKeyName(provider);
3712
4141
  this.stateStore.repointDeletedKeyEverywhere(provider, keyName, active);
4142
+ this.stateStore.repointDeletedKeyInDefault(provider, keyName, active);
3713
4143
  }
3714
4144
  /** Restore per-project provider keys when entering a project. For each
3715
4145
  * provider that has a saved key for `cwd`, activate it if it differs from
@@ -3734,25 +4164,40 @@ export class ClientSession {
3734
4164
  }
3735
4165
  }
3736
4166
  /** When a model is set, ensure its provider's per-project key is restored.
3737
- * Silent + self-healing like the bulk restore above. */
4167
+ * Silent + self-healing like the bulk restore above. Falls back to the
4168
+ * GLOBAL default key when the project has no pin for this provider (new
4169
+ * project following the global default model). */
3738
4170
  async restoreKeyForModel(modelId, cwd) {
3739
4171
  const slash = modelId.indexOf("/");
3740
4172
  if (slash <= 0)
3741
4173
  return;
3742
4174
  const provider = modelId.slice(0, slash);
3743
- const saved = this.stateStore.getProjectProviderKey(this.clientId, cwd, provider);
3744
- if (!saved)
4175
+ const projectPin = this.stateStore.getProjectProviderKey(this.clientId, cwd, provider);
4176
+ if (projectPin) {
4177
+ const cur = this.modelAdmin.getActiveKeyName(provider);
4178
+ if (cur === projectPin)
4179
+ return;
4180
+ if (!this.modelAdmin.hasProviderKey(provider, projectPin)) {
4181
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
4182
+ return;
4183
+ }
4184
+ const ok = await this.modelAdmin.activateProviderKey(provider, projectPin, { silent: true });
4185
+ if (!ok)
4186
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
3745
4187
  return;
3746
- const cur = this.modelAdmin.getActiveKeyName(provider);
3747
- if (cur === saved)
4188
+ }
4189
+ // No project pin — follow the global default key (if any). No
4190
+ // self-healing deletes here: the ref belongs to the global default,
4191
+ // not this project (key deletions already repoint it, see
4192
+ // repointDeletedKeyInDefault).
4193
+ const globalPin = this.stateStore.getDefaultProviderKey(provider);
4194
+ if (!globalPin)
3748
4195
  return;
3749
- if (!this.modelAdmin.hasProviderKey(provider, saved)) {
3750
- this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
4196
+ if (this.modelAdmin.getActiveKeyName(provider) === globalPin)
3751
4197
  return;
3752
- }
3753
- const ok = await this.modelAdmin.activateProviderKey(provider, saved, { silent: true });
3754
- if (!ok)
3755
- this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
4198
+ if (!this.modelAdmin.hasProviderKey(provider, globalPin))
4199
+ return;
4200
+ await this.modelAdmin.activateProviderKey(provider, globalPin, { silent: true });
3756
4201
  }
3757
4202
  /** Remember the just-selected model (and the key that was active for its
3758
4203
  * provider) for the current project. Called IMMEDIATELY on model selection —
@@ -3776,9 +4221,10 @@ export class ClientSession {
3776
4221
  * own per-session model: switching back to a RUNNING / completed chat must not
3777
4222
  * silently overwrite its model with the project default. So a fresh chat in the
3778
4223
  * project gets the remembered model; an in-progress one keeps what it had and
3779
- * the user switches via the picker. Silent on failure (model no longer in catalog). */
4224
+ * the user switches via the picker. Silent on failure (model no longer in catalog).
4225
+ * Fallback chain: project memory > GLOBAL default model > SDK default (no-op). */
3780
4226
  async restoreProjectModelForCwd(cwd) {
3781
- const savedModel = this.stateStore.getProjectModel(this.clientId, cwd);
4227
+ const savedModel = this.stateStore.getProjectModel(this.clientId, cwd) ?? this.stateStore.getDefaultModel();
3782
4228
  if (!savedModel)
3783
4229
  return;
3784
4230
  try {
@@ -3833,6 +4279,40 @@ export class ClientSession {
3833
4279
  }
3834
4280
  }
3835
4281
  }
4282
+ /** 把压缩软上限换算成各存活会话的 compaction reserveTokens 覆盖
4283
+ * (issue #229)。与重试覆盖同一 live 机制:applyOverrides 只改内存
4284
+ * 合并视图,SDK 每次自动压缩检查前都重读 getCompactionSettings(),
4285
+ * 无需 reload;软上限关闭时回填 SDK 默认 reserve(不让旧覆盖泄漏)。
4286
+ * 窗口未知(会话未就绪/无模型)的会话跳过——创建/就绪/换模型路径
4287
+ * 会重放(见各 applyRetryOverrides 调用点)。 */
4288
+ applyCompactionOverrides() {
4289
+ const s = this.settingsSvc.current;
4290
+ for (const c of this.convs.values()) {
4291
+ try {
4292
+ const modelId = modelKeyOf(c.session);
4293
+ const contextWindow = contextWindowOf(c.session);
4294
+ const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelId);
4295
+ const reserve = softCapToReserve(contextWindow, cap);
4296
+ c.session.settingsManager.applyOverrides({
4297
+ compaction: { reserveTokens: reserve ?? DEFAULT_COMPACTION_RESERVE_TOKENS },
4298
+ });
4299
+ }
4300
+ catch {
4301
+ // 会话未就绪或已释放 → 其 runtime 创建时统一注入。
4302
+ }
4303
+ }
4304
+ }
4305
+ /** 当前活动对话的生效软上限(快照底栏标记线用;null = 关闭/未知)。 */
4306
+ activeSoftCap(contextWindow) {
4307
+ try {
4308
+ const s = this.settingsSvc.current;
4309
+ const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelKeyOf(this.session));
4310
+ return softCapToReserve(contextWindow, cap) === null ? null : cap;
4311
+ }
4312
+ catch {
4313
+ return null;
4314
+ }
4315
+ }
3836
4316
  /** Extensions/skills changed externally (e.g. `pi remove` finished in the
3837
4317
  * terminal): re-run session.reload() and re-push state. Streaming-safe —
3838
4318
  * deferred to agent_end, same as settings reloads. */
@@ -3867,6 +4347,7 @@ export class ClientSession {
3867
4347
  try {
3868
4348
  await this.session.reload();
3869
4349
  this.applyRetryOverrides();
4350
+ this.applyCompactionOverrides();
3870
4351
  this.applyToolGating(this.session);
3871
4352
  await this.pushSlashCommands();
3872
4353
  this.pushSettings();
@@ -3929,8 +4410,14 @@ export class ClientSession {
3929
4410
  return (this.pluginToolsProvider?.() ?? []).filter((t) => !off.has(t.name)).map(pluginToolToDefinition);
3930
4411
  }
3931
4412
  /** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除;禁用工具同步移除)。
3932
- * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。 */
4413
+ * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。
4414
+ * 模板白名单的子代理(subagentBarsPluginTools)跳过:工厂期就没注册,这里
4415
+ * 不回补,否则白名单等于没关门。 */
3933
4416
  syncPluginTools(session) {
4417
+ for (const conv of this.convs.values()) {
4418
+ if (conv.session === session && conv.subagentBarsPluginTools)
4419
+ return;
4420
+ }
3934
4421
  try {
3935
4422
  const defs = this.enabledPluginToolDefs();
3936
4423
  const next = syncPluginToolsIntoSession(session, defs, this.appliedPluginToolNames);
@@ -4039,6 +4526,21 @@ export class ClientSession {
4039
4526
  return false;
4040
4527
  }
4041
4528
  }
4529
+ /** 某对话的转录最小结构(内存实时消息,含未落盘的;与 conversationReadHost
4530
+ * 的 readRunningConversation 同一取数逻辑 —— 并行提醒算触碰集时复用,
4531
+ * 不另起读取路径)。 */
4532
+ convTranscript(conv) {
4533
+ let raw = [];
4534
+ try {
4535
+ raw = (conv.session.messages ??
4536
+ conv.session.agent.state.messages ??
4537
+ []);
4538
+ }
4539
+ catch {
4540
+ raw = [];
4541
+ }
4542
+ return raw.map(toTranscriptInput);
4543
+ }
4042
4544
  /** issue #145:当前活动对话的 session 文件(resolved),无则 undefined。 */
4043
4545
  activeSessionFileResolved() {
4044
4546
  try {
@@ -4202,33 +4704,135 @@ export class ClientSession {
4202
4704
  // issue #145:同项目并行感知 —— 同一 cwd 下别处(或其他对话)正在跑时,
4203
4705
  // 允许并行(可以同时改不同部分),但用户与 AI 都必须知道。只在新一轮启动时
4204
4706
  // 通告一次(steer/排队等流式中发送不重复打扰)。
4205
- if (!conv.isSubagent && !s.isStreaming) {
4707
+ // parallelReminderEnabled=false 时整段跳过(不发 notice、不注 AI、不通知对端)。
4708
+ if (!conv.isSubagent && !s.isStreaming && this.settingsSvc.current.parallelReminderEnabled !== false) {
4709
+ // 认领心跳:本对话发 prompt = 还活着,自己名下的认领续期(同步内存操作)。
4710
+ try {
4711
+ this.getClaimStore?.().touch(conv.id);
4712
+ }
4713
+ catch {
4714
+ // ignore
4715
+ }
4716
+ let projectClaims = [];
4717
+ try {
4718
+ projectClaims = this.getClaimStore?.().list(conv.cwd) ?? [];
4719
+ }
4720
+ catch {
4721
+ projectClaims = [];
4722
+ }
4723
+ // 本窗口正在跑的:子代理也算进来(以前 !c.isSubagent 把它们排除在外,
4724
+ // 对方用子代理干活时 AI 完全收不到提示),标明归属。触碰集就地从内存
4725
+ // 消息算,无 I/O,不阻塞发送路径。
4206
4726
  const localRunners = [...this.convs.values()]
4207
- .filter((c) => c.id !== conv.id && !c.isSubagent && c.cwd === conv.cwd && this.conversationStreaming(c))
4208
- .map((c) => ({ title: c.title }));
4727
+ .filter((c) => c.id !== conv.id && c.cwd === conv.cwd && this.conversationStreaming(c))
4728
+ .map((c) => {
4729
+ let label;
4730
+ if (c.isSubagent) {
4731
+ const parent = c.parentId ? this.convs.get(c.parentId) : undefined;
4732
+ label = parent ? `本窗口「${parent.title}」的子代理「${c.title}」` : `本窗口子代理「${c.title}」`;
4733
+ }
4734
+ else {
4735
+ label = `本窗口「${c.title}」`;
4736
+ }
4737
+ return { label, touches: extractTouches(this.convTranscript(c)) };
4738
+ });
4209
4739
  const externalRunners = (this.listProjectRunners?.(conv.cwd) ?? []).filter((r) => r.sessionFile === undefined || (activeFile !== undefined && resolve(r.sessionFile) !== activeFile));
4210
- const runnerTitles = [
4211
- ...localRunners.map((r) => `本窗口「${r.title}」`),
4740
+ // 取舍(诚实降级):外部运行只有 title + sessionFile,触碰集要读对方
4741
+ // 转录文件 —— 同步文件 I/O 会阻塞发送路径,不做;提醒里如实写
4742
+ // 「外部运行的文件触碰未知」,不编造。
4743
+ // 每条 ≤60 字符(以前整串 slice(0, 600),经常从半截路径处拦腰截断)。
4744
+ const capItem = (st) => (st.length <= 60 ? st : `${st.slice(0, 59)}…`);
4745
+ const noticeTitles = [
4746
+ ...localRunners.map((r) => r.label),
4212
4747
  ...externalRunners.map((r) => `另一处「${r.title}」`),
4213
- ];
4214
- if (runnerTitles.length > 0) {
4215
- const shown = runnerTitles.slice(0, 3).join("、");
4216
- const more = runnerTitles.length > 3 ? `等 ${runnerTitles.length} 处` : "";
4748
+ ].map(capItem);
4749
+ const aiItems = [
4750
+ ...localRunners.map((r) => `${r.label}·${r.touches.length} files`),
4751
+ ...externalRunners.map((r) => `另一处「${r.title}」·touches unknown`),
4752
+ ].map(capItem);
4753
+ if (noticeTitles.length > 0) {
4754
+ const shown = noticeTitles.slice(0, 3).join("、");
4755
+ const more = noticeTitles.length > 3 ? `等 ${noticeTitles.length} 处` : "";
4217
4756
  this.emit({
4218
4757
  type: "notice",
4219
4758
  level: "info",
4220
4759
  text: `同项目并行提醒:${shown}${more}正在同一项目运行。你可以继续(适合改不同文件),改动同一文件前请先确认;拿不准就等它跑完。`,
4221
4760
  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.`,
4222
4761
  });
4223
- // 给 AI 的上下文:评估冲突概率,拿不准就 ask_user_question 让用户选
4224
- // (并行 / 等它跑完 / 只读围观)。display:false —— 用户界面只看上面的 notice。
4225
- const aiReminder = `(System reminder: ${runnerTitles.length} other run(s) [${runnerTitles.join("; ").slice(0, 600)}] ` +
4226
- `are currently running in the same project directory. You may work in parallel on different files, ` +
4227
- `but before reading/writing files or running commands, assess the conflict probability with the other run(s) ` +
4228
- `(same files? same commands? migrations?). If a conflict is likely or you are unsure, ` +
4229
- `use ask_user_question to let the user choose: continue in parallel / wait / watch read-only.)\n` +
4230
- `(系统提醒:同一项目另有 ${runnerTitles.length} 处运行(${shown}${more})。改不同文件可并行;` +
4231
- `读写文件或跑命令前先评估冲突概率,拿不准就用 ask_user_question 让用户选择:并行 / 等它跑完 / 只读围观。)`;
4762
+ // 给 AI 的上下文:交集由服务端算好写明“⚠ 双方都动过 X”,AI 不用自己
4763
+ // 算;拿不准就 ask_user_question 让用户选(并行 / 等它跑完 / 只读围观)。
4764
+ // display:false —— 用户界面只看上面的 notice。分两档:无交集只给一行
4765
+ // (省 token),有交集才展开细节。
4766
+ const mine = extractTouches(this.convTranscript(conv));
4767
+ // 认领升级(advisory,但比触碰更强:这是对方的事前意图):
4768
+ // 我动过 + 对方认领 → 最强信号单独点名;其他认领只给一行汇总。
4769
+ const othersClaims = projectClaims.filter((c) => c.ownerConvId !== conv.id);
4770
+ const myClaimed = matchClaims(mine, othersClaims.map((c) => ({
4771
+ path: c.path,
4772
+ ownerConvId: c.ownerConvId,
4773
+ ownerTitle: c.ownerTitle,
4774
+ claimedAt: 0,
4775
+ expiresAt: 0,
4776
+ })), conv.cwd);
4777
+ const claimHitEn = myClaimed
4778
+ .slice(0, 3)
4779
+ .map((h) => `${h.touch.path} (claimed by ${h.claim.ownerTitle})`)
4780
+ .join("; ");
4781
+ const claimHitZh = myClaimed
4782
+ .slice(0, 3)
4783
+ .map((h) => `${h.touch.path}(${h.claim.ownerTitle}已认领)`)
4784
+ .join("、");
4785
+ const claimMore = myClaimed.length > 3 ? ` (+${myClaimed.length - 3})` : "";
4786
+ const claimsSummaryEn = othersClaims.length > 0
4787
+ ? ` Claimed by others (steer clear): ${othersClaims
4788
+ .slice(0, 3)
4789
+ .map((c) => `${c.path} ("${c.ownerTitle}")`)
4790
+ .join("; ")}${othersClaims.length > 3 ? ` (+${othersClaims.length - 3})` : ""}.`
4791
+ : "";
4792
+ const claimsSummaryZh = othersClaims.length > 0
4793
+ ? ` 对方认领(绕行):${othersClaims
4794
+ .slice(0, 3)
4795
+ .map((c) => `${c.path}(「${c.ownerTitle}」)`)
4796
+ .join("、")}${othersClaims.length > 3 ? `(等 ${othersClaims.length - 3} 处)` : ""}。`
4797
+ : "";
4798
+ const clashes = localRunners
4799
+ .map((r) => ({ label: r.label, hits: intersectTouches(r.touches, mine) }))
4800
+ .filter((r) => r.hits.length > 0);
4801
+ const extNoteEn = externalRunners.length > 0 ? ` Touched files of external run(s) are unknown.` : "";
4802
+ const extNoteZh = externalRunners.length > 0 ? `外部运行的文件触碰未知。` : "";
4803
+ let aiReminder;
4804
+ if (clashes.length === 0 && myClaimed.length === 0) {
4805
+ aiReminder =
4806
+ `(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
4807
+ `are currently running in the same project directory. No file written by both you and them was detected, ` +
4808
+ `so working on different files in parallel is fine; before writing the same files or running project-wide ` +
4809
+ `commands, assess the conflict risk first, and use ask_user_question when unsure ` +
4810
+ `(continue in parallel / wait / watch read-only).${extNoteEn}${claimsSummaryEn})\n` +
4811
+ `(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。未发现双方都写过的文件,` +
4812
+ `改不同文件可并行;动同一文件或跑全局命令前先评估冲突,拿不准就用 ask_user_question 让用户选择:` +
4813
+ `并行 / 等它跑完 / 只读围观。${extNoteZh}${claimsSummaryZh})`;
4814
+ }
4815
+ else {
4816
+ // 有交集档:每处 ≤3 条完整路径 + 计数(路径永不截断,见 conversation-touches)。
4817
+ const clashPartsEn = clashes.map((h) => `${h.label} — you both wrote: ${formatTouchesCompact(h.hits)}`);
4818
+ if (myClaimed.length > 0) {
4819
+ clashPartsEn.push(`⚠ you touched and others claimed: ${claimHitEn}${claimMore} — ask the user before touching these again`);
4820
+ }
4821
+ const clashEn = clashPartsEn.join("; ");
4822
+ const clashPartsZh = clashes.map((h) => `⚠ ${h.label}双方都动过:${formatTouchesCompact(h.hits)}`);
4823
+ if (myClaimed.length > 0) {
4824
+ clashPartsZh.push(`⚠ 你动过、对方已认领:${claimHitZh}${claimMore} —— 动之前先问用户`);
4825
+ }
4826
+ const clashZh = clashPartsZh.join(";");
4827
+ aiReminder =
4828
+ `(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
4829
+ `are currently running in the same project directory. ⚠ ${clashEn} — re-read these files before ` +
4830
+ `touching them again, and use ask_user_question when unsure ` +
4831
+ `(continue in parallel / wait / watch read-only).${extNoteEn})\n` +
4832
+ `(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。` +
4833
+ `${clashZh} —— 再动这些文件前先读最新内容,拿不准就用 ask_user_question 让用户选择:` +
4834
+ `并行 / 等它跑完 / 只读围观。${extNoteZh})`;
4835
+ }
4232
4836
  try {
4233
4837
  await s.sendCustomMessage({
4234
4838
  customType: "parallel-work-reminder",
@@ -4655,11 +5259,17 @@ export class ClientSession {
4655
5259
  this.clearAllToolWatchdogs(conv);
4656
5260
  conv.toolStartTimes.clear();
4657
5261
  await conv.runtime.dispose();
4658
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
5262
+ // #235:转录链损坏时修一次再试(见 openManagerAndRuntime)。
5263
+ const opened = await this.openManagerAndRuntime(() => SessionManager.continueRecent(conv.cwd), (m) => createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
4659
5264
  cwd: conv.cwd,
4660
5265
  agentDir: this.agentDir,
4661
- sessionManager: SessionManager.continueRecent(conv.cwd),
4662
- });
5266
+ sessionManager: m,
5267
+ }), async () => (await SessionManager.list(conv.cwd))[0]?.path);
5268
+ const runtime = opened.runtime;
5269
+ if (opened.repair) {
5270
+ for (const n of this.transcriptRepairNotices(opened.repair))
5271
+ this.emit(n);
5272
+ }
4663
5273
  conv.runtime = runtime;
4664
5274
  conv.session = runtime.session;
4665
5275
  this.emit({
@@ -4814,9 +5424,11 @@ export class ClientSession {
4814
5424
  return;
4815
5425
  // parentId joins the live chain: a null-parent marker would become a
4816
5426
  // second root and hijack the leaf, corrupting the transcript.
5427
+ // #235: id 必须唯一——硬编码共享 id + SDK byId last-wins + 标记恰为末行
5428
+ // 时新消息 parent 记成共享 id = 下次 open 回溯成环,整个会话打不开。
4817
5429
  const marker = {
4818
5430
  type: "custom",
4819
- id: "pi-web-ui-compaction-pending",
5431
+ id: makeCompactionMarkerId("pending"),
4820
5432
  parentId: conv.session.sessionManager.getLeafId(),
4821
5433
  timestamp: new Date().toISOString(),
4822
5434
  customType: "pi-web-ui/compaction-pending",
@@ -4843,10 +5455,18 @@ export class ClientSession {
4843
5455
  const file = conv.session.sessionFile;
4844
5456
  if (!file || !existsSync(file))
4845
5457
  return;
4846
- const id = "pi-web-ui-compaction-pending";
4847
5458
  const raw = readFileSync(file, "utf8");
4848
5459
  const lines = raw.split("\n");
4849
- const idx = lines.findIndex((line) => line.includes(`"${id}"`));
5460
+ // #235:按 customType 定位(id 自本 fix 起唯一,老文件仍是硬编码 id,
5461
+ // 按 id 找已不可靠)。取最后一个:重叠压缩时它属于本次,旧残留留给
5462
+ // 下次 open 的 repair/notice 处理。
5463
+ let idx = -1;
5464
+ for (let i = lines.length - 1; i >= 0; i--) {
5465
+ if (lines[i].includes(`"${COMPACTION_PENDING_TYPE}"`)) {
5466
+ idx = i;
5467
+ break;
5468
+ }
5469
+ }
4850
5470
  if (idx < 0)
4851
5471
  return;
4852
5472
  // ponytail: full-file rewrite on compaction end — compactions are rare
@@ -4864,7 +5484,7 @@ export class ClientSession {
4864
5484
  }
4865
5485
  const done = {
4866
5486
  type: "custom",
4867
- id: "pi-web-ui-compaction-done",
5487
+ id: makeCompactionMarkerId("done"),
4868
5488
  parentId,
4869
5489
  timestamp: new Date().toISOString(),
4870
5490
  customType: "pi-web-ui/compaction-done",
@@ -4894,15 +5514,80 @@ export class ClientSession {
4894
5514
  return;
4895
5515
  const kept = raw.split("\n").filter((line) => !line.includes("pi-web-ui/compaction-pending"));
4896
5516
  writeFileSync(file, kept.join("\n"));
4897
- this.emit({
5517
+ this.emitCompactionInterruptedNotice();
5518
+ }
5519
+ catch {
5520
+ // Best-effort, same as the write path.
5521
+ }
5522
+ }
5523
+ emitCompactionInterruptedNotice() {
5524
+ this.emit({
5525
+ type: "notice",
5526
+ level: "warning",
5527
+ text: "上次压缩上下文被服务端重启打断,未完成。可发送 /compact 重试。",
5528
+ textEn: "The last context compaction was interrupted by a server restart and did not finish. Send /compact to retry.",
5529
+ });
5530
+ }
5531
+ /** #235 修复产生的提示(调用方决定 emit 还是进 pendingNotices)。 */
5532
+ transcriptRepairNotices(repair) {
5533
+ const out = [];
5534
+ if (repair.interrupted) {
5535
+ out.push({
4898
5536
  type: "notice",
4899
5537
  level: "warning",
4900
5538
  text: "上次压缩上下文被服务端重启打断,未完成。可发送 /compact 重试。",
4901
5539
  textEn: "The last context compaction was interrupted by a server restart and did not finish. Send /compact to retry.",
4902
5540
  });
4903
5541
  }
5542
+ if (repair.renamedIds > 0 || repair.rewiredParents > 0 || repair.cyclesBroken > 0) {
5543
+ out.push({
5544
+ type: "notice",
5545
+ level: "warning",
5546
+ text: `对话记录链损坏已自动修复(重复的压缩标记),原文件备份在 ${repair.backup ?? "同目录 .bak 文件"}。如内容异常可手动恢复。`,
5547
+ textEn: `The conversation transcript had a corrupted parent chain (duplicate compaction markers) and was auto-repaired. The original file is backed up at ${repair.backup ?? "a .bak file next to it"}; restore it manually if anything looks off.`,
5548
+ });
5549
+ }
5550
+ return out;
5551
+ }
5552
+ /**
5553
+ * #235:已知路径先修后开(openConversation 走这条——单文件预扫描零负担)。
5554
+ * 返回 null = 文件健康或无需处理;返回 repair = 修过,调用方弹提示。
5555
+ */
5556
+ repairTranscriptFileBeforeOpen(filePath) {
5557
+ let repair = null;
5558
+ try {
5559
+ repair = repairSessionFile(filePath);
5560
+ }
4904
5561
  catch {
4905
- // Best-effort, same as the write path.
5562
+ return null;
5563
+ }
5564
+ if (!repair?.changed)
5565
+ return null;
5566
+ for (const n of this.transcriptRepairNotices(repair))
5567
+ this.emit(n);
5568
+ return repair;
5569
+ }
5570
+ /**
5571
+ * #235:manager+runtime 一起建,链损坏报错则修最近文件后重试一次。
5572
+ * SessionManager.open 本身不走 parent 链(真正死循环的是 runtime 初始化里的
5573
+ * getBranch),所以重试必须把两步都包进来;修完用全新 manager 重读。
5574
+ */
5575
+ async openManagerAndRuntime(makeManager, makeRuntime, locateFile) {
5576
+ try {
5577
+ const manager = makeManager();
5578
+ const runtime = await makeRuntime(manager);
5579
+ return { manager, runtime, repair: null };
5580
+ }
5581
+ catch (err) {
5582
+ if (!looksLikeChainCorruption(err))
5583
+ throw err;
5584
+ const file = await locateFile().catch(() => undefined);
5585
+ const repair = file ? repairSessionFile(file) : null;
5586
+ if (!repair?.changed)
5587
+ throw err;
5588
+ const manager = makeManager();
5589
+ const runtime = await makeRuntime(manager);
5590
+ return { manager, runtime, repair };
4906
5591
  }
4907
5592
  }
4908
5593
  displaceActive() {
@@ -4959,6 +5644,14 @@ export class ClientSession {
4959
5644
  const conv = this.convs.get(id);
4960
5645
  if (!conv || id === this.activeId)
4961
5646
  return;
5647
+ // 对话真关闭(dismiss/释放)→ 放掉它的认领。过户不走这里(对话换个会话
5648
+ // 继续,owner 不变,认领继续有效),所以只在此处释放。
5649
+ try {
5650
+ this.getClaimStore?.().releaseByOwner(id);
5651
+ }
5652
+ catch {
5653
+ // ignore
5654
+ }
4962
5655
  this.convs.delete(id);
4963
5656
  this.clearAllToolWatchdogs(conv);
4964
5657
  conv.terminals.killAll();
@@ -4966,6 +5659,15 @@ export class ClientSession {
4966
5659
  conv.unsubscribe = undefined;
4967
5660
  void conv.runtime.dispose().catch(() => { });
4968
5661
  }
5662
+ /** 本会话是否持有这个 SDK 会话对应的对话;命中则给出它的**当前** id
5663
+ * (AgentService 按 runtime 身份解析过户后的归属方用,见 findConversationHome)。 */
5664
+ conversationIdOfSession(sdkSession) {
5665
+ for (const c of this.convs.values()) {
5666
+ if (c.session === sdkSession)
5667
+ return c.id;
5668
+ }
5669
+ return undefined;
5670
+ }
4969
5671
  /** 过户用的对话摘要(AgentService 拼移动集合 + 容量检查用)。 */
4970
5672
  takeoverBriefs() {
4971
5673
  return [...this.convs.values()].map((c) => ({
@@ -5426,6 +6128,8 @@ export class ClientSession {
5426
6128
  }
5427
6129
  }
5428
6130
  rmSync(abs, { force: true });
6131
+ // 转录删了,sidecar 再留着就是孤儿,一起清掉(不存在不报错)。
6132
+ removeTouchSidecar(abs);
5429
6133
  // 转录删了,未发送草稿再留着就是孤儿,一起清掉。
5430
6134
  try {
5431
6135
  this.drafts.pruneSessionFile(abs);
@@ -5997,6 +6701,9 @@ export class ClientSession {
5997
6701
  });
5998
6702
  }
5999
6703
  }
6704
+ // #235:先修后开——坏转录到 open 后的 getBranch 会死循环,修完再读。
6705
+ // 单文件预扫描,健康文件只多一次小读;修过即弹提示(含压缩被打断)。
6706
+ this.repairTranscriptFileBeforeOpen(targetPath);
6000
6707
  const sessionManager = SessionManager.open(targetPath);
6001
6708
  const targetCwd = sessionManager.getCwd();
6002
6709
  const conversationId = this.nextConversationId();
@@ -6301,6 +7008,79 @@ export class ClientSession {
6301
7008
  async scmQuery(kind, reqId, arg) {
6302
7009
  return this.files.scmQuery(kind, reqId, arg);
6303
7010
  }
7011
+ /**
7012
+ * SCM「AI 生成提交信息」:用当前对话模型做一次 completeSimple 一次性补全
7013
+ * (与视觉桥同一条通路)——不进对话上下文、不打断正在流式的回复。
7014
+ * 恰好应答一次:任何失败都以 ok:false 的 scm_data(kind "commitmsg")收尾,
7015
+ * 前端按钮不会卡在转圈。
7016
+ */
7017
+ async scmGenCommitMessage(reqId) {
7018
+ const lang = this.getLang();
7019
+ const cwd = this.conv?.cwd ?? this.cwd;
7020
+ const reply = (ok, extra) => {
7021
+ this.emit({ type: "scm_data", reqId, kind: "commitmsg", ok, ...extra });
7022
+ };
7023
+ const fail = (err) => {
7024
+ reply(false, { error: err instanceof Error ? err.message : String(err) });
7025
+ };
7026
+ try {
7027
+ const runtime = this.runtime.services.modelRuntime;
7028
+ const model = this.session?.model;
7029
+ if (!model) {
7030
+ throw new Error(pick(lang, "当前没有可用模型——先在顶栏选择一个模型再生成", "No model available — pick one in the top bar first", "scm.commitmsg.no.model"));
7031
+ }
7032
+ const ctx = await scmCommitContext(cwd, () => lang);
7033
+ const input = buildCommitMsgInput(ctx, lang === "zh" ? "zh" : "en");
7034
+ if (!input) {
7035
+ throw new Error(pick(lang, "没有可描述的更改(工作区干净)", "Nothing to describe (working tree clean)", "scm.commitmsg.no.changes"));
7036
+ }
7037
+ const ac = new AbortController();
7038
+ const timer = setTimeout(() => ac.abort(), SCM_COMMITMSG_TIMEOUT_MS);
7039
+ // 提示词可配置(设置 → 提示词 → AI 提交信息):追加/替换内置默认。
7040
+ const commitSettings = this.settingsSvc.current;
7041
+ const systemPrompt = buildCommitMsgPrompt(commitSettings.scmCommitMsgPromptMode === "replace" ? "replace" : "append", commitSettings.scmCommitMsgPrompt ?? "");
7042
+ let msg;
7043
+ try {
7044
+ msg = await runtime.completeSimple(model, {
7045
+ systemPrompt,
7046
+ messages: [
7047
+ {
7048
+ role: "user",
7049
+ timestamp: Date.now(),
7050
+ content: [{ type: "text", text: input }],
7051
+ },
7052
+ ],
7053
+ }, { signal: ac.signal, maxTokens: 400 });
7054
+ }
7055
+ finally {
7056
+ clearTimeout(timer);
7057
+ }
7058
+ if (msg.stopReason === "error" || msg.stopReason === "aborted") {
7059
+ throw new Error(msg.errorMessage ||
7060
+ pick(lang, `模型异常终止(${msg.stopReason})`, `Model terminated abnormally (${msg.stopReason})`, "scm.commitmsg.model.terminated"));
7061
+ }
7062
+ const raw = msg.content
7063
+ .filter((b) => b.type === "text")
7064
+ .map((b) => b.text ?? "")
7065
+ .join("\n");
7066
+ const text = sanitizeCommitMessage(raw);
7067
+ if (!text) {
7068
+ throw new Error(pick(lang, "模型返回了空的提交信息", "The model returned an empty commit message", "scm.commitmsg.empty"));
7069
+ }
7070
+ reply(true, { text });
7071
+ }
7072
+ catch (err) {
7073
+ if (isNotRepoError(err)) {
7074
+ fail(new Error(pick(lang, "当前目录不是 Git 仓库", "Current directory is not a Git repository", "scm.commitmsg.not.repo")));
7075
+ return;
7076
+ }
7077
+ if (err instanceof Error && /abort/i.test(`${err.name} ${err.message}`)) {
7078
+ fail(new Error(pick(lang, `生成提交信息超时(${Math.round(SCM_COMMITMSG_TIMEOUT_MS / 1000)} 秒)`, `Commit-message generation timed out (${Math.round(SCM_COMMITMSG_TIMEOUT_MS / 1000)}s)`, "scm.commitmsg.timeout")));
7079
+ return;
7080
+ }
7081
+ fail(err);
7082
+ }
7083
+ }
6304
7084
  /** Read a workspace file for the preview panel (size-capped, binary-safe). */
6305
7085
  async readFile(relPath) {
6306
7086
  return this.files.readFile(relPath);
@@ -6469,7 +7249,6 @@ export class ClientSession {
6469
7249
  // default-opening it would strand the tab on a conversation it
6470
7250
  // cannot use (the prompt guard refuses) with a stale leaf that
6471
7251
  // forks history once the owner finishes. Land blank instead.
6472
- let sessionManager = SessionManager.continueRecent(abs);
6473
7252
  let resumeSkipped = null;
6474
7253
  // 无人在跑时不扫目录(首访切项目的常见情形零开销)。
6475
7254
  if (this.hasStreamingElsewhere?.() ?? false) {
@@ -6478,7 +7257,6 @@ export class ClientSession {
6478
7257
  const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
6479
7258
  const owner = recent ? this.findSessionOwner?.(recent) : null;
6480
7259
  if (owner?.isStreaming) {
6481
- sessionManager = SessionManager.create(abs);
6482
7260
  resumeSkipped = owner;
6483
7261
  }
6484
7262
  }
@@ -6488,11 +7266,18 @@ export class ClientSession {
6488
7266
  }
6489
7267
  const conversationId = this.nextConversationId();
6490
7268
  const terminals = this.makeTerminalManager(conversationId, abs);
6491
- const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
7269
+ // #235:manager+runtime 一起建,转录链损坏时修最近文件后重试一次
7270
+ // (见 openManagerAndRuntime)。blank(别处在跑)是全新空会话,不会坏。
7271
+ const opened = await this.openManagerAndRuntime(() => (resumeSkipped ? SessionManager.create(abs) : SessionManager.continueRecent(abs)), (m) => createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
6492
7272
  cwd: abs,
6493
7273
  agentDir: this.agentDir,
6494
- sessionManager,
6495
- });
7274
+ sessionManager: m,
7275
+ }), async () => (await SessionManager.list(abs))[0]?.path);
7276
+ const newRuntime = opened.runtime;
7277
+ if (opened.repair) {
7278
+ for (const n of this.transcriptRepairNotices(opened.repair))
7279
+ this.emit(n);
7280
+ }
6496
7281
  const conv = this.makeConversation(newRuntime, conversationId, terminals);
6497
7282
  this.convs.set(conv.id, conv);
6498
7283
  this.activeId = conv.id;
@@ -6642,7 +7427,9 @@ export class ClientSession {
6642
7427
  return "";
6643
7428
  }
6644
7429
  }
6645
- /** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5"). */
7430
+ /** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5").
7431
+ * 失败时只发 notice 不抛错(UI 路径靠 notice 提示,见 cycleModel 等调用方)。
7432
+ * 需要「失败即拒绝」的无头路径(插件/定时任务)用 switchModelOrThrow。 */
6646
7433
  async setModel(modelId) {
6647
7434
  try {
6648
7435
  const mr = this.runtime.services.modelRuntime;
@@ -6661,6 +7448,8 @@ export class ClientSession {
6661
7448
  // project (not only after a turn). This is what makes project switching
6662
7449
  // restore both the model and the provider key.
6663
7450
  this.rememberProjectModel(modelId);
7451
+ // 换模型后按新模型的窗口重算软上限覆盖(按模型覆盖可能不同,issue #229)。
7452
+ this.applyCompactionOverrides();
6664
7453
  }
6665
7454
  catch (err) {
6666
7455
  this.emit({
@@ -6672,6 +7461,78 @@ export class ClientSession {
6672
7461
  }
6673
7462
  this.flushSnapshot();
6674
7463
  }
7464
+ /** Set the GLOBAL default model ("provider/id"): projects with no memory
7465
+ * fall back to it (project memory wins). Also remembers the provider's
7466
+ * currently-active key globally so new projects restore the same {model,
7467
+ * key} pair. Shared across clients, persisted server-side. */
7468
+ async setDefaultModel(modelId) {
7469
+ try {
7470
+ const mr = this.runtime.services.modelRuntime;
7471
+ const slash = modelId.indexOf("/");
7472
+ if (slash <= 0 || slash === modelId.length - 1) {
7473
+ throw new Error(`无效的模型 ID:${modelId}`);
7474
+ }
7475
+ const provider = modelId.slice(0, slash);
7476
+ const id = modelId.slice(slash + 1);
7477
+ if (!mr.getModel(provider, id))
7478
+ throw new Error(`模型不存在:${modelId}`);
7479
+ this.stateStore.saveDefaultModel(modelId);
7480
+ const active = this.modelAdmin.getActiveKeyName(provider);
7481
+ if (active)
7482
+ this.stateStore.saveDefaultProviderKey(provider, active);
7483
+ this.pushDefaultModel();
7484
+ this.emit({
7485
+ type: "notice",
7486
+ level: "info",
7487
+ text: `🌍 已设全局默认模型 ${modelId}(新项目自动使用,项目记忆优先)`,
7488
+ textEn: `🌍 Global default model set to ${modelId} (new projects follow it; project memory wins)`,
7489
+ });
7490
+ }
7491
+ catch (err) {
7492
+ this.emit({
7493
+ type: "notice",
7494
+ level: "error",
7495
+ text: `设置全局默认模型失败:${err.message}`,
7496
+ textEn: `Failed to set global default model: ${err.message}`,
7497
+ });
7498
+ }
7499
+ this.flushSnapshot();
7500
+ }
7501
+ /** Clear the GLOBAL default model (new projects fall back to the SDK default). */
7502
+ clearDefaultModel() {
7503
+ this.stateStore.clearDefaultModel();
7504
+ this.pushDefaultModel();
7505
+ this.emit({
7506
+ type: "notice",
7507
+ level: "info",
7508
+ text: "🌍 已清除全局默认模型(新项目回到 SDK 默认)",
7509
+ textEn: "🌍 Global default model cleared (new projects use the SDK default)",
7510
+ });
7511
+ this.flushSnapshot();
7512
+ }
7513
+ /** Push the current global default model (attach + after every change). */
7514
+ pushDefaultModel() {
7515
+ this.emit({ type: "default_model", modelId: this.stateStore.getDefaultModel() ?? null });
7516
+ }
7517
+ /** 切换模型,失败时抛出(无头路径专用:插件 host.chat / 定时任务)。
7518
+ * setModel 为兼容 UI 把异常吞成 notice(面板要能看到原因、调用方是 fire-and-forget),
7519
+ * 无头路径拿不到那个 notice,于是「模型 ID 打错/没配密钥」会变成静默按旧模型跑 ——
7520
+ * 账单与效果都和用户预期不符。这里统一改成响亮失败。 */
7521
+ async switchModelOrThrow(modelId) {
7522
+ await this.setModel(modelId);
7523
+ // 复核结果:读不到(无活跃对话)不阻断,读得到且不符才拒绝。
7524
+ // 注意 session 是 getter,无活跃对话时会抛,不能用 `?.` 兜底。
7525
+ let curId = "";
7526
+ try {
7527
+ const cur = this.session?.model;
7528
+ curId = cur ? `${cur.provider}/${cur.id}` : "";
7529
+ }
7530
+ catch {
7531
+ curId = "";
7532
+ }
7533
+ if (curId && curId !== modelId)
7534
+ throw new Error(`切换模型失败(${modelId}),当前仍是 ${curId} —— 请检查模型 ID 与供应商密钥`);
7535
+ }
6675
7536
  /** Set the thinking level for future turns. */
6676
7537
  setThinking(level) {
6677
7538
  try {
@@ -6762,6 +7623,42 @@ export class ClientSession {
6762
7623
  }
6763
7624
  }
6764
7625
  }
7626
+ /** issue #226:插件无头调用的工作目录校验(纯函数,可单测)。
7627
+ * 存在性语义与定时任务一致(须存在且为目录,不默默跑错目录);另在
7628
+ * Windows 下拒绝 SystemRoot 及其子树(如 C:\Windows\System32)——后台服务/
7629
+ * 快捷方式启动时宿主 cwd 常飘到 system32,直接跑就是高危误操作。 */
7630
+ export function checkPluginCwd(cwd) {
7631
+ const trimmed = String(cwd ?? "").trim();
7632
+ if (!trimmed)
7633
+ return { ok: false, error: "工作目录为空" };
7634
+ let abs;
7635
+ try {
7636
+ abs =
7637
+ process.platform === "win32" && /^[A-Za-z]:$/.test(trimmed) ? `${trimmed.toUpperCase()}${sep}` : resolve(trimmed);
7638
+ }
7639
+ catch {
7640
+ return { ok: false, error: `工作目录非法:${trimmed}` };
7641
+ }
7642
+ try {
7643
+ if (!statSync(abs).isDirectory())
7644
+ throw new Error("not-a-dir");
7645
+ }
7646
+ catch {
7647
+ return { ok: false, error: `目标项目不存在或不是目录:${trimmed}` };
7648
+ }
7649
+ if (process.platform === "win32") {
7650
+ const sysRoot = (process.env.SystemRoot || process.env.windir || "C:\\Windows")
7651
+ .replace(/\//g, "\\")
7652
+ .replace(/\\+$/, "");
7653
+ const norm = abs.replace(/\//g, "\\").replace(/\\+$/, "");
7654
+ const low = norm.toLowerCase();
7655
+ const rootLow = sysRoot.toLowerCase();
7656
+ if (low === rootLow || low.startsWith(`${rootLow}\\`)) {
7657
+ return { ok: false, error: `拒绝在系统目录执行:${abs}(请在插件设置里指定项目工作目录)` };
7658
+ }
7659
+ }
7660
+ return { ok: true, abs };
7661
+ }
6765
7662
  export class AgentService {
6766
7663
  cwd;
6767
7664
  /** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
@@ -6781,6 +7678,8 @@ export class AgentService {
6781
7678
  /** index.ts 注入:内置调度存储(attach 时拷贝到每个新会话,供 schedule_* 工具)。 */
6782
7679
  schedulerStore = undefined;
6783
7680
  clients = new Map();
7681
+ /** 全局认领表(跨浏览器标签页共享;<dataDir>/claims.json,best-effort 持久化)。 */
7682
+ claimStore;
6784
7683
  /** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
6785
7684
  * session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
6786
7685
  * once existing runs finish. Controlled via the local control socket:
@@ -6801,6 +7700,7 @@ export class AgentService {
6801
7700
  constructor(cwd, stateFile) {
6802
7701
  this.cwd = cwd;
6803
7702
  this.stateStore = new ClientStateStore(stateFile);
7703
+ this.claimStore = new ClaimStore(join(this.stateStore.dataDir, "claims.json"));
6804
7704
  }
6805
7705
  /** Get or create the session for a client, racing attach calls safely. */
6806
7706
  /** True while the service is draining — new work is refused. */
@@ -6875,30 +7775,165 @@ export class AgentService {
6875
7775
  }
6876
7776
  /** issue #193:定时任务唤醒发起对话。逐个客户端找持有方,用 steer 语义投递
6877
7777
  * (运行时插队、未跑时普通投递,不切用户当前对话);都找不到回 ok:false,
6878
- * 调用方(index.ts executor)回落无头执行。quiesced 时直接拒绝。 */
6879
- async wakeConversation(id, text) {
6880
- if (!id || !text.trim())
7778
+ * 调用方(index.ts executor)回落视口/无头执行。quiesced 时直接拒绝。
7779
+ * issue #226:成功时带回持有方 clientId(插件绑定网页会话时原样回执)。
7780
+ * issue #231:opts.sessionFile 是跨压缩/重启的稳定键 —— 优先按它认同一会话
7781
+ * (内存对话 id 重启即失效,压缩后同文件对话可能已换新 id,成功时带回**实际**
7782
+ * 投递的 conversationId + sessionFile,调用方据此重绑定任务);id 相位带 cwd
7783
+ * 护栏(各客户端计数器都从 c1 开始,不校验会把报告投进无关项目)。压缩进行中
7784
+ * 的持有方回 busy:true(调用方另寻视口兄弟,而不是当成“不在”静默转无头)。 */
7785
+ async wakeConversation(id, text, opts) {
7786
+ const wantFile = String(opts?.sessionFile ?? "").trim();
7787
+ const wantCwd = String(opts?.cwd ?? "").trim();
7788
+ if ((!id && !wantFile) || !text.trim())
6881
7789
  return { ok: false, error: "唤醒目标或文本为空" };
6882
7790
  if (this.quiesced)
6883
7791
  return { ok: false, error: "服务器正忙(quiesced),请稍后重试" };
6884
- for (const cs of this.clients.values()) {
7792
+ const liveFile = (c) => {
6885
7793
  try {
6886
- const r = await cs.steerOwnConversation(id, text);
6887
- if (r) {
7794
+ return String(c.session.sessionFile ?? "");
7795
+ }
7796
+ catch {
7797
+ return "";
7798
+ }
7799
+ };
7800
+ const flush = (cs) => {
7801
+ try {
7802
+ cs.flushSnapshot();
7803
+ }
7804
+ catch {
7805
+ // 推送失败不影响已投递的唤醒
7806
+ }
7807
+ };
7808
+ // 相位一:落盘会话文件(稳定键)。同文件可能在多处打开,取最近活跃者;
7809
+ // 全部忙(压缩中)则报 busy,调用方去找视口兄弟。
7810
+ if (wantFile) {
7811
+ const hits = [];
7812
+ for (const [clientId, cs] of this.clients) {
7813
+ try {
7814
+ const conv = cs.resolveSchedulerTarget({ sessionFile: wantFile });
7815
+ if (conv)
7816
+ hits.push({ cs, clientId, conv });
7817
+ }
7818
+ catch {
7819
+ // 单客户端坏了继续找下一个
7820
+ }
7821
+ }
7822
+ hits.sort((a, b) => b.conv.lastActiveAt - a.conv.lastActiveAt);
7823
+ let busyError;
7824
+ for (const h of hits) {
7825
+ try {
7826
+ const r = await h.cs.trySteerScheduler(h.conv, text);
7827
+ if (r.ok) {
7828
+ flush(h.cs);
7829
+ return { ok: true, conversationId: h.conv.id, sessionFile: liveFile(h.conv), clientId: h.clientId };
7830
+ }
7831
+ if (r.busy)
7832
+ busyError = r.error;
7833
+ // 非忙失败(投递异常)试下一个同文件持有方
7834
+ }
7835
+ catch {
7836
+ // 单客户端坏了继续找下一个
7837
+ }
7838
+ }
7839
+ if (hits.length > 0) {
7840
+ if (busyError)
7841
+ return { ok: false, busy: true, error: busyError };
7842
+ // 同文件持有方都在但都投递失败 —— id 相位大概率指向同一批,无需再试
7843
+ return { ok: false, error: "目标对话投递失败(持有方异常)" };
7844
+ }
7845
+ // 无同文件持有方 —— 老任务只有 id,继续相位二
7846
+ }
7847
+ // 相位二:内存对话 id(易失键,必须配 cwd 护栏防跨项目串台)。
7848
+ if (id) {
7849
+ const hits = [];
7850
+ for (const [clientId, cs] of this.clients) {
7851
+ try {
7852
+ const conv = cs.resolveSchedulerTarget({
7853
+ conversationId: id,
7854
+ ...(wantCwd ? { cwd: wantCwd } : {}),
7855
+ });
7856
+ if (conv)
7857
+ hits.push({ cs, clientId, conv });
7858
+ }
7859
+ catch {
7860
+ // 单客户端坏了继续找下一个
7861
+ }
7862
+ }
7863
+ hits.sort((a, b) => b.conv.lastActiveAt - a.conv.lastActiveAt);
7864
+ let busyError;
7865
+ for (const h of hits) {
7866
+ try {
7867
+ const r = await h.cs.trySteerScheduler(h.conv, text);
7868
+ if (r.ok) {
7869
+ flush(h.cs);
7870
+ return { ok: true, conversationId: h.conv.id, sessionFile: liveFile(h.conv), clientId: h.clientId };
7871
+ }
7872
+ if (r.busy)
7873
+ busyError = r.error;
7874
+ }
7875
+ catch {
7876
+ // 单客户端坏了继续找下一个
7877
+ }
7878
+ }
7879
+ if (busyError)
7880
+ return { ok: false, busy: true, error: busyError };
7881
+ }
7882
+ return { ok: false, error: "目标对话不在运行中(已关闭或服务重启过)" };
7883
+ }
7884
+ /** issue #231:同项目视口回退 —— 原绑定对话不在时,把唤醒投给该项目最近活跃
7885
+ * 的对话(用户当前正看着的面),而不是静默转无头。excludeIds 跳过已知忙对话;
7886
+ * 候选全部忙回 busy:true;无候选回 ok:false。成功带回实际投递方(调用方重绑定)。 */
7887
+ async wakeViewportInCwd(cwd, text, excludeIds) {
7888
+ const want = String(cwd ?? "").trim();
7889
+ if (!want || !text.trim())
7890
+ return { ok: false, error: "回退目标或文本为空" };
7891
+ if (this.quiesced)
7892
+ return { ok: false, error: "服务器正忙(quiesced),请稍后重试" };
7893
+ const cands = [];
7894
+ for (const [clientId, cs] of this.clients) {
7895
+ try {
7896
+ const conv = cs.findViewportInCwd(want, excludeIds);
7897
+ if (conv)
7898
+ cands.push({ cs, clientId, conv });
7899
+ }
7900
+ catch {
7901
+ // 单客户端坏了继续找下一个
7902
+ }
7903
+ }
7904
+ cands.sort((a, b) => b.conv.lastActiveAt - a.conv.lastActiveAt);
7905
+ if (cands.length === 0)
7906
+ return { ok: false, error: "同项目无存活对话" };
7907
+ let busyError;
7908
+ for (const c of cands) {
7909
+ try {
7910
+ const r = await c.cs.trySteerScheduler(c.conv, text);
7911
+ if (r.ok) {
6888
7912
  try {
6889
- cs.flushSnapshot();
7913
+ c.cs.flushSnapshot();
6890
7914
  }
6891
7915
  catch {
6892
7916
  // 推送失败不影响已投递的唤醒
6893
7917
  }
6894
- return { ok: true, conversationId: id };
7918
+ let f = "";
7919
+ try {
7920
+ f = String(c.conv.session.sessionFile ?? "");
7921
+ }
7922
+ catch {
7923
+ f = "";
7924
+ }
7925
+ return { ok: true, conversationId: c.conv.id, sessionFile: f, clientId: c.clientId };
6895
7926
  }
7927
+ if (r.busy)
7928
+ busyError = r.error;
6896
7929
  }
6897
7930
  catch {
6898
7931
  // 单客户端坏了继续找下一个
6899
7932
  }
6900
7933
  }
6901
- return { ok: false, error: "目标对话不在运行中(已关闭或服务重启过)" };
7934
+ if (busyError)
7935
+ return { ok: false, busy: true, error: busyError };
7936
+ return { ok: false, error: "同项目对话投递失败" };
6902
7937
  }
6903
7938
  /** issue #145:别处在某 cwd 下正在跑的对话(同项目并行感知用,不含请求方)。 */
6904
7939
  listProjectRunners(cwd, excludeClientId) {
@@ -6911,6 +7946,23 @@ export class AgentService {
6911
7946
  }
6912
7947
  return out;
6913
7948
  }
7949
+ /** 按 SDK 会话(runtime 身份)找它当前归属的客户端会话与对话 id(过户后归属会变):
7950
+ * 桥接工具(问卷 / 页面)在调用瞬间用它投递,见 ClientSession.bridgeTarget。
7951
+ * 一条对话任一时刻只属于一个会话(过户先摘后插),扫一遍即可 —— 问卷/截图都是
7952
+ * 低频调用,不值得为此再维护一张全局索引。 */
7953
+ findConversationHome(sdkSession) {
7954
+ for (const cs of this.clients.values()) {
7955
+ try {
7956
+ const convId = cs.conversationIdOfSession(sdkSession);
7957
+ if (convId)
7958
+ return { session: cs, convId };
7959
+ }
7960
+ catch {
7961
+ // 单客户端坏了不影响解析
7962
+ }
7963
+ }
7964
+ return undefined;
7965
+ }
6914
7966
  /** issue #145:别处所有正在跑的对话(左栏 elsewhere 只读感知 + 手动过户用)。
6915
7967
  * owner/convId 标识过户目标(手动过户入口);DSH 引擎不填(不可过户)。 */
6916
7968
  listExternalRunning(excludeClientId) {
@@ -6985,7 +8037,11 @@ export class AgentService {
6985
8037
  * 快照/notice 发了即丢,不攒内存。fire-and-forget:prompt 投递即返回,
6986
8038
  * 运行结果经 onRunEvent(run_end) 按 conversationId 关联。
6987
8039
  * v1 语义:与该服务 cwd 下最近会话共享(单用户视角连续);peer 名由插件
6988
- * 拼进文本前缀,per-peer 会话隔离以后再加。 */
8040
+ * 拼进文本前缀,per-peer 会话隔离以后再加。
8041
+ * issue #226:对齐定时任务的四件套——conversationId 命中时走 steer 语义
8042
+ * 投递(网页端实时可见,miss 则回落无头);cwd 显式 pin 住(不存在/系统
8043
+ * 目录即拒绝,不默默跑错目录);model/thinkingLevel 投递前应用(失败即
8044
+ * 拒绝,不回落,避免账单/效果与预期不符)。 */
6989
8045
  async chatFromPlugin(pluginId, req) {
6990
8046
  const safe = String(pluginId ?? "plugin").replace(/[^A-Za-z0-9_-]/g, "") || "plugin";
6991
8047
  const acct = String(req?.accountId ?? "default").replace(/[^A-Za-z0-9_-]/g, "") || "default";
@@ -6995,10 +8051,52 @@ export class AgentService {
6995
8051
  throw new Error("chatFromPlugin: text 为空");
6996
8052
  if (this.quiesced)
6997
8053
  throw new QuiesceRejectedError("插件无头调用被拒绝,请等服务器恢复后重试");
8054
+ // 1. 绑定已有会话:steer 语义投递,网页端实时可见(微信当远程遥控器用)。
8055
+ // miss/已回收时不抛错,回落无头伪客户端(浏览器关着时微信照常可用)。
8056
+ const target = String(req?.conversationId ?? "").trim();
8057
+ if (target) {
8058
+ const w = await this.wakeConversation(target, text);
8059
+ if (w.ok)
8060
+ return { conversationId: target, clientId: w.clientId ?? clientId };
8061
+ }
8062
+ // 2. 工作空间:不传回落伪客户端当前目录;传了必须存在且非系统目录。
8063
+ const cwdReq = String(req?.cwd ?? "").trim();
8064
+ let cwdAbs = "";
8065
+ if (cwdReq) {
8066
+ const chk = checkPluginCwd(cwdReq);
8067
+ if (!chk.ok)
8068
+ throw new Error(`chatFromPlugin: ${chk.error}`);
8069
+ cwdAbs = chk.abs ?? "";
8070
+ }
6998
8071
  const cs = await this.attach(clientId, () => { });
6999
- const before = cs.readConversationForPlugins()?.conversationId ?? "";
8072
+ try {
8073
+ if (cwdAbs && cs.cwd !== cwdAbs)
8074
+ await cs.setCwd(cwdAbs);
8075
+ }
8076
+ catch (err) {
8077
+ throw new Error(`chatFromPlugin: 切换工作目录失败(${cwdAbs}):${err.message}`);
8078
+ }
8079
+ const model = String(req?.model ?? "").trim();
8080
+ if (model) {
8081
+ try {
8082
+ await cs.switchModelOrThrow(model);
8083
+ }
8084
+ catch (err) {
8085
+ throw new Error(`chatFromPlugin: 切换模型失败(${model}):${err.message}`);
8086
+ }
8087
+ }
8088
+ const thinking = String(req?.thinkingLevel ?? "").trim();
8089
+ if (thinking) {
8090
+ try {
8091
+ cs.setThinking(thinking);
8092
+ }
8093
+ catch (err) {
8094
+ throw new Error(`chatFromPlugin: 切换思考强度失败(${thinking}):${err.message}`);
8095
+ }
8096
+ }
8097
+ const conversationId = cs.readConversationForPlugins()?.conversationId ?? "";
7000
8098
  void cs.prompt(text);
7001
- return { conversationId: before, clientId };
8099
+ return { conversationId, clientId };
7002
8100
  }
7003
8101
  /** 内置定时任务的无头执行(issue #184,server/scheduler-tasks.ts 的 executor)。
7004
8102
  * 每个任务独立伪客户端 `scheduler:<taskId>`(专属会话连续、无浏览器也能跑);
@@ -7030,7 +8128,7 @@ export class AgentService {
7030
8128
  const model = String(task.model ?? "").trim();
7031
8129
  if (model) {
7032
8130
  try {
7033
- await cs.setModel(model);
8131
+ await cs.switchModelOrThrow(model);
7034
8132
  }
7035
8133
  catch (err) {
7036
8134
  return { ok: false, error: `切换模型失败(${model}):${err.message}` };
@@ -7194,6 +8292,8 @@ export class AgentService {
7194
8292
  cs.findSessionOwner = (targetPath) => this.findSessionOwner(targetPath, clientId);
7195
8293
  cs.hasStreamingElsewhere = () => this.hasStreamingElsewhere(clientId);
7196
8294
  cs.listProjectRunners = (cwd) => this.listProjectRunners(cwd, clientId);
8295
+ cs.getClaimStore = () => this.claimStore;
8296
+ cs.findConversationHome = (sdkSession) => this.findConversationHome(sdkSession);
7197
8297
  cs.listExternalRunning = () => this.listExternalRunning(clientId);
7198
8298
  cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
7199
8299
  cs.onRunningChanged = () => this.pokeExternalRunning(clientId);