pi-async 0.1.0 → 0.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/src/core/spawn.ts CHANGED
@@ -1,28 +1,78 @@
1
- import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
+ import { buildChildEnv, INTERNAL_MAX_OUTPUT_ENV, type EnvPolicy } from "./env.ts";
5
6
  import type { TaskSnapshot } from "./task.ts";
6
7
 
8
+ export type { EnvPolicy };
9
+
7
10
  export interface SpawnHandle { pid: number; exit: Promise<{ code: number }>; }
8
11
 
9
- /** Start a shell command in its own POSIX process group and capture merged output. */
10
- export function spawnDetached(command: string, opts: { outputFile: string; env?: Record<string, string> }): SpawnHandle {
12
+ export interface SpawnOptions {
13
+ outputFile: string;
14
+ env?: Record<string, string>;
15
+ cwd?: string;
16
+ envPolicy?: EnvPolicy;
17
+ }
18
+
19
+ /** Derive per-stream sidecar paths next to the merged `.output` file. */
20
+ export function streamPaths(outputFile: string): { stdout: string; stderr: string } {
21
+ if (outputFile.endsWith(".output")) {
22
+ const base = outputFile.slice(0, -".output".length);
23
+ return { stdout: `${base}.stdout`, stderr: `${base}.stderr` };
24
+ }
25
+ return { stdout: `${outputFile}.stdout`, stderr: `${outputFile}.stderr` };
26
+ }
27
+
28
+ /** Resolve the inheritance policy; an unknown `PI_ASYNC_ENV_INHERIT` falls back to `safe` (D-10). */
29
+ export function resolveEnvPolicy(explicit?: EnvPolicy): EnvPolicy {
30
+ if (explicit) return explicit;
31
+ const inherited = process.env.PI_ASYNC_ENV_INHERIT;
32
+ if (inherited === "all") return "all";
33
+ if (inherited === "none") return "none";
34
+ return "safe";
35
+ }
36
+
37
+ /** Fail loudly before forking when the requested cwd cannot be used. */
38
+ function assertUsableCwd(cwd: string | undefined): void {
39
+ if (cwd === undefined) return;
40
+ let stats: ReturnType<typeof statSync>;
41
+ try {
42
+ stats = statSync(cwd);
43
+ } catch {
44
+ throw new Error(`Working directory does not exist: ${cwd}`);
45
+ }
46
+ if (!stats.isDirectory()) throw new Error(`Working directory is not a directory: ${cwd}`);
47
+ }
48
+
49
+ /** Start a shell command in its own POSIX process group; capture merged + separate streams. */
50
+ export function spawnDetached(command: string, opts: SpawnOptions): SpawnHandle {
51
+ assertUsableCwd(opts.cwd);
11
52
  mkdirSync(dirname(opts.outputFile), { recursive: true });
12
- const capBytes = finiteNumber(opts.env?.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES ?? process.env.PI_ASYNC_MAX_OUTPUT_BYTES, 20 * 1024 * 1024);
13
- const env = { ...process.env, ...opts.env };
14
- delete env.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES;
15
- const child = spawn("/bin/sh", ["-c", command], { detached: true, stdio: ["ignore", "pipe", "pipe"], env });
16
- if (!child.pid) throw new Error("Failed to start detached task process");
17
- const output = createWriteStream(opts.outputFile, { flags: "w", mode: 0o600 });
53
+ const { stdout: stdoutFile, stderr: stderrFile } = streamPaths(opts.outputFile);
54
+ const capBytes = finiteNumber(opts.env?.[INTERNAL_MAX_OUTPUT_ENV] ?? process.env.PI_ASYNC_MAX_OUTPUT_BYTES, 20 * 1024 * 1024);
55
+ const env = buildChildEnv(process.env, opts.env, resolveEnvPolicy(opts.envPolicy));
56
+ // Always pass cwd explicitly: the host process cwd is not the session cwd (REV-13).
57
+ const child = spawn("/bin/sh", ["-c", command], { detached: true, stdio: ["ignore", "pipe", "pipe"], env, cwd: opts.cwd });
58
+ // Attach a no-op error listener before any throw so a failed spawn can never surface as an
59
+ // unhandled 'error' event (which would crash the host); the exit promise re-observes it.
60
+ child.on("error", () => undefined);
61
+ if (!child.pid) throw new Error(`Failed to start detached task process${opts.cwd ? ` in ${opts.cwd}` : ""}`);
62
+ const merged = createWriteStream(opts.outputFile, { flags: "w", mode: 0o600 });
63
+ const stdoutOut = createWriteStream(stdoutFile, { flags: "w", mode: 0o600 });
64
+ const stderrOut = createWriteStream(stderrFile, { flags: "w", mode: 0o600 });
65
+ // Cleanup may unlink the data dir while pipes still flush; ignore late write errors.
66
+ for (const stream of [merged, stdoutOut, stderrOut]) stream.on("error", () => undefined);
18
67
  let totalBytes = 0;
19
68
  let capReached = false;
20
- const copy = (chunk: Buffer): void => {
69
+ const copy = (chunk: Buffer, stream: "stdout" | "stderr"): void => {
21
70
  if (capReached) return;
22
71
  const remaining = Math.max(0, capBytes - totalBytes);
23
72
  const accepted = chunk.subarray(0, remaining);
24
73
  if (accepted.length) {
25
- output.write(accepted);
74
+ merged.write(accepted);
75
+ (stream === "stdout" ? stdoutOut : stderrOut).write(accepted);
26
76
  totalBytes += accepted.length;
27
77
  }
28
78
  if (accepted.length < chunk.length) {
@@ -35,12 +85,19 @@ export function spawnDetached(command: string, opts: { outputFile: string; env?:
35
85
  });
36
86
  }
37
87
  };
38
- child.stdout?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk)));
39
- child.stderr?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk)));
88
+ child.stdout?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk), "stdout"));
89
+ child.stderr?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk), "stderr"));
40
90
  const exit = new Promise<{ code: number }>((resolve, reject) => {
41
91
  child.once("error", reject);
42
92
  child.once("close", (code) => {
43
- output.end(() => resolve({ code: code ?? 1 }));
93
+ let pending = 3;
94
+ const done = (): void => {
95
+ pending--;
96
+ if (pending === 0) resolve({ code: code ?? 1 });
97
+ };
98
+ merged.end(done);
99
+ stdoutOut.end(done);
100
+ stderrOut.end(done);
44
101
  });
45
102
  });
