pi-subagents 0.40.0 → 0.42.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.
Files changed (121) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +54 -1879
  3. package/agents/oracle.md +1 -0
  4. package/inspector-runner.mjs +11 -0
  5. package/package.json +13 -5
  6. package/prompts/parallel-context-build.md +1 -1
  7. package/prompts/parallel-handoff-plan.md +1 -1
  8. package/prompts/review-loop.md +1 -1
  9. package/skills/pi-subagents/SKILL.md +9 -7
  10. package/skills/pi-subagents/references/constraints-and-recipes.md +24 -32
  11. package/skills/pi-subagents/references/execution-controls.md +99 -97
  12. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  13. package/skills/pi-subagents/references/prompting-and-roles.md +23 -30
  14. package/src/agents/agent-management.ts +155 -65
  15. package/src/agents/agent-serializer.ts +19 -0
  16. package/src/agents/agents.ts +154 -71
  17. package/src/agents/chain-serializer.ts +10 -7
  18. package/src/agents/frontmatter.ts +5 -3
  19. package/src/agents/identity.ts +1 -1
  20. package/src/agents/proactive-skills.ts +13 -10
  21. package/src/agents/skills.ts +23 -6
  22. package/src/api/control-channel.ts +4 -0
  23. package/src/api/delegation.ts +26 -194
  24. package/src/api/external-runs.ts +129 -0
  25. package/src/api/intercom-bridge.ts +3 -0
  26. package/src/api/pi-args.ts +5 -0
  27. package/src/api/preflight.ts +3 -3
  28. package/src/api/shared-types.ts +19 -0
  29. package/src/extension/config.ts +10 -0
  30. package/src/extension/control-notices.ts +5 -39
  31. package/src/extension/doctor.ts +10 -9
  32. package/src/extension/fanout-child.ts +7 -4
  33. package/src/extension/index.ts +232 -68
  34. package/src/extension/rpc.ts +18 -12
  35. package/src/extension/schemas.ts +48 -37
  36. package/src/extension/tool-description.ts +36 -86
  37. package/src/inspectors/herdr/actions.ts +229 -0
  38. package/src/inspectors/herdr/client.ts +130 -0
  39. package/src/inspectors/herdr/inspector-runner.ts +141 -0
  40. package/src/inspectors/herdr/project-panes.ts +154 -0
  41. package/src/integrations/herdr-status.ts +330 -0
  42. package/src/intercom/intercom-bridge.ts +3 -2
  43. package/src/intercom/result-intercom.ts +5 -1
  44. package/src/missions/actions.ts +374 -0
  45. package/src/missions/lifecycle.ts +314 -0
  46. package/src/missions/store.ts +442 -0
  47. package/src/missions/types.ts +135 -0
  48. package/src/policy/authority.ts +46 -0
  49. package/src/profiles/profiles.ts +29 -3
  50. package/src/runs/background/async-execution.ts +110 -50
  51. package/src/runs/background/async-job-tracker.ts +27 -4
  52. package/src/runs/background/async-resume.ts +6 -6
  53. package/src/runs/background/async-status.ts +29 -1
  54. package/src/runs/background/auto-drain.ts +3 -3
  55. package/src/runs/background/chain-append.ts +3 -2
  56. package/src/runs/background/control-channel.ts +9 -7
  57. package/src/runs/background/fleet-view.ts +3 -4
  58. package/src/runs/background/notify.ts +2 -1
  59. package/src/runs/background/process-terminal.ts +5 -5
  60. package/src/runs/background/result-watcher.ts +13 -5
  61. package/src/runs/background/run-id-resolver.ts +3 -3
  62. package/src/runs/background/run-status.ts +35 -8
  63. package/src/runs/background/scheduled-runs.ts +602 -375
  64. package/src/runs/background/stale-run-reconciler.ts +3 -3
  65. package/src/runs/background/subagent-runner.ts +608 -445
  66. package/src/runs/background/subagent-wait.ts +50 -9
  67. package/src/runs/background/wait-subscriptions.ts +253 -0
  68. package/src/runs/background/wait-tool.ts +12 -4
  69. package/src/runs/foreground/async-steering-action.ts +3 -3
  70. package/src/runs/foreground/chain-clarify.ts +8 -4
  71. package/src/runs/foreground/chain-execution.ts +56 -30
  72. package/src/runs/foreground/execution.ts +34 -9
  73. package/src/runs/foreground/subagent-executor.ts +1036 -274
  74. package/src/runs/shared/acceptance.ts +28 -6
  75. package/src/runs/shared/child-protocol.ts +302 -22
  76. package/src/runs/shared/dynamic-fanout.ts +1 -1
  77. package/src/runs/shared/external-cli-runner.ts +130 -0
  78. package/src/runs/shared/long-running-guard.ts +42 -1
  79. package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -0
  80. package/src/runs/shared/nested-events.ts +59 -5
  81. package/src/runs/shared/nested-render.ts +9 -4
  82. package/src/runs/shared/parallel-handoff.ts +86 -2
  83. package/src/runs/shared/parallel-utils.ts +11 -2
  84. package/src/runs/shared/permissions.ts +95 -0
  85. package/src/runs/shared/pi-args.ts +468 -109
  86. package/src/runs/shared/pi-spawn.ts +11 -1
  87. package/src/runs/shared/run-history.ts +1 -1
  88. package/src/runs/shared/subagent-prompt-runtime.ts +36 -5
  89. package/src/runs/shared/subagent-startup-retry.ts +5 -2
  90. package/src/runs/shared/turn-budget.ts +6 -6
  91. package/src/runs/shared/worktree.ts +122 -12
  92. package/src/shared/accessible-dir.ts +29 -7
  93. package/src/shared/artifacts.ts +143 -1
  94. package/src/shared/fork-context.ts +3 -2
  95. package/src/shared/launch-contract.ts +1 -0
  96. package/src/shared/settings.ts +10 -0
  97. package/src/shared/types.ts +156 -14
  98. package/src/shared/utils.ts +8 -6
  99. package/src/slash/delegation-adapters.ts +32 -194
  100. package/src/slash/delegation-request.ts +43 -126
  101. package/src/slash/prompt-template-bridge.ts +158 -205
  102. package/src/slash/prompt-workflows.ts +21 -57
  103. package/src/slash/slash-bridge.ts +14 -0
  104. package/src/slash/slash-commands.ts +31 -632
  105. package/src/slash/subagents-admin.ts +18 -14
  106. package/src/tui/fleet-status.ts +156 -21
  107. package/src/tui/fleet-transcript.ts +110 -5
  108. package/src/tui/fleet.ts +57 -25
  109. package/src/tui/render.ts +368 -152
  110. package/src/types/pi-runtime-compat.d.ts +8 -0
  111. package/src/watchdog/lsp-diagnostics.ts +12 -7
  112. package/src/watchdog/model-selection.ts +2 -2
  113. package/src/watchdog/permission-arbiter.ts +145 -0
  114. package/src/watchdog/register-child.ts +1 -1
  115. package/src/watchdog/register-main.ts +1 -1
  116. package/src/watchdog/review.ts +4 -1
  117. package/src/watchdog/runtime.ts +3 -2
  118. package/src/workflows/chat-progress.ts +140 -0
  119. package/src/workflows/scripted-workflow.ts +415 -0
  120. package/agents/advisor.md +0 -73
  121. package/src/extension/chain-validation.ts +0 -181
