pi-herdr-agents 2.0.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +20 -14
  2. package/CHANGELOG.md +63 -1
  3. package/CONTEXT.md +86 -32
  4. package/README.md +262 -141
  5. package/RELEASING.md +4 -2
  6. package/config.json.example +0 -3
  7. package/docs/README.md +38 -15
  8. package/docs/adr/0001-btw-ephemeral-side-questions.md +1 -1
  9. package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +1 -1
  10. package/docs/adr/0003-installable-role-packs.md +21 -13
  11. package/docs/adr/0009-remove-workflow-subsystem.md +1 -1
  12. package/docs/adr/0011-explicit-worktree-cleanup.md +1 -1
  13. package/docs/adr/0012-adopt-maestro-seams-in-repo.md +45 -0
  14. package/docs/adr/0013-pack-neutral-execution-host.md +63 -0
  15. package/docs/adr/0014-operator-cancel-terminal-intent.md +134 -0
  16. package/docs/worktree-subagents.md +10 -6
  17. package/maestro/adapters/fake/fake-harness-adapter.ts +263 -0
  18. package/{pi-extension/subagents/activity.ts → maestro/adapters/pi/activity-file.ts} +15 -72
  19. package/{pi-extension/subagents → maestro/adapters/pi/child}/subagent-done.ts +30 -3
  20. package/{pi-extension/subagents → maestro/adapters/pi}/completion.ts +4 -4
  21. package/{pi-extension/subagents → maestro/adapters/pi}/launch.ts +388 -285
  22. package/maestro/adapters/pi/model-registry.ts +46 -0
  23. package/maestro/adapters/pi/pi-harness-adapter.ts +701 -0
  24. package/maestro/adapters/pi/process-identity.ts +303 -0
  25. package/{pi-extension/subagents → maestro/adapters/pi}/session.ts +1 -13
  26. package/maestro/adapters/pi/task-model-init.ts +55 -0
  27. package/maestro/core/activity.ts +29 -0
  28. package/maestro/core/config/model-config.ts +473 -0
  29. package/{pi-extension/subagents → maestro/core/config}/pane-config.ts +4 -6
  30. package/{pi-extension/subagents → maestro/core/config}/persistent-config.ts +4 -6
  31. package/{pi-extension/subagents → maestro/core/config}/role-config.ts +15 -10
  32. package/{pi-extension/subagents → maestro/core/config}/supervision-config.ts +4 -6
  33. package/{pi-extension/subagents → maestro/core/config}/task-model-init.ts +45 -58
  34. package/maestro/core/config/task-model-types.ts +22 -0
  35. package/maestro/core/harness-adapter.ts +117 -0
  36. package/maestro/core/index.ts +3 -0
  37. package/{pi-extension/subagents → maestro/core}/lifecycle.ts +27 -126
  38. package/maestro/core/roles/discovery.ts +555 -0
  39. package/{pi-extension/subagents/runtime-routing.ts → maestro/core/routing.ts} +36 -43
  40. package/maestro/core/shell.ts +3 -0
  41. package/{pi-extension/subagents → maestro/core}/status.ts +3 -7
  42. package/{pi-extension/subagents → maestro/core}/supervision.ts +1 -1
  43. package/maestro/core/surface-provider.ts +185 -0
  44. package/maestro/core/types.ts +449 -0
  45. package/maestro/core/worktree-cleanup.ts +374 -0
  46. package/maestro/core/worktree.ts +93 -0
  47. package/maestro/runtime/index.ts +44 -0
  48. package/maestro/runtime/pi-run-session.ts +1210 -0
  49. package/maestro/runtime/run-session.ts +801 -0
  50. package/maestro/runtime/surface-retention.ts +6 -0
  51. package/maestro/runtime/task-model-init.ts +22 -0
  52. package/maestro/runtime/worktree-operations.ts +627 -0
  53. package/maestro/surfaces/fake/fake-surface-provider.ts +311 -0
  54. package/maestro/surfaces/herdr/herdr-surface-provider.ts +200 -0
  55. package/{pi-extension/subagents → maestro/surfaces/herdr}/herdr.ts +65 -14
  56. package/{pi-extension/subagents → maestro/surfaces/herdr}/terminal.ts +7 -5
  57. package/package.json +10 -9
  58. package/pi-extension/subagents/config-path.ts +5 -1
  59. package/pi-extension/subagents/index.ts +848 -1805
  60. package/pi-extension/subagents/model-registry.ts +46 -0
  61. package/skills/pi-herdr-agents/SKILL.md +276 -0
  62. package/agents/adversarial-reviewer.md +0 -153
  63. package/agents/planner.md +0 -87
  64. package/agents/poteto.md +0 -32
  65. package/agents/reviewer.md +0 -132
  66. package/agents/scout.md +0 -105
  67. package/agents/visual-tester.md +0 -234
  68. package/agents/worker.md +0 -121
  69. package/docs/review-evaluation.md +0 -183
  70. package/pi-extension/subagents/model-config.ts +0 -305
  71. package/pi-extension/subagents/plan-skill.md +0 -280
  72. package/pi-extension/subagents/worktree-cleanup.ts +0 -789
  73. package/skills/orchestrate/SKILL.md +0 -91
  74. package/skills/orchestrate/adversarial-review-example.js +0 -400
  75. package/skills/orchestrate/adversarial-review.md +0 -87
  76. /package/{pi-extension/subagents → maestro/core/config}/type-guards.ts +0 -0
  77. /package/{pi-extension/subagents → maestro/core}/wake.ts +0 -0
