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/CHANGELOG.md +135 -1
- package/README.md +73 -19
- package/docs/CONTRACT.md +242 -53
- package/extensions/index.ts +211 -59
- package/package.json +4 -2
- package/src/core/duration.ts +8 -4
- package/src/core/env.ts +111 -0
- package/src/core/notify.ts +63 -15
- package/src/core/registry.ts +465 -41
- package/src/core/spawn.ts +46 -11
- package/src/core/task.ts +13 -0
- package/src/guard/settle-guard.ts +152 -43
- package/src/tools/index.ts +185 -30
- package/src/tui/panel.ts +308 -74
- package/src/tui/rail.ts +76 -45
- package/src/tui/status.ts +86 -30
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): {
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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?.
|
|
23
|
-
const env =
|
|
24
|
-
|
|
25
|
-
const child = spawn("/bin/sh", ["-c", command], { detached: true, stdio: ["ignore", "pipe", "pipe"], env });
|
|
26
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
67
|
-
if (this.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
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({
|
|
60
|
-
|
|
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") {
|
|
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(
|
|
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({
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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({
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
+
}
|