pi-web-ui 0.68.1 → 0.69.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 (50) hide show
  1. package/dist/server/agent-service.js +306 -102
  2. package/dist/server/attachments.js +7 -2
  3. package/dist/server/client-state.js +31 -2
  4. package/dist/server/dsh/dsh-agent-service.js +154 -39
  5. package/dist/server/dsh/dsh-client.js +9 -8
  6. package/dist/server/dsh/dsh-sessions.js +4 -3
  7. package/dist/server/dsh/runtime/runtime-root.mjs +17 -11
  8. package/dist/server/edit-soft-tool.js +33 -26
  9. package/dist/server/files-service.js +12 -7
  10. package/dist/server/goal-service.js +85 -24
  11. package/dist/server/i18n.js +157 -0
  12. package/dist/server/index.js +124 -6
  13. package/dist/server/locales.js +210 -0
  14. package/dist/server/managed.js +61 -0
  15. package/dist/server/marker-service.js +20 -7
  16. package/dist/server/markers/builtins/notify.js +19 -6
  17. package/dist/server/markers/builtins/rename.js +41 -8
  18. package/dist/server/markers/builtins/todo.js +107 -30
  19. package/dist/server/markers/registry.js +2 -2
  20. package/dist/server/mcp-bridge.js +3 -1
  21. package/dist/server/model-admin.js +25 -14
  22. package/dist/server/plugin-catalog.js +7 -3
  23. package/dist/server/plugin-updater.js +6 -2
  24. package/dist/server/plugins.js +40 -17
  25. package/dist/server/prompt-composer.js +76 -16
  26. package/dist/server/protocol-version.js +1 -1
  27. package/dist/server/scm.js +18 -25
  28. package/dist/server/serialize.js +1 -0
  29. package/dist/server/settings-service.js +29 -1
  30. package/dist/server/subagent-templates.js +105 -0
  31. package/dist/server/subagents.js +155 -54
  32. package/dist/server/tabs.js +87 -0
  33. package/dist/server/terminals.js +88 -48
  34. package/dist/server/update-check.js +56 -52
  35. package/dist/server/vision-bridge.js +34 -12
  36. package/package.json +4 -1
  37. package/web/dist/assets/TerminalPanel-Cj8zsjx-.js +6 -0
  38. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +32 -0
  39. package/web/dist/assets/index-DCOcsPFm.js +334 -0
  40. package/web/dist/assets/index-jH2Bb-0X.css +10 -0
  41. package/web/dist/assets/markdown-Cpo0pNcR.js +51 -0
  42. package/web/dist/assets/{react-C9ovnpIm.js → react-CtudoG1_.js} +2 -2
  43. package/web/dist/assets/xterm-B96xOxS9.js +38 -0
  44. package/web/dist/index.html +4 -4
  45. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +0 -32
  46. package/web/dist/assets/TerminalPanel-IJF_fssI.js +0 -6
  47. package/web/dist/assets/index-BmiyyjKp.css +0 -10
  48. package/web/dist/assets/index-qoTr5KXy.js +0 -332
  49. package/web/dist/assets/markdown-DRBrS2Nf.js +0 -51
  50. package/web/dist/assets/xterm-D1D2FVe3.js +0 -38
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { createRequire } from "node:module";
16
16
  import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
17
- import { basename, dirname, join, resolve, sep } from "node:path";
17
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
@@ -28,14 +28,15 @@ import { MarkerService } from "./marker-service.js";
28
28
  import { SlashCommandsService, parseSlash } from "./slash-commands.js";
29
29
  import { ModelAdminService } from "./model-admin.js";
30
30
  import { FilesService, MACHINE_ROOT, workspacePath } from "./files-service.js";
31
- import { isExtensionDisabled, isExtensionEnabled, ClientStateStore } from "./client-state.js";
32
- import { SubagentTemplatesStore } from "./subagent-templates.js";
31
+ import { isExtensionDisabled, isExtensionEnabled, normalizeRetryMaxAttempts, ClientStateStore, } from "./client-state.js";
32
+ import { pick, resolveServerLang } from "./i18n.js";
33
+ import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
33
34
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
34
35
  import { WebUIContext, mockThemeProxy } from "./webui-context.js";
35
36
  import { makeEditSoftTool, SOFT_EDIT_TOOL_NAME } from "./edit-soft-tool.js";
