pi-cohort 5.2.0 → 5.3.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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.3.0] - 2026-09-01
4
+
5
+ ### Changed
6
+
7
+ - Foreground parallel `worktree: true` patches now write to a run-scoped
8
+ directory (`<session artifacts>/<runId>/worktree-diffs`) instead of a
9
+ session-wide `worktree-diffs` directory shared by every dispatch, so
10
+ concurrent or sequential worktree runs no longer overwrite each other's
11
+ patches. Background (`<asyncDir>/worktree-diffs/step-<i>`) and chain
12
+ (`<chainDir>/worktree-diffs/step-<i>`) paths are unchanged. A relative
13
+ `output:` on a `worktree: true` task now resolves to the run's per-task
14
+ directory (`<session artifacts>/<runId>/task-<i>/` foreground,
15
+ `<asyncDir>/step-<i>/task-<j>/` async) instead of inside the throwaway
16
+ checkout, so the report survives worktree teardown and no longer pollutes
17
+ the captured patch; an absolute `output:` is unaffected, and `reads:` plus
18
+ the task's working directory still resolve inside the checkout. This is a
19
+ behavior change for anyone relying on the old patch/output paths, not a
20
+ breaking API change - no parameter, schema, or return shape changed.
21
+ - Stale artifact directories are now pruned recursively by the artifact
22
+ cleanup sweep, alongside files (previously only files were pruned, since
23
+ `unlinkSync` throws on a directory).
24
+
25
+ ### Fixed
26
+
27
+ - A relative `output:` that escapes its per-task directory (`../report.md`)
28
+ or normalizes to the directory itself (`.`) is now rejected at dispatch
29
+ with a tool error instead of writing outside the intended location.
30
+ - Worktree diffs are now captured before every post-execution return
31
+ (normal, interrupted, detached, intercom-receipt), so the diff summary is
32
+ attached even when a run ends early; a patch-capture failure is now
33
+ surfaced in the summary instead of silently reporting an empty patch.
34
+
3
35
  ## [5.2.0] - 2026-09-01
4
36
 
5
37
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cohort",
3
- "version": "5.2.0",
3
+ "version": "5.3.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",
@@ -11,7 +11,7 @@ import { createRequire } from "node:module";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import type { AgentConfig } from "../../agents/agents.ts";
13
13
  import { applyThinkingSuffix } from "../shared/pi-args.ts";
14
- import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
14
+ import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveParallelTaskOutputPath, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
15
15
  import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
16
16
  import type { RunnerStep } from "../shared/parallel-utils.ts";
17
17
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
@@ -373,7 +373,7 @@ export function executeAsyncChain(
373
373
  ...(s.model ? { model: s.model } : {}),
374
374
  };
375
375
  };
376
- const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior) => {
376
+ const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, isolatedLeaf?: { runDir: string; index: number }) => {
377
377
  const a = agents.find((x) => x.name === s.agent)!;
378
378
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
379
379
  const instructionCwd = behaviorCwd ?? stepCwd;
@@ -392,7 +392,16 @@ export function executeAsyncChain(
392
392
  const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
393
393
  if (behavior.progress) progressInstructionCreated = true;
394
394
  const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, runnerCwd, isFirstProgressAgent);
395
- const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd);
395
+ const resolvedOutput = resolveParallelTaskOutputPath({
396
+ output: behavior.output,
397
+ ctxCwd: ctx.cwd,
398
+ taskCwd: instructionCwd,
399
+ isolated: Boolean(isolatedLeaf),
400
+ runDir: isolatedLeaf?.runDir,
401
+ index: isolatedLeaf?.index ?? 0,
402
+ });
403
+ if ("error" in resolvedOutput) throw new AsyncStartValidationError(resolvedOutput.error);
404
+ const outputPath = resolvedOutput.path;
396
405
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
397
406
  if (validationError) throw new AsyncStartValidationError(validationError);
398
407
  let taskTemplate = s.task ?? "{previous}";
@@ -470,7 +479,14 @@ export function executeAsyncChain(
470
479
  behaviorCwd = undefined;
471
480
  }
472
481
  }
