pi-web-ui 0.94.1 → 0.96.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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -29,7 +29,7 @@ import { basename, dirname, join, resolve, sep } from "node:path";
29
29
  import { randomUUID } from "node:crypto";
30
30
  import { homedir } from "node:os";
31
31
  import { BgServerTracker } from "../bg-servers.js";
32
- import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS, normalizeToolWatchdogTimeoutMs } from "../client-state.js";
32
+ import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS, normalizePathKey, normalizeToolWatchdogTimeoutMs, } from "../client-state.js";
33
33
  import { normalizeUiLayout } from "../client-state.js";
34
34
  import { FilesService, workspacePath, desktopDirWire } from "../files-service.js";
35
35
  import { QuiesceRejectedError } from "../agent-service.js";
@@ -124,9 +124,11 @@ const DEFAULT_SETTINGS = {
124
124
  terminalToolsEnabled: false,
125
125
  terminalBash: false,
126
126
  terminalBashIdleMs: 15_000,
127
+ terminalBashMaxForegroundMs: 60_000,
127
128
  toolWatchdogTimeoutMs: 20 * 60_000,
128
129
  editSoftEnabled: false,
129
130
  questionnaireEnabled: true,
131
+ toolApprovalEnabled: true,
130
132
  goalModeEnabled: true,
131
133
  parallelReminderEnabled: true,
132
134
  thinkingWrap: false,
@@ -271,9 +273,11 @@ export class DshClientSession {
271
273
  terminalToolsEnabled: savedSettings.terminalToolsEnabled,
272
274
  terminalBash: savedSettings.terminalBash,
273
275
  terminalBashIdleMs: savedSettings.terminalBashIdleMs,
276
+ terminalBashMaxForegroundMs: savedSettings.terminalBashMaxForegroundMs ?? 60_000,
274
277
  toolWatchdogTimeoutMs: savedSettings.toolWatchdogTimeoutMs ?? 20 * 60_000,
275
278
  editSoftEnabled: savedSettings.editSoftEnabled,
276
279
  questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
280
+ toolApprovalEnabled: savedSettings.toolApprovalEnabled ?? true,
277
281
  goalModeEnabled: savedSettings.goalModeEnabled ?? true,
278
282
  parallelReminderEnabled: savedSettings.parallelReminderEnabled ?? true,
279
283
  thinkingWrap: savedSettings.thinkingWrap,
@@ -364,6 +368,7 @@ export class DshClientSession {
364
368
  // 直播帧能力跟着运行时进程走:重启后重新探测(换运行时版本也能回落到持久 assistant/chunk)。
365
369
  conv.liveChunks = false;
366
370
  }
371
+ this.emitConversations();
367
372
  const now = Date.now();
368
373
  if (now - this.runtimeRestart.windowStart > DshClientSession.RUNTIME_RESTART_WINDOW_MS) {
369
374
  this.runtimeRestart.windowStart = now;
@@ -1131,6 +1136,7 @@ export class DshClientSession {
1131
1136
  conv.streaming = null;
1132
1137
  this.refreshConversationTitle(conv);
1133
1138
  this.scheduleSessionsRefresh();
1139
+ this.emitConversations();
1134
1140
  }
1135
1141
  this.flushSnapshot();
1136
1142
  }
@@ -1412,8 +1418,10 @@ export class DshClientSession {
1412
1418
  if (active.messages.length === 0 && active.terminals.list().length === 0) {
1413
1419
  if (preset)
1414
1420
  await this.selectAgentPreset(preset);
1415
- else
1421
+ else {
1422
+ this.pushSettings();
1416
1423
  this.flushSnapshot();
1424
+ }
1417
1425
  return true;
1418
1426
  }
1419
1427
  for (const conv of this.convs.values()) {
@@ -1447,6 +1455,7 @@ export class DshClientSession {
1447
1455
  this.emitConversations();
1448
1456
  this.emitGoalStatus();
1449
1457
  this.pushTerminals();
1458
+ this.pushSettings();
1450
1459
  this.flushSnapshot();
1451
1460
  void this.refreshActivePermission().catch(() => { });
1452
1461
  return true;
@@ -1481,6 +1490,7 @@ export class DshClientSession {
1481
1490
  this.emitConversations();
1482
1491
  this.emitGoalStatus();
1483
1492
  this.pushTerminals();
1493
+ this.pushSettings();
1484
1494
  this.flushSnapshot(true);
1485
1495
  // 切会话带上权限值(cwd 变化走运行时重启,onStarted 会重拉)。
1486
1496
  void this.refreshActivePermission().catch(() => { });
@@ -1803,62 +1813,46 @@ export class DshClientSession {
1803
1813
  }
1804
1814
  }
1805
1815
  else if (resolved) {
1806
- if (a.mode === "inline" || a.mode === undefined) {
1807
- // 内联文本(小文件直接读内容)。
1816
+ // 一律只给路径引用:不再把文件内容注入 prompt(小文件也一样),模型用
1817
+ // 自己的读取工具按需读。行范围模式带上行号提示。
1818
+ // 工作区图片文件仍走 attachment store → 真 image 块(模型可看图)。
1819
+ const refText = pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref", {
1820
+ "resolved.rel": resolved.rel,
1821
+ }) +
1822
+ (a.mode === "lines" && a.lines
1823
+ ? pick(lang, `(第 ${a.lines.start}-${a.lines.end} 行)`, ` (lines ${a.lines.start}-${a.lines.end})`)
1824
+ : "");
1825
+ let isImage = false;
1826
+ try {
1827
+ const st = statSync(resolved.abs);
1828
+ isImage = st.isFile() && st.size > 0 && st.size <= 512 * 1024 && previewKind(resolved.abs) === "image";
1829
+ }
1830
+ catch {
1831
+ /* 不存在/不可读 → 照样给路径引用,读取工具会自己报错 */
1832
+ }
1833
+ if (isImage) {
1808
1834
  try {
1809
- const st = statSync(resolved.abs);
1810
- if (st.size <= 512 * 1024) {
1811
- const buf = readFileSync(resolved.abs);
1812
- const kind = previewKind(resolved.abs);
1813
- if (kind === "image") {
1814
- // 工作区图片文件 → attachment store → 真 image 块。
1815
- try {
1816
- const ext = (resolved.rel.match(/\.([a-z0-9]+)$/iu)?.[1] ?? "png").toLowerCase();
1817
- const mediaType = ext === "jpg" || ext === "jpeg"
1818
- ? "image/jpeg"
1819
- : ext === "webp"
1820
- ? "image/webp"
1821
- : ext === "gif"
1822
- ? "image/gif"
1823
- : "image/png";
1824
- const saved = await this.runtime.attachmentSave(mediaType, buf.toString("base64"), resolved.rel);
1825
- blocks.push({ type: "image", attachment: saved.ref });
1826
- }
1827
- catch {
1828
- blocks.push({
1829
- type: "text",
1830
- text: pick(lang, `\n[图片附件: ${resolved.rel}]`, `\n[Image attachment: ${resolved.rel}]`, "dsh.attach.image.ref", { "resolved.rel": resolved.rel }),
1831
- });
1832
- }
1833
- }
1834
- else {
1835
- const enc = this.decodeText(buf);
1836
- const capped = enc.length > 100_000 ? `${enc.slice(0, 100_000)}\n… [truncated]` : enc;
1837
- blocks.push({
1838
- type: "text",
1839
- text: `\n<file path="${resolved.rel}">\n${capped}\n</file>`,
1840
- });
1841
- }
1842
- }
1843
- else {
1844
- blocks.push({
1845
- type: "text",
1846
- text: pick(lang, `\n[文件引用: ${resolved.rel}(大文件,请用读取工具查看)]`, `\n[File reference: ${resolved.rel} (large file, use the read tool to view it)]`, "dsh.attach.file.large", { "resolved.rel": resolved.rel }),
1847
- });
1848
- }
1835
+ const buf = readFileSync(resolved.abs);
1836
+ const ext = (resolved.rel.match(/\.([a-z0-9]+)$/iu)?.[1] ?? "png").toLowerCase();
1837
+ const mediaType = ext === "jpg" || ext === "jpeg"
1838
+ ? "image/jpeg"
1839
+ : ext === "webp"
1840
+ ? "image/webp"
1841
+ : ext === "gif"
1842
+ ? "image/gif"
1843
+ : "image/png";
1844
+ const saved = await this.runtime.attachmentSave(mediaType, buf.toString("base64"), resolved.rel);
1845
+ blocks.push({ type: "image", attachment: saved.ref });
1849
1846
  }
1850
1847
  catch {
1851
1848
  blocks.push({
1852
1849
  type: "text",
1853
- text: pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref.fallback", { "resolved.rel": resolved.rel }),
1850
+ text: pick(lang, `\n[图片附件: ${resolved.rel}]`, `\n[Image attachment: ${resolved.rel}]`, "dsh.attach.image.ref", { "resolved.rel": resolved.rel }),
1854
1851
  });
1855
1852
  }
1856
1853
  }
1857
1854
  else {
1858
- blocks.push({
1859
- type: "text",
1860
- text: pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref", { "resolved.rel": resolved.rel }),
1861
- });
1855
+ blocks.push({ type: "text", text: refText });
1862
1856
  }
1863
1857
  }
1864
1858
  else if (a.name) {
@@ -1870,19 +1864,6 @@ export class DshClientSession {
1870
1864
  }
1871
1865
  return blocks;
1872
1866
  }
1873
- decodeText(buf) {
1874
- try {
1875
- return new TextDecoder("utf-8", { fatal: true }).decode(buf);
1876
- }
1877
- catch {
1878
- try {
1879
- return new TextDecoder("gbk").decode(buf);
1880
- }
1881
- catch {
1882
- return buf.toString("latin1");
1883
- }
1884
- }
1885
- }
1886
1867
  /** 中止:kill 运行时进程树(所有 conversation 的运行停止)→ 自动重启保持可用。 */
1887
1868
  async abort() {
1888
1869
  if (!this.runtime.alive)
@@ -1890,6 +1871,7 @@ export class DshClientSession {
1890
1871
  const conv = this.conv;
1891
1872
  conv.isStreaming = false;
1892
1873
  conv.streaming = null;
1874
+ this.emitConversations();
1893
1875
  // 手动停止 → 清当前会话的 DSH 原生目标(半成品运行不该继续被轮次驱动)。
1894
1876
  // 旧进程还活着,先 goal/clear 落盘,再重启运行时。
1895
1877
  if (conv.dsGoal || conv.goal.goal) {
@@ -2239,6 +2221,7 @@ export class DshClientSession {
2239
2221
  this.activeId = conv.id;
2240
2222
  this.emitConversations();
2241
2223
  this.pushTerminals();
2224
+ this.pushSettings();
2242
2225
  this.flushSnapshot(true);
2243
2226
  }
2244
2227
  catch (err) {
@@ -2311,9 +2294,10 @@ export class DshClientSession {
2311
2294
  // -----------------------------------------------------------------------
2312
2295
  async pushProjects() {
2313
2296
  const saved = this.stateStore.get(this.clientId);
2297
+ const removedKeys = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
2314
2298
  const projects = new Map();
2315
2299
  for (const p of saved.projects ?? []) {
2316
- if (!(saved.removedProjects ?? []).includes(p.path)) {
2300
+ if (!removedKeys.has(normalizePathKey(p.path))) {
2317
2301
  projects.set(p.path, p.lastUsed);
2318
2302
  }
2319
2303
  }
@@ -2325,7 +2309,7 @@ export class DshClientSession {
2325
2309
  for (const e of entries) {
2326
2310
  if (e.isDirectory()) {
2327
2311
  const cwd = this.decodeProjectKey(e.name);
2328
- if (cwd && !projects.has(cwd))
2312
+ if (cwd && !projects.has(cwd) && !removedKeys.has(normalizePathKey(cwd)))
2329
2313
  cwdProjects.add(cwd);
2330
2314
  }
2331
2315
  }
@@ -2335,7 +2319,9 @@ export class DshClientSession {
2335
2319
  }
2336
2320
  for (const cwd of cwdProjects)
2337
2321
  projects.set(cwd, Date.now());
2338
- projects.set(this.cwd, Date.now());
2322
+ if (!removedKeys.has(normalizePathKey(this.cwd))) {
2323
+ projects.set(this.cwd, Date.now());
2324
+ }
2339
2325
  const list = [...projects.entries()]
2340
2326
  .map(([path, lastUsed]) => ({ path, lastUsed }))
2341
2327
  .sort((a, b) => b.lastUsed - a.lastUsed)
@@ -2624,6 +2610,7 @@ export class DshClientSession {
2624
2610
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2625
2611
  terminalBash: this.settings.terminalBash,
2626
2612
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2613
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
2627
2614
  toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
2628
2615
  // DSH 引擎无 customTool 注册面(工具来自 shipped preset),read 目录覆盖面不存在。
2629
2616
  readDirEnabled: true,
@@ -2634,6 +2621,10 @@ export class DshClientSession {
2634
2621
  softCapTokens: 0,
2635
2622
  softCapByModel: {},
2636
2623
  questionnaireEnabled: this.settings.questionnaireEnabled,
2624
+ toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
2625
+ // DSH 无审批弹窗,策略恒为空(保协议完整)。
2626
+ approvalPolicy: { allowAll: false, categories: [] },
2627
+ approvalRules: [],
2637
2628
  goalModeEnabled: this.settings.goalModeEnabled,
2638
2629
  parallelReminderEnabled: this.settings.parallelReminderEnabled,
2639
2630
  thinkingWrap: this.settings.thinkingWrap,
@@ -2695,12 +2686,16 @@ export class DshClientSession {
2695
2686
  this.settings.terminalBash = partial.terminalBash;
2696
2687
  if (partial.terminalBashIdleMs !== undefined)
2697
2688
  this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
2689
+ if (partial.terminalBashMaxForegroundMs !== undefined)
2690
+ this.settings.terminalBashMaxForegroundMs = partial.terminalBashMaxForegroundMs;
2698
2691
  if (partial.toolWatchdogTimeoutMs !== undefined)
2699
2692
  this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
2700
2693
  if (partial.editSoftEnabled !== undefined)
2701
2694
  this.settings.editSoftEnabled = partial.editSoftEnabled;
2702
2695
  if (partial.questionnaireEnabled !== undefined)
2703
2696
  this.settings.questionnaireEnabled = partial.questionnaireEnabled;
2697
+ if (partial.toolApprovalEnabled !== undefined)
2698
+ this.settings.toolApprovalEnabled = partial.toolApprovalEnabled;
2704
2699
  if (partial.goalModeEnabled !== undefined)
2705
2700
  this.settings.goalModeEnabled = partial.goalModeEnabled;
2706
2701
  if (partial.parallelReminderEnabled !== undefined)
@@ -2735,6 +2730,7 @@ export class DshClientSession {
2735
2730
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2736
2731
  terminalBash: this.settings.terminalBash,
2737
2732
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2733
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
2738
2734
  editSoftEnabled: this.settings.editSoftEnabled,
2739
2735
  // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2740
2736
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
@@ -2742,6 +2738,7 @@ export class DshClientSession {
2742
2738
  softCapTokens: 0,
2743
2739
  softCapByModel: {},
2744
2740
  questionnaireEnabled: this.settings.questionnaireEnabled,
2741
+ toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
2745
2742
  goalModeEnabled: this.settings.goalModeEnabled,
2746
2743
  parallelReminderEnabled: this.settings.parallelReminderEnabled,
2747
2744
  thinkingWrap: this.settings.thinkingWrap,
@@ -2902,6 +2899,7 @@ export class DshClientSession {
2902
2899
  }
2903
2900
  conv.agentPreset = res.preset ?? target;
2904
2901
  this.emitConversations();
2902
+ this.pushSettings();
2905
2903
  this.flushSnapshot();
2906
2904
  }
2907
2905
  catch (err) {
@@ -3059,6 +3057,7 @@ export class DshClientSession {
3059
3057
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
3060
3058
  terminalBash: this.settings.terminalBash,
3061
3059
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
3060
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
3062
3061
  editSoftEnabled: this.settings.editSoftEnabled,
3063
3062
  // DSH 无独立重试配置,预设沿用默认值。
3064
3063
  // DSH 无 skill 全文注入概念,给空保预设类型完整。
@@ -3099,6 +3098,8 @@ export class DshClientSession {
3099
3098
  this.settings.terminalToolsEnabled = preset.terminalToolsEnabled;
3100
3099
  this.settings.terminalBash = preset.terminalBash;
3101
3100
  this.settings.terminalBashIdleMs = preset.terminalBashIdleMs;
3101
+ this.settings.terminalBashMaxForegroundMs =
3102
+ preset.terminalBashMaxForegroundMs ?? this.settings.terminalBashMaxForegroundMs;
3102
3103
  this.settings.editSoftEnabled = preset.editSoftEnabled ?? this.settings.editSoftEnabled;
3103
3104
  this.settings.reviewPrompt = preset.reviewPrompt ?? "";
3104
3105
  this.stateStore.saveSettings(this.clientId, {
@@ -3109,6 +3110,7 @@ export class DshClientSession {
3109
3110
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
3110
3111
  terminalBash: this.settings.terminalBash,
3111
3112
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
3113
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
3112
3114
  editSoftEnabled: this.settings.editSoftEnabled,
3113
3115
  thinkingWrap: this.settings.thinkingWrap,
3114
3116
  toolsWrap: this.settings.toolsWrap,
@@ -3457,7 +3459,7 @@ export class DshClientSession {
3457
3459
  `# User's raw requirement`,
3458
3460
  draft,
3459
3461
  ``,
3460
- `Use the ask_user_question tool to ask the user focused questions to pin down the essential, ambiguous details. Ask ONE question at a time, usually 2 to 4 questions total: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment). Prefer multiple-choice questions (options) when you can offer clear choices.`,
3462
+ `Use the ask_user_question tool to ask the user focused questions to pin down the essential, ambiguous details. Ask ONE question at a time, strictly 1 to 3 questions total: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment). Prefer 2 to 4 mutually exclusive options with the recommended choice placed FIRST, and explain the tradeoff in description.`,
3461
3463
  `Once you have enough to write an unambiguous, reviewable goal, STOP asking and reply with EXACTLY this format and nothing else (no preamble, no bullets):`,
3462
3464
  `GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`,
3463
3465
  `Do NOT call create_goal or update_goal — just output the GOAL: line. If the user cancels or stops answering, still produce a sensible best-effort GOAL from what you already know.`,
@@ -4078,6 +4080,7 @@ export class DshClientSession {
4078
4080
  // 文件树跟随新项目(服务端原生 watcher 自动重挂)。
4079
4081
  void this.listFiles(undefined);
4080
4082
  this.pushTerminals();
4083
+ this.pushSettings();
4081
4084
  this.flushSnapshot(true);
4082
4085
  }
4083
4086
  catch (err) {
@@ -12,7 +12,11 @@
12
12
  * 在文件里找一段连续行,其核心序列与 oldText 完全一致(仅唯一匹配才写)。
13
13
  * - 命中后按「整行替换」写入 newText **原样**(AI 给的缩进就是最终缩进),
14
14
  * 只做必要的行尾换行平衡。
15
- * - 若不支持片段(oldText 不是完整行)会在严格匹配阶段返回错误提示。
15
+ * - 若不支持片段(oldText 不是完整行)会返回错误提示:
16
+ * • 多行 oldText 跨行但首/尾未对齐行边界 → 直接拒绝(否则会吃掉行首/行尾残留、
17
+ * 写出粘连内容);
18
+ * • 宽松阶段整块对不上、且首/尾行只是某行的一部分 → 报「片段」错而非笼统的「找不到」;
19
+ * • 单行片段(如 `b();`)不改变行结构,仍照旧支持。
16
20
  *
17
21
  * 开关:设置面板「编辑」页 `editSoftEnabled`(默认关)。关闭时该工具从活跃集移除。
18
22
  */
@@ -22,24 +26,26 @@ import { homedir } from "node:os";
22
26
  import { isAbsolute, join, resolve as nodeResolve } from "node:path";
23
27
  import { Type } from "typebox";
24
28
  import { defineTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, } from "@earendil-works/pi-coding-agent";
25
- import { bilingual, pick } from "./i18n.js";
29
+ import { pick } from "./i18n.js";
26
30
  import { EDIT_SOFT_TOOL_NAME } from "./tool-manager.js";
27
31
  /** 独立宽松编辑工具名(唯一登记见 tool-manager.ts;此处别名保兼容)。 */
28
32
  export const SOFT_EDIT_TOOL_NAME = EDIT_SOFT_TOOL_NAME;
29
33
  const replaceEditSchema = Type.Object({
30
34
  oldText: Type.String({
31
- description: bilingual("Text to replace. Loose matching: the content of each non-empty line (trimmed of leading/trailing whitespace) must match the corresponding file lines; leading-indentation (spaces/tabs) differences are ignored. Prefer whole lines/blocks.", "要替换的文本。宽松匹配:每个非空行的内容(去掉首尾空白)需与文件中对应行一致;行首缩进(空格/制表符)的差异会被忽略。建议按整行/整块提供。"),
35
+ description: "Text to replace. Loose matching: the content of each non-empty line (trimmed of leading/trailing whitespace) must match the corresponding file lines; " +
36
+ "leading-indentation (spaces/tabs) differences are ignored. Prefer whole lines/blocks.",
32
37
  }),
33
38
  newText: Type.String({
34
- description: bilingual("Replacement text (written verbatim; the indentation you provide is final).", "替换后的文本(原样写入,缩进即最终缩进)。"),
39
+ description: "Replacement text (written verbatim; the indentation you provide is final).",
35
40
  }),
36
41
  }, {});
37
42
  const editSoftSchema = Type.Object({
38
43
  path: Type.String({
39
- description: bilingual("Path of the file to edit (relative or absolute).", "要编辑的文件路径(相对或绝对)"),
44
+ description: "Path of the file to edit (relative or absolute).",
40
45
  }),
41
46
  edits: Type.Array(replaceEditSchema, {
42
- description: bilingual("One or more targeted replacements. Each edit matches against the original file (not incrementally); do not include overlapping/nested edits; merge edits touching the same block or nearby lines into one.", "一个或多个定向替换。每个 edit 相对原文件匹配(非增量);不要包含重叠/嵌套的 edit;同一块或相邻行请合并成一个 edit。"),
47
+ description: "One or more targeted replacements. Each edit matches against the original file (not incrementally); " +
48
+ "do not include overlapping/nested edits; merge edits touching the same block or nearby lines into one.",
43
49
  }),
44
50
  }, {});
45
51
  function isSingleEditInput(value) {
@@ -142,6 +148,50 @@ export function oldTextCores(oldTextLF) {
142
148
  parts.pop();
143
149
  return parts.map((p) => p.trim());
144
150
  }
151
+ /**
152
+ * 判断 oldText 的首行/末行是否只是「片段」(某文件行的一部分)。
153
+ * 宽松匹配要求每行都是完整行;当整块对不上时,若首/末行的核心确实出现在文件的
154
+ * **行中部**(既不在行首也不在行尾),则极可能是模型漏抄了行首/行尾。
155
+ *
156
+ * 仅在「整块核心序列一次都没命中」后才调用,因此只作诊断,不影响正常匹配。
157
+ */
158
+ export function fragmentLineEnds(cores, units) {
159
+ const isMidLineFragment = (core) => {
160
+ if (core === "")
161
+ return false;
162
+ // 先看有没有任意一行与它整行相同:有 → 不是片段(只是缩进/上下文不对)。
163
+ if (units.some((u) => u.core === core))
164
+ return false;
165
+ // 再看它是否出现在某行的中部(前后都还有内容)→ 是片段。
166
+ return units.some((u) => {
167
+ const idx = u.raw.indexOf(core);
168
+ return idx > 0 && idx + core.length < u.raw.length;
169
+ });
170
+ };
171
+ const ends = [];
172
+ if (cores.length > 0 && isMidLineFragment(cores[0]))
173
+ ends.push(0);
174
+ if (cores.length > 1 && isMidLineFragment(cores[cores.length - 1]))
175
+ ends.push(1);
176
+ return ends;
177
+ }
178
+ /**
179
+ * 判断一段**精确命中**是否为「跨行但未对齐整行」的非法片段。
180
+ *
181
+ * 只包含单行的 oldText(如 `const x = 1; `)是安全的:替换不会改动行结构。
182
+ * 但若 oldText 跨越了换行、且首/尾没落在行边界(既不在行首/行尾,也没包含整行),
183
+ * 直接子串替换会吃掉行首/行尾的残留,写出粘连内容——例如在
184
+ * `foo(a);\nfoo(b);` 上把 `a);\nfoo(` 换成 `z();` 会得到 `foo(z();b);`。
185
+ * 这几乎总是模型漏抄行首/行尾所致,因此报错而不静默写坏。
186
+ */
187
+ export function isMisalignedMultilineFragment(content, start, oldTextLF) {
188
+ if (!oldTextLF.includes("\n"))
189
+ return false; // 单行片段:不影响行结构,允许
190
+ const end = start + oldTextLF.length;
191
+ const startsAtLineStart = start === 0 || content[start - 1] === "\n";
192
+ const endsAtLineBoundary = end === content.length || content[end] === "\n" || content[end - 1] === "\n";
193
+ return !(startsAtLineStart && endsAtLineBoundary);
194
+ }
145
195
  /** NOT_FOUND / NOT_UNIQUE 用异常类型区分(与内置 edit 报错风格一致)。 */
146
196
  class SoftEditMatchError extends Error {
147
197
  constructor(message) {
@@ -158,7 +208,12 @@ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIn
158
208
  }
159
209
  // 1) 精确子串匹配(等价普通 edit,支持片段)
160
210
  const exactIdx = normalizedContent.indexOf(oldTextLF);
161
- if (exactIdx !== -1) {
211
+ // 非法片段防御:多行 oldText 若未对齐整行边界,精确子串替换会吃掉行首/行尾
212
+ // 残留、写出粘连内容(如 `foo(a);\nfoo(b);` 把 `a);\nfoo(` 换成 `z();` →
213
+ // `foo(z();b);`)。这种命中一律不走精确路径,改为交给下面的「整行宽松匹配」;
214
+ // 只有当整行也匹配不上时才报错拒绝(避免误伤「首行省略缩进」等合法情况)。
215
+ const exactMisaligned = exactIdx !== -1 && isMisalignedMultilineFragment(normalizedContent, exactIdx, oldTextLF);
216
+ if (exactIdx !== -1 && !exactMisaligned) {
162
217
  // 唯一性:精确匹配出现多次 → 报错(模型应提供更多上下文)
163
218
  const occurrences = normalizedContent.split(oldTextLF).length - 1;
164
219
  if (occurrences > 1) {
@@ -186,6 +241,14 @@ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIn
186
241
  starts.push(i);
187
242
  }
188
243
  if (starts.length === 0) {
244
+ // 非法片段诊断:宽松匹配要求 oldText 的每一行都是**完整行**。若首/尾行只是
245
+ // 某文件行的一部分(模型漏抄了行首/行尾,例如只给了 `a);` 而非 `foo(a);`),
246
+ // 逐行核心永远对不上;这里给出针对性提示,而不是笼统的「找不到」。
247
+ // exactMisaligned 说明精确子串能命中、但跨行且未对齐整行(会写出粘连内容),
248
+ // 而整行匹配又对不上 —— 这种命中绝对不能走精确路径。
249
+ if (exactMisaligned || fragmentLineEnds(cores, units).length > 0) {
250
+ throw new SoftEditMatchError(pick(lang, `在 ${path} 中找不到该文本:edits[${editIndex}].oldText 跨越多行但首/尾没有落在行边界上(首行或末行只是文件中某行的一部分),而本工具按**整行**匹配。请让 oldText 的每一行都是完整行——行首缩进可以省略,但行内容必须完整。`, `Could not find the text in ${path}: edits[${editIndex}].oldText spans multiple lines but its start/end do not fall on line boundaries (its first or last line is only part of a file line), and this tool matches whole lines. Make every line of oldText a complete line — leading indentation may be omitted, but the line content must be complete.`, "editsoft.fragment.not.supported", { path, editIndex }));
251
+ }
189
252
  throw new SoftEditMatchError(pick(lang, `在 ${path} 中找不到该文本(精确或忽略缩进均未命中)。oldText 必须与文件的行内容一致;行首空白差异会被忽略,但实际内容必须完全相同。`, `Could not find the text (exact or indentation-insensitive) in ${path}. The oldText must match the file's line content; leading-whitespace differences are ignored, but the actual content must be identical.`, "editsoft.match.not.found", { path }));
190
253
  }
191
254
  if (starts.length > 1) {
@@ -217,10 +280,14 @@ export function applySoftEdits(normalizedContent, edits, path, lang = "en") {
217
280
  throw new SoftEditMatchError(pick(lang, `在 ${path} 中的 edits 重叠。请合并为一个 edit 或定位到不相交的区域。`, `edits overlap in ${path}. Merge them into one edit or target disjoint regions.`, "editsoft.edits.overlap", { path }));
218
281
  }
219
282
  }
220
- // 逆序应用,保持左侧偏移稳定
283
+ // 逆序应用,保持左侧偏移稳定。
284
+ // 关键:必须按**位置升序**(前面的 `sorted`)再逆序应用,不能按 edits 的传入
285
+ // 顺序逆序——模型若把靠后的 edit 写在前面,未排序的逆序应用会让后面的替换先
286
+ // 改变长度,前面的偏移随即串位,写出错乱内容(历史 bug:protocol.ts /
287
+ // use-chat.ts / ChatInput.tsx 被写坏)。内置 edit 同样先按 matchIndex 排序,此为对齐语义。
221
288
  let result = normalizedContent;
222
- for (let i = replacements.length - 1; i >= 0; i--) {
223
- const r = replacements[i];
289
+ for (let i = sorted.length - 1; i >= 0; i--) {
290
+ const r = sorted[i];
224
291
  result = result.slice(0, r.start) + r.insertText + result.slice(r.end);
225
292
  }
226
293
  if (result === normalizedContent) {
@@ -237,13 +304,14 @@ export function makeEditSoftTool(fallbackCwd, getLang) {
237
304
  return defineTool({
238
305
  name: SOFT_EDIT_TOOL_NAME,
239
306
  label: "Edit (indentation-insensitive)",
240
- description: bilingual("Edit a single file using text replacement that is tolerant of indentation. Every edits[].oldText is matched to the file by line content: leading whitespace (spaces/tabs) differences between your oldText and the file are ignored, so an edit does not fail just because the indentation differs. Use this when the built-in edit tool rejects your oldText due to a whitespace mismatch (common in JS/JSON/etc.). Prefer whole-line/whole-block oldText. newText is written exactly as provided.", "用对缩进不敏感的文本替换编辑单个文件。每个 edits[].oldText 按行内容与文件匹配:oldText 与文件之间的行首空白(空格/制表符)差异会被忽略,因此不会仅因缩进不同而失败。当内置 edit 工具因空白不匹配拒绝你的 oldText 时使用本工具(常见于 JS/JSON 等)。oldText 尽量按整行/整块提供。newText 按原样写入。"),
241
- promptSnippet: bilingual("edit a file tolerating indentation differences (whitespace-insensitive match)", "编辑文件,容忍缩进差异(空白不敏感匹配)"),
307
+ description: "Edit a single file with text replacement tolerant of indentation: oldText is matched by line content, ignoring leading-whitespace differences, so edits don't fail on indentation mismatch. " +
308
+ "Use when the built-in edit rejects oldText due to whitespace (common in JS/JSON). Prefer whole-line/whole-block oldText; newText is written exactly as provided.",
309
+ promptSnippet: "edit a file tolerating indentation differences (whitespace-insensitive match)",
242
310
  promptGuidelines: [
243
- bilingual("Use edit_soft when edit fails because the oldText indentation/spacing differs from the file", "当 edit 因 oldText 缩进/空白与文件不一致而失败时,使用 edit_soft"),
244
- bilingual("Each edits[].oldText is matched to whole lines by trimmed content; leading whitespace is ignored", "每个 edits[].oldText 按去首尾空白后的内容匹配到整行;行首空白会被忽略"),
245
- bilingual("newText is written verbatim — the indentation you provide is the final indentation in the file", "newText 按原样写入——你提供的缩进就是文件中的最终缩进"),
246
- bilingual("Keep edits[].oldText as small as possible while still being unique; merge nearby changes into one edit", "edits[].oldText 在保持唯一的前提下尽量小;相邻改动合并为一个 edit"),
311
+ "Use edit_soft when edit fails because the oldText indentation/spacing differs from the file",
312
+ "Each edits[].oldText is matched to whole lines by trimmed content; leading whitespace is ignored",
313
+ "newText is written verbatim — the indentation you provide is the final indentation in the file",
314
+ "Keep edits[].oldText as small as possible while still being unique; " + "merge nearby changes into one edit",
247
315
  ],
248
316
  parameters: editSoftSchema,
249
317
  prepareArguments: prepareSoftEditArguments,