pi-better-subagents 0.2.0 → 0.3.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.
@@ -2,11 +2,11 @@
2
2
  /**
3
3
  * OS-level write sandbox mechanism shared by Pi extensions.
4
4
  *
5
- * Kernel-enforced confinement: the sandboxed process may READ anywhere and use
6
- * the network (so web_fetch and the model API keep working), but may only WRITE
7
- * under a single canonical root plus the system paths pi itself needs. Unlike a
8
- * cooperative guardrails layer (which pattern-matches tool inputs), this cannot
9
- * be evaded by a crafted bash command — the write syscall itself is denied.
5
+ * Legacy policies are write-only: a sandboxed process may READ anywhere and use
6
+ * the network, but may only WRITE under a canonical root plus runtime paths.
7
+ * Optional permissions add capability restrictions for reads, writes, launches
8
+ * and network access. They cover known credential files, not OS keychains,
9
+ * credential services, or tokens inherited in the child environment.
10
10
  *
11
11
  * This module owns the mechanism only: backend discovery, canonical path
12
12
  * containment, write-deny compilation, macOS SBPL profile construction, Linux
@@ -35,6 +35,14 @@ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
35
35
  /** Identifies which kernel mechanism a plan will use. */
36
36
  export type SandboxBackendId = "macos-seatbelt" | "linux-bubblewrap";
37
37
 
38
+ export type SandboxPermissions = {
39
+ projectFiles: "off" | "read" | "read-write";
40
+ outsideProject: "off" | "read" | "read-write";
41
+ storedCredentials: "off" | "read" | "read-write";
42
+ commands: boolean;
43
+ network: boolean;
44
+ };
45
+
38
46
  /**
39
47
  * What a sandboxed process may write. `writableRoot` and `denyWrite` entries may
40
48
  * be relative or contain symlinks; they are canonicalized before use.
@@ -55,6 +63,10 @@ export type SandboxWritePolicy = {
55
63
  denyWrite?: readonly string[];
56
64
  /** Home directory whose `~/.pi` state stays writable on macOS. */
57
65
  home: string;
66
+ /** Optional capability profile; omission preserves the original write-only sandbox. */
67
+ permissions?: SandboxPermissions;
68
+ /** Trusted per-launch runtime state, never supplied by model tool arguments. */
69
+ runtimeWrite?: readonly string[];
58
70
  };
59
71
 
60
72
  /** The executable and argv to run inside the sandbox, preserved verbatim. */
@@ -109,6 +121,9 @@ export type CompiledSandboxWritePolicy = {
109
121
  readonly writableRoot: string;
110
122
  readonly denyWrite: readonly string[];
111
123
  readonly home: string;
124
+ readonly permissions?: SandboxPermissions;
125
+ readonly credentialPaths?: readonly string[];
126
+ readonly runtimeWrite?: readonly string[];
112
127
  };
113
128
 
114
129
  /** Why a write target is or is not permitted by a compiled policy. */
@@ -117,11 +132,15 @@ export type WriteAccessDecision =
117
132
  | {
118
133
  allowed: false;
119
134
  path: string;
120
- reason: "outside-writable-root" | "write-denied";
135
+ reason: "outside-writable-root" | "write-denied" | "permission-denied";
121
136
  /** The compiled deny entry that matched, for `write-denied` only. */
122
137
  deniedBy?: string;
123
138
  };
124
139
 
140
+ export type ReadAccessDecision =
141
+ | { allowed: true; path: string }
142
+ | { allowed: false; path: string; reason: "read-denied" };
143
+
125
144
  /** What the current platform can enforce, and why it cannot when it cannot. */
126
145
  export type SandboxSupport =
127
146
  | { supported: true; platform: string; backend: SandboxBackendId; executable: string }
