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.
@@ -3,10 +3,19 @@ import { promisify } from "node:util";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { resolve, relative, isAbsolute, sep } from "node:path";
5
5
  import {
6
+ isBoolean,
6
7
  isFiniteNumber,
8
+ isNonEmptyString,
7
9
  isPlainObject,
8
10
  isString,
9
11
  } from "../../core/config/type-guards.ts";
12
+ import {
13
+ isCompletePrimaryWorkspaceClaim,
14
+ rememberOpenedPrimaryWorkspace,
15
+ type OpenedPrimaryWorkspaceReport,
16
+ type PrimaryWorkspaceClaim,
17
+ type ReleasedPrimaryWorkspaceClaim,
18
+ } from "../../core/opened-primary-workspace.ts";
10
19
  import { WorktreeProvisioningError } from "../../core/surface-provider.ts";
11
20
 
12
21
  const execFileAsync = promisify(execFile);
@@ -83,6 +92,8 @@ export interface HerdrWorktreeSurface {
83
92
  branch: string;
84
93
  workspaceId: string;
85
94
  paneId: string;
95
+ /** Primary-workspace snapshot failures; the launch still succeeds. */
96
+ diagnostics?: string[];
86
97
  }
87
98
 
88
99
  function extractHerdrWorktree(output: string): HerdrWorktreeSurface {
@@ -536,15 +547,181 @@ function retainWorktreeTab(
536
547
  return worktree;
537
548
  }
538
549
 
550
+ /** Each launch snapshot: `worktree list` before and after create, and the opened workspace's `pane list`. */
551
+ export const OPENED_PRIMARY_SNAPSHOT_TIMEOUT_MS = 3_000;
552
+ export const OPENED_PRIMARY_SNAPSHOT_CALLS = 3;
553
+ /** Synchronous launch blocking if every snapshot runs to its timeout. */
554
+ export const OPENED_PRIMARY_SNAPSHOT_WORST_CASE_MS =
555
+ OPENED_PRIMARY_SNAPSHOT_CALLS * OPENED_PRIMARY_SNAPSHOT_TIMEOUT_MS;
556
+
557
+ /** Each asynchronous removal read: `worktree list`, `workspace get`, `pane list`. */
558
+ export const OPENED_PRIMARY_REPORT_TIMEOUT_MS = 3_000;
559
+ export const OPENED_PRIMARY_REPORT_CALLS = 3;
560
+ /** Wall time added to a removal if every read runs to its timeout; the event loop is not blocked. */
561
+ export const OPENED_PRIMARY_REPORT_WORST_CASE_MS =
562
+ OPENED_PRIMARY_REPORT_CALLS * OPENED_PRIMARY_REPORT_TIMEOUT_MS;
563
+
564
+ interface HerdrWorktreeSource {
565
+ repoKey?: string;
566
+ checkoutPath?: string;
567
+ primaryWorkspaceId?: string;
568
+ }
569
+
570
+ type SnapshotPhase = "before create" | "after create" | "opened workspace";
571
+
572
+ function snapshotFailure(
573
+ phase: SnapshotPhase,
574
+ error: any,
575
+ diagnostics: string[],
576
+ ): void {
577
+ diagnostics.push(
578
+ `Primary workspace snapshot (${phase}) failed: ${error instanceof Error ? error.message : String(error)}`,
579
+ );
580
+ }
581
+
582
+ function parseHerdrWorktreeSource(output: string): HerdrWorktreeSource {
583
+ const parsed = parseHerdrJson(output);
584
+ if (parsed?.result?.type !== "worktree_list") {
585
+ throw new Error("Unexpected herdr worktree list output");
586
+ }
587
+ const source = parsed.result.source;
588
+ const info: HerdrWorktreeSource = {};
589
+ if (!isPlainObject(source)) return info;
590
+ if (isNonEmptyString(source.repo_key)) info.repoKey = source.repo_key;
591
+ if (isNonEmptyString(source.source_checkout_path))
592
+ info.checkoutPath = source.source_checkout_path;
593
+ if (isNonEmptyString(source.source_workspace_id))
594
+ info.primaryWorkspaceId = source.source_workspace_id;
595
+ return info;
596
+ }
597
+
598
+ function readHerdrWorktreeSource(
599
+ cwd: string,
600
+ timeout: number,
601
+ phase: SnapshotPhase,
602
+ diagnostics: string[],
603
+ ): HerdrWorktreeSource | undefined {
604
+ try {
605
+ return parseHerdrWorktreeSource(
606
+ herdrExec(["worktree", "list", "--cwd", cwd], timeout),
607
+ );
608
+ } catch (error) {
609
+ snapshotFailure(phase, error, diagnostics);
610
+ return undefined;
611
+ }
612
+ }
613
+
614
+ interface ClaimedPane {
615
+ paneId: string;
616
+ terminalId?: string;
617
+ cwd?: string;
618
+ foregroundCwd?: string;
619
+ }
620
+
621
+ function parseClaimedPanes(output: string, workspaceId: string): ClaimedPane[] {
622
+ const parsed = parseHerdrJson(output);
623
+ if (
624
+ parsed?.result?.type !== "pane_list" ||
625
+ !Array.isArray(parsed.result.panes)
626
+ ) {
627
+ throw new Error("Unexpected herdr pane list output");
628
+ }
629
+ const panes: ClaimedPane[] = [];
630
+ for (const pane of parsed.result.panes) {
631
+ if (!isPlainObject(pane) || pane.workspace_id !== workspaceId) continue;
632
+ if (!isNonEmptyString(pane.pane_id)) continue;
633
+ const record: ClaimedPane = { paneId: pane.pane_id };
634
+ if (isNonEmptyString(pane.terminal_id))
635
+ record.terminalId = pane.terminal_id;
636
+ if (isNonEmptyString(pane.cwd)) record.cwd = pane.cwd;
637
+ if (isNonEmptyString(pane.foreground_cwd))
638
+ record.foregroundCwd = pane.foreground_cwd;
639
+ panes.push(record);
640
+ }
641
+ return panes;
642
+ }
643
+
644
+ function readPrimaryTerminalId(
645
+ workspaceId: string,
646
+ timeout: number,
647
+ diagnostics: string[],
648
+ ): string | undefined {
649
+ try {
650
+ const panes = parseClaimedPanes(
651
+ herdrExec(["pane", "list", "--workspace", workspaceId], timeout),
652
+ workspaceId,
653
+ );
654
+ return panes.length === 1 ? panes[0].terminalId : undefined;
655
+ } catch (error) {
656
+ snapshotFailure("opened workspace", error, diagnostics);
657
+ return undefined;
658
+ }
659
+ }
660
+
661
+ function openedPrimaryClaim(
662
+ source: HerdrWorktreeSource | undefined,
663
+ timeout: number,
664
+ diagnostics: string[],
665
+ ): PrimaryWorkspaceClaim | undefined {
666
+ if (!source?.primaryWorkspaceId || !source.repoKey || !source.checkoutPath)
667
+ return undefined;
668
+ const terminalId = readPrimaryTerminalId(
669
+ source.primaryWorkspaceId,
670
+ timeout,
671
+ diagnostics,
672
+ );
673
+ if (!terminalId) return undefined;
674
+ const claim: PrimaryWorkspaceClaim = {
675
+ workspaceId: source.primaryWorkspaceId,
676
+ repoKey: source.repoKey,
677
+ terminalId,
678
+ checkoutPath: source.checkoutPath,
679
+ };
680
+ return isCompletePrimaryWorkspaceClaim(claim) ? claim : undefined;
681
+ }
682
+
683
+ function withDiagnostics(
684
+ worktree: HerdrWorktreeSurface,
685
+ diagnostics: string[],
686
+ ): HerdrWorktreeSurface {
687
+ return diagnostics.length ? { ...worktree, diagnostics } : worktree;
688
+ }
689
+
539
690
  export function createHerdrWorktree(
540
691
  name: string,
541
692
  cwd: string,
542
693
  branch: string,
543
694
  base: string,
695
+ snapshotTimeoutMs = OPENED_PRIMARY_SNAPSHOT_TIMEOUT_MS,
544
696
  ): HerdrWorktreeSurface {
697
+ // A failed before-snapshot claims nothing and skips the later snapshots.
698
+ // Herdr does not report which workspace create opened, so a workspace that
699
+ // appears during a long checkout can be misattributed. Removal only
700
+ // suggests closing it; it never closes the workspace.
701
+ const diagnostics: string[] = [];
702
+ const before = readHerdrWorktreeSource(
703
+ cwd,
704
+ snapshotTimeoutMs,
705
+ "before create",
706
+ diagnostics,
707
+ );
545
708
  const output = herdrExec(buildWorktreeCreateArgs(name, cwd, branch, base));
709
+ const after =
710
+ before && !before.primaryWorkspaceId
711
+ ? readHerdrWorktreeSource(
712
+ cwd,
713
+ snapshotTimeoutMs,
714
+ "after create",
715
+ diagnostics,
716
+ )
717
+ : undefined;
718
+ const claim = openedPrimaryClaim(after, snapshotTimeoutMs, diagnostics);
719
+ if (claim) rememberOpenedPrimaryWorkspace(claim);
546
720
  try {
547
- return retainWorktreeTab(extractHerdrWorktree(output), output);
721
+ return withDiagnostics(
722
+ retainWorktreeTab(extractHerdrWorktree(output), output),
723
+ diagnostics,
724
+ );
548
725
  } catch (parseError) {
549
726
  let recovered: HerdrWorktreeSurface | HerdrWorktreeInfo | undefined;
550
727
  try {
@@ -553,7 +730,7 @@ export function createHerdrWorktree(
553
730
  throw parseError;
554
731
  }
555
732
  if (recovered?.workspaceId && "paneId" in recovered)
556
- return retainWorktreeTab(recovered, output);
733
+ return withDiagnostics(retainWorktreeTab(recovered, output), diagnostics);
557
734
  if (recovered) {
558
735
  throw new HerdrWorktreeCreateError(
559
736
  `Herdr created branch ${branch}, but its workspace response was incomplete`,
@@ -564,6 +741,172 @@ export function createHerdrWorktree(
564
741
  }
565
742
  }
566
743
 
744
+ interface DecodedPrimaryWorkspace {
745
+ label?: string;
746
+ focused?: boolean;
747
+ tabCount?: number;
748
+ linked?: boolean;
749
+ repoKey?: string;
750
+ repoName?: string;
751
+ }
752
+
753
+ function decodePrimaryWorkspace(
754
+ value: any,
755
+ ): DecodedPrimaryWorkspace | undefined {
756
+ if (!isPlainObject(value)) return undefined;
757
+ const decoded: DecodedPrimaryWorkspace = {};
758
+ if (isNonEmptyString(value.label)) decoded.label = value.label;
759
+ if (isBoolean(value.focused)) decoded.focused = value.focused;
760
+ if (isFiniteNumber(value.tab_count)) decoded.tabCount = value.tab_count;
761
+ if (!isPlainObject(value.worktree)) return decoded;
762
+ if (isBoolean(value.worktree.is_linked_worktree))
763
+ decoded.linked = value.worktree.is_linked_worktree;
764
+ if (isNonEmptyString(value.worktree.repo_key))
765
+ decoded.repoKey = value.worktree.repo_key;
766
+ if (isNonEmptyString(value.worktree.repo_name))
767
+ decoded.repoName = value.worktree.repo_name;
768
+ return decoded;
769
+ }
770
+
771
+ function workspaceLooksUntouched(
772
+ workspace: DecodedPrimaryWorkspace | undefined,
773
+ repoKey: string,
774
+ ): boolean {
775
+ return (
776
+ workspace?.label !== undefined &&
777
+ workspace.repoName !== undefined &&
778
+ workspace.label === workspace.repoName &&
779
+ workspace.focused === false &&
780
+ workspace.tabCount === 1 &&
781
+ workspace.linked === false &&
782
+ workspace.repoKey === repoKey
783
+ );
784
+ }
785
+
786
+ /**
787
+ * Any open workspace in the listing other than the primary's own principal
788
+ * row blocks the note, including rows whose linked flag is missing.
789
+ */
790
+ function otherWorkspaceIsOpen(listing: string, primaryId: string): boolean {
791
+ const worktrees = parseHerdrJson(listing)?.result?.worktrees;
792
+ if (!Array.isArray(worktrees)) return true;
793
+ return worktrees.some((row) => {
794
+ if (!isPlainObject(row)) return true;
795
+ const open = row.open_workspace_id;
796
+ if (open === undefined || open === null) return false;
797
+ return !(open === primaryId && row.is_linked_worktree === false);
798
+ });
799
+ }
800
+
801
+ function sameCheckoutPath(left: string, right: string): boolean {
802
+ if (left === right) return true;
803
+ try {
804
+ return realpathSync(left) === realpathSync(right);
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+
810
+ function releaseClaim(
811
+ claim: PrimaryWorkspaceClaim,
812
+ ): ReleasedPrimaryWorkspaceClaim {
813
+ return {
814
+ workspaceId: claim.workspaceId,
815
+ repoKey: claim.repoKey,
816
+ terminalId: claim.terminalId,
817
+ };
818
+ }
819
+
820
+ interface PrimaryInspection {
821
+ release: ReleasedPrimaryWorkspaceClaim[];
822
+ matched?: PrimaryWorkspaceClaim;
823
+ }
824
+
825
+ async function inspectOpenedPrimary(
826
+ workspaceId: string,
827
+ repoKey: string,
828
+ named: readonly PrimaryWorkspaceClaim[],
829
+ ): Promise<PrimaryInspection> {
830
+ const decoded = decodePrimaryWorkspace(
831
+ parseHerdrJson(
832
+ await herdrExecAsync(
833
+ ["workspace", "get", workspaceId],
834
+ OPENED_PRIMARY_REPORT_TIMEOUT_MS,
835
+ ),
836
+ )?.result?.workspace,
837
+ );
838
+ if (!workspaceLooksUntouched(decoded, repoKey)) return { release: [] };
839
+ const panes = parseClaimedPanes(
840
+ await herdrExecAsync(
841
+ ["pane", "list", "--workspace", workspaceId],
842
+ OPENED_PRIMARY_REPORT_TIMEOUT_MS,
843
+ ),
844
+ workspaceId,
845
+ );
846
+ if (panes.length !== 1) return { release: [] };
847
+ const pane = panes[0];
848
+ const matched = pane.terminalId
849
+ ? named.find((claim) => claim.terminalId === pane.terminalId)
850
+ : undefined;
851
+ if (!matched) return { release: named.map(releaseClaim) };
852
+ const stale = named.filter((claim) => claim !== matched).map(releaseClaim);
853
+ if (
854
+ !pane.cwd ||
855
+ !pane.foregroundCwd ||
856
+ !sameCheckoutPath(pane.cwd, matched.checkoutPath) ||
857
+ !sameCheckoutPath(pane.foregroundCwd, matched.checkoutPath)
858
+ )
859
+ return { release: stale };
860
+ return { release: stale, matched };
861
+ }
862
+
863
+ export function openedPrimaryWorkspaceNote(workspaceId: string): string {
864
+ return `${workspaceId} appears to have been opened by worktree creation; if you haven't used it, close it with herdr workspace close ${workspaceId}`;
865
+ }
866
+
867
+ /**
868
+ * Suggest closing a primary workspace this process recorded at worktree
869
+ * creation when it still looks untouched. Never closes the workspace. At most
870
+ * three asynchronous reads, each bounded by OPENED_PRIMARY_REPORT_TIMEOUT_MS,
871
+ * all taken after the checkout is removed and immediately before the note.
872
+ * Returns undefined when no claim applies to the source Herdr reports.
873
+ */
874
+ export async function reportOpenedPrimaryWorkspace(
875
+ cwd: string,
876
+ claims: readonly PrimaryWorkspaceClaim[],
877
+ ): Promise<OpenedPrimaryWorkspaceReport | undefined> {
878
+ const complete = claims.filter(isCompletePrimaryWorkspaceClaim);
879
+ if (!complete.length) return undefined;
880
+ const listing = await herdrExecAsync(
881
+ ["worktree", "list", "--cwd", cwd],
882
+ OPENED_PRIMARY_REPORT_TIMEOUT_MS,
883
+ );
884
+ const source = parseHerdrWorktreeSource(listing);
885
+ if (!source.repoKey) return undefined;
886
+ const repoKey = source.repoKey;
887
+ const own = complete.filter((claim) => claim.repoKey === repoKey);
888
+ if (!own.length) return undefined;
889
+ const id = source.primaryWorkspaceId;
890
+ const elsewhere = own
891
+ .filter((claim) => claim.workspaceId !== id)
892
+ .map(releaseClaim);
893
+ const named = own.filter((claim) => claim.workspaceId === id);
894
+ if (!id || !named.length || otherWorkspaceIsOpen(listing, id))
895
+ return { repoKey, releasedClaims: elsewhere };
896
+ const inspection = await inspectOpenedPrimary(id, repoKey, named);
897
+ if (!inspection.matched)
898
+ return { repoKey, releasedClaims: [...elsewhere, ...inspection.release] };
899
+ return {
900
+ note: openedPrimaryWorkspaceNote(id),
901
+ repoKey,
902
+ releasedClaims: [
903
+ ...elsewhere,
904
+ ...inspection.release,
905
+ releaseClaim(inspection.matched),
906
+ ],
907
+ };
908
+ }
909
+
567
910
  export function createHerdrSurfaceSplit(
568
911
  name: string,
569
912
  direction: "right" | "down",
@@ -847,52 +1190,6 @@ function isHerdrShellReady(info: HerdrPaneProcessInfo): boolean {
847
1190
  );
848
1191
  }
849
1192
 
850
- function isExpectedPiProcess(
851
- process: HerdrForegroundProcess,
852
- sessionFile: string,
853
- cwd: string,
854
- ): boolean {
855
- const sessionIndex = process.argv?.indexOf("--session") ?? -1;
856
- return (
857
- (process.name === "pi" || process.argv0?.split("/").pop() === "pi") &&
858
- sessionIndex >= 0 &&
859
- process.argv?.[sessionIndex + 1] === sessionFile &&
860
- process.cwd === cwd
861
- );
862
- }
863
-
864
- export async function waitForHerdrPiReady(
865
- surface: string,
866
- sessionFile: string,
867
- cwd: string,
868
- options: { timeoutMs?: number; intervalMs?: number } = {},
869
- ): Promise<void> {
870
- const timeoutMs = options.timeoutMs ?? 10_000;
871
- const intervalMs = options.intervalMs ?? 50;
872
- const deadline = Date.now() + timeoutMs;
873
- let lastError = "expected Pi process not observed";
874
-
875
- while (Date.now() <= deadline) {
876
- try {
877
- const info = await getHerdrPaneProcessInfoAsync(surface);
878
- if (
879
- info.foregroundProcesses.some((process) =>
880
- isExpectedPiProcess(process, sessionFile, cwd),
881
- )
882
- ) {
883
- return;
884
- }
885
- } catch (error) {
886
- lastError = error instanceof Error ? error.message : String(error);
887
- }
888
- if (Date.now() >= deadline) break;
889
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
890
- }
891
- throw new Error(
892
- `Timed out waiting for Pi session ${sessionFile} in Herdr pane ${surface}: ${lastError}`,
893
- );
894
- }
895
-
896
1193
  export async function waitForHerdrShellReady(
897
1194
  surface: string,
898
1195
  options: {
@@ -1051,6 +1348,5 @@ export const __herdrTest__ = {
1051
1348
  parseHerdrPaneSnapshot,
1052
1349
  parsePaneProcessInfo,
1053
1350
  isHerdrShellReady,
1054
- isExpectedPiProcess,
1055
1351
  withMockHerdrExec,
1056
1352
  };
@@ -10,7 +10,6 @@ import {
10
10
  createHerdrWorktree,
11
11
  focusHerdrWorkspace,
12
12
  getHerdrPaneProcessInfo,
13
- waitForHerdrPiReady,
14
13
  waitForHerdrShellReady,
15
14
  isHerdrAvailable,
16
15
  isProcessAlive,
@@ -191,15 +190,6 @@ export async function waitForShellReady(
191
190
  return waitForHerdrShellReady(paneId, options);
192
191
  }
193
192
 
194
- export async function waitForPiReady(
195
- paneId: PaneId,
196
- sessionFile: string,
197
- cwd: string,
198
- ): Promise<void> {
199
- assertTerminalAvailable();
200
- return waitForHerdrPiReady(paneId, sessionFile, cwd);
201
- }
202
-
203
193
  export async function waitForPaneAbsence(
204
194
  paneId: PaneId,
205
195
  options?: { timeoutMs?: number; intervalMs?: number },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-herdr-agents",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "description": "Asynchronous Pi subagents in Herdr, with optional isolated Git worktrees",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -684,6 +684,16 @@ function formatWorktreeHandoff(worktree: WorktreeHandoff): string {
684
684
  return lines.join("\n");
685
685
  }
686
686
 
687
+ function launchDiagnosticsText(
688
+ diagnostics: readonly string[] | undefined,
689
+ prefix: string,
690
+ suffix: string,
691
+ ): string {
692
+ return diagnostics?.length
693
+ ? `${prefix}Launch diagnostics: ${diagnostics.join("; ")}.${suffix}`
694
+ : "";
695
+ }
696
+
687
697
  function resolveResultPresentation(
688
698
  result: Pick<
689
699
  SubagentResult,
@@ -2833,6 +2843,7 @@ export default function subagentsExtension(
2833
2843
  (running.worktree
2834
2844
  ? ` in worktree ${running.worktree.path} on branch ${running.worktree.branch}. `
2835
2845
  : ". ") +
2846
+ launchDiagnosticsText(running.worktree?.diagnostics, "", " ") +
2836
2847
  `Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
2837
2848
  `The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` +
2838
2849
  `Until then, move on to other work or tell the user you're waiting.`,
@@ -3462,10 +3473,13 @@ export default function subagentsExtension(
3462
3473
  throw new Error("Worktree handoff did not return worktree metadata");
3463
3474
  }
3464
3475
  ctx.ui.notify(
3465
- result.focusError
3476
+ (result.focusError
3466
3477
  ? `Worktree launched, but workspace focus failed: ${result.focusError}\nWorktree: ${worktree.path}`
3467
- : `Worktree launched in ${worktree.path} (workspace ${worktree.workspaceId}).`,
3468
- result.focusError ? "warning" : "info",
3478
+ : `Worktree launched in ${worktree.path} (workspace ${worktree.workspaceId}).`) +
3479
+ launchDiagnosticsText(worktree.diagnostics, "\n", ""),
3480
+ result.focusError || worktree.diagnostics?.length
3481
+ ? "warning"
3482
+ : "info",
3469
3483
  );
3470
3484
  } catch (error) {
3471
3485
  ctx.ui.notify(
@@ -210,7 +210,12 @@ independent writer.
210
210
  blockers. Startup does not scan it.
211
211
  - `worktree_remove({ target, preserve? })` / `/worktree remove <target>
212
212
  [--preserve]` is explicit, parent-only, and needs separate authorization;
213
- stopping or interrupting a child never authorizes cleanup. Dirty work is
213
+ stopping or interrupting a child never authorizes cleanup. Removal never
214
+ closes the source repository's primary workspace. If this process's create
215
+ appears to have opened that workspace and it still looks untouched, the
216
+ result suggests `herdr workspace close <id>` if you haven't used it. Another
217
+ session or a restarted process says nothing about it. Primary-workspace
218
+ snapshot failures at launch appear as launch diagnostics. Dirty work is
214
219
  blocked unless committed first or `preserve: true` makes a WIP commit (SHA
215
220
  reported). Eligibility is rechecked at removal and fails closed (unknown
216
221
  state, live holder, untracked files, conflicts, detached HEAD, locks,