pi-pier 0.1.4 → 0.2.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 (55) hide show
  1. package/package.json +1 -1
  2. package/schemas/role-manifest.schema.json +1 -2
  3. package/src/bootstrap.ts +18 -22
  4. package/src/core/grid-shape.ts +13 -14
  5. package/src/core/subagent.ts +325 -857
  6. package/src/core/terminal.ts +25 -28
  7. package/src/core/todo.ts +47 -53
  8. package/src/gc-core.ts +15 -15
  9. package/src/git-adapter.ts +104 -0
  10. package/src/herdr-client.ts +76 -68
  11. package/src/history-store.ts +70 -36
  12. package/src/index-locks.ts +105 -0
  13. package/src/index-master.ts +113 -0
  14. package/src/index-notices.ts +43 -0
  15. package/src/index-pipe.ts +73 -0
  16. package/src/index-runtime.ts +23 -0
  17. package/src/index-worker.ts +39 -0
  18. package/src/index.ts +134 -386
  19. package/src/ledger.ts +15 -17
  20. package/src/lock-core.ts +15 -29
  21. package/src/manifest-compose.ts +9 -23
  22. package/src/notice-buffer.ts +9 -14
  23. package/src/pane-title.ts +24 -24
  24. package/src/pi-surface.ts +22 -29
  25. package/src/pipe-channel.ts +67 -34
  26. package/src/platform-paths.ts +72 -0
  27. package/src/progress-core.ts +26 -24
  28. package/src/reconcile-core.ts +25 -24
  29. package/src/role-loader.ts +32 -35
  30. package/src/role-manifest.ts +24 -28
  31. package/src/roles/master.json +2 -2
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/runtime-policy.ts +66 -0
  34. package/src/session-tail.ts +46 -45
  35. package/src/settle-wake-core.ts +19 -18
  36. package/src/slim-frame.ts +45 -44
  37. package/src/stale-core.ts +23 -21
  38. package/src/storage-layout.ts +80 -0
  39. package/src/subagent-core.ts +151 -94
  40. package/src/subagent-gc.ts +237 -0
  41. package/src/subagent-git-io.ts +80 -0
  42. package/src/subagent-launch.ts +110 -0
  43. package/src/subagent-poll-loop.ts +285 -0
  44. package/src/subagent-poller.ts +90 -0
  45. package/src/subagent-port.ts +27 -0
  46. package/src/subagent-scope.ts +4 -4
  47. package/src/subagent-session-io.ts +143 -0
  48. package/src/subagent-spawn.ts +156 -0
  49. package/src/terminal-core.ts +56 -54
  50. package/src/todo-core.ts +27 -64
  51. package/src/todo-read-hook.ts +28 -24
  52. package/src/todo-reminder-core.ts +89 -0
  53. package/src/todos-service.ts +36 -25
  54. package/src/tool-gate.ts +7 -28
  55. package/src/vocab.ts +18 -19
package/src/ledger.ts CHANGED
@@ -1,35 +1,33 @@
1
1
  /**
2
- * 档1 dispose 账本(D80⑤ + D79)。
2
+ * Disposal ledger shared by HMR and session shutdown (D80⑤ + D79).
3
3
  *
4
- * 背景:hmr partial reload **不跑旧 fiber 的 effect-disposers**(spike A 实证)——
5
- * 模块持有的进程级资源(setInterval / pipe server / watcher)在热换后泄漏。
6
- * 补偿:core 模块把进程级资源登记进账本(key = 模块文件);hmr/reload 事件
7
- * 按文件名触发 disposeKey,拆掉旧版本登记的资源;session_shutdown 走 disposeAll。
4
+ * HMR partial reload can skip old fiber effect disposers, so process-level resources
5
+ * such as intervals, pipe servers, and watchers would leak without compensation.
6
+ * Core modules register those resources by module key; reload disposes matching keys,
7
+ * while session_shutdown disposes everything.
8
8
  *
9
- * D79 同构复用:未来 pi-surface proxy 的注册反清理(registerTool/on 的账本)
10
- * 与本账本同一机制(key 可用 'pi-surface' 等逻辑名,不必是文件)。
11
- *
12
- * 纯逻辑零依赖;接缝在 bootstrap(hmr 事件)与 index(session_shutdown)。
9
+ * D79 reuses this mechanism for pi-surface registration cleanup; logical keys need not
10
+ * be file paths. The ledger stays dependency-free and connects through bootstrap/index.
13
11
  */
