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
@@ -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") {
@@ -1,4 +1,5 @@
1
1
  import type { Usage } from "../../shared/types.ts";
2
+ import { formatProcessSignalError } from "./process-signal.ts";
2
3
 
3
4
  /**
4
5
  * Retry delays for child processes that exit before any model or tool activity.
@@ -10,6 +11,8 @@ export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
10
11
  /** A genuine startup race should fail well before a model request can complete. */
11
12
  export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
12
13
 
14
+ const SIGKILL_PROCESS_SIGNAL_ERROR = formatProcessSignalError("SIGKILL");
15
+
13
16
  export interface SubagentStartupFailureEvidence {
14
17
  exitCode: number | null | undefined;
15
18
  error?: string;
@@ -46,7 +49,7 @@ export function isRetryableSubagentStartupFailure(evidence: SubagentStartupFailu
46
49
  return evidence.exitCode !== undefined
47
50
  && evidence.exitCode !== null
48
51
  && evidence.exitCode !== 0
49
- && !evidence.error?.trim()
52
+ && (!evidence.error?.trim() || evidence.error === SIGKILL_PROCESS_SIGNAL_ERROR)
50
53
  && !evidence.finalOutput?.trim()
51
54
  && evidence.messageCount === 0
52
55
  && evidence.toolCount === 0
@@ -54,7 +57,7 @@ export function isRetryableSubagentStartupFailure(evidence: SubagentStartupFailu
54
57
  && evidence.durationMs >= 0
55
58
  && evidence.durationMs <= MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS
56
59
  && evidence.protocolError === undefined
57
- && (evidence.processSignal === undefined || evidence.processSignal === null)
60
+ && (evidence.processSignal === undefined || evidence.processSignal === null || evidence.processSignal === "SIGKILL")
58
61
  && evidence.observedMutationAttempt !== true
59
62
  && evidence.detached !== true
60
63
  && evidence.interrupted !== true
@@ -23,9 +23,10 @@ export function resolveTurnBudgetConfig(
23
23
  return { turnBudget: { maxTurns: budget.maxTurns, graceTurns } };
24
24
  }
25
25
 
26
- export function appendTurnBudgetSystemPrompt(systemPrompt: string, budget: ResolvedTurnBudget | undefined): string {
26
+ export function appendTurnBudgetSystemPrompt(systemPrompt: string, budget: { maxTurns: number; graceTurns?: number } | undefined): string {
27
27
  if (!budget) return systemPrompt;
28
- const grace = budget.graceTurns === 1 ? "1 additional assistant turn" : `${budget.graceTurns} additional assistant turns`;
28
+ const graceTurns = budget.graceTurns ?? DEFAULT_TURN_BUDGET_GRACE_TURNS;
29
+ const grace = graceTurns === 1 ? "1 additional assistant turn" : `${graceTurns} additional assistant turns`;
29
30
  const block = [
30
31
  "## Turn budget",
31
32
  `This child run has a soft budget of ${budget.maxTurns} assistant turn${budget.maxTurns === 1 ? "" : "s"}.`,
@@ -55,8 +56,8 @@ export function formatTurnBudgetOutput(message: string, output: string): string
55
56
  : message;
56
57
  }
57
58
 
58
- export function initialTurnBudgetState(budget: ResolvedTurnBudget): TurnBudgetState {
59
- return { ...budget, outcome: "within-budget", turnCount: 0 };
59
+ export function initialTurnBudgetState(budget: { maxTurns: number; graceTurns?: number }): TurnBudgetState {
60
+ return { maxTurns: budget.maxTurns, graceTurns: budget.graceTurns ?? DEFAULT_TURN_BUDGET_GRACE_TURNS, outcome: "within-budget", turnCount: 0 };
60
61
  }
61
62
 
62
63
  export function turnBudgetState(budget: ResolvedTurnBudget, turnCount: number, exceeded: boolean): TurnBudgetState {
@@ -91,8 +92,7 @@ export function turnBudgetDecision(
91
92
  enforceHardLimit = false,
92
93
  ): "continue" | "defer" | "abort" {
93
94
  const hardLimit = budget.maxTurns + budget.graceTurns;
94
- if (turnCount < hardLimit) return "continue";
95
+ if (terminalAssistantStop || turnCount < hardLimit) return "continue";
95
96
  if (toolWorkActiveOrStarting && !enforceHardLimit) return "defer";
96
- if (turnCount === hardLimit && terminalAssistantStop) return "continue";
97
97
  return "abort";
98
98
  }
@@ -2,14 +2,16 @@ import { spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { resolveAuthorityDecision, type AuthorityPolicyConfig } from "../../policy/authority.ts";
5
6
 
6
7
  export interface WorktreeSetup {
7
8
  cwd: string;
8
9
  worktrees: WorktreeInfo[];
9
10
  baseCommit: string;
11
+ capturedDiffs?: WorktreeDiff[];
10
12
  }
11
13
 
12
- interface WorktreeInfo {
14
+ export interface WorktreeInfo {
13
15
  path: string;
14
16
  agentCwd: string;
15
17
  branch: string;
@@ -36,9 +38,21 @@ export interface WorktreeCleanupTask {
36
38
  branch: string;
37
39
  worktreeRemoved: boolean;
38
40
  branchRemoved: boolean;
41
+ preserved?: boolean;
42
+ reason?: string;
39
43
  errors?: string[];
40
44
  }
41
45
 
46
+ export type WorktreeCleanupIntent =
47
+ | { kind: "preserve"; capturedDiffs?: WorktreeDiff[]; handoffManifestPath?: string }
48
+ | {
49
+ kind: "discard";
50
+ authorization:
51
+ | { kind: "policy"; policy?: AuthorityPolicyConfig }
52
+ | { kind: "confirmed"; policy?: AuthorityPolicyConfig };
53
+ }
54
+ | { kind: "setup-rollback" };
55
+
42
56
  export interface WorktreeCleanupReport {
43
57
  state: "complete" | "partial";
44
58
  tasks: WorktreeCleanupTask[];
@@ -121,7 +135,9 @@ function resolveRepoState(cwd: string): RepoState {
121
135
  const cwdRelative = resolveRepoCwdRelative(cwd);
122
136
  const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
123
137
 
124
- const status = runGitChecked(toplevel, ["status", "--porcelain"]);
138
+ // pi-subagents writes durable runtime state under .pi-subagents/ by default;
139
+ // that state must not make managed isolation unusable for later runs.
140
+ const status = runGitChecked(toplevel, ["status", "--porcelain", "--", ":!.pi-subagents"]);
125
141
  if (status.trim().length > 0) {
126
142
  throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
127
143
  }
@@ -515,21 +531,111 @@ function writeEmptyPatch(patchPath: string): void {
515
531
  }
516
532
  }
517
533
 
518
- function cleanupSingleWorktree(repoCwd: string, worktree: WorktreeInfo): WorktreeCleanupTask {
534
+ function handoffRecordsPatch(manifestPath: string | undefined, patchPath: string): boolean {
535
+ if (!manifestPath || !fs.existsSync(manifestPath)) return false;
536
+ try {
537
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as {
538
+ version?: unknown;
539
+ groups?: Array<{ children?: Array<{ patch?: { path?: unknown; error?: unknown } }> }>;
540
+ };
541
+ if (manifest.version !== 1 || !Array.isArray(manifest.groups)) return false;
542
+ const resolvedPatchPath = path.resolve(patchPath);
543
+ return manifest.groups.some((group) => Array.isArray(group.children) && group.children.some((child) =>
544
+ child.patch?.error === undefined
545
+ && typeof child.patch?.path === "string"
546
+ && path.resolve(child.patch.path) === resolvedPatchPath,
547
+ ));
548
+ } catch {
549
+ return false;
550
+ }
551
+ }
552
+
553
+ function cleanupSingleWorktree(
554
+ setup: WorktreeSetup,
555
+ worktree: WorktreeInfo,
556
+ intent: WorktreeCleanupIntent,
557
+ ): WorktreeCleanupTask {
519
558
  const errors: string[] = [];
520
559
  let worktreeRemoved = false;
521
560
  let branchRemoved = false;
561
+ if (intent.kind !== "setup-rollback") {
562
+ try {
563
+ removeSyntheticPathsBeforeDiff(worktree);
564
+ } catch (error) {
565
+ errors.push(`synthetic path cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
566
+ }
567
+ const status = runGit(worktree.path, ["status", "--porcelain"]);
568
+ const baseDiff = runGit(worktree.path, ["diff", "--quiet", setup.baseCommit, "--"]);
569
+ if (status.status !== 0 || (baseDiff.status !== 0 && baseDiff.status !== 1)) {
570
+ const reason = status.status !== 0
571
+ ? status.stderr.trim() || status.stdout.trim() || "git status failed"
572
+ : baseDiff.stderr.trim() || baseDiff.stdout.trim() || "git diff check failed";
573
+ return {
574
+ index: worktree.index,
575
+ path: worktree.path,
576
+ branch: worktree.branch,
577
+ worktreeRemoved: false,
578
+ branchRemoved: false,
579
+ preserved: true,
580
+ reason: "cleanup safety check failed",
581
+ errors: [...errors, `cleanup refused: ${reason}`],
582
+ };
583
+ }
584
+ const hasWork = status.stdout.trim().length > 0 || baseDiff.status === 1;
585
+ if (hasWork && intent.kind === "preserve") {
586
+ const captured = (intent.capturedDiffs ?? setup.capturedDiffs)?.find((diff) => diff.index === worktree.index);
587
+ const patchCaptured = captured !== undefined
588
+ && captured.error === undefined
589
+ && fs.existsSync(captured.patchPath)
590
+ && fs.statSync(captured.patchPath).size > 0
591
+ && handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath);
592
+ if (!patchCaptured) {
593
+ const reason = "worktree contains changes that are not represented by a captured handoff patch";
594
+ return {
595
+ index: worktree.index,
596
+ path: worktree.path,
597
+ branch: worktree.branch,
598
+ worktreeRemoved: false,
599
+ branchRemoved: false,
600
+ preserved: true,
601
+ reason,
602
+ errors: [...errors, `cleanup refused: ${reason}; preserved ${worktree.path}`],
603
+ };
604
+ }
605
+ }
606
+ if (hasWork && intent.kind === "discard") {
607
+ const decision = resolveAuthorityDecision({ action: "discardWorktree", policy: intent.authorization.policy });
608
+ const authorized = decision === "auto" || (decision === "confirm" && intent.authorization.kind === "confirmed");
609
+ if (!authorized) {
610
+ const reason = decision === "forbid"
611
+ ? "authority policy forbids worktree discard"
612
+ : "worktree discard requires explicit user confirmation";
613
+ return {
614
+ index: worktree.index,
615
+ path: worktree.path,
616
+ branch: worktree.branch,
617
+ worktreeRemoved: false,
618
+ branchRemoved: false,
619
+ preserved: true,
620
+ reason,
621
+ errors: [...errors, `cleanup refused: ${reason}; preserved ${worktree.path}`],
622
+ };
623
+ }
624
+ }
625
+ }
522
626
  try {
523
- runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]);
627
+ runGitChecked(setup.cwd, ["worktree", "remove", "--force", worktree.path]);
524
628
  worktreeRemoved = true;
525
629
  } catch (error) {
526
630
  errors.push(`worktree removal failed: ${error instanceof Error ? error.message : String(error)}`);
527
631
  }
528
- try {
529
- runGitChecked(repoCwd, ["branch", "-D", worktree.branch]);
530
- branchRemoved = true;
531
- } catch (error) {
532
- errors.push(`branch removal failed: ${error instanceof Error ? error.message : String(error)}`);
632
+ if (worktreeRemoved) {
633
+ try {
634
+ runGitChecked(setup.cwd, ["branch", "-D", worktree.branch]);
635
+ branchRemoved = true;
636
+ } catch (error) {
637
+ errors.push(`branch removal failed: ${error instanceof Error ? error.message : String(error)}`);
638
+ }
533
639
  }
534
640
  return {
535
641
  index: worktree.index,
@@ -569,7 +675,7 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
569
675
  cwd: repo.toplevel,
570
676
  worktrees,
571
677
  baseCommit: repo.baseCommit,
572
- });
678
+ }, { kind: "setup-rollback" });
573
679
  throw error;
574
680
  }
575
681
 
@@ -602,13 +708,17 @@ export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir:
602
708
  }
603
709
  }
604
710
 
711
+ setup.capturedDiffs = diffs;
605
712
  return diffs;
606
713
  }
607
714
 
608
- export function cleanupWorktrees(setup: WorktreeSetup): WorktreeCleanupReport {
715
+ export function cleanupWorktrees(
716
+ setup: WorktreeSetup,
717
+ intent: WorktreeCleanupIntent = { kind: "preserve", ...(setup.capturedDiffs ? { capturedDiffs: setup.capturedDiffs } : {}) },
718
+ ): WorktreeCleanupReport {
609
719
  const tasks: WorktreeCleanupTask[] = [];
610
720
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
611
- tasks.push(cleanupSingleWorktree(setup.cwd, setup.worktrees[index]!));
721
+ tasks.push(cleanupSingleWorktree(setup, setup.worktrees[index]!, intent));
612
722
  }
613
723
  tasks.sort((left, right) => left.index - right.index);
614
724
  const errors: string[] = [];
@@ -8,18 +8,40 @@ type AccessibleDirOptions = {
8
8
  retryDirectoryErrors?: boolean;
9
9
  retryDelaysMs?: readonly number[];
10
10
  wait?: (delayMs: number) => void;
11
+ pid?: number;
11
12
  };
12
13
 
13
- export function ensureAccessibleDir(dirPath: string, options: AccessibleDirOptions = {}): void {
14
+ export function ensureAccessibleDir(dirPath: string, options: AccessibleDirOptions = {}): string {
14
15
  const fsImpl = options.fs ?? fs;
16
+ const pid = options.pid ?? process.pid;
15
17
  const retryDirectoryErrors = options.retryDirectoryErrors ?? process.platform === "win32";
16
18
  const retryDelaysMs = retryDirectoryErrors ? options.retryDelaysMs ?? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS : [];
17
19
  const wait = options.wait ?? waitForFileSystemRetry;
18
20
 
19
- runFileSystemOperationWithRetry(() => {
20
- fsImpl.mkdirSync(dirPath, { recursive: true });
21
- }, { retryDelaysMs, wait });
22
- runFileSystemOperationWithRetry(() => {
23
- fsImpl.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
24
- }, { retryDelaysMs, wait });
21
+ const mkdirWithRetry = (target: string): void => {
22
+ runFileSystemOperationWithRetry(() => {
23
+ fsImpl.mkdirSync(target, { recursive: true });
24
+ }, { retryDelaysMs, wait });
25
+ };
26
+ const accessWithRetry = (target: string): void => {
27
+ runFileSystemOperationWithRetry(() => {
28
+ fsImpl.accessSync(target, fs.constants.R_OK | fs.constants.W_OK);
29
+ }, { retryDelaysMs, wait });
30
+ };
31
+ const fallbackPath = (): string => {
32
+ const fallback = `${dirPath}-${pid}`;
33
+ mkdirWithRetry(fallback);
34
+ accessWithRetry(fallback);
35
+ return fallback;
36
+ };
37
+
38
+ try {
39
+ mkdirWithRetry(dirPath);
40
+ accessWithRetry(dirPath);
41
+ return dirPath;
42
+ } catch (error) {
43
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
44
+ if (code !== "EPERM" && code !== "EACCES") throw error;
45
+ return fallbackPath();
46
+ }
25
47
  }
@@ -1,10 +1,135 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { TEMP_ARTIFACTS_DIR, type ArtifactPaths, type ArtifactDirPreference } from "./types.ts";
3
+ import { CHAIN_RUNS_DIR, TEMP_ARTIFACTS_DIR, type ArtifactPaths, type ArtifactDirPreference } from "./types.ts";
4
4
  import { getAgentDir } from "./utils.ts";
5
5
  const CLEANUP_MARKER_FILE = ".last-cleanup";
6
6
  const PROJECT_ARTIFACT_ROOT = ".pi-subagents";
7
7
 
8
+ const PROJECT_ARTIFACT_PATHS = [
9
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/output.md`,
10
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_input.md`,
11
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_output.md`,
12
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker.jsonl`,
13
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_transcript.jsonl`,
14
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_meta.json`,
15
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/progress/run/progress.md`,
16
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/output.md`,
17
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/run/output.md`,
18
+ `${PROJECT_ARTIFACT_ROOT}/chain-runs/run.json`,
19
+ ];
20
+
21
+ function globMatchesPath(pattern: string, filePath: string): boolean {
22
+ let expression = "^";
23
+ for (let index = 0; index < pattern.length; index += 1) {
24
+ const character = pattern[index];
25
+ if (character === undefined) break;
26
+ if (character === "*" && pattern[index + 1] === "*") {
27
+ index += 1;
28
+ if (pattern[index + 1] === "/") {
29
+ index += 1;
30
+ expression += "(?:.*/)?";
31
+ } else {
32
+ expression += ".*";
33
+ }
34
+ } else if (character === "*") {
35
+ expression += "[^/]*";
36
+ } else if (character === "?") {
37
+ expression += "[^/]";
38
+ } else if (character === "[") {
39
+ const end = pattern.indexOf("]", index + 1);
40
+ if (end === -1) expression += "\\[";
41
+ else {
42
+ expression += pattern.slice(index, end + 1);
43
+ index = end;
44
+ }
45
+ } else {
46
+ expression += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
47
+ }
48
+ }
49
+ try {
50
+ return new RegExp(`${expression}$`).test(filePath);
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function normalizePattern(pattern: string): string {
57
+ return pattern.replace(/^\.\//, "").replace(/^\//, "").replace(/\/+$/, "");
58
+ }
59
+
60
+ function patternMatchesArtifactPath(pattern: string, artifactPath: string): boolean {
61
+ const normalized = normalizePattern(pattern);
62
+ return normalized === PROJECT_ARTIFACT_ROOT
63
+ || normalized === "*"
64
+ || artifactPath.startsWith(`${normalized}/`)
65
+ || globMatchesPath(normalized, artifactPath);
66
+ }
67
+
68
+ function patternMatchesProjectArtifacts(pattern: string): boolean {
69
+ return PROJECT_ARTIFACT_PATHS.some((artifactPath) => patternMatchesArtifactPath(pattern, artifactPath));
70
+ }
71
+
72
+ function ignoreFileExcludesProjectArtifacts(filePath: string): boolean {
73
+ if (!fs.existsSync(filePath)) return false;
74
+ try {
75
+ const excluded = new Set<string>();
76
+ for (const rawLine of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
77
+ const line = rawLine.trim();
78
+ if (!line || line.startsWith("#")) continue;
79
+ const negated = line.startsWith("!");
80
+ const pattern = negated ? line.slice(1) : line;
81
+ for (const artifactPath of PROJECT_ARTIFACT_PATHS) {
82
+ if (!patternMatchesArtifactPath(pattern, artifactPath)) continue;
83
+ if (negated) excluded.delete(artifactPath);
84
+ else excluded.add(artifactPath);
85
+ }
86
+ }
87
+ return excluded.size === PROJECT_ARTIFACT_PATHS.length;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function filesIncludeProjectArtifacts(files: unknown): boolean | undefined {
94
+ if (!Array.isArray(files)) return undefined;
95
+ const included = new Set<string>();
96
+ for (const entry of files) {
97
+ if (typeof entry !== "string") continue;
98
+ const negated = entry.startsWith("!");
99
+ const pattern = negated ? entry.slice(1) : entry;
100
+ for (const artifactPath of PROJECT_ARTIFACT_PATHS) {
101
+ if (!patternMatchesArtifactPath(pattern, artifactPath)) continue;
102
+ if (negated) included.delete(artifactPath);
103
+ else included.add(artifactPath);
104
+ }
105
+ }
106
+ return included.size > 0;
107
+ }
108
+
109
+ /** Returns a package-publishing warning when project artifacts can enter npm packages. */
110
+ export function getProjectArtifactPackagingWarning(cwd: string): string | undefined {
111
+ const packagePath = path.join(cwd, "package.json");
112
+ if (!fs.existsSync(packagePath)) return undefined;
113
+
114
+ let packageJson: Record<string, unknown>;
115
+ try {
116
+ const parsed: unknown = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
117
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
118
+ packageJson = parsed as Record<string, unknown>;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+
123
+ const filesIncludeArtifacts = filesIncludeProjectArtifacts(packageJson.files);
124
+ if (filesIncludeArtifacts === false) return undefined;
125
+
126
+ const npmIgnorePath = path.join(cwd, ".npmignore");
127
+ const ignorePath = fs.existsSync(npmIgnorePath) ? npmIgnorePath : path.join(cwd, ".gitignore");
128
+ if (filesIncludeArtifacts === undefined && ignoreFileExcludesProjectArtifacts(ignorePath)) return undefined;
129
+
130
+ return "Project-scoped subagent artifacts can be included when this package is published. Add '.pi-subagents/' to .npmignore, restrict package.json files, or set artifactDir to 'session' or 'temp'.";
131
+ }
132
+
8
133
  export function getProjectSubagentsDir(cwd: string): string {
9
134
  return path.join(cwd, PROJECT_ARTIFACT_ROOT);
10
135
  }
@@ -17,6 +142,21 @@ export function getProjectChainRunsDir(cwd: string): string {
17
142
  return path.join(getProjectSubagentsDir(cwd), "chain-runs");
18
143
  }
19
144
 
145
+ export function getChainRunsDir(
146
+ projectCwd: string,
147
+ dirPreference: ArtifactDirPreference = "project",
148
+ ): string {
149
+ switch (dirPreference) {
150
+ case "project":
151
+ return getProjectChainRunsDir(projectCwd);
152
+ case "session":
153
+ case "temp":
154
+ return CHAIN_RUNS_DIR;
155
+ default:
156
+ throw new Error(`Unsupported artifactDir ${JSON.stringify(dirPreference)}; expected "project", "session", or "temp".`);
157
+ }
158
+ }
159
+
20
160
  export function getArtifactsDir(
21
161
  sessionFile: string | null,
22
162
  projectCwd?: string,
@@ -61,6 +201,7 @@ export function ensureArtifactsDir(dir: string): void {
61
201
  }
62
202
 
63
203
  export function writeArtifact(filePath: string, content: string): void {
204
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
64
205
  fs.writeFileSync(filePath, content, "utf-8");
65
206
  }
66
207
 
@@ -78,6 +219,7 @@ export function formatOutputArtifactContent(input: {
78
219
  }
79
220
 
80
221
  export function writeMetadata(filePath: string, metadata: object): void {
222
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
81
223
  fs.writeFileSync(filePath, JSON.stringify(metadata, null, 2), "utf-8");
82
224
  }
83
225
 
@@ -77,8 +77,9 @@ function isUnsafeAnthropicThinkingBlock(message: BranchSessionEntry["message"],
77
77
  const isAnthropic = provider === "anthropic" || api === "anthropic-messages" || model.startsWith("anthropic/");
78
78
  if (block.type === "redacted_thinking") return true;
79
79
  if (block.type !== "thinking" || !isAnthropic) return false;
80
- const signature = "thinkingSignature" in block ? block.thinkingSignature : "signature" in block ? block.signature : undefined;
81
- return block.redacted === true || (typeof signature === "string" && signature.length > 0);
80
+ const record = block as Record<string, unknown>;
81
+ const signature = "thinkingSignature" in record ? record.thinkingSignature : "signature" in record ? record.signature : undefined;
82
+ return record.redacted === true || (typeof signature === "string" && signature.length > 0);
82
83
  }
83
84
 
84
85
  function createEntryId(entries: BranchSessionEntry[]): string {
@@ -38,6 +38,7 @@ export function projectAgentDefinition(agent: AgentConfig): Record<string, unkno
38
38
  packageName: agent.packageName,
39
39
  filePath: agent.filePath,
40
40
  fileContentDigest: fileDigest(agent.filePath),
41
+ runner: agent.runner,
41
42
  systemPrompt: agent.systemPrompt,
42
43
  systemPromptMode: agent.systemPromptMode,
43
44
  inheritProjectContext: agent.inheritProjectContext,
@@ -120,6 +120,16 @@ export interface CheckpointStep {
120
120
  message?: string;
121
121
  phase?: string;
122
122
  label?: string;
123
+ agent?: string;
124
+ task?: string;
125
+ as?: string;
126
+ output?: string | false;
127
+ outputMode?: OutputMode;
128
+ reads?: string[] | false;
129
+ progress?: boolean;
130
+ skill?: string | string[] | false;
131
+ skills?: string[] | false;
132
+ model?: string;
123
133
  }
124
134
 
125
135
  /** Parallel step: multiple agents running concurrently */