pi-web-ui 0.90.1 → 0.91.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 (33) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/server/agent-service.js +763 -61
  3. package/dist/server/client-state.js +70 -5
  4. package/dist/server/compaction-markers.js +287 -0
  5. package/dist/server/delegate-task.js +12 -1
  6. package/dist/server/dsh/dsh-agent-service.js +21 -1
  7. package/dist/server/file-transfer-routes.js +4 -1
  8. package/dist/server/icon-svg.js +35 -0
  9. package/dist/server/index.js +120 -30
  10. package/dist/server/plugin-catalog.js +5 -0
  11. package/dist/server/plugin-facilities.js +42 -11
  12. package/dist/server/plugins.js +47 -8
  13. package/dist/server/prompt-composer.js +1 -1
  14. package/dist/server/protocol-version.js +1 -1
  15. package/dist/server/schedule-agent-tool.js +23 -5
  16. package/dist/server/scheduler-tasks.js +60 -1
  17. package/dist/server/scm-commitmsg.js +140 -0
  18. package/dist/server/scm.js +42 -0
  19. package/dist/server/settings-service.js +40 -0
  20. package/dist/server/skill-tool.js +120 -0
  21. package/dist/server/soft-cap.js +68 -0
  22. package/dist/server/subagent-templates.js +6 -0
  23. package/dist/server/subagents.js +157 -16
  24. package/dist/server/tool-manager.js +7 -1
  25. package/package.json +2 -1
  26. package/plugins/catalog.json +10 -0
  27. package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-GtDQ1h85.js} +1 -1
  28. package/web/dist/assets/es-DIDZppIF.js +2 -0
  29. package/web/dist/assets/index-CdsKltOS.css +1 -0
  30. package/web/dist/assets/index-OoULNF70.js +363 -0
  31. package/web/dist/index.html +2 -2
  32. package/web/dist/assets/index-DJIV4A-t.js +0 -355
  33. 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";
@@ -41,14 +42,19 @@ import { ComposerDraftsStore } from "./composer-drafts.js";
41
42
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
42
43
  import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isTerminalGuidanceOn, MARKERS_LIST_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";
46
48
  import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
47
49
  import { makeDelegateTaskTool } from "./delegate-task.js";
48
50
  import { makeConversationReadTool, parseTranscriptLines, toTranscriptInput, } from "./conversation-read-tool.js";
51
+ import { makeSkillTool } from "./skill-tool.js";
49
52
  import { makeScheduleTools } from "./schedule-agent-tool.js";
53
+ import { sameSessionFile } from "./scheduler-tasks.js";
50
54
  import { buildAttachmentMessages, parseModelSpec } from "./attachments.js";
51
55
  import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
56
+ import { isNotRepoError, scmCommitContext } from "./scm.js";
57
+ import { buildCommitMsgInput, buildCommitMsgPrompt, sanitizeCommitMessage } from "./scm-commitmsg.js";
52
58
  import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
53
59
  import { launchOrigin, toServiceInfo } from "./launch-origin.js";
54
60
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
@@ -68,6 +74,8 @@ const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
68
74
  /** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
69
75
  const DELTA_ACTIVE_WINDOW_MS = 1500;
70
76
  const WIDGET_REFRESH_MS = 2000;
77
+ /** SCM「AI 生成提交信息」的单次补全超时——慢供应商不该让按钮转圈到天荒地老。 */
78
+ const SCM_COMMITMSG_TIMEOUT_MS = 60_000;
71
79
  /** Model-stall watchdog: warn (don't abort — deep thinking can be legitimately
72
80
  * quiet for minutes) when a streaming run produced NO SDK events for this long.
73
81
  * Covers the failure class the per-tool watchdog cannot see: half-open API
@@ -648,6 +656,13 @@ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
648
656
  * runtime alive; conversations of other projects keep their own lists).
649
657
  * 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
650
658
  const MAX_OPEN_CONVERSATIONS = 8;
659
+ /** 同时存活的子代理上限(按客户端计,含嵌套派生的孙子辈)。每个子代理都是一个完整
660
+ * runtime + TerminalManager,无上限时 AI 一次并行派发几十个会把服务进程拖垮。
661
+ * 主对话的 8 个上限是按项目计的,子代理按客户端全局计(wait_all 本来就是全局口径)。 */
662
+ const MAX_SUBAGENTS = 16;
663
+ /** SubagentSnapshot.prompt 下发上限:存的是全量 prompt,快照里只带前 N 字符,
664
+ * 避免 subagent_list 一次把几个长 prompt 全推给模型烧 token。 */
665
+ const SUBAGENT_PROMPT_SNAPSHOT_CAP = 2000;
651
666
  const DEFAULT_CONV_TITLE = "新对话";
652
667
  /** First user text in a session, truncated for the conversation list. */
