pi-pier 0.2.6 → 0.3.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 (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +13 -1
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +458 -0
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +270 -47
  19. package/src/core/todo.ts +49 -14
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -0
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +5 -0
  33. package/src/index.ts +360 -60
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +60 -3
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +161 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
@@ -22,8 +22,17 @@ export interface GitError extends Error {
22
22
  export type GitExecFile = (
23
23
  file: string,
24
24
  args: readonly string[],
25
- options: { timeout: number; encoding: 'utf8' },
26
- ) => Promise<{ stdout: string; stderr: string }>
25
+ options: { timeout: number; encoding: 'utf8'; maxBuffer: number },
26
+ ) => Promise<{ stdout: string; stderr: string }>;
27
+
28
+ /**
29
+ * Output ceiling for one git call. `execFile` defaults to 1MB, which a large checkout exceeds
30
+ * (`status --porcelain` on a repo with tens of thousands of untracked files, or `diff --stat`
31
+ * across a wide change): the call then fails with ENOBUFS, callers normalize it to null, and the
32
+ * user sees a silently missing stat line — or "failed to create worktree" for a worktree that was
33
+ * in fact created. 64MB bounds memory without hitting realistic repositories.
34
+ */
35
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
27
36
 
28
37
  /**
29
38
  * Minimal Git adapter for worktree and status operations.
@@ -79,7 +88,7 @@ export class NodeGitAdapter implements GitAdapter {
79
88
  const { stdout, stderr } = await this.exec(
80
89
  this.gitExecutable,
81
90
  ['-C', cwd, ...args],
82
- { timeout: this.timeoutMs, encoding: 'utf8' },
91
+ { timeout: this.timeoutMs, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER },
83
92
  )
84
93
  return { stdout, stderr }
85
94
  } catch (err) {
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * Minimal herdr socket API client used inside the pi extension.
3
3
  *
4
- * Validated against herdr 0.8.0-preview (protocol 19, Windows named pipe; see WIRE.md):
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:
5
7
  * - Transport is NDJSON: request {id, method, params} → response {id, result} | {id, error:{code,message}};
6
8
  * - **Control requests use one connection per request**; the server closes it after replying;
7
9
  * - Windows targets are named pipes whose name is the complete socket_path with a \\.\pipe\ prefix;
8
10
  * - pane.report_agent reports {pane_id, source, agent, state, message?, agent_session_id?, agent_session_path?}
9
11
  * 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
+ * - 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;
12
15
  * - agent.list {} → {type:'agent_list', agents: AgentInfo[]};
13
16
  * - agent.wait {target, until[], timeout_ms?} → the matching agent, or an error on timeout;
14
17
  * - agent.send_keys {target, keys[]} / pane.send_text {pane_id, text}.
@@ -49,8 +52,54 @@ export interface AgentInfo {
49
52
  session: string | null;
50
53
  stateLabels: Record<string, string>;
51
54
  tokens: Record<string, string | null>;
55
+ /** Herdr 0.9.1: foreground cwd of the process currently controlling the PTY. */
56
+ foregroundCwd?: string | null;
52
57
  }
53
58
 
59
+ export interface PaneListItem {
60
+ paneId: string;
61
+ tabId: string;
62
+ workspaceId: string;
63
+ agentStatus: string;
64
+ foregroundCwd?: string | null;
65
+ terminalTitle?: string | null;
66
+ terminalTitleStripped?: string | null;
67
+ }
68
+
69
+ export interface PaneLayoutCell {
70
+ paneId: string;
71
+ x: number;
72
+ y: number;
73
+ w: number;
74
+ h: number;
75
+ focused: boolean;
76
+ }
77
+
78
+ export interface PaneLayoutSnapshot {
79
+ tabId: string | null;
80
+ zoomed: boolean;
81
+ focusedPaneId: string | null;
82
+ panes: PaneLayoutCell[];
83
+ }
84
+
85
+ export interface OpenPluginPaneOptions {
86
+ pluginId: string;
87
+ entrypoint: string;
88
+ placement?: 'popup' | 'overlay' | 'split' | 'tab' | 'zoomed';
89
+ width?: string;
90
+ height?: string;
91
+ focus?: boolean;
92
+ cwd?: string;
93
+ env?: Record<string, string>;
94
+ targetPaneId?: string;
95
+ workspaceId?: string;
96
+ }
97
+
98
+ export type OpenPluginPaneResult =
99
+ | { mode: 'popup'; ok: true }
100
+ | { mode: 'pane'; paneId: string }
101
+ | { mode: 'fallback_tab'; tabId?: string; paneId?: string };
102
+
54
103
  /** TabInfo projection; fields mirror the observed tab schema. */
