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.
Files changed (88) hide show
  1. package/CHANGELOG.md +144 -2
  2. package/README.md +29 -3
  3. package/README.zh-CN.md +28 -3
  4. package/bin/pi-web-ui.mjs +75 -25
  5. package/dist/server/agent-service.js +1829 -262
  6. package/dist/server/approval-rules.js +5 -10
  7. package/dist/server/atomic-file.js +40 -0
  8. package/dist/server/attachments.js +22 -0
  9. package/dist/server/claim-store.js +5 -11
  10. package/dist/server/client-state.js +296 -11
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +148 -0
  13. package/dist/server/composer-drafts.js +4 -6
  14. package/dist/server/control-socket.js +0 -40
  15. package/dist/server/delegate-mode.js +143 -0
  16. package/dist/server/delegate-task.js +10 -10
  17. package/dist/server/dsh/dsh-agent-service.js +66 -31
  18. package/dist/server/dsh/dsh-client.js +48 -10
  19. package/dist/server/dsh/dsh-serialize.js +12 -10
  20. package/dist/server/dsh/dsh-sessions.js +20 -6
  21. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  22. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  23. package/dist/server/ensure-bash.js +3 -5
  24. package/dist/server/eval-tool.js +11 -5
  25. package/dist/server/goal-review-gate.js +250 -0
  26. package/dist/server/goal-service.js +1272 -448
  27. package/dist/server/index.js +118 -16
  28. package/dist/server/launch-origin.js +0 -4
  29. package/dist/server/locales.js +3 -5
  30. package/dist/server/lsp-tool.js +123 -24
  31. package/dist/server/markers/builtins/notify.js +4 -2
  32. package/dist/server/markers/builtins/rename.js +0 -1
  33. package/dist/server/markers/builtins/todo.js +6 -28
  34. package/dist/server/markers/marker.js +0 -15
  35. package/dist/server/markers/registry.js +0 -3
  36. package/dist/server/markers/store.js +0 -3
  37. package/dist/server/mcp-bridge.js +13 -0
  38. package/dist/server/mcp-hot-reload.js +12 -5
  39. package/dist/server/model-admin.js +19 -18
  40. package/dist/server/model-enrich.js +0 -3
  41. package/dist/server/patch-tool.js +22 -6
  42. package/dist/server/patch-turn-end-boundary.js +77 -0
  43. package/dist/server/permission-preset.js +69 -0
  44. package/dist/server/plan-manager.js +150 -5
  45. package/dist/server/plan-mode.js +414 -0
  46. package/dist/server/plugin-api-catalog.js +2 -0
  47. package/dist/server/plugin-catalog.js +6 -6
  48. package/dist/server/plugin-dom.js +3 -4
  49. package/dist/server/plugin-facilities.js +39 -8
  50. package/dist/server/plugin-grants.js +5 -8
  51. package/dist/server/plugin-permissions.js +4 -6
  52. package/dist/server/plugin-schedule.js +4 -7
  53. package/dist/server/plugin-updater.js +151 -38
  54. package/dist/server/plugins.js +22 -5
  55. package/dist/server/prompt-composer.js +12 -3
  56. package/dist/server/protocol-version.js +1 -1
  57. package/dist/server/resolve-global-sdk.js +8 -0
  58. package/dist/server/schedule-agent-tool.js +5 -1
  59. package/dist/server/scheduler-tasks.js +46 -27
  60. package/dist/server/sdk-origin.js +27 -0
  61. package/dist/server/serialize.js +33 -9
  62. package/dist/server/settings-service.js +18 -6
  63. package/dist/server/slash-commands.js +16 -0
  64. package/dist/server/subagent-templates.js +4 -10
  65. package/dist/server/subagents.js +379 -416
  66. package/dist/server/terminals.js +11 -6
  67. package/dist/server/text-quote.js +59 -0
  68. package/dist/server/text-sniff.js +15 -9
  69. package/dist/server/tool-manager.js +60 -7
  70. package/dist/server/tool-overrides.js +24 -0
  71. package/dist/server/update-check.js +80 -28
  72. package/dist/server/wait-subscription-scan.js +5 -1
  73. package/extensions/webui.ts +15 -1
  74. package/package.json +33 -21
  75. package/plugin-sdk/client-utils.mjs +92 -0
  76. package/plugin-sdk/index.d.ts +21 -0
  77. package/plugin-sdk/index.mjs +2 -0
  78. package/plugins/catalog.json +7 -0
  79. package/themes/zhupi-dark.css +5 -30
  80. package/themes/zhupi.css +5 -30
  81. package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-C88r3r3Q.js} +1 -1
  82. package/web/dist/assets/index-2jDtjHvW.css +1 -0
  83. package/web/dist/assets/index-BjdK7s1M.js +368 -0
  84. package/web/dist/index.html +2 -2
  85. package/web/dist/sw.js +2 -1
  86. package/web/public/sw.js +2 -1
  87. package/web/dist/assets/index-B7Zp23Vc.css +0 -1
  88. package/web/dist/assets/index-ByfoaDwp.js +0 -366
@@ -29,8 +29,9 @@
29
29
  * 老用户删掉的内置规则不会在重启后复活;发版新增的内置规则可安全合并进老用户文件)。
30
30
  * - 内存缓存 + 文件 mtime 感知,支持直接编辑 JSON 文件热生效。
31
31
  */
32
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
33
- import { dirname, resolve, sep } from "node:path";
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
- mkdirSync(dirname(this.seededPath()), { recursive: true });
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
- mkdirSync(dirname(this.filePath), { recursive: true });
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 { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
23
- import { dirname, resolve, sep } from "node:path";
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
- mkdirSync(dirname(this.file), { recursive: true });
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
- // tmp 带进程唯一后缀(同 client-state.ts 惯例):避免并发进程互踩
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 { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
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 = process.platform === "win32" ? abs.toLowerCase() : abs;
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
- if (migrated) {
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
- mkdirSync(dirname(this.filePath), { recursive: true });
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
- description: "Recent tokens to keep uncompacted (1000-64000). Smaller values (e.g. 3000-8000) compact more aggressively. Omit = auto-calculated from session size.",
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.",