46
103
  return { pid: child.pid, exit };
@@ -51,7 +108,6 @@ function finiteNumber(value: string | undefined, fallback: number): number {
51
108
  return Number.isFinite(parsed) ? parsed : fallback;
52
109
  }
53
110
 
54
- /** Merge the cap marker into the task snapshot with the same atomic-rename strategy as the registry. */
55
111
  function persistOutputCap(outputFile: string): void {
56
112
  const metadataFile = outputFile.endsWith(".output") ? `${outputFile.slice(0, -7)}.json` : `${outputFile}.json`;
57
113
  try {
@@ -75,17 +131,37 @@ function groupExists(pid: number): boolean {
75
131
  }
76
132
  }
77
133
 
134
+ /** True only if the process group has a non-zombie member (#6). */
135
+ function groupHasLiveMember(pgid: number): boolean {
136
+ try {
137
+ for (const entry of readdirSync("/proc")) {
138
+ if (!/^\d+$/.test(entry)) continue;
139
+ try {
140
+ const stat = readFileSync(`/proc/${entry}/stat`, "utf8");
141
+ const closeParen = stat.lastIndexOf(") ");
142
+ if (closeParen < 0) continue;
143
+ const rest = stat.slice(closeParen + 2).split(" ");
144
+ const state = rest[0];
145
+ const pgrp = Number(rest[2]);
146
+ if (pgrp === pgid && state !== "Z") return true;
147
+ } catch { /* raced */ }
148
+ }
149
+ return false;
150
+ } catch {
151
+ return groupExists(pgid);
152
+ }
153
+ }
154
+
78
155
  async function waitForGroupExit(pid: number, timeoutMs: number): Promise<boolean> {
79
156
  const deadline = Date.now() + timeoutMs;
80
- while (groupExists(pid) && Date.now() < deadline) await delay(Math.min(100, Math.max(1, deadline - Date.now())));
81
- return !groupExists(pid);
157
+ while (groupHasLiveMember(pid) && Date.now() < deadline) await delay(Math.min(100, Math.max(1, deadline - Date.now())));
158
+ return !groupHasLiveMember(pid);
82
159
  }
83
160
 
84
- /** Terminate the detached process group, escalating from TERM to KILL. */
85
161
  export async function killTree(pid: number, opts?: { graceMs?: number }): Promise<{ confirmed: boolean }> {
86
162
  const graceMs = opts?.graceMs ?? 3000;
87
163
  if (!Number.isInteger(pid) || pid <= 1) throw new RangeError("pid must identify a child process");
88
- if (!groupExists(pid)) return { confirmed: true };
164
+ if (!groupHasLiveMember(pid)) return { confirmed: true };
89
165
  try { process.kill(-pid, "SIGTERM"); } catch (error) {
90
166
  if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
91
167
  }
@@ -93,6 +169,5 @@ export async function killTree(pid: number, opts?: { graceMs?: number }): Promis
93
169
  try { process.kill(-pid, "SIGKILL"); } catch (error) {
94
170
  if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
95
171
  }
96
- // Bounded probe: do not hang shutdown forever if the kernel cannot prove death.
97
172
  return { confirmed: await waitForGroupExit(pid, 1500) };
98
173
  }
package/src/core/task.ts CHANGED
@@ -1,19 +1,32 @@
1
- export type TaskStatus = "pending" | "running" | "completed" | "failed" | "killed";
1
+ export const TASK_STATUSES = ["pending", "running", "completed", "failed", "killed"] as const;
2
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
2
3
  export type TaskMode = "background" | "sync" | "delayed";
3
4
  export type KillKind = "user" | "timeout" | "output_cap" | "shutdown" | "watch_failure";
5
+ export type FailureKind = "lost" | "blind_exhausted" | "watch_timeout";
4
6
 
5
7
  export const TERMINAL_STATUSES = ["completed", "failed", "killed"] as const satisfies readonly TaskStatus[];
6
8
 
9
+ export function isTaskStatus(value: unknown): value is TaskStatus {
10
+ return typeof value === "string" && (TASK_STATUSES as readonly string[]).includes(value);
11
+ }
12
+
7
13
  export function isTerminal(status: TaskStatus): boolean {
8
14
  return TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]);
