pi-pier 0.1.5 → 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
@@ -1,34 +1,34 @@
1
1
  /**
2
- * herdr socket API 最小客户端(pi 扩展内使用)。
2
+ * Minimal herdr socket API client used inside the pi extension.
3
3
  *
4
- * 已对 herdr 0.8.0-preview(protocol 19, Windows named pipe)逐项实测(见 WIRE.md):
5
- * - 传输:NDJSON;请求 {id, method, params} → 响应 {id, result} | {id, error:{code,message}};
6
- * - **控制请求 = 一连接一请求**(server 应答后即关连接);
7
- * - Windows 目标 = named pipe,名称为完整 socket_path 字符串,连接时前缀 \\.\pipe\;
8
- * - pane.report_agent {pane_id, source, agent, state, message?, agent_session_id?, agent_session_path?}
9
- * 其中 state ∈ idle|working|blocked|unknown('done' 是 server 派生状态,客户端不报);
10
- * - pane.report_metadata {pane_id, source, title?, state_labels?, clear_title?, clear_state_labels?}
11
- * M22 起只报标题投影;升级首次附带把旧 pi-herdr 分块 token 写成 null;
12
- * - agent.list {} → {type:'agent_list', agents: AgentInfo[]};
13
- * - agent.wait {target, until[], timeout_ms?} → 状态命中返回 agent,超时为错误(timeout 语义);
14
- * - agent.send_keys {target, keys[]} / pane.send_text {pane_id, text}。
4
+ * Validated against herdr 0.8.0-preview (protocol 19, Windows named pipe; see WIRE.md):
5
+ * - Transport is NDJSON: request {id, method, params} → response {id, result} | {id, error:{code,message}};
6
+ * - **Control requests use one connection per request**; the server closes it after replying;
7
+ * - Windows targets are named pipes whose name is the complete socket_path with a \\.\pipe\ prefix;
8
+ * - pane.report_agent reports {pane_id, source, agent, state, message?, agent_session_id?, agent_session_path?}
9
+ * where state ∈ idle|working|blocked|unknown ('done' is server-derived and is not reported by the client);
10
+ * - pane.report_metadata reports {pane_id, source, title?, state_labels?, clear_title?, clear_state_labels?}
11
+ * M22 reports only the title projection; the first upgrade also clears legacy pi-herdr chunk tokens to null;
12
+ * - agent.list {} → {type:'agent_list', agents: AgentInfo[]};
13
+ * - agent.wait {target, until[], timeout_ms?} → the matching agent, or an error on timeout;
14
+ * - agent.send_keys {target, keys[]} / pane.send_text {pane_id, text}.
15
15
  *
16
- * v1.1(DESIGN.md §12)接口面:
17
- * 报告 ×3、目录、spawn、注入(send_text)、按键(send_keys)、等待(agent.wait)、会话路径查询。
18
- * 已删除:waitSettled(标记版)、readPaneText(pane 文本解析)、onAgentState(订阅长连接)
19
- * —— 结果通道改会话 JSONL(session-tail.ts),状态等待改 server 端 agent.wait。
16
+ * The v1.1 (DESIGN.md §12) surface reports three projections, lists, spawns, injects
17
+ * text, sends keys, waits via agent.wait, and queries session paths. Removed operations
18
+ * (waitSettled marker polling, readPaneText parsing, onAgentState long-lived subscriptions)
19
+ * are replaced by session JSONL results (session-tail.ts) and server-side agent.wait.
20
20
  *
21
- * 设计(DESIGN.md §4.1):无 herdr 环境 → Noop 降级,pi 独立可用;
22
- * 上报类调用失败静默,绝不影响 pi 主流程。
21
+ * Design (DESIGN.md §4.1): without herdr, fall back to Noop so pi remains independent;
22
+ * failures in reporting calls stay silent and never affect the main pi flow.
23
23
  */
24
24
  import { REPORT_AGENT_SOURCE, type TodoItem } from './vocab.ts';
25
25
  import { BLOCKED_LABEL_KEY, SIDEBAR_ASK_TOKEN, formatBlockedLabel, formatPaneTitle, sidebarTodoTokens, staleTokenClearance } from './pane-title.ts';
26
26
  import { LOCK_BATCH_LIMIT, LOCK_TTL_MS } from './lock-core.ts';
27
27
  import * as net from 'node:net';
28
28
 
29
- /* ── 类型 ──────────────────────────────────────────────────────────── */
29
+ /* ── Types ──────────────────────────────────────────────────────────── */
30
30
 
31
- /** herdr agent 语义状态(schema 实测五态;'done' 为 server 派生)。 */
31
+ /** Herdr agent semantic state; schema has five observed states and 'done' is server-derived. */
32
32
  export type HerdrAgentState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown';
