pi-web-ui 0.68.2 → 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 (42) hide show
  1. package/dist/server/agent-service.js +157 -50
  2. package/dist/server/attachments.js +7 -2
  3. package/dist/server/client-state.js +27 -0
  4. package/dist/server/dsh/dsh-agent-service.js +139 -38
  5. package/dist/server/dsh/dsh-client.js +9 -8
  6. package/dist/server/dsh/dsh-sessions.js +4 -3
  7. package/dist/server/edit-soft-tool.js +33 -26
  8. package/dist/server/files-service.js +12 -7
  9. package/dist/server/goal-service.js +85 -24
  10. package/dist/server/i18n.js +157 -0
  11. package/dist/server/index.js +76 -18
  12. package/dist/server/locales.js +55 -1
  13. package/dist/server/managed.js +61 -0
  14. package/dist/server/marker-service.js +20 -7
  15. package/dist/server/markers/builtins/notify.js +19 -6
  16. package/dist/server/markers/builtins/rename.js +41 -8
  17. package/dist/server/markers/builtins/todo.js +107 -30
  18. package/dist/server/markers/registry.js +2 -2
  19. package/dist/server/mcp-bridge.js +3 -1
  20. package/dist/server/model-admin.js +25 -14
  21. package/dist/server/plugin-catalog.js +7 -3
  22. package/dist/server/plugin-updater.js +6 -2
  23. package/dist/server/plugins.js +40 -17
  24. package/dist/server/prompt-composer.js +42 -16
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/scm.js +18 -25
  27. package/dist/server/serialize.js +1 -0
  28. package/dist/server/settings-service.js +20 -1
  29. package/dist/server/subagent-templates.js +105 -0
  30. package/dist/server/subagents.js +155 -54
  31. package/dist/server/tabs.js +87 -0
  32. package/dist/server/terminals.js +88 -48
  33. package/dist/server/update-check.js +7 -2
  34. package/dist/server/vision-bridge.js +34 -12
  35. package/package.json +2 -1
  36. package/web/dist/assets/{TerminalPanel-BQ5NTB9Y.js → TerminalPanel-Cj8zsjx-.js} +1 -1
  37. package/web/dist/assets/index-DCOcsPFm.js +334 -0
  38. package/web/dist/assets/{index-C_I-6Zul.css → index-jH2Bb-0X.css} +1 -1
  39. package/web/dist/assets/{markdown-DOsihKaR.js → markdown-Cpo0pNcR.js} +1 -1
  40. package/web/dist/assets/{react-DIP6JKYk.js → react-CtudoG1_.js} +1 -1
  41. package/web/dist/index.html +4 -4
  42. package/web/dist/assets/index-Ck5pa3XK.js +0 -333
@@ -28,12 +28,13 @@ 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
39
  import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
39
40
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
@@ -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;
@@ -260,7 +263,7 @@ export function makeAskUserQuestionTool(clientSession) {
260
263
  aborted: signal?.aborted,
261
264
  });
262
265
  if (answers === null) {
263
- throw new Error("用户取消了提问");
266
+ throw new Error("User cancelled the question.\n用户取消了提问。");
264
267
  }
265
268
  // 工具结果:把每道题的回答拼成简洁文本给模型,同时留 details 供 UI 展示。
