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.
@@ -1,6 +1,23 @@
1
1
  import type { PiCtx } from "../core/notify.ts";
2
2
  import type { TaskRegistry } from "../core/registry.ts";
3
- import 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.
@@ -21,8 +38,28 @@ function toolResult(payload: unknown, isError = false): { content: Array<{ type:
21
38
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], details: { payload }, isError };
22
39
  }
23
40
 
24
- 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 to avoid premature matches.";
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 };
@@ -30,7 +67,10 @@ const conditionSchema = {
30
67
  oneOf: [
31
68
  { type: "object", properties: { type: { const: "exit_code" }, equals: { type: "number" } }, required: ["type", "equals"], additionalProperties: false },
32
69
  ...["stdout_contains", "stderr_contains"].map((type) => ({ type: "object", properties: { type: { const: type }, value: stringField }, required: ["type", "value"], additionalProperties: false })),
33
- { type: "object", properties: { type: { const: "json_path_exists" }, path: stringField }, required: ["type", "path"], additionalProperties: false },
70
+ { type: "object", properties: {
71
+ type: { const: "json_path_exists" },
72
+ path: { type: "string", pattern: "^\\$", description: "JSONPath rooted at $; e.g. $.ready or $.items[0].state (bare names like \"ready\" are rejected)" },
73
+ }, required: ["type", "path"], additionalProperties: false },
34
74
  { type: "object", properties: { type: { const: "output_matches" }, regex: stringField }, required: ["type", "regex"], additionalProperties: false },
35
75
  ],
36
76
  };
@@ -42,51 +82,205 @@ const watchSchema = {
42
82
  }, required: ["intervalMs", "timeoutMs", "success_when", "failure_when"], additionalProperties: false,
43
83
  };
44
84
 
45
- /** Register the frozen five-tool surface. */
46
- export function registerTools(pi: PiCtx, registry: TaskRegistry): void {
47
- 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
+ };
48
132
  // SAFETY: ExtensionAPI exposes registerTool at runtime; this local structural view keeps schemas dependency-free.
49
133
  const api = pi as unknown as { registerTool?: (tool: ToolDef) => void };
50
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
+
51
138
  api.registerTool({
52
- name: "async_run", label: "Run async command", description: `Run a background, delayed, or synchronous shell command. ${promptGuidelines}`,
53
- 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"], [
54
- { if: { properties: { mode: { const: "sync" } }, required: ["mode"] }, then: { required: ["timeoutMs"] } },
55
- { if: { properties: { mode: { const: "delayed" } }, required: ["mode"] }, then: { required: ["delayMs"] } },
56
- { if: { properties: { mode: { const: "sync" } }, required: ["mode"] }, then: { not: { required: ["watch"] } } },
57
- ]),
58
- execute: async (_id, params, signal) => {
139
+ name: "async_run", label: "Run async command",
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,
143
+ // Mode-conditional required fields + mode-scoped rejects enforced in execute (#45/#42),
144
+ // not allOf/then (host validator emits opaque "must match then schema").
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);
59
157
  const mode = String(params.mode ?? "background");
60
158
  if (mode !== "background" && mode !== "sync" && mode !== "delayed") throw new Error(`Unsupported async_run mode: ${mode}`);
61
159
  const command = String(params.command ?? "");
62
160
  if (mode === "sync" && params.timeoutMs === undefined) throw new Error("timeoutMs is required for sync mode");
63
161
  if (mode === "delayed" && params.delayMs === undefined) throw new Error("delayMs is required for delayed mode");
64
162
  if (params.watch !== undefined && mode === "sync") throw new Error("watch is only supported for background and delayed modes");
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");
165
+ if (mode !== "delayed" && params.delayMs !== undefined) throw new Error("delayMs is only valid for delayed mode");
65
166
  const options = {
66
167
  command,
168
+ cwd: sessionCwd(ctx),
67
169
  ...(typeof params.name === "string" ? { name: params.name } : {}),
68
170
  ...(typeof params.taskTimeoutMs === "number" ? { taskTimeoutMs: params.taskTimeoutMs } : {}),
69
171
  ...(typeof params.notifyOnCompletion === "boolean" ? { notifyOnCompletion: params.notifyOnCompletion } : {}),
70
172
  ...(typeof params.triggerOnCompletion === "boolean" ? { triggerOnCompletion: params.triggerOnCompletion } : {}),
71
173
  ...(params.env && typeof params.env === "object" ? { env: params.env as Record<string, string> } : {}),
72
- ...(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 } : {}),
73
175
  };
74
- 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
+ }
75
191
  const task = mode === "delayed"
76
- ? registry.scheduleDelayed({ ...options, mode: "delayed" }, Number(params.delayMs))
192
+ ? registry.scheduleDelayed(options, Number(params.delayMs))
77
193
  : registry.submit({ ...options, mode: "background" });
78
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.` });
79
195
  },
80
196
  });
81
- 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) => {
82
- if (params.timeoutMs === undefined) throw new Error("timeoutMs is required");
83
- return toolResult(await registry.wait(params.taskIds as string[], Number(params.timeoutMs), signal));
84
- } });
85
- 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) => {
86
- const maxBytes = params.maxBytes === undefined ? MAX_LOG_BYTES : Number(params.maxBytes);
87
- if (maxBytes > MAX_LOG_BYTES) throw new Error("maxBytes must be at most 50 KiB");
88
- return toolResult(registry.logs(String(params.taskId), { maxBytes, tail: params.tail !== false }));
89
- } });
90
- api.registerTool({ name: "async_status", label: "Async task status", description: "List async tasks and current status.", parameters: schema({ includeHistory: { type: "boolean" } }), execute: async (_id, params) => toolResult(registry.list({ includeHistory: params.includeHistory !== false })) });
91
- api.registerTool({ name: "async_kill", label: "Kill async task", description: "Terminate an async task and its process group.", parameters: schema({ taskId: stringField }, ["taskId"]), execute: async (_id, params) => { await registry.kill(String(params.taskId), "user"); return toolResult({ taskId: params.taskId, status: registry.get(String(params.taskId))?.status }); } });
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
+ });
226
+ api.registerTool({
227
+ name: "async_status", label: "Async task status",
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));
251
+ },
252
+ });
253
+ api.registerTool({
254
+ name: "async_kill", label: "Kill async task", description: "Terminate an async task (taskId or name) and its process group.",
255
+ parameters: schema({ taskId: stringField }, ["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;
261
+ const before = registry.get(taskId);
262
+ const alreadyTerminal = before !== undefined && isTerminal(before.status);
263
+ let warning: string | undefined;
264
+ try {
265
+ await registry.kill(taskId, "user");
266
+ } catch (error) {
267
+ // Kill may still have finalized the task; surface confirmation failures as warning (#6).
268
+ warning = error instanceof Error ? error.message : String(error);
269
+ }
270
+ const after = registry.get(taskId);
271
+ return toolResult({
272
+ taskId,
273
+ status: after?.status,
274
+ ...(after?.killKind ? { killKind: after.killKind } : {}),
275
+ alreadyTerminal,
276
+ processAlive: registry.isProcessAlive(taskId),
277
+ ...(warning ? { warning } : {}),
278
+ }, Boolean(warning && after?.status !== "killed"));
279
+ },
280
+ });
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();
92
286
  }