33
33
 
34
34
  export interface HerdrEnv {
@@ -38,20 +38,20 @@ export interface HerdrEnv {
38
38
  tabId: string;
39
39
  }
40
40
 
41
- /** report_agent 可上报的状态('done' 由 server 派生)。 */
41
+ /** State reportable through report_agent; 'done' is derived by the server. */
42
42
  export type PaneAgentState = 'idle' | 'working' | 'blocked' | 'unknown';
43
43
 
44
44
  export interface AgentInfo {
45
45
  paneId: string;
46
46
  agent: string | null;
47
47
  status: HerdrAgentState;
48
- /** agent_session.value(session id 或路径),供恢复/跳转。 */
48
+ /** agent_session.value (session ID or path), retained for recovery and navigation. */
49
49
  session: string | null;
50
50
  stateLabels: Record<string, string>;
51
51
  tokens: Record<string, string | null>;
52
52
  }
53
53
 
54
- /** TabInfo 投影(schema 实测:tab_id/workspace_id/number/label/focused/pane_count/agent_status)。 */
54
+ /** TabInfo projection; fields mirror the observed tab schema. */
55
55
  export interface TabInfo {
56
56
  tabId: string;
57
57
  workspaceId: string;
@@ -73,64 +73,71 @@ export function detectHerdrEnv(env: NodeJS.ProcessEnv = process.env): HerdrEnv |
73
73
  };
74
74
  }
75
75
 
