pi-subagents 0.40.0 → 0.41.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 (119) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +246 -525
  3. package/agents/oracle.md +1 -0
  4. package/package.json +12 -4
  5. package/prompts/parallel-context-build.md +1 -1
  6. package/prompts/parallel-handoff-plan.md +1 -1
  7. package/prompts/review-loop.md +1 -1
  8. package/skills/pi-subagents/SKILL.md +6 -6
  9. package/skills/pi-subagents/references/constraints-and-recipes.md +19 -26
  10. package/skills/pi-subagents/references/execution-controls.md +98 -97
  11. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  12. package/skills/pi-subagents/references/prompting-and-roles.md +18 -27
  13. package/src/agents/agent-management.ts +155 -65
  14. package/src/agents/agent-serializer.ts +19 -0
  15. package/src/agents/agents.ts +154 -71
  16. package/src/agents/chain-serializer.ts +10 -7
  17. package/src/agents/frontmatter.ts +5 -3
  18. package/src/agents/identity.ts +1 -1
  19. package/src/agents/proactive-skills.ts +13 -10
  20. package/src/agents/skills.ts +23 -6
  21. package/src/api/control-channel.ts +4 -0
  22. package/src/api/delegation.ts +26 -194
  23. package/src/api/external-runs.ts +129 -0
  24. package/src/api/intercom-bridge.ts +3 -0
  25. package/src/api/pi-args.ts +5 -0
  26. package/src/api/preflight.ts +3 -3
  27. package/src/api/shared-types.ts +19 -0
  28. package/src/extension/config.ts +10 -0
  29. package/src/extension/control-notices.ts +5 -39
  30. package/src/extension/doctor.ts +10 -9
  31. package/src/extension/fanout-child.ts +7 -4
  32. package/src/extension/index.ts +232 -68
  33. package/src/extension/rpc.ts +18 -12
  34. package/src/extension/schemas.ts +48 -37
  35. package/src/extension/tool-description.ts +36 -86
  36. package/src/inspectors/herdr/actions.ts +229 -0
  37. package/src/inspectors/herdr/client.ts +130 -0
  38. package/src/inspectors/herdr/inspector-runner.ts +141 -0
  39. package/src/inspectors/herdr/project-panes.ts +154 -0
  40. package/src/integrations/herdr-status.ts +330 -0
  41. package/src/intercom/intercom-bridge.ts +3 -2
  42. package/src/intercom/result-intercom.ts +5 -1
  43. package/src/missions/actions.ts +372 -0
  44. package/src/missions/lifecycle.ts +314 -0
  45. package/src/missions/store.ts +442 -0
  46. package/src/missions/types.ts +135 -0
  47. package/src/policy/authority.ts +46 -0
  48. package/src/profiles/profiles.ts +29 -3
  49. package/src/runs/background/async-execution.ts +98 -49
  50. package/src/runs/background/async-job-tracker.ts +10 -2
  51. package/src/runs/background/async-resume.ts +6 -6
  52. package/src/runs/background/async-status.ts +29 -1
  53. package/src/runs/background/auto-drain.ts +3 -3
  54. package/src/runs/background/chain-append.ts +3 -2
  55. package/src/runs/background/control-channel.ts +9 -7
  56. package/src/runs/background/fleet-view.ts +3 -4
  57. package/src/runs/background/notify.ts +2 -1
  58. package/src/runs/background/process-terminal.ts +5 -5
  59. package/src/runs/background/result-watcher.ts +13 -5
  60. package/src/runs/background/run-id-resolver.ts +3 -3
  61. package/src/runs/background/run-status.ts +35 -8
  62. package/src/runs/background/scheduled-runs.ts +602 -375
  63. package/src/runs/background/stale-run-reconciler.ts +3 -3
  64. package/src/runs/background/subagent-runner.ts +608 -445
  65. package/src/runs/background/subagent-wait.ts +50 -9
  66. package/src/runs/background/wait-subscriptions.ts +253 -0
  67. package/src/runs/background/wait-tool.ts +12 -4
  68. package/src/runs/foreground/async-steering-action.ts +3 -3
  69. package/src/runs/foreground/chain-clarify.ts +8 -4
  70. package/src/runs/foreground/chain-execution.ts +56 -30
  71. package/src/runs/foreground/execution.ts +15 -2
  72. package/src/runs/foreground/subagent-executor.ts +1023 -273
  73. package/src/runs/shared/acceptance.ts +28 -6
  74. package/src/runs/shared/child-protocol.ts +302 -22
  75. package/src/runs/shared/dynamic-fanout.ts +1 -1
  76. package/src/runs/shared/external-cli-runner.ts +130 -0
  77. package/src/runs/shared/long-running-guard.ts +42 -1
  78. package/src/runs/shared/nested-events.ts +59 -5
  79. package/src/runs/shared/nested-render.ts +9 -4
  80. package/src/runs/shared/parallel-handoff.ts +86 -2
  81. package/src/runs/shared/parallel-utils.ts +11 -2
  82. package/src/runs/shared/permissions.ts +95 -0
  83. package/src/runs/shared/pi-args.ts +11 -1
  84. package/src/runs/shared/pi-spawn.ts +11 -1
  85. package/src/runs/shared/run-history.ts +1 -1
  86. package/src/runs/shared/subagent-prompt-runtime.ts +36 -5
  87. package/src/runs/shared/subagent-startup-retry.ts +5 -2
  88. package/src/runs/shared/turn-budget.ts +6 -6
  89. package/src/runs/shared/worktree.ts +122 -12
  90. package/src/shared/accessible-dir.ts +29 -7
  91. package/src/shared/artifacts.ts +18 -1
  92. package/src/shared/fork-context.ts +3 -2
  93. package/src/shared/launch-contract.ts +1 -0
  94. package/src/shared/settings.ts +10 -0
  95. package/src/shared/types.ts +156 -14
  96. package/src/shared/utils.ts +8 -6
  97. package/src/slash/delegation-adapters.ts +32 -194
  98. package/src/slash/delegation-request.ts +43 -126
  99. package/src/slash/prompt-template-bridge.ts +158 -205
  100. package/src/slash/prompt-workflows.ts +21 -57
  101. package/src/slash/slash-bridge.ts +14 -0
  102. package/src/slash/slash-commands.ts +31 -632
  103. package/src/slash/subagents-admin.ts +18 -14
  104. package/src/tui/fleet-status.ts +156 -21
  105. package/src/tui/fleet-transcript.ts +110 -5
  106. package/src/tui/fleet.ts +56 -24
  107. package/src/tui/render.ts +291 -109
  108. package/src/types/pi-runtime-compat.d.ts +14 -0
  109. package/src/watchdog/lsp-diagnostics.ts +12 -7
  110. package/src/watchdog/model-selection.ts +2 -2
  111. package/src/watchdog/permission-arbiter.ts +145 -0
  112. package/src/watchdog/register-child.ts +1 -1
  113. package/src/watchdog/register-main.ts +1 -1
  114. package/src/watchdog/review.ts +4 -1
  115. package/src/watchdog/runtime.ts +3 -2
  116. package/src/workflows/chat-progress.ts +140 -0
  117. package/src/workflows/scripted-workflow.ts +415 -0
  118. package/agents/advisor.md +0 -73
  119. package/src/extension/chain-validation.ts +0 -181