@@ -141,6 +160,29 @@ type SandboxBackend = {
141
160
 
142
161
  const MACOS_SANDBOX_EXEC = "/usr/bin/sandbox-exec";
143
162
 
163
+ const CREDENTIAL_LOCATIONS = [
164
+ ".ssh", ".aws", ".config/gh", ".config/gcloud", ".azure", ".kube",
165
+ ".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
166
+ ] as const;
167
+
168
+ // Executables, dynamic libraries and OS frameworks needed to start a child.
169
+ // This deliberately excludes the home directory, /etc, and credential stores.
170
+ // A process relying on /etc or /proc configuration (DNS, certificates, NSS)
171
+ // may not start or function under Linux outsideProject=off; callers must not
172
+ // silently remount these broad host trees to work around that failure.
173
+ const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Library", "/System/Cryptexes", "/Library/Apple", "/Library/Developer", "/opt/homebrew"];
174
+ const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
175
+ const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
176
+
177
+ /** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
178
+ export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
179
+ const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
180
+ const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
181
+ if (configuredAgentDir) paths.push(join(configuredAgentDir.startsWith("~/")
182
+ ? join(home, configuredAgentDir.slice(2)) : configuredAgentDir, "auth.json"));
183
+ return [...new Set(paths.map((path) => canonicalizePath(path, seams)))].sort();
184
+ }
185
+
144
186
  function currentPlatform(seams: SandboxSeams): string {
145
187
  return (seams.platform ?? osPlatform)();
146
188
  }
@@ -154,7 +196,19 @@ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string
154
196
  const canonicalize = seams.canonicalize ?? realpathSync;
155
197
  const absolute = resolve(path);
156
198
  try {
157
- return canonicalize(absolute);
199
+ const resolved = canonicalize(absolute);
200
+ // APFS firmlinks are not resolved by realpath. Normalize the Data-volume
201
+ // alias only when both names demonstrably refer to the same inode.
202
+ const dataPrefix = "/System/Volumes/Data";
203
+ if (!seams.canonicalize && currentPlatform(seams) === "darwin" && resolved.startsWith(`${dataPrefix}/`)) {
204
+ const candidate = resolved.slice(dataPrefix.length);
205
+ try {
206
+ const source = statSync(resolved);
207
+ const alias = statSync(candidate);
208
+ if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync(candidate);
209
+ } catch { /* An unrelated Data-volume path keeps its original identity. */ }
210
+ }
211
+ return resolved;
158
212
  } catch {
159
213
  // Not created yet (or unreadable): canonicalize the parent instead.
160
214
  }
@@ -179,7 +233,14 @@ function compile(
179
233
  ...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
180
234
  ].sort();
181
235
 
182
- return { writableRoot, denyWrite, home: policy.home };
236
+ return {
237
+ writableRoot, denyWrite, home: policy.home,
238
+ ...(policy.permissions && {
239
+ permissions: { ...policy.permissions },
240
+ credentialPaths: credentialFilePaths(policy.home, seams),
241
+ runtimeWrite: (policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
242
+ }),
243
+ };
183
244
  }
184
245
 
