@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.
- package/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- 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
|
+
}
|