pi-web-ui 0.90.1 → 0.91.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 (33) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/server/agent-service.js +763 -61
  3. package/dist/server/client-state.js +70 -5
  4. package/dist/server/compaction-markers.js +287 -0
  5. package/dist/server/delegate-task.js +12 -1
  6. package/dist/server/dsh/dsh-agent-service.js +21 -1
  7. package/dist/server/file-transfer-routes.js +4 -1
  8. package/dist/server/icon-svg.js +35 -0
  9. package/dist/server/index.js +120 -30
  10. package/dist/server/plugin-catalog.js +5 -0
  11. package/dist/server/plugin-facilities.js +42 -11
  12. package/dist/server/plugins.js +47 -8
  13. package/dist/server/prompt-composer.js +1 -1
  14. package/dist/server/protocol-version.js +1 -1
  15. package/dist/server/schedule-agent-tool.js +23 -5
  16. package/dist/server/scheduler-tasks.js +60 -1
  17. package/dist/server/scm-commitmsg.js +140 -0
  18. package/dist/server/scm.js +42 -0
  19. package/dist/server/settings-service.js +40 -0
  20. package/dist/server/skill-tool.js +120 -0
  21. package/dist/server/soft-cap.js +68 -0
  22. package/dist/server/subagent-templates.js +6 -0
  23. package/dist/server/subagents.js +157 -16
  24. package/dist/server/tool-manager.js +7 -1
  25. package/package.json +2 -1
  26. package/plugins/catalog.json +10 -0
  27. package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-GtDQ1h85.js} +1 -1
  28. package/web/dist/assets/es-DIDZppIF.js +2 -0
  29. package/web/dist/assets/index-CdsKltOS.css +1 -0
  30. package/web/dist/assets/index-OoULNF70.js +363 -0
  31. package/web/dist/index.html +2 -2
  32. package/web/dist/assets/index-DJIV4A-t.js +0 -355
  33. package/web/dist/assets/index-DLdcRBs_.css +0 -1
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
10
  import { dirname, isAbsolute, resolve } from "node:path";