185
246
  /**
@@ -198,6 +259,31 @@ function contains(root: string, target: string): boolean {
198
259
  return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
199
260
  }
200
261
 
262
+ function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
263
+ return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
264
+ }
265
+
266
+ function runtimeRoots(seams: SandboxSeams): string[] {
267
+ return [...new Set(READ_RUNTIME_ROOTS.map((path) => canonicalizePath(path, seams)))];
268
+ }
269
+
270
+ /** Decide read access using the same canonical path and capability precedence as writes. */
271
+ export function evaluateReadAccess(
272
+ target: string,
273
+ policy: CompiledSandboxWritePolicy,
274
+ seams: SandboxSeams = {},
275
+ ): ReadAccessDecision {
276
+ const path = canonicalizePath(target, seams);
277
+ const permissions = policy.permissions;
278
+ if (!permissions) return { allowed: true, path };
279
+ const mode = isCredential(path, policy) ? permissions.storedCredentials
280
+ : policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
281
+ : contains(policy.writableRoot, path) ? permissions.projectFiles
282
+ : path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
283
+ : permissions.outsideProject;
284
+ return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
285
+ }
286
+
201
287
  /**
202
288
  * Decide whether an in-process write to `target` is permitted by a compiled
203
289
  * policy. This is the same containment rule the kernel backends enforce, for
@@ -209,7 +295,7 @@ export function evaluateWriteAccess(
209
295
  seams: SandboxSeams = {},
210
296
  ): WriteAccessDecision {
211
297
  const path = canonicalizePath(target, seams);
212
- if (!contains(policy.writableRoot, path)) {
298
+ if (!policy.permissions && !contains(policy.writableRoot, path)) {
213
299
  return { allowed: false, path, reason: "outside-writable-root" };
214
300
  }
215
301
  for (const denied of policy.denyWrite) {
@@ -217,6 +303,14 @@ export function evaluateWriteAccess(
217
303
  return { allowed: false, path, reason: "write-denied", deniedBy: denied };
218
304
  }
219
305
  }
306
+ if (policy.permissions) {
307
+ const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
308
+ : policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
309
+ : contains(policy.writableRoot, path) ? policy.permissions.projectFiles
310
+ : contains(canonicalizePath("/dev", seams), path) ? "read-write"
311
+ : policy.permissions.outsideProject;
312
+ if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
313
+ }
220
314
  return { allowed: true, path };
221
315
  }
222
316
 
@@ -225,13 +319,70 @@ function sbpl(path: string): string {
225
319
  return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
226
320
  }
227
321
 
322
+ function validateRuntimeHome(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): void {
323
+ if (policy.permissions?.outsideProject !== "off") return;
324
+ const home = canonicalizePath(policy.home, seams);
325
+ if (RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), home))) {
326
+ throw new Error("outsideProject=off cannot expose a home under a required system runtime root; move the home or use another permission mode.");
327
+ }
328
+ }
329
+
330
+ function protectedAncestors(paths: readonly string[]): string[] {
331
+ const parents = new Set<string>();
332
+ for (const path of paths) {
333
+ for (let parent = dirname(path); dirname(parent) !== parent; parent = dirname(parent)) parents.add(parent);
334
+ }
335
+ return [...parents].sort((a, b) => a.length - b.length);
336
+ }
337
+
338
+ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
339
+ validateRuntimeHome(policy, seams);
340
+ const permissions = policy.permissions!;
341
+ const protectedPaths = [
342
+ ...policy.denyWrite,
343
+ ...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
344
+ ...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
345
+ ];
346
+ const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
347
+ if (permissions.outsideProject === "off") {
348
+ rules.push("(deny file-read*)", "(allow file-read-metadata)", '(allow file-read* (literal "/"))');
349
+ for (const root of runtimeRoots(seams)) {
350
+ rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
351
+ }
352
+ }
353
+ if (permissions.outsideProject === "read-write") rules.push("(allow file-write*)");
354
+ // Temporary host paths are not granted for off/read: doing so would expose
355
+ // other users' files in temp. /dev remains necessary for basic shell I/O.
356
+ for (const root of TEMP_ROOTS.filter((path) => permissions.outsideProject === "read-write" || path === "/dev")
357
+ .map((path) => canonicalizePath(path, seams))) {
358
+ rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
359
+ }
360
+ const scoped = (root: string, mode: SandboxPermissions["projectFiles"]) => {
361
+ if (mode === "off") rules.push(`(deny file-read* (subpath ${sbpl(root)}))`);
362
+ else rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
363
+ if (mode === "read-write") rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
364
+ else rules.push(`(deny file-write* (subpath ${sbpl(root)}))`);
365
+ };
366
+ // Last matching SBPL rule wins. Credential rules override project and outside;
367
+ // explicit denyWrite entries always override every write allowance.
368
+ scoped(policy.writableRoot, permissions.projectFiles);
369
+ for (const path of policy.runtimeWrite ?? []) scoped(path, "read-write");
370
+ for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
371
+ for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
372
+ // Protect the directory entries, not their contents: unrelated children can
373
+ // still be created, while renaming a parent cannot move a denied subtree.
374
+ for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
375
+ if (!permissions.network) rules.push("(deny network*)");
376
+ return [...rules, ""].join("\n");
377
+ }
378
+
228
379
  /** Build the macOS sandbox-exec wrapper and its SBPL profile. */
