pi-cohort 5.1.3 → 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,50 @@
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
+
35
+ ## [5.2.0] - 2026-09-01
36
+
37
+ ### Added
38
+
39
+ - `runner.log` diagnostics for the detached async runner: captures the
40
+ runner's stdout/stderr, spawn-time jiti revalidation, and enriched failure
41
+ messages that append the log's tail; the run dir is also exported to the
42
+ child as `PI_SUBAGENT_RUN_DIR` and echoed on an `Async dir:` start line.
43
+ - Builtin `monitor` persona: watches an already-started job (async run dir,
44
+ PID, log, or probe command), reports progress deltas on a cadence, and
45
+ flags stalls, plus SKILL.md guidance to pair it with long-running async
46
+ jobs.
47
+
3
48
  ## [5.1.3] - 2026-08-18
4
49
 
5
50
  ### Fixed
package/README.md CHANGED
@@ -101,7 +101,7 @@ Full reference: agent/chain authoring in [doc/agents-and-chains.md](doc/agents-a
101
101
  | Term | Meaning |
102
102
  |---|---|
103
103
  | Subagent | A focused child Pi session with one job and (by default) a fresh context. |
104
- | Agent (persona) | A markdown file with frontmatter defining a specialist: `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `oracle`, `delegate`. Full table: [doc/agents-and-chains.md](doc/agents-and-chains.md#builtin-agents-in-plain-english). |
104
+ | Agent (persona) | A markdown file with frontmatter defining a specialist: `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `oracle`, `delegate`, `monitor`. Full table: [doc/agents-and-chains.md](doc/agents-and-chains.md#builtin-agents-in-plain-english). |
105
105
  | Chain | A saved or inline sequence of agent steps, with fan-out/fan-in support. |
106
106
  | Fresh vs. forked context | Fresh = clean slate; forked = a real branch of the parent's session history. |
107
107
  | Recursion guard | Depth cap on nested delegation so a child can only fan out if explicitly allowed. |
@@ -142,6 +142,10 @@ Optional companions:
142
142
 
143
143
  See [CHANGELOG.md](CHANGELOG.md) for shipped work in progress.
144
144
 
145
+ ## Contributing
146
+
147
+ See [CONTRIBUTING.md](CONTRIBUTING.md) - issues follow a Context / Problem / Idea / Acceptance Criteria template; PRs run the [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) workflow (one-liners exempt from ceremony, never from keeping docs truthful).
148
+
145
149
  ## Support
146
150
 
147
151
  If `pi-cohort` is useful, consider [buying me a coffee](https://buymeacoffee.com/jjurasszek).
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: monitor
3
+ description: Watches a job you already started - not for doing the work. Reports progress deltas on a cadence, flags stalls, exits when the job ends.
4
+ tools: read, bash
5
+ thinking: low
6
+ completionGuard: false
7
+ ---
8
+
9
+ You observe a job someone else runs. Never execute, restart, or modify it.
10
+
11
+ The task names your target: an absolute async run dir (preferred - read its status.json, tail output logs and runner.log), a PID (POSIX: ps -p <pid>; Windows: tasklist /FI "PID eq <pid>"), a log file (tail), or a probe command. If the target is already terminated at your first check, report that and exit. If it is unreadable, report "cannot observe target: <reason>" and exit - never guess, never loop.
12
+
13
+ Write your trail to $PI_SUBAGENT_RUN_DIR/trail.md. If that variable is unset, create a directory with mktemp -d and write there. Never write under the workspace.
14
+
15
+ Loop:
16
+ 1. Record your start time. Run the first check immediately, before any sleep.
17
+ 2. Each cycle: check the target is alive, collect progress (new log lines, counts, phases, best-effort ETA), and compose a one-line delta vs the previous cycle.
18
+ 3. Append the delta with a timestamp to the trail.
19
+ 4. If contact_supervisor is available, send the delta with reason "progress_update".
20
+ 5. Sleep the cadence interval in chunks of <= 5 minutes, then repeat.
21
+
22
+ Cadence: every 15 minutes unless the task sets another interval.
23
+
24
+ Stall: no growth in the watched log and no change in status.json lastUpdate since the previous cycle. Judge stall only by the signals your target has; a signal that does not exist never counts as change. Report it as "no output for <interval>, possible stall". After two consecutive silent cycles, escalate with contact_supervisor reason "need_decision". Never report "still working" without evidence.
25
+
26
+ Exit: when the target reaches a terminal state, send a final summary and end - the summary is your run result. Stop 24h after your recorded start time even if the target lives. Without contact_supervisor, the trail and final summary are the record; behave identically otherwise.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cohort",
3
- "version": "5.1.3",
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",
@@ -351,6 +351,19 @@ subagent({ action: "doctor" })
351
351
 
352
352
  Humans can use `/cohort-doctor` for the same read-only report. It checks runtime paths, discovery counts, async support, current session context, and intercom bridge state.
353
353
 
354
+ Long-running job hygiene:
355
+
356
+ - The job's task must instruct it to emit observable progress as it works (log lines, counts, phase names; best-effort ETA). A silent long job is a defect.
357
+ - Pair the job with a monitor dispatch, giving it the absolute async dir from the `Async dir:` line of the start message:
358
+
359
+ ```typescript
360
+ subagent({ agent: "worker", async: true, task: "<long job - emit progress lines as you work>" })
361
+ -> Async: worker [R] Async dir: <D>
362
+ subagent({ agent: "monitor", async: true, task: "Watch async run R at <D>. Report every 15m. Stop when it ends." })
363
+ ```
364
+
365
+ - Live 15m reports require the pi-intercom bridge; without it the monitor's trail and final summary are post-hoc records.
366
+
354
367
  ### Subagent control
355
368
 
356
369
  Subagent control is the runtime visibility and intervention layer for delegated runs. It is separate from lifecycle status. Lifecycle status says whether a child is `queued`, `running`, `paused`, `complete`, or `failed`. Activity reporting is factual: it tracks the last observed activity time and the current tool when known. It does not pretend to know that a child is truly stuck.
@@ -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";
@@ -88,7 +88,20 @@ function resolveJitiCliPath(): string | undefined {
88
88
  return undefined;
89
89
  }
90
90
 
91
- const jitiCliPath = resolveJitiCliPath();
91
+ export function createJitiCliResolver(deps: { resolve?: () => string | undefined; exists?: (p: string) => boolean } = {}): () => string | undefined {
92
+ const resolve = deps.resolve ?? resolveJitiCliPath;
93
+ const exists = deps.exists ?? ((p: string) => fs.existsSync(p));
94
+ let cached = resolve();
95
+ return () => {
96
+ if (cached && exists(cached)) return cached;
97
+ cached = resolve();
98
+ if (cached && exists(cached)) return cached;
99
+ cached = undefined;
100
+ return undefined;
101
+ };
102
+ }
103
+
104
+ const ensureJitiCliPath = createJitiCliResolver();
92
105
 
93
106
  interface AsyncExecutionContext {
94
107
  pi: ExtensionAPI;
@@ -172,13 +185,60 @@ export function formatAsyncStartedMessage(headline: string): string {
172
185
  * Check if jiti is available for async execution
173
186
  */
174
187
  export function isAsyncAvailable(): boolean {
175
- return jitiCliPath !== undefined;
188
+ return ensureJitiCliPath() !== undefined;
189
+ }
190
+
191
+ /**
192
+ * Spawn a detached process with stderr/stdout captured to <asyncDir>/runner.log.
193
+ * Falls back to ignored stdio if the log file cannot be opened; the parent always
194
+ * closes its copy of the fd, on every exit path.
195
+ */
196
+ export function spawnDetachedWithLog(command: string, args: string[], cwd: string, asyncDir: string, spawnImpl: typeof spawn = spawn): { pid?: number; error?: string } {
197
+ let fd: number | undefined;
198
+ try {
199
+ try {
200
+ fd = fs.openSync(path.join(asyncDir, "runner.log"), "a");
201
+ } catch (error) {
202
+ const message = error instanceof Error ? error.message : String(error);
203
+ console.error(`[pi-cohort] could not open runner.log in ${asyncDir}: ${message}`);
204
+ }
205
+ const proc = spawnImpl(command, args, {
206
+ cwd,
207
+ detached: true,
208
+ stdio: fd === undefined ? "ignore" : ["ignore", fd, fd],
209
+ windowsHide: true,
210
+ });
211
+ proc.on("error", (error) => {
212
+ console.error(`[pi-cohort] async spawn failed: ${error.message}`);
213
+ });
214
+ if (typeof proc.pid !== "number") {
215
+ return { error: `async runner did not produce a pid for cwd: ${cwd}` };
216
+ }
217
+ proc.unref();
218
+ return { pid: proc.pid };
219
+ } finally {
220
+ if (fd !== undefined) {
221
+ try {
222
+ fs.closeSync(fd);
223
+ } catch {
224
+ // parent-side close failure never misreports a launched child
225
+ }
226
+ }
227
+ }
176
228
  }
177
229
 
178
230
  /**
179
231
  * Spawn the async runner process
180
232
  */
181
- function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number; error?: string } {
233
+ export function spawnRunner(
234
+ cfg: object,
235
+ suffix: string,
236
+ cwd: string,
237
+ asyncDir: string,
238
+ deps: { ensureJiti?: () => string | undefined; spawnImpl?: typeof spawn } = {},
239
+ ): { pid?: number; error?: string } {
240
+ const ensureJiti = deps.ensureJiti ?? ensureJitiCliPath;
241
+ const jitiCliPath = ensureJiti();
182
242
  if (!jitiCliPath) {
183
243
  return { error: "upstream jiti for TypeScript execution could not be found; ensure package dependencies are installed" };
184
244
  }
@@ -197,20 +257,15 @@ function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number;
197
257
  fs.writeFileSync(cfgPath, JSON.stringify(cfg));
198
258
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
199
259
 
200
- const proc = spawn(process.execPath, [jitiCliPath, runner, cfgPath], {
201
- cwd,
202
- detached: true,
203
- stdio: "ignore",
204
- windowsHide: true,
205
- });
206
- proc.on("error", (error) => {
207
- console.error(`[pi-cohort] async spawn failed: ${error.message}`);
208
- });
209
- if (typeof proc.pid !== "number") {
210
- return { error: `async runner did not produce a pid for cwd: ${cwd}` };
211
- }
212
- proc.unref();
213
- return { pid: proc.pid };
260
+ return spawnDetachedWithLog(process.execPath, [jitiCliPath, runner, cfgPath], cwd, asyncDir, deps.spawnImpl ?? spawn);
261
+ }
262
+
263
+ /**
264
+ * Build the headline shown at the top of an async start message, e.g.
265
+ * "Async single: my-agent [run-id]\nAsync dir: /tmp/...".
266
+ */
267
+ export function asyncStartHeadline(prefix: string, id: string, asyncDir: string): string {
268
+ return `${prefix} [${id}]\nAsync dir: ${asyncDir}`;
214
269
  }
215
270
 
216
271
  function formatAsyncStartError(mode: SubagentRunMode, message: string): AsyncExecutionResult {
@@ -318,7 +373,7 @@ export function executeAsyncChain(
318
373
  ...(s.model ? { model: s.model } : {}),
319
374
  };
320
375
  };
321
- 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 }) => {
322
377
  const a = agents.find((x) => x.name === s.agent)!;
323
378
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
324
379
  const instructionCwd = behaviorCwd ?? stepCwd;
@@ -337,7 +392,16 @@ export function executeAsyncChain(
337
392
  const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
338
393
  if (behavior.progress) progressInstructionCreated = true;
339
394
  const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, runnerCwd, isFirstProgressAgent);
340
- 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;
341
405
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
342
406
  if (validationError) throw new AsyncStartValidationError(validationError);
343
407
  let taskTemplate = s.task ?? "{previous}";
@@ -415,7 +479,14 @@ export function executeAsyncChain(
415
479
  behaviorCwd = undefined;
416
480
  }
417
481
  }
418
- 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
+ );
419
490
  }),