55
104
  export interface TabInfo {
56
105
  tabId: string;
@@ -79,12 +128,31 @@ export function herdrSocketTarget(socketPath: string, platform: NodeJS.Platform
79
128
  return socketPath.startsWith('\\\\.\\pipe\\') ? socketPath : `\\\\.\\pipe\\${socketPath}`;
80
129
  }
81
130
 
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
+ */
136
+ export function herdrUnavailableHint(err: unknown): string | null {
137
+ const msg = String((err as { message?: unknown })?.message ?? err);
138
+ if (!/ENOENT|ECONNREFUSED|ECONNRESET|EPIPE|ENOTCONN|not connected|socket|connect |timeout/i.test(msg)) {
139
+ return null;
140
+ }
141
+ return `herdr unreachable (${msg}) — check that the herdr server is running and HERDR_SOCKET_PATH matches its socket`;
142
+ }
143
+
144
+
145
+ /** Wire protocol this client is written against (herdr 0.9.1). See test/fixtures/herdr-contract.json. */
146
+ export const HERDR_PROTOCOL_EXPECTED = 22;
147
+
148
+ export type WaitForOutputResult =
149
+ | { matched: true }
150
+ | { matched: false; reason: 'timeout' | 'unavailable' };
82
151
 
83
152
  export interface HerdrClientLike {
84
153
  readonly available: boolean;
85
154
  reportAgent(state: PaneAgentState, message: string | null): Promise<void>;
86
- reportAgentSession(sessionPath: string | null): Promise<void>;
87
- reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void>;
155
+ reportMetadata(meta: { session: string; items: readonly TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void>;
88
156
  /** M18: report write-lock tokens (lock-<hash> → paneId|path); null releases, and batches stay within 16 keys. */
89
157
  reportLockTokens(tokens: Record<string, string | null>): Promise<void>;
90
158
  /**
@@ -109,21 +177,31 @@ export interface HerdrClientLike {
109
177
  /** v1.2: Focus a pane before adding it to a group tab. */
110
178
  focusPane(paneId: string): Promise<void>;
111
179
  /** v1.2: Split a new shell pane in the current tab after focus, placing it in the group tab; return its pane ID. */
112
- splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string }): Promise<string>;
180
+ splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string; focus?: boolean; ratio?: number }): Promise<string>;
113
181
  /** v1.2: Close a pane; observed behavior kills its process tree and herdr closes an empty tab. */
114
182
  closePane(paneId: string): Promise<void>;
115
183
  /** v1.2: Create a tab with a root shell pane, returning tabId/paneId for group-tab infrastructure. */