229
380
  function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
230
381
  // Match on the real (symlink-resolved) path — sandbox-exec evaluates the
231
382
  // canonical path, so /tmp/x must be written as /private/tmp/x.
232
383
  const policy = compile(args.policy, seams, false);
233
384
 
234
- const profile = [
385
+ const profile = policy.permissions ? buildPermissionProfile(policy, seams) : [
235
386
  "(version 1)",
236
387
  "(allow default)", // permissive base: reads, exec, network
237
388
  "(deny file-write*)", // ...then deny all writes...
@@ -344,6 +495,7 @@ function buildLinuxSandboxCommand(
344
495
  // boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
345
496
  // widening the writable root.
346
497
  const policy = compile(args.policy, seams, true);
498
+ if (policy.permissions) return buildLinuxPermissionCommand(bwrap, args, policy, seams);
347
499
  const materialize = seams.materializeDenyPath ?? materializeDenyPath;
348
500
  const denyBinds = policy.denyWrite.flatMap((path) => {
349
501
  const mountable = writableInsideLinuxSandbox(path, policy.writableRoot) && materialize(path);
@@ -368,6 +520,113 @@ function buildLinuxSandboxCommand(
368
520
  };
369
521
  }
370
522
 
523
+ function buildLinuxPermissionCommand(
524
+ bwrap: string,
525
+ args: SandboxCommandArgs,
526
+ policy: CompiledSandboxWritePolicy,
527
+ seams: SandboxSeams,
528
+ ): SandboxCommand {
529
+ const permissions = policy.permissions!;
530
+ validateRuntimeHome(policy, seams);
531
+ const project = policy.writableRoot;
532
+ const credentials = policy.credentialPaths ?? [];
533
+ const writableProject = permissions.projectFiles === "read-write";
534
+ const overlappingCredentials = credentials.filter((path) => contains(project, path));
535
+ if (credentials.some((path) => contains(path, project)) &&
536
+ permissions.projectFiles !== permissions.storedCredentials) {
537
+ throw new Error("Linux bubblewrap cannot apply differing project and credential permissions when a credential directory contains the project.");
538
+ }
539
+ if (writableProject && policy.denyWrite.some((path) => contains(path, project))) {
540
+ throw new Error("Linux bubblewrap cannot make a project writable inside a write-denied directory.");
541
+ }
542
+ // Protected leaves and their writable ancestors become mount points below.
543
+ // Linux refuses renaming mount points, preventing ancestor replacement.
544
+ if (permissions.outsideProject === "read-write" && (
545
+ permissions.projectFiles !== "read-write" || permissions.storedCredentials !== "read-write" ||
546
+ policy.denyWrite.length > 0
547
+ )) {
548
+ throw new Error("Linux bubblewrap cannot enforce restricted project/credential/denyWrite paths under a writable outsideProject mount.");
549
+ }
550
+ if (permissions.outsideProject === "read" && permissions.storedCredentials === "read-write" &&
551
+ credentials.some((path) => !contains(project, path))) {
552
+ throw new Error("Linux bubblewrap cannot write credential stores under a read-only outsideProject root.");
553
+ }
554
+ if (permissions.projectFiles === "read" && permissions.storedCredentials === "read-write" &&
555
+ overlappingCredentials.length > 0) {
556
+ throw new Error("Linux bubblewrap cannot write credential stores under a read-only project mount.");
557
+ }
558
+ if (permissions.outsideProject === "read" && permissions.storedCredentials === "off") {
559
+ throw new Error("Linux bubblewrap cannot hide stored credentials in a read-only whole-root bind.");
560
+ }
561
+ if (permissions.outsideProject === "off" && permissions.storedCredentials === "off" &&
562
+ credentials.some((path) => contains(project, path) && permissions.projectFiles !== "off")) {
563
+ throw new Error("Linux bubblewrap cannot hide credentials inside a visible read-only project.");
564
+ }
565
+ if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
566
+ RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), project))) {
567
+ throw new Error("Linux bubblewrap cannot hide a project nested under a required system runtime bind.");
568
+ }
569
+ if (permissions.outsideProject === "off" && credentials.some((path) =>
570
+ !contains(project, path) &&
571
+ RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), path)) &&
572
+ permissions.storedCredentials === "off")) {
573
+ throw new Error("Linux bubblewrap cannot hide credentials under a required system runtime bind.");
574
+ }
575
+ if (permissions.outsideProject === "off" && permissions.storedCredentials === "read-write" &&
576
+ credentials.some((path) => !contains(project, path))) {
577
+ throw new Error("Linux bubblewrap cannot create or safely bind writable credential stores outside a hidden root.");
578
+ }
579
+ if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
580
+ permissions.storedCredentials !== "off" && credentials.some((path) => contains(project, path))) {
581
+ throw new Error("Linux bubblewrap cannot expose credentials inside a hidden project without exposing the project.");
582
+ }
583
+ if (permissions.outsideProject !== "off" && permissions.projectFiles === "off") {
584
+ throw new Error("Linux bubblewrap cannot hide a project inside a visible outsideProject root.");
585
+ }
586
+ const mounts: string[] = permissions.outsideProject === "off" ? ["--tmpfs", "/"]
587
+ : [permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/", "/"];
588
+ if (permissions.outsideProject === "off") {
589
+ // Bounded system executable/library roots only. /tmp is private, not a
590
+ // host bind: otherwise outsideProject=off would expose user temp data.
591
+ for (const root of RUNTIME_ROOTS) {
592
+ if (existsSync(root)) mounts.push("--ro-bind", root, root);
593
+ }
594
+ mounts.push("--tmpfs", "/tmp");
595
+ } else {
596
+ mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
597
+ }
598
+ mounts.push("--dev", "/dev");
599
+ if (permissions.projectFiles !== "off") {
600
+ mounts.push(writableProject ? "--bind" : "--ro-bind", project, project);
601
+ }
602
+ if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
603
+ for (const path of credentials) {
604
+ if (existsSync(path) && !contains(project, path)) mounts.push("--ro-bind", path, path);
605
+ }
606
+ }
607
+ for (const path of policy.runtimeWrite ?? []) {
608
+ if ([...credentials, ...policy.denyWrite].some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path))) {
609
+ throw new Error("Runtime directory overlaps protected credentials or control paths.");
610
+ }
611
+ mounts.push("--bind", path, path);
612
+ }
613
+ const protectedPaths = [...policy.denyWrite,
614
+ ...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
615
+ if (writableProject) {
616
+ const materialize = seams.materializeDenyPath ?? materializeDenyPath;
617
+ const leaves = protectedPaths.filter((path) => contains(project, path) && materialize(path));
618
+ for (const parent of protectedAncestors(leaves).filter((path) => contains(project, path) && path !== project)) {
619
+ mounts.push("--bind", parent, parent);
620
+ }
621
+ for (const path of leaves) mounts.push("--ro-bind", path, path);
622
+ }
623
+ return {
624
+ file: bwrap,
625
+ fileArgs: [...mounts, ...(!permissions.network ? ["--unshare-net"] : []),
626
+ "--", args.execPath, ...args.execArgs],
627
+ };
628
+ }
629
+
371
630
  function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