14
12
 
15
13
  import { fileURLToPath } from 'node:url';
16
14
 
17
- /** 规范化 key:file:// URL 与普通路径统一为绝对路径(hmr filename 与 import.meta.url 可互比)。 */
15
+ /** Normalize file URLs and paths to comparable absolute keys for HMR matching. */
18
16
  export function normalizeModuleKey(spec: string): string {
19
17
  let s = String(spec);
20
18
  try {
21
19
  if (s.startsWith('file://')) s = fileURLToPath(s);
22
20
  } catch {
23
- /* 非法 URL 按原样使用 */
21
+ /* Keep malformed URLs unchanged so disposal can still use the caller's key. */
24
22
  }
25
23
  return s.replace(/\\/g, '/');
26
24
  }
27
25
 
28
26
  export class DisposeLedger {
29
- /** 登记序(LIFO 拆除与 cordis effect 语义一致)。 */
27
+ /** LIFO order matches Cordis effect disposal semantics. */
30
28
  private order: Array<{ key: string; dispose: () => void }> = [];
31
29
 
32
- /** 登记一个待拆资源;返回撤销函数(资源自拆后从账本移除,防二次拆)。 */
30
+ /** Register a resource and return cancellation so self-disposal cannot run twice. */
33
31
  add(spec: string, dispose: () => void): () => void {
34
32
  const entry = { key: normalizeModuleKey(spec), dispose };
35
33
  this.order.push(entry);
@@ -39,7 +37,7 @@ export class DisposeLedger {
39
37
  };
40
38
  }
41
39
 
42
- /** hmr 补偿(D80⑤):只拆匹配 key 的登记项(LIFO),返回拆除数。 */
40
+ /** HMR compensation disposes only matching keys in LIFO order. */
43
41
  disposeKey(spec: string | string[]): number {
44
42
  const keys = new Set((Array.isArray(spec) ? spec : [spec]).map(normalizeModuleKey));
45
43
  let n = 0;
@@ -47,19 +45,19 @@ export class DisposeLedger {
47
45
  if (keys.has(this.order[i].key)) {
48
46
  const { dispose } = this.order[i];
49
47
  this.order.splice(i, 1);
50
- try { dispose(); } catch { /* 补偿拆除非致命 */ }
48
+ try { dispose(); } catch { /* Compensation failures are non-fatal. */ }
51
49
  n++;
52
50
  }
53
51
  }
54
52
  return n;
55
53
  }
56
54
 
57
- /** 全量 LIFO(session_shutdown 路径),返回拆除数。 */
55
+ /** Dispose every entry in LIFO order during session_shutdown. */
58
56
  disposeAll(): number {
59
57
  let n = 0;
60
58
  while (this.order.length > 0) {
61
59
  const { dispose } = this.order.pop()!;
62
- try { dispose(); } catch { /* 同上 */ }
60
+ try { dispose(); } catch { /* Compensation failures are non-fatal. */ }
63
61
  n++;
64
62
  }
65
63
  return n;
package/src/lock-core.ts CHANGED
@@ -1,38 +1,24 @@
1
- /**
2
- * M18 文件级写锁纯核心(S2 拍板:默认软 veto;PI_HERDR_WRITE_LOCK=1 硬启;2026-08-18)。
3
- *
4
- * 通道(D3 无新协议,schema 实测约束):锁登记 = herdr `pane.report_metadata` tokens
5
- * ——键必须匹配 `^[A-Za-z0-9_-]{1,32}$`(路径进不了键!)→ **键 = `lock-<FNV-1a 64位哈希>`,
6
- * 值 = `<paneId>|<归一路径>`**(值无模式限制,路径放值里保可读性);
7
- * 每次上报 ≤16 键(adapter 分批)。冲突检查 = `agent.list` 读全 pane tokens;
8
- * pane 关闭 → agent 条目消失 = 锁失效;settled → 显式置 null 释放。
9
- *
10
- * 语义:
11
- * - 参与工具:write / edit(`{path}`;bash 文件目标提取不可靠,v1 声明为 seam);
12
- * - 同 pane 重入放行;异 pane 持有 → 软 warn(工具照跑,结果附警告)/ 硬 block;
13
- * - 归一:resolve(cwd) + 反斜杠→斜杠 + 小写(Windows 大小写不敏感)+ 去尾斜杠;
14
- * - 哈希碰撞(64 位,~10² 文件量级可忽略):软模式多一次无害警告;硬模式极小概率误拦。
15
- */
1
+ /** Why: Preserve the established compatibility and safety behavior (D3, M18, S2). */
16
2
  import { resolve, sep } from 'node:path';
17
3
 
18
- /** 硬启开关(env 名固定)。 */
4
+ /** Why: Preserve the established compatibility and safety behavior. */
19
5
  export const WRITE_LOCK_ENV = 'PI_HERDR_WRITE_LOCK';
20
- /** v1 参与写锁的工具(取 input.path)。 */
6
+ /** Why: Preserve the established compatibility and safety behavior. */
21
7
  export const WRITE_TOOLS = ['write', 'edit'] as const;
22
- /** 锁 token 键前缀(schema:^[A-Za-z0-9_-]{1,32}$)。 */
8
+ /** Why: Preserve the established compatibility and safety behavior. */
23
9
  export const LOCK_TOKEN_PREFIX = 'lock-';
24
- /** 单次 report_metadata 的 token 键上限(schema maxProperties=16)。 */
10
+ /** Why: Preserve the established compatibility and safety behavior. */
25
11
  export const LOCK_BATCH_LIMIT = 16;
26
- /** 锁 token TTL(pane 崩溃兜底;正常路径 settled 即释放)。 */
12
+ /** Why: Preserve the established compatibility and safety behavior. */
27
13
  export const LOCK_TTL_MS = 60 * 60 * 1000;
28
14
 
29
- /** agent.list 里与本模块相关的视图。 */
15
+ /** Why: Preserve the established compatibility and safety behavior. */
30
16
  export interface LockAgentView {
31
17
  paneId: string;
32
18
  tokens: Record<string, string | null>;
33
19
  }
34
20
 
35
- /* ── 路径归一 ─────────────────────────────────────────────────────── */
21
+ /** Why: Preserve the established compatibility and safety behavior. */
36
22
 
37
23
  export function normalizeLockPath(p: string, cwd: string): string {
38
24
  const abs = resolve(cwd, p);
@@ -40,9 +26,9 @@ export function normalizeLockPath(p: string, cwd: string): string {
40
26
  return unified.toLowerCase().replace(/\/+$/, '');
41
27
  }
42
28
 
43
- /* ── token 编解码(key = 哈希;value = paneId|path) ─────────────── */
29
+ /** Why: Preserve the established compatibility and safety behavior. */
44
30
 
45
- /** FNV-1a 64 位 → 16 位 hex(BigInt 纯实现,无 node:crypto 依赖,可单测)。 */
31
+ /** Why: Preserve the established compatibility and safety behavior. */
46
32
  export function fnv1a64(s: string): string {
47
33
  let h = 0xcbf29ce484222325n;
48
34
  for (let i = 0; i < s.length; i++) {
@@ -66,12 +52,12 @@ export function parseLockTokenValue(v: string): { holderPaneId: string; path: st
66
52
  return { holderPaneId: v.slice(0, i), path: v.slice(i + 1) };
67
53
  }
68
54
 
69
- /** token 键是否属于锁命名空间。 */
55
+ /** Why: Preserve the established compatibility and safety behavior. */
70
56
  export function isLockTokenKey(key: string): boolean {
71
57
  return key.startsWith(LOCK_TOKEN_PREFIX) && key.length > LOCK_TOKEN_PREFIX.length;
72
58
  }
73
59
 
74
- /* ── 工具路径提取 ─────────────────────────────────────────────────── */
60
+ /** Why: Preserve the established compatibility and safety behavior. */
75
61
 
76
62
  export function writePathsOfTool(toolName: string, input: unknown): string[] {
77
63
  if (!(WRITE_TOOLS as readonly string[]).includes(toolName)) return [];
@@ -79,7 +65,7 @@ export function writePathsOfTool(toolName: string, input: unknown): string[] {
79
65
  return typeof p === 'string' && p.trim() ? [p] : [];
80
66
  }
81
67
 
82
- /* ── 冲突判定(value 解码持有人;同 pane 重入放行) ───────────────── */
68
+ /** Why: Preserve the established compatibility and safety behavior. */
83
69
 
84
70
  export function findLockConflict(
85
71
  agents: readonly LockAgentView[],
@@ -96,7 +82,7 @@ export function findLockConflict(
96
82
  return null;
97
83
  }
98
84
 
99
- /* ── 决策 ─────────────────────────────────────────────────────────── */
85
+ /** Why: Preserve the established compatibility and safety behavior. */
100
86
 
101
87
  export type WriteGuardPlan =
102
88
  | { kind: 'skip' }
@@ -136,7 +122,7 @@ export function planWriteGuard(opts: {
136
122
  return { kind: 'pass', paths };
137
123
  }
138
124
 
139
- /* ── token 构造(分批在 adapter) ─────────────────────────────────── */
125
+ /** Why: Preserve the established compatibility and safety behavior. */
140
126
 
141
127
  export function acquireTokensFor(normPaths: readonly string[], paneId: string): Record<string, string> {
142
128
  const out: Record<string, string> = {};
@@ -1,14 +1,4 @@
1
- /**
2
- * 档2 Week2-3:manifest 三态合成(C7 v2;2026-08-18)。
3
- *
4
- * `manifest.tools = (roleBaseline ∪ modelSuggested) \ {tool | rule(tool) = deny}`
5
- * `manifest.permissions[tool] = rule(tool) ?? rule['*'] ?? 'allow'`
6
- *
7
- * 规则最高优先(安全护栏不可被模型建议绕过);deny 排除、ask 标记、allow 放行;
8
- * 合成后为空 = 配置错误(EMPTY_MANIFEST,防 deny-all trap,照抄 DSH 纪律);
9
- * 基线为空 = INVALID_ROLE_CONFIG(协调工具强制,档案级校验在 role-manifest.ts)。
10
- * 排序确定(便于测试与缓存比较)。
11
- */
1
+ /** Why: Preserve the established compatibility and safety behavior (C7). */
12
2
  import type { PermissionAction, RoleManifest, UnknownToolStance } from './role-manifest.ts';
13
3
  import { loadRoleConfig } from './role-loader.ts';
14
4
 
@@ -16,14 +6,14 @@ export interface ManifestSources {
16
6
  roleBaseline: readonly string[];
17
7
  modelSuggested: readonly string[];
18
8
  rulePermissions: Record<string, PermissionAction>;
19
- /** D82:未知工具姿态(缺省 deny)。 */
9
+ /** Why: Preserve the established compatibility and safety behavior (D82). */
20
10
  unknownTools?: UnknownToolStance;
21
11
  }
22
12
 
23
13
  export interface ComposedManifest {
24
14
  tools: string[];
25
15
  permissions: Record<string, PermissionAction>;
26
- /** D82:解析后的未知工具姿态(缺省 deny)。 */
16
+ /** Why: Preserve the established compatibility and safety behavior (D82). */
27
17
  unknownTools: UnknownToolStance;
28
18
  }
29
19
 
@@ -54,16 +44,16 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
54
44
 
55
45
  const tools: string[] = [];
56
46
  const permissions: Record<string, PermissionAction> = {};
57
- // D82:规则键全量并入 permissions(含 deny 与基线外的排除族)——
58
- // stance=allow 下闸门/可见层仍能对排除族持禁令(deny 记忆不丢)。
47
+ // Why: Preserve the established compatibility and safety behavior (D82).
48
+ // Why: Preserve the established compatibility and safety behavior.
59
49
  for (const [k, v] of Object.entries(rules)) {
60
- if (k === '*') continue; // 通配是默认值不是逐工具记录
50
+ if (k === '*') continue; // Why: Preserve the established compatibility and safety behavior.
61
51
  permissions[k] = v;
62
52
  }
63
53
  for (const tool of candidates) {
64
54
  const action = rules[tool] ?? defaultAction;
65
55
  permissions[tool] = action;
66
- if (action === 'deny') continue; // 安全护栏:不进 manifest.tools
56
+ if (action === 'deny') continue; // Why: Preserve the established compatibility and safety behavior.
67
57
  tools.push(tool);
68
58
  }
69
59
 
@@ -76,15 +66,11 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
76
66
  );
77
67
  }
78
68
 
79
- tools.sort(); // 确定性
69
+ tools.sort(); // Why: Preserve the established compatibility and safety behavior.
80
70
  return { tools, permissions, unknownTools: stance };
81
71
  }
82
72
 
83
- /**
84
- * 端到端:档案名 → loadRoleConfig → 合成(显式 tools/rules 即全部语义;
85
- * roleType 自动约束已随 WS-D8 移除——"不配发即禁止"由 tools 列表本身表达)。
86
- * master 侧 subagent 工具的调用缝;loadRole 可注入(测试替身)。
87
- */
73
+ /** Why: Preserve the established compatibility and safety behavior (WS-D8). */
88
74
  export function composeForRole(
89
75
  roleName: string,
90
76
  modelSuggested: readonly string[],
@@ -1,23 +1,18 @@
1
1
  /**
2
- * D92 结算通知折叠器(followup 堆积修复的纯函数半)。
2
+ * D92 settlement-notice collapser (pure half of the followup-flood fix).
3
3
  *
4
- * 背景:结算通知原走 pi.sendUserMessage(deliverAs:'followUp')——pi 语义是
5
- * 「agent 没有更多 tool call 时才投递」= 整个 run 结束才回填。master 长任务
6
- * 执行中 N 个 subagent 结算/失败重启,通知全攒在队列里,run 结束瞬间洪水灌入。
7
- *
8
- * 修复分两半(另一半在 index.ts 接线):
9
- * - 忙时入扩展自己的缓冲,turn_end(LLM 迭代边界)以 steer 折叠批量注入;
10
- * - 单次注入最多展示 max 条全文,其余收进尾行(全量走 history 台账/list_agents)。
4
+ * Why: `sendUserMessage(followUp)` only delivers when the agent has no more
5
+ * tool calls, so a long master run queues every settlement and floods at the
6
+ * end. Buffer while busy; collapse at turn_end. The other half is the index
7
+ * notice buffer.
11
8
  */
12
-
13
- /** 折叠展示的最大条数(用户拍板:最多 3 条)。 */
14
9
  export const NOTICE_MAX_SHOWN = 3;
15
10
 
16
11
  /**
17
- * 把一批通知折叠成单条消息体。
18
- * - 空 → null(不注入)
19
- * - ≤max 条 → 原文逐条(\n\n 连接),不套任何包装(单条时与旧格式逐字节一致)
20
- * - >max 条 → 前 max 条原文 + 尾行汇总(指向 history 台账 / list_agents 查全量)
12
+ * Collapse a batch into one message body.
13
+ * - empty → null (do not inject)
14
+ * - ≤max → originals joined with `\n\n` (single-item byte-identical to the old format)
15
+ * - >max → first max originals + a tail pointing at history / list_agents
21
16
  */
22
17
  export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
23
18
  if (contents.length === 0) return null;
package/src/pane-title.ts CHANGED
@@ -1,37 +1,38 @@
1
1
  /**
2
- * pane todo → herdr 窗格头 / 侧栏投影(M22,D62/D68)。
2
+ * Projects the pane todo snapshot into a herdr pane header/sidebar (M22, D62/D68).
3
3
  *
4
- * 权威仍在 pi 会话 JSONL;这里只把快照收成 title + blocked 徽标。
5
- * herdr 把 title / state_label 截到 80 字符——本地先裁,避免半截 emoji。
4
+ * The pi session JSONL remains authoritative; this layer only condenses the snapshot into a title and blocked badge.
5
+ * herdr truncates title/state_label to 80 characters, so truncate locally to avoid cutting an emoji in half.
6
6
  */
7
7
  import { countTodos, PI_HERDR_META_KEY, type TodoItem } from './vocab.ts';
8
8
  import { formatAge, isArchived } from './stale-core.ts';
9
9
 
10
- /** herdr 对 title / state_label 的字符上限(官方文档 + schema 实测)。 */
10
+ /** herdr's character limit for title/state_label (from official documentation and schema measurements). */
11
11
  export const TITLE_MAX = 80;
12
- /** 空列表:调用方发 clear_title,不写空串。 */
12
+ /** Empty list: callers send clear_title rather than an empty string. */
13
13
  export const TITLE_EMPTY = null;
14
14
  /**
15
- * state_labels 合法键只有 idle|working|blocked|done|unknown
16
- * (herdr socket-api;WIRE 早期 `todo` 被拒 invalid_state_label)。
15
+ * Valid state_labels keys are only idle|working|blocked|done|unknown
16
+ * (herdr socket-api; the early WIRE `todo` value was rejected as invalid_state_label).
17
17
  */
18
18
  export const BLOCKED_LABEL_KEY = 'blocked';
19
19
 
20
20
  /**
21
- * D93:todo 摘要的侧边栏 custom token 名(herdr `[ui.sidebar.agents]` 里以 `$pi-todo` 引用)。
22
- * 值与 pane title 同源同格式(`▶i ○p ■b ✓c (N/M) · activity`)。
21
+ * D93: custom sidebar token for the todo summary (referenced as `$pi-todo` by herdr `[ui.sidebar.agents]`).
22
+ * Keep its value in the same format and source as the pane title (`▶i ○p ■b ✓c (N/M) · activity`).
23
23
  */
24
24
  export const SIDEBAR_TODO_TOKEN = 'pi-todo';
25
- /** D95:ask_user_question 等待标志 token 名(workbench 热力分级读它区分 ask vs block)。 */
25
+ /** D95: token marking an ask_user_question wait (the workbench heat scale uses it to distinguish ask from block). */
26
26
  export const SIDEBAR_ASK_TOKEN = 'pi-ask';
27
27
 
28
28
  /**
29
- * D93:构造 report_metadata 的 pi-todo token 补丁。
30
- * 有 title → 值 = title(侧边栏可排 `$pi-todo`);无 todo → 空串(herdr patch 语义删除键,不留旧摘要)。
29
+ * D93: build the pi-todo token patch for report_metadata.
30
+ * A title becomes the token value so the sidebar can render `$pi-todo`; no todo becomes an empty string so herdr's
31
+ * patch semantics remove the key rather than retaining an old summary.
31
32
  *
32
- * D96 修正:**不再合并 stale**——stale(16 个 null)是一次性清理(reportMetadata 单独发),
33
- * 合并会使总数 17 > herdr tokens maxProperties=16 → 整个请求被拒 → title/tokens 全丢
34
- * (用户实机:pane title 消失 + 侧边栏 token 消失,D93 回归根因)。
33
+ * D96 correction: **do not merge stale**. Stale (16 nulls) is a one-time cleanup sent separately via reportMetadata;
34
+ * merging would make 17 entries exceed herdr's tokens maxProperties=16, rejecting the entire request and dropping
35
+ * both title and tokens (observed on the user's machine: pane title and sidebar token disappeared; D93 regression root cause).
35
36
  */
36
37
  export function sidebarTodoTokens(title: string | null): Record<string, string> {
37
38
  return { [SIDEBAR_TODO_TOKEN]: title ?? '' };
@@ -44,13 +45,13 @@ function clipTitle(s: string): string {
44
45
  }
45
46
 
46
47
  /**
47
- * `▶i ○p ■b ✓c (N/M [~eta]) · <activity>`(D91 图标统一:黑白字符四件套)。
48
- * activity = 第一条 in_progress,否则 fallbackDescription,再否则只报计数。
49
- * 空列表 → null。progressSuffix(M16)= formatProgressSuffix 产物。
48
+ * `▶i ○p ■b ✓c (N/M [~eta]) · <activity>` (D91 unifies the four black-and-white status glyphs).
49
+ * activity is the first in_progress item, then fallbackDescription, then only the counts.
50
+ * Empty list → null. progressSuffix (M16) is produced by formatProgressSuffix.
50
51
  *
51
- * 反冻结(stale-core D):全完成 + 墙钟过期(archived)→ 死列表降权为
52
- * `✓N done <age>`,不再以 ▶○■✓ 满权重视窗冒充当前状态;"在干嘛"由
53
- * report_agent 的工具徽标/活动行承担,todo token 只报计划真实状态。
52
+ * Anti-freeze behavior (stale-core D): once all work is complete and the wall clock expires (archived), lower the
53
+ * dead list to `✓N done <age>` instead of presenting it as a fully weighted current-state view; report_agent's tool
54
+ * badge/activity line explains what is happening, while the todo token reports only the plan's actual state.
54
55
  */
55
56
  export function formatPaneTitle(
56
57
  items: readonly TodoItem[],
@@ -73,7 +74,7 @@ export function formatPaneTitle(
73
74
  return clipTitle(activity ? `${withProgress} · ${activity}` : withProgress);
74
75
  }
75
76
 
76
- /** 第一条 blocked 的 blocker(缺则用 content);无 blocked → null。 */
77
+ /** First blocked item's blocker (fall back to content); no blocked item → null. */
77
78
  export function formatBlockedLabel(items: readonly TodoItem[]): string | null {
78
79
  const blocked = items.find((it) => it.status === 'blocked');
79
80
  if (!blocked) return null;
@@ -82,8 +83,7 @@ export function formatBlockedLabel(items: readonly TodoItem[]): string | null {
82
83
  }
83
84
 
84
85
  /**
85
- * 升级后首次上报用来清掉旧 16-key 分块(herdr tokens 按 key 合并,
86
- * 不写 null 会残留——M13b)。
86
+ * The first post-upgrade report clears the old 16-key chunks (herdr merges tokens by key, so omitted nulls remain—M13b).
87
87
  */
88
88
  export function staleTokenClearance(): Record<string, null> {
89
89
  const tokens: Record<string, null> = { [PI_HERDR_META_KEY]: null };
package/src/pi-surface.ts CHANGED
@@ -1,31 +1,23 @@
1
1
  /**
2
- * 档1 pi-surface(D79):包住 pi ExtensionAPI 的注册面。
2
+ * Proxy the pi ExtensionAPI registration surface (D79).
3
3
  *
4
- * 背景(源码实证 pi dist/core/extensions/loader.js):
5
- * - `registerTool(...): void`、`on(...): void` —— **pi 不提供反注册函数**;
6
- * - `extension.tools.set(tool.name, …)` —— 工具表按名覆盖:同名重注册 = 覆盖
7
- * (hmr 热换后新版本自然顶掉旧条目);
8
- * - 事件监听是列表 —— 重注册会**双触发**(热换泄漏面)。
4
+ * pi exposes registration without unregister APIs: tools overwrite by name, but event
5
+ * listeners accumulate. HMR therefore needs tombstoned, generation-scoped wrappers so
6
+ * old listeners become inert while the latest generation remains active.
9
7
  *
10
- * 补偿策略(tombstone,世代化):每个 core 模块经 `forModule(key)` 拿到scoped 面,
11
- * 其 registerTool/on 都包 alive 旗标。**世代规则**(d87 修,源读 cordis-plugin-hmr):
12
- * hmr partialReload 真实时序 = 重挂新插件**之后**才 emit('hmr/reload') → bootstrap
13
- * hook 才 disposeKey——若按「翻当前组」实现,翻掉的是新版本正引用的组(死到货)。
14
- * 故:挂载即自翻同 key 更旧世代(新插件体执行 = 旧版本确定已死);账本 disposer
15
- * 只收割「登记世代及之前」,登记后新挂的豁免;disposeModule(显式)翻全部。
16
- *
17
- * 与 dispose 账本(D80⑤ 修正后定位)咬合:构造时传入 ledger,forModule
18
- * 自动登记 `disposeModule(key)`——hmr/reload 按文件名补偿即翻旗。
8
+ * Because HMR emits reload after mounting the replacement (d87), mounting a key retires
9
+ * older generations immediately. The ledger then collects only generations registered
10
+ * through the reload boundary; explicit disposal retires every generation.
19
11
  */
20
12
  import type { DisposeLedger } from './ledger.ts';
21
13
 
22
- /** pi 的 ToolResult 最小形态(inert 返回用)。 */
14
+ /** Minimal pi ToolResult shape used when a disposed tool is invoked. */
23
15
  const INERT_TOOL_RESULT = {
24
16
  content: [{ type: 'text' as const, text: 'Error: tool module disposed (hot-reloaded away); the new version has re-registered it.' }],
25
17
  details: {},
26
18
  };
27
19
 
28
- /** 模块级 scoped 面:registerTool/on 都带 alive 墓碑;registerCommand 走 Map 覆盖(pi 实测语义,无需墓碑)。 */
20
+ /** Module-scoped surface: tombstone tools and events, while command replacement is safe because pi stores commands in a Map. */
29
21
  export interface ScopedSurface {
30
22
  registerTool(def: Record<string, unknown> & { name: string }): void;
31
23
  registerCommand(name: string, options: Record<string, unknown>): void;
@@ -34,17 +26,17 @@ export interface ScopedSurface {
34
26
 
35
27
  interface Group {
36
28
  alive: boolean;
37
- /** 挂载世代号(单射递增;账本 disposer 按此判定收割范围)。 */
29
+ /** Monotonic mount generation used by the ledger disposer to determine its collection boundary. */
38
30
  epoch: number;
39
31
  }
40
32
 
41
- /** pi 注册面代理(D79)。raw 直通非注册方法(append/exec/setActiveTools…)。 */
33
+ /** Proxy for pi's registration surface (D79); non-registration methods (append/exec/setActiveTools, etc.) pass through raw. */
42
34
  export class PiSurface<P extends object> {
43
35
  private groups = new Map<string, Group>();
44
- /** 每 key 的世代列表(挂载即自翻更旧 → 活口至多 1 个,死口待账本回收)。 */
36
+ /** Generation history per key; mounting retires older generations so at most one stays alive until ledger collection. */
45
37
  private generations = new Map<string, Group[]>();
46
38
  private epochCounter = 0;
47
- /** 账本登记世代(disposer 只收割 epoch ≤ 此值;消费即删,下次挂载补登)。 */
39
+ /** Ledger generation boundary; disposal collects epochs up to this value, then the next mount registers again. */
48
40
  private ledgerEntryEpoch = new Map<string, number>();
49
41
  private readonly pi: P;
50
42
  private readonly ledger?: DisposeLedger;
@@ -54,15 +46,15 @@ export class PiSurface<P extends object> {
54
46
  this.ledger = ledger;
55
47
  }
56
48
 
57
- /** 原始 pi(不经包装的直通面;已迁移模块不应再用其注册)。 */
49
+ /** Raw pi surface, bypassing wrappers; migrated modules must not use it for registration. */
58
50
  get raw(): P {
59
51
  return this.pi;
60
52
  }
61
53
 
62
54
  /**
63
- * 拿一个模块的 scoped 注册面。**每次调用 = 新世代**:挂载即自翻同 key 旧世代
64
- * (新插件体执行意味着旧版本已死——hmr registry.delete 与手动重挂两态统一)。
65
- * 账本登记按 key 单例(消费后下次挂载补登)。
55
+ * Get a module-scoped registration surface. Each call creates a new generation, retiring older
56
+ * generations for the same key so HMR replacement and manual remounting share one lifecycle.
57
+ * The ledger registers each key once and registers it again only after its entry is consumed.
66
58
  */
67
59
  forModule(key: string): ScopedSurface {
68
60
  this.epochCounter += 1;
@@ -102,8 +94,9 @@ export class PiSurface<P extends object> {
102
94
  );
103
95
  },
104
96
  registerCommand: (name, options) => {
105
- // pi 的 commands 是 Map<name>(dist 实测 .set)——同名重注册覆盖,热换安全;
106
- // handler 包墓碑仅为对称保险(覆盖语义下旧 handler 不会再被调)。
97
+ // pi stores commands as Map<name> (verified in the dist implementation), so same-name
98
+ // registration replaces safely; the handler tombstone is a symmetry safeguard even though
99
+ // replacement means the old handler will no longer be called.
107
100
  const handler = options.handler as ((...a: unknown[]) => unknown) | undefined;
108
101
  const wrapped = handler
109
102
  ? (...a: unknown[]) => (group!.alive ? handler(...a) : undefined)
@@ -118,7 +111,7 @@ export class PiSurface<P extends object> {
118
111
  };
119
112
  }
120
113
 
121
- /** 翻墓碑(显式拆除,hmr 补偿不走这里):翻该 key 全部世代;组不存在返回 false。 */
114
+ /** Retire all generations for a key during explicit disposal; return false when the key is absent. */
122
115
  disposeModule(key: string): boolean {
123
116
  const had = this.groups.has(key);
124
117
  for (const g of this.generations.get(key) ?? []) g.alive = false;
@@ -128,7 +121,7 @@ export class PiSurface<P extends object> {
128
121
  return had;
129
122
  }
130
123
 
131
- /** 组数(测试/诊断)。 */
124
+ /** Number of live module groups, for tests and diagnostics. */
132
125
  get moduleCount(): number {
133
126
  return this.groups.size;
134
127
  }