@pify/swarm 0.9.2 → 0.11.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.
package/src/builtin.ts CHANGED
@@ -16,8 +16,18 @@ You are a disciplined review subagent. Inspect, evaluate, and report findings
16
16
  with evidence — never guess; verify from the code itself. You cannot modify
17
17
  anything: your deliverable is the report.
18
18
 
19
- For each finding give: file:line, what is wrong, why it matters, and a
20
- concrete suggestion. Rank findings by severity. If the code is fine, say so
19
+ Flag only defects you can prove: a concrete, introduced problem with a real
20
+ impact you can name a bug, a broken contract, data loss, a security hole. Do
21
+ not report style, taste, or hypotheticals; "could theoretically" is not a
22
+ finding. Weight the review on:
23
+ - correctness: logic errors, wrong edge cases, broken or unhandled contracts;
24
+ - untrusted input reaching a dangerous sink: SQL/command injection, path
25
+ traversal, SSRF, open redirect, unsafe deserialization, missing authz;
26
+ - clean code, but only where it bites: needless duplication, dead code, an
27
+ abstraction that hides a real bug — never mere preference.
28
+
29
+ For each finding give: file:line, what is wrong, why it matters (the concrete
30
+ impact), and a concrete fix. Rank by severity. If the code is sound, say so
21
31
  plainly — do not invent issues. End with a one-paragraph verdict.`,
22
32
 
23
33
  scout: `---