76
+ /** Resolve herdr socket target (Windows named-pipe prefix vs Unix path). */
77
+ export function herdrSocketTarget(socketPath: string, platform: NodeJS.Platform = process.platform): string {
78
+ if (platform !== 'win32') return socketPath;
79
+ return socketPath.startsWith('\\\\.\\pipe\\') ? socketPath : `\\\\.\\pipe\\${socketPath}`;
80
+ }
81
+
82
+
76
83
  export interface HerdrClientLike {
77
84
  readonly available: boolean;
78
85
  reportAgent(state: PaneAgentState, message: string | null): Promise<void>;
79
86
  reportAgentSession(sessionPath: string | null): Promise<void>;
80
87
  reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void>;
81
- /** M18:写锁 token 上报(键 lock-<hash>,值 paneId|path;null = 释放;≤16 键/批自动分批)。 */
88
+ /** M18: report write-lock tokens (lock-<hash> → paneId|path); null releases, and batches stay within 16 keys. */
82
89
  reportLockTokens(tokens: Record<string, string | null>): Promise<void>;
83
90
  /**
84
- * D93:侧边栏 agent 显示名(display_agent = role 名)。
85
- * 一次上报持续生效(无 TTL);null = 清除回检测值。尽力而为。
91
+ * D93: sidebar agent display name (display_agent is the role name).
92
+ * The report persists without a TTL; null clears it back to the detected value. Best effort.
86
93
  */
87
94
  reportDisplayAgent(name: string | null): Promise<void>;
88
95
  /**
89
- * D95:ask_user_question 等待标志(tokens['pi-ask'];null 清空)。
90
- * 侧边栏/热力分级用:blocked + pi-ask = 人类闸门(ask 级),纯 blocked = block 级。
96
+ * D95: ask_user_question waiting marker (tokens['pi-ask']; null clears it).
97
+ * Sidebar/heatmap grading distinguishes blocked + pi-ask (ask level) from plain blocked (block level).
91
98
  */
92
99
  reportAskFlag(text: string | null): Promise<void>;
93
100
  listAgents(): Promise<AgentInfo[]>;
94
- /** 在新 tab 里以 argv 启动子 pane(layout.apply),返回 pane/tab id。 */
101
+ /** Start a child pane with argv in a new tab (layout.apply), returning pane/tab IDs. */
95
102
  spawnSubPane(opts: { label: string; command: string[]; cwd: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
96
- /** 向 pane 终端注入文本(pi 编辑器输入 + Enter;send_text+CR 实测可直达;仅用于 launchLine 起进程)。 */
103
+ /** Inject text into a pane terminal (pi editor input + Enter); send_text+CR reaches it directly and is used only to start launchLine. */
97
104
  sendPaneText(paneId: string, text: string): Promise<void>;
98
- /** server 端等待 agent 状态;命中返回状态,超时返回 null,错误抛出。 */
105
+ /** Wait for an agent state server-side; return the matched state, null on timeout, and throw on error. */
99
106
  waitAgent(paneId: string, until: HerdrAgentState[], timeoutMs: number): Promise<HerdrAgentState | null>;
100
- /** 查询子代理上报的会话路径(agent_session.value,kind=path);无则 null。 */
107
+ /** Query the child-agent session path (agent_session.value, kind=path); return null when absent. */
101
108
  getAgentSessionPath(paneId: string): Promise<string | null>;
102
- /** v1.2:聚焦 pane(组 tab 追加的前置)。 */
109
+ /** v1.2: Focus a pane before adding it to a group tab. */
103
110
  focusPane(paneId: string): Promise<void>;
104
- /** v1.2:在当前 tab 拆分出新 shell pane(focus 后调用,落组 tab)。返回新 paneId。 */
111
+ /** v1.2: Split a new shell pane in the current tab after focus, placing it in the group tab; return its pane ID. */
105
112
  splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string }): Promise<string>;
106
- /** v1.2:关闭 pane(实测杀进程树;空 tab 由 herdr 自动关)。 */
113
+ /** v1.2: Close a pane; observed behavior kills its process tree and herdr closes an empty tab. */
107
114
  closePane(paneId: string): Promise<void>;
108
- /** v1.2:创建 tab(含根 shell pane),返回 tabId/paneId(组 tab 基础设施)。 */
115
+ /** v1.2: Create a tab with a root shell pane, returning tabId/paneId for group-tab infrastructure. */
109
116
  createTab(opts: { workspaceId: string; label?: string; cwd?: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
110
- /** v1.2:列出全部 pane(含 tab 归属;组 tab 追加用)。 */
117
+ /** v1.2: List all panes with tab ownership for group-tab additions. */
111
118
  listPanes(): Promise<Array<{ paneId: string; tabId: string; workspaceId: string; agentStatus: string }>>;
112
- /** D91:导出 tab 布局树(layout.export;paneId/tabId 二选一定位)。尽力而为:失败返回 null。 */
119
+ /** D91: Export the tab layout tree, locating it by paneId or tabId; best effort returns null on failure. */
113
120
  exportLayout(opts: { paneId?: string; tabId?: string }): Promise<{ tabId: string | null; zoomed: boolean; root: unknown } | null>;
114
- /** v1.3:列出 tab(tab.list → {type:'tab_list', tabs:[TabInfo]},schema 实测)。 */
121
+ /** v1.3: List tabs (tab.list); fields follow the observed schema. */
115
122
  tabList(): Promise<TabInfo[]>;
116
- /** v1.3:查 tab 详情(tab.get → {type:'tab_info', tab});不存在返回 null。 */
123
+ /** v1.3: Get tab details (tab.get); return null when absent. */
117
124
  tabGet(tabId: string): Promise<TabInfo | null>;
118
- /** v1.3:关闭 tab(tab.close → {type:'tab_closed', ...};级联关闭其 pane)。 */
125
+ /** v1.3: Close a tab (tab.close), cascading to its panes. */
119
126
  tabClose(tabId: string): Promise<void>;
120
- /** M14:发送按键组合(pane.send_keys;Herdr key-combo:ctrl+c / enter / esc …)。 */
127
+ /** M14: Send a key combination (pane.send_keys; Herdr key combos include ctrl+c, enter, and esc). */
121
128
  sendPaneKeys(paneId: string, keys: string[]): Promise<void>;
122
- /** M14:读 pane 输出缓冲(pane.read;source=recent 近期缓冲,strip_ansi=false 保留 T6 检测原料)。 */
129
+ /** M14: Read the pane output buffer; recent preserves ANSI data for T6 detection by default. */
123
130
  readPane(paneId: string, opts?: {
124
131
  source?: 'visible' | 'recent' | 'recent_unwrapped';
125
132
  lines?: number;
126
133
  stripAnsi?: boolean;
127
134
  }): Promise<{ text: string; revision: number; truncated: boolean }>;
128
- /** M14:等输出匹配(pane.wait_for_output;substring/regex)。超时返回 null,错误抛出。 */
135
+ /** M14: Wait for output to match a substring or regex; return null on timeout and throw on error. */
129
136
  waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<boolean | null>;
130
137
  close(): void;
131
138
  }
132
139
 
133
- /** 深度优先在响应对象里找第一个字符串字段(herdr 信封形状多样,id 位置不一)。 */
140
+ /** Find the first string field named key by depth-first search; herdr envelopes place IDs inconsistently. */
134
141
  function findIdIn(obj: unknown, key: string, depth = 0): string | null {
135
142
  if (!obj || depth > 6) return null;
136
143
  if (typeof obj === 'object') {
@@ -198,23 +205,24 @@ export class NoopHerdrClient implements HerdrClientLike {
198
205
  close(): void {}
199
206
  }
200
207
 
201
- /* ── 实现 ──────────────────────────────────────────────────────────── */
208
+ /* ── Implementation ────────────────────────────────────────────────── */
202
209
 
203
210
  export class HerdrClient implements HerdrClientLike {
204
211
  readonly available = true;
205
212
  private clearedStaleTokens = false;
213
+ private readonly env: HerdrEnv;
206
214
 
207
- constructor(private readonly env: HerdrEnv) {}
215
+ constructor(env: HerdrEnv) {
216
+ this.env = env;
217
+ }
208
218
 
209
219
  private target(): string {
210
- const p = this.env.socketPath;
211
- if (process.platform !== 'win32') return p;
212
- return p.startsWith('\\\\.\\pipe\\') ? p : `\\\\.\\pipe\\${p}`;
220
+ return herdrSocketTarget(this.env.socketPath);
213
221
  }
214
222
 
215
223
  /**
216
- * 控制请求:一连接一请求(server 应答后关连接,实测协议行为)。
217
- * 读满一行响应即完成;响应是 {id, result} | {id, error}。
224
+ * Control requests use one connection per request; the server closes it after replying, as observed in the protocol.
225
+ * Reading a complete response line finishes the request; responses are {id, result} or {id, error}.
218
226
  */
219
227
  private request(method: string, params: Record<string, unknown>, timeoutMs = 15000): Promise<unknown> {
220
228
  return new Promise((resolve, reject) => {
@@ -272,7 +280,7 @@ export class HerdrClient implements HerdrClientLike {
272
280
  });
273
281
  }
274
282
 
275
- /* ── 上报类(失败静默:工作台镜像尽力而为,绝不影响 pi 主流程) ── */
283
+ /* ── Reporting (silent best effort: the workbench mirror must never affect pi's main flow) ── */
276
284
 
277
285
  async reportAgent(state: PaneAgentState, message: string | null): Promise<void> {
278
286
  try {
@@ -284,7 +292,7 @@ export class HerdrClient implements HerdrClientLike {
284
292
  ...(message ? { message } : {}),
285
293
  });
286
294
  } catch {
287
- /* 静默 */
295
+ /* Silent best effort. */
288
296
  }
289
297
  }
290
298
 
@@ -298,7 +306,7 @@ export class HerdrClient implements HerdrClientLike {
298
306
  agent_session_path: sessionPath,
299
307
  });
300
308
  } catch {
301
- /* 静默 */
309
+ /* Silent best effort. */
302
310
  }
303
311
  }
304
312
 
@@ -310,8 +318,8 @@ export class HerdrClient implements HerdrClientLike {
310
318
  lastWriteAt: meta.lastWriteAt,
311
319
  });
312
320
  const blocked = formatBlockedLabel(meta.items);
313
- // D96:stale 清理与日常上报分离——stale(16) + pi-todo(1) = 17 > herdr tokens max=16
314
- // → 整个请求被拒 → title/tokens 全丢(D93 回归根因)。stale 单独批次,成功后才置位。
321
+ // D96: separate stale cleanup from the daily report—stale(16) + pi-todo(1) exceeds herdr's 16-token limit,
322
+ // so one rejected request would lose both title and tokens (the D93 regression root cause). Set stale in its own batch first.
315
323
  if (!this.clearedStaleTokens) {
316
324
  await this.request('pane.report_metadata', {
317
325
  pane_id: this.env.paneId,
@@ -328,12 +336,12 @@ export class HerdrClient implements HerdrClientLike {
328
336
  ...(blocked
329
337
  ? { state_labels: { [BLOCKED_LABEL_KEY]: blocked } }
330
338
  : { clear_state_labels: true }),
331
- // D93:todo 摘要进 custom token(空串 = 清键,无 todo 不留旧摘要)
339
+ // D93: put the todo summary in a custom token; an empty string clears the key so stale summaries do not remain.
332
340
  tokens: sidebarTodoTokens(title),
333
341
  ttl_ms: 86400000,
334
342
  });
335
343
  } catch {
336
- /* 静默:标题投影尽力而为,绝不影响 pi 主流程 */
344
+ /* Silent best effort: the title projection must never affect pi's main flow. */
337
345
  }
338
346
  }
339
347
 
@@ -341,7 +349,7 @@ export class HerdrClient implements HerdrClientLike {
341
349
  const keys = Object.keys(tokens);
342
350
  if (keys.length === 0) return;
343
351
  try {
344
- // schema maxProperties=16 → 分批(每批独立请求,全部尽力而为)
352
+ // schema maxProperties=16, so split into independent best-effort requests.
345
353
  for (let i = 0; i < keys.length; i += LOCK_BATCH_LIMIT) {
346
354
  const batch: Record<string, string | null> = {};
347
355
  for (const k of keys.slice(i, i + LOCK_BATCH_LIMIT)) batch[k] = tokens[k];
@@ -353,7 +361,7 @@ export class HerdrClient implements HerdrClientLike {
353
361
  });
354
362
  }
355
363
  } catch {
356
- /* 锁登记尽力而为(软 veto 下失败 = 少一次警告,不阻断) */
364
+ /* Lock registration is best effort; under soft veto, failure only loses a warning and does not block. */
357
365
  }
358
366
  }