@@ -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
+ }
@@ -15,6 +15,7 @@ import { CHILD_TOOL_DIAGNOSTIC_PATH_ENV, MCP_DIRECT_CHILD_TOOLS_ENV, REQUIRED_CH
15
15
  import { CHILD_WATCHDOG_CONFIG_ENV, encodeChildWatchdogConfig, type ChildWatchdogConfig } from "../../watchdog/child-status.ts";
16
16
  import { WAIT_TOOL_ENABLED_ENV } from "../background/wait-config.ts";
17
17
  import { PI_CODING_AGENT_PACKAGE_ROOT_ENV } from "../../shared/utils.ts";
18
+ import { encodePermissionRules, PERMISSION_AUDIT_PATH_ENV, PERMISSION_POLICY_ENV, type PermissionRules } from "./permissions.ts";
18
19
  import { SUBAGENT_CAPABILITY_CEILING_ENV, capabilityCeilingAgentRestrictionSources, decodeSubagentCapabilityCeiling, encodeSubagentCapabilityCeiling, intersectSubagentCapabilityCeilings, isAgentAllowedByCapabilityCeiling, type ResolvedSubagentCapabilityCeiling, type SubagentCapabilityAudit } from "./capability-ceiling.ts";
19
20
 
20
21
  const TASK_ARG_LIMIT = 8000;
@@ -87,6 +88,8 @@ export interface BuildPiArgsInput {
87
88
  };
88
89
  toolBudget?: ResolvedToolBudget;
89
90
  allowZeroToolBudget?: boolean;
91
+ permissionRules?: PermissionRules;
92
+ permissionAuditPath?: string;
90
93
  childWatchdog?: ChildWatchdogConfig;
91
94
  waitToolEnabled?: boolean;
92
95
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
@@ -125,7 +128,11 @@ export interface ResolvePiLaunchToolPlanInput {
125
128
  mcpDirectTools?: string[];
126
129
  cwd?: string;
127
130
  requireReadTool?: boolean;
128
- structuredOutput?: boolean;
131
+ structuredOutput?: boolean | {
132
+ schema: JsonSchemaObject;
133
+ schemaPath: string;
134
+ outputPath: string;
135
+ };
129
136
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
130
137
  inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
131
138
  agentName?: string;
@@ -386,6 +393,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
386
393
  if (input.parentSessionId) {
387
394
  env[SUBAGENT_ORCHESTRATOR_SESSION_ID_ENV] = input.parentSessionId;
388
395
  }
396
+ const encodedPermissionRules = encodePermissionRules(input.permissionRules);
389
397
  if (input.orchestratorIntercomTarget && input.parentSessionId && input.runId && input.childAgentName) {
390
398
  const childIndex = input.childIndex ?? 0;
391
399
  const channelDir = supervisorChannelDir(input.runId, input.childAgentName, childIndex);
@@ -393,6 +401,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
393
401
  fs.mkdirSync(path.join(channelDir, "replies"), { recursive: true });
394
402
  env[SUBAGENT_SUPERVISOR_CHANNEL_DIR_ENV] = channelDir;
395
403
  }
404
+ if (encodedPermissionRules) env[PERMISSION_AUDIT_PATH_ENV] = input.permissionAuditPath ?? path.join(tempDir, "permission-audit.jsonl");
396
405
  if (input.runId) {
397
406
  env[SUBAGENT_RUN_ID_ENV] = input.runId;
398
407
  }
@@ -407,6 +416,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
407
416
  else env.MCP_DIRECT_TOOLS = "__none__";
408
417
  const encodedCapabilityCeiling = encodeSubagentCapabilityCeiling(toolPlan.capabilityCeiling);
409
418
  if (encodedCapabilityCeiling) env[SUBAGENT_CAPABILITY_CEILING_ENV] = encodedCapabilityCeiling;
419
+ if (encodedPermissionRules) env[PERMISSION_POLICY_ENV] = encodedPermissionRules;
410
420
  if (input.structuredOutput) {
411
421
  env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
412
422
  env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
@@ -70,6 +70,11 @@ function normalizePath(filePath: string): string {
70
70
  return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
71
71
  }
72
72
 
73
+ function isStandalonePiExecutable(execPath: string): boolean {
74
+ const executableName = execPath.split(/[\\/]/).pop();
75
+ return /^pi(?:\.exe)?$/i.test(executableName ?? "");
76
+ }
77
+
73
78
  export function resolvePiCliScript(
74
79
  deps: PiSpawnDeps = {},
75
80
  ): string | undefined {
@@ -141,10 +146,15 @@ export function getPiSpawnCommand(
141
146
  return { command: piBinary, args };
142
147
  }
143
148
 
149
+ const execPath = deps.execPath ?? process.execPath;
150
+ if (isStandalonePiExecutable(execPath)) {
151
+ return { command: execPath, args };
152
+ }
153
+
144
154
  const piCliPath = resolvePiCliScript(deps);
145
155
  if (piCliPath) {
146
156
  return {
147
- command: deps.execPath ?? process.execPath,
157
+ command: execPath,
148
158
  args: [piCliPath, ...args],
149
159
  };
150
160
  }
@@ -176,6 +176,6 @@ export function loadRunsForAgent(agent: string): RunEntry[] {
176
176
 
177
177
  return lines
178
178
  .map((line) => { try { return JSON.parse(line) as RunEntry; } catch { return undefined; } })
179
- .filter((entry): entry is RunEntry => Boolean(entry) && entry.agent === agent)
179
+ .filter((entry): entry is RunEntry => entry !== undefined && entry.agent === agent)
180
180
  .reverse();
181
181
  }
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
5
+ import { decodePermissionRules, permissionDecision, PERMISSION_AUDIT_PATH_ENV, PERMISSION_POLICY_ENV } from "./permissions.ts";
5
6
  import { consumeSteerRequestsFromDir, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
6
7
  import { SUBAGENT_CHILD_AGENT_ENV, SUBAGENT_CHILD_INDEX_ENV, SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_STEER_ACK_DIR_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
7
8
  import { RUNTIME_EXTENSION_ACK_EVENT, RUNTIME_EXTENSION_ACK_PATH_ENV, isRuntimeAcknowledgedExtensionId, writeRuntimeAcknowledgedExtensions } from "./runtime-acknowledged-extensions.ts";
@@ -18,6 +19,8 @@ import type { JsonSchemaObject, ResolvedToolBudget, SubagentState } from "../../
18
19
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
19
20
  import { resolveWatchPath } from "../../shared/utils.ts";
20
21
  import { registerChildWatchdog } from "../../watchdog/register-child.ts";
22
+ import { CHILD_WATCHDOG_CONFIG_ENV } from "../../watchdog/child-status.ts";
23
+ import { requestWatchdogPermission, type WatchdogPermissionRequest, type WatchdogPermissionResult } from "../../watchdog/permission-arbiter.ts";
21
24
  import { SUBAGENT_WATCHDOG_WARNING_TYPE } from "../../watchdog/types.ts";
22
25
  import { resolveWaitToolConfig } from "../background/wait-config.ts";
23
26
  import { registerWaitTool } from "../background/wait-tool.ts";
@@ -241,6 +244,31 @@ export function formatSteerMessage(request: SteerRequest): string {
241
244
  ].join("\n");
242
245
  }
243
246
 
247
+ export function registerPermissionGate(
248
+ pi: ExtensionAPI,
249
+ requestPermission: (request: WatchdogPermissionRequest) => Promise<WatchdogPermissionResult> = requestWatchdogPermission,
250
+ ): void {
251
+ const rules = decodePermissionRules(process.env[PERMISSION_POLICY_ENV]);
252
+ if (!rules) return;
253
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: { toolName?: string; input?: unknown }, ctx: ExtensionContext) => unknown) => void;
254
+ onRuntimeEvent("tool_call", async (event, ctx) => {
255
+ const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
256
+ const decision = permissionDecision(rules, toolName);
257
+ if (decision === "allow") return undefined;
258
+ if (decision === "deny") return { block: true, reason: `Blocked by pi-subagents permission rule: '${toolName}' is denied.` };
259
+ const result = await requestPermission({
260
+ ctx,
261
+ toolName,
262
+ args: event.input ?? {},
263
+ rawWatchdogConfig: process.env[CHILD_WATCHDOG_CONFIG_ENV],
264
+ auditPath: process.env[PERMISSION_AUDIT_PATH_ENV],
265
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
266
+ });
267
+ if (result.approved) return undefined;
268
+ return { block: true, reason: `Blocked by pi-subagents permission rule: ${result.reason}` };
269
+ });
270
+ }
271
+
244
272
  function registerToolBudget(pi: ExtensionAPI, budget: ResolvedToolBudget | undefined): void {
245
273
  if (!budget) return;
246
274
  let toolCount = 0;
@@ -360,7 +388,7 @@ export function registerSteeringInbox(
360
388
  return undefined;
361
389
  };
362
390
 
363
- const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
391
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: unknown) => unknown) => void;
364
392
  // Register input before the watcher so an accepted extension input cannot race request dispatch.
365
393
  onRuntimeEvent("input", onInput);
366
394
  onRuntimeEvent("session_start", () => start());
@@ -377,6 +405,7 @@ export function registerSteeringInbox(
377
405
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
378
406
  registerRuntimeExtensionAcknowledgements(pi);
379
407
  registerSteeringInbox(pi);
408
+ registerPermissionGate(pi);
380
409
  registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV], { allowZero: process.env[TOOL_BUDGET_ZERO_AUTH_ENV] === "1" }));