116
184
  createTab(opts: { workspaceId: string; label?: string; cwd?: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
117
185
  /** v1.2: List all panes with tab ownership for group-tab additions. */
118
- listPanes(): Promise<Array<{ paneId: string; tabId: string; workspaceId: string; agentStatus: string }>>;
186
+ listPanes(): Promise<PaneListItem[]>;
119
187
  /** D91: Export the tab layout tree, locating it by paneId or tabId; best effort returns null on failure. */
120
- exportLayout(opts: { paneId?: string; tabId?: string }): Promise<{ tabId: string | null; zoomed: boolean; root: unknown } | null>;
188
+ exportLayout(opts?: { paneId?: string; tabId?: string }): Promise<{ tabId: string | null; zoomed: boolean; root: unknown; focusedPaneId: string | null } | null>;
189
+ /** 0.9.1: live pane rects for the tab containing paneId. Best effort returns null. */
190
+ paneLayout(opts?: { paneId?: string }): Promise<PaneLayoutSnapshot | null>;
121
191
  /** v1.3: List tabs (tab.list); fields follow the observed schema. */
122
192
  tabList(): Promise<TabInfo[]>;
123
193
  /** v1.3: Get tab details (tab.get); return null when absent. */
124
194
  tabGet(tabId: string): Promise<TabInfo | null>;
125
195
  /** v1.3: Close a tab (tab.close), cascading to its panes. */
126
196
  tabClose(tabId: string): Promise<void>;
197
+ /** 0.9.1: Launch a manifest pane entrypoint with placement (popup/tab/split/overlay/zoomed). */
198
+ 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
+ agentExplain(target: string): Promise<Record<string, unknown> | null>;
203
+ /** 0.9.1: Query server version via ping. Cached in memory. */
204
+ getServerVersion(): Promise<string | null>;
127
205
  /** M14: Send a key combination (pane.send_keys; Herdr key combos include ctrl+c, enter, and esc). */
128
206
  sendPaneKeys(paneId: string, keys: string[]): Promise<void>;
129
207
  /** M14: Read the pane output buffer; recent preserves ANSI data for T6 detection by default. */
@@ -132,8 +210,14 @@ export interface HerdrClientLike {
132
210
  lines?: number;
133
211
  stripAnsi?: boolean;
134
212
  }): Promise<{ text: string; revision: number; truncated: boolean }>;
135
- /** M14: Wait for output to match a substring or regex; return null on timeout and throw on error. */
136
- waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<boolean | null>;
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. */
220
+ waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<WaitForOutputResult>;
137
221
  close(): void;
138
222
  }
139
223
 