36
- import { makeSubagentTools, subagentTitle, } from "./subagents.js";
37
+ import { makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
37
38
  import { buildAttachmentMessages } from "./attachments.js";
38
- import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
39
+ import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
39
40
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
40
41
  import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
41
42
  const SNAPSHOT_INTERVAL_MS = 60;
@@ -196,11 +197,13 @@ function makeMarkersListTool(getActiveId, markerSvc) {
196
197
  return {
197
198
  name: "markers_list",
198
199
  label: "List marker state",
199
- description: "只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] 等)写在回答正文里,不要调用本工具做写操作。",
200
+ description: "Read-only query of inline marker state. All WRITE operations must use inline markers ([[todo:new:...]] etc.) in the reply body — never use this tool for writes.\n只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] 等)写在回答正文里,不要调用本工具做写操作。",
200
201
  parameters: Type.Object({
201
202
  action: Type.Unsafe({ enum: ["list"] }),
202
203
  tool: Type.Optional(Type.Literal("todo")),
203
- includeDeleted: Type.Optional(Type.Boolean({ description: "是否包含已删除任务(tombstone,仅 todo)" })),
204
+ includeDeleted: Type.Optional(Type.Boolean({
205
+ description: "Whether to include deleted tasks (tombstones, todo only).\n是否包含已删除任务(tombstone,仅 todo)。",
206
+ })),
204
207
  }),
205
208
  execute: async (_id, params) => {
206
209
  const p = params;
@@ -215,6 +218,67 @@ function makeMarkersListTool(getActiveId, markerSvc) {
215
218
  },
216
219
  };
217
220
  }
221
+ /**
222
+ * 标准 pi 引擎的 ask_user_question 工具:模型调用时把问题桥到浏览器(复用 DSH
223
+ * 引擎的 question_pending/question_answer 协议,前端 DshQuestionDialog 富渲染),
224
+ * 阻塞 agent 循环直到用户在浏览器回答或取消。
225
+ *
226
+ * 标准 SDK 没有内建 ask_user_question,故由 pi-web-ui 以 customTool 注册(与
227
+ * bash/edit 同机制)。DSH 引擎走 goal-rpc 的 userQuestions provider,两者互不
228
+ * 冲突(各引擎各走各的)。
229
+ *
230
+ * askUser 签名带 {aborted} 快照而非完整 AbortSignal:customTool 的 execute 信号
231
+ * 服务于整个 agent 生命周期,这里按「已中止即拒绝」的最小语义处理,避免与其它
232
+ * 工具的取消逻辑纠缠。
233
+ */
234
+ export function makeAskUserQuestionTool(clientSession) {
235
+ const QuestionOptionSchema = Type.Object({
236
+ label: Type.String({ description: "Display label for the option" }),
237
+ description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
238
+ preview: Type.Optional(Type.String({
239
+ description: "Optional preview rendered below when this option is selected (markdown or HTML — use for mockups/code/config).",
240
+ })),
241
+ });
242
+ const QuestionSchema = Type.Object({
243
+ id: Type.String({ description: "Unique identifier for this question" }),
244
+ question: Type.String({ description: "The full question text to display (markdown/HTML ok)" }),
245
+ detail: Type.Optional(Type.String({ description: "Optional detail/context shown under the question" })),
246
+ header: Type.Optional(Type.String({ description: "Optional short header for this question" })),
247
+ options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Available options to choose from" })),
248
+ multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
249
+ });
250
+ return {
251
+ name: "ask_user_question",
252
+ label: "Ask the user",
253
+ description: "Ask the user focused questions to pin down ambiguous requirements. Use for clarifying the task, confirming decisions, or getting preferences. Each question renders a browser dialog with markdown/HTML rich text; options may carry a `preview`. Submit or cancel to resume.",
254
+ parameters: Type.Object({
255
+ questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
256
+ }),
257
+ execute: async (_id, params, signal) => {
258
+ const qs = params.questions;
259
+ if (!Array.isArray(qs) || qs.length === 0) {
260
+ throw new Error("ask_user_question requires at least one question");
261
+ }
262
+ const answers = await clientSession.askUser(qs, {
263
+ aborted: signal?.aborted,
264
+ });
265
+ if (answers === null) {
266
+ throw new Error("User cancelled the question.\n用户取消了提问。");
267
+ }
268
+ // 工具结果:把每道题的回答拼成简洁文本给模型,同时留 details 供 UI 展示。
269
+ const lines = answers.map((a) => {
270
+ const q = qs.find((q) => q.id === a.id);
271
+ const label = a.selected.join(", ");
272
+ const custom = a.custom?.trim() ? ` (wrote: ${a.custom.trim()})` : "";
273
+ return `${q?.header ?? q?.id ?? a.id}: ${label || "(no selection)"}${custom}`;
274
+ });
275
+ return {
276
+ content: [{ type: "text", text: lines.join("\n") }],
277
+ details: { answers },
278
+ };
279
+ },
280
+ };
281
+ }
218
282
  /**
219
283
  * 插件结构化工具 → SDK ToolDefinition。
220
284
  * execute 返回值宽容处理:{content,details} 原样收编;字符串/对象包成文本块。
@@ -514,6 +578,8 @@ export class ClientSession {
514
578
  isDisposed: () => this.disposed,
515
579
  getCwd: () => this.cwd,
516
580
  getActiveCwd: () => this.conv?.cwd ?? this.cwd,
581
+ // issue #91:文件服务错误文案按客户端 UI 语言出中英(英文默认)。
582
+ getLang: () => this.getLang(),
517
583
  });
518
584
  bg = new BgServerTracker({
519
585
  emit: (msg) => this.emit(msg),
@@ -568,7 +634,9 @@ export class ClientSession {
568
634
  return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
569
635
  }
570
636
  makeTerminalManager(conversationId, cwd) {
571
- const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
637
+ const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd,
638
+ // issue #91:终端输入错误按客户端 UI 语言出中英(英文默认)。
639
+ () => this.getLang());
572
640
  // 终端活力检测:AI 触碰过的终端静默 ≥ 阈值(PI_WEB_TERMINAL_IDLE_MS,
573
641
  // 默认 15s)且该对话正在运行时,注入一条 steer 消息唤醒 AI 去检查。
574
642
  mgr.onAgentIdle = (terminalId, idleMs, title, lastLines) => this.notifyTerminalIdle(conversationId, terminalId, idleMs, title, lastLines);
@@ -638,18 +706,20 @@ export class ClientSession {
638
706
  * `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
639
707
  * 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
640
708
  * runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
641
- async spawnSubagentConversation(prompt, type, cwd, apply, model) {
709
+ async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
642
710
  const conversationId = `sa-${randomUUID().slice(0, 8)}`;
643
711
  const terminals = this.makeTerminalManager(conversationId, cwd);
644
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply), {
712
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
645
713
  cwd,
646
714
  agentDir: this.agentDir,
647
715
  sessionManager: SessionManager.inMemory(cwd),
648
716
  });
649
717
  const conv = this.makeConversation(runtime, conversationId, terminals);
650
718
  conv.isSubagent = true;
651
- // 派发时刻的 active 即父对话(子代理也可再派发,自然嵌套)。
652
- conv.parentId = this.activeId || undefined;
719
+ // 父对话 = 真正派发它的会话(按会话归属的 host 包装填入)。直接用 active
720
+ // 会错:后台对话运行时用户可能正看着别的项目对话,孩子会被记到无关
721
+ // 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
722
+ conv.parentId = parentId ?? this.activeId ?? undefined;
653
723
  conv.subagentType = type;
654
724
  conv.listed = true;
655
725
  conv.title = subagentTitle(prompt);
@@ -842,6 +912,8 @@ export class ClientSession {
842
912
  windowsPersona: process.platform === "win32" ? WINDOWS_PERSONA : "",
843
913
  terminalGuidance: this.settingsSvc.current.terminalToolsEnabled !== false ? TERMINAL_TOOLS_GUIDANCE : "",
844
914
  markersGuidance: this.markerSvc.buildGuidance(),
915
+ // issue #91:组合模板各来源段按客户端 UI 语言渲染(英文默认)。
916
+ lang: this.getLang(),
845
917
  contextFiles: src.contextFiles,
846
918
  skills: src.skills,
847
919
  };
@@ -868,6 +940,7 @@ export class ClientSession {
868
940
  const active = sess.getActiveToolNames();
869
941
  const snippets = {};
870
942
  const guidelines = [];
943
+ const schemaEntries = [];
871
944
  for (const name of active) {
872
945
  const def = sess.getToolDefinition(name);
873
946
  if (!def)
@@ -876,6 +949,11 @@ export class ClientSession {
876
949
  snippets[name] = def.promptSnippet;
877
950
  if (def.promptGuidelines)
878
951
  guidelines.push(...def.promptGuidelines);
952
+ schemaEntries.push({
953
+ name,
954
+ description: def.description,
955
+ parameters: def.parameters,
956
+ });
879
957
  }
880
958
  const loader = sess.resourceLoader;
881
959
  const texts = resolveSectionTexts(this.composeInputs({
@@ -895,7 +973,7 @@ export class ClientSession {
895
973
  const ovs = this.settingsSvc.current.promptOverrides ?? {};
896
974
  const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
897
975
  const rendered = !tpl && !hasOverride ? undefined : renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, ovs);
898
- return { texts, full: rendered ?? sess.systemPrompt };
976
+ return { texts, full: rendered ?? sess.systemPrompt, toolsSchema: buildToolsSchemaText(schemaEntries) };
899
977
  }
900
978
  catch {
901
979
  // Session not ready yet.
@@ -905,7 +983,7 @@ export class ClientSession {
905
983
  /** 设置面板预览用的 host 回调(见 SettingsHost.promptSnapshot):完整生效提示词
906
984
  * + 各来源默认(自动)内容。会话未就绪时给空值,面板保持可编辑但不预览。 */
907
985
  promptSnapshot() {
908
- return this.sessionPromptSnapshot() ?? { full: "", texts: {} };
986
+ return this.sessionPromptSnapshot() ?? { full: "", texts: {}, toolsSchema: "" };
909
987
  }
910
988
  /** Web-facing extension UI context (widgets, notifications). */
911
989
  webUi = new WebUIContext((msg) => this.emit(msg));
@@ -916,14 +994,14 @@ export class ClientSession {
916
994
  * 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
917
995
  */
918
996
  subagentHost = {
919
- spawnSubagent: (prompt, type, cwd, templateName, model) => {
997
+ spawnSubagent: (prompt, type, cwd, templateName, model, parentId) => {
920
998
  // 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
921
999
  const tpl = templateName ? this.subagentTemplates.get(templateName) : undefined;
922
1000
  if (templateName && (!tpl || !tpl.enabled)) {
923
- throw new Error(`子代理模板不可用:${templateName}(不存在或已停用)`);
1001
+ throw new Error(pick(this.getLang(), `子代理模板不可用:${templateName}(不存在或已停用)`, `Subagent template unavailable: ${templateName} (missing or disabled)`, "agent.subagent.template.unavailable", { templateName: templateName }));
924
1002
  }
925
1003
  // 模型优先级:显式 model 参数 > 模板自带模型 > 设置面板默认模型;都不给 = 跟随主对话。
926
- return this.spawnSubagentConversation(prompt, type, cwd, tpl, model);
1004
+ return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId);
927
1005
  },
928
1006
  getSubagent: (convId) => this.getSubagentSnapshot(convId),
929
1007
  listSubagents: () => this.listSubagentSnapshots(),
@@ -936,14 +1014,16 @@ export class ClientSession {
936
1014
  stopSubagent: async (convId) => {
937
1015
  const conv = this.convs.get(convId);
938
1016
  if (conv && (conv.session.isStreaming || !conv.session.isIdle)) {
939
- await this.interruptRun(conv, "用户停止子代理");
1017
+ await this.interruptRun(conv, pick(this.getLang(), "用户停止子代理", "User stopped the subagent", "agent.subagent.stop.user"));
940
1018
  }
941
1019
  },
1020
+ // issue #91:子代理工具返回按客户端 UI 语言出中英(英文默认)。
1021
+ lang: () => this.getLang(),
942
1022
  // 只向 AI 暴露 enabled 的模板(停用的对 AI 不可见)。
943
1023
  listTemplates: () => this.subagentTemplates
944
1024
  .list()
945
1025
  .filter((t) => t.enabled)
946
- .map((t) => ({ name: t.name, description: t.description, model: t.model })),
1026
+ .map((t) => ({ name: t.name, description: t.description, descriptionEn: t.descriptionEn, model: t.model })),
947
1027
  isTemplateUsable: (name) => {
948
1028
  const t = this.subagentTemplates.get(name);
949
1029
  return !!t && t.enabled;
@@ -1000,6 +1080,14 @@ export class ClientSession {
1000
1080
  subagentTemplates;
1001
1081
  /** 内置标记服务(todo/notify/svc/rename 等,可全局/分组开关)。 */
1002
1082
  markerSvc;
1083
+ // -----------------------------------------------------------------------
1084
+ // 用户提问桥(标准 pi 引擎的 ask_user_question customTool):与 DSH 引擎的
1085
+ // question_pending/question_answer 同协议。模型调 ask_user_question 工具 →
1086
+ // 本桥发 question_pending 给浏览器 → 等 question_answer → resolve/reject
1087
+ // 工具结果(agent 循环阻塞)。一次只展示一个提问(agent 阻塞在工具执行)。
1088
+ // -----------------------------------------------------------------------
1089
+ questionSeq = 0;
1090
+ pendingQuestions = new Map();
1003
1091
  constructor(clientId, cwd, agentDir, stateStore) {
1004
1092
  this.clientId = clientId;
1005
1093
  this.cwd = cwd;
@@ -1022,6 +1110,8 @@ export class ClientSession {
1022
1110
  // 复用现有重命名路径(内存标题 + 磁盘 session_info)
1023
1111
  void this.renameConversation(convId, title);
1024
1112
  },
1113
+ // issue #91:标记引导/错误按客户端 UI 语言出中英(英文默认)。
1114
+ lang: () => this.getLang(),
1025
1115
  });
1026
1116
  this.settingsSvc = new SettingsService({
1027
1117
  clientId,
@@ -1035,10 +1125,14 @@ export class ClientSession {
1035
1125
  isStreaming: () => this.session.isStreaming,
1036
1126
  reloadSession: async () => {
1037
1127
  await this.session.reload();
1128
+ // reload() 重读磁盘 settings.json,会丢掉内存 applyOverrides
1129
+ // (含重试次数覆盖)——依次重放:重试覆盖 → 终端门控。
1130
+ this.applyRetryOverrides();
1038
1131
  // reload() 会把 custom 工具重新加回活跃集——重放终端开关。
1039
1132
  this.applyToolGating(this.session);
1040
1133
  await this.pushSlashCommands();
1041
1134
  },
1135
+ applyRetryOverrides: () => this.applyRetryOverrides(),
1042
1136
  promptSnapshot: () => this.promptSnapshot(),
1043
1137
  getMarkerState: () => ({
1044
1138
  markersEnabled: this.markerSvc.current.markersEnabled,
@@ -1055,6 +1149,10 @@ export class ClientSession {
1055
1149
  flushSnapshot: () => this.flushSnapshot(),
1056
1150
  isDisposed: () => this.disposed,
1057
1151
  quiesceBlocked: () => this.quiesceBlocked(),
1152
+ // issue #91:目标/审查文案按客户端 UI 语言出中英(英文默认)。
1153
+ lang: () => this.getLang(),
1154
+ // 目标模式总开关(设置面板「目标审查」页):关 → 目标入口一律拒绝。
1155
+ goalModeEnabled: () => this.settingsSvc.current.goalModeEnabled !== false,
1058
1156
  activeConvId: () => this.activeId,
1059
1157
  activeConv: () => this.conv,
1060
1158
  getConv: (id) => this.convs.get(id),
@@ -1082,7 +1180,7 @@ export class ClientSession {
1082
1180
  const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
1083
1181
  const conversationId = cs.nextConversationId();
1084
1182
  const terminals = cs.makeTerminalManager(conversationId, cwd);
1085
- const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals), {
1183
+ const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals, undefined, conversationId), {
1086
1184
  cwd,
1087
1185
  agentDir,
1088
1186
  // Resume the most recent session for this project — the SDK default
@@ -1120,7 +1218,7 @@ export class ClientSession {
1120
1218
  * 应用(prompt replace/append + 白名单),其余(终端接管、Windows persona
1121
1219
  * 等)仍跟随主会话设置。undefined = 按主会话设置(普通对话/不选模板的子代理)。
1122
1220
  */
1123
- makeRuntimeFactory(terminals, apply) {
1221
+ makeRuntimeFactory(terminals, apply, ownerId) {
1124
1222
  return async ({ cwd: effectiveCwd, sessionManager }) => {
1125
1223
  const services = await createAgentSessionServices({
1126
1224
  cwd: effectiveCwd,
@@ -1143,8 +1241,8 @@ export class ClientSession {
1143
1241
  systemPromptOverride: (base) => {
1144
1242
  if (typeof base === "string" && base) {
1145
1243
  this.lastBaseSystemPrompt = base;
1146
- if (apply && apply.promptMode === "replace" && apply.systemPrompt.trim()) {
1147
- return apply.systemPrompt;
1244
+ if (apply && apply.promptMode === "replace" && pickTemplatePrompt(apply, this.getLang()).trim()) {
1245
+ return pickTemplatePrompt(apply, this.getLang());
1148
1246
  }
1149
1247
  }
1150
1248
  return undefined;
@@ -1154,8 +1252,8 @@ export class ClientSession {
1154
1252
  if (!apply)
1155
1253
  this.lastSdkAppendFiles = base.slice();
1156
1254
  const out = [...base];
1157
- if (apply && apply.promptMode === "append" && apply.systemPrompt.trim()) {
1158
- out.push(apply.systemPrompt);
1255
+ if (apply && apply.promptMode === "append" && pickTemplatePrompt(apply, this.getLang()).trim()) {
1256
+ out.push(pickTemplatePrompt(apply, this.getLang()));
1159
1257
  }
1160
1258
  // 主会话自定义「追加」已并入组合模板的 {{append}} 覆盖,不再在此注入。
1161
1259
  if (process.platform === "win32") {
@@ -1219,12 +1317,12 @@ export class ClientSession {
1219
1317
  // 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
1220
1318
  // systemPromptOverride 整体替换,此处边界不存在会自然跳过。
1221
1319
  if (apply) {
1222
- if (apply.promptMode !== "replace" || !apply.systemPrompt.trim())
1320
+ if (apply.promptMode !== "replace" || !pickTemplatePrompt(apply, this.getLang()).trim())
1223
1321
  return undefined;
1224
1322
  const boundary = event.systemPrompt.indexOf("\n\nAvailable tools:");
1225
1323
  if (boundary === -1)
1226
1324
  return undefined;
1227
- const swapped = apply.systemPrompt.trimEnd() + event.systemPrompt.slice(boundary);
1325
+ const swapped = pickTemplatePrompt(apply, this.getLang()).trimEnd() + event.systemPrompt.slice(boundary);
1228
1326
  return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
1229
1327
  }
1230
1328
  // 主会话:组合模板渲染(模板为空且无覆盖时返回 undefined = 用 SDK 默认)。
@@ -1262,20 +1360,32 @@ export class ClientSession {
1262
1360
  idleMs: () => Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) || 0),
1263
1361
  kills: this.bashKills,
1264
1362
  notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
1363
+ // issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
1364
+ lang: () => this.getLang(),
1265
1365
  }),
1266
1366
  // 设置关 → 原生 bash;开 → 终端 bash。
1267
1367
  () => this.settingsSvc.current.terminalBash),
1268
- ...makePersistentTerminalTools(terminals, effectiveCwd),
1368
+ ...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang()),
1269
1369
  // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置)。
1270
- makeEditSoftTool(effectiveCwd),
1370
+ makeEditSoftTool(effectiveCwd, () => this.getLang()),
1271
1371
  // 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
1272
1372
  // refreshPluginTools 动态补入已有会话)。
1273
1373
  ...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
1274
1374
  // 第一方子代理工具(spawn/get_result/steer/list/stop)。子代理会话
1275
- // 也注册了它们,因此可自然嵌套派发。
1276
- ...makeSubagentTools(this.subagentHost),
1375
+ // 也注册了它们,因此可自然嵌套派发。host 按 ownerId 包装:子代理的
1376
+ // 父对话 = 真正调用 spawn 的那个会话(本 runtime 所属会话),而不是
1377
+ // 派发瞬间的 active——后台对话继续产出时用户可能已切到别的项目,用
1378
+ // activeId 会把孩子记到无关会话名下、沉到别的组/底部(issue #95)。
1379
+ // ownerId 即本 runtime 所属会话(创建时就已知,见各调用点)。
1380
+ ...(ownerId
1381
+ ? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId))
1382
+ : makeSubagentTools(this.subagentHost)),
1277
1383
  // 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
1278
1384
  makeMarkersListTool(() => this.activeId, this.markerSvc),
1385
+ // 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
1386
+ // 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
1387
+ // DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
1388
+ makeAskUserQuestionTool(this),
1279
1389
  ],
1280
1390
  });
1281
1391
  // 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
@@ -1697,18 +1807,17 @@ export class ClientSession {
1697
1807
  conv.queueSteering = [...event.steering];
1698
1808
  conv.queueFollowUp = [...event.followUp];
1699
1809
  break;
1700
- // 手动 /compact 或阈值/溢出自动压缩开始——立即反馈,避免「没反应」
1701
- // (此前 compaction_start/end 事件被 switch 静默丢弃,issue #33)。
1810
+ // 手动 /compact 或阈值/溢出自动压缩开始——常驻进度条(快照 compaction
1811
+ // 字段),而不是一次性 toast(toast 几秒就消失,而摘要生成可能持续
1812
+ // 数十秒,用户会以为「没反应」)。立即 flush 让进度条第一时间出现。
1702
1813
  case "compaction_start": {
1703
- this.emit({
1704
- type: "notice",
1705
- level: "info",
1706
- text: "正在压缩上下文…(压缩摘要将显示在消息区)",
1707
- textEn: "Compacting context… (the summary will appear in the message list)",
1708
- });
1814
+ conv.compactionState = { reason: event.reason, startedAt: Date.now() };
1815
+ conv.lastCompactionTokens = null;
1816
+ this.flushSnapshot();
1709
1817
  break;
1710
1818
  }
1711
1819
  case "compaction_end": {
1820
+ conv.compactionState = null;
1712
1821
  if (event.errorMessage) {
1713
1822
  this.emit({
1714
1823
  type: "notice",
@@ -1728,6 +1837,8 @@ export class ClientSession {
1728
1837
  else if (event.result) {
1729
1838
  const { tokensBefore, estimatedTokensAfter } = event.result;
1730
1839
  const after = estimatedTokensAfter ?? tokensBefore;
1840
+ // 记住压缩后大小:SDK 在下轮响应前报 null,快照用此回填底栏。
1841
+ conv.lastCompactionTokens = estimatedTokensAfter ?? null;
1731
1842
  this.emit({
1732
1843
  type: "notice",
1733
1844
  level: "info",
@@ -2065,13 +2176,25 @@ export class ClientSession {
2065
2176
  totalMessages: s.totalMessages,
2066
2177
  tokens: s.tokens,
2067
2178
  cost: s.cost,
2068
- contextUsage: s.contextUsage
2069
- ? {
2070
- tokens: s.contextUsage.tokens,
2071
- contextWindow: s.contextUsage.contextWindow,
2072
- percent: s.contextUsage.percent,
2179
+ contextUsage: (() => {
2180
+ const cu = s.contextUsage;
2181
+ if (!cu)
2182
+ return stats.contextUsage;
2183
+ // 压缩刚结束、下轮响应未到:SDK 报 null,用压缩结果回填约数。
2184
+ if (cu.tokens == null && conv.lastCompactionTokens != null && cu.contextWindow > 0) {
2185
+ return {
2186
+ tokens: conv.lastCompactionTokens,
2187
+ contextWindow: cu.contextWindow,
2188
+ percent: (conv.lastCompactionTokens / cu.contextWindow) * 100,
2189
+ estimated: true,
2190
+ };
2073
2191
  }
2074
- : stats.contextUsage,
2192
+ return {
2193
+ tokens: cu.tokens,
2194
+ contextWindow: cu.contextWindow,
2195
+ percent: cu.percent,
2196
+ };
2197
+ })(),
2075
2198
  };
2076
2199
  }
2077
2200
  catch {
@@ -2108,6 +2231,7 @@ export class ClientSession {
2108
2231
  queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
2109
2232
  errorMessage: state.errorMessage,
2110
2233
  retry: conv.retryState ?? null,
2234
+ compaction: conv.compactionState ?? null,
2111
2235
  tools: state.tools.map((t) => t.name),
2112
2236
  version: ++this.version,
2113
2237
  piConfigured: this.isPiConfigured(),
@@ -2168,6 +2292,55 @@ export class ClientSession {
2168
2292
  resolveDialog(id, value) {
2169
2293
  this.webUi.resolveDialog(id, value);
2170
2294
  }
2295
+ // -----------------------------------------------------------------------
2296
+ // 用户提问桥(标准 pi 引擎 ask_user_question customTool)
2297
+ // -----------------------------------------------------------------------
2298
+ /** 标准引擎模型调 ask_user_question:发 question_pending 给浏览器并阻塞等待
2299
+ * question_answer。sig 为工具执行信号的当前状态(aborted → 立即 reject)。
2300
+ * 返回 answers(用户选中/自定义),或 null(用户取消)。 */
2301
+ askUser(questions, sig) {
2302
+ return new Promise((resolve, reject) => {
2303
+ if (sig?.aborted || this.disposed) {
2304
+ reject(new Error("ask_user_question 已中止"));
2305
+ return;
2306
+ }
2307
+ // 问卷开关(默认开):关 → 不弹对话框,立即报错让模型得知已禁用。
2308
+ if (this.settingsSvc.current.questionnaireEnabled === false) {
2309
+ reject(new Error("问卷功能已关闭,可在设置中重新开启"));
2310
+ return;
2311
+ }
2312
+ const id = `q-${++this.questionSeq}`;
2313
+ this.pendingQuestions.set(id, resolve);
2314
+ this.emit({
2315
+ type: "question_pending",
2316
+ id,
2317
+ questions,
2318
+ });
2319
+ });
2320
+ }
2321
+ /** 前端回答模型提问(question_answer → 恢复 askUser 的 Promise)。id 需匹配
2322
+ * pendingQuestions 中键;cancelled 或未匹配(例如用户早已切走)时按「取消」处理
2323
+ * —— 把挂起的提问全部 reject,让模型知道用户离开了。 */
2324
+ resolveQuestion(id, answers, cancelled) {
2325
+ const resolve = this.pendingQuestions.get(id);
2326
+ if (resolve) {
2327
+ this.pendingQuestions.delete(id);
2328
+ resolve(cancelled ? null : answers);
2329
+ }
2330
+ }
2331
+ /** 标准引擎的 question_answer 路由入口(index.ts 经 cs.answerQuestion?. 转发)。
2332
+ * DSH 引擎的 AgentService 也实现了同名方法,此处为 ClientSession 的转发。 */
2333
+ answerQuestion(id, answers, cancelled) {
2334
+ this.resolveQuestion(id, answers, cancelled);
2335
+ return Promise.resolve();
2336
+ }
2337
+ /** 关闭所有挂起提问(切对话 / dispose 时清理):以「取消」解析,避免模型挂死。 */
2338
+ cancelPendingQuestions() {
2339
+ for (const [, resolve] of this.pendingQuestions) {
2340
+ resolve(null);
2341
+ }
2342
+ this.pendingQuestions.clear();
2343
+ }
2171
2344
  /**
2172
2345
  * Whether the pi agent has at least one usable model. ModelRuntime's
2173
2346
  * available snapshot already accounts for models.json, auth.json, env-var
@@ -2188,65 +2361,32 @@ export class ClientSession {
2188
2361
  * probe). Cached machine-wide (same binary for every client) for 10s —
2189
2362
  * the check is only rerun after install or when the cache expires.
2190
2363
  *
2191
- * The probe runs ASYNCHRONOUSLY in the background: this getter serves the
2192
- * last known value and never blocks the event loop. It previously used
2193
- * spawnSync here, which could deadlock the whole server on Android/Termux:
2194
- * fork() inside a multi-threaded process (websocket handlers + snapshot
2195
- * serialization are active) occasionally left the forked child stuck
2196
- * between fork and exec (futex_wait) while the blocked main thread sat in
2197
- * spawnSync's pipe_read — the server kept running but stopped accepting
2198
- * connections. Observed reproducibly on Android/Termux (Node 26).
2364
+ * The probe is FORK-FREE: it scans PATH for the pi executable instead of
2365
+ * spawning `pi --version`. Do not reintroduce a spawn here ANY fork on
2366
+ * the main thread of this multi-threaded server can deadlock the whole
2367
+ * process on Android/Termux (issue #78): libuv's uv_spawn blocks its
2368
+ * caller reading the child's error pipe, and that pipe never closes when
2369
+ * the forked child deadlocks between fork and exec. This applies to
2370
+ * asynchronous spawns too — the previous async probe reproduced the hang.
2199
2371
  */
2200
2372
  static piCliProbe = null;
2201
- static piCliProbePending = false;
2202
2373
  static PI_CLI_PROBE_TTL_MS = 10_000;
2203
2374
  isPiCliInstalled() {
2204
2375
  const now = Date.now();
2205
2376
  const cached = ClientSession.piCliProbe;
2206
2377
  if (cached && now - cached.at < ClientSession.PI_CLI_PROBE_TTL_MS)
2207
2378
  return cached.installed;
2208
- ClientSession.refreshPiCliProbe();
2209
- return cached?.installed ?? false;
2379
+ const installed = ClientSession.piCliOnPath();
2380
+ ClientSession.piCliProbe = { at: now, installed };
2381
+ return installed;
2210
2382
  }
2211
- static refreshPiCliProbe() {
2212
- if (ClientSession.piCliProbePending)
2213
- return;
2214
- ClientSession.piCliProbePending = true;
2215
- let proc;
2216
- try {
2217
- proc = spawn("pi", ["--version"], {
2218
- stdio: "ignore",
2219
- // Windows: `pi` resolves to a pi.cmd shim — spawn can only
2220
- // exec those through a shell (else ENOENT).
2221
- shell: process.platform === "win32",
2222
- });
2383
+ static piCliOnPath() {
2384
+ const dirs = (process.env.PATH ?? "").split(delimiter);
2385
+ for (const dir of dirs) {
2386
+ if (dir && existsSync(join(dir, "pi")))
2387
+ return true;
2223
2388
  }
2224
- catch {
2225
- ClientSession.piCliProbe = { at: Date.now(), installed: false };
2226
- ClientSession.piCliProbePending = false;
2227
- return;
2228
- }
2229
- const finish = (installed) => {
2230
- ClientSession.piCliProbe = { at: Date.now(), installed };
2231
- ClientSession.piCliProbePending = false;
2232
- };
2233
- const timer = setTimeout(() => {
2234
- try {
2235
- proc.kill();
2236
- }
2237
- catch {
2238
- /* already exited */
2239
- }
2240
- finish(false);
2241
- }, 5000);
2242
- proc.on("error", () => {
2243
- clearTimeout(timer);
2244
- finish(false);
2245
- });
2246
- proc.on("close", (code) => {
2247
- clearTimeout(timer);
2248
- finish(code === 0);
2249
- });
2389
+ return false;
2250
2390
  }
2251
2391
  static invalidatePiCliProbe() {
2252
2392
  ClientSession.piCliProbe = null;
@@ -2371,7 +2511,7 @@ export class ClientSession {
2371
2511
  }
2372
2512
  try {
2373
2513
  const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion());
2374
- const items = await checkAllUpdates(targets);
2514
+ const items = await checkAllUpdates(targets, undefined, () => this.getLang());
2375
2515
  this.updatesAllCache = { at: Date.now(), items };
2376
2516
  this.emit({ type: "update_status_all", items });
2377
2517
  }
@@ -2486,7 +2626,11 @@ export class ClientSession {
2486
2626
  this.flushSnapshot();
2487
2627
  },
2488
2628
  refreshSessions: () => this.refreshSessions(),
2489
- afterReload: () => this.applyToolGating(this.session),
2629
+ afterReload: () => {
2630
+ // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 终端门控。
2631
+ this.applyRetryOverrides();
2632
+ this.applyToolGating(this.session);
2633
+ },
2490
2634
  pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
2491
2635
  execPluginCommand: async (name, args) => {
2492
2636
  const def = this.pluginCommandsProvider?.().find((c) => c.name === name);
@@ -2531,10 +2675,10 @@ export class ClientSession {
2531
2675
  return this.modelAdmin.listModelsConfig();
2532
2676
  }
2533
2677
  fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
2534
- return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api);
2678
+ return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
2535
2679
  }
2536
2680
  refreshProviderModels(providerId, reqId) {
2537
- return this.modelAdmin.refreshProviderModels(providerId, reqId);
2681
+ return this.modelAdmin.refreshProviderModels(providerId, reqId, () => this.getLang());
2538
2682
  }
2539
2683
  /** Copy a built-in provider into an editable custom-provider draft
2540
2684
  * (clone_provider_result) — lets the user run a second API key without
@@ -2671,6 +2815,22 @@ export class ClientSession {
2671
2815
  pushSettings() {
2672
2816
  this.settingsSvc.push();
2673
2817
  }
2818
+ /** 把设置面板的出错重试次数注入全部存活会话的 SDK SettingsManager。
2819
+ * applyOverrides 只改内存合并视图(不碰 ~/.pi/agent/settings.json),
2820
+ * 且 SDK 每次退避前都重读 getRetrySettings()——即时生效、无需 reload。
2821
+ * 但 session.reload() 会重读磁盘丢掉覆盖,每次 reload 后必须重放
2822
+ * (reloadSession / afterReload / 标记开关直载路径均已接)。 */
2823
+ applyRetryOverrides() {
2824
+ const n = normalizeRetryMaxAttempts(this.settingsSvc.current.retryMaxAttempts);
2825
+ for (const c of this.convs.values()) {
2826
+ try {
2827
+ c.session.settingsManager.applyOverrides({ retry: { maxRetries: n } });
2828
+ }
2829
+ catch {
2830
+ // 会话未就绪或已释放 → 其 runtime 创建时统一注入。
2831
+ }
2832
+ }
2833
+ }
2674
2834
  /** Extensions/skills changed externally (e.g. `pi remove` finished in the
2675
2835
  * terminal): re-run session.reload() and re-push state. Streaming-safe —
2676
2836
  * deferred to agent_end, same as settings reloads. */
@@ -2697,6 +2857,7 @@ export class ClientSession {
2697
2857
  if (!this.session.isStreaming) {
2698
2858
  try {
2699
2859
  await this.session.reload();
2860
+ this.applyRetryOverrides();
2700
2861
  this.applyToolGating(this.session);
2701
2862
  await this.pushSlashCommands();
2702
2863
  this.pushSettings();
@@ -2775,6 +2936,26 @@ export class ClientSession {
2775
2936
  // 兼容旧入口:reload + 刷目录在宿主回调里完成
2776
2937
  return this.settingsSvc.applyRuntime();
2777
2938
  }
2939
+ /** Server language for this client (issue #91): resolved LIVE from the
2940
+ * persisted UI locale — "zh" only for zh*; everything else (including
2941
+ * never-reported) is English. Per-call tool return values read this on
2942
+ * every invocation, so they follow language switches with no rebuild. */
2943
+ getLang() {
2944
+ return resolveServerLang(this.stateStore.get(this.clientId).locale);
2945
+ }
2946
+ /** Persist the browser UI locale (hello.locale / set_locale) and refresh
2947
+ * lang-aware prompt segments. Reuses the settings reload path, so it is
2948
+ * streaming-safe (deferred to agent_end mid-run, same as settings). */
2949
+ async setLocale(locale) {
2950
+ const code = locale.trim().slice(0, 16);
2951
+ if (!code)
2952
+ return;
2953
+ const prev = this.getLang();
2954
+ this.stateStore.saveLocale(this.clientId, code);
2955
+ if (this.getLang() === prev)
2956
+ return; // same server language — nothing to re-render
2957
+ await this.applySettingsReload();
2958
+ }
2778
2959
  // ---------------------------------------------------------------------------
2779
2960
  // Commands
2780
2961
  // ---------------------------------------------------------------------------
@@ -2868,6 +3049,8 @@ export class ClientSession {
2868
3049
  emit: (msg) => this.emit(msg),
2869
3050
  settings: this.settingsSvc.current,
2870
3051
  session: this.session,
3052
+ // issue #91:附件/视觉桥文案按客户端 UI 语言出中英(英文默认)。
3053
+ getLang: () => this.getLang(),
2871
3054
  }, attachments);
2872
3055
  for (const aside of asides) {
2873
3056
  await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
@@ -3129,7 +3312,7 @@ export class ClientSession {
3129
3312
  this.clearAllToolWatchdogs(conv);
3130
3313
  conv.toolStartTimes.clear();
3131
3314
  await conv.runtime.dispose();
3132
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals), {
3315
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
3133
3316
  cwd: conv.cwd,
3134
3317
  agentDir: this.agentDir,
3135
3318
  sessionManager: SessionManager.continueRecent(conv.cwd),
@@ -3208,7 +3391,7 @@ export class ClientSession {
3208
3391
  try {
3209
3392
  const conversationId = this.nextConversationId();
3210
3393
  const terminals = this.makeTerminalManager(conversationId, this.cwd);
3211
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
3394
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
3212
3395
  cwd: this.cwd,
3213
3396
  agentDir: this.agentDir,
3214
3397
  sessionManager: SessionManager.create(this.cwd),
@@ -3762,7 +3945,7 @@ export class ClientSession {
3762
3945
  const targetCwd = sessionManager.getCwd();
3763
3946
  const conversationId = this.nextConversationId();
3764
3947
  openedTerminals = this.makeTerminalManager(conversationId, targetCwd);
3765
- openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals), {
3948
+ openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals, undefined, conversationId), {
3766
3949
  cwd: targetCwd,
3767
3950
  agentDir: this.agentDir,
3768
3951
  sessionManager,
@@ -4114,7 +4297,7 @@ export class ClientSession {
4114
4297
  // First visit to this project: resume its most recent session.
4115
4298
  const conversationId = this.nextConversationId();
4116
4299
  const terminals = this.makeTerminalManager(conversationId, abs);
4117
- const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
4300
+ const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
4118
4301
  cwd: abs,
4119
4302
  agentDir: this.agentDir,
4120
4303
  sessionManager: SessionManager.continueRecent(abs),
@@ -4327,6 +4510,8 @@ export class ClientSession {
4327
4510
  this.files.unwatchDir();
4328
4511
  this.files.unwatchGit();
4329
4512
  this.webUi.dispose();
4513
+ // 关闭所有挂起的用户提问(dispose 时以「取消」解析,避免模型挂死)。
4514
+ this.cancelPendingQuestions();
4330
4515
  this.bg.stop();
4331
4516
  for (const conv of this.convs.values()) {
4332
4517
  this.clearAllToolWatchdogs(conv);
@@ -4518,6 +4703,25 @@ export class AgentService {
4518
4703
  for (const cs of this.clients.values())
4519
4704
  cs.refreshPluginTools();
4520
4705
  }
4706
+ /** Browser UI locale report (hello.locale / set_locale): persist per client
4707
+ * and refresh lang-aware prompts (streaming-safe via ClientSession). */
4708
+ async setLocale(clientId, locale) {
4709
+ const cs = this.clients.get(clientId);
4710
+ if (cs) {
4711
+ await cs.setLocale(locale);
4712
+ return;
4713
+ }
4714
+ // hello race: session still being created — wait for it, then apply.
4715
+ const inflight = this.pending.get(clientId);
4716
+ if (inflight) {
4717
+ try {
4718
+ await (await inflight).setLocale(locale);
4719
+ }
4720
+ catch {
4721
+ /* attach failed — nothing to apply to */
4722
+ }
4723
+ }
4724
+ }
4521
4725
  /** 插件斜杠命令集合变化时由 index.ts 触发:重推各客户端的命令目录。 */
4522
4726
  applyPluginCommandCatalog() {
4523
4727
  for (const cs of this.clients.values())