pi-cohort 7.4.5 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [7.6.0] - 2026-10-26
4
+
5
+ ### Breaking
6
+ - All extension settings now come only from `settings.json#subagents`: the profile is below project levels, and the nearest defined value wins. Move the legacy configuration object's keys under `subagents` in the profile's `settings.json` or project `.pi/settings.json`; the old file is ignored and triggers one top-level session-start migration warning. Hooks and backend selection follow each execution unit's effective cwd, including detached runs. (#29)
7
+
8
+ ## [7.5.0] - 2026-10-26
9
+
10
+ ### Added
11
+ - `worktreeTeardownHook` and `worktreeTeardownHookTimeoutMs` let consumers clean up external resources before task checkout removal, including setup rollback; failures produce best-effort warnings without changing task results. (#27)
12
+
13
+ ### Fixed
14
+ - The Tests workflow passes on Windows again: the fanout extension-inheritance integration test hands its preload to `--import` as a `file://` URL instead of a bare drive-letter path.
15
+
3
16
  ## [7.4.5] - 2026-10-23
4
17
 
5
18
  ### Fixed
package/README.md CHANGED
@@ -186,7 +186,7 @@ Full reference: agent/chain authoring in [doc/agents-and-chains.md](doc/agents-a
186
186
 
187
187
  ## Configuration
188
188
 
189
- Most installs need zero configuration. Full env/JSON reference: [doc/configuration.md](doc/configuration.md).
189
+ Most installs need zero configuration. Set options under `subagents` in the profile's `settings.json` or project `.pi/settings.json`; project levels override the profile, nearest wins. See the [settings reference and migration guide](doc/configuration.md) for defaults, whole-object replacement, and moving legacy configuration.
190
190
 
191
191
  Run history lives under the pinned profile's `pi-cohort/` root and has no automatic age expiry. Status listings can grow without bound as history accumulates. To remove history manually, delete only a terminal top-level run directory after confirming its entire subtree has stopped; never delete queued or running state, individual nested records, or shared route files. This removes that directory's status and resume lookup, not separately stored session files or shared routes. Keep those sessions if you need their context; deleting a run directory is not a complete session purge. Storage details: [doc/observability.md](doc/observability.md#files-logs-and-observability).
192
192
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cohort",
3
- "version": "7.4.5",
3
+ "version": "7.6.0",
4
4
  "description": "Delegate Pi work to focused child agents: code review, scouting, implementation, parallel audits, saved chains, and background jobs.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import type { AcceptanceInput, OutputMode, StopCondition } from "../shared/types.ts";
9
+ import type { AcceptanceInput, ExtensionConfig, OutputMode, StopCondition } from "../shared/types.ts";
10
10
  import { getAgentDir } from "../shared/utils.ts";
11
11
  import { KNOWN_FIELDS } from "./agent-serializer.ts";
12
12
  import { parseChain, parseJsonChain } from "./chain-serializer.ts";
@@ -114,12 +114,29 @@ export interface AgentConfig {
114
114
  override?: BuiltinAgentOverrideInfo;
115
115
  }
116
116
 
117
- interface SubagentSettings {
117
+ interface SubagentSettings extends ExtensionConfig {
118
118
  overrides: Record<string, BuiltinAgentOverrideConfig>;
119
119
  disableBuiltins?: boolean;
120
120
  executionBackend?: string;
121
121
  }
122
122
 
123
+ const EXTENSION_SETTING_TYPES = {
124
+ asyncByDefault: "boolean",
125
+ forceTopLevelAsync: "boolean",
126
+ defaultSessionDir: "string",
127
+ maxSubagentDepth: "depth",
128
+ control: "object",
129
+ parallel: "object",
130
+ chain: "object",
131
+ worktreeSetupHook: "string",
132
+ worktreeSetupHookTimeoutMs: "timeout",
133
+ worktreeTeardownHook: "string",
134
+ worktreeTeardownHookTimeoutMs: "timeout",
135
+ showRosterOnStart: "boolean",
136
+ forwardParentFlags: "boolean",
137
+ executionBackend: "backend",
138
+ } as const;
139
+
123
140
  const EMPTY_SUBAGENT_SETTINGS: SubagentSettings = { overrides: {} };
124
141
 
125
142
  export interface ChainStepConfig {
@@ -475,16 +492,28 @@ function readSubagentSettings(filePath: string | null): SubagentSettings {
475
492
  executionBackend = trimmed;
476
493
  }
477
494
 
495
+ const extension: Record<string, unknown> = {};
496
+ for (const [key, type] of Object.entries(EXTENSION_SETTING_TYPES)) {
497
+ if (type === "backend" || !(key in subagentsObject)) continue;
498
+ const value = subagentsObject[key];
499
+ const valid = type === "timeout"
500
+ || (type === "depth" ? typeof value === "number" && Number.isInteger(value) && value >= 0
501
+ : type === "object" ? value !== null && typeof value === "object" && !Array.isArray(value)
502
+ : typeof value === type);
503
+ if (valid) extension[key] = value;
504
+ else console.warn(`Subagent settings in '${filePath}' have invalid '${key}'; expected ${type}.`);
505
+ }
506
+
478
507
  const parsed: Record<string, BuiltinAgentOverrideConfig> = {};
479
508
  const agentOverrides = subagentsObject.agentOverrides;
480
509
  if (!agentOverrides || typeof agentOverrides !== "object" || Array.isArray(agentOverrides)) {
481
- return { overrides: parsed, disableBuiltins, executionBackend };
510
+ return { ...extension, overrides: parsed, disableBuiltins, executionBackend };
482
511
  }
483
512
  for (const [name, value] of Object.entries(agentOverrides)) {
484
513
  const override = parseBuiltinOverrideEntry(name, value, filePath);
485
514
  if (override) parsed[name] = override;
486
515
  }
487
- return { overrides: parsed, disableBuiltins, executionBackend };
516
+ return { ...extension, overrides: parsed, disableBuiltins, executionBackend };
488
517
  }
489
518
 
490
519
  // Merge project .pi/settings.json across every enumerated level, farthest-first
@@ -498,7 +527,7 @@ function readMergedProjectSubagentSettings(cwd: string): SubagentSettings {
498
527
 
499
528
  const overrides: Record<string, BuiltinAgentOverrideConfig> = {};
500
529
  let disableBuiltins: boolean | undefined;
501
- let executionBackend: string | undefined;
530
+ const extension: ExtensionConfig = {};
502
531
  for (const level of levels) {
503
532
  const settingsPath = path.join(level, ".pi", "settings.json");
504
533
  let levelSettings: SubagentSettings;
@@ -519,16 +548,25 @@ function readMergedProjectSubagentSettings(cwd: string): SubagentSettings {
519
548
  if (levelSettings.disableBuiltins !== undefined) {
520
549
  disableBuiltins = levelSettings.disableBuiltins;
521
550
  }
522
- if (levelSettings.executionBackend !== undefined) {
523
- executionBackend = levelSettings.executionBackend;
524
- }
551
+ Object.assign(extension, projectExtensionSettings(levelSettings));
525
552
  }
526
- return { overrides, disableBuiltins, executionBackend };
553
+ return { ...extension, overrides, disableBuiltins };
527
554
  }
528
555
 
529
- export function readProjectExecutionBackend(cwd: string): string | undefined {
530
- const merged = readMergedProjectSubagentSettings(cwd);
531
- return merged.executionBackend;
556
+ function projectExtensionSettings(settings: SubagentSettings): ExtensionConfig {
557
+ return Object.fromEntries(
558
+ Object.keys(EXTENSION_SETTING_TYPES)
559
+ .filter((key) => settings[key as keyof ExtensionConfig] !== undefined)
560
+ .map((key) => [key, settings[key as keyof ExtensionConfig]]),
561
+ );
562
+ }
563
+
564
+ export function resolveExtensionConfig(cwd: string): ExtensionConfig {
565
+ const profile = readSubagentSettings(getUserAgentSettingsPath());
566
+ return {
567
+ ...projectExtensionSettings(profile),
568
+ ...projectExtensionSettings(readMergedProjectSubagentSettings(cwd)),
569
+ };
532
570
  }
533
571
 
534
572
  function composeOverrideTools(
@@ -1,5 +1,4 @@
1
1
  import { selectExecutionBackend } from "./selection.ts";
2
- import { readProjectExecutionBackend } from "../agents/agents.ts";
3
2
  import type { ExtensionConfig } from "../shared/types.ts";
4
3
  import type { ExecutionBackendPreparation, ExecutionBackendSelectionResult } from "./selection.ts";
5
4
 
@@ -13,7 +12,6 @@ export interface ConfiguredSelectionInput {
13
12
  export async function selectConfiguredExecutionBackend(
14
13
  input: ConfiguredSelectionInput,
15
14
  ): Promise<ExecutionBackendSelectionResult> {
16
- const projectPreference = readProjectExecutionBackend(input.cwd);
17
- const preference = projectPreference ?? input.userConfig.executionBackend ?? "auto";
15
+ const preference = input.userConfig.executionBackend ?? "auto";
18
16
  return selectExecutionBackend(preference, input.preparation);
19
17
  }
@@ -2,14 +2,12 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
- import { discoverAgents } from "../agents/agents.ts";
5
+ import { resolveExtensionConfig, discoverAgents } from "../agents/agents.ts";
6
6
  import { getArtifactsDir } from "../shared/artifacts.ts";
7
7
  import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
8
8
  import { SUBAGENT_CHILD_ENV, SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
9
9
  import { readNestedControlRequests, resolveNestedRouteFromEnv, writeNestedControlResult } from "../runs/shared/nested-events.ts";
10
10
  import { SubagentParams } from "./schemas.ts";
11
- import { loadConfig } from "./config.ts";
12
- import { deriveForwardedFlags } from "../runs/shared/forward-flags.ts";
13
11
  import { RUN_STATE_ROOT_DIR, type Details, type SubagentState } from "../shared/types.ts";
14
12
  import { emptyGrandTotal } from "./grand-total.ts";
15
13
 
@@ -126,18 +124,15 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
126
124
  if (registeredApis.has(pi)) return;
127
125
  registeredApis.add(pi);
128
126
 
129
- const config = loadConfig();
130
127
  const state = createChildSafeState();
131
128
  const executor = createSubagentExecutor({
132
129
  pi,
133
130
  state,
134
- config,
135
- asyncByDefault: config.asyncByDefault === true,
131
+ resolveConfig: resolveExtensionConfig,
136
132
  tempArtifactsDir: getArtifactsDir(null),
137
133
  getSubagentSessionRoot,
138
134
  expandTilde,
139
135
  discoverAgents,
140
- forwardedFlags: deriveForwardedFlags(process.argv, config),
141
136
  allowMutatingManagementActions: false,
142
137
  });
143
138
 
@@ -6,10 +6,18 @@
6
6
  * - Async: Background execution, emits events when done
7
7
  *
8
8
  * Modes: single (agent + task), parallel (tasks[]), chain (chain[] with {previous})
9
- * Toggle: async parameter (default: false, configurable via config.json)
9
+ * Toggle: async parameter (default: false, configurable via settings.json#subagents)
10
10
  *
11
- * Config file: ~/.pi/agent/extensions/pi-cohort/config.json
12
- * { "asyncByDefault": true, "forceTopLevelAsync": true, "maxSubagentDepth": 1, "showRosterOnStart": true, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
11
+ * Settings: settings.json#subagents (profile or project .pi/settings.json)
12
+ * { "subagents": {
13
+ * "asyncByDefault": true, "forceTopLevelAsync": true,
14
+ * "defaultSessionDir": "./sessions", "maxSubagentDepth": 1,
15
+ * "control": {}, "parallel": { "maxTasks": 8 },
16
+ * "chain": { "dynamicFanout": { "maxItems": 8 } },
17
+ * "worktreeSetupHook": "./scripts/setup-worktree.mjs", "worktreeSetupHookTimeoutMs": 30000,
18
+ * "worktreeTeardownHook": "./scripts/teardown-worktree.mjs", "worktreeTeardownHookTimeoutMs": 30000,
19
+ * "showRosterOnStart": true, "forwardParentFlags": true, "executionBackend": "auto"
20
+ * } }
13
21
  */
14
22
 
15
23
  import * as fs from "node:fs";
@@ -18,7 +26,7 @@ import * as path from "node:path";
18
26
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
19
27
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
20
28
  import { Box, Container, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
21
- import { discoverAgents, discoverAgentsAll } from "../agents/agents.ts";
29
+ import { resolveExtensionConfig, discoverAgents, discoverAgentsAll } from "../agents/agents.ts";
22
30
  import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "../shared/artifacts.ts";
23
31
  import { resolveCurrentSessionId } from "../shared/session-identity.ts";
24
32
  import { cleanupOldChainDirs } from "../shared/settings.ts";
@@ -36,8 +44,7 @@ import { inspectSubagentStatus } from "../runs/background/run-status.ts";
36
44
  import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/background/notify.ts";
37
45
  import { SUBAGENT_CHILD_ENV } from "../runs/shared/pi-args.ts";
38
46
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
39
- import { loadConfig } from "./config.ts";
40
- import { deriveForwardedFlags } from "../runs/shared/forward-flags.ts";
47
+ import { getAgentDir } from "../shared/utils.ts";
41
48
  import {
42
49
  type Details,
43
50
  type SubagentState,
@@ -61,8 +68,6 @@ import {
61
68
  type SubagentControlMessageDetails,
62
69
  } from "./control-notices.ts";
63
70
 
64
- export { loadConfig } from "./config.ts";
65
-
66
71
  /**
67
72
  * Derive subagent session base directory from parent session file.
68
73
  * If parent session is ~/.pi/agent/sessions/abc123.jsonl,
@@ -250,8 +255,6 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
250
255
  ensureAccessibleDir(ASYNC_DIR, true);
251
256
  cleanupOldChainDirs();
252
257
 
253
- const config = loadConfig();
254
- const asyncByDefault = config.asyncByDefault === true;
255
258
  const tempArtifactsDir = getArtifactsDir(null);
256
259
  cleanupAllArtifactDirs(DEFAULT_ARTIFACT_CONFIG.cleanupDays);
257
260
 
@@ -290,13 +293,11 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
290
293
  const executor = createSubagentExecutor({
291
294
  pi,
292
295
  state,
293
- config,
294
- asyncByDefault,
296
+ resolveConfig: resolveExtensionConfig,
295
297
  tempArtifactsDir,
296
298
  getSubagentSessionRoot,
297
299
  expandTilde,
298
300
  discoverAgents,
299
- forwardedFlags: deriveForwardedFlags(process.argv, config),
300
301
  });
301
302
 
302
303
  pi.registerMessageRenderer<SlashMessageDetails>(SLASH_RESULT_TYPE, (message, options, theme) => {
@@ -542,6 +543,13 @@ DIAGNOSTICS:
542
543
 
543
544
  pi.on("session_start", (_event, ctx) => {
544
545
  resetSessionState(ctx);
546
+ const config = resolveExtensionConfig(ctx.cwd);
547
+ const legacyConfigPath = path.join(getAgentDir(), "extensions", "pi-cohort", "config.json");
548
+ if (fs.existsSync(legacyConfigPath)) {
549
+ const message = `${legacyConfigPath}: pi-cohort no longer reads this file; move its keys under \`subagents\` in \`settings.json\`.`;
550
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
551
+ else console.warn(message);
552
+ }
545
553
  if (ctx.hasUI && config.showRosterOnStart !== false) {
546
554
  const roster = formatSubagentRoster(ctx.cwd);
547
555
  // Posted to scrollback at start (alongside [Skills]/[Extensions]). Safe to
@@ -80,6 +80,8 @@ interface AsyncChainParams {
80
80
  maxSubagentDepth: number;
81
81
  worktreeSetupHook?: string;
82
82
  worktreeSetupHookTimeoutMs?: number;
83
+ worktreeTeardownHook?: string;
84
+ worktreeTeardownHookTimeoutMs?: number;
83
85
  controlConfig?: ResolvedControlConfig;
84
86
  nestedRoute?: NestedRouteInfo;
85
87
  forwardedFlags?: string[];
@@ -115,6 +117,8 @@ interface AsyncSingleParams {
115
117
  maxSubagentDepth: number;
116
118
  worktreeSetupHook?: string;
117
119
  worktreeSetupHookTimeoutMs?: number;
120
+ worktreeTeardownHook?: string;
121
+ worktreeTeardownHookTimeoutMs?: number;
118
122
  controlConfig?: ResolvedControlConfig;
119
123
  nestedRoute?: NestedRouteInfo;
120
124
  forwardedFlags?: string[];
@@ -376,6 +380,8 @@ export function executeAsyncChain(
376
380
  maxSubagentDepth,
377
381
  worktreeSetupHook,
378
382
  worktreeSetupHookTimeoutMs,
383
+ worktreeTeardownHook,
384
+ worktreeTeardownHookTimeoutMs,
379
385
  controlConfig,
380
386
  nestedRoute,
381
387
  forwardedFlags,
@@ -630,6 +636,8 @@ export function executeAsyncChain(
630
636
  forwardedFlags,
631
637
  worktreeSetupHook,
632
638
  worktreeSetupHookTimeoutMs,
639
+ worktreeTeardownHook,
640
+ worktreeTeardownHookTimeoutMs,
633
641
  controlConfig,
634
642
  resultMode,
635
643
  executionBackends: detachedExecutionBackends(ctx.executionBackend),
@@ -748,6 +756,8 @@ export function executeAsyncSingle(
748
756
  maxSubagentDepth,
749
757
  worktreeSetupHook,
750
758
  worktreeSetupHookTimeoutMs,
759
+ worktreeTeardownHook,
760
+ worktreeTeardownHookTimeoutMs,
751
761
  controlConfig,
752
762
  nestedRoute,
753
763
  forwardedFlags,
@@ -840,6 +850,8 @@ export function executeAsyncSingle(
840
850
  forwardedFlags,
841
851
  worktreeSetupHook,
842
852
  worktreeSetupHookTimeoutMs,
853
+ worktreeTeardownHook,
854
+ worktreeTeardownHookTimeoutMs,
843
855
  controlConfig,
844
856
  resultMode: "single",
845
857
  executionBackends: detachedExecutionBackends(ctx.executionBackend),
@@ -1,5 +1,6 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { PINNED_AGENT_DIR_OVERRIDE } from "../../shared/agent-dir.ts";
3
+ import { resolveExtensionConfig } from "../../agents/agents.ts";
3
4
  import * as fs from "node:fs";
4
5
  import * as path from "node:path";
5
6
  import { pathToFileURL } from "node:url";
@@ -120,6 +121,8 @@ interface SubagentRunConfig {
120
121
  forwardedFlags?: string[];
121
122
  worktreeSetupHook?: string;
122
123
  worktreeSetupHookTimeoutMs?: number;
124
+ worktreeTeardownHook?: string;
125
+ worktreeTeardownHookTimeoutMs?: number;
123
126
  controlConfig?: ResolvedControlConfig;
124
127
  resultMode?: SubagentRunMode;
125
128
  dynamicFanoutMaxItems?: number;
@@ -606,6 +609,7 @@ function writeRunLog(
606
609
 
607
610
  /** Internal context for one detached-runner leaf. */
608
611
  export interface SingleStepContext {
612
+ jobCwd?: string;
609
613
  previousOutput: string;
610
614
  outputs?: ChainOutputMap;
611
615
  trees?: TreeRegistry;
@@ -664,9 +668,13 @@ export async function runSingleStep(
664
668
  }
665
669
  let backendSelection: import("../../execution-backend/selection.ts").ExecutionBackendSelectionResult;
666
670
  try {
671
+ const unitConfig = resolveExtensionConfig(childCwd);
672
+ const jobPreference = path.resolve(childCwd) === path.resolve(ctx.jobCwd ?? ctx.cwd)
673
+ ? ctx.executionBackendConfig?.userPreference
674
+ : undefined;
667
675
  backendSelection = await selectConfiguredExecutionBackend({
668
676
  cwd: childCwd,
669
- userConfig: { executionBackend: ctx.executionBackendConfig?.userPreference },
677
+ userConfig: { executionBackend: unitConfig.executionBackend ?? jobPreference },
670
678
  preparation: ctx.executionBackendCoordinator,
671
679
  });
672
680
  } catch (error) {
@@ -1959,6 +1967,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1959
1967
  writeStatusPayload();
1960
1968
  appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.started", ts: taskStartTime, runId: id, stepIndex: fi, agent: task.agent }));
1961
1969
  const singleResult = await runSingleStep(task, {
1970
+ jobCwd: config.cwd,
1962
1971
  previousOutput, placeholder, cwd, sessionEnabled,
1963
1972
  outputs,
1964
1973
  trees,
@@ -2100,8 +2109,37 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2100
2109
  }
2101
2110
 
2102
2111
  if (isParallelGroup(step)) {
2103
- const group = step;
2104
- const groupCwd = group.cwd?.includes("{outputs.") ? group.cwd : resolveChildCwd(cwd, group.cwd);
2112
+ let group = step;
2113
+ let groupCwd = group.cwd?.includes("{outputs.") ? group.cwd : resolveChildCwd(cwd, group.cwd);
2114
+ let groupConfig: ReturnType<typeof resolveExtensionConfig> = {};
2115
+ if (group.worktree) {
2116
+ try {
2117
+ groupCwd = resolveChildCwd(cwd, resolveOutputReferences(group.cwd ?? cwd, outputs, trees));
2118
+ group = {
2119
+ ...group,
2120
+ parallel: group.parallel.map((task) => ({
2121
+ ...task,
2122
+ cwd: task.cwd === undefined ? undefined : resolveChildCwd(cwd, resolveOutputReferences(task.cwd, outputs, trees)),
2123
+ })),
2124
+ };
2125
+ const resolved = resolveExtensionConfig(groupCwd);
2126
+ const fallback = path.resolve(groupCwd) === path.resolve(config.cwd) ? config : {};
2127
+ groupConfig = {
2128
+ worktreeSetupHook: resolved.worktreeSetupHook ?? fallback.worktreeSetupHook,
2129
+ worktreeSetupHookTimeoutMs: resolved.worktreeSetupHookTimeoutMs === undefined ? fallback.worktreeSetupHookTimeoutMs : resolved.worktreeSetupHookTimeoutMs,
2130
+ worktreeTeardownHook: resolved.worktreeTeardownHook ?? fallback.worktreeTeardownHook,
2131
+ worktreeTeardownHookTimeoutMs: resolved.worktreeTeardownHookTimeoutMs === undefined ? fallback.worktreeTeardownHookTimeoutMs : resolved.worktreeTeardownHookTimeoutMs,
2132
+ };
2133
+ } catch (error) {
2134
+ markParallelGroupSetupFailure({
2135
+ statusPayload, results, group, groupStartFlatIndex: flatIndex,
2136
+ setupError: error instanceof Error ? error.message : String(error), failedAt: Date.now(),
2137
+ statusPath, eventsPath, asyncDir, runId: id, stepIndex,
2138
+ });
2139
+ flatIndex += group.parallel.length;
2140
+ break;
2141
+ }
2142
+ }
2105
2143
  const concurrency = group.concurrency ?? MAX_PARALLEL_CONCURRENCY;
2106
2144
  const failFast = group.failFast ?? false;
2107
2145
  const groupStartFlatIndex = flatIndex;
@@ -2130,8 +2168,11 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2130
2168
  try {
2131
2169
  worktreeSetup = createWorktrees(groupCwd, `${id}-s${stepIndex}`, group.parallel.length, {
2132
2170
  agents: group.parallel.map((task) => task.agent),
2133
- setupHook: config.worktreeSetupHook
2134
- ? { hookPath: config.worktreeSetupHook, timeoutMs: config.worktreeSetupHookTimeoutMs }
2171
+ setupHook: groupConfig.worktreeSetupHook
2172
+ ? { hookPath: groupConfig.worktreeSetupHook, timeoutMs: groupConfig.worktreeSetupHookTimeoutMs }
2173
+ : undefined,
2174
+ teardownHook: groupConfig.worktreeTeardownHook
2175
+ ? { hookPath: groupConfig.worktreeTeardownHook, timeoutMs: groupConfig.worktreeTeardownHookTimeoutMs }
2135
2176
  : undefined,
2136
2177
  });
2137
2178
  } catch (error) {
@@ -2213,9 +2254,10 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2213
2254
  const taskSessionDir = config.sessionDir
2214
2255
  ? path.join(config.sessionDir, `parallel-${fi}`)
2215
2256
  : undefined;
2216
- const { taskForRun, taskCwd } = prepareParallelTaskRun(task, cwd, worktreeSetup, taskIdx, group.cwd);
2257
+ const { taskForRun, taskCwd } = prepareParallelTaskRun(task, cwd, worktreeSetup, taskIdx, groupCwd);
2217
2258
 
2218
2259
  const singleResult = await runSingleStep(taskForRun, {
2260
+ jobCwd: config.cwd,
2219
2261
  previousOutput, placeholder, cwd: taskCwd, sessionEnabled,
2220
2262
  outputs,
2221
2263
  trees,
@@ -2395,6 +2437,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2395
2437
  }));
2396
2438
 
2397
2439
  const singleResult = await runSingleStep(seqStep, {
2440
+ jobCwd: config.cwd,
2398
2441
  previousOutput, placeholder, cwd, sessionEnabled,
2399
2442
  outputs,
2400
2443
  trees,
@@ -31,6 +31,7 @@ import {
31
31
  type ResolvedTemplates,
32
32
  } from "../../shared/settings.ts";
33
33
  import { discoverAvailableSkills, normalizeSkillInput } from "../../agents/skills.ts";
34
+ import type { ExtensionConfig } from "../../shared/types.ts";
34
35
  import { runSync } from "./execution.ts";
35
36
  import type { PiRuntimeIdentity } from "../shared/pi-spawn.ts";
36
37
  import { buildChainSummary, buildChainOutputsSection, buildChainStopSection, type ChainSummaryOptions } from "../../shared/formatters.ts";
@@ -142,6 +143,7 @@ interface ParallelChainRunInput {
142
143
  maxSubagentDepth: number;
143
144
  nestedRoute?: NestedRouteInfo;
144
145
  forwardedFlags?: string[];
146
+ resolveConfig?: (cwd: string) => ExtensionConfig;
145
147
  }
146
148
 
147
149
  function buildChainExecutionDetails(input: ChainExecutionDetailsInput): Details {
@@ -295,6 +297,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
295
297
  const result = await runSync(input.ctx.cwd, input.agents, task.agent, taskStr, {
296
298
  runtime: input.runtime,
297
299
  cwd: taskCwd,
300
+ executionBackend: input.resolveConfig?.(taskCwd).executionBackend,
298
301
  signal: input.signal,
299
302
  interruptSignal: interruptController.signal,
300
303
  runId: input.runId,
@@ -420,7 +423,10 @@ interface ChainExecutionParams {
420
423
  nestedRoute?: NestedRouteInfo;
421
424
  worktreeSetupHook?: string;
422
425
  worktreeSetupHookTimeoutMs?: number;
426
+ worktreeTeardownHook?: string;
427
+ worktreeTeardownHookTimeoutMs?: number;
423
428
  forwardedFlags?: string[];
429
+ resolveConfig?: (cwd: string) => ExtensionConfig;
424
430
  }
425
431
 
426
432
  interface ChainExecutionResult {
@@ -643,10 +649,12 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
643
649
 
644
650
  if (isParallelStep(step)) {
645
651
  const parallelTemplates = stepTemplates as string[];
646
- const parallelCwd = resolveChildCwd(cwd ?? ctx.cwd, step.cwd);
652
+ const parallelCwd = resolveChildCwd(cwd ?? ctx.cwd, step.cwd === undefined ? undefined : resolveOutputReferences(step.cwd, outputs, trees));
647
653
  let worktreeSetup: WorktreeSetup | undefined;
648
654
  if (step.worktree) {
649
- const worktreeTaskCwdConflict = findWorktreeTaskCwdConflict(step.parallel, parallelCwd);
655
+ const groupConfig = params.resolveConfig?.(parallelCwd) ?? params;
656
+ const resolvedTasks = step.parallel.map((task) => ({ ...task, cwd: task.cwd === undefined ? undefined : resolveOutputReferences(task.cwd, outputs, trees) }));
657
+ const worktreeTaskCwdConflict = findWorktreeTaskCwdConflict(resolvedTasks, parallelCwd);
650
658
  if (worktreeTaskCwdConflict) {
651
659
  return buildChainExecutionErrorResult(
652
660
  `parallel chain step ${stepIndex + 1}: ${formatWorktreeTaskCwdConflict(worktreeTaskCwdConflict, parallelCwd)}`,
@@ -657,8 +665,11 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
657
665
  try {
658
666
  worktreeSetup = createWorktrees(parallelCwd, `${runId}-s${stepIndex}`, step.parallel.length, {
659
667
  agents: step.parallel.map((task) => task.agent),
660
- setupHook: params.worktreeSetupHook
661
- ? { hookPath: params.worktreeSetupHook, timeoutMs: params.worktreeSetupHookTimeoutMs }
668
+ setupHook: groupConfig.worktreeSetupHook
669
+ ? { hookPath: groupConfig.worktreeSetupHook, timeoutMs: groupConfig.worktreeSetupHookTimeoutMs }
670
+ : undefined,
671
+ teardownHook: groupConfig.worktreeTeardownHook
672
+ ? { hookPath: groupConfig.worktreeTeardownHook, timeoutMs: groupConfig.worktreeTeardownHookTimeoutMs }
662
673
  : undefined,
663
674
  });
664
675
  } catch (error) {
@@ -698,6 +709,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
698
709
  runId,
699
710
  globalTaskIndex,
700
711
  forwardedFlags: params.forwardedFlags,
712
+ resolveConfig: params.resolveConfig,
701
713
  sessionDirForIndex,
702
714
  sessionFileForIndex,
703
715
  shareEnabled,
@@ -709,7 +721,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
709
721
  allProgress,
710
722
  outputs,
711
723
  trees,
712
- stepCwd: step.cwd,
724
+ stepCwd: parallelCwd,
713
725
  chainAgents,
714
726
  chainSteps,
715
727
  totalSteps,
@@ -907,6 +919,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
907
919
  runId,
908
920
  globalTaskIndex,
909
921
  forwardedFlags: params.forwardedFlags,
922
+ resolveConfig: params.resolveConfig,
910
923
  sessionDirForIndex,
911
924
  sessionFileForIndex,
912
925
  shareEnabled,
@@ -1126,6 +1139,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1126
1139
  const r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
1127
1140
  runtime: params.runtime,
1128
1141
  cwd: stepCwd,
1142
+ executionBackend: params.resolveConfig?.(stepCwd).executionBackend,
1129
1143
  signal,
1130
1144
  interruptSignal: interruptController.signal,
1131
1145
  runId,
@@ -8,6 +8,7 @@ import type { AgentConfig, AgentScope } from "../../agents/agents.ts";
8
8
  import { getArtifactsDir } from "../../shared/artifacts.ts";
9
9
  import { ChainClarifyComponent, type ChainClarifyResult } from "./chain-clarify.ts";
10
10
  import { parentModelFullId, toModelInfo, type ModelInfo } from "../../shared/model-info.ts";
11
+ import { deriveForwardedFlags } from "../shared/forward-flags.ts";
11
12
  import { executeChain } from "./chain-execution.ts";
12
13
  import { resolveExecutionAgentScope } from "../../agents/agent-scope.ts";
13
14
  import { handleManagementAction } from "../../agents/agent-management.ts";
@@ -137,9 +138,7 @@ interface ExecutorDeps {
137
138
  captureRuntime?: () => PiRuntimeIdentity;
138
139
  pi: ExtensionAPI;
139
140
  state: SubagentState;
140
- config: ExtensionConfig;
141
- forwardedFlags: string[];
142
- asyncByDefault: boolean;
141
+ resolveConfig: (cwd: string) => ExtensionConfig;
143
142
  tempArtifactsDir: string;
144
143
  getSubagentSessionRoot: (parentSessionFile: string | null) => string;
145
144
  expandTilde: (p: string) => string;
@@ -147,6 +146,8 @@ interface ExecutorDeps {
147
146
  allowMutatingManagementActions?: boolean;
148
147
  }
149
148
 
149
+ type CallExecutorDeps = ExecutorDeps & { config: ExtensionConfig; forwardedFlags: string[]; asyncByDefault: boolean };
150
+
150
151
  interface ExecutionContextData {
151
152
  runtime: PiRuntimeIdentity;
152
153
  params: SubagentParamsLike;
@@ -205,7 +206,7 @@ function formatForegroundActivity(control: SubagentState["foregroundControls"] e
205
206
  return [`active ${seconds}s ago`, ...facts].join(" | ");
206
207
  }
207
208
 
208
- function nestedResolutionScopeForExecutor(deps: ExecutorDeps): NestedRunResolutionScope | undefined {
209
+ function nestedResolutionScopeForExecutor(deps: CallExecutorDeps): NestedRunResolutionScope | undefined {
209
210
  if (deps.allowMutatingManagementActions !== false) return undefined;
210
211
  const route = resolveInheritedNestedRouteFromEnv();
211
212
  const address = route ? resolveNestedParentAddressFromEnv() : undefined;
@@ -215,7 +216,7 @@ function nestedResolutionScopeForExecutor(deps: ExecutorDeps): NestedRunResoluti
215
216
  };
216
217
  }
217
218
 
218
- function buildAsyncContext(ctx: ExtensionContext, deps: ExecutorDeps, cwd: string, runtime: PiRuntimeIdentity) {
219
+ function buildAsyncContext(ctx: ExtensionContext, deps: CallExecutorDeps, cwd: string, runtime: PiRuntimeIdentity) {
219
220
  return {
220
221
  runtime,
221
222
  pi: deps.pi,
@@ -581,7 +582,7 @@ async function resumeAsyncRun(input: {
581
582
  params: SubagentParamsLike;
582
583
  requestCwd: string;
583
584
  ctx: ExtensionContext;
584
- deps: ExecutorDeps;
585
+ deps: CallExecutorDeps;
585
586
  }): Promise<AgentToolResult<Details>> {
586
587
  const followUp = (input.params.message ?? input.params.task ?? "").trim();
587
588
  if (!followUp) {
@@ -627,6 +628,7 @@ async function resumeAsyncRun(input: {
627
628
 
628
629
  input.deps.state.currentSessionId = resolveCurrentSessionId(input.ctx.sessionManager);
629
630
  const effectiveCwd = target.cwd ?? input.requestCwd;
631
+ const unitConfig = effectiveCwd === input.requestCwd ? input.deps.config : input.deps.resolveConfig(effectiveCwd);
630
632
  const scope: AgentScope = resolveExecutionAgentScope(input.params.agentScope);
631
633
  const discoveredAgents = input.deps.discoverAgents(effectiveCwd, scope).agents;
632
634
  const agentConfig = discoveredAgents.find((agent) => agent.name === target.agent);
@@ -649,7 +651,7 @@ async function resumeAsyncRun(input: {
649
651
  agent: target.agent,
650
652
  task: buildRevivedAsyncTask(target, followUp),
651
653
  agentConfig,
652
- ctx: buildAsyncContext(input.ctx, input.deps, input.requestCwd, target.runtime),
654
+ ctx: { ...buildAsyncContext(input.ctx, input.deps, input.requestCwd, target.runtime), executionBackend: unitConfig.executionBackend },
653
655
  cwd: effectiveCwd,
654
656
  maxOutput: input.params.maxOutput,
655
657
  artifactsDir: input.deps.tempArtifactsDir,
@@ -663,8 +665,10 @@ async function resumeAsyncRun(input: {
663
665
  originSessionRoot: originalRoot,
664
666
  sessionFile: target.sessionFile,
665
667
  maxSubagentDepth: resolveCurrentMaxSubagentDepth(input.deps.config.maxSubagentDepth),
666
- worktreeSetupHook: input.deps.config.worktreeSetupHook,
667
- worktreeSetupHookTimeoutMs: input.deps.config.worktreeSetupHookTimeoutMs,
668
+ worktreeSetupHook: unitConfig.worktreeSetupHook,
669
+ worktreeSetupHookTimeoutMs: unitConfig.worktreeSetupHookTimeoutMs,
670
+ worktreeTeardownHook: unitConfig.worktreeTeardownHook,
671
+ worktreeTeardownHookTimeoutMs: unitConfig.worktreeTeardownHookTimeoutMs,
668
672
  controlConfig: resolveControlConfig(input.deps.config.control, input.params.control),
669
673
  availableModels,
670
674
  forwardedFlags: input.deps.forwardedFlags,
@@ -978,7 +982,7 @@ function wrapChainTasksForFork(chain: ChainStep[], context: SubagentParamsLike["
978
982
  });
979
983
  }
980
984
 
981
- function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentToolResult<Details> | null {
985
+ function runAsyncPath(data: ExecutionContextData, deps: CallExecutorDeps): AgentToolResult<Details> | null {
982
986
  const {
983
987
  params,
984
988
  effectiveCwd,
@@ -1073,6 +1077,8 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1073
1077
  maxSubagentDepth: currentMaxSubagentDepth,
1074
1078
  worktreeSetupHook: deps.config.worktreeSetupHook,
1075
1079
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
1080
+ worktreeTeardownHook: deps.config.worktreeTeardownHook,
1081
+ worktreeTeardownHookTimeoutMs: deps.config.worktreeTeardownHookTimeoutMs,
1076
1082
  controlConfig,
1077
1083
  nestedRoute,
1078
1084
  forwardedFlags: deps.forwardedFlags,
@@ -1102,6 +1108,8 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1102
1108
  maxSubagentDepth: currentMaxSubagentDepth,
1103
1109
  worktreeSetupHook: deps.config.worktreeSetupHook,
1104
1110
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
1111
+ worktreeTeardownHook: deps.config.worktreeTeardownHook,
1112
+ worktreeTeardownHookTimeoutMs: deps.config.worktreeTeardownHookTimeoutMs,
1105
1113
  controlConfig,
1106
1114
  nestedRoute,
1107
1115
  forwardedFlags: deps.forwardedFlags,
@@ -1144,6 +1152,8 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1144
1152
  maxSubagentDepth,
1145
1153
  worktreeSetupHook: deps.config.worktreeSetupHook,
1146
1154
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
1155
+ worktreeTeardownHook: deps.config.worktreeTeardownHook,
1156
+ worktreeTeardownHookTimeoutMs: deps.config.worktreeTeardownHookTimeoutMs,
1147
1157
  controlConfig,
1148
1158
  nestedRoute,
1149
1159
  acceptance: params.acceptance,
@@ -1154,7 +1164,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1154
1164
  return null;
1155
1165
  }
1156
1166
 
1157
- async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Promise<AgentToolResult<Details>> {
1167
+ async function runChainPath(data: ExecutionContextData, deps: CallExecutorDeps): Promise<AgentToolResult<Details>> {
1158
1168
  const {
1159
1169
  params,
1160
1170
  effectiveCwd,
@@ -1200,10 +1210,13 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1200
1210
  nestedRoute: foregroundControl?.nestedRoute,
1201
1211
  chainSkills,
1202
1212
  chainDir: params.chainDir,
1213
+ resolveConfig: deps.resolveConfig,
1203
1214
  dynamicFanoutMaxItems: deps.config.chain?.dynamicFanout?.maxItems,
1204
1215
  maxSubagentDepth: currentMaxSubagentDepth,
1205
1216
  worktreeSetupHook: deps.config.worktreeSetupHook,
1206
1217
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
1218
+ worktreeTeardownHook: deps.config.worktreeTeardownHook,
1219
+ worktreeTeardownHookTimeoutMs: deps.config.worktreeTeardownHookTimeoutMs,
1207
1220
  forwardedFlags: deps.forwardedFlags,
1208
1221
  });
1209
1222
 
@@ -1237,6 +1250,8 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1237
1250
  maxSubagentDepth: currentMaxSubagentDepth,
1238
1251
  worktreeSetupHook: deps.config.worktreeSetupHook,
1239
1252
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
1253
+ worktreeTeardownHook: deps.config.worktreeTeardownHook,
1254
+ worktreeTeardownHookTimeoutMs: deps.config.worktreeTeardownHookTimeoutMs,
1240
1255
  controlConfig,
1241
1256
  nestedRoute: data.nestedRoute,
1242
1257
  forwardedFlags: deps.forwardedFlags,
@@ -1276,7 +1291,7 @@ interface ForegroundParallelRunInput {
1276
1291
  onControlEvent?: (event: ControlEvent) => void;
1277
1292
  foregroundControl?: SubagentState["foregroundControls"] extends Map<string, infer T> ? T : never;
1278
1293
  concurrencyLimit: number;
1279
- executionBackend?: string;
1294
+ resolveConfig: (cwd: string) => ExtensionConfig;
1280
1295
  liveResults: (SingleResult | undefined)[];
1281
1296
  liveProgress: (AgentProgress | undefined)[];
1282
1297
  onUpdate?: (r: AgentToolResult<Details>) => void;
@@ -1299,6 +1314,8 @@ function createParallelWorktreeSetup(
1299
1314
  tasks: TaskParam[],
1300
1315
  setupHook: ExtensionConfig["worktreeSetupHook"],
1301
1316
  setupHookTimeoutMs: ExtensionConfig["worktreeSetupHookTimeoutMs"],
1317
+ teardownHook: ExtensionConfig["worktreeTeardownHook"],
1318
+ teardownHookTimeoutMs: ExtensionConfig["worktreeTeardownHookTimeoutMs"],
1302
1319
  ): { setup?: WorktreeSetup; errorResult?: AgentToolResult<Details> } {
1303
1320
  if (!enabled) return {};
1304
1321
  try {
@@ -1308,6 +1325,9 @@ function createParallelWorktreeSetup(
1308
1325
  setupHook: setupHook
1309
1326
  ? { hookPath: setupHook, timeoutMs: setupHookTimeoutMs }
1310
1327
  : undefined,
1328
+ teardownHook: teardownHook
1329
+ ? { hookPath: teardownHook, timeoutMs: teardownHookTimeoutMs }
1330
+ : undefined,
1311
1331
  }),
1312
1332
  };
1313
1333
  } catch (error) {
@@ -1464,7 +1484,7 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1464
1484
  signal: input.signal,
1465
1485
  interruptSignal: interruptController.signal,
1466
1486
  runId: input.runId,
1467
- executionBackend: input.executionBackend,
1487
+ executionBackend: input.resolveConfig(taskCwd).executionBackend,
1468
1488
  index,
1469
1489
  sessionDir: input.sessionDirForIndex(index),
1470
1490
  sessionFile: input.sessionFileForIndex(index),
@@ -1530,7 +1550,7 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1530
1550
  });
1531
1551
  }
1532
1552
 
1533
- async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps): Promise<AgentToolResult<Details>> {
1553
+ async function runParallelPath(data: ExecutionContextData, deps: CallExecutorDeps): Promise<AgentToolResult<Details>> {
1534
1554
  const {
1535
1555
  params,
1536
1556
  effectiveCwd,
@@ -1691,6 +1711,8 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1691
1711
  maxSubagentDepth: currentMaxSubagentDepth,
1692
1712
  worktreeSetupHook: deps.config.worktreeSetupHook,
1693
1713
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
1714
+ worktreeTeardownHook: deps.config.worktreeTeardownHook,
1715
+ worktreeTeardownHookTimeoutMs: deps.config.worktreeTeardownHookTimeoutMs,
1694
1716
  controlConfig,
1695
1717
  forwardedFlags: deps.forwardedFlags,
1696
1718
  });
@@ -1709,6 +1731,8 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1709
1731
  tasks,
1710
1732
  deps.config.worktreeSetupHook,
1711
1733
  deps.config.worktreeSetupHookTimeoutMs,
1734
+ deps.config.worktreeTeardownHook,
1735
+ deps.config.worktreeTeardownHookTimeoutMs,
1712
1736
  );
1713
1737
  if (errorResult) return errorResult;
1714
1738
 
@@ -1786,7 +1810,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1786
1810
  onUpdate: onUpdateWithCost,
1787
1811
  worktreeSetup,
1788
1812
  forwardedFlags: deps.forwardedFlags,
1789
- executionBackend: deps.config.executionBackend,
1813
+ resolveConfig: deps.resolveConfig,
1790
1814
  });
1791
1815
  if (foregroundControl) updateForegroundNestedProjection(foregroundControl);
1792
1816
  recordSyncCost(deps.state.grandTotal, runId, results.reduce((sum, r) => sum + r.usage.cost, 0) + sumNestedCost(foregroundControl?.nestedChildren));
@@ -1846,7 +1870,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1846
1870
  }
1847
1871
  }
1848
1872
 
1849
- async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Promise<AgentToolResult<Details>> {
1873
+ async function runSinglePath(data: ExecutionContextData, deps: CallExecutorDeps): Promise<AgentToolResult<Details>> {
1850
1874
  const {
1851
1875
  params,
1852
1876
  effectiveCwd,
@@ -1952,6 +1976,8 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1952
1976
  maxSubagentDepth,
1953
1977
  worktreeSetupHook: deps.config.worktreeSetupHook,
1954
1978
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
1979
+ worktreeTeardownHook: deps.config.worktreeTeardownHook,
1980
+ worktreeTeardownHookTimeoutMs: deps.config.worktreeTeardownHookTimeoutMs,
1955
1981
  controlConfig,
1956
1982
  forwardedFlags: deps.forwardedFlags,
1957
1983
  });
@@ -2109,7 +2135,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2109
2135
  };
2110
2136
  }
2111
2137
 
2112
- export function createSubagentExecutor(deps: ExecutorDeps): {
2138
+ export function createSubagentExecutor(services: ExecutorDeps): {
2113
2139
  execute: (
2114
2140
  id: string,
2115
2141
  params: SubagentParamsLike,
@@ -2122,7 +2148,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2122
2148
  let runtimeError: unknown;
2123
2149
  try {
2124
2150
  // SDK hosts are not launched through Pi's CLI; the installation this extension was loaded from is the fallback.
2125
- runtime = (deps.captureRuntime ?? (() => capturePiRuntime({ installRoot: getPackageDir() })))();
2151
+ runtime = (services.captureRuntime ?? (() => capturePiRuntime({ installRoot: getPackageDir() })))();
2126
2152
  } catch (error) {
2127
2153
  runtimeError = error;
2128
2154
  }
@@ -2133,11 +2159,13 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2133
2159
  onUpdate: ((r: AgentToolResult<Details>) => void) | undefined,
2134
2160
  ctx: ExtensionContext,
2135
2161
  ): Promise<AgentToolResult<Details>> => {
2162
+ const requestCwd = resolveRequestedCwd(ctx.cwd, params.cwd);
2163
+ const config = services.resolveConfig(requestCwd);
2164
+ const deps: CallExecutorDeps = { ...services, config, forwardedFlags: deriveForwardedFlags(process.argv, config), asyncByDefault: config.asyncByDefault === true };
2136
2165
  deps.state.baseCwd = ctx.cwd;
2137
2166
  deps.state.foregroundRuns ??= new Map();
2138
2167
  deps.state.foregroundControls ??= new Map();
2139
2168
  deps.state.lastForegroundControlId ??= null;
2140
- const requestCwd = resolveRequestedCwd(ctx.cwd, params.cwd);
2141
2169
  const paramsWithResolvedCwd = params.cwd === undefined ? params : { ...params, cwd: requestCwd };
2142
2170
  if (params.action) {
2143
2171
  if (params.action === "doctor") {
@@ -81,9 +81,6 @@ export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutCon
81
81
  }
82
82
  for (const { template, cwd } of taskTemplatesForStep(step)) {
83
83
  for (const match of template.matchAll(OUTPUT_REF_PATTERN)) {
84
- if (cwd && isParallelStep(step) && step.worktree) {
85
- throw new ChainOutputValidationError(`Chain step ${stepIndex + 1}: cwd output references cannot be combined with worktree: true.`);
86
- }
87
84
  const rawReference = match[0];
88
85
  const parsed = parseOutputReference(match[1]!);
89
86
  if (!parsed) {
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import * as fs from "node:fs";
2
+ import fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
 
@@ -10,6 +10,8 @@ export interface WorktreeSetup {
10
10
  }
11
11
 
12
12
  interface WorktreeInfo {
13
+ hookInput: WorktreeSetupHookInput;
14
+ teardownHook?: ResolvedWorktreeTeardownHook;
13
15
  path: string;
14
16
  agentCwd: string;
15
17
  branch: string;
@@ -41,7 +43,18 @@ interface WorktreeSetupHookConfig {
41
43
  timeoutMs?: number;
42
44
  }
43
45
 
46
+ interface WorktreeTeardownHookConfig {
47
+ hookPath: string;
48
+ timeoutMs?: number;
49
+ }
50
+
51
+ interface ResolvedWorktreeTeardownHook {
52
+ hookPath: string;
53
+ timeoutMs: number;
54
+ }
55
+
44
56
  interface CreateWorktreesOptions {
57
+ teardownHook?: WorktreeTeardownHookConfig;
45
58
  agents?: string[];
46
59
  setupHook?: WorktreeSetupHookConfig;
47
60
  }
@@ -229,6 +242,43 @@ function resolveWorktreeSetupHook(
229
242
  };
230
243
  }
231
244
 
245
+ function resolveWorktreeTeardownHook(
246
+ repoRoot: string,
247
+ config: WorktreeTeardownHookConfig | undefined,
248
+ ): ResolvedWorktreeTeardownHook | undefined {
249
+ if (!config) return undefined;
250
+ const hookPath = config.hookPath.trim();
251
+ if (!hookPath) {
252
+ throw new Error("worktree teardown hook path cannot be empty");
253
+ }
254
+
255
+ const expandedHookPath = hookPath.startsWith("~/") ? path.join(os.homedir(), hookPath.slice(2)) : hookPath;
256
+ let resolvedPath: string;
257
+ if (path.isAbsolute(expandedHookPath)) {
258
+ resolvedPath = expandedHookPath;
259
+ } else if (expandedHookPath.includes("/") || expandedHookPath.includes("\\")) {
260
+ resolvedPath = path.resolve(repoRoot, expandedHookPath);
261
+ } else {
262
+ throw new Error("worktree teardown hook must be an absolute path or a repo-relative path");
263
+ }
264
+
265
+ if (!fs.existsSync(resolvedPath)) {
266
+ throw new Error(`worktree teardown hook not found: ${resolvedPath}`);
267
+ }
268
+ if (fs.statSync(resolvedPath).isDirectory()) {
269
+ throw new Error(`worktree teardown hook must be a file, got directory: ${resolvedPath}`);
270
+ }
271
+
272
+ const timeoutMs = config.timeoutMs === undefined ? 30000 : config.timeoutMs;
273
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
274
+ throw new Error("worktree teardown hook timeout must be an integer greater than 0");
275
+ }
276
+ return {
277
+ hookPath: resolvedPath,
278
+ timeoutMs: timeoutMs,
279
+ };
280
+ }
281
+
232
282
  function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
233
283
  const trimmed = rawPath.trim();
234
284
  if (!trimmed) throw new Error("synthetic path cannot be empty");
@@ -313,6 +363,40 @@ function runWorktreeSetupHook(
313
363
  return [...uniquePaths];
314
364
  }
315
365
 
366
+ function warnWorktreeCleanup(message: string): void {
367
+ try { fs.write(2, message + "\n", () => {}); } catch {}
368
+ }
369
+
370
+ function runWorktreeTeardownHook(
371
+ hook: ResolvedWorktreeTeardownHook | undefined,
372
+ input: WorktreeSetupHookInput,
373
+ ): void {
374
+ if (!hook) return;
375
+ try {
376
+ const result = spawnSync(hook.hookPath, [], {
377
+ cwd: input.worktreePath,
378
+ encoding: "utf-8",
379
+ input: JSON.stringify(input),
380
+ timeout: hook.timeoutMs,
381
+ killSignal: "SIGKILL",
382
+ shell: false,
383
+ stdio: ["pipe", "ignore", "pipe"],
384
+ });
385
+ let cause: string;
386
+ if (result.error) {
387
+ cause = "code" in result.error && result.error.code === "ETIMEDOUT"
388
+ ? `timed out after ${hook.timeoutMs}ms`
389
+ : result.error.message;
390
+ } else if (result.status !== 0) {
391
+ cause = result.signal ? `signal ${result.signal}` : `exit code ${result.status}`;
392
+ } else return;
393
+ const stderr = result.stderr?.trim();
394
+ warnWorktreeCleanup(`worktree teardown hook failed for '${input.worktreePath}': ${cause}${stderr ? `: ${stderr}` : ""}`);
395
+ } catch (error) {
396
+ warnWorktreeCleanup(`worktree teardown hook failed for '${input.worktreePath}': ${error instanceof Error ? error.message : String(error)}`);
397
+ }
398
+ }
399
+
316
400
  function createSingleWorktree(
317
401
  toplevel: string,
318
402
  cwdRelative: string,
@@ -320,6 +404,7 @@ function createSingleWorktree(
320
404
  index: number,
321
405
  baseCommit: string,
322
406
  setupHook: ResolvedWorktreeSetupHook | undefined,
407
+ teardownHook: ResolvedWorktreeTeardownHook | undefined,
323
408
  agent: string | undefined,
324
409
  ): WorktreeInfo {
325
410
  const branch = buildWorktreeBranch(runId, index);
@@ -331,27 +416,22 @@ function createSingleWorktree(
331
416
  }
332
417
 
333
418
  const agentCwd = cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
419
+ const hookInput: WorktreeSetupHookInput = {
420
+ version: 1, repoRoot: toplevel, worktreePath, agentCwd, branch, index, runId, baseCommit, agent,
421
+ };
334
422
  try {
335
423
  const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktreePath);
336
424
  const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
337
425
 
338
426
  if (setupHook) {
339
- const hookSyntheticPaths = runWorktreeSetupHook(setupHook, {
340
- version: 1,
341
- repoRoot: toplevel,
342
- worktreePath,
343
- agentCwd,
344
- branch,
345
- index,
346
- runId,
347
- baseCommit,
348
- agent,
349
- });
427
+ const hookSyntheticPaths = runWorktreeSetupHook(setupHook, hookInput);
350
428
  syntheticPaths.push(...hookSyntheticPaths);
351
429
  }
352
430
 
353
431
  return {
354
432
  path: worktreePath,
433
+ hookInput,
434
+ teardownHook,
355
435
  agentCwd,
356
436
  branch,
357
437
  index,
@@ -359,11 +439,12 @@ function createSingleWorktree(
359
439
  syntheticPaths,
360
440
  };
361
441
  } catch (error) {
362
- try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktreePath]); } catch {
363
- // Best-effort rollback; preserve the original setup failure.
442
+ runWorktreeTeardownHook(teardownHook, hookInput);
443
+ try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktreePath]); } catch (cleanupError) {
444
+ warnWorktreeCleanup(`worktree remove failed for '${worktreePath}': ${cleanupError}`);
364
445
  }
365
- try { runGitChecked(toplevel, ["branch", "-D", branch]); } catch {
366
- // Best-effort rollback; preserve the original setup failure.
446
+ try { runGitChecked(toplevel, ["branch", "-D", branch]); } catch (cleanupError) {
447
+ warnWorktreeCleanup(`branch -D '${branch}' failed for '${worktreePath}': ${cleanupError}`);
367
448
  }
368
449
  throw error;
369
450
  }
@@ -477,11 +558,12 @@ function writeEmptyPatch(patchPath: string): void {
477
558
  }
478
559
 
479
560
  function cleanupSingleWorktree(repoCwd: string, worktree: WorktreeInfo): void {
480
- try { runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]); } catch {
481
- // Cleanup is best-effort to avoid masking caller errors.
561
+ runWorktreeTeardownHook(worktree.teardownHook, worktree.hookInput);
562
+ try { runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]); } catch (error) {
563
+ warnWorktreeCleanup(`worktree remove failed for '${worktree.path}': ${error}`);
482
564
  }
483
- try { runGitChecked(repoCwd, ["branch", "-D", worktree.branch]); } catch {
484
- // Cleanup is best-effort to avoid masking caller errors.
565
+ try { runGitChecked(repoCwd, ["branch", "-D", worktree.branch]); } catch (error) {
566
+ warnWorktreeCleanup(`branch -D '${worktree.branch}' failed for '${worktree.path}': ${error}`);
485
567
  }
486
568
  }
487
569
 
@@ -498,6 +580,7 @@ export function patchPathForDiff(diff: WorktreeDiff): string | undefined {
498
580
  export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
499
581
  const repo = resolveRepoState(cwd);
500
582
  const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
583
+ const teardownHook = resolveWorktreeTeardownHook(repo.toplevel, options?.teardownHook);
501
584
  const worktrees: WorktreeInfo[] = [];
502
585
 
503
586
  try {
@@ -509,6 +592,7 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
509
592
  index,
510
593
  repo.baseCommit,
511
594
  setupHook,
595
+ teardownHook,
512
596
  options?.agents?.[index],
513
597
  ));
514
598
  }
@@ -859,6 +859,7 @@ interface ExtensionChainConfig {
859
859
  };
860
860
  }
861
861
 
862
+ /** Sparse extension settings resolved from settings.json#subagents. */
862
863
  export interface ExtensionConfig {
863
864
  asyncByDefault?: boolean;
864
865
  forceTopLevelAsync?: boolean;
@@ -869,6 +870,8 @@ export interface ExtensionConfig {
869
870
  chain?: ExtensionChainConfig;
870
871
  worktreeSetupHook?: string;
871
872
  worktreeSetupHookTimeoutMs?: number;
873
+ worktreeTeardownHook?: string;
874
+ worktreeTeardownHookTimeoutMs?: number;
872
875
  /** Print a roster of discovered subagents/chains into chat at session start. Default: true. */
873
876
  showRosterOnStart?: boolean;
874
877
  /** Forward the parent pi's extension CLI flags (e.g. --no-autofix) into spawned children. Default: true. */
@@ -1,16 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import type { ExtensionConfig } from "../shared/types.ts";
4
- import { getAgentDir } from "../shared/utils.ts";
5
-
6
- export function loadConfig(): ExtensionConfig {
7
- const configPath = path.join(getAgentDir(), "extensions", "pi-cohort", "config.json");
8
- try {
9
- if (fs.existsSync(configPath)) {
10
- return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ExtensionConfig;
11
- }
12
- } catch (error) {
13
- console.error(`Failed to load pi-cohort config from '${configPath}':`, error);
14
- }
15
- return {};
16
- }