pi-web-ui 0.97.0 → 0.99.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 (80) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/bin/pi-web-ui.mjs +64 -23
  5. package/dist/server/agent-service.js +645 -147
  6. package/dist/server/approval-rules.js +5 -10
  7. package/dist/server/atomic-file.js +40 -0
  8. package/dist/server/attachments.js +22 -0
  9. package/dist/server/claim-store.js +5 -11
  10. package/dist/server/client-state.js +220 -23
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +7 -5
  13. package/dist/server/composer-drafts.js +4 -6
  14. package/dist/server/control-socket.js +0 -40
  15. package/dist/server/delegate-mode.js +6 -12
  16. package/dist/server/dsh/dsh-agent-service.js +32 -30
  17. package/dist/server/dsh/dsh-client.js +45 -9
  18. package/dist/server/dsh/dsh-serialize.js +12 -10
  19. package/dist/server/dsh/dsh-sessions.js +20 -6
  20. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  21. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  22. package/dist/server/ensure-bash.js +3 -5
  23. package/dist/server/eval-tool.js +30 -5
  24. package/dist/server/goal-review-gate.js +7 -20
  25. package/dist/server/goal-service.js +77 -10
  26. package/dist/server/index.js +83 -15
  27. package/dist/server/launch-origin.js +0 -4
  28. package/dist/server/locales.js +3 -5
  29. package/dist/server/lsp-tool.js +110 -14
  30. package/dist/server/markers/builtins/notify.js +4 -2
  31. package/dist/server/markers/builtins/rename.js +0 -1
  32. package/dist/server/markers/builtins/todo.js +6 -26
  33. package/dist/server/markers/marker.js +0 -15
  34. package/dist/server/markers/registry.js +0 -3
  35. package/dist/server/markers/store.js +0 -3
  36. package/dist/server/mcp-hot-reload.js +12 -5
  37. package/dist/server/model-admin.js +19 -18
  38. package/dist/server/model-enrich.js +2 -3
  39. package/dist/server/patch-tool.js +78 -28
  40. package/dist/server/permission-preset.js +25 -0
  41. package/dist/server/plan-manager.js +196 -10
  42. package/dist/server/plan-mode.js +58 -20
  43. package/dist/server/plugin-api-catalog.js +2 -0
  44. package/dist/server/plugin-catalog.js +6 -6
  45. package/dist/server/plugin-dom.js +3 -4
  46. package/dist/server/plugin-facilities.js +3 -5
  47. package/dist/server/plugin-grants.js +5 -8
  48. package/dist/server/plugin-permissions.js +4 -6
  49. package/dist/server/plugin-schedule.js +4 -7
  50. package/dist/server/plugin-updater.js +151 -38
  51. package/dist/server/plugins.js +16 -2
  52. package/dist/server/prompt-composer.js +12 -3
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/resolve-global-sdk.js +8 -0
  55. package/dist/server/schedule-agent-tool.js +5 -1
  56. package/dist/server/scheduler-tasks.js +46 -27
  57. package/dist/server/sdk-origin.js +27 -0
  58. package/dist/server/serialize.js +22 -9
  59. package/dist/server/slash-commands.js +16 -0
  60. package/dist/server/subagent-templates.js +4 -10
  61. package/dist/server/subagents.js +22 -11
  62. package/dist/server/terminals.js +24 -12
  63. package/dist/server/text-quote.js +59 -0
  64. package/dist/server/tool-manager.js +12 -3
  65. package/dist/server/tool-overrides.js +24 -0
  66. package/dist/server/update-check.js +69 -40
  67. package/extensions/webui.ts +15 -1
  68. package/package.json +31 -19
  69. package/plugin-sdk/client-utils.mjs +92 -0
  70. package/plugin-sdk/index.d.ts +21 -0
  71. package/plugin-sdk/index.mjs +2 -0
  72. package/plugins/catalog.json +7 -0
  73. package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-NerrJlTS.js} +1 -1
  74. package/web/dist/assets/index-BdMaGCcs.css +1 -0
  75. package/web/dist/assets/index-ByCBN4Kt.js +368 -0
  76. package/web/dist/index.html +2 -2
  77. package/web/dist/sw.js +2 -1
  78. package/web/public/sw.js +2 -1
  79. package/web/dist/assets/index-DwToli0J.css +0 -1
  80. package/web/dist/assets/index-oI7UKOqJ.js +0 -368
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * 判定全部是纯函数(无 IO),单测见 tests/unit/delegate-mode.test.ts。
17
17
  */
