pi-cohort 5.1.2 → 5.2.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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.2.0] - 2026-09-01
4
+
5
+ ### Added
6
+
7
+ - `runner.log` diagnostics for the detached async runner: captures the
8
+ runner's stdout/stderr, spawn-time jiti revalidation, and enriched failure
9
+ messages that append the log's tail; the run dir is also exported to the
10
+ child as `PI_SUBAGENT_RUN_DIR` and echoed on an `Async dir:` start line.
11
+ - Builtin `monitor` persona: watches an already-started job (async run dir,
12
+ PID, log, or probe command), reports progress deltas on a cadence, and
13
+ flags stalls, plus SKILL.md guidance to pair it with long-running async
14
+ jobs.
15
+
16
+ ## [5.1.3] - 2026-08-18
17
+
18
+ ### Fixed
19
+
20
+ - Foreground control notices are steered to a turn boundary while the session
21
+ is streaming instead of splicing into an open tool cycle (which corrupted
22
+ the session with a duplicated `tool_use_id`); idle delivery still appends
23
+ without triggering a turn. (#7)
24
+
25
+ ### Changed
26
+
27
+ - `dir` no longer advertised by the subagent tool schema (follows `runId`,
28
+ #5) - still accepted at runtime for async status/resume, `id` preferred.
29
+
3
30
  ## [5.1.2] - 2026-08-07
4
31
 
5
32
  ### Changed
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.2",
3
+ "version": "5.2.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",
@@ -36,8 +36,8 @@
36
36
  "scripts": {
37
37
  "check:agents-core": "node scripts/check-agents-core.mjs",
38
38
  "test": "npm run test:unit",
39
- "test:unit": "node --experimental-strip-types --test test/unit/*.test.ts",
40
- "test:integration": "node --experimental-transform-types --import ./test/support/register-loader.mjs --test test/integration/*.test.ts",
39
+ "test:unit": "node --test test/unit/*.test.ts",
40
+ "test:integration": "node --import ./test/support/register-loader.mjs --test test/integration/*.test.ts",
41
41
  "test:all": "npm run check:agents-core && npm run test:unit && npm run test:integration"
42
42
  },
43
43
  "pi": {
@@ -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.
@@ -39,6 +39,7 @@ function deliverControlNotice(input: {
39
39
  pi: Pick<ExtensionAPI, "sendMessage">;
40
40
  visibleControlNotices: Set<string>;
41
41
  details: SubagentControlMessageDetails;
42
+ isIdle: () => boolean;
42
43
  }): void {
43
44
  const childIntercomTarget = controlNoticeTarget(input.details);
44
45
  const key = controlNotificationKey(input.details.event, childIntercomTarget);
@@ -52,7 +53,7 @@ function deliverControlNotice(input: {
52
53
  display: true,
53
54
  details: { ...input.details, childIntercomTarget, noticeText },
54
55
  },
55
- { triggerTurn: input.details.source !== "foreground" },
56
+ { triggerTurn: input.details.source !== "foreground" || !input.isIdle() },
56
57
  );
57
58
  }
58
59
 
@@ -72,8 +73,9 @@ export function handleSubagentControlNotice(input: {
72
73
  foregroundDelayMs?: number;
73
74
  }): void {
74
75
  if (!input.details?.event || input.details.event.type === "active_long_running") return;
76
+ const isIdle = () => input.state.lastUiContext?.isIdle() ?? false;
75
77
  if (input.details.source !== "foreground") {
76
- deliverControlNotice(input);
78
+ deliverControlNotice({ ...input, isIdle });
77
79
  return;
78
80
  }
79
81
 
@@ -85,7 +87,7 @@ export function handleSubagentControlNotice(input: {
85
87
  const timer = setTimeout(() => {
86
88
  pending.delete(timerKey);
87
89
  if (!isForegroundNoticeStillActionable(input.state, input.details)) return;
88
- deliverControlNotice(input);
90
+ deliverControlNotice({ ...input, isIdle });
89
91
  }, input.foregroundDelayMs ?? 1000);
90
92
  timer.unref?.();
91
93
  pending.set(timerKey, timer);
@@ -183,10 +183,13 @@ function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | un
183
183
  }
184
184
 
185
185
  class SubagentControlNoticeComponent implements Component {
186
- constructor(
187
- private readonly details: SubagentControlMessageDetails,
188
- private readonly theme: ExtensionContext["ui"]["theme"],
189
- ) {}
186
+ private readonly details: SubagentControlMessageDetails;
187
+ private readonly theme: ExtensionContext["ui"]["theme"];
188
+
189
+ constructor(details: SubagentControlMessageDetails, theme: ExtensionContext["ui"]["theme"]) {
190
+ this.details = details;
191
+ this.theme = theme;
192
+ }
190
193
 
191
194
  invalidate(): void {}
192
195
 
@@ -259,9 +259,6 @@ export const SubagentParams = Type.Object({
259
259
  id: Type.Optional(Type.String({
260
260
  description: "Run id/prefix for status/interrupt/resume."
261
261
  })),
262
- dir: Type.Optional(Type.String({
263
- description: "Async run dir for status/resume."
264
- })),
265
262
  index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based index for per-child actions." })),
266
263
  message: Type.Optional(Type.String({ description: "Follow-up message for resume." })),
267
264
  // Chain identifier for management (can't reuse 'chain' — that's the execution array)
@@ -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 {
@@ -503,6 +558,7 @@ export function executeAsyncChain(
503
558
  },
504
559
  id,
505
560
  runnerCwd,
561
+ asyncDir,
506
562
  );
507
563
  } catch (error) {
508
564
  const message = error instanceof Error ? error.message : String(error);
@@ -603,7 +659,7 @@ export function executeAsyncChain(
603
659
  .join(" -> ");
604
660
 
605
661
  return {
606
- content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
662
+ content: [{ type: "text", text: formatAsyncStartedMessage(asyncStartHeadline(`Async ${resultMode}: ${chainDesc}`, id, asyncDir)) }],
607
663
  details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph },
608
664
  };
609
665
  }
@@ -738,6 +794,7 @@ export function executeAsyncSingle(
738
794
  },
739
795
  id,
740
796
  runnerCwd,
797
+ asyncDir,
741
798
  );
742
799
  } catch (error) {
743
800
  const message = error instanceof Error ? error.message : String(error);
@@ -796,7 +853,7 @@ export function executeAsyncSingle(
796
853
  }
797
854
 
798
855
  return {
799
- content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`) }],
856
+ content: [{ type: "text", text: formatAsyncStartedMessage(asyncStartHeadline(`Async: ${agent}`, id, asyncDir)) }],
800
857
  details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir },
801
858
  };
802
859
  }
@@ -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`);
@@ -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(":");