pi-pier 0.3.0 → 0.3.1

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 (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +8 -12
  9. package/src/compact-coordinator.ts +278 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
@@ -1,28 +1,20 @@
1
1
  /**
2
2
  * Minimal herdr socket API client used inside the pi extension.
3
3
  *
4
- * Validated against herdr 0.9.1 (protocol 22, Windows named pipe; see WIRE.md). The request shapes
5
- * pier builds are pinned to herdr's own `api schema` by test/herdr-contract.test.ts — regenerate
6
- * test/fixtures/herdr-contract.json and bump HERDR_PROTOCOL_EXPECTED when the server moves on:
4
+ * Validated against herdr 0.9.1 (protocol 22; see WIRE.md). The request shapes pier builds are pinned
5
+ * to herdr's own `api schema` by test/herdr-client.test.ts — regenerate test/fixtures/herdr-contract.json
6
+ * and bump HERDR_PROTOCOL_EXPECTED when the server moves on:
7
7
  * - Transport is NDJSON: request {id, method, params} → response {id, result} | {id, error:{code,message}};
8
8
  * - **Control requests use one connection per request**; the server closes it after replying;
9
9
  * - Windows targets are named pipes whose name is the complete socket_path with a \\.\pipe\ prefix;
10
- * - pane.report_agent reports {pane_id, source, agent, state, message?, agent_session_id?, agent_session_path?}
11
- * where state ∈ idle|working|blocked|unknown ('done' is server-derived and is not reported by the client);
12
- * - pane.report_metadata reports {pane_id, source, title?, state_labels?, clear_title?, clear_state_labels?,
13
- * tokens?, ttl_ms?} (ttl_ms is capped at 24 h by the server); the first report of a session also clears
14
- * legacy pi-herdr chunk tokens to null;
15
- * - agent.list {} → {type:'agent_list', agents: AgentInfo[]};
16
- * - agent.wait {target, until[], timeout_ms?} → the matching agent, or an error on timeout;
17
- * - agent.send_keys {target, keys[]} / pane.send_text {pane_id, text}.
10
+ * - pane.report_agent reports {pane_id, source, agent, state, message?} where state ∈
11
+ * idle|working|blocked|unknown ('done' is server-derived and is not reported by the client);
12
+ * - pane.report_metadata reports {pane_id, source, title?, state_labels?, clear_*, tokens?, ttl_ms?}
13
+ * (ttl_ms is capped at 24 h by the server; the first report of a session also clears legacy
14
+ * pi-herdr chunk tokens to null).
18
15
  *
19
- * The v1.1 (DESIGN.md §12) surface reports three projections, lists, spawns, injects
20
- * text, sends keys, waits via agent.wait, and queries session paths. Removed operations
21
- * (waitSettled marker polling, readPaneText parsing, onAgentState long-lived subscriptions)
22
- * are replaced by session JSONL results (session-tail.ts) and server-side agent.wait.
23
- *
24
- * Design (DESIGN.md §4.1): without herdr, fall back to Noop so pi remains independent;
25
- * failures in reporting calls stay silent and never affect the main pi flow.
16
+ * Reporting failures stay silent and never affect the main pi flow; without herdr the Noop client
17
+ * keeps pi independent (DESIGN.md §4.1).
26
18
  */
27
19
  import { REPORT_AGENT_SOURCE, type TodoItem } from './vocab.ts';
28
20
  import { BLOCKED_LABEL_KEY, SIDEBAR_ASK_TOKEN, formatBlockedLabel, formatPaneTitle, sidebarTodoTokens, staleTokenClearance } from './pane-title.ts';
@@ -31,7 +23,6 @@ import * as net from 'node:net';
31
23
 
32
24
  /* ── Types ──────────────────────────────────────────────────────────── */
33
25
 
34
- /** Herdr agent semantic state; schema has five observed states and 'done' is server-derived. */
35
26
  export type HerdrAgentState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown';
36
27
 
37
28
  export interface HerdrEnv {
@@ -100,7 +91,6 @@ export type OpenPluginPaneResult =
100
91
  | { mode: 'pane'; paneId: string }
101
92
  | { mode: 'fallback_tab'; tabId?: string; paneId?: string };
102
93
 
103
- /** TabInfo projection; fields mirror the observed tab schema. */
104
94
  export interface TabInfo {
105
95
  tabId: string;
106
96
  workspaceId: string;
@@ -122,17 +112,13 @@ export function detectHerdrEnv(env: NodeJS.ProcessEnv = process.env): HerdrEnv |
122
112
  };
123
113
  }
124
114
 
125
- /** Resolve herdr socket target (Windows named-pipe prefix vs Unix path). */
126
115
  export function herdrSocketTarget(socketPath: string, platform: NodeJS.Platform = process.platform): string {
127
116
  if (platform !== 'win32') return socketPath;
128
117
  return socketPath.startsWith('\\\\.\\pipe\\') ? socketPath : `\\\\.\\pipe\\${socketPath}`;
129
118
  }
130
119
 
131
- /**
132
- * B5: herdr can be configured but unreachable (server stopped, stale/typo'd HERDR_SOCKET_PATH).
133
- * A raw errno ("connect ENOENT /Users/…/herdr.sock") tells the model nothing to act on, so tool
134
- * layers append this one sentence. Returns null when the failure is not a transport failure.
135
- */
120
+ /** B5: herdr configured but unreachable (stopped server, stale HERDR_SOCKET_PATH) — tool layers append
121
+ * this sentence because a raw errno tells the model nothing. Null when the failure is not transport. */
136
122
  export function herdrUnavailableHint(err: unknown): string | null {
137
123
  const msg = String((err as { message?: unknown })?.message ?? err);
138
124
  if (!/ENOENT|ECONNREFUSED|ECONNRESET|EPIPE|ENOTCONN|not connected|socket|connect |timeout/i.test(msg)) {
@@ -141,7 +127,6 @@ export function herdrUnavailableHint(err: unknown): string | null {
141
127
  return `herdr unreachable (${msg}) — check that the herdr server is running and HERDR_SOCKET_PATH matches its socket`;
142
128
  }
143
129
 
144
-
145
130
  /** Wire protocol this client is written against (herdr 0.9.1). See test/fixtures/herdr-contract.json. */
146
131
  export const HERDR_PROTOCOL_EXPECTED = 22;
147
132
 
@@ -149,74 +134,60 @@ export type WaitForOutputResult =
149
134
  | { matched: true }
150
135
  | { matched: false; reason: 'timeout' | 'unavailable' };
151
136
 
137
+ /** pane.read / agent.read selection: `recent` keeps ANSI so T6 fullscreen detection still sees it. */
138
+ export interface ReadOptions {
139
+ source?: 'visible' | 'recent' | 'recent_unwrapped';
140
+ lines?: number;
141
+ stripAnsi?: boolean;
142
+ }
143
+
144
+ /** Decoded read payload; `revision` is the server's buffer revision (0 when unreadable). */
145
+ export interface ReadBuffer {
146
+ text: string;
147
+ revision: number;
148
+ truncated: boolean;
149
+ }
150
+
152
151
  export interface HerdrClientLike {
153
152
  readonly available: boolean;
154
153
  reportAgent(state: PaneAgentState, message: string | null): Promise<void>;
155
154
  reportMetadata(meta: { session: string; items: readonly TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void>;
156
155
  /** M18: report write-lock tokens (lock-<hash> → paneId|path); null releases, and batches stay within 16 keys. */
157
156
  reportLockTokens(tokens: Record<string, string | null>): Promise<void>;
158
- /**
159
- * D93: sidebar agent display name (display_agent is the role name).
160
- * The report persists without a TTL; null clears it back to the detected value. Best effort.
161
- */
157
+ /** D93: sidebar agent display name (display_agent is the role name); null clears it. Persists, no TTL. */
162
158
  reportDisplayAgent(name: string | null): Promise<void>;
163
159
  /**
164
- * D95: ask_user_question waiting marker (tokens['pi-ask']; null clears it).
165
- * Sidebar/heatmap grading distinguishes blocked + pi-ask (ask level) from plain blocked (block level).
160
+ * D95: ask_user_question waiting marker (tokens['pi-ask']; null clears it). The sidebar/heatmap
161
+ * grade blocked + pi-ask as the ask level, distinct from plain block.
166
162
  */
167
163
  reportAskFlag(text: string | null): Promise<void>;
168
164
  listAgents(): Promise<AgentInfo[]>;
169
- /** Start a child pane with argv in a new tab (layout.apply), returning pane/tab IDs. */
170
- spawnSubPane(opts: { label: string; command: string[]; cwd: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
171
- /** Inject text into a pane terminal (pi editor input + Enter); send_text+CR reaches it directly and is used only to start launchLine. */
165
+ /** Inject text into a pane terminal (pi editor input + Enter); the PTY channel is used only to start launchLine. */
172
166
  sendPaneText(paneId: string, text: string): Promise<void>;
173
- /** Wait for an agent state server-side; return the matched state, null on timeout, and throw on error. */
167
+ /** Server-side wait; null on timeout, throws on any other error. */
174
168
  waitAgent(paneId: string, until: HerdrAgentState[], timeoutMs: number): Promise<HerdrAgentState | null>;
175
169
  /** Query the child-agent session path (agent_session.value, kind=path); return null when absent. */
176
170
  getAgentSessionPath(paneId: string): Promise<string | null>;
177
- /** v1.2: Focus a pane before adding it to a group tab. */
178
- focusPane(paneId: string): Promise<void>;
179
- /** v1.2: Split a new shell pane in the current tab after focus, placing it in the group tab; return its pane ID. */
180
171
  splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string; focus?: boolean; ratio?: number }): Promise<string>;
181
- /** v1.2: Close a pane; observed behavior kills its process tree and herdr closes an empty tab. */
172
+ /** Close a pane; herdr kills its process tree and closes an empty tab. */
182
173
  closePane(paneId: string): Promise<void>;
183
- /** v1.2: Create a tab with a root shell pane, returning tabId/paneId for group-tab infrastructure. */
184
174
  createTab(opts: { workspaceId: string; label?: string; cwd?: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
185
- /** v1.2: List all panes with tab ownership for group-tab additions. */
186
175
  listPanes(): Promise<PaneListItem[]>;
187
- /** D91: Export the tab layout tree, locating it by paneId or tabId; best effort returns null on failure. */
176
+ /** D91: layout tree by paneId or tabId; null on failure. */
188
177
  exportLayout(opts?: { paneId?: string; tabId?: string }): Promise<{ tabId: string | null; zoomed: boolean; root: unknown; focusedPaneId: string | null } | null>;
189
178
  /** 0.9.1: live pane rects for the tab containing paneId. Best effort returns null. */
190
179
  paneLayout(opts?: { paneId?: string }): Promise<PaneLayoutSnapshot | null>;
191
- /** v1.3: List tabs (tab.list); fields follow the observed schema. */
192
180
  tabList(): Promise<TabInfo[]>;
193
- /** v1.3: Get tab details (tab.get); return null when absent. */
194
- tabGet(tabId: string): Promise<TabInfo | null>;
195
- /** v1.3: Close a tab (tab.close), cascading to its panes. */
181
+ /** v1.3: tab.close cascades to the tab's panes. */
196
182
  tabClose(tabId: string): Promise<void>;
197
183
  /** 0.9.1: Launch a manifest pane entrypoint with placement (popup/tab/split/overlay/zoomed). */
198
184
  openPluginPane(opts: OpenPluginPaneOptions): Promise<OpenPluginPaneResult>;
199
- /** 0.9.1: Close active popup if open; returns false if no popup was open. */
200
- closePopup(): Promise<boolean>;
201
- /** 0.9.1: Query agent explain diagnostics. */
202
185
  agentExplain(target: string): Promise<Record<string, unknown> | null>;
203
- /** 0.9.1: Query server version via ping. Cached in memory. */
204
186
  getServerVersion(): Promise<string | null>;
205
- /** M14: Send a key combination (pane.send_keys; Herdr key combos include ctrl+c, enter, and esc). */
187
+ /** pane.send_keys; herdr key combos include ctrl+c, enter, and esc. */
206
188
  sendPaneKeys(paneId: string, keys: string[]): Promise<void>;
207
- /** M14: Read the pane output buffer; recent preserves ANSI data for T6 detection by default. */
208
- readPane(paneId: string, opts?: {
209
- source?: 'visible' | 'recent' | 'recent_unwrapped';
210
- lines?: number;
211
- stripAnsi?: boolean;
212
- }): Promise<{ text: string; revision: number; truncated: boolean }>;
213
- /** Read the agent output buffer via herdr 0.9 agent.read RPC. */
214
- readAgent(target: string, opts?: {
215
- source?: 'visible' | 'recent' | 'recent_unwrapped';
216
- lines?: number;
217
- stripAnsi?: boolean;
218
- }): Promise<{ text: string; revision: number; truncated: boolean }>;
219
- /** M14: Wait for output to match a substring or regex; return discriminated result on match/timeout/unavailable. */
189
+ readPane(paneId: string, opts?: ReadOptions): Promise<ReadBuffer>;
190
+ readAgent(target: string, opts?: ReadOptions): Promise<ReadBuffer>;
220
191
  waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<WaitForOutputResult>;
221
192
  close(): void;
222
193
  }
@@ -237,6 +208,7 @@ function findIdIn(obj: unknown, key: string, depth = 0): string | null {
237
208
 
238
209
  /* ── Noop ──────────────────────────────────────────────────────────── */
239
210
 
211
+ /** Stand-in outside a herdr pane: queries answer empty, pane creation throws, reporting is a no-op. */
240
212
  export class NoopHerdrClient implements HerdrClientLike {
241
213
  readonly available = false;
242
214
  async reportAgent(): Promise<void> {}
@@ -244,66 +216,26 @@ export class NoopHerdrClient implements HerdrClientLike {
244
216
  async reportLockTokens(): Promise<void> {}
245
217
  async reportDisplayAgent(): Promise<void> {}
246
218
  async reportAskFlag(): Promise<void> {}
247
- async listAgents(): Promise<AgentInfo[]> {
248
- return [];
249
- }
250
- async spawnSubPane(): Promise<{ tabId: string; paneId: string }> {
251
- throw new Error('subagent requires a herdr-managed pane');
252
- }
253
219
  async sendPaneText(): Promise<void> {}
254
- async waitAgent(): Promise<null> {
255
- return null;
256
- }
257
- async getAgentSessionPath(): Promise<null> {
258
- return null;
259
- }
260
- async focusPane(): Promise<void> {}
261
- async splitPane(): Promise<string> {
262
- throw new Error('subagent requires a herdr-managed pane');
263
- }
264
220
  async closePane(): Promise<void> {}
265
- async createTab(): Promise<{ tabId: string; paneId: string }> {
266
- throw new Error('subagent requires a herdr-managed pane');
267
- }
268
- async listPanes(): Promise<PaneListItem[]> {
269
- return [];
270
- }
271
- async exportLayout(): Promise<null> {
272
- return null;
273
- }
274
- async paneLayout(): Promise<null> {
275
- return null;
276
- }
277
- async tabList(): Promise<TabInfo[]> {
278
- return [];
279
- }
280
- async tabGet(): Promise<TabInfo | null> {
281
- return null;
282
- }
283
221
  async tabClose(): Promise<void> {}
284
- async openPluginPane(): Promise<OpenPluginPaneResult> {
285
- throw new Error('plugin pane requires a herdr-managed pane');
286
- }
287
- async closePopup(): Promise<boolean> {
288
- return false;
289
- }
290
- async agentExplain(): Promise<null> {
291
- return null;
292
- }
293
- async getServerVersion(): Promise<null> {
294
- return null;
295
- }
296
222
  async sendPaneKeys(): Promise<void> {}
297
- async readPane(): Promise<{ text: string; revision: number; truncated: boolean }> {
298
- return { text: '', revision: 0, truncated: false };
299
- }
300
- async readAgent(): Promise<{ text: string; revision: number; truncated: boolean }> {
301
- return { text: '', revision: 0, truncated: false };
302
- }
303
- async waitForOutput(): Promise<WaitForOutputResult> {
304
- return { matched: false, reason: 'unavailable' };
305
- }
306
223
  close(): void {}
224
+ async listAgents(): Promise<AgentInfo[]> { return []; }
225
+ async listPanes(): Promise<PaneListItem[]> { return []; }
226
+ async tabList(): Promise<TabInfo[]> { return []; }
227
+ async waitAgent(): Promise<null> { return null; }
228
+ async getAgentSessionPath(): Promise<null> { return null; }
229
+ async exportLayout(): Promise<null> { return null; }
230
+ async paneLayout(): Promise<null> { return null; }
231
+ async agentExplain(): Promise<null> { return null; }
232
+ async getServerVersion(): Promise<null> { return null; }
233
+ async readPane(): Promise<ReadBuffer> { return { text: '', revision: 0, truncated: false }; }
234
+ async readAgent(): Promise<ReadBuffer> { return { text: '', revision: 0, truncated: false }; }
235
+ async waitForOutput(): Promise<WaitForOutputResult> { return { matched: false, reason: 'unavailable' }; }
236
+ async splitPane(): Promise<string> { throw new Error('subagent requires a herdr-managed pane'); }
237
+ async createTab(): Promise<{ tabId: string; paneId: string }> { throw new Error('subagent requires a herdr-managed pane'); }
238
+ async openPluginPane(): Promise<OpenPluginPaneResult> { throw new Error('plugin pane requires a herdr-managed pane'); }
307
239
  }
308
240
 
309
241
  /* ── Implementation ────────────────────────────────────────────────── */
@@ -321,10 +253,7 @@ export class HerdrClient implements HerdrClientLike {
321
253
  return herdrSocketTarget(this.env.socketPath);
322
254
  }
323
255
 
324
- /**
325
- * Control requests use one connection per request; the server closes it after replying, as observed in the protocol.
326
- * Reading a complete response line finishes the request; responses are {id, result} or {id, error}.
327
- */
256
+ /** One connection per control request: the reply is a single {id,result}|{id,error} line. */
328
257
  private request(method: string, params: Record<string, unknown>, timeoutMs = 15000): Promise<unknown> {
329
258
  return new Promise((resolve, reject) => {
330
259
  const sock = net.createConnection(this.target());
@@ -393,13 +322,11 @@ export class HerdrClient implements HerdrClientLike {
393
322
  ...(message ? { message } : {}),
394
323
  });
395
324
  } catch {
396
- /* Silent best effort. */
397
325
  }
398
326
  }
399
327
 
400
- // D-2: no reportAgentSession(). herdr's native pi integration owns the session path; pier stopped
401
- // sending pane.report_agent_session so the field has a single writer. `report_agent` keeps carrying the
402
- // activity badge, because `state` is a required field there and pier's todo/role text is pier-only data.
328
+ // Single writer: herdr's native pi integration owns the session path, so pier never sends
329
+ // pane.report_agent_session; the activity badge stays on report_agent.
403
330
 
404
331
  async reportMetadata(meta: { session: string; items: readonly TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void> {
405
332
  try {
@@ -409,8 +336,8 @@ export class HerdrClient implements HerdrClientLike {
409
336
  lastWriteAt: meta.lastWriteAt,
410
337
  });
411
338
  const blocked = formatBlockedLabel(meta.items);
412
- // D96: separate stale cleanup from the daily report—stale(16) + pi-todo(1) exceeds herdr's 16-token limit,
413
- // so one rejected request would lose both title and tokens (the D93 regression root cause). Set stale in its own batch first.
339
+ // D96: stale cleanup needs its own batch — stale(16) + pi-todo(1) exceeds herdr's 16-token
340
+ // limit, and the rejected request would drop both the title and the tokens.
414
341
  if (!this.clearedStaleTokens) {
415
342
  await this.request('pane.report_metadata', {
416
343
  pane_id: this.env.paneId,
@@ -427,12 +354,11 @@ export class HerdrClient implements HerdrClientLike {
427
354
  ...(blocked
428
355
  ? { state_labels: { [BLOCKED_LABEL_KEY]: blocked } }
429
356
  : { clear_state_labels: true }),
430
- // D93: put the todo summary in a custom token; an empty string clears the key so stale summaries do not remain.
357
+ // D93: the todo summary is a custom token; an empty string clears the key instead of leaving a stale summary.
431
358
  tokens: sidebarTodoTokens(title),
432
359
  ttl_ms: 86400000,
433
360
  });
434
361
  } catch {
435
- /* Silent best effort: the title projection must never affect pi's main flow. */
436
362
  }
437
363
  }
438
364
 
@@ -440,7 +366,6 @@ export class HerdrClient implements HerdrClientLike {
440
366
  const keys = Object.keys(tokens);
441
367
  if (keys.length === 0) return;
442
368
  try {
443
- // schema maxProperties=16, so split into independent best-effort requests.
444
369
  for (let i = 0; i < keys.length; i += LOCK_BATCH_LIMIT) {
445
370
  const batch: Record<string, string | null> = {};
446
371
  for (const k of keys.slice(i, i + LOCK_BATCH_LIMIT)) batch[k] = tokens[k];
@@ -464,7 +389,6 @@ export class HerdrClient implements HerdrClientLike {
464
389
  ...(name ? { display_agent: name } : { clear_display_agent: true }),
465
390
  });
466
391
  } catch {
467
- /* Silent best effort: sidebar identity is supplementary. */
468
392
  }
469
393
  }
470
394
 
@@ -477,7 +401,6 @@ export class HerdrClient implements HerdrClientLike {
477
401
  ttl_ms: 86400000,
478
402
  });
479
403
  } catch {
480
- /* Silent best effort: the human-gate marker is supplementary. */
481
404
  }
482
405
  }
483
406
 
@@ -503,16 +426,6 @@ export class HerdrClient implements HerdrClientLike {
503
426
  });
504
427
  }
505
428
 
506
- async spawnSubPane(opts: { label: string; command: string[]; cwd: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }> {
507
- const result = (await this.request('layout.apply', {
508
- tab_label: opts.label,
509
- root: { type: 'pane', command: opts.command, cwd: opts.cwd, ...(opts.env ? { env: opts.env } : {}) },
510
- })) as Record<string, unknown>;
511
- const paneId = findIdIn(result, 'pane_id');
512
- if (!paneId) throw new Error('layout.apply: no pane_id in response');
513
- return { tabId: findIdIn(result, 'tab_id') ?? '', paneId };
514
- }
515
-
516
429
  async sendPaneText(paneId: string, text: string): Promise<void> {
517
430
  // A trailing \r acts as Enter; observed behavior sends text to the pi editor and submits it.
518
431
  await this.request('pane.send_text', { pane_id: paneId, text: text + '\r' });
@@ -531,8 +444,7 @@ export class HerdrClient implements HerdrClientLike {
531
444
  const msg = String((err as Error)?.message ?? err);
532
445
  if (/timeout/i.test(msg)) return null;
533
446
  // A14: herdr reports agent_not_found while a freshly spawned pane's agent is still registering;
534
- // callers poll `waitAgent`, so map it to the same "nothing yet" result as a timeout instead of
535
- // failing the whole spawn with a raw server error.
447
+ // callers poll, so map it to the same "nothing yet" result as a timeout.
536
448
  if (/agent_not_found|agent target .* not found/i.test(msg)) return null;
537
449
  throw err;
538
450
  }
@@ -544,10 +456,6 @@ export class HerdrClient implements HerdrClientLike {
544
456
  return a?.session ?? null;
545
457
  }
546
458
 
547
- async focusPane(paneId: string): Promise<void> {
548
- await this.request('pane.focus', { pane_id: paneId });
549
- }
550
-
551
459
  async splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string; focus?: boolean; ratio?: number } = {}): Promise<string> {
552
460
  const params: Record<string, unknown> = {
553
461
  direction: opts.direction ?? 'right',
@@ -676,11 +584,6 @@ export class HerdrClient implements HerdrClientLike {
676
584
  return (result?.tabs ?? []).map((t) => this.mapTabInfo(t)).filter((t): t is TabInfo => t !== null);
677
585
  }
678
586
 
679
- async tabGet(tabId: string): Promise<TabInfo | null> {
680
- const result = (await this.request('tab.get', { tab_id: tabId })) as { tab?: Record<string, unknown> | null } | null;
681
- return this.mapTabInfo(result?.tab ?? null);
682
- }
683
-
684
587
  async tabClose(tabId: string): Promise<void> {
685
588
  await this.request('tab.close', { tab_id: tabId });
686
589
  }
@@ -708,15 +611,8 @@ export class HerdrClient implements HerdrClientLike {
708
611
  // 0.9.0 serde rejects unknown `popup` as "unknown variant" / invalid_params — not a dedicated
709
612
  // invalid_placement code. Any popup failure retries as tab; tab failure surfaces to Level 3.
710
613
  if (opts.placement !== 'popup') throw err;
711
- const fallbackRes = (await this.request('plugin.pane.open', {
712
- plugin_id: opts.pluginId,
713
- entrypoint: opts.entrypoint,
714
- placement: 'tab',
715
- ...(opts.focus !== undefined ? { focus: opts.focus } : {}),
716
- ...(opts.cwd ? { cwd: opts.cwd } : {}),
717
- ...(opts.env ? { env: opts.env } : {}),
718
- ...(opts.workspaceId ? { workspace_id: opts.workspaceId } : {}),
719
- })) as Record<string, unknown> | null;
614
+ const { width: _width, height: _height, target_pane_id: _target, ...base } = params;
615
+ const fallbackRes = (await this.request('plugin.pane.open', { ...base, placement: 'tab' })) as Record<string, unknown> | null;
720
616
  return {
721
617
  mode: 'fallback_tab',
722
618
  paneId: findIdIn(fallbackRes, 'pane_id') ?? undefined,
@@ -725,19 +621,6 @@ export class HerdrClient implements HerdrClientLike {
725
621
  }
726
622
  }
727
623
 
728
- async closePopup(): Promise<boolean> {
729
- try {
730
- await this.request('popup.close', {});
731
- return true;
732
- } catch (err) {
733
- const msg = String((err as Error)?.message ?? err);
734
- if (/popup_not_open|not found|not supported|unknown method/i.test(msg)) {
735
- return false;
736
- }
737
- throw err;
738
- }
739
- }
740
-
741
624
  async agentExplain(target: string): Promise<Record<string, unknown> | null> {
742
625
  try {
743
626
  const result = (await this.request('agent.explain', { target })) as Record<string, unknown> | null;
@@ -763,7 +646,6 @@ export class HerdrClient implements HerdrClientLike {
763
646
  return result.version;
764
647
  }
765
648
  } catch {
766
- /* ignore */
767
649
  }
768
650
  return null;
769
651
  }
@@ -772,19 +654,23 @@ export class HerdrClient implements HerdrClientLike {
772
654
  await this.request('pane.send_keys', { pane_id: paneId, keys });
773
655
  }
774
656
 
775
- async readPane(paneId: string, opts: {
776
- source?: 'visible' | 'recent' | 'recent_unwrapped';
777
- lines?: number;
778
- stripAnsi?: boolean;
779
- } = {}): Promise<{ text: string; revision: number; truncated: boolean }> {
780
- // Observed envelope: {type:'pane_read', read:{text, revision, truncated, ...}}; the payload is nested under read.
781
- const result = (await this.request('pane.read', {
782
- pane_id: paneId,
657
+ /** Shared pane.read/agent.read decode: payload nested under `read` (older servers answer it flat). */
658
+ private async readBuffer(
659
+ method: 'pane.read' | 'agent.read',
660
+ target: { pane_id: string } | { target: string },
661
+ opts: ReadOptions,
662
+ stripAnsiDefault: boolean,
663
+ ): Promise<ReadBuffer> {
664
+ const result = (await this.request(method, {
665
+ ...target,
783
666
  source: opts.source ?? 'recent',
784
667
  format: 'text',
785
- strip_ansi: opts.stripAnsi ?? false,
668
+ strip_ansi: opts.stripAnsi ?? stripAnsiDefault,
786
669
  ...(opts.lines != null ? { lines: opts.lines } : {}),
787
- })) as { read?: { text?: unknown; revision?: unknown; truncated?: unknown }; text?: unknown; revision?: unknown; truncated?: unknown } | null;
670
+ })) as {
671
+ read?: { text?: unknown; revision?: unknown; truncated?: unknown };
672
+ text?: unknown; revision?: unknown; truncated?: unknown;
673
+ } | null;
788
674
  const payload = result?.read ?? result ?? {};
789
675
  return {
790
676
  text: typeof payload.text === 'string' ? payload.text : '',
@@ -793,24 +679,12 @@ export class HerdrClient implements HerdrClientLike {
793
679
  };
794
680
  }
795
681
 
796
- async readAgent(target: string, opts: {
797
- source?: 'visible' | 'recent' | 'recent_unwrapped';
798
- lines?: number;
799
- stripAnsi?: boolean;
800
- } = {}): Promise<{ text: string; revision: number; truncated: boolean }> {
801
- const result = (await this.request('agent.read', {
802
- target,
803
- source: opts.source ?? 'recent',
804
- format: 'text',
805
- strip_ansi: opts.stripAnsi ?? true,
806
- ...(opts.lines != null ? { lines: opts.lines } : {}),
807
- })) as { read?: { text?: unknown; revision?: unknown; truncated?: unknown }; text?: unknown; revision?: unknown; truncated?: unknown } | null;
808
- const payload = result?.read ?? result ?? {};
809
- return {
810
- text: typeof payload.text === 'string' ? payload.text : '',
811
- revision: typeof payload.revision === 'number' ? payload.revision : 0,
812
- truncated: payload.truncated === true,
813
- };
682
+ async readPane(paneId: string, opts: ReadOptions = {}): Promise<ReadBuffer> {
683
+ return this.readBuffer('pane.read', { pane_id: paneId }, opts, false);
684
+ }
685
+
686
+ async readAgent(target: string, opts: ReadOptions = {}): Promise<ReadBuffer> {
687
+ return this.readBuffer('agent.read', { target }, opts, true);
814
688
  }
815
689
 
816
690
  async waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<WaitForOutputResult> {
@@ -1,10 +1,7 @@
1
1
  /**
2
- * Workspace history (v1.2, DESIGN.md §13.4).
3
- *
4
- * Task history is append-only JSONL partitioned by cwd, mirroring pi session partitioning:
5
- * <agentRoot>/herdr-pi/history/--<cwd>--/history.jsonl
6
- * Records are immutable so GC can remove panes without deleting history. Recovery keeps
7
- * both the sessionFile and launchCommand snapshots as fallbacks.
2
+ * Delegation ledger: append-only JSONL partitioned by cwd, mirroring pi's session partitioning
3
+ * (<agentRoot>/herdr-pi/history/--<cwd>--/history.jsonl). Rows are immutable, so GC can drop panes
4
+ * without losing history; recovery keeps both the sessionFile and launchCommand snapshots.
8
5
  */
9
6
  import * as fs from 'node:fs';
10
7
  import * as path from 'node:path';
@@ -12,13 +9,13 @@ import { historyFilePath, preferredHistoryFile } from './storage-layout.ts';
12
9
 
13
10
  export { historyFilePath, preferredHistoryFile };
14
11
 
15
- /** Legacy short/resident entries normalize to task; missing values do too, while role names pass through unchanged. */
12
+ /** Legacy short/resident entries normalize to task, as do missing values; role names pass through. */
16
13
  export function normalizeEntryKind(kind: string | null | undefined): string {
17
14
  if (!kind || kind === 'short' || kind === 'resident') return 'task';
18
15
  return kind;
19
16
  }
20
17
 
21
- /** O6: only a .jsonl path is accepted during settlement, so invalid reports cannot overwrite a valid path. */
18
+ /** Only a .jsonl path is accepted, so an invalid report cannot overwrite a valid one. */
22
19
  export function applyReportedSessionFile(
23
20
  current: string | null,
24
21
  reported: string | null | undefined,
@@ -29,11 +26,9 @@ export function applyReportedSessionFile(
29
26
 
30
27
  export interface HistoryEntry {
31
28
  taskId: string;
32
- /** 'task' or a role name; disk reads normalize short/resident to task for compatibility. */
33
29
  kind: string;
34
30
  paneId: string;
35
31
  tabId: string;
36
- /** v1.3: task tab name retained for history review and revival; omitted by older entries. */
37
32
  tabName?: string;
38
33
  workspaceId: string;
39
34
  cwd: string;
@@ -45,19 +40,17 @@ export interface HistoryEntry {
45
40
  status: 'running' | 'settled' | 'consumed' | 'closed';
46
41
  outcome?: string | null;
47
42
  createdAt: number;
48
- /** M13c: durable consumption time, which provides the GC TTL basis. */
49
43
  consumedAt?: number | null;
50
44
  closedAt?: number | null;
51
- /** Previous-generation pane ID, retained when work is redone or revived. */
52
45
  revivedFrom?: string | null;
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). */
46
+ /** Writer marker (spawn / poll-settle / poll-timeout / gc / zombie-sweep …) for auditability. */
55
47
  via?: string;
56
48
  }
57
49
 
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. */
50
+ /**
51
+ * When a patch omits `outcome`, inherit the latest non-empty value for the taskId: GC bookkeeping
52
+ * must not erase a settlement result under the "latest row is state" rule.
53
+ */
61
54
  export function inheritOutcome(lastOutcome: string | null | undefined, patchOutcome: string | null | undefined): string | null {
62
55
  if (patchOutcome !== undefined) return patchOutcome;
63
56
  return lastOutcome ?? null;
@@ -82,7 +75,6 @@ function coerceHistoryEntry(obj: unknown): HistoryEntry | null {
82
75
  return { ...raw, kind: normalizeEntryKind(raw.kind) };
83
76
  }
84
77
 
85
- /** Parse line by line; skip junk JSON and records missing taskId/status. */
86
78
  export function parseHistoryEntries(text: string): HistoryEntry[] {
87
79
  const entries: HistoryEntry[] = [];
88
80
  for (const line of (text ?? '').split(/\r?\n/)) {
@@ -107,7 +99,7 @@ export type HistoryReadResult =
107
99
  | { status: 'missing' }
108
100
  | { status: 'unreadable'; error: Error };
109
101
 
110
- /** Distinguish missing vs permission/IO failure. readHistory still returns [] for both. */
102
+ /** Distinguish missing vs permission/IO failure; readHistory still returns [] for both. */
111
103
  export function inspectHistory(file: string): HistoryReadResult {
112
104
  try {
113
105
  if (!fs.existsSync(file)) return { status: 'missing' };
@@ -118,15 +110,37 @@ export function inspectHistory(file: string): HistoryReadResult {
118
110
  }
119
111
  }
120
112
 
121
- /** Read a history file; missing or unreadable yields []. */
122
113
  export function readHistory(file: string): HistoryEntry[] {
123
114
  const r = inspectHistory(file);
124
115
  return r.status === 'ok' ? r.entries : [];
125
116
  }
126
117
 
118
+ /** Every ledger under `<agentRoot>/herdr-pi/history`. Resume must see tasks spawned into another checkout. */
119
+ export function readHistoryTree(agentRoot: string): HistoryEntry[] {
120
+ const root = path.join(agentRoot, 'herdr-pi', 'history');
121
+ let names: string[];
122
+ try {
123
+ names = fs.readdirSync(root);
124
+ } catch {
125
+ return [];
126
+ }
127
+ const all: HistoryEntry[] = [];
128
+ for (const name of names) {
129
+ let isDir = false;
130
+ try {
131
+ isDir = fs.statSync(path.join(root, name)).isDirectory();
132
+ } catch {
133
+ continue;
134
+ }
135
+ if (!isDir) continue;
136
+ all.push(...readHistory(path.join(root, name, 'history.jsonl')));
137
+ }
138
+ return all;
139
+ }
140
+
127
141
  export type HistoryWriteResult = { ok: true } | { ok: false; error: Error };
128
142
 
129
- /** Append one entry. Failures are logged and returned so the ledger is not silently lost. */
143
+ /** Failures are logged and returned so the ledger is not silently lost. */
130
144
  export function appendHistory(file: string, entry: HistoryEntry): HistoryWriteResult {
131
145
  try {
132
146
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -139,7 +153,6 @@ export function appendHistory(file: string, entry: HistoryEntry): HistoryWriteRe
139
153
  }
140
154
  }
141
155
 
142
- /** Group all generations of each taskId in ascending createdAt order for review. */
143
156
  export function generationsByTask(entries: readonly HistoryEntry[]): Map<string, HistoryEntry[]> {
144
157
  const map = new Map<string, HistoryEntry[]>();
145
158
  for (const e of entries) {
@@ -151,7 +164,7 @@ export function generationsByTask(entries: readonly HistoryEntry[]): Map<string,
151
164
  return map;
152
165
  }
153
166
 
154
- /** Return the newest generation for taskId, which is the one used for resume. */
167
+ /** Newest generation for taskId — the one used for resume. */
155
168
  export function latestGeneration(entries: readonly HistoryEntry[], taskId: string): HistoryEntry | null {
156
169
  const gens = generationsByTask(entries).get(taskId);
157
170
  if (!gens || gens.length === 0) return null;