18
- import { bashCommandIsReadOnly } from "./plan-mode.js";
18
+ import { bashCommandIsReadOnly, SESSION_DISPATCH_TOOLS } from "./plan-mode.js";
19
19
  /** 审查者模式下确定禁止的工具:与计划模式同名单(写 / 改 / 增删 / git 写 / 形态变换)。 */
20
20
  const BLOCKED_TOOLS = new Set([
21
21
  "write",
@@ -47,6 +47,8 @@ const BLOCKED_TOOLS = new Set([
47
47
  "truncate",
48
48
  "rename",
49
49
  "git",
50
+ // 代码求值(eval 沙箱可写真实文件系统 —— 主对话只审阅不施工,#436)
51
+ "eval",
50
52
  "format",
51
53
  "prettier",
52
54
  "lint_fix",
@@ -57,18 +59,10 @@ const BLOCKED_TOOLS = new Set([
57
59
  * 模型再自己 spawn 一个只会绕开「一个常驻执行对话」这条语义线(多份上下文、
58
60
  * 多份配额、左栏一堆对话),而且落盘对话占项目会话名额。
59
61
  * 与计划模式禁它的理由不同:那边是「别绕开只读约束」,这边是「别绕开统一派活」。
62
+ * 名单 = 三闸门共用的真实注册派发工具(SESSION_DISPATCH_TOOLS,#436 前是一堆
63
+ * 幽灵名)+ subagent 整工具(这里不分 action,审查者对话一概不派不问)。
60
64
  */
61
- const DISPATCH_TOOLS = new Set([
62
- "spawn",
63
- "subagent_spawn",
64
- "subagent",
65
- "delegate_task",
66
- "spawn_subagent",
67
- "schedule",
68
- "set_goal",
69
- "start_goal_wizard",
70
- "set_plan_mode",
71
- ]);
65
+ const DISPATCH_TOOLS = new Set([...SESSION_DISPATCH_TOOLS, "subagent"]);
72
66
  /** bash 家族(要逐条判命令是否只读)。 */
73
67
  const BASH_TOOLS = new Set(["bash", "shell", "sh", "zsh", "terminal", "terminal_bash", "run_command", "exec"]);
74
68
  /** 从工具参数里取命令文本(各工具字段名不同)。 */
@@ -24,8 +24,9 @@
24
24
  * 引擎无关模块复用:FilesService(文件树/预览)、scm.ts(git 查询)、
25
25
  * BgServerTracker(后台任务)、TerminalManager(PTY)、uploads.ts。
26
26
  */
27
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
27
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
28
28
  import { basename, dirname, join, resolve, sep } from "node:path";
29
+ import { writeJsonAtomicSync } from "../atomic-file.js";
29
30
  import { randomUUID } from "node:crypto";
30
31
  import { homedir } from "node:os";
31
32
  import { BgServerTracker } from "../bg-servers.js";
@@ -45,6 +46,7 @@ import { previewKind } from "../text-sniff.js";
45
46
  import { removeQueuedByIndexOrText } from "../queue-utils.js";
46
47
  import { launchOrigin, toServiceInfo } from "../launch-origin.js";
47
48
  import { DshRuntime, loadDeepSeekKey } from "./dsh-client.js";
49
+ import { formatTextQuote, readTextQuote, messageTextWithQuotes } from "../text-quote.js";
48
50
  import { DshStreamAccumulator, assistantMessageEventToUiMessage, toolResultEventToUiMessage, userMessageEventToUiMessage, } from "./dsh-serialize.js";
49
51
  import { firstUserText, findSessionFilesForCwd, readSessionLog, replayEventsToMessages, sessionLogPermission, sessionLogPreset, } from "./dsh-sessions.js";
50
52
  import { generatePresetClones, PRESET_DEFAULT_ID } from "./preset-clones.js";
@@ -902,9 +904,8 @@ export class DshClientSession {
902
904
  break;
903
905
  const msg = userMessageEventToUiMessage(ev.data);
904
906
  // 重复文本(DSH 有时重放同一用户消息)→ 去重。
905
- const text = msg.content.map((c) => ("text" in c ? c.text : "")).join("");
906
- if (text &&
907
- conv.messages.some((m) => m.role === "user" && m.content.map((c) => ("text" in c ? c.text : "")).join("") === text)) {
907
+ const text = messageTextWithQuotes(msg);
908
+ if (text && conv.messages.some((m) => m.role === "user" && messageTextWithQuotes(m) === text)) {
908
909
  break;
909
910
  }
910
911
  this.appendMessage(conv, msg);
@@ -1579,7 +1580,7 @@ export class DshClientSession {
1579
1580
  // 磁盘回放会话(switch_session)没有 live runtime session —— DSH 的
1580
1581
  // JSON-RPC 面不支持恢复(id collision),自动 fork 新会话继续:把历史
1581
1582
  // 作为上下文注入首条 prompt,前端提示。
1582
- if (conv.fromDisk && text.trim()) {
1583
+ if (conv.fromDisk && (text.trim() || attachments?.some((a) => a.mode === "quote" && readTextQuote(a.quote)))) {
1583
1584
  const histText = this.histToContext(conv);
1584
1585
  conv = this.forkConversation(conv);
1585
1586
  if (histText.trim()) {
@@ -1688,6 +1689,12 @@ export class DshClientSession {
1688
1689
  : []),
1689
1690
  ],
1690
1691
  timestamp: Date.now(),
1692
+ details: {
1693
+ quotes: (attachments ?? [])
1694
+ .filter((a) => a.mode === "quote")
1695
+ .map((a) => readTextQuote(a.quote))
1696
+ .filter((q) => q !== null),
1697
+ },
1691
1698
  };
1692
1699
  this.appendMessage(conv, optimistic);
1693
1700
  this.flushSnapshot();
@@ -1721,7 +1728,7 @@ export class DshClientSession {
1721
1728
  histToContext(conv) {
1722
1729
  return conv.messages
1723
1730
  .map((m) => {
1724
- const blocks = m.content.map((c) => ("text" in c ? c.text : "")).join("\n");
1731
+ const blocks = messageTextWithQuotes(m);
1725
1732
  return blocks ? `[${m.role === "assistant" ? "AI" : m.role}] ${blocks}` : "";
1726
1733
  })
1727
1734
  .filter(Boolean)
@@ -1756,10 +1763,7 @@ export class DshClientSession {
1756
1763
  for (let i = conv.messages.length - 1; i >= 0; i--) {
1757
1764
  const m = conv.messages[i];
1758
1765
  if (m.role === "user") {
1759
- lastUser = m.content
1760
- .map((c) => ("text" in c ? c.text : ""))
1761
- .join("")
1762
- .trim();
1766
+ lastUser = messageTextWithQuotes(m).trim();
1763
1767
  if (lastUser)
1764
1768
  break;
1765
1769
  }
@@ -1794,6 +1798,19 @@ export class DshClientSession {
1794
1798
  if (!Array.isArray(attachments))
1795
1799
  return blocks;
1796
1800
  for (const a of attachments) {
1801
+ if (a.mode === "quote") {
1802
+ const quote = readTextQuote(a.quote);
1803
+ if (quote)
1804
+ blocks.push({ type: "text", text: formatTextQuote(quote) });
1805
+ else
1806
+ this.emit({
1807
+ type: "notice",
1808
+ level: "warning",
1809
+ text: "引用内容无效,请重新选择文字",
1810
+ textEn: "Select the text again to add a quote.",
1811
+ });
1812
+ continue;
1813
+ }
1797
1814
  const resolved = a.path ? workspacePath(this.cwd, a.path) : null;
1798
1815
  if (a.imageData) {
1799
1816
  // 视觉桥:base64 图片 → attachment store → 真 image 块(模型可看图)。
@@ -2306,7 +2323,7 @@ export class DshClientSession {
2306
2323
  // 项目 / 文件 / SCM / 搜索
2307
2324
  // -----------------------------------------------------------------------
2308
2325
  async pushProjects() {
2309
- const recent = this.stateStore.getRecentProjects(this.clientId);
2326
+ const recent = await this.stateStore.getRecentProjects(this.clientId);
2310
2327
  const removedKeys = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
2311
2328
  const projects = new Map();
2312
2329
  for (const p of recent) {
@@ -2580,11 +2597,6 @@ export class DshClientSession {
2580
2597
  }
2581
2598
  this.flushSnapshot(true);
2582
2599
  }
2583
- async cycleModel() {
2584
- const idx = DSH_MODELS.findIndex((m) => m.id === this.model);
2585
- const next = DSH_MODELS[(idx + 1) % DSH_MODELS.length];
2586
- await this.setModel(next.id);
2587
- }
2588
2600
  setThinking(level) {
2589
2601
  if (level !== "high") {
2590
2602
  this.emit({
@@ -2598,15 +2610,6 @@ export class DshClientSession {
2598
2610
  this.thinkingLevel = level;
2599
2611
  this.flushSnapshot();
2600
2612
  }
2601
- cycleThinking() {
2602
- // DSH 固定 high。
2603
- this.emit({
2604
- type: "notice",
2605
- level: "info",
2606
- text: "DeepSeek V4 仅支持高思考强度",
2607
- textEn: "DeepSeek V4 only supports high thinking intensity",
2608
- });
2609
- }
2610
2613
  // -----------------------------------------------------------------------
2611
2614
  // 设置 / 系统提示词
2612
2615
  // -----------------------------------------------------------------------
@@ -3779,8 +3782,7 @@ export class DshClientSession {
3779
3782
  /* new file */
3780
3783
  }
3781
3784
  auth[this.normalizeDshProvider(provider)] = { type: "api_key", key };
3782
- mkdirSync(dirname(authPath), { recursive: true });
3783
- writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n");
3785
+ writeJsonAtomicSync(authPath, auth);
3784
3786
  this.emit({
3785
3787
  type: "notice",
3786
3788
  level: "info",
@@ -3810,7 +3812,7 @@ export class DshClientSession {
3810
3812
  return;
3811
3813
  }
3812
3814
  delete auth[pid];
3813
- writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n");
3815
+ writeJsonAtomicSync(authPath, auth);
3814
3816
  this.emit({
3815
3817
  type: "notice",
3816
3818
  level: "info",
@@ -3989,11 +3991,11 @@ export class DshClientSession {
3989
3991
  const fresh = this.addConversation(newSessionId, this.cwd, false);
3990
3992
  // 回放编辑点之前的消息(作为会话初始上下文:DSH 无 seed 机制,v1 用
3991
3993
  // 简化——直接把历史作为一条提示词说明附上)。
3992
- const head = conv.messages.slice(0, idx + 1);
3994
+ const head = conv.messages.slice(0, idx);
3993
3995
  // 把编辑前的对话内容写进新会话的 prompt(尽力保留上下文)。
3994
3996
  const contextNote = head
3995
3997
  .map((m) => {
3996
- const blocks = m.content.map((c) => ("text" in c ? c.text : "")).join("\n");
3998
+ const blocks = messageTextWithQuotes(m);
3997
3999
  return `[${m.role}] ${blocks}`;
3998
4000
  })
3999
4001
  .join("\n");
@@ -78,6 +78,7 @@ export class DshRuntime {
78
78
  notificationHandler = null;
79
79
  stderrTail = "";
80
80
  closed = false;
81
+ disposed = false;
81
82
  initialized = false;
82
83
  /** PI_WEB_DSH_DEBUG=1 时把 RPC 帧/生命周期事件打到 stderr(诊断用,默认关)。 */
83
84
  debugEnabled = process.env.PI_WEB_DSH_DEBUG === "1";
@@ -122,6 +123,9 @@ export class DshRuntime {
122
123
  }
123
124
  /** 启动子进程 + initialize 握手(幂等;并发调用共享同一个启动任务)。 */
124
125
  start() {
126
+ if (this.disposed) {
127
+ return Promise.reject(new DshTransportError(bilingual("client has been disposed", "客户端已销毁")));
128
+ }
125
129
  if (this.alive && this.initialized)
126
130
  return Promise.resolve();
127
131
  if (!this.startPromise) {
@@ -270,7 +274,10 @@ export class DshRuntime {
270
274
  if (!proc || !proc.stdin || proc.stdin.destroyed) {
271
275
  throw new DshTransportError(bilingual("runtime not started", "runtime 未启动"));
272
276
  }
273
- proc.stdin.write(JSON.stringify(msg) + "\n");
277
+ const ok = proc.stdin.write(JSON.stringify(msg) + "\n");
278
+ if (!ok) {
279
+ this.debug("stdin buffer full, backpressure triggered");
280
+ }
274
281
  }
275
282
  /**
276
283
  * 向一个会话排队 prompt。返回持久化收据 messageId(运行时接受即 resolve)。
@@ -411,8 +418,9 @@ export class DshRuntime {
411
418
  }
412
419
  /** 优雅关闭:shutdown 握手 → stdin EOF → SIGTERM → SIGKILL 阶梯。 */
413
420
  async close() {
414
- if (this.closed)
421
+ if (this.disposed)
415
422
  return;
423
+ this.disposed = true;
416
424
  this.closed = true;
417
425
  const proc = this.proc;
418
426
  if (!proc || proc.exitCode !== null) {
@@ -487,16 +495,44 @@ export class DshRuntime {
487
495
  this.failPending(new DshTransportError(bilingual("runtime killed (interrupt)", "运行时已被终止(中断)")));
488
496
  try {
489
497
  if (process.platform === "win32") {
490
- const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
491
- stdio: "ignore",
492
- windowsHide: true,
493
- });
494
- killer.on("error", () => {
498
+ await new Promise((resolve) => {
499
+ let settled = false;
500
+ const done = () => {
501
+ if (!settled) {
502
+ settled = true;
503
+ clearTimeout(timer);
504
+ resolve();
505
+ }
506
+ };
507
+ const timer = setTimeout(done, 1500);
495
508
  try {
496
- proc.kill("SIGKILL");
509
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
510
+ stdio: "ignore",
511
+ windowsHide: true,
512
+ });
513
+ killer.on("close", (code) => {
514
+ if (code !== 0) {
515
+ try {
516
+ proc.kill("SIGKILL");
517
+ }
518
+ catch { }
519
+ }
520
+ done();
521
+ });
522
+ killer.on("error", () => {
523
+ try {
524
+ proc.kill("SIGKILL");
525
+ }
526
+ catch { }
527
+ done();
528
+ });
497
529
  }
498
530
  catch {
499
- /* already dead */
531
+ try {
532
+ proc.kill("SIGKILL");
533
+ }
534
+ catch { }
535
+ done();
500
536
  }
501
537
  });
502
538
  }
@@ -23,15 +23,8 @@
23
23
  * - streamingMessage:由 assistant/chunk 增量累积(reasoning→thinking、
24
24
  * text→text、tool-call→toolCall),id 用 stream-<会话seq> 稳定跨快照。
25
25
  */
26
- const TEXT_CAP = 200_000;
27
- const TOOL_OUTPUT_CAP = 100_000;
28
- const ARGS_CAP = 20_000;
29
- function truncate(s, cap) {
30
- const str = typeof s === "string" ? s : String(s ?? "");
31
- if (str.length <= cap)
32
- return { text: str, truncated: false };
33
- return { text: `${str.slice(0, cap)}\n\n… [truncated]`, truncated: true };
34
- }
26
+ import { parseTextQuote } from "../text-quote.js";
27
+ import { ARGS_CAP, TEXT_CAP, TOOL_OUTPUT_CAP, truncate } from "../serialize.js";
35
28
  /** DSH user-role content blocks → UiContentBlock[]。 */
36
29
  export function serializeUserBlocks(blocks) {
37
30
  if (!Array.isArray(blocks) || blocks.length === 0) {
@@ -102,10 +95,19 @@ export function toolResultEventToUiMessage(data) {
102
95
  }
103
96
  /** user/message 事件 data → UiMessage。 */
104
97
  export function userMessageEventToUiMessage(data) {
98
+ const quotes = [];
99
+ const content = (data.content ?? []).filter((block) => {
100
+ const quote = block.type === "text" ? parseTextQuote(block.text ?? "") : null;
101
+ if (!quote)
102
+ return true;
103
+ quotes.push(quote);
104
+ return false;
105
+ });
105
106
  return {
106
107
  id: `u-${data.id ?? "?"}`,
107
108
  role: "user",
108
- content: serializeUserBlocks(data.content ?? []),
109
+ content: serializeUserBlocks(content),
110
+ ...(quotes.length ? { details: { quotes } } : {}),
109
111
  timestamp: data.time,
110
112
  };
111
113
  }
@@ -84,14 +84,28 @@ function zstdDecompressAll(buf) {
84
84
  }
85
85
  }
86
86
  let out = "";
87
- for (let i = 0; i < starts.length; i++) {
87
+ let i = 0;
88
+ while (i < starts.length) {
88
89
  const from = starts[i];
89
- const to = i + 1 < starts.length ? starts[i + 1] : buf.length;
90
- try {
91
- out += zstdDecompressSync(buf.subarray(from, to)).toString("utf8");
90
+ let nextIdx = i + 1;
91
+ let decompressed = null;
92
+ // 贪心尝试:若数据体内部误命中魔数导致切帧错误,向后试探合并到下一个魔数直至解压成功(issue #467)
93
+ while (nextIdx <= starts.length) {
94
+ const to = nextIdx < starts.length ? starts[nextIdx] : buf.length;
95
+ try {
96
+ decompressed = zstdDecompressSync(buf.subarray(from, to));
97
+ break;
98
+ }
99
+ catch {
100
+ nextIdx++;
101
+ }
92
102
  }
93
- catch {
94
- /* skip unreadable frame */
103
+ if (decompressed) {
104
+ out += decompressed.toString("utf8");
105
+ i = nextIdx;
106
+ }
107
+ else {
108
+ i++;
95
109
  }
96
110
  }
97
111
  return out || buf.toString("utf8");
@@ -870,10 +870,19 @@ class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
870
870
  return new Promise((resolve, reject) => {
871
871
  const timer = setTimeout(() => {
872
872
  const queued = this.questionBridge.queue.findIndex((q) => q.qid === qid);
873
- if (queued >= 0) this.questionBridge.queue.splice(queued, 1);
873
+ if (queued >= 0) {
874
+ this.questionBridge.queue.splice(queued, 1);
875
+ // #459:排队条目超时也必须 settle——此前只 splice 不 reject,
876
+ // 该 ask 的 Promise 永不落定,agent-loop 卡死在工具调用上。
877
+ reject(new Error("提问超时(等待回答过久)"));
878
+ return;
879
+ }
874
880
  if (this.questionBridge.pending?.qid === qid) {
875
881
  this.questionBridge.pending = null;
876
882
  reject(new Error("提问超时(等待回答过久)"));
883
+ // #459:pending 腾出后必须派发下一个排队提问——否则队列无人派发,
884
+ // 后续条目只能等各自超时且永不 settle,整条提问桥永久停摆。
885
+ this.dispatchNextQuestion();
877
886
  }
878
887
  }, QUESTION_TIMEOUT_MS);
879
888
  timer.unref?.();
@@ -939,6 +948,10 @@ class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
939
948
  }
940
949
  const bytes = new Uint8Array(Buffer.from(params.data, "base64"));
941
950
  if (bytes.length === 0) throw new TypeError("attachment/save: empty image data");
951
+ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20MB 上限
952
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
953
+ throw new RangeError(`attachment/save: image data exceeds 20MB limit (${bytes.length} bytes)`);
954
+ }
942
955
  const ref = await this.ctx.attachments.saveImage({
943
956
  data: bytes,
944
957
  mediaType,
@@ -1,5 +1,5 @@
1
1
  // Shared runtime-tree resolution for the pi-web-ui DSH runtime.
2
- // Used by launcher.mjs (boot-time) and probe-mixed.mjs (self-check).
2
+ // Used by launcher.mjs (boot-time) and dev/dsh-probes/probe-mixed.mjs (self-check).
3
3
  //
4
4
  // A "runtime tree" is a node_modules root that directly contains the dsh
5
5
  // runtime packages: `@deepseek-ai/dsh-base` (with its cordis.patch.yml
@@ -11,9 +11,10 @@
11
11
  * Download is fire-and-forget at server start and never throws: on failure
12
12
  * the terminal simply falls back to $COMSPEC (cmd.exe) as before.
13
13
  */
14
- import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
14
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { join } from "node:path";
17
+ import { writeAtomicSync } from "./atomic-file.js";
17
18
  /** Official busybox-w32 64-bit Unicode build (Win10 1903+ / Win11). */
18
19
  const BUSYBOX_URL = "https://frippery.org/files/busybox/busybox64u.exe";
19
20
  /** busybox.exe is ~660KB; anything far smaller is an error page, not a binary. */
@@ -55,7 +56,6 @@ export async function ensureWindowsBash() {
55
56
  return null; // Git Bash preferred — nothing to install.
56
57
  const dir = windowsBashDir();
57
58
  mkdirSync(dir, { recursive: true });
58
- const tmp = join(dir, `busybox-${process.pid}.tmp`);
59
59
  try {
60
60
  const res = await fetch(BUSYBOX_URL, {
61
61
  signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
@@ -65,13 +65,11 @@ export async function ensureWindowsBash() {
65
65
  const buf = Buffer.from(await res.arrayBuffer());
66
66
  if (buf.length < MIN_SIZE)
67
67
  return null; // error/HTML page, not the exe.
68
- writeFileSync(tmp, buf);
69
- renameSync(tmp, join(dir, "busybox.exe"));
68
+ writeAtomicSync(join(dir, "busybox.exe"), buf);
70
69
  copyFileSync(join(dir, "busybox.exe"), target);
71
70
  return target;
72
71
  }
73
72
  catch {
74
- rmSync(tmp, { force: true });
75
73
  return null;
76
74
  }
77
75
  }
@@ -287,8 +287,9 @@ class EvalKernel {
287
287
  this.proc = spawn(pyCmd, ["-u", "-c", PYTHON_DRIVER_SCRIPT], spawnOpts);
288
288
  }
289
289
  else {
290
- // js / ts:独立 node -e 驱动,避免 eval 里的死循环卡住服务端事件循环。
291
- this.proc = spawn(process.execPath, ["-e", NODE_DRIVER_SCRIPT], spawnOpts);
290
+ // js / ts:独立 node -e 驱动,ts 时带 --experimental-strip-types 支持 TypeScript 原生剥离执行。
291
+ const nodeArgs = this.language === "ts" ? ["--experimental-strip-types", "-e", NODE_DRIVER_SCRIPT] : ["-e", NODE_DRIVER_SCRIPT];
292
+ this.proc = spawn(process.execPath, nodeArgs, spawnOpts);
292
293
  }
293
294
  // stderr 必须持续排空:驱动只把用户的 print/console 劫进 StringIO,但
294
295
  // os.write(2, …) / 原生扩展 / eval 里再 spawn 的子进程仍写真实 fd 2。
@@ -522,7 +523,28 @@ export function makeEvalTool(opts) {
522
523
  }),
523
524
  execute: async (_id, params) => {
524
525
  const lang = getLang();
525
- const language = params.language ?? "py";
526
+ let language = params.language ?? "py";
527
+ if (language === "python")
528
+ language = "py"; // 常见笔误别名(issue #462)
529
+ if (language !== "py" && language !== "js" && language !== "ts") {
530
+ // schema 已声明 enum(["py","js","ts"])(#417),但那只影响提示不拦运行时;
531
+ // getKernel 的 normLang 只映射 ts,其余任意值会原样建出名为它的 Node 内核,
532
+ // Python 代码被 node 执行报 JS 语法错且模型无从知道是键写错。
533
+ const text = pick(lang, `错误:language 仅支持 py / js / ts,收到「${language}」。`, `Error: language must be one of py / js / ts; got "${language}".`);
534
+ return {
535
+ content: [{ type: "text", text }],
536
+ details: {
537
+ language,
538
+ durationMs: 0,
539
+ ok: false,
540
+ stdout: "",
541
+ stderr: "",
542
+ result: null,
543
+ error: text,
544
+ reset: params.reset ?? false,
545
+ },
546
+ };
547
+ }
526
548
  const timeoutSec = Math.min(Math.max(1, params.timeout ?? DEFAULT_TIMEOUT_SECONDS), MAX_TIMEOUT_SECONDS);
527
549
  const timeoutMs = timeoutSec * 1000;
528
550
  const startTime = Date.now();
@@ -569,8 +591,11 @@ export function makeEvalTool(opts) {
569
591
  }
570
592
  catch (err) {
571
593
  const durationMs = Date.now() - startTime;
572
- const errMsg = err.message;
573
- const text = `[eval:${language} error (${durationMs}ms)]\n${errMsg}`;
594
+ let errMsg = err.message;
595
+ if (errMsg.includes("Python executable not found in PATH")) {
596
+ errMsg = pick(lang, "未在系统 PATH 中找到 Python 可执行程序,请安装 Python 或使用 language: 'js'。", "Python executable not found in PATH. Please install Python or use language: 'js'.");
597
+ }
598
+ const text = `${pick(lang, `[eval:${language} 出错 (${durationMs}ms)]`, `[eval:${language} error (${durationMs}ms)]`)}\n${errMsg}`;
574
599
  return {
575
600
  content: [{ type: "text", text }],
576
601
  details: {
@@ -16,7 +16,7 @@
16
16
  * 同样被包裹;但之后经 refreshPluginTools 动态补入已有会话的不在覆盖面。
17
17
  * 自研插件若绕过 wrapper 直接写文件同样拦不住 —— 只读是硬闸门,不是安全边界。
18
18
  */
19
- import { bashCommandIsReadOnly } from "./plan-mode.js";
19
+ import { bashCommandIsReadOnly, SESSION_DISPATCH_TOOLS } from "./plan-mode.js";
20
20
  /** 审查回合禁写的工具:与计划/审查者模式同名单(写 / 改 / 增删 / git 写 / 形态变换)。 */
21
21
  const WRITE_TOOLS = new Set([
22
22
  // 文件写入与编辑(第一方 + SDK 内置 + 编辑器类扩展的常见名)
@@ -51,6 +51,8 @@ const WRITE_TOOLS = new Set([
51
51
  "rename",
52
52
  // 版本控制写(git add/commit/checkout/reset…;只读子命令走 bash 白名单)
53
53
  "git",
54
+ // 代码求值(eval 沙箱可写真实文件系统 —— 审查回合只读核实,#436)
55
+ "eval",
54
56
  // 形态变换(会重写整个文件树)
55
57
  "format",
56
58
  "prettier",
@@ -60,27 +62,12 @@ const WRITE_TOOLS = new Set([
60
62
  /**
61
63
  * 审查回合禁掉的「派发类」工具:轮次由服务端控制(D6)—— 派生 / 等待 / 指挥
62
64
  * 别的对话、排程、改模式 / 开新目标都会绕开「一轮 = 派活 + 判定」的口径。
63
- * 只读的 `conversation_read` / `subagent_get_result` / `subagent_list` 不在名单里:
65
+ * 名单 = 三闸门共用的真实注册派发工具(SESSION_DISPATCH_TOOLS,#436 前是一堆
66
+ * 幽灵名)。subagent 不在此列:上面的按 action 判定已拦掉全部写向 action,
67
+ * 只读的 `conversation_read` / subagent get_result/list/templates 放行 ——
64
68
  * 审查者可以按需看执行者的转录(契约要求看实际状态,而不是信描述)。
65
69
  */
66
- const DISPATCH_TOOLS = new Set([
67
- "spawn",
68
- "spawn_agent",
69
- "subagent_spawn",
70
- "spawn_subagent",
71
- "subagent_steer",
72
- "subagent_stop",
73
- "subagent_wait_all",
74
- "subagent_handoff",
75
- "delegate_task",
76
- "schedule",
77
- "schedule_agent",
78
- "host_schedule",
79
- "create_goal",
80
- "set_goal",
81
- "start_goal_wizard",
82
- "set_plan_mode",
83
- ]);
70
+ const DISPATCH_TOOLS = SESSION_DISPATCH_TOOLS;
84
71
  /** bash 家族(要逐条判命令是否只读;与审查者模式同名单)。 */
85
72
  const BASH_TOOLS = new Set(["bash", "shell", "sh", "zsh", "terminal", "terminal_bash", "run_command", "exec"]);
86
73
  /** 引号感知的命令切分(按 | ; && || 换行拆段;含 $( / 反引号时整条视为一段交白名单判