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.
- package/CHANGELOG.md +48 -1
- package/dist/server/agent-service.js +763 -61
- package/dist/server/client-state.js +70 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +21 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +120 -30
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugins.js +47 -8
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/schedule-agent-tool.js +23 -5
- package/dist/server/scheduler-tasks.js +60 -1
- package/dist/server/scm-commitmsg.js +140 -0
- package/dist/server/scm.js +42 -0
- package/dist/server/settings-service.js +40 -0
- package/dist/server/skill-tool.js +120 -0
- package/dist/server/soft-cap.js +68 -0
- package/dist/server/subagent-templates.js +6 -0
- package/dist/server/subagents.js +157 -16
- package/dist/server/tool-manager.js +7 -1
- package/package.json +2 -1
- package/plugins/catalog.json +10 -0
- package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-GtDQ1h85.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- package/web/dist/assets/index-CdsKltOS.css +1 -0
- package/web/dist/assets/index-OoULNF70.js +363 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJIV4A-t.js +0 -355
- 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
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|