@@ -38,6 +38,7 @@ interface ServerEntry {
38
38
  exposeResources?: boolean;
39
39
  includeTools?: string[];
40
40
  excludeTools?: string[];
41
+ protocolVersion?: string;
41
42
  directTools?: boolean | string[];
42
43
  }
43
44
 
@@ -284,6 +285,7 @@ export function computeMcpServerHash(definition: ServerEntry): string {
284
285
  exposeResources: definition.exposeResources,
285
286
  includeTools: definition.includeTools,
286
287
  excludeTools: definition.excludeTools,
288
+ protocolVersion: definition.protocolVersion,
287
289
  };
288
290
  return createHash("sha256").update(stableStringify(identity)).digest("hex");
289
291
  }
@@ -2,8 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import {
5
- ASYNC_DIR,
6
- RESULTS_DIR,
5
+ DIRS,
7
6
  TEMP_ROOT_DIR,
8
7
  type AsyncJobState,
9
8
  type AsyncStatus,
@@ -30,6 +29,7 @@ import {
30
29
  } from "./pi-args.ts";
31
30
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
32
31
  import { sanitizeProcessTerminal } from "../background/process-terminal.ts";
32
+ import { THINKING_LEVELS } from "../../shared/model-info.ts";
33
33
 
34
34
  export const NESTED_EVENTS_DIR = path.join(TEMP_ROOT_DIR, "nested-subagent-events");
35
35
  const ROUTE_FILE = "route.json";
@@ -281,9 +281,13 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
281
281
  const status = raw.status === "pending" || raw.status === "running" || raw.status === "complete" || raw.status === "completed" || raw.status === "failed" || raw.status === "paused" || raw.status === "stopped"
282
282
  ? raw.status
283
283
  : "pending";
284
+ const model = stringValue(raw.model);
285
+ const thinking = THINKING_LEVELS.find((level) => level === raw.thinking);
284
286
  return {
285
287
  agent,
286
288
  status,
289
+ ...(model ? { model } : {}),
290
+ ...(thinking ? { thinking } : {}),
287
291
  ...(stringValue(raw.sessionFile, 2048) ? { sessionFile: stringValue(raw.sessionFile, 2048) } : {}),
288
292
  ...(raw.activityState === "active_long_running" || raw.activityState === "needs_attention" ? { activityState: raw.activityState } : {}),
289
293
  ...(clampNumber(raw.lastActivityAt) !== undefined ? { lastActivityAt: clampNumber(raw.lastActivityAt) } : {}),
@@ -324,6 +328,8 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
324
328
  depth: Math.min(Math.max(0, clampNumber(raw.depth) ?? 0), MAX_DEPTH),
325
329
  path: pathParts,
326
330
  state: sanitizeState(raw.state, "running"),
331
+ ...(stringValue(raw.model) ? { model: stringValue(raw.model) } : {}),
332
+ ...(THINKING_LEVELS.find((level) => level === raw.thinking) ? { thinking: THINKING_LEVELS.find((level) => level === raw.thinking) } : {}),
327
333
  ...(stringValue(raw.asyncDir, 2048) ? { asyncDir: stringValue(raw.asyncDir, 2048) } : {}),
328
334
  ...(clampNumber(raw.pid) !== undefined && clampNumber(raw.pid)! > 0 && Number.isInteger(clampNumber(raw.pid)) ? { pid: clampNumber(raw.pid) } : {}),
329
335
  ...(stringValue(raw.sessionId, 256) ? { sessionId: stringValue(raw.sessionId, 256) } : {}),
@@ -415,6 +421,36 @@ function terminal(state: NestedRunState): boolean {
415
421
  return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
416
422
  }
417
423
 
424
+ function mergeBoundedChildren(existing: NestedRunSummary[] | undefined, incoming: NestedRunSummary[] | undefined): NestedRunSummary[] | undefined {
425
+ if (incoming === undefined) return existing?.slice(0, MAX_CHILDREN);
426
+ const incomingById = new Map(incoming.map((child) => [child.id, child]));
427
+ const merged = (existing ?? []).map((child) => incomingById.get(child.id) ?? child);
428
+ for (const child of incoming) {
429
+ if (!existing?.some((prior) => prior.id === child.id)) merged.push(child);
430
+ }
431
+ return merged.slice(0, MAX_CHILDREN);
432
+ }
433
+
434
+ function mergeStepSummary(existing: NestedStepSummary | undefined, incoming: NestedStepSummary): NestedStepSummary {
435
+ if (!existing || existing.agent !== incoming.agent) return { ...incoming, ...(incoming.children ? { children: incoming.children.slice(0, MAX_CHILDREN) } : {}) };
436
+ const metadata = { ...existing } as Partial<NestedStepSummary>;
437
+ delete metadata.status;
438
+ delete metadata.activityState;
439
+ delete metadata.lastActivityAt;
440
+ delete metadata.currentTool;
441
+ delete metadata.currentToolStartedAt;
442
+ delete metadata.currentPath;
443
+ delete metadata.turnCount;
444
+ delete metadata.toolCount;
445
+ delete metadata.children;
446
+ const children = mergeBoundedChildren(existing.children, incoming.children);
447
+ return {
448
+ ...metadata,
449
+ ...incoming,
450
+ ...(children ? { children } : {}),
451
+ };
452
+ }
453
+
418
454
  function mergeSummary(existing: NestedRunSummary | undefined, event: NestedEventRecord): NestedRunSummary {
419
455
  const incomingState = event.type === "subagent.nested.completed" && event.child.state === "running" ? "complete" : event.child.state;
420
456
  const incoming = { ...event.child, state: incomingState, lastUpdate: event.child.lastUpdate ?? event.ts };
@@ -424,7 +460,21 @@ function mergeSummary(existing: NestedRunSummary | undefined, event: NestedEvent
424
460
  if (incomingUpdate < existingUpdate) return existing;
425
461
  if (terminal(existing.state) && !terminal(incoming.state)) return existing;
426
462
  if (terminal(existing.state) && terminal(incoming.state) && incomingUpdate === existingUpdate) return existing;
427
- return { ...existing, ...incoming, state: incoming.state, lastUpdate: Math.max(existingUpdate, incomingUpdate) };
463
+ const existingSteps = existing.steps ?? [];
464
+ const incomingSteps = incoming.steps;
465
+ const steps = incomingSteps === undefined
466
+ ? existing.steps
467
+ : Array.from({ length: Math.min(MAX_STEPS, Math.max(existingSteps.length, incomingSteps.length)) }, (_, index) =>
468
+ incomingSteps[index] ? mergeStepSummary(existingSteps[index], incomingSteps[index]!) : existingSteps[index]!).filter((step): step is NestedStepSummary => Boolean(step));
469
+ const children = mergeBoundedChildren(existing.children, incoming.children);
470
+ return {
471
+ ...existing,
472
+ ...incoming,
473
+ ...(steps ? { steps } : {}),
474
+ ...(children ? { children } : {}),
475
+ state: incoming.state,
476
+ lastUpdate: Math.max(existingUpdate, incomingUpdate),
477
+ };
428
478
  }
429
479
 
430
480
  function attachChild(children: NestedRunSummary[], event: NestedEventRecord): NestedRunSummary[] {
@@ -947,6 +997,8 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
947
997
  ...(status.pid ? { pid: status.pid } : {}),
948
998
  ...(status.sessionId ? { sessionId: status.sessionId } : {}),
949
999
  mode: status.mode ?? fallback.mode,
1000
+ ...(status.steps?.length === 1 && status.steps[0]?.model ? { model: status.steps[0].model } : {}),
1001
+ ...(status.steps?.length === 1 && status.steps[0]?.thinking ? { thinking: status.steps[0].thinking } : {}),
950
1002
  ...(status.processTerminal ? { processTerminal: sanitizeProcessTerminal(status.processTerminal, { runId: status.runId || fallback.id, runnerProcessInstanceId: status.processTerminal.runnerProcessInstanceId }, `${asyncDir}/status.json`) } : {}),
951
1003
  ...(status.launchResolvedExtensions ? { launchResolvedExtensions: status.launchResolvedExtensions } : {}),
952
1004
  ...runtimeAcknowledgedEntry(status.runtimeAcknowledgedExtensions),
@@ -978,6 +1030,8 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
978
1030
  ...(status.steps?.length ? { steps: status.steps.map((step, index) => ({
979
1031
  agent: step.agent,
980
1032
  status: step.status,
1033
+ ...(step.model ? { model: step.model } : {}),
1034
+ ...(step.thinking ? { thinking: step.thinking } : {}),
981
1035
  ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
982
1036
  ...(step.activityState ? { activityState: step.activityState } : {}),
983
1037
  ...(step.lastActivityAt !== undefined ? { lastActivityAt: step.lastActivityAt } : {}),
@@ -1012,11 +1066,11 @@ export function nestedArtifactEnv(rootRunId: string, parentRunId: string): Recor
1012
1066
 
1013
1067
  export function isTopLevelAsyncDir(asyncDir: string): boolean {
1014
1068
  const resolved = path.resolve(asyncDir);
1015
- return containedPath(ASYNC_DIR, resolved) && !containedPath(path.join(TEMP_ROOT_DIR, "nested-subagent-runs"), resolved);
1069
+ return containedPath(DIRS.async, resolved) && !containedPath(path.join(TEMP_ROOT_DIR, "nested-subagent-runs"), resolved);
1016
1070
  }
1017
1071
 
1018
1072
  export function nestedResultsPath(rootRunId: string, id: string): string {
1019
1073
  assertSafeId("rootRunId", rootRunId);
1020
1074
  assertSafeId("id", id);
1021
- return path.join(RESULTS_DIR, "nested", rootRunId, `${id}.json`);
1075
+ return path.join(DIRS.results, "nested", rootRunId, `${id}.json`);
1022
1076
  }
@@ -1,4 +1,4 @@
1
- import { formatDuration, formatTokens, shortenPath } from "../../shared/formatters.ts";
1
+ import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
2
2
  import { formatActivityLabel } from "../../shared/status-format.ts";
3
3
  import type { ActivityState, NestedRunSummary, NestedStepSummary } from "../../shared/types.ts";
4
4
 
@@ -8,12 +8,13 @@ export interface NestedRunCounts {
8
8
  paused: number;
9
9
  complete: number;
10
10
  failed: number;
11
+ rejected: number;
11
12
  stopped: number;
12
13
  queued: number;
13
14
  }
14
15
 
15
16
  export function countNestedRuns(children: NestedRunSummary[] | undefined): NestedRunCounts {
16
- const counts: NestedRunCounts = { total: 0, running: 0, paused: 0, complete: 0, failed: 0, stopped: 0, queued: 0 };
17
+ const counts: NestedRunCounts = { total: 0, running: 0, paused: 0, complete: 0, failed: 0, rejected: 0, stopped: 0, queued: 0 };
17
18
  for (const child of children ?? []) {
18
19
  counts.total++;
19
20
  counts[child.state]++;
@@ -23,6 +24,7 @@ export function countNestedRuns(children: NestedRunSummary[] | undefined): Neste
23
24
  counts.paused += nested.paused;
24
25
  counts.complete += nested.complete;
25
26
  counts.failed += nested.failed;
27
+ counts.rejected += nested.rejected;
26
28
  counts.stopped += nested.stopped;
27
29
  counts.queued += nested.queued;
28
30
  }
@@ -36,6 +38,7 @@ export function formatNestedAggregate(children: NestedRunSummary[] | undefined):
36
38
  counts.running > 0 ? `${counts.running} running` : "",
37
39
  counts.paused > 0 ? `${counts.paused} paused` : "",
38
40
  counts.failed > 0 ? `${counts.failed} failed` : "",
41
+ counts.rejected > 0 ? `${counts.rejected} rejected` : "",
39
42
  counts.stopped > 0 ? `${counts.stopped} stopped` : "",
40
43
  counts.complete > 0 ? `${counts.complete} complete` : "",
41
44
  counts.queued > 0 ? `${counts.queued} queued` : "",
@@ -88,7 +91,8 @@ function formatNestedRunLines(children: NestedRunSummary[] | undefined, options:
88
91
  }
89
92
  const activity = child.state === "running" ? formatNestedActivity(child) : undefined;
90
93
  const error = child.error ? ` | error: ${child.error}` : "";
91
- lines.push(`${indent}↳ ${nestedRunLabel(child)} [${child.id}] ${child.state}${activity ? ` | ${activity}` : ""}${error}`);
94
+ const modelThinking = formatModelThinking(child.model, child.thinking);
95
+ lines.push(`${indent}↳ ${nestedRunLabel(child)} [${child.id}] ${child.state}${modelThinking ? ` | ${modelThinking}` : ""}${activity ? ` | ${activity}` : ""}${error}`);
92
96
  if (options.commandHints && lines.length < options.maxLines) lines.push(`${indent} Status: subagent({ action: "status", id: "${child.id}" })`);
93
97
  if (depth === options.maxDepth) {
94
98
  const aggregate = formatNestedAggregate([...(child.steps?.flatMap((step) => step.children ?? []) ?? []), ...(child.children ?? [])]);
@@ -98,7 +102,8 @@ function formatNestedRunLines(children: NestedRunSummary[] | undefined, options:
98
102
  for (const [stepIndex, step] of (child.steps ?? []).entries()) {
99
103
  if (lines.length >= options.maxLines) return;
100
104
  const stepActivity = step.status === "running" ? formatNestedActivity(step) : undefined;
101
- lines.push(`${indent} ${stepIndex + 1}. ${step.agent} ${step.status}${stepActivity ? ` | ${stepActivity}` : ""}${step.error ? ` | error: ${step.error}` : ""}`);
105
+ const stepModelThinking = formatModelThinking(step.model, step.thinking);
106
+ lines.push(`${indent} ${stepIndex + 1}. ${step.agent} ${step.status}${stepModelThinking ? ` | ${stepModelThinking}` : ""}${stepActivity ? ` | ${stepActivity}` : ""}${step.error ? ` | error: ${step.error}` : ""}`);
102
107
  append(step.children, depth + 1, `${indent} `);
103
108
  }
104
109
  append(child.children, depth + 1, `${indent} `);
@@ -11,7 +11,9 @@ import type {
11
11
  WorktreeCleanupReport,
12
12
  WorktreeDiff,
13
13
  WorktreeSetup,
14
+ WorktreeCleanupIntent,
14
15
  } from "./worktree.ts";
16
+ import { cleanupWorktrees } from "./worktree.ts";
15
17
 
16
18
  export interface ParallelHandoffResult {
17
19
  agent: string;
@@ -79,7 +81,7 @@ export function writeParallelHandoffGroup(input: {
79
81
  flatStartIndex: number;
80
82
  setup: WorktreeSetup;
81
83
  diffs: WorktreeDiff[];
82
- cleanup: WorktreeCleanupReport;
84
+ cleanup?: WorktreeCleanupReport;
83
85
  results: ParallelHandoffResult[];
84
86
  now?: number;
85
87
  }): ParallelHandoffReference {
@@ -122,7 +124,19 @@ export function writeParallelHandoffGroup(input: {
122
124
  },
123
125
  };
124
126
  }),
125
- cleanup: input.cleanup,
127
+ cleanup: input.cleanup ?? {
128
+ state: "partial",
129
+ pruned: false,
130
+ tasks: input.setup.worktrees.map((worktree) => ({
131
+ index: worktree.index,
132
+ path: worktree.path,
133
+ branch: worktree.branch,
134
+ worktreeRemoved: false,
135
+ branchRemoved: false,
136
+ preserved: true,
137
+ reason: "cleanup pending durable handoff capture",
138
+ })),
139
+ },
126
140
  };
127
141
  const groups = existing?.groups.filter((candidate) => candidate.stepIndex !== input.stepIndex) ?? [];
128
142
  groups.push(group);
@@ -145,6 +159,19 @@ export function parallelHandoffPath(baseDir: string, runId?: string): string {
145
159
  return runId ? path.join(baseDir, "handoffs", `${runId}.json`) : path.join(baseDir, "handoff.json");
146
160
  }
147
161
 
162
+ export function writePendingParallelHandoff(input: {
163
+ manifestPath: string;
164
+ runId: string;
165
+ mode: "parallel" | "chain";
166
+ source: "foreground" | "async";
167
+ cwd: string;
168
+ stepIndex: number;
169
+ flatStartIndex: number;
170
+ setup: WorktreeSetup;
171
+ }): ParallelHandoffReference {
172
+ return writeParallelHandoffGroup({ ...input, diffs: [], results: [] });
173
+ }
174
+
148
175
  export function formatParallelHandoffReference(reference: ParallelHandoffReference): string {
149
176
  return `Parallel handoff: ${reference.path} (${reference.childCount} children, ${reference.changedPatches} changed patches, cleanup ${reference.cleanupState})`;
150
177
  }
@@ -152,3 +179,60 @@ export function formatParallelHandoffReference(reference: ParallelHandoffReferen
152
179
  export function formatParallelHandoffError(error: unknown): string {
153
180
  return `Parallel handoff unavailable: ${error instanceof Error ? error.message : String(error)}`;
154
181
  }
182
+
183
+ export function discardPreservedWorktrees(
184
+ manifestPath: string,
185
+ authorization: Extract<WorktreeCleanupIntent, { kind: "discard" }>["authorization"],
186
+ ): { manifest: ParallelHandoffManifest; text: string } {
187
+ const resolvedPath = path.resolve(manifestPath);
188
+ const manifest = readManifest(resolvedPath);
189
+ if (!manifest) throw new Error(`Parallel handoff manifest not found: ${resolvedPath}`);
190
+ let attempted = 0;
191
+ for (const group of manifest.groups) {
192
+ const pending = group.cleanup.tasks.filter((task) => task.preserved && (!task.worktreeRemoved || !task.branchRemoved));
193
+ if (pending.length === 0) continue;
194
+ attempted += pending.length;
195
+ const report = cleanupWorktrees({
196
+ cwd: group.repoRoot,
197
+ baseCommit: group.baseCommit,
198
+ worktrees: pending.map((task) => ({
199
+ path: task.path,
200
+ agentCwd: task.path,
201
+ branch: task.branch,
202
+ index: task.index,
203
+ nodeModulesLinked: false,
204
+ syntheticPaths: [],
205
+ })),
206
+ }, { kind: "discard", authorization });
207
+ const updates = new Map(report.tasks.map((task) => [task.index, task.worktreeRemoved && task.branchRemoved
208
+ ? task
209
+ : { ...task, preserved: true, reason: task.reason ?? "discard cleanup remains incomplete" }]));
210
+ group.cleanup = {
211
+ state: group.cleanup.tasks.every((task) => {
212
+ const next = updates.get(task.index) ?? task;
213
+ return next.worktreeRemoved && next.branchRemoved;
214
+ }) && report.pruned ? "complete" : "partial",
215
+ tasks: group.cleanup.tasks.map((task) => updates.get(task.index) ?? task),
216
+ pruned: report.pruned,
217
+ ...(report.errors ? { errors: report.errors } : {}),
218
+ };
219
+ }
220
+ manifest.updatedAt = Date.now();
221
+ writeAtomicJson(resolvedPath, manifest);
222
+ const remaining = manifest.groups.flatMap((group) => group.cleanup.tasks.map((task) => ({ group, task })))
223
+ .filter(({ task }) => task.preserved && (!task.worktreeRemoved || !task.branchRemoved));
224
+ const lines = attempted === 0
225
+ ? [`No preserved worktrees remain in ${resolvedPath}.`]
226
+ : [`Discard processed ${attempted} preserved worktree${attempted === 1 ? "" : "s"}.`, `Manifest: ${resolvedPath}`];
227
+ if (remaining.length > 0) {
228
+ lines.push("", "Some worktrees remain. Inspect and remove them manually if appropriate:");
229
+ for (const { group, task } of remaining) {
230
+ lines.push(
231
+ ` git -C ${JSON.stringify(group.repoRoot)} status --short`,
232
+ ` git -C ${JSON.stringify(group.repoRoot)} worktree remove --force ${JSON.stringify(task.path)}`,
233
+ ` git -C ${JSON.stringify(group.repoRoot)} branch -D ${JSON.stringify(task.branch)}`,
234
+ );
235
+ }
236
+ }
237
+ return { manifest, text: lines.join("\n") };
238
+ }
@@ -1,8 +1,13 @@
1
+ export type ResolvedRunnerConfig = import("../../shared/types.ts").AgentRunnerConfig;
2
+
1
3
  export interface RunnerSubagentStep {
2
4
  /** Session id of the direct parent session for permission-system ask forwarding. */
3
5
  parentSessionId?: string;
6
+ /** Resolved opt-in rules for native Pi child tool calls. */
7
+ permissionRules?: import("./permissions.ts").PermissionRules;
4
8
  agent: string;
5
9
  task: string;
10
+ runner?: ResolvedRunnerConfig;
6
11
  /** Resolved launch context for this child. */
7
12
  context?: "fresh" | "fork";
8
13
  importAsyncRoot?: {
@@ -86,6 +91,8 @@ export interface DynamicRunnerGroup {
86
91
  acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
87
92
  agentContract?: import("../../shared/types.ts").AgentContract;
88
93
  gateOn?: import("../../shared/types.ts").ChainGateLayer;
94
+ capabilityCeiling?: import("./capability-ceiling.ts").ResolvedSubagentCapabilityCeiling;
95
+ capabilityAudit?: import("./capability-ceiling.ts").SubagentCapabilityAudit;
89
96
  }
90
97
 
91
98
  export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup | RunnerCheckpointStep;
@@ -178,15 +185,17 @@ export async function mapConcurrent<T, R>(
178
185
  try {
179
186
  while (next < items.length) {
180
187
  const i = next++;
188
+ if (!(i in items)) throw new Error(`Missing parallel item at index ${i}`);
189
+ const item = items[i] as T;
181
190
  if (globalSemaphore) {
182
191
  await globalSemaphore.acquire();
183
192
  try {
184
- results[i] = await fn(items[i], i);
193
+ results[i] = await fn(item, i);
185
194
  } finally {
186
195
  globalSemaphore.release();
187
196
  }
188
197
  } else {
189
- results[i] = await fn(items[i], i);
198
+ results[i] = await fn(item, i);
190
199
  }
191
200
  }
192
201
  } finally {
@@ -0,0 +1,95 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export type PermissionDecision = "allow" | "ask" | "deny";
5
+ export type PermissionRules = Record<string, PermissionDecision>;
6
+ export interface PermissionConfig { rules?: PermissionRules }
7
+
8
+ export const PERMISSION_POLICY_ENV = "PI_SUBAGENT_PERMISSION_POLICY";
9
+ export const PERMISSION_AUDIT_PATH_ENV = "PI_SUBAGENT_PERMISSION_AUDIT_PATH";
10
+ const INTERNAL_TOOLS = new Set(["contact_supervisor", "intercom", "subagent_wait", "structured_output"]);
11
+ const DECISIONS = new Set<PermissionDecision>(["allow", "ask", "deny"]);
12
+ const MAX_POLICY_BYTES = 16 * 1024;
13
+ const MAX_PREVIEW_BYTES = 2048;
14
+ const SECRET_KEY = /(?:authorization|cookie|credential|password|secret|token|api[-_]?key)/i;
15
+ const SECRET_VALUE = /\b(?:Bearer\s+\S+|(?:sk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{8,})\b/gi;
16
+
17
+ export function validatePermissionRules(value: unknown, label: string): PermissionRules | undefined {
18
+ if (value === undefined) return undefined;
19
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object mapping tool names to allow, ask, or deny.`);
20
+ const result: PermissionRules = {};
21
+ for (const [tool, decision] of Object.entries(value)) {
22
+ if (!tool.trim()) throw new Error(`${label} contains an empty tool name.`);
23
+ if (tool === "bash") throw new Error(`${label}.bash is unsupported; pi-subagents leaves bash policy to pi-guard.`);
24
+ if (INTERNAL_TOOLS.has(tool)) throw new Error(`${label}.${tool} is reserved for child coordination and cannot be gated.`);
25
+ if (!DECISIONS.has(decision as PermissionDecision)) throw new Error(`${label}.${tool} must be allow, ask, or deny.`);
26
+ result[tool] = decision as PermissionDecision;
27
+ }
28
+ return Object.keys(result).length ? result : undefined;
29
+ }
30
+
31
+ export function validatePermissionConfig(value: unknown, label = "config.permissions"): PermissionConfig | undefined {
32
+ if (value === undefined) return undefined;
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
34
+ const object = value as Record<string, unknown>;
35
+ const unknown = Object.keys(object).filter((key) => key !== "rules");
36
+ if (unknown.length) throw new Error(`${label} has unsupported fields: ${unknown.join(", ")}.`);
37
+ return { rules: validatePermissionRules(object.rules, `${label}.rules`) };
38
+ }
39
+
40
+ export function resolvePermissionRules(globalConfig?: PermissionConfig, agentRules?: PermissionRules): PermissionRules | undefined {
41
+ const merged = { ...(globalConfig?.rules ?? {}), ...(agentRules ?? {}) };
42
+ for (const [tool, decision] of Object.entries(merged)) if (decision === "allow") delete merged[tool];
43
+ return Object.keys(merged).length ? merged : undefined;
44
+ }
45
+
46
+ export function permissionDecision(rules: PermissionRules | undefined, toolName: string): PermissionDecision {
47
+ if (toolName === "bash" || INTERNAL_TOOLS.has(toolName)) return "allow";
48
+ return rules?.[toolName] ?? "allow";
49
+ }
50
+
51
+ export function encodePermissionRules(rules: PermissionRules | undefined): string | undefined {
52
+ if (!rules || Object.keys(rules).length === 0) return undefined;
53
+ const encoded = JSON.stringify(rules);
54
+ if (Buffer.byteLength(encoded, "utf-8") > MAX_POLICY_BYTES) throw new Error("Resolved permission policy is too large.");
55
+ return encoded;
56
+ }
57
+
58
+ export function decodePermissionRules(encoded: string | undefined): PermissionRules | undefined {
59
+ if (!encoded?.trim()) return undefined;
60
+ return validatePermissionRules(JSON.parse(encoded), PERMISSION_POLICY_ENV);
61
+ }
62
+
63
+ function redact(value: unknown, key = "", depth = 0): unknown {
64
+ if (SECRET_KEY.test(key)) return "[redacted]";
65
+ if (depth >= 3) return "[truncated]";
66
+ if (Array.isArray(value)) return value.slice(0, 10).map((item) => redact(item, "", depth + 1));
67
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).slice(0, 20).map(([entryKey, entryValue]) => [entryKey, redact(entryValue, entryKey, depth + 1)]));
68
+ if (typeof value === "string") {
69
+ const redacted = value.replace(SECRET_VALUE, "[redacted]");
70
+ return redacted.length > 500 ? `${redacted.slice(0, 500)}…` : redacted;
71
+ }
72
+ return value;
73
+ }
74
+
75
+ export function permissionArgsPreview(input: unknown): string {
76
+ const serialized = JSON.stringify(redact(input));
77
+ if (!serialized) return "{}";
78
+ if (Buffer.byteLength(serialized, "utf-8") <= MAX_PREVIEW_BYTES) return serialized;
79
+ const maxContentBytes = MAX_PREVIEW_BYTES - Buffer.byteLength("…", "utf-8");
80
+ let preview = "";
81
+ let previewBytes = 0;
82
+ for (const character of serialized) {
83
+ const characterBytes = Buffer.byteLength(character, "utf-8");
84
+ if (previewBytes + characterBytes > maxContentBytes) break;
85
+ preview += character;
86
+ previewBytes += characterBytes;
87
+ }
88
+ return `${preview}…`;
89
+ }
90
+
91
+ export function appendPermissionAudit(filePath: string | undefined, record: Record<string, unknown>): void {
92
+ if (!filePath) return;
93
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
94
+ fs.appendFileSync(filePath, `${JSON.stringify(record)}\n`, { encoding: "utf-8", mode: 0o600 });
95
+ }