pi-web-ui 0.96.1 → 0.98.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 +144 -2
- package/README.md +29 -3
- package/README.zh-CN.md +28 -3
- package/bin/pi-web-ui.mjs +75 -25
- package/dist/server/agent-service.js +1829 -262
- package/dist/server/approval-rules.js +5 -10
- package/dist/server/atomic-file.js +40 -0
- package/dist/server/attachments.js +22 -0
- package/dist/server/claim-store.js +5 -11
- package/dist/server/client-state.js +296 -11
- package/dist/server/compact-context-tool.js +3 -1
- package/dist/server/compacted-history.js +148 -0
- package/dist/server/composer-drafts.js +4 -6
- package/dist/server/control-socket.js +0 -40
- package/dist/server/delegate-mode.js +143 -0
- package/dist/server/delegate-task.js +10 -10
- package/dist/server/dsh/dsh-agent-service.js +66 -31
- package/dist/server/dsh/dsh-client.js +48 -10
- package/dist/server/dsh/dsh-serialize.js +12 -10
- package/dist/server/dsh/dsh-sessions.js +20 -6
- package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
- package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
- package/dist/server/ensure-bash.js +3 -5
- package/dist/server/eval-tool.js +11 -5
- package/dist/server/goal-review-gate.js +250 -0
- package/dist/server/goal-service.js +1272 -448
- package/dist/server/index.js +118 -16
- package/dist/server/launch-origin.js +0 -4
- package/dist/server/locales.js +3 -5
- package/dist/server/lsp-tool.js +123 -24
- package/dist/server/markers/builtins/notify.js +4 -2
- package/dist/server/markers/builtins/rename.js +0 -1
- package/dist/server/markers/builtins/todo.js +6 -28
- package/dist/server/markers/marker.js +0 -15
- package/dist/server/markers/registry.js +0 -3
- package/dist/server/markers/store.js +0 -3
- package/dist/server/mcp-bridge.js +13 -0
- package/dist/server/mcp-hot-reload.js +12 -5
- package/dist/server/model-admin.js +19 -18
- package/dist/server/model-enrich.js +0 -3
- package/dist/server/patch-tool.js +22 -6
- package/dist/server/patch-turn-end-boundary.js +77 -0
- package/dist/server/permission-preset.js +69 -0
- package/dist/server/plan-manager.js +150 -5
- package/dist/server/plan-mode.js +414 -0
- package/dist/server/plugin-api-catalog.js +2 -0
- package/dist/server/plugin-catalog.js +6 -6
- package/dist/server/plugin-dom.js +3 -4
- package/dist/server/plugin-facilities.js +39 -8
- package/dist/server/plugin-grants.js +5 -8
- package/dist/server/plugin-permissions.js +4 -6
- package/dist/server/plugin-schedule.js +4 -7
- package/dist/server/plugin-updater.js +151 -38
- package/dist/server/plugins.js +22 -5
- package/dist/server/prompt-composer.js +12 -3
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/resolve-global-sdk.js +8 -0
- package/dist/server/schedule-agent-tool.js +5 -1
- package/dist/server/scheduler-tasks.js +46 -27
- package/dist/server/sdk-origin.js +27 -0
- package/dist/server/serialize.js +33 -9
- package/dist/server/settings-service.js +18 -6
- package/dist/server/slash-commands.js +16 -0
- package/dist/server/subagent-templates.js +4 -10
- package/dist/server/subagents.js +379 -416
- package/dist/server/terminals.js +11 -6
- package/dist/server/text-quote.js +59 -0
- package/dist/server/text-sniff.js +15 -9
- package/dist/server/tool-manager.js +60 -7
- package/dist/server/tool-overrides.js +24 -0
- package/dist/server/update-check.js +80 -28
- package/dist/server/wait-subscription-scan.js +5 -1
- package/extensions/webui.ts +15 -1
- package/package.json +33 -21
- package/plugin-sdk/client-utils.mjs +92 -0
- package/plugin-sdk/index.d.ts +21 -0
- package/plugin-sdk/index.mjs +2 -0
- package/plugins/catalog.json +7 -0
- package/themes/zhupi-dark.css +5 -30
- package/themes/zhupi.css +5 -30
- package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-C88r3r3Q.js} +1 -1
- package/web/dist/assets/index-2jDtjHvW.css +1 -0
- package/web/dist/assets/index-BjdK7s1M.js +368 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -1
- package/web/public/sw.js +2 -1
- package/web/dist/assets/index-B7Zp23Vc.css +0 -1
- package/web/dist/assets/index-ByfoaDwp.js +0 -366
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
* 老用户删掉的内置规则不会在重启后复活;发版新增的内置规则可安全合并进老用户文件)。
|
|
30
30
|
* - 内存缓存 + 文件 mtime 感知,支持直接编辑 JSON 文件热生效。
|
|
31
31
|
*/
|
|
32
|
-
import { existsSync,
|
|
33
|
-
import {
|
|
32
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
33
|
+
import { resolve, sep } from "node:path";
|
|
34
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
34
35
|
/**
|
|
35
36
|
* 跨平台判定 target 路径是否严格位于 root 目录内部或就是 root 本身。
|
|
36
37
|
* 针对 Windows 盘符大小写不敏感及正反斜杠统一进行规范化,防止 .. 路径穿越逃逸。
|
|
@@ -438,10 +439,7 @@ export class ApprovalRulesStore {
|
|
|
438
439
|
}
|
|
439
440
|
saveSeeded(names) {
|
|
440
441
|
try {
|
|
441
|
-
|
|
442
|
-
const tmp = `${this.seededPath()}.${process.pid}.tmp`;
|
|
443
|
-
writeFileSync(tmp, JSON.stringify([...names].sort(), null, 2) + "\n");
|
|
444
|
-
renameSync(tmp, this.seededPath());
|
|
442
|
+
writeJsonAtomicSync(this.seededPath(), [...names].sort());
|
|
445
443
|
}
|
|
446
444
|
catch {
|
|
447
445
|
// best effort
|
|
@@ -492,10 +490,7 @@ export class ApprovalRulesStore {
|
|
|
492
490
|
}
|
|
493
491
|
persist() {
|
|
494
492
|
try {
|
|
495
|
-
|
|
496
|
-
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
497
|
-
writeFileSync(tmp, JSON.stringify(this.rules ?? [], null, 2) + "\n");
|
|
498
|
-
renameSync(tmp, this.filePath);
|
|
493
|
+
writeJsonAtomicSync(this.filePath, this.rules ?? []);
|
|
499
494
|
try {
|
|
500
495
|
this.lastMtime = statSync(this.filePath).mtimeMs;
|
|
501
496
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
/**
|
|
5
|
+
* 跨平台安全原子写文件:先写临时文件再 renameSync,进程崩溃不留半截文件。
|
|
6
|
+
* - tmp 文件位于目标文件同目录下(保证处于同物理卷,rename 为原子操作);
|
|
7
|
+
* - tmp 文件名携带 pid + 时间戳 + 随机串,防止同机多进程互踩;
|
|
8
|
+
* - Windows 下目标文件若被外部临时占用(杀软/文本编辑器锁),rename 失败后退回原地写兜底;
|
|
9
|
+
* - 确保无论成功失败,临时文件在 finally 中及时清理,杜绝残留 .tmp 垃圾文件。
|
|
10
|
+
*/
|
|
11
|
+
export function writeAtomicSync(file, data) {
|
|
12
|
+
const dir = dirname(file);
|
|
13
|
+
mkdirSync(dir, { recursive: true });
|
|
14
|
+
const suffix = `${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}.tmp`;
|
|
15
|
+
const tmp = `${file}.${suffix}`;
|
|
16
|
+
try {
|
|
17
|
+
writeFileSync(tmp, data);
|
|
18
|
+
try {
|
|
19
|
+
renameSync(tmp, file);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
writeFileSync(file, data);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
try {
|
|
27
|
+
rmSync(tmp, { force: true });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// ignore cleanup errors
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 原子写 JSON 辅助函数(默认 2 空格缩进 + 末尾换行)。
|
|
36
|
+
*/
|
|
37
|
+
export function writeJsonAtomicSync(file, data, indent = 2) {
|
|
38
|
+
const text = JSON.stringify(data, null, indent) + "\n";
|
|
39
|
+
writeAtomicSync(file, text);
|
|
40
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatTextQuote, readTextQuote } from "./text-quote.js";
|
|
1
2
|
import { sniffImageMime } from "./text-sniff.js";
|
|
2
3
|
import { saveUpload, uploadsRoot } from "./uploads.js";
|
|
3
4
|
import { isAbsoluteWirePath, wireToAbs } from "./files-service.js";
|
|
@@ -230,6 +231,27 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
230
231
|
}
|
|
231
232
|
}
|
|
232
233
|
for (const [idx, att] of attachments.entries()) {
|
|
234
|
+
if (att.mode === "quote") {
|
|
235
|
+
const quote = readTextQuote(att.quote);
|
|
236
|
+
if (!quote) {
|
|
237
|
+
ctx.emit({
|
|
238
|
+
type: "notice",
|
|
239
|
+
level: "warning",
|
|
240
|
+
text: "引用内容无效,请重新选择文字",
|
|
241
|
+
textEn: "Select the text again to add a quote.",
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
out.push({
|
|
246
|
+
message: {
|
|
247
|
+
customType: "file",
|
|
248
|
+
content: [{ type: "text", text: formatTextQuote(quote) }],
|
|
249
|
+
display: true,
|
|
250
|
+
details: { mode: "quote", quote },
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
233
255
|
// Quoted conversation (left-panel right-click / global-search quote):
|
|
234
256
|
// `path` is unused — the reference travels in conversationId (running
|
|
235
257
|
// conversation, incl. subagents) or sessionPath (history transcript).
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
// (认领时按 conv cwd resolve),比对时触碰路径同样 resolve 后比较 —— 后缀
|
|
20
20
|
// 模糊匹配继续禁用(假阳性比漏报更糟,见 conversation-touches 文件头)。
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
22
|
+
import { readFileSync, statSync, unlinkSync } from "node:fs";
|
|
23
|
+
import { resolve, sep } from "node:path";
|
|
24
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
24
25
|
import { normalizeTouchPath, unionTouchLists } from "./conversation-touches.js";
|
|
25
26
|
/** 认领默认 TTL:30 分钟(每次发 prompt 心跳续期,对话关闭时释放)。 */
|
|
26
27
|
export const CLAIM_TTL_MS = 30 * 60 * 1000;
|
|
@@ -211,11 +212,7 @@ export class ClaimStore {
|
|
|
211
212
|
}
|
|
212
213
|
save() {
|
|
213
214
|
try {
|
|
214
|
-
|
|
215
|
-
// tmp 带进程唯一后缀:同机多实例(多 worktree/多开)写同一文件不互踩
|
|
216
|
-
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
217
|
-
writeFileSync(tmp, JSON.stringify({ version: CLAIMS_FILE_VERSION, tables: Object.fromEntries(this.tables) }));
|
|
218
|
-
renameSync(tmp, this.file);
|
|
215
|
+
writeJsonAtomicSync(this.file, { version: CLAIMS_FILE_VERSION, tables: Object.fromEntries(this.tables) });
|
|
219
216
|
}
|
|
220
217
|
catch {
|
|
221
218
|
// 持久化故障绝不能弄崩 server(内存表照常用)
|
|
@@ -281,10 +278,7 @@ export async function mergeTouchSidecar(sessionFile, fresh) {
|
|
|
281
278
|
return;
|
|
282
279
|
try {
|
|
283
280
|
const merged = unionTouchLists(readTouchSidecar(sessionFile) ?? [], fresh).slice(0, SIDECAR_MAX_ENTRIES);
|
|
284
|
-
|
|
285
|
-
const tmp = `${sidecarPath(sessionFile)}.${process.pid}.tmp`;
|
|
286
|
-
writeFileSync(tmp, JSON.stringify({ version: 1, updatedAt: Date.now(), files: merged }));
|
|
287
|
-
renameSync(tmp, sidecarPath(sessionFile));
|
|
281
|
+
writeJsonAtomicSync(sidecarPath(sessionFile), { version: 1, updatedAt: Date.now(), files: merged });
|
|
288
282
|
}
|
|
289
283
|
catch {
|
|
290
284
|
// sidecar 只是加速+防压缩丢失,写坏了下次重算
|
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 从 agent-service.ts 抽出,行为保持不变。
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import * as fsPromises from "node:fs/promises";
|
|
10
11
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
12
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
11
13
|
import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
|
|
12
14
|
import { deriveLegacy, legacyToDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
|
|
15
|
+
import { UI_SLOTS } from "./plugins.js";
|
|
13
16
|
/** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
|
|
14
17
|
export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
|
|
15
18
|
/** 工具执行看门狗超时的默认值(默认 20 分钟;环境变量 PI_WEB_TOOL_TIMEOUT_MS 覆盖)。 */
|
|
@@ -168,6 +171,35 @@ export function normalizeUiLayout(v) {
|
|
|
168
171
|
}
|
|
169
172
|
// 顶栏按钮文字总开关:只收布尔值(缺席 = 显示,兼容老存档)。
|
|
170
173
|
const topbarText = typeof o.topbarText === "boolean" ? o.topbarText : undefined;
|
|
174
|
+
// 侧边图标停靠栏形态:只收布尔值(缺席 = 贴边槽位)。
|
|
175
|
+
const sideDockFloat = typeof o.sideDockFloat === "boolean" ? o.sideDockFloat : undefined;
|
|
176
|
+
// 用户自定义槽位/位置:只收合法 slot 字符串
|
|
177
|
+
let slots;
|
|
178
|
+
if (o.slots && typeof o.slots === "object" && !Array.isArray(o.slots)) {
|
|
179
|
+
slots = {};
|
|
180
|
+
for (const [k, val] of Object.entries(o.slots).slice(0, 200)) {
|
|
181
|
+
if (k.length > 0 && k.length <= 96 && typeof val === "string" && UI_SLOTS.has(val)) {
|
|
182
|
+
slots[k] = val;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (Object.keys(slots).length === 0)
|
|
186
|
+
slots = undefined;
|
|
187
|
+
}
|
|
188
|
+
// 品牌槽位同样折进 host:brand
|
|
189
|
+
if (slots && ("host:brand-logo" in slots || "host:brand-name" in slots)) {
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const [k, val] of Object.entries(slots)) {
|
|
192
|
+
if (k === "host:brand-logo" || k === "host:brand-name")
|
|
193
|
+
continue;
|
|
194
|
+
out[k] = val;
|
|
195
|
+
}
|
|
196
|
+
if (out[BRAND_NEW] === undefined) {
|
|
197
|
+
const picked = slots["host:brand-logo"] ?? slots["host:brand-name"];
|
|
198
|
+
if (picked !== undefined)
|
|
199
|
+
out[BRAND_NEW] = picked;
|
|
200
|
+
}
|
|
201
|
+
slots = Object.keys(out).length ? out : undefined;
|
|
202
|
+
}
|
|
171
203
|
return {
|
|
172
204
|
...(hidden ? { hidden } : {}),
|
|
173
205
|
...(shown ? { shown } : {}),
|
|
@@ -176,6 +208,8 @@ export function normalizeUiLayout(v) {
|
|
|
176
208
|
...(align ? { align } : {}),
|
|
177
209
|
...(labels ? { labels } : {}),
|
|
178
210
|
...(topbarText !== undefined ? { topbarText } : {}),
|
|
211
|
+
...(sideDockFloat !== undefined ? { sideDockFloat } : {}),
|
|
212
|
+
...(slots ? { slots } : {}),
|
|
179
213
|
};
|
|
180
214
|
}
|
|
181
215
|
/** 归一化技能名单:字符串数组原样过滤;其他(含旧 bool 开关)回落空数组。 */
|
|
@@ -249,7 +283,7 @@ export function normalizeWorkspaceRoots(v) {
|
|
|
249
283
|
if (!p || !isAbsolute(p))
|
|
250
284
|
continue;
|
|
251
285
|
const abs = resolve(p);
|
|
252
|
-
const key =
|
|
286
|
+
const key = normalizePathKey(abs);
|
|
253
287
|
if (seen.has(key))
|
|
254
288
|
continue;
|
|
255
289
|
seen.add(key);
|
|
@@ -269,6 +303,17 @@ export function normalizePathKey(p) {
|
|
|
269
303
|
return process.platform === "win32" ? p.toLowerCase() : p;
|
|
270
304
|
}
|
|
271
305
|
}
|
|
306
|
+
/** 死 clientId 状态的保留期(issue #441):距最近活跃超过该时长的 per-client 键在
|
|
307
|
+
* 加载/定期扫描时淘汰。取 30 天——clientId 存 sessionStorage,每个标签页每次会话
|
|
308
|
+
* 都生成新 id,远超 30 天才回来的标签页实际等同全新会话,误伤概率可忽略。 */
|
|
309
|
+
export const CLIENT_STATE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
310
|
+
/** per-client 键数上限(issue #441):活跃客户端数超过该值时按最近活跃保留前 50、
|
|
311
|
+
* 淘汰最旧的。兜底上限:完全无时间戳可推断的存量键不按年龄淘汰,靠它保证
|
|
312
|
+
* client-state.json 不随时间无界膨胀。 */
|
|
313
|
+
export const MAX_TRACKED_CLIENTS = 50;
|
|
314
|
+
/** 死键清理扫描的最小间隔:load() 是所有读写的高频入口,扫描按 1 小时节流,
|
|
315
|
+
* 首次加载必扫。 */
|
|
316
|
+
export const CLIENT_STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
272
317
|
/**
|
|
273
318
|
* Persists which workspace each browser client last used + which workspaces it
|
|
274
319
|
* has opened, so a server restart / page reload restores the same project and
|
|
@@ -278,6 +323,8 @@ export function normalizePathKey(p) {
|
|
|
278
323
|
export class ClientStateStore {
|
|
279
324
|
filePath;
|
|
280
325
|
cache = null;
|
|
326
|
+
/** 上次死键清理扫描的时间戳(epoch ms;0 = 尚未扫过,首次 load 必扫)。 */
|
|
327
|
+
lastSweepAt = 0;
|
|
281
328
|
constructor(filePath) {
|
|
282
329
|
this.filePath = filePath;
|
|
283
330
|
}
|
|
@@ -294,8 +341,10 @@ export class ClientStateStore {
|
|
|
294
341
|
return dirname(this.filePath);
|
|
295
342
|
}
|
|
296
343
|
load() {
|
|
297
|
-
if (this.cache)
|
|
344
|
+
if (this.cache) {
|
|
345
|
+
this.sweepStaleClientsIfNeeded();
|
|
298
346
|
return this.cache;
|
|
347
|
+
}
|
|
299
348
|
try {
|
|
300
349
|
const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
301
350
|
this.cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
@@ -320,20 +369,85 @@ export class ClientStateStore {
|
|
|
320
369
|
}
|
|
321
370
|
}
|
|
322
371
|
}
|
|
323
|
-
|
|
372
|
+
// 死键清理(issue #441):在墓碑迁移之后做——各 client 的墓碑已先并入全局键,
|
|
373
|
+
// 淘汰死 client 不会丢墓碑;合并一次 save 落盘(迁移和淘汰只发生一次时也只写一遍)。
|
|
374
|
+
this.lastSweepAt = Date.now();
|
|
375
|
+
const evicted = this.sweepDeadClients();
|
|
376
|
+
if (migrated || evicted > 0) {
|
|
324
377
|
this.save();
|
|
325
378
|
}
|
|
326
379
|
return this.cache;
|
|
327
380
|
}
|
|
381
|
+
/** 死键清理的节流入口:首次 load 必扫,之后按 CLIENT_STATE_SWEEP_INTERVAL_MS 节流。 */
|
|
382
|
+
sweepStaleClientsIfNeeded() {
|
|
383
|
+
if (Date.now() - this.lastSweepAt < CLIENT_STATE_SWEEP_INTERVAL_MS)
|
|
384
|
+
return;
|
|
385
|
+
this.lastSweepAt = Date.now();
|
|
386
|
+
if (this.sweepDeadClients() > 0)
|
|
387
|
+
this.save();
|
|
388
|
+
}
|
|
389
|
+
/** 淘汰长期不活跃的 per-client 状态(issue #441)。
|
|
390
|
+
*
|
|
391
|
+
* 背景:clientId 存 sessionStorage——每个浏览器标签页每次会话都生成新 id,服务端
|
|
392
|
+
* 却为每个见过的 id 永久建键(projects / workspaceRoots / projectProviderKeys /
|
|
393
|
+
* projectModels…),client-state.json 随死键线性膨胀,save() 的同步
|
|
394
|
+
* JSON.stringify + writeFileSync 成本也随之线性增长。
|
|
395
|
+
*
|
|
396
|
+
* 策略(保守,只动内存态,下次任意 save 自然落盘;绝不直接删文件):
|
|
397
|
+
* - 距最近活跃超过 CLIENT_STATE_RETENTION_MS(30 天)的 clientId 淘汰。活跃时间
|
|
398
|
+
* 优先取显式 lastActive(写路径 touchClient 维护),旧存档回退按
|
|
399
|
+
* max(projects[].lastUsed, interrupted[].at) 推断;推断值写回内存态,随下次
|
|
400
|
+
* save 落盘。完全无时间戳可推断的(无法判定活跃度)不按年龄淘汰,仅受数量上限约束。
|
|
401
|
+
* - 淘汰后仍超过 MAX_TRACKED_CLIENTS(50)时按最近活跃保留前 50,其余淘汰
|
|
402
|
+
* (无时间戳的排最旧优先淘汰)。
|
|
403
|
+
* - 全局键 __settings__ 永不淘汰:设置面板 config / 预设 / 墓碑都在它下面,
|
|
404
|
+
* 与任何 clientId 无关。
|
|
405
|
+
*
|
|
406
|
+
* 返回淘汰的键数(0 = 无变化,调用方无需为此 save)。 */
|
|
407
|
+
sweepDeadClients() {
|
|
408
|
+
const all = this.cache;
|
|
409
|
+
if (!all)
|
|
410
|
+
return 0;
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
const dead = new Set();
|
|
413
|
+
const activity = new Map();
|
|
414
|
+
for (const [id, state] of Object.entries(all)) {
|
|
415
|
+
if (id === ClientStateStore.GLOBAL_SETTINGS_KEY)
|
|
416
|
+
continue;
|
|
417
|
+
let at = state.lastActive ?? 0;
|
|
418
|
+
if (at <= 0) {
|
|
419
|
+
for (const p of state.projects ?? [])
|
|
420
|
+
if (p.lastUsed > at)
|
|
421
|
+
at = p.lastUsed;
|
|
422
|
+
for (const i of state.interrupted ?? [])
|
|
423
|
+
if (i.at > at)
|
|
424
|
+
at = i.at;
|
|
425
|
+
if (at > 0)
|
|
426
|
+
state.lastActive = at;
|
|
427
|
+
}
|
|
428
|
+
activity.set(id, at);
|
|
429
|
+
if (at > 0 && now - at > CLIENT_STATE_RETENTION_MS)
|
|
430
|
+
dead.add(id);
|
|
431
|
+
}
|
|
432
|
+
if (activity.size - dead.size > MAX_TRACKED_CLIENTS) {
|
|
433
|
+
const survivors = [...activity.entries()].filter(([id]) => !dead.has(id)).sort((a, b) => b[1] - a[1]);
|
|
434
|
+
for (const [id] of survivors.slice(MAX_TRACKED_CLIENTS))
|
|
435
|
+
dead.add(id);
|
|
436
|
+
}
|
|
437
|
+
for (const id of dead)
|
|
438
|
+
delete all[id];
|
|
439
|
+
return dead.size;
|
|
440
|
+
}
|
|
441
|
+
/** 打点客户端活跃时间(内存态,随任意后续 save 落盘;不主动触发 save)。
|
|
442
|
+
* 仅在 per-client 写路径上调用,键尚不存在时是 no-op(创建方随后 ??= 补上)。 */
|
|
443
|
+
touchClient(all, clientId) {
|
|
444
|
+
const state = all[clientId];
|
|
445
|
+
if (state)
|
|
446
|
+
state.lastActive = Date.now();
|
|
447
|
+
}
|
|
328
448
|
save() {
|
|
329
449
|
try {
|
|
330
|
-
|
|
331
|
-
// Atomic write (tmp + rename): a crash mid-write must never leave a
|
|
332
|
-
// half-written JSON — that would wipe ALL persisted state (recent
|
|
333
|
-
// projects / presets / settings / goal prefs) on next load.
|
|
334
|
-
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
335
|
-
writeFileSync(tmp, JSON.stringify(this.cache, null, 2) + "\n");
|
|
336
|
-
renameSync(tmp, this.filePath);
|
|
450
|
+
writeJsonAtomicSync(this.filePath, this.cache);
|
|
337
451
|
}
|
|
338
452
|
catch {
|
|
339
453
|
// best effort
|
|
@@ -346,6 +460,7 @@ export class ClientStateStore {
|
|
|
346
460
|
remember(clientId, cwd) {
|
|
347
461
|
const all = this.load();
|
|
348
462
|
const state = (all[clientId] ??= { projects: [] });
|
|
463
|
+
this.touchClient(all, clientId);
|
|
349
464
|
state.lastCwd = cwd;
|
|
350
465
|
const now = Date.now();
|
|
351
466
|
const targetKey = normalizePathKey(cwd);
|
|
@@ -353,6 +468,12 @@ export class ClientStateStore {
|
|
|
353
468
|
{ path: cwd, lastUsed: now },
|
|
354
469
|
...state.projects.filter((p) => normalizePathKey(p.path) !== targetKey),
|
|
355
470
|
].slice(0, 30);
|
|
471
|
+
// Also persist in global settings so new tabs and sessions inherit it immediately
|
|
472
|
+
const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
|
|
473
|
+
globalState.projects = [
|
|
474
|
+
{ path: cwd, lastUsed: now },
|
|
475
|
+
...(globalState.projects ?? []).filter((p) => normalizePathKey(p.path) !== targetKey),
|
|
476
|
+
].slice(0, 50);
|
|
356
477
|
// Opening the workspace again clears its removal tombstone across all clients and global settings.
|
|
357
478
|
for (const cState of Object.values(all)) {
|
|
358
479
|
if (cState.removedProjects?.length) {
|
|
@@ -371,6 +492,7 @@ export class ClientStateStore {
|
|
|
371
492
|
const all = this.load();
|
|
372
493
|
const targetKey = normalizePathKey(cwd);
|
|
373
494
|
const state = (all[clientId] ??= { projects: [] });
|
|
495
|
+
this.touchClient(all, clientId);
|
|
374
496
|
state.projects = state.projects.filter((p) => normalizePathKey(p.path) !== targetKey);
|
|
375
497
|
if (state.lastCwd && normalizePathKey(state.lastCwd) === targetKey)
|
|
376
498
|
delete state.lastCwd;
|
|
@@ -409,6 +531,90 @@ export class ClientStateStore {
|
|
|
409
531
|
}
|
|
410
532
|
return result;
|
|
411
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Get recent projects merged across the client's own history, global settings,
|
|
536
|
+
* and other clients in this store, filtering out tombstoned paths and non-existent paths.
|
|
537
|
+
*
|
|
538
|
+
* 性能(issue #441):先按 lastUsed 排序截断到 30 条,再对候选做存活性探测——
|
|
539
|
+
* 旧实现先对全部合并路径逐个同步 existsSync 再截断,Windows 上已断连的网络
|
|
540
|
+
* 驱动器单次 existsSync 可阻塞数秒且直接跑在事件循环上,会把整个服务界面
|
|
541
|
+
* (所有客户端 WS/HTTP)冻结。截断后探测把单次探测次数封顶 30;探测改异步
|
|
542
|
+
* fsPromises.access(调用链本就是 async),不再阻塞事件循环。代价是前 30 名
|
|
543
|
+
* 里有失效路径时不再回补更旧的项目(列表可能短于 30),属可接受的取舍。
|
|
544
|
+
*/
|
|
545
|
+
async getRecentProjects(clientId) {
|
|
546
|
+
const all = this.load();
|
|
547
|
+
const removedKeys = new Set(this.getRemovedProjects(clientId).map(normalizePathKey));
|
|
548
|
+
const map = new Map();
|
|
549
|
+
const merge = (list) => {
|
|
550
|
+
if (!list)
|
|
551
|
+
return;
|
|
552
|
+
for (const p of list) {
|
|
553
|
+
if (!p?.path)
|
|
554
|
+
continue;
|
|
555
|
+
const key = normalizePathKey(p.path);
|
|
556
|
+
if (removedKeys.has(key))
|
|
557
|
+
continue;
|
|
558
|
+
const existing = map.get(key);
|
|
559
|
+
if (!existing || p.lastUsed > existing.lastUsed) {
|
|
560
|
+
map.set(key, { path: p.path, lastUsed: p.lastUsed });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
// Merge client's own projects first, then global settings, then all other clients
|
|
565
|
+
merge(all[clientId]?.projects);
|
|
566
|
+
merge(all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.projects);
|
|
567
|
+
for (const [id, cState] of Object.entries(all)) {
|
|
568
|
+
if (id !== clientId && id !== ClientStateStore.GLOBAL_SETTINGS_KEY) {
|
|
569
|
+
merge(cState.projects);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// 先截断后探测:探测次数 ≤ 30,且并发执行(Promise.allSettled 吸收单点失败)。
|
|
573
|
+
const candidates = [...map.values()].sort((a, b) => b.lastUsed - a.lastUsed).slice(0, 30);
|
|
574
|
+
const probes = await Promise.allSettled(candidates.map((p) => this.pathExists(p.path)));
|
|
575
|
+
return candidates.filter((_, i) => {
|
|
576
|
+
const probe = probes[i];
|
|
577
|
+
return probe?.status === "fulfilled" && probe.value;
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
/** 单条路径的存活性探测(issue #441):异步 access,绝不阻塞事件循环。
|
|
581
|
+
* 独立成 protected 方法便于单测用子类覆写做计数探针。 */
|
|
582
|
+
async pathExists(path) {
|
|
583
|
+
return fsPromises.access(path).then(() => true, () => false);
|
|
584
|
+
}
|
|
585
|
+
/** Record discovered projects into global settings cache (without clobbering tombstones). */
|
|
586
|
+
mergeDiscoveredProjects(projects) {
|
|
587
|
+
if (!projects.length)
|
|
588
|
+
return;
|
|
589
|
+
const all = this.load();
|
|
590
|
+
const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
|
|
591
|
+
const removedKeys = new Set((globalState.removedProjects ?? []).map(normalizePathKey));
|
|
592
|
+
const map = new Map();
|
|
593
|
+
for (const p of globalState.projects ?? []) {
|
|
594
|
+
const key = normalizePathKey(p.path);
|
|
595
|
+
if (!removedKeys.has(key))
|
|
596
|
+
map.set(key, p);
|
|
597
|
+
}
|
|
598
|
+
let changed = false;
|
|
599
|
+
for (const p of projects) {
|
|
600
|
+
const key = normalizePathKey(p.path);
|
|
601
|
+
if (removedKeys.has(key))
|
|
602
|
+
continue;
|
|
603
|
+
const existing = map.get(key);
|
|
604
|
+
if (!existing) {
|
|
605
|
+
map.set(key, p);
|
|
606
|
+
changed = true;
|
|
607
|
+
}
|
|
608
|
+
else if (p.lastUsed > existing.lastUsed) {
|
|
609
|
+
existing.lastUsed = p.lastUsed;
|
|
610
|
+
changed = true;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (changed) {
|
|
614
|
+
globalState.projects = [...map.values()].sort((a, b) => b.lastUsed - a.lastUsed).slice(0, 50);
|
|
615
|
+
this.save();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
412
618
|
/** Last-used goal/review prefs for a client, or undefined if never set. */
|
|
413
619
|
getGoalPrefs(clientId) {
|
|
414
620
|
const s = this.load()[clientId];
|
|
@@ -418,6 +624,7 @@ export class ClientStateStore {
|
|
|
418
624
|
reviewModel: s.goalPrefs.reviewModel ?? null,
|
|
419
625
|
maxRounds: s.goalPrefs.maxRounds ?? 0,
|
|
420
626
|
locked: s.goalPrefs.locked ?? true,
|
|
627
|
+
execModel: s.goalPrefs.execModel ?? null,
|
|
421
628
|
};
|
|
422
629
|
}
|
|
423
630
|
/** 某项目当前的额外工作区根(空数组 = 单根)。 */
|
|
@@ -428,6 +635,7 @@ export class ClientStateStore {
|
|
|
428
635
|
saveWorkspaceRoots(clientId, cwd, roots) {
|
|
429
636
|
const all = this.load();
|
|
430
637
|
const state = (all[clientId] ??= { projects: [] });
|
|
638
|
+
this.touchClient(all, clientId);
|
|
431
639
|
const next = normalizeWorkspaceRoots(roots);
|
|
432
640
|
if (next.length === 0) {
|
|
433
641
|
if (state.workspaceRoots) {
|
|
@@ -448,6 +656,7 @@ export class ClientStateStore {
|
|
|
448
656
|
return;
|
|
449
657
|
const all = this.load();
|
|
450
658
|
const state = (all[clientId] ??= { projects: [] });
|
|
659
|
+
this.touchClient(all, clientId);
|
|
451
660
|
if (state.locale === code)
|
|
452
661
|
return;
|
|
453
662
|
state.locale = code;
|
|
@@ -457,10 +666,12 @@ export class ClientStateStore {
|
|
|
457
666
|
saveGoalPrefs(clientId, prefs) {
|
|
458
667
|
const all = this.load();
|
|
459
668
|
const state = (all[clientId] ??= { projects: [] });
|
|
669
|
+
this.touchClient(all, clientId);
|
|
460
670
|
state.goalPrefs = {
|
|
461
671
|
reviewModel: prefs?.reviewModel ?? null,
|
|
462
672
|
maxRounds: prefs?.maxRounds ?? 0,
|
|
463
673
|
locked: prefs?.locked ?? true,
|
|
674
|
+
execModel: prefs?.execModel ?? null,
|
|
464
675
|
};
|
|
465
676
|
this.save();
|
|
466
677
|
}
|
|
@@ -471,6 +682,7 @@ export class ClientStateStore {
|
|
|
471
682
|
return;
|
|
472
683
|
const all = this.load();
|
|
473
684
|
const state = (all[clientId] ??= { projects: [] });
|
|
685
|
+
this.touchClient(all, clientId);
|
|
474
686
|
state.interrupted = list.slice(0, 8);
|
|
475
687
|
this.save();
|
|
476
688
|
}
|
|
@@ -481,6 +693,7 @@ export class ClientStateStore {
|
|
|
481
693
|
const state = all[clientId];
|
|
482
694
|
const list = state?.interrupted;
|
|
483
695
|
if (list?.length && state) {
|
|
696
|
+
this.touchClient(all, clientId);
|
|
484
697
|
delete state.interrupted;
|
|
485
698
|
this.save();
|
|
486
699
|
}
|
|
@@ -542,6 +755,8 @@ export class ClientStateStore {
|
|
|
542
755
|
visionBridgePrompt: stored?.visionBridgePrompt ?? "",
|
|
543
756
|
scmCommitMsgPromptMode: stored?.scmCommitMsgPromptMode === "replace" ? "replace" : "append",
|
|
544
757
|
scmCommitMsgPrompt: stored?.scmCommitMsgPrompt ?? "",
|
|
758
|
+
planModePromptMode: stored?.planModePromptMode === "replace" ? "replace" : "append",
|
|
759
|
+
planModePrompt: stored?.planModePrompt ?? "",
|
|
545
760
|
subagentDefaultModel: stored?.subagentDefaultModel ?? null,
|
|
546
761
|
retryMaxAttempts: normalizeRetryMaxAttempts(stored?.retryMaxAttempts),
|
|
547
762
|
softCapTokens: normalizeSoftCapTokens(stored?.softCapTokens),
|
|
@@ -606,6 +821,8 @@ export class ClientStateStore {
|
|
|
606
821
|
visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
|
|
607
822
|
scmCommitMsgPromptMode: settings.scmCommitMsgPromptMode ?? cur.scmCommitMsgPromptMode ?? "append",
|
|
608
823
|
scmCommitMsgPrompt: settings.scmCommitMsgPrompt ?? cur.scmCommitMsgPrompt ?? "",
|
|
824
|
+
planModePromptMode: settings.planModePromptMode ?? cur.planModePromptMode ?? "append",
|
|
825
|
+
planModePrompt: settings.planModePrompt ?? cur.planModePrompt ?? "",
|
|
609
826
|
reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
|
|
610
827
|
reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
|
|
611
828
|
disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
|
|
@@ -667,6 +884,7 @@ export class ClientStateStore {
|
|
|
667
884
|
const gMap = (globalState.projectProviderKeys ??= {});
|
|
668
885
|
(gMap[cwd] ??= {})[provider] = keyName;
|
|
669
886
|
const state = (all[clientId] ??= { projects: [] });
|
|
887
|
+
this.touchClient(all, clientId);
|
|
670
888
|
const map = (state.projectProviderKeys ??= {});
|
|
671
889
|
const inner = (map[cwd] ??= {});
|
|
672
890
|
inner[provider] = keyName;
|
|
@@ -783,6 +1001,7 @@ export class ClientStateStore {
|
|
|
783
1001
|
(globalState.projectModels ??= {})[cwd] = modelId;
|
|
784
1002
|
// 同时写入本客户端
|
|
785
1003
|
const state = (all[clientId] ??= { projects: [] });
|
|
1004
|
+
this.touchClient(all, clientId);
|
|
786
1005
|
(state.projectModels ??= {})[cwd] = modelId;
|
|
787
1006
|
this.save();
|
|
788
1007
|
}
|
|
@@ -836,6 +1055,72 @@ export class ClientStateStore {
|
|
|
836
1055
|
(state.defaultProviderKeys ??= {})[provider] = keyName;
|
|
837
1056
|
this.save();
|
|
838
1057
|
}
|
|
1058
|
+
/** 获取某项目已钉住的会话文件路径列表。 */
|
|
1059
|
+
getPinnedSessions(cwd) {
|
|
1060
|
+
const normCwd = normalizePathKey(cwd);
|
|
1061
|
+
const map = this.load()[ClientStateStore.GLOBAL_SETTINGS_KEY]?.pinnedSessions;
|
|
1062
|
+
return map?.[normCwd] ?? [];
|
|
1063
|
+
}
|
|
1064
|
+
/** 钉住 / 取消钉住某个会话文件。 */
|
|
1065
|
+
setSessionPinned(cwd, sessionPath, pinned) {
|
|
1066
|
+
const normCwd = normalizePathKey(cwd);
|
|
1067
|
+
const normPath = normalizePathKey(sessionPath);
|
|
1068
|
+
const absPath = resolve(sessionPath);
|
|
1069
|
+
const all = this.load();
|
|
1070
|
+
const state = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
|
|
1071
|
+
const map = (state.pinnedSessions ??= {});
|
|
1072
|
+
const list = map[normCwd] ?? [];
|
|
1073
|
+
const filtered = list.filter((p) => normalizePathKey(p) !== normPath);
|
|
1074
|
+
if (pinned) {
|
|
1075
|
+
filtered.push(absPath);
|
|
1076
|
+
map[normCwd] = filtered;
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
if (filtered.length > 0) {
|
|
1080
|
+
map[normCwd] = filtered;
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
delete map[normCwd];
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (Object.keys(map).length === 0) {
|
|
1087
|
+
delete state.pinnedSessions;
|
|
1088
|
+
}
|
|
1089
|
+
this.save();
|
|
1090
|
+
}
|
|
1091
|
+
/** 检查某个会话文件是否已被钉住。 */
|
|
1092
|
+
isSessionPinned(cwd, sessionPath) {
|
|
1093
|
+
const list = this.getPinnedSessions(cwd);
|
|
1094
|
+
const norm = normalizePathKey(sessionPath);
|
|
1095
|
+
return list.some((p) => normalizePathKey(p) === norm);
|
|
1096
|
+
}
|
|
1097
|
+
/** 会话文件被删除时清理钉住记录。 */
|
|
1098
|
+
cleanPinnedSession(sessionPath) {
|
|
1099
|
+
const norm = normalizePathKey(sessionPath);
|
|
1100
|
+
const all = this.load();
|
|
1101
|
+
const map = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.pinnedSessions;
|
|
1102
|
+
if (!map)
|
|
1103
|
+
return;
|
|
1104
|
+
let changed = false;
|
|
1105
|
+
for (const [cwdKey, list] of Object.entries(map)) {
|
|
1106
|
+
const next = list.filter((p) => normalizePathKey(p) !== norm);
|
|
1107
|
+
if (next.length !== list.length) {
|
|
1108
|
+
changed = true;
|
|
1109
|
+
if (next.length > 0) {
|
|
1110
|
+
map[cwdKey] = next;
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
delete map[cwdKey];
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (changed) {
|
|
1118
|
+
if (Object.keys(map).length === 0) {
|
|
1119
|
+
delete all[ClientStateStore.GLOBAL_SETTINGS_KEY].pinnedSessions;
|
|
1120
|
+
}
|
|
1121
|
+
this.save();
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
839
1124
|
/** 全局默认 key 跟随删除:被删的 key 若是全局默认记的,指到接替者(无接替则删引用)。 */
|
|
840
1125
|
repointDeletedKeyInDefault(provider, deletedKeyName, newActive) {
|
|
841
1126
|
const all = this.load();
|
|
@@ -61,7 +61,9 @@ export const CompactContextParams = Type.Object({
|
|
|
61
61
|
description: "Compression focus and requirements for the current issue/task. State: 1) the active problem/goal; 2) context to PRESERVE (key decisions, code changes, conventions, user constraints); 3) what to REMOVE or condense (failed attempts, resolved debugging, off-topic history).",
|
|
62
62
|
}),
|
|
63
63
|
keepRecentTokens: Type.Optional(Type.Number({
|
|
64
|
-
|
|
64
|
+
minimum: MIN_KEEP_RECENT_TOKENS,
|
|
65
|
+
maximum: MAX_KEEP_RECENT_TOKENS,
|
|
66
|
+
description: "Recent tokens to keep uncompacted (1000-100000). Smaller values (e.g. 3000-8000) compact more aggressively. Omit = auto-calculated from session size.",
|
|
65
67
|
})),
|
|
66
68
|
summary: Type.Optional(Type.String({
|
|
67
69
|
description: "Custom structured summary written by you; used as the core basis for the compaction entry if provided.",
|