@@ -33,11 +33,13 @@ export function parseAgentFile(
33
33
 
34
34
  const thinkingRaw = fields.get("thinking")?.toLowerCase();
35
35
  const maxTurnsRaw = Number.parseInt(fields.get("max_turns") ?? "", 10);
36
+ const tools = parseTools(fields.get("tools"));
37
+ if (tools === null) return null;
36
38
 
37
39
  return {
38
40
  name: name.toLowerCase(),
39
41
  description,
40
- tools: parseTools(fields.get("tools")),
42
+ tools,
41
43
  model: fields.get("model") || null,
42
44
  thinking: (THINKING_LEVELS as readonly string[]).includes(thinkingRaw ?? "")
43
45
  ? (thinkingRaw as ThinkingLevelName)
@@ -64,11 +66,20 @@ function parseList(raw: string | undefined): string[] {
64
66
  .filter(Boolean);
65
67
  }
66
68
 
67
- function parseTools(raw: string | undefined): ValidTool[] {
69
+ /**
70
+ * Read-only default keeps a def missing `tools:` from mutating anything.
71
+ * A `tools:` line where NOTHING resolves is different: the author asked for
72
+ * a specific tool set and got the read-only default instead, so the agent
73
+ * runs with a contract nobody wrote. That is rejected — the file is dropped,
74
+ * rather than quietly running as something else. (Same rule as @pify/subagent.)
75
+ */
76
+ function parseTools(raw: string | undefined): ValidTool[] | null {
68
77
  if (!raw) return ["read", "grep", "find", "ls"];
69
- const valid = raw
78
+ const requested = raw
70
79
  .split(",")
71
80
  .map((t) => t.trim().toLowerCase())
72
- .filter((t): t is ValidTool => (VALID_TOOLS as readonly string[]).includes(t));
73
- return valid.length > 0 ? valid : ["read", "grep", "find", "ls"];
81
+ .filter(Boolean);
82
+ if (requested.length === 0) return ["read", "grep", "find", "ls"];
83
+ const valid = requested.filter((t): t is ValidTool => (VALID_TOOLS as readonly string[]).includes(t));
84
+ return valid.length > 0 ? valid : null;
74
85
  }
package/src/gate.ts ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * What a gate actually proved.
3
+ *
4
+ * A gate exists so a step is verified by running something rather than by a
5
+ * model saying it went well. Judging that run by its exit code alone leaves a
6
+ * hole big enough to drive a workflow through: a command that never ran the
7
+ * check still exits 0. A mistyped script name under `sh -c`, a test runner
8
+ * that matched no tests, a `|| true` someone left in — each one reports
9
+ * success while proving nothing.
10
+ *
11
+ * So a gate may state what success looks like. When it does, exiting 0
12
+ * without that evidence is its own outcome (`result_missing`) rather than a
13
+ * pass. The vocabulary is FradSer/pi-monitor's result contract.
14
+ *
15
+ * The same distinction runs one step further. A check that ran and said no is
16
+ * evidence; a check that could not run at all is *not evidence of anything*.
17
+ * A misspelled command, a runner that is not installed, a gate whose own regex
18
+ * does not compile — none of those are the code failing, and reporting them as
19
+ * `failure` sends the reader looking for a bug in the work instead of a typo
20
+ * in the gate. That case is `no_attestation`: still not a pass, but honest
21
+ * about having proved nothing either way.
22
+ */
23
+
24
+ import { spawn, type ChildProcess } from "node:child_process";
25
+
26
+ export type GateOutcome =
27
+ | "success"
28
+ | "failure"
29
+ | "result_missing"
30
+ | "timeout"
31
+ | "no_attestation";
32
+
33
+ export interface GateContract {
34
+ /** The command to run. */
35
+ command: string;
36
+ /** Regex source: success requires a match in the combined output. */
37
+ expect?: string;
38
+ /** Regex source: a match means failure even when the command exits 0. */
39
+ failure?: string;
40
+ timeoutMs?: number;
41
+ }
42
+
43
+ export interface GateRun {
44
+ /** Exit status, or null when the process was killed (timeout/signal). */
45
+ status: number | null;
46
+ /** Signal that killed it, when one did. */
47
+ signal?: string | null;
48
+ output: string;
49
+ /** True when the runner stopped it at the timeout. */
50
+ timedOut?: boolean;
51
+ /**
52
+ * The command never became a process — it could not be spawned, the shell
53
+ * was missing, the working directory was gone. Not a verdict on the work.
54
+ */
55
+ spawnError?: string;
56
+ }
57
+
58
+ export interface GateVerdict {
59
+ outcome: GateOutcome;
60
+ ok: boolean;
61
+ /** One line for the run log, in this package's words. */
62
+ reason: string;
63
+ }
64
+
65
+ /** Accept a bare command string or a full contract. */
66
+ export function normalizeGate(gate: string | GateContract): GateContract {
67
+ return typeof gate === "string" ? { command: gate } : gate;
68
+ }
69
+
70
+ function compile(source: string | undefined): RegExp | null {
71
+ if (!source) return null;
72
+ try {
73
+ return new RegExp(source, "m");
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Judge a finished gate run. Order matters: a timeout is a timeout whatever
81
+ * else happened, an explicit failure pattern beats a zero exit, and a missing
82
+ * success pattern is never a pass.
83
+ */
84
+ export function evaluateGate(contract: GateContract, run: GateRun): GateVerdict {
85
+ if (run.spawnError) {
86
+ return {
87
+ outcome: "no_attestation",
88
+ ok: false,
89
+ reason: `gate never ran (${run.spawnError}) — nothing was proved either way`,
90
+ };
91
+ }
92
+
93
+ // A timeout is a real verdict: the check was given its deadline and did not
94
+ // clear it. That is different from the case below.
95
+ if (run.timedOut || (run.status === null && run.signal)) {
96
+ return {
97
+ outcome: "timeout",
98
+ ok: false,
99
+ reason: `gate timed out after ${contract.timeoutMs ?? "the default"}ms`,
100
+ };
101
+ }
102
+
103
+ // Neither an exit code nor a signal: the process did not run to a verdict
104
+ // and nothing killed it, so there is no result to report as one.
105
+ if (run.status === null) {
106
+ return {
107
+ outcome: "no_attestation",
108
+ ok: false,
109
+ reason: "gate produced no exit status — nothing was proved either way",
110
+ };
111
+ }
112
+
113
+ const failurePattern = compile(contract.failure);
114
+ if (failurePattern && failurePattern.test(run.output)) {
115
+ return {
116
+ outcome: "failure",
117
+ ok: false,
118
+ reason: `gate output matched its failure pattern /${contract.failure}/`,
119
+ };
120
+ }
121
+
122
+ if (run.status !== 0) {
123
+ return { outcome: "failure", ok: false, reason: `gate exited ${run.status}` };
124
+ }
125
+
126
+ const expectPattern = compile(contract.expect);
127
+ if (expectPattern && !expectPattern.test(run.output)) {
128
+ // The hole this closes: the command ran, said nothing that proves the
129
+ // check happened, and exited 0.
130
+ return {
131
+ outcome: "result_missing",
132
+ ok: false,
133
+ reason: `gate exited 0 but its output never matched /${contract.expect}/ — nothing was verified`,
134
+ };
135
+ }
136
+
137
+ return {
138
+ outcome: "success",
139
+ ok: true,
140
+ reason: expectPattern ? `gate passed and matched /${contract.expect}/` : "gate exited 0",
141
+ };
142
+ }
143
+
144
+ /** The part of a call a gate needs in order to know who else was in the room. */
145
+ export interface GateSibling {
146
+ id: number;
147
+ label: string;
148
+ status: string;
149
+ workDir?: string;
150
+ }
151
+
152
+ /**
153
+ * Which other calls were live in the same working directory while this gate
154
+ * ran — the ones that make its verdict unattributable.
155
+ *
156
+ * Only concurrency in the *same* directory counts. Two agents under
157
+ * `isolation: "worktree"` have their own checkouts and cannot disturb each
158
+ * other, which is exactly why isolation is the fix rather than a warning.
159
+ */
160
+ export function sharedWith(
161
+ self: GateSibling,
162
+ subject: string,
163
+ siblings: readonly GateSibling[],
164
+ ): string[] {
165
+ return siblings
166
+ .filter((s) => s.id !== self.id && s.status === "running" && (s.workDir ?? subject) === subject)
167
+ .map((s) => s.label);
168
+ }
169
+
170
+ /**
171
+ * One line saying what a verdict is worth, given who else was editing.
172
+ * A pass earned over a tree two other agents were changing is reported as what
173
+ * it is: true of the tree, not of this agent's work.
174
+ */
175
+ export function attributionNote(record: {
176
+ ok: boolean;
177
+ sharedWith: readonly string[];
178
+ }): string | null {
179
+ if (record.sharedWith.length === 0) return null;
180
+ const others = record.sharedWith.join(", ");
181
+ return record.ok
182
+ ? `judged a directory ${others} ${record.sharedWith.length === 1 ? "was" : "were"} also changing — true of the tree, not of this agent's work alone`
183
+ : `judged a directory ${others} ${record.sharedWith.length === 1 ? "was" : "were"} also changing — the cause may not be this agent's work`;
184
+ }
185
+
186
+ /** An unparseable pattern is a broken contract, not a passing one. */
187
+ export function contractProblems(contract: GateContract): string[] {
188
+ const problems: string[] = [];
189
+ if (!contract.command.trim()) problems.push("gate has no command");
190
+ for (const [field, source] of [
191
+ ["expect", contract.expect],
192
+ ["failure", contract.failure],
193
+ ] as const) {
194
+ if (source && !compile(source)) problems.push(`gate ${field} is not a valid regular expression`);
195
+ }
196
+ if (contract.timeoutMs !== undefined && !(contract.timeoutMs > 0)) {
197
+ problems.push("gate timeoutMs must be positive");
198
+ }
199
+ return problems;
200
+ }
201
+
202
+ /** How long a gate may run before the deadline is its verdict. */
203
+ export const GATE_TIMEOUT_MS = 120_000;
204
+
205
+ /**
206
+ * Output kept from a gate. A real suite prints books; the verdict is at the
207
+ * end, so it is the tail that is kept when a gate prints past this.
208
+ */
209
+ const GATE_MAX_OUTPUT = 16 * 1024 * 1024;
210
+
211
+ /**
212
+ * Run a gate and judge it against its contract, with the shell in the subject
213
+ * working directory. A bare string keeps the exit-code meaning; a contract can
214
+ * also say what success has to look like, which is what stops a command that
215
+ * never ran the check from passing.
216
+ *
217
+ * Asynchronous, and that is load-bearing. A gate is a test suite or a build,
218
+ * and the first version ran it with spawnSync — which held pi's whole event
219
+ * loop for the duration: nothing rendered, Esc could not be delivered, and
220
+ * every other child's provider stream sat unread until the gate returned,
221
+ * up to the full deadline. The deadline is enforced here (the shell is
222
+ * killed on timeout) and output is capped rather than erroring, so a
223
+ * chatty-but-passing gate still passes.
224
+ *
225
+ * The command comes from the caller — the same trust level as the bash tool in
226
+ * this session — so this adds no capability the caller did not already have.
227
+ */
228
+ export function runGate(gate: string | GateContract, cwd: string): Promise<GateVerdict & { output: string }> {
229
+ const contract = normalizeGate(gate);
230
+ const problems = contractProblems(contract);
231
+ if (problems.length > 0) {
232
+ // A gate that cannot be run is not a verdict on the work. Calling this a
233
+ // failure would report a typo in the gate as a defect in the code.
234
+ return Promise.resolve({ outcome: "no_attestation", ok: false, reason: problems.join("; "), output: "" });
235
+ }
236
+ const timeoutMs = contract.timeoutMs ?? GATE_TIMEOUT_MS;
237
+
238
+ return new Promise((resolve) => {
239
+ let child: ChildProcess;
240
+ try {
241
+ child = spawn(contract.command, {
242
+ shell: true,
243
+ cwd,
244
+ windowsHide: true,
245
+ stdio: ["ignore", "pipe", "pipe"],
246
+ // POSIX: lead a process group, so a timeout can kill the whole tree
247
+ // and not just the shell that started it.
248
+ detached: process.platform !== "win32",
249
+ });
250
+ } catch (err) {
251
+ resolve({
252
+ outcome: "no_attestation",
253
+ ok: false,
254
+ reason: `gate could not be started: ${err instanceof Error ? err.message : String(err)}`,
255
+ output: "",
256
+ });
257
+ return;
258
+ }
259
+
260
+ // stdout and stderr in arrival order — how a person would have read the
261
+ // terminal — trimmed from the front once past the cap.
262
+ const chunks: Buffer[] = [];
263
+ let size = 0;
264
+ const keep = (chunk: Buffer): void => {
265
+ chunks.push(chunk);
266
+ size += chunk.length;
267
+ while (size > GATE_MAX_OUTPUT && chunks.length > 1) size -= chunks.shift()!.length;
268
+ };
269
+ child.stdout?.on("data", keep);
270
+ child.stderr?.on("data", keep);
271
+
272
+ let timedOut = false;
273
+ const timer = setTimeout(() => {
274
+ timedOut = true;
275
+ killTree(child);
276
+ }, timeoutMs);
277
+
278
+ let settled = false;
279
+ let exited: { status: number | null; signal: string | null } | null = null;
280
+ const finish = (spawnError?: string): void => {
281
+ if (settled) return;
282
+ settled = true;
283
+ clearTimeout(timer);
284
+ const output = Buffer.concat(chunks).toString("utf8").trim();
285
+ resolve({
286
+ ...evaluateGate(
287
+ { ...contract, timeoutMs },
288
+ { status: exited?.status ?? null, signal: exited?.signal ?? null, output, timedOut, spawnError },
289
+ ),
290
+ output,
291
+ });
292
+ };
293
+ // A failure to start arrives as an event, not a throw. After a timeout
294
+ // kill, an error is the kill's doing, not a spawn failure.
295
+ child.on("error", (err) => {
296
+ exited ??= { status: null, signal: null };
297
+ finish(timedOut ? undefined : err.message);
298
+ });
299
+ // `close` waits for the pipes, which a grandchild that outlived the shell
300
+ // can hold open indefinitely; `exit` is the shell's own verdict. Wait for
301
+ // the pipes briefly so a normal exit keeps all of its output, then finish
302
+ // regardless — a gate must never hang a run past its deadline.
303
+ child.on("close", (status, signal) => {
304
+ exited ??= { status, signal };
305
+ finish();
306
+ });
307
+ child.on("exit", (status, signal) => {
308
+ exited = { status, signal };
309
+ const grace = setTimeout(() => finish(), EXIT_DRAIN_MS);
310
+ grace.unref?.();
311
+ });
312
+ });
313
+ }
314
+
315
+ /** How long to wait for a gate's pipes after its shell has exited. */
316
+ const EXIT_DRAIN_MS = 500;
317
+
318
+ /**
319
+ * Kill a gate's whole process tree. The shell alone dying leaves the test
320
+ * suite it started running — on Windows `child.kill()` reaches only cmd.exe,
321
+ * and on POSIX only the shell — so a deadline that merely killed the shell
322
+ * would report a timeout while the suite ran on, holding the pipes open.
323
+ */
324
+ function killTree(child: ChildProcess): void {
325
+ const pid = child.pid;
326
+ if (pid === undefined) return;
327
+ if (process.platform === "win32") {
328
+ // By absolute path: PATH is the user's, and a gate has run under odd ones.
329
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
330
+ try {
331
+ spawn(taskkill, ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }).on("error", () => {});
332
+ } catch {
333
+ // taskkill unavailable: the plain kill below is all that is left
334
+ }
335
+ try {
336
+ child.kill();
337
+ } catch {
338
+ // already gone
339
+ }
340
+ return;
341
+ }
342
+ try {
343
+ process.kill(-pid, "SIGKILL");
344
+ } catch {
345
+ try {
346
+ child.kill("SIGKILL");
347
+ } catch {
348
+ // already gone
349
+ }
350
+ }
351
+ }
package/src/isolate.ts CHANGED
@@ -33,7 +33,12 @@ export function sanitizeSlug(raw: string): string {
33
33
  return slug || "run";
34
34
  }
35
35
 
36
- export function createIsolationWorktree(cwd: string, rawSlug: string): Isolation {
36
+ /** Where isolation worktrees live by default: ~/.worktrees. */
37
+ export function defaultWorktreeRoot(): string {
38
+ return join(homedir(), ".worktrees");
39
+ }
40
+
41
+ export function createIsolationWorktree(cwd: string, rawSlug: string, root: string = defaultWorktreeRoot()): Isolation {
37
42
  let toplevel: string;
38
43
  try {
39
44
  toplevel = git(cwd, ["rev-parse", "--show-toplevel"]);
@@ -44,11 +49,11 @@ export function createIsolationWorktree(cwd: string, rawSlug: string): Isolation
44
49
  const slug = sanitizeSlug(rawSlug);
45
50
 
46
51
  let branch = `agent/${slug}`;
47
- let path = join(homedir(), ".worktrees", repo, slug);
52
+ let path = join(root, repo, slug);
48
53
  let counter = 2;
49
54
  while (existsSync(path) || branchExists(cwd, branch)) {
50
55
  branch = `agent/${slug}-${counter}`;
51
- path = join(homedir(), ".worktrees", repo, `${slug}-${counter}`);
56
+ path = join(root, repo, `${slug}-${counter}`);
52
57
  counter++;
53
58
  if (counter > 50) throw new Error("Could not find a free worktree slot.");
54
59
  }
@@ -71,15 +76,70 @@ function branchExists(cwd: string, branch: string): boolean {
71
76
  }
72
77
  }
73
78
 
74
- /** Note appended to a child's report when it ran isolated. */
79
+ /**
80
+ * Note appended to a child's report when it ran isolated and left work
81
+ * behind. That work is UNCOMMITTED unless the child chose to commit — the
82
+ * builtin worker never does — and @pify/worktree's worktree_merge refuses a
83
+ * dirty tree, so the old note ("merge with worktree_merge") sent the model to
84
+ * a tool that would turn it away. Say what state the tree is in and what to
85
+ * do about it.
86
+ */
75
87
  export function isolationNote(isolation: Isolation): string {
88
+ const at = `git -C "${isolation.path}"`;
76
89
  return [
77
- `Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}).`,
78
- `The main checkout is untouched. Merge with @pify/worktree's worktree_merge branch="${isolation.branch}",`,
79
- `or inspect: cd "${isolation.path}" && git log --stat`,
90
+ `Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}); the main checkout is untouched.`,
91
+ `Its changes are in that worktree, uncommitted unless the child committed them. To bring them back:`,
92
+ ` review: ${at} status && ${at} diff`,
93
+ ` commit: ${at} add -A && ${at} commit -m "<what changed>"`,
94
+ ` merge: @pify/worktree's worktree_merge branch="${isolation.branch}" (refuses an uncommitted tree)`,
95
+ `or discard it: git worktree remove --force "${isolation.path}".`,
80
96
  ].join("\n");
81
97
  }
82
98
 
99
+ /** Note when an isolated run changed nothing and its worktree was removed. */
100
+ export const CLEAN_WORKTREE_NOTE =
101
+ "Ran isolated in a temporary worktree; it changed nothing, so the worktree was removed.";
102
+
103
+ /**
104
+ * The fields the isolation epilogue writes onto a call record. Kept structural
105
+ * so isolate.ts stays free of any dependency on the extension's types — the
106
+ * run's AgentCallState satisfies it by shape.
107
+ */
108
+ export interface IsolationSink {
109
+ worktree?: string;
110
+ branch?: string;
111
+ }
112
+
113
+ /**
114
+ * Close out an isolated child's worktree exactly once, whatever its outcome.
115
+ *
116
+ * This is the epilogue EVERY terminal path of a child call must reach —
117
+ * success, schema mismatch, gate failure, abort, or a thrown error. Before,
118
+ * only the prose success path ran it, so a `schema:` step (or any failure or
119
+ * abort) left its worktree and branch behind forever. It runs
120
+ * removeIfUnchanged — a read-only step's worktree is deleted, a step that did
121
+ * work is kept — and when the worktree is kept it records where the edits live
122
+ * on the call so a non-prose result (a schema object, a null from an error)
123
+ * can still name the location instead of orphaning it.
124
+ *
125
+ * Returns the human note for callers that render prose; callers on non-prose
126
+ * paths rely on the pointer written to `sink`. Never throws (removeIfUnchanged
127
+ * already swallows its own failures): the cleanup must not sink a run.
128
+ */
129
+ export function settleWorktree(
130
+ cwd: string,
131
+ isolation: Isolation,
132
+ sink: IsolationSink,
133
+ ): { removed: boolean; note: string } {
134
+ const removed = removeIfUnchanged(cwd, isolation);
135
+ if (removed) return { removed: true, note: CLEAN_WORKTREE_NOTE };
136
+ // Kept: there is work to merge. Record the pointer so the location survives
137
+ // on the call record even when the returned value is data or null.
138
+ sink.worktree = isolation.path;
139
+ sink.branch = isolation.branch;
140
+ return { removed: false, note: isolationNote(isolation) };
141
+ }
142
+
83
143
  /**
84
144
  * Remove a worktree the child left untouched. An isolated run that changed
85
145
  * nothing is the common case — a review, a search, a question — and keeping
package/src/outcome.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * "It finished" and "it worked" are different facts.
3
+ *
4
+ * RunStatus answers the first one: did the child session run to the end, error
5
+ * out, or get stopped. It says nothing about whether the task was actually
6
+ * accomplished, so a child that hits its brief's wall at turn three and writes
7
+ * a polite report explaining why is recorded exactly like one that shipped the
8
+ * feature — `done`. The caller then has to read prose to find out which.
9
+ *
10
+ * So record the task outcome separately, and record *how well it is known*
11
+ * separately again. A gate that ran and failed is evidence and overrides a
12
+ * child's claim of success. A gate that could not run, or that exited 0 without
13
+ * proving anything, is not evidence of failure either — reporting it as one
14
+ * would blame the work for a typo in the gate. That case leaves the outcome
15
+ * alone and says the verification was inconclusive.
16
+ *
17
+ * Pure and dependency-free: every rule here is a function of facts the
18
+ * extension already has.
19
+ */
20
+
21
+ import type { GateOutcome } from "./gate.ts";
22
+
23
+ /** What the delegated task actually came to. */
24
+ export type TaskOutcome = "succeeded" | "blocked" | "failed";
25
+
26
+ /** How well that outcome is known. Orthogonal to the outcome itself. */
27
+ export type Verification = "not-requested" | "passed" | "failed" | "inconclusive";
28
+
29
+ /** The marker a child may end its report with to declare its own outcome. */
30
+ const DECLARATION = /^\s*outcome:\s*(succeeded|blocked|failed)\s*$/gim;
31
+
32
+ /**
33
+ * Read a child's self-declared outcome, if it made one. Last declaration wins:
34
+ * a report that revises itself means the later line.
35
+ *
36
+ * This is a *claim*, not evidence — the value is that it is parseable, and that
37
+ * a blocked child can say so in one place instead of burying it in prose.
38
+ * Absent or unparseable means "no claim", never a failure.
39
+ */
40
+ export function parseDeclaredOutcome(text: string | null | undefined): TaskOutcome | undefined {
41
+ if (!text) return undefined;
42
+ let found: TaskOutcome | undefined;
43
+ DECLARATION.lastIndex = 0;
44
+ for (const m of text.matchAll(DECLARATION)) found = m[1]!.toLowerCase() as TaskOutcome;
45
+ return found;
46
+ }
47
+
48
+ /** Strip the declaration line so it does not also show up in the report body. */
49
+ export function stripDeclaration(text: string): string {
50
+ return text.replace(DECLARATION, "").replace(/\n{3,}/g, "\n\n").trim();
51
+ }
52
+
53
+ /** What a finished gate proved about the work, in verification terms. */
54
+ export function gateVerification(outcome: GateOutcome): Verification {
55
+ if (outcome === "success") return "passed";
56
+ if (outcome === "failure" || outcome === "timeout") return "failed";
57
+ // result_missing and no_attestation both mean the gate settled without
58
+ // establishing anything — not a verdict against the work.
59
+ return "inconclusive";
60
+ }
61
+
62
+ export interface OutcomeInput {
63
+ /** Lifecycle: did the session itself run to the end? */
64
+ status: "running" | "done" | "error" | "aborted";
65
+ /** The child's own claim, when it made one. */
66
+ declared?: TaskOutcome;
67
+ /** What the gate proved, when one ran. */
68
+ verification?: Verification;
69
+ }
70
+
71
+ /**
72
+ * Settle the task outcome from the facts, most authoritative first:
73
+ * a session that did not finish cannot have succeeded; a gate that failed
74
+ * outranks any claim; then the child's own claim; then success by default.
75
+ */
76
+ export function deriveOutcome(input: OutcomeInput): TaskOutcome {
77
+ if (input.status !== "done") return "failed";
78
+ if (input.verification === "failed") return "failed";
79
+ if (input.declared) return input.declared;
80
+ return "succeeded";
81
+ }
82
+
83
+ /** One line for the report, naming both facts. */
84
+ export function outcomeLine(outcome: TaskOutcome, verification: Verification): string {
85
+ const how =
86
+ verification === "not-requested"
87
+ ? "no gate was requested, so this is the agent's own account"
88
+ : verification === "passed"
89
+ ? "a gate ran and passed"
90
+ : verification === "failed"
91
+ ? "a gate ran and failed"
92
+ : "a gate ran but proved nothing either way";
93
+ return `[outcome] ${outcome} — ${how}`;
94
+ }
package/src/pending.ts CHANGED
@@ -29,6 +29,13 @@ export interface PendingInput {
29
29
  now: number;
30
30
  /** What the caller asks for to collect it, e.g. `agent_result`. */
31
31
  collectWith: string;
32
+ /**
33
+ * Whether a UI/interactive session is present. Delivery needs a session
34
+ * that outlives the run; a headless `pi -p` run tears down when the prompt
35
+ * resolves, so "it will be delivered, do not poll" is a promise that cannot
36
+ * be kept there. Default true so existing callers keep the interactive text.
37
+ */
38
+ interactive?: boolean;
32
39
  }
33
40
 
34
41
  export interface PendingResult {
@@ -39,8 +46,12 @@ export interface PendingResult {
39
46
  /** True: this will resolve on its own. It is a wait, not a failure. */
40
47
  retryable: boolean;
41
48
  elapsedMs: number;
42
- /** False, and load-bearing: there is nothing to poll for. */
43
- pollRequired: false;
49
+ /**
50
+ * Interactive: false, and load-bearing — there is nothing to poll for.
51
+ * Headless: true — the session ends with this turn, so the model MUST
52
+ * collect within it or the result is lost.
53
+ */
54
+ pollRequired: boolean;
44
55
  };
45
56
  }
46
57
 
@@ -58,15 +69,28 @@ function elapsed(ms: number): string {
58
69
  export function pendingResult(input: PendingInput): PendingResult {
59
70
  const ms = Math.max(0, input.now - input.startedAt);
60
71
  const state = input.kind === "queued" ? "queued behind the concurrency cap" : "still running";
72
+ // A headless run has no session to deliver into — it ends when this turn
73
+ // does. Telling the model "do not poll, it will be delivered" there strands
74
+ // it awaiting a message that never comes; it must collect within the turn.
75
+ const headless = input.interactive === false;
76
+ const text = headless
77
+ ? [
78
+ `${input.id} is ${state} (${elapsed(ms)}).`,
79
+ "",
80
+ "This is a headless run: nothing is delivered after your turn ends. Call",
81
+ `${input.collectWith} again in this same turn until it returns the result — do not end`,
82
+ "your turn expecting to be picked back up.",
83
+ ].join("\n")
84
+ : [
85
+ `${input.id} is ${state} (${elapsed(ms)}).`,
86
+ "",
87
+ "Do not poll for it. The result is delivered to you automatically the moment it lands,",
88
+ `so there is nothing to wait for here — carry on with other work, or finish your turn and`,
89
+ `you will be picked back up. ${input.collectWith} is only needed if you want it early.`,
90
+ ].join("\n");
61
91
  return {
62
- text: [
63
- `${input.id} is ${state} (${elapsed(ms)}).`,
64
- "",
65
- "Do not poll for it. The result is delivered to you automatically the moment it lands,",
66
- `so there is nothing to wait for here — carry on with other work, or finish your turn and`,
67
- `you will be picked back up. ${input.collectWith} is only needed if you want it early.`,
68
- ].join("\n"),
69
- details: { id: input.id, status: input.kind, retryable: true, elapsedMs: ms, pollRequired: false },
92
+ text,
93
+ details: { id: input.id, status: input.kind, retryable: true, elapsedMs: ms, pollRequired: headless },
70
94
  };
71
95
  }
72
96