pi-subagents 0.31.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.
@@ -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
+ }
@@ -4,6 +4,7 @@ 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 { consumeInterruptRequest, watchAsyncControlInbox } from "./control-channel.ts";
7
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";
@@ -1507,6 +1508,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1507
1508
  }
1508
1509
 
1509
1510
  const interruptRunner = () => {
1511
+ consumeInterruptRequest(asyncDir);
1510
1512
  if (interrupted || statusPayload.state !== "running") return;
1511
1513
  interrupted = true;
1512
1514
  const now = Date.now();
@@ -1532,6 +1534,10 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1532
1534
  activeChildInterrupt?.();
1533
1535
  };
1534
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 });
1535
1541
  appendJsonl(
1536
1542
  eventsPath,
1537
1543
  JSON.stringify({
@@ -2317,6 +2323,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2317
2323
  clearInterval(activityTimer);
2318
2324
  activityTimer = undefined;
2319
2325
  }
2326
+ disposeControlInbox();
2320
2327
  const effectiveSessionFile = sessionFile ?? latestSessionFile;
2321
2328
  const runEndedAt = Date.now();
2322
2329
  statusPayload.state = interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed";
@@ -51,6 +51,8 @@ import {
51
51
  stripDetailsOutputsForIntercomReceipt,
52
52
  } from "../../intercom/result-intercom.ts";
53
53
  import { buildRevivedAsyncTask, interruptLiveAsyncResumeTarget, resolveAsyncResumeTarget } from "../background/async-resume.ts";
54
+ import { deliverInterruptRequest } from "../background/control-channel.ts";
55
+ import { reconcileAsyncRun } from "../background/stale-run-reconciler.ts";
54
56
  import { resolveAsyncRootResultPath } from "../background/chain-root-attachment.ts";
55
57
  import { createNestedRoute, readNestedControlResults, resolveInheritedNestedRouteFromEnv, resolveNestedAsyncDir, resolveNestedParentAddressFromEnv, updateForegroundNestedProjection, writeNestedControlRequest, writeNestedEvent, type NestedRunResolutionScope } from "../shared/nested-events.ts";
56
58
  import { resolveSubagentRunId, type ResolvedSubagentRunId } from "../background/run-id-resolver.ts";
@@ -97,7 +99,6 @@ import {
97
99
  wrapForkTask,
98
100
  } from "../../shared/types.ts";
99
101
 
100
- const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
101
102
  const MUTATING_MANAGEMENT_ACTIONS = new Set(["create", "update", "delete"]);
102
103
 
103
104
  interface TaskParam {
@@ -372,7 +373,17 @@ function resolveResumeTarget(params: SubagentParamsLike, state: SubagentState, o
372
373
  throw new Error("Run not found. Provide id or runId.");
373
374
  }
374
375
 
375
- function getAsyncInterruptTarget(state: SubagentState, runId: string | undefined): { asyncId: string; asyncDir: string } | undefined {
376
+ function getAsyncInterruptTarget(
377
+ state: SubagentState,
378
+ runId: string | undefined,
379
+ location?: { asyncDir: string | null; resolvedId?: string },
380
+ ): { asyncId: string; asyncDir: string } | undefined {
381
+ if (location?.asyncDir) {
382
+ return {
383
+ asyncId: location.resolvedId ?? runId ?? path.basename(location.asyncDir),
384
+ asyncDir: location.asyncDir,
385
+ };
386
+ }
376
387
  if (runId) {
377
388
  const direct = state.asyncJobs.get(runId);
378
389
  if (direct) return { asyncId: direct.asyncId, asyncDir: direct.asyncDir };
@@ -415,10 +426,15 @@ function emitControlNotification(input: {
415
426
  }
416
427
  }
417
428
 
418
- function interruptAsyncRun(state: SubagentState, runId: string | undefined, kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean): AgentToolResult<Details> | null {
419
- const target = getAsyncInterruptTarget(state, runId);
429
+ function interruptAsyncRun(
430
+ state: SubagentState,
431
+ runId: string | undefined,
432
+ kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean,
433
+ location?: { asyncDir: string | null; resolvedId?: string },
434
+ ): AgentToolResult<Details> | null {
435
+ const target = getAsyncInterruptTarget(state, runId, location);
420
436
  if (!target) return null;
421
- const status = readStatus(target.asyncDir);
437
+ const status = reconcileAsyncRun(target.asyncDir, { kill }).status;
422
438
  if (!status || status.state !== "running" || typeof status.pid !== "number") {
423
439
  return {
424
440
  content: [{ type: "text", text: `No running async run with an interrupt-capable pid was found for '${runId ?? "current"}'.` }],
@@ -427,7 +443,7 @@ function interruptAsyncRun(state: SubagentState, runId: string | undefined, kill
427
443
  };
428
444
  }
429
445
  try {
430
- (kill ?? process.kill)(status.pid, ASYNC_INTERRUPT_SIGNAL);
446
+ deliverInterruptRequest({ asyncDir: target.asyncDir, pid: status.pid, kill, source: "interrupt-action" });
431
447
  const tracked = state.asyncJobs.get(target.asyncId);
432
448
  if (tracked) {
433
449
  tracked.activityState = undefined;
@@ -719,11 +735,11 @@ function directNestedAsyncInterrupt(target: ResolvedSubagentRunId & { kind: "nes
719
735
  const run = target.match.run;
720
736
  const asyncDir = resolveNestedAsyncDir(target.match.rootRunId, run);
721
737
  if (!asyncDir) return undefined;
722
- const status = readStatus(asyncDir);
738
+ const status = reconcileAsyncRun(asyncDir, { resultsDir: path.join(RESULTS_DIR, "nested", target.match.rootRunId) }).status;
723
739
  const pid = typeof status?.pid === "number" && status.pid > 0 ? status.pid : run.pid;
724
740
  if (!status || status.state !== "running" || typeof pid !== "number" || pid <= 0) return undefined;
725
741
  try {
726
- process.kill(pid, ASYNC_INTERRUPT_SIGNAL);
742
+ deliverInterruptRequest({ asyncDir, pid, source: "nested-interrupt" });
727
743
  return { content: [{ type: "text", text: `Interrupt requested for nested async run ${run.id}.` }], details: { mode: "management", results: [] } };
728
744
  } catch (error) {
729
745
  const message = error instanceof Error ? error.message : String(error);
@@ -800,6 +816,7 @@ async function resumeAsyncRun(input: {
800
816
  target,
801
817
  state: input.deps.state,
802
818
  kill: input.deps.kill,
819
+ resultsDir: RESULTS_DIR,
803
820
  });
804
821
  if (!interrupt.ok) {
805
822
  return {
@@ -2784,7 +2801,12 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2784
2801
  details: { mode: "management", results: [] },
2785
2802
  };
2786
2803
  }
2787
- const asyncInterruptResult = interruptAsyncRun(deps.state, resolved?.kind === "async" ? resolved.id : targetRunId, deps.kill);
2804
+ const asyncInterruptResult = interruptAsyncRun(
2805
+ deps.state,
2806
+ resolved?.kind === "async" ? resolved.id : targetRunId,
2807
+ deps.kill,
2808
+ resolved?.kind === "async" ? resolved.location : undefined,
2809
+ );
2788
2810
  if (asyncInterruptResult) return asyncInterruptResult;
2789
2811
  return {
2790
2812
  content: [{ type: "text", text: "No interrupt-capable run found in this session." }],
@@ -35,6 +35,7 @@ export const CHILD_FANOUT_BOUNDARY_INSTRUCTIONS = [
35
35
  const PARENT_ONLY_CUSTOM_MESSAGE_TYPES = new Set([
36
36
  "subagent-orchestration-instructions",
37
37
  "subagent-slash-result",
38
+ "subagent-slash-text-result",
38
39
  "subagent-notify",
39
40
  "subagent_control_notice",
40
41
  "subagent-control",
@@ -923,6 +923,7 @@ export const CHAIN_RUNS_DIR = path.join(TEMP_ROOT_DIR, "chain-runs");
923
923
  export const TEMP_ARTIFACTS_DIR = path.join(TEMP_ROOT_DIR, "artifacts");
924
924
  export const WIDGET_KEY = "subagent-async";
925
925
  export const SLASH_RESULT_TYPE = "subagent-slash-result";
926
+ export const SLASH_TEXT_RESULT_TYPE = "subagent-slash-text-result";
926
927
  export const SLASH_SUBAGENT_REQUEST_EVENT = "subagent:slash:request";
927
928
  export const SLASH_SUBAGENT_STARTED_EVENT = "subagent:slash:started";
928
929
  export const SLASH_SUBAGENT_RESPONSE_EVENT = "subagent:slash:response";