pi-subagents 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +189 -18
  3. package/agents/context-builder.md +3 -3
  4. package/agents/planner.md +1 -1
  5. package/agents/researcher.md +1 -1
  6. package/agents/scout.md +1 -1
  7. package/package.json +7 -7
  8. package/skills/pi-subagents/SKILL.md +5 -0
  9. package/src/agents/agent-management.ts +170 -6
  10. package/src/agents/agent-serializer.ts +31 -13
  11. package/src/agents/agents.ts +207 -23
  12. package/src/agents/frontmatter.ts +66 -2
  13. package/src/agents/skills.ts +117 -20
  14. package/src/extension/doctor.ts +20 -0
  15. package/src/extension/fanout-child.ts +1 -0
  16. package/src/extension/index.ts +58 -4
  17. package/src/extension/schemas.ts +10 -76
  18. package/src/intercom/intercom-bridge.ts +27 -4
  19. package/src/profiles/profiles.ts +637 -0
  20. package/src/runs/background/async-execution.ts +14 -4
  21. package/src/runs/background/async-job-tracker.ts +56 -11
  22. package/src/runs/background/async-resume.ts +11 -13
  23. package/src/runs/background/control-channel.ts +177 -0
  24. package/src/runs/background/result-watcher.ts +11 -2
  25. package/src/runs/background/stale-run-reconciler.ts +9 -4
  26. package/src/runs/background/subagent-runner.ts +86 -3
  27. package/src/runs/foreground/chain-execution.ts +26 -2
  28. package/src/runs/foreground/execution.ts +113 -8
  29. package/src/runs/foreground/subagent-executor.ts +356 -86
  30. package/src/runs/shared/acceptance.ts +285 -34
  31. package/src/runs/shared/completion-guard.ts +1 -1
  32. package/src/runs/shared/dynamic-fanout.ts +4 -2
  33. package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
  34. package/src/runs/shared/parallel-utils.ts +6 -1
  35. package/src/runs/shared/pi-args.ts +9 -1
  36. package/src/runs/shared/single-output.ts +15 -1
  37. package/src/runs/shared/subagent-prompt-runtime.ts +1 -0
  38. package/src/shared/settings.ts +1 -0
  39. package/src/shared/types.ts +9 -2
  40. package/src/shared/utils.ts +19 -1
  41. package/src/slash/prompt-template-bridge.ts +26 -3
  42. package/src/slash/slash-commands.ts +642 -43
  43. package/src/tui/render.ts +265 -13
@@ -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 { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, 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";
@@ -95,6 +95,8 @@ interface AsyncExecutionContext {
95
95
  pi: ExtensionAPI;
96
96
  cwd: string;
97
97
  currentSessionId: string;
98
+ /** Parent session id used by permission-system ask forwarding. */
99
+ parentSessionId?: string;
98
100
  currentModelProvider?: string;
99
101
  currentModel?: ParentModel;
100
102
  }
@@ -115,6 +117,7 @@ interface AsyncChainParams {
115
117
  sessionRoot?: string;
116
118
  chainSkills?: string[];
117
119
  sessionFilesByFlatIndex?: (string | undefined)[];
120
+ progressDir?: string;
118
121
  dynamicFanoutMaxItems?: number;
119
122
  maxSubagentDepth: number;
120
123
  worktreeSetupHook?: string;
@@ -170,6 +173,7 @@ export interface AsyncRunnerStepBuildParams {
170
173
  cwd?: string;
171
174
  chainSkills?: string[];
172
175
  sessionFilesByFlatIndex?: (string | undefined)[];
176
+ progressDir?: string;
173
177
  dynamicFanoutMaxItems?: number;
174
178
  maxSubagentDepth: number;
175
179
  asyncDir: string;
@@ -277,6 +281,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
277
281
  const chainSkills = params.chainSkills ?? [];
278
282
  const availableModels = params.availableModels;
279
283
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
284
+ const progressDir = params.progressDir ?? runnerCwd;
280
285
  const graphChain: ChainStep[] = params.attachRoot
281
286
  ? [{
282
287
  agent: params.attachRoot.agent,
@@ -346,8 +351,9 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
346
351
  const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false);
347
352
  const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
348
353
  if (behavior.progress) progressInstructionCreated = true;
349
- const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, runnerCwd, isFirstProgressAgent);
354
+ const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, progressDir, isFirstProgressAgent);
350
355
  const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd);
356
+ systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
351
357
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
352
358
  if (validationError) throw new AsyncStartValidationError(validationError);
353
359
  let taskTemplate = s.task ?? "{previous}";
@@ -359,6 +365,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
359
365
  const primaryModel = resolveSubagentModelOverride(requestedModel, ctx.currentModel, availableModels, ctx.currentModelProvider);
360
366
  const model = applyThinkingSuffix(primaryModel, a.thinking);
361
367
  return {
368
+ parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
362
369
  agent: s.agent,
363
370
  task,
364
371
  phase: s.phase,
@@ -414,7 +421,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
414
421
  });