372
631
  const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
373
632
  if (!bwrap) return undefined;
@@ -436,10 +695,16 @@ export function maybeBuildSandboxCommand(
436
695
  request: SandboxRequest,
437
696
  seams: SandboxSeams = {},
438
697
  ): SandboxCommand | undefined {
698
+ if (args.policy.permissions?.commands === false) {
699
+ throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
700
+ }
439
701
  if (!request.sandboxEnabled) return undefined;
440
702
 
441
703
  const backend = selectedSandboxBackend(seams);
442
704
  if (!backend) {
705
+ if (args.policy.permissions) {
706
+ throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
707
+ }
443
708
  if (request.explicitSandbox) {
444
709
  const reason = sandboxUnavailableMessage(seams);
445
710
  throw new Error(request.remedy ? `${reason} ${request.remedy}` : reason);
@@ -458,5 +723,12 @@ export function buildSandboxCommand(
458
723
  args: SandboxCommandArgs,
459
724
  seams: SandboxSeams = {},
460
725
  ): SandboxCommand {
461
- return (selectedSandboxBackend(seams) ?? macOSSandboxBackend).buildCommand(args, seams);
726
+ if (args.policy.permissions?.commands === false) {
727
+ throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
728
+ }
729
+ const backend = selectedSandboxBackend(seams);
730
+ if (!backend && args.policy.permissions) {
731
+ throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
732
+ }
733
+ return (backend ?? macOSSandboxBackend).buildCommand(args, seams);
462
734
  }
package/tools.ts CHANGED
@@ -18,6 +18,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
18
  import { readMeta, listMetas, effectiveStatus, isFinalResultStatus, type RunMeta, type RunStatus } from "./registry.ts";
19
19
  import { parseRun, tailLog, formatSubagentOutputBody } from "./parse.ts";
20
20
  import { buildSubagentResultText } from "./finalization.ts";
21
+ import { failureSummary, prependFailureSummary } from "./failures.ts";
21
22
  import { formatOrphanedResult } from "./lifecycle.ts";
22
23
  import { stopRun } from "./stop.ts";
23
24
  import { fmtElapsed, fmtSpend } from "./widget.ts";
@@ -258,6 +259,10 @@ export function subagentListTool(Type: TypeModule): ToolDefinition {
258
259
  statusOf: effectiveStatus,
259
260
  usageById: (id: string) => parseRun(id).usage,
260
261
  healthById,
262
+ failureById: (id: string) => {
263
+ const meta = metas.find((m) => m.id === id);
264
+ return meta ? failureSummary(id, meta.cwd, meta.status !== "running" && meta.status !== "orphaned") : "";
265
+ },
261
266
  }));
262
267
  },
