pi-better-subagents 0.2.0 → 0.4.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/index.ts CHANGED
@@ -37,11 +37,15 @@ import { parseRun, readRunTranscript, resetParseRunCursor, type Usage } from "./
37
37
  import { finalizeRun as finalizeRunCore } from "./finalization.ts";
38
38
  import { loadConfig, normalizeTools, resolveExtensionPath, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS, DEFAULT_MAX_CONCURRENT } from "./config.ts";
39
39
  import { resolveExtensions, extensionArgs } from "./extensions.ts";
40
- import { maybeBuildSandboxCommand } from "./sandbox.ts";
40
+ import { prepareTaskRuntime } from "./task-policy.ts";
41
+ import { canonicalizePath } from "./shared-sandbox-core.ts";
42
+ import { TASK_BUILTINS } from "./shared-task-sandbox.ts";
43
+ import { observeSandboxPermissions, resolveSubagentPermissions } from "./permission-policy.ts";
41
44
  import { resolveSubagentWorkspace } from "./git-workspace.ts";
42
- import { homedir } from "node:os";
43
45
  import { join } from "node:path";
44
46
  import {
47
+ baseDir,
48
+ taskWorkspaceDir,
45
49
  sessionsDir,
46
50
  runDir,
47
51
  logPathFor,
@@ -91,6 +95,8 @@ import {
91
95
  } from "./capacity.mjs";
92
96
  import { buildHealthCallbackDelivery } from "./completion.ts";
93
97
  import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
98
+ import { collectRunFailures, failurePath, failureSummary, formatFailureSummary, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
99
+ import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
94
100
  import {
95
101
  text,
96
102
  subagentListTool,
@@ -334,11 +340,15 @@ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
334
340
  || meta.completionCallbackSentAt !== undefined
335
341
  || meta.completionCallbackSuppressedAt !== undefined) return;
336
342
  const label = meta.name ? `${meta.name} (${id})` : id;
343
+ const observations = Object.values(collectRunFailures(id, meta.cwd, true).observations);
344
+ const unresolved = observations.filter((observation) => observation.status === "unresolved");
345
+ const observationStatus = unresolved.some((observation) => observation.category === "observation-incomplete")
346
+ ? "observation incomplete" : unresolved.length ? "unresolved failure observations" : undefined;
337
347
  getCallbackBatcher(pi).enqueue({
338
348
  source: "subagent",
339
349
  id,
340
350
  label,
341
- status: meta.status,
351
+ status: observationStatus ? `${meta.status}; ${observationStatus}` : meta.status,
342
352
  detailTool: "subagent_result",
343
353
  callback: true,
344
354
  isDelivered: () => {
@@ -348,10 +358,18 @@ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
348
358
  },
349
359
  getSuppressionReason: () => {
350
360
  const current = readMeta(id);
351
- if (!current) return "subagent metadata is unavailable";
361
+ if (!current) throw new Error("Subagent metadata is unavailable; defer completion");
352
362
  return callbackSuppressionReason(current);
353
363
  },
354
- onDelivered: (at) => markCompletionCallbackSent(id, at),
364
+ onDelivered: (at) => {
365
+ const state = collectRunFailures(id, meta.cwd, true);
366
+ const due = pendingFailureAttention(state, at, { terminal: true });
367
+ if (due) {
368
+ markFailureAttentionDelivered(failurePath(id), due, at);
369
+ if (due.incidents.some((incident) => pendingFailureAttention(collectRunFailures(id, meta.cwd, true), at, { terminal: true })?.incidents.includes(incident))) return;
370
+ }
371
+ markCompletionCallbackSent(id, at);
372
+ },
355
373
  onSuppressed: (reason, at) => markCompletionCallbackSuppressed(id, reason, at),
356
374
  });
357
375
  }
@@ -364,6 +382,27 @@ function recoverCompletionCallbacks(pi: ExtensionAPI): void {
364
382
  }
365
383
  }
366
384
 
385
+ function deliverFailureAttention(pi: ExtensionAPI | undefined, meta: RunMeta, now: number): void {
386
+ if (!pi || meta.callback === false || callbackSuppressionReason(meta)) return;
387
+ if ((meta.status === "orphaned" || meta.status === "lost") && !isHealthCallbackHandled(meta, meta.status)) return;
388
+ const state = collectRunFailures(meta.id, meta.cwd, meta.status !== "running" && meta.status !== "orphaned");
389
+ const pending = pendingFailureAttention(state, now);
390
+ if (!pending || (meta.status !== "running" && meta.status !== "orphaned" && meta.completionCallbackPendingAt !== undefined)) return;
391
+ const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
392
+ void getCallbackBatcher(pi).deliverUrgent({
393
+ source: "subagent", id: meta.id, label, status: `failure:${pending.key}`,
394
+ customType: "subagent-failure",
395
+ content: `${formatFailureSummary(state)}\nInspect: subagent_output id=${JSON.stringify(meta.id)}`,
396
+ isDelivered: () => failureAttentionHandled(collectRunFailures(meta.id, meta.cwd), pending.incidents),
397
+ getSuppressionReason: () => {
398
+ const current = readMeta(meta.id);
399
+ if (!current) throw new Error("Subagent metadata is unavailable; defer failure notification");
400
+ return callbackSuppressionReason(current);
401
+ },
402
+ onDelivered: (at) => { markFailureAttentionDelivered(failurePath(meta.id), pending, at); },
403
+ });
404
+ }
405
+
367
406
  function markHealthCallbackSuppressed(meta: RunMeta, status: "orphaned" | "lost", reason: string, now: number): void {
368
407
  if (status === "orphaned") {
369
408
  if (meta.orphanedCallbackSuppressedAt !== undefined) return;
@@ -532,6 +571,9 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
532
571
 
533
572
  const callback = meta.callback !== false;
534
573
  const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
574
+ const failureState = collectRunFailures(meta.id, meta.cwd, status === "lost");
575
+ const failureText = formatFailureSummary(failureState);
576
+ const attention = pendingFailureAttention(failureState, now, { terminal: status === "lost" });
535
577
  const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
536
578
  if (!delivery) {
537
579
  // callback:false — model follow-up suppressed; mark handled so recovery
@@ -547,19 +589,24 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
547
589
  label,
548
590
  status,
549
591
  customType: "subagent-health",
550
- content: delivery.content,
592
+ content: prependFailureSummary(delivery.content, failureText),
551
593
  isDelivered: () => {
552
594
  const current = readMeta(meta.id);
553
- return current ? isHealthCallbackHandled(current, status) : true;
595
+ if (!current) throw new Error("Subagent metadata is unavailable; defer health notification");
596
+ return isHealthCallbackHandled(current, status);
554
597
  },
555
598
  getSuppressionReason: () => {
556
599
  const current = readMeta(meta.id);
557
- if (!current) return "subagent metadata is unavailable";
600
+ if (!current) throw new Error("Subagent metadata is unavailable; defer health notification");
558
601
  return callbackSuppressionReason(current);
559
602
  },
560
603
  onDelivered: (at) => {
561
604
  const current = readMeta(meta.id);
562
605
  if (!current || isHealthCallbackHandled(current, status)) return;
606
+ if (attention) {
607
+ markFailureAttentionDelivered(failurePath(meta.id), attention, at);
608
+ if (attention.incidents.some((incident) => pendingFailureAttention(collectRunFailures(meta.id, meta.cwd), at, { terminal: true })?.incidents.includes(incident))) return;
609
+ }
563
610
  if (status === "orphaned") current.orphanedCallbackSentAt = at;
564
611
  else current.lostCallbackSentAt = at;
565
612
  writeMeta(current);
@@ -586,12 +633,25 @@ function reconcileHealth(): void {
586
633
  if (!meta) continue;
587
634
  if (meta.status !== "running" && meta.status !== "orphaned" && meta.status !== "lost") continue;
588
635
  const now = Date.now();
636
+ if (meta.status === "orphaned" || meta.status === "lost") {
637
+ observeFailures(failurePath(meta.id), [{ id: `supervision:${meta.status}`, operation: `supervision:${meta.status}`, kind: "incomplete",
638
+ summary: meta.status === "lost" ? "Child supervision was lost; outcome is unknown" : "Child supervision interrupted; related work may still be alive" }], now);
639
+ }
640
+ deliverFailureAttention(pi, meta, now);
589
641
 
590
642
  if (meta.status === "running" || meta.status === "orphaned") {
591
643
  const result = reconcileRun(meta, realProcessProbe, now);
592
644
  if (result.changed) {
593
645
  Object.assign(meta, result.patch, { status: result.status });
594
646
  writeMeta(meta);
647
+ if (result.status === "lost") {
648
+ observeFailures(failurePath(meta.id), [
649
+ { id: "supervision:orphaned-resolved", operation: "supervision:orphaned", kind: "recovered", incidents: ["supervision:orphaned"] },
650
+ { id: "supervision:lost", operation: "supervision:lost", kind: "incomplete", summary: "Child supervision was lost; outcome is unknown" },
651
+ ], now);
652
+ } else if (result.status === "orphaned") {
653
+ observeFailures(failurePath(meta.id), [{ id: "supervision:orphaned", operation: "supervision:orphaned", kind: "incomplete", summary: "Child supervision interrupted; related work may still be alive" }], now);
654
+ }
595
655
  if (result.transition) {
596
656
  // Human-visible health (always) on fresh transitions.
597
657
  if (!callbackSuppressionReason(meta)) {
@@ -612,8 +672,13 @@ function reconcileHealth(): void {
612
672
  deliverHealthCallback(pi, meta, meta.status, now);
613
673
  }
614
674
  }
615
- // Stop existing the moment nothing current-parent needs monitoring/recovery.
616
- if (!needsMonitoring(listMetasForParent(process.pid))) stopHealthTicker();
675
+ // Completed runs can still have failed completion handoffs to retry.
676
+ for (const summary of listMetasForParent(process.pid)) {
677
+ if (!pi || !ownedByThisParent(summary) || summary.status === "running" || summary.status === "orphaned" || summary.status === "lost") continue;
678
+ const meta = readMeta(summary.id);
679
+ if (meta && meta.completionCallbackPendingAt !== undefined && meta.completionCallbackSentAt === undefined && meta.completionCallbackSuppressedAt === undefined) enqueueCompletionCallback(pi!, meta.id);
680
+ }
681
+ if (!needsMonitoring(listMetasForParent(process.pid)) && !hasPendingFailureCallbacks()) stopHealthTicker();
617
682
  }
618
683
 
619
684
  /**
@@ -669,6 +734,12 @@ function reconcileAbandonedRuns(now: number = Date.now()): number {
669
734
  return adopted;
670
735
  }
671
736
 
737
+ function hasPendingFailureCallbacks(): boolean {
738
+ return listMetasForParent(process.pid).some((m) => ownedByThisParent(m) && m.callback !== false &&
739
+ m.completionCallbackPendingAt !== undefined && m.completionCallbackSentAt === undefined && m.completionCallbackSuppressedAt === undefined &&
740
+ !callbackSuppressionReason(m));
741
+ }
742
+
672
743
  /** Start the reconciliation loop if it isn't already running. */
673
744
  function ensureHealthTicker(): void {
674
745
  if (healthTicker) return;
@@ -847,6 +918,8 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
847
918
  if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
848
919
  if (row.tool) bits.push(row.tool);
849
920
  if (row.spend) bits.push(row.spend);
921
+ const failure = failureSummary(row.id, metaById.get(row.id)?.cwd ?? "", row.status !== "running" && row.status !== "orphaned");
922
+ const firstFailure = failure.split("\n")[0] || "";
850
923
  return {
851
924
  providerId: "subagents",
852
925
  id: row.id,
@@ -859,7 +932,8 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
859
932
  statusTone: statusTone(row.status),
860
933
  kind: "subagent",
861
934
  elapsed: row.elapsed,
862
- primary: bits.join(" · ") || "subagent run",
935
+ primary: firstFailure || bits.join(" · ") || "subagent run",
936
+ secondary: firstFailure ? bits.join(" · ") : undefined,
863
937
  facts: row.healthFacts,
864
938
  sortStartedAt: metaById.get(row.id)?.startedAt ?? now,
865
939
  expiresAt: (() => {
@@ -909,7 +983,9 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
909
983
  if (!detail) return null;
910
984
  void options;
911
985
  const transcript = readRunTranscript(id);
986
+ const failure = failureSummary(id, readMeta(id)?.cwd ?? "", detail.status !== "running" && detail.status !== "orphaned");
912
987
  const metadata = [
988
+ ...(failure ? [{ label: "failure", value: failure.split("\n")[0]! }] : []),
913
989
  { label: "provider", value: "Subagents" },
914
990
  { label: "id", value: detail.id },
915
991
  ...(detail.role ? [{ label: "role", value: String(detail.role) }] : []),
@@ -926,11 +1002,11 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
926
1002
  title: detail.name || detail.id,
927
1003
  status: detail.status,
928
1004
  statusTone: statusTone(detail.status),
929
- subtitle: detail.currentTool ? `current tool ${detail.currentTool}` : undefined,
1005
+ subtitle: failure.split("\n")[0] || (detail.currentTool ? `current tool ${detail.currentTool}` : undefined),
930
1006
  metadata,
931
- evidence: { label: "transcript", text: detail.output || "(no transcript yet)" },
1007
+ evidence: { label: "transcript", text: prependFailureSummary(detail.output || "(no transcript yet)", failure) },
932
1008
  transcript: transcript.entries,
933
- transcriptDiagnostic: transcript.diagnostic,
1009
+ transcriptDiagnostic: failure ? prependFailureSummary(transcript.diagnostic ?? "", failure) : transcript.diagnostic,
934
1010
  footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
935
1011
  };
936
1012
  }
@@ -1113,13 +1189,14 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
1113
1189
  // Host-facing wrapper around first-party finalizer (finalization.ts).
1114
1190
  // Coherent child-exit evidence may supersede provisional orphaned/lost
1115
1191
  // reconciliation; finalization.ts enforces canExitFinalize + lifecycle authority.
1116
- finalizeRunCore(id, code, {
1192
+ const result = finalizeRunCore(id, code, {
1117
1193
  renderWidget,
1118
1194
  notify: (message, level) => {
1119
1195
  try { ctx.ui.notify(message, level); } catch { /* ignore */ }
1120
1196
  },
1121
1197
  sendMessage: () => enqueueCompletionCallback(pi, id),
1122
1198
  });
1199
+ if (result.applied && hasPendingFailureCallbacks()) ensureHealthTicker();
1123
1200
  }
1124
1201
 
1125
1202
  /** String for one role, or an array when the caller assigns more than one. Arrays reach clarification instead of being rejected. */
@@ -1138,6 +1215,7 @@ export default function (pi: ExtensionAPI) {
1138
1215
  healthPi = pi;
1139
1216
  ensureSubagentProvider();
1140
1217
  registerSubagentsGoalProvider(pi);
1218
+ observeSandboxPermissions(pi);
1141
1219
  let acceptanceResultToolRef: { execute: (toolCallId: string, params: { id: string }) => Promise<unknown> } | undefined;
1142
1220
  function publishAcceptanceHooks(tool?: NonNullable<typeof acceptanceResultToolRef>): void {
1143
1221
  if (process.env.PI_CATALOG_ACCEPTANCE_PROBE !== "1") return;
@@ -1238,6 +1316,7 @@ export default function (pi: ExtensionAPI) {
1238
1316
  sandboxDir?: string;
1239
1317
  }> {
1240
1318
  assertThinkingLevel(p.thinking);
1319
+ const permissionPlan = resolveSubagentPermissions(pi, p.sandbox);
1241
1320
  const cfg = loadConfig();
1242
1321
  let model: string | undefined;
1243
1322
  let thinking: ThinkingLevel | undefined;
@@ -1268,17 +1347,17 @@ export default function (pi: ExtensionAPI) {
1268
1347
  // Sandbox is ON by default. sandbox_dir moves the confinement + working
1269
1348
  // dir elsewhere. git_clone_workspace prepares a disposable clone with
1270
1349
  // .git/ inside the writable root for Git-mutating sandboxed subagents.
1271
- const explicitSandbox = p.sandbox === true || typeof p.sandbox_dir === "string" || p.git_clone_workspace === true;
1272
- const sandboxEnabled = p.sandbox !== false; // default on
1350
+ const sandboxEnabled = permissionPlan.sandboxEnabled;
1273
1351
 
1274
- mkdirSync(sessionsDir(), { recursive: true });
1275
1352
  const id = nextRunId();
1353
+ const childSessionDir = sandboxEnabled ? join(sessionsDir(), id) : sessionsDir();
1354
+ mkdirSync(childSessionDir, { recursive: true });
1276
1355
  mkdirSync(runDir(id), { recursive: true });
1277
1356
 
1278
1357
  const workspace = resolveSubagentWorkspace({
1279
1358
  ctxCwd: ctx.cwd,
1280
1359
  cwd: p.cwd,
1281
- sandboxDir: p.sandbox_dir,
1360
+ sandboxDir: p.sandbox_dir ?? (sandboxEnabled && p.git_clone_workspace ? taskWorkspaceDir(id) : undefined),
1282
1361
  gitCloneWorkspace: p.git_clone_workspace,
1283
1362
  runId: id,
1284
1363
  runDirPath: runDir(id),
@@ -1302,9 +1381,14 @@ export default function (pi: ExtensionAPI) {
1302
1381
  }
1303
1382
 
1304
1383
  const resolution = resolveExtensions({
1305
- tools: allow, model, clean, allowNested: p.allow_nested, config: cfg,
1384
+ tools: sandboxEnabled ? allow.split(",").filter((name) => (TASK_BUILTINS as readonly string[]).includes(name)).join(",") : allow,
1385
+ model, clean, allowNested: sandboxEnabled ? false : p.allow_nested, config: cfg,
1306
1386
  });
1307
- const { args: extArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
1387
+ const { args: resolvedExtArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
1388
+ const extArgs = resolvedExtArgs.map((value, index) => sandboxEnabled && resolvedExtArgs[index - 1] === "--extension" ? canonicalizePath(value) : value);
1389
+ if (sandboxEnabled && resolution.mode === "inherit") {
1390
+ throw new Error("Task confinement requires explicit extensions; inheritExtensions is unsupported while sandboxing is enabled.");
1391
+ }
1308
1392
  if (missing.length) {
1309
1393
  throw new Error(
1310
1394
  `Subagent needs extension(s) that are not installed: ${missing.join(", ")}. ` +
@@ -1320,26 +1404,32 @@ export default function (pi: ExtensionAPI) {
1320
1404
 
1321
1405
  const args = [
1322
1406
  "-p", "--mode", "json",
1323
- "--session-dir", sessionsDir(),
1407
+ "--session-dir", childSessionDir,
1324
1408
  "--session-id", id,
1325
1409
  ...extArgs,
1326
1410
  ...(model ? ["--model", model] : []),
1327
1411
  ...(thinking ? ["--thinking", thinking] : []),
1328
- ...(allow ? ["--tools", allow] : []),
1412
+ ...(allow && !sandboxEnabled ? ["--tools", allow] : []),
1329
1413
  ...(excludes.size ? ["--exclude-tools", [...excludes].join(",")] : []),
1330
- ...(p.approve ? ["--approve"] : []),
1414
+ ...(sandboxEnabled ? ["--no-builtin-tools", "--no-approve"] : p.approve ? ["--approve"] : []),
1331
1415
  p.prompt,
1332
1416
  ];
1333
1417
 
1334
1418
  const piBin = resolvePiBinary();
1335
- const sandboxCommand = requestedSandboxDir
1336
- ? maybeBuildSandboxCommand({
1337
- profilePath: join(runDir(id), "sandbox.sb"),
1338
- writableDir: requestedSandboxDir, home: homedir(), piBin, piArgs: args,
1339
- }, { sandboxEnabled, explicitSandbox })
1340
- : undefined;
1341
- const cmd = sandboxCommand ?? { file: piBin, fileArgs: args };
1342
- const sandboxDir = sandboxCommand ? requestedSandboxDir : undefined;
1419
+ const selectedTools = allow.split(",").filter((name) => name && !excludes.has(name));
1420
+ const unavailableTools = sandboxEnabled ? selectedTools.filter((name) => !(TASK_BUILTINS as readonly string[]).includes(name)) : [];
1421
+ if (sandboxEnabled && selectedTools.length && unavailableTools.length === selectedTools.length) {
1422
+ throw new Error(`No requested tool has a verified task sandbox adapter: ${unavailableTools.join(", ")}.`);
1423
+ }
1424
+ const taskRuntime = sandboxEnabled && requestedSandboxDir ? prepareTaskRuntime({
1425
+ root: requestedSandboxDir, controlDir: join(runDir(id), "control"), piBin,
1426
+ tools: selectedTools, permissions: permissionPlan.permissions,
1427
+ extensionPaths: extArgs.flatMap((arg, index) => arg === "--extension" && extArgs[index + 1] ? [extArgs[index + 1]!] : []),
1428
+ runtimeRoots: [baseDir()],
1429
+ }) : undefined;
1430
+ if (sandboxEnabled && !taskRuntime) throw new Error("Task sandbox has no workspace; refusing an unconfined child.");
1431
+ const cmd = taskRuntime ? { file: taskRuntime.file, fileArgs: [...taskRuntime.fileArgs, ...args] } : { file: piBin, fileArgs: args };
1432
+ const sandboxDir = taskRuntime ? requestedSandboxDir : undefined;
1343
1433
 
1344
1434
  const spawned = spawnDetached({ file: cmd.file, fileArgs: cmd.fileArgs, cwd, logPath: logPathFor(id) });
1345
1435
  // Record process identity (pgid, start-time token) so health
@@ -1357,7 +1447,7 @@ export default function (pi: ExtensionAPI) {
1357
1447
  promptPreview: p.prompt.slice(0, 200),
1358
1448
  startedAt: Date.now(), logPath: logPathFor(id), sessionId: id,
1359
1449
  callbackOrigin,
1360
- sandbox: sandboxDir, callback: p.callback !== false,
1450
+ sandbox: sandboxDir, taskRuntime: Boolean(taskRuntime), taskScratch: taskRuntime?.policy.scratch, callback: p.callback !== false,
1361
1451
  ...batchInfo,
1362
1452
  // The launch record is JSON. Registry freezes that value; it does not
1363
1453
  // require the resolver's nominal type to carry an index signature.
@@ -1379,11 +1469,11 @@ export default function (pi: ExtensionAPI) {
1379
1469
  : resolution.specs.length
1380
1470
  ? `Runtime: isolated · extensions ${resolution.specs.join(", ")}\n`
1381
1471
  : `Runtime: isolated · built-in tools only\n`;
1382
- const warn = resolution.unmapped.length
1472
+ const warn = (unavailableTools.length ? `Task sandbox: unavailable adapters for ${unavailableTools.join(", ")}; these tools are disabled.\n` : "") + (resolution.unmapped.length
1383
1473
  ? `NOTE: no extension mapped for ${resolution.unmapped.join(", ")} — ` +
1384
1474
  `${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
1385
1475
  `Add a toolExtensions entry in config.json.\n`
1386
- : "";
1476
+ : "");
1387
1477
  return { id, meta, spawned, runtime, warn, sandboxDir };
1388
1478
  }
1389
1479
 
@@ -1403,7 +1493,7 @@ export default function (pi: ExtensionAPI) {
1403
1493
  ...SUBAGENT_ORCHESTRATION_GUIDELINES,
1404
1494
  "The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
1405
1495
  ...CATALOG_GUIDELINES,
1406
- "By default the subagent is sandboxed (writes confined to its working dir, reads and network open) and triggers completion here on finish. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
1496
+ "By default the subagent is sandboxed. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Without published settings, legacy write confinement applies. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
1407
1497
  "Use git_clone_workspace:true when the subagent will mutate Git in a sandbox. The parent prepares a disposable, self-contained clone with a real .git/ directory inside the sandbox root, so linked-worktree metadata outside the sandbox cannot stall the child.",
1408
1498
  ],
1409
1499
  parameters: Type.Object({
@@ -1417,7 +1507,7 @@ export default function (pi: ExtensionAPI) {
1417
1507
  tools: Type.Optional(Type.String({ description: "Tool allowlist: comma-separated names the child may use (e.g. 'read,bash,web_fetch'). This ALSO selects which extensions load — only packages backing a requested tool are loaded. Defaults to the configured safe set." })),
1418
1508
  exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist, applied on top of the allowlist." })),
1419
1509
  clean: Type.Optional(Type.Boolean({ description: "Run a hermetic child with NO extensions at all (only built-ins: read, bash, edit, write). Default false — the extensions backing the requested tools load, so web_fetch and model auth (e.g. xai) work." })),
1420
- sandbox: Type.Optional(Type.Boolean({ description: "Default TRUE (macOS): kernel-confine the child's file WRITES to its working dir — reads and network stay open, but it cannot write outside, whatever it runs. Set false to allow writes anywhere." })),
1510
+ sandbox: Type.Optional(Type.Boolean({ description: "Use the human Subagents profile from /sandbox. An enabled human profile cannot be bypassed with false. Without published settings, defaults to kernel write confinement; false opts out of that legacy default." })),
1421
1511
  sandbox_dir: Type.Optional(Type.String({ description: "Confine writes to (and run the child in) this directory instead of the working dir. Created if missing." })),
1422
1512
  callback: Type.Optional(Type.Boolean({ description: "Default TRUE: on completion, trigger a turn that calls subagent_result and presents the result. Set false to finish quietly — the result is then read on demand via subagent_result." })),
1423
1513
  cwd: Type.Optional(Type.String({ description: "Working directory (default: current)." })),
@@ -1475,7 +1565,7 @@ export default function (pi: ExtensionAPI) {
1475
1565
  (p.callback === false
1476
1566
  ? `Running in the background; the foreground is free. It will finish quietly — read the result with subagent_result id=${id}.\n`
1477
1567
  : `Running in the background; the foreground is free. Its result will be posted back here when it finishes.\n`) +
1478
- (sandboxDir ? `Sandboxed: writes confined to ${sandboxDir}\n` : "") +
1568
+ (sandboxDir ? `Sandboxed: project root ${sandboxDir}; launch permissions apply.\n` : "") +
1479
1569
  runtime + warn +
1480
1570
  `Log: ${logPathFor(id)}`,
1481
1571
  );
@@ -1518,7 +1608,7 @@ export default function (pi: ExtensionAPI) {
1518
1608
  thinking: Type.Optional(Type.String({ description: "Reasoning effort applied to every job: off, minimal, low, medium, high, xhigh, or max." })),
1519
1609
  tools: Type.Optional(Type.String({ description: "Tool allowlist applied to every job." })),
1520
1610
  exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist applied to every job." })),
1521
- sandbox: Type.Optional(Type.Boolean({ description: "Default TRUE: kernel-confine writes to the working dir." })),
1611
+ sandbox: Type.Optional(Type.Boolean({ description: "Use the human Subagents profile; false cannot override an enabled profile. Legacy default is write confinement." })),
1522
1612
  sandbox_dir: Type.Optional(Type.String({ description: "Writable root for every job." })),
1523
1613
  callback: Type.Optional(Type.Boolean({ description: "Default TRUE: post result back on completion." })),
1524
1614
  clean: Type.Optional(Type.Boolean({ description: "Hermetic builtins-only child; no extensions load." })),
@@ -1810,7 +1900,7 @@ export default function (pi: ExtensionAPI) {
1810
1900
  // Resume supervision reconciliation + durable health-callback recovery
1811
1901
  // across /reload while current-parent work still needs the ticker
1812
1902
  // (running/orphaned, or unmarked lost); it stops itself when idle.
1813
- if (needsMonitoring(listMetasForParent(process.pid))) ensureHealthTicker();
1903
+ if (needsMonitoring(listMetasForParent(process.pid)) || hasPendingFailureCallbacks()) ensureHealthTicker();
1814
1904
  });
1815
1905
 
1816
1906
  pi.on("session_before_switch", () => {
package/list.mjs CHANGED
@@ -74,10 +74,11 @@ export function formatSubagentListRow(meta, p) {
74
74
  const name = meta.name ? `${meta.name} ` : "";
75
75
  const stat = `${elapsed}${spend ? ` · ${spend}` : ""}`;
76
76
  const health = formatListHealthSuffix(p.health);
77
+ const failure = p.failure ? `\n ${p.failure.replace(/\n/g, "\n ")}` : "";
77
78
  const batch = meta.batchId
78
79
  ? ` [batch: ${meta.batchName ? `${meta.batchName} ` : ""}${meta.batchId}]`
79
80
  : "";
80
- return `• ${name}${meta.id} [${status}] ${meta.model ?? "?"} ${stat}${health}${batch}\n ${promptPreview(meta)}`;
81
+ return `• ${name}${meta.id} [${status}] ${meta.model ?? "?"} ${stat}${health}${batch}${failure}\n ${promptPreview(meta)}`;
81
82
  }
82
83
 
83
84
  export function buildSubagentList(p) {
@@ -87,6 +88,7 @@ export function buildSubagentList(p) {
87
88
  const statusOf = p.statusOf ?? ((meta) => meta.status);
88
89
  const usageById = p.usageById ?? (() => undefined);
89
90
  const healthById = p.healthById ?? (() => undefined);
91
+ const failureById = p.failureById ?? (() => "");
90
92
 
91
93
  const scoped = (p.metas ?? [])
92
94
  .filter((meta) => options.all || meta.spawnPid === parentPid)
@@ -110,6 +112,7 @@ export function buildSubagentList(p) {
110
112
  now,
111
113
  usage: usageById(row.meta.id),
112
114
  health: healthById(row.meta.id),
115
+ failure: failureById(row.meta.id),
113
116
  })));
114
117
 
115
118
  if (matching.length > displayed.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,9 +30,9 @@
30
30
  "access": "public"
31
31
  },
32
32
  "scripts": {
33
- "pretest": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
34
- "prepack": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
35
- "typecheck": "node -e \"console.log('pi-better-subagents: typecheck skipped for legacy mixed TS/MJS package')\"",
33
+ "pretest": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
34
+ "prepack": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
35
+ "typecheck": "tsc -p tsconfig.task-runtime.json",
36
36
  "test": "node --import tsx --test tests/*.test.mjs",
37
37
  "test:cross-session": "node --import tsx --test --test-name-pattern \"callback session isolation\" tests/extension_health_lifecycle.test.mjs",
38
38
  "pretest:macos-sandbox": "node ../../scripts/sync-shared-sandbox-core.mjs",
@@ -51,11 +51,12 @@
51
51
  "docs/agent-catalog-operations.md",
52
52
  "docs/agent-model-resolution.md",
53
53
  "docs/agent-catalog-lifecycle.md",
54
- "docs/agent-catalog-acceptance.md"
54
+ "docs/agent-catalog-acceptance.md",
55
+ "docs/failure-observations.md"
55
56
  ],
56
57
  "peerDependencies": {
57
58
  "@earendil-works/pi-ai": "*",
58
- "@earendil-works/pi-coding-agent": "*",
59
+ "@earendil-works/pi-coding-agent": ">=0.82.1",
59
60
  "@earendil-works/pi-tui": "*"
60
61
  },
61
62
  "dependencies": {
@@ -0,0 +1,104 @@
1
+ /** Optional sandbox-extension policy, mirrored per Pi event bus at launch time. */
2
+ export const SANDBOX_POLICY_CHANNEL = "pi-better-sandbox:policy";
3
+ export const SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:policy-request";
4
+
5
+ type Access = "off" | "read" | "read-write";
6
+ export type PermissionProfile = Readonly<{
7
+ enabled: boolean;
8
+ projectFiles: Access;
9
+ outsideProject: Access;
10
+ storedCredentials: Access;
11
+ commands: boolean;
12
+ network: boolean;
13
+ }>;
14
+ export type PermissionSnapshot = Readonly<{
15
+ permissions?: PermissionProfile;
16
+ subagentPermissions?: PermissionProfile;
17
+ }>;
18
+
19
+ type EventBus = {
20
+ on(channel: string, handler: (data: unknown) => void): unknown;
21
+ emit(channel: string, data: unknown): void;
22
+ };
23
+ const mirrors = new WeakMap<EventBus, { policy?: PermissionSnapshot; error?: Error }>();
24
+
25
+ function busOf(pi: unknown): EventBus | undefined {
26
+ const bus = (pi as { events?: unknown } | undefined)?.events;
27
+ if (!bus || typeof bus !== "object") return undefined;
28
+ const candidate = bus as Partial<EventBus>;
29
+ return typeof candidate.on === "function" && typeof candidate.emit === "function" ? candidate as EventBus : undefined;
30
+ }
31
+
32
+ function profile(value: unknown): PermissionProfile {
33
+ if (!value || typeof value !== "object") throw new Error("Invalid sandbox permission profile.");
34
+ const p = value as Record<string, unknown>;
35
+ const access = (v: unknown): v is Access => v === "off" || v === "read" || v === "read-write";
36
+ if (typeof p.enabled !== "boolean" || typeof p.commands !== "boolean" || typeof p.network !== "boolean" ||
37
+ !access(p.projectFiles) || !access(p.outsideProject) || !access(p.storedCredentials)) {
38
+ throw new Error("Invalid sandbox permission profile; update permissions in the sandbox UI.");
39
+ }
40
+ return Object.freeze({
41
+ enabled: p.enabled, commands: p.commands, network: p.network,
42
+ projectFiles: p.projectFiles, outsideProject: p.outsideProject, storedCredentials: p.storedCredentials,
43
+ });
44
+ }
45
+
46
+ function readPolicy(data: unknown): PermissionSnapshot | undefined {
47
+ if (!data || typeof data !== "object") return undefined;
48
+ const p = data as Record<string, unknown>;
49
+ if (!["inactive", "enabled", "disabled", "unavailable", "failed"].includes(String(p.state))) return undefined;
50
+ return Object.freeze({
51
+ ...(p.permissions === undefined ? {} : { permissions: profile(p.permissions) }),
52
+ ...(p.subagentPermissions === undefined ? {} : { subagentPermissions: profile(p.subagentPermissions) }),
53
+ });
54
+ }
55
+
56
+ /** Subscribe before launching; request a replay for either extension load order. */
57
+ export function observeSandboxPermissions(pi: unknown): void {
58
+ const bus = busOf(pi);
59
+ if (!bus || mirrors.has(bus)) return;
60
+ const mirror: { policy?: PermissionSnapshot; error?: Error } = {};
61
+ mirrors.set(bus, mirror);
62
+ bus.on(SANDBOX_POLICY_CHANNEL, (data) => {
63
+ try {
64
+ const next = readPolicy(data);
65
+ if (next) { mirror.policy = next; mirror.error = undefined; }
66
+ } catch (error) {
67
+ mirror.error = error as Error;
68
+ }
69
+ });
70
+ bus.emit(SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
71
+ }
72
+
73
+ export function currentSandboxPermissions(pi: unknown): PermissionSnapshot | undefined {
74
+ observeSandboxPermissions(pi);
75
+ const bus = busOf(pi);
76
+ if (!bus) return undefined;
77
+ bus.emit(SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
78
+ const mirror = mirrors.get(bus);
79
+ if (mirror?.error) throw mirror.error;
80
+ return mirror?.policy;
81
+ }
82
+
83
+ /** Resolve before allocating a run. Absent settings retain the legacy default-on policy. */
84
+ export function resolveSubagentPermissions(pi: unknown, requestedSandbox: boolean | undefined): {
85
+ sandboxEnabled: boolean;
86
+ enforced: boolean;
87
+ permissions?: Omit<PermissionProfile, "enabled">;
88
+ } {
89
+ const snapshot = currentSandboxPermissions(pi);
90
+ if (snapshot?.permissions?.enabled && !snapshot.permissions.commands) {
91
+ throw new Error("Main sandbox profile disables commands. Enable commands in the sandbox UI before launching a subagent.");
92
+ }
93
+ const child = snapshot?.subagentPermissions;
94
+ if (!child) return { sandboxEnabled: requestedSandbox !== false, enforced: false };
95
+ if (child.enabled && requestedSandbox === false) {
96
+ throw new Error("Subagent sandbox is enforced by the human-enabled profile; sandbox:false cannot bypass it. Change Subagents permissions in the sandbox UI.");
97
+ }
98
+ const sandboxEnabled = child.enabled || requestedSandbox === true;
99
+ if (!sandboxEnabled) return { sandboxEnabled: false, enforced: false };
100
+ // Starting Pi is runtime work. These capabilities constrain task tools;
101
+ // they do not disable provider transport or the fixed file-operation worker.
102
+ const { enabled: _enabled, ...permissions } = child;
103
+ return { sandboxEnabled: true, enforced: true, permissions };
104
+ }
package/registry.ts CHANGED
@@ -14,10 +14,10 @@
14
14
 
15
15
  import { execFileSync } from "node:child_process";
16
16
  import { createHash, randomBytes } from "node:crypto";
17
- import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
18
18
  import { createRequire } from "node:module";
19
19
  import { tmpdir } from "node:os";
20
- import { dirname, join } from "node:path";
20
+ import { basename, dirname, join } from "node:path";
21
21
  import { processExists } from "./spawn.ts";
22
22
  import type { LifecycleClassification } from "./lifecycle.ts";
23
23
 
@@ -149,6 +149,9 @@ export interface RunMeta {
149
149
  lostCallbackSuppressedReason?: string;
150
150
  /** Writable dir the child is OS-sandboxed to, if any. */
151
151
  sandbox?: string;
152
+ /** Pi is trusted; model task operations use an immutable kernel policy. */
153
+ taskRuntime?: boolean;
154
+ taskScratch?: string;
152
155
  /** Whether completion posts the result back to the main session (default true). */
153
156
  callback?: boolean;
154
157
  /** Batch ID for runs launched via subagent_spawn_batch. */
@@ -176,11 +179,16 @@ export interface RunMeta {
176
179
 
177
180
  /** Root runtime dir, deliberately OUTSIDE any repo. */
178
181
  export function baseDir(): string {
179
- return join(tmpdir(), "pi-better-subagents");
182
+ return join(realpathSync(tmpdir()), "pi-better-subagents");
180
183
  }
181
184
  export function sessionsDir(): string {
182
185
  return join(baseDir(), "sessions");
183
186
  }
187
+ export function taskWorkspaceDir(id: string): string {
188
+ if (!/^sa_[a-z0-9]+_[a-z0-9]+$/i.test(id)) throw new Error("Invalid task workspace run ID.");
189
+ return join(realpathSync(tmpdir()), "pi-better-subagent-workspaces", id);
190
+ }
191
+
184
192
  export function runDir(id: string): string {
185
193
  return join(baseDir(), "runs", id);
186
194
  }
@@ -837,6 +845,12 @@ export function readMeta(id: string): RunMeta | undefined {
837
845
  export function removeMetaArtifacts(meta: RunMeta): boolean {
838
846
  try {
839
847
  rmSync(runDir(meta.id), { recursive: true, force: true });
848
+ if (meta.taskRuntime && /^sa_[a-z0-9]+_[a-z0-9]+$/i.test(meta.id) && meta.cwd === taskWorkspaceDir(meta.id)) {
849
+ rmSync(taskWorkspaceDir(meta.id), { recursive: true, force: true });
850
+ }
851
+ if (meta.taskRuntime && meta.taskScratch && dirname(meta.taskScratch) === realpathSync(tmpdir()) && /^pi-task-scratch-[a-z0-9]{6}$/i.test(basename(meta.taskScratch))) {
852
+ rmSync(meta.taskScratch, { recursive: true, force: true });
853
+ }
840
854
  metaCache.delete(meta.id);
841
855
  removeIndexEntry(join(baseDir(), "by-parent", String(meta.spawnPid)), meta.id);
842
856
  removeIndexEntry(join(baseDir(), "by-parent-active", String(meta.spawnPid)), meta.id);