415
422
  const progressPrecreated = parallelBehaviors.some((behavior) => behavior.progress);
416
423
  if (progressPrecreated) {
417
- if (!s.worktree) writeInitialProgressFile(runnerCwd);
424
+ if (!s.worktree || params.progressDir) writeInitialProgressFile(progressDir);
418
425
  progressInstructionCreated = true;
419
426
  }
420
427
  return {
@@ -439,7 +446,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
439
446
  const behavior = suppressProgressForReadOnlyTask(resolveStepBehavior(agent, buildStepOverrides(s.parallel), chainSkills), s.parallel.task, originalTask);
440
447
  const progressPrecreated = behavior.progress;
441
448
  if (progressPrecreated) {
442
- writeInitialProgressFile(runnerCwd);
449
+ writeInitialProgressFile(progressDir);
443
450
  progressInstructionCreated = true;
444
451
  }
445
452
  return {
@@ -539,6 +546,7 @@ export function executeAsyncChain(
539
546
  cwd,
540
547
  chainSkills: params.chainSkills,
541
548
  sessionFilesByFlatIndex,
549
+ progressDir: params.progressDir ?? (resultMode === "parallel" ? path.join(asyncDir, "progress") : undefined),
542
550
  dynamicFanoutMaxItems: params.dynamicFanoutMaxItems,
543
551
  maxSubagentDepth,
544
552
  asyncDir,
@@ -765,6 +773,7 @@ export function executeAsyncSingle(
765
773
 
766
774
  const effectiveOutput = normalizeSingleOutputOverride(params.output, agentConfig.output);
767
775
  const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, runnerCwd);
776
+ systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
768
777
  const outputMode = params.outputMode ?? "inline";
769
778
  const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
770
779
  if (validationError) return formatAsyncStartError("single", validationError);
@@ -783,6 +792,7 @@ export function executeAsyncSingle(
783
792
  id,
784
793
  steps: [
785
794
  {
795
+ parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
786
796
  agent,
787
797
  task: taskWithOutputInstruction,
788
798
  cwd: runnerCwd,
@@ -26,6 +26,10 @@ interface AsyncJobTrackerOptions {
26
26
  now?: () => number;
27
27
  }
28
28
 
29
+ const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
30
+ const MAX_CONTROL_EVENT_LINE_BYTES = 1024 * 1024;
31
+ const CONTROL_EVENT_SCAN_WINDOW_BYTES = 2 * 1024 * 1024;
32
+
29
33
  export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: SubagentState, asyncDirRoot: string, options: AsyncJobTrackerOptions = {}): {
30
34
  ensurePoller: () => void;
31
35
  handleStarted: (data: unknown) => void;
@@ -68,25 +72,24 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
68
72
  }
69
73
  try {
70
74
  const stat = fs.fstatSync(fd);
71
- const cursor = stat.size < (job.controlEventCursor ?? 0) ? 0 : (job.controlEventCursor ?? 0);
75
+ const savedCursor = job.controlEventCursor;
76
+ let cursor = stat.size < (savedCursor ?? 0) ? 0 : (savedCursor ?? 0);
77
+ const startedFromTail = savedCursor === undefined && stat.size > CONTROL_EVENT_SCAN_WINDOW_BYTES;
78
+ if (startedFromTail) cursor = stat.size - CONTROL_EVENT_SCAN_WINDOW_BYTES;
72
79
  if (stat.size <= cursor) return;
73
- const buffer = Buffer.alloc(stat.size - cursor);
74
- fs.readSync(fd, buffer, 0, buffer.length, cursor);
75
- const lastNewline = buffer.lastIndexOf(0x0a);
76
- if (lastNewline === -1) return;
77
- job.controlEventCursor = cursor + lastNewline + 1;
78
- for (const line of buffer.subarray(0, lastNewline).toString("utf-8").split("\n")) {
79
- if (!line.trim()) continue;
80
+ const scanEnd = Math.min(stat.size, cursor + CONTROL_EVENT_SCAN_WINDOW_BYTES);
81
+ const handleLine = (line: string) => {
82
+ if (!line.trim()) return;
80
83
  let parsed: unknown;
81
84
  try {
82
85
  parsed = JSON.parse(line);
83
86
  } catch (error) {
84
87
  console.error(`Ignoring malformed async control event in '${eventsPath}':`, error);
85
- continue;
88
+ return;
86
89
  }
87
- if (!parsed || typeof parsed !== "object" || (parsed as { type?: unknown }).type !== "subagent.control") continue;
90
+ if (!parsed || typeof parsed !== "object" || (parsed as { type?: unknown }).type !== "subagent.control") return;
88
91
  const record = parsed as { event?: ControlEvent; channels?: string[]; childIntercomTarget?: string; noticeText?: string; intercom?: { to?: string; message?: string } };
89
- if (!record.event || !Array.isArray(record.channels)) continue;
92
+ if (!record.event || !Array.isArray(record.channels)) return;
90
93
  const payload = {
91
94
  event: record.event,
92
95
  source: "async" as const,
@@ -104,7 +107,48 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
104
107
  message: record.intercom.message,
105
108
  });
106
109
  }
110
+ };
111
+ let readCursor = cursor;
112
+ let lastCompleteCursor = cursor;
113
+ let lineParts: Buffer[] = [];
114
+ let lineBytes = 0;
115
+ let skippingOversizedLine = startedFromTail;
116
+ const appendLineSegment = (segment: Buffer) => {
117
+ if (segment.length === 0 || skippingOversizedLine) return;
118
+ if (lineBytes + segment.length > MAX_CONTROL_EVENT_LINE_BYTES) {
119
+ lineParts = [];
120
+ lineBytes = 0;
121
+ skippingOversizedLine = true;
122
+ return;
123
+ }
124
+ lineParts.push(segment);
125
+ lineBytes += segment.length;
126
+ };
127
+ while (readCursor < scanEnd) {
128
+ const toRead = Math.min(CONTROL_EVENT_READ_CHUNK_BYTES, scanEnd - readCursor);
129
+ const buffer = Buffer.alloc(toRead);
130
+ const bytesRead = fs.readSync(fd, buffer, 0, toRead, readCursor);
131
+ if (bytesRead <= 0) break;
132
+ const chunk = bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead);
133
+ let lineStart = 0;
134
+ for (let index = 0; index < chunk.length; index++) {
135
+ if (chunk[index] !== 0x0a) continue;
136
+ appendLineSegment(chunk.subarray(lineStart, index));
137
+ if (!skippingOversizedLine && lineBytes > 0) {
138
+ handleLine(Buffer.concat(lineParts, lineBytes).toString("utf-8"));
139
+ }
140
+ lineParts = [];
141
+ lineBytes = 0;
142
+ skippingOversizedLine = false;
143
+ lastCompleteCursor = readCursor + index + 1;
144
+ lineStart = index + 1;
145
+ }
146
+ appendLineSegment(chunk.subarray(lineStart));
147
+ readCursor += bytesRead;
148
+ if (skippingOversizedLine) job.controlEventCursor = readCursor;
107
149
  }
150
+ if (lastCompleteCursor > cursor) job.controlEventCursor = lastCompleteCursor;
151
+ else if (scanEnd < stat.size || startedFromTail) job.controlEventCursor = scanEnd;
108
152
  } catch (error) {
109
153
  console.error(`Failed to read async control events for '${job.asyncDir}':`, error);
110
154
  } finally {
@@ -261,6 +305,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
261
305
  activeParallelGroup: Boolean(firstGroupCount && firstGroupCount > 0),
262
306
  startedAt: now,
263
307
  updatedAt: now,
308
+ controlEventCursor: 0,
264
309
  });
265
310
  ensurePoller();
266
311
  if (state.lastUiContext) {
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type SubagentState } from "../../shared/types.ts";
4
4
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
5
+ import { deliverInterruptRequest } from "./control-channel.ts";
5
6
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
6
7
 
7
8
  export const ASYNC_RESUME_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
@@ -38,33 +39,30 @@ export type AsyncResumeTarget = {
38
39
 
39
40
  type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => boolean;
40
41
 
41
- function readAsyncStatus(asyncDir: string): AsyncStatus | null {
42
- const statusPath = path.join(asyncDir, "status.json");
43
- try {
44
- return JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
45
- } catch (error) {
46
- const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
47
- if (code === "ENOENT") return null;
48
- throw error;
49
- }
50
- }
51
-
52
42
  export function interruptLiveAsyncResumeTarget(input: {
53
43
  target: AsyncResumeTarget & { kind: "live" };
54
44
  state?: Pick<SubagentState, "asyncJobs">;
55
45
  kill?: KillFn;
56
46
  now?: () => number;
47
+ resultsDir?: string;
57
48
  }): { ok: true; asyncId: string } | { ok: false; message: string } {
58
49
  const asyncId = input.target.runId;
59
50
  if (!input.target.asyncDir) {
60
51
  return { ok: false, message: `Async run ${asyncId} is live but does not have an async directory to interrupt.` };
61
52
  }
62
- const status = readAsyncStatus(input.target.asyncDir);
53
+ const status = reconcileAsyncRun(input.target.asyncDir, { resultsDir: input.resultsDir, kill: input.kill, now: input.now }).status;
63
54
  if (!status || status.state !== "running" || typeof status.pid !== "number") {
64
55
  return { ok: false, message: `Async run ${asyncId} is live but no interrupt-capable runner pid was found.` };
65
56
  }
66
57
  try {
67
- (input.kill ?? process.kill)(status.pid, ASYNC_RESUME_INTERRUPT_SIGNAL);
58
+ deliverInterruptRequest({
59
+ asyncDir: input.target.asyncDir,
60
+ pid: status.pid,
61
+ kill: input.kill,
62
+ signal: ASYNC_RESUME_INTERRUPT_SIGNAL,
63
+ now: input.now,
64
+ source: "async-resume",
65
+ });
68
66
  const tracked = input.state?.asyncJobs.get(asyncId);
69
67
  if (tracked) {
70
68
  tracked.activityState = undefined;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Cross-OS control channel for async subagent runs.
3
+ *
4
+ * Background runs are detached OS processes. The original control path delivered
5
+ * an interrupt with `process.kill(pid, SIGUSR2|SIGBREAK)`, but Windows cannot
6
+ * deliver those signals cross-process via `process.kill` and throws `ENOSYS`,
7
+ * which left async runs uninterruptible (no stop, no live steer) on Windows.
8
+ *
9
+ * This module adds a portable, file-based control inbox inside the run directory.
10
+ * The parent drops an interrupt request file; the runner watches the inbox and
11
+ * routes the request into its existing graceful `interruptRunner()` (pause +
12
+ * resumable), identically on every platform. The OS signal is kept only as an
13
+ * opportunistic fast-path; its failure is non-fatal because the file inbox is
14
+ * authoritative.
15
+ */
16
+
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
20
+ import { POLL_INTERVAL_MS } from "../../shared/types.ts";
21
+
22
+ /**
23
+ * Opportunistic fast-path interrupt signal. On Unix `SIGUSR2` is trapped by the
24
+ * runner; on Windows `process.kill(pid, "SIGBREAK")` is not deliverable
25
+ * cross-process and throws `ENOSYS`, so the file inbox below is the real channel.
26
+ */
27
+ export const INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
28
+
29
+ export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch">;
30
+ export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
31
+ type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
32
+
33
+ export interface InterruptRequest {
34
+ type: "interrupt";
35
+ ts?: number;
36
+ source?: string;
37
+ reason?: string;
38
+ }
39
+
40
+ /** Control inbox directory inside an async run dir. */
41
+ export function controlInboxDir(asyncDir: string): string {
42
+ return path.join(asyncDir, "control");
43
+ }
44
+
45
+ /** Path of the portable interrupt request file. */
46
+ export function interruptRequestPath(asyncDir: string): string {
47
+ return path.join(controlInboxDir(asyncDir), "interrupt.json");
48
+ }
49
+
50
+ /**
51
+ * Parent side: drop a portable interrupt request the runner's inbox watcher will
52
+ * pick up regardless of OS. Written atomically (temp + rename), dir auto-created.
53
+ */
54
+ export function requestAsyncInterrupt(
55
+ asyncDir: string,
56
+ payload: Omit<InterruptRequest, "type"> = {},
57
+ deps: { now?: () => number } = {},
58
+ ): string {
59
+ const requestPath = interruptRequestPath(asyncDir);
60
+ const request: InterruptRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type: "interrupt" };
61
+ writeAtomicJson(requestPath, request);
62
+ return requestPath;
63
+ }
64
+
65
+ /**
66
+ * Runner side: consume a pending interrupt request. Idempotent — removes the file
67
+ * so each distinct request fires exactly once. Returns whether one was pending.
68
+ */
69
+ export function consumeInterruptRequest(
70
+ asyncDir: string,
71
+ fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
72
+ ): boolean {
73
+ const requestPath = interruptRequestPath(asyncDir);
74
+ if (!fsImpl.existsSync(requestPath)) return false;
75
+ try {
76
+ fsImpl.rmSync(requestPath, { force: true, recursive: true });
77
+ } catch {
78
+ // Already removed by a concurrent check — still counts as consumed.
79
+ }
80
+ return true;
81
+ }
82
+
83
+ /**
84
+ * Parent side: portable interrupt = authoritative file request + best-effort OS
85
+ * signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
86
+ * is swallowed because the file inbox is authoritative there. Other signal
87
+ * failures are surfaced because they usually mean the runner is not alive to
88
+ * consume the request.
89
+ */
90
+ export function deliverInterruptRequest(input: {
91
+ asyncDir: string;
92
+ pid?: number;
93
+ kill?: KillFn;
94
+ signal?: NodeJS.Signals;
95
+ now?: () => number;
96
+ source?: string;
97
+ }): void {
98
+ const requestPath = requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
99
+ if (typeof input.pid === "number" && input.pid > 0) {
100
+ try {
101
+ (input.kill ?? process.kill)(input.pid, input.signal ?? INTERRUPT_SIGNAL);
102
+ } catch (error) {
103
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOSYS") {
104
+ // File inbox is authoritative when custom cross-process signals are unavailable.
105
+ return;
106
+ }
107
+ try {
108
+ fs.rmSync(requestPath, { force: true });
109
+ } catch {
110
+ // Best effort cleanup; the caller still gets the signal failure.
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Runner side: watch the control inbox and route interrupt requests into
119
+ * `onInterrupt`. Uses `fs.watch` when available plus an interval poll as a
120
+ * portable safety net (covers filesystems/platforms where `fs.watch` is
121
+ * unreliable). Fires once per distinct request. Returns a disposer.
122
+ */
123
+ export function watchAsyncControlInbox(
124
+ asyncDir: string,
125
+ opts: {
126
+ onInterrupt: () => void;
127
+ pollIntervalMs?: number;
128
+ fs?: ControlChannelFs;
129
+ timers?: ControlChannelTimers;
130
+ },
131
+ ): () => void {
132
+ const fsImpl = opts.fs ?? fs;
133
+ const timers = opts.timers ?? { setInterval, clearInterval };
134
+ const dir = controlInboxDir(asyncDir);
135
+ try {
136
+ fsImpl.mkdirSync(dir, { recursive: true });
137
+ } catch {
138
+ // Best effort — the poll/watch below tolerates a missing dir.
139
+ }
140
+
141
+ let disposed = false;
142
+ const check = (): void => {
143
+ if (disposed) return;
144
+ try {
145
+ if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
146
+ } catch {
147
+ // Never let inbox errors crash the runner.
148
+ }
149
+ };
150
+
151
+ // Handle a request that may have arrived before the watcher started.
152
+ check();
153
+
154
+ let watcher: fs.FSWatcher | undefined;
155
+ try {
156
+ watcher = fsImpl.watch(dir, () => check());
157
+ watcher.on?.("error", () => {
158
+ // fs.watch can emit on transient FS errors; the interval poll keeps us live.
159
+ });
160
+ } catch {
161
+ watcher = undefined;
162
+ }
163
+
164
+ const interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
165
+ interval.unref?.();
166
+
167
+ return () => {
168
+ if (disposed) return;
169
+ disposed = true;
170
+ try {
171
+ watcher?.close();
172
+ } catch {
173
+ // ignore
174
+ }
175
+ timers.clearInterval(interval);
176
+ };
177
+ }
@@ -21,7 +21,7 @@ import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-
21
21
  const WATCHER_RESTART_DELAY_MS = 3000;
22
22
  const POLL_INTERVAL_MS = 3000;
23
23
 
24
- type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "watch">;
24
+ type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "watch">;
25
25
 
26
26
  type ResultWatcherTimers = {
27
27
  setTimeout: typeof setTimeout;
@@ -91,6 +91,14 @@ function shouldFallBackToPolling(error: unknown): boolean {
91
91
  return code === "EMFILE" || code === "ENOSPC";
92
92
  }
93
93
 
94
+ function resolveNativeWatchDir(fsApi: ResultWatcherFs, resultsDir: string): string {
95
+ try {
96
+ return fsApi.realpathSync.native(resultsDir);
97
+ } catch {
98
+ return resultsDir;
99
+ }
100
+ }
101
+
94
102
  export function createResultWatcher(
95
103
  pi: { events: IntercomEventBus },
96
104
  state: SubagentState,
@@ -264,7 +272,8 @@ export function createResultWatcher(
264
272
  state.watcherRestartTimer = null;
265
273
  }
266
274
  try {
267
- state.watcher = fsApi.watch(resultsDir, (ev, file) => {
275
+ const watchDir = resolveNativeWatchDir(fsApi, resultsDir);
276
+ state.watcher = fsApi.watch(watchDir, (ev, file) => {
268
277
  if (ev !== "rename" || !file) return;
269
278
  const fileName = file.toString();
270
279
  if (!fileName.endsWith(".json")) return;
@@ -48,9 +48,14 @@ function isNotFoundError(error: unknown): boolean {
48
48
  && (error as NodeJS.ErrnoException).code === "ENOENT";
49
49
  }
50
50
 
51
- function appendJsonl(filePath: string, payload: object): void {
52
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
53
- fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
51
+ function appendJsonlBestEffort(filePath: string, payload: object): void {
52
+ try {
53
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
54
+ fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
55
+ } catch {
56
+ // Repair status/result writes are the important path. A broken or full
57
+ // diagnostic event log must not make stale-run reconciliation fail.
58
+ }
54
59
  }
55
60
 
56
61
  function readStatusFile(asyncDir: string): AsyncStatus | null {
@@ -223,7 +228,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
223
228
  const repair = buildFailedRepair(status, asyncDir, now, reason);
224
229
  writeAtomicJson(resultPath, repair.result);
225
230
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
226
- appendJsonl(path.join(asyncDir, "events.jsonl"), {
231
+ appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
227
232
  type: "subagent.run.repaired_stale",
228
233
  ts: now,
229
234
  runId: repair.status.runId,
@@ -4,7 +4,8 @@ import * as path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import type { Message } from "@earendil-works/pi-ai";
6
6
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
7
- import { appendJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
7
+ import { consumeInterruptRequest, watchAsyncControlInbox } from "./control-channel.ts";
8
+ import { appendJsonl as appendRawJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
8
9
  import { PI_CODING_AGENT_PACKAGE, getPiSpawnCommand, resolveInstalledPiPackageRoot } from "../shared/pi-spawn.ts";
9
10
  import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
10
11
  import {
@@ -131,6 +132,79 @@ interface StepResult {
131
132
  }
132
133
 
133
134
  const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
135
+ const DEFAULT_MAX_ASYNC_EVENTS_BYTES = 50 * 1024 * 1024;
136
+ const ASYNC_EVENTS_MAX_BYTES_ENV = "PI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES";
137
+ const TRUNCATED_EVENT_TYPE = "subagent.events.truncated";
138
+ const TRUNCATION_MARKER_RESERVE_BYTES = 512;
139
+
140
+ interface AsyncEventLogState {
141
+ bytes: number;
142
+ diagnosticsTruncated: boolean;
143
+ }
144
+
145
+ const asyncEventLogStates = new Map<string, AsyncEventLogState>();
146
+
147
+ function maxAsyncEventsBytes(): number {
148
+ const raw = process.env[ASYNC_EVENTS_MAX_BYTES_ENV];
149
+ if (!raw) return DEFAULT_MAX_ASYNC_EVENTS_BYTES;
150
+ const parsed = Number(raw);
151
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_MAX_ASYNC_EVENTS_BYTES;
152
+ return Math.floor(parsed);
153
+ }
154
+
155
+ function eventLogState(filePath: string): AsyncEventLogState {
156
+ let state = asyncEventLogStates.get(filePath);
157
+ if (state) return state;
158
+ let bytes = 0;
159
+ try {
160
+ bytes = fs.statSync(filePath).size;
161
+ } catch (error) {
162
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
163
+ // Diagnostic event accounting is best-effort; writes below are also safe.
164
+ }
165
+ }
166
+ state = { bytes, diagnosticsTruncated: false };
167
+ asyncEventLogStates.set(filePath, state);
168
+ return state;
169
+ }
170
+
171
+ function appendJsonl(filePath: string, line: string): void {
172
+ try {
173
+ appendRawJsonl(filePath, line);
174
+ const state = asyncEventLogStates.get(filePath);
175
+ if (state) state.bytes += Buffer.byteLength(`${line}\n`, "utf-8");
176
+ } catch {
177
+ // Async event logging is diagnostic and must not fail the run.
178
+ }
179
+ }
180
+
181
+ function appendDiagnosticJsonl(filePath: string, line: string, droppedEventType?: string): void {
182
+ if (!line.trim()) return;
183
+ const state = eventLogState(filePath);
184
+ if (state.diagnosticsTruncated) return;
185
+ const maxBytes = maxAsyncEventsBytes();
186
+ const chunkBytes = Buffer.byteLength(`${line}\n`, "utf-8");
187
+ const diagnosticBudget = Math.max(0, maxBytes - TRUNCATION_MARKER_RESERVE_BYTES);
188
+ if (state.bytes + chunkBytes <= diagnosticBudget) {
189
+ appendJsonl(filePath, line);
190
+ return;
191
+ }
192
+
193
+ const marker = JSON.stringify({
194
+ type: TRUNCATED_EVENT_TYPE,
195
+ ts: Date.now(),
196
+ maxBytes,
197
+ droppedEventType,
198
+ });
199
+ if (state.bytes + Buffer.byteLength(`${marker}\n`, "utf-8") <= maxBytes) {
200
+ appendJsonl(filePath, marker);
201
+ }
202
+ state.diagnosticsTruncated = true;
203
+ }
204
+
205
+ function shouldPersistChildEvent(event: Record<string, unknown>): boolean {
206
+ return event.type !== "message_update";
207
+ }
134
208
 
135
209
  function findLatestSessionFile(sessionDir: string): string | null {
136
210
  try {
@@ -274,14 +348,15 @@ function runPiStreaming(
274
348
 
275
349
  const appendChildEvent = (event: Record<string, unknown>) => {
276
350
  if (!childEventContext) return;
277
- appendJsonl(childEventContext.eventsPath, JSON.stringify({
351
+ if (!shouldPersistChildEvent(event)) return;
352
+ appendDiagnosticJsonl(childEventContext.eventsPath, JSON.stringify({
278
353
  ...event,
279
354
  subagentSource: "child",
280
355
  subagentRunId: childEventContext.runId,
281
356
  subagentStepIndex: childEventContext.stepIndex,
282
357
  subagentAgent: childEventContext.agent,
283
358
  observedAt: Date.now(),
284
- }));
359
+ }), typeof event.type === "string" ? event.type : undefined);
285
360
  };
286
361
 
287
362
  const appendChildLine = (type: "subagent.child.stdout" | "subagent.child.stderr", line: string) => {
@@ -676,6 +751,7 @@ async function runSingleStep(
676
751
  }
677
752
  }
678
753
  const { args, env, tempDir } = buildPiArgs({
754
+ parentSessionId: step.parentSessionId,
679
755
  baseArgs: ["--mode", "json", "-p"],
680
756
  task,
681
757
  sessionEnabled,
@@ -684,6 +760,7 @@ async function runSingleStep(
684
760
  model: candidate,
685
761
  inheritProjectContext: step.inheritProjectContext,
686
762
  inheritSkills: step.inheritSkills,
763
+ requireReadTool: Boolean(step.skills?.length),
687
764
  tools: step.tools,
688
765
  extensions: step.extensions,
689
766
  subagentOnlyExtensions: step.subagentOnlyExtensions,
@@ -1431,6 +1508,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1431
1508
  }
1432
1509
 
1433
1510
  const interruptRunner = () => {
1511
+ consumeInterruptRequest(asyncDir);
1434
1512
  if (interrupted || statusPayload.state !== "running") return;
1435
1513
  interrupted = true;
1436
1514
  const now = Date.now();
@@ -1456,6 +1534,10 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1456
1534
  activeChildInterrupt?.();
1457
1535
  };
1458
1536
  process.on(ASYNC_INTERRUPT_SIGNAL, interruptRunner);
1537
+ // Portable control inbox: the parent drops an interrupt request file here when
1538
+ // it cannot deliver the OS signal (e.g. ENOSYS on Windows). Routes into the
1539
+ // same graceful interruptRunner() so stop/steer work on every platform.
1540
+ const disposeControlInbox = watchAsyncControlInbox(asyncDir, { onInterrupt: interruptRunner });
1459
1541
  appendJsonl(
1460
1542
  eventsPath,
1461
1543
  JSON.stringify({
@@ -2241,6 +2323,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2241
2323
  clearInterval(activityTimer);
2242
2324
  activityTimer = undefined;
2243
2325
  }
2326
+ disposeControlInbox();
2244
2327
  const effectiveSessionFile = sessionFile ?? latestSessionFile;
2245
2328
  const runEndedAt = Date.now();
2246
2329
  statusPayload.state = interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed";