pi-herdr-agents 1.6.0 → 1.7.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.
package/AGENTS.md CHANGED
@@ -4,7 +4,7 @@ These instructions apply to humans and coding agents changing `pi-herdr-agents`.
4
4
 
5
5
  ## What this package is
6
6
 
7
- `pi-herdr-agents` (Pi Herdr Agents) is a Pi extension that launches asynchronous Pi child agents exclusively in Herdr. Ordinary runs use dedicated Herdr panes/tabs. Writing tasks may opt into one isolated Herdr-managed Git worktree per branch. Legacy role definitions that request an external CLI fail before Herdr creates resources.
7
+ `pi-herdr-agents` (Pi Herdr Agents) is a Pi extension that launches asynchronous Pi child agents exclusively in Herdr. Ordinary runs group child panes in extension-owned `Agents` tabs by default. Writing tasks may opt into one isolated Herdr-managed Git worktree per branch. Legacy role definitions that request an external CLI fail before Herdr creates resources.
8
8
 
9
9
  The extension is fire-and-forget: `subagent` returns an acknowledgement, and completion is delivered to the parent automatically. Never add polling guidance that tells callers to sleep, tail sessions, or repeatedly check status.
10
10
 
@@ -22,7 +22,7 @@ Bundled role prompts live in [`agents/`](agents/). The native `/skill:orchestrat
22
22
  ## Code map
23
23
 
24
24
  - `pi-extension/subagents/index.ts` — public tools/commands, agent discovery, launch/watch lifecycle, completion delivery, worktree manifests and handoffs
25
- - `pi-extension/subagents/herdr.ts` — Herdr CLI argument construction and response parsing
25
+ - `pi-extension/subagents/herdr.ts` — Herdr CLI calls, response parsing, and ID-based Agents tab placement and capacity
26
26
  - `pi-extension/subagents/terminal.ts` — terminal adapter used by the lifecycle
27
27
  - `pi-extension/subagents/lifecycle.ts`, `status.ts`, `activity.ts` — process/turn state and widget projection
28
28
  - `pi-extension/subagents/wake.ts`, `supervision.ts`, `supervision-config.ts` — file wake-ups, shared pane reconciliation, polling fallback, and supervision configuration
package/CHANGELOG.md CHANGED
@@ -7,7 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
9
9
 
10
- ## [v1.6.0](https://github.com/giuseppecrj/pi-herdr-agents/compare/v1.5.1...v1.6.0)
10
+ ## [v1.7.0](https://github.com/giuseppecrj/pi-herdr-agents/compare/v1.6.0...v1.7.0)
11
+
12
+ ### Merged
13
+
14
+ - feat(subagents): checkout-aware Agents tabs and safe cleanup (#33) [`#43`](https://github.com/giuseppecrj/pi-herdr-agents/pull/43)
15
+ - feat: no-progress advisories for active subagents (#40) [`#41`](https://github.com/giuseppecrj/pi-herdr-agents/pull/41)
16
+ - fix(subagents): measure recovered hang duration and track outstanding tool calls [`#40`](https://github.com/giuseppecrj/pi-herdr-agents/pull/40)
17
+
18
+ ### Commits
19
+
20
+ - feat(subagents): group agents by checkout with safe pane cleanup (#33) [`59a1075`](https://github.com/giuseppecrj/pi-herdr-agents/commit/59a10756722e7cce3b2ded07db6e1adb04abb0ab)
21
+ - fix(subagents): preserve delivery cleanup and live caller identity (#33) [`26987c5`](https://github.com/giuseppecrj/pi-herdr-agents/commit/26987c58a2c882f31a0d935d7e97ce9dd8c4cce8)
22
+ - feat(subagents): no-progress hang advisories for active children (#40) [`1ebf356`](https://github.com/giuseppecrj/pi-herdr-agents/commit/1ebf356521dd46dd6ee035fe3ebba0262992f399)
23
+ - fix(subagents): keep no-progress recovery guidance evidence-based (#40) [`9cbf155`](https://github.com/giuseppecrj/pi-herdr-agents/commit/9cbf155e291c063f70ef86800ef3bfe3c742c6c6)
24
+ - test(integration): hang advisory coverage (#40) [`075f4fe`](https://github.com/giuseppecrj/pi-herdr-agents/commit/075f4feb17938df28a92fa00851d606e5a83b258)
25
+ - fix(subagents): preserve boundary-aligned records in bounded session tails (#40) [`f096fdb`](https://github.com/giuseppecrj/pi-herdr-agents/commit/f096fdb681921e9ab460610d53e456758802725f)
26
+
27
+ ## [v1.6.0](https://github.com/giuseppecrj/pi-herdr-agents/compare/v1.5.1...v1.6.0) - 2026-09-08
11
28
 
12
29
  ### Merged
13
30
 
@@ -34,11 +51,13 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
34
51
  - fix(subagents): scope pane snapshots to registered owners and confirm absence (#29) [`c73a132`](https://github.com/giuseppecrj/pi-herdr-agents/commit/c73a13211f743dc881c506226f457c6a5045e41b)
35
52
  - feat(subagents): persistent specialist docs, glossary, and integration coverage (#32) [`e0f052d`](https://github.com/giuseppecrj/pi-herdr-agents/commit/e0f052da7b717a9c4c21ae1d83172acb1a27452c)
36
53
  - fix(subagents): guard terminal evidence against racing semantic records (#29) [`f2e41f7`](https://github.com/giuseppecrj/pi-herdr-agents/commit/f2e41f74f185c13a89b49301e226e04948d4c088)
54
+ - chore: release v1.6.0 [`8a18189`](https://github.com/giuseppecrj/pi-herdr-agents/commit/8a181890479f46d5955d4f1bac3be759a2d39010)
37
55
  - fix(subagents): type-safe validation for persistent sidecar, ledger, and config parsing [`fe81be5`](https://github.com/giuseppecrj/pi-herdr-agents/commit/fe81be5ca76eba504ff59fcf46ba3e9499753a70)
38
56
  - fix(orchestrate): parse review reports out of wrapped public delivery [`9f11918`](https://github.com/giuseppecrj/pi-herdr-agents/commit/9f11918a407be39388999feeee16970e9d888105)
39
57
  - fix(subagents): close stopped persistent specialist panes (#32) [`8d0b6e7`](https://github.com/giuseppecrj/pi-herdr-agents/commit/8d0b6e7e9703fd7416e8ddc0ffb97dc26d48ab6b)
40
58
  - docs(context): define child wake-up signal and result delivery terms [`a3c159e`](https://github.com/giuseppecrj/pi-herdr-agents/commit/a3c159e2056d13be5697494a13bcb84bdcf92bab)
41
59
  - docs(subagents): clarify persistent help requests (#32) [`16e9363`](https://github.com/giuseppecrj/pi-herdr-agents/commit/16e9363180cf49b5e03003f5ea1e57b38ac8adbd)
60
+ - docs(release): require workflow-worker.js absence in the pack preview (#31) [`9d40cc0`](https://github.com/giuseppecrj/pi-herdr-agents/commit/9d40cc0f3aa08ce203a720947bf097a4b66c9357)
42
61
 
43
62
  ## [v1.5.1](https://github.com/giuseppecrj/pi-herdr-agents/compare/v1.5.0...v1.5.1) - 2026-09-05
44
63
 
package/CONTEXT.md CHANGED
@@ -18,6 +18,12 @@ The parent-facing handoff of a child run's observed outcome and available
18
18
  evidence. Receiving it does not establish that the work is correct or accepted.
19
19
  _Avoid_: Wake-up signal, acceptance
20
20
 
21
+ **No-progress advisory**:
22
+ An internal warning that an active child shows no durable progress in its session
23
+ JSONL or activity snapshot. It is advisory only and never changes the child's
24
+ outcome or triggers recovery.
25
+ _Avoid_: Hang verdict, automatic recovery, stall replacement
26
+
21
27
  **Legacy external CLI role**:
22
28
  An old role definition that contains `cli`. Discovery reports a migration
23
29
  diagnostic, and launch fails before Herdr creates a pane or worktree. Remove
@@ -76,6 +82,19 @@ The append-only evidence record of persistent task dispatch and terminal
76
82
  outcomes for one session generation.
77
83
  _Avoid_: Work queue, mutable task list
78
84
 
85
+ **Agents tab**:
86
+ An extension-owned Herdr tab grouping delegated child panes in an existing
87
+ checkout workspace. Ownership comes from returned IDs, not its display label.
88
+ The pane cap includes every live pane; overflow creates another tab, not a
89
+ workspace. Separate parent processes own separate groups.
90
+ _Avoid_: Agent workspace, label-based ownership, automatic rearrangement
91
+
92
+ **Retained checkout shell**:
93
+ The interactive shell in a managed worktree's root pane, preserved after the
94
+ child Pi process exits. Temporary review panes can close without deleting this
95
+ surface or its checkout.
96
+ _Avoid_: Completed agent process, disposable pane, automatic worktree cleanup
97
+
79
98
  **Worktree lease**:
80
99
  The lifetime-exclusive binding between a persistent specialist generation and
81
100
  one managed worktree, when that specialist writes in a worktree.
package/README.md CHANGED
@@ -83,7 +83,7 @@ Use ordinary panes for read-only agents. A single or sequential writer can work
83
83
 
84
84
  ![Pi Herdr Agents lifecycle: spawn a child, run it in Herdr, supervise live state, and deliver one bounded result to the parent.](https://raw.githubusercontent.com/giuseppecrj/pi-herdr-agents/main/docs/assets/async-subagent-lifecycle.png)
85
85
 
86
- A `subagent` call creates a dedicated Herdr pane or worktree, launches a child Pi session, and returns `started`. The parent watcher combines Herdr process state with child activity details and projects the result into a live widget:
86
+ A `subagent` call selects the target checkout, reuses its Herdr workspace, and gives the child a pane in an extension-owned `Agents` tab. Four panes fit in each tab by default; overflow opens another tab in the same workspace. A worktree is created only when explicitly requested for checkout isolation. The call launches a child Pi session and returns `started`. The parent watcher combines Herdr process state with child activity details and projects the result into a live widget:
87
87
 
88
88
  ```text
89
89
  ╭─ Subagents ──────────────────── 1 active · 1 open ─╮
@@ -92,7 +92,7 @@ A `subagent` call creates a dedicated Herdr pane or worktree, launches a child P
92
92
  ╰────────────────────────────────────────────────────╯
93
93
  ```
94
94
 
95
- When the child completes, the parent receives one bounded `subagent_result` message and starts a new turn with that result in context. Callers never need to poll, tail session files, or wait in a shell loop.
95
+ When the child completes, the parent receives one bounded `subagent_result` message and starts a new turn with that result in context. Disposable ordinary panes close after result delivery; Herdr removes a tab when its last pane closes. Persistent specialists keep their pane between tasks, and managed worktree roots return to retained interactive shells. Callers never need to poll, tail session files, or wait in a shell loop.
96
96
 
97
97
  ## Troubleshooting completion delivery
98
98
 
@@ -301,11 +301,13 @@ cp config.json.example config.json
301
301
  "maxAgents": 3
302
302
  },
303
303
  "supervision": {
304
- "forcePolling": false
304
+ "forcePolling": false,
305
+ "hangWarningMinutes": 15
305
306
  },
306
307
  "panes": {
307
- "mode": "tab",
308
- "direction": "right"
308
+ "mode": "grouped",
309
+ "direction": "right",
310
+ "maxPerTab": 4
309
311
  }
310
312
  }
311
313
  ```
@@ -347,6 +349,34 @@ disable wake-ups and use that legacy cadence deliberately. The setting is read
347
349
  when the coordinator is created, so run `/reload` after changing it.
348
350
  `subagents_list` reports the active transport mode (`wake+batch`,
349
351
  `polling(forced)`, or `polling(fallback)`) and watcher count.
352
+
353
+ `supervision.hangWarningMinutes` defaults to `15`; set it to `0` to disable
354
+ no-progress advisories. For example, this keeps the default transport and sets
355
+ a 30-minute advisory budget:
356
+
357
+ ```json
358
+ {
359
+ "supervision": {
360
+ "forcePolling": false,
361
+ "hangWarningMinutes": 30
362
+ }
363
+ }
364
+ ```
365
+
366
+ While a child projects active or blocked, the parent compares durable session
367
+ JSONL and activity-snapshot updates against this budget. An advisory is warning-only, fires once per no-progress episode, and
368
+ never interrupts, kills, retries, or restarts a child. It identifies `blocked-tool` (an outstanding tool call may still complete),
369
+ `truncated-turn` (an observed `toolUse` stop with no tool call; its cause is unknown), or
370
+ `generic-no-progress` when neither condition is established, then
371
+ includes the session path and manual recovery options. Ordinary children can be
372
+ interrupted or, after manual termination, resumed or newly spawned. Persistent
373
+ ordinary-pane specialists can be interrupted or stopped with `subagent_stop` and
374
+ replaced; they cannot be resumed. Managed-worktree children, including persistent
375
+ ones, retain their workspace and continue there only after the previous process
376
+ has exited; do not use `subagent_resume` or start a concurrent writer. Interactive children stay
377
+ quiet just as they do for stalled/recovered notices; their widget state still
378
+ updates. A later durable update clears the episode and sends the corresponding
379
+ recovered notice for non-interactive children.
350
380
  `polling(fallback)` means at least one tracked child is using per-child polling;
351
381
  other children can still use wake+batch.
352
382
 
@@ -359,7 +389,13 @@ was 4.82 s. The benchmark measures `/proc` CPU ticks for the supervisor and
359
389
  isolated Herdr tree, not parent-model latency; raw samples are written to
360
390
  `/tmp/issue29-bench/` by `test/bench/supervision-bench.mjs`.
361
391
 
362
- Set `panes.mode` to `"split"` to open ordinary public `subagent` and `subagent_resume` launches, including bare forks and `/iterate`, as splits of the stable parent pane. Set `panes.direction` to `"right"` or `"down"`; it defaults to `"right"` and is ignored when mode is `"tab"`. The default `"tab"` mode preserves existing behavior. Managed worktrees still use separate workspaces, while `/btw` keeps its existing tab behavior.
392
+ `panes.mode` defaults to `"grouped"` when omitted. Ordinary public `subagent` and `subagent_resume` launches, including bare forks and `/iterate`, fill extension-owned `Agents`, `Agents 2`, etc. tabs in the target checkout's existing workspace. `panes.maxPerTab` is a positive safe integer, defaults to `4`, and counts all live panes in each owned tab, including user-added panes and retained shells. Overlapping launches in one parent respect this cap. It is independent of `persistent.maxAgents`.
393
+
394
+ Checkout matching uses Herdr's canonical `worktree.checkout_path` and includes descendant directories. Shell working directories do not establish workspace ownership. If no checkout matches (including non-Git directories), placement uses the caller's workspace; overflow never creates a workspace. A reviewer with `cwd` set to a managed checkout joins that workspace without creating another worktree. Resume placement uses the saved session's cwd.
395
+
396
+ Explicit `panes.mode: "tab"` preserves one new tab per ordinary child in the caller's workspace. Explicit `"split"` preserves splits of the stable parent pane. `panes.direction` is `"right"` (default) or `"down"` and applies to grouped and legacy splits. `maxPerTab` does not affect these legacy modes. Managed worktrees retain their separate workspaces, while `/btw` keeps its existing tab behavior.
397
+
398
+ Ownership is tracked by returned pane/tab/workspace IDs, never labels. Separate parent processes own separate groups; `/reload` preserves a parent's in-memory ownership, but a full restart does not adopt old tabs. Placement never moves existing panes or renames user tabs. Background launches preserve focus; Herdr may resize sibling panes when splitting or closing. User-added panes are never closed by automatic tab cleanup. An owned tab remains reusable while user panes remain, even after all child panes close.
363
399
 
364
400
  Run `/reload` after changing role, model, or pane settings.
365
401
 
@@ -457,7 +493,7 @@ A launch with `worktree` and an effective bundled `scout`, `reviewer`, or `adver
457
493
 
458
494
  The child starts at the returned worktree root. Tell writing agents to test and commit when you want a commit-based handoff, and tell them not to push, merge, switch branches, or remove the worktree. The parent owns review and integration.
459
495
 
460
- Successful, failed, and help-requesting runs retain their workspace. Completion includes the worktree path, Herdr workspace, branch, base/head SHAs, commits ahead, changed and untracked files, and clean/dirty/conflicted state. Here, `clean` means no uncommitted files; the branch may still contain commits. If Git inspection fails, state is reported as unknown rather than guessed.
496
+ Successful, failed, and help-requesting worktree runs retain their workspace and root shell. A reviewer's disposable pane can close without closing that root, tab, or checkout. Completion includes the worktree path, Herdr workspace, branch, base/head SHAs, commits ahead, changed and untracked files, and clean/dirty/conflicted state. Here, `clean` means no uncommitted files; the branch may still contain commits. If Git inspection fails, state is reported as unknown rather than guessed.
461
497
 
462
498
  An ownership manifest is written under the parent session's `artifacts/<session-id>/worktree-runs/` directory before Herdr creates resources. V1 does not automatically recover watchers after a full process restart, and `subagent_resume` does not reattach the managed worktree lifecycle.
463
499
 
@@ -568,7 +604,7 @@ Phase 5: Integrate → Parent reviews and integrates worktree branches on
568
604
  Phase 6: Review → Reviewer subagent checks the integrated changes
569
605
  ```
570
606
 
571
- The parent workspace and tab names stay unchanged. Subagents are created in newly named tabs or panes for each phase.
607
+ The parent workspace and tab names stay unchanged. Subagents use the configured placement policy; grouped mode reuses available space in owned Agents tabs.
572
608
 
573
609
  ---
574
610
 
@@ -12,10 +12,12 @@
12
12
  "maxAgents": 3
13
13
  },
14
14
  "supervision": {
15
- "forcePolling": false
15
+ "forcePolling": false,
16
+ "hangWarningMinutes": 15
16
17
  },
17
18
  "panes": {
18
- "mode": "tab",
19
- "direction": "right"
19
+ "mode": "grouped",
20
+ "direction": "right",
21
+ "maxPerTab": 4
20
22
  }
21
23
  }
@@ -46,7 +46,7 @@ For a worktree launch:
46
46
  - `worktree.branch` is a new, unique branch name. Git/Herdr rejects a branch that cannot be created or is already checked out elsewhere.
47
47
  - `worktree.base` may be any revision that resolves to a commit in the source repository. It defaults to committed `HEAD`.
48
48
  - The extension resolves `base` to an exact SHA, writes an ownership manifest, then calls `herdr worktree create --no-focus`.
49
- - The child starts at the root of the returned worktree, in that workspace's root pane.
49
+ - The child starts at the root of the returned worktree, in that workspace's retained root pane and newly owned `Agents` tab. Finishing Pi returns to the interactive checkout shell; it does not close the root pane.
50
50
  - Uncommitted and untracked files from the parent checkout are not copied. Commit anything the child must see before spawning it, or pass the needed context in the task.
51
51
  - Worktree creation does not steal terminal focus.
52
52
 
@@ -139,7 +139,7 @@ For parallel read-only review, prepare one stable existing checkout of the pull
139
139
 
140
140
  1. The parent records the canonical repository root, exact comparison base and head SHAs, and exact task/spec evidence. It makes sure no writer changes the checkout while review runs.
141
141
  2. Decide explicitly whether staged, unstaged, and untracked files are in scope. For included dirty state, record a bounded inventory and fingerprint; a commit SHA alone cannot pin it.
142
- 3. Start each read-only child in an ordinary pane with `cwd` set to that checkout. Omit `worktree`.
142
+ 3. Start each read-only child in an ordinary pane with `cwd` set to that checkout. Omit `worktree`. With default grouped placement, the extension reuses that checkout's workspace and available space in this parent's owned Agents tabs, including a retained writer's root tab. It never moves the writer or creates another worktree. Separate parent processes do not adopt one another's tabs by label.
143
143
  4. Give every reviewer the same exact scope. Require it to report the repository root and `git rev-parse HEAD` before its review result.
144
144
  5. Before each dependent review wave and before reporting, recheck the head and dirty-state fingerprint. Drift makes prior evidence stale; review the new state again instead of mixing revisions.
145
145
 
@@ -205,7 +205,9 @@ This manual continuation is not watched by the original parent lifecycle. Do not
205
205
 
206
206
  ## Cleanup
207
207
 
208
- Cleanup is always explicit. First make sure commits, patches, or uncommitted files are no longer needed. Then remove the Herdr worktree workspace:
208
+ Worktree and branch cleanup is always explicit. Ordinary temporary reviewer panes close after result delivery; Herdr removes their tab only if its last pane closes. The retained worktree root shell is excluded from automatic cleanup, and user-added panes are preserved. Persistent specialists retain their pane between tasks and follow the existing explicit stop semantics.
209
+
210
+ First make sure commits, patches, or uncommitted files are no longer needed. Then remove the Herdr worktree workspace:
209
211
 
210
212
  ```bash
211
213
  herdr worktree remove --workspace <workspace-id>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-herdr-agents",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Asynchronous Pi subagents in Herdr, with optional isolated Git worktrees",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,5 +1,7 @@
1
1
  import { execFile, execSync, execFileSync } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { realpathSync } from "node:fs";
4
+ import { resolve, relative, isAbsolute, sep } from "node:path";
3
5
  import { isFiniteNumber, isPlainObject, isString } from "./type-guards.ts";
4
6
 
5
7
  const execFileAsync = promisify(execFile);
@@ -132,33 +134,25 @@ interface HerdrCurrentPaneInfo {
132
134
  }
133
135
 
134
136
  function getHerdrCurrentPaneInfo(): HerdrCurrentPaneInfo {
135
- const paneId = process.env.HERDR_PANE_ID;
136
- const tabId = process.env.HERDR_TAB_ID;
137
- const workspaceId = process.env.HERDR_WORKSPACE_ID;
138
-
139
- // Fall back to `herdr pane current` if any identity env var is missing —
140
- // older herdr versions may not set all three.
141
- if (!paneId || !tabId || !workspaceId) {
142
- const output = herdrExec(buildCurrentPaneArgs());
143
- const parsed = parseHerdrJson(output);
144
- const pane = parsed?.result?.pane;
145
- if (
146
- !isString(pane?.pane_id) ||
147
- !isString(pane?.tab_id) ||
148
- !isString(pane?.workspace_id)
149
- ) {
150
- throw new Error(
151
- `Unexpected herdr pane current output: ${output.trim() || "(empty)"}`,
152
- );
153
- }
154
- return {
155
- pane_id: pane.pane_id,
156
- tab_id: pane.tab_id,
157
- workspace_id: pane.workspace_id,
158
- };
137
+ // Inherited IDs go stale after a pane moves. Herdr resolves the calling
138
+ // terminal's original identity to its live pane, tab, and workspace.
139
+ const output = herdrExec(buildCurrentPaneArgs());
140
+ const parsed = parseHerdrJson(output);
141
+ const pane = parsed?.result?.pane;
142
+ if (
143
+ !isString(pane?.pane_id) ||
144
+ !isString(pane?.tab_id) ||
145
+ !isString(pane?.workspace_id)
146
+ ) {
147
+ throw new Error(
148
+ `Unexpected herdr pane current output: ${output.trim() || "(empty)"}`,
149
+ );
159
150
  }
160
-
161
- return { pane_id: paneId, tab_id: tabId, workspace_id: workspaceId };
151
+ return {
152
+ pane_id: pane.pane_id,
153
+ tab_id: pane.tab_id,
154
+ workspace_id: pane.workspace_id,
155
+ };
162
156
  }
163
157
 
164
158
  function buildTabCreateArgs(
@@ -217,14 +211,11 @@ function buildWorktreeCreateArgs(
217
211
  ];
218
212
  }
219
213
 
220
- export function createHerdrSurface(name: string): string {
221
- // Create a new tab per subagent so parallel spawns each get a full tab
222
- // instead of ever-narrower splits of the parent pane. Target the current
223
- // workspace explicitly because Herdr's implicit default may be another space.
214
+ export function createHerdrSurface(name: string, cwd = process.cwd()): string {
215
+ // Legacy tab mode and BTW target the caller workspace explicitly; Herdr's
216
+ // implicit default may be another workspace.
224
217
  const { workspace_id: workspaceId } = getHerdrCurrentPaneInfo();
225
- const output = herdrExec(
226
- buildTabCreateArgs(name, process.cwd(), workspaceId),
227
- );
218
+ const output = herdrExec(buildTabCreateArgs(name, cwd, workspaceId));
228
219
  const paneId = extractHerdrRootPaneId(output, "tab create");
229
220
  try {
230
221
  herdrExec(["pane", "rename", paneId, name]);
@@ -234,6 +225,147 @@ export function createHerdrSurface(name: string): string {
234
225
  return paneId;
235
226
  }
236
227
 
228
+ interface OwnedAgentsTab {
229
+ workspaceId: string;
230
+ panes: Set<string>;
231
+ retainedPaneId?: string;
232
+ }
233
+
234
+ // In-memory ownership survives /reload, not process restart. Separate parent
235
+ // processes never adopt tabs by label or share a capacity reservation.
236
+ const agentsTabsKey = Symbol.for("pi-herdr-subagents:agents-tabs");
237
+ // SAFETY: this extension alone writes this process-local symbol.
238
+ const placementGlobal = globalThis as typeof globalThis & {
239
+ [agentsTabsKey]?: Map<string, OwnedAgentsTab>;
240
+ };
241
+ const agentsTabs = (placementGlobal[agentsTabsKey] ??= new Map<
242
+ string,
243
+ OwnedAgentsTab
244
+ >());
245
+
246
+ function canonicalPath(path: string): string {
247
+ try {
248
+ return realpathSync(path);
249
+ } catch {
250
+ return resolve(path);
251
+ }
252
+ }
253
+
254
+ function containsCwd(root: string, cwd: string): boolean {
255
+ const child = relative(root, cwd);
256
+ return (
257
+ child === "" ||
258
+ (child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child))
259
+ );
260
+ }
261
+
262
+ function placementPanes(): Array<{
263
+ pane_id: string;
264
+ tab_id: string;
265
+ workspace_id: string;
266
+ }> {
267
+ const parsed = parseHerdrJson(herdrExec(["pane", "list"]));
268
+ const panes = parsed?.result?.panes;
269
+ if (
270
+ parsed?.result?.type !== "pane_list" ||
271
+ !Array.isArray(panes) ||
272
+ panes.some(
273
+ (pane) =>
274
+ !isString(pane?.pane_id) ||
275
+ !isString(pane?.tab_id) ||
276
+ !isString(pane?.workspace_id),
277
+ )
278
+ ) {
279
+ throw new Error("Unexpected herdr pane list output for placement");
280
+ }
281
+ return panes;
282
+ }
283
+
284
+ export function createHerdrGroupedSurface(
285
+ name: string,
286
+ cwd: string,
287
+ maxPerTab: number,
288
+ direction: "right" | "down",
289
+ ): string {
290
+ const callerWorkspace = getHerdrCurrentPaneInfo().workspace_id;
291
+ const parsed = parseHerdrJson(herdrExec(["workspace", "list"]));
292
+ const workspaces = parsed?.result?.workspaces;
293
+ if (parsed?.result?.type !== "workspace_list" || !Array.isArray(workspaces)) {
294
+ throw new Error("Unexpected herdr workspace list output for placement");
295
+ }
296
+ const panes = placementPanes();
297
+ const target = canonicalPath(cwd);
298
+ let workspaceId = callerWorkspace;
299
+ let matchLength = -1;
300
+ for (const workspace of workspaces) {
301
+ if (!isString(workspace?.workspace_id))
302
+ throw new Error("Unexpected herdr workspace identity");
303
+ // Herdr exposes checkout ownership, but no stable non-Git workspace root.
304
+ // A shell's incidental cwd is not a workspace association.
305
+ const checkout = workspace.worktree?.checkout_path;
306
+ if (!isString(checkout)) continue;
307
+ const canonical = canonicalPath(checkout);
308
+ if (
309
+ containsCwd(canonical, target) &&
310
+ (canonical.length > matchLength ||
311
+ (canonical.length === matchLength &&
312
+ workspace.workspace_id === callerWorkspace))
313
+ ) {
314
+ workspaceId = workspace.workspace_id;
315
+ matchLength = canonical.length;
316
+ }
317
+ }
318
+
319
+ // The entire inspect/create/record window is synchronous, before launch's
320
+ // first await. Overlapping launches in this parent cannot overbook a tab.
321
+ let paneId: string | undefined;
322
+ for (const [tabId, owned] of agentsTabs) {
323
+ if (owned.workspaceId !== workspaceId) continue;
324
+ const live = panes.filter(
325
+ (pane) => pane.tab_id === tabId && pane.workspace_id === workspaceId,
326
+ );
327
+ if (live.length === 0) {
328
+ agentsTabs.delete(tabId);
329
+ continue;
330
+ }
331
+ if (live.length >= maxPerTab) continue;
332
+ // The tab ID remains ours even when only user-added panes survive.
333
+ const anchor =
334
+ live.find((pane) => owned.panes.has(pane.pane_id)) ?? live[0];
335
+ paneId = extractHerdrPaneId(
336
+ herdrExec(buildPaneSplitArgs(anchor.pane_id, direction, cwd)),
337
+ "pane split",
338
+ );
339
+ owned.panes.add(paneId);
340
+ break;
341
+ }
342
+ if (!paneId) {
343
+ const count = [...agentsTabs.values()].filter(
344
+ (tab) => tab.workspaceId === workspaceId,
345
+ ).length;
346
+ const label = count === 0 ? "Agents" : `Agents ${count + 1}`;
347
+ const output = herdrExec(buildTabCreateArgs(label, cwd, workspaceId));
348
+ paneId = extractHerdrRootPaneId(output, "tab create");
349
+ const tabId = parseHerdrJson(output)?.result?.tab?.tab_id;
350
+ if (!isString(tabId) || !tabId) {
351
+ // Only the explicitly returned pane is ours to roll back.
352
+ try {
353
+ herdrExec(["pane", "close", paneId]);
354
+ } catch {
355
+ /* preserve parse error */
356
+ }
357
+ throw new Error("Unexpected herdr tab create identity");
358
+ }
359
+ agentsTabs.set(tabId, { workspaceId, panes: new Set([paneId]) });
360
+ }
361
+ try {
362
+ herdrExec(["pane", "rename", paneId, name]);
363
+ } catch {
364
+ /* cosmetic */
365
+ }
366
+ return paneId;
367
+ }
368
+
237
369
  /** Worktree records returned by `herdr worktree list`. */
238
370
  export interface HerdrWorktreeInfo {
239
371
  branch: string;
@@ -298,7 +430,9 @@ function parseHerdrPaneList(output: string, workspaceId: string): string[] {
298
430
  ) {
299
431
  throw new Error("Unexpected herdr pane list output");
300
432
  }
301
- return parsed.result.panes
433
+ const panes: Array<{ workspace_id?: unknown; pane_id?: unknown }> =
434
+ parsed.result.panes;
435
+ return panes
302
436
  .filter((pane) => pane.workspace_id === workspaceId)
303
437
  .map((pane) => pane.pane_id)
304
438
  .filter(isString);
@@ -327,6 +461,34 @@ function recoverHerdrWorktree(
327
461
  };
328
462
  }
329
463
 
464
+ function retainWorktreeTab(
465
+ worktree: HerdrWorktreeSurface,
466
+ output: string,
467
+ ): HerdrWorktreeSurface {
468
+ const returnedTabId = parseHerdrJson(output)?.result?.tab?.tab_id;
469
+ const tabId =
470
+ isString(returnedTabId) && returnedTabId
471
+ ? returnedTabId
472
+ : placementPanes().find(
473
+ (pane) =>
474
+ pane.pane_id === worktree.paneId &&
475
+ pane.workspace_id === worktree.workspaceId,
476
+ )?.tab_id;
477
+ if (tabId) {
478
+ agentsTabs.set(tabId, {
479
+ workspaceId: worktree.workspaceId,
480
+ panes: new Set([worktree.paneId]),
481
+ retainedPaneId: worktree.paneId,
482
+ });
483
+ try {
484
+ herdrExec(["tab", "rename", tabId, "Agents"]);
485
+ } catch {
486
+ /* cosmetic */
487
+ }
488
+ }
489
+ return worktree;
490
+ }
491
+
330
492
  export function createHerdrWorktree(
331
493
  name: string,
332
494
  cwd: string,
@@ -335,7 +497,7 @@ export function createHerdrWorktree(
335
497
  ): HerdrWorktreeSurface {
336
498
  const output = herdrExec(buildWorktreeCreateArgs(name, cwd, branch, base));
337
499
  try {
338
- return extractHerdrWorktree(output);
500
+ return retainWorktreeTab(extractHerdrWorktree(output), output);
339
501
  } catch (parseError) {
340
502
  let recovered: HerdrWorktreeSurface | HerdrWorktreeInfo | undefined;
341
503
  try {
@@ -343,7 +505,8 @@ export function createHerdrWorktree(
343
505
  } catch {
344
506
  throw parseError;
345
507
  }
346
- if (recovered?.workspaceId && "paneId" in recovered) return recovered;
508
+ if (recovered?.workspaceId && "paneId" in recovered)
509
+ return retainWorktreeTab(recovered, output);
347
510
  if (recovered) {
348
511
  throw new HerdrWorktreeCreateError(
349
512
  `Herdr created branch ${branch}, but its workspace response was incomplete`,
@@ -357,11 +520,10 @@ export function createHerdrWorktree(
357
520
  export function createHerdrSurfaceSplit(
358
521
  name: string,
359
522
  direction: "right" | "down",
523
+ cwd = process.cwd(),
360
524
  ): string {
361
525
  const parentPaneId = getHerdrParentPaneId();
362
- const output = herdrExec(
363
- buildPaneSplitArgs(parentPaneId, direction, process.cwd()),
364
- );
526
+ const output = herdrExec(buildPaneSplitArgs(parentPaneId, direction, cwd));
365
527
  const paneId = extractHerdrPaneId(output, "pane split");
366
528
  try {
367
529
  herdrExec(["pane", "rename", paneId, name]);
@@ -759,7 +921,14 @@ export function sendHerdrEscape(surface: string): void {
759
921
  }
760
922
 
761
923
  export function closeHerdrSurface(surface: string): void {
924
+ for (const owned of agentsTabs.values()) {
925
+ if (owned.retainedPaneId === surface) return;
926
+ }
927
+ // Herdr removes a tab when its last pane closes. Never close a whole tab:
928
+ // a user may have added a pane since our last snapshot.
762
929
  herdrExec(["pane", "close", surface]);
930
+ // Keep tab ownership until placement observes that the tab is actually gone.
931
+ for (const owned of agentsTabs.values()) owned.panes.delete(surface);
763
932
  }
764
933
 
765
934
  export function renameHerdrTab(title: string): void {