pi-herdr-agents 3.0.1 → 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.
@@ -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
+ }
@@ -8,6 +8,10 @@ import {
8
8
  export const RECONCILE_INTERVAL_MS = 4_800;
9
9
  export const POLLING_INTERVAL_MS = 1_000;
10
10
  const BATCH_UNHEALTHY_MS = 5_000;
11
+ const ABORT_MESSAGE = "Aborted while waiting for subagent to finish";
12
+ const UNREGISTERED_MESSAGE = "Supervision registration was unregistered";
13
+ const CONCURRENT_WAIT_MESSAGE =
14
+ "Supervision registration already has a parked wait";
11
15
 
12
16
  export interface PaneListEntry {
13
17
  paneId: string;
@@ -30,12 +34,18 @@ interface Entry {
30
34
  generation: number;
31
35
  wakeRegistration?: WakeRegistration;
32
36
  pending?: WakeReason;
33
- resolve?: (reason: WakeReason) => void;
37
+ parked?: ParkedWait;
38
+ unregistered?: boolean;
34
39
  inspection?: PaneInspection;
35
40
  fallback: boolean;
36
41
  fileFallback: boolean;
37
42
  }
38
43
 
44
+ interface ParkedWait {
45
+ resolve(reason: WakeReason): void;
46
+ reject(error: Error): void;
47
+ }
48
+
39
49
  export interface SupervisionDiagnostics {
40
50
  mode: "wake+batch" | "polling(forced)" | "polling(fallback)";
41
51
  watcherCount: number;
@@ -138,12 +148,15 @@ export class SupervisionCoordinator {
138
148
  }
139
149
 
140
150
  private unregister(entry: Entry): void {
151
+ entry.unregistered = true;
141
152
  entry.wakeRegistration?.unregister();
142
153
  this.entries.delete(entry);
143
154
  if (this.entries.size === 0 && this.timer) {
144
155
  clearInterval(this.timer);
145
156
  this.timer = undefined;
146
157
  }
158
+ // Nothing can wake a parked wait now; settle it so its retain is released.
159
+ entry.parked?.reject(new Error(UNREGISTERED_MESSAGE));
147
160
  }
148
161
 
149
162
  private enterFallback(entry: Entry): void {
@@ -155,10 +168,12 @@ export class SupervisionCoordinator {
155
168
  }
156
169
 
157
170
  private wait(entry: Entry, signal: AbortSignal): Promise<WakeReason> {
158
- if (signal.aborted)
159
- return Promise.reject(
160
- new Error("Aborted while waiting for subagent to finish"),
161
- );
171
+ if (signal.aborted) return Promise.reject(new Error(ABORT_MESSAGE));
172
+ if (entry.unregistered)
173
+ return Promise.reject(new Error(UNREGISTERED_MESSAGE));
174
+ // One completion loop consumes a registration: `pending` queues a single
175
+ // reason for a single waiter, so a second concurrent wait is a caller bug.
176
+ if (entry.parked) return Promise.reject(new Error(CONCURRENT_WAIT_MESSAGE));
162
177
  if (entry.pending) {
163
178
  const reason = entry.pending;
164
179
  entry.pending = undefined;
@@ -166,14 +181,24 @@ export class SupervisionCoordinator {
166
181
  }
167
182
  if (entry.fallback) return this.poll(signal);
168
183
  return new Promise((resolve, reject) => {
184
+ // Polling keeps its own timer referenced. A wake wait has only this
185
+ // promise, so retain the unref'd directory watch until it settles.
186
+ const release = this.wakeRegistry.retain();
187
+ let settled = false;
188
+ const finish = (settle: () => void) => {
189
+ if (settled) return;
190
+ settled = true;
191
+ signal.removeEventListener("abort", onAbort);
192
+ entry.parked = undefined;
193
+ release();
194
+ settle();
195
+ };
169
196
  const onAbort = () => {
170
- entry.resolve = undefined;
171
- reject(new Error("Aborted while waiting for subagent to finish"));
197
+ finish(() => reject(new Error(ABORT_MESSAGE)));
172
198
  };
173
- entry.resolve = (reason) => {
174
- signal.removeEventListener("abort", onAbort);
175
- entry.resolve = undefined;
176
- resolve(reason);
199
+ entry.parked = {
200
+ resolve: (reason) => finish(() => resolve(reason)),
201
+ reject: (error) => finish(() => reject(error)),
177
202
  };
178
203
  signal.addEventListener("abort", onAbort, { once: true });
179
204
  });
@@ -187,7 +212,7 @@ export class SupervisionCoordinator {
187
212
  }, POLLING_INTERVAL_MS);
188
213
  const onAbort = () => {
189
214
  clearTimeout(timer);
190
- reject(new Error("Aborted while waiting for subagent to finish"));
215
+ reject(new Error(ABORT_MESSAGE));
191
216
  };
192
217
  signal.addEventListener("abort", onAbort, { once: true });
193
218
  });
@@ -196,7 +221,7 @@ export class SupervisionCoordinator {
196
221
  private signal(entry: Entry, reason: WakeReason): void {
197
222
  if (this.closed) return;
198
223
  if (reason === "wake") entry.inspection = undefined;
199
- if (entry.resolve) entry.resolve(reason);
224
+ if (entry.parked) entry.parked.resolve(reason);
200
225
  // A wake must never downgrade a queued reconciliation: the reconcile
201
226
  // reason carries the epoch's pane inspection, and skipping it would
202
227
  // defer pane-disappearance detection past the documented 5s bound.
@@ -6,6 +6,10 @@
6
6
  * Methods may be sync or async; providers choose. The CLI is the only place
7
7
  * that wires a provider to an adapter (via core's SurfaceHandle).
8
8
  */
9
+ import type {
10
+ OpenedPrimaryWorkspaceReport,
11
+ PrimaryWorkspaceClaim,
12
+ } from "./opened-primary-workspace.ts";
9
13
  import type { PaneInspection, SurfaceHandle } from "./types.ts";
10
14
 
11
15
  // pi-herdr-agents extension
@@ -48,6 +52,13 @@ export interface WorktreeSurface {
48
52
  /** Provider-native workspace/container id for later removal. */
49
53
  workspaceId: string;
50
54
  surfaceId: string;
55
+ /** Non-fatal provisioning problems the parent should see, such as a failed snapshot. */
56
+ diagnostics?: string[];
57
+ }
58
+
59
+ export interface ReportOpenedPrimaryWorkspaceInput {
60
+ sourceRepo: string;
61
+ claims: readonly PrimaryWorkspaceClaim[];
51
62
  }
52
63
 
53
64
  export type RecoveredWorktreeProvisioning = Pick<
@@ -135,6 +146,17 @@ export interface SurfaceProvider {
135
146
  opts?: { timeoutMs?: number },
136
147
  ): void | Promise<void>;
137
148
 
149
+ /**
150
+ * Suggest closing a claimed primary workspace that still looks untouched.
151
+ * Never closes it. Unsupported providers return undefined.
152
+ */
153
+ reportOpenedPrimaryWorkspace(
154
+ input: ReportOpenedPrimaryWorkspaceInput,
155
+ ):
156
+ | OpenedPrimaryWorkspaceReport
157
+ | undefined
158
+ | Promise<OpenedPrimaryWorkspaceReport | undefined>;
159
+
138
160
  /** Human-readable hint when isAvailable() is false; today's terminalSetupHint(). */
139
161
  // pi-herdr-agents extension
140
162
  setupHint(): string;
@@ -27,6 +27,7 @@ export class FileWakeRegistry {
27
27
  private readonly directories = new Map<string, DirectoryWatcher>();
28
28
  private readonly watchDirectory: typeof watch;
29
29
  private closed = false;
30
+ private retained = 0;
30
31
 
31
32
  constructor(watchDirectory: typeof watch = watch) {
32
33
  this.watchDirectory = watchDirectory;
@@ -57,7 +58,10 @@ export class FileWakeRegistry {
57
58
  }
58
59
  });
59
60
  current = { watcher, entries: new Set() };
61
+ // Idle watches must not keep the process running. A parked waiter
62
+ // retains them so this callback can still run.
60
63
  watcher.unref();
64
+ if (this.retained > 0) watcher.ref();
61
65
  watcher.on("error", () => this.failDirectory(directory));
62
66
  this.directories.set(directory, current);
63
67
  } catch {
@@ -85,12 +89,45 @@ export class FileWakeRegistry {
85
89
  return this.directories.size;
86
90
  }
87
91
 
92
+ /** Outstanding retains; every directory watch is referenced while nonzero. */
93
+ get retainedWaits(): number {
94
+ return this.retained;
95
+ }
96
+
97
+ /**
98
+ * Pin every active directory watch until the returned function runs.
99
+ * A bare wake promise does not keep the event loop alive, and idle watches
100
+ * are unref'd, so without this the callback never runs when that promise is
101
+ * the only pending work.
102
+ */
103
+ retain(): () => void {
104
+ if (this.closed) return () => {};
105
+ this.retained++;
106
+ if (this.retained === 1) this.applyWatcherRef(true);
107
+ let released = false;
108
+ return () => {
109
+ if (released) return;
110
+ released = true;
111
+ if (this.closed || this.retained === 0) return;
112
+ this.retained--;
113
+ if (this.retained === 0) this.applyWatcherRef(false);
114
+ };
115
+ }
116
+
88
117
  close(): void {
89
118
  this.closed = true;
119
+ this.retained = 0;
90
120
  for (const { watcher } of this.directories.values()) watcher.close();
91
121
  this.directories.clear();
92
122
  }
93
123
 
124
+ private applyWatcherRef(active: boolean): void {
125
+ for (const { watcher } of this.directories.values()) {
126
+ if (active) watcher.ref();
127
+ else watcher.unref();
128
+ }
129
+ }
130
+
94
131
  private failDirectory(directory: string): void {
95
132
  if (this.closed) return;
96
133
  const current = this.directories.get(directory);
@@ -1,5 +1,11 @@
1
1
  import { isAbsolute, relative, sep } from "node:path";
2
2
  import { isString, type JsonObject } from "./config/type-guards.ts";
3
+ import {
4
+ forgetOpenedPrimaryWorkspaceClaims,
5
+ openedPrimaryWorkspaceClaims,
6
+ type OpenedPrimaryWorkspaceReport,
7
+ type PrimaryWorkspaceClaim,
8
+ } from "./opened-primary-workspace.ts";
3
9
  import type { WorktreeSurfaceInfo } from "./surface-provider.ts";
4
10
 
5
11
  export interface CleanupGitState {
@@ -53,6 +59,17 @@ export interface WorktreeCleanupOperations {
53
59
  exists(path: string): boolean;
54
60
  preserve(entry: WorktreeInventoryEntry): string;
55
61
  removeWorkspace(id: string): void | Promise<void>;
62
+ /**
63
+ * Suggest, never close, a primary workspace this process's worktree
64
+ * creation appears to have opened. Undefined means the surface cannot report.
65
+ */
66
+ reportOpenedPrimaryWorkspace(
67
+ sourceRepo: string,
68
+ claims: readonly PrimaryWorkspaceClaim[],
69
+ ):
70
+ | OpenedPrimaryWorkspaceReport
71
+ | undefined
72
+ | Promise<OpenedPrimaryWorkspaceReport | undefined>;
56
73
  removeCheckout(sourceRepo: string, path: string): void | Promise<void>;
57
74
  prune(sourceRepo: string): void;
58
75
  writeManifest(file: string, value: JsonObject): void;
@@ -80,6 +97,21 @@ function contained(root: string, path: string): boolean {
80
97
  );
81
98
  }
82
99
 
100
+ function claimsForSource(
101
+ claims: readonly PrimaryWorkspaceClaim[],
102
+ sourceRepo: string,
103
+ ops: WorktreeCleanupOperations,
104
+ ): PrimaryWorkspaceClaim[] {
105
+ return claims.filter((claim) => {
106
+ if (claim.checkoutPath === sourceRepo) return true;
107
+ try {
108
+ return ops.realpath(claim.checkoutPath) === sourceRepo;
109
+ } catch {
110
+ return false;
111
+ }
112
+ });
113
+ }
114
+
83
115
  export function cleanupBlockers(entry: WorktreeInventoryEntry): string[] {
84
116
  const git = entry.git;
85
117
  const blockers: string[] = [];
@@ -345,6 +377,25 @@ export async function removeContainedWorktree(
345
377
  checkoutRemoved = true;
346
378
  if (!entry.workspaceId) ops.prune(entry.sourceRepo);
347
379
  const warnings: string[] = [];
380
+ let primaryNote = "";
381
+ const claims = claimsForSource(
382
+ openedPrimaryWorkspaceClaims(),
383
+ entry.sourceRepo,
384
+ ops,
385
+ );
386
+ if (claims.length) {
387
+ try {
388
+ const report = await ops.reportOpenedPrimaryWorkspace(
389
+ entry.sourceRepo,
390
+ claims,
391
+ );
392
+ if (report?.note) primaryNote = ` ${report.note}`;
393
+ if (report?.releasedClaims.length)
394
+ forgetOpenedPrimaryWorkspaceClaims(report.releasedClaims);
395
+ } catch (error) {
396
+ warnings.push(`Primary workspace report failed: ${message(error)}`);
397
+ }
398
+ }
348
399
  for (const manifest of entry.manifest) {
349
400
  try {
350
401
  ops.writeManifest(manifest.file, {
@@ -361,7 +412,7 @@ export async function removeContainedWorktree(
361
412
  status: "removed",
362
413
  entry,
363
414
  preservationSha,
364
- message: `Removed ${entry.path}. Branch ${entry.branch} and its commits retained.${preservationSha ? ` Preservation commit: ${preservationSha}.` : ""}${ignoredNotice()}${warnings.length ? ` Warning: ${warnings.join("; ")}` : entry.manifest.length ? " Manifest marked removed." : " No reachable manifest (orphan)."}`,
415
+ message: `Removed ${entry.path}. Branch ${entry.branch} and its commits retained.${preservationSha ? ` Preservation commit: ${preservationSha}.` : ""}${ignoredNotice()}${primaryNote}${warnings.length ? ` Warning: ${warnings.join("; ")}` : entry.manifest.length ? " Manifest marked removed." : " No reachable manifest (orphan)."}`,
365
416
  });
366
417
  } catch (error) {
367
418
  return finish({
@@ -11,6 +11,8 @@ export interface WorktreeLaunch {
11
11
  sessionFile?: string;
12
12
  sourceSessionFile?: string;
13
13
  handoffMessage?: string;
14
+ /** Non-fatal provisioning problems reported with the launch. */
15
+ diagnostics?: string[];
14
16
  }
15
17
 
16
18
  export interface FailedWorktreeManifest extends JsonObject {
@@ -11,7 +11,7 @@ import {
11
11
  unlinkSync,
12
12
  } from "node:fs";
13
13
  import { homedir } from "node:os";
14
- import { dirname, isAbsolute, join, relative, sep } from "node:path";
14
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
15
  import { isRecord, type JsonObject } from "../core/config/type-guards.ts";
16
16
  import type { SurfaceProvider } from "../core/surface-provider.ts";
17
17
  import type {
@@ -245,7 +245,19 @@ function resolveSource(path: string): string {
245
245
  "--git-common-dir",
246
246
  ]).trim(),
247
247
  );
248
- const root = realpathSync(dirname(common));
248
+ // A submodule's common directory lives under the superproject's
249
+ // .git/modules; only core.worktree, including included config, names its checkout.
250
+ const configured = git(common, [
251
+ `--git-dir=${common}`,
252
+ "config",
253
+ "--default",
254
+ "",
255
+ "--get",
256
+ "core.worktree",
257
+ ]).trim();
258
+ const root = realpathSync(
259
+ configured ? resolve(common, configured) : dirname(common),
260
+ );
249
261
  if (realpathSync(git(root, ["rev-parse", "--show-toplevel"]).trim()) !== root)
250
262
  throw new Error("Cannot prove source repository root");
251
263
  if (
@@ -600,6 +612,8 @@ export function createWorktreeCleanupOperations(
600
612
  },
601
613
  removeWorkspace: (id) =>
602
614
  provider.removeWorktreeSurface(id, { timeoutMs: CLEANUP_TIMEOUT_MS }),
615
+ reportOpenedPrimaryWorkspace: (sourceRepo, claims) =>
616
+ provider.reportOpenedPrimaryWorkspace({ sourceRepo, claims }),
603
617
  removeCheckout: (source, path) => {
604
618
  git(source, ["worktree", "remove", "--", path]);
605
619
  },
@@ -1,7 +1,9 @@
1
1
  import path from "node:path";
2
+ import type { OpenedPrimaryWorkspaceReport } from "../../core/opened-primary-workspace.ts";
2
3
  import type {
3
4
  CreateSurfaceOptions,
4
5
  CreateWorktreeSurfaceOptions,
6
+ ReportOpenedPrimaryWorkspaceInput,
5
7
  SurfaceInfo,
6
8
  SurfaceProcessInfo,
7
9
  SurfaceProvider,
@@ -147,6 +149,12 @@ export class FakeSurfaceProvider implements SurfaceProvider {
147
149
  this.removeSurface(worktree.surfaceId);
148
150
  }
149
151
 
152
+ reportOpenedPrimaryWorkspace(
153
+ _input: ReportOpenedPrimaryWorkspaceInput,
154
+ ): OpenedPrimaryWorkspaceReport | undefined {
155
+ return undefined;
156
+ }
157
+
150
158
  setupHint(): string {
151
159
  return "Fake surface provider is always available in memory.";
152
160
  }
@@ -1,12 +1,14 @@
1
1
  import type {
2
2
  CreateSurfaceOptions,
3
3
  CreateWorktreeSurfaceOptions,
4
+ ReportOpenedPrimaryWorkspaceInput,
4
5
  SurfaceInfo,
5
6
  SurfaceProcessInfo,
6
7
  SurfaceProvider,
7
8
  WorktreeSurface,
8
9
  WorktreeSurfaceInfo,
9
10
  } from "../../core/surface-provider.ts";
11
+ import type { OpenedPrimaryWorkspaceReport } from "../../core/opened-primary-workspace.ts";
10
12
  import type { PaneInspection, SurfaceHandle } from "../../core/types.ts";
11
13
  import type { PaneConfig } from "../../core/config/pane-config.ts";
12
14
  import {
@@ -16,6 +18,7 @@ import {
16
18
  createHerdrSurfaceSplit,
17
19
  createHerdrWorktree,
18
20
  focusHerdrWorkspace,
21
+ reportOpenedPrimaryWorkspace as reportHerdrOpenedPrimaryWorkspace,
19
22
  getHerdrPaneProcessInfo,
20
23
  getHerdrPaneProcessInfoAsync,
21
24
  inspectHerdrPane,
@@ -111,12 +114,20 @@ export class HerdrSurfaceProvider implements SurfaceProvider {
111
114
  opts.branch,
112
115
  opts.base,
113
116
  );
114
- return {
117
+ const surface: WorktreeSurface = {
115
118
  path: worktree.path,
116
119
  branch: worktree.branch,
117
120
  workspaceId: worktree.workspaceId,
118
121
  surfaceId: worktree.paneId,
119
122
  };
123
+ if (worktree.diagnostics) surface.diagnostics = worktree.diagnostics;
124
+ return surface;
125
+ }
126
+
127
+ reportOpenedPrimaryWorkspace(
128
+ input: ReportOpenedPrimaryWorkspaceInput,
129
+ ): Promise<OpenedPrimaryWorkspaceReport | undefined> {
130
+ return reportHerdrOpenedPrimaryWorkspace(input.sourceRepo, input.claims);
120
131
  }
121
132
 
122
133
  removeWorktreeSurface(