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
@@ -0,0 +1,701 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import type {
7
+ HarnessAdapter,
8
+ ResumeOptions,
9
+ SpawnOptions,
10
+ } from "../../core/harness-adapter.ts";
11
+ import type {
12
+ AgentHandle,
13
+ AgentState,
14
+ CompletionEvidence,
15
+ } from "../../core/types.ts";
16
+ import type { SurfaceProvider } from "../../core/surface-provider.ts";
17
+ import { readSubagentActivityFile } from "./activity-file.ts";
18
+ import {
19
+ markCompletionDetected,
20
+ markInterruptRequested,
21
+ observeActivity,
22
+ observePaneInspection,
23
+ } from "../../core/lifecycle.ts";
24
+ import { isNonEmptyString } from "../../core/config/type-guards.ts";
25
+ import type { PaneConfig } from "../../core/config/pane-config.ts";
26
+ import {
27
+ isThinkingLevel,
28
+ parseExactModelRef,
29
+ resolveRuntimePlan,
30
+ type ModelRegistryAdapter,
31
+ type ParentRuntime,
32
+ type ResolvedRuntimePlan,
33
+ } from "../../core/routing.ts";
34
+ import type { FileWakeRegistry } from "../../core/wake.ts";
35
+ import type { SupervisionCoordinator } from "../../core/supervision.ts";
36
+ import {
37
+ judgeProcessIdentity,
38
+ linuxProcessProbe,
39
+ terminateProcessIdentity,
40
+ type PiProcessIdentity,
41
+ type ProcessIdentityProbe,
42
+ type ProcessIdentityState,
43
+ } from "./process-identity.ts";
44
+ import {
45
+ launchOperationsFromSurface,
46
+ launchPiSubagent,
47
+ settleBefore,
48
+ startProcessIdentityCapture,
49
+ type FreshPiLaunchRequest,
50
+ type PiLaunchOperations,
51
+ type PiRunningChild,
52
+ } from "./launch.ts";
53
+ import type { WorktreeOperations } from "../../core/worktree.ts";
54
+ import { waitForCompletion } from "./completion.ts";
55
+ import {
56
+ appendPersistentDeliveryLedger,
57
+ findObservedSessionRuntime,
58
+ getNewEntries,
59
+ inspectFinalAssistantMessage,
60
+ readPersistentTaskEvents,
61
+ readSubagentSessionPolicy,
62
+ writePersistentTaskInbox,
63
+ } from "./session.ts";
64
+
65
+ export interface PiSpawnOptions extends SpawnOptions {
66
+ /** Pi host normalization: retain validation/provenance and raw cwd/config origins.
67
+ * Presence selects this snapshot, including intentionally omitted agent/tools/cwd.
68
+ * Generic callers continue to resolve their role/runtime through the adapter.
69
+ */
70
+ resolvedLaunch?: {
71
+ runtimePlan: ResolvedRuntimePlan;
72
+ agent?: string;
73
+ cwd?: string;
74
+ roleCwd?: string;
75
+ tools?: string;
76
+ };
77
+ /** Preserve opaque host identities during the staged migration. Not the core task/session ID. */
78
+ launchIdentity?: {
79
+ id?: string;
80
+ logicalId?: string;
81
+ generationId?: string;
82
+ taskId?: string;
83
+ };
84
+ }
85
+ export interface PiHarnessAdapterOptions {
86
+ surface: SurfaceProvider;
87
+ paneConfig: PaneConfig;
88
+ modelRegistry: ModelRegistryAdapter;
89
+ parent: FreshPiLaunchRequest["parent"];
90
+ /** Compatibility only; supervision owns its wake registry and registrations. */
91
+ wake?: FileWakeRegistry;
92
+ supervision: SupervisionCoordinator;
93
+ /** The active parent's registry runtime can precede persisted model-change entries. */
94
+ parentRuntime?: ParentRuntime;
95
+ /** Optional launch-operation injection, keeping the real Pi protocol in unit tests. */
96
+ operations?: PiLaunchOperations;
97
+ worktreeOperations?: WorktreeOperations;
98
+ /** Kernel process facts for a retained worktree child's identity; tests inject it. */
99
+ processProbe?: ProcessIdentityProbe;
100
+ /** Bound for confirming a retained worktree child's exit. Default: 5000ms. */
101
+ killTimeoutMs?: number;
102
+ /** Explicit local evidence/lifecycle bridge. The host still owns delivery and deduplication. */
103
+ onObservation?(
104
+ child: PiRunningChild,
105
+ kind:
106
+ | "local-evidence"
107
+ | "pane"
108
+ | "tick"
109
+ | "completion"
110
+ | "state"
111
+ | "interrupt",
112
+ ): void;
113
+ }
114
+ interface CompletionWait {
115
+ controller: AbortController;
116
+ promise: Promise<CompletionEvidence>;
117
+ waiters: number;
118
+ }
119
+
120
+ /** Pi's existing launch and child-sidecar protocol behind the harness seam. */
121
+ export class PiHarnessAdapter implements HarnessAdapter {
122
+ readonly name = "pi";
123
+ private readonly options: PiHarnessAdapterOptions;
124
+ private readonly operations: PiLaunchOperations;
125
+ private readonly children = new Map<string, PiRunningChild>();
126
+ private readonly evidence = new Map<string, CompletionEvidence>();
127
+ private readonly waits = new Map<string, CompletionWait>();
128
+ constructor(options: PiHarnessAdapterOptions) {
129
+ this.options = options;
130
+ this.operations =
131
+ options.operations ??
132
+ launchOperationsFromSurface(
133
+ options.surface,
134
+ options.paneConfig,
135
+ options.worktreeOperations,
136
+ );
137
+ }
138
+ isAvailable(): boolean {
139
+ try {
140
+ execFileSync("pi", ["--version"], { stdio: "ignore" });
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+ async spawn(opts: PiSpawnOptions): Promise<AgentHandle> {
147
+ this.rejectEnvironment(opts.env);
148
+ if (opts.worktree && !opts.worktreeRequest)
149
+ throw new Error(
150
+ "Pi requires an unprovisioned worktreeRequest; it owns worktree acquisition",
151
+ );
152
+ // One ordinary attempt per spawn; the run session owns retries and their evidence.
153
+ if (opts.worktreeRequest && opts.runtime?.fallbacks?.length)
154
+ throw new Error(
155
+ "Model fallbacks are not supported for worktree subagents.",
156
+ );
157
+ if (
158
+ opts.session?.parentSessionId &&
159
+ opts.session.parentSessionId !== this.options.parent.sessionId
160
+ )
161
+ throw new Error(
162
+ "Pi session parent identity does not match the launch parent",
163
+ );
164
+ const defaults = opts.role.defaults;
165
+ const persistent =
166
+ opts.behavior?.persistent ?? defaults?.persistent ?? false;
167
+ const autoExit = persistent
168
+ ? false
169
+ : (opts.behavior?.autoExit ?? defaults?.autoExit ?? false);
170
+ const interactive =
171
+ opts.behavior?.interactive ??
172
+ opts.interactive ??
173
+ (persistent ? false : (defaults?.interactive ?? !autoExit));
174
+ const denied = new Set([
175
+ ...(defaults?.denyTools ?? []),
176
+ ...(opts.behavior?.denyTools ?? []),
177
+ ]);
178
+ if (defaults?.spawning === false)
179
+ for (const name of [
180
+ "subagent",
181
+ "subagent_interrupt",
182
+ "subagent_cancel",
183
+ "subagent_send",
184
+ "subagent_stop",
185
+ "subagents_list",
186
+ "subagent_resume",
187
+ ])
188
+ denied.add(name);
189
+ const resolved = opts.resolvedLaunch;
190
+ const cwd = resolved ? resolved.cwd : opts.cwd;
191
+ const roleCwd = resolved ? resolved.roleCwd : defaults?.cwd;
192
+ const id = opts.launchIdentity?.id ?? randomUUID();
193
+ const taskId = opts.launchIdentity?.taskId ?? randomUUID();
194
+ const child = await launchPiSubagent(
195
+ {
196
+ kind: "fresh",
197
+ id,
198
+ name: opts.name,
199
+ task: opts.task,
200
+ agent: resolved ? resolved.agent : opts.role.name,
201
+ cwd,
202
+ worktree: opts.worktreeRequest,
203
+ // An omitted override must not force lineage-only/default fork to standalone.
204
+ fork: opts.session
205
+ ? opts.session.mode === "fork"
206
+ ? true
207
+ : opts.session.mode === "standalone"
208
+ ? false
209
+ : undefined
210
+ : undefined,
211
+ surface: opts.surface?.id,
212
+ parent: this.options.parent,
213
+ runtimePlan:
214
+ resolved?.runtimePlan ??
215
+ resolveRuntimePlan(
216
+ opts.runtime ?? {},
217
+ defaults ?? {},
218
+ this.parentRuntime(opts),
219
+ this.options.modelRegistry,
220
+ ),
221
+ behavior: {
222
+ tools: resolved
223
+ ? resolved.tools
224
+ : (opts.tools ?? opts.role.allowedTools).join(","),
225
+ cwd: roleCwd,
226
+ skills: (opts.behavior?.skills ?? defaults?.skills)?.join(","),
227
+ deniedTools: [...denied],
228
+ autoExit,
229
+ interactive,
230
+ persistent,
231
+ logicalId: opts.launchIdentity?.logicalId ?? id,
232
+ generationId: opts.launchIdentity?.generationId ?? randomUUID(),
233
+ taskId,
234
+ identity: opts.systemPrompt ?? opts.role.systemPrompt,
235
+ systemPromptMode:
236
+ opts.behavior?.systemPromptMode ?? defaults?.systemPromptMode,
237
+ sessionMode:
238
+ opts.session?.mode ?? defaults?.sessionMode ?? "standalone",
239
+ },
240
+ },
241
+ this.operations,
242
+ );
243
+ if (persistent) {
244
+ const policy = readSubagentSessionPolicy(child.sessionFile);
245
+ if (policy.version !== 2)
246
+ throw new Error("Persistent launch policy was not written as v2.");
247
+ Object.assign(child, {
248
+ persistent: true,
249
+ logicalId: policy.logicalId,
250
+ generationId: policy.generationId,
251
+ policyHash: policy.policyHash,
252
+ policyTools: policy.tools,
253
+ policyDeniedTools: policy.deniedTools,
254
+ tasksCompleted: 0,
255
+ taskId,
256
+ inboxSequence: 0,
257
+ observedTaskEvents: 0,
258
+ });
259
+ appendPersistentDeliveryLedger(child.sessionFile, {
260
+ task: taskId,
261
+ outcome: "dispatched",
262
+ generation: policy.generationId,
263
+ logicalId: policy.logicalId,
264
+ policyHash: policy.policyHash,
265
+ });
266
+ }
267
+ const rawCwd = cwd ?? roleCwd;
268
+ const cwdBase =
269
+ cwd == null && roleCwd != null
270
+ ? (this.options.parent.agentDir ??
271
+ process.env.PI_CODING_AGENT_DIR ??
272
+ join(homedir(), ".pi", "agent"))
273
+ : (this.options.parent.invocationCwd ?? this.options.parent.cwd);
274
+ return this.track(
275
+ child,
276
+ rawCwd
277
+ ? rawCwd.startsWith("/")
278
+ ? rawCwd
279
+ : join(cwdBase, rawCwd)
280
+ : this.options.parent.cwd,
281
+ );
282
+ }
283
+ async resume(opts: ResumeOptions): Promise<AgentHandle> {
284
+ this.rejectEnvironment(opts.env);
285
+ if (opts.surface)
286
+ throw new Error("Pi public resume owns its new ordinary pane");
287
+ if (opts.tools)
288
+ throw new Error(
289
+ "Pi resume restores saved tools; tool overrides are not supported",
290
+ );
291
+ const child = await launchPiSubagent(
292
+ {
293
+ kind: "resume",
294
+ name: opts.name,
295
+ sessionFile: opts.sessionId,
296
+ message: opts.message,
297
+ parent: this.options.parent,
298
+ behavior: { autoExit: opts.autoExit },
299
+ },
300
+ this.operations,
301
+ );
302
+ const header = getNewEntries(child.sessionFile, 0).find(
303
+ (entry) => entry.type === "session",
304
+ );
305
+ return this.track(
306
+ child,
307
+ isNonEmptyString(header?.cwd) ? header.cwd : process.cwd(),
308
+ );
309
+ }
310
+ /** Temporary Pi-specific bridge for runtime migration; never infer task delivery from coarse state. */
311
+ getRunningChild(handle: AgentHandle): PiRunningChild {
312
+ const child = this.children.get(handle.id);
313
+ if (
314
+ !child ||
315
+ handle.harness !== this.name ||
316
+ handle.sessionId !== child.sessionFile
317
+ )
318
+ throw new Error(`Unknown Pi child ${handle.id}`);
319
+ return child;
320
+ }
321
+ readPersistentEvents(handle: AgentHandle) {
322
+ const child = this.getRunningChild(handle);
323
+ return readPersistentTaskEvents(child.sessionFile).filter(
324
+ (event) => event.generation === child.generationId,
325
+ );
326
+ }
327
+ async getState(handle: AgentHandle): Promise<AgentState> {
328
+ try {
329
+ if (handle.harness !== this.name) return "unknown";
330
+ if (this.evidence.has(this.runKey(handle))) return "done";
331
+ const child = this.getRunningChild(handle);
332
+ const read = readSubagentActivityFile(child.activityFile, child.id);
333
+ let pane;
334
+ try {
335
+ pane = await this.options.surface.inspectSurface(child.surface);
336
+ } catch {
337
+ pane = { kind: "unavailable" as const };
338
+ }
339
+ child.lifecycle = observeActivity(
340
+ observePaneInspection(child.lifecycle, pane, Date.now()),
341
+ read,
342
+ Date.now(),
343
+ );
344
+ this.options.onObservation?.(child, "state");
345
+ if (pane.kind !== "present") return "unknown";
346
+ switch (child.lifecycle.turn.kind) {
347
+ case "active":
348
+ return "working";
349
+ case "blocked":
350
+ case "waiting":
351
+ return "blocked";
352
+ case "starting":
353
+ return "idle";
354
+ default:
355
+ return "unknown";
356
+ }
357
+ } catch {
358
+ return "unknown";
359
+ }
360
+ }
361
+ async interrupt(handle: AgentHandle): Promise<void> {
362
+ const child = this.getRunningChild(handle);
363
+ await this.options.surface.sendKeys(child.surface, "Escape");
364
+ child.lifecycle = markInterruptRequested(child.lifecycle, Date.now());
365
+ this.options.onObservation?.(child, "interrupt");
366
+ }
367
+ /** Resolves only with termination evidence; otherwise rejects (unconfirmed). */
368
+ async kill(handle: AgentHandle): Promise<void> {
369
+ const child = this.getRunningChild(handle);
370
+ // A worktree root pane is the retained review workspace (Herdr refuses to
371
+ // close it): stop only the owned Pi process and keep pane and checkout.
372
+ if (child.worktree) return this.stopRetainedPi(child);
373
+ let closeError: unknown;
374
+ try {
375
+ await this.options.surface.closeSurface(child.surface);
376
+ } catch (error) {
377
+ // Absence is still checked: a pane that is already gone is terminated.
378
+ closeError = error;
379
+ }
380
+ try {
381
+ await this.options.surface.waitForSurfaceAbsence(child.surface);
382
+ } catch (error) {
383
+ if (closeError === undefined) throw error;
384
+ throw new Error(`${errorText(closeError)}; ${errorText(error)}`);
385
+ }
386
+ }
387
+ /**
388
+ * Exit evidence is only the launch-verified identity (PID + start time, on
389
+ * this boot and PID namespace) no longer existing, or a gone pane while that
390
+ * identity is not known alive. SIGTERM goes only to that identity,
391
+ * re-verified first; without it nothing is signalled and the cancel stays
392
+ * unconfirmed. A failed SIGTERM to a live identity stays unconfirmed unless
393
+ * that identity then exits. Herdr's foreground list and argv text are never
394
+ * evidence.
395
+ * Every provider await is bounded by the deadline, and a late answer is
396
+ * dropped, so an unconfirmed outcome is reported on time and never flipped.
397
+ */
398
+ private async stopRetainedPi(child: PiRunningChild): Promise<void> {
399
+ const probe = this.options.processProbe ?? linuxProcessProbe;
400
+ const timeoutMs = this.options.killTimeoutMs ?? 5_000;
401
+ const deadline = Date.now() + timeoutMs;
402
+ const paneGone = async () =>
403
+ (
404
+ await settleBefore(
405
+ // Providers may throw synchronously or reject; both mean "unknown".
406
+ Promise.resolve()
407
+ .then(() => this.options.surface.inspectSurface(child.surface))
408
+ .catch(() => undefined),
409
+ deadline,
410
+ )
411
+ )?.kind === "missing";
412
+ let identity = child.processIdentity;
413
+ if (!identity) {
414
+ // An expired capture gets one fresh attempt within this cancel's budget.
415
+ if (child.processIdentityError !== undefined)
416
+ startProcessIdentityCapture(child, this.operations, {
417
+ timeoutMs: Math.max(0, deadline - Date.now()),
418
+ });
419
+ // Inspect alongside the capture wait so absence evidence is not
420
+ // starved by it; the identity is re-read after the inspection.
421
+ const absence = paneGone();
422
+ const captured = await settleBefore(
423
+ child.processIdentityCapture,
424
+ deadline,
425
+ );
426
+ const gone = (child.processIdentity ?? captured) ? false : await absence;
427
+ identity = child.processIdentity ?? captured;
428
+ if (!identity) {
429
+ if (gone) return;
430
+ throw new Error(
431
+ `Owned Pi process identity was not captured for retained worktree pane ${child.surface}; nothing was signalled: ${child.processIdentityError ?? (child.processIdentityCapture ? "identity capture is still pending" : "no identity was recorded at launch")}`,
432
+ );
433
+ }
434
+ }
435
+ let signalled = false;
436
+ const verified = identity;
437
+ const signal = (): ProcessIdentityState => {
438
+ const before = judgeProcessIdentity(verified, probe);
439
+ if (before.kind !== "alive") return before;
440
+ const after = terminateProcessIdentity(verified, probe);
441
+ if (after.kind === "alive") signalled = true;
442
+ if (after.kind !== "unknown") return after;
443
+ // The identity was just known alive and the signal failed: only its
444
+ // own exit confirms; pane absence never does.
445
+ if (judgeProcessIdentity(verified, probe).kind === "exited")
446
+ return { kind: "exited" };
447
+ throw new Error(
448
+ `Owned Pi process exit unconfirmed in retained worktree pane ${child.surface}: ${after.reason}; process ${verified.pid} was alive when signalled`,
449
+ );
450
+ };
451
+ let state = signal();
452
+ let paneSeenGone = false;
453
+ for (;;) {
454
+ if (state.kind === "exited") return;
455
+ // Unknown is never signalled and waiting cannot make it evidence.
456
+ if (state.kind === "unknown") {
457
+ if (!paneSeenGone && !(await paneGone())) break;
458
+ paneSeenGone = true;
459
+ // The inspection awaited: confirm only while still not known alive.
460
+ state = judgeProcessIdentity(identity, probe);
461
+ if (state.kind === "alive" && !signalled) state = signal();
462
+ if (state.kind !== "alive") return;
463
+ }
464
+ const remaining = deadline - Date.now();
465
+ if (remaining <= 0) break;
466
+ await new Promise((resolve) =>
467
+ setTimeout(resolve, Math.min(50, remaining)),
468
+ );
469
+ state = judgeProcessIdentity(identity, probe);
470
+ }
471
+ throw new Error(
472
+ `Owned Pi process exit unconfirmed in retained worktree pane ${child.surface}: ${describeIdentityState(identity, state, signalled, timeoutMs)}`,
473
+ );
474
+ }
475
+ async readOutput(handle: AgentHandle, lines?: number): Promise<string> {
476
+ return this.options.surface.readScreen(
477
+ this.getRunningChild(handle).surface,
478
+ lines,
479
+ );
480
+ }
481
+ exitCode(handle: AgentHandle): number | undefined {
482
+ return this.evidence.get(this.runKey(handle))?.exitCode;
483
+ }
484
+ async sendInput(handle: AgentHandle, text: string): Promise<void> {
485
+ const child = this.getRunningChild(handle);
486
+ if (!child.persistent)
487
+ throw new Error("child is not a persistent specialist");
488
+ const task = randomUUID();
489
+ const facts = {
490
+ task,
491
+ generation: child.generationId!,
492
+ logicalId: child.logicalId!,
493
+ policyHash: child.policyHash!,
494
+ };
495
+ if (
496
+ this.evidence.has(this.runKey(handle)) ||
497
+ child.stopState ||
498
+ child.taskId != null ||
499
+ child.tasksCompleted == null
500
+ ) {
501
+ appendPersistentDeliveryLedger(child.sessionFile, {
502
+ ...facts,
503
+ outcome: "rejected-busy",
504
+ });
505
+ throw new Error(
506
+ `Persistent specialist "${child.name}" is busy or stopped; task ${task} was rejected-busy. Resend after the pending result.`,
507
+ );
508
+ }
509
+ writePersistentTaskInbox(
510
+ child.sessionFile,
511
+ (child.inboxSequence = (child.inboxSequence ?? 0) + 1),
512
+ { task, message: text },
513
+ );
514
+ child.taskId = task;
515
+ appendPersistentDeliveryLedger(child.sessionFile, {
516
+ ...facts,
517
+ outcome: "dispatched",
518
+ });
519
+ }
520
+ async awaitCompletion(
521
+ handle: AgentHandle,
522
+ signal: AbortSignal,
523
+ ): Promise<CompletionEvidence> {
524
+ if (signal.aborted)
525
+ throw new Error("Aborted while waiting for subagent to finish");
526
+ const key = this.runKey(handle);
527
+ const recorded = this.evidence.get(key);
528
+ if (recorded) return recorded;
529
+ const child = this.getRunningChild(handle);
530
+ let wait = this.waits.get(key);
531
+ if (!wait || wait.controller.signal.aborted) {
532
+ const controller = new AbortController();
533
+ const registration = this.options.supervision.register(
534
+ child.sessionFile,
535
+ child.surface,
536
+ );
537
+ const next: CompletionWait = {
538
+ controller,
539
+ waiters: 0,
540
+ promise: waitForCompletion(controller.signal, {
541
+ intervalMs: 1000,
542
+ sessionFile: child.sessionFile,
543
+ waitForNextCheck: registration.wait,
544
+ readTerminalTail: async () =>
545
+ this.options.surface.readScreen(child.surface, 5),
546
+ inspectPane: registration.inspectPane,
547
+ onLocalEvidence: () =>
548
+ this.options.onObservation?.(child, "local-evidence"),
549
+ onPaneInspection: (pane, at) => {
550
+ child.lifecycle = observePaneInspection(child.lifecycle, pane, at);
551
+ this.options.onObservation?.(child, "pane");
552
+ },
553
+ onTick: () => {
554
+ this.options.onObservation?.(child, "tick");
555
+ },
556
+ })
557
+ .then((result) => {
558
+ const final = existsSync(child.sessionFile)
559
+ ? inspectFinalAssistantMessage(
560
+ getNewEntries(child.sessionFile, 0),
561
+ )
562
+ : undefined;
563
+ const evidence: CompletionEvidence = {
564
+ ...result,
565
+ sessionRef: child.sessionFile,
566
+ };
567
+ if (final && (final.text != null || final.stopReason)) {
568
+ evidence.finalMessage = {
569
+ text: final.text ?? "",
570
+ stopReason: final.stopReason,
571
+ };
572
+ if (final.stopReason === "error" && result.errorMessage)
573
+ evidence.finalMessage.errorMessage = result.errorMessage;
574
+ }
575
+ if (child.worktree)
576
+ evidence.worktree =
577
+ this.worktreeOperations().captureWorktreeHandoff(
578
+ child.worktree,
579
+ );
580
+ this.evidence.set(key, evidence);
581
+ child.lifecycle = markCompletionDetected(
582
+ child.lifecycle,
583
+ result,
584
+ Date.now(),
585
+ );
586
+ this.options.onObservation?.(child, "completion");
587
+ return evidence;
588
+ })
589
+ .finally(() => {
590
+ registration.unregister();
591
+ if (this.waits.get(key) === next) this.waits.delete(key);
592
+ }),
593
+ };
594
+ this.waits.set(key, next);
595
+ wait = next;
596
+ }
597
+ return this.joinWait(wait, signal);
598
+ }
599
+ private worktreeOperations(): WorktreeOperations {
600
+ const operations = this.operations.worktree;
601
+ if (!operations) throw new Error("Worktree operations are unavailable");
602
+ return operations;
603
+ }
604
+ private joinWait(
605
+ wait: CompletionWait,
606
+ signal: AbortSignal,
607
+ ): Promise<CompletionEvidence> {
608
+ wait.waiters++;
609
+ const abort = () =>
610
+ rejectWait(new Error("Aborted while waiting for subagent to finish"));
611
+ let rejectWait!: (error: Error) => void;
612
+ return new Promise<CompletionEvidence>((resolve, reject) => {
613
+ rejectWait = reject;
614
+ signal.addEventListener("abort", abort, { once: true });
615
+ wait.promise.then(resolve, reject);
616
+ }).finally(() => {
617
+ // Each consumer owns only its cancellation, never another consumer's wait.
618
+ signal.removeEventListener("abort", abort);
619
+ wait.waiters--;
620
+ if (wait.waiters === 0) wait.controller.abort();
621
+ });
622
+ }
623
+ private runKey(handle: Pick<AgentHandle, "id" | "sessionId">): string {
624
+ // Legacy fallback retries reuse the opaque child ID, but create a new Pi session.
625
+ return JSON.stringify([handle.id, handle.sessionId]);
626
+ }
627
+ private track(child: PiRunningChild, cwd: string): AgentHandle {
628
+ this.children.set(child.id, child);
629
+ const handle: AgentHandle = {
630
+ id: child.id,
631
+ name: child.name,
632
+ role: child.agent ?? "",
633
+ harness: this.name,
634
+ cwd: child.worktree?.path ?? cwd,
635
+ startedAt: child.startTime,
636
+ sessionId: child.sessionFile,
637
+ surfaceId: child.surface,
638
+ };
639
+ if (child.worktree)
640
+ handle.worktree = {
641
+ id: child.id,
642
+ owner: "maestro",
643
+ branch: child.worktree.branch,
644
+ baseRef: child.worktree.baseRef,
645
+ baseSha: child.worktree.baseSha,
646
+ createdAt: child.startTime,
647
+ path: child.worktree.path,
648
+ state: "running",
649
+ manifestFile: child.worktree.manifestFile,
650
+ };
651
+ return handle;
652
+ }
653
+ private parentRuntime(opts: SpawnOptions): ParentRuntime {
654
+ if (this.options.parentRuntime) return this.options.parentRuntime;
655
+ const observed = existsSync(this.options.parent.sessionFile)
656
+ ? findObservedSessionRuntime(
657
+ getNewEntries(this.options.parent.sessionFile, 0),
658
+ )
659
+ : {};
660
+ if (
661
+ observed.provider &&
662
+ observed.modelId &&
663
+ observed.thinking &&
664
+ isThinkingLevel(observed.thinking)
665
+ )
666
+ return {
667
+ provider: observed.provider,
668
+ modelId: observed.modelId,
669
+ thinking: observed.thinking,
670
+ };
671
+ const model = parseExactModelRef(
672
+ opts.runtime?.model ?? opts.role.defaults?.model ?? "",
673
+ );
674
+ const thinking = opts.runtime?.thinking ?? opts.role.defaults?.thinking;
675
+ if (model && thinking) return { ...model, thinking };
676
+ throw new Error(
677
+ "Pi launch requires the active parent runtime or explicit model and thinking",
678
+ );
679
+ }
680
+ private rejectEnvironment(env: Record<string, string> | undefined): void {
681
+ if (env && Object.keys(env).length)
682
+ throw new Error(
683
+ "The existing Pi launch protocol does not support per-child environment overrides",
684
+ );
685
+ }
686
+ }
687
+
688
+ function errorText(cause: unknown): string {
689
+ return cause instanceof Error ? cause.message : String(cause);
690
+ }
691
+
692
+ function describeIdentityState(
693
+ identity: PiProcessIdentity,
694
+ state: ProcessIdentityState,
695
+ signalled: boolean,
696
+ timeoutMs: number,
697
+ ): string {
698
+ if (state.kind === "unknown")
699
+ return `${state.reason}${signalled ? " after SIGTERM" : "; not signalled"}`;
700
+ return `process ${identity.pid} (start time ${identity.startTime}) is still ${state.kind} ${timeoutMs}ms after SIGTERM`;
701
+ }