pi-web-ui 0.62.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +504 -497
  3. package/README.zh-CN.md +427 -422
  4. package/bin/pi-web-ui.mjs +1809 -1881
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +54 -54
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +318 -88
  10. package/dist/server/attachments.js +37 -28
  11. package/dist/server/bg-servers.js +6 -1
  12. package/dist/server/client-state.js +15 -7
  13. package/dist/server/control-socket.js +1 -5
  14. package/dist/server/dsh/dsh-agent-service.js +454 -118
  15. package/dist/server/dsh/dsh-client.js +1 -3
  16. package/dist/server/dsh/dsh-sessions.js +4 -14
  17. package/dist/server/dsh/runtime/cordis.yml +1 -1
  18. package/dist/server/dsh/runtime/goal-rpc.mjs +645 -662
  19. package/dist/server/dsh/runtime/launcher.mjs +8 -18
  20. package/dist/server/dsh/runtime/override.patch.yml +9 -9
  21. package/dist/server/dsh/runtime/runtime-root.mjs +7 -11
  22. package/dist/server/ensure-bash.js +2 -5
  23. package/dist/server/files-service.js +42 -36
  24. package/dist/server/goal-service.js +59 -30
  25. package/dist/server/index.js +17 -10
  26. package/dist/server/mcp-bridge.js +11 -5
  27. package/dist/server/model-admin.js +78 -72
  28. package/dist/server/plugin-facilities.js +2 -4
  29. package/dist/server/plugin-updater.js +1 -1
  30. package/dist/server/plugins.js +26 -8
  31. package/dist/server/process-utils.js +4 -4
  32. package/dist/server/protocol-version.js +1 -1
  33. package/dist/server/scm.js +27 -21
  34. package/dist/server/serialize.js +1 -3
  35. package/dist/server/settings-service.js +63 -21
  36. package/dist/server/slash-commands.js +29 -3
  37. package/dist/server/subagent-templates.js +249 -0
  38. package/dist/server/subagents.js +150 -0
  39. package/dist/server/terminals.js +101 -40
  40. package/dist/server/text-sniff.js +4 -24
  41. package/dist/server/update-check.js +2 -4
  42. package/dist/server/uploads.js +1 -3
  43. package/dist/server/vision-bridge.js +2 -7
  44. package/dist/server/wait-subscription-scan.js +102 -21
  45. package/dist/server/webui-context.js +1 -1
  46. package/extensions/webui.ts +190 -192
  47. package/package.json +109 -103
  48. package/web/dist/assets/TerminalPanel-B9vcI7-_.js +2 -0
  49. package/web/dist/assets/index-B8Wv29p5.css +10 -0
  50. package/web/dist/assets/index-CWUWD0rq.js +323 -0
  51. package/web/dist/favicon.svg +8 -8
  52. package/web/dist/index.html +15 -15
  53. package/web/public/favicon.svg +8 -8
  54. package/web/dist/assets/TerminalPanel-BkCWDsIG.js +0 -2
  55. package/web/dist/assets/index-BRVKed7W.js +0 -321
  56. package/web/dist/assets/index-ORTpR6ZQ.css +0 -10
@@ -11,28 +11,29 @@
11
11
  * so reconnects just re-request a snapshot.
12
12
  */
13
13
  import { spawn, spawnSync } from "node:child_process";
14
- import { existsSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
15
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
14
+ import { randomUUID } from "node:crypto";
15
+ import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
16
+ import { basename, dirname, join, resolve, sep } from "node:path";
16
17
  import { fileURLToPath } from "node:url";
17
- import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
18
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
18
19
  import { Type } from "typebox";
19
20
  import { BgServerTracker } from "./bg-servers.js";
20
21
  import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, } from "./update-check.js";
