@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Files-changed report for a NON-worktree leg. Claude Code and Codex tell the supervisor which
3
+ * files a sub-agent touched; a worktree leg gets that from `captureWorktreeArtifact`'s unified
4
+ * diff (engine.ts's `appendWorktreeArtifact`), but a leg without isolation runs in the SHARED
5
+ * checkout — there is no private diff to export, only "what changed in `git status` while this
6
+ * leg ran". That is inherently approximate (a parallel sibling can dirty the same files), so the
7
+ * report says so plainly rather than reading like an authoritative per-leg diff.
8
+ *
9
+ * Pure over `GitExec` (see worktree.ts) so it is unit-tested without a real repository; the only
10
+ * process boundary is `captureStatus`'s two `git` calls (rev-parse gate + porcelain status), gated
11
+ * by `findGitRoot`'s synchronous filesystem walk so a cwd outside any repository never pays for
12
+ * them at all.
13
+ */
14
+
15
+ import { existsSync } from "node:fs";
16
+ import { dirname, join, resolve } from "node:path";
17
+
18
+ import { normalizeWritePath, pathsOverlap } from "../core/ownership.ts";
19
+ import type { GitExec } from "./worktree.ts";
20
+
21
+ /** path → 2-character porcelain status code (e.g. " M", "??", "A ", "R "). */
22
+ export interface StatusSnapshot {
23
+ entries: Map<string, string>;
24
+ }
25
+
26
+ export interface ChangedPath {
27
+ path: string;
28
+ kind: "M" | "A" | "D" | "R" | "?" | "clean";
29
+ }
30
+
31
+ /** Bound how many changed paths are named in the report — a leg that rewrites hundreds of files
32
+ * (a codemod, a lockfile regen) must not flood the supervisor's context with every path. */
33
+ export const MAX_REPORTED_PATHS = 40;
34
+
35
+ function kindFromXY(xy: string): ChangedPath["kind"] {
36
+ if (xy === "??") return "?";
37
+ const x = xy[0];
38
+ const y = xy[1];
39
+ if (x === "A" || y === "A") return "A";
40
+ if (x === "D" || y === "D") return "D";
41
+ if (x === "R" || y === "R") return "R";
42
+ return "M";
43
+ }
44
+
45
+ /** Parse `git status --porcelain=v1 -z` output. Each entry is `XY path\0`; a rename/copy
46
+ * (X or Y is `R`/`C`) carries an extra `\0`-terminated old-path field, which is consumed here
47
+ * and not itself recorded — only the new path is a "changed path" from the leg's point of view. */
48
+ export function parsePorcelainZ(stdout: string): StatusSnapshot {
49
+ const entries = new Map<string, string>();
50
+ const parts = stdout.split("\0");
51
+ let i = 0;
52
+ while (i < parts.length) {
53
+ const entry = parts[i];
54
+ i++;
55
+ if (!entry) continue;
56
+ const xy = entry.slice(0, 2);
57
+ const path = entry.slice(3);
58
+ entries.set(path, xy);
59
+ if (xy[0] === "R" || xy[0] === "C" || xy[1] === "R" || xy[1] === "C") i++; // skip the old-path field
60
+ }
61
+ return { entries };
62
+ }
63
+
64
+ /** How many ancestor directories {@link findGitRoot} will check above `start` before giving up —
65
+ * generous for any real project nesting, but finite so a pathological path can't loop. */
66
+ const MAX_GIT_ROOT_ANCESTORS = 12;
67
+
68
+ /** Walk up from `start` looking for a `.git` entry (a directory for a normal checkout, a file for
69
+ * a submodule/worktree pointer) — what real `git -C <dir> ...` does internally when `<dir>` is a
70
+ * subdirectory of the repository. Reimplemented here as a synchronous, injectable `exists` check
71
+ * (default `existsSync`) so `captureStatus` can skip the real `git` round trip for a cwd that
72
+ * plainly isn't inside ANY repository, without missing one whose top level is merely an ANCESTOR
73
+ * of `start` — the common case of a Pi session started in a package subdirectory of a larger
74
+ * repo. `undefined` when no ancestor within {@link MAX_GIT_ROOT_ANCESTORS} has `.git`, or once the
75
+ * filesystem root is reached. Pure over the injected `exists`, so it is unit-tested without a
76
+ * filesystem. */
77
+ export function findGitRoot(start: string, exists: (p: string) => boolean = existsSync): string | undefined {
78
+ let current = resolve(start);
79
+ for (let i = 0; i < MAX_GIT_ROOT_ANCESTORS; i++) {
80
+ if (exists(join(current, ".git"))) return current;
81
+ const parent = dirname(current);
82
+ if (parent === current) return undefined; // reached the filesystem root
83
+ current = parent;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /** `undefined` when `root` is not (inside) a Git repository or `git status` fails — never throws
89
+ * (mirrors `GitExec`'s own never-throw contract), so a leg without a repo, or one whose git is
90
+ * briefly unavailable, degrades to "no report" rather than breaking the leg it only observes.
91
+ *
92
+ * Gated by {@link findGitRoot}: a cwd that plainly isn't inside any repository (within the walk's
93
+ * bound) never spawns a real `git` process — a genuine per-leg cost otherwise paid on every run
94
+ * outside a repository. The discovered repository top, not `root` itself, is passed to `git -C`
95
+ * below (the porcelain output is repo-relative either way).
96
+ *
97
+ * `knownGitRoot` lets a caller that already resolved (and, via a PRIOR call to this function,
98
+ * vouched for) the repository root skip BOTH the filesystem walk and the `rev-parse` liveness
99
+ * gate — `withChangeReport`'s before/after pair shares one root, so only the FIRST capture needs
100
+ * to prove it; the second trusts that proof rather than re-spawning `rev-parse` for no new
101
+ * information. Omit it for the general case (an unresolved `root`, or a standalone caller with no
102
+ * prior proof), which keeps today's full discover-then-verify behavior unchanged. */
103
+ export async function captureStatus(root: string, exec: GitExec, knownGitRoot?: string): Promise<StatusSnapshot | undefined> {
104
+ const gitRoot = knownGitRoot ?? findGitRoot(root);
105
+ if (!gitRoot) return undefined;
106
+ if (knownGitRoot === undefined) {
107
+ const repo = await exec(["-C", gitRoot, "rev-parse", "--is-inside-work-tree"]);
108
+ if (repo.code !== 0 || repo.stdout.trim().toLowerCase() !== "true") return undefined;
109
+ }
110
+ const status = await exec(["-C", gitRoot, "status", "--porcelain=v1", "--untracked-files=all", "-z"]);
111
+ if (status.code !== 0) return undefined;
112
+ return parsePorcelainZ(status.stdout);
113
+ }
114
+
115
+ /** Paths that appeared or changed status between the two snapshots, followed by paths that
116
+ * disappeared (resolved back to clean) — in that order; `renderChangeReport` sorts for display. */
117
+ export function diffStatus(before: StatusSnapshot, after: StatusSnapshot): ChangedPath[] {
118
+ const changes: ChangedPath[] = [];
119
+ for (const [path, xy] of after.entries) {
120
+ if (before.entries.get(path) === xy) continue;
121
+ changes.push({ path, kind: kindFromXY(xy) });
122
+ }
123
+ for (const [path] of before.entries) {
124
+ if (!after.entries.has(path)) changes.push({ path, kind: "clean" });
125
+ }
126
+ return changes;
127
+ }
128
+
129
+ /** Render the block appended to a leg's output, or `undefined` when nothing changed (the caller
130
+ * must not tack an empty block onto an untouched result). `writeSet` is the leg's OWN declared
131
+ * ownership (from `delegate`'s `writeSet`); a changed path outside it is flagged — not blocked,
132
+ * since a shared checkout has no enforcement lever here, only a supervisor-visible warning. */
133
+ export function renderChangeReport(changes: ChangedPath[], writeSet?: readonly string[]): string | undefined {
134
+ if (changes.length === 0) return undefined;
135
+ const sorted = [...changes].sort((a, b) => a.path.localeCompare(b.path));
136
+ const owned = writeSet && writeSet.length > 0 ? writeSet.map(normalizeWritePath) : undefined;
137
+ const lines = sorted.slice(0, MAX_REPORTED_PATHS).map((c) => {
138
+ const outside = owned !== undefined && !owned.some((ws) => pathsOverlap(normalizeWritePath(c.path), ws));
139
+ return `- ${c.path} (${c.kind})${outside ? " ⚠ outside declared writeSet" : ""}`;
140
+ });
141
+ if (sorted.length > MAX_REPORTED_PATHS) lines.push(`+${sorted.length - MAX_REPORTED_PATHS} more`);
142
+ return ["--- FILES CHANGED DURING THIS LEG (shared checkout; parallel legs may overlap) ---", ...lines].join("\n");
143
+ }
@@ -0,0 +1,532 @@
1
+ /**
2
+ * ChildProcessEngine — runs a sub-agent as a separate `pi --mode json -p`
3
+ * process (the correctness baseline; guardrails I1). Cross-OS via the same
4
+ * binary-resolution recipe Pi's reference subagent uses. The JSON stream is
5
+ * folded by the pure `stream.ts` helpers; this file is only process plumbing.
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ import { applyEvent, type ChildUsage, createStreamState, feedLines, type ProgressSnapshot, snapshot } from "./stream.ts";
14
+
15
+ export interface ChildRunSpec {
16
+ task: string;
17
+ model?: string;
18
+ tools?: string[];
19
+ excludeTools?: string[];
20
+ systemPrompt?: string;
21
+ cwd?: string;
22
+ }
23
+
24
+ export interface ChildRunResult {
25
+ ok: boolean;
26
+ output: string;
27
+ usage: ChildUsage;
28
+ exitCode: number;
29
+ stderr: string;
30
+ /** True only when an external AbortSignal cancelled the run. */
31
+ aborted: boolean;
32
+ /** True when the idle watchdog OR the hard wall-clock cap fired. Disjoint from `aborted`. */
33
+ timedOut: boolean;
34
+ model?: string;
35
+ stopReason?: string;
36
+ errorMessage?: string;
37
+ }
38
+
39
+ export interface ChildEngineOptions {
40
+ /** Override how the `pi` invocation is resolved (used in tests). */
41
+ resolveInvocation?: (args: string[]) => { command: string; args: string[] };
42
+ /** Grace period before escalating SIGTERM → force tree-kill on abort/timeout. */
43
+ killGraceMs?: number;
44
+ /** Live progress callback (for async peek / supervision). */
45
+ onProgress?: (snapshot: ProgressSnapshot) => void;
46
+ /** IDLE window (ms): a child that emits NOTHING for this long is killed (SIGTERM→tree-kill).
47
+ * Any output re-arms it, so a long-but-active child survives. 0/absent = no idle watchdog. */
48
+ timeoutMs?: number;
49
+ /** HARD wall-clock cap (ms): a definite lifetime ceiling, armed ONCE and never re-armed by
50
+ * output — so it kills a busy-but-non-converging child (a loop that keeps emitting) that the
51
+ * idle window above never catches. 0/absent = no cap. */
52
+ hardTimeoutMs?: number;
53
+ /** STARTUP deadline (ms): a child that never makes PROGRESS (no completed turn, no tokens,
54
+ * no streamed output) within this window is killed as a stalled start — the "never started"
55
+ * case the idle window is too generous for (a headless `mcp: true` leg whose MCP adapter hangs
56
+ * in init emits only the session header, then nothing). The FIRST progress cancels it
57
+ * permanently, so a slow-but-streaming turn is never touched. 0/absent = no startup deadline.
58
+ *
59
+ * CHOOSE THE VALUE FOR THE WHOLE COLD START, not for init alone. Only the CHILD'S OWN
60
+ * assistant output counts as progress — pi's echo of the delivered prompt is filtered out
61
+ * (stream.ts), and header/turn_start noise never counted — so the window has to cover
62
+ * process spawn + pi init + MCP init + the FIRST provider response. A queued or
63
+ * rate-limited provider, a cold serverless endpoint or a local model doing a long prompt
64
+ * eval can spend minutes there, and this deadline kills such a leg outright (failureKind
65
+ * "timeout"); the idle watchdog would not, because it is re-armed by any stdout at all. */
66
+ startupTimeoutMs?: number;
67
+ /** The child may legitimately go silent while BLOCKED on a supervisor reply (`contact_supervisor`
68
+ * decision/interview over the broker, up to the bus ask cap). The idle watchdog and the startup
69
+ * deadline stay armed regardless — silence is ambiguous on its own — but when either FIRES, this
70
+ * is consulted first: true means the bus confirms a live pending ask from this child, so the
71
+ * watchdog re-arms instead of killing; false means the silence is real. Absent ⇒ never blocked,
72
+ * i.e. today's behavior. The hard cap (`hardTimeoutMs`) still applies unconditionally either way. */
73
+ isBlocked?: () => boolean;
74
+ /** Override the cross-OS force tree-kill (used in tests). Defaults to
75
+ * {@link killProcessTree}. */
76
+ killProcessTree?: (pid: number) => void;
77
+ /** Cap on retained stderr bytes (default 256 KiB) — a verbose child must not
78
+ * inflate supervisor memory without bound. */
79
+ maxStderrBytes?: number;
80
+ /** Extra env vars merged into the child's environment on top of the defaults
81
+ * (e.g. the broker wiring `PI_PERSONA_BUS`/`PI_PERSONA_HANDLE` — adapter.ts's job,
82
+ * this file only plumbs them through). Absent ⇒ env is exactly today's. */
83
+ env?: Record<string, string>;
84
+ }
85
+
86
+ /** Resolve how to re-invoke `pi` on any OS (script vs generic runtime vs PATH). */
87
+ export function getPiInvocation(args: string[]): { command: string; args: string[] } {
88
+ const currentScript = process.argv[1];
89
+ const isBunVirtual = currentScript?.startsWith("/$bunfs/root/") ?? false;
90
+ if (currentScript && !isBunVirtual && fs.existsSync(currentScript)) {
91
+ return { command: process.execPath, args: [currentScript, ...args] };
92
+ }
93
+ const execName = path.basename(process.execPath).toLowerCase();
94
+ if (!/^(node|bun)(\.exe)?$/.test(execName)) return { command: process.execPath, args };
95
+ return { command: "pi", args };
96
+ }
97
+
98
+ /**
99
+ * Force-kill a process *and its children*, cross-OS. Mirrors Pi's
100
+ * `killProcessTree` helper (taskkill /F /T on Windows; SIGKILL to the process
101
+ * group, falling back to the lone pid, on POSIX).
102
+ *
103
+ * This is the escalation used when a child ignores SIGTERM. `proc.kill("SIGKILL")`
104
+ * alone is unreliable: `proc.killed` only records that a signal was *sent* (so a
105
+ * `!proc.killed` guard no-ops once SIGTERM was delivered), and a single-pid kill
106
+ * never reaches grandchildren (e.g. a `pi` child's own tool subprocesses).
107
+ */
108
+ export function killProcessTree(pid: number): void {
109
+ if (process.platform === "win32") {
110
+ try {
111
+ const tk = spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, windowsHide: true });
112
+ // An async spawn 'error' (e.g. taskkill missing, EACCES across a session boundary) is
113
+ // emitted on the ChildProcess — with no listener Node re-throws it as an UNCAUGHT
114
+ // exception that crashes the host. The try/catch only covers a synchronous throw.
115
+ tk.on("error", () => {});
116
+ tk.unref();
117
+ } catch {
118
+ /* ignore — best effort */
119
+ }
120
+ return;
121
+ }
122
+ try {
123
+ // Negative pid targets the whole process group (the child is spawned
124
+ // `detached`, so it is its own group leader).
125
+ process.kill(-pid, "SIGKILL");
126
+ } catch {
127
+ try {
128
+ process.kill(pid, "SIGKILL");
129
+ } catch {
130
+ /* already dead */
131
+ }
132
+ }
133
+ }
134
+
135
+ async function writeTempPrompt(content: string): Promise<{ path: string; cleanup: () => void }> {
136
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-persona-"));
137
+ const file = path.join(dir, "prompt.md");
138
+ const cleanup = () => {
139
+ try {
140
+ fs.rmSync(dir, { recursive: true, force: true });
141
+ } catch {
142
+ /* ignore */
143
+ }
144
+ };
145
+ try {
146
+ await fs.promises.writeFile(file, content, { encoding: "utf-8", mode: 0o600 });
147
+ } catch (err) {
148
+ // Don't leak the just-created temp dir if the write fails before we can
149
+ // hand the caller a cleanup handle.
150
+ cleanup();
151
+ throw err;
152
+ }
153
+ return { path: file, cleanup };
154
+ }
155
+
156
+ export async function runChildAgent(
157
+ spec: ChildRunSpec,
158
+ signal?: AbortSignal,
159
+ opts: ChildEngineOptions = {},
160
+ ): Promise<ChildRunResult> {
161
+ const resolveInvocation = opts.resolveInvocation ?? getPiInvocation;
162
+ const killGraceMs = opts.killGraceMs ?? 5000;
163
+ const forceKillTree = opts.killProcessTree ?? killProcessTree;
164
+ const maxStderrBytes = opts.maxStderrBytes ?? 256 * 1024;
165
+ const maxLineBytes = 1024 * 1024; // a single unterminated >1 MiB line is stream noise — loud failure, never a silent drop
166
+
167
+ const args: string[] = ["--mode", "json", "-p", "--no-session"];
168
+ if (spec.model) args.push("--model", spec.model);
169
+ if (spec.tools !== undefined) {
170
+ if (spec.tools.length > 0) args.push("--tools", spec.tools.join(","));
171
+ else args.push("--no-tools");
172
+ }
173
+ if (spec.excludeTools && spec.excludeTools.length > 0) args.push("--exclude-tools", spec.excludeTools.join(","));
174
+
175
+ let prompt: { path: string; cleanup: () => void } | undefined;
176
+ if (spec.systemPrompt?.trim()) {
177
+ prompt = await writeTempPrompt(spec.systemPrompt);
178
+ args.push("--append-system-prompt", prompt.path);
179
+ }
180
+ // The task goes over STDIN, not argv: `pi -p` prepends piped stdin to the initial
181
+ // message, and a flow-phase task (base task + every upstream phase's output) easily
182
+ // exceeds Windows' ~32 KiB command-line cap — as argv it would fail to spawn.
183
+ const taskInput = `Task: ${spec.task}`;
184
+
185
+ const state = createStreamState();
186
+ let stderr = "";
187
+ let stderrTruncated = false;
188
+ let aborted = false;
189
+ let timedOut = false;
190
+ let hardTimedOut = false;
191
+ let hardCapMs = 0; // the value that actually armed the hard cap (opts.hardTimeoutMs) — for the error message
192
+ let startupTimedOut = false;
193
+ let killSignal: NodeJS.Signals | undefined; // the signal that ended the child, if any (POSIX)
194
+ let progressed = false; // set once the child produces its FIRST real progress (turn/tokens/output)
195
+ let spawnError: Error | undefined;
196
+ let oversizedLine = false; // a >maxLineBytes unterminated line — loud failure, never a silent drop
197
+ let diedWithoutStatus = false; // 'close' with null code AND null signal — never a clean exit
198
+
199
+ try {
200
+ const exitCode = await new Promise<number>((resolveP) => {
201
+ const inv = resolveInvocation(args);
202
+ // A stale broker wiring in the SUPERVISOR's own environment must not leak into a
203
+ // plain child: PI_PERSONA_BUS would boot the child's bridge against a dead (or
204
+ // foreign) endpoint. The adapter re-adds these deliberately via opts.env when a
205
+ // broker is actually live for THIS run.
206
+ const inherited = { ...process.env };
207
+ delete inherited.PI_PERSONA_BUS;
208
+ delete inherited.PI_PERSONA_HANDLE;
209
+ delete inherited.PI_PERSONA_PEERS;
210
+ delete inherited.PI_PERSONA_ALLOW_BLOCKING;
211
+ const proc = spawn(inv.command, inv.args, {
212
+ cwd: spec.cwd ?? process.cwd(),
213
+ shell: false,
214
+ stdio: ["pipe", "pipe", "pipe"],
215
+ // POSIX: give the child its own process group so an escalated
216
+ // force-kill reaches its grandchildren too.
217
+ detached: process.platform !== "win32",
218
+ // CRITICAL: disable pi-persona inside the child so it is a pure executor
219
+ // (no persona restore, no input-hook orchestration, no `delegate` tool).
220
+ // Without this, a globally-installed pi-persona makes every child a
221
+ // supervisor that re-spawns → exponential fork bomb.
222
+ // PI_PERSONA_CHILD marks the child process; PI_PERSONA_LEG is the dedicated
223
+ // "delegated worker leg" marker a companion extension reads to tell a real leg from
224
+ // a user-set PI_PERSONA_DISABLE kill switch (see inproc.ts pushDisableGuard).
225
+ env: { ...inherited, PI_PERSONA_DISABLE: "1", PI_PERSONA_CHILD: "1", PI_PERSONA_LEG: "1", ...opts.env },
226
+ });
227
+
228
+ let settled = false;
229
+ let exited = false; // real liveness: set on 'close' (NOT proc.killed, which is "signal sent")
230
+ let killing = false;
231
+ let buffer = "";
232
+ let timer: ReturnType<typeof setTimeout> | undefined;
233
+ let hardTimer: ReturnType<typeof setTimeout> | undefined;
234
+ let startupTimer: ReturnType<typeof setTimeout> | undefined;
235
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
236
+ let lastResortTimer: ReturnType<typeof setTimeout> | undefined;
237
+
238
+ // Calls whose start crossed the progress seam but whose end has not. The tool NAME is kept
239
+ // beside the id because a synthetic close in finish() has to carry it.
240
+ const openToolCalls = new Map<string, string>();
241
+
242
+ const onLine = (line: string) => {
243
+ if (!line.trim()) return;
244
+ try {
245
+ const toolEvent = applyEvent(state, JSON.parse(line));
246
+ // Ordinary stream progress stays coalesced to one snapshot per stdout chunk below.
247
+ // Tool lifecycle transitions are the exception: several can share one chunk and each
248
+ // authoritative transition must cross the progress seam exactly once.
249
+ if (toolEvent) {
250
+ if (toolEvent.phase === "start") openToolCalls.set(toolEvent.callId, toolEvent.name);
251
+ else openToolCalls.delete(toolEvent.callId);
252
+ opts.onProgress?.(snapshot(state, toolEvent));
253
+ }
254
+ } catch {
255
+ /* ignore non-JSON noise */
256
+ }
257
+ };
258
+
259
+ // Graceful SIGTERM, then escalate to a force tree-kill if the child has
260
+ // not actually exited after the grace window. Idempotent and timer-safe.
261
+ const kill = () => {
262
+ if (killing) return;
263
+ killing = true;
264
+ if (timer) clearTimeout(timer);
265
+ if (hardTimer) clearTimeout(hardTimer);
266
+ if (startupTimer) clearTimeout(startupTimer);
267
+ const pid = proc.pid;
268
+ // POSIX: the child is a process-GROUP leader (detached above), so signal the
269
+ // whole group — its own tool subprocesses (grandchildren) must die with it,
270
+ // not survive as orphans holding the workspace.
271
+ const signalGroup = (sig: NodeJS.Signals): boolean => {
272
+ if (process.platform === "win32" || pid === undefined) return false;
273
+ try {
274
+ process.kill(-pid, sig);
275
+ return true;
276
+ } catch {
277
+ return false; // the group is already gone
278
+ }
279
+ };
280
+ // Last resort: if NEITHER 'close' nor 'error' ever arrives after a kill (a
281
+ // wedged process handle, a failed tree-kill), settle the run anyway — an
282
+ // unsettled promise here hangs the whole strategy.
283
+ lastResortTimer = setTimeout(() => {
284
+ if (!settled) finish(1);
285
+ }, killGraceMs + 5000);
286
+ lastResortTimer.unref?.();
287
+ // On Windows, proc.kill("SIGTERM") maps to TerminateProcess: it kills ONLY the root
288
+ // and fires `close` synchronously, which clears the grace timer before it can run —
289
+ // so the tree-kill never fires and the child's own tool subprocesses are orphaned.
290
+ // Go straight to the force tree-kill (taskkill /F /T) there.
291
+ if (process.platform === "win32") {
292
+ if (pid !== undefined) forceKillTree(pid);
293
+ return;
294
+ }
295
+ if (!signalGroup("SIGTERM")) {
296
+ try {
297
+ proc.kill("SIGTERM");
298
+ } catch {
299
+ /* ignore */
300
+ }
301
+ }
302
+ graceTimer = setTimeout(() => {
303
+ if (!exited && pid !== undefined) {
304
+ forceKillTree(pid);
305
+ signalGroup("SIGKILL"); // belt-and-braces for grandchildren the tree-kill missed
306
+ }
307
+ }, killGraceMs);
308
+ graceTimer.unref?.();
309
+ };
310
+
311
+ const onAbort = () => {
312
+ aborted = true;
313
+ kill();
314
+ };
315
+
316
+ // Resolve exactly once; flush the trailing partial line + a final
317
+ // progress tick, and detach the abort listener so a shared/long-lived
318
+ // AbortSignal doesn't accumulate dead `kill` closures.
319
+ const finish = (code: number) => {
320
+ if (settled) return;
321
+ settled = true;
322
+ if (timer) clearTimeout(timer);
323
+ if (hardTimer) clearTimeout(hardTimer);
324
+ if (startupTimer) clearTimeout(startupTimer);
325
+ if (graceTimer) clearTimeout(graceTimer);
326
+ if (lastResortTimer) clearTimeout(lastResortTimer);
327
+ if (signal) signal.removeEventListener("abort", onAbort);
328
+ // Close can beat the last `data` tick's oversized check (a child that
329
+ // dumps one huge unterminated write and exits). Treat that remainder
330
+ // as stream noise too — never parse it as a trailing line and "succeed".
331
+ if (buffer.length > maxLineBytes) {
332
+ oversizedLine = true;
333
+ buffer = "";
334
+ } else if (buffer.trim()) onLine(buffer);
335
+ // A killed child (UI stop, idle/hard/startup deadline) never emits the
336
+ // tool_execution_end for whatever it was running, so close each abandoned call here —
337
+ // exactly once, mirroring what applyEvent would have produced. Without it the consumer
338
+ // shows that tool "running" forever and its telemetry record is pinned by the replay
339
+ // seed. A child that ended normally has nothing open, so nothing is double-closed.
340
+ for (const [callId, name] of openToolCalls) opts.onProgress?.(snapshot(state, { phase: "end", callId, name, failed: true }));
341
+ openToolCalls.clear();
342
+ opts.onProgress?.(snapshot(state));
343
+ resolveP(code);
344
+ };
345
+
346
+ // Idle timeout: a child that emits NOTHING for `timeoutMs` is treated as hung
347
+ // and killed — but any output (re)arms the timer, so a long-but-*active* agent
348
+ // (streaming turn/tool events) keeps running.
349
+ // Hard wall-clock cap: armed ONCE, never re-armed by output — a definite lifetime ceiling
350
+ // that kills a busy-but-non-converging child the idle window above never catches.
351
+ const armHardCap = () => {
352
+ const capMs = opts.hardTimeoutMs && opts.hardTimeoutMs > 0 ? opts.hardTimeoutMs : 0;
353
+ if (capMs <= 0 || settled || killing) return;
354
+ hardCapMs = capMs;
355
+ hardTimer = setTimeout(() => {
356
+ hardTimedOut = true;
357
+ kill();
358
+ }, capMs);
359
+ hardTimer.unref?.();
360
+ };
361
+ const armTimeout = () => {
362
+ if (!opts.timeoutMs || opts.timeoutMs <= 0 || settled || killing) return;
363
+ if (timer) clearTimeout(timer);
364
+ timer = setTimeout(() => {
365
+ // The bus, not this local flag, is the source of truth for "still blocked": a
366
+ // child mid-ask legitimately emits nothing, and only the supervisor's bus knows
367
+ // whether that ask is still outstanding right now.
368
+ if (opts.isBlocked?.()) {
369
+ armTimeout();
370
+ return;
371
+ }
372
+ timedOut = true;
373
+ kill();
374
+ }, opts.timeoutMs);
375
+ timer.unref?.();
376
+ };
377
+ // Startup deadline: armed ONCE at spawn, cancelled the instant the child makes real
378
+ // progress. Fires for a child that has produced no assistant output at all (no completed
379
+ // turn / tokens / streamed text) — the header line + bare turn_start noise do NOT count,
380
+ // so a stalled init is caught while a slow-but-streaming turn survives. It cannot see
381
+ // the difference between a hung init and a first provider response that simply hasn't
382
+ // arrived, so the window has to be sized for the slowest acceptable cold start.
383
+ const armStartup = () => {
384
+ if (!opts.startupTimeoutMs || opts.startupTimeoutMs <= 0 || settled || killing) return;
385
+ startupTimer = setTimeout(() => {
386
+ if (progressed) return;
387
+ if (opts.isBlocked?.()) {
388
+ armStartup();
389
+ return;
390
+ }
391
+ startupTimedOut = true;
392
+ kill();
393
+ }, opts.startupTimeoutMs);
394
+ startupTimer.unref?.();
395
+ };
396
+ const noteProgress = () => {
397
+ if (progressed) return;
398
+ const s = snapshot(state);
399
+ if (s.turns > 0 || s.tokens > 0 || s.output.length > 0) {
400
+ progressed = true;
401
+ if (startupTimer) clearTimeout(startupTimer);
402
+ }
403
+ };
404
+
405
+ // Deliver the task and close stdin (pi waits for EOF before starting). A child
406
+ // that dies before reading (spawn failure, early exit) emits an async 'error'
407
+ // (EPIPE) on stdin — tolerate it, the exit path reports the real cause.
408
+ proc.stdin?.on("error", () => {});
409
+ proc.stdin?.end(taskInput);
410
+
411
+ proc.stdout?.setEncoding("utf8");
412
+ proc.stdout?.on("data", (d: string) => {
413
+ if (oversizedLine) return; // already failing loudly — stop processing the flood
414
+ const { lines, rest } = feedLines(buffer, d);
415
+ // An unterminated line over maxLineBytes is stream noise (or a stuck flood),
416
+ // never a leg's real output. Dropping it silently would let a flooded leg
417
+ // "succeed" on truncated output — kill the child and fail LOUDLY instead.
418
+ if (rest.length > maxLineBytes) {
419
+ oversizedLine = true;
420
+ buffer = "";
421
+ kill();
422
+ return;
423
+ }
424
+ buffer = rest;
425
+ for (const l of lines) onLine(l);
426
+ noteProgress(); // first real progress cancels the startup deadline
427
+ opts.onProgress?.(snapshot(state));
428
+ armTimeout(); // output → reset the idle clock
429
+ });
430
+ // A stream 'error' (e.g. EPIPE on a dying child) on an emitter with no
431
+ // listener is rethrown as an uncaught exception — tolerate it.
432
+ proc.stdout?.on("error", () => {});
433
+
434
+ proc.stderr?.on("data", (d: Buffer) => {
435
+ if (stderr.length >= maxStderrBytes) return;
436
+ const chunk = d.toString();
437
+ const room = maxStderrBytes - stderr.length;
438
+ if (chunk.length > room) {
439
+ stderr += chunk.slice(0, room);
440
+ if (!stderrTruncated) {
441
+ stderr += "\n…[stderr truncated]";
442
+ stderrTruncated = true;
443
+ }
444
+ } else {
445
+ stderr += chunk;
446
+ }
447
+ });
448
+ proc.stderr?.on("error", () => {});
449
+
450
+ proc.on("close", (code, sig) => {
451
+ exited = true;
452
+ // POSIX signal death closes with code=null. Mapping that to 0 would report a
453
+ // child killed by something OUTSIDE this engine (OOM killer, `kill -9`, shutdown)
454
+ // as a clean success carrying its truncated mid-run output — none of the
455
+ // abort/timeout flags are set for those, so nothing else would catch it.
456
+ // A null code WITHOUT a signal is just as abnormal (a wedged spawn): never 0.
457
+ if (sig) killSignal = sig;
458
+ if (code === null && !sig) diedWithoutStatus = true;
459
+ finish(code ?? 1);
460
+ });
461
+ // A spawn failure (e.g. ENOENT: `pi` not on PATH) must not be silently
462
+ // folded into a bare exit code — capture it so it surfaces in errorMessage.
463
+ proc.on("error", (err: Error) => {
464
+ spawnError = err;
465
+ finish(1);
466
+ });
467
+
468
+ armTimeout(); // start the idle clock (reset on every chunk of output)
469
+ armHardCap(); // start the lifetime ceiling (never reset)
470
+ armStartup(); // start the first-progress deadline (cancelled by the first real progress)
471
+ if (signal) {
472
+ if (signal.aborted) onAbort();
473
+ else signal.addEventListener("abort", onAbort, { once: true });
474
+ }
475
+ });
476
+
477
+ const ok =
478
+ !aborted &&
479
+ !timedOut &&
480
+ !hardTimedOut &&
481
+ !startupTimedOut &&
482
+ !oversizedLine &&
483
+ !diedWithoutStatus &&
484
+ exitCode === 0 &&
485
+ state.stopReason !== "error" &&
486
+ state.stopReason !== "aborted";
487
+ const result: ChildRunResult = {
488
+ ok,
489
+ output: state.output,
490
+ usage: state.usage,
491
+ exitCode,
492
+ stderr,
493
+ aborted,
494
+ // A hard-cap OR startup-deadline kill is a timeout-class death (never a provider reroute) —
495
+ // surface it via the same flag the idle timeout uses; the errorMessage below says WHICH fired.
496
+ timedOut: timedOut || hardTimedOut || startupTimedOut,
497
+ };
498
+ if (state.model !== undefined) result.model = state.model;
499
+ if (state.stopReason !== undefined) result.stopReason = state.stopReason;
500
+ // errorMessage precedence: a timeout/abort is the *cause of death*, so label
501
+ // it explicitly and fold in any stream-level error rather than letting the
502
+ // stream message hide why the child actually stopped.
503
+ const streamErr = state.errorMessage;
504
+ if (hardTimedOut) {
505
+ result.errorMessage = `agent exceeded the ${hardCapMs}ms hard cap${streamErr ? ` (last error: ${streamErr})` : ""}`;
506
+ } else if (startupTimedOut) {
507
+ // Two readings fit the same evidence and the engine cannot distinguish them, so name
508
+ // both: claiming "it never started" would misdirect the operator of a leg that did
509
+ // start and was waiting on a slow first response.
510
+ result.errorMessage = `agent produced no progress within the ${opts.startupTimeoutMs}ms startup window — either it never started (a stalled init, e.g. an MCP adapter hanging) or its first provider response was slower than the window (a queued or rate-limited provider, a cold local model); raise or disable the window with PI_PERSONA_AGENT_STARTUP_MS (0 disables)${streamErr ? ` (last error: ${streamErr})` : ""}`;
511
+ } else if (timedOut) {
512
+ result.errorMessage = `agent timed out — no output for ${opts.timeoutMs}ms${streamErr ? ` (last error: ${streamErr})` : ""}`;
513
+ } else if (aborted) {
514
+ result.errorMessage = `agent aborted${streamErr ? ` (last error: ${streamErr})` : ""}`;
515
+ } else if (oversizedLine) {
516
+ result.errorMessage = `agent emitted an unterminated line over ${maxLineBytes} bytes — killed as stream noise (a flooded leg fails loudly; it never "succeeds" on truncated output)`;
517
+ } else if (diedWithoutStatus) {
518
+ result.errorMessage = `agent process closed without an exit code or signal${streamErr ? ` (last error: ${streamErr})` : ""}`;
519
+ } else if (spawnError) {
520
+ result.errorMessage = `failed to spawn pi: ${spawnError.message}`;
521
+ } else if (killSignal) {
522
+ // Reached only for a kill this engine did NOT initiate — the branches above own
523
+ // every engine-initiated one and say which deadline fired.
524
+ result.errorMessage = `agent process was killed by ${killSignal}${streamErr ? ` (last error: ${streamErr})` : ""}`;
525
+ } else if (streamErr !== undefined) {
526
+ result.errorMessage = streamErr;
527
+ }
528
+ return result;
529
+ } finally {
530
+ prompt?.cleanup();
531
+ }
532
+ }