263
268
  } as ToolDefinition;
@@ -300,7 +305,7 @@ export function subagentOutputTool(Type: TypeModule): ToolDefinition {
300
305
  // Health diagnostics for orphaned/lost/degraded only (#67). Healthy/quiet
301
306
  // stays on today's body. Independent of meta.callback.
302
307
  const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
303
- return text(appendHealthDiagnostic(body, healthLine));
308
+ return text(prependFailureSummary(appendHealthDiagnostic(body, healthLine), failureSummary(p.id, meta.cwd, st !== "running" && st !== "orphaned")));
304
309
  },
305
310
  } as ToolDefinition;
306
311
  }
@@ -342,9 +347,9 @@ export function subagentResultTool(Type: TypeModule): ToolDefinition {
342
347
  const rawTail = tailLog(p.id, 40);
343
348
  const body = `${head}\n${formatOrphanedResult(r, rawTail)}`;
344
349
  const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
345
- return subagentResultText(appendHealthDiagnostic(body, healthLine));
350
+ return subagentResultText(prependFailureSummary(appendHealthDiagnostic(body, healthLine), failureSummary(p.id, meta.cwd)));
346
351
  }
347
- return subagentResultText(`Run ${p.id} is still running — no result yet. You'll be notified when it finishes; don't poll.`);
352
+ return subagentResultText(prependFailureSummary(`Run ${p.id} is still running — no result yet. You'll be notified when it finishes; don't poll.`, failureSummary(p.id, meta.cwd)));
348
353
  }
349
354
  // Lifecycle-aware body (complete-stream authority + diagnostics).
350
355
  // Lost runs go through formatLostResult inside formatSubagentResult (#65).
@@ -356,7 +361,7 @@ export function subagentResultTool(Type: TypeModule): ToolDefinition {
356
361
  // Append degraded/lost health facts when present; completed/failed
357
362
  // happy paths stay quiet when observation is non-actionable.
358
363
  const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
359
- return subagentResultText(appendHealthDiagnostic(body, healthLine));
364
+ return subagentResultText(prependFailureSummary(appendHealthDiagnostic(body, healthLine), failureSummary(p.id, meta.cwd, true)));
360
365
  },
361
366
  } as ToolDefinition;
362
367
  }