21
- import { hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
22
+ import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
22
23
  import { syncPluginToolsIntoSession } from "./plugins.js";
23
24
  import { SettingsService } from "./settings-service.js";
24
25
  import { GoalService } from "./goal-service.js";
25
26
  import { SlashCommandsService, parseSlash } from "./slash-commands.js";
26
27
  import { ModelAdminService } from "./model-admin.js";
27
28
  import { FilesService, workspacePath } from "./files-service.js";
28
- import { isExtensionDisabled, ClientStateStore, } from "./client-state.js";
29
- import { saveUpload } from "./uploads.js";
29
+ import { isExtensionDisabled, isExtensionEnabled, ClientStateStore } from "./client-state.js";
30
+ import { SubagentTemplatesStore } from "./subagent-templates.js";
30
31
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
31
32
  import { WebUIContext } from "./webui-context.js";
32
- import { buildAttachmentMessages, parseModelSpec, } from "./attachments.js";
33
- import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
34
- import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
35
- import { buildVisionBridgePrompt, SYSTEM_PROMPT, transcribeImages, } from "./vision-bridge.js";
33
+ import { makeSubagentTools, subagentTitle, } from "./subagents.js";
34
+ import { buildAttachmentMessages } from "./attachments.js";
35
+ import { serializeMessage, serializeStreamingMessage } from "./serialize.js";
36
+ import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
36
37
  const SNAPSHOT_INTERVAL_MS = 60;
37
38
  /** While assistant deltas are flowing, live rendering is carried by
38
39
  * message_delta — full snapshots become pure reconciliation checkpoints, so
@@ -82,14 +83,14 @@ export class QuiesceRejectedError extends Error {
82
83
  * programs wait for input that never comes. Legacy Chinese files are often
83
84
  * GBK/GB2312 — read them with the right encoding, never paste mojibake into
84
85
  * reasoning/answers. */
85
- const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
86
-
87
-
88
-
89
- - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
90
- - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
91
- - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
92
-
86
+ const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
87
+
88
+
89
+
90
+ - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
91
+ - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
92
+ - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
93
+
93
94
  Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
94
95
  /** bash 工具输出限制/过滤管道引导:模型习惯套 `| tail/-n`、`| head`、`| grep`、`| less`
95
96
  * 等限输出。这些管道在持久终端里会①缓冲(可见终端全程哑火、看不到实时进度)②把退出码
@@ -181,9 +182,7 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
181
182
  */
182
183
  function pluginToolToDefinition(tool) {
183
184
  const normalize = (result) => {
184
- if (result &&
185
- typeof result === "object" &&
186
- Array.isArray(result.content)) {
185
+ if (result && typeof result === "object" && Array.isArray(result.content)) {
187
186
  return result;
188
187
  }
189
188
  const text = typeof result === "string" ? result : JSON.stringify(result ?? null, null, 2);
@@ -200,9 +199,7 @@ function pluginToolToDefinition(tool) {
200
199
  properties: {},
201
200
  }),
202
201
  execute: async (toolCallId, params, signal, onUpdate) => {
203
- const raw = await tool.execute(toolCallId, params, signal, onUpdate
204
- ? (partial) => onUpdate(normalize(partial))
205
- : undefined);
202
+ const raw = await tool.execute(toolCallId, params, signal, onUpdate ? (partial) => onUpdate(normalize(partial)) : undefined);
206
203
  return normalize(raw);
207
204
  },
208
205
  };
@@ -240,13 +237,10 @@ function contentFingerprint(m) {
240
237
  // with a mock theme to plain text lines, and push them to the client.
241
238
  // ---------------------------------------------------------------------------
242
239
  function extractPartialText(partial) {
243
- const content = partial
244
- ?.content;
240
+ const content = partial?.content;
245
241
  if (Array.isArray(content)) {
246
242
  const text = content
247
- .map((c) => c?.type === "text"
248
- ? c.text
249
- : "")
243
+ .map((c) => (c?.type === "text" ? c.text : ""))
250
244
  .join("");
251
245
  return text.length > 0 ? text : null;
252
246
  }
@@ -531,6 +525,77 @@ export class ClientSession {
531
525
  .catch(() => { });
532
526
  }
533
527
  }
528
+ /** 创建子代理 conversation(inMemory runtime + 独立 terminals),listed 入左栏,
529
+ * 并在其上触发一次完整回合。返回 convId(= 工具 runId)。 */
530
+ async spawnSubagentConversation(prompt, type, cwd, apply) {
531
+ const conversationId = `sa-${randomUUID().slice(0, 8)}`;
532
+ const terminals = this.makeTerminalManager(conversationId, cwd);
533
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply), {
534
+ cwd,
535
+ agentDir: this.agentDir,
536
+ sessionManager: SessionManager.inMemory(cwd),
537
+ });
538
+ const conv = this.makeConversation(runtime, conversationId, terminals);
539
+ conv.isSubagent = true;
540
+ conv.subagentType = type;
541
+ conv.listed = true;
542
+ conv.title = subagentTitle(prompt);
543
+ this.convs.set(conv.id, conv);
544
+ // 扩展绑定(rpc 模式;不给 uiContext,避免与主对话的 widget 冲突)。
545
+ try {
546
+ await conv.session.bindExtensions({
547
+ mode: "rpc",
548
+ onError: (err) => this.emit({ type: "notice", level: "error", text: err.error }),
549
+ });
550
+ }
551
+ catch {
552
+ // 绑定失败不阻断运行。
553
+ }
554
+ // 触发回合(后台执行;失败转识为通知)。
555
+ void conv.session.sendUserMessage(prompt).catch((err) => {
556
+ this.emit({
557
+ type: "notice",
558
+ level: "error",
559
+ text: `子代理 ${conversationId} 启动失败: ${err instanceof Error ? err.message : String(err)}`,
560
+ });
561
+ });
562
+ this.emitConversations();
563
+ return conv.id;
564
+ }
565
+ getSubagentSnapshot(convId) {
566
+ const conv = this.convs.get(convId);
567
+ if (!conv?.isSubagent || !conv.session)
568
+ return undefined;
569
+ return this.toSubagentSnapshot(conv);
570
+ }
571
+ listSubagentSnapshots() {
572
+ return [...this.convs.values()]
573
+ .filter((c) => c.isSubagent)
574
+ .sort((a, b) => a.createdAt - b.createdAt)
575
+ .map((c) => this.toSubagentSnapshot(c));
576
+ }
577
+ toSubagentSnapshot(conv) {
578
+ const streaming = conv.session.isStreaming;
579
+ const state = streaming ? "running" : "done";
580
+ let messageCount = 0;
581
+ try {
582
+ messageCount = conv.session.getSessionStats().totalMessages;
583
+ }
584
+ catch {
585
+ // session being replaced — report defaults
586
+ }
587
+ return {
588
+ convId: conv.id,
589
+ type: conv.subagentType ?? "general",
590
+ title: conv.title,
591
+ prompt: "",
592
+ state,
593
+ streaming,
594
+ messageCount,
595
+ model: conv.session.model?.id,
596
+ output: conv.session.getLastAssistantText() ?? "",
597
+ };
598
+ }
534
599
  emitTerminal(conversationId, msg) {
535
600
  // Background conversations keep collecting output in their own PTY buffer.
536
601
  // Do not stream it into the active xterm; push the retained window on switch.
@@ -602,6 +667,45 @@ export class ClientSession {
602
667
  }
603
668
  /** Web-facing extension UI context (widgets, notifications). */
604
669
  webUi = new WebUIContext((msg) => this.emit(msg));
670
+ /**
671
+ * 第一方子代理 host(见 subagents.ts 设计头注)。子代理 = 一个标记
672
+ * isSubagent 的普通 Conversation:inMemory runtime(不落盘、不进
673
+ * 历史/resume 列表)、listed=true 出现在左栏「运行的对话」并向用户可见——
674
+ * 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
675
+ */
676
+ subagentHost = {
677
+ spawnSubagent: (prompt, type, cwd, templateName) => {
678
+ // 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
679
+ const tpl = templateName ? this.subagentTemplates.get(templateName) : undefined;
680
+ if (templateName && (!tpl || !tpl.enabled)) {
681
+ throw new Error(`子代理模板不可用:${templateName}(不存在或已停用)`);
682
+ }
683
+ return this.spawnSubagentConversation(prompt, type, cwd, tpl);
684
+ },
685
+ getSubagent: (convId) => this.getSubagentSnapshot(convId),
686
+ listSubagents: () => this.listSubagentSnapshots(),
687
+ steerSubagent: async (convId, message) => {
688
+ const conv = this.convs.get(convId);
689
+ if (!conv?.session)
690
+ return;
691
+ await conv.session.sendUserMessage(message, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
692
+ },
693
+ stopSubagent: async (convId) => {
694
+ const conv = this.convs.get(convId);
695
+ if (conv && (conv.session.isStreaming || !conv.session.isIdle)) {
696
+ await this.interruptRun(conv, "用户停止子代理");
697
+ }
698
+ },
699
+ // 只向 AI 暴露 enabled 的模板(停用的对 AI 不可见)。
700
+ listTemplates: () => this.subagentTemplates
701
+ .list()
702
+ .filter((t) => t.enabled)
703
+ .map((t) => ({ name: t.name, description: t.description })),
704
+ isTemplateUsable: (name) => {
705
+ const t = this.subagentTemplates.get(name);
706
+ return !!t && t.enabled;
707
+ },
708
+ };
605
709
  widgetsTimer = null;
606
710
  /** Model-stall watchdog interval (see startStallTimer). */
607
711
  stallTimer = null;
@@ -649,11 +753,14 @@ export class ClientSession {
649
753
  gitWatchCwd = null;
650
754
  gitDirtyTimer = null;
651
755
  watchTimer = null;
756
+ /** 子代理模板库(全局共享,<dataDir>/subagent-templates.json)。 */
757
+ subagentTemplates;
652
758
  constructor(clientId, cwd, agentDir, stateStore) {
653
759
  this.clientId = clientId;
654
760
  this.cwd = cwd;
655
761
  this.agentDir = agentDir;
656
762
  this.stateStore = stateStore;
763
+ this.subagentTemplates = new SubagentTemplatesStore(join(stateStore.dataDir, "subagent-templates.json"));
657
764
  this.settingsSvc = new SettingsService({
658
765
  clientId,
659
766
  stateStore,
@@ -672,7 +779,7 @@ export class ClientSession {
672
779
  },
673
780
  effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
674
781
  effectiveSystemPrompt: () => this.effectiveSystemPrompt(),
675
- });
782
+ }, this.subagentTemplates);
676
783
  this.goalSvc = new GoalService({
677
784
  clientId,
678
785
  agentDir,
@@ -741,8 +848,12 @@ export class ClientSession {
741
848
  * Factory for cwd-bound runtimes. All conversations share ONE ModelRuntime
742
849
  * (the model choice is client-wide), so later conversations reuse the
743
850
  * instance created with the first one.
851
+ *
852
+ * `apply`(可选):子代理模板 —— 会话的 system prompt / 技能 / 扩展按模板
853
+ * 应用(prompt replace/append + 白名单),其余(终端接管、Windows persona
854
+ * 等)仍跟随主会话设置。undefined = 按主会话设置(普通对话/不选模板的子代理)。
744
855
  */
745
- makeRuntimeFactory(terminals) {
856
+ makeRuntimeFactory(terminals, apply) {
746
857
  return async ({ cwd: effectiveCwd, sessionManager }) => {
747
858
  const services = await createAgentSessionServices({
748
859
  cwd: effectiveCwd,
@@ -751,6 +862,10 @@ export class ClientSession {
751
862
  // 在每次 resourceLoader.reload() 时重放,且读取 this.settings 的当前
752
863
  // 值——因此 session.reload() 即可让系统提示词 / 技能 / 插件开关生效,
753
864
  // 新对话(新 runtime)也会自动带上当前设置。
865
+ // 子代理带模板(apply)时:prompt/skills/extensions 改读模板视图——
866
+ // replace 模式整体替换为模板提示词;append 模式把模板提示词追加到
867
+ // 末尾(此时主会话的自定义 prompt 不再叠加,角色由模板定义);非空
868
+ // 白名单取代主会话开关(只启用这些),空白名单 = 跟随主会话。
754
869
  resourceLoaderOptions: {
755
870
  // 系统提示词:replace 模式整体替换;append 模式追加到提示词末尾。
756
871
  systemPromptOverride: (base) => {
@@ -759,6 +874,9 @@ export class ClientSession {
759
874
  if (typeof base === "string" && base) {
760
875
  this.lastBaseSystemPrompt = base;
761
876
  }
877
+ if (apply && apply.promptMode === "replace" && apply.systemPrompt.trim()) {
878
+ return apply.systemPrompt;
879
+ }
762
880
  return this.settingsSvc.current.promptMode === "replace" &&
763
881
  this.settingsSvc.current.customSystemPrompt.trim()
764
882
  ? this.settingsSvc.current.customSystemPrompt
@@ -766,9 +884,14 @@ export class ClientSession {
766
884
  },
767
885
  appendSystemPromptOverride: (base) => {
768
886
  const out = [...base];
769
- const custom = this.settingsSvc.current.customSystemPrompt.trim();
770
- if (this.settingsSvc.current.promptMode === "append" && custom) {
771
- out.push(custom);
887
+ if (apply && apply.promptMode === "append" && apply.systemPrompt.trim()) {
888
+ out.push(apply.systemPrompt);
889
+ }
890
+ else {
891
+ const custom = this.settingsSvc.current.customSystemPrompt.trim();
892
+ if (this.settingsSvc.current.promptMode === "append" && custom) {
893
+ out.push(custom);
894
+ }
772
895
  }
773
896
  if (process.platform === "win32") {
774
897
  // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端
@@ -788,18 +911,30 @@ export class ClientSession {
788
911
  out.push(PIPELESS_BASH_GUIDANCE);
789
912
  return out;
790
913
  },
791
- // 技能开关:禁用的技能从系统提示词和 /skill: 目录中剔除。
792
- skillsOverride: (res) => ({
793
- ...res,
794
- skills: res.skills.filter((s) => !this.settingsSvc.current.disabledSkills.includes(s.name)),
795
- }),
796
- // 插件开关:禁用的扩展整个卸载(工具 / 命令随之消失)。
914
+ // 技能:模板非空白名单时只启用白名单里的;否则按主会话禁用集过滤。
915
+ skillsOverride: (res) => {
916
+ if (apply && apply.enabledSkills.length > 0) {
917
+ const set = new Set(apply.enabledSkills);
918
+ return { ...res, skills: res.skills.filter((s) => set.has(s.name)) };
919
+ }
920
+ return {
921
+ ...res,
922
+ skills: res.skills.filter((s) => !this.settingsSvc.current.disabledSkills.includes(s.name)),
923
+ };
924
+ },
925
+ // 插件:模板非空扩展白名单时只加载白名单里的;否则按主会话禁用集过滤。
797
926
  // 注意 SDK 在 extensionsOverride 之后才补 sourceInfo,包扩展此处只能靠路径
798
- // 匹配 —— isExtensionDisabled 同时比对 npm:<pkg> 候选键。
799
- extensionsOverride: (res) => ({
800
- ...res,
801
- extensions: res.extensions.filter((e) => !isExtensionDisabled(e, this.settingsSvc.current.disabledExtensions)),
802
- }),
927
+ // 匹配 —— isExtensionDisabled / isExtensionEnabled 同时比对 npm:<pkg> 候选键。
928
+ extensionsOverride: (res) => {
929
+ if (apply && apply.enabledExtensions.length > 0) {
930
+ const set = new Set(apply.enabledExtensions);
931
+ return { ...res, extensions: res.extensions.filter((e) => isExtensionEnabled(e, [...set])) };
932
+ }
933
+ return {
934
+ ...res,
935
+ extensions: res.extensions.filter((e) => !isExtensionDisabled(e, this.settingsSvc.current.disabledExtensions)),
936
+ };
937
+ },
803
938
  },
804
939
  });
805
940
  const created = await createAgentSessionFromServices({
@@ -823,6 +958,9 @@ export class ClientSession {
823
958
  // 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
824
959
  // refreshPluginTools 动态补入已有会话)。
825
960
  ...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
961
+ // 第一方子代理工具(spawn/get_result/steer/list/stop)。子代理会话
962
+ // 也注册了它们,因此可自然嵌套派发。
963
+ ...makeSubagentTools(this.subagentHost),
826
964
  ],
827
965
  });
828
966
  // 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
@@ -848,6 +986,7 @@ export class ClientSession {
848
986
  return {
849
987
  id,
850
988
  title: conversationTitle(runtime.session),
989
+ isSubagent: false,
851
990
  runtime,
852
991
  session: runtime.session,
853
992
  cwd: runtime.cwd,
@@ -891,13 +1030,12 @@ export class ClientSession {
891
1030
  notifyInterrupted(list) {
892
1031
  if (!list || list.length === 0)
893
1032
  return;
894
- const names = list
895
- .map((r) => `「${r.title}」(${r.cwd})`)
896
- .join("、");
1033
+ const names = list.map((r) => `「${r.title}」(${r.cwd})`).join("、");
897
1034
  this.pendingNotices.push({
898
1035
  type: "notice",
899
1036
  level: "warning",
900
1037
  text: `上次服务重启时有 ${list.length} 个进行中的对话被中断:${names}。可在历史对话中恢复继续。`,
1038
+ textEn: `${list.length} running conversation(s) were interrupted by the last restart: ${names}. Resume them from History.`,
901
1039
  });
902
1040
  }
903
1041
  /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
@@ -949,6 +1087,7 @@ export class ClientSession {
949
1087
  emit(msg) {
950
1088
  if (this.disposed)
951
1089
  return;
1090
+ // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
952
1091
  for (const sink of [...this.sinks])
953
1092
  sink(msg);
954
1093
  }
@@ -991,15 +1130,14 @@ export class ClientSession {
991
1130
  return;
992
1131
  const now = Date.now();
993
1132
  for (const conv of this.convs.values()) {
994
- if (!conv.stallNoticed &&
995
- conv.session.isStreaming &&
996
- now - conv.lastSdkEventAt > STALL_NOTIFY_MS) {
1133
+ if (!conv.stallNoticed && conv.session.isStreaming && now - conv.lastSdkEventAt > STALL_NOTIFY_MS) {
997
1134
  conv.stallNoticed = true;
998
1135
  const mins = Math.round((now - conv.lastSdkEventAt) / 60_000);
999
1136
  this.emit({
1000
1137
  type: "notice",
1001
1138
  level: "warning",
1002
1139
  text: `对话「${conv.title}」已 ${mins} 分钟无任何响应,可能已失联(网络中断或服务端挂起)。可点击停止后重试。`,
1140
+ textEn: `Conversation "${conv.title}" has been silent for ${mins} min — possibly disconnected (network or hung server). Stop it and retry.`,
1003
1141
  });
1004
1142
  }
1005
1143
  }
@@ -1018,6 +1156,7 @@ export class ClientSession {
1018
1156
  type: "notice",
1019
1157
  level: "warning",
1020
1158
  text: `工具执行超过 ${Math.round(TOOL_WATCHDOG_TIMEOUT_MS / 60_000)} 分钟,已自动终止(防止挂死)。可调整超时:环境变量 PI_WEB_TOOL_TIMEOUT_MS(毫秒)。`,
1159
+ textEn: `Tool ran over ${Math.round(TOOL_WATCHDOG_TIMEOUT_MS / 60_000)} min and was auto-terminated (hang guard). Tune via PI_WEB_TOOL_TIMEOUT_MS (ms).`,
1021
1160
  });
1022
1161
  conv.toolStartTimes.delete(toolCallId);
1023
1162
  // Abort the run (kills the process tree via the SDK's abort signal);
@@ -1107,9 +1246,7 @@ export class ClientSession {
1107
1246
  const content = event.result?.content;
1108
1247
  const text = Array.isArray(content)
1109
1248
  ? content
1110
- .map((c) => (typeof c === "object" &&
1111
- c !== null &&
1112
- c.type === "text")
1249
+ .map((c) => typeof c === "object" && c !== null && c.type === "text"
1113
1250
  ? (c.text ?? "")
1114
1251
  : "")
1115
1252
  .join("\n")
@@ -1153,6 +1290,7 @@ export class ClientSession {
1153
1290
  type: "notice",
1154
1291
  level: "info",
1155
1292
  text: "正在压缩上下文…(压缩摘要将显示在消息区)",
1293
+ textEn: "Compacting context… (the summary will appear in the message list)",
1156
1294
  });
1157
1295
  break;
1158
1296
  }
@@ -1162,6 +1300,7 @@ export class ClientSession {
1162
1300
  type: "notice",
1163
1301
  level: "error",
1164
1302
  text: `压缩上下文失败:${event.errorMessage}`,
1303
+ textEn: `Context compaction failed: ${event.errorMessage}`,
1165
1304
  });
1166
1305
  }
1167
1306
  else if (event.aborted) {
@@ -1169,6 +1308,7 @@ export class ClientSession {
1169
1308
  type: "notice",
1170
1309
  level: "warning",
1171
1310
  text: "压缩上下文已取消",
1311
+ textEn: "Context compaction cancelled",
1172
1312
  });
1173
1313
  }
1174
1314
  else if (event.result) {
@@ -1178,6 +1318,7 @@ export class ClientSession {
1178
1318
  type: "notice",
1179
1319
  level: "info",
1180
1320
  text: `上下文压缩完成:${tokensBefore.toLocaleString()} → ${after.toLocaleString()} tokens(摘要已插入消息区)`,
1321
+ textEn: `Context compacted: ${tokensBefore.toLocaleString()} → ${after.toLocaleString()} tokens (summary inserted into the message list)`,
1181
1322
  });
1182
1323
  }
1183
1324
  break;
@@ -1263,9 +1404,7 @@ export class ClientSession {
1263
1404
  // Snapshot checkpoint policy: deltas carry live rendering during streaming;
1264
1405
  // full snapshots are reconciliation checkpoints taken immediately at
1265
1406
  // run/tool boundaries and on a slow timer otherwise.
1266
- if (event.type === "agent_end" ||
1267
- event.type === "tool_execution_end" ||
1268
- event.type === "compaction_end") {
1407
+ if (event.type === "agent_end" || event.type === "tool_execution_end" || event.type === "compaction_end") {
1269
1408
  this.flushSnapshot();
1270
1409
  }
1271
1410
  else {
@@ -1309,9 +1448,7 @@ export class ClientSession {
1309
1448
  // timestamp alone collides in the cache and only the first one renders
1310
1449
  // — append a cheap content fingerprint to keep them distinct while
1311
1450
  // staying stable across snapshots (content never changes once persisted).
1312
- const key = m.role === "toolResult"
1313
- ? `t:${m.toolCallId}`
1314
- : `${m.role}:${m.timestamp}:${contentFingerprint(m)}`;
1451
+ const key = m.role === "toolResult" ? `t:${m.toolCallId}` : `${m.role}:${m.timestamp}:${contentFingerprint(m)}`;
1315
1452
  let n = conv.msgIds.get(key);
1316
1453
  if (n === undefined) {
1317
1454
  n = conv.nextMsgId++;
@@ -1407,9 +1544,7 @@ export class ClientSession {
1407
1544
  // (the SDK only pushes it into state.messages at message_end). Surfacing
1408
1545
  // it here is what makes thinking + text stream into the browser at
1409
1546
  // ~60ms granularity instead of appearing only when the turn finishes.
1410
- streamingMessage: state.streamingMessage
1411
- ? serializeStreamingMessage(state.streamingMessage)
1412
- : null,
1547
+ streamingMessage: state.streamingMessage ? serializeStreamingMessage(state.streamingMessage) : null,
1413
1548
  isStreaming: this.session.isStreaming,
1414
1549
  model: model
1415
1550
  ? {
@@ -1447,10 +1582,7 @@ export class ClientSession {
1447
1582
  return;
1448
1583
  const cur = this.currentMessages();
1449
1584
  const prev = this.emittedMessages;
1450
- let incremental = !forceFull &&
1451
- prev !== null &&
1452
- this.emittedConvId === this.activeId &&
1453
- prev.length <= cur.length;
1585
+ let incremental = !forceFull && prev !== null && this.emittedConvId === this.activeId && prev.length <= cur.length;
1454
1586
  if (incremental && prev) {
1455
1587
  for (let i = 0; i < prev.length; i++) {
1456
1588
  if (prev[i] !== cur[i]) {
@@ -1646,10 +1778,7 @@ export class ClientSession {
1646
1778
  * within UPDATE_ALL_CACHE_MS; pass force=true (explicit refresh) to bypass.
1647
1779
  */
1648
1780
  async checkUpdatesAll(force = false) {
1649
- if (!force &&
1650
- this.updatesAllCache &&
1651
- Date.now() - this.updatesAllCache.at <
1652
- ClientSession.UPDATE_ALL_CACHE_MS) {
1781
+ if (!force && this.updatesAllCache && Date.now() - this.updatesAllCache.at < ClientSession.UPDATE_ALL_CACHE_MS) {
1653
1782
  this.emit({
1654
1783
  type: "update_status_all",
1655
1784
  items: this.updatesAllCache.items,
@@ -1686,6 +1815,7 @@ export class ClientSession {
1686
1815
  type: "notice",
1687
1816
  level: "info",
1688
1817
  text: "正在安装 pi agent CLI(npm i -g @earendil-works/pi-coding-agent)…",
1818
+ textEn: "Installing pi agent CLI (npm i -g @earendil-works/pi-coding-agent)…",
1689
1819
  });
1690
1820
  const { code, out } = await this.runAsync("npm", ["i", "-g", "@earendil-works/pi-coding-agent"], 180_000);
1691
1821
  if (code === 0) {
@@ -1693,6 +1823,7 @@ export class ClientSession {
1693
1823
  type: "notice",
1694
1824
  level: "info",
1695
1825
  text: "✅ pi agent CLI 安装完成。填入 API 密钥即可开始,或在终端运行 pi 完成登录。",
1826
+ textEn: "✅ pi agent CLI installed. Enter an API key to start, or run pi in a terminal to log in.",
1696
1827
  });
1697
1828
  this.emit({ type: "install_result", ok: true, detail: "" });
1698
1829
  }
@@ -1701,6 +1832,7 @@ export class ClientSession {
1701
1832
  type: "notice",
1702
1833
  level: "error",
1703
1834
  text: `pi agent 安装失败(${code ?? "timeout"}):${out.slice(0, 400)}`,
1835
+ textEn: `pi agent install failed (${code ?? "timeout"}): ${out.slice(0, 400)}`,
1704
1836
  });
1705
1837
  this.emit({
1706
1838
  type: "install_result",
@@ -1714,6 +1846,7 @@ export class ClientSession {
1714
1846
  type: "notice",
1715
1847
  level: "error",
1716
1848
  text: `pi agent 安装失败:${err.message}`,
1849
+ textEn: `pi agent install failed: ${err.message}`,
1717
1850
  });
1718
1851
  }
1719
1852
  // The CLI may just have landed on PATH (or the install may have failed) —
@@ -1738,9 +1871,7 @@ export class ClientSession {
1738
1871
  // During active streaming the deltas carry live rendering — full snapshots
1739
1872
  // are just a periodic reconciliation checkpoint, so send them far less
1740
1873
  // often (they serialize the whole session; big sessions made this path OOM).
1741
- const interval = Date.now() - this.lastDeltaAt < DELTA_ACTIVE_WINDOW_MS
1742
- ? STREAMING_SNAPSHOT_INTERVAL_MS
1743
- : SNAPSHOT_INTERVAL_MS;
1874
+ const interval = Date.now() - this.lastDeltaAt < DELTA_ACTIVE_WINDOW_MS ? STREAMING_SNAPSHOT_INTERVAL_MS : SNAPSHOT_INTERVAL_MS;
1744
1875
  this.snapshotTimer = setTimeout(() => {
1745
1876
  this.snapshotTimer = null;
1746
1877
  this.emitSnapshotNow();
@@ -1775,6 +1906,7 @@ export class ClientSession {
1775
1906
  type: "notice",
1776
1907
  level: "error",
1777
1908
  text: `插件命令 /${name} 执行失败:${err.message}`,
1909
+ textEn: `Plugin command /${name} failed: ${err.message}`,
1778
1910
  });
1779
1911
  }
1780
1912
  return true;
@@ -1963,6 +2095,14 @@ export class ClientSession {
1963
2095
  async deletePreset(name) {
1964
2096
  return this.settingsSvc.deletePreset(name);
1965
2097
  }
2098
+ /** Upsert 一个子代理模板(全局共享)。 */
2099
+ async saveSubagentTemplate(template) {
2100
+ return this.settingsSvc.saveTemplate(template);
2101
+ }
2102
+ /** 删除一个子代理模板。 */
2103
+ async deleteSubagentTemplate(name) {
2104
+ return this.settingsSvc.deleteTemplate(name);
2105
+ }
1966
2106
  /** Make settings effective in the running runtime(流式中则延迟到 agent_end)。 */
1967
2107
  async applyRuntimeSettings() {
1968
2108
  return this.settingsSvc.applyRuntime();
@@ -2023,6 +2163,7 @@ export class ClientSession {
2023
2163
  type: "notice",
2024
2164
  level: "error",
2025
2165
  text: "服务器正在排空存量工作(quiesce),已拒绝新的对话/消息/编辑。存量运行会继续跑完;用 pi-web-ui server unquiesce 可恢复。",
2166
+ textEn: "Server is draining (quiesce) and rejected the new chat/message/edit. Existing runs continue; resume with pi-web-ui server unquiesce.",
2026
2167
  });
2027
2168
  this.flushSnapshot();
2028
2169
  return true;
@@ -2086,8 +2227,7 @@ export class ClientSession {
2086
2227
  // re-derives it from the persisted transcript when needed.
2087
2228
  if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
2088
2229
  const trimmed = text.trim().replace(/\s+/g, " ");
2089
- conv.title =
2090
- trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
2230
+ conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
2091
2231
  this.emitConversations();
2092
2232
  }
2093
2233
  // Attach files as independent nextTurn context messages (asides) so the
@@ -2126,6 +2266,7 @@ export class ClientSession {
2126
2266
  type: "notice",
2127
2267
  level: "error",
2128
2268
  text: `提示发送失败:${err.message}`,
2269
+ textEn: `Failed to send prompt: ${err.message}`,
2129
2270
  });
2130
2271
  }
2131
2272
  // The active conversation (captured at prompt start — see above) has been
@@ -2206,6 +2347,7 @@ export class ClientSession {
2206
2347
  type: "notice",
2207
2348
  level: "error",
2208
2349
  text: `重新入队插队消息失败:${err.message}`,
2350
+ textEn: `Failed to re-queue the steer message: ${err.message}`,
2209
2351
  });
2210
2352
  }
2211
2353
  }
@@ -2218,6 +2360,7 @@ export class ClientSession {
2218
2360
  type: "notice",
2219
2361
  level: "error",
2220
2362
  text: `重新入队排队消息失败:${err.message}`,
2363
+ textEn: `Failed to re-queue the queued message: ${err.message}`,
2221
2364
  });
2222
2365
  }
2223
2366
  }
@@ -2246,6 +2389,7 @@ export class ClientSession {
2246
2389
  type: "notice",
2247
2390
  level: "info",
2248
2391
  text: `后台任务「${taskId}」不存在或已结束`,
2392
+ textEn: `Background task "${taskId}" does not exist or has ended`,
2249
2393
  });
2250
2394
  }
2251
2395
  this.bg.push();
@@ -2270,16 +2414,19 @@ export class ClientSession {
2270
2414
  type: "notice",
2271
2415
  level: "info",
2272
2416
  text: "当前没有正在运行的 bash 命令",
2417
+ textEn: "No bash command is running",
2273
2418
  });
2274
2419
  this.flushSnapshot();
2275
2420
  return;
2276
2421
  }
2422
+ // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
2277
2423
  for (const ac of [...this.bashKills])
2278
2424
  ac.abort();
2279
2425
  this.emit({
2280
2426
  type: "notice",
2281
2427
  level: "info",
2282
2428
  text: "已停止 bash 命令(对话继续)",
2429
+ textEn: "Bash command stopped (conversation continues)",
2283
2430
  });
2284
2431
  // 让 AI 明确知道是用户手动停止:sendUserMessage 触发下一轮,agent
2285
2432
  // 会看到「命令被用户中止」而不是普通失败,并据此继续(不会困惑于
@@ -2328,6 +2475,7 @@ export class ClientSession {
2328
2475
  type: "notice",
2329
2476
  level: "error",
2330
2477
  text: `中止失败:${err.message}`,
2478
+ textEn: `Abort failed: ${err.message}`,
2331
2479
  });
2332
2480
  }
2333
2481
  // 3) abort returned but no agent_end within the settle window → the
@@ -2368,6 +2516,7 @@ export class ClientSession {
2368
2516
  type: "notice",
2369
2517
  level: "error",
2370
2518
  text: `强制中断失败:${err.message}`,
2519
+ textEn: `Force-stop failed: ${err.message}`,
2371
2520
  });
2372
2521
  }
2373
2522
  }
@@ -2410,6 +2559,7 @@ export class ClientSession {
2410
2559
  type: "notice",
2411
2560
  level: "warning",
2412
2561
  text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
2562
+ textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
2413
2563
  });
2414
2564
  return;
2415
2565
  }
@@ -2462,6 +2612,7 @@ export class ClientSession {
2462
2612
  type: "notice",
2463
2613
  level: "error",
2464
2614
  text: `新建对话失败:${err.message}`,
2615
+ textEn: `Failed to create chat: ${err.message}`,
2465
2616
  });
2466
2617
  }
2467
2618
  this.flushSnapshot();
@@ -2489,6 +2640,12 @@ export class ClientSession {
2489
2640
  // Also retain when a non-expired pi-subagents wait-subscription record
2490
2641
  // exists on disk for this session: a finished background subagent run
2491
2642
  // still owes this conversation a wake-up turn.
2643
+ // 更靠前的阶段:run 本身还在 queued/running(workflow 编排中)时释放
2644
+ // runtime 同样杀死扩展宿主并 abort 所有 live workflow controller,比
2645
+ // wake 订阅早一步——磁盘 .active-runs marker + status.json 探测(pi-web-ui #52)。
2646
+ // Retain while the session has active (queued/running) pi-subagents async
2647
+ // runs on disk — the extension host would otherwise be torn down and its
2648
+ // workflow controllers aborted mid-flight.
2492
2649
  const retained = shouldRetainActive({
2493
2650
  reviewing: conv.goal.reviewing,
2494
2651
  wizardRunning: conv.wizardRunning,
@@ -2496,6 +2653,7 @@ export class ClientSession {
2496
2653
  openTerminals: conv.terminals.list().length,
2497
2654
  listed: conv.listed,
2498
2655
  promptedSinceActive: conv.promptedSinceActive,
2656
+ hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
2499
2657
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
2500
2658
  });
2501
2659
  if (retained) {
@@ -2585,6 +2743,7 @@ export class ClientSession {
2585
2743
  cwd: conv.cwd,
2586
2744
  messageCount,
2587
2745
  isStreaming,
2746
+ isSubagent: !!conv.isSubagent,
2588
2747
  });
2589
2748
  }
2590
2749
  this.emit({
@@ -2650,9 +2809,7 @@ export class ClientSession {
2650
2809
  source: "web",
2651
2810
  });
2652
2811
  }
2653
- const sorted = [...sessions.values()]
2654
- .sort((a, b) => b.modified - a.modified)
2655
- .slice(0, 200); // newest first — the panel shows recent history
2812
+ const sorted = [...sessions.values()].sort((a, b) => b.modified - a.modified).slice(0, 200); // newest first — the panel shows recent history
2656
2813
  this.emit({ type: "sessions", sessions: sorted });
2657
2814
  }
2658
2815
  catch {
@@ -2685,6 +2842,7 @@ export class ClientSession {
2685
2842
  type: "notice",
2686
2843
  level: "error",
2687
2844
  text: "只能删除会话目录中的对话记录",
2845
+ textEn: "Only transcripts inside the session directory can be deleted",
2688
2846
  });
2689
2847
  return;
2690
2848
  }
@@ -2703,6 +2861,7 @@ export class ClientSession {
2703
2861
  type: "notice",
2704
2862
  level: "warning",
2705
2863
  text: "该对话正在后台运行,请先停止或关闭该对话再删除",
2864
+ textEn: "This conversation is still running — stop or close it before deleting",
2706
2865
  });
2707
2866
  return;
2708
2867
  }
@@ -2755,9 +2914,68 @@ export class ClientSession {
2755
2914
  type: "notice",
2756
2915
  level: "error",
2757
2916
  text: `删除会话失败:${err.message}`,
2917
+ textEn: `Failed to delete session: ${err.message}`,
2758
2918
  });
2759
2919
  }
2760
2920
  }
2921
+ /** Dismiss a running conversation from the left-panel list without deleting its
2922
+ * transcript file. Only idle (non-streaming) conversations that are not
2923
+ * retained by terminal/wake/review state can be dismissed. The session stays
2924
+ * in history and can be reopened. */
2925
+ async dismissConversation(id) {
2926
+ const conv = this.convs.get(id);
2927
+ if (!conv) {
2928
+ this.emit({ type: "notice", level: "warning", text: "该对话不存在或已关闭" });
2929
+ return;
2930
+ }
2931
+ if (id === this.activeId) {
2932
+ this.emit({ type: "notice", level: "warning", text: "当前对话不能直接移出,请先切换到其他对话" });
2933
+ return;
2934
+ }
2935
+ if (!conv.listed) {
2936
+ // Not in list anyway — nothing to do.
2937
+ this.emitConversations();
2938
+ return;
2939
+ }
2940
+ // Streaming / retained conversations refuse dismissal — mirrors displaceActive retention.
2941
+ const retained = shouldRetainActive({
2942
+ reviewing: conv.goal.reviewing,
2943
+ wizardRunning: conv.wizardRunning,
2944
+ streaming: conv.session.isStreaming,
2945
+ openTerminals: conv.terminals.list().length,
2946
+ listed: conv.listed,
2947
+ promptedSinceActive: conv.promptedSinceActive,
2948
+ hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
2949
+ hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
2950
+ });
2951
+ if (retained) {
2952
+ if (conv.session.isStreaming) {
2953
+ this.emit({
2954
+ type: "notice",
2955
+ level: "warning",
2956
+ text: `对话「${conv.title}」仍在运行中,请先等待结束或点击停止后再移出`,
2957
+ });
2958
+ }
2959
+ else if (conv.terminals.list().length > 0) {
2960
+ this.emit({
2961
+ type: "notice",
2962
+ level: "warning",
2963
+ text: `对话「${conv.title}」还有未关闭的终端,请先关闭终端后再移出`,
2964
+ });
2965
+ }
2966
+ else {
2967
+ this.emit({
2968
+ type: "notice",
2969
+ level: "warning",
2970
+ text: `对话「${conv.title}」暂时无法移出(存在待处理的后台任务/审查)`,
2971
+ });
2972
+ }
2973
+ return;
2974
+ }
2975
+ this.removeConversation(id);
2976
+ this.emitConversations();
2977
+ this.flushSnapshot();
2978
+ }
2761
2979
  /** Open a persisted session as the active conversation (from listSessions).
2762
2980
  *
2763
2981
  * A persisted-session click must follow the same ownership rule as
@@ -2795,9 +3013,7 @@ export class ClientSession {
2795
3013
  // is known-good. This keeps a failed history open entirely non-destructive.
2796
3014
  const oldListed = this.conv.listed;
2797
3015
  const displaced = this.displaceActive();
2798
- const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length +
2799
- 1 -
2800
- (displaced?.cwd === targetCwd ? 1 : 0);
3016
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length + 1 - (displaced?.cwd === targetCwd ? 1 : 0);
2801
3017
  if (openInProject > MAX_OPEN_CONVERSATIONS) {
2802
3018
  // displaceActive() may have promoted a streaming conversation into the
2803
3019
  // running list. Roll that presentation-only mutation back because no
@@ -2811,6 +3027,7 @@ export class ClientSession {
2811
3027
  type: "notice",
2812
3028
  level: "warning",
2813
3029
  text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
3030
+ textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
2814
3031
  });
2815
3032
  return;
2816
3033
  }
@@ -2844,6 +3061,7 @@ export class ClientSession {
2844
3061
  type: "notice",
2845
3062
  level: "error",
2846
3063
  text: `切换会话失败:${err.message}`,
3064
+ textEn: `Failed to switch session: ${err.message}`,
2847
3065
  });
2848
3066
  }
2849
3067
  this.flushSnapshot();
@@ -2896,6 +3114,7 @@ export class ClientSession {
2896
3114
  type: "notice",
2897
3115
  level: "warning",
2898
3116
  text: "编辑内容为空,已取消",
3117
+ textEn: "Edited content is empty — cancelled",
2899
3118
  });
2900
3119
  this.flushSnapshot();
2901
3120
  return;
@@ -2906,6 +3125,7 @@ export class ClientSession {
2906
3125
  type: "notice",
2907
3126
  level: "error",
2908
3127
  text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
3128
+ textEn: "Message to edit not found (may have been compacted or is on another branch)",
2909
3129
  });
2910
3130
  this.flushSnapshot();
2911
3131
  return;
@@ -2920,6 +3140,7 @@ export class ClientSession {
2920
3140
  type: "notice",
2921
3141
  level: "info",
2922
3142
  text: "已取消编辑重问",
3143
+ textEn: "Edit-and-reask cancelled",
2923
3144
  });
2924
3145
  this.flushSnapshot();
2925
3146
  return;
@@ -2941,6 +3162,7 @@ export class ClientSession {
2941
3162
  type: "notice",
2942
3163
  level: "info",
2943
3164
  text: "已从该问题重新提问(原对话保留在会话列表中)",
3165
+ textEn: "Re-asked from that question (the original stays in the session list)",
2944
3166
  });
2945
3167
  }
2946
3168
  catch (err) {
@@ -2948,6 +3170,7 @@ export class ClientSession {
2948
3170
  type: "notice",
2949
3171
  level: "error",
2950
3172
  text: `编辑重问失败:${err.message}`,
3173
+ textEn: `Edit-and-reask failed: ${err.message}`,
2951
3174
  });
2952
3175
  }
2953
3176
  this.flushSnapshot();
@@ -3059,6 +3282,7 @@ export class ClientSession {
3059
3282
  type: "notice",
3060
3283
  level: "error",
3061
3284
  text: `切换模型失败:${err.message}`,
3285
+ textEn: `Failed to switch model: ${err.message}`,
3062
3286
  });
3063
3287
  }
3064
3288
  this.flushSnapshot();
@@ -3085,6 +3309,7 @@ export class ClientSession {
3085
3309
  type: "notice",
3086
3310
  level: "info",
3087
3311
  text: `已在工作目录:${abs}`,
3312
+ textEn: `Already in directory: ${abs}`,
3088
3313
  });
3089
3314
  this.flushSnapshot();
3090
3315
  return;
@@ -3098,8 +3323,7 @@ export class ClientSession {
3098
3323
  // project has none open yet.
3099
3324
  let target;
3100
3325
  for (const c of this.convs.values()) {
3101
- if (c.cwd === abs &&
3102
- (!target || c.lastActiveAt > target.lastActiveAt)) {
3326
+ if (c.cwd === abs && (!target || c.lastActiveAt > target.lastActiveAt)) {
3103
3327
  target = c;
3104
3328
  }
3105
3329
  }
@@ -3154,6 +3378,7 @@ export class ClientSession {
3154
3378
  type: "notice",
3155
3379
  level: "info",
3156
3380
  text: `已切换到工作目录:${abs}`,
3381
+ textEn: `Switched to directory: ${abs}`,
3157
3382
  });
3158
3383
  void this.refreshSessions();
3159
3384
  void this.listFiles(undefined);
@@ -3165,6 +3390,7 @@ export class ClientSession {
3165
3390
  type: "notice",
3166
3391
  level: "error",
3167
3392
  text: `切换工作目录失败:${err.message}`,
3393
+ textEn: `Failed to switch directory: ${err.message}`,
3168
3394
  });
3169
3395
  }
3170
3396
  this.flushSnapshot();
@@ -3188,6 +3414,7 @@ export class ClientSession {
3188
3414
  type: "notice",
3189
3415
  level: "error",
3190
3416
  text: `获取模型列表失败:${err.message}`,
3417
+ textEn: `Failed to fetch model list: ${err.message}`,
3191
3418
  });
3192
3419
  }
3193
3420
  }
@@ -3245,6 +3472,7 @@ export class ClientSession {
3245
3472
  type: "notice",
3246
3473
  level: "error",
3247
3474
  text: `切换模型失败:${err.message}`,
3475
+ textEn: `Failed to switch model: ${err.message}`,
3248
3476
  });
3249
3477
  }
3250
3478
  this.flushSnapshot();
@@ -3259,6 +3487,7 @@ export class ClientSession {
3259
3487
  type: "notice",
3260
3488
  level: "error",
3261
3489
  text: `切换思考强度失败:${err.message}`,
3490
+ textEn: `Failed to switch thinking level: ${err.message}`,
3262
3491
  });
3263
3492
  }
3264
3493
  this.flushSnapshot();
@@ -3272,6 +3501,7 @@ export class ClientSession {
3272
3501
  type: "notice",
3273
3502
  level: "error",
3274
3503
  text: `切换思考强度失败:${err.message}`,
3504
+ textEn: `Failed to switch thinking level: ${err.message}`,
3275
3505
  });
3276
3506
  }
3277
3507
  this.flushSnapshot();
@@ -3292,7 +3522,7 @@ export class ClientSession {
3292
3522
  return;
3293
3523
  }
3294
3524
  this.emit({ type: "commands", commands, path });
3295
- this.emit({ type: "notice", level: "info", text: `命令已保存:${path}` });
3525
+ this.emit({ type: "notice", level: "info", text: `命令已保存:${path}`, textEn: `Command saved: ${path}` });
3296
3526
  }
3297
3527
  async dispose() {
3298
3528
  this.disposed = true;
@@ -3380,9 +3610,7 @@ export class AgentService {
3380
3610
  }
3381
3611
  /** Snapshot for the control socket / status command. */
3382
3612
  quiesceInfo() {
3383
- return this.quiesced
3384
- ? { quiesced: true, quiescedSince: this.quiescedAt }
3385
- : { quiesced: false };
3613
+ return this.quiesced ? { quiesced: true, quiescedSince: this.quiescedAt } : { quiesced: false };
3386
3614
  }
3387
3615
  /** Aggregate across every client session: conversations with in-flight runs. */
3388
3616
  activeConversations() {
@@ -3460,6 +3688,7 @@ export class AgentService {
3460
3688
  type: "notice",
3461
3689
  level: "info",
3462
3690
  text: `已恢复上次的工作目录:${cwd}`,
3691
+ textEn: `Restored the last working directory: ${cwd}`,
3463
3692
  });
3464
3693
  }
3465
3694
  }
@@ -3508,6 +3737,7 @@ export class AgentService {
3508
3737
  async disposeAll() {
3509
3738
  // Record still-streaming conversations BEFORE tearing anything down, so
3510
3739
  // the next attach can tell the user what was lost (SIGTERM / update).
3740
+ // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
3511
3741
  for (const [clientId, cs] of [...this.clients]) {
3512
3742
  try {
3513
3743
  const running = cs.streamingSummaries();