359
367
 
@@ -365,7 +373,7 @@ export class HerdrClient implements HerdrClientLike {
365
373
  ...(name ? { display_agent: name } : { clear_display_agent: true }),
366
374
  });
367
375
  } catch {
368
- /* 静默:侧边栏标识尽力而为 */
376
+ /* Silent best effort: sidebar identity is supplementary. */
369
377
  }
370
378
  }
371
379
 
@@ -378,11 +386,11 @@ export class HerdrClient implements HerdrClientLike {
378
386
  ttl_ms: 86400000,
379
387
  });
380
388
  } catch {
381
- /* 静默:人类闸门标志尽力而为 */
389
+ /* Silent best effort: the human-gate marker is supplementary. */
382
390
  }
383
391
  }
384
392
 
385
- /* ── 查询与控制 ───────────────────────────────────────────────────── */
393
+ /* ── Queries and control ─────────────────────────────────────────── */
386
394
 
387
395
  async listAgents(): Promise<AgentInfo[]> {
388
396
  const result = (await this.request('agent.list', {})) as {
@@ -414,7 +422,7 @@ export class HerdrClient implements HerdrClientLike {
414
422
  }
415
423
 
416
424
  async sendPaneText(paneId: string, text: string): Promise<void> {
417
- // 尾部 \r 即 Enter(实测:文本进入 pi 编辑器并提交)
425
+ // A trailing \r acts as Enter; observed behavior sends text to the pi editor and submits it.
418
426
  await this.request('pane.send_text', { pane_id: paneId, text: text + '\r' });
419
427
  }
420
428
 
@@ -489,7 +497,7 @@ export class HerdrClient implements HerdrClientLike {
489
497
  ...(opts.paneId ? { pane_id: opts.paneId } : {}),
490
498
  ...(opts.tabId ? { tab_id: opts.tabId } : {}),
491
499
  })) as Record<string, unknown> | null;
