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,116 @@
1
+ // ---------------------------------------------------------------------------
2
+ // claim-files-tool.ts — 文件认领(claim_files):并行冲突的「事前打招呼」
3
+ // ---------------------------------------------------------------------------
4
+ // 触碰集是事后信息(写完才知道),claim 是事前意图("我要改这几个文件")。
5
+ // advisory 语义:认领只出现在提醒/files/list 里升级措辞,不拦 edit/bash ——
6
+ // 强制锁会死锁(见 claim-store.ts 文件头)。先到先得;自己的可刷新,
7
+ // 别人的动不了;过期自动释放 + 发 prompt 心跳续期 + 对话关闭释放。
8
+ //
9
+ // 常驻工具、不进 AGENT_TOOL_CATALOG(例外,理由见下):目录里的每个工具都
10
+ // 必须在设置页有行(settings-tool-rows.test.ts 强制),而 web/src 正被并行
11
+ // 任务占用。认领是纯 advisory(关掉只会少提醒),常驻默认开可接受;目录项 +
12
+ // 设置行等 web/src 空出来后补(follow-up)。DSH 引擎无 customTool 注册面,
13
+ // 只有 pi 引擎对话能认领(提醒是服务端算的,DSH 照样能看到别人的认领)。
14
+ // ---------------------------------------------------------------------------
15
+ import { defineTool } from "@earendil-works/pi-coding-agent";
16
+ import { Type } from "typebox";
17
+ import { bilingual, pick } from "./i18n.js";
18
+ import { CLAIM_TTL_MS, resolveClaimPath } from "./claim-store.js";
19
+ /** 工具名(改名需同步改 agent-service 注册处注释与单测)。 */
20
+ export const CLAIM_FILES_TOOL_NAME = "claim_files";
21
+ function fmtTtl(ms) {
22
+ const m = Math.round(ms / 60000);
23
+ return m >= 60 ? `${Math.floor(m / 60)}h${m % 60 ? `${m % 60}m` : ""}` : `${m}m`;
24
+ }
25
+ export function makeClaimFilesTool(host, lang) {
26
+ const getLang = lang ?? (() => "en");
27
+ const text = (t, details = {}) => ({
28
+ content: [{ type: "text", text: t }],
29
+ details,
30
+ });
31
+ return defineTool({
32
+ name: CLAIM_FILES_TOOL_NAME,
33
+ label: "Claim files to avoid parallel conflicts",
34
+ description: bilingual("Declare which files you are about to edit so parallel runs in the same project can steer clear (advisory only — it never blocks edits). " +
35
+ "action=claim reserves paths for THIS conversation (first wins; already claimed by others is reported, not overridden); action=release frees yours (no paths = all yours); action=list shows this project's claim table. " +
36
+ "Claims expire after ~30 min idle (each of your prompts refreshes them) and are released when the conversation closes. " +
37
+ "Paths must stay inside the project directory. Only pi-engine conversations can claim (no customTool surface on DSH).", "声明你要改哪些文件,让同项目的并行对话绕行(纯建议,不拦任何编辑)。" +
38
+ "action=claim 把路径记到本对话名下(先到先得;别人已认领的会告诉你,不抢占);action=release 放掉你自己的(不给 paths = 全放);action=list 看本项目的认领表。" +
39
+ "认领闲置约 30 分钟过期(你每次发 prompt 自动续期),对话关闭时释放。" +
40
+ "路径必须在项目目录内。只有 pi 引擎对话能认领(DSH 无 customTool 注册面)。"),
41
+ promptSnippet: "claim files you are about to edit (advisory, first-wins) so parallel runs steer clear",
42
+ parameters: Type.Object({
43
+ action: Type.Optional(Type.String({ description: 'claim = reserve paths; release = free yours; list = show table. Default "list".' })),
44
+ paths: Type.Optional(Type.Array(Type.String(), { description: "claim/release only: file paths (inside the project)." })),
45
+ note: Type.Optional(Type.String({ description: "claim only: why you need them (shown to others, ≤200 chars)." })),
46
+ ttlMinutes: Type.Optional(Type.Number({ description: "claim only: expiry in minutes (1-480, default 30; refreshed by your prompts)." })),
47
+ }),
48
+ execute: async (_id, p, _signal, _onUpdate, _ctx) => {
49
+ const store = host.store();
50
+ if (!store) {
51
+ return text(pick(getLang(), `认领表暂不可用(服务端未接线),先按无认领处理。`, `Claim store unavailable (not wired); proceed as if nothing is claimed.`, "claimfiles.no.store"));
52
+ }
53
+ const cwd = host.cwd();
54
+ const self = host.self();
55
+ const action = (p.action ?? "list").trim().toLowerCase();
56
+ if (action === "list") {
57
+ const rows = store.list(cwd);
58
+ const L = getLang();
59
+ if (rows.length === 0) {
60
+ return text(pick(L, `本项目暂无认领(parallel reminder 的触碰集照常工作)。`, `No claims in this project (parallel touch reminders still apply).`, "claimfiles.list.empty"), { cwd, total: 0, claims: [] });
61
+ }
62
+ const lines = rows.map((c) => `- ${c.path} · 「${c.ownerTitle}」${c.note ? ` · ${c.note}` : ""} · ${pick(L, `剩约 ${fmtTtl(Math.max(0, c.expiresAt - Date.now()))}`, `~${fmtTtl(Math.max(0, c.expiresAt - Date.now()))} left`, "claimfiles.list.ttl", { ttl: fmtTtl(Math.max(0, c.expiresAt - Date.now())) })}`);
63
+ return text(`${pick(L, `本项目认领表(${rows.length} 条,先到先得,只作绕行参考):`, `Claim table for this project (${rows.length}, first-wins, advisory):`, "claimfiles.list.head", { total: rows.length })}\n${lines.join("\n")}`, {
64
+ cwd,
65
+ total: rows.length,
66
+ claims: rows.map((c) => ({ path: c.path, ownerTitle: c.ownerTitle, note: c.note })),
67
+ });
68
+ }
69
+ if (action === "claim" || action === "release") {
70
+ const rawPaths = Array.isArray(p.paths) ? p.paths : [];
71
+ if (action === "release" && rawPaths.length === 0) {
72
+ const n = store.release(cwd, self.convId);
73
+ return text(pick(getLang(), n > 0 ? `已释放你名下 ${n} 条认领。` : `你名下没有认领,无事可做。`, n > 0 ? `Released ${n} of your claim(s).` : `You hold no claims; nothing to do.`, "claimfiles.release.all", { n }), { cwd, released: n });
74
+ }
75
+ if (rawPaths.length === 0) {
76
+ return text(pick(getLang(), `action=${action} 需要 paths(release 不给 paths = 全放自己名下的)。`, `action=${action} needs paths (release without paths = free all yours).`, "claimfiles.bad.paths", { action }));
77
+ }
78
+ const bad = rawPaths.filter((s) => !resolveClaimPath(s, cwd));
79
+ if (bad.length > 0) {
80
+ return text(pick(getLang(), `这些路径不在项目目录内,认领只接受项目内路径:${bad.slice(0, 3).join("、")}${bad.length > 3 ? `(等 ${bad.length} 个)` : ""}。`, `These paths escape the project directory (claims are project-local): ${bad.slice(0, 3).join(", ")}${bad.length > 3 ? ` (+${bad.length - 3})` : ""}.`, "claimfiles.bad.outside", { paths: bad.slice(0, 3).join(", ") }));
81
+ }
82
+ if (action === "claim") {
83
+ const ttlMs = typeof p.ttlMinutes === "number" && Number.isFinite(p.ttlMinutes)
84
+ ? Math.floor(p.ttlMinutes * 60000)
85
+ : CLAIM_TTL_MS;
86
+ const { claimed, conflicts } = store.claim(cwd, { convId: self.convId, title: self.title }, rawPaths.map((s) => ({
87
+ path: s,
88
+ ...(typeof p.note === "string" && p.note.trim() ? { note: p.note } : {}),
89
+ ttlMs,
90
+ })));
91
+ const L = getLang();
92
+ const okLine = claimed.length > 0
93
+ ? pick(L, `已认领 ${claimed.length} 个(${claimed.map((c) => c.path).join("、")}),约 ${fmtTtl(ttlMs)} 后过期(你发 prompt 自动续)。`, `Claimed ${claimed.length} (${claimed.map((c) => c.path).join(", ")}), expiring in ~${fmtTtl(ttlMs)} (refreshed by your prompts).`, "claimfiles.claim.ok", { n: claimed.length })
94
+ : "";
95
+ const clashLine = conflicts.length > 0
96
+ ? pick(L, `先到先得,以下已被别人认领(没抢占):${conflicts.map((c) => `${c.path}(「${c.claim.ownerTitle}」${c.claim.note ? `:${c.claim.note}` : ""})`).join(";")} —— 绕行,或问用户。`, `First-wins; already claimed by others (not overridden): ${conflicts.map((c) => `${c.path} ("${c.claim.ownerTitle}"${c.claim.note ? `: ${c.claim.note}` : ""})`).join("; ")} — steer clear or ask the user.`, "claimfiles.claim.conflict", { n: conflicts.length })
97
+ : "";
98
+ return text([okLine, clashLine].filter(Boolean).join("\n") ||
99
+ pick(L, `无事可做。`, `Nothing to do.`, "claimfiles.claim.noop"), {
100
+ cwd,
101
+ claimed: claimed.map((c) => c.path),
102
+ conflicts: conflicts.map((c) => ({ path: c.path, ownerTitle: c.claim.ownerTitle })),
103
+ });
104
+ }
105
+ // release 指定 paths:只放自己的,别人的动不了(返回里如实说)。
106
+ const n = store.release(cwd, self.convId, rawPaths);
107
+ return text(pick(getLang(), n > 0
108
+ ? `已释放 ${n} 条你名下的认领(别人的动不了,也没动)。`
109
+ : `这些路径没有你名下的认领(可能是别人的,无权释放)。`, n > 0
110
+ ? `Released ${n} of your claim(s) (others' untouched, as they should be).`
111
+ : `None of these are yours (possibly someone else's — not yours to release).`, "claimfiles.release.paths", { n }), { cwd, released: n });
112
+ }
113
+ return text(pick(getLang(), `action 非法:${p.action}(只能是 claim、release 或 list)。`, `Invalid action: ${p.action} (must be "claim", "release" or "list").`, "claimfiles.bad.action", { "p.action": p.action }));
114
+ },
115
+ });
116
+ }
@@ -0,0 +1,301 @@
1
+ // ---------------------------------------------------------------------------
2
+ // claim-store.ts — 文件认领表(claim store)+ 触碰 sidecar
3
+ // ---------------------------------------------------------------------------
4
+ // 背景:触碰集是「事后诸葛亮」(写完才知道),并行冲突要的是「事前打招呼」。
5
+ // claim = 某对话声明「我要改这几个文件」,其他对话在提醒/files/status 里看到
6
+ // 并绕行。注意这只是 advisory(建议):提醒措辞升级,但不拦 edit/bash ——
7
+ // 强制锁会引入死锁与 stall(见设计说明), first-wins + 过期自动释放是唯一的
8
+ // 防腐机制(fail-open:丢认领只会少一条提醒,绝不卡死任何人)。
9
+ //
10
+ // 存放:
11
+ // - 认领表全局共享(跨浏览器标签页可见)→ AgentService 级单例,落盘
12
+ // <dataDir>/claims.json(SubagentTemplatesStore 同款 best-effort 持久化:
13
+ // 写坏/读坏绝不弄崩 server,坏文件直接丢弃重建)。
14
+ // - 触碰 sidecar 按会话落盘(<转录文件>.touches.json):压缩后旧消息被摘要
15
+ // 替代,sidecar 让 files/status 在压缩后仍有答案;只读小文件,不扫全量转录。
16
+ // inMemory 子代理没有转录文件 → 无 sidecar,走实时消息(老样子)。
17
+ //
18
+ // key 口径:认领按「项目 cwd」分表(默认不跨项目);路径一律存绝对路径
19
+ // (认领时按 conv cwd resolve),比对时触碰路径同样 resolve 后比较 —— 后缀
20
+ // 模糊匹配继续禁用(假阳性比漏报更糟,见 conversation-touches 文件头)。
21
+ // ---------------------------------------------------------------------------
22
+ import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
23
+ import { dirname, resolve, sep } from "node:path";
24
+ import { normalizeTouchPath, unionTouchLists } from "./conversation-touches.js";
25
+ /** 认领默认 TTL:30 分钟(每次发 prompt 心跳续期,对话关闭时释放)。 */
26
+ export const CLAIM_TTL_MS = 30 * 60 * 1000;
27
+ /** 认领 TTL 上下限(工具参数钳制用)。 */
28
+ export const CLAIM_TTL_MIN_MS = 60 * 1000;
29
+ export const CLAIM_TTL_MAX_MS = 8 * 60 * 60 * 1000;
30
+ /** sidecar 条目上限(防无界增长;超了按 lastTs 掐尾)。 */
31
+ export const SIDECAR_MAX_ENTRIES = 500;
32
+ /** claims.json 版本(以后改格式时迁移用)。 */
33
+ const CLAIMS_FILE_VERSION = 1;
34
+ /** cwd 分表键:绝对归一(大小写不归一,见 touches 局限 3)。 */
35
+ function cwdKey(cwd) {
36
+ try {
37
+ return resolve(cwd);
38
+ }
39
+ catch {
40
+ return cwd;
41
+ }
42
+ }
43
+ /** 认领路径合法性:必须落在 conv cwd 内(../ 逃逸拒绝,由工具转报错)。 */
44
+ export function resolveClaimPath(raw, cwd) {
45
+ if (typeof raw !== "string" || !raw.trim())
46
+ return undefined;
47
+ let abs;
48
+ try {
49
+ abs = resolve(cwd, normalizeTouchPath(raw));
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ const root = cwdKey(cwd);
55
+ if (abs !== root && !abs.startsWith(root + sep))
56
+ return undefined;
57
+ return abs;
58
+ }
59
+ function isValidClaim(c) {
60
+ if (!c || typeof c !== "object")
61
+ return false;
62
+ const o = c;
63
+ return (typeof o.path === "string" &&
64
+ !!o.path &&
65
+ typeof o.ownerConvId === "string" &&
66
+ !!o.ownerConvId &&
67
+ typeof o.ownerTitle === "string" &&
68
+ typeof o.claimedAt === "number" &&
69
+ typeof o.expiresAt === "number");
70
+ }
71
+ /** 项目级认领表(AgentService 级单例;I/O 全 best-effort)。 */
72
+ export class ClaimStore {
73
+ file;
74
+ tables = new Map();
75
+ constructor(file) {
76
+ this.file = file;
77
+ this.load();
78
+ }
79
+ /** 认领:先到先得。别人(含过期前)的 → conflicts;自己的 → 刷新 note/过期。 */
80
+ claim(cwd, owner, inputs, now = Date.now()) {
81
+ const key = cwdKey(cwd);
82
+ this.sweepTable(key, now);
83
+ const table = this.tables.get(key) ?? [];
84
+ const claimed = [];
85
+ const conflicts = [];
86
+ for (const input of inputs) {
87
+ const abs = resolveClaimPath(input.path, cwd);
88
+ if (!abs)
89
+ continue; // 非法路径调用方已拦,这里只跳过不抛错
90
+ const ttl = typeof input.ttlMs === "number" && Number.isFinite(input.ttlMs)
91
+ ? Math.min(CLAIM_TTL_MAX_MS, Math.max(CLAIM_TTL_MIN_MS, Math.floor(input.ttlMs)))
92
+ : CLAIM_TTL_MS;
93
+ const existing = table.find((c) => c.path === abs);
94
+ if (existing && existing.ownerConvId !== owner.convId) {
95
+ conflicts.push({ path: abs, claim: { ...existing } });
96
+ continue;
97
+ }
98
+ const row = {
99
+ path: abs,
100
+ ownerConvId: owner.convId,
101
+ ownerTitle: owner.title,
102
+ ...(typeof input.note === "string" && input.note.trim() ? { note: input.note.trim().slice(0, 200) } : {}),
103
+ claimedAt: existing?.claimedAt ?? now,
104
+ expiresAt: now + ttl,
105
+ };
106
+ if (existing)
107
+ Object.assign(existing, row);
108
+ else
109
+ table.push(row);
110
+ claimed.push({ ...row });
111
+ }
112
+ this.tables.set(key, table);
113
+ this.save();
114
+ return { claimed, conflicts };
115
+ }
116
+ /** 释放:只放自己的(别人的动不了);paths 空 = 全放。返回释放条数。 */
117
+ release(cwd, ownerConvId, paths, now = Date.now()) {
118
+ const key = cwdKey(cwd);
119
+ this.sweepTable(key, now);
120
+ const table = this.tables.get(key) ?? [];
121
+ let drop = null;
122
+ if (Array.isArray(paths) && paths.length > 0) {
123
+ drop = new Set();
124
+ for (const p of paths) {
125
+ const abs = resolveClaimPath(p, cwd);
126
+ if (abs)
127
+ drop.add(abs);
128
+ }
129
+ }
130
+ const before = table.length;
131
+ this.tables.set(key, table.filter((c) => c.ownerConvId !== ownerConvId || (drop !== null && !drop.has(c.path))));
132
+ const n = before - (this.tables.get(key)?.length ?? 0);
133
+ if (n > 0)
134
+ this.save();
135
+ return n;
136
+ }
137
+ /** 按 owner 全放(对话关闭/释放时调)。返回释放条数。 */
138
+ releaseByOwner(ownerConvId, now = Date.now()) {
139
+ let n = 0;
140
+ for (const [key, table] of this.tables) {
141
+ this.sweepTable(key, now);
142
+ const kept = (this.tables.get(key) ?? table).filter((c) => c.ownerConvId !== ownerConvId);
143
+ n += table.length - kept.length;
144
+ this.tables.set(key, kept);
145
+ }
146
+ if (n > 0)
147
+ this.save();
148
+ return n;
149
+ }
150
+ /** 心跳续期:该 owner 的全部认领延长 TTL(发 prompt 时调)。返回续期条数。 */
151
+ touch(ownerConvId, now = Date.now()) {
152
+ let n = 0;
153
+ for (const [key, table] of this.tables) {
154
+ this.sweepTable(key, now);
155
+ for (const c of this.tables.get(key) ?? table) {
156
+ if (c.ownerConvId === ownerConvId) {
157
+ c.expiresAt = now + CLAIM_TTL_MS;
158
+ n++;
159
+ }
160
+ }
161
+ }
162
+ if (n > 0)
163
+ this.save();
164
+ return n;
165
+ }
166
+ /** 读某项目认领表(已扫过期,返回副本,调用方随便改)。 */
167
+ list(cwd, now = Date.now()) {
168
+ const key = cwdKey(cwd);
169
+ this.sweepTable(key, now);
170
+ return (this.tables.get(key) ?? []).map((c) => ({ ...c }));
171
+ }
172
+ sweepTable(key, now) {
173
+ const table = this.tables.get(key);
174
+ if (!table || table.length === 0)
175
+ return;
176
+ const kept = table.filter((c) => c.expiresAt > now);
177
+ if (kept.length !== table.length) {
178
+ this.tables.set(key, kept);
179
+ this.save();
180
+ }
181
+ }
182
+ load() {
183
+ let raw = "";
184
+ try {
185
+ raw = readFileSync(this.file, "utf8");
186
+ }
187
+ catch {
188
+ return; // 文件不存在 = 空表
189
+ }
190
+ try {
191
+ const data = JSON.parse(raw);
192
+ if (!data || typeof data !== "object" || data.version !== CLAIMS_FILE_VERSION)
193
+ return;
194
+ if (!data.tables || typeof data.tables !== "object")
195
+ return;
196
+ for (const [key, rows] of Object.entries(data.tables)) {
197
+ if (!Array.isArray(rows))
198
+ continue;
199
+ const valid = rows.filter(isValidClaim).map((c) => ({
200
+ ...c,
201
+ note: typeof c.note === "string" ? c.note : undefined,
202
+ }));
203
+ if (valid.length > 0)
204
+ this.tables.set(key, valid);
205
+ }
206
+ }
207
+ catch {
208
+ // 坏文件丢弃(下次 save 覆盖重建),绝不抛错
209
+ this.tables.clear();
210
+ }
211
+ }
212
+ save() {
213
+ try {
214
+ mkdirSync(dirname(this.file), { recursive: true });
215
+ const tmp = `${this.file}.tmp`;
216
+ writeFileSync(tmp, JSON.stringify({ version: CLAIMS_FILE_VERSION, tables: Object.fromEntries(this.tables) }));
217
+ renameSync(tmp, this.file);
218
+ }
219
+ catch {
220
+ // 持久化故障绝不能弄崩 server(内存表照常用)
221
+ }
222
+ }
223
+ }
224
+ /** 触碰集里命中认领表的条目(双方都 resolve 成绝对路径后比较)。 */
225
+ export function matchClaims(touches, claims, cwd) {
226
+ if (!Array.isArray(touches) || !Array.isArray(claims) || touches.length === 0 || claims.length === 0)
227
+ return [];
228
+ const byPath = new Map(claims.map((c) => [c.path, c]));
229
+ const out = [];
230
+ for (const t of touches) {
231
+ let abs;
232
+ try {
233
+ abs = resolve(cwd, normalizeTouchPath(t.path));
234
+ }
235
+ catch {
236
+ continue;
237
+ }
238
+ const hit = byPath.get(abs);
239
+ if (hit)
240
+ out.push({ touch: t, claim: hit });
241
+ }
242
+ return out;
243
+ }
244
+ function sidecarPath(sessionFile) {
245
+ return `${sessionFile}.touches.json`;
246
+ }
247
+ /** 读触碰 sidecar(同步小文件;不存在/坏了 → undefined,调用方回落现算)。 */
248
+ export function readTouchSidecar(sessionFile) {
249
+ if (typeof sessionFile !== "string" || !sessionFile)
250
+ return undefined;
251
+ let raw = "";
252
+ try {
253
+ if (statSync(sidecarPath(sessionFile)).size > 1024 * 1024)
254
+ return undefined;
255
+ raw = readFileSync(sidecarPath(sessionFile), "utf8");
256
+ }
257
+ catch {
258
+ return undefined;
259
+ }
260
+ try {
261
+ const data = JSON.parse(raw);
262
+ if (!data || !Array.isArray(data.files))
263
+ return undefined;
264
+ const valid = data.files.filter((f) => !!f && typeof f === "object" && typeof f.path === "string");
265
+ return valid.map((f) => ({
266
+ path: f.path,
267
+ count: typeof f.count === "number" && Number.isFinite(f.count) ? f.count : 1,
268
+ lastTs: typeof f.lastTs === "number" && Number.isFinite(f.lastTs) ? f.lastTs : 0,
269
+ }));
270
+ }
271
+ catch {
272
+ return undefined;
273
+ }
274
+ }
275
+ /** 写触碰 sidecar(读-合并-写;空集不写;全 best-effort, never throw)。 */
276
+ export async function mergeTouchSidecar(sessionFile, fresh) {
277
+ if (typeof sessionFile !== "string" || !sessionFile)
278
+ return;
279
+ if (!Array.isArray(fresh) || fresh.length === 0)
280
+ return;
281
+ try {
282
+ const merged = unionTouchLists(readTouchSidecar(sessionFile) ?? [], fresh).slice(0, SIDECAR_MAX_ENTRIES);
283
+ const tmp = `${sidecarPath(sessionFile)}.tmp`;
284
+ writeFileSync(tmp, JSON.stringify({ version: 1, updatedAt: Date.now(), files: merged }));
285
+ renameSync(tmp, sidecarPath(sessionFile));
286
+ }
287
+ catch {
288
+ // sidecar 只是加速+防压缩丢失,写坏了下次重算
289
+ }
290
+ }
291
+ /** 删 sidecar(会话删除时顺带;不存在不报错)。 */
292
+ export function removeTouchSidecar(sessionFile) {
293
+ if (typeof sessionFile !== "string" || !sessionFile)
294
+ return;
295
+ try {
296
+ unlinkSync(sidecarPath(sessionFile));
297
+ }
298
+ catch {
299
+ // 不存在或删不掉都无所谓
300
+ }
301
+ }
@@ -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 开关)回落空数组。 */
@@ -367,6 +419,7 @@ export class ClientStateStore {
367
419
  : (stored?.terminalToolsEnabled ?? false),
368
420
  terminalBash: stored?.terminalBash ?? false,
369
421
  terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
422
+ readDirEnabled: stored?.readDirEnabled ?? true,
370
423
  editSoftEnabled: stored?.disabledAgentTools !== undefined
371
424
  ? deriveLegacy(legacyToDisabled(stored)).editSoftEnabled
372
425
  : (stored?.editSoftEnabled ?? false),
@@ -374,6 +427,7 @@ export class ClientStateStore {
374
427
  ? deriveLegacy(legacyToDisabled(stored)).questionnaireEnabled
375
428
  : (stored?.questionnaireEnabled ?? true),
376
429
  goalModeEnabled: stored?.goalModeEnabled ?? true,
430
+ parallelReminderEnabled: stored?.parallelReminderEnabled ?? true,
377
431
  thinkingWrap: stored?.thinkingWrap ?? false,
378
432
  devNoCache: stored?.devNoCache,
379
433
  autoReload: stored?.autoReload,
@@ -383,8 +437,12 @@ export class ClientStateStore {
383
437
  visionBridgeModel: stored?.visionBridgeModel ?? null,
384
438
  visionBridgePromptMode: stored?.visionBridgePromptMode === "replace" ? "replace" : "append",
385
439
  visionBridgePrompt: stored?.visionBridgePrompt ?? "",
440
+ scmCommitMsgPromptMode: stored?.scmCommitMsgPromptMode === "replace" ? "replace" : "append",
441
+ scmCommitMsgPrompt: stored?.scmCommitMsgPrompt ?? "",
386
442
  subagentDefaultModel: stored?.subagentDefaultModel ?? null,
387
443
  retryMaxAttempts: normalizeRetryMaxAttempts(stored?.retryMaxAttempts),
444
+ softCapTokens: normalizeSoftCapTokens(stored?.softCapTokens),
445
+ softCapByModel: normalizeSoftCapByModel(stored?.softCapByModel),
388
446
  quickPhrases: stored?.quickPhrases ?? [],
389
447
  quickPhrasesEnabled: stored?.quickPhrasesEnabled ?? true,
390
448
  reviewPrompt: stored?.reviewPrompt ?? "",
@@ -416,9 +474,11 @@ export class ClientStateStore {
416
474
  terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? false,
417
475
  terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
418
476
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
477
+ readDirEnabled: settings.readDirEnabled ?? cur.readDirEnabled ?? true,
419
478
  editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
420
479
  questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
421
480
  goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
481
+ parallelReminderEnabled: settings.parallelReminderEnabled ?? cur.parallelReminderEnabled ?? true,
422
482
  thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
423
483
  devNoCache: settings.devNoCache ?? cur.devNoCache,
424
484
  autoReload: settings.autoReload ?? cur.autoReload,
@@ -428,8 +488,12 @@ export class ClientStateStore {
428
488
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
429
489
  subagentDefaultModel: settings.subagentDefaultModel ?? cur.subagentDefaultModel ?? null,
430
490
  retryMaxAttempts: normalizeRetryMaxAttempts(settings.retryMaxAttempts ?? cur.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS),
491
+ softCapTokens: normalizeSoftCapTokens(settings.softCapTokens ?? cur.softCapTokens ?? 0),
492
+ softCapByModel: normalizeSoftCapByModel(settings.softCapByModel ?? cur.softCapByModel ?? {}),
431
493
  visionBridgePromptMode: settings.visionBridgePromptMode ?? cur.visionBridgePromptMode ?? "append",
432
494
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
495
+ scmCommitMsgPromptMode: settings.scmCommitMsgPromptMode ?? cur.scmCommitMsgPromptMode ?? "append",
496
+ scmCommitMsgPrompt: settings.scmCommitMsgPrompt ?? cur.scmCommitMsgPrompt ?? "",
433
497
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
434
498
  reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
435
499
  disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
@@ -450,6 +514,9 @@ export class ClientStateStore {
450
514
  reviewDisabledSkills: p.reviewDisabledSkills ?? [],
451
515
  // Older presets predate the configurable retry count.
452
516
  retryMaxAttempts: normalizeRetryMaxAttempts(p.retryMaxAttempts),
517
+ // Older presets predate the compaction soft cap (issue #229).
518
+ softCapTokens: normalizeSoftCapTokens(p.softCapTokens),
519
+ softCapByModel: normalizeSoftCapByModel(p.softCapByModel),
453
520
  }));
454
521
  }
455
522
  /** Persist the named settings presets — global shared config. */
@@ -567,6 +634,53 @@ export class ClientStateStore {
567
634
  delete all[clientId].projectModels;
568
635
  this.save();
569
636
  }
637
+ /** 全局默认模型(跨客户端共享,存 __settings__ 键)。缺省 = 未设置。 */
638
+ getDefaultModel() {
639
+ return this.load()[ClientStateStore.GLOBAL_SETTINGS_KEY]?.defaultModel;
640
+ }
641
+ /** 设置全局默认模型("provider/id")。 */
642
+ saveDefaultModel(modelId) {
643
+ const all = this.load();
644
+ const state = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
645
+ state.defaultModel = modelId;
646
+ this.save();
647
+ }
648
+ /** 清除全局默认模型(连带其 key 记忆)。 */
649
+ clearDefaultModel() {
650
+ const all = this.load();
651
+ const state = all[ClientStateStore.GLOBAL_SETTINGS_KEY];
652
+ if (!state)
653
+ return;
654
+ delete state.defaultModel;
655
+ delete state.defaultProviderKeys;
656
+ this.save();
657
+ }
658
+ /** 取全局默认的某 provider key。 */
659
+ getDefaultProviderKey(provider) {
660
+ return this.load()[ClientStateStore.GLOBAL_SETTINGS_KEY]?.defaultProviderKeys?.[provider];
661
+ }
662
+ /** 记全局默认的某 provider key(设全局默认模型时连带记)。 */
663
+ saveDefaultProviderKey(provider, keyName) {
664
+ const all = this.load();
665
+ const state = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
666
+ (state.defaultProviderKeys ??= {})[provider] = keyName;
667
+ this.save();
668
+ }
669
+ /** 全局默认 key 跟随删除:被删的 key 若是全局默认记的,指到接替者(无接替则删引用)。 */
670
+ repointDeletedKeyInDefault(provider, deletedKeyName, newActive) {
671
+ const all = this.load();
672
+ const map = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.defaultProviderKeys;
673
+ if (!map || map[provider] !== deletedKeyName)
674
+ return;
675
+ if (newActive)
676
+ map[provider] = newActive;
677
+ else {
678
+ delete map[provider];
679
+ if (Object.keys(map).length === 0)
680
+ delete all[ClientStateStore.GLOBAL_SETTINGS_KEY].defaultProviderKeys;
681
+ }
682
+ this.save();
683
+ }
570
684
  /** 全局「快捷短语已 seed」标记(非 per-clientId)。
571
685
  *
572
686
  * 为什么全局:clientId 存 sessionStorage(每标签页独立、关浏览器即失),按