pi-web-ui 0.97.0 → 0.99.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 (80) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/bin/pi-web-ui.mjs +64 -23
  5. package/dist/server/agent-service.js +645 -147
  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 +220 -23
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +7 -5
  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 +6 -12
  16. package/dist/server/dsh/dsh-agent-service.js +32 -30
  17. package/dist/server/dsh/dsh-client.js +45 -9
  18. package/dist/server/dsh/dsh-serialize.js +12 -10
  19. package/dist/server/dsh/dsh-sessions.js +20 -6
  20. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  21. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  22. package/dist/server/ensure-bash.js +3 -5
  23. package/dist/server/eval-tool.js +30 -5
  24. package/dist/server/goal-review-gate.js +7 -20
  25. package/dist/server/goal-service.js +77 -10
  26. package/dist/server/index.js +83 -15
  27. package/dist/server/launch-origin.js +0 -4
  28. package/dist/server/locales.js +3 -5
  29. package/dist/server/lsp-tool.js +110 -14
  30. package/dist/server/markers/builtins/notify.js +4 -2
  31. package/dist/server/markers/builtins/rename.js +0 -1
  32. package/dist/server/markers/builtins/todo.js +6 -26
  33. package/dist/server/markers/marker.js +0 -15
  34. package/dist/server/markers/registry.js +0 -3
  35. package/dist/server/markers/store.js +0 -3
  36. package/dist/server/mcp-hot-reload.js +12 -5
  37. package/dist/server/model-admin.js +19 -18
  38. package/dist/server/model-enrich.js +2 -3
  39. package/dist/server/patch-tool.js +78 -28
  40. package/dist/server/permission-preset.js +25 -0
  41. package/dist/server/plan-manager.js +196 -10
  42. package/dist/server/plan-mode.js +58 -20
  43. package/dist/server/plugin-api-catalog.js +2 -0
  44. package/dist/server/plugin-catalog.js +6 -6
  45. package/dist/server/plugin-dom.js +3 -4
  46. package/dist/server/plugin-facilities.js +3 -5
  47. package/dist/server/plugin-grants.js +5 -8
  48. package/dist/server/plugin-permissions.js +4 -6
  49. package/dist/server/plugin-schedule.js +4 -7
  50. package/dist/server/plugin-updater.js +151 -38
  51. package/dist/server/plugins.js +16 -2
  52. package/dist/server/prompt-composer.js +12 -3
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/resolve-global-sdk.js +8 -0
  55. package/dist/server/schedule-agent-tool.js +5 -1
  56. package/dist/server/scheduler-tasks.js +46 -27
  57. package/dist/server/sdk-origin.js +27 -0
  58. package/dist/server/serialize.js +22 -9
  59. package/dist/server/slash-commands.js +16 -0
  60. package/dist/server/subagent-templates.js +4 -10
  61. package/dist/server/subagents.js +22 -11
  62. package/dist/server/terminals.js +24 -12
  63. package/dist/server/text-quote.js +59 -0
  64. package/dist/server/tool-manager.js +12 -3
  65. package/dist/server/tool-overrides.js +24 -0
  66. package/dist/server/update-check.js +69 -40
  67. package/extensions/webui.ts +15 -1
  68. package/package.json +31 -19
  69. package/plugin-sdk/client-utils.mjs +92 -0
  70. package/plugin-sdk/index.d.ts +21 -0
  71. package/plugin-sdk/index.mjs +2 -0
  72. package/plugins/catalog.json +7 -0
  73. package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-NerrJlTS.js} +1 -1
  74. package/web/dist/assets/index-BdMaGCcs.css +1 -0
  75. package/web/dist/assets/index-ByCBN4Kt.js +368 -0
  76. package/web/dist/index.html +2 -2
  77. package/web/dist/sw.js +2 -1
  78. package/web/public/sw.js +2 -1
  79. package/web/dist/assets/index-DwToli0J.css +0 -1
  80. package/web/dist/assets/index-oI7UKOqJ.js +0 -368
