pi-async 0.2.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,29 +1,64 @@
1
- import { createWriteStream, existsSync, mkdirSync, readdirSync, 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
 
12
+ export interface SpawnOptions {
13
+ outputFile: string;
14
+ env?: Record<string, string>;
15
+ cwd?: string;
16
+ envPolicy?: EnvPolicy;
17
+ }
18
+
9
19
  /** Derive per-stream sidecar paths next to the merged `.output` file. */
10
- export function streamPaths(outputFile: string): { stdoutFile: string; stderrFile: string } {
20
+ export function streamPaths(outputFile: string): { stdout: string; stderr: string } {
11
21
  if (outputFile.endsWith(".output")) {
12
22
  const base = outputFile.slice(0, -".output".length);
13
- return { stdoutFile: `${base}.stdout`, stderrFile: `${base}.stderr` };
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}`);
14
45
  }
15
- return { stdoutFile: `${outputFile}.stdout`, stderrFile: `${outputFile}.stderr` };
46
+ if (!stats.isDirectory()) throw new Error(`Working directory is not a directory: ${cwd}`);
16
47
  }
17
48
 
18
49
  /** Start a shell command in its own POSIX process group; capture merged + separate streams. */
19
- export function spawnDetached(command: string, opts: { outputFile: string; env?: Record<string, string> }): SpawnHandle {
50
+ export function spawnDetached(command: string, opts: SpawnOptions): SpawnHandle {
51
+ assertUsableCwd(opts.cwd);
20
52
  mkdirSync(dirname(opts.outputFile), { recursive: true });
21
- const { stdoutFile, stderrFile } = streamPaths(opts.outputFile);
22
- const capBytes = finiteNumber(opts.env?.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES ?? process.env.PI_ASYNC_MAX_OUTPUT_BYTES, 20 * 1024 * 1024);
23
- const env = { ...process.env, ...opts.env };
24
- delete env.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES;
25
- const child = spawn("/bin/sh", ["-c", command], { detached: true, stdio: ["ignore", "pipe", "pipe"], env });
26
- if (!child.pid) throw new Error("Failed to start detached task process");
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}` : ""}`);
27
62
  const merged = createWriteStream(opts.outputFile, { flags: "w", mode: 0o600 });
28
63
  const stdoutOut = createWriteStream(stdoutFile, { flags: "w", mode: 0o600 });
29
64
  const stderrOut = createWriteStream(stderrFile, { flags: "w", mode: 0o600 });
package/src/core/task.ts CHANGED
@@ -56,6 +56,8 @@ export interface RunOptions {
56
56
  triggerOnCompletion?: boolean;
57
57
  env?: Record<string, string>;
58
58
  watch?: WatchSpec;
59
+ /** 0.3.0: directory the task runs in; defaults to the registry/session cwd, then process.cwd(). */
60
+ cwd?: string;
59
61
  }
60
62
 
61
63
  export interface RunReceipt {
@@ -99,4 +101,15 @@ export interface TaskSnapshot {
99
101
  outputConsumed: boolean;
100
102
  watch?: WatchSpec;
101
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;
102
115
  }
@@ -1,75 +1,184 @@
1
1
  import type { PiCtx } from "../core/notify.ts";
2
2
  import type { TaskRegistry } from "../core/registry.ts";
3
- import { envNumber } from "../core/env.ts";
3
+ import type { TaskSnapshot } from "../core/task.ts";
4
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
+ */
5
50
  export class SettleGuard {
6
51
  private readonly maxReminders: number;
7
- private readonly heartbeatMs: number;
8
52
  private reminders = 0;
9
- private heartbeat?: ReturnType<typeof setInterval>;
53
+ private settledSent = false;
10
54
  private removeHooks: Array<() => void> = [];
55
+ private disposed = false;
11
56
  private readonly pi: PiCtx;
12
57
  private readonly registry: TaskRegistry;
13
58
 
14
- constructor(pi: PiCtx, registry: TaskRegistry, opts?: { maxReminders?: number; heartbeatMs?: number }) {
59
+ constructor(pi: PiCtx, registry: TaskRegistry, opts?: { maxReminders?: number }) {
15
60
  this.pi = pi;
16
61
  this.registry = registry;
17
62
  this.maxReminders = opts?.maxReminders ?? 3;
18
- this.heartbeatMs = opts?.heartbeatMs ?? envNumber("PI_ASYNC_HEARTBEAT_MS", 60_000, { integer: true, min: 0 });
19
63
  }
20
64
 
21
65
  register(): void {
22
- // SAFETY: PiCtx hooks vary by runtime; these optional operations are only invoked after presence checks.
23
- const hooks = this.pi as unknown as {
24
- on?: (event: string, handler: (...args: unknown[]) => unknown) => void;
25
- off?: (event: string, handler: (...args: unknown[]) => unknown) => void;
26
- 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));
27
79
  };
28
- const beforeSettle = () => {
80
+
81
+ const beforeSettle = (event?: unknown): BeforeSettleResult | undefined => {
82
+ if (this.disposed) return undefined;
29
83
  const active = this.registry.list({ includeHistory: false });
30
- if (active.length === 0) { this.reminders = 0; this.reportActivity(); return; }
31
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);
32
94
  if (this.reminders >= this.maxReminders) {
33
- hooks.appendEntry?.("async-task-unresolved", { message: `${active.length} async task(s) remain unresolved: ${active.map((task) => task.taskId).join(", ")}` });
34
- 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] };
35
104
  }
36
105
  this.reminders++;
37
- 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.` });
38
- 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 };
39
113
  };
40
- 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
+
41
147
  // SAFETY: PI_LEGION_NODE_ID is the documented legion-child marker; presence opts into settle reminders.
42
148
  const legionChild = process.env.PI_LEGION_NODE_ID !== undefined;
43
- if (legionChild) hooks.on?.("agent_before_settle", beforeSettle);
44
- hooks.on?.("agent_start", beforeRun);
45
- const cleanup = () => {
46
- if (this.heartbeat) clearInterval(this.heartbeat);
47
- if (legionChild) hooks.off?.("agent_before_settle", beforeSettle);
48
- hooks.off?.("agent_start", beforeRun);
49
- this.removeHooks = [];
50
- };
51
- hooks.on?.("session_shutdown", () => { void cleanup(); });
52
- this.removeHooks.push(cleanup);
53
- 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
+ });
54
159
  this.reportActivity();
55
160
  }
56
161
 
57
- private reportActivity(): void {
58
- // SAFETY: setActivity is feature-detected because older Pi hosts do not implement the optional API.
59
- const ui = (this.pi as unknown as { ui?: { setActivity?: (id: string, value: { message: string } | undefined) => void } }).ui;
60
- if (typeof ui?.setActivity !== "function") return;
61
- const count = this.registry.activeCount();
62
- const total = count.running + count.pending;
63
- ui.setActivity("pi-async", total ? { message: `${total} async task${total === 1 ? "" : "s"}` } : 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
+ }
64
168
  }
65
169
 
66
- private startHeartbeat(hooks: { appendEntry?: (customType: string, data: unknown) => void }): void {
67
- if (this.heartbeat || this.heartbeatMs <= 0) return;
68
- this.heartbeat = setInterval(() => {
69
- if (!this.registry.hasActiveWork()) return;
70
- hooks.appendEntry?.("async-task-heartbeat", { timestamp: Date.now(), active: this.registry.activeCount() });
71
- }, this.heartbeatMs);
72
- 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
+ }
73
183
  }
74
184
  }
75
-
@@ -1,6 +1,23 @@
1
1
  import type { PiCtx } from "../core/notify.ts";
2
2
  import type { TaskRegistry } from "../core/registry.ts";
3
- import { isTerminal, type WatchSpec } from "../core/task.ts";
3
+ import { isTaskStatus, isTerminal, TASK_STATUSES, type TaskSnapshot, type TaskStatus, type WatchSpec } from "../core/task.ts";
4
+
5
+ /**
6
+ * Structural view of pi's event-handler context (`ExtensionToolContext`).
7
+ *
8
+ * 0.3.0 deliberately does NOT import pi's `ExtensionToolContext` type: the
9
+ * package keeps its type baseline at pi 1.2.0, where the 5th `execute`
10
+ * argument existed but surface details moved between 1.2 and 1.5. Only `cwd`
11
+ * (session working directory) and `sessionManager` (data-dir resolution) are
12
+ * read, so a structural type is enough and stays compatible across hosts.
13
+ */
14
+ export interface ToolContext {
15
+ cwd?: string;
16
+ sessionManager?: {
17
+ getSessionDir?: () => string;
18
+ getSessionId?: () => string;
19
+ };
20
+ }
4
21
 
5
22
  /**
6
23
  * Wrap an extension-tool return value into pi's ToolResultMessage shape.
@@ -23,6 +40,26 @@ function toolResult(payload: unknown, isError = false): { content: Array<{ type:
23
40
 
24
41
  const promptGuidelines = "Check commands must not swallow errors with || true; map unknown states to failure; prefer structured output and jq -e; anchor output_matches regexes (compiled with multiline `m`: ^/$ are line boundaries) to avoid premature matches; prefer stdout_contains for simple cases and avoid nested quantifiers.";
25
42
  const MAX_LOG_BYTES = 50 * 1024;
43
+ const DEFAULT_MAX_SYNC_TIMEOUT_MS = 600_000;
44
+ const MAX_STATUS_LIMIT = 50;
45
+ const DEFAULT_STATUS_LIMIT = 50;
46
+
47
+ /** One-line snippet shown in the default system prompt's "Available tools" section (D-17). */
48
+ const ASYNC_RUN_PROMPT_SNIPPET = "Run a shell command in the background, with optional watch conditions and completion notification.";
49
+
50
+ /** Guidelines appended to the default system prompt while async_run is active (D-17). */
51
+ const ASYNC_RUN_PROMPT_GUIDELINES = [
52
+ "Use async_run for long-running commands (servers, watchers, builds over a minute) instead of a blocking bash call; short commands belong in bash.",
53
+ "Collect background work with async_wait, inspect it with async_logs, and use async_status to see what is still active before ending a turn.",
54
+ "Pass name so later async_wait/async_logs/async_kill calls can address the task by name instead of its opaque taskId.",
55
+ ];
56
+
57
+ /** `PI_ASYNC_MAX_SYNC_TIMEOUT_MS`, defaulting to 10 minutes when unset/invalid. */
58
+ function resolveMaxSyncTimeoutMs(): number {
59
+ const parsed = Number(process.env.PI_ASYNC_MAX_SYNC_TIMEOUT_MS);
60
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_MAX_SYNC_TIMEOUT_MS;
61
+ }
62
+
26
63
  const schema = (properties: Record<string, unknown>, required: string[] = [], allOf: unknown[] = []) => ({ type: "object", properties, required, additionalProperties: false, ...(allOf.length ? { allOf } : {}) });
27
64
  const stringField = { type: "string" };
28
65
  const timeoutField = { type: "number", minimum: 0 };
@@ -45,19 +82,78 @@ const watchSchema = {
45
82
  }, required: ["intervalMs", "timeoutMs", "success_when", "failure_when"], additionalProperties: false,
46
83
  };
47
84
 