420
491
  concurrency: s.concurrency,
421
492
  failFast: s.failFast,
@@ -503,6 +574,7 @@ export function executeAsyncChain(
503
574
  },
504
575
  id,
505
576
  runnerCwd,
577
+ asyncDir,
506
578
  );
507
579
  } catch (error) {
508
580
  const message = error instanceof Error ? error.message : String(error);
@@ -603,7 +675,7 @@ export function executeAsyncChain(
603
675
  .join(" -> ");
604
676
 
605
677
  return {
606
- content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
678
+ content: [{ type: "text", text: formatAsyncStartedMessage(asyncStartHeadline(`Async ${resultMode}: ${chainDesc}`, id, asyncDir)) }],
607
679
  details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph },
608
680
  };
609
681
  }
@@ -738,6 +810,7 @@ export function executeAsyncSingle(
738
810
  },
739
811
  id,
740
812
  runnerCwd,
813
+ asyncDir,
741
814
  );
742
815
  } catch (error) {
743
816
  const message = error instanceof Error ? error.message : String(error);
@@ -796,7 +869,7 @@ export function executeAsyncSingle(
796
869
  }
797
870
 
798
871
  return {
799
- content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`) }],
872
+ content: [{ type: "text", text: formatAsyncStartedMessage(asyncStartHeadline(`Async: ${agent}`, id, asyncDir)) }],
800
873
  details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir },
801
874
  };
802
875
  }
@@ -7,7 +7,7 @@ import { readStatus } from "../../shared/utils.ts";
7
7
  import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot } from "../shared/nested-events.ts";
8
8
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
9
9
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
10
- import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
10
+ import { readRunnerLogTail, reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
11
11
 
12
12
  interface AsyncRunStepSummary {
13
13
  index: number;
@@ -64,6 +64,7 @@ export interface AsyncRunSummary {
64
64
  sessionFile?: string;
65
65
  nestedChildren?: NestedRunSummary[];
66
66
  nestedWarnings?: string[];
67
+ diagnostic?: string;
67
68
  }
68
69
 
69
70
  interface AsyncRunListOptions {
@@ -218,6 +219,29 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
218
219
  });
219
220
  }
220
221
 
222
+ const PRE_STATUS_CRASH_GRACE_MS = 10_000;
223
+
224
+ function summarizePreStatusCrash(asyncDir: string, now: number): AsyncRunSummary | undefined {
225
+ const log = readRunnerLogTail(asyncDir);
226
+ if (!log.tail) return undefined;
227
+ let startedAt: number;
228
+ try {
229
+ startedAt = fs.statSync(asyncDir).mtimeMs;
230
+ } catch {
231
+ return undefined;
232
+ }
233
+ if (now - startedAt < PRE_STATUS_CRASH_GRACE_MS) return undefined;
234
+ return {
235
+ id: path.basename(asyncDir),
236
+ asyncDir,
237
+ state: "failed",
238
+ mode: "single",
239
+ startedAt,
240
+ steps: [],
241
+ diagnostic: `Runner log tail (${log.path}):\n${log.tail}`,
242
+ };
243
+ }
244
+
221
245
  export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
222
246
  let entries: string[];
223
247
  try {
@@ -237,7 +261,11 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
237
261
  ? undefined
238
262
  : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
239
263
  const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
240
- if (!status) continue;
264
+ if (!status) {
265
+ const crashed = summarizePreStatusCrash(asyncDir, options.now?.() ?? Date.now());
266
+ if (crashed && (!allowedStates || allowedStates.has(crashed.state))) runs.push(crashed);
267
+ continue;
268
+ }
241
269
  const nestedWarnings: string[] = [];
242
270
  try {
243
271
  const nestedRoute = findNestedRouteForRootId(status.runId || path.basename(asyncDir));
@@ -329,6 +357,7 @@ export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active as
329
357
  const outputPath = formatAsyncRunOutputPath(run);
330
358
  if (outputPath) lines.push(` output: ${shortenPath(outputPath)}`);
331
359
  if (run.sessionFile) lines.push(` session: ${shortenPath(run.sessionFile)}`);
360
+ if (run.diagnostic) for (const diagLine of run.diagnostic.split("\n")) lines.push(` ${diagLine}`);
332
361
  lines.push("");
333
362
  }
334
363
  return lines.join("\n").trimEnd();
@@ -10,7 +10,7 @@ import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts
10
10
  import { resolveAsyncRunLocation } from "./async-resume.ts";
11
11
  import { resolveSubagentRunId } from "./run-id-resolver.ts";
12
12
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
13
- import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
13
+ import { readRunnerLogTail, reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
14
14
  import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot, type NestedRunResolutionScope } from "../shared/nested-events.ts";
15
15
 
16
16
  interface RunStatusParams {
@@ -238,6 +238,8 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
238
238
  }
239
239
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
240
240
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
241
+ const runnerLogPath = path.join(asyncDir, "runner.log");
242
+ if (fs.existsSync(runnerLogPath)) lines.push(`Runner log: ${runnerLogPath}`);
241
243
 
242
244
  return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
243
245
  }
@@ -264,6 +266,18 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
264
266
  }
265
267
  }
266
268
 
269
+ if (asyncDir) {
270
+ const log = readRunnerLogTail(asyncDir);
271
+ const text = log.tail
272
+ ? `Status file not found.\nRunner log tail (${log.path}):\n${log.tail}`
273
+ : `Status file not found.\nRunner log (empty or missing): expected at ${log.path}`;
274
+ return {
275
+ content: [{ type: "text", text }],
276
+ isError: true,
277
+ details: { mode: "single", results: [] },
278
+ };
279
+ }
280
+
267
281
  return {
268
282
  content: [{ type: "text", text: "Status file not found." }],
269
283
  isError: true,
@@ -164,10 +164,35 @@ function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, no
164
164
  };
165
165
  }
166
166
 
167
+ export function readRunnerLogTail(asyncDir: string, maxBytes = 4096, maxLines = 20): { path: string; tail?: string } {
168
+ const logPath = path.join(asyncDir, "runner.log");
169
+ try {
170
+ const stat = fs.statSync(logPath);
171
+ if (stat.size === 0) return { path: logPath };
172
+ const fd = fs.openSync(logPath, "r");
173
+ try {
174
+ const length = Math.min(stat.size, maxBytes);
175
+ const buffer = Buffer.alloc(length);
176
+ fs.readSync(fd, buffer, 0, length, Math.max(0, stat.size - maxBytes));
177
+ const lines = buffer.toString("utf-8").split("\n").filter((line) => line.trim() !== "");
178
+ const tail = lines.slice(-maxLines).join("\n");
179
+ return tail ? { path: logPath, tail } : { path: logPath };
180
+ } finally {
181
+ fs.closeSync(fd);
182
+ }
183
+ } catch {
184
+ return { path: logPath };
185
+ }
186
+ }
187
+
167
188
  function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: object; message: string } {
168
189
  const runId = status.runId || path.basename(asyncDir);
169
190
  const pid = typeof status.pid === "number" ? status.pid : "unknown";
170
191
  const message = reason ?? `Async runner process ${pid} exited or disappeared before writing a result. Marked run failed by stale-run reconciliation.`;
192
+ const log = readRunnerLogTail(asyncDir);
193
+ const enrichedMessage = log.tail
194
+ ? `${message}\nRunner log tail (${log.path}):\n${log.tail}`
195
+ : `${message}\nRunner log (empty or missing): expected at ${log.path}`;
171
196
  const steps = status.steps?.length ? status.steps : [{ agent: "subagent", status: "running" as const }];
172
197
  const repairedSteps = steps.map((step) => step.status === "running" || step.status === "pending"
173
198
  ? {
@@ -177,7 +202,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
177
202
  endedAt: step.endedAt ?? now,
178
203
  durationMs: step.startedAt !== undefined && step.durationMs === undefined ? Math.max(0, now - step.startedAt) : step.durationMs,
179
204
  exitCode: step.exitCode ?? 1,
180
- error: step.error ?? message,
205
+ error: step.error ?? enrichedMessage,
181
206
  }
182
207
  : step);
183
208
  const repairedStatus: AsyncStatus = {
@@ -191,18 +216,18 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
191
216
  const resultAgent = repairedSteps[status.currentStep ?? 0]?.agent ?? repairedSteps[0]?.agent ?? "subagent";
192
217
  return {
193
218
  status: repairedStatus,
194
- message,
219
+ message: enrichedMessage,
195
220
  result: {
196
221
  id: runId,
197
222
  agent: resultAgent,
198
223
  mode: status.mode,
199
224
  success: false,
200
225
  state: "failed",
201
- summary: message,
226
+ summary: enrichedMessage,
202
227
  results: repairedSteps.map((step) => ({
203
228
  agent: step.agent,
204
- output: step.status === "complete" || step.status === "completed" ? "" : message,
205
- error: step.status === "complete" || step.status === "completed" ? undefined : step.error ?? message,
229
+ output: step.status === "complete" || step.status === "completed" ? "" : enrichedMessage,
230
+ error: step.status === "complete" || step.status === "completed" ? undefined : step.error ?? enrichedMessage,
206
231
  success: step.status === "complete" || step.status === "completed",
207
232
  model: step.model,
208
233
  attemptedModels: step.attemptedModels,
@@ -44,7 +44,7 @@ import {
44
44
  aggregateParallelOutputs,
45
45
  MAX_PARALLEL_CONCURRENCY,
46
46
  } from "../shared/parallel-utils.ts";
47
- import { buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
47
+ import { buildPiArgs, cleanupTempDir, runDirEnv } from "../shared/pi-args.ts";
48
48
  import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
49
49
  import { createStructuredOutputRuntime, readStructuredOutput } from "../shared/structured-output.ts";
50
50
  import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
@@ -967,6 +967,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
967
967
  const overallStartTime = Date.now();
968
968
  const shareEnabled = config.share === true;
969
969
  const asyncDir = config.asyncDir;
970
+ Object.assign(process.env, runDirEnv(asyncDir));
970
971
  const statusPath = path.join(asyncDir, "status.json");
971
972
  const eventsPath = path.join(asyncDir, "events.jsonl");
972
973
  const logPath = path.join(asyncDir, `subagent-log-${id}.md`);
@@ -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 }],
@@ -13,6 +13,7 @@ const FANOUT_CHILD_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.
13
13
  export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
14
14
  export const SUBAGENT_ORCHESTRATOR_TARGET_ENV = "PI_SUBAGENT_ORCHESTRATOR_TARGET";
15
15
  export const SUBAGENT_RUN_ID_ENV = "PI_SUBAGENT_RUN_ID";
16
+ export const SUBAGENT_RUN_DIR_ENV = "PI_SUBAGENT_RUN_DIR";
16
17
  export const SUBAGENT_CHILD_AGENT_ENV = "PI_SUBAGENT_CHILD_AGENT";
17
18
  export const SUBAGENT_CHILD_INDEX_ENV = "PI_SUBAGENT_CHILD_INDEX";
18
19
  export const SUBAGENT_FANOUT_CHILD_ENV = "PI_SUBAGENT_FANOUT_CHILD";
@@ -68,6 +69,10 @@ interface BuildPiArgsResult {
68
69
  tempDir?: string;
69
70
  }
70
71
 
72
+ export function runDirEnv(asyncDir: string): Record<string, string> {
73
+ return { [SUBAGENT_RUN_DIR_ENV]: asyncDir };
74
+ }
75
+
71
76
  export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined): string | undefined {
72
77
  if (!model || !thinking || thinking === "off") return model;
73
78
  const colonIdx = model.lastIndexOf(":");
@@ -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