pi-web-ui 0.90.1 → 0.92.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 (54) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/bin/pi-web-ui.mjs +21 -2
  3. package/dist/server/agent-service.js +1206 -106
  4. package/dist/server/claim-files-tool.js +116 -0
  5. package/dist/server/claim-store.js +301 -0
  6. package/dist/server/client-state.js +119 -5
  7. package/dist/server/compaction-markers.js +287 -0
  8. package/dist/server/conversation-read-tool.js +369 -27
  9. package/dist/server/conversation-touches.js +357 -0
  10. package/dist/server/delegate-task.js +12 -1
  11. package/dist/server/dsh/dsh-agent-service.js +28 -1
  12. package/dist/server/file-transfer-routes.js +4 -1
  13. package/dist/server/icon-svg.js +35 -0
  14. package/dist/server/index.js +152 -34
  15. package/dist/server/model-admin.js +124 -2
  16. package/dist/server/model-enrich.js +478 -0
  17. package/dist/server/plugin-catalog.js +5 -0
  18. package/dist/server/plugin-facilities.js +42 -11
  19. package/dist/server/plugin-schedule.js +27 -2
  20. package/dist/server/plugins.js +112 -18
  21. package/dist/server/present-files-tool.js +303 -0
  22. package/dist/server/prompt-composer.js +1 -1
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +91 -0
  25. package/dist/server/schedule-agent-tool.js +23 -5
  26. package/dist/server/scheduler-tasks.js +60 -1
  27. package/dist/server/scm-commitmsg.js +140 -0
  28. package/dist/server/scm.js +42 -0
  29. package/dist/server/serialize.js +21 -1
  30. package/dist/server/settings-service.js +47 -0
  31. package/dist/server/skill-tool.js +120 -0
  32. package/dist/server/soft-cap.js +68 -0
  33. package/dist/server/subagent-templates.js +6 -0
  34. package/dist/server/subagents.js +157 -16
  35. package/dist/server/text-sniff.js +15 -0
  36. package/dist/server/themes.js +17 -2
  37. package/dist/server/tool-manager.js +13 -1
  38. package/package.json +2 -1
  39. package/plugin-sdk/README.md +1 -0
  40. package/plugins/catalog.json +20 -0
  41. package/themes/catppuccin-latte.css +136 -0
  42. package/themes/catppuccin.css +140 -0
  43. package/themes/nord.css +130 -0
  44. package/themes/one-dark.css +131 -0
  45. package/themes/paper.css +12 -12
  46. package/themes/solarized-light.css +131 -0
  47. package/themes/tokyo-night.css +140 -0
  48. package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
  49. package/web/dist/assets/es-DIDZppIF.js +2 -0
  50. package/web/dist/assets/index-BgrnP9Jz.js +364 -0
  51. package/web/dist/assets/index-Cy8dSTZt.css +1 -0
  52. package/web/dist/index.html +2 -2
  53. package/web/dist/assets/index-DJIV4A-t.js +0 -355
  54. package/web/dist/assets/index-DLdcRBs_.css +0 -1