48
- /** Register the frozen five-tool surface. */
49
- export function registerTools(pi: PiCtx, registry: TaskRegistry): void {
50
- type ToolDef = { name: string; label: string; description: string; parameters: unknown; execute: (toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown> };
85
+ /** Number of the clamped `async_status` limit: default 50, clamped to 1–50 (D-08). */
86
+ function clampStatusLimit(value: unknown): number {
87
+ const raw = Number(value);
88
+ if (!Number.isFinite(raw)) return DEFAULT_STATUS_LIMIT;
89
+ return Math.min(MAX_STATUS_LIMIT, Math.max(1, Math.floor(raw)));
90
+ }
91
+
92
+ /**
93
+ * `async_status` redaction (PLAN §2.7): `metadata.delayedEnv` values become
94
+ * `"***"` while keys survive. Returns a shallow copy — the registry's live
95
+ * snapshot must never be mutated by a read-only tool.
96
+ */
97
+ function redactSnapshot(task: TaskSnapshot): TaskSnapshot {
98
+ const metadata = { ...task.metadata };
99
+ const delayedEnv = metadata.delayedEnv;
100
+ if (delayedEnv && typeof delayedEnv === "object" && !Array.isArray(delayedEnv)) {
101
+ const redacted: Record<string, string> = {};
102
+ for (const key of Object.keys(delayedEnv as Record<string, unknown>)) redacted[key] = "***";
103
+ metadata.delayedEnv = redacted;
104
+ }
105
+ return { ...task, metadata };
106
+ }
107
+
108
+ /** Descending by `finishedAt ?? createdAt` (D-08 history ordering). */
109
+ function byRecency(a: TaskSnapshot, b: TaskSnapshot): number {
110
+ return (b.finishedAt ?? b.createdAt) - (a.finishedAt ?? a.createdAt);
111
+ }
112
+
113
+ /**
114
+ * Register the frozen five-tool surface.
115
+ *
116
+ * `ensureConfigured` lets the extension wire a lazy, once-only
117
+ * `registry.configure({dataDir, cwd})` before the first tool call — tools may
118
+ * run before `session_start` (e.g. an immediate tool call), and the registry
119
+ * then still needs its session cwd/dataDir (PLAN §2.8). Tools stay usable
120
+ * without it (tests, direct embedding).
121
+ */
122
+ export function registerTools(pi: PiCtx, registry: TaskRegistry, ensureConfigured?: (ctx?: ToolContext) => void): void {
123
+ type ToolDef = {
124
+ name: string;
125
+ label: string;
126
+ description: string;
127
+ promptSnippet?: string;
128
+ promptGuidelines?: string[];
129
+ parameters: unknown;
130
+ execute: (toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal, onUpdate?: unknown, ctx?: ToolContext) => Promise<unknown>;
131
+ };
51
132
  // SAFETY: ExtensionAPI exposes registerTool at runtime; this local structural view keeps schemas dependency-free.
52
133
  const api = pi as unknown as { registerTool?: (tool: ToolDef) => void };
53
134
  if (!api.registerTool) throw new Error("Pi host does not expose registerTool");
135
+ const maxSyncTimeoutMs = resolveMaxSyncTimeoutMs();
136
+ const configure = (ctx?: ToolContext): void => ensureConfigured?.(ctx);
137
+
54
138
  api.registerTool({
55
139
  name: "async_run", label: "Run async command",
56
140
  description: `Run a background, delayed, or synchronous shell command. mode=sync requires timeoutMs; mode=delayed requires delayMs. onTimeout (sync only): "promote" (default) hands the still-running task to background and returns; "abort" stops WAITING only — the command keeps running in the background and will still notify on completion (Esc semantics; does not kill). ${promptGuidelines}`,
141
+ promptSnippet: ASYNC_RUN_PROMPT_SNIPPET,
142
+ promptGuidelines: ASYNC_RUN_PROMPT_GUIDELINES,
57
143
  // Mode-conditional required fields + mode-scoped rejects enforced in execute (#45/#42),
58
144
  // not allOf/then (host validator emits opaque "must match then schema").
59
- parameters: schema({ command: stringField, name: stringField, mode: { type: "string", enum: ["background", "sync", "delayed"], default: "background" }, timeoutMs: timeoutField, delayMs: timeoutField, taskTimeoutMs: timeoutField, onTimeout: { type: "string", enum: ["promote", "abort"], default: "promote" }, notifyOnCompletion: { type: "boolean" }, triggerOnCompletion: { type: "boolean" }, env: { type: "object", additionalProperties: { type: "string" } }, watch: watchSchema }, ["command"]),
60
- execute: async (_id, params, signal) => {
145
+ parameters: schema({
146
+ command: stringField, name: stringField,
147
+ mode: { type: "string", enum: ["background", "sync", "delayed"], default: "background" },
148
+ timeoutMs: { type: "number", minimum: 0, maximum: maxSyncTimeoutMs, description: `Sync-mode wait budget in ms (max ${maxSyncTimeoutMs}, from PI_ASYNC_MAX_SYNC_TIMEOUT_MS).` },
149
+ delayMs: timeoutField, taskTimeoutMs: timeoutField,
150
+ onTimeout: { type: "string", enum: ["promote", "abort"], default: "promote" },
151
+ notifyOnCompletion: { type: "boolean" }, triggerOnCompletion: { type: "boolean" },
152
+ env: { type: "object", additionalProperties: { type: "string" } },
153
+ watch: watchSchema,
154
+ }, ["command"]),
155
+ execute: async (_id, params, signal, _onUpdate, ctx) => {
156
+ configure(ctx);
61
157
  const mode = String(params.mode ?? "background");
62
158
  if (mode !== "background" && mode !== "sync" && mode !== "delayed") throw new Error(`Unsupported async_run mode: ${mode}`);
63
159
  const command = String(params.command ?? "");
@@ -65,50 +161,103 @@ export function registerTools(pi: PiCtx, registry: TaskRegistry): void {
65
161
  if (mode === "delayed" && params.delayMs === undefined) throw new Error("delayMs is required for delayed mode");
66
162
  if (params.watch !== undefined && mode === "sync") throw new Error("watch is only supported for background and delayed modes");
67
163
  if (mode !== "sync" && params.timeoutMs !== undefined) throw new Error("timeoutMs is only valid for sync mode (use taskTimeoutMs to bound background/delayed tasks)");
164
+ if (mode !== "sync" && params.onTimeout !== undefined) throw new Error("onTimeout is only valid for sync mode");
68
165
  if (mode !== "delayed" && params.delayMs !== undefined) throw new Error("delayMs is only valid for delayed mode");
69
166
  const options = {
70
167
  command,
168
+ cwd: sessionCwd(ctx),
71
169
  ...(typeof params.name === "string" ? { name: params.name } : {}),
72
170
  ...(typeof params.taskTimeoutMs === "number" ? { taskTimeoutMs: params.taskTimeoutMs } : {}),
73
171
  ...(typeof params.notifyOnCompletion === "boolean" ? { notifyOnCompletion: params.notifyOnCompletion } : {}),
74
172
  ...(typeof params.triggerOnCompletion === "boolean" ? { triggerOnCompletion: params.triggerOnCompletion } : {}),
75
173
  ...(params.env && typeof params.env === "object" ? { env: params.env as Record<string, string> } : {}),
76
- ...(params.watch && typeof params.watch === "object" ? { watch: params.watch as WatchSpec } : {}),
174
+ ...(mode !== "sync" && params.watch && typeof params.watch === "object" ? { watch: params.watch as WatchSpec } : {}),
77
175
  };
78
- if (mode === "sync") { const r = await registry.runSync({ ...options, mode: "sync" }, Number(params.timeoutMs), params.onTimeout === "abort" ? "abort" : "promote", signal); return toolResult(r); }
176
+ if (mode === "sync") {
177
+ const timeoutMs = Number(params.timeoutMs);
178
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error("timeoutMs must be a non-negative finite number");
179
+ if (timeoutMs > maxSyncTimeoutMs) {
180
+ throw new Error(`timeoutMs must be at most ${maxSyncTimeoutMs} ms for sync mode (raise PI_ASYNC_MAX_SYNC_TIMEOUT_MS to allow longer waits; promote the task to background instead)`);
181
+ }
182
+ const result = await registry.runSync({ ...options, mode: "sync" }, timeoutMs, params.onTimeout === "abort" ? "abort" : "promote", signal);
183
+ // D-19 (narrowing D-18 to PLAN §2.7's exact wording): only a *finished* sync run with a
184
+ // non-zero exit is a tool error. promote/abort timeouts leave the task running
185
+ // (status "running") — that is not a non-zero exit and must not read as a failure;
186
+ // the payload still carries the full SyncResult (status/promoted/exitCode/outputTail).
187
+ const ended = result.status === "completed" || result.status === "failed" || result.status === "killed";
188
+ const isError = ended && (result.status !== "completed" || (result.exitCode !== undefined && result.exitCode !== 0));
189
+ return toolResult(result, isError);
190
+ }
79
191
  const task = mode === "delayed"
80
- ? registry.scheduleDelayed({ ...options, mode: "delayed" }, Number(params.delayMs))
192
+ ? registry.scheduleDelayed(options, Number(params.delayMs))
81
193
  : registry.submit({ ...options, mode: "background" });
82
194
  return toolResult({ taskId: task.taskId, name: task.name, status: task.status, mode: task.mode, outputHint: `Use async_logs with taskId ${task.taskId} to inspect early output.` });
83
195
  },
84
196
  });
85
- api.registerTool({ name: "async_wait", label: "Wait for async tasks", description: "Wait for tasks to finish or return notReady; waiting does not kill tasks.", parameters: schema({ taskIds: { type: "array", items: stringField }, timeoutMs: timeoutField }, ["taskIds", "timeoutMs"]), execute: async (_id, params, signal) => {
86
- if (params.timeoutMs === undefined) throw new Error("timeoutMs is required");
87
- return toolResult(await registry.wait(params.taskIds as string[], Number(params.timeoutMs), signal));
88
- } });
89
- api.registerTool({ name: "async_logs", label: "Read async output", description: "Read bounded, ANSI-stripped task output.", parameters: schema({ taskId: stringField, maxBytes: { type: "number", minimum: 0, maximum: MAX_LOG_BYTES }, tail: { type: "boolean" } }, ["taskId"]), execute: async (_id, params) => {
90
- const maxBytes = params.maxBytes === undefined ? MAX_LOG_BYTES : Number(params.maxBytes);
91
- if (maxBytes > MAX_LOG_BYTES) throw new Error("maxBytes must be at most 50 KiB");
92
- return toolResult(registry.logs(String(params.taskId), { maxBytes, tail: params.tail !== false }));
93
- } });
197
+ api.registerTool({
198
+ name: "async_wait", label: "Wait for async tasks",
199
+ description: "Wait for tasks (taskId or name) to finish or return notReady; waiting does not kill tasks.",
200
+ parameters: schema({ taskIds: { type: "array", items: stringField }, timeoutMs: timeoutField }, ["taskIds", "timeoutMs"]),
201
+ execute: async (_id, params, signal, _onUpdate, ctx) => {
202
+ configure(ctx);
203
+ if (params.timeoutMs === undefined) throw new Error("timeoutMs is required");
204
+ const taskIds = Array.isArray(params.taskIds) ? params.taskIds.map((id) => registry.resolveTaskId(String(id)).taskId) : [];
205
+ return toolResult(await registry.wait(taskIds, Number(params.timeoutMs), signal));
206
+ },
207
+ });
208
+ api.registerTool({
209
+ name: "async_logs", label: "Read async output",
210
+ description: "Read bounded, ANSI-stripped task output (taskId or name). stream selects merged/stdout/stderr.",
211
+ parameters: schema({
212
+ taskId: stringField,
213
+ maxBytes: { type: "number", minimum: 0, maximum: MAX_LOG_BYTES },
214
+ tail: { type: "boolean" },
215
+ stream: { type: "string", enum: ["merged", "stdout", "stderr"], default: "merged" },
216
+ }, ["taskId"]),
217
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
218
+ configure(ctx);
219
+ const maxBytes = params.maxBytes === undefined ? MAX_LOG_BYTES : Number(params.maxBytes);
220
+ if (maxBytes > MAX_LOG_BYTES) throw new Error("maxBytes must be at most 50 KiB");
221
+ const stream = params.stream === "stdout" || params.stream === "stderr" ? params.stream : "merged";
222
+ const { taskId } = registry.resolveTaskId(String(params.taskId));
223
+ return toolResult(registry.logs(taskId, { maxBytes, tail: params.tail !== false, stream }));
224
+ },
225
+ });
94
226
  api.registerTool({
95
227
  name: "async_status", label: "Async task status",
96
- description: "List async tasks and current status. Defaults to active (running/pending) only; pass includeHistory:true for up to 50 recent tasks.",
97
- parameters: schema({ includeHistory: { type: "boolean" } }),
98
- execute: async (_id, params) => {
99
- const MAX_HISTORY = 50;
100
- if (params.includeHistory === true) {
101
- const all = registry.list({ includeHistory: true });
102
- return toolResult(all.slice(-MAX_HISTORY));
103
- }
104
- return toolResult(registry.list({ includeHistory: false }));
228
+ description: "List async tasks and current status. Defaults to active (running/pending) only; pass includeHistory:true (or an explicit status) for up to 50 recent tasks, newest first.",
229
+ parameters: schema({
230
+ includeHistory: { type: "boolean" },
231
+ status: { type: "string", enum: [...TASK_STATUSES, "active"] },
232
+ name: stringField,
233
+ limit: { type: "number", minimum: 1, maximum: MAX_STATUS_LIMIT },
234
+ }),
235
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
236
+ configure(ctx);
237
+ const includeHistory = params.includeHistory === true;
238
+ const status: TaskStatus | "active" | undefined = isTaskStatus(params.status) ? params.status : params.status === "active" ? "active" : undefined;
239
+ const name = typeof params.name === "string" && params.name.length > 0 ? params.name : undefined;
240
+ const limit = clampStatusLimit(params.limit);
241
+ const tasks = registry.list({
242
+ // An explicit status promises history visibility (a status filter is a history query).
243
+ includeHistory: includeHistory || status !== undefined,
244
+ ...(status === undefined ? {} : { status }),
245
+ ...(name === undefined ? {} : { name }),
246
+ limit,
247
+ });
248
+ // D-08: history views are newest-first; the default active view keeps insertion order.
249
+ const ordered = includeHistory || status !== undefined ? [...tasks].sort(byRecency) : tasks;
250
+ return toolResult(ordered.map(redactSnapshot));
105
251
  },
106
252
  });
107
253
  api.registerTool({
108
- name: "async_kill", label: "Kill async task", description: "Terminate an async task and its process group.",
254
+ name: "async_kill", label: "Kill async task", description: "Terminate an async task (taskId or name) and its process group.",
109
255
  parameters: schema({ taskId: stringField }, ["taskId"]),
110
- execute: async (_id, params) => {
111
- const taskId = String(params.taskId);
256
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
257
+ configure(ctx);
258
+ // Unknown id/name throws here (registry message: "Unknown task or name: ...") → host isError result.
259
+ const resolved = registry.resolveTaskId(String(params.taskId));
260
+ const taskId = resolved.taskId;
112
261
  const before = registry.get(taskId);
113
262
  const alreadyTerminal = before !== undefined && isTerminal(before.status);
114
263
  let warning: string | undefined;
@@ -124,8 +273,14 @@ export function registerTools(pi: PiCtx, registry: TaskRegistry): void {
124
273
  status: after?.status,
125
274
  ...(after?.killKind ? { killKind: after.killKind } : {}),
126
275
  alreadyTerminal,
276
+ processAlive: registry.isProcessAlive(taskId),
127
277
  ...(warning ? { warning } : {}),
128
278
  }, Boolean(warning && after?.status !== "killed"));
129
279
  },
130
280
  });
131
281
  }
282
+
283
+ /** Session cwd for tool-initiated runs: `ctx.cwd` when present, else the host process cwd (PLAN §2.7). */
284
+ function sessionCwd(ctx?: ToolContext): string {
285
+ return typeof ctx?.cwd === "string" && ctx.cwd.length > 0 ? ctx.cwd : process.cwd();
286
+ }