9
15
  }
10
16
 
11
- /** Derive a concise, single-line display name from a shell command. */
17
+ /** Cap a task name at `limit` Unicode code points without splitting surrogate pairs (#28). */
18
+ export function capName(name: string, limit = 48): string {
19
+ const characters = Array.from(name);
20
+ return characters.length <= limit ? name : characters.slice(0, limit - 1).join("") + "…";
21
+ }
22
+
23
+ /** Derive a concise, single-line display name from a shell command.
24
+ * Uses the first non-blank line so a leading newline does not collapse to the fallback (#23). */
12
25
  export function deriveName(command: string): string {
13
- const normalized = command.split(/\r?\n/, 1)[0]?.trim().replace(/\s+/g, " ") ?? "";
26
+ const firstContentLine = command.split(/\r?\n/).find((line) => line.trim().length > 0);
27
+ const normalized = firstContentLine?.trim().replace(/\s+/g, " ") ?? "";
14
28
  if (normalized.length === 0) return "async task";
15
- const characters = Array.from(normalized);
16
- return characters.length <= 48 ? normalized : characters.slice(0, 47).join("") + "…";
29
+ return capName(normalized);
17
30
  }
18
31
 
19
32
  export type Condition =
@@ -43,6 +56,8 @@ export interface RunOptions {
43
56
  triggerOnCompletion?: boolean;
44
57
  env?: Record<string, string>;
45
58
  watch?: WatchSpec;
59
+ /** 0.3.0: directory the task runs in; defaults to the registry/session cwd, then process.cwd(). */
60
+ cwd?: string;
46
61
  }
47
62
 