@@ -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 { existsSync, 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);
@@ -377,6 +492,7 @@ export class ClientStateStore {
377
492
  const all = this.load();
378
493
  const targetKey = normalizePathKey(cwd);
379
494
  const state = (all[clientId] ??= { projects: [] });
495
+ this.touchClient(all, clientId);
380
496
  state.projects = state.projects.filter((p) => normalizePathKey(p.path) !== targetKey);
381
497
  if (state.lastCwd && normalizePathKey(state.lastCwd) === targetKey)
382
498
  delete state.lastCwd;
@@ -418,8 +534,15 @@ export class ClientStateStore {
418
534
  /**
419
535
  * Get recent projects merged across the client's own history, global settings,
420
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),属可接受的取舍。
421
544
  */
422
- getRecentProjects(clientId) {
545
+ async getRecentProjects(clientId) {
423
546
  const all = this.load();
424
547
  const removedKeys = new Set(this.getRemovedProjects(clientId).map(normalizePathKey));
425
548
  const map = new Map();
@@ -446,17 +569,18 @@ export class ClientStateStore {
446
569
  merge(cState.projects);
447
570
  }
448
571
  }
449
- return [...map.values()]
450
- .filter((p) => {
451
- try {
452
- return existsSync(p.path);
453
- }
454
- catch {
455
- return false;
456
- }
457
- })
458
- .sort((a, b) => b.lastUsed - a.lastUsed)
459
- .slice(0, 30);
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);
460
584
  }
461
585
  /** Record discovered projects into global settings cache (without clobbering tombstones). */
462
586
  mergeDiscoveredProjects(projects) {
@@ -511,6 +635,7 @@ export class ClientStateStore {
511
635
  saveWorkspaceRoots(clientId, cwd, roots) {
512
636
  const all = this.load();
513
637
  const state = (all[clientId] ??= { projects: [] });
638
+ this.touchClient(all, clientId);
514
639
  const next = normalizeWorkspaceRoots(roots);
515
640
  if (next.length === 0) {
516
641
  if (state.workspaceRoots) {
@@ -531,6 +656,7 @@ export class ClientStateStore {
531
656
  return;
532
657
  const all = this.load();
533
658
  const state = (all[clientId] ??= { projects: [] });
659
+ this.touchClient(all, clientId);
534
660
  if (state.locale === code)
535
661
  return;
536
662
  state.locale = code;
@@ -540,6 +666,7 @@ export class ClientStateStore {
540
666
  saveGoalPrefs(clientId, prefs) {
541
667
  const all = this.load();
542
668
  const state = (all[clientId] ??= { projects: [] });
669
+ this.touchClient(all, clientId);
543
670
  state.goalPrefs = {
544
671
  reviewModel: prefs?.reviewModel ?? null,
545
672
  maxRounds: prefs?.maxRounds ?? 0,
@@ -555,6 +682,7 @@ export class ClientStateStore {
555
682
  return;
556
683
  const all = this.load();
557
684
  const state = (all[clientId] ??= { projects: [] });
685
+ this.touchClient(all, clientId);
558
686
  state.interrupted = list.slice(0, 8);
559
687
  this.save();
560
688
  }
@@ -565,6 +693,7 @@ export class ClientStateStore {
565
693
  const state = all[clientId];
566
694
  const list = state?.interrupted;
567
695
  if (list?.length && state) {
696
+ this.touchClient(all, clientId);
568
697
  delete state.interrupted;
569
698
  this.save();
570
699
  }
@@ -755,6 +884,7 @@ export class ClientStateStore {
755
884
  const gMap = (globalState.projectProviderKeys ??= {});
756
885
  (gMap[cwd] ??= {})[provider] = keyName;
757
886
  const state = (all[clientId] ??= { projects: [] });
887
+ this.touchClient(all, clientId);
758
888
  const map = (state.projectProviderKeys ??= {});
759
889
  const inner = (map[cwd] ??= {});
760
890
  inner[provider] = keyName;
@@ -871,6 +1001,7 @@ export class ClientStateStore {
871
1001
  (globalState.projectModels ??= {})[cwd] = modelId;
872
1002
  // 同时写入本客户端
873
1003
  const state = (all[clientId] ??= { projects: [] });
1004
+ this.touchClient(all, clientId);
874
1005
  (state.projectModels ??= {})[cwd] = modelId;
875
1006
  this.save();
876
1007
  }
@@ -924,6 +1055,72 @@ export class ClientStateStore {
924
1055
  (state.defaultProviderKeys ??= {})[provider] = keyName;
925
1056
  this.save();
926
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
+ }
927
1124
  /** 全局默认 key 跟随删除:被删的 key 若是全局默认记的,指到接替者(无接替则删引用)。 */
928
1125
  repointDeletedKeyInDefault(provider, deletedKeyName, newActive) {
929
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.",
@@ -74,14 +74,15 @@ export function sliceCompactedEntries(targetCompaction, branch) {
74
74
  * 将被折叠的原始 entries 转换为只读 UiMessage[] 列表。
75
75
  * 纯函数。
76
76
  */
77
- export function serializeCompactedEntries(entries) {
77
+ export function serializeCompactedEntries(entries, seqOf) {
78
78
  const result = [];
79
79
  let seq = 1;
80
80
  for (const entry of entries) {
81
81
  if (entry.type === "message" && entry.message) {
82
82
  if (entry.message.role === "system")
83
83
  continue;
84
- const ui = serializeMessage(entry.message, seq++);
84
+ const s = seqOf ? seqOf(entry.message) : seq++;
85
+ const ui = serializeMessage(entry.message, s);
85
86
  if (ui)
86
87
  result.push(ui);
87
88
  }
@@ -91,7 +92,8 @@ export function serializeCompactedEntries(entries) {
91
92
  content: entry.content,
92
93
  timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : 0,
93
94
  };
94
- const ui = serializeMessage(m, seq++);
95
+ const s = seqOf ? seqOf(m) : seq++;
96
+ const ui = serializeMessage(m, s);
95
97
  if (ui)
96
98
  result.push(ui);
97
99
  }
@@ -119,7 +121,7 @@ export function getCompactedMessages(sessionManager, compactionMessageId, seqOf)
119
121
  null;
120
122
  }
121
123
  if (!targetCompaction || targetCompaction.type !== "compaction") {
122
- return { messages: [], error: "Compaction entry not found" };
124
+ return { messages: [], error: "compactedHistoryNotFound" };
123
125
  }
124
126
  // 2. 获取该节点的分支祖先链
125
127
  let branch = [];
@@ -136,7 +138,7 @@ export function getCompactedMessages(sessionManager, compactionMessageId, seqOf)
136
138
  }
137
139
  // 3. 提取折叠区间并序列化
138
140
  const foldedEntries = sliceCompactedEntries(targetCompaction, branch);
139
- const messages = serializeCompactedEntries(foldedEntries);
141
+ const messages = serializeCompactedEntries(foldedEntries, seqOf);
140
142
  return { messages };
141
143
  }
142
144
  catch (err) {
@@ -11,8 +11,9 @@
11
11
  * - 与 per-client 的 client-state.json 不同:按 sessionId 全局存,跨标签页可见。
12
12
  * - 文件 I/O 一律 best-effort:持久化故障绝不能弄崩 server(同 subagent-templates)。
13
13
  */
14
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
- import { basename, dirname } from "node:path";
14
+ import { readFileSync, renameSync } from "node:fs";
15
+ import { basename } from "node:path";
16
+ import { writeJsonAtomicSync } from "./atomic-file.js";
16
17
  /** 单条草稿上限字符数:超出截断再存(快照全量下发时携带,太大浪费流量)。 */
17
18
  export const DRAFT_TEXT_MAX = 20_000;
18
19
  /** 草稿保留期:超过这么久没更新,下次加载时清扫(兜底;正常路径靠 clear/prune)。 */
@@ -97,10 +98,7 @@ export class ComposerDraftsStore {
97
98
  if (!this.cache)
98
99
  return;
99
100
  try {
100
- mkdirSync(dirname(this.filePath), { recursive: true });
101
- const tmp = `${this.filePath}.tmp-${process.pid}`;
102
- writeFileSync(tmp, JSON.stringify(this.cache, null, 2) + "\n");
103
- renameSync(tmp, this.filePath);
101
+ writeJsonAtomicSync(this.filePath, this.cache);
104
102
  }
105
103
  catch {
106
104
  // best-effort:草稿丢了可以重打,server 绝不能因此崩。
@@ -43,8 +43,6 @@ function probeControlPath(path) {
43
43
  }
44
44
  /** How long a control connection may sit idle before the server closes it. */
45
45
  const CONTROL_IDLE_TIMEOUT_MS = 5_000;
46
- /** How long the CLI waits for a reply before giving up. */
47
- const CONTROL_CLIENT_TIMEOUT_MS = 3_000;
48
46
  /** Socket path (POSIX) or pipe name (Windows). */
49
47
  export function controlPath(dataDir, port) {
50
48
  return process.platform === "win32" ? `\\\\.\\pipe\\pi-web-ui-${port}` : join(dataDir, "pi-web-ui.sock");
@@ -162,41 +160,3 @@ export function startControlServer(opts) {
162
160
  }
163
161
  };
164
162
  }
165
- /**
166
- * CLI-side client: send one command and return the parsed reply (or null if
167
- * the server is unreachable / timed out).
168
- */
169
- export function sendControlCommand(dataDir, port, cmd) {
170
- const path = controlPath(dataDir, port);
171
- return new Promise((resolve) => {
172
- const sock = createConnection(path);
173
- let done = false;
174
- const finish = (v) => {
175
- if (done)
176
- return;
177
- done = true;
178
- clearTimeout(timer);
179
- sock.destroy();
180
- resolve(v);
181
- };
182
- const timer = setTimeout(() => finish(null), CONTROL_CLIENT_TIMEOUT_MS);
183
- let buf = "";
184
- sock.on("connect", () => {
185
- sock.write(JSON.stringify({ cmd }) + "\n");
186
- });
187
- sock.on("data", (chunk) => {
188
- buf += chunk.toString("utf8");
189
- const nl = buf.indexOf("\n");
190
- if (nl >= 0) {
191
- try {
192
- finish(JSON.parse(buf.slice(0, nl)));
193
- }
194
- catch {
195
- finish(null);
196
- }
197
- }
198
- });
199
- sock.on("error", () => finish(null));
200
- sock.on("close", () => finish(null));
201
- });
202
- }