@@ -156,7 +240,6 @@ function findIdIn(obj: unknown, key: string, depth = 0): string | null {
156
240
  export class NoopHerdrClient implements HerdrClientLike {
157
241
  readonly available = false;
158
242
  async reportAgent(): Promise<void> {}
159
- async reportAgentSession(): Promise<void> {}
160
243
  async reportMetadata(): Promise<void> {}
161
244
  async reportLockTokens(): Promise<void> {}
162
245
  async reportDisplayAgent(): Promise<void> {}
@@ -182,12 +265,15 @@ export class NoopHerdrClient implements HerdrClientLike {
182
265
  async createTab(): Promise<{ tabId: string; paneId: string }> {
183
266
  throw new Error('subagent requires a herdr-managed pane');
184
267
  }
185
- async listPanes(): Promise<Array<{ paneId: string; tabId: string; workspaceId: string; agentStatus: string }>> {
268
+ async listPanes(): Promise<PaneListItem[]> {
186
269
  return [];
187
270
  }
188
271
  async exportLayout(): Promise<null> {
189
272
  return null;
190
273
  }
274
+ async paneLayout(): Promise<null> {
275
+ return null;
276
+ }
191
277
  async tabList(): Promise<TabInfo[]> {
192
278
  return [];
193
279
  }
@@ -195,12 +281,27 @@ export class NoopHerdrClient implements HerdrClientLike {
195
281
  return null;
196
282
  }
197
283
  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
+ }
198
296
  async sendPaneKeys(): Promise<void> {}
199
297
  async readPane(): Promise<{ text: string; revision: number; truncated: boolean }> {
200
298
  return { text: '', revision: 0, truncated: false };
201
299
  }
202
- async waitForOutput(): Promise<boolean | null> {
203
- return null;
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' };
204
305
  }
205
306
  close(): void {}
206
307
  }
@@ -296,21 +397,11 @@ export class HerdrClient implements HerdrClientLike {
296
397
  }
297
398
  }
298
399
 
299
- async reportAgentSession(sessionPath: string | null): Promise<void> {
300
- if (!sessionPath) return;
301
- try {
302
- await this.request('pane.report_agent_session', {
303
- pane_id: this.env.paneId,
304
- source: REPORT_AGENT_SOURCE,
305
- agent: 'pi',
306
- agent_session_path: sessionPath,
307
- });
308
- } catch {
309
- /* Silent best effort. */
310
- }
311
- }
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.
312
403
 
313
- async reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void> {
404
+ async reportMetadata(meta: { session: string; items: readonly TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void> {
314
405
  try {
315
406
  void meta.session;
316
407
  const title = formatPaneTitle(meta.items, null, {
@@ -407,6 +498,7 @@ export class HerdrClient implements HerdrClientLike {
407
498
  session: session?.value != null ? String(session.value) : null,
408
499
  stateLabels: (a.state_labels ?? {}) as Record<string, string>,
409
500
  tokens: (a.tokens ?? {}) as Record<string, string | null>,
501
+ ...(a.foreground_cwd ? { foregroundCwd: String(a.foreground_cwd) } : {}),
410
502
  };
411
503
  });
412
504
  }
@@ -438,6 +530,10 @@ export class HerdrClient implements HerdrClientLike {
438
530
  } catch (err) {
439
531
  const msg = String((err as Error)?.message ?? err);
440
532
  if (/timeout/i.test(msg)) return null;
533
+ // 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.
536
+ if (/agent_not_found|agent target .* not found/i.test(msg)) return null;
441
537
  throw err;
442
538
  }
443
539
  }
@@ -452,13 +548,28 @@ export class HerdrClient implements HerdrClientLike {
452
548
  await this.request('pane.focus', { pane_id: paneId });
453
549
  }
454
550
 
455
- async splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string } = {}): Promise<string> {
456
- const result = (await this.request('pane.split', {
551
+ async splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string; focus?: boolean; ratio?: number } = {}): Promise<string> {
552
+ const params: Record<string, unknown> = {
457
553
  direction: opts.direction ?? 'right',
458
554
  ...(opts.targetPaneId ? { target_pane_id: opts.targetPaneId } : {}),
459
555
  ...(opts.cwd ? { cwd: opts.cwd } : {}),
460
556
  ...(opts.env ? { env: opts.env } : {}),
461
- })) as Record<string, unknown>;
557
+ ...(opts.focus !== undefined ? { focus: opts.focus } : {}),
558
+ ...(opts.ratio !== undefined ? { ratio: opts.ratio } : {}),
559
+ };
560
+ try {
561
+ return await this.splitPaneRequest(params);
562
+ } catch (err) {
563
+ if (opts.ratio === undefined) throw err;
564
+ const msg = String((err as Error)?.message ?? err);
565
+ if (!/unknown|invalid_params|unexpected/i.test(msg)) throw err;
566
+ const { ratio: _ratio, ...rest } = params;
567
+ return await this.splitPaneRequest(rest);
568
+ }
569
+ }
570
+
571
+ private async splitPaneRequest(params: Record<string, unknown>): Promise<string> {
572
+ const result = (await this.request('pane.split', params)) as Record<string, unknown>;
462
573
  const paneId = findIdIn(result, 'pane_id');
463
574
  if (!paneId) throw new Error('pane.split: no pane_id in response');
464
575
  return paneId;
@@ -481,17 +592,20 @@ export class HerdrClient implements HerdrClientLike {
481
592
  return { tabId, paneId: paneId ?? '' };
482
593
  }
483
594
 
484
- async listPanes(): Promise<Array<{ paneId: string; tabId: string; workspaceId: string; agentStatus: string }>> {
595
+ async listPanes(): Promise<PaneListItem[]> {
485
596
  const result = (await this.request('pane.list', {})) as { panes?: Array<Record<string, unknown>> } | null;
486
597
  return (result?.panes ?? []).map((p) => ({
487
598
  paneId: String(p.pane_id ?? ''),
488
599
  tabId: String(p.tab_id ?? ''),
489
600
  workspaceId: String(p.workspace_id ?? ''),
490
601
  agentStatus: String(p.agent_status ?? 'unknown'),
602
+ ...(p.foreground_cwd ? { foregroundCwd: String(p.foreground_cwd) } : {}),
603
+ ...(p.terminal_title ? { terminalTitle: String(p.terminal_title) } : {}),
604
+ ...(p.terminal_title_stripped ? { terminalTitleStripped: String(p.terminal_title_stripped) } : {}),
491
605
  }));
492
606
  }
493
607
 
494
- async exportLayout(opts: { paneId?: string; tabId?: string }): Promise<{ tabId: string | null; zoomed: boolean; root: unknown } | null> {
608
+ async exportLayout(opts: { paneId?: string; tabId?: string } = {}): Promise<{ tabId: string | null; zoomed: boolean; root: unknown; focusedPaneId: string | null } | null> {
495
609
  try {
496
610
  const result = (await this.request('layout.export', {
497
611
  ...(opts.paneId ? { pane_id: opts.paneId } : {}),
@@ -504,6 +618,42 @@ export class HerdrClient implements HerdrClientLike {
504
618
  tabId: typeof layout.tab_id === 'string' ? layout.tab_id : null,
505
619
  zoomed: Boolean(layout.zoomed),
506
620
  root: layout.root ?? null,
621
+ // D-4: focus heat needs to know which pane the human is on; layout.export reports it.
622
+ focusedPaneId: typeof layout.focused_pane_id === 'string' ? layout.focused_pane_id : null,
623
+ };
624
+ } catch {
625
+ return null;
626
+ }
627
+ }
628
+
629
+ async paneLayout(opts: { paneId?: string } = {}): Promise<PaneLayoutSnapshot | null> {
630
+ try {
631
+ const result = (await this.request('pane.layout', {
632
+ ...(opts.paneId ? { pane_id: opts.paneId } : {}),
633
+ })) as Record<string, unknown> | null;
634
+ const layout = (result?.layout ?? result) as Record<string, unknown> | null | undefined;
635
+ if (!layout || typeof layout !== 'object' || !Array.isArray(layout.panes)) return null;
636
+ const panes: PaneLayoutCell[] = [];
637
+ for (const raw of layout.panes) {
638
+ if (!raw || typeof raw !== 'object') continue;
639
+ const p = raw as Record<string, unknown>;
640
+ const rect = (p.rect && typeof p.rect === 'object') ? p.rect as Record<string, unknown> : p;
641
+ const paneId = typeof p.pane_id === 'string' ? p.pane_id : null;
642
+ if (!paneId) continue;
643
+ panes.push({
644
+ paneId,
645
+ x: Number(rect.x) || 0,
646
+ y: Number(rect.y) || 0,
647
+ w: Number(rect.width ?? rect.w) || 0,
648
+ h: Number(rect.height ?? rect.h) || 0,
649
+ focused: p.focused === true,
650
+ });
651
+ }
652
+ return {
653
+ tabId: typeof layout.tab_id === 'string' ? layout.tab_id : null,
654
+ zoomed: Boolean(layout.zoomed),
655
+ focusedPaneId: typeof layout.focused_pane_id === 'string' ? layout.focused_pane_id : null,
656
+ panes,
507
657
  };
508
658
  } catch {
509
659
  return null;
@@ -535,6 +685,89 @@ export class HerdrClient implements HerdrClientLike {
535
685
  await this.request('tab.close', { tab_id: tabId });
536
686
  }
537
687
 
688
+ async openPluginPane(opts: OpenPluginPaneOptions): Promise<OpenPluginPaneResult> {
689
+ const params: Record<string, unknown> = {
690
+ plugin_id: opts.pluginId,
691
+ entrypoint: opts.entrypoint,
692
+ ...(opts.placement ? { placement: opts.placement } : {}),
693
+ ...(opts.width ? { width: opts.width } : {}),
694
+ ...(opts.height ? { height: opts.height } : {}),
695
+ ...(opts.focus !== undefined ? { focus: opts.focus } : {}),
696
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
697
+ ...(opts.env ? { env: opts.env } : {}),
698
+ ...(opts.targetPaneId ? { target_pane_id: opts.targetPaneId } : {}),
699
+ ...(opts.workspaceId ? { workspace_id: opts.workspaceId } : {}),
700
+ };
701
+ try {
702
+ const result = (await this.request('plugin.pane.open', params)) as Record<string, unknown> | null;
703
+ if (opts.placement === 'popup') {
704
+ return { mode: 'popup', ok: true };
705
+ }
706
+ return { mode: 'pane', paneId: findIdIn(result, 'pane_id') ?? '' };
707
+ } catch (err) {
708
+ // 0.9.0 serde rejects unknown `popup` as "unknown variant" / invalid_params — not a dedicated
709
+ // invalid_placement code. Any popup failure retries as tab; tab failure surfaces to Level 3.
710
+ 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;
720
+ return {
721
+ mode: 'fallback_tab',
722
+ paneId: findIdIn(fallbackRes, 'pane_id') ?? undefined,
723
+ tabId: findIdIn(fallbackRes, 'tab_id') ?? undefined,
724
+ };
725
+ }
726
+ }
727
+
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
+ async agentExplain(target: string): Promise<Record<string, unknown> | null> {
742
+ try {
743
+ const result = (await this.request('agent.explain', { target })) as Record<string, unknown> | null;
744
+ if (!result || typeof result !== 'object') return null;
745
+ const nested = result.explain;
746
+ if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
747
+ return nested as Record<string, unknown>;
748
+ }
749
+ return result;
750
+ } catch {
751
+ return null;
752
+ }
753
+ }
754
+
755
+ private cachedVersion: string | null = null;
756
+
757
+ async getServerVersion(): Promise<string | null> {
758
+ if (this.cachedVersion) return this.cachedVersion;
759
+ try {
760
+ const result = (await this.request('ping', {})) as { version?: unknown } | null;
761
+ if (typeof result?.version === 'string') {
762
+ this.cachedVersion = result.version;
763
+ return result.version;
764
+ }
765
+ } catch {
766
+ /* ignore */
767
+ }
768
+ return null;
769
+ }
770
+
538
771
  async sendPaneKeys(paneId: string, keys: string[]): Promise<void> {
539
772
  await this.request('pane.send_keys', { pane_id: paneId, keys });
540
773
  }
@@ -560,7 +793,27 @@ export class HerdrClient implements HerdrClientLike {
560
793
  };
561
794
  }
562
795
 
563
- async waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<boolean | null> {
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
+ };
814
+ }
815
+
816
+ async waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<WaitForOutputResult> {
564
817
  try {
565
818
  await this.request('pane.wait_for_output', {
566
819
  pane_id: paneId,
@@ -568,10 +821,13 @@ export class HerdrClient implements HerdrClientLike {
568
821
  match,
569
822
  timeout_ms: timeoutMs,
570
823
  }, timeoutMs + 5000);
571
- return true;
824
+ return { matched: true };
572
825
  } catch (err) {
573
826
  const msg = String((err as Error)?.message ?? err);
574
- if (/timeout/i.test(msg)) return null;
827
+ if (/timeout/i.test(msg)) return { matched: false, reason: 'timeout' };
828
+ if (/not supported|not implemented|unknown method|unavailable|method not found/i.test(msg)) {
829
+ return { matched: false, reason: 'unavailable' };
830
+ }
575
831
  throw err;
576
832
  }
577
833
  }
@@ -16,6 +16,10 @@ import {
16
16
  } from './lock-core.ts';
17
17
  import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
18
18
 
19
+ export interface WriteLocksHandle {
20
+ getHeldLocks: () => readonly string[];
21
+ }
22
+
19
23
  export function installWriteLocks(
20
24
  pi: ExtensionAPI,
21
25
  opts: {
@@ -23,11 +27,15 @@ export function installWriteLocks(
23
27
  env: HerdrEnv | null;
24
28
  hard: boolean;
25
29
  },
26
- ): void {
30
+ ): WriteLocksHandle {
27
31
  const { client, env, hard } = opts;
28
32
  const heldLocks = new Set<string>();
29
33
  const lockWarnByToolCall = new Map<string, string>();
30
34
 
35
+ if (!client.available || !env) {
36
+ return { getHeldLocks: () => [] };
37
+ }
38
+
31
39
  async function acquireLocks(paths: readonly string[]): Promise<void> {
32
40
  for (const p of paths) heldLocks.add(p);
33
41
  await client.reportLockTokens(acquireTokensFor(paths, env?.paneId ?? ''));
@@ -37,17 +45,24 @@ export function installWriteLocks(
37
45
  if (!client.available || !env) return;
38
46
  const cwd = ctx?.cwd ?? process.cwd();
39
47
  let agents: LockAgentView[];
48
+ let ownForegroundCwd: string | null = null;
40
49
  try {
41
- agents = (await client.listAgents()).map((a) => ({ paneId: a.paneId, tokens: a.tokens }));
50
+ const liveAgents = await client.listAgents();
51
+ agents = liveAgents.map((a) => ({ paneId: a.paneId, tokens: a.tokens }));
52
+ const me = liveAgents.find((a) => a.paneId === env.paneId);
53
+ if (me?.foregroundCwd) {
54
+ ownForegroundCwd = me.foregroundCwd;
55
+ }
42
56
  } catch {
43
57
  return;
44
58
  }
59
+ const effectiveCwd = ownForegroundCwd ?? cwd;
45
60
  const plan = planWriteGuard({
46
61
  toolName: event.toolName ?? '',
47
62
  input: event.input,
48
63
  agents,
49
64
  ownPaneId: env.paneId,
50
- cwd,
65
+ cwd: effectiveCwd,
51
66
  hard,
52
67
  });
53
68
  if (plan.kind === 'skip') return;
@@ -60,13 +75,14 @@ export function installWriteLocks(
60
75
  await acquireLocks(plan.paths);
61
76
  });
62
77
 
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);
78
+ pi.on('tool_result', async (event: unknown) => {
79
+ const rec = (event ?? {}) as { toolCallId?: unknown; content?: unknown };
80
+ if (typeof rec.toolCallId !== 'string') return;
81
+ const warning = lockWarnByToolCall.get(rec.toolCallId);
66
82
  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 }] };
83
+ lockWarnByToolCall.delete(rec.toolCallId);
84
+ const content = Array.isArray(rec.content) ? rec.content as Array<{ type: 'text'; text: string }> : [];
85
+ return { content: [...content, { type: 'text' as const, text: warning }] };
70
86
  });
71
87
 
72
88
  pi.on('agent_settled', async () => {
@@ -102,4 +118,6 @@ export function installWriteLocks(
102
118
  ui?.notify?.(lines.join('\n'), 'info');
103
119
  },
104
120
  });
121
+
122
+ return { getHeldLocks: () => [...heldLocks] };
105
123
  }
@@ -35,6 +35,8 @@ export interface MasterPluginMount {
35
35
  reconcileOnSettlement: (description: string, outcome: 'settled' | 'failed') => string[];
36
36
  withReconcileNotes: (base: string, notes: readonly string[]) => string;
37
37
  claimSettleNotice: (key: string) => boolean;
38
+ isCompactionInFlight?: () => boolean;
39
+ isIntentionalAbort?: () => boolean;
38
40
  }
39
41
 
40
42
  async function loadEntry(
@@ -89,9 +91,12 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
89
91
  (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
90
92
  },
91
93
  state: m.todoUi,
94
+ getBlockedDepth: m.getBlockedDepth,
92
95
  stopReminder: {
93
96
  getBlockedDepth: m.getBlockedDepth,
94
97
  getRunningSubs: () => m.port.current?.listRunningSubs().length ?? 0,
98
+ isCompactionInFlight: m.isCompactionInFlight,
99
+ isIntentionalAbort: m.isIntentionalAbort,
95
100
  },
96
101
  });
97
102
  await loadEntry(sessionRoot, useLoader, './core/todo.ts', todoPlugin);
@@ -5,7 +5,7 @@
5
5
  * tool calls — a long master run queues every settlement and floods at the
6
6
  * end. Buffer while busy; flush at turn_end (steer) / agent_settled (followUp).
7
7
  */
8
- import { collapseNotices } from './notice-buffer.ts';
8
+ import { NOTICE_MAX_SHOWN, collapseNotices } from './notice-buffer.ts';
9
9
 
10
10
  export type NoticeSendMode = 'steer' | 'followUp';
11
11
 
@@ -19,6 +19,12 @@ export function createNoticeBuffer(opts: {
19
19
  /** True while the agent is in a turn, or after an abort that must not wake a new run. */
20
20
  isBusy: () => boolean;
21
21
  send: (content: string, mode: NoticeSendMode) => Promise<void>;
22
+ /**
23
+ * P0-2 (RFC docs/rfc-jev-integration.md §3): relevance reorder of a collapsed
24
+ * batch before truncation. Only consulted above the show cap; returning null
25
+ * (or omitting the hook) keeps arrival order. Implementations must not throw.
26
+ */
27
+ rank?: (contents: readonly string[]) => Promise<readonly string[] | null>;
22
28
  }): NoticeBuffer {
23
29
  const pending: string[] = [];
24
30
  const pendingPaneIds = new Set<string>();
@@ -35,8 +41,13 @@ export function createNoticeBuffer(opts: {
35
41
  noticePending: () => pendingPaneIds,
36
42
  async flush(mode) {
37
43
  if (pending.length === 0) return;
38
- const collapsed = collapseNotices(pending.splice(0));
44
+ const batch = pending.splice(0);
45
+ let ordered: readonly string[] = batch;
46
+ if (opts.rank && batch.length > NOTICE_MAX_SHOWN) {
47
+ ordered = (await opts.rank(batch)) ?? batch;
48
+ }
39
49
  pendingPaneIds.clear();
50
+ const collapsed = collapseNotices(ordered);
40
51
  if (collapsed) await opts.send(collapsed, mode);
41
52
  },
42
53
  };
package/src/index-pipe.ts CHANGED
@@ -53,6 +53,11 @@ export async function handlePipeRequest(
53
53
  return { type: 'ok', id: req.id };
54
54
  case 'reply': {
55
55
  s.port.current?.applyReplySession(req.paneId, req.sessionFile);
56
+ // B8: the claim key is `${paneId}:${requestId}` and the poll loop claims exactly the same shape
57
+ // (subagent-poll-loop.ts settle path). The two agree because a child echoes the id of the pipe
58
+ // request it answered (`id: req.id` in index.ts's settle push), and the parent hands that same
59
+ // id to startPoller (`prompt-<taskId>` / `fu-<ts>` in core/subagent.ts). Keep both sides in step:
60
+ // a mismatch here surfaces as two "finished" notices for one settlement.
56
61
  if (s.claimSettleNotice(`${req.paneId}:${req.id}`)) {
57
62
  const notes = s.port.current?.reconcileOnReply(req.paneId) ?? [];
58
63
  const statLine = await s.port.current?.settleStatLine(req.paneId) ?? null;