266
269
  const lines = answers.map((a) => {
@@ -575,6 +578,8 @@ export class ClientSession {
575
578
  isDisposed: () => this.disposed,
576
579
  getCwd: () => this.cwd,
577
580
  getActiveCwd: () => this.conv?.cwd ?? this.cwd,
581
+ // issue #91:文件服务错误文案按客户端 UI 语言出中英(英文默认)。
582
+ getLang: () => this.getLang(),
578
583
  });
579
584
  bg = new BgServerTracker({
580
585
  emit: (msg) => this.emit(msg),
@@ -629,7 +634,9 @@ export class ClientSession {
629
634
  return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
630
635
  }
631
636
  makeTerminalManager(conversationId, cwd) {
632
- 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());
633
640
  // 终端活力检测:AI 触碰过的终端静默 ≥ 阈值(PI_WEB_TERMINAL_IDLE_MS,
634
641
  // 默认 15s)且该对话正在运行时,注入一条 steer 消息唤醒 AI 去检查。
635
642
  mgr.onAgentIdle = (terminalId, idleMs, title, lastLines) => this.notifyTerminalIdle(conversationId, terminalId, idleMs, title, lastLines);
@@ -699,18 +706,20 @@ export class ClientSession {
699
706
  * `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
700
707
  * 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
701
708
  * runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
702
- async spawnSubagentConversation(prompt, type, cwd, apply, model) {
709
+ async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
703
710
  const conversationId = `sa-${randomUUID().slice(0, 8)}`;
704
711
  const terminals = this.makeTerminalManager(conversationId, cwd);
705
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply), {
712
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
706
713
  cwd,
707
714
  agentDir: this.agentDir,
708
715
  sessionManager: SessionManager.inMemory(cwd),
709
716
  });
710
717
  const conv = this.makeConversation(runtime, conversationId, terminals);
711
718
  conv.isSubagent = true;
712
- // 派发时刻的 active 即父对话(子代理也可再派发,自然嵌套)。
713
- conv.parentId = this.activeId || undefined;
719
+ // 父对话 = 真正派发它的会话(按会话归属的 host 包装填入)。直接用 active
720
+ // 会错:后台对话运行时用户可能正看着别的项目对话,孩子会被记到无关
721
+ // 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
722
+ conv.parentId = parentId ?? this.activeId ?? undefined;
714
723
  conv.subagentType = type;
715
724
  conv.listed = true;
716
725
  conv.title = subagentTitle(prompt);
@@ -903,6 +912,8 @@ export class ClientSession {
903
912
  windowsPersona: process.platform === "win32" ? WINDOWS_PERSONA : "",
904
913
  terminalGuidance: this.settingsSvc.current.terminalToolsEnabled !== false ? TERMINAL_TOOLS_GUIDANCE : "",
905
914
  markersGuidance: this.markerSvc.buildGuidance(),
915
+ // issue #91:组合模板各来源段按客户端 UI 语言渲染(英文默认)。
916
+ lang: this.getLang(),
906
917
  contextFiles: src.contextFiles,
907
918
  skills: src.skills,
908
919
  };
@@ -983,14 +994,14 @@ export class ClientSession {
983
994
  * 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
984
995
  */
985
996
  subagentHost = {
986
- spawnSubagent: (prompt, type, cwd, templateName, model) => {
997
+ spawnSubagent: (prompt, type, cwd, templateName, model, parentId) => {
987
998
  // 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
988
999
  const tpl = templateName ? this.subagentTemplates.get(templateName) : undefined;
989
1000
  if (templateName && (!tpl || !tpl.enabled)) {
990
- 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 }));
991
1002
  }
992
1003
  // 模型优先级:显式 model 参数 > 模板自带模型 > 设置面板默认模型;都不给 = 跟随主对话。
993
- return this.spawnSubagentConversation(prompt, type, cwd, tpl, model);
1004
+ return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId);
994
1005
  },
995
1006
  getSubagent: (convId) => this.getSubagentSnapshot(convId),
996
1007
  listSubagents: () => this.listSubagentSnapshots(),
@@ -1003,14 +1014,16 @@ export class ClientSession {
1003
1014
  stopSubagent: async (convId) => {
1004
1015
  const conv = this.convs.get(convId);
1005
1016
  if (conv && (conv.session.isStreaming || !conv.session.isIdle)) {
1006
- await this.interruptRun(conv, "用户停止子代理");
1017
+ await this.interruptRun(conv, pick(this.getLang(), "用户停止子代理", "User stopped the subagent", "agent.subagent.stop.user"));
1007
1018
  }
1008
1019
  },
1020
+ // issue #91:子代理工具返回按客户端 UI 语言出中英(英文默认)。
1021
+ lang: () => this.getLang(),
1009
1022
  // 只向 AI 暴露 enabled 的模板(停用的对 AI 不可见)。
1010
1023
  listTemplates: () => this.subagentTemplates
1011
1024
  .list()
1012
1025
  .filter((t) => t.enabled)
1013
- .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 })),
1014
1027
  isTemplateUsable: (name) => {
1015
1028
  const t = this.subagentTemplates.get(name);
1016
1029
  return !!t && t.enabled;
@@ -1097,6 +1110,8 @@ export class ClientSession {
1097
1110
  // 复用现有重命名路径(内存标题 + 磁盘 session_info)
1098
1111
  void this.renameConversation(convId, title);
1099
1112
  },
1113
+ // issue #91:标记引导/错误按客户端 UI 语言出中英(英文默认)。
1114
+ lang: () => this.getLang(),
1100
1115
  });
1101
1116
  this.settingsSvc = new SettingsService({
1102
1117
  clientId,
@@ -1110,10 +1125,14 @@ export class ClientSession {
1110
1125
  isStreaming: () => this.session.isStreaming,
1111
1126
  reloadSession: async () => {
1112
1127
  await this.session.reload();
1128
+ // reload() 重读磁盘 settings.json,会丢掉内存 applyOverrides
1129
+ // (含重试次数覆盖)——依次重放:重试覆盖 → 终端门控。
1130
+ this.applyRetryOverrides();
1113
1131
  // reload() 会把 custom 工具重新加回活跃集——重放终端开关。
1114
1132
  this.applyToolGating(this.session);
1115
1133
  await this.pushSlashCommands();
1116
1134
  },
1135
+ applyRetryOverrides: () => this.applyRetryOverrides(),
1117
1136
  promptSnapshot: () => this.promptSnapshot(),
1118
1137
  getMarkerState: () => ({
1119
1138
  markersEnabled: this.markerSvc.current.markersEnabled,
@@ -1130,6 +1149,10 @@ export class ClientSession {
1130
1149
  flushSnapshot: () => this.flushSnapshot(),
1131
1150
  isDisposed: () => this.disposed,
1132
1151
  quiesceBlocked: () => this.quiesceBlocked(),
1152
+ // issue #91:目标/审查文案按客户端 UI 语言出中英(英文默认)。
1153
+ lang: () => this.getLang(),
1154
+ // 目标模式总开关(设置面板「目标审查」页):关 → 目标入口一律拒绝。
1155
+ goalModeEnabled: () => this.settingsSvc.current.goalModeEnabled !== false,
1133
1156
  activeConvId: () => this.activeId,
1134
1157
  activeConv: () => this.conv,
1135
1158
  getConv: (id) => this.convs.get(id),
@@ -1157,7 +1180,7 @@ export class ClientSession {
1157
1180
  const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
1158
1181
  const conversationId = cs.nextConversationId();
1159
1182
  const terminals = cs.makeTerminalManager(conversationId, cwd);
1160
- const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals), {
1183
+ const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals, undefined, conversationId), {
1161
1184
  cwd,
1162
1185
  agentDir,
1163
1186
  // Resume the most recent session for this project — the SDK default
@@ -1195,7 +1218,7 @@ export class ClientSession {
1195
1218
  * 应用(prompt replace/append + 白名单),其余(终端接管、Windows persona
1196
1219
  * 等)仍跟随主会话设置。undefined = 按主会话设置(普通对话/不选模板的子代理)。
1197
1220
  */
1198
- makeRuntimeFactory(terminals, apply) {
1221
+ makeRuntimeFactory(terminals, apply, ownerId) {
1199
1222
  return async ({ cwd: effectiveCwd, sessionManager }) => {
1200
1223
  const services = await createAgentSessionServices({
1201
1224
  cwd: effectiveCwd,
@@ -1218,8 +1241,8 @@ export class ClientSession {
1218
1241
  systemPromptOverride: (base) => {
1219
1242
  if (typeof base === "string" && base) {
1220
1243
  this.lastBaseSystemPrompt = base;
1221
- if (apply && apply.promptMode === "replace" && apply.systemPrompt.trim()) {
1222
- return apply.systemPrompt;
1244
+ if (apply && apply.promptMode === "replace" && pickTemplatePrompt(apply, this.getLang()).trim()) {
1245
+ return pickTemplatePrompt(apply, this.getLang());
1223
1246
  }
1224
1247
  }
1225
1248
  return undefined;
@@ -1229,8 +1252,8 @@ export class ClientSession {
1229
1252
  if (!apply)
1230
1253
  this.lastSdkAppendFiles = base.slice();
1231
1254
  const out = [...base];
1232
- if (apply && apply.promptMode === "append" && apply.systemPrompt.trim()) {
1233
- out.push(apply.systemPrompt);
1255
+ if (apply && apply.promptMode === "append" && pickTemplatePrompt(apply, this.getLang()).trim()) {
1256
+ out.push(pickTemplatePrompt(apply, this.getLang()));
1234
1257
  }
1235
1258
  // 主会话自定义「追加」已并入组合模板的 {{append}} 覆盖,不再在此注入。
1236
1259
  if (process.platform === "win32") {
@@ -1294,12 +1317,12 @@ export class ClientSession {
1294
1317
  // 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
1295
1318
  // systemPromptOverride 整体替换,此处边界不存在会自然跳过。
1296
1319
  if (apply) {
1297
- if (apply.promptMode !== "replace" || !apply.systemPrompt.trim())
1320
+ if (apply.promptMode !== "replace" || !pickTemplatePrompt(apply, this.getLang()).trim())
1298
1321
  return undefined;
1299
1322
  const boundary = event.systemPrompt.indexOf("\n\nAvailable tools:");
1300
1323
  if (boundary === -1)
1301
1324
  return undefined;
1302
- const swapped = apply.systemPrompt.trimEnd() + event.systemPrompt.slice(boundary);
1325
+ const swapped = pickTemplatePrompt(apply, this.getLang()).trimEnd() + event.systemPrompt.slice(boundary);
1303
1326
  return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
1304
1327
  }
1305
1328
  // 主会话:组合模板渲染(模板为空且无覆盖时返回 undefined = 用 SDK 默认)。
@@ -1337,18 +1360,26 @@ export class ClientSession {
1337
1360
  idleMs: () => Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) || 0),
1338
1361
  kills: this.bashKills,
1339
1362
  notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
1363
+ // issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
1364
+ lang: () => this.getLang(),
1340
1365
  }),
1341
1366
  // 设置关 → 原生 bash;开 → 终端 bash。
1342
1367
  () => this.settingsSvc.current.terminalBash),
1343
- ...makePersistentTerminalTools(terminals, effectiveCwd),
1368
+ ...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang()),
1344
1369
  // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置)。
1345
- makeEditSoftTool(effectiveCwd),
1370
+ makeEditSoftTool(effectiveCwd, () => this.getLang()),
1346
1371
  // 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
1347
1372
  // refreshPluginTools 动态补入已有会话)。
1348
1373
  ...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
1349
1374
  // 第一方子代理工具(spawn/get_result/steer/list/stop)。子代理会话
1350
- // 也注册了它们,因此可自然嵌套派发。
1351
- ...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)),
1352
1383
  // 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
1353
1384
  makeMarkersListTool(() => this.activeId, this.markerSvc),
1354
1385
  // 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
@@ -1776,18 +1807,17 @@ export class ClientSession {
1776
1807
  conv.queueSteering = [...event.steering];
1777
1808
  conv.queueFollowUp = [...event.followUp];
1778
1809
  break;
1779
- // 手动 /compact 或阈值/溢出自动压缩开始——立即反馈,避免「没反应」
1780
- // (此前 compaction_start/end 事件被 switch 静默丢弃,issue #33)。
1810
+ // 手动 /compact 或阈值/溢出自动压缩开始——常驻进度条(快照 compaction
1811
+ // 字段),而不是一次性 toast(toast 几秒就消失,而摘要生成可能持续
1812
+ // 数十秒,用户会以为「没反应」)。立即 flush 让进度条第一时间出现。
1781
1813
  case "compaction_start": {
1782
- this.emit({
1783
- type: "notice",
1784
- level: "info",
1785
- text: "正在压缩上下文…(压缩摘要将显示在消息区)",
1786
- textEn: "Compacting context… (the summary will appear in the message list)",
1787
- });
1814
+ conv.compactionState = { reason: event.reason, startedAt: Date.now() };
1815
+ conv.lastCompactionTokens = null;
1816
+ this.flushSnapshot();
1788
1817
  break;
1789
1818
  }
1790
1819
  case "compaction_end": {
1820
+ conv.compactionState = null;
1791
1821
  if (event.errorMessage) {
1792
1822
  this.emit({
1793
1823
  type: "notice",
@@ -1807,6 +1837,8 @@ export class ClientSession {
1807
1837
  else if (event.result) {
1808
1838
  const { tokensBefore, estimatedTokensAfter } = event.result;
1809
1839
  const after = estimatedTokensAfter ?? tokensBefore;
1840
+ // 记住压缩后大小:SDK 在下轮响应前报 null,快照用此回填底栏。
1841
+ conv.lastCompactionTokens = estimatedTokensAfter ?? null;
1810
1842
  this.emit({
1811
1843
  type: "notice",
1812
1844
  level: "info",
@@ -2144,13 +2176,25 @@ export class ClientSession {
2144
2176
  totalMessages: s.totalMessages,
2145
2177
  tokens: s.tokens,
2146
2178
  cost: s.cost,
2147
- contextUsage: s.contextUsage
2148
- ? {
2149
- tokens: s.contextUsage.tokens,
2150
- contextWindow: s.contextUsage.contextWindow,
2151
- 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
+ };
2152
2191
  }
2153
- : stats.contextUsage,
2192
+ return {
2193
+ tokens: cu.tokens,
2194
+ contextWindow: cu.contextWindow,
2195
+ percent: cu.percent,
2196
+ };
2197
+ })(),
2154
2198
  };
2155
2199
  }
2156
2200
  catch {
@@ -2187,6 +2231,7 @@ export class ClientSession {
2187
2231
  queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
2188
2232
  errorMessage: state.errorMessage,
2189
2233
  retry: conv.retryState ?? null,
2234
+ compaction: conv.compactionState ?? null,
2190
2235
  tools: state.tools.map((t) => t.name),
2191
2236
  version: ++this.version,
2192
2237
  piConfigured: this.isPiConfigured(),
@@ -2466,7 +2511,7 @@ export class ClientSession {
2466
2511
  }
2467
2512
  try {
2468
2513
  const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion());
2469
- const items = await checkAllUpdates(targets);
2514
+ const items = await checkAllUpdates(targets, undefined, () => this.getLang());
2470
2515
  this.updatesAllCache = { at: Date.now(), items };
2471
2516
  this.emit({ type: "update_status_all", items });
2472
2517
  }
@@ -2581,7 +2626,11 @@ export class ClientSession {
2581
2626
  this.flushSnapshot();
2582
2627
  },
2583
2628
  refreshSessions: () => this.refreshSessions(),
2584
- afterReload: () => this.applyToolGating(this.session),
2629
+ afterReload: () => {
2630
+ // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 终端门控。
2631
+ this.applyRetryOverrides();
2632
+ this.applyToolGating(this.session);
2633
+ },
2585
2634
  pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
2586
2635
  execPluginCommand: async (name, args) => {
2587
2636
  const def = this.pluginCommandsProvider?.().find((c) => c.name === name);
@@ -2626,10 +2675,10 @@ export class ClientSession {
2626
2675
  return this.modelAdmin.listModelsConfig();
2627
2676
  }
2628
2677
  fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
2629
- return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api);
2678
+ return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
2630
2679
  }
2631
2680
  refreshProviderModels(providerId, reqId) {
2632
- return this.modelAdmin.refreshProviderModels(providerId, reqId);
2681
+ return this.modelAdmin.refreshProviderModels(providerId, reqId, () => this.getLang());
2633
2682
  }
2634
2683
  /** Copy a built-in provider into an editable custom-provider draft
2635
2684
  * (clone_provider_result) — lets the user run a second API key without
@@ -2766,6 +2815,22 @@ export class ClientSession {
2766
2815
  pushSettings() {
2767
2816
  this.settingsSvc.push();
2768
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
+ }
2769
2834
  /** Extensions/skills changed externally (e.g. `pi remove` finished in the
2770
2835
  * terminal): re-run session.reload() and re-push state. Streaming-safe —
2771
2836
  * deferred to agent_end, same as settings reloads. */
@@ -2792,6 +2857,7 @@ export class ClientSession {
2792
2857
  if (!this.session.isStreaming) {
2793
2858
  try {
2794
2859
  await this.session.reload();
2860
+ this.applyRetryOverrides();
2795
2861
  this.applyToolGating(this.session);
2796
2862
  await this.pushSlashCommands();
2797
2863
  this.pushSettings();
@@ -2870,6 +2936,26 @@ export class ClientSession {
2870
2936
  // 兼容旧入口:reload + 刷目录在宿主回调里完成
2871
2937
  return this.settingsSvc.applyRuntime();
2872
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
+ }
2873
2959
  // ---------------------------------------------------------------------------
2874
2960
  // Commands
2875
2961
  // ---------------------------------------------------------------------------
@@ -2963,6 +3049,8 @@ export class ClientSession {
2963
3049
  emit: (msg) => this.emit(msg),
2964
3050
  settings: this.settingsSvc.current,
2965
3051
  session: this.session,
3052
+ // issue #91:附件/视觉桥文案按客户端 UI 语言出中英(英文默认)。
3053
+ getLang: () => this.getLang(),
2966
3054
  }, attachments);
2967
3055
  for (const aside of asides) {
2968
3056
  await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
@@ -3224,7 +3312,7 @@ export class ClientSession {
3224
3312
  this.clearAllToolWatchdogs(conv);
3225
3313
  conv.toolStartTimes.clear();
3226
3314
  await conv.runtime.dispose();
3227
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals), {
3315
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
3228
3316
  cwd: conv.cwd,
3229
3317
  agentDir: this.agentDir,
3230
3318
  sessionManager: SessionManager.continueRecent(conv.cwd),
@@ -3303,7 +3391,7 @@ export class ClientSession {
3303
3391
  try {
3304
3392
  const conversationId = this.nextConversationId();
3305
3393
  const terminals = this.makeTerminalManager(conversationId, this.cwd);
3306
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
3394
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
3307
3395
  cwd: this.cwd,
3308
3396
  agentDir: this.agentDir,
3309
3397
  sessionManager: SessionManager.create(this.cwd),
@@ -3857,7 +3945,7 @@ export class ClientSession {
3857
3945
  const targetCwd = sessionManager.getCwd();
3858
3946
  const conversationId = this.nextConversationId();
3859
3947
  openedTerminals = this.makeTerminalManager(conversationId, targetCwd);
3860
- openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals), {
3948
+ openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals, undefined, conversationId), {
3861
3949
  cwd: targetCwd,
3862
3950
  agentDir: this.agentDir,
3863
3951
  sessionManager,
@@ -4209,7 +4297,7 @@ export class ClientSession {
4209
4297
  // First visit to this project: resume its most recent session.
4210
4298
  const conversationId = this.nextConversationId();
4211
4299
  const terminals = this.makeTerminalManager(conversationId, abs);
4212
- const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
4300
+ const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
4213
4301
  cwd: abs,
4214
4302
  agentDir: this.agentDir,
4215
4303
  sessionManager: SessionManager.continueRecent(abs),
@@ -4615,6 +4703,25 @@ export class AgentService {
4615
4703
  for (const cs of this.clients.values())
4616
4704
  cs.refreshPluginTools();
4617
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
+ }
4618
4725
  /** 插件斜杠命令集合变化时由 index.ts 触发:重推各客户端的命令目录。 */
4619
4726
  applyPluginCommandCatalog() {
4620
4727
  for (const cs of this.clients.values())
@@ -188,12 +188,16 @@ export async function buildAttachmentMessages(ctx, attachments) {
188
188
  else {
189
189
  // Batch hash so re-sending identical images (edit & re-ask) reuses
190
190
  // the transcript instead of re-burning tokens on the vision API.
191
+ // issue #91:转写提示词按客户端 UI 语言选用(英文默认),语言进缓存键。
192
+ const vLang = ctx.getLang?.() ?? "en";
191
193
  // The active transcription prompt is part of the key: changing
192
194
  // the custom prompt must invalidate cached transcripts made with
193
195
  // the old prompt.
194
196
  const batchHash = bridgedImages.map((b) => `${b.att.name ?? "img"}:${b.raw.slice(0, 48)}`).join("|") +
195
197
  "::" +
196
- buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt);
198
+ buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt, vLang) +
199
+ "::" +
200
+ vLang;
197
201
  let transcript = visionBridgeCache.get(batchHash);
198
202
  if (transcript === undefined) {
199
203
  ctx.emit({
@@ -210,7 +214,8 @@ export async function buildAttachmentMessages(ctx, attachments) {
210
214
  name: b.att.name,
211
215
  })), {
212
216
  model: chosenModel ?? undefined,
213
- systemPrompt: buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt),
217
+ systemPrompt: buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt, vLang),
218
+ lang: vLang,
214
219
  });
215
220
  visionBridgeCache.set(batchHash, transcript);
216
221
  ctx.emit({
@@ -8,6 +8,15 @@
8
8
  */
9
9
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
10
  import { dirname } from "node:path";
11
+ /** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
12
+ export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
13
+ /** 归一化重试次数:非数值回落默认,钳制到 [0, 100] 整数。 */
14
+ export function normalizeRetryMaxAttempts(v) {
15
+ const n = Math.floor(Number(v));
16
+ if (!Number.isFinite(n))
17
+ return DEFAULT_RETRY_MAX_ATTEMPTS;
18
+ return Math.min(100, Math.max(0, n));
19
+ }
11
20
  /** Stable identity of an extension for the enable/disable toggle: the npm
12
21
  * spec for packages (survives version bumps), the resolved entry path
13
22
  * otherwise. */
@@ -143,6 +152,18 @@ export class ClientStateStore {
143
152
  locked: s.goalPrefs.locked ?? true,
144
153
  };
145
154
  }
155
+ /** Persist the client's UI locale code (hello/set_locale; best-effort). */
156
+ saveLocale(clientId, locale) {
157
+ const code = locale.trim().slice(0, 16);
158
+ if (!code)
159
+ return;
160
+ const all = this.load();
161
+ const state = (all[clientId] ??= { projects: [] });
162
+ if (state.locale === code)
163
+ return;
164
+ state.locale = code;
165
+ this.save();
166
+ }
146
167
  /** Persist the client's goal/review preferences (model choice, rounds, lock). */
147
168
  saveGoalPrefs(clientId, prefs) {
148
169
  const all = this.load();
@@ -205,6 +226,7 @@ export class ClientStateStore {
205
226
  terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
206
227
  editSoftEnabled: stored?.editSoftEnabled ?? false,
207
228
  questionnaireEnabled: stored?.questionnaireEnabled ?? true,
229
+ goalModeEnabled: stored?.goalModeEnabled ?? true,
208
230
  thinkingWrap: stored?.thinkingWrap ?? false,
209
231
  toolsWrap: stored?.toolsWrap ?? true,
210
232
  visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
@@ -212,6 +234,7 @@ export class ClientStateStore {
212
234
  visionBridgePromptMode: stored?.visionBridgePromptMode === "replace" ? "replace" : "append",
213
235
  visionBridgePrompt: stored?.visionBridgePrompt ?? "",
214
236
  subagentDefaultModel: stored?.subagentDefaultModel ?? null,
237
+ retryMaxAttempts: normalizeRetryMaxAttempts(stored?.retryMaxAttempts),
215
238
  quickPhrases: stored?.quickPhrases ?? [],
216
239
  quickPhrasesEnabled: stored?.quickPhrasesEnabled ?? true,
217
240
  reviewPrompt: stored?.reviewPrompt ?? "",
@@ -236,11 +259,13 @@ export class ClientStateStore {
236
259
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
237
260
  editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
238
261
  questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
262
+ goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
239
263
  thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
240
264
  toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
241
265
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
242
266
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
243
267
  subagentDefaultModel: settings.subagentDefaultModel ?? cur.subagentDefaultModel ?? null,
268
+ retryMaxAttempts: normalizeRetryMaxAttempts(settings.retryMaxAttempts ?? cur.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS),
244
269
  visionBridgePromptMode: settings.visionBridgePromptMode ?? cur.visionBridgePromptMode ?? "append",
245
270
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
246
271
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
@@ -258,6 +283,8 @@ export class ClientStateStore {
258
283
  // Older client-state files predate review settings.
259
284
  reviewPrompt: p.reviewPrompt ?? "",
260
285
  reviewDisabledSkills: p.reviewDisabledSkills ?? [],
286
+ // Older presets predate the configurable retry count.
287
+ retryMaxAttempts: normalizeRetryMaxAttempts(p.retryMaxAttempts),
261
288
  }));
262
289
  }
263
290
  /** Persist the client's named settings presets. */