48
63
  export interface RunReceipt {
@@ -59,9 +74,13 @@ export interface SyncResult extends RunReceipt {
59
74
  promoted: boolean;
60
75
  }
61
76
 
77
+ export type WaitResultItem = { taskId: string; status: TaskStatus; exitCode?: number; outputTail: string; killKind?: KillKind; failureKind?: FailureKind };
78
+ export type WaitNotReadyItem = { taskId: string; status: "running" | "pending"; elapsedMs: number };
79
+ /** All-terminal → { results }; timeout-only → { notReady }; mixed timeout → { results, notReady } (REV-7 / #43 + #37 kinds). */
62
80
  export type WaitOutcome =
63
- | { results: Array<{ taskId: string; status: TaskStatus; exitCode?: number; outputTail: string }> }
64
- | { notReady: Array<{ taskId: string; status: "running" | "pending"; elapsedMs: number }> };
81
+ | { results: WaitResultItem[] }
82
+ | { notReady: WaitNotReadyItem[] }
83
+ | { results: WaitResultItem[]; notReady: WaitNotReadyItem[] };
65
84
 
66
85
  export interface TaskSnapshot {
67
86
  taskId: string;
@@ -72,7 +91,7 @@ export interface TaskSnapshot {
72
91
  pid?: number;
73
92
  exitCode?: number;
74
93
  killKind?: KillKind;
75
- failureKind?: "lost";
94
+ failureKind?: FailureKind;
76
95
  createdAt: number;
77
96
  startedAt?: number;
78
97
  finishedAt?: number;
@@ -82,4 +101,15 @@ export interface TaskSnapshot {
82
101
  outputConsumed: boolean;
83
102
  watch?: WatchSpec;
84
103
  metadata: Record<string, unknown>;
104
+ // 0.3.0 additions (all optional so pre-0.3.0 snapshots stay readable).
105
+ /** Working directory the task executes in (REV-13). */
106
+ cwd?: string;
107
+ /** Absolute deadline for `taskTimeoutMs` (reload re-arms the remaining time). */
108
+ timeoutAt?: number;
109
+ /** Absolute deadline for the watch window (reload restores the remaining budget). */
110
+ watchDeadline?: number;
111
+ /** Number of completion-notification attempts so far (honesty accounting, no retries). */
112
+ notifyAttempts?: number;
113
+ /** Process liveness seen when the snapshot was produced (false once finalized). */
114
+ processAlive?: boolean;
85
115
  }
package/src/core/watch.ts CHANGED
@@ -2,6 +2,35 @@ import type { Condition, WatchSpec } from "./task.ts";
2
2
 
3
3
  const compiledRegexes = new WeakMap<Condition, RegExp>();
4
4
 
5
+ /** Captured streams for watch evaluation: stdout/stderr are distinct; output is merged. */
6
+ export interface WatchOutput {
7
+ output: string;
8
+ stdout: string;
9
+ stderr: string;
10
+ }
11
+
12
+ /** Cap regex subject size so catastrophic patterns cannot grow without bound (#24). */
13
+ export const MAX_OUTPUT_MATCH_BYTES = 64 * 1024;
14
+
15
+ /**
16
+ * Classic exponential-backtracking shape: a group that itself ends with a
17
+ * quantifier, then another quantifier — e.g. (a+)+ / (a*)* / (a|b+)+.
18
+ * Conservative; does not catch every polynomial case.
19
+ */
20
+ const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[+*]\)\s*[+*]/;
21
+
22
+ function rejectDangerousRegex(regex: string): string | undefined {
23
+ if (NESTED_QUANTIFIER.test(regex)) {
24
+ return "regex has nested quantifiers (ReDoS risk); simplify or use stdout_contains";
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ function matchSubject(output: string): string {
30
+ if (output.length <= MAX_OUTPUT_MATCH_BYTES) return output;
31
+ return output.slice(-MAX_OUTPUT_MATCH_BYTES);
32
+ }
33
+
5
34
  export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false; error: string } {
6
35
  const invalid = (error: string) => ({ ok: false as const, error });
7
36
  if (!spec || typeof spec !== "object") return invalid("watch must be an object");
@@ -38,13 +67,18 @@ export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false;
38
67
  break;
39
68
  case "json_path_exists":
40
69
  if (typeof condition.path !== "string" || parseJsonPath(condition.path) === undefined) {
41
- return invalid(`${location}: invalid JSON path`);
70
+ const shown = typeof condition.path === "string" ? JSON.stringify(condition.path) : String(condition.path);
71
+ return invalid(`${location}: invalid JSON path ${shown}: paths must start with "$" (e.g. $.ready or $.items[0].state)`);
42
72
  }
43
73
  break;
44
74
  case "output_matches":
45
75
  if (typeof condition.regex !== "string") return invalid(`${location}: regex must be a string`);
76
+ {
77
+ const danger = rejectDangerousRegex(condition.regex);
78
+ if (danger) return invalid(`${location}: ${danger}`);
79
+ }
46
80
  try {
47
- compiledRegexes.set(condition, new RegExp(condition.regex));
81
+ compiledRegexes.set(condition, new RegExp(condition.regex, "m"));
48
82
  } catch {
49
83
  return invalid(`${location}: invalid regex`);
50
84
  }
@@ -57,7 +91,9 @@ export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false;
57
91
  return { ok: true };
58
92
  }
59
93
 
60
- export function evalConditions(conds: Condition[], ctx: { output: string; exitCode?: number }): boolean {
94
+ export type WatchEvalContext = WatchOutput & { exitCode?: number };
95
+
96
+ export function evalConditions(conds: Condition[], ctx: WatchEvalContext): boolean {
61
97
  const result = evaluateConditionsDetailed(conds, ctx);
62
98
  return result.status === "matched" && result.matched;
63
99
  }
@@ -65,7 +101,7 @@ export function evalConditions(conds: Condition[], ctx: { output: string; exitCo
65
101
  export interface WatchLoopDeps {
66
102
  taskId: string;
67
103
  spec: WatchSpec;
68
- readOutput: () => string;
104
+ readOutput: () => WatchOutput;
69
105
  getExitCode: () => number | undefined;
70
106
  onSuccess: (detail: string) => void;
71
107
  onFailure: () => void;
@@ -111,17 +147,17 @@ export class WatchLoop {
111
147
  const conditions = [...this.deps.spec.failure_when, ...this.deps.spec.success_when];
112
148
  const needsOutput = conditions.some((condition) => condition.type !== "exit_code");
113
149
  const needsExitCode = conditions.some((condition) => condition.type === "exit_code");
114
- let output = "";
150
+ let streams: WatchOutput = { output: "", stdout: "", stderr: "" };
115
151
  let exitCode: number | undefined;
116
152
  try {
117
- if (needsOutput) output = this.deps.readOutput();
153
+ if (needsOutput) streams = this.deps.readOutput();
118
154
  if (needsExitCode) exitCode = this.deps.getExitCode();
119
155
  } catch {
120
156
  this.countBlindCheck();
121
157
  return;
122
158
  }
123
159
 
124
- const ctx = { output, exitCode };
160
+ const ctx: WatchEvalContext = { ...streams, exitCode };
125
161
  const failure = evaluateConditionsDetailed(this.deps.spec.failure_when, ctx);
126
162
  if (failure.status === "matched" && failure.matched) {
127
163
  this.finish(this.deps.onFailure);
@@ -178,7 +214,7 @@ type ConditionEvaluation =
178
214
  | { status: "pending" }
179
215
  | { status: "error" };
180
216
 
181
- function evaluateConditionsDetailed(conditions: Condition[], ctx: { output: string; exitCode?: number }): ConditionEvaluation {
217
+ function evaluateConditionsDetailed(conditions: Condition[], ctx: WatchEvalContext): ConditionEvaluation {
182
218
  let pending = false;
183
219
  let error = false;
184
220
  for (const condition of conditions) {
@@ -192,21 +228,21 @@ function evaluateConditionsDetailed(conditions: Condition[], ctx: { output: stri
192
228
  return { status: "matched", matched: false };
193
229
  }
194
230
 
195
- function evaluateCondition(condition: Condition, ctx: { output: string; exitCode?: number }): ConditionEvaluation {
231
+ function evaluateCondition(condition: Condition, ctx: WatchEvalContext): ConditionEvaluation {
196
232
  switch (condition.type) {
197
233
  case "exit_code":
198
234
  return ctx.exitCode === undefined
199
235
  ? { status: "pending" }
200
236
  : { status: "matched", matched: ctx.exitCode === condition.equals };
201
237
  case "stdout_contains":
238
+ return { status: "matched", matched: ctx.stdout.includes(condition.value) };
202
239
  case "stderr_contains":
203
- // The frozen WatchLoop context exposes the captured merged output stream.
204
- return { status: "matched", matched: ctx.output.includes(condition.value) };
240
+ return { status: "matched", matched: ctx.stderr.includes(condition.value) };
205
241
  case "output_matches": {
206
242
  let regex = compiledRegexes.get(condition);
207
243
  if (!regex) {
208
244
  try {
209
- regex = new RegExp(condition.regex);
245
+ regex = new RegExp(condition.regex, "m");
210
246
  compiledRegexes.set(condition, regex);
211
247
  } catch {
212
248
  return { status: "error" };
@@ -214,7 +250,7 @@ function evaluateCondition(condition: Condition, ctx: { output: string; exitCode
214
250
  }
215
251
  // Global/sticky expressions otherwise retain lastIndex between watch polls.
216
252
  regex.lastIndex = 0;
217
- return { status: "matched", matched: regex.test(ctx.output) };
253
+ return { status: "matched", matched: regex.test(matchSubject(ctx.output)) };
218
254
  }
219
255
  case "json_path_exists": {
220
256
  let root: unknown;
@@ -1,78 +1,184 @@
1
1
  import type { PiCtx } from "../core/notify.ts";
2
2
  import type { TaskRegistry } from "../core/registry.ts";
3
+ import type { TaskSnapshot } from "../core/task.ts";
3
4
 
5
+ /** `custom_message` draft pi accepts from `agent_before_settle` (PLAN §2.6). */
6
+ interface CustomMessageDraft {
7
+ type: "custom_message";
8
+ customType: string;
9
+ content: string;
10
+ display: false;
11
+ }
12
+
13
+ /** The slice of pi's `AgentBeforeSettleEvent` this guard reads; `entries` may be absent. */
14
+ interface BeforeSettleEvent {
15
+ entries?: unknown[];
16
+ }
17
+
18
+ /** The slice of pi's `AgentSettledEvent` this guard reads. */
19
+ interface SettledEvent {
20
+ aborted?: boolean;
21
+ }
22
+
23
+ /** `agent_before_settle` result: appended drafts plus an optional extra provider request. */
24
+ interface BeforeSettleResult {
25
+ entries: unknown[];
26
+ continue?: boolean;
27
+ }
28
+
29
+ type HostHooks = {
30
+ on?: (event: string, handler: (...args: unknown[]) => unknown) => unknown;
31
+ off?: (event: string, handler: (...args: unknown[]) => unknown) => void;
32
+ };
33
+
34
+ type MessageHost = {
35
+ sendMessage?: (message: unknown, options: unknown) => unknown;
36
+ };
37
+
38
+ const idsOf = (active: TaskSnapshot[]): string => active.map((task) => task.taskId).join(", ");
39
+
40
+ /**
41
+ * Keeps a legion child from reporting a verdict while async tasks are still in flight.
42
+ *
43
+ * 0.3.0 (D-12) removed the old `setInterval` heartbeat: it only ever wrote
44
+ * `async-task-heartbeat` entries nobody read, and because timer callbacks run outside pi's
45
+ * event-loop `try/catch` a stale ctx there escaped as an `uncaughtException` (crash
46
+ * 2026-10-21, `Timeout._onTimeout` at settle-guard heartbeat). Reminders are now produced
47
+ * inside the `agent_before_settle` boundary, where pi tolerates (and contains) extension
48
+ * errors, and `agent_settled` (`aborted` runs) sends at most one follow-up per generation.
49
+ */
4
50
  export class SettleGuard {
5
51
  private readonly maxReminders: number;
6
- private readonly heartbeatMs: number;
7
52
  private reminders = 0;
8
- private heartbeat?: ReturnType<typeof setInterval>;
53
+ private settledSent = false;
9
54
  private removeHooks: Array<() => void> = [];
55
+ private disposed = false;
10
56
  private readonly pi: PiCtx;
11
57
  private readonly registry: TaskRegistry;
12
58
 
13
- constructor(pi: PiCtx, registry: TaskRegistry, opts?: { maxReminders?: number; heartbeatMs?: number }) {
59
+ constructor(pi: PiCtx, registry: TaskRegistry, opts?: { maxReminders?: number }) {
14
60
  this.pi = pi;
15
61
  this.registry = registry;
16
62
  this.maxReminders = opts?.maxReminders ?? 3;
17
- this.heartbeatMs = opts?.heartbeatMs ?? finiteEnvNumber("PI_ASYNC_HEARTBEAT_MS", 60_000);
18
63
  }
19
64
 
20
65
  register(): void {
21
- // SAFETY: PiCtx hooks vary by runtime; these optional operations are only invoked after presence checks.
22
- const hooks = this.pi as unknown as {
23
- on?: (event: string, handler: (...args: unknown[]) => unknown) => void;
24
- off?: (event: string, handler: (...args: unknown[]) => unknown) => void;
25
- appendEntry?: (customType: string, data: unknown) => void;
66
+ // D-12: one env switch disarms the guard entirely — it registers nothing at all.
67
+ if (process.env.PI_ASYNC_SETTLE_GUARD === "0") return;
68
+ if (this.disposed) return;
69
+ // SAFETY: PiCtx hooks vary by runtime; `on` returns the unsubscribe function (the real
70
+ // host has no `pi.off`), so both shapes are feature-detected before use.
71
+ const hooks = this.pi as unknown as HostHooks;
72
+ const unsubscribers: Array<() => void> = [];
73
+ const listen = (event: string, handler: (...args: unknown[]) => unknown): void => {
74
+ const result = hooks.on?.(event, handler);
75
+ // SAFETY: pi.on() returns an unsubscribe function; older/mocked hosts may return void, in
76
+ // which case there is nothing to release and the guard relies on `off`/`disposed` alone.
77
+ if (typeof result === "function") unsubscribers.push(result as () => void);
78
+ else unsubscribers.push(() => hooks.off?.(event, handler));
26
79
  };
27
- const beforeSettle = () => {
80
+
81
+ const beforeSettle = (event?: unknown): BeforeSettleResult | undefined => {
82
+ if (this.disposed) return undefined;
28
83
  const active = this.registry.list({ includeHistory: false });
29
- if (active.length === 0) { this.reminders = 0; this.reportActivity(); return; }
30
84
  this.reportActivity();
85
+ // A stale ctx made reportActivity tear us down; the generation is gone, so stay silent.
86
+ if (this.disposed) return undefined;
87
+ if (active.length === 0) {
88
+ this.reminders = 0;
89
+ return undefined;
90
+ }
91
+ // Any drafts pi already queued for this boundary are preserved in front of ours.
92
+ const entries = [...((event as BeforeSettleEvent | undefined)?.entries ?? [])];
93
+ const ids = idsOf(active);
31
94
  if (this.reminders >= this.maxReminders) {
32
- hooks.appendEntry?.("async-task-unresolved", { message: `${active.length} async task(s) remain unresolved: ${active.map((task) => task.taskId).join(", ")}` });
33
- return;
95
+ // Budget spent: record the unresolved list for the transcript, then let the run settle
96
+ // (`continue` omitted so pi does not force another provider request).
97
+ const unresolved: CustomMessageDraft = {
98
+ type: "custom_message",
99
+ customType: "async-task-unresolved",
100
+ content: `${active.length} async task(s) left unresolved: ${ids}. Collect them with async_wait or async_status before finishing.`,
101
+ display: false,
102
+ };
103
+ return { entries: [...entries, unresolved] };
34
104
  }
35
105
  this.reminders++;
36
- hooks.appendEntry?.("async-task-settle-reminder", { message: `${active.length} async task(s) not collected (${active.map((task) => task.taskId).join(", ")}); use async_wait/async_status before reporting verdict.` });
37
- return { continue: true };
106
+ const reminder: CustomMessageDraft = {
107
+ type: "custom_message",
108
+ customType: "async-task-settle-reminder",
109
+ content: `${active.length} async task(s) not collected yet: ${ids}. Call async_wait or async_status before reporting a verdict.`,
110
+ display: false,
111
+ };
112
+ return { entries: [...entries, reminder], continue: true };
38
113
  };
39
- const beforeRun = () => { this.reminders = 0; };
114
+
115
+ const agentStart = (): void => {
116
+ this.reminders = 0;
117
+ this.settledSent = false;
118
+ };
119
+
120
+ const agentSettled = (event?: unknown): void => {
121
+ if (this.disposed || this.settledSent) return;
122
+ if ((event as SettledEvent | undefined)?.aborted !== true) return;
123
+ if (!this.registry.hasActiveWork()) return;
124
+ // D-13: at most one follow-up per generation; latch before the host call so a throw
125
+ // cannot turn into a retry storm.
126
+ this.settledSent = true;
127
+ let ids = "";
128
+ try {
129
+ ids = idsOf(this.registry.list({ includeHistory: false }));
130
+ } catch { /* best effort: the snapshot is decoration for the message body */ }
131
+ const content = `This run was aborted with async task(s) still running${ids ? `: ${ids}` : ""}. Use async_wait or async_status to collect them.`;
132
+ try {
133
+ // SAFETY: sendMessage is part of ExtensionAPI; feature-detected because mocks and older
134
+ // hosts may omit it, and a stale ctx getter throws on access.
135
+ const host = this.pi as unknown as MessageHost;
136
+ const send = host.sendMessage;
137
+ if (typeof send !== "function") return;
138
+ const result = send.call(host, { customType: "async-task-unresolved", content }, { deliverAs: "followUp" });
139
+ // SAFETY: hosts return void; a mock/future host may return a promise, which must never
140
+ // surface as an unhandled rejection.
141
+ if (result && typeof (result as PromiseLike<unknown>).then === "function") {
142
+ void Promise.resolve(result as PromiseLike<unknown>).catch(() => undefined);
143
+ }
144
+ } catch { /* best effort: sending is advisory, never fatal to settle */ }
145
+ };
146
+
40
147
  // SAFETY: PI_LEGION_NODE_ID is the documented legion-child marker; presence opts into settle reminders.
41
148
  const legionChild = process.env.PI_LEGION_NODE_ID !== undefined;
42
- if (legionChild) hooks.on?.("agent_before_settle", beforeSettle);
43
- hooks.on?.("agent_start", beforeRun);
44
- const cleanup = () => {
45
- if (this.heartbeat) clearInterval(this.heartbeat);
46
- if (legionChild) hooks.off?.("agent_before_settle", beforeSettle);
47
- hooks.off?.("agent_start", beforeRun);
48
- this.removeHooks = [];
49
- };
50
- hooks.on?.("session_shutdown", () => { void cleanup(); });
51
- this.removeHooks.push(cleanup);
52
- this.startHeartbeat(hooks);
149
+ if (legionChild) listen("agent_before_settle", beforeSettle);
150
+ listen("agent_start", agentStart);
151
+ // Thread 2 of the guard: registered for every session (not only legion children).
152
+ listen("agent_settled", agentSettled);
153
+ listen("session_shutdown", () => { this.dispose(); });
154
+ this.removeHooks.push(() => {
155
+ for (const unsubscribe of unsubscribers.splice(0)) {
156
+ try { unsubscribe(); } catch { /* best effort: the host ctx may already be stale */ }
157
+ }
158
+ });
53
159
  this.reportActivity();
54
160
  }
55
161
 
56
- private reportActivity(): void {
57
- // SAFETY: setActivity is feature-detected because older Pi hosts do not implement the optional API.
58
- const ui = (this.pi as unknown as { ui?: { setActivity?: (id: string, value: { message: string } | undefined) => void } }).ui;
59
- if (typeof ui?.setActivity !== "function") return;
60
- const count = this.registry.activeCount();
61
- const total = count.running + count.pending;
62
- ui.setActivity("pi-async", total ? { message: `${total} async tasks` } : undefined);
162
+ /** Idempotent teardown; a second call is a no-op. */
163
+ dispose(): void {
164
+ this.disposed = true;
165
+ for (const remove of this.removeHooks.splice(0)) {
166
+ try { remove(); } catch { /* best effort: the host ctx may already be stale */ }
167
+ }
63
168
  }
64
169
 
65
- private startHeartbeat(hooks: { appendEntry?: (customType: string, data: unknown) => void }): void {
66
- if (this.heartbeat || this.heartbeatMs <= 0) return;
67
- this.heartbeat = setInterval(() => {
68
- if (!this.registry.hasActiveWork()) return;
69
- hooks.appendEntry?.("async-task-heartbeat", { timestamp: Date.now(), active: this.registry.activeCount() });
70
- }, this.heartbeatMs);
71
- this.heartbeat.unref?.();
170
+ private reportActivity(): void {
171
+ if (this.disposed) return;
172
+ try {
173
+ // SAFETY: `pi.ui` itself throws on a stale ctx, so the read is guarded with the call.
174
+ const ui = (this.pi as unknown as { ui?: { setActivity?: (id: string, value: { message: string } | undefined) => void } }).ui;
175
+ if (typeof ui?.setActivity !== "function") return;
176
+ const count = this.registry.activeCount();
177
+ const total = count.running + count.pending;
178
+ ui.setActivity("pi-async", total ? { message: `${total} async task${total === 1 ? "" : "s"}` } : undefined);
179
+ } catch {
180
+ // The host abandoned this generation; detach instead of retrying into a dead API.
181
+ this.dispose();
182
+ }
72
183
  }
73
184
  }
74
-
75
- function finiteEnvNumber(name: string, fallback: number): number {
76
- const parsed = Number(process.env[name] ?? fallback);
77
- return Number.isFinite(parsed) ? parsed : fallback;
78
- }