pi-web-ui 0.91.0 → 0.93.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 (64) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +69 -14
  3. package/README.zh-CN.md +36 -10
  4. package/bin/pi-web-ui.mjs +39 -8
  5. package/dist/server/agent-service.js +938 -163
  6. package/dist/server/auth-cookie.js +26 -0
  7. package/dist/server/claim-files-tool.js +115 -0
  8. package/dist/server/claim-store.js +301 -0
  9. package/dist/server/client-state.js +114 -6
  10. package/dist/server/conversation-read-tool.js +371 -28
  11. package/dist/server/conversation-touches.js +357 -0
  12. package/dist/server/delegate-task.js +10 -3
  13. package/dist/server/dsh/dsh-agent-service.js +50 -1
  14. package/dist/server/files-service.js +78 -26
  15. package/dist/server/http-proxy.js +72 -0
  16. package/dist/server/index.js +94 -11
  17. package/dist/server/model-admin.js +124 -2
  18. package/dist/server/model-enrich.js +478 -0
  19. package/dist/server/patch-node-pty.js +11 -0
  20. package/dist/server/plugin-schedule.js +27 -2
  21. package/dist/server/plugins.js +66 -10
  22. package/dist/server/present-files-tool.js +303 -0
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +125 -0
  25. package/dist/server/resolve-global-sdk.js +72 -0
  26. package/dist/server/sdk-origin.js +64 -0
  27. package/dist/server/serialize.js +80 -22
  28. package/dist/server/settings-service.js +19 -1
  29. package/dist/server/skill-tool.js +2 -1
  30. package/dist/server/subagents.js +30 -13
  31. package/dist/server/terminals.js +38 -18
  32. package/dist/server/text-sniff.js +15 -0
  33. package/dist/server/themes.js +32 -2
  34. package/dist/server/tool-info.js +89 -0
  35. package/dist/server/tool-manager.js +110 -11
  36. package/package.json +2 -2
  37. package/plugin-sdk/README.md +1 -0
  38. package/plugins/catalog.json +20 -0
  39. package/themes/ayu-light.css +136 -0
  40. package/themes/catppuccin-latte.css +140 -0
  41. package/themes/catppuccin.css +144 -0
  42. package/themes/codex.css +136 -0
  43. package/themes/cyberpunk.css +4 -0
  44. package/themes/dazzle.css +4 -0
  45. package/themes/everforest-light.css +136 -0
  46. package/themes/geist.css +136 -0
  47. package/themes/gruvbox-light.css +136 -0
  48. package/themes/kanagawa-lotus.css +136 -0
  49. package/themes/md-preview.css +4 -0
  50. package/themes/mist.css +4 -0
  51. package/themes/nord.css +134 -0
  52. package/themes/one-dark.css +135 -0
  53. package/themes/paper.css +16 -12
  54. package/themes/rose-pine-dawn.css +136 -0
  55. package/themes/sakura.css +4 -0
  56. package/themes/solarized-light.css +135 -0
  57. package/themes/tokyo-night.css +144 -0
  58. package/themes/white.css +4 -0
  59. package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-C1A23aI-.js} +1 -1
  60. package/web/dist/assets/index-B48QWf0I.js +364 -0
  61. package/web/dist/assets/index-DGL5OInG.css +1 -0
  62. package/web/dist/index.html +2 -2
  63. package/web/dist/assets/index-CdsKltOS.css +0 -1
  64. package/web/dist/assets/index-OoULNF70.js +0 -363
