pi-herdr-agents 3.0.2 → 3.0.3

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
@@ -96,6 +96,8 @@ npm pack --dry-run
96
96
  git diff --check
97
97
  ```
98
98
 
99
+ `npm run lint` needs Node.js 22.18+ (or 22.6–22.17 with `NODE_OPTIONS=--experimental-strip-types`) because oxlint imports `oxlint.config.ts` and its `tools/oxlint/anti-slop/index.ts` plugin as TypeScript.
100
+
99
101
  Run LSP diagnostics on every changed TypeScript file; lint and tests do not catch every TypeScript error.
100
102
 
101
103
  For Herdr or lifecycle changes, run the deterministic suite from inside Herdr. Run only one integration suite at a time on a Herdr instance; concurrent suites compete for terminal focus and process capacity and can cause false timeouts or leaked test resources.
package/CHANGELOG.md CHANGED
@@ -7,12 +7,44 @@ 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
- ## [v3.0.2](https://github.com/giuseppecrj/pi-herdr-agents/compare/v3.0.1...v3.0.2)
10
+ ## [v3.0.3](https://github.com/giuseppecrj/pi-herdr-agents/compare/v3.0.2...v3.0.3)
11
+
12
+ ### Merged
13
+
14
+ - test(integration): re-read worktree row before asserting its open workspace [`#81`](https://github.com/giuseppecrj/pi-herdr-agents/pull/81)
15
+ - Fix Pi readiness check when Pi rewrites argv [`#80`](https://github.com/giuseppecrj/pi-herdr-agents/pull/80)
16
+ - Fix tsc errors and git-version-sensitive index test; document lint Node floor [`#79`](https://github.com/giuseppecrj/pi-herdr-agents/pull/79)
17
+ - fix(worktrees): report the primary workspace creation opened [`#78`](https://github.com/giuseppecrj/pi-herdr-agents/pull/78)
18
+ - fix: keep parked supervision wakes alive until they settle [`#77`](https://github.com/giuseppecrj/pi-herdr-agents/pull/77)
19
+
20
+ ### Commits
21
+
22
+ - remove openspec [`5e31900`](https://github.com/giuseppecrj/pi-herdr-agents/commit/5e31900ea96fdaab6fcf26fc9a335497dcbc528d)
23
+ - fix(worktrees): bound the primary workspace report and hedge its note [`44a18a8`](https://github.com/giuseppecrj/pi-herdr-agents/commit/44a18a8145549801bd17007f05beb8a8cddf41b9)
24
+ - Confirm Pi startup by launch environment, not rewritten argv [`bbfe7e7`](https://github.com/giuseppecrj/pi-herdr-agents/commit/bbfe7e7093bd76d8803562ddb6263c27cfe4a982)
25
+ - fix: settle stranded supervision waits and test wake retain accounting [`d18df1c`](https://github.com/giuseppecrj/pi-herdr-agents/commit/d18df1cb83bedf86951d2f9702a4a6185b44a8e8)
26
+ - test: bound the identity-capture loop anchor and close supervision test gaps [`4a0e71a`](https://github.com/giuseppecrj/pi-herdr-agents/commit/4a0e71a97ebc9ba3f21a5377504f24c3d837d9a2)
27
+ - Treat an unreadable Pi environment as unproven, not fatal [`b69151a`](https://github.com/giuseppecrj/pi-herdr-agents/commit/b69151ae85bd13c45bdf3e980c0b36e315c7c863)
28
+ - test(worktree-cleanup): make the index-restore test hermetic and deterministic [`16840f6`](https://github.com/giuseppecrj/pi-herdr-agents/commit/16840f64b9481983cf44fc928061fd4e6822434d)
29
+ - test(worktree-cleanup): clear inherited git repository variables in the fixture [`323e1fb`](https://github.com/giuseppecrj/pi-herdr-agents/commit/323e1fbf6e6240eb5e7000d63fc84e23b391259a)
30
+ - test(worktrees): expect launch snapshots in scoped inventory [`f733040`](https://github.com/giuseppecrj/pi-herdr-agents/commit/f73304088c8a781b1ed7333a5a09175ec7c896e9)
31
+ - fix(types): resolve strict tsc errors in activity scope guard and recorder [`26bc9ef`](https://github.com/giuseppecrj/pi-herdr-agents/commit/26bc9ef2e8a2afc23901641efdd19661fcd3f756)
32
+ - docs: limit the lint type-stripping fallback to Node 22.6-22.17 [`ccab597`](https://github.com/giuseppecrj/pi-herdr-agents/commit/ccab597cfaf2bcf6e37889ae3103dd6e9c78c6ef)
33
+ - docs: note that npm run lint needs Node.js 22.18 or newer [`b8ffa8d`](https://github.com/giuseppecrj/pi-herdr-agents/commit/b8ffa8d1d83419588db8d4aed506d5d236360673)
34
+ - test(integration): check cancel manifest workspace id; clarify Herdr Node note [`aac18d9`](https://github.com/giuseppecrj/pi-herdr-agents/commit/aac18d926385582ccc36783fd8a9475db7df7e3d)
35
+ - test(worktree-cleanup): snapshot the index after git status [`aa869b2`](https://github.com/giuseppecrj/pi-herdr-agents/commit/aa869b2a62c8868d377b134d3f05b029692f2eb0)
36
+ - docs: integration tests need the Herdr server on Node.js 22.19+ [`bdd791b`](https://github.com/giuseppecrj/pi-herdr-agents/commit/bdd791bd934ea3174db32e1fcf1353353506224b)
37
+
38
+ ## [v3.0.2](https://github.com/giuseppecrj/pi-herdr-agents/compare/v3.0.1...v3.0.2) - 2026-10-06
11
39
 
12
40
  ### Merged
13
41
 
14
42
  - fix(subagents): resolve submodule sources and refresh fallback context [`#76`](https://github.com/giuseppecrj/pi-herdr-agents/pull/76)
15
43
 
44
+ ### Commits
45
+
46
+ - chore: release v3.0.2 [`245e54e`](https://github.com/giuseppecrj/pi-herdr-agents/commit/245e54e7ccc937f69bee6093906d26b03b8a92c9)
47
+
16
48
  ## [v3.0.1](https://github.com/giuseppecrj/pi-herdr-agents/compare/v3.0.0...v3.0.1) - 2026-10-06
17
49
 
18
50
  ### Merged
package/CONTEXT.md CHANGED
@@ -71,6 +71,13 @@ verified by the parent against the Herdr pane. A worktree cancel signals and
71
71
  judges exit only by it.
72
72
  _Avoid_: argv match, foreground process, shell visibility
73
73
 
74
+ **Pi startup confirmation**:
75
+ The bounded check that a `/worktree` handoff's Pi is running before its
76
+ workspace is focused: a Pi process in the root pane with the worktree cwd whose
77
+ launch-time environment names the launched session in
78
+ `PI_HERDR_AGENTS_SESSION`. Nothing inside Pi reads that marker.
79
+ _Avoid_: argv match, `--session` visibility, child-context hint
80
+
74
81
  **No-progress advisory**:
75
82
  An internal warning that an active child shows no durable progress in its session
76
83
  JSONL or activity snapshot. It is advisory only and never changes the child's
@@ -182,9 +189,13 @@ global enumeration and unsupported platforms remain blockers.
182
189
  _Avoid_: Proven unrelated, machine-wide inactivity, bypass permission
183
190
 
184
191
  **Explicit worktree removal**:
185
- A parent-requested removal of one named managed checkout and its open workspace,
186
- with absence verification and retained branch history. Never automatic reaping.
187
- _Avoid_: Branch deletion, completion cleanup
192
+ A parent-requested removal of one named managed checkout and its open worktree
193
+ workspace, with absence verification and retained branch history. Never automatic
194
+ reaping. It never closes the source repository's primary workspace. When this
195
+ process recorded that worktree creation appeared to open that workspace and a
196
+ read taken just before the report still shows it untouched, removal suggests
197
+ the `herdr workspace close` command, to use only if the workspace is unused.
198
+ _Avoid_: Branch deletion, completion cleanup, automatic close of a workspace the user may have used
188
199
 
189
200
  **Dirty-state preservation**:
190
201
  Explicit opt-in staging and WIP commitment of a worktree's uncommitted and
package/README.md CHANGED
@@ -553,9 +553,14 @@ project definitions keep their precedence over them.
553
553
 
554
554
  On supported local filesystems, supervision uses file wake-ups plus one shared
555
555
  4.8-second pane reconciliation. A wake-up only prompts fresh evidence
556
- collection; it never establishes a result by itself. If the watcher or shared
557
- pane inspection becomes unavailable, supervision quietly returns to the legacy
558
- one-second polling cadence. No caller action is required.
556
+ collection; it never establishes a result by itself. While any child's wait for
557
+ that check is parked, the coordinator's file watches stay referenced, so the
558
+ sidecar is still observed when the parent has no other event-loop work. The
559
+ reference is released when the wait settles, is aborted, or its child is
560
+ unregistered. With no parked wait, the watches stay unreferenced and do not
561
+ keep the process running. If the watcher
562
+ or shared pane inspection becomes unavailable, supervision quietly returns to
563
+ the legacy one-second polling cadence. No caller action is required.
559
564
 
560
565
  Set `supervision.forcePolling` to `true` in the durable user `config.json` to
561
566
  disable wake-ups and use that legacy cadence deliberately. The setting is read
@@ -866,15 +871,19 @@ is no other parent/child protocol.
866
871
 
867
872
  `/worktree <worktree> [task]` creates a Herdr-managed worktree from the current committed branch and launches a new interactive Pi session there with the active conversation branch. The original session remains available. Use `/worktree list` or `worktree_list({})` to inspect managed worktrees, including cross-session orphans, whose canonical source repositories are inside the session's cwd subtree. This is a new-process handoff, not an in-place move of the existing shell or Pi process.
868
873
 
874
+ The destination workspace is focused only after Pi startup is confirmed. Every fresh launch, including the handoff, sets `PI_HERDR_AGENTS_SESSION` to its session file; confirmation requires a Pi process in the root pane with the worktree cwd whose launch-time environment carries that value. Pi rewrites its process title, so command-line text is never evidence. Nothing inside Pi reads the variable, and it is not a child-context hint. Confirmation reads Linux `/proc`; see [Worktree subagents](docs/worktree-subagents.md).
875
+
869
876
  ---
870
877
 
871
878
  ### Explicit worktree cleanup
872
879
 
873
880
  Parent sessions can call `worktree_remove({ target: "<path|branch|workspace-id>", preserve: true })` or `/worktree remove <target> [--preserve]`. Preservation is optional and never implied: dirty work is blocked unless explicitly committed first or preserved as a WIP commit. The result reports its SHA even if removal later fails or is refused. A failed preservation commit restores the pre-preservation index and never proceeds to removal. Inventory and removal reports disclose exact ignored-file counts: enumeration is streamed rather than buffered as one listing. Ignored files do not block cleanup and are not captured by preservation; failed counting still blocks removal.
874
881
 
875
- Eligibility is rechecked at removal time: canonical source-repository cwd containment, registered linked checkout, no detected process holder, known live child, or persistent lease, and clean Git state with no untracked files or conflicts. A successful launch from a linked checkout does not itself authorize its removal; cleanup uses the canonical principal/source repository under the invoking parent session's cwd, not `manifest.sourceCwd` or shared Git identity. Start the parent Pi session rooted at that principal checkout or an ancestor containing it, then use normal explicit cleanup; `cd` inside an existing Pi session does not change the session cwd. A source repository that is a Git submodule is located through the `core.worktree` setting of its shared Git directory under the superproject's `.git/modules`, not through that directory's parent. Unknown inspection, identity disagreements, detached HEAD, locked checkouts, and initialized submodules block removal. Out-of-scope repositories are never eligible. Open workspaces use Herdr removal; orphans use Git removal and registration pruning after checkout absence is verified. Owned reachable manifests are marked `removed`; manifests from other sessions are not required or rewritten. Stale removed manifests never govern a recreated checkout. Missing or dangling manifest paths do not affect unrelated checkouts; undecidable or conflicting manifest identity still blocks removal. A failed manifest update after removal is reported as a warning. Symlinked ancestors are supported; checkout symlinks escaping the canonical managed root are blocked. Process inspection covers observable same-user processes across sessions, regardless of runtime name, exempting only Herdr-confirmed idle retained shells, never runtimes at the same PID. Unreadable individual process details produce non-blocking warnings without an override flag; scanning continues so another observable holder still blocks removal. Human and structured inventory/removal results disclose incomplete coverage, including warnings seen before a later recheck or failure. Same-user inspection is permission-limited, and other-user processes are not inspected: a protected process could hold the checkout undetected. Warnings aggregate counts and bounded PID samples, not commands or environments. Linux uses `/proc`; macOS uses same-user `lsof` cwd records and warns for unreadable individual records. Unsupported platforms, failed global enumeration (including a failed `lsof` with partial output), and other unverifiable eligibility evidence still block removal. Cleanup Git and Herdr calls have 30-second timeouts.
882
+ Eligibility is rechecked at removal time: canonical source-repository cwd containment, registered linked checkout, no detected process holder, known live child, or persistent lease, and clean Git state with no untracked files or conflicts. A successful launch from a linked checkout does not itself authorize its removal; cleanup uses the canonical principal/source repository under the invoking parent session's cwd, not `manifest.sourceCwd` or shared Git identity. Start the parent Pi session rooted at that principal checkout or an ancestor containing it, then use normal explicit cleanup; `cd` inside an existing Pi session does not change the session cwd. A source repository that is a Git submodule is located through the `core.worktree` setting of its shared Git directory under the superproject's `.git/modules`, not through that directory's parent. Unknown inspection, identity disagreements, detached HEAD, locked checkouts, and initialized submodules block removal. Out-of-scope repositories are never eligible. Open worktree workspaces use Herdr removal. The source repository's primary workspace is never closed automatically. If this process recorded that worktree creation appeared to open it and a read taken just before the report still shows it untouched, removal adds `wX appears to have been opened by worktree creation; if you haven't used it, close it with herdr workspace close wX`; a missing or wrong-typed field, another open workspace for the repository, another session, or a restarted process reports nothing. Orphans use Git removal and registration pruning after checkout absence is verified. Owned reachable manifests are marked `removed`; manifests from other sessions are not required or rewritten. Stale removed manifests never govern a recreated checkout. Missing or dangling manifest paths do not affect unrelated checkouts; undecidable or conflicting manifest identity still blocks removal. A failed manifest update after removal is reported as a warning. Symlinked ancestors are supported; checkout symlinks escaping the canonical managed root are blocked. Process inspection covers observable same-user processes across sessions, regardless of runtime name, exempting only Herdr-confirmed idle retained shells, never runtimes at the same PID. Unreadable individual process details produce non-blocking warnings without an override flag; scanning continues so another observable holder still blocks removal. Human and structured inventory/removal results disclose incomplete coverage, including warnings seen before a later recheck or failure. Same-user inspection is permission-limited, and other-user processes are not inspected: a protected process could hold the checkout undetected. Warnings aggregate counts and bounded PID samples, not commands or environments. Linux uses `/proc`; macOS uses same-user `lsof` cwd records and warns for unreadable individual records. Unsupported platforms, failed global enumeration (including a failed `lsof` with partial output), and other unverifiable eligibility evidence still block removal. Cleanup Git and Herdr calls have 30-second timeouts.
883
+
884
+ A worktree launch may add three synchronous Herdr snapshots (`worktree list` before create, `worktree list` after create, and `pane list` for the opened primary), each bounded at 3 seconds, 9 seconds if all three run to their timeouts. A failed snapshot claims nothing and is returned as a launch diagnostic, shown in the `subagent` acknowledgement and the `/worktree` notice; it is not written to the console. Claims are kept only in this process and are not written to the manifest. Ordinary non-worktree launches add no Herdr calls. Explicit removal adds a primary-workspace report only when this process holds a claim for the removed source repository: `worktree list`, `workspace get`, and `pane list`, each asynchronous and bounded at 3 seconds, taken after the checkout is removed. That is at most three calls and 9 seconds of wall time, without blocking the event loop. These report calls sit on top of the existing cleanup Git and Herdr calls, which stay at 30 seconds each.
876
885
 
877
- Cleanup never deletes or rewrites branches, uses force flags, or runs automatically. Session startup does not scan worktree inventory, avoiding blocking Pi initialization; use `/worktree list` or `worktree_list` for an explicit inventory. Child sessions retain `/worktree list` and `/worktree <name>`, but receive neither cleanup tools nor the remove subcommand. Their repository-local listing labels detached entries `(detached HEAD)`; a detached sibling does not prevent inspection of named branches. See [cleanup and recovery](docs/worktree-subagents.md#cleanup) for details.
886
+ Cleanup never deletes or rewrites branches, uses force flags, or runs automatically. It never closes the source repository's primary workspace. Session startup does not scan worktree inventory, avoiding blocking Pi initialization; use `/worktree list` or `worktree_list` for an explicit inventory. Child sessions retain `/worktree list` and `/worktree <name>`, but receive neither cleanup tools nor the remove subcommand. Their repository-local listing labels detached entries `(detached HEAD)`; a detached sibling does not prevent inspection of named branches. See [cleanup and recovery](docs/worktree-subagents.md#cleanup) for details.
878
887
 
879
888
  ## Custom Agents
880
889
 
@@ -1258,12 +1267,16 @@ npm run lint
1258
1267
  npm pack --dry-run
1259
1268
  ```
1260
1269
 
1270
+ `npm run lint` needs Node.js 22.18+ (or 22.6–22.17 with `NODE_OPTIONS=--experimental-strip-types`) because oxlint imports TypeScript directly: its configuration, `oxlint.config.ts`, and the JavaScript plugin it loads, `tools/oxlint/anti-slop/index.ts`. Without type stripping, oxlint fails before linting with `Unknown file extension ".ts"`. This is a contributor tooling requirement, not a runtime requirement for package users.
1271
+
1261
1272
  Run the required end-to-end suite from inside Herdr:
1262
1273
 
1263
1274
  ```bash
1264
1275
  npm run test:integration
1265
1276
  ```
1266
1277
 
1278
+ Start the Herdr server with Node.js 22.19+ on its `PATH`, because test panes inherit the server's environment, not the test runner's: Pi 1.0.0 needs Node.js 22.19+ and crashes at startup on versions older than 22.15.
1279
+
1267
1280
  The deterministic suite launches real Pi sessions, Herdr panes, and worktrees without provider credentials. The optional live-provider smoke test is not a merge gate:
1268
1281
 
1269
1282
  ```bash
package/docs/README.md CHANGED
@@ -60,10 +60,11 @@ real implementations; in-memory fakes are conformance fixtures only.
60
60
  | [`0008`](adr/0008-adopt-pi-only-subagent-execution.md) | Partially superseded by 0012 | Keep Pi-only execution; 0012 supersedes only the adapter-seam restriction. |
61
61
  | [`0009`](adr/0009-remove-workflow-subsystem.md) | Accepted; skill ownership moved by 0013 | Remove the workflow subsystem; use public subagent fan-out and parent synthesis. |
62
62
  | [`0010`](adr/0010-persistent-specialists-as-session-generations.md) | Accepted | Define persistent specialists as logical identities with policy-bound session generations. |
63
- | [`0011`](adr/0011-explicit-worktree-cleanup.md) | Accepted | Authorize explicit worktree cleanup by cwd containment; retain branches and reject automatic reaping. |
63
+ | [`0011`](adr/0011-explicit-worktree-cleanup.md) | Accepted; amended by 0015 | Authorize explicit worktree cleanup by cwd containment; retain branches and reject automatic reaping. |
64
64
  | [`0012`](adr/0012-adopt-maestro-seams-in-repo.md) | Accepted | Adopt in-repo seams and conformance fakes; keep one package, Pi-only execution, and Herdr-only surfaces. |
65
65
  | [`0013`](adr/0013-pack-neutral-execution-host.md) | Accepted | Ship a pack-neutral execution host; roles and workflows move to optional packs, and `/iterate` and `/btw` are removed. |
66
66
  | [`0014`](adr/0014-operator-cancel-terminal-intent.md) | Accepted | Operator cancel records terminal intent before owned termination: no fallback, one confirmed cancelled result, unconfirmed runs stay live. |
67
+ | [`0015`](adr/0015-report-opened-primary-workspace.md) | Accepted | Report, and never automatically close, a primary workspace that worktree creation opened. |
67
68
 
68
69
  ## Historical material
69
70
 
@@ -1,6 +1,6 @@
1
1
  # ADR-0011: Authorize explicit worktree cleanup by cwd containment
2
2
 
3
- - **Status:** Accepted
3
+ - **Status:** Accepted; amended by [ADR-0015](0015-report-opened-primary-workspace.md)
4
4
  - **Date:** 2026-09-17
5
5
  - **Scope:** Managed subagent worktree cleanup (issue #45)
6
6
 
@@ -0,0 +1,121 @@
1
+ # ADR-0015: Report the primary workspace worktree creation opened
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-10-07
5
+ - **Amends:** [ADR-0011](0011-explicit-worktree-cleanup.md)
6
+
7
+ ## Context
8
+
9
+ `herdr worktree create` groups a new worktree with the source repository's
10
+ primary workspace and opens that workspace when none is open. `herdr worktree
11
+ remove` removes only the linked checkout, so the first worktree of an idle
12
+ repository leaves an empty shell workspace behind.
13
+
14
+ Herdr's `WorktreeCreated` result does not include `created_parent`, so this
15
+ extension cannot ask Herdr which workspace the create call opened. The only
16
+ signal is a `herdr worktree list --cwd` snapshot before create and another
17
+ after it. A workspace the user or another session opens during a long checkout
18
+ can be attributed to this create. That race is inherent in the snapshots.
19
+
20
+ ## Decision
21
+
22
+ Explicit worktree removal never closes the source repository's primary
23
+ workspace.
24
+
25
+ On a worktree launch, this process may record one claim: workspace id, the
26
+ source `repo_key`, the only pane's `terminal_id`, and the checkout path the
27
+ workspace was opened at. A workspace that was already open is not claimed. A
28
+ snapshot that fails, times out, or lacks a required field claims nothing. The
29
+ failure is returned as a launch diagnostic, shown in the `subagent`
30
+ acknowledgement and the `/worktree` notice, and never written to the console.
31
+ Ordinary non-worktree launches do not take these snapshots.
32
+
33
+ The claim lives only in the creating process and is not written to the
34
+ manifest. Removal from another session, or after this process restarts,
35
+ reports nothing. Extension reload inside the same process keeps the in-memory
36
+ claim.
37
+
38
+ On explicit removal, core checks whether this process holds a claim whose
39
+ checkout is the removed source repository. If not, removal makes no report
40
+ calls at all. Otherwise the Herdr surface reads the source once more, after
41
+ the checkout is removed and immediately before any sentence. It may tell the
42
+ parent:
43
+
44
+ `wX appears to have been opened by worktree creation; if you haven't used it, close it with herdr workspace close wX`
45
+
46
+ only when that read shows the workspace untouched: the default label equal to
47
+ the repository name (both present), not focused, one tab, not a linked
48
+ worktree, the claimed `repo_key`, one pane, the recorded terminal, and `cwd`
49
+ and `foreground_cwd` at the recorded checkout. No other workspace may be open
50
+ for the repository; a listed row with an open workspace and no boolean
51
+ `is_linked_worktree` blocks the sentence too. Any missing or wrong-typed field
52
+ a check uses means the workspace is not untouched, so removal says nothing
53
+ about it. Shell idleness and child processes are not checked: the sentence is
54
+ advisory and hedged, and the extra reads would double the removal cost
55
+ without making a close safe.
56
+
57
+ The surface method is `SurfaceProvider.reportOpenedPrimaryWorkspace`. Herdr
58
+ implements it. Other surfaces return no report. Core does not import Herdr.
59
+
60
+ A failed report is a warning. The checkout removal still succeeds. Nothing in
61
+ this path polls, retries, or sleeps.
62
+
63
+ ### Blocking time
64
+
65
+ A worktree launch adds at most three synchronous Herdr calls, each bounded at
66
+ 3 seconds:
67
+
68
+ 1. `herdr worktree list --cwd` before create
69
+ 2. `herdr worktree list --cwd` after create, only when the before snapshot
70
+ succeeded and showed no primary workspace
71
+ 3. `herdr pane list --workspace` for that workspace's terminal, only when the
72
+ after snapshot shows a new primary workspace with a repo key and checkout
73
+ path
74
+
75
+ If each runs to its timeout, the extra blocking time is 9 seconds. A failed
76
+ before-snapshot skips the later two. `herdr worktree create` itself is an
77
+ existing call and is not given a new timeout here.
78
+
79
+ Explicit removal adds at most three asynchronous calls, each bounded at
80
+ 3 seconds, and only when this process holds a claim for the removed source
81
+ repository:
82
+
83
+ 1. `herdr worktree list --cwd`
84
+ 2. `herdr workspace get`
85
+ 3. `herdr pane list --workspace`
86
+
87
+ If each runs to its timeout, removal takes up to 9 more seconds of wall time.
88
+ The calls do not block the event loop. A failed or decisive call stops the
89
+ rest. These calls are in addition to the existing cleanup Git and Herdr calls,
90
+ which stay at 30 seconds each.
91
+
92
+ ## Rejected alternatives
93
+
94
+ - **Automatic close from the untouched-workspace heuristic.** One tab, one
95
+ pane, the same terminal id, cwd at the checkout, and the default label, even
96
+ with an idle shell and no children, cannot tell whether the user worked in
97
+ the workspace. `git
98
+ log`, a test run, and `cd` back to the checkout leave that same picture, and
99
+ closing it would destroy the scrollback.
100
+ - **Pane fingerprinting.** Reading `herdr pane read --source recent` at claim
101
+ time and requiring the same text before a close would catch some commands
102
+ the heuristic misses. It is not clearly better than reporting. Prompt
103
+ redraws, hooks, and Herdr's own startup change the text without a user
104
+ command; a cleared screen looks untouched; a read can fail; and the gap
105
+ between the confirming read and a close is still a race. A mistaken close
106
+ is destructive. A mistaken report is a command the user can ignore.
107
+ - **Trusting the manifest after restart.** The creating process is the only
108
+ observer of the before/after snapshots. A later process would be acting on
109
+ a claim it did not make.
110
+
111
+ ## Consequences
112
+
113
+ Cwd containment, branch retention, and the rejection of automatic reaping in
114
+ ADR-0011 are unchanged. A misattributed claim can name a workspace this create
115
+ did not open, so the sentence says the workspace "appears" to have been opened
116
+ and asks the user to close it only if they have not used it. The message does
117
+ not close it. There is no second re-check: the single set of reads is taken
118
+ immediately before the sentence, and a workspace that changes after those
119
+ reads can still be named. The sentence is still only a suggestion.
120
+
121
+ See the [operating guide](../worktree-subagents.md#cleanup).
@@ -48,11 +48,12 @@ For a worktree launch:
48
48
  - `worktree.branch` is a new, unique branch name. Git/Herdr rejects a branch that cannot be created or is already checked out elsewhere.
49
49
  - `worktree.base` may be any revision that resolves to a commit in the source repository. It defaults to committed `HEAD`.
50
50
  - The extension resolves `base` to an exact SHA, writes an ownership manifest, then calls `herdr worktree create --no-focus`.
51
+ - Herdr groups the new worktree with the source repository's primary workspace and opens one when none is open. The extension reads `herdr worktree list --cwd` before and after creation, and `herdr pane list` for the opened workspace's terminal, only to record a claim in this process. Each of those reads has a 3-second timeout. A worktree launch runs at most three of them; if each runs to its timeout, that is 9 seconds on top of create. A workspace you already had open is not claimed. An unreadable, timed-out, or incomplete snapshot claims nothing and is reported as a launch diagnostic in the `subagent` acknowledgement or `/worktree` notice, never on the console. A failed before-snapshot skips the later reads. Ordinary pane launches do not make these calls. The claim is not written to the manifest and is not kept after this process exits.
51
52
  - 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. A successful launch from a linked checkout does not itself authorize cleanup there: cleanup checks the canonical principal/source repository under the invoking parent session's cwd, not `manifest.sourceCwd` or shared Git identity. Start the parent Pi session rooted at the principal checkout or an ancestor containing it, then use normal explicit cleanup; changing directories inside an existing Pi session does not change its session cwd.
52
53
  - 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.
53
54
  - Worktree creation does not steal terminal focus.
54
55
 
55
- For an explicit interactive handoff, use `/worktree <worktree> [task]`. It creates the worktree from the current committed branch, forks the active conversation branch into the target-cwd session, launches a normal long-lived Pi process in the returned root pane, and focuses the destination workspace only after Herdr confirms Pi is running with the expected session and worktree cwd. Use `/worktree list` to inspect managed worktrees whose source repositories are inside the current cwd subtree, including cross-session orphans. The original process and session remain intact; pane movement is not used to change a running shell's cwd.
56
+ For an explicit interactive handoff, use `/worktree <worktree> [task]`. It creates the worktree from the current committed branch, forks the active conversation branch into the target-cwd session, launches a normal long-lived Pi process in the returned root pane, and focuses the destination workspace only after Pi startup is confirmed: a Pi process in the root pane's Herdr foreground list must have the worktree cwd and, in its launch-time environment (`/proc/<pid>/environ`), `PI_HERDR_AGENTS_SESSION` naming the forked session file. An unreadable environment (for example, a process that is exiting) never confirms startup. Command-line text is never evidence, because Pi rewrites its process title and Herdr then reports only `pi`. The confirmation reads Linux `/proc`; on other hosts it fails within its bounded wait and the launch-failure retention below applies. Use `/worktree list` to inspect managed worktrees whose source repositories are inside the current cwd subtree, including cross-session orphans. The original process and session remain intact; pane movement is not used to change a running shell's cwd.
56
57
 
57
58
  `worktree` cannot be set in agent frontmatter and is not exposed by the `/subagent <agent> <task>` shorthand. It is selected per call to the `subagent` tool. Ordered model fallback lists are not supported for worktree subagents: a failed attempt retains its worktree and branch for review, so a retry cannot safely reuse the requested branch. A persistent specialist either holds one worktree lease for its full lifetime or runs read-only in an ordinary pane; it cannot be re-bound.
58
59
 
@@ -235,7 +236,7 @@ Dirty work requires an explicit `--preserve` or `preserve: true`. This stages al
235
236
 
236
237
  Ignored files do not block cleanup. Inventory shows their exact file count, and a successful removal reports how many were deleted. Counting streams the NUL-delimited Git listing rather than buffering all paths; errors and the 30-second timeout still block removal, never report a guessed zero. Preservation does not capture ignored files; attempted and successful preservation reports disclose that exclusion when ignored files are present.
237
238
 
238
- Open workspaces are removed through Herdr. Git-only orphans use `git worktree remove`, verify checkout absence, and then prune stale Git registrations. Failed removal is reported, never forced. A reachable owned manifest is merge-updated to `removed` with `workspaceRemovedAt` only after success; absent cross-session manifests are reported and are not rewritten. A manifest update failure after removal is a removed-with-warning result, not a failed removal. Removed manifests provide already-removed no-op evidence only; a new checkout at the same path is classified independently. Missing and dangling manifest paths are non-matches. Permission errors, symlink loops, and other undecidable manifest identities remain blockers, as do genuine identity disagreements. Cleanup Git and Herdr calls have 30-second timeouts; a timed-out operation fails closed rather than proceeding with removal.
239
+ Open worktree workspaces are removed through Herdr. That removal never closes the source repository's primary workspace. If this process recorded that worktree creation opened it, removal reads the workspace after the checkout is removed and may report `wX appears to have been opened by worktree creation; if you haven't used it, close it with herdr workspace close wX` only when those reads, taken immediately before that sentence, show the workspace untouched: the default label equal to the repository name, not focused, one tab, not a linked worktree, the claimed `repo_key`, one pane, the recorded terminal, and both `cwd` and `foreground_cwd` at the recorded checkout, with no other workspace open for the repository. A listed row with an open workspace but no boolean `is_linked_worktree` blocks the sentence. A missing or wrong-typed `label`, `repo_name`, `focused`, `tab_count`, `foreground_cwd`, `terminal_id`, or any other field a check uses means the workspace is not untouched, so removal says nothing about it. Shell idleness and child processes are not checked; the sentence is a hedged suggestion, not a close. The before/after list can attribute a workspace that you or another session opened during a long checkout, because Herdr does not say which workspace create opened; the message does not close anything, so a wrong id is only a suggestion. Claims live only in the creating process: removal from another session, or after a restart, reports nothing. The report's longest path is three asynchronous calls — `worktree list`, `workspace get`, and `pane list`, 3 seconds each — 9 seconds of wall time if every call runs to its timeout, without blocking the event loop. A failed call stops the rest. With no claim for the removed source repository, removal adds none of these calls. Git-only orphans use `git worktree remove`, verify checkout absence, and then prune stale Git registrations. Failed removal is reported, never forced. A reachable owned manifest is merge-updated to `removed` with `workspaceRemovedAt` only after success; absent cross-session manifests are reported and are not rewritten. A manifest update failure after removal is a removed-with-warning result, not a failed removal. Removed manifests provide already-removed no-op evidence only; a new checkout at the same path is classified independently. Missing and dangling manifest paths are non-matches. Permission errors, symlink loops, and other undecidable manifest identities remain blockers, as do genuine identity disagreements. Cleanup Git and Herdr calls have 30-second timeouts; a timed-out operation fails closed rather than proceeding with removal.
239
240
 
240
241
  Branches and their commits are always retained: cleanup never deletes, force-updates, or rewrites a branch. It never runs on completion, shutdown, or a timer. Session startup skips worktree inventory to avoid blocking Pi initialization; use `/worktree list` or `worktree_list` for an explicit inventory. Outside Herdr, explicit inventory tools remain available and report unavailable inspection as unknown. Unsupported platforms or failed global process enumeration block removal; individual visibility gaps are disclosed warnings, never proof that an orphan is idle.
241
242
 
@@ -246,7 +247,8 @@ This first version intentionally does not provide:
246
247
  - automatic push, PR creation, merge, or cherry-pick
247
248
  - automatic worktree or branch removal
248
249
  - worktree-aware `subagent_resume`
249
- - durable restart reconciliation
250
+ - durable restart reconciliation (an opened-primary-workspace claim does not survive process restart)
251
+ - automatic close of the source repository's primary workspace
250
252
  - dependency DAG scheduling or merge queues
251
253
  - stacked-branch management
252
254
  - multi-repository workspaces
@@ -316,8 +316,18 @@ export function createSubagentActivityRecorder(params: {
316
316
  const runningChildId = params.runningChildId?.trim();
317
317
  const activityFile = params.activityFile?.trim();
318
318
  if (!runningChildId || !activityFile) return createNoopRecorder();
319
+ return createFileActivityRecorder(
320
+ runningChildId,
321
+ activityFile,
322
+ params.now ?? (() => Date.now()),
323
+ );
324
+ }
319
325
 
320
- const now = params.now ?? (() => Date.now());
326
+ function createFileActivityRecorder(
327
+ runningChildId: string,
328
+ activityFile: string,
329
+ now: () => number,
330
+ ): SubagentActivityRecorder {
321
331
  const createdAt = now();
322
332
  const activity: SubagentActivityState = {
323
333
  version: 1,
@@ -24,9 +24,11 @@ import type {
24
24
  import {
25
25
  getSubagentProcessIdentityFile,
26
26
  linuxProcessProbe,
27
+ readLinuxProcessEnvironment,
27
28
  readProcessIdentityRecord,
28
29
  verifyProcessIdentityRecord,
29
30
  type PiProcessIdentity,
31
+ type ProcessEnvironmentReader,
30
32
  type ProcessIdentityProbe,
31
33
  } from "./process-identity.ts";
32
34
  import {
@@ -141,11 +143,12 @@ export interface ProcessIdentityExpectation {
141
143
 
142
144
  type MaybePromise<T> = T | Promise<T>;
143
145
 
144
- interface WorktreeSurfaceForLaunch {
146
+ export interface WorktreeSurfaceForLaunch {
145
147
  path: string;
146
148
  branch: string;
147
149
  workspaceId: string;
148
150
  paneId: string;
151
+ diagnostics?: string[];
149
152
  }
150
153
 
151
154
  export interface PiLaunchOperations {
@@ -190,30 +193,38 @@ function placementFromPaneConfig(config: PaneConfig) {
190
193
  function worktreeSurfaceForLaunch(
191
194
  worktree: WorktreeSurface,
192
195
  ): WorktreeSurfaceForLaunch {
193
- return {
196
+ const surface: WorktreeSurfaceForLaunch = {
194
197
  path: worktree.path,
195
198
  branch: worktree.branch,
196
199
  workspaceId: worktree.workspaceId,
197
200
  paneId: worktree.surfaceId,
198
201
  };
202
+ if (worktree.diagnostics?.length) surface.diagnostics = worktree.diagnostics;
203
+ return surface;
199
204
  }
200
205
 
206
+ /**
207
+ * Names the launched session in the Pi process's exec-time environment. Pi
208
+ * rewrites its command line, so `--session` is never readiness evidence.
209
+ * Nothing inside Pi reads it.
210
+ */
211
+ export const PI_LAUNCH_SESSION_ENV = "PI_HERDR_AGENTS_SESSION";
212
+
201
213
  export function isExpectedPiProcess(
202
214
  process: {
215
+ pid: number;
203
216
  name?: string;
204
217
  argv0?: string;
205
- argv?: string[];
206
218
  cwd?: string;
207
219
  },
208
220
  sessionFile: string,
209
221
  cwd: string,
222
+ readEnvironment: ProcessEnvironmentReader = readLinuxProcessEnvironment,
210
223
  ): boolean {
211
- const sessionIndex = process.argv?.indexOf("--session") ?? -1;
212
224
  return (
213
225
  (process.name === "pi" || process.argv0?.split("/").pop() === "pi") &&
214
- sessionIndex >= 0 &&
215
- process.argv?.[sessionIndex + 1] === sessionFile &&
216
- process.cwd === cwd
226
+ process.cwd === cwd &&
227
+ readEnvironment(process.pid)?.get(PI_LAUNCH_SESSION_ENV) === sessionFile
217
228
  );
218
229
  }
219
230
 
@@ -232,15 +243,17 @@ async function waitForSurfacePiReady(
232
243
  while (Date.now() <= deadline) {
233
244
  try {
234
245
  const info = await Promise.resolve(provider.getProcessInfo(surface));
235
- if (
236
- info.foregroundProcesses.some((process) =>
237
- isExpectedPiProcess(process, sessionFile, cwd),
238
- )
239
- ) {
240
- return;
246
+ // An unreadable process (one that is exiting) proves nothing, but must
247
+ // not hide a matching sibling.
248
+ for (const process of info.foregroundProcesses) {
249
+ try {
250
+ if (isExpectedPiProcess(process, sessionFile, cwd)) return;
251
+ } catch (error) {
252
+ lastError = errorMessage(error);
253
+ }
241
254
  }
242
255
  } catch (error) {
243
- lastError = error instanceof Error ? error.message : String(error);
256
+ lastError = errorMessage(error);
244
257
  }
245
258
  if (Date.now() >= deadline) break;
246
259
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -636,6 +649,7 @@ async function prepareLaunchSurface(
636
649
  baseSha,
637
650
  manifestFile,
638
651
  };
652
+ if (created.diagnostics?.length) worktree.diagnostics = created.diagnostics;
639
653
  worktreeOps.writeWorktreeManifest(manifestFile, {
640
654
  state: "provisioned",
641
655
  ...ownership,
@@ -857,7 +871,7 @@ function buildPiCommand(
857
871
  }
858
872
  }
859
873
 
860
- const env: string[] = [];
874
+ const env = [`${PI_LAUNCH_SESSION_ENV}=${shellQuote(artifacts.sessionFile)}`];
861
875
  if (artifacts.localAgentDir) {
862
876
  env.push(`PI_CODING_AGENT_DIR=${shellQuote(artifacts.localAgentDir)}`);
863
877
  } else if (process.env.PI_CODING_AGENT_DIR) {
@@ -123,6 +123,44 @@ export function createLinuxProcessProbe(
123
123
 
124
124
  export const linuxProcessProbe = createLinuxProcessProbe();
125
125
 
126
+ /**
127
+ * A process's environment as it was at exec. Unlike its command line, Pi's
128
+ * process-title rewrite leaves this intact. `undefined` only when no such PID
129
+ * exists; throws on non-Linux hosts and any other read failure.
130
+ */
131
+ export type ProcessEnvironmentReader = (
132
+ pid: number,
133
+ ) => Map<string, string> | undefined;
134
+
135
+ export function createLinuxProcessEnvironmentReader(
136
+ read: (path: string) => string = (path) => readFileSync(path, "utf8"),
137
+ ): ProcessEnvironmentReader {
138
+ return (pid) => {
139
+ if (process.platform !== "linux")
140
+ throw new Error(
141
+ `process environment is unreadable on ${process.platform}; only Linux /proc is read`,
142
+ );
143
+ let text: string;
144
+ try {
145
+ text = read(`/proc/${pid}/environ`);
146
+ } catch (error) {
147
+ const code = error instanceof Error ? errnoCode(error) : undefined;
148
+ if (code === "ENOENT" || code === "ESRCH") return undefined;
149
+ throw error;
150
+ }
151
+ const environment = new Map<string, string>();
152
+ for (const entry of text.split("\0")) {
153
+ const separator = entry.indexOf("=");
154
+ if (separator > 0 && !environment.has(entry.slice(0, separator)))
155
+ environment.set(entry.slice(0, separator), entry.slice(separator + 1));
156
+ }
157
+ return environment;
158
+ };
159
+ }
160
+
161
+ export const readLinuxProcessEnvironment =
162
+ createLinuxProcessEnvironmentReader();
163
+
126
164
  /** The calling process's identity; throws when this host cannot establish it. */
127
165
  export function readOwnProcessIdentity(
128
166
  probe: ProcessIdentityProbe = linuxProcessProbe,
@@ -5,7 +5,7 @@ import type {
5
5
  SubagentActivityState,
6
6
  } from "./types.ts";
7
7
 
8
- const KNOWN_SCOPES = new Set<SubagentActivityScope>([
8
+ const KNOWN_SCOPES: ReadonlySet<string> = new Set<SubagentActivityScope>([
9
9
  "agent",
10
10
  "turn",
11
11
  "provider",
@@ -0,0 +1,108 @@
1
+ import { isNonEmptyString } from "./config/type-guards.ts";
2
+
3
+ /**
4
+ * A primary workspace this process saw Herdr open while creating a worktree.
5
+ * The record lives only in this process. Another session is another process,
6
+ * and a restart starts empty, so neither reports a claim. Nothing is persisted.
7
+ */
8
+ export interface PrimaryWorkspaceClaim {
9
+ workspaceId: string;
10
+ repoKey: string;
11
+ terminalId: string;
12
+ checkoutPath: string;
13
+ }
14
+
15
+ export interface ReleasedPrimaryWorkspaceClaim {
16
+ workspaceId: string;
17
+ repoKey: string;
18
+ terminalId: string;
19
+ }
20
+
21
+ /**
22
+ * A report never closes a workspace. `note` is absent when nothing is safe to
23
+ * suggest, and never states the attribution as fact.
24
+ */
25
+ export interface OpenedPrimaryWorkspaceReport {
26
+ note?: string;
27
+ repoKey: string;
28
+ releasedClaims: ReleasedPrimaryWorkspaceClaim[];
29
+ }
30
+
31
+ const openedPrimaryWorkspacesKey = Symbol.for(
32
+ "pi-herdr-agents:opened-primary-workspaces",
33
+ );
34
+
35
+ interface OpenedPrimaryWorkspaceStore {
36
+ claims: PrimaryWorkspaceClaim[];
37
+ }
38
+
39
+ function store(): OpenedPrimaryWorkspaceStore {
40
+ // SAFETY: this extension alone writes this process-local symbol.
41
+ const globalStore = globalThis as typeof globalThis & {
42
+ [openedPrimaryWorkspacesKey]?: OpenedPrimaryWorkspaceStore;
43
+ };
44
+ return (globalStore[openedPrimaryWorkspacesKey] ??= { claims: [] });
45
+ }
46
+
47
+ export function isCompletePrimaryWorkspaceClaim(
48
+ value: PrimaryWorkspaceClaim,
49
+ ): boolean {
50
+ return (
51
+ isNonEmptyString(value.workspaceId) &&
52
+ isNonEmptyString(value.repoKey) &&
53
+ isNonEmptyString(value.terminalId) &&
54
+ isNonEmptyString(value.checkoutPath)
55
+ );
56
+ }
57
+
58
+ function sameClaim(
59
+ left: PrimaryWorkspaceClaim,
60
+ right: PrimaryWorkspaceClaim,
61
+ ): boolean {
62
+ return (
63
+ left.workspaceId === right.workspaceId &&
64
+ left.repoKey === right.repoKey &&
65
+ left.terminalId === right.terminalId &&
66
+ left.checkoutPath === right.checkoutPath
67
+ );
68
+ }
69
+
70
+ export function rememberOpenedPrimaryWorkspace(
71
+ claim: PrimaryWorkspaceClaim,
72
+ ): void {
73
+ if (!isCompletePrimaryWorkspaceClaim(claim)) return;
74
+ const claims = store().claims;
75
+ if (claims.some((existing) => sameClaim(existing, claim))) return;
76
+ claims.push({
77
+ workspaceId: claim.workspaceId,
78
+ repoKey: claim.repoKey,
79
+ terminalId: claim.terminalId,
80
+ checkoutPath: claim.checkoutPath,
81
+ });
82
+ }
83
+
84
+ export function openedPrimaryWorkspaceClaims(): PrimaryWorkspaceClaim[] {
85
+ return store()
86
+ .claims.filter(isCompletePrimaryWorkspaceClaim)
87
+ .map((claim) => ({ ...claim }));
88
+ }
89
+
90
+ export function forgetOpenedPrimaryWorkspaceClaims(
91
+ released: readonly ReleasedPrimaryWorkspaceClaim[],
92
+ ): void {
93
+ if (!released.length) return;
94
+ const claims = store().claims;
95
+ store().claims = claims.filter(
96
+ (claim) =>
97
+ !released.some(
98
+ (entry) =>
99
+ entry.workspaceId === claim.workspaceId &&
100
+ entry.repoKey === claim.repoKey &&
101
+ entry.terminalId === claim.terminalId,
102
+ ),
103
+ );
104
+ }
105
+
106
+ export function clearOpenedPrimaryWorkspaceClaims(): void {
107
+ store().claims = [];
108
+ }