473
- return buildSeqStep(t, nextSessionFile(), behaviorCwd, progressPrecreated, parallelBehaviors[taskIndex]);
482
+ return buildSeqStep(
483
+ t,
484
+ nextSessionFile(),
485
+ behaviorCwd,
486
+ progressPrecreated,
487
+ parallelBehaviors[taskIndex],
488
+ s.worktree ? { runDir: path.join(asyncDir, `step-${stepIndex}`), index: taskIndex } : undefined,
489
+ );
474
490
  }),
475
491
  concurrency: s.concurrency,
476
492
  failFast: s.failFast,
@@ -38,7 +38,7 @@ import { createForkContextResolver } from "../../shared/fork-context.ts";
38
38
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
39
39
  import { applyIntercomBridgeToAgent, INTERCOM_BRIDGE_MARKER, resolveIntercomBridge, resolveIntercomSessionTarget, resolveSubagentIntercomTarget, type IntercomBridgeState } from "../../intercom/intercom-bridge.ts";
40
40
  import { formatControlIntercomMessage, formatControlNoticeMessage, resolveControlConfig, shouldNotifyControlEvent } from "../shared/subagent-control.ts";
41
- import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeTopLevelOutput, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
41
+ import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeTopLevelOutput, resolveParallelTaskOutputPath, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
42
42
  import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, readStatus, resolveChildCwd } from "../../shared/utils.ts";