653
668
  function conversationTitle(session) {
@@ -784,6 +799,26 @@ export function isInsideSessionsDir(agentDir, targetPath) {
784
799
  const sessionsRoot = resolve(agentDir, "sessions");
785
800
  return abs.startsWith(sessionsRoot + sep);
786
801
  }
802
+ /** 会话当前模型的 "provider/id"(无模型时 null;软上限按模型覆盖用,issue #229)。 */
803
+ function modelKeyOf(session) {
804
+ const m = session?.model;
805
+ if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
806
+ return null;
807
+ return `${m.provider}/${m.id}`;
808
+ }
809
+ /** 会话当前模型的上下文窗口(未知时 0):live 统计优先,模型定义回落。 */
810
+ function contextWindowOf(session) {
811
+ try {
812
+ const live = session?.getSessionStats?.()?.contextUsage?.contextWindow;
813
+ if (typeof live === "number" && live > 0)
814
+ return Math.floor(live);
815
+ }
816
+ catch {
817
+ // 会话未就绪 → 回落模型定义。
818
+ }
819
+ const def = session?.model?.contextWindow;
820
+ return typeof def === "number" && def > 0 ? Math.floor(def) : 0;
821
+ }
787
822
  /**
788
823
  * 选一个断开的残留会话给新标签认领(纯函数):
789
824
  * - 还有别的在线浏览器(非伪客户端且 live)→ 不认领(新标签是第二块屏,
@@ -804,6 +839,23 @@ export function pickAdoptableOrphan(cands) {
804
839
  }
805
840
  return best?.id ?? null;
806
841
  }
842
+ /** 同项目判定(纯函数):调度视口回退与 id 唤醒的 cwd 护栏用。
843
+ * Windows 大小写/分隔符差异归一,空串永不相等。 */
844
+ export function sameCwd(a, b) {
845
+ const x = String(a ?? "").trim();
846
+ const y = String(b ?? "").trim();
847
+ if (!x || !y)
848
+ return false;
849
+ const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
850
+ if (norm(x) === norm(y))
851
+ return true;
852
+ try {
853
+ return norm(resolve(x)) === norm(resolve(y));
854
+ }
855
+ catch {
856
+ return false;
857
+ }
858
+ }
807
859
  export class ClientSession {
808
860
  clientId;
809
861
  /** Set by AgentService.attach: reflects the SERVICE-wide quiesce flag
@@ -986,12 +1038,27 @@ export class ClientSession {
986
1038
  * 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
987
1039
  * runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
988
1040
  async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
1041
+ // 数量上限先行:每个子代理都是完整 runtime + TerminalManager,无上限时一次
1042
+ // 并行派发几十个会把服务进程拖垮。抛错由 subagent_spawn / delegate_task 转成
1043
+ // 返回文本,AI 读到后可改串行 / 等待收口后重试,而不是看到工具异常。
1044
+ const liveSubagents = [...this.convs.values()].filter((c) => c.isSubagent).length;
1045
+ if (liveSubagents >= MAX_SUBAGENTS) {
1046
+ throw new Error(pick(this.getLang(), `子代理数量已达上限(${MAX_SUBAGENTS} 个),请先用 subagent_wait_all 等一部分完成、或用 subagent_stop 停掉不需要的再派发`, `Subagent limit reached (${MAX_SUBAGENTS} live). Wait for some with subagent_wait_all or stop unneeded ones with subagent_stop before spawning more`, "agent.subagent.limit.reached", { limit: MAX_SUBAGENTS }));
1047
+ }
1048
+ // 真正的派发者(withSubagentOwner 按 runtime 归属填入):cwd 基准 / 跟随模型 /
1049
+ // 跟随思考强度一律读它,而不是派发瞬间的 active——后台对话产出时用户可能正
1050
+ // 看着别的项目,读 active 会跟错模型、把相对 cwd 解析到错误的项目下。
1051
+ const spawner = parentId ? this.convs.get(parentId) : undefined;
1052
+ const spawnerSession = spawner?.session ?? this.session;
1053
+ const baseCwd = spawner?.cwd ?? this.cwd;
1054
+ // 相对 cwd 按派发者所在目录解析:直接透传会相对 server 进程 cwd 落到别处。
1055
+ const resolvedCwd = cwd ? resolve(baseCwd, cwd) : baseCwd;
989
1056
  const conversationId = `sa-${randomUUID().slice(0, 8)}`;
990
- const terminals = this.makeTerminalManager(conversationId, cwd);
1057
+ const terminals = this.makeTerminalManager(conversationId, resolvedCwd);
991
1058
  const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
992
- cwd,
1059
+ cwd: resolvedCwd,
993
1060
  agentDir: this.agentDir,
994
- sessionManager: SessionManager.inMemory(cwd),
1061
+ sessionManager: SessionManager.inMemory(resolvedCwd),
995
1062
  });
996
1063
  const conv = this.makeConversation(runtime, conversationId, terminals);
997
1064
  conv.isSubagent = true;
@@ -1000,6 +1067,11 @@ export class ClientSession {
1000
1067
  // 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
1001
1068
  conv.parentId = parentId ?? this.activeId ?? undefined;
1002
1069
  conv.subagentType = type;
1070
+ conv.subagentPrompt = prompt;
1071
+ // 插件工具门与模板扩展白名单对齐:白名单非空时插件/MCP 工具(无 SDK
1072
+ // extensionKey 身份)不进该会话。工厂期 customTools 不注册 + 下面的
1073
+ // syncPluginTools 不回补,模板热改不影响已运行的子代理(与 prompt/技能一致)。
1074
+ conv.subagentBarsPluginTools = !!apply && apply.enabledExtensions.length > 0;
1003
1075
  conv.listed = true;
1004
1076
  conv.title = subagentTitle(prompt);
1005
1077
  this.convs.set(conv.id, conv);
@@ -1009,6 +1081,8 @@ export class ClientSession {
1009
1081
  conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
1010
1082
  // 子代理不走 bindSession——这里同样注入面板的重试次数覆盖。
1011
1083
  this.applyRetryOverrides();
1084
+ // 软上限覆盖同样重放(子代理跟随主对话的压缩阈值,issue #229)。
1085
+ this.applyCompactionOverrides();
1012
1086
  // 扩展绑定(rpc 模式);用 headless 的 Web UI context:
1013
1087
  // 扩展绑定时不会因缺方法崩,UI 输出也不下发(不会与主对话的 widget/status 冲突)。
1014
1088
  try {
@@ -1030,8 +1104,8 @@ export class ClientSession {
1030
1104
  const resolvedModel = model ?? (apply?.model?.trim() || null) ?? (this.settingsSvc.current.subagentDefaultModel || null);
1031
1105
  const followModel = resolvedModel
1032
1106
  ? resolvedModel
1033
- : this.session.model
1034
- ? `${this.session.model.provider}/${this.session.model.id}`
1107
+ : spawnerSession.model
1108
+ ? `${spawnerSession.model.provider}/${spawnerSession.model.id}`
1035
1109
  : null;
1036
1110
  if (followModel) {
1037
1111
  const slash = followModel.indexOf("/");
@@ -1041,7 +1115,8 @@ export class ClientSession {
1041
1115
  if (m) {
1042
1116
  try {
1043
1117
  // 先恢复该 provider 的项目密钥(setModel 的鉴权检查要用),再换模型。
1044
- await this.restoreKeyForModel(followModel, cwd);
1118
+ // 按子代理自己的目录恢复(跨目录派发时派发者的密钥不一定适用)。
1119
+ await this.restoreKeyForModel(followModel, resolvedCwd);
1045
1120
  await conv.session.setModel(m);
1046
1121
  }
1047
1122
  catch (err) {
@@ -1063,12 +1138,12 @@ export class ClientSession {
1063
1138
  });
1064
1139
  }
1065
1140
  }
1066
- // 思考强度:模板指定则固定用它,否则跟随主对话当前强度(与「跟随主对话模型」
1067
- // 同一取数源:this.session,即共享 ModelRuntime 的当前活动会话)。所以子代理默认
1068
- // 与主对话一致,而不是默默回到 SDK 默认档位。放在换模型之后:setModel 会按模型
1069
- // 能力重算强度,我们先让它算完再覆盖。不传 persist:只影响这个子代理会话,不动
1070
- // 全局默认强度;模型不支持的档位由 SDK 自动收敛(reasoning:false 的模型只能是 off)。
1071
- const thinkingLevel = apply?.thinkingLevel?.trim() || this.session.thinkingLevel;
1141
+ // 思考强度:模板指定则固定用它,否则跟随派发者当前强度(与「跟随派发者模型」
1142
+ // 同一取数源:spawnerSession)。所以子代理默认与派发者一致,而不是默默回到
1143
+ // SDK 默认档位。放在换模型之后:setModel 会按模型能力重算强度,我们先让它
1144
+ // 算完再覆盖。不传 persist:只影响这个子代理会话,不动全局默认强度;模型不
1145
+ // 支持的档位由 SDK 自动收敛(reasoning:false 的模型只能是 off)。
1146
+ const thinkingLevel = apply?.thinkingLevel?.trim() || spawnerSession.thinkingLevel;
1072
1147
  if (thinkingLevel) {
1073
1148
  try {
1074
1149
  conv.session.setThinkingLevel(thinkingLevel);
@@ -1109,7 +1184,10 @@ export class ClientSession {
1109
1184
  }
1110
1185
  toSubagentSnapshot(conv) {
1111
1186
  const streaming = conv.session.isStreaming;
1112
- const state = streaming ? "running" : "done";
1187
+ const { error, canceled } = this.subagentRunOutcome(conv);
1188
+ // state 如实反映终态:之前 canceled 的子代理报的也是 done,只能靠独立 flag
1189
+ // 分辨。"queued" 保留给未来(排队调度),当前 spawn 即运行,无排队态。
1190
+ const state = streaming ? "running" : canceled ? "canceled" : "done";
1113
1191
  let messageCount = 0;
1114
1192
  try {
1115
1193
  messageCount = conv.session.getSessionStats().totalMessages;
@@ -1117,12 +1195,14 @@ export class ClientSession {
1117
1195
  catch {
1118
1196
  // session being replaced — report defaults
1119
1197
  }
1120
- const { error, canceled } = this.subagentRunOutcome(conv);
1198
+ const fullPrompt = conv.subagentPrompt ?? "";
1121
1199
  return {
1122
1200
  convId: conv.id,
1123
1201
  type: conv.subagentType ?? "general",
1124
1202
  title: conv.title,
1125
- prompt: "",
1203
+ prompt: fullPrompt.length > SUBAGENT_PROMPT_SNAPSHOT_CAP
1204
+ ? `${fullPrompt.slice(0, SUBAGENT_PROMPT_SNAPSHOT_CAP)}\n… [truncated]`
1205
+ : fullPrompt,
1126
1206
  state,
1127
1207
  streaming,
1128
1208
  error,
@@ -1130,6 +1210,7 @@ export class ClientSession {
1130
1210
  messageCount,
1131
1211
  model: conv.session.model?.id,
1132
1212
  output: conv.session.getLastAssistantText() ?? "",
1213
+ parentId: conv.parentId,
1133
1214
  };
1134
1215
  }
1135
1216
  /** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
@@ -1200,6 +1281,9 @@ export class ClientSession {
1200
1281
  lastSdkAppendFiles = [];
1201
1282
  /** 当前活动会话的工具/资源快照 → composer 输入。cwd 取活动对话的。 */
1202
1283
  composeInputs(src) {
1284
+ // 技能名录指纹观测(只打日志,不干预组装;预览与 run 共用此入口,
1285
+ // 变化才记一行,首轮静默)。用未注文的目录(fill 前),全文注入不影响指纹。
1286
+ this.noteSkillCatalogDigest(src.skills);
1203
1287
  return {
1204
1288
  cwd: src.cwd,
1205
1289
  systemPromptFile: this.lastBaseSystemPrompt || undefined,
@@ -1364,12 +1448,32 @@ export class ClientSession {
1364
1448
  return !!t && t.enabled;
1365
1449
  },
1366
1450
  };
1367
- /** schedule_* 工具的数据宿主:全局调度存储+创建时刻 live 的 cwd/活动对话。 */
1451
+ /** schedule_* 工具的数据宿主:全局调度存储+创建时刻 live 的 cwd/活动对话。
1452
+ * issue #231:同时快照 owner 对话的落盘会话文件(压缩/重启后稳定),触发时
1453
+ * 先按 sessionFile 认同一会话(内存对话 id 重启即失效,不可单独做持久键)。 */
1368
1454
  scheduleToolHost() {
1369
1455
  return {
1370
1456
  store: () => this.schedulerStore,
1371
1457
  cwd: () => this.cwd,
1372
1458
  activeConversationId: () => this.activeId,
1459
+ conversationInfo: (id) => {
1460
+ try {
1461
+ const target = (id ?? "").trim() ? this.convs.get((id ?? "").trim()) : this.convs.get(this.activeId);
1462
+ if (!target)
1463
+ return undefined;
1464
+ let sessionFile = "";
1465
+ try {
1466
+ sessionFile = String(target.session.sessionFile ?? "");
1467
+ }
1468
+ catch {
1469
+ sessionFile = "";
1470
+ }
1471
+ return { cwd: target.cwd ?? this.cwd, sessionFile };
1472
+ }
1473
+ catch {
1474
+ return undefined;
1475
+ }
1476
+ },
1373
1477
  };
1374
1478
  }
1375
1479
  /** conversation_read 工具的数据宿主:读本客户端的 conversation 体系 +
@@ -1452,6 +1556,45 @@ export class ClientSession {
1452
1556
  },
1453
1557
  };
1454
1558
  }
1559
+ /** skill 工具的数据宿主:读所属会话 loader 的实时技能表 + 主会话禁用集过滤
1560
+ * (与 skillsOverride 主会话语义一致)。ownerId 语义同 browser_page(本
1561
+ * runtime 所属会话,不是派发瞬间的 active)。失败回空目录,不抛错。 */
1562
+ skillToolHost(ownerId) {
1563
+ return {
1564
+ listSkills: () => {
1565
+ try {
1566
+ const target = (ownerId ?? "").trim() !== "" ? this.convs.get(ownerId.trim()) : this.convs.get(this.activeId);
1567
+ const all = target?.session.resourceLoader.getSkills().skills ?? [];
1568
+ const disabled = new Set(this.settingsSvc.current.disabledSkills);
1569
+ return all
1570
+ .filter((s) => !disabled.has(s.name))
1571
+ .map((s) => ({
1572
+ name: s.name,
1573
+ description: s.description ?? "",
1574
+ filePath: s.filePath ?? "",
1575
+ }));
1576
+ }
1577
+ catch {
1578
+ return [];
1579
+ }
1580
+ },
1581
+ };
1582
+ }
1583
+ /** 技能名录指纹(sha256 over name+description,非正文):变化才打一行日志,
1584
+ * 首轮静默。只观测不干预组装(digest 第一步:日志;复用以后再说)。 */
1585
+ lastSkillCatalogDigest = "";
1586
+ noteSkillCatalogDigest(skills) {
1587
+ const d = createHash("sha256")
1588
+ .update(skills.map((s) => `${s.name}\n${s.description}`).join("\n"))
1589
+ .digest("hex")
1590
+ .slice(0, 16);
1591
+ if (d === this.lastSkillCatalogDigest)
1592
+ return;
1593
+ const prev = this.lastSkillCatalogDigest;
1594
+ this.lastSkillCatalogDigest = d;
1595
+ if (prev)
1596
+ console.log(`[skills] catalog digest ${prev}→${d} (${skills.length} skills)`);
1597
+ }
1455
1598
  widgetsTimer = null;
1456
1599
  /** Model-stall watchdog interval (see startStallTimer). */
1457
1600
  stallTimer = null;
@@ -1575,13 +1718,15 @@ export class ClientSession {
1575
1718
  reloadSession: async () => {
1576
1719
  await this.session.reload();
1577
1720
  // reload() 重读磁盘 settings.json,会丢掉内存 applyOverrides
1578
- // (含重试次数覆盖)——依次重放:重试覆盖 → 终端门控。
1721
+ // (含重试次数覆盖)——依次重放:重试覆盖 → 软上限覆盖 → 终端门控。
1579
1722
  this.applyRetryOverrides();
1723
+ this.applyCompactionOverrides();
1580
1724
  // reload() 会把 custom 工具重新加回活跃集——重放终端开关。
1581
1725
  this.applyToolGating(this.session);
1582
1726
  await this.pushSlashCommands();
1583
1727
  },
1584
1728
  applyRetryOverrides: () => this.applyRetryOverrides(),
1729
+ applyCompactionOverrides: () => this.applyCompactionOverrides(),
1585
1730
  applyToolGating: () => this.applyToolGating(this.session),
1586
1731
  promptSnapshot: () => this.promptSnapshot(),
1587
1732
  getMarkerState: () => ({
@@ -1631,15 +1776,22 @@ export class ClientSession {
1631
1776
  const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
1632
1777
  const conversationId = cs.nextConversationId();
1633
1778
  const terminals = cs.makeTerminalManager(conversationId, cwd);
1634
- const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals, undefined, conversationId), {
1779
+ // Resume the most recent session for this project — the SDK default
1780
+ // per-project dir (<agentDir>/sessions/--<cwd>--/, shared with the
1781
+ // pi CLI/TUI) — or start a fresh one on first visit.
1782
+ // issue #145: opts.blank = 跳过恢复(最近那条在别处跑着),直接空白新对话。
1783
+ // issue #235:坏转录(重复压缩标记成环)修一次再试,否则整项目首屏
1784
+ // "Failed to initialize session"。
1785
+ const opened = await cs.openManagerAndRuntime(() => (opts?.blank ? SessionManager.create(cwd) : SessionManager.continueRecent(cwd)), (m) => createAgentSessionRuntime(cs.makeRuntimeFactory(terminals, undefined, conversationId), {
1635
1786
  cwd,
1636
1787
  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
- });
1788
+ sessionManager: m,
1789
+ }), async () => (await SessionManager.list(cwd))[0]?.path);
1790
+ const runtime = opened.runtime;
1791
+ if (opened.repair) {
1792
+ for (const n of cs.transcriptRepairNotices(opened.repair))
1793
+ cs.pendingNotices.push(n);
1794
+ }
1643
1795
  // First conversation = the resumed session; it also seeds the shared
1644
1796
  // ModelRuntime that every later conversation reuses.
1645
1797
  cs.sharedModelRuntime = runtime.services.modelRuntime;
@@ -1779,12 +1931,18 @@ export class ClientSession {
1779
1931
  // 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
1780
1932
  // systemPromptOverride 整体替换,此处边界不存在会自然跳过。
1781
1933
  if (apply) {
1782
- if (apply.promptMode !== "replace" || !pickTemplatePrompt(apply, this.getLang()).trim())
1934
+ const tplPrompt = pickTemplatePrompt(apply, this.getLang()).trim();
1935
+ if (apply.promptMode !== "replace" || !tplPrompt)
1783
1936
  return undefined;
1784
1937
  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);
1938
+ // 边界串是 SDK 提示词的内部格式:版本一变就可能对不上。
1939
+ // 对不上时不再静默回退默认 persona(模板等于没生效),而是把模板
1940
+ // 提示词前置拼接——角色约束仍在,只是灵魂段没被精确替换。
1941
+ if (boundary === -1) {
1942
+ const fallback = `${tplPrompt}\n\n${event.systemPrompt}`;
1943
+ return fallback === event.systemPrompt ? undefined : { systemPrompt: fallback };
1944
+ }
1945
+ const swapped = tplPrompt + event.systemPrompt.slice(boundary);
1788
1946
  return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
1789
1947
  }
1790
1948
  // 主会话:组合模板渲染(模板为空且无覆盖时返回 undefined = 用 SDK 默认)。
@@ -1834,7 +1992,10 @@ export class ClientSession {
1834
1992
  makeEditSoftTool(effectiveCwd, () => this.getLang()),
1835
1993
  // 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
1836
1994
  // 后续注册经 refreshPluginTools 动态补入已有会话)。
1837
- ...this.enabledPluginToolDefs(),
1995
+ // 子代理模板带非空扩展白名单时不注入:插件/MCP 工具没有 SDK extensionKey
1996
+ // 身份、无法参与白名单匹配,全放行等于白名单没关门,全收编才符合「只加载这些」。
1997
+ // 空白名单 = 跟随主会话(插件工具照常进入子代理)。
1998
+ ...(apply && apply.enabledExtensions.length > 0 ? [] : this.enabledPluginToolDefs()),
1838
1999
  // 第一方子代理工具(spawn/get_result/steer/list/stop)。子代理会话
1839
2000
  // 也注册了它们,因此可自然嵌套派发。host 按 ownerId 包装:子代理的
1840
2001
  // 父对话 = 真正调用 spawn 的那个会话(本 runtime 所属会话),而不是
@@ -1866,6 +2027,10 @@ export class ClientSession {
1866
2027
  // 会话同样注册了它,可自然嵌套读取。不需要 ownerId——读的是本
1867
2028
  // 客户端的 conversation 体系与落盘历史,与派发者无关。
1868
2029
  makeConversationReadTool(this.conversationReadHost(), () => this.getLang()),
2030
+ // 技能全文按名加载(名录在 {{skills}} 段):模型不再拼路径调 read。
2031
+ // 子代理会话同样注册(owner 即真正派发的父对话,读该会话 loader)。
2032
+ // DSH 引擎无 customTool 注册面,不接。开关走统一工具 tab。
2033
+ makeSkillTool(this.skillToolHost(ownerId), () => this.getLang()),
1869
2034
  // 定时唤醒三件套(schedule_task/list/cancel,issue #193):默认绑定
1870
2035
  // 发起对话(ownerId,无则活动对话),到期 steer 语义唤醒它;子代理
1871
2036
  // 会话同样注册(owner 即真正派发的父对话)。开关走统一工具 tab。
@@ -2075,6 +2240,8 @@ export class ClientSession {
2075
2240
  // 默认重试 3 次——这里把面板的 retryMaxAttempts 覆盖注入,否则“设了 6
2076
2241
  // 次还是按 3 次重试”。已存在会话重复注入是幂等的(同值覆盖)。
2077
2242
  this.applyRetryOverrides();
2243
+ // 软上限覆盖同路重放(新 runtime 的 SettingsManager 是干净的,issue #229)。
2244
+ this.applyCompactionOverrides();
2078
2245
  this.scheduleSnapshot();
2079
2246
  this.webUi.refresh();
2080
2247
  this.startWidgetsTimer();
@@ -2343,6 +2510,94 @@ export class ClientSession {
2343
2510
  await conv.session.sendUserMessage(text, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
2344
2511
  return { ok: true };
2345
2512
  }
2513
+ /** issue #231:本客户端内按稳定键定位调度唤醒目标。
2514
+ * sessionFile 优先(压缩/重启后内存对话 id 已变,落盘会话文件才是同一会话);
2515
+ * 只有没给 sessionFile 时才按内存 id 找,且 id 必须附带 cwd 一致才认 ——
2516
+ * 各客户端计数器都从 c1 开始,跨项目同 id 必然撞车,不校验 cwd 会把巡检
2517
+ * 报告投进完全无关的项目对话。
2518
+ * excludeIds 用于视口回退时跳过已知的忙对话。 */
2519
+ resolveSchedulerTarget(opts) {
2520
+ const wantFile = String(opts.sessionFile ?? "").trim();
2521
+ const wantId = String(opts.conversationId ?? "").trim();
2522
+ const wantCwd = String(opts.cwd ?? "").trim();
2523
+ const excluded = opts.excludeIds;
2524
+ if (wantFile) {
2525
+ let best = null;
2526
+ for (const c of this.convs.values()) {
2527
+ if (!c?.session || (excluded && excluded.has(c.id)))
2528
+ continue;
2529
+ let f = "";
2530
+ try {
2531
+ f = String(c.session.sessionFile ?? "");
2532
+ }
2533
+ catch {
2534
+ continue;
2535
+ }
2536
+ if (!sameSessionFile(f, wantFile))
2537
+ continue;
2538
+ if (!best || c.lastActiveAt > best.lastActiveAt)
2539
+ best = c;
2540
+ }
2541
+ if (best)
2542
+ return best;
2543
+ }
2544
+ if (wantId) {
2545
+ const c = this.convs.get(wantId);
2546
+ if (!c?.session || (excluded && excluded.has(c.id)))
2547
+ return null;
2548
+ if (wantCwd && !sameCwd(c.cwd, wantCwd))
2549
+ return null;
2550
+ return c;
2551
+ }
2552
+ return null;
2553
+ }
2554
+ /** issue #231:本客户端内同项目的最近活跃对话(视口回退目标)。
2555
+ * 主对话优先(用户正看着的面),没有主对话时才考虑子代理行;
2556
+ * 最近活跃者即用户当前的视口。 */
2557
+ findViewportInCwd(cwd, excludeIds) {
2558
+ const want = String(cwd ?? "").trim();
2559
+ if (!want)
2560
+ return null;
2561
+ let best = null;
2562
+ let bestSub = null;
2563
+ for (const c of this.convs.values()) {
2564
+ if (!c?.session || (excludeIds && excludeIds.has(c.id)))
2565
+ continue;
2566
+ if (!sameCwd(c.cwd, want))
2567
+ continue;
2568
+ if (c.isSubagent) {
2569
+ if (!bestSub || c.lastActiveAt > bestSub.lastActiveAt)
2570
+ bestSub = c;
2571
+ }
2572
+ else if (!best || c.lastActiveAt > best.lastActiveAt) {
2573
+ best = c;
2574
+ }
2575
+ }
2576
+ return best ?? bestSub;
2577
+ }
2578
+ /** issue #231:带压缩忙检测的调度 steer。压缩进行中时 SDK 直接抛错
2579
+ * (Cannot submit a prompt while compaction is in progress),调用方据 busy
2580
+ * 另寻视口兄弟或稍后重试,而不是当成“对话不在”静默转无头。 */
2581
+ async trySteerScheduler(conv, text) {
2582
+ try {
2583
+ try {
2584
+ if (conv.session.isCompacting === true) {
2585
+ return { ok: false, busy: true, error: "上下文压缩进行中,稍后重试" };
2586
+ }
2587
+ }
2588
+ catch {
2589
+ /* 读不到压缩态就直接投递,失败按异常走 */
2590
+ }
2591
+ await conv.session.sendUserMessage(text, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
2592
+ return { ok: true };
2593
+ }
2594
+ catch (err) {
2595
+ const msg = String(err?.message ?? err);
2596
+ if (/compaction is in progress/i.test(msg))
2597
+ return { ok: false, busy: true, error: msg };
2598
+ return { ok: false, busy: false, error: msg };
2599
+ }
2600
+ }
2346
2601
  /** 插件扩展点(供 runSteerer):向指定对话插队一条用户消息,复用子代理 steer 的
2347
2602
  * sendUserMessage + deliverAs:'steer' 路径(运行时插队;未跑时按普通消息投递)。
2348
2603
  * 先找本客户端 conversations,找不到再经 steerConversationElsewhere 问其他客户端;
@@ -2949,12 +3204,14 @@ export class ClientSession {
2949
3204
  contextWindow: cu.contextWindow,
2950
3205
  percent: (conv.lastCompactionTokens / cu.contextWindow) * 100,
2951
3206
  estimated: true,
3207
+ softCap: this.activeSoftCap(cu.contextWindow),
2952
3208
  };
2953
3209
  }
2954
3210
  return {
2955
3211
  tokens: cu.tokens,
2956
3212
  contextWindow: cu.contextWindow,
2957
3213
  percent: cu.percent,
3214
+ softCap: this.activeSoftCap(cu.contextWindow),
2958
3215
  };
2959
3216
  })(),
2960
3217
  };
@@ -3588,8 +3845,9 @@ export class ClientSession {
3588
3845
  },
3589
3846
  refreshSessions: () => this.refreshSessions(),
3590
3847
  afterReload: () => {
3591
- // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 终端门控。
3848
+ // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 软上限覆盖 + 终端门控。
3592
3849
  this.applyRetryOverrides();
3850
+ this.applyCompactionOverrides();
3593
3851
  this.applyToolGating(this.session);
3594
3852
  },
3595
3853
  pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
@@ -3833,6 +4091,40 @@ export class ClientSession {
3833
4091
  }
3834
4092
  }
3835
4093
  }
4094
+ /** 把压缩软上限换算成各存活会话的 compaction reserveTokens 覆盖
4095
+ * (issue #229)。与重试覆盖同一 live 机制:applyOverrides 只改内存
4096
+ * 合并视图,SDK 每次自动压缩检查前都重读 getCompactionSettings(),
4097
+ * 无需 reload;软上限关闭时回填 SDK 默认 reserve(不让旧覆盖泄漏)。
4098
+ * 窗口未知(会话未就绪/无模型)的会话跳过——创建/就绪/换模型路径
4099
+ * 会重放(见各 applyRetryOverrides 调用点)。 */
4100
+ applyCompactionOverrides() {
4101
+ const s = this.settingsSvc.current;
4102
+ for (const c of this.convs.values()) {
4103
+ try {
4104
+ const modelId = modelKeyOf(c.session);
4105
+ const contextWindow = contextWindowOf(c.session);
4106
+ const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelId);
4107
+ const reserve = softCapToReserve(contextWindow, cap);
4108
+ c.session.settingsManager.applyOverrides({
4109
+ compaction: { reserveTokens: reserve ?? DEFAULT_COMPACTION_RESERVE_TOKENS },
4110
+ });
4111
+ }
4112
+ catch {
4113
+ // 会话未就绪或已释放 → 其 runtime 创建时统一注入。
4114
+ }
4115
+ }
4116
+ }
4117
+ /** 当前活动对话的生效软上限(快照底栏标记线用;null = 关闭/未知)。 */
4118
+ activeSoftCap(contextWindow) {
4119
+ try {
4120
+ const s = this.settingsSvc.current;
4121
+ const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelKeyOf(this.session));
4122
+ return softCapToReserve(contextWindow, cap) === null ? null : cap;
4123
+ }
4124
+ catch {
4125
+ return null;
4126
+ }
4127
+ }
3836
4128
  /** Extensions/skills changed externally (e.g. `pi remove` finished in the
3837
4129
  * terminal): re-run session.reload() and re-push state. Streaming-safe —
3838
4130
  * deferred to agent_end, same as settings reloads. */
@@ -3867,6 +4159,7 @@ export class ClientSession {
3867
4159
  try {
3868
4160
  await this.session.reload();
3869
4161
  this.applyRetryOverrides();
4162
+ this.applyCompactionOverrides();
3870
4163
  this.applyToolGating(this.session);
3871
4164
  await this.pushSlashCommands();
3872
4165
  this.pushSettings();
@@ -3929,8 +4222,14 @@ export class ClientSession {
3929
4222
  return (this.pluginToolsProvider?.() ?? []).filter((t) => !off.has(t.name)).map(pluginToolToDefinition);
3930
4223
  }
3931
4224
  /** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除;禁用工具同步移除)。
3932
- * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。 */
4225
+ * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。
4226
+ * 模板白名单的子代理(subagentBarsPluginTools)跳过:工厂期就没注册,这里
4227
+ * 不回补,否则白名单等于没关门。 */
3933
4228
  syncPluginTools(session) {
4229
+ for (const conv of this.convs.values()) {
4230
+ if (conv.session === session && conv.subagentBarsPluginTools)
4231
+ return;
4232
+ }
3934
4233
  try {
3935
4234
  const defs = this.enabledPluginToolDefs();
3936
4235
  const next = syncPluginToolsIntoSession(session, defs, this.appliedPluginToolNames);
@@ -4202,7 +4501,8 @@ export class ClientSession {
4202
4501
  // issue #145:同项目并行感知 —— 同一 cwd 下别处(或其他对话)正在跑时,
4203
4502
  // 允许并行(可以同时改不同部分),但用户与 AI 都必须知道。只在新一轮启动时
4204
4503
  // 通告一次(steer/排队等流式中发送不重复打扰)。
4205
- if (!conv.isSubagent && !s.isStreaming) {
4504
+ // parallelReminderEnabled=false 时整段跳过(不发 notice、不注 AI、不通知对端)。
4505
+ if (!conv.isSubagent && !s.isStreaming && this.settingsSvc.current.parallelReminderEnabled !== false) {
4206
4506
  const localRunners = [...this.convs.values()]
4207
4507
  .filter((c) => c.id !== conv.id && !c.isSubagent && c.cwd === conv.cwd && this.conversationStreaming(c))
4208
4508
  .map((c) => ({ title: c.title }));
@@ -4655,11 +4955,17 @@ export class ClientSession {
4655
4955
  this.clearAllToolWatchdogs(conv);
4656
4956
  conv.toolStartTimes.clear();
4657
4957
  await conv.runtime.dispose();
4658
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
4958
+ // #235:转录链损坏时修一次再试(见 openManagerAndRuntime)。
4959
+ const opened = await this.openManagerAndRuntime(() => SessionManager.continueRecent(conv.cwd), (m) => createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
4659
4960
  cwd: conv.cwd,
4660
4961
  agentDir: this.agentDir,
4661
- sessionManager: SessionManager.continueRecent(conv.cwd),
4662
- });
4962
+ sessionManager: m,
4963
+ }), async () => (await SessionManager.list(conv.cwd))[0]?.path);
4964
+ const runtime = opened.runtime;
4965
+ if (opened.repair) {
4966
+ for (const n of this.transcriptRepairNotices(opened.repair))
4967
+ this.emit(n);
4968
+ }
4663
4969
  conv.runtime = runtime;
4664
4970
  conv.session = runtime.session;
4665
4971
  this.emit({
@@ -4814,9 +5120,11 @@ export class ClientSession {
4814
5120
  return;
4815
5121
  // parentId joins the live chain: a null-parent marker would become a
4816
5122
  // second root and hijack the leaf, corrupting the transcript.
5123
+ // #235: id 必须唯一——硬编码共享 id + SDK byId last-wins + 标记恰为末行
5124
+ // 时新消息 parent 记成共享 id = 下次 open 回溯成环,整个会话打不开。
4817
5125
  const marker = {
4818
5126
  type: "custom",
4819
- id: "pi-web-ui-compaction-pending",
5127
+ id: makeCompactionMarkerId("pending"),
4820
5128
  parentId: conv.session.sessionManager.getLeafId(),
4821
5129
  timestamp: new Date().toISOString(),
4822
5130
  customType: "pi-web-ui/compaction-pending",
@@ -4843,10 +5151,18 @@ export class ClientSession {
4843
5151
  const file = conv.session.sessionFile;
4844
5152
  if (!file || !existsSync(file))
4845
5153
  return;
4846
- const id = "pi-web-ui-compaction-pending";
4847
5154
  const raw = readFileSync(file, "utf8");
4848
5155
  const lines = raw.split("\n");
4849
- const idx = lines.findIndex((line) => line.includes(`"${id}"`));
5156
+ // #235:按 customType 定位(id 自本 fix 起唯一,老文件仍是硬编码 id,
5157
+ // 按 id 找已不可靠)。取最后一个:重叠压缩时它属于本次,旧残留留给
5158
+ // 下次 open 的 repair/notice 处理。
5159
+ let idx = -1;
5160
+ for (let i = lines.length - 1; i >= 0; i--) {
5161
+ if (lines[i].includes(`"${COMPACTION_PENDING_TYPE}"`)) {
5162
+ idx = i;
5163
+ break;
5164
+ }
5165
+ }
4850
5166
  if (idx < 0)
4851
5167
  return;
4852
5168
  // ponytail: full-file rewrite on compaction end — compactions are rare
@@ -4864,7 +5180,7 @@ export class ClientSession {
4864
5180
  }
4865
5181
  const done = {
4866
5182
  type: "custom",
4867
- id: "pi-web-ui-compaction-done",
5183
+ id: makeCompactionMarkerId("done"),
4868
5184
  parentId,
4869
5185
  timestamp: new Date().toISOString(),
4870
5186
  customType: "pi-web-ui/compaction-done",
@@ -4894,15 +5210,80 @@ export class ClientSession {
4894
5210
  return;
4895
5211
  const kept = raw.split("\n").filter((line) => !line.includes("pi-web-ui/compaction-pending"));
4896
5212
  writeFileSync(file, kept.join("\n"));
4897
- this.emit({
5213
+ this.emitCompactionInterruptedNotice();
5214
+ }
5215
+ catch {
5216
+ // Best-effort, same as the write path.
5217
+ }
5218
+ }
5219
+ emitCompactionInterruptedNotice() {
5220
+ this.emit({
5221
+ type: "notice",
5222
+ level: "warning",
5223
+ text: "上次压缩上下文被服务端重启打断,未完成。可发送 /compact 重试。",
5224
+ textEn: "The last context compaction was interrupted by a server restart and did not finish. Send /compact to retry.",
5225
+ });
5226
+ }
5227
+ /** #235 修复产生的提示(调用方决定 emit 还是进 pendingNotices)。 */
5228
+ transcriptRepairNotices(repair) {
5229
+ const out = [];
5230
+ if (repair.interrupted) {
5231
+ out.push({
4898
5232
  type: "notice",
4899
5233
  level: "warning",
4900
5234
  text: "上次压缩上下文被服务端重启打断,未完成。可发送 /compact 重试。",
4901
5235
  textEn: "The last context compaction was interrupted by a server restart and did not finish. Send /compact to retry.",
4902
5236
  });
4903
5237
  }
5238
+ if (repair.renamedIds > 0 || repair.rewiredParents > 0 || repair.cyclesBroken > 0) {
5239
+ out.push({
5240
+ type: "notice",
5241
+ level: "warning",
5242
+ text: `对话记录链损坏已自动修复(重复的压缩标记),原文件备份在 ${repair.backup ?? "同目录 .bak 文件"}。如内容异常可手动恢复。`,
5243
+ 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.`,
5244
+ });
5245
+ }
5246
+ return out;
5247
+ }
5248
+ /**
5249
+ * #235:已知路径先修后开(openConversation 走这条——单文件预扫描零负担)。
5250
+ * 返回 null = 文件健康或无需处理;返回 repair = 修过,调用方弹提示。
5251
+ */
5252
+ repairTranscriptFileBeforeOpen(filePath) {
5253
+ let repair = null;
5254
+ try {
5255
+ repair = repairSessionFile(filePath);
5256
+ }
4904
5257
  catch {
4905
- // Best-effort, same as the write path.
5258
+ return null;
5259
+ }
5260
+ if (!repair?.changed)
5261
+ return null;
5262
+ for (const n of this.transcriptRepairNotices(repair))
5263
+ this.emit(n);
5264
+ return repair;
5265
+ }
5266
+ /**
5267
+ * #235:manager+runtime 一起建,链损坏报错则修最近文件后重试一次。
5268
+ * SessionManager.open 本身不走 parent 链(真正死循环的是 runtime 初始化里的
5269
+ * getBranch),所以重试必须把两步都包进来;修完用全新 manager 重读。
5270
+ */
5271
+ async openManagerAndRuntime(makeManager, makeRuntime, locateFile) {
5272
+ try {
5273
+ const manager = makeManager();
5274
+ const runtime = await makeRuntime(manager);
5275
+ return { manager, runtime, repair: null };
5276
+ }
5277
+ catch (err) {
5278
+ if (!looksLikeChainCorruption(err))
5279
+ throw err;
5280
+ const file = await locateFile().catch(() => undefined);
5281
+ const repair = file ? repairSessionFile(file) : null;
5282
+ if (!repair?.changed)
5283
+ throw err;
5284
+ const manager = makeManager();
5285
+ const runtime = await makeRuntime(manager);
5286
+ return { manager, runtime, repair };
4906
5287
  }
4907
5288
  }
4908
5289
  displaceActive() {
@@ -5997,6 +6378,9 @@ export class ClientSession {
5997
6378
  });
5998
6379
  }
5999
6380
  }
6381
+ // #235:先修后开——坏转录到 open 后的 getBranch 会死循环,修完再读。
6382
+ // 单文件预扫描,健康文件只多一次小读;修过即弹提示(含压缩被打断)。
6383
+ this.repairTranscriptFileBeforeOpen(targetPath);
6000
6384
  const sessionManager = SessionManager.open(targetPath);
6001
6385
  const targetCwd = sessionManager.getCwd();
6002
6386
  const conversationId = this.nextConversationId();
@@ -6301,6 +6685,79 @@ export class ClientSession {
6301
6685
  async scmQuery(kind, reqId, arg) {
6302
6686
  return this.files.scmQuery(kind, reqId, arg);
6303
6687
  }
6688
+ /**
6689
+ * SCM「AI 生成提交信息」:用当前对话模型做一次 completeSimple 一次性补全
6690
+ * (与视觉桥同一条通路)——不进对话上下文、不打断正在流式的回复。
6691
+ * 恰好应答一次:任何失败都以 ok:false 的 scm_data(kind "commitmsg")收尾,
6692
+ * 前端按钮不会卡在转圈。
6693
+ */
6694
+ async scmGenCommitMessage(reqId) {
6695
+ const lang = this.getLang();
6696
+ const cwd = this.conv?.cwd ?? this.cwd;
6697
+ const reply = (ok, extra) => {
6698
+ this.emit({ type: "scm_data", reqId, kind: "commitmsg", ok, ...extra });
6699
+ };
6700
+ const fail = (err) => {
6701
+ reply(false, { error: err instanceof Error ? err.message : String(err) });
6702
+ };
6703
+ try {
6704
+ const runtime = this.runtime.services.modelRuntime;
6705
+ const model = this.session?.model;
6706
+ if (!model) {
6707
+ throw new Error(pick(lang, "当前没有可用模型——先在顶栏选择一个模型再生成", "No model available — pick one in the top bar first", "scm.commitmsg.no.model"));
6708
+ }
6709
+ const ctx = await scmCommitContext(cwd, () => lang);
6710
+ const input = buildCommitMsgInput(ctx, lang === "zh" ? "zh" : "en");
6711
+ if (!input) {
6712
+ throw new Error(pick(lang, "没有可描述的更改(工作区干净)", "Nothing to describe (working tree clean)", "scm.commitmsg.no.changes"));
6713
+ }
6714
+ const ac = new AbortController();
6715
+ const timer = setTimeout(() => ac.abort(), SCM_COMMITMSG_TIMEOUT_MS);
6716
+ // 提示词可配置(设置 → 提示词 → AI 提交信息):追加/替换内置默认。
6717
+ const commitSettings = this.settingsSvc.current;
6718
+ const systemPrompt = buildCommitMsgPrompt(commitSettings.scmCommitMsgPromptMode === "replace" ? "replace" : "append", commitSettings.scmCommitMsgPrompt ?? "");
6719
+ let msg;
6720
+ try {
6721
+ msg = await runtime.completeSimple(model, {
6722
+ systemPrompt,
6723
+ messages: [
6724
+ {
6725
+ role: "user",
6726
+ timestamp: Date.now(),
6727
+ content: [{ type: "text", text: input }],
6728
+ },
6729
+ ],
6730
+ }, { signal: ac.signal, maxTokens: 400 });
6731
+ }
6732
+ finally {
6733
+ clearTimeout(timer);
6734
+ }
6735
+ if (msg.stopReason === "error" || msg.stopReason === "aborted") {
6736
+ throw new Error(msg.errorMessage ||
6737
+ pick(lang, `模型异常终止(${msg.stopReason})`, `Model terminated abnormally (${msg.stopReason})`, "scm.commitmsg.model.terminated"));
6738
+ }
6739
+ const raw = msg.content
6740
+ .filter((b) => b.type === "text")
6741
+ .map((b) => b.text ?? "")
6742
+ .join("\n");
6743
+ const text = sanitizeCommitMessage(raw);
6744
+ if (!text) {
6745
+ throw new Error(pick(lang, "模型返回了空的提交信息", "The model returned an empty commit message", "scm.commitmsg.empty"));
6746
+ }
6747
+ reply(true, { text });
6748
+ }
6749
+ catch (err) {
6750
+ if (isNotRepoError(err)) {
6751
+ fail(new Error(pick(lang, "当前目录不是 Git 仓库", "Current directory is not a Git repository", "scm.commitmsg.not.repo")));
6752
+ return;
6753
+ }
6754
+ if (err instanceof Error && /abort/i.test(`${err.name} ${err.message}`)) {
6755
+ 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")));
6756
+ return;
6757
+ }
6758
+ fail(err);
6759
+ }
6760
+ }
6304
6761
  /** Read a workspace file for the preview panel (size-capped, binary-safe). */
6305
6762
  async readFile(relPath) {
6306
6763
  return this.files.readFile(relPath);
@@ -6469,7 +6926,6 @@ export class ClientSession {
6469
6926
  // default-opening it would strand the tab on a conversation it
6470
6927
  // cannot use (the prompt guard refuses) with a stale leaf that
6471
6928
  // forks history once the owner finishes. Land blank instead.
6472
- let sessionManager = SessionManager.continueRecent(abs);
6473
6929
  let resumeSkipped = null;
6474
6930
  // 无人在跑时不扫目录(首访切项目的常见情形零开销)。
6475
6931
  if (this.hasStreamingElsewhere?.() ?? false) {
@@ -6478,7 +6934,6 @@ export class ClientSession {
6478
6934
  const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
6479
6935
  const owner = recent ? this.findSessionOwner?.(recent) : null;
6480
6936
  if (owner?.isStreaming) {
6481
- sessionManager = SessionManager.create(abs);
6482
6937
  resumeSkipped = owner;
6483
6938
  }
6484
6939
  }
@@ -6488,11 +6943,18 @@ export class ClientSession {
6488
6943
  }
6489
6944
  const conversationId = this.nextConversationId();
6490
6945
  const terminals = this.makeTerminalManager(conversationId, abs);
6491
- const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
6946
+ // #235:manager+runtime 一起建,转录链损坏时修最近文件后重试一次
6947
+ // (见 openManagerAndRuntime)。blank(别处在跑)是全新空会话,不会坏。
6948
+ const opened = await this.openManagerAndRuntime(() => (resumeSkipped ? SessionManager.create(abs) : SessionManager.continueRecent(abs)), (m) => createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
6492
6949
  cwd: abs,
6493
6950
  agentDir: this.agentDir,
6494
- sessionManager,
6495
- });
6951
+ sessionManager: m,
6952
+ }), async () => (await SessionManager.list(abs))[0]?.path);
6953
+ const newRuntime = opened.runtime;
6954
+ if (opened.repair) {
6955
+ for (const n of this.transcriptRepairNotices(opened.repair))
6956
+ this.emit(n);
6957
+ }
6496
6958
  const conv = this.makeConversation(newRuntime, conversationId, terminals);
6497
6959
  this.convs.set(conv.id, conv);
6498
6960
  this.activeId = conv.id;
@@ -6642,7 +7104,9 @@ export class ClientSession {
6642
7104
  return "";
6643
7105
  }
6644
7106
  }
6645
- /** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5"). */
7107
+ /** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5").
7108
+ * 失败时只发 notice 不抛错(UI 路径靠 notice 提示,见 cycleModel 等调用方)。
7109
+ * 需要「失败即拒绝」的无头路径(插件/定时任务)用 switchModelOrThrow。 */
6646
7110
  async setModel(modelId) {
6647
7111
  try {
6648
7112
  const mr = this.runtime.services.modelRuntime;
@@ -6661,6 +7125,8 @@ export class ClientSession {
6661
7125
  // project (not only after a turn). This is what makes project switching
6662
7126
  // restore both the model and the provider key.
6663
7127
  this.rememberProjectModel(modelId);
7128
+ // 换模型后按新模型的窗口重算软上限覆盖(按模型覆盖可能不同,issue #229)。
7129
+ this.applyCompactionOverrides();
6664
7130
  }
6665
7131
  catch (err) {
6666
7132
  this.emit({
@@ -6672,6 +7138,25 @@ export class ClientSession {
6672
7138
  }
6673
7139
  this.flushSnapshot();
6674
7140
  }
7141
+ /** 切换模型,失败时抛出(无头路径专用:插件 host.chat / 定时任务)。
7142
+ * setModel 为兼容 UI 把异常吞成 notice(面板要能看到原因、调用方是 fire-and-forget),
7143
+ * 无头路径拿不到那个 notice,于是「模型 ID 打错/没配密钥」会变成静默按旧模型跑 ——
7144
+ * 账单与效果都和用户预期不符。这里统一改成响亮失败。 */
7145
+ async switchModelOrThrow(modelId) {
7146
+ await this.setModel(modelId);
7147
+ // 复核结果:读不到(无活跃对话)不阻断,读得到且不符才拒绝。
7148
+ // 注意 session 是 getter,无活跃对话时会抛,不能用 `?.` 兜底。
7149
+ let curId = "";
7150
+ try {
7151
+ const cur = this.session?.model;
7152
+ curId = cur ? `${cur.provider}/${cur.id}` : "";
7153
+ }
7154
+ catch {
7155
+ curId = "";
7156
+ }
7157
+ if (curId && curId !== modelId)
7158
+ throw new Error(`切换模型失败(${modelId}),当前仍是 ${curId} —— 请检查模型 ID 与供应商密钥`);
7159
+ }
6675
7160
  /** Set the thinking level for future turns. */
6676
7161
  setThinking(level) {
6677
7162
  try {
@@ -6762,6 +7247,42 @@ export class ClientSession {
6762
7247
  }
6763
7248
  }
6764
7249
  }
7250
+ /** issue #226:插件无头调用的工作目录校验(纯函数,可单测)。
7251
+ * 存在性语义与定时任务一致(须存在且为目录,不默默跑错目录);另在
7252
+ * Windows 下拒绝 SystemRoot 及其子树(如 C:\Windows\System32)——后台服务/
7253
+ * 快捷方式启动时宿主 cwd 常飘到 system32,直接跑就是高危误操作。 */
7254
+ export function checkPluginCwd(cwd) {
7255
+ const trimmed = String(cwd ?? "").trim();
7256
+ if (!trimmed)
7257
+ return { ok: false, error: "工作目录为空" };
7258
+ let abs;
7259
+ try {
7260
+ abs =
7261
+ process.platform === "win32" && /^[A-Za-z]:$/.test(trimmed) ? `${trimmed.toUpperCase()}${sep}` : resolve(trimmed);
7262
+ }
7263
+ catch {
7264
+ return { ok: false, error: `工作目录非法:${trimmed}` };
7265
+ }
7266
+ try {
7267
+ if (!statSync(abs).isDirectory())
7268
+ throw new Error("not-a-dir");
7269
+ }
7270
+ catch {
7271
+ return { ok: false, error: `目标项目不存在或不是目录:${trimmed}` };
7272
+ }
7273
+ if (process.platform === "win32") {
7274
+ const sysRoot = (process.env.SystemRoot || process.env.windir || "C:\\Windows")
7275
+ .replace(/\//g, "\\")
7276
+ .replace(/\\+$/, "");
7277
+ const norm = abs.replace(/\//g, "\\").replace(/\\+$/, "");
7278
+ const low = norm.toLowerCase();
7279
+ const rootLow = sysRoot.toLowerCase();
7280
+ if (low === rootLow || low.startsWith(`${rootLow}\\`)) {
7281
+ return { ok: false, error: `拒绝在系统目录执行:${abs}(请在插件设置里指定项目工作目录)` };
7282
+ }
7283
+ }
7284
+ return { ok: true, abs };
7285
+ }
6765
7286
  export class AgentService {
6766
7287
  cwd;
6767
7288
  /** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
@@ -6875,30 +7396,165 @@ export class AgentService {
6875
7396
  }
6876
7397
  /** issue #193:定时任务唤醒发起对话。逐个客户端找持有方,用 steer 语义投递
6877
7398
  * (运行时插队、未跑时普通投递,不切用户当前对话);都找不到回 ok:false,
6878
- * 调用方(index.ts executor)回落无头执行。quiesced 时直接拒绝。 */
6879
- async wakeConversation(id, text) {
6880
- if (!id || !text.trim())
7399
+ * 调用方(index.ts executor)回落视口/无头执行。quiesced 时直接拒绝。
7400
+ * issue #226:成功时带回持有方 clientId(插件绑定网页会话时原样回执)。
7401
+ * issue #231:opts.sessionFile 是跨压缩/重启的稳定键 —— 优先按它认同一会话
7402
+ * (内存对话 id 重启即失效,压缩后同文件对话可能已换新 id,成功时带回**实际**
7403
+ * 投递的 conversationId + sessionFile,调用方据此重绑定任务);id 相位带 cwd
7404
+ * 护栏(各客户端计数器都从 c1 开始,不校验会把报告投进无关项目)。压缩进行中
7405
+ * 的持有方回 busy:true(调用方另寻视口兄弟,而不是当成“不在”静默转无头)。 */
7406
+ async wakeConversation(id, text, opts) {
7407
+ const wantFile = String(opts?.sessionFile ?? "").trim();
7408
+ const wantCwd = String(opts?.cwd ?? "").trim();
7409
+ if ((!id && !wantFile) || !text.trim())
6881
7410
  return { ok: false, error: "唤醒目标或文本为空" };
6882
7411
  if (this.quiesced)
6883
7412
  return { ok: false, error: "服务器正忙(quiesced),请稍后重试" };
6884
- for (const cs of this.clients.values()) {
7413
+ const liveFile = (c) => {
6885
7414
  try {
6886
- const r = await cs.steerOwnConversation(id, text);
6887
- if (r) {
7415
+ return String(c.session.sessionFile ?? "");
7416
+ }
7417
+ catch {
7418
+ return "";
7419
+ }
7420
+ };
7421
+ const flush = (cs) => {
7422
+ try {
7423
+ cs.flushSnapshot();
7424
+ }
7425
+ catch {
7426
+ // 推送失败不影响已投递的唤醒
7427
+ }
7428
+ };
7429
+ // 相位一:落盘会话文件(稳定键)。同文件可能在多处打开,取最近活跃者;
7430
+ // 全部忙(压缩中)则报 busy,调用方去找视口兄弟。
7431
+ if (wantFile) {
7432
+ const hits = [];
7433
+ for (const [clientId, cs] of this.clients) {
7434
+ try {
7435
+ const conv = cs.resolveSchedulerTarget({ sessionFile: wantFile });
7436
+ if (conv)
7437
+ hits.push({ cs, clientId, conv });
7438
+ }
7439
+ catch {
7440
+ // 单客户端坏了继续找下一个
7441
+ }
7442
+ }
7443
+ hits.sort((a, b) => b.conv.lastActiveAt - a.conv.lastActiveAt);
7444
+ let busyError;
7445
+ for (const h of hits) {
7446
+ try {
7447
+ const r = await h.cs.trySteerScheduler(h.conv, text);
7448
+ if (r.ok) {
7449
+ flush(h.cs);
7450
+ return { ok: true, conversationId: h.conv.id, sessionFile: liveFile(h.conv), clientId: h.clientId };
7451
+ }
7452
+ if (r.busy)
7453
+ busyError = r.error;
7454
+ // 非忙失败(投递异常)试下一个同文件持有方
7455
+ }
7456
+ catch {
7457
+ // 单客户端坏了继续找下一个
7458
+ }
7459
+ }
7460
+ if (hits.length > 0) {
7461
+ if (busyError)
7462
+ return { ok: false, busy: true, error: busyError };
7463
+ // 同文件持有方都在但都投递失败 —— id 相位大概率指向同一批,无需再试
7464
+ return { ok: false, error: "目标对话投递失败(持有方异常)" };
7465
+ }
7466
+ // 无同文件持有方 —— 老任务只有 id,继续相位二
7467
+ }
7468
+ // 相位二:内存对话 id(易失键,必须配 cwd 护栏防跨项目串台)。
7469
+ if (id) {
7470
+ const hits = [];
7471
+ for (const [clientId, cs] of this.clients) {
7472
+ try {
7473
+ const conv = cs.resolveSchedulerTarget({
7474
+ conversationId: id,
7475
+ ...(wantCwd ? { cwd: wantCwd } : {}),
7476
+ });
7477
+ if (conv)
7478
+ hits.push({ cs, clientId, conv });
7479
+ }
7480
+ catch {
7481
+ // 单客户端坏了继续找下一个
7482
+ }
7483
+ }
7484
+ hits.sort((a, b) => b.conv.lastActiveAt - a.conv.lastActiveAt);
7485
+ let busyError;
7486
+ for (const h of hits) {
7487
+ try {
7488
+ const r = await h.cs.trySteerScheduler(h.conv, text);
7489
+ if (r.ok) {
7490
+ flush(h.cs);
7491
+ return { ok: true, conversationId: h.conv.id, sessionFile: liveFile(h.conv), clientId: h.clientId };
7492
+ }
7493
+ if (r.busy)
7494
+ busyError = r.error;
7495
+ }
7496
+ catch {
7497
+ // 单客户端坏了继续找下一个
7498
+ }
7499
+ }
7500
+ if (busyError)
7501
+ return { ok: false, busy: true, error: busyError };
7502
+ }
7503
+ return { ok: false, error: "目标对话不在运行中(已关闭或服务重启过)" };
7504
+ }
7505
+ /** issue #231:同项目视口回退 —— 原绑定对话不在时,把唤醒投给该项目最近活跃
7506
+ * 的对话(用户当前正看着的面),而不是静默转无头。excludeIds 跳过已知忙对话;
7507
+ * 候选全部忙回 busy:true;无候选回 ok:false。成功带回实际投递方(调用方重绑定)。 */
7508
+ async wakeViewportInCwd(cwd, text, excludeIds) {
7509
+ const want = String(cwd ?? "").trim();
7510
+ if (!want || !text.trim())
7511
+ return { ok: false, error: "回退目标或文本为空" };
7512
+ if (this.quiesced)
7513
+ return { ok: false, error: "服务器正忙(quiesced),请稍后重试" };
7514
+ const cands = [];
7515
+ for (const [clientId, cs] of this.clients) {
7516
+ try {
7517
+ const conv = cs.findViewportInCwd(want, excludeIds);
7518
+ if (conv)
7519
+ cands.push({ cs, clientId, conv });
7520
+ }
7521
+ catch {
7522
+ // 单客户端坏了继续找下一个
7523
+ }
7524
+ }
7525
+ cands.sort((a, b) => b.conv.lastActiveAt - a.conv.lastActiveAt);
7526
+ if (cands.length === 0)
7527
+ return { ok: false, error: "同项目无存活对话" };
7528
+ let busyError;
7529
+ for (const c of cands) {
7530
+ try {
7531
+ const r = await c.cs.trySteerScheduler(c.conv, text);
7532
+ if (r.ok) {
6888
7533
  try {
6889
- cs.flushSnapshot();
7534
+ c.cs.flushSnapshot();
6890
7535
  }
6891
7536
  catch {
6892
7537
  // 推送失败不影响已投递的唤醒
6893
7538
  }
6894
- return { ok: true, conversationId: id };
7539
+ let f = "";
7540
+ try {
7541
+ f = String(c.conv.session.sessionFile ?? "");
7542
+ }
7543
+ catch {
7544
+ f = "";
7545
+ }
7546
+ return { ok: true, conversationId: c.conv.id, sessionFile: f, clientId: c.clientId };
6895
7547
  }
7548
+ if (r.busy)
7549
+ busyError = r.error;
6896
7550
  }
6897
7551
  catch {
6898
7552
  // 单客户端坏了继续找下一个
6899
7553
  }
6900
7554
  }
6901
- return { ok: false, error: "目标对话不在运行中(已关闭或服务重启过)" };
7555
+ if (busyError)
7556
+ return { ok: false, busy: true, error: busyError };
7557
+ return { ok: false, error: "同项目对话投递失败" };
6902
7558
  }
6903
7559
  /** issue #145:别处在某 cwd 下正在跑的对话(同项目并行感知用,不含请求方)。 */
6904
7560
  listProjectRunners(cwd, excludeClientId) {
@@ -6985,7 +7641,11 @@ export class AgentService {
6985
7641
  * 快照/notice 发了即丢,不攒内存。fire-and-forget:prompt 投递即返回,
6986
7642
  * 运行结果经 onRunEvent(run_end) 按 conversationId 关联。
6987
7643
  * v1 语义:与该服务 cwd 下最近会话共享(单用户视角连续);peer 名由插件
6988
- * 拼进文本前缀,per-peer 会话隔离以后再加。 */
7644
+ * 拼进文本前缀,per-peer 会话隔离以后再加。
7645
+ * issue #226:对齐定时任务的四件套——conversationId 命中时走 steer 语义
7646
+ * 投递(网页端实时可见,miss 则回落无头);cwd 显式 pin 住(不存在/系统
7647
+ * 目录即拒绝,不默默跑错目录);model/thinkingLevel 投递前应用(失败即
7648
+ * 拒绝,不回落,避免账单/效果与预期不符)。 */
6989
7649
  async chatFromPlugin(pluginId, req) {
6990
7650
  const safe = String(pluginId ?? "plugin").replace(/[^A-Za-z0-9_-]/g, "") || "plugin";
6991
7651
  const acct = String(req?.accountId ?? "default").replace(/[^A-Za-z0-9_-]/g, "") || "default";
@@ -6995,10 +7655,52 @@ export class AgentService {
6995
7655
  throw new Error("chatFromPlugin: text 为空");
6996
7656
  if (this.quiesced)
6997
7657
  throw new QuiesceRejectedError("插件无头调用被拒绝,请等服务器恢复后重试");
7658
+ // 1. 绑定已有会话:steer 语义投递,网页端实时可见(微信当远程遥控器用)。
7659
+ // miss/已回收时不抛错,回落无头伪客户端(浏览器关着时微信照常可用)。
7660
+ const target = String(req?.conversationId ?? "").trim();
7661
+ if (target) {
7662
+ const w = await this.wakeConversation(target, text);
7663
+ if (w.ok)
7664
+ return { conversationId: target, clientId: w.clientId ?? clientId };
7665
+ }
7666
+ // 2. 工作空间:不传回落伪客户端当前目录;传了必须存在且非系统目录。
7667
+ const cwdReq = String(req?.cwd ?? "").trim();
7668
+ let cwdAbs = "";
7669
+ if (cwdReq) {
7670
+ const chk = checkPluginCwd(cwdReq);
7671
+ if (!chk.ok)
7672
+ throw new Error(`chatFromPlugin: ${chk.error}`);
7673
+ cwdAbs = chk.abs ?? "";
7674
+ }
6998
7675
  const cs = await this.attach(clientId, () => { });
6999
- const before = cs.readConversationForPlugins()?.conversationId ?? "";
7676
+ try {
7677
+ if (cwdAbs && cs.cwd !== cwdAbs)
7678
+ await cs.setCwd(cwdAbs);
7679
+ }
7680
+ catch (err) {
7681
+ throw new Error(`chatFromPlugin: 切换工作目录失败(${cwdAbs}):${err.message}`);
7682
+ }
7683
+ const model = String(req?.model ?? "").trim();
7684
+ if (model) {
7685
+ try {
7686
+ await cs.switchModelOrThrow(model);
7687
+ }
7688
+ catch (err) {
7689
+ throw new Error(`chatFromPlugin: 切换模型失败(${model}):${err.message}`);
7690
+ }
7691
+ }
7692
+ const thinking = String(req?.thinkingLevel ?? "").trim();
7693
+ if (thinking) {
7694
+ try {
7695
+ cs.setThinking(thinking);
7696
+ }
7697
+ catch (err) {
7698
+ throw new Error(`chatFromPlugin: 切换思考强度失败(${thinking}):${err.message}`);
7699
+ }
7700
+ }
7701
+ const conversationId = cs.readConversationForPlugins()?.conversationId ?? "";
7000
7702
  void cs.prompt(text);
7001
- return { conversationId: before, clientId };
7703
+ return { conversationId, clientId };
7002
7704
  }
7003
7705
  /** 内置定时任务的无头执行(issue #184,server/scheduler-tasks.ts 的 executor)。
7004
7706
  * 每个任务独立伪客户端 `scheduler:<taskId>`(专属会话连续、无浏览器也能跑);
@@ -7030,7 +7732,7 @@ export class AgentService {
7030
7732
  const model = String(task.model ?? "").trim();
7031
7733
  if (model) {
7032
7734
  try {
7033
- await cs.setModel(model);
7735
+ await cs.switchModelOrThrow(model);
7034
7736
  }
7035
7737
  catch (err) {
7036
7738
  return { ok: false, error: `切换模型失败(${model}):${err.message}` };