11
+ import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
11
12
  import { deriveLegacy, legacyToDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
12
13
  /** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
13
14
  export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
@@ -66,11 +67,44 @@ export function normalizeUiLayout(v) {
66
67
  }
67
68
  return Object.keys(out).length ? out : undefined;
68
69
  };
69
- const hidden = arr(o.hidden, 200);
70
- const shown = arr(o.shown, 200);
71
- const order = arr(o.order, 200);
72
- const groups = dict(o.groups, 200);
73
- const labels = dict(o.labels, 200);
70
+ // 品牌二合一迁移:旧 host:brand-logo/host:brand-name → host:brand(去重;显式新值赢)。
71
+ const BRAND_NEW = "host:brand";
72
+ const isBrandOld = (id) => id === "host:brand-logo" || id === "host:brand-name";
73
+ const mapBrandList = (list) => {
74
+ if (!list)
75
+ return undefined;
76
+ const out = [];
77
+ for (const id of list) {
78
+ const mapped = isBrandOld(id) ? BRAND_NEW : id;
79
+ if (!out.includes(mapped))
80
+ out.push(mapped);
81
+ }
82
+ return out.length ? out : undefined;
83
+ };
84
+ /** 旧品牌 key 折进 host:brand:logo 优先还是名称优先由调用方定(对齐/分组跟 logo,走名称)。 */
85
+ const foldBrandDict = (src, logoFirst) => {
86
+ if (!src)
87
+ return undefined;
88
+ const out = {};
89
+ for (const [k, val] of Object.entries(src)) {
90
+ if (isBrandOld(k))
91
+ continue;
92
+ out[k] = val;
93
+ }
94
+ if (out[BRAND_NEW] === undefined) {
95
+ const picked = logoFirst
96
+ ? (src["host:brand-logo"] ?? src["host:brand-name"])
97
+ : (src["host:brand-name"] ?? src["host:brand-logo"]);
98
+ if (picked !== undefined)
99
+ out[BRAND_NEW] = picked;
100
+ }
101
+ return Object.keys(out).length ? out : undefined;
102
+ };
103
+ const hidden = mapBrandList(arr(o.hidden, 200));
104
+ const shown = mapBrandList(arr(o.shown, 200));
105
+ const order = mapBrandList(arr(o.order, 200));
106
+ const groups = foldBrandDict(dict(o.groups, 200), true);
107
+ const labels = foldBrandDict(dict(o.labels, 200), false);
74
108
  // 用户对齐:只收 start/center/end(手改脏值回落丢弃,不污染合并结果)。
75
109
  let align;
76
110
  if (o.align && typeof o.align === "object" && !Array.isArray(o.align)) {
@@ -83,6 +117,23 @@ export function normalizeUiLayout(v) {
83
117
  if (Object.keys(align).length === 0)
84
118
  align = undefined;
85
119
  }
120
+ // 品牌对齐同样折进 host:brand(跟 logo 的值,显式新值赢)。
121
+ if (align && ("host:brand-logo" in align || "host:brand-name" in align)) {
122
+ const out = {};
123
+ for (const [k, val] of Object.entries(align)) {
124
+ if (k === "host:brand-logo" || k === "host:brand-name")
125
+ continue;
126
+ out[k] = val;
127
+ }
128
+ if (out[BRAND_NEW] === undefined) {
129
+ const picked = align["host:brand-logo"] ?? align["host:brand-name"];
130
+ if (picked !== undefined)
131
+ out[BRAND_NEW] = picked;
132
+ }
133
+ align = Object.keys(out).length ? out : undefined;
134
+ }
135
+ // 顶栏按钮文字总开关:只收布尔值(缺席 = 显示,兼容老存档)。
136
+ const topbarText = typeof o.topbarText === "boolean" ? o.topbarText : undefined;
86
137
  return {
87
138
  ...(hidden ? { hidden } : {}),
88
139
  ...(shown ? { shown } : {}),
@@ -90,6 +141,7 @@ export function normalizeUiLayout(v) {
90
141
  ...(groups ? { groups } : {}),
91
142
  ...(align ? { align } : {}),
92
143
  ...(labels ? { labels } : {}),
144
+ ...(topbarText !== undefined ? { topbarText } : {}),
93
145
  };
94
146
  }
95
147
  /** 归一化技能名单:字符串数组原样过滤;其他(含旧 bool 开关)回落空数组。 */
@@ -374,6 +426,7 @@ export class ClientStateStore {
374
426
  ? deriveLegacy(legacyToDisabled(stored)).questionnaireEnabled
375
427
  : (stored?.questionnaireEnabled ?? true),
376
428
  goalModeEnabled: stored?.goalModeEnabled ?? true,
429
+ parallelReminderEnabled: stored?.parallelReminderEnabled ?? true,
377
430
  thinkingWrap: stored?.thinkingWrap ?? false,
378
431
  devNoCache: stored?.devNoCache,
379
432
  autoReload: stored?.autoReload,
@@ -383,8 +436,12 @@ export class ClientStateStore {
383
436
  visionBridgeModel: stored?.visionBridgeModel ?? null,
384
437
  visionBridgePromptMode: stored?.visionBridgePromptMode === "replace" ? "replace" : "append",
385
438
  visionBridgePrompt: stored?.visionBridgePrompt ?? "",
439
+ scmCommitMsgPromptMode: stored?.scmCommitMsgPromptMode === "replace" ? "replace" : "append",
440
+ scmCommitMsgPrompt: stored?.scmCommitMsgPrompt ?? "",
386
441
  subagentDefaultModel: stored?.subagentDefaultModel ?? null,
387
442
  retryMaxAttempts: normalizeRetryMaxAttempts(stored?.retryMaxAttempts),
443
+ softCapTokens: normalizeSoftCapTokens(stored?.softCapTokens),
444
+ softCapByModel: normalizeSoftCapByModel(stored?.softCapByModel),
388
445
  quickPhrases: stored?.quickPhrases ?? [],
389
446
  quickPhrasesEnabled: stored?.quickPhrasesEnabled ?? true,
390
447
  reviewPrompt: stored?.reviewPrompt ?? "",
@@ -419,6 +476,7 @@ export class ClientStateStore {
419
476
  editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
420
477
  questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
421
478
  goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
479
+ parallelReminderEnabled: settings.parallelReminderEnabled ?? cur.parallelReminderEnabled ?? true,
422
480
  thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
423
481
  devNoCache: settings.devNoCache ?? cur.devNoCache,
424
482
  autoReload: settings.autoReload ?? cur.autoReload,
@@ -428,8 +486,12 @@ export class ClientStateStore {
428
486
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
429
487
  subagentDefaultModel: settings.subagentDefaultModel ?? cur.subagentDefaultModel ?? null,
430
488
  retryMaxAttempts: normalizeRetryMaxAttempts(settings.retryMaxAttempts ?? cur.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS),
489
+ softCapTokens: normalizeSoftCapTokens(settings.softCapTokens ?? cur.softCapTokens ?? 0),
490
+ softCapByModel: normalizeSoftCapByModel(settings.softCapByModel ?? cur.softCapByModel ?? {}),
431
491
  visionBridgePromptMode: settings.visionBridgePromptMode ?? cur.visionBridgePromptMode ?? "append",
432
492
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
493
+ scmCommitMsgPromptMode: settings.scmCommitMsgPromptMode ?? cur.scmCommitMsgPromptMode ?? "append",
494
+ scmCommitMsgPrompt: settings.scmCommitMsgPrompt ?? cur.scmCommitMsgPrompt ?? "",
433
495
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
434
496
  reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
435
497
  disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
@@ -450,6 +512,9 @@ export class ClientStateStore {
450
512
  reviewDisabledSkills: p.reviewDisabledSkills ?? [],
451
513
  // Older presets predate the configurable retry count.
452
514
  retryMaxAttempts: normalizeRetryMaxAttempts(p.retryMaxAttempts),
515
+ // Older presets predate the compaction soft cap (issue #229).
516
+ softCapTokens: normalizeSoftCapTokens(p.softCapTokens),
517
+ softCapByModel: normalizeSoftCapByModel(p.softCapByModel),
453
518
  }));
454
519
  }
455
520
  /** Persist the named settings presets — global shared config. */
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Compaction transcript markers + transcript repair (issue #235).
3
+ *
4
+ * 背景:pi-web-ui 在 compaction_start 时往会话 JSONL 尾追加一行自有的 custom
5
+ * 标记(crash-safe 的"正在压缩"记录),compaction_end 时原地改写为完成标记。
6
+ * 0.90.x 用硬编码 id(pi-web-ui-compaction-pending / -done),多次压缩后文件
7
+ * 里出现多个同名 entry;SDK 的 byId 索引是 last-wins,leaf 又是文件最后一行,
8
+ * 若标记恰为末行则新消息的 parentId 记成共享 id,下次 open 回溯 parent 链成环,
9
+ * getBranch 死循环 push 直到 V8 抛 RangeError: Invalid array length,整个会话
10
+ * 打不开("Failed to initialize session")。
11
+ *
12
+ * 本模块:
13
+ * - makeCompactionMarkerId:每次压缩唯一 id,根除新坏文件;
14
+ * - repairSessionTranscript:纯函数,移除残留 pending(其子节点旁路到 pending
15
+ * 的 parent,marker 对 LLM 上下文零贡献)+ 重复 id 改名 + 重复 id 引用改指
16
+ * 最近的前序同名 + 环路截断。只改坏行,其余字节不动;
17
+ * - repairSessionFile:落盘版,改前写 <原名>.bak(已存在则保留最早那份);
18
+ * - looksLikeChainCorruption:识别链损坏报错,供 open 重试分支用。
19
+ */
20
+ import { randomUUID } from "node:crypto";
21
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
+ export const COMPACTION_PENDING_TYPE = "pi-web-ui/compaction-pending";
23
+ export const COMPACTION_DONE_TYPE = "pi-web-ui/compaction-done";
24
+ /** 0.90.x 写死的 id——repair 用来识别存量坏标记。 */
25
+ const LEGACY_PENDING_ID = "pi-web-ui-compaction-pending";
26
+ const LEGACY_DONE_ID = "pi-web-ui-compaction-done";
27
+ /** 每次压缩唯一 id:Date.now 保证先后可辨,random 保证同毫秒不撞。 */
28
+ export function makeCompactionMarkerId(kind) {
29
+ return `pi-web-ui-compaction-${kind}-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
30
+ }
31
+ function markerKindOf(entry) {
32
+ if (typeof entry !== "object" || entry === null)
33
+ return null;
34
+ const e = entry;
35
+ if (e.type !== "custom" || typeof e.id !== "string")
36
+ return null;
37
+ if (e.customType === COMPACTION_PENDING_TYPE)
38
+ return "pending";
39
+ if (e.customType === COMPACTION_DONE_TYPE)
40
+ return "done";
41
+ // 兜底:customType 丢失的手工改坏文件,按 id 前缀认(SDK 自生成的 id 不带此前缀)。
42
+ if (e.id === LEGACY_PENDING_ID || e.id.startsWith(`${LEGACY_PENDING_ID}-`))
43
+ return "pending";
44
+ if (e.id === LEGACY_DONE_ID || e.id.startsWith(`${LEGACY_DONE_ID}-`))
45
+ return "done";
46
+ return null;
47
+ }
48
+ function parentIdOf(entry) {
49
+ const p = entry.parentId;
50
+ return typeof p === "string" ? p : null;
51
+ }
52
+ /**
53
+ * 修复会话转录文本。健康文件原样返回(changed=false,text === raw)。
54
+ * 有坏必修:pending 残留→删(子节点旁路);重复 id→首个保留、其余改名,
55
+ * 引用改指最近前序同名;残余环→截断。调用方负责落盘+备份(见 repairSessionFile)。
56
+ */
57
+ export function repairSessionTranscript(raw) {
58
+ const result = {
59
+ text: raw,
60
+ changed: false,
61
+ removedPending: 0,
62
+ renamedIds: 0,
63
+ rewiredParents: 0,
64
+ cyclesBroken: 0,
65
+ interrupted: false,
66
+ };
67
+ const lines = raw.split("\n");
68
+ const slots = lines.map((line) => {
69
+ if (!line.trim())
70
+ return { raw: line, entry: null, removed: false, dirty: false, finalId: null };
71
+ try {
72
+ const parsed = JSON.parse(line);
73
+ if (typeof parsed === "object" && parsed !== null) {
74
+ const id = parsed.id;
75
+ return {
76
+ raw: line,
77
+ entry: parsed,
78
+ removed: false,
79
+ dirty: false,
80
+ finalId: typeof id === "string" ? id : null,
81
+ };
82
+ }
83
+ }
84
+ catch {
85
+ // 脏行:SDK 加载时同样跳过,这里原样保留。
86
+ }
87
+ return { raw: line, entry: null, removed: false, dirty: false, finalId: null };
88
+ });
89
+ // 第 1 步:移除残留 pending,记录 id→parent 供子节点旁路。
90
+ const pendingParent = new Map();
91
+ for (const s of slots) {
92
+ if (!s.entry || markerKindOf(s.entry) !== "pending")
93
+ continue;
94
+ const id = typeof s.entry.id === "string" ? s.entry.id : null;
95
+ if (!id)
96
+ continue;
97
+ s.removed = true;
98
+ result.removedPending += 1;
99
+ if (!pendingParent.has(id))
100
+ pendingParent.set(id, parentIdOf(s.entry));
101
+ }
102
+ if (pendingParent.size > 0) {
103
+ const resolveBypass = (id) => {
104
+ // pending 套 pending 理论上不存在,visited 防万一。
105
+ const seen = new Set();
106
+ let cur = id;
107
+ while (cur !== null && pendingParent.has(cur) && !seen.has(cur)) {
108
+ seen.add(cur);
109
+ cur = pendingParent.get(cur) ?? null;
110
+ }
111
+ return cur;
112
+ };
113
+ for (const s of slots) {
114
+ if (s.removed || !s.entry)
115
+ continue;
116
+ const p = parentIdOf(s.entry);
117
+ if (p === null || !pendingParent.has(p))
118
+ continue;
119
+ const next = resolveBypass(p);
120
+ if (next === p)
121
+ continue;
122
+ if (next === null) {
123
+ // pending 本就没有 parent(首行标记)——子节点提升为根。
124
+ s.entry.parentId = null;
125
+ }
126
+ else {
127
+ s.entry.parentId = next;
128
+ }
129
+ s.dirty = true;
130
+ result.rewiredParents += 1;
131
+ }
132
+ }
133
+ // 第 2 步:重复 id 改名(首个保留),引用改指"最近的前序同名"。
134
+ // 先算每行的最终 id,再统一改引用,避免先改后认错。
135
+ const live = slots.filter((s) => !s.removed && s.entry && s.finalId !== null);
136
+ const seenIds = new Set();
137
+ const takenIds = new Set();
138
+ for (const s of live)
139
+ takenIds.add(s.finalId);
140
+ const dupSeq = new Map();
141
+ for (const s of live) {
142
+ const id = s.finalId;
143
+ if (!seenIds.has(id)) {
144
+ seenIds.add(id);
145
+ continue;
146
+ }
147
+ let k = (dupSeq.get(id) ?? 0) + 1;
148
+ let candidate = `${id}--dup-${k}`;
149
+ while (takenIds.has(candidate)) {
150
+ k += 1;
151
+ candidate = `${id}--dup-${k}`;
152
+ }
153
+ dupSeq.set(id, k);
154
+ s.finalId = candidate;
155
+ takenIds.add(candidate);
156
+ result.renamedIds += 1;
157
+ }
158
+ const dupIds = new Set();
159
+ {
160
+ const counts = new Map();
161
+ for (const s of live) {
162
+ const orig = typeof s.entry?.id === "string" ? s.entry.id : null;
163
+ if (orig)
164
+ counts.set(orig, (counts.get(orig) ?? 0) + 1);
165
+ }
166
+ for (const [id, c] of counts)
167
+ if (c > 1)
168
+ dupIds.add(id);
169
+ }
170
+ if (dupIds.size > 0) {
171
+ // origId → 按行序排好的 (行号, 最终id)
172
+ const occ = new Map();
173
+ live.forEach((s, i) => {
174
+ const orig = typeof s.entry?.id === "string" ? s.entry.id : null;
175
+ if (!orig || !dupIds.has(orig))
176
+ return;
177
+ let arr = occ.get(orig);
178
+ if (!arr) {
179
+ arr = [];
180
+ occ.set(orig, arr);
181
+ }
182
+ arr.push({ idx: i, finalId: s.finalId });
183
+ });
184
+ live.forEach((s, i) => {
185
+ if (!s.entry)
186
+ return;
187
+ const p = parentIdOf(s.entry);
188
+ if (p === null || !dupIds.has(p))
189
+ return;
190
+ const arr = occ.get(p);
191
+ let target = null;
192
+ for (const o of arr) {
193
+ if (o.idx < i)
194
+ target = o.finalId;
195
+ else
196
+ break;
197
+ }
198
+ // 引用落在全部同名之前(正常追加不可能,手改文件才有)——保持悬空,
199
+ // SDK 按 orphan 截断,安全降级。
200
+ if (target === null || target === p)
201
+ return;
202
+ s.entry.parentId = target;
203
+ s.dirty = true;
204
+ result.rewiredParents += 1;
205
+ });
206
+ // 应用改名。
207
+ for (const s of live) {
208
+ if (!s.entry || s.finalId === null || s.entry.id === s.finalId)
209
+ continue;
210
+ s.entry.id = s.finalId;
211
+ s.dirty = true;
212
+ }
213
+ }
214
+ // 第 3 步:残余环截断(纵深防御——前两步之后正常已无环)。
215
+ // byId 语义与 SDK 一致:同名取其一(此时已无重复)。
216
+ if (live.length > 0) {
217
+ const byId = new Map();
218
+ for (const s of live)
219
+ if (s.finalId !== null)
220
+ byId.set(s.finalId, s);
221
+ for (const s of live) {
222
+ if (!s.entry)
223
+ continue;
224
+ const seen = new Set([s.finalId]);
225
+ let prev = s;
226
+ let cur = parentIdOf(s.entry);
227
+ while (cur !== null) {
228
+ if (seen.has(cur)) {
229
+ prev.entry.parentId = null;
230
+ prev.dirty = true;
231
+ result.cyclesBroken += 1;
232
+ break;
233
+ }
234
+ seen.add(cur);
235
+ const next = byId.get(cur);
236
+ if (!next?.entry)
237
+ break;
238
+ prev = next;
239
+ cur = parentIdOf(next.entry);
240
+ }
241
+ }
242
+ }
243
+ const anyChange = result.removedPending > 0 || result.renamedIds > 0 || result.rewiredParents > 0 || result.cyclesBroken > 0;
244
+ if (!anyChange)
245
+ return result;
246
+ result.changed = true;
247
+ result.interrupted = result.removedPending > 0;
248
+ const out = [];
249
+ for (const s of slots) {
250
+ if (s.removed)
251
+ continue;
252
+ out.push(s.dirty && s.entry ? JSON.stringify(s.entry) : s.raw);
253
+ }
254
+ result.text = out.join("\n");
255
+ return result;
256
+ }
257
+ /**
258
+ * 落盘版修复:健康文件只读不写;坏文件先写 <原名>.bak(已存在则保留最早那份,
259
+ * 不覆盖),再写回修复文本。读失败/写失败返回 null(调用方按原错误继续抛)。
260
+ */
261
+ export function repairSessionFile(filePath) {
262
+ let raw;
263
+ try {
264
+ raw = readFileSync(filePath, "utf8");
265
+ }
266
+ catch {
267
+ return null;
268
+ }
269
+ const r = repairSessionTranscript(raw);
270
+ if (!r.changed)
271
+ return { ...r, file: filePath, backup: null };
272
+ try {
273
+ const bak = `${filePath}.bak`;
274
+ if (!existsSync(bak))
275
+ writeFileSync(bak, raw, "utf8");
276
+ writeFileSync(filePath, r.text, "utf8");
277
+ return { ...r, file: filePath, backup: bak };
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }
283
+ /** 链损坏的典型报错(SDK getBranch 死循环 → V8 RangeError)。 */
284
+ export function looksLikeChainCorruption(err) {
285
+ const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
286
+ return /invalid array length|maximum call stack|too much recursion/i.test(msg);
287
+ }
@@ -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}」`);
@@ -2590,8 +2593,12 @@ export class DshClientSession {
2590
2593
  editSoftEnabled: this.settings.editSoftEnabled,
2591
2594
  // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2592
2595
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2596
+ // DSH 另有运行时:压缩软上限是 pi 引擎概念,这里保持关闭保协议完整。
2597
+ softCapTokens: 0,
2598
+ softCapByModel: {},
2593
2599
  questionnaireEnabled: this.settings.questionnaireEnabled,
2594
2600
  goalModeEnabled: this.settings.goalModeEnabled,
2601
+ parallelReminderEnabled: this.settings.parallelReminderEnabled,
2595
2602
  thinkingWrap: this.settings.thinkingWrap,
2596
2603
  toolsWrap: this.settings.toolsWrap,
2597
2604
  // DSH 无 skill 全文注入概念,给空保协议完整。
@@ -2600,6 +2607,9 @@ export class DshClientSession {
2600
2607
  visionBridgeModel: null,
2601
2608
  visionBridgePromptMode: "append",
2602
2609
  visionBridgePrompt: "",
2610
+ // DSH 无「AI 生成提交信息」(scm_commitmsg 分发处直接报错),保协议完整。
2611
+ scmCommitMsgPromptMode: "append",
2612
+ scmCommitMsgPrompt: "",
2603
2613
  reviewPrompt: this.settings.reviewPrompt,
2604
2614
  reviewDisabledSkills: [],
2605
2615
  disabledPlugins: this.settings.disabledPlugins,
@@ -2611,6 +2621,7 @@ export class DshClientSession {
2611
2621
  // DSH 引擎不接标准 pi 的 customTool 工具 schema(走 goal-rpc),此处给空。
2612
2622
  toolsSchema: "",
2613
2623
  visionBridgeDefaultPrompt: "",
2624
+ scmCommitMsgDefaultPrompt: "",
2614
2625
  visionModels: [],
2615
2626
  skills: this.skillsCache,
2616
2627
  reviewSkills: [],
@@ -2652,6 +2663,8 @@ export class DshClientSession {
2652
2663
  this.settings.questionnaireEnabled = partial.questionnaireEnabled;
2653
2664
  if (partial.goalModeEnabled !== undefined)
2654
2665
  this.settings.goalModeEnabled = partial.goalModeEnabled;
2666
+ if (partial.parallelReminderEnabled !== undefined)
2667
+ this.settings.parallelReminderEnabled = partial.parallelReminderEnabled;
2655
2668
  if (partial.thinkingWrap !== undefined)
2656
2669
  this.settings.thinkingWrap = partial.thinkingWrap;
2657
2670
  if (partial.toolsWrap !== undefined)
@@ -2683,8 +2696,12 @@ export class DshClientSession {
2683
2696
  editSoftEnabled: this.settings.editSoftEnabled,
2684
2697
  // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2685
2698
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2699
+ // DSH 另有运行时:压缩软上限是 pi 引擎概念,这里保持关闭保协议完整。
2700
+ softCapTokens: 0,
2701
+ softCapByModel: {},
2686
2702
  questionnaireEnabled: this.settings.questionnaireEnabled,
2687
2703
  goalModeEnabled: this.settings.goalModeEnabled,
2704
+ parallelReminderEnabled: this.settings.parallelReminderEnabled,
2688
2705
  thinkingWrap: this.settings.thinkingWrap,
2689
2706
  toolsWrap: this.settings.toolsWrap,
2690
2707
  disabledPlugins: this.settings.disabledPlugins,
@@ -3004,6 +3021,9 @@ export class DshClientSession {
3004
3021
  // DSH 无 skill 全文注入概念,给空保预设类型完整。
3005
3022
  skillsFullText: [],
3006
3023
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
3024
+ // DSH 另有运行时:压缩软上限是 pi 引擎概念,预设里保持关闭。
3025
+ softCapTokens: 0,
3026
+ softCapByModel: {},
3007
3027
  visionBridgePromptMode: "append",
3008
3028
  visionBridgePrompt: "",
3009
3029
  reviewPrompt: this.settings.reviewPrompt,
@@ -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
+ }