@@ -1,20 +1,34 @@
1
- import { execFileSync } from "node:child_process";
2
- import {
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- renameSync,
7
- writeFileSync,
8
- } from "node:fs";
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
2
  import { dirname, join } from "node:path";
10
3
  import { homedir } from "node:os";
11
4
  import { fileURLToPath } from "node:url";
12
- import { getSubagentActivityFile } from "./activity.ts";
13
- import { createLifecycle, type SubagentLifecycle } from "./lifecycle.ts";
14
- import type { ResolvedRuntimePlan } from "./runtime-routing.ts";
15
- import { createSubagentPaneFactory, loadPaneConfig } from "./pane-config.ts";
16
- import { HerdrWorktreeCreateError } from "./herdr.ts";
17
- import { isNonEmptyString, isRecord, type JsonObject } from "./type-guards.ts";
5
+ import { getSubagentActivityFile } from "./activity-file.ts";
6
+ import {
7
+ createLifecycle,
8
+ type SubagentLifecycle,
9
+ } from "../../core/lifecycle.ts";
10
+ import type { ResolvedRuntimePlan } from "../../core/routing.ts";
11
+ import type { PaneConfig } from "../../core/config/pane-config.ts";
12
+ import { isNonEmptyString } from "../../core/config/type-guards.ts";
13
+ import { shellQuote } from "../../core/shell.ts";
14
+ import type {
15
+ SurfaceProvider,
16
+ WorktreeSurface,
17
+ } from "../../core/surface-provider.ts";
18
+ import { WorktreeProvisioningError } from "../../core/surface-provider.ts";
19
+ import type {
20
+ FailedWorktreeManifest,
21
+ WorktreeLaunch,
22
+ WorktreeOperations,
23
+ } from "../../core/worktree.ts";
24
+ import {
25
+ getSubagentProcessIdentityFile,
26
+ linuxProcessProbe,
27
+ readProcessIdentityRecord,
28
+ verifyProcessIdentityRecord,
29
+ type PiProcessIdentity,
30
+ type ProcessIdentityProbe,
31
+ } from "./process-identity.ts";
18
32
  import {
19
33
  createWorktreeSessionFork,
20
34
  getNewEntries,
@@ -22,61 +36,11 @@ import {
22
36
  seedSubagentSessionFile,
23
37
  writeSubagentSessionPolicy,
24
38
  } from "./session.ts";
25
- import {
26
- closePane,
27
- createSubagentPane,
28
- createGroupedSubagentPane,
29
- createSubagentWorktree,
30
- splitCurrentPane,
31
- runScriptInPane,
32
- shellQuote,
33
- waitForPiReady,
34
- waitForShellReady,
35
- focusWorkspace,
36
- type HerdrWorktreeSurface,
37
- } from "./terminal.ts";
38
39
 
39
40
  const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
40
41
 
41
42
  type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
42
43
 
43
- export interface WorktreeLaunch {
44
- path: string;
45
- workspaceId: string;
46
- paneId: string;
47
- branch: string;
48
- baseRef: string;
49
- baseSha: string;
50
- manifestFile: string;
51
- sessionFile?: string;
52
- sourceSessionFile?: string;
53
- handoffMessage?: string;
54
- }
55
-
56
- interface FailedWorktreeManifest extends JsonObject {
57
- state: "failed";
58
- id: string;
59
- name: string;
60
- sourceCwd: string;
61
- branch: string;
62
- baseRef: string;
63
- baseSha: string;
64
- createdAt: number;
65
- path?: string;
66
- workspaceId?: string;
67
- error?: string;
68
- }
69
-
70
- export interface WorktreeHandoff extends WorktreeLaunch {
71
- headSha: string | null;
72
- commitsAhead: number | null;
73
- clean: boolean | null;
74
- conflicted: boolean | null;
75
- changedFiles: string[] | null;
76
- untrackedFiles: string[] | null;
77
- gitError?: string;
78
- }
79
-
80
44
  export interface FreshPiLaunchRequest {
81
45
  kind: "fresh";
82
46
  id?: string;
@@ -84,7 +48,7 @@ export interface FreshPiLaunchRequest {
84
48
  task: string;
85
49
  agent?: string;
86
50
  cwd?: string;
87
- worktree?: { branch: string; base?: string };
51
+ worktree?: { branch: string; base?: string } | null;
88
52
  fork?: boolean;
89
53
  handoff?: { leafId: string };
90
54
  surface?: string;
@@ -159,47 +123,265 @@ export interface PiRunningChild {
159
123
  stopState?: "requested" | "pending" | "failed";
160
124
  stopFailure?: string;
161
125
  crashNotified?: boolean;
126
+ /** Managed worktree children: where the child records its own process identity. */
127
+ processIdentityFile?: string;
128
+ /** Verified at launch readiness; a worktree cancel's only signal and exit evidence. */
129
+ processIdentity?: PiProcessIdentity;
130
+ /** Settles when launch-readiness capture verifies or gives up; never rejects. */
131
+ processIdentityCapture?: Promise<PiProcessIdentity | undefined>;
132
+ processIdentityError?: string;
133
+ }
134
+
135
+ /** What a captured identity must match: this run, its session, and its pane. */
136
+ export interface ProcessIdentityExpectation {
137
+ file: string;
138
+ id: string;
139
+ sessionFile: string;
140
+ }
141
+
142
+ type MaybePromise<T> = T | Promise<T>;
143
+
144
+ interface WorktreeSurfaceForLaunch {
145
+ path: string;
146
+ branch: string;
147
+ workspaceId: string;
148
+ paneId: string;
162
149
  }
163
150
 
164
151
  export interface PiLaunchOperations {
165
- createPane(name: string, cwd?: string): string;
152
+ /** Required for managed worktree launches; ordinary panes need no Git effects. */
153
+ worktree?: WorktreeOperations;
154
+ createPane(name: string, cwd?: string): MaybePromise<string>;
166
155
  createWorktree(
167
156
  name: string,
168
157
  cwd: string,
169
158
  branch: string,
170
159
  base: string,
171
- ): HerdrWorktreeSurface;
160
+ ): MaybePromise<WorktreeSurfaceForLaunch>;
172
161
  waitForShellReady(surface: string): Promise<void>;
173
162
  runScript(
174
163
  surface: string,
175
164
  command: string,
176
165
  options: { scriptPath: string; scriptPreamble: string },
177
- ): string;
178
- closePane(pane: string): void;
166
+ ): MaybePromise<string>;
167
+ closePane(pane: string): MaybePromise<void>;
179
168
  waitForPiReady?(
180
169
  surface: string,
181
170
  sessionFile: string,
182
171
  cwd: string,
183
172
  ): Promise<void>;
184
- focusWorkspace?(workspaceId: string): void;
173
+ focusWorkspace?(workspaceId: string): MaybePromise<void>;
174
+ /** Bounded readiness capture of a worktree child's verified process identity. */
175
+ captureProcessIdentity?(
176
+ surface: string,
177
+ expected: ProcessIdentityExpectation,
178
+ options?: { timeoutMs?: number },
179
+ ): Promise<PiProcessIdentity>;
180
+ }
181
+
182
+ function placementFromPaneConfig(config: PaneConfig) {
183
+ if (config.mode === "split") {
184
+ return { kind: "split" as const, direction: config.direction };
185
+ }
186
+ if (config.mode === "tab") return { kind: "tab" as const };
187
+ return { kind: "grouped" as const };
188
+ }
189
+
190
+ function worktreeSurfaceForLaunch(
191
+ worktree: WorktreeSurface,
192
+ ): WorktreeSurfaceForLaunch {
193
+ return {
194
+ path: worktree.path,
195
+ branch: worktree.branch,
196
+ workspaceId: worktree.workspaceId,
197
+ paneId: worktree.surfaceId,
198
+ };
199
+ }
200
+
201
+ export function isExpectedPiProcess(
202
+ process: {
203
+ name?: string;
204
+ argv0?: string;
205
+ argv?: string[];
206
+ cwd?: string;
207
+ },
208
+ sessionFile: string,
209
+ cwd: string,
210
+ ): boolean {
211
+ const sessionIndex = process.argv?.indexOf("--session") ?? -1;
212
+ return (
213
+ (process.name === "pi" || process.argv0?.split("/").pop() === "pi") &&
214
+ sessionIndex >= 0 &&
215
+ process.argv?.[sessionIndex + 1] === sessionFile &&
216
+ process.cwd === cwd
217
+ );
218
+ }
219
+
220
+ async function waitForSurfacePiReady(
221
+ provider: SurfaceProvider,
222
+ surface: string,
223
+ sessionFile: string,
224
+ cwd: string,
225
+ options: { timeoutMs?: number; intervalMs?: number } = {},
226
+ ): Promise<void> {
227
+ const timeoutMs = options.timeoutMs ?? 10_000;
228
+ const intervalMs = options.intervalMs ?? 50;
229
+ const deadline = Date.now() + timeoutMs;
230
+ let lastError = "expected Pi process not observed";
231
+
232
+ while (Date.now() <= deadline) {
233
+ try {
234
+ const info = await Promise.resolve(provider.getProcessInfo(surface));
235
+ if (
236
+ info.foregroundProcesses.some((process) =>
237
+ isExpectedPiProcess(process, sessionFile, cwd),
238
+ )
239
+ ) {
240
+ return;
241
+ }
242
+ } catch (error) {
243
+ lastError = error instanceof Error ? error.message : String(error);
244
+ }
245
+ if (Date.now() >= deadline) break;
246
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
247
+ }
248
+ throw new Error(
249
+ `Timed out waiting for Pi session ${sessionFile} in Herdr pane ${surface}: ${lastError}`,
250
+ );
251
+ }
252
+
253
+ /**
254
+ * Wait for the child to publish its own identity, then accept it only while
255
+ * that process is alive in this host's namespace as the Herdr pane shell or a
256
+ * descendant of it. Command-line text is never consulted: Pi rewrites it.
257
+ */
258
+ export async function captureSurfacePiProcessIdentity(
259
+ provider: Pick<SurfaceProvider, "getProcessInfo">,
260
+ surface: string,
261
+ expected: ProcessIdentityExpectation,
262
+ options: {
263
+ timeoutMs?: number;
264
+ intervalMs?: number;
265
+ probe?: ProcessIdentityProbe;
266
+ } = {},
267
+ ): Promise<PiProcessIdentity> {
268
+ const timeoutMs = options.timeoutMs ?? 15_000;
269
+ const intervalMs = options.intervalMs ?? 100;
270
+ const deadline = Date.now() + timeoutMs;
271
+ let lastError = "the child has not recorded its process identity";
272
+ let answered = false;
273
+ for (;;) {
274
+ try {
275
+ const record = readProcessIdentityRecord(expected.file);
276
+ if (record) {
277
+ // The deadline bounds Herdr too; a late answer is dropped, never accepted.
278
+ const info = await settleBefore(
279
+ Promise.resolve().then(() => provider.getProcessInfo(surface)),
280
+ deadline,
281
+ { unref: true },
282
+ );
283
+ if (!info) {
284
+ // The deadline passed; an earlier answer's refusal says more.
285
+ if (!answered)
286
+ lastError = `Herdr process info for pane ${surface} timed out`;
287
+ break;
288
+ }
289
+ answered = true;
290
+ if (info.shellPid === undefined)
291
+ throw new Error(`Herdr reports no shell for pane ${surface}`);
292
+ return verifyProcessIdentityRecord(
293
+ record,
294
+ { ...expected, shellPid: info.shellPid },
295
+ options.probe ?? linuxProcessProbe,
296
+ );
297
+ }
298
+ } catch (error) {
299
+ lastError = errorMessage(error);
300
+ }
301
+ const remaining = deadline - Date.now();
302
+ if (remaining <= 0) break;
303
+ // Background capture must never hold the parent process open.
304
+ await new Promise((resolve) =>
305
+ setTimeout(resolve, Math.min(intervalMs, remaining)).unref(),
306
+ );
307
+ }
308
+ throw new Error(
309
+ `Process identity not captured within ${timeoutMs}ms: ${lastError}`,
310
+ );
185
311
  }
186
312
 
187
- const paneConfig = loadPaneConfig();
313
+ /**
314
+ * The pending value if it settles before the deadline; otherwise undefined.
315
+ * A value that arrives after the deadline is dropped, never reported, even
316
+ * when a delayed event loop runs it before the overdue timer.
317
+ */
318
+ export async function settleBefore<T>(
319
+ pending: Promise<T> | undefined,
320
+ deadline: number,
321
+ options: { unref?: boolean } = {},
322
+ ): Promise<T | undefined> {
323
+ if (!pending) return undefined;
324
+ let expire!: () => void;
325
+ const expired = new Promise<undefined>((resolve) => {
326
+ expire = () => resolve(undefined);
327
+ });
328
+ const timer = setTimeout(expire, Math.max(0, deadline - Date.now()));
329
+ if (options.unref) timer.unref();
330
+ try {
331
+ const onTime = pending.then((value) =>
332
+ Date.now() > deadline ? undefined : value,
333
+ );
334
+ return await Promise.race([onTime, expired]);
335
+ } finally {
336
+ clearTimeout(timer);
337
+ }
338
+ }
188
339
 
189
- const defaultOperations: PiLaunchOperations = {
190
- createPane: createSubagentPaneFactory(
191
- paneConfig,
192
- createSubagentPane,
193
- splitCurrentPane,
194
- createGroupedSubagentPane,
195
- ),
196
- createWorktree: createSubagentWorktree,
197
- waitForShellReady,
198
- runScript: runScriptInPane,
199
- closePane,
200
- waitForPiReady,
201
- focusWorkspace,
202
- };
340
+ export function launchOperationsFromSurface(
341
+ provider: SurfaceProvider,
342
+ config: PaneConfig,
343
+ worktree?: WorktreeOperations,
344
+ ): PiLaunchOperations {
345
+ return {
346
+ worktree,
347
+ createPane(name, cwd) {
348
+ return provider.createSurface({
349
+ name,
350
+ cwd: cwd ?? process.cwd(),
351
+ placement: placementFromPaneConfig(config),
352
+ });
353
+ },
354
+ async createWorktree(name, cwd, branch, base) {
355
+ const worktree = await Promise.resolve(
356
+ provider.createWorktreeSurface({ name, cwd, branch, base }),
357
+ );
358
+ return worktreeSurfaceForLaunch(worktree);
359
+ },
360
+ waitForShellReady(surface) {
361
+ return provider.waitForShellReady(surface);
362
+ },
363
+ runScript(surface, command, options) {
364
+ return provider.runScript(surface, command, options);
365
+ },
366
+ closePane(pane) {
367
+ return provider.closeSurface(pane);
368
+ },
369
+ waitForPiReady(surface, sessionFile, cwd) {
370
+ return waitForSurfacePiReady(provider, surface, sessionFile, cwd);
371
+ },
372
+ focusWorkspace(workspaceId) {
373
+ return provider.focusWorkspace(workspaceId);
374
+ },
375
+ captureProcessIdentity(surface, expected, options) {
376
+ return captureSurfacePiProcessIdentity(
377
+ provider,
378
+ surface,
379
+ expected,
380
+ options,
381
+ );
382
+ },
383
+ };
384
+ }
203
385
 
204
386
  interface ResolvedLaunch {
205
387
  request: FreshPiLaunchRequest;
@@ -224,6 +406,7 @@ interface PreparedSurface {
224
406
  interface PreparedSession extends PreparedSurface {
225
407
  sessionFile: string;
226
408
  activityFile: string;
409
+ processIdentityFile?: string;
227
410
  }
228
411
 
229
412
  interface PreparedArtifacts extends PreparedSession {
@@ -237,7 +420,7 @@ interface PreparedArtifacts extends PreparedSession {
237
420
  */
238
421
  export async function launchPiSubagent(
239
422
  request: PiLaunchRequest,
240
- operations: PiLaunchOperations = defaultOperations,
423
+ operations: PiLaunchOperations,
241
424
  ): Promise<PiRunningChild> {
242
425
  return request.kind === "resume"
243
426
  ? launchResumedPiSubagent(request, operations)
@@ -246,7 +429,7 @@ export async function launchPiSubagent(
246
429
 
247
430
  export async function launchPiWorktreeHandoff(
248
431
  request: FreshPiLaunchRequest,
249
- operations: PiLaunchOperations = defaultOperations,
432
+ operations: PiLaunchOperations,
250
433
  ): Promise<{ running: PiRunningChild; focusError?: string }> {
251
434
  if (!request.worktree || !request.handoff) {
252
435
  throw new Error("A worktree handoff requires a worktree and active leaf");
@@ -256,13 +439,16 @@ export async function launchPiWorktreeHandoff(
256
439
  throw new Error("Worktree handoff did not create a managed worktree");
257
440
  }
258
441
  try {
259
- operations.focusWorkspace?.(running.worktree.workspaceId);
442
+ await operations.focusWorkspace?.(running.worktree.workspaceId);
260
443
  } catch (error) {
261
444
  const focusError = errorMessage(error);
262
- writeWorktreeManifest(running.worktree.manifestFile, {
263
- state: "running",
264
- focusError,
265
- });
445
+ requireWorktreeOperations(operations).writeWorktreeManifest(
446
+ running.worktree.manifestFile,
447
+ {
448
+ state: "running",
449
+ focusError,
450
+ },
451
+ );
266
452
  return {
267
453
  running,
268
454
  focusError,
@@ -279,16 +465,16 @@ async function launchFreshPiSubagent(
279
465
  let surface: PreparedSurface | undefined;
280
466
 
281
467
  try {
282
- surface = prepareLaunchSurface(resolved, operations);
283
- const session = prepareChildSession(resolved, surface);
468
+ surface = await prepareLaunchSurface(resolved, operations);
469
+ const session = prepareChildSession(resolved, surface, operations);
284
470
  const handoffArtifacts = request.handoff
285
- ? prepareTaskArtifacts(resolved, session)
471
+ ? prepareTaskArtifacts(resolved, session, operations)
286
472
  : undefined;
287
473
  await confirmShellReady(session, operations);
288
474
  const artifacts =
289
- handoffArtifacts ?? prepareTaskArtifacts(resolved, session);
475
+ handoffArtifacts ?? prepareTaskArtifacts(resolved, session, operations);
290
476
  const command = buildPiCommand(resolved, artifacts);
291
- const launchScriptFile = startPiProcess(
477
+ const launchScriptFile = await startPiProcess(
292
478
  resolved,
293
479
  artifacts,
294
480
  command,
@@ -304,25 +490,31 @@ async function launchFreshPiSubagent(
304
490
  artifacts.targetCwd,
305
491
  );
306
492
  if (artifacts.worktree) {
307
- persistWorktreeResult(artifacts.worktree, "running");
493
+ requireWorktreeOperations(operations).persistWorktreeResult(
494
+ artifacts.worktree,
495
+ "running",
496
+ );
308
497
  }
309
498
  }
310
- return createRunningChild(resolved, artifacts, launchScriptFile);
499
+ const running = createRunningChild(resolved, artifacts, launchScriptFile);
500
+ startProcessIdentityCapture(running, operations);
501
+ return running;
311
502
  } catch (error) {
312
503
  if (!surface) throw error;
313
504
  if (!surface.worktree) {
314
505
  if (!request.surface) {
315
506
  try {
316
- operations.closePane(surface.surface);
507
+ await operations.closePane(surface.surface);
317
508
  } catch {
318
509
  // The launch error remains authoritative when cleanup also fails.
319
510
  }
320
511
  }
321
512
  throw error;
322
513
  }
323
- const handoff = captureWorktreeHandoff(surface.worktree);
514
+ const worktreeOps = requireWorktreeOperations(operations);
515
+ const handoff = worktreeOps.captureWorktreeHandoff(surface.worktree);
324
516
  try {
325
- persistWorktreeResult(surface.worktree, "failed", handoff);
517
+ worktreeOps.persistWorktreeResult(surface.worktree, "failed", handoff);
326
518
  } catch {
327
519
  // The launch error remains authoritative when persistence also fails.
328
520
  }
@@ -371,16 +563,16 @@ function resolveLaunchRequest(request: FreshPiLaunchRequest): ResolvedLaunch {
371
563
  };
372
564
  }
373
565
 
374
- function prepareLaunchSurface(
566
+ async function prepareLaunchSurface(
375
567
  resolved: ResolvedLaunch,
376
568
  operations: PiLaunchOperations,
377
- ): PreparedSurface {
569
+ ): Promise<PreparedSurface> {
378
570
  const { request } = resolved;
379
571
  if (!request.worktree) {
380
572
  return {
381
573
  surface:
382
574
  request.surface ??
383
- operations.createPane(request.name, resolved.sourceCwd),
575
+ (await operations.createPane(request.name, resolved.sourceCwd)),
384
576
  targetCwd: resolved.sourceCwd,
385
577
  effectiveAgentDir: resolved.localAgentDir ?? resolved.agentDir,
386
578
  localAgentDir: resolved.localAgentDir,
@@ -389,8 +581,12 @@ function prepareLaunchSurface(
389
581
  if (request.surface)
390
582
  throw new Error("A worktree subagent cannot use a pre-created pane");
391
583
 
584
+ const worktreeOps = requireWorktreeOperations(operations);
392
585
  const baseRef = request.worktree.base ?? "HEAD";
393
- const baseSha = resolveGitCommit(resolved.sourceCwd, baseRef);
586
+ const baseSha = worktreeOps.resolveGitCommit(resolved.sourceCwd, baseRef);
587
+ const provisionCwd = worktreeOps.resolveWorktreeProvisionCwd(
588
+ resolved.sourceCwd,
589
+ );
394
590
  const manifestFile = join(
395
591
  resolved.artifactDir,
396
592
  "worktree-runs",
@@ -405,16 +601,16 @@ function prepareLaunchSurface(
405
601
  baseSha,
406
602
  createdAt: resolved.startTime,
407
603
  };
408
- writeWorktreeManifest(manifestFile, {
604
+ worktreeOps.writeWorktreeManifest(manifestFile, {
409
605
  state: "provisioning",
410
606
  ...ownership,
411
607
  });
412
608
 
413
- let created: HerdrWorktreeSurface;
609
+ let created: WorktreeSurfaceForLaunch;
414
610
  try {
415
- created = operations.createWorktree(
611
+ created = await operations.createWorktree(
416
612
  request.name,
417
- resolved.sourceCwd,
613
+ provisionCwd,
418
614
  request.worktree.branch,
419
615
  baseSha,
420
616
  );
@@ -423,11 +619,11 @@ function prepareLaunchSurface(
423
619
  state: "failed",
424
620
  ...ownership,
425
621
  };
426
- if (error instanceof HerdrWorktreeCreateError) {
622
+ if (error instanceof WorktreeProvisioningError) {
427
623
  Object.assign(failedManifest, error.recoveredWorktree);
428
624
  }
429
625
  failedManifest.error = errorMessage(error);
430
- writeWorktreeManifest(manifestFile, failedManifest);
626
+ worktreeOps.writeWorktreeManifest(manifestFile, failedManifest);
431
627
  throw error;
432
628
  }
433
629
 
@@ -440,7 +636,7 @@ function prepareLaunchSurface(
440
636
  baseSha,
441
637
  manifestFile,
442
638
  };
443
- writeWorktreeManifest(manifestFile, {
639
+ worktreeOps.writeWorktreeManifest(manifestFile, {
444
640
  state: "provisioned",
445
641
  ...ownership,
446
642
  ...worktree,
@@ -461,6 +657,7 @@ function prepareLaunchSurface(
461
657
  function prepareChildSession(
462
658
  resolved: ResolvedLaunch,
463
659
  surface: PreparedSurface,
660
+ operations: PiLaunchOperations,
464
661
  ): PreparedSession {
465
662
  const sessionDir = getDefaultSessionDirFor(
466
663
  surface.targetCwd,
@@ -476,7 +673,10 @@ function prepareChildSession(
476
673
  const sessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
477
674
  if (surface.worktree) {
478
675
  surface.worktree.sessionFile = sessionFile;
479
- writeWorktreeManifest(surface.worktree.manifestFile, { sessionFile });
676
+ requireWorktreeOperations(operations).writeWorktreeManifest(
677
+ surface.worktree.manifestFile,
678
+ { sessionFile },
679
+ );
480
680
  }
481
681
  writeSubagentSessionPolicy(sessionFile, {
482
682
  owner: surface.worktree ? "managed-worktree" : "public",
@@ -498,7 +698,16 @@ function prepareChildSession(
498
698
  resolved.artifactDir,
499
699
  resolved.id,
500
700
  );
501
- return { ...surface, sessionFile, activityFile };
701
+ return {
702
+ ...surface,
703
+ sessionFile,
704
+ activityFile,
705
+ // A handoff Pi loads no child protocol extension, so it records nothing.
706
+ processIdentityFile:
707
+ surface.worktree && !resolved.request.handoff
708
+ ? getSubagentProcessIdentityFile(sessionFile)
709
+ : undefined,
710
+ };
502
711
  }
503
712
 
504
713
  async function confirmShellReady(
@@ -530,6 +739,7 @@ function buildWorktreeHandoffMessage(
530
739
  function prepareTaskArtifacts(
531
740
  resolved: ResolvedLaunch,
532
741
  session: PreparedSession,
742
+ operations: PiLaunchOperations,
533
743
  ): PreparedArtifacts {
534
744
  const { request } = resolved;
535
745
  if (request.handoff) {
@@ -550,10 +760,13 @@ function prepareTaskArtifacts(
550
760
  });
551
761
  session.worktree.sourceSessionFile = request.parent.sessionFile;
552
762
  session.worktree.handoffMessage = handoffMessage;
553
- writeWorktreeManifest(session.worktree.manifestFile, {
554
- sourceSessionFile: request.parent.sessionFile,
555
- handoffMessage,
556
- });
763
+ requireWorktreeOperations(operations).writeWorktreeManifest(
764
+ session.worktree.manifestFile,
765
+ {
766
+ sourceSessionFile: request.parent.sessionFile,
767
+ handoffMessage,
768
+ },
769
+ );
557
770
  } else if (resolved.sessionMode !== "standalone") {
558
771
  seedSubagentSessionFile({
559
772
  mode: resolved.sessionMode,
@@ -615,7 +828,7 @@ function buildPiCommand(
615
828
  shellQuote(artifacts.sessionFile),
616
829
  ...(request.handoff
617
830
  ? []
618
- : ["-e", shellQuote(join(SUBAGENTS_DIR, "subagent-done.ts"))]),
831
+ : ["-e", shellQuote(join(SUBAGENTS_DIR, "child", "subagent-done.ts"))]),
619
832
  "--model",
620
833
  shellQuote(request.runtimePlan.model),
621
834
  "--thinking",
@@ -675,6 +888,10 @@ function buildPiCommand(
675
888
  env.push(`PI_SUBAGENT_ID=${shellQuote(resolved.id)}`);
676
889
  env.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellQuote(artifacts.activityFile)}`);
677
890
  env.push(`PI_SUBAGENT_SURFACE=${shellQuote(artifacts.surface)}`);
891
+ if (artifacts.processIdentityFile)
892
+ env.push(
893
+ `PI_SUBAGENT_PROCESS_FILE=${shellQuote(artifacts.processIdentityFile)}`,
894
+ );
678
895
  }
679
896
 
680
897
  const piCommand =
@@ -685,21 +902,24 @@ function buildPiCommand(
685
902
  : `${piCommand}; echo '__SUBAGENT_DONE_'$?'__'`;
686
903
  }
687
904
 
688
- function startPiProcess(
905
+ async function startPiProcess(
689
906
  resolved: ResolvedLaunch,
690
907
  artifacts: PreparedArtifacts,
691
908
  command: string,
692
909
  operations: PiLaunchOperations,
693
- ): string {
910
+ ): Promise<string> {
694
911
  const launchScriptFile = join(
695
912
  resolved.artifactDir,
696
913
  "subagent-scripts",
697
914
  `${safeName(resolved.request.name) || "subagent"}-${resolved.id}.sh`,
698
915
  );
699
916
  if (artifacts.worktree && !resolved.request.handoff) {
700
- persistWorktreeResult(artifacts.worktree, "running");
917
+ requireWorktreeOperations(operations).persistWorktreeResult(
918
+ artifacts.worktree,
919
+ "running",
920
+ );
701
921
  }
702
- return operations.runScript(artifacts.surface, command, {
922
+ return await operations.runScript(artifacts.surface, command, {
703
923
  scriptPath: launchScriptFile,
704
924
  scriptPreamble: [
705
925
  shellComment(`Subagent launch script for ${resolved.request.name}`),
@@ -729,9 +949,43 @@ function createRunningChild(
729
949
  runtimePlan: resolved.request.runtimePlan,
730
950
  worktree: artifacts.worktree,
731
951
  lifecycle: createLifecycle(resolved.startTime),
952
+ processIdentityFile: artifacts.processIdentityFile,
732
953
  };
733
954
  }
734
955
 
956
+ // Capture runs beside the launch so readiness never delays the acknowledgement;
957
+ // a cancel awaits it, and without a verified identity nothing is signalled. A
958
+ // cancel after an expired capture starts a fresh one bounded by its own budget.
959
+ export function startProcessIdentityCapture(
960
+ child: PiRunningChild,
961
+ operations: PiLaunchOperations,
962
+ options?: { timeoutMs?: number },
963
+ ): void {
964
+ const file = child.processIdentityFile;
965
+ if (!file) return;
966
+ const capture = operations.captureProcessIdentity;
967
+ if (!capture) {
968
+ child.processIdentityError = "process identity capture is unavailable";
969
+ return;
970
+ }
971
+ child.processIdentityError = undefined;
972
+ child.processIdentityCapture = Promise.resolve()
973
+ .then(() =>
974
+ capture(
975
+ child.surface,
976
+ { file, id: child.id, sessionFile: child.sessionFile },
977
+ options,
978
+ ),
979
+ )
980
+ .then(
981
+ (identity) => (child.processIdentity = identity),
982
+ (error) => {
983
+ child.processIdentityError = errorMessage(error);
984
+ return undefined;
985
+ },
986
+ );
987
+ }
988
+
735
989
  async function launchResumedPiSubagent(
736
990
  request: ResumePiLaunchRequest,
737
991
  operations: PiLaunchOperations,
@@ -761,7 +1015,7 @@ async function launchResumedPiSubagent(
761
1015
  (entry) => entry.type === "session",
762
1016
  );
763
1017
  const cwd = isNonEmptyString(header?.cwd) ? header.cwd : process.cwd();
764
- const surface = operations.createPane(request.name, cwd);
1018
+ const surface = await operations.createPane(request.name, cwd);
765
1019
  try {
766
1020
  await operations.waitForShellReady(surface);
767
1021
  const activityFile = getSubagentActivityFile(artifactDir, id);
@@ -802,10 +1056,10 @@ async function launchResumedPiSubagent(
802
1056
  shellQuote(request.sessionFile),
803
1057
  ...(toolAllowlist ? ["--tools", shellQuote(toolAllowlist)] : []),
804
1058
  "-e",
805
- shellQuote(join(SUBAGENTS_DIR, "subagent-done.ts")),
1059
+ shellQuote(join(SUBAGENTS_DIR, "child", "subagent-done.ts")),
806
1060
  ...(messageFile ? [shellQuote(`@${messageFile}`)] : []),
807
1061
  ].join(" ");
808
- const launchScriptFile = operations.runScript(
1062
+ const launchScriptFile = await operations.runScript(
809
1063
  surface,
810
1064
  `${command}; echo '__SUBAGENT_DONE_'$?'__'`,
811
1065
  {
@@ -840,7 +1094,7 @@ async function launchResumedPiSubagent(
840
1094
  };
841
1095
  } catch (error) {
842
1096
  try {
843
- operations.closePane(surface);
1097
+ await operations.closePane(surface);
844
1098
  } catch {
845
1099
  // The launch error remains authoritative when cleanup also fails.
846
1100
  }
@@ -911,163 +1165,12 @@ function safeName(name: string): string {
911
1165
  .replace(/^-|-$/g, "");
912
1166
  }
913
1167
 
914
- function resolveGitCommit(cwd: string, ref: string): string {
915
- return execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
916
- cwd,
917
- encoding: "utf8",
918
- }).trim();
919
- }
920
-
921
- export function readWorktreeManifest(path: string): JsonObject | undefined {
922
- try {
923
- const value: unknown = JSON.parse(readFileSync(path, "utf8"));
924
- if (
925
- isRecord(value) &&
926
- value.version === 1 &&
927
- value.kind === "worktree-run" &&
928
- value.owner === "pi-herdr-subagents"
929
- )
930
- return value;
931
- } catch {
932
- // Unreachable or malformed manifests do not establish ownership.
933
- }
934
- return undefined;
935
- }
936
-
937
- export function writeWorktreeManifest(path: string, value: JsonObject): void {
938
- mkdirSync(dirname(path), { recursive: true });
939
- let existing: JsonObject = {};
940
- if (existsSync(path)) {
941
- try {
942
- existing = JSON.parse(readFileSync(path, "utf8"));
943
- } catch {
944
- existing = {};
945
- }
946
- }
947
- const tempPath = `${path}.tmp`;
948
- writeFileSync(
949
- tempPath,
950
- `${JSON.stringify(
951
- {
952
- ...existing,
953
- ...value,
954
- version: 1,
955
- kind: "worktree-run",
956
- owner: "pi-herdr-subagents",
957
- updatedAt: Date.now(),
958
- },
959
- null,
960
- 2,
961
- )}\n`,
962
- );
963
- renameSync(tempPath, path);
964
- }
965
-
966
- function gitPathList(cwd: string, args: string[]): string[] {
967
- return execFileSync("git", args, { cwd, encoding: "utf8" })
968
- .split("\0")
969
- .filter(Boolean);
970
- }
971
-
972
- export function captureWorktreeHandoff(
973
- worktree: WorktreeLaunch,
974
- ): WorktreeHandoff {
975
- try {
976
- const headSha = resolveGitCommit(worktree.path, "HEAD");
977
- const status = execFileSync(
978
- "git",
979
- ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
980
- { cwd: worktree.path, encoding: "utf8" },
981
- );
982
- const untrackedFiles = gitPathList(worktree.path, [
983
- "ls-files",
984
- "--others",
985
- "--exclude-standard",
986
- "-z",
987
- ]);
988
- const conflictedFiles = gitPathList(worktree.path, [
989
- "diff",
990
- "--name-only",
991
- "--diff-filter=U",
992
- "-z",
993
- ]);
994
- const changedFiles = new Set([
995
- ...gitPathList(worktree.path, [
996
- "diff",
997
- "--name-only",
998
- "-z",
999
- `${worktree.baseSha}...HEAD`,
1000
- ]),
1001
- ...gitPathList(worktree.path, ["diff", "--name-only", "-z"]),
1002
- ...gitPathList(worktree.path, ["diff", "--cached", "--name-only", "-z"]),
1003
- ...untrackedFiles,
1004
- ]);
1005
- const commitsAhead = Number.parseInt(
1006
- execFileSync(
1007
- "git",
1008
- ["rev-list", "--count", `${worktree.baseSha}..HEAD`],
1009
- { cwd: worktree.path, encoding: "utf8" },
1010
- ).trim(),
1011
- 10,
1012
- );
1013
- return {
1014
- ...worktree,
1015
- headSha,
1016
- commitsAhead: Number.isFinite(commitsAhead) ? commitsAhead : 0,
1017
- clean: status.length === 0,
1018
- conflicted: conflictedFiles.length > 0,
1019
- changedFiles: [...changedFiles].sort(),
1020
- untrackedFiles: untrackedFiles.sort(),
1021
- };
1022
- } catch (error) {
1023
- return {
1024
- ...worktree,
1025
- headSha: null,
1026
- commitsAhead: null,
1027
- clean: null,
1028
- conflicted: null,
1029
- changedFiles: null,
1030
- untrackedFiles: null,
1031
- gitError: errorMessage(error),
1032
- };
1033
- }
1034
- }
1035
-
1036
- export function persistWorktreeResult(
1037
- worktree: WorktreeLaunch,
1038
- state: "running" | "ready_for_review" | "failed" | "needs_help" | "removed",
1039
- handoff?: WorktreeHandoff,
1040
- ): void {
1041
- writeWorktreeManifest(worktree.manifestFile, {
1042
- state,
1043
- ...worktree,
1044
- ...handoff,
1045
- });
1046
- }
1047
-
1048
- export function runSubagentScript(
1049
- surface: string,
1050
- command: string,
1051
- options: Parameters<typeof runScriptInPane>[2],
1052
- worktree?: WorktreeLaunch,
1053
- run: typeof runScriptInPane = runScriptInPane,
1054
- ): string {
1055
- if (worktree) persistWorktreeResult(worktree, "running");
1056
- try {
1057
- return run(surface, command, options);
1058
- } catch (error) {
1059
- if (!worktree) throw error;
1060
- const handoff = captureWorktreeHandoff(worktree);
1061
- try {
1062
- persistWorktreeResult(worktree, "failed", handoff);
1063
- } catch {
1064
- // The launch error remains authoritative when persistence also fails.
1065
- }
1066
- throw new Error(
1067
- `Failed to launch subagent; worktree retained at ${worktree.path} ` +
1068
- `(workspace ${worktree.workspaceId}): ${errorMessage(error)}`,
1069
- );
1070
- }
1168
+ function requireWorktreeOperations(
1169
+ operations: PiLaunchOperations,
1170
+ ): WorktreeOperations {
1171
+ if (!operations.worktree)
1172
+ throw new Error("Worktree operations are unavailable");
1173
+ return operations.worktree;
1071
1174
  }
1072
1175
 
1073
1176
  function errorMessage(error: any): string {