@@ -0,0 +1,357 @@
1
+ // ---------------------------------------------------------------------------
2
+ // conversation-touches.ts — 会话「触碰文件集」提取(纯函数,零 node 依赖)
3
+ // ---------------------------------------------------------------------------
4
+ // 背景:同项目多对话并行时,服务端完全知道「谁碰过哪些文件」(实时消息与落盘
5
+ // 转录里都有工具调用记录),但既没算也没给 AI,导致 AI 只能靠猜冲突。本模块
6
+ // 只实现一次触碰集解析器,三处复用:并行提醒(agent-service.ts)、
7
+ // conversation_read 的 action:"files" 与 action:"status"。
8
+ //
9
+ // 输入形状(两条路都已核实,见下「数据来源」):
10
+ // - 实时路:session.agent.state.messages(SDK AgentMessage),assistant 消息
11
+ // 的 content 里是 {type:"toolCall", id, name, arguments} 块;
12
+ // - 历史路:转录 jsonl 的 message.content,原样保留同一形状(parseTranscript
13
+ // Lines 透传 content)。
14
+ // 注意:textOfContent 只在「渲染文本」里丢了参数(仍渲染 `[tool call: name]`),
15
+ // content 结构里的 arguments 一直都在。所以 toolCalls 字段是 additive 的便捷
16
+ // 访问点(一处解析、多处复用,并行 toolCall 也能表示),不是新数据源;取不到
17
+ // 时回落扫 content,再取不到就给空集 —— 绝不伪造。
18
+ //
19
+ // 提取规则(只算写,不算读 —— 这是准确性的关键):
20
+ // 写工具白名单:edit / write / edit_soft(参数 path;兼容 file_path/filePath
21
+ // 别名)。白名单之外的一切工具调用一律忽略,所以 read/ls/glob/grep 等读操作
22
+ // 天然不算 —— 不需要也不维护「读工具黑名单」。
23
+ // bash 高置信度写入迹象(拿不准就丢,宁可漏报不要假阳性):
24
+ // - 重定向 `>` / `>>` 的目标词(含 `2> file` 这类 fd 前缀;排除 `>&N`、
25
+ // /dev/null、NUL、&N;heredoc `<<`/`<<<` 不算写入);
26
+ // - rm / rmdir / mkdir / touch 的非 flag 参数;
27
+ // - cp / mv 只取最后一个非 flag 参数(目标端;源端是读/被删,不算写)。
28
+ // 解析是轻量分词器(引号/转义感知,`;`/`&&`/`||`/`|`/`&`/`(`/`)` 切分命令
29
+ // 段),不是完整 shell 解析器。
30
+ //
31
+ // 已知局限(如实记录,调用方展示时不要夸大):
32
+ // 1. 压缩(compaction)后旧消息被摘要替代,压缩前的触碰会丢失 —— 触碰集是
33
+ // 「当前可见转录」的下界,不是全集。
34
+ // 2. 相对路径按字面记录(edit 常给绝对路径、bash 常给相对路径),跨会话交集
35
+ // 可能因此漏报;但绝不做后缀模糊匹配(同名不同目录会假阳性,比漏报更糟)。
36
+ // 3. 路径归一化只做:去首尾空白、反斜杠转斜杠、去 `./` 前缀。Windows 大小写
37
+ // 不敏感不做归一。
38
+ // 4. powershell/cmd 风格写入(Set-Content/Out-File/del/copy)、git apply、
39
+ // tee 落盘、xargs/timeout/nice 等包装词之后的子命令 —— 一律不提取
40
+ // (sudo/env/command/time/nohup/doas 这几个常见包装词能跳过)。
41
+ // 5. 引号内的 `>`(如 echo "a > b")不提取;但 `eval`/`sh -c` 套娃串内的重定
42
+ // 向会被当成普通命令解析 —— 置信度下降,属已知噪声源。
43
+ // ---------------------------------------------------------------------------
44
+ /** 工具调用的参数键:SDK 用 arguments,兼容 input/args 两种历史写法。 */
45
+ function argsOfBlock(blk) {
46
+ if (typeof blk.arguments !== "undefined")
47
+ return blk.arguments;
48
+ if (typeof blk.input !== "undefined")
49
+ return blk.input;
50
+ return blk.args;
51
+ }
52
+ /** content 块数组里工具调用的 (name, args) 对(非 toolCall 块跳过)。 */
53
+ export function toolCallRefsOfContent(content) {
54
+ if (!Array.isArray(content))
55
+ return [];
56
+ const out = [];
57
+ for (const b of content) {
58
+ if (!b || typeof b !== "object")
59
+ continue;
60
+ const blk = b;
61
+ if (blk.type !== "toolCall" || typeof blk.name !== "string" || !blk.name)
62
+ continue;
63
+ const args = argsOfBlock(blk);
64
+ if (typeof args === "undefined")
65
+ out.push({ name: blk.name });
66
+ else
67
+ out.push({ name: blk.name, args });
68
+ }
69
+ return out;
70
+ }
71
+ /** 一条消息的全部工具调用:优先已解析的 toolCalls 字段,回落扫 content。 */
72
+ export function toolCallsOf(m) {
73
+ if (!m || typeof m !== "object")
74
+ return [];
75
+ if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
76
+ return m.toolCalls.filter((t) => !!t && typeof t === "object" && typeof t.name === "string");
77
+ }
78
+ return toolCallRefsOfContent(m.content);
79
+ }
80
+ /** 写工具白名单:只有这些工具的目标路径才算「写」。 */
81
+ const WRITE_TOOL_NAMES = new Set(["edit", "write", "edit_soft"]);
82
+ function pathOfWriteArgs(args) {
83
+ if (!args || typeof args !== "object")
84
+ return undefined;
85
+ const a = args;
86
+ for (const k of ["path", "file_path", "filePath"]) {
87
+ const v = a[k];
88
+ if (typeof v === "string" && v.trim())
89
+ return v.trim();
90
+ }
91
+ return undefined;
92
+ }
93
+ /** 路径轻归一(见文件头局限 3):只做无损、低风险的规范化。 */
94
+ export function normalizeTouchPath(p) {
95
+ let s = p.trim().replace(/\\/g, "/");
96
+ while (s.startsWith("./"))
97
+ s = s.slice(2);
98
+ return s;
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // bash 命令解析(高置信度写入迹象,见文件头规则)
102
+ // ---------------------------------------------------------------------------
103
+ /** shell 分词:引号/反斜杠转义感知,引号剥掉(`echo "a > b"` 的 > 不会误报)。 */
104
+ function tokenizeShell(cmd) {
105
+ const toks = [];
106
+ let cur = "";
107
+ let quote = null;
108
+ let i = 0;
109
+ const push = () => {
110
+ if (cur !== "") {
111
+ toks.push(cur);
112
+ cur = "";
113
+ }
114
+ };
115
+ while (i < cmd.length) {
116
+ const ch = cmd[i];
117
+ if (quote) {
118
+ if (ch === "\\" && quote === '"' && i + 1 < cmd.length) {
119
+ cur += cmd[i + 1];
120
+ i += 2;
121
+ continue;
122
+ }
123
+ if (ch === quote)
124
+ quote = null;
125
+ else
126
+ cur += ch;
127
+ i++;
128
+ continue;
129
+ }
130
+ if (ch === "\\" && i + 1 < cmd.length) {
131
+ cur += cmd[i + 1];
132
+ i += 2;
133
+ continue;
134
+ }
135
+ if (ch === "'" || ch === '"') {
136
+ quote = ch;
137
+ i++;
138
+ continue;
139
+ }
140
+ if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
141
+ push();
142
+ i++;
143
+ continue;
144
+ }
145
+ // 多字符操作符先行:>>、<<、&&、||、2>、1>> 等。
146
+ const three = cmd.slice(i, i + 3);
147
+ const two = cmd.slice(i, i + 2);
148
+ if (/^\d>>/.test(three) || three === "<<<") {
149
+ push();
150
+ toks.push(three);
151
+ i += 3;
152
+ continue;
153
+ }
154
+ if (two === ">>" || two === "<<" || two === "&&" || two === "||" || /^\d>$/.test(two) || /^\d</.test(two)) {
155
+ push();
156
+ toks.push(two);
157
+ i += 2;
158
+ continue;
159
+ }
160
+ if (ch === ">" || ch === "<" || ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")") {
161
+ push();
162
+ toks.push(ch);
163
+ i++;
164
+ continue;
165
+ }
166
+ cur += ch;
167
+ i++;
168
+ }
169
+ push();
170
+ return toks;
171
+ }
172
+ const SEGMENT_SEPS = new Set([";", "&&", "||", "|", "&", "(", ")"]);
173
+ /** 命令段首可跳过的包装词(之后仍是命令位置)。 */
174
+ const WRAPPER_WORDS = new Set(["sudo", "env", "command", "time", "nohup", "doas"]);
175
+ const MULTI_TARGET_WRITES = new Set(["rm", "rmdir", "mkdir", "touch"]);
176
+ /** 只算目标端(最后一个非 flag 参数)的命令。 */
177
+ const DEST_ONLY_WRITES = new Set(["cp", "mv"]);
178
+ /** 重定向目标黑名单:不是文件。 */
179
+ function isNonFileTarget(t) {
180
+ if (t === "/dev/null" || t.toLowerCase() === "nul")
181
+ return true;
182
+ if (/^&\d*$/.test(t))
183
+ return true;
184
+ if (t === "-")
185
+ return true;
186
+ return false;
187
+ }
188
+ /** bash 命令 → 高置信度写入路径(去重前,调用方计数)。 */
189
+ export function bashWriteTargets(command) {
190
+ if (typeof command !== "string" || !command.trim())
191
+ return [];
192
+ const toks = tokenizeShell(command);
193
+ const out = [];
194
+ // 命令段内状态:cmd = 当前命令词(null = 还在找命令词),args = 该段非 flag 参数。
195
+ let cmd = null;
196
+ let pendingMulti = [];
197
+ let pendingDest = null;
198
+ let afterDoubleDash = false;
199
+ let skipNext = false;
200
+ const flush = () => {
201
+ out.push(...pendingMulti);
202
+ if (pendingDest !== null)
203
+ out.push(pendingDest);
204
+ pendingMulti = [];
205
+ pendingDest = null;
206
+ };
207
+ for (let i = 0; i < toks.length; i++) {
208
+ const t = toks[i];
209
+ if (skipNext) {
210
+ skipNext = false;
211
+ continue;
212
+ }
213
+ if (SEGMENT_SEPS.has(t)) {
214
+ flush();
215
+ cmd = null;
216
+ afterDoubleDash = false;
217
+ continue;
218
+ }
219
+ // 重定向:`[fd]>[>] 目标`;heredoc 一律跳过。分隔符不能当目标吃掉
220
+ // (`echo a > ; rm x` 里 `;` 必须保留,否则后半段命令会错位)。
221
+ if (/^(\d*)(>>?)$/.test(t) || t === "<<" || t === "<<<" || /^(\d*)<<<?$/.test(t)) {
222
+ const next = toks[i + 1];
223
+ if (typeof next !== "undefined" && !SEGMENT_SEPS.has(next)) {
224
+ if (t.includes(">") && !isNonFileTarget(next))
225
+ out.push(next);
226
+ skipNext = true;
227
+ }
228
+ continue;
229
+ }
230
+ if (t === "--") {
231
+ afterDoubleDash = true;
232
+ continue;
233
+ }
234
+ if (cmd === null) {
235
+ if (WRAPPER_WORDS.has(t))
236
+ continue;
237
+ if (t.includes("=") && !t.startsWith("-"))
238
+ continue; // VAR=val 前缀赋值
239
+ cmd = t;
240
+ if (cmd === "xargs")
241
+ cmd = "\0xargs-opaque"; // xargs 后的是子命令+参数,别当文件(局限 4)
242
+ continue;
243
+ }
244
+ if (cmd === "\0xargs-opaque")
245
+ continue;
246
+ if (!afterDoubleDash && t.startsWith("-") && t.length > 1)
247
+ continue; // flag
248
+ if (MULTI_TARGET_WRITES.has(cmd))
249
+ pendingMulti.push(t);
250
+ else if (DEST_ONLY_WRITES.has(cmd))
251
+ pendingDest = t; // 只留最后一个
252
+ }
253
+ flush();
254
+ return out.filter((p) => p !== "" && !isNonFileTarget(p));
255
+ }
256
+ /** 转录消息 → 触碰文件集(按次数降序,次数相同按最后时间降序)。 */
257
+ export function extractTouches(messages, opts) {
258
+ if (!Array.isArray(messages) || messages.length === 0)
259
+ return [];
260
+ const max = Math.max(1, Math.floor(opts?.maxMessages ?? 500));
261
+ const slice = messages.slice(Math.max(0, messages.length - max));
262
+ const acc = new Map();
263
+ const bump = (raw, ts) => {
264
+ const path = normalizeTouchPath(raw);
265
+ if (!path)
266
+ return;
267
+ const e = acc.get(path);
268
+ if (e) {
269
+ e.count++;
270
+ if (ts > e.lastTs)
271
+ e.lastTs = ts;
272
+ }
273
+ else {
274
+ acc.set(path, { count: 1, lastTs: ts });
275
+ }
276
+ };
277
+ for (const m of slice) {
278
+ if (!m || typeof m !== "object")
279
+ continue;
280
+ const ts = typeof m.timestamp === "number" && Number.isFinite(m.timestamp) ? m.timestamp : 0;
281
+ for (const call of toolCallsOf(m)) {
282
+ const name = call.name;
283
+ if (WRITE_TOOL_NAMES.has(name)) {
284
+ const p = pathOfWriteArgs(call.args);
285
+ if (p)
286
+ bump(p, ts);
287
+ continue;
288
+ }
289
+ if (name === "bash" && call.args && typeof call.args === "object") {
290
+ const c = call.args.command;
291
+ if (typeof c === "string") {
292
+ for (const p of bashWriteTargets(c))
293
+ bump(p, ts);
294
+ }
295
+ }
296
+ }
297
+ // `!` 命令 / bashExecution 转录消息:命令在顶层 command 字段。
298
+ if (m.role === "bashExecution" && typeof m.command === "string") {
299
+ for (const p of bashWriteTargets(m.command))
300
+ bump(p, ts);
301
+ }
302
+ }
303
+ return [...acc.entries()]
304
+ .map(([path, e]) => ({ path, count: e.count, lastTs: e.lastTs }))
305
+ .sort((a, b) => b.count - a.count || b.lastTs - a.lastTs);
306
+ }
307
+ /** 两个触碰集求并(sidecar 合并用):sidecar 是历史快照、live 是当前全量,
308
+ * 两者消息集重叠,所以 count 取大不相加 —— 相加会把同一批触碰算两遍。
309
+ * 压缩后次数只能保证是下界(presence 与 lastTs 精确,交集计算不受影响)。 */
310
+ export function unionTouchLists(a, b) {
311
+ const acc = new Map();
312
+ for (const f of [...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])]) {
313
+ if (!f || typeof f.path !== "string")
314
+ continue;
315
+ const e = acc.get(f.path);
316
+ const count = typeof f.count === "number" && Number.isFinite(f.count) ? f.count : 0;
317
+ const lastTs = typeof f.lastTs === "number" && Number.isFinite(f.lastTs) ? f.lastTs : 0;
318
+ if (e) {
319
+ if (count > e.count)
320
+ e.count = count;
321
+ if (lastTs > e.lastTs)
322
+ e.lastTs = lastTs;
323
+ }
324
+ else {
325
+ acc.set(f.path, { count, lastTs });
326
+ }
327
+ }
328
+ return [...acc.entries()]
329
+ .map(([path, e]) => ({ path, count: e.count, lastTs: e.lastTs }))
330
+ .sort((x, y) => y.count - x.count || y.lastTs - x.lastTs);
331
+ }
332
+ /** 两个触碰集的交集:取 a 中同时出现在 b 里的条目(计数/时间沿用 a 的)。 */
333
+ export function intersectTouches(a, b) {
334
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || b.length === 0)
335
+ return [];
336
+ const inB = new Set(b.map((f) => f.path));
337
+ return a.filter((f) => inB.has(f.path));
338
+ }
339
+ /** 单条触碰格式化(供提醒用):条目超长时优先保路径完整 —— 路径永不截断
340
+ * (半截路径不可用,还可能误导 AI 去碰错文件),只丢 `×N` 后缀。 */
341
+ export function formatTouchEntry(f, perItemCap = 60) {
342
+ const full = f.count > 1 ? `${f.path} ×${f.count}` : f.path;
343
+ if (full.length <= perItemCap)
344
+ return full;
345
+ return f.path;
346
+ }
347
+ /** 触碰集紧凑格式化:前 N 条完整路径 + `… (+K)` 计数(静默丢弃是大忌)。 */
348
+ export function formatTouchesCompact(files, opts) {
349
+ if (!Array.isArray(files) || files.length === 0)
350
+ return "";
351
+ const maxItems = Math.max(1, Math.floor(opts?.maxItems ?? 3));
352
+ const cap = Math.max(10, Math.floor(opts?.perItemCap ?? 60));
353
+ const shown = files.slice(0, maxItems).map((f) => formatTouchEntry(f, cap));
354
+ if (files.length > maxItems)
355
+ shown.push(`… (+${files.length - maxItems})`);
356
+ return shown.join("、");
357
+ }
@@ -17,9 +17,10 @@
17
17
  import { Type } from "typebox";