492
- // 响应信封:{layout:{tab_id,zoomed,root}};兼容 root 直接在顶层
500
+ // Observed envelope: {layout:{tab_id,zoomed,root}}; accept root directly at the top level for compatibility.
493
501
  const layout = (result?.layout ?? result) as Record<string, unknown> | null | undefined;
494
502
  if (!layout || typeof layout !== 'object' || !('root' in layout)) return null;
495
503
  return {
@@ -536,7 +544,7 @@ export class HerdrClient implements HerdrClientLike {
536
544
  lines?: number;
537
545
  stripAnsi?: boolean;
538
546
  } = {}): Promise<{ text: string; revision: number; truncated: boolean }> {
539
- // 信封实测:{type:'pane_read', read:{text, revision, truncated, ...}}(payload 套在 read 下)
547
+ // Observed envelope: {type:'pane_read', read:{text, revision, truncated, ...}}; the payload is nested under read.
540
548
  const result = (await this.request('pane.read', {
541
549
  pane_id: paneId,
542
550
  source: opts.source ?? 'recent',
@@ -569,7 +577,7 @@ export class HerdrClient implements HerdrClientLike {
569
577
  }
570
578
 
571
579
  close(): void {
572
- /* v1.1 无长驻连接;保留接口语义(Noop) */
580
+ /* v1.1 has no long-lived connection; retain the interface semantics as a no-op. */
573
581
  }
574
582
  }
575
583
 
@@ -1,21 +1,24 @@
1
1
  /**
2
- * workspace 历史记录(v1.2,DESIGN.md §13.4)。
2
+ * Workspace history (v1.2, DESIGN.md §13.4).
3
3
  *
4
- * 任务历史 = append-only JSONL,按 cwd 分区(与 pi 会话分区同构):
4
+ * Task history is append-only JSONL partitioned by cwd, mirroring pi session partitioning:
5
5
  * <agentRoot>/herdr-pi/history/--<cwd>--/history.jsonl
6
- * 记录不可变:GC 只关 pane,绝不删历史。恢复双保险:sessionFile + launchCommand 快照。
6
+ * Records are immutable so GC can remove panes without deleting history. Recovery keeps
7
+ * both the sessionFile and launchCommand snapshots as fallbacks.
7
8
  */
8
9
  import * as fs from 'node:fs';
9
10
  import * as path from 'node:path';
10
- import { sessionDirName } from './session-tail.ts';
11
+ import { historyFilePath, preferredHistoryFile } from './storage-layout.ts';
11
12
 
12
- /** 旧 short/resident → task;空/缺省 → task;role 名原样。 */
13
+ export { historyFilePath, preferredHistoryFile };
14
+
15
+ /** Legacy short/resident entries normalize to task; missing values do too, while role names pass through unchanged. */
13
16
  export function normalizeEntryKind(kind: string | null | undefined): string {
14
17
  if (!kind || kind === 'short' || kind === 'resident') return 'task';
15
18
  return kind;
16
19
  }
17
20
 
18
- /** 结算回写:只接受 .jsonl 路径,非法不覆盖(O6)。 */
21
+ /** O6: only a .jsonl path is accepted during settlement, so invalid reports cannot overwrite a valid path. */
19
22
  export function applyReportedSessionFile(
20
23
  current: string | null,
21
24
  reported: string | null | undefined,
@@ -26,57 +29,70 @@ export function applyReportedSessionFile(
26
29
 
27
30
  export interface HistoryEntry {
28
31
  taskId: string;
29
- /** 'task' 或 role 名;读盘时 short/resident 归一为 task。 */
32
+ /** 'task' or a role name; disk reads normalize short/resident to task for compatibility. */
30
33
  kind: string;
31
34
  paneId: string;
32
35
  tabId: string;
33
- /** v1.3:任务 tab 名(历史回看/复活参考;旧条目缺省)。 */
36
+ /** v1.3: task tab name retained for history review and revival; omitted by older entries. */
34
37
  tabName?: string;
35
38
  workspaceId: string;
36
39
  cwd: string;
37
40
  description: string;
38
- /** 子代理会话文件(resume 首选)。 */
41
+ /** Child-agent session file, preferred when resuming. */
39
42
  sessionFile: string | null;
40
- /** 启动命令快照(sessionFile 丢失时重建空会话续接)。 */
43
+ /** Launch command snapshot, used to rebuild an empty continuation when sessionFile is missing. */
41
44
  launchCommand: string[];
42
45
  status: 'running' | 'settled' | 'consumed' | 'closed';
43
46
  outcome?: string | null;
44
47
  createdAt: number;
45
- /** 消费时间(M13c 起随条目落盘;GC TTL 判据的 durable 记录)。 */
48
+ /** M13c: durable consumption time, which provides the GC TTL basis. */
46
49
  consumedAt?: number | null;
47
50
  closedAt?: number | null;
48
- /** 打回重做/复活时的前代 paneId。 */
51
+ /** Previous-generation pane ID, retained when work is redone or revived. */
49
52
  revivedFrom?: string | null;
50
- /** B5:写手标记(spawn / poll-settle / poll-timeout / gc / zombie-sweep …)——
51
- * 同秒多行可审计(01a03c0d 台账 row#23/24 双写实证)。 */
53
+ /** B5: writer marker (spawn / poll-settle / poll-timeout / gc / zombie-sweep …), so
54
+ * multiple entries in one second remain auditable (ledger row#23/24 proves both writes). */
52
55
  via?: string;
53
56
  }
54
57
 
55
- /** B5:closed 行 outcome 继承——patch 未显式指定 outcome 时沿用该 taskId 最近非空值,
56
- * 「最新行即现状」语义下结算成果不因 GC 补记而丢失。 */
58
+ /** B5: inherit outcome for a closed row—when patch omits it, use the latest non-empty value
59
+ * for that taskId, preventing GC bookkeeping from erasing settlement results under the
60
+ * “latest row is state” rule. */
57
61
  export function inheritOutcome(lastOutcome: string | null | undefined, patchOutcome: string | null | undefined): string | null {
58
62
  if (patchOutcome !== undefined) return patchOutcome;
59
63
  return lastOutcome ?? null;
60
64
  }
61
65
 
62
- export function historyFilePath(agentRoot: string, cwd: string): string {
63
- return path.join(agentRoot, 'herdr-pi', 'history', sessionDirName(cwd), 'history.jsonl');
66
+
67
+
68
+ const HISTORY_STATUSES: Record<string, true> = {
69
+ running: true,
70
+ settled: true,
71
+ consumed: true,
72
+ closed: true,
73
+ };
74
+
75
+ function coerceHistoryEntry(obj: unknown): HistoryEntry | null {
76
+ if (obj === null || typeof obj !== 'object') return null;
77
+ const rec = obj as Record<string, unknown>;
78
+ if (typeof rec.taskId !== 'string' || rec.taskId.trim() === '') return null;
79
+ if (typeof rec.status !== 'string' || HISTORY_STATUSES[rec.status] !== true) return null;
80
+ if (rec.createdAt !== undefined && !Number.isFinite(rec.createdAt)) return null;
81
+ const raw = rec as unknown as HistoryEntry;
82
+ return { ...raw, kind: normalizeEntryKind(raw.kind) };
64
83
  }
65
84
 
66
- /** 容忍式逐行解析(损坏行跳过)。 */
85
+ /** Parse line by line; skip junk JSON and records missing taskId/status. */
67
86
  export function parseHistoryEntries(text: string): HistoryEntry[] {
68
87
  const entries: HistoryEntry[] = [];
69
88
  for (const line of (text ?? '').split(/\r?\n/)) {
70
89
  const t = line.trim();
71
90
  if (!t) continue;
72
91
  try {
73
- const obj = JSON.parse(t);
74
- if (obj && typeof obj === 'object' && typeof obj.taskId === 'string') {
75
- const raw = obj as HistoryEntry;
76
- entries.push({ ...raw, kind: normalizeEntryKind(raw.kind) });
77
- }
92
+ const next = coerceHistoryEntry(JSON.parse(t));
93
+ if (next) entries.push(next);
78
94
  } catch {
79
- /* 跳过损坏行 */
95
+ /* Skip malformed records. */
80
96
  }
81
97
  }
82
98
  return entries;
@@ -86,26 +102,44 @@ function serializeEntry(entry: HistoryEntry): string {
86
102
  return JSON.stringify(entry);
87
103
  }
88
104
 
89
- /** 读取历史文件;不存在返回 []。 */
90
- export function readHistory(file: string): HistoryEntry[] {
105
+ export type HistoryReadResult =
106
+ | { status: 'ok'; entries: HistoryEntry[] }
107
+ | { status: 'missing' }
108
+ | { status: 'unreadable'; error: Error };
109
+
110
+ /** Distinguish missing vs permission/IO failure. readHistory still returns [] for both. */
111
+ export function inspectHistory(file: string): HistoryReadResult {
91
112
  try {
92
- return parseHistoryEntries(fs.readFileSync(file, 'utf8'));
93
- } catch {
94
- return [];
113
+ if (!fs.existsSync(file)) return { status: 'missing' };
114
+ return { status: 'ok', entries: parseHistoryEntries(fs.readFileSync(file, 'utf8')) };
115
+ } catch (error) {
116
+ const err = error instanceof Error ? error : new Error(String(error));
117
+ return { status: 'unreadable', error: err };
95
118
  }
96
119
  }
97
120
 
98
- /** 追加一条(确保目录存在)。 */
99
- export function appendHistory(file: string, entry: HistoryEntry): void {
121
+ /** Read a history file; missing or unreadable yields []. */
122
+ export function readHistory(file: string): HistoryEntry[] {
123
+ const r = inspectHistory(file);
124
+ return r.status === 'ok' ? r.entries : [];
125
+ }
126
+
127
+ export type HistoryWriteResult = { ok: true } | { ok: false; error: Error };
128
+
129
+ /** Append one entry. Failures are logged and returned so the ledger is not silently lost. */
130
+ export function appendHistory(file: string, entry: HistoryEntry): HistoryWriteResult {
100
131
  try {
101
132
  fs.mkdirSync(path.dirname(file), { recursive: true });
102
133
  fs.appendFileSync(file, serializeEntry(entry) + '\n');
103
- } catch {
104
- /* 尽力而为:历史写入失败不影响主流程 */
134
+ return { ok: true };
135
+ } catch (error) {
136
+ const err = error instanceof Error ? error : new Error(String(error));
137
+ console.error(`[pi-herdr] history append failed: ${file}: ${err.message}`);
138
+ return { ok: false, error: err };
105
139
  }
106
140
  }
107
141
 
108
- /** 折叠:每个 taskId 的全部代际(按 createdAt 升序)。 */
142
+ /** Group all generations of each taskId in ascending createdAt order for review. */
109
143
  export function generationsByTask(entries: readonly HistoryEntry[]): Map<string, HistoryEntry[]> {
110
144
  const map = new Map<string, HistoryEntry[]>();
111
145
  for (const e of entries) {
@@ -117,7 +151,7 @@ export function generationsByTask(entries: readonly HistoryEntry[]): Map<string,
117
151
  return map;
118
152
  }
119
153
 
120
- /** 取 taskId 的最新代(resume 用)。 */
154
+ /** Return the newest generation for taskId, which is the one used for resume. */
121
155
  export function latestGeneration(entries: readonly HistoryEntry[], taskId: string): HistoryEntry | null {
122
156
  const gens = generationsByTask(entries).get(taskId);
123
157
  if (!gens || gens.length === 0) return null;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * M18 file write-lock installer (soft veto / hard block).
3
+ *
4
+ * Why: lock acquire/release/command lived in the composition root next to
5
+ * unrelated session wiring. The installer owns the three pi hooks and the
6
+ * held-lock set; fail-open on agent.list remains the collaboration policy.
7
+ */
8
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
9
+ import {
10
+ acquireTokensFor,
11
+ isLockTokenKey,
12
+ parseLockTokenValue,
13
+ planWriteGuard,
14
+ releaseTokensFor,
15
+ type LockAgentView,
16
+ } from './lock-core.ts';
17
+ import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
18
+
19
+ export function installWriteLocks(
20
+ pi: ExtensionAPI,
21
+ opts: {
22
+ client: HerdrClientLike;
23
+ env: HerdrEnv | null;
24
+ hard: boolean;
25
+ },
26
+ ): void {
27
+ const { client, env, hard } = opts;
28
+ const heldLocks = new Set<string>();
29
+ const lockWarnByToolCall = new Map<string, string>();
30
+
31
+ async function acquireLocks(paths: readonly string[]): Promise<void> {
32
+ for (const p of paths) heldLocks.add(p);
33
+ await client.reportLockTokens(acquireTokensFor(paths, env?.paneId ?? ''));
34
+ }
35
+
36
+ pi.on('tool_call', async (event: { toolName?: string; toolCallId?: string; input?: unknown }, ctx: { cwd?: string }) => {
37
+ if (!client.available || !env) return;
38
+ const cwd = ctx?.cwd ?? process.cwd();
39
+ let agents: LockAgentView[];
40
+ try {
41
+ agents = (await client.listAgents()).map((a) => ({ paneId: a.paneId, tokens: a.tokens }));
42
+ } catch {
43
+ return;
44
+ }
45
+ const plan = planWriteGuard({
46
+ toolName: event.toolName ?? '',
47
+ input: event.input,
48
+ agents,
49
+ ownPaneId: env.paneId,
50
+ cwd,
51
+ hard,
52
+ });
53
+ if (plan.kind === 'skip') return;
54
+ if (plan.kind === 'block') {
55
+ return { block: true, reason: plan.reason };
56
+ }
57
+ if (plan.kind === 'warn' && typeof event.toolCallId === 'string') {
58
+ lockWarnByToolCall.set(event.toolCallId, plan.warning);
59
+ }
60
+ await acquireLocks(plan.paths);
61
+ });
62
+
63
+ pi.on('tool_result', async (event: { toolCallId?: string; content?: Array<{ type: string; text: string }> }) => {
64
+ if (typeof event?.toolCallId !== 'string') return;
65
+ const warning = lockWarnByToolCall.get(event.toolCallId);
66
+ if (!warning) return;
67
+ lockWarnByToolCall.delete(event.toolCallId);
68
+ const content = Array.isArray(event.content) ? event.content : [];
69
+ return { content: [...content, { type: 'text', text: warning }] };
70
+ });
71
+
72
+ pi.on('agent_settled', async () => {
73
+ if (heldLocks.size === 0) return;
74
+ const paths = [...heldLocks];
75
+ heldLocks.clear();
76
+ await client.reportLockTokens(releaseTokensFor(paths));
77
+ });
78
+
79
+ pi.registerCommand('locks', {
80
+ description: 'Show write locks held by this pane and all live panes (M18)',
81
+ handler: async (_args, ctx) => {
82
+ const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
83
+ const mine = [...heldLocks];
84
+ const lines = [`held by this pane (${mine.length}):`];
85
+ lines.push(...(mine.length ? mine.map((p) => ` ${p}`) : [' (none)']));
86
+ lines.push('all live locks:');
87
+ let any = false;
88
+ try {
89
+ for (const a of await client.listAgents()) {
90
+ for (const [k, v] of Object.entries(a.tokens)) {
91
+ if (!isLockTokenKey(k) || typeof v !== 'string' || !v) continue;
92
+ const parsed = parseLockTokenValue(v);
93
+ if (!parsed) continue;
94
+ any = true;
95
+ lines.push(` ${parsed.path} → pane ${parsed.holderPaneId}`);
96
+ }
97
+ }
98
+ } catch {
99
+ lines.push(' (agent.list failed)');
100
+ }
101
+ if (!any && lines[lines.length - 1] !== ' (agent.list failed)') lines.push(' (none)');
102
+ ui?.notify?.(lines.join('\n'), 'info');
103
+ },
104
+ });
105
+ }