@@ -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
+ }
@@ -159,7 +159,18 @@ export function makeDelegateTaskTool(host, lang) {
159
159
  if (err)
160
160
  return text(err, { delegated: false, agent: input.agent });
161
161
  const prompt = buildDelegationPrompt(input, getLang());
162
- const convId = await host.spawnSubagent(prompt, "delegate", ctx.cwd, input.agent, input.model);
162
+ // 与 subagent_spawn 同理:spawn 通道是唯一的失败面(数量上限 / runtime
163
+ // 创建失败都经由 host 抛错),转成返回文本让 AI 能读到原因并修正重试。
164
+ let convId;
165
+ try {
166
+ convId = await host.spawnSubagent(prompt, "delegate", ctx.cwd, input.agent, input.model);
167
+ }
168
+ catch (err) {
169
+ const msg = err instanceof Error ? err.message : String(err);
170
+ return text(pick(getLang(), `派单启动失败:${msg}`, `Failed to start delegation: ${msg}`, "delegate.start.failed", {
171
+ error: msg,
172
+ }), { delegated: false, agent: input.agent });
173
+ }
163
174
  const title = subagentTitle(prompt);
164
175
  const modelLineZh = input.model ? `\n模型:${input.model}` : "";
165
176
  const modelLineEn = input.model ? `\nModel: ${input.model}` : "";
@@ -125,6 +125,7 @@ const DEFAULT_SETTINGS = {
125
125
  editSoftEnabled: false,
126
126
  questionnaireEnabled: true,
127
127
  goalModeEnabled: true,
128
+ parallelReminderEnabled: true,
128
129
  thinkingWrap: false,
129
130
  toolsWrap: true,
130
131
  disabledPlugins: [],
@@ -269,6 +270,7 @@ export class DshClientSession {
269
270
  editSoftEnabled: savedSettings.editSoftEnabled,
270
271
  questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
271
272
  goalModeEnabled: savedSettings.goalModeEnabled ?? true,
273
+ parallelReminderEnabled: savedSettings.parallelReminderEnabled ?? true,
272
274
  thinkingWrap: savedSettings.thinkingWrap,
273
275
  toolsWrap: savedSettings.toolsWrap,
274
276
  disabledPlugins: savedSettings.disabledPlugins ?? [],
@@ -1485,8 +1487,9 @@ export class DshClientSession {
1485
1487
  }
1486
1488
  // issue #145:同项目并行感知(与 pi 引擎同语义;DSH 无 display:false 的
1487
1489
  // custom 消息通道,提醒以前置系统文本随本轮发给运行时,用户气泡保持原文)。
1490
+ // parallelReminderEnabled=false 时整段跳过。
1488
1491
  let sysPrefix = "";
1489
- if (!conv.isStreaming) {
1492
+ if (!conv.isStreaming && this.settings.parallelReminderEnabled !== false) {
1490
1493
  const localTitles = [...this.convs.values()]
1491
1494
  .filter((c) => c.id !== conv.id && c.cwd === conv.cwd && c.isStreaming)
1492
1495
  .map((c) => `本窗口「${c.title}」`);
@@ -2587,11 +2590,17 @@ export class DshClientSession {
2587
2590
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2588
2591
  terminalBash: this.settings.terminalBash,
2589
2592
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2593
+ // DSH 引擎无 customTool 注册面(工具来自 shipped preset),read 目录覆盖面不存在。
2594
+ readDirEnabled: true,
2590
2595
  editSoftEnabled: this.settings.editSoftEnabled,
2591
2596
  // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2592
2597
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2598
+ // DSH 另有运行时:压缩软上限是 pi 引擎概念,这里保持关闭保协议完整。
2599
+ softCapTokens: 0,
2600
+ softCapByModel: {},
2593
2601
  questionnaireEnabled: this.settings.questionnaireEnabled,
2594
2602
  goalModeEnabled: this.settings.goalModeEnabled,
2603
+ parallelReminderEnabled: this.settings.parallelReminderEnabled,
2595
2604
  thinkingWrap: this.settings.thinkingWrap,
2596
2605
  toolsWrap: this.settings.toolsWrap,
2597
2606
  // DSH 无 skill 全文注入概念,给空保协议完整。
@@ -2600,6 +2609,9 @@ export class DshClientSession {
2600
2609
  visionBridgeModel: null,
2601
2610
  visionBridgePromptMode: "append",
2602
2611
  visionBridgePrompt: "",
2612
+ // DSH 无「AI 生成提交信息」(scm_commitmsg 分发处直接报错),保协议完整。
2613
+ scmCommitMsgPromptMode: "append",
2614
+ scmCommitMsgPrompt: "",
2603
2615
  reviewPrompt: this.settings.reviewPrompt,
2604
2616
  reviewDisabledSkills: [],
2605
2617
  disabledPlugins: this.settings.disabledPlugins,
@@ -2611,6 +2623,7 @@ export class DshClientSession {
2611
2623
  // DSH 引擎不接标准 pi 的 customTool 工具 schema(走 goal-rpc),此处给空。
2612
2624
  toolsSchema: "",
2613
2625
  visionBridgeDefaultPrompt: "",
2626
+ scmCommitMsgDefaultPrompt: "",
2614
2627
  visionModels: [],
2615
2628
  skills: this.skillsCache,
2616
2629
  reviewSkills: [],
@@ -2652,6 +2665,8 @@ export class DshClientSession {
2652
2665
  this.settings.questionnaireEnabled = partial.questionnaireEnabled;
2653
2666
  if (partial.goalModeEnabled !== undefined)
2654
2667
  this.settings.goalModeEnabled = partial.goalModeEnabled;
2668
+ if (partial.parallelReminderEnabled !== undefined)
2669
+ this.settings.parallelReminderEnabled = partial.parallelReminderEnabled;
2655
2670
  if (partial.thinkingWrap !== undefined)
2656
2671
  this.settings.thinkingWrap = partial.thinkingWrap;
2657
2672
  if (partial.toolsWrap !== undefined)
@@ -2683,8 +2698,12 @@ export class DshClientSession {
2683
2698
  editSoftEnabled: this.settings.editSoftEnabled,
2684
2699
  // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2685
2700
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2701
+ // DSH 另有运行时:压缩软上限是 pi 引擎概念,这里保持关闭保协议完整。
2702
+ softCapTokens: 0,
2703
+ softCapByModel: {},
2686
2704
  questionnaireEnabled: this.settings.questionnaireEnabled,
2687
2705
  goalModeEnabled: this.settings.goalModeEnabled,
2706
+ parallelReminderEnabled: this.settings.parallelReminderEnabled,
2688
2707
  thinkingWrap: this.settings.thinkingWrap,
2689
2708
  toolsWrap: this.settings.toolsWrap,
2690
2709
  disabledPlugins: this.settings.disabledPlugins,
@@ -3004,6 +3023,9 @@ export class DshClientSession {
3004
3023
  // DSH 无 skill 全文注入概念,给空保预设类型完整。
3005
3024
  skillsFullText: [],
3006
3025
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
3026
+ // DSH 另有运行时:压缩软上限是 pi 引擎概念,预设里保持关闭。
3027
+ softCapTokens: 0,
3028
+ softCapByModel: {},
3007
3029
  visionBridgePromptMode: "append",
3008
3030
  visionBridgePrompt: "",
3009
3031
  reviewPrompt: this.settings.reviewPrompt,
@@ -3860,6 +3882,11 @@ export class DshClientSession {
3860
3882
  this.emit({ type: "notice", level: "error", text: error, textEn: errorEn });
3861
3883
  this.emit({ type: "clone_provider_result", reqId, ok: false, error });
3862
3884
  }
3885
+ async enrichModels(reqId, _ids, _hints) {
3886
+ const error = pick(this.getLang(), "DSH 引擎不支持自定义 provider", "The DSH engine does not support custom providers", "dsh.provider.custom.unsupported");
3887
+ this.emit({ type: "enrich_models_result", reqId, ok: false, error });
3888
+ }
3889
+ abortEnrichModels(_reqId) { }
3863
3890
  // -----------------------------------------------------------------------
3864
3891
  // 其他
3865
3892
  // -----------------------------------------------------------------------
@@ -59,7 +59,10 @@ export function registerFileTransferRoutes(app, getCwd, temporaryRoot = tmpdir()
59
59
  if ((await stat(archive.path)).size > 200 * 1024 * 1024)
60
60
  throw new Error("Temporary download exceeds 200 MiB; compress in place and download the archive instead");
61
61
  // The callback runs on completion AND disconnect. Never expose a reusable temp URL.
62
- await new Promise((ok, fail) => res.download(archive.path, archive.name, (err) => (err ? fail(err) : ok())));
62
+ // dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,
63
+ // TMPDIR 等临时根位于隐藏目录下(如 ~/.cache)时下载会被判 404。
64
+ // 路径是服务端刚创建的受控临时包,放行安全。
65
+ await new Promise((ok, fail) => res.download(archive.path, archive.name, { dotfiles: "allow" }, (err) => (err ? fail(err) : ok())));
63
66
  }
64
67
  else
65
68
  res.json({ name: archive.name });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 插件内联 SVG 图标(manifest.json `iconSvg` / catalog `iconSvg`)的校验与规范化。
3
+ *
4
+ * 为什么是纯函数独立模块:
5
+ * - server/plugins.ts 与 server/plugin-catalog.ts 都要用;normalize 放 plugins.ts
6
+ * 里会形成循环依赖(plugins.ts 已 import plugin-catalog.ts)。
7
+ * - 前端另有一份 sanitize(web/src/plugin-icon.tsx):服务端只做形状校验与长度
8
+ * 截断(入库前),渲染前的前端 sanitize 负责真正的 XSS 过滤(去 script/on*)。
9
+ */
10
+ /** iconSvg 上限:16px 展示位,正常图标 <2KB;超了直接拒掉(不是截断,截断会断标签)。 */
11
+ export const MAX_ICON_SVG_LENGTH = 8192;
12
+ /**
13
+ * 校验并规范化内联 SVG 图标;非法返回 undefined。
14
+ * 接受:以 `<svg` 开头(含 `<?xml` 前导也认)、`</svg>` 结尾、不含 `<script`、
15
+ * 不含行内事件处理器(`on*=`) 的字符串。返回 trim 后的原文(不改写内容)。
16
+ */
17
+ export function normalizeIconSvg(raw) {
18
+ if (typeof raw !== "string")
19
+ return undefined;
20
+ const s = raw.trim();
21
+ if (!s || s.length > MAX_ICON_SVG_LENGTH)
22
+ return undefined;
23
+ const body = s.startsWith("<?xml") ? s.slice(s.indexOf("?>") + 2).trimStart() : s;
24
+ if (!/^<svg[\s>]/.test(body))
25
+ return undefined;
26
+ if (!/<\/svg\s*>\s*$/.test(body))
27
+ return undefined;
28
+ if (/<script[\s>]/i.test(body))
29
+ return undefined;
30
+ if (/\son\w+\s*=/i.test(body))
31
+ return undefined;
32
+ if (/javascript\s*:/i.test(body))
33
+ return undefined;
34
+ return s;
35
+ }