43
43
  import {
44
44
  attachNestedChildrenToResultChildren,
@@ -1468,28 +1468,62 @@ function resolveParallelTaskCwd(
1468
1468
  function buildParallelWorktreeSuffix(
1469
1469
  worktreeSetup: WorktreeSetup | undefined,
1470
1470
  artifactsDir: string,
1471
+ runId: string,
1471
1472
  tasks: TaskParam[],
1472
1473
  ): string {
1473
1474
  if (!worktreeSetup) return "";
1474
- const diffsDir = path.join(artifactsDir, "worktree-diffs");
1475
+ const diffsDir = path.join(artifactsDir, runId, "worktree-diffs");
1475
1476
  const diffs = diffWorktrees(worktreeSetup, tasks.map((task) => task.agent), diffsDir);
1476
1477
  return formatWorktreeDiffSummary(diffs);
1477
1478
  }
1478
1479
 
1480
+ function resolveParallelTaskOutput(input: {
1481
+ output: string | boolean | undefined;
1482
+ task: TaskParam;
1483
+ paramsCwd: string;
1484
+ ctxCwd: string;
1485
+ worktreeSetup: WorktreeSetup | undefined;
1486
+ artifactsDir: string;
1487
+ runId: string;
1488
+ index: number;
1489
+ }): { path: string | undefined } | { error: string } {
1490
+ const taskCwd = resolveParallelTaskCwd(input.task, input.paramsCwd, input.worktreeSetup, input.index);
1491
+ return resolveParallelTaskOutputPath({
1492
+ output: input.output,
1493
+ ctxCwd: input.ctxCwd,
1494
+ taskCwd,
1495
+ isolated: Boolean(input.worktreeSetup),
1496
+ runDir: path.join(input.artifactsDir, input.runId),
1497
+ index: input.index,
1498
+ });
1499
+ }
1500
+
1479
1501
  function findDuplicateParallelOutputPath(input: {
1480
1502
  tasks: TaskParam[];
1481
1503
  behaviors: ResolvedStepBehavior[];
1482
1504
  paramsCwd: string;
1483
1505
  ctxCwd: string;
1484
1506
  worktreeSetup?: WorktreeSetup;
1507
+ artifactsDir: string;
1508
+ runId: string;
1485
1509
  }): string | undefined {
1486
1510
  const seen = new Map<string, { index: number; agent: string }>();
1487
1511
  for (let index = 0; index < input.tasks.length; index++) {
1488
1512
  const behavior = input.behaviors[index];
1489
1513
  if (!behavior?.output) continue;
1490
1514
  const task = input.tasks[index]!;
1491
- const taskCwd = resolveParallelTaskCwd(task, input.paramsCwd, input.worktreeSetup, index);
1492
- const outputPath = resolveSingleOutputPath(behavior.output, input.ctxCwd, taskCwd);
1515
+ const resolved = resolveParallelTaskOutput({
1516
+ output: behavior.output,
1517
+ task,
1518
+ paramsCwd: input.paramsCwd,
1519
+ ctxCwd: input.ctxCwd,
1520
+ worktreeSetup: input.worktreeSetup,
1521
+ artifactsDir: input.artifactsDir,
1522
+ runId: input.runId,
1523
+ index,
1524
+ });
1525
+ if ("error" in resolved) return resolved.error;
1526
+ const outputPath = resolved.path;
1493
1527
  if (!outputPath) continue;
1494
1528
  const previous = seen.get(outputPath);
1495
1529
  if (previous) {
@@ -1511,7 +1545,17 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1511
1545
  const progressInstructions = behavior
1512
1546
  ? buildChainInstructions({ ...behavior, output: false, reads: false }, input.paramsCwd, index === input.firstProgressIndex)
1513
1547
  : { prefix: "", suffix: "" };
1514
- const outputPath = resolveSingleOutputPath(behavior?.output, input.ctx.cwd, taskCwd);
1548
+ const resolvedOutput = resolveParallelTaskOutput({
1549
+ output: behavior?.output,
1550
+ task,
1551
+ paramsCwd: input.paramsCwd,
1552
+ ctxCwd: input.ctx.cwd,
1553
+ worktreeSetup: input.worktreeSetup,
1554
+ artifactsDir: input.artifactsDir,
1555
+ runId: input.runId,
1556
+ index,
1557
+ });
1558
+ const outputPath = "error" in resolvedOutput ? undefined : resolvedOutput.path;
1515
1559
  const taskText = injectSingleOutputInstruction(
1516
1560
  `${readInstructions.prefix}${input.taskTexts[index]!}${progressInstructions.suffix}`,
1517
1561
  outputPath,
@@ -1799,12 +1843,23 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1799
1843
  paramsCwd: effectiveCwd,
1800
1844
  ctxCwd: ctx.cwd,
1801
1845
  worktreeSetup,
1846
+ artifactsDir,
1847
+ runId,
1802
1848
  });
1803
1849
  if (duplicateOutputError) return buildParallelModeError(duplicateOutputError);
1804
1850
  for (let index = 0; index < tasks.length; index++) {
1805
- const taskCwd = resolveParallelTaskCwd(tasks[index]!, effectiveCwd, worktreeSetup, index);
1806
- const outputPath = resolveSingleOutputPath(behaviors[index]?.output, ctx.cwd, taskCwd);
1807
- const validationError = validateFileOnlyOutputMode(behaviors[index]?.outputMode, outputPath, `Parallel task ${index + 1} (${tasks[index]!.agent})`);
1851
+ const resolved = resolveParallelTaskOutput({
1852
+ output: behaviors[index]?.output,
1853
+ task: tasks[index]!,
1854
+ paramsCwd: effectiveCwd,
1855
+ ctxCwd: ctx.cwd,
1856
+ worktreeSetup,
1857
+ artifactsDir,
1858
+ runId,
1859
+ index,
1860
+ });
1861
+ if ("error" in resolved) return buildParallelModeError(resolved.error);
1862
+ const validationError = validateFileOnlyOutputMode(behaviors[index]?.outputMode, resolved.path, `Parallel task ${index + 1} (${tasks[index]!.agent})`);
1808
1863
  if (validationError) return buildParallelModeError(validationError);
1809
1864
  }
1810
1865
 
@@ -1871,6 +1926,9 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1871
1926
  if (result.artifactPaths) allArtifactPaths.push(result.artifactPaths);
1872
1927
  }
1873
1928
 
1929
+ const worktreeSuffix = buildParallelWorktreeSuffix(worktreeSetup, artifactsDir, runId, tasks);
1930
+ const withWorktreeSuffix = (text: string): string => (worktreeSuffix ? `${text}\n\n${worktreeSuffix}` : text);
1931
+
1874
1932
  const interrupted = results.find((result) => result.interrupted);
1875
1933
  const details = compactForegroundDetails({
1876
1934
  mode: "parallel",
@@ -1882,7 +1940,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1882
1940
  rememberForegroundRun(deps.state, { runId, mode: "parallel", cwd: effectiveCwd, results: details.results });
1883
1941
  if (interrupted) {
1884
1942
  return {
1885
- content: [{ type: "text", text: `Parallel run paused after interrupt (${interrupted.agent}). Waiting for explicit next action.` }],
1943
+ content: [{ type: "text", text: withWorktreeSuffix(`Parallel run paused after interrupt (${interrupted.agent}). Waiting for explicit next action.`) }],
1886
1944
  details,
1887
1945
  };
1888
1946
  }
@@ -1890,7 +1948,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1890
1948
  const detached = detachedIndex >= 0 ? results[detachedIndex] : undefined;
1891
1949
  if (detached) {
1892
1950
  return {
1893
- content: [{ type: "text", text: `Parallel run detached for intercom coordination (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
1951
+ content: [{ type: "text", text: withWorktreeSuffix(`Parallel run detached for intercom coordination (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.`) }],
1894
1952
  details,
1895
1953
  };
1896
1954
  }
@@ -1906,12 +1964,11 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1906
1964
  });
1907
1965
  if (intercomReceipt) {
1908
1966
  return {
1909
- content: [{ type: "text", text: intercomReceipt.text }],
1967
+ content: [{ type: "text", text: withWorktreeSuffix(intercomReceipt.text) }],
1910
1968
  details: intercomReceipt.details,
1911
1969
  };
1912
1970
  }
1913
1971
 
1914
- const worktreeSuffix = buildParallelWorktreeSuffix(worktreeSetup, artifactsDir, tasks);
1915
1972
  const ok = results.filter((result) => result.exitCode === 0).length;
1916
1973
  const downgradeNote = backgroundRequestedWhileClarifying ? " (background requested, but clarify kept this run foreground)" : "";
1917
1974
  const aggregatedOutput = aggregateParallelOutputs(
@@ -1925,9 +1982,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1925
1982
  );
1926
1983
 
1927
1984
  const summary = `${ok}/${results.length} succeeded${downgradeNote}`;
1928
- const fullContent = worktreeSuffix
1929
- ? `${summary}\n\n${aggregatedOutput}\n\n${worktreeSuffix}`
1930
- : `${summary}\n\n${aggregatedOutput}`;
1985
+ const fullContent = withWorktreeSuffix(`${summary}\n\n${aggregatedOutput}`);
1931
1986
 
1932
1987
  return {
1933
1988
  content: [{ type: "text", text: fullContent }],
@@ -40,6 +40,37 @@ export function resolveSingleOutputPath(
40
40
  return path.resolve(baseCwd, output);
41
41
  }
42
42
 
43
+ export interface ParallelTaskOutputInput {
44
+ output: string | boolean | undefined;
45
+ ctxCwd: string;
46
+ taskCwd?: string;
47
+ isolated: boolean;
48
+ runDir?: string;
49
+ index: number;
50
+ }
51
+
52
+ export type ParallelTaskOutputResult = { path: string | undefined } | { error: string };
53
+
54
+ export function resolveParallelTaskOutputPath(input: ParallelTaskOutputInput): ParallelTaskOutputResult {
55
+ const { output, ctxCwd, taskCwd, isolated, runDir, index } = input;
56
+ const redirectable = isolated
57
+ && Boolean(runDir)
58
+ && typeof output === "string"
59
+ && output.length > 0
60
+ && output !== "false"
61
+ && output !== "true"
62
+ && !path.isAbsolute(output);
63
+ if (!redirectable) return { path: resolveSingleOutputPath(output, ctxCwd, taskCwd) };
64
+
65
+ const leaf = path.join(runDir!, `task-${index}`);
66
+ const resolved = path.resolve(leaf, output as string);
67
+ const relative = path.relative(leaf, resolved);
68
+ if (relative === "" || relative.split(path.sep)[0] === ".." || path.isAbsolute(relative)) {
69
+ return { error: `Parallel task ${index + 1} output '${output}' resolves outside its per-task directory (${resolved}). Use a path inside ${leaf}, or an absolute path.` };
70
+ }
71
+ return { path: resolved };
72
+ }
73
+
43
74
  export function injectSingleOutputInstruction(task: string, outputPath: string | undefined): string {
44
75
  if (!outputPath) return task;
45
76
  return `${task}\n\n---\n**Output:** Write your findings to: ${outputPath}`;
@@ -27,6 +27,7 @@ interface WorktreeDiff {
27
27
  insertions: number;
28
28
  deletions: number;
29
29
  patchPath: string;
30
+ captureError?: string;
30
31
  }
31
32
 
32
33
  interface WorktreeTaskCwdConflict {
@@ -523,24 +524,28 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
523
524
  }
524
525
 
525
526
  export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir: string): WorktreeDiff[] {
527
+ const agentFor = (index: number): string => agents[index] ?? `task-${index + 1}`;
526
528
  try {
527
529
  fs.mkdirSync(diffsDir, { recursive: true });
528
- } catch {
529
- // Returning no diffs is safer than failing the whole command on artifact-dir issues.
530
- return [];
530
+ } catch (error) {
531
+ const message = error instanceof Error ? error.message : String(error);
532
+ return setup.worktrees.map((worktree, index) => ({
533
+ ...emptyDiff(index, agentFor(index), worktree.branch, ""),
534
+ captureError: `could not create patch directory '${diffsDir}': ${message}`,
535
+ }));
531
536
  }
532
537
 
533
538
  const diffs: WorktreeDiff[] = [];
534
539
  for (let index = 0; index < setup.worktrees.length; index++) {
535
540
  const worktree = setup.worktrees[index]!;
536
- const agent = agents[index] ?? `task-${index + 1}`;
541
+ const agent = agentFor(index);
537
542
  const patchPath = path.join(diffsDir, `task-${index}-${safePatchAgentName(agent)}.patch`);
538
543
  try {
539
544
  diffs.push(captureWorktreeDiff(setup, worktree, agent, patchPath));
540
- } catch {
541
- // Preserve execution flow; failed diff capture maps to an empty per-task patch.
545
+ } catch (error) {
546
+ const message = error instanceof Error ? error.message : String(error);
542
547
  writeEmptyPatch(patchPath);
543
- diffs.push(emptyDiff(index, agent, worktree.branch, patchPath));
548
+ diffs.push({ ...emptyDiff(index, agent, worktree.branch, patchPath), captureError: message });
544
549
  }
545
550
  }
546
551
 
@@ -558,7 +563,8 @@ export function cleanupWorktrees(setup: WorktreeSetup): void {
558
563
 
559
564
  export function formatWorktreeDiffSummary(diffs: WorktreeDiff[]): string {
560
565
  const changed = diffs.filter(hasWorktreeChanges);
561
- if (changed.length === 0) return "";
566
+ const failed = diffs.filter((diff) => diff.captureError);
567
+ if (changed.length === 0 && failed.length === 0) return "";
562
568
 
563
569
  const lines: string[] = ["=== Worktree Changes ===", ""];
564
570
  for (const diff of changed) {
@@ -570,8 +576,11 @@ export function formatWorktreeDiffSummary(diffs: WorktreeDiff[]): string {
570
576
  }
571
577
  lines.push("");
572
578
  }
579
+ for (const diff of failed) {
580
+ lines.push(`--- Task ${diff.index + 1} (${diff.agent}): patch capture FAILED: ${diff.captureError} ---`, "");
581
+ }
573
582
 
574
- const patchesDir = path.dirname(changed[0]!.patchPath);
575
- lines.push(`Full patches: ${patchesDir}`);
583
+ const withPatch = changed.find((diff) => diff.patchPath.length > 0);
584
+ if (withPatch) lines.push(`Full patches: ${path.dirname(withPatch.patchPath)}`);
576
585
  return lines.join("\n").trimEnd();
577
586
  }
@@ -60,7 +60,8 @@ export function cleanupOldArtifacts(dir: string, maxAgeDays: number): void {
60
60
  try {
61
61
  const stat = fs.statSync(filePath);
62
62
  if (stat.mtimeMs < cutoff) {
63
- fs.unlinkSync(filePath);
63
+ if (stat.isDirectory()) fs.rmSync(filePath, { recursive: true, force: true });
64
+ else fs.unlinkSync(filePath);
64
65
  }
65
66
  } catch {
66
67
  // Artifact cleanup is best-effort housekeeping. Skip files that disappear