381
410
  registerChildWatchdog(pi);
382
411
  const waitToolEnabled = resolveWaitToolConfig().enabled;
@@ -408,7 +437,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
408
437
  nativeSupervisorFallbackRegistered = true;
409
438
  registerNativeSupervisorClient(pi);
410
439
  };
411
- const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
440
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: unknown) => unknown) => void;
412
441
  onRuntimeEvent("session_start", (_event: unknown, ctx: unknown) => {
413
442
  const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
414
443
  waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
@@ -455,13 +484,15 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
455
484
  });
456
485
  }
457
486
 
458
- onRuntimeEvent("context", (event: { messages: unknown[] }) => {
487
+ onRuntimeEvent("context", (event: unknown) => {
488
+ if (!event || typeof event !== "object" || !("messages" in event) || !Array.isArray(event.messages)) return undefined;
459
489
  const messages = stripParentOnlySubagentMessages(event.messages);
460
490
  if (messages === event.messages) return undefined;
461
491
  return { messages };
462
492
  });
463
493
 
464
- onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
494
+ onRuntimeEvent("before_agent_start", async (event: unknown) => {
495
+ if (!event || typeof event !== "object" || !("systemPrompt" in event) || typeof event.systemPrompt !== "string") return undefined;
465
496
  registerNativeSupervisorFallbackOnce();
466
497
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
467
498
  if (intercomSessionName && typeof pi.setSessionName === "function") {