18
18
  import { defineTool } from "@earendil-works/pi-coding-agent";
19
19
  import { bilingual, pick } from "./i18n.js";
20
+ import { DELEGATE_TASK_TOOL_NAME } from "./tool-manager.js";
20
21
  import { subagentTitle } from "./subagents.js";
21
- /** 工具名(前端 ToolCallBlock 派单卡片靠它识别;改名需同步改前端)。 */
22
- export const DELEGATE_TOOL_NAME = "delegate_task";
22
+ /** 工具名(唯一值在 tool-manager.ts;本别名保留:前端派单卡片与旧导入沿用它)。 */
23
+ export const DELEGATE_TOOL_NAME = DELEGATE_TASK_TOOL_NAME;
23
24
  /** 各段最小长度(trim 后字符数):TASK 必须具体,OUTCOME 必须可验收,其余段不许空着。 */
24
25
  const MIN_TASK = 20;
25
26
  const MIN_OUTCOME = 10;
@@ -40,6 +41,7 @@ export function normalizeDelegation(params) {
40
41
  must_not_do: str(p.must_not_do),
41
42
  context: str(p.context),
42
43
  model: str(p.model).trim() || undefined,
44
+ persist: typeof p.persist === "boolean" ? p.persist : undefined,
43
45
  };
44
46
  }
45
47
  /**
@@ -122,6 +124,11 @@ const delegateSchema = Type.Object({
122
124
  model: Type.Optional(Type.String({
123
125
  description: bilingual('Optional model "provider/id" for this delegation; omit = template model → panel default → follow the main conversation model.', "可选:本次派单的子代理模型(provider/id);不传 = 模板模型 → 面板默认 → 跟随主对话模型。"),
124
126
  })),
127
+ persist: Type.Optional(Type.Boolean({
128
+ description: bilingual("Optional: persist this conversation to disk as a regular session (saved in history, resumable). " +
129
+ "Default false (lightweight in-memory subagent). Use true for tasks that need long-term retention.", "可选:是否将该对话持久化落盘为普通对话(保存在历史会话中,可随时回顾与继续)。" +
130
+ "默认 false(轻量内存子代理)。需要长期留存的任务建议设为 true。"),
131
+ })),
125
132
  });
126
133
  /** 结构化派单工具:校验六段 → 拼装标准 prompt → 走 host.spawnSubagent 真子代理。 */
127
134
  export function makeDelegateTaskTool(host, lang) {
@@ -163,7 +170,7 @@ export function makeDelegateTaskTool(host, lang) {
163
170
  // 创建失败都经由 host 抛错),转成返回文本让 AI 能读到原因并修正重试。
164
171
  let convId;
165
172
  try {
166
- convId = await host.spawnSubagent(prompt, "delegate", ctx.cwd, input.agent, input.model);
173
+ convId = await host.spawnSubagent(prompt, "delegate", ctx.cwd, input.agent, input.model, undefined, input.persist);
167
174
  }
168
175
  catch (err) {
169
176
  const msg = err instanceof Error ? err.message : String(err);
@@ -29,12 +29,14 @@ 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 } from "../client-state.js";
32
+ import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS, 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";
36
36
  import { NATIVE_COMMANDS, parseSlash } from "../slash-commands.js";
37
37
  import { bilingual, pick, resolveServerLang } from "../i18n.js";
38
+ // 工具定义说明的归一化(工具卡右键 → 「显示工具详细信息」,见 getToolInfo)。
39
+ import { normalizeToolInfo, unsupportedToolInfo } from "../tool-info.js";
38
40
  import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js";
39
41
  import { saveUpload } from "../uploads.js";
40
42
  import { checkAll as checkAllUpdates, collectTargets, resolveNpmRegistry } from "../update-check.js";
@@ -122,12 +124,14 @@ const DEFAULT_SETTINGS = {
122
124
  terminalToolsEnabled: false,
123
125
  terminalBash: false,
124
126
  terminalBashIdleMs: 15_000,
127
+ toolWatchdogTimeoutMs: 20 * 60_000,
125
128
  editSoftEnabled: false,
126
129
  questionnaireEnabled: true,
127
130
  goalModeEnabled: true,
128
131
  parallelReminderEnabled: true,
129
132
  thinkingWrap: false,
130
133
  toolsWrap: true,
134
+ toolImagesEnabled: true,
131
135
  disabledPlugins: [],
132
136
  uiLayout: {},
133
137
  reviewPrompt: "",
@@ -267,12 +271,14 @@ export class DshClientSession {
267
271
  terminalToolsEnabled: savedSettings.terminalToolsEnabled,
268
272
  terminalBash: savedSettings.terminalBash,
269
273
  terminalBashIdleMs: savedSettings.terminalBashIdleMs,
274
+ toolWatchdogTimeoutMs: savedSettings.toolWatchdogTimeoutMs ?? 20 * 60_000,
270
275
  editSoftEnabled: savedSettings.editSoftEnabled,
271
276
  questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
272
277
  goalModeEnabled: savedSettings.goalModeEnabled ?? true,
273
278
  parallelReminderEnabled: savedSettings.parallelReminderEnabled ?? true,
274
279
  thinkingWrap: savedSettings.thinkingWrap,
275
280
  toolsWrap: savedSettings.toolsWrap,
281
+ toolImagesEnabled: savedSettings.toolImagesEnabled ?? true,
276
282
  disabledPlugins: savedSettings.disabledPlugins ?? [],
277
283
  uiLayout: normalizeUiLayout(savedSettings.uiLayout),
278
284
  reviewPrompt: savedSettings.reviewPrompt,
@@ -563,16 +569,22 @@ export class DshClientSession {
563
569
  // 记下待答问卷:`question_pending` 只推给「当时在线」的连接,刷新页面
564
570
  // /WS 重连后靠快照(UiState.pendingQuestion)把对话框恢复出来。
565
571
  // DSH 的提问桥是 runtime 级的(无 conversationId),故不分对话。
572
+ const dshConv = this.convs.get(this.activeId);
573
+ const conversationTitle = dshConv?.title;
566
574
  this.pendingQuestion = {
567
575
  id: params0.id,
568
576
  ...(typeof params0.deadline === "number" ? { deadline: params0.deadline } : {}),
569
577
  questions: mapped,
578
+ ...(this.activeId ? { conversationId: this.activeId } : {}),
579
+ ...(conversationTitle ? { conversationTitle } : {}),
570
580
  };
571
581
  this.emit({
572
582
  type: "question_pending",
573
583
  id: params0.id,
574
584
  ...(typeof params0.deadline === "number" ? { deadline: params0.deadline } : {}),
575
585
  questions: mapped,
586
+ ...(this.activeId ? { conversationId: this.activeId } : {}),
587
+ ...(conversationTitle ? { conversationTitle } : {}),
576
588
  });
577
589
  }
578
590
  else if (method === "tools.call.request") {
@@ -658,6 +670,28 @@ export class DshClientSession {
658
670
  console.error(`[dsh] syncPluginTools 失败 (client=${this.clientId}):`, err);
659
671
  }
660
672
  }
673
+ /**
674
+ * 取一条工具的**定义说明**(工具卡右键 → 「显示工具详细信息」)→ `tool_info`。
675
+ *
676
+ * DSH 侧的定义来自运行时(`tools/list`:DSH 原生工具 + 桥接进来的插件工具)。
677
+ * 拿不到(运行时没起来 / RPC 报错)回 `unsupported`,让前端说「当前引擎不支持」
678
+ * 而不是「未找到工具定义」—— 后者会让用户以为工具名写错了。
679
+ */
680
+ async getToolInfo(name) {
681
+ if (!this.runtime.alive) {
682
+ this.emit({ type: "tool_info", ...unsupportedToolInfo(name) });
683
+ return;
684
+ }
685
+ try {
686
+ const res = await this.runtime.listTools();
687
+ const found = (res.tools ?? []).find((t) => t.name === name);
688
+ this.emit({ type: "tool_info", ...normalizeToolInfo(name, found) });
689
+ }
690
+ catch (err) {
691
+ console.error(`[dsh] listTools 失败 (client=${this.clientId}):`, err);
692
+ this.emit({ type: "tool_info", ...unsupportedToolInfo(name) });
693
+ }
694
+ }
661
695
  /** 从运行时拉取技能清单 → 缓存 → 重推 settings_state(enabled 由 disabledSkills 推导)。 */
662
696
  async refreshSkillsFromRuntime() {
663
697
  if (!this.runtime.alive)
@@ -2590,6 +2624,9 @@ export class DshClientSession {
2590
2624
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2591
2625
  terminalBash: this.settings.terminalBash,
2592
2626
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2627
+ toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
2628
+ // DSH 引擎无 customTool 注册面(工具来自 shipped preset),read 目录覆盖面不存在。
2629
+ readDirEnabled: true,
2593
2630
  editSoftEnabled: this.settings.editSoftEnabled,
2594
2631
  // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2595
2632
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
@@ -2601,6 +2638,7 @@ export class DshClientSession {
2601
2638
  parallelReminderEnabled: this.settings.parallelReminderEnabled,
2602
2639
  thinkingWrap: this.settings.thinkingWrap,
2603
2640
  toolsWrap: this.settings.toolsWrap,
2641
+ toolImagesEnabled: this.settings.toolImagesEnabled,
2604
2642
  // DSH 无 skill 全文注入概念,给空保协议完整。
2605
2643
  skillsFullText: [],
2606
2644
  visionBridgeEnabled: false,
@@ -2657,6 +2695,8 @@ export class DshClientSession {
2657
2695
  this.settings.terminalBash = partial.terminalBash;
2658
2696
  if (partial.terminalBashIdleMs !== undefined)
2659
2697
  this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
2698
+ if (partial.toolWatchdogTimeoutMs !== undefined)
2699
+ this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
2660
2700
  if (partial.editSoftEnabled !== undefined)
2661
2701
  this.settings.editSoftEnabled = partial.editSoftEnabled;
2662
2702
  if (partial.questionnaireEnabled !== undefined)
@@ -2669,6 +2709,8 @@ export class DshClientSession {
2669
2709
  this.settings.thinkingWrap = partial.thinkingWrap;
2670
2710
  if (partial.toolsWrap !== undefined)
2671
2711
  this.settings.toolsWrap = partial.toolsWrap;
2712
+ if (partial.toolImagesEnabled !== undefined)
2713
+ this.settings.toolImagesEnabled = partial.toolImagesEnabled;
2672
2714
  if (partial.disabledPlugins !== undefined)
2673
2715
  this.settings.disabledPlugins = partial.disabledPlugins;
2674
2716
  if (partial.uiLayout !== undefined)
@@ -2704,6 +2746,7 @@ export class DshClientSession {
2704
2746
  parallelReminderEnabled: this.settings.parallelReminderEnabled,
2705
2747
  thinkingWrap: this.settings.thinkingWrap,
2706
2748
  toolsWrap: this.settings.toolsWrap,
2749
+ toolImagesEnabled: this.settings.toolImagesEnabled,
2707
2750
  disabledPlugins: this.settings.disabledPlugins,
2708
2751
  uiLayout: normalizeUiLayout(this.settings.uiLayout),
2709
2752
  reviewPrompt: this.settings.reviewPrompt,
@@ -3069,6 +3112,7 @@ export class DshClientSession {
3069
3112
  editSoftEnabled: this.settings.editSoftEnabled,
3070
3113
  thinkingWrap: this.settings.thinkingWrap,
3071
3114
  toolsWrap: this.settings.toolsWrap,
3115
+ toolImagesEnabled: this.settings.toolImagesEnabled,
3072
3116
  disabledPlugins: this.settings.disabledPlugins,
3073
3117
  uiLayout: normalizeUiLayout(this.settings.uiLayout),
3074
3118
  reviewPrompt: this.settings.reviewPrompt,
@@ -3880,6 +3924,11 @@ export class DshClientSession {
3880
3924
  this.emit({ type: "notice", level: "error", text: error, textEn: errorEn });
3881
3925
  this.emit({ type: "clone_provider_result", reqId, ok: false, error });
3882
3926
  }
3927
+ async enrichModels(reqId, _ids, _hints) {
3928
+ const error = pick(this.getLang(), "DSH 引擎不支持自定义 provider", "The DSH engine does not support custom providers", "dsh.provider.custom.unsupported");
3929
+ this.emit({ type: "enrich_models_result", reqId, ok: false, error });
3930
+ }
3931
+ abortEnrichModels(_reqId) { }
3883
3932
  // -----------------------------------------------------------------------
3884
3933
  // 其他
3885
3934
  // -----------------------------------------------------------------------