@maxiaochao/pi-toolkit 0.12.2 → 0.13.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.
@@ -0,0 +1,240 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import type { BashOperations } from "@earendil-works/pi-coding-agent";
7
+ import { TaskLog, type LogWrite, type OutputView } from "./output.ts";
8
+
9
+ export interface Clock {
10
+ now(): number;
11
+ setTimeout(callback: () => void, delay: number): unknown;
12
+ clearTimeout(handle: unknown): void;
13
+ }
14
+ export const systemClock: Clock = {
15
+ now: () => Date.now(),
16
+ setTimeout: (callback, delay) => setTimeout(callback, delay),
17
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
18
+ };
19
+ export type TaskStatus = "running" | "succeeded" | "failed" | "cancelled" | "timed_out" | "output_limit";
20
+ export interface TaskSnapshot {
21
+ id: string;
22
+ status: TaskStatus;
23
+ description: string;
24
+ cwd: string;
25
+ started_at: number;
26
+ ended_at?: number;
27
+ timeout: number;
28
+ elapsed_seconds: number;
29
+ log_path: string;
30
+ bytes: number;
31
+ capture_complete: boolean;
32
+ exit_code?: number;
33
+ reason?: string;
34
+ }
35
+ export interface StartParams {
36
+ command: string;
37
+ description?: string;
38
+ cwd?: string;
39
+ timeout?: number;
40
+ }
41
+ interface Task {
42
+ data: TaskSnapshot;
43
+ log: TaskLog;
44
+ controller: AbortController;
45
+ timer?: unknown;
46
+ done: Promise<void>;
47
+ stop?: Exclude<TaskStatus, "running" | "succeeded">;
48
+ captureLost: boolean;
49
+ }
50
+ export interface TaskList {
51
+ tasks: TaskSnapshot[];
52
+ running: number;
53
+ total: number;
54
+ omitted: number;
55
+ }
56
+
57
+ export function summary(text: string, max = 200): string {
58
+ return Array.from(stripVTControlCharacters(text).replace(/[\x00-\x1f\x7f\uFFF9-\uFFFB]/g, " ").trim()).slice(0, max).join("");
59
+ }
60
+ export function errorText(error: unknown): string {
61
+ return summary(error instanceof Error ? error.message : String(error), 500);
62
+ }
63
+
64
+ /** Session-owned jobs; tool calls only transfer control, never own the job signal. */
65
+ export class BackgroundTasks {
66
+ private readonly operations: BashOperations;
67
+ readonly clock: Clock;
68
+ private readonly onComplete: (task: TaskSnapshot) => void;
69
+ private readonly onChange: () => void;
70
+ private readonly logRoot: string;
71
+ private readonly logWrite?: LogWrite;
72
+ private readonly taskBytes: number;
73
+ private readonly sessionBytes: number;
74
+ private readonly tasks = new Map<string, Task>();
75
+ private directory?: string;
76
+ private bytes = 0;
77
+ private closed = false;
78
+ private shutdownPromise?: Promise<void>;
79
+
80
+ constructor(options: {
81
+ operations: BashOperations;
82
+ clock?: Clock;
83
+ onComplete?: (task: TaskSnapshot) => void;
84
+ onChange?: () => void;
85
+ logRoot?: string;
86
+ logWrite?: LogWrite;
87
+ limits?: { taskBytes: number; sessionBytes: number };
88
+ }) {
89
+ this.operations = options.operations;
90
+ this.clock = options.clock ?? systemClock;
91
+ this.onComplete = options.onComplete ?? (() => {});
92
+ this.onChange = options.onChange ?? (() => {});
93
+ this.logRoot = options.logRoot ?? tmpdir();
94
+ this.logWrite = options.logWrite;
95
+ this.taskBytes = options.limits?.taskBytes ?? 32 * 1024 * 1024;
96
+ this.sessionBytes = options.limits?.sessionBytes ?? 256 * 1024 * 1024;
97
+ if (this.taskBytes <= 0 || this.sessionBytes <= 0) throw new Error("Log limits must be positive");
98
+ }
99
+
100
+ start(params: StartParams, sessionCwd: string): TaskSnapshot {
101
+ if (this.closed) throw new Error("Background tasks session is closed");
102
+ if (typeof params.command !== "string" || !params.command.trim()) throw new Error("start requires a non-empty command");
103
+ const timeout = params.timeout === undefined ? 1800 : params.timeout;
104
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 7200) throw new Error("timeout must be an integer from 1 to 7200 seconds (default 1800)");
105
+ if (params.description !== undefined && typeof params.description !== "string") throw new Error("description must be a string");
106
+ if (params.cwd !== undefined && (typeof params.cwd !== "string" || !params.cwd.trim())) throw new Error("cwd must be a non-empty directory path");
107
+ const cwd = resolve(sessionCwd, params.cwd ?? ".");
108
+ try {
109
+ if (!statSync(cwd).isDirectory()) throw new Error("not a directory");
110
+ } catch (error) { throw new Error(`Invalid working directory: ${errorText(error)}`); }
111
+ if (this.running() >= 2) throw new Error("At most two background tasks may be running; cancel one or wait for completion");
112
+ if (this.bytes >= this.sessionBytes) throw new Error("Session log budget exhausted; start a new session to reset it");
113
+ this.directory ??= mkdtempSync(join(this.logRoot, "pi-background-tasks-"));
114
+ let id: string;
115
+ do { id = `bg-${randomUUID().replace(/-/g, "").slice(0, 12)}`; } while (this.tasks.has(id));
116
+ const log = new TaskLog(join(this.directory, `${id}.log`), this.logWrite);
117
+ const task: Task = {
118
+ data: {
119
+ id, status: "running", description: summary(params.description || params.command.split("\n", 1)[0]), cwd,
120
+ started_at: this.clock.now(), timeout, elapsed_seconds: 0, log_path: log.path,
121
+ bytes: 0, capture_complete: false,
122
+ },
123
+ log, controller: new AbortController(), done: Promise.resolve(), captureLost: false,
124
+ };
125
+ // This path is synchronous: reserve the slot before any execution can await.
126
+ this.tasks.set(id, task);
127
+ task.timer = this.clock.setTimeout(() => this.stop(task, "timed_out", `Runtime limit of ${timeout} seconds reached`), timeout * 1000);
128
+ const accepted = this.snapshot(task);
129
+ task.done = this.run(task, params.command);
130
+ this.changed();
131
+ return accepted;
132
+ }
133
+
134
+ get(id: string): TaskSnapshot { return this.snapshot(this.find(id)); }
135
+
136
+ list(): TaskList {
137
+ const all = [...this.tasks.values()];
138
+ const active = all.filter((task) => task.data.status === "running");
139
+ const ended = all.filter((task) => task.data.status !== "running").reverse().slice(0, 20);
140
+ return { tasks: [...active, ...ended].map((task) => this.snapshot(task)), running: active.length, total: all.length, omitted: all.length - active.length - ended.length };
141
+ }
142
+
143
+ output(id: string): TaskSnapshot & OutputView {
144
+ const task = this.find(id);
145
+ try { return { ...this.snapshot(task), ...task.log.read() }; }
146
+ catch (error) {
147
+ this.captureFailure(task, `Cannot read task log: ${errorText(error)}`);
148
+ throw new Error(`Cannot read task log: ${errorText(error)}`);
149
+ }
150
+ }
151
+
152
+ async cancel(id: string): Promise<TaskSnapshot> {
153
+ const task = this.find(id);
154
+ this.stop(task, "cancelled", "Cancelled by request");
155
+ await task.done;
156
+ return this.snapshot(task);
157
+ }
158
+
159
+ shutdown(): Promise<void> {
160
+ if (this.shutdownPromise) return this.shutdownPromise;
161
+ this.closed = true;
162
+ for (const task of this.tasks.values()) this.stop(task, "cancelled", "Session closed");
163
+ this.shutdownPromise = Promise.all([...this.tasks.values()].map((task) => task.done)).then(() => {
164
+ if (this.directory) rmSync(this.directory, { recursive: true, force: true });
165
+ });
166
+ return this.shutdownPromise;
167
+ }
168
+
169
+ private async run(task: Task, command: string): Promise<void> {
170
+ try {
171
+ const { exitCode } = await this.operations.exec(command, task.data.cwd, {
172
+ signal: task.controller.signal,
173
+ onData: (data) => this.append(task, data),
174
+ });
175
+ if (exitCode !== null) task.data.exit_code = exitCode;
176
+ if (!task.stop && exitCode !== 0) this.stop(task, "failed", `Command exited with ${exitCode === null ? "no exit code" : `code ${exitCode}`}`);
177
+ } catch (error) {
178
+ this.stop(task, "failed", errorText(error));
179
+ } finally {
180
+ if (task.timer !== undefined) this.clock.clearTimeout(task.timer);
181
+ task.timer = undefined;
182
+ try { task.log.finish(); }
183
+ catch (error) {
184
+ this.captureFailure(task, `Cannot close task log: ${errorText(error)}`);
185
+ }
186
+ task.data.status = task.stop ?? "succeeded";
187
+ task.data.ended_at = this.clock.now();
188
+ task.data.capture_complete = !task.captureLost;
189
+ this.changed();
190
+ if (!this.closed) {
191
+ try { this.onComplete(this.snapshot(task)); }
192
+ catch (error) { console.error(`Background completion notification failed: ${errorText(error)}`); }
193
+ }
194
+ }
195
+ }
196
+
197
+ private append(task: Task, data: Buffer): void {
198
+ if (task.captureLost || task.data.status !== "running" || data.length === 0) return;
199
+ const remaining = Math.max(0, Math.min(this.taskBytes - task.log.bytes, this.sessionBytes - this.bytes));
200
+ const before = task.log.bytes;
201
+ try { if (remaining > 0) task.log.append(data.subarray(0, remaining)); }
202
+ catch (error) {
203
+ this.captureFailure(task, `Cannot write task log: ${errorText(error)}`);
204
+ } finally { this.bytes += task.log.bytes - before; }
205
+ if (data.length >= remaining) {
206
+ task.captureLost = true;
207
+ this.stop(task, "output_limit", `Log output limit reached (task ${this.taskBytes} bytes; session ${this.sessionBytes} bytes)`);
208
+ }
209
+ }
210
+
211
+ private captureFailure(task: Task, reason: string): void {
212
+ task.captureLost = true;
213
+ if (task.stop || task.data.status !== "running") {
214
+ task.data.reason = summary(`${task.data.reason ? `${task.data.reason}; ` : ""}${reason}`, 500);
215
+ }
216
+ this.stop(task, "failed", reason);
217
+ }
218
+
219
+ private stop(task: Task, status: NonNullable<Task["stop"]>, reason: string): void {
220
+ if (task.data.status !== "running" || task.stop) return;
221
+ task.stop = status;
222
+ task.data.reason = summary(reason, 500);
223
+ task.controller.abort();
224
+ }
225
+
226
+ private running(): number { return [...this.tasks.values()].filter((task) => task.data.status === "running").length; }
227
+ private find(id: string): Task {
228
+ const task = this.tasks.get(id);
229
+ if (!task) throw new Error("Unknown background task ID in this session");
230
+ return task;
231
+ }
232
+ private snapshot(task: Task): TaskSnapshot {
233
+ return { ...task.data, capture_complete: task.data.capture_complete && !task.captureLost, bytes: task.log.bytes, elapsed_seconds: Math.max(0, ((task.data.ended_at ?? this.clock.now()) - task.data.started_at) / 1000) };
234
+ }
235
+ private changed(): void {
236
+ if (this.closed) return;
237
+ try { this.onChange(); }
238
+ catch (error) { console.error(`Background task display failed: ${errorText(error)}`); }
239
+ }
240
+ }
@@ -0,0 +1,128 @@
1
+ import { closeSync, openSync, readSync, writeSync } from "node:fs";
2
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateTail, type TruncationResult } from "@earendil-works/pi-coding-agent";
3
+
4
+ const PROGRAM_BYTES = 1024 * 1024;
5
+ export type LogWrite = (fd: number, data: Buffer) => number;
6
+
7
+ export interface OutputView {
8
+ text: string;
9
+ output: string;
10
+ truncated: boolean;
11
+ truncation: TruncationResult;
12
+ }
13
+
14
+ /** Owns one log. Reads are range-limited even when the on-disk log is large. */
15
+ export class TaskLog {
16
+ readonly path: string;
17
+ bytes = 0;
18
+ private fd: number;
19
+ private closed = false;
20
+ private readonly write: LogWrite;
21
+ private decoder = new TextDecoder();
22
+ private decodedBytes = 0;
23
+ private lines = 0;
24
+ private openLine = false;
25
+ private lastLineBytes = 0;
26
+
27
+ constructor(path: string, write: LogWrite = (fd, data) => writeSync(fd, data)) {
28
+ this.path = path;
29
+ this.write = write;
30
+ this.fd = openSync(path, "wx", 0o600);
31
+ }
32
+
33
+ append(data: Buffer): void {
34
+ if (this.closed) throw new Error("Log is closed");
35
+ let offset = 0;
36
+ while (offset < data.length) {
37
+ const remaining = data.subarray(offset);
38
+ const count = this.write(this.fd, remaining);
39
+ if (!Number.isInteger(count) || count <= 0 || count > remaining.length) throw new Error("Log write made invalid progress");
40
+ this.bytes += count;
41
+ this.countText(this.decoder.decode(remaining.subarray(0, count), { stream: true }));
42
+ offset += count;
43
+ }
44
+ }
45
+
46
+ finish(): void {
47
+ if (this.closed) return;
48
+ this.closed = true;
49
+ try { this.countText(this.decoder.decode()); }
50
+ finally { closeSync(this.fd); }
51
+ }
52
+
53
+ read(): OutputView {
54
+ const fd = openSync(this.path, "r");
55
+ try {
56
+ // Keep a bounded working window comparable to Bash's rolling tail. This
57
+ // preserves an oversized last line that ends with a newline before trimming.
58
+ const start = Math.max(0, this.bytes - DEFAULT_MAX_BYTES * 4 - 4);
59
+ const tail = this.range(fd, start, this.bytes - start);
60
+ let first = 0;
61
+ while (first < tail.length && (tail[first] & 0xc0) === 0x80) first++;
62
+ let snapshot = new TextDecoder().decode(tail.subarray(first), { stream: !this.closed });
63
+ if (start > 0) {
64
+ const previous = this.range(fd, start - 1, 1)[0];
65
+ if (first > 0 || previous !== 0x0a) {
66
+ const newline = snapshot.indexOf("\n");
67
+ if (newline !== -1) snapshot = snapshot.slice(newline + 1);
68
+ }
69
+ }
70
+ const truncation = truncateTail(snapshot);
71
+ const totalLines = this.lines + (this.openLine ? 1 : 0);
72
+ truncation.truncated = totalLines > DEFAULT_MAX_LINES || this.decodedBytes > DEFAULT_MAX_BYTES;
73
+ truncation.truncatedBy = truncation.truncated
74
+ ? (truncation.truncatedBy ?? (this.decodedBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines")) : null;
75
+ truncation.totalLines = totalLines;
76
+ truncation.totalBytes = this.decodedBytes;
77
+ let text = truncation.content || "(no output)";
78
+ if (truncation.truncated) {
79
+ const end = totalLines;
80
+ const begin = end - truncation.outputLines + 1;
81
+ const detail = truncation.lastLinePartial
82
+ ? `last ${formatSize(truncation.outputBytes)} of line ${end} (line is ${formatSize(this.lastLineBytes)})`
83
+ : `lines ${begin}-${end} of ${end}${truncation.truncatedBy === "bytes" ? ` (${formatSize(DEFAULT_MAX_BYTES)} limit)` : ""}`;
84
+ text += `\n\n[Showing ${detail}. Full output: ${this.path}]`;
85
+ }
86
+ const program = this.readProgram(fd);
87
+ return { text, output: program.content, truncated: program.truncated, truncation };
88
+ } finally { closeSync(fd); }
89
+ }
90
+
91
+ private readProgram(fd: number): { content: string; truncated: boolean } {
92
+ if (this.bytes <= PROGRAM_BYTES) {
93
+ return { content: new TextDecoder().decode(this.range(fd, 0, this.bytes), { stream: !this.closed }), truncated: false };
94
+ }
95
+ const half = PROGRAM_BYTES / 2;
96
+ const head = this.range(fd, 0, half);
97
+ const tail = this.range(fd, this.bytes - half, half);
98
+ const headText = new TextDecoder().decode(head, { stream: true });
99
+ let first = 0;
100
+ while (first < tail.length && (tail[first] & 0xc0) === 0x80) first++;
101
+ const tailText = new TextDecoder().decode(tail.subarray(first), { stream: !this.closed });
102
+ return { content: `${headText}\n\n[... ${this.bytes - PROGRAM_BYTES} bytes omitted ...]\n\n${tailText}`, truncated: true };
103
+ }
104
+
105
+ private range(fd: number, position: number, length: number): Buffer {
106
+ const data = Buffer.alloc(length);
107
+ let offset = 0;
108
+ while (offset < length) {
109
+ const count = readSync(fd, data, offset, length - offset, position + offset);
110
+ if (count === 0) throw new Error("Log changed unexpectedly while reading");
111
+ offset += count;
112
+ }
113
+ return data;
114
+ }
115
+
116
+ private countText(text: string): void {
117
+ this.decodedBytes += Buffer.byteLength(text);
118
+ const last = text.lastIndexOf("\n");
119
+ if (last === -1) {
120
+ if (text.length) this.openLine = true;
121
+ this.lastLineBytes += Buffer.byteLength(text);
122
+ } else {
123
+ for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) this.lines++;
124
+ this.lastLineBytes = Buffer.byteLength(text.slice(last + 1));
125
+ this.openLine = last < text.length - 1;
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,54 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { Clock, TaskSnapshot } from "./background-tasks.ts";
4
+
5
+ export const WIDGET_KEY = "background-tasks";
6
+ export const FINISHED_VISIBLE_MS = 5000;
7
+
8
+ export function visibleTasks(tasks: TaskSnapshot[], now: number): TaskSnapshot[] {
9
+ return tasks.filter((task) => task.status === "running" || (task.ended_at !== undefined && now < task.ended_at + FINISHED_VISIBLE_MS));
10
+ }
11
+
12
+ export function duration(seconds: number): string {
13
+ const value = Math.max(0, Math.floor(seconds));
14
+ if (value >= 3600) return `${Math.floor(value / 3600)}h${value % 3600 ? ` ${Math.floor(value % 3600 / 60)}m` : ""}`;
15
+ if (value >= 60) return `${Math.floor(value / 60)}m${value % 60 ? ` ${value % 60}s` : ""}`;
16
+ return `${value}s`;
17
+ }
18
+
19
+ export class BackgroundWidget {
20
+ private lines?: string[];
21
+ private width?: number;
22
+ private readonly getTasks: () => TaskSnapshot[];
23
+ private readonly clock: Clock;
24
+ private readonly theme: Theme;
25
+
26
+ constructor(getTasks: () => TaskSnapshot[], clock: Clock, theme: Theme) {
27
+ this.getTasks = getTasks;
28
+ this.clock = clock;
29
+ this.theme = theme;
30
+ }
31
+
32
+ render(width: number): string[] {
33
+ if (this.lines && this.width === width) return this.lines;
34
+ const tasks = visibleTasks(this.getTasks(), this.clock.now());
35
+ const active = tasks.filter((task) => task.status === "running").length;
36
+ const { theme } = this;
37
+ this.lines = [
38
+ truncateToWidth(theme.fg("accent", `Background tasks ${active}/2`), width),
39
+ ...tasks.map((task) => {
40
+ const color = task.status === "running" ? "accent" : task.status === "succeeded" ? "success" : task.status === "cancelled" ? "muted" : "error";
41
+ const marker = task.status === "running" ? "●" : task.status === "succeeded" ? "✓" : task.status === "cancelled" ? "○" : "✗";
42
+ const elapsed = ((task.ended_at ?? this.clock.now()) - task.started_at) / 1000;
43
+ return truncateToWidth(` ${theme.fg(color, marker)} ${theme.fg("accent", task.id)} · ${duration(elapsed)} / ${duration(task.timeout)} · ${theme.fg(color, task.status)} · ${theme.fg("text", task.description)}`, width);
44
+ }),
45
+ ];
46
+ this.width = width;
47
+ return this.lines;
48
+ }
49
+
50
+ invalidate(): void {
51
+ this.lines = undefined;
52
+ this.width = undefined;
53
+ }
54
+ }
@@ -0,0 +1,192 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import register from "../extensions/background-tasks.ts";
5
+ import { fixture, flush } from "./helpers.mjs";
6
+
7
+ function harness(mode = "tui", hasUI = true) {
8
+ const f = fixture();
9
+ const handlers = new Map();
10
+ const commands = new Map();
11
+ const messages = [];
12
+ const notices = [];
13
+ const widgets = new Map([["scheduler", { render: () => ["Scheduler"] }], ["herdr-subagents", { render: () => ["Fleet"] }]]);
14
+ const placements = new Map();
15
+ const statuses = new Map();
16
+ let tool, renders = 0;
17
+ const theme = { fg: (_color, text) => text, bold: (text) => text };
18
+ const ctx = {
19
+ cwd: f.root, mode, hasUI,
20
+ ui: {
21
+ setWidget(key, factory, options) {
22
+ if (!factory) { widgets.delete(key); return; }
23
+ widgets.set(key, factory({ requestRender() { renders++; } }, theme));
24
+ placements.set(key, options);
25
+ },
26
+ setStatus(key, value) { if (value === undefined) statuses.delete(key); else statuses.set(key, value); },
27
+ notify(message, level) { notices.push({ message, level }); },
28
+ },
29
+ };
30
+ const pi = {
31
+ on(name, handler) { handlers.set(name, handler); },
32
+ registerTool(value) { tool = value; },
33
+ registerCommand(name, value) { commands.set(name, value); },
34
+ sendMessage(message, options) { messages.push({ message, options }); },
35
+ };
36
+ register(pi, { operations: f.operations, clock: f.clock, logRoot: f.root });
37
+ return {
38
+ ...f, handlers, commands, messages, notices, widgets, placements, statuses, ctx,
39
+ get tool() { return tool; }, get renders() { return renders; },
40
+ async close() {
41
+ const closing = handlers.get("session_shutdown")({}, ctx);
42
+ for (const call of f.calls) call.resolve();
43
+ await closing;
44
+ await f.close();
45
+ },
46
+ };
47
+ }
48
+
49
+ test("registration is compact, does not inject prompt rules or start session resources", async () => {
50
+ const h = harness();
51
+ try {
52
+ assert.equal(h.tool.name, "background_task");
53
+ assert.match(h.tool.description, /return task IDs immediately/);
54
+ assert.ok(h.tool.description.length < 200);
55
+ assert.equal(h.tool.promptGuidelines, undefined);
56
+ assert.ok(h.tool.outputSchema);
57
+ assert.equal(h.tool.annotations.readOnlyHint, false);
58
+ assert.equal(h.tool.annotations.destructiveHint, true);
59
+ assert.ok(!h.handlers.has("before_agent_start"));
60
+ assert.equal(h.clock.timers.size, 0);
61
+ assert.equal(h.calls.length, 0);
62
+ assert.equal((await h.tool.execute("early", { action: "list" })).isError, true);
63
+ } finally { await h.close(); }
64
+ });
65
+
66
+ test("tools, commands, widget and followUp share the same session jobs", async () => {
67
+ const h = harness();
68
+ try {
69
+ await h.handlers.get("session_start")({}, h.ctx);
70
+ const parentSignal = new AbortController();
71
+ const result = await h.tool.execute("start", { action: "start", command: "sleep 60", description: "测试构建", timeout: 60 }, parentSignal.signal, undefined, h.ctx);
72
+ assert.equal(result.isError, false);
73
+ const job = result.structuredContent.task;
74
+ assert.equal(job.status, "running");
75
+ parentSignal.abort();
76
+ assert.equal(h.calls[0].signal.aborted, false, "parent turn cancellation does not own the accepted background job");
77
+ assert.deepEqual(h.placements.get("background-tasks"), { placement: "aboveEditor" });
78
+ h.clock.advance(1000);
79
+ const widget = h.widgets.get("background-tasks");
80
+ assert.match(widget.render(140).join("\n"), /1s \/ 1m/);
81
+ for (const width of [1, 8, 40, 80]) assert.ok(widget.render(width).every((line) => visibleWidth(line) <= width));
82
+ assert.ok(h.widgets.has("scheduler") && h.widgets.has("herdr-subagents"));
83
+
84
+ h.calls[0].onData(Buffer.from("logs only on explicit read\n"));
85
+ await h.commands.get("bg").handler(`output ${job.id}`, h.ctx);
86
+ assert.match(h.messages.at(-1).message.content, /logs only on explicit read/);
87
+ assert.equal(h.messages.at(-1).options.triggerTurn, false);
88
+ const output = await h.tool.execute("output", { action: "output", id: job.id });
89
+ assert.equal(output.structuredContent.output, "logs only on explicit read\n");
90
+ assert.equal(output.details.output, undefined);
91
+
92
+ h.calls[0].resolve(7);
93
+ await flush();
94
+ const completed = h.messages.filter((m) => m.message.customType === "background-task-finished");
95
+ assert.equal(completed.length, 1);
96
+ assert.deepEqual(completed[0].options, { triggerTurn: true, deliverAs: "followUp" });
97
+ assert.match(completed[0].message.content, /failed.*exit 7/);
98
+ assert.ok(!completed[0].message.content.includes("logs only"));
99
+ assert.match(h.widgets.get("background-tasks").render(140).join("\n"), /failed/);
100
+ h.clock.advance(4999);
101
+ assert.ok(h.widgets.has("background-tasks"));
102
+ h.clock.advance(1);
103
+ assert.equal(h.widgets.has("background-tasks"), false);
104
+ assert.equal(h.clock.timers.size, 0);
105
+ assert.equal((await h.tool.execute("history", { action: "output", id: job.id })).structuredContent.task.status, "failed");
106
+ assert.ok(h.renders > 0);
107
+ } finally { await h.close(); }
108
+ });
109
+
110
+ test("cancel and shutdown are idempotent; new sessions cannot receive old completions", async () => {
111
+ const h = harness();
112
+ try {
113
+ await h.handlers.get("session_start")({}, h.ctx);
114
+ const a = (await h.tool.execute("a", { action: "start", command: "first" })).structuredContent.task;
115
+ const b = (await h.tool.execute("b", { action: "start", command: "second" })).structuredContent.task;
116
+ assert.equal((await h.tool.execute("c", { action: "start", command: "third" })).isError, true);
117
+ const cancel = h.tool.execute("cancel", { action: "cancel", id: a.id });
118
+ h.calls[0].reject(new Error("aborted"));
119
+ assert.equal((await cancel).structuredContent.task.status, "cancelled");
120
+ const count = h.messages.length;
121
+ await h.commands.get("bg").handler(`cancel ${a.id}`, h.ctx);
122
+ assert.equal(h.messages.filter((m) => m.message.customType === "background-task-finished").length, 1);
123
+ assert.equal(h.messages.length, count + 1, "only the explicit command acknowledgement is added");
124
+ const shutdown = h.handlers.get("session_shutdown")({ reason: "reload" }, h.ctx);
125
+ assert.equal(h.calls[1].signal.aborted, true);
126
+ h.calls[1].resolve();
127
+ await shutdown;
128
+ await h.handlers.get("session_start")({ reason: "reload" }, h.ctx);
129
+ assert.equal((await h.tool.execute("old", { action: "output", id: b.id })).isError, true);
130
+ assert.equal(h.messages.filter((m) => m.message.customType === "background-task-finished").length, 1);
131
+ assert.equal(h.clock.timers.size, 0);
132
+ assert.ok(h.widgets.has("scheduler") && h.widgets.has("herdr-subagents"));
133
+ } finally { await h.close(); }
134
+ });
135
+
136
+ for (const [mode, hasUI] of [["rpc", true], ["print", false], ["json", false]]) {
137
+ test(`${mode}: tools work without a TUI or render timer`, async () => {
138
+ const h = harness(mode, hasUI);
139
+ try {
140
+ await h.handlers.get("session_start")({}, h.ctx);
141
+ const result = await h.tool.execute("job", { action: "start", command: "ok" });
142
+ assert.equal(result.structuredContent.task.status, "running");
143
+ assert.equal(h.widgets.has("background-tasks"), false);
144
+ assert.equal(h.clock.timers.size, 1, "only the execution deadline exists");
145
+ if (hasUI) assert.match(h.statuses.get("background-tasks"), /1\/2/);
146
+ h.calls[0].resolve();
147
+ await flush();
148
+ assert.equal(h.clock.timers.size, 0);
149
+ assert.equal(h.statuses.has("background-tasks"), false);
150
+ assert.equal(h.messages.at(-1).options.deliverAs, "followUp");
151
+ } finally { await h.close(); }
152
+ });
153
+ }
154
+
155
+ test("bad actions, irrelevant fields, aborted acceptance and command syntax fail visibly", async () => {
156
+ const h = harness();
157
+ try {
158
+ await h.handlers.get("session_start")({}, h.ctx);
159
+ for (const params of [
160
+ { action: "other" }, { action: "start" }, { action: "list", command: "ignored" },
161
+ { action: "output" }, { action: "cancel", id: "unknown" },
162
+ { action: "start", command: "x", id: "irrelevant" },
163
+ ]) {
164
+ const result = await h.tool.execute("bad", params);
165
+ assert.equal(result.isError, true);
166
+ assert.ok(result.structuredContent.error.length <= 500);
167
+ }
168
+ const abort = new AbortController(); abort.abort();
169
+ assert.equal((await h.tool.execute("abort", { action: "start", command: "x" }, abort.signal)).isError, true);
170
+ await h.commands.get("bg").handler("start echo hello", h.ctx);
171
+ assert.equal(h.notices.at(-1).level, "error");
172
+ assert.equal(h.calls.length, 0);
173
+ } finally { await h.close(); }
174
+ });
175
+
176
+ for (const reason of ["new", "resume", "fork", "quit"]) {
177
+ test(`${reason} shutdown suppresses old notifications, removes timers and isolates IDs`, async () => {
178
+ const h = harness();
179
+ try {
180
+ await h.handlers.get("session_start")({}, h.ctx);
181
+ const job = (await h.tool.execute("start", { action: "start", command: "pending" })).structuredContent.task;
182
+ const stopping = h.handlers.get("session_shutdown")({ reason }, h.ctx);
183
+ assert.equal(h.calls[0].signal.aborted, true);
184
+ h.calls[0].reject(new Error("aborted"));
185
+ await stopping;
186
+ await h.handlers.get("session_start")({ reason }, h.ctx);
187
+ assert.equal(h.messages.length, 0);
188
+ assert.equal(h.clock.timers.size, 0);
189
+ assert.equal((await h.tool.execute("old", { action: "output", id: job.id })).isError, true);
190
+ } finally { await h.close(); }
191
+ });
192
+ }
@@ -0,0 +1,63 @@
1
+ // Offline fixture only. No network, real credentials, or nested model calls.
2
+ import { appendFileSync } from "node:fs";
3
+ import { createAssistantMessageEventStream, getCurrentTools } from "@earendil-works/pi-ai";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+
6
+ export default function (pi: ExtensionAPI) {
7
+ const record = (entry: unknown) => {
8
+ if (process.env.BG_SMOKE_EVENTS) appendFileSync(process.env.BG_SMOKE_EVENTS, JSON.stringify(entry) + "\n");
9
+ };
10
+ let sequence = 0;
11
+ pi.registerProvider("bg-fixture", {
12
+ name: "Offline background-task fixture", baseUrl: "http://127.0.0.1:1", api: "bg-fixture",
13
+ apiKey: "fixture-not-a-secret",
14
+ models: [{ id: "deterministic", name: "Offline fixture (no network)", reasoning: false, input: ["text"], contextWindow: 128000, maxTokens: 4096, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
15
+ streamSimple(model, context) {
16
+ const stream = createAssistantMessageEventStream();
17
+ queueMicrotask(() => {
18
+ const last = context.messages.at(-1) as any;
19
+ const text = typeof last?.content === "string" ? last.content : (last?.content ?? []).filter((block: any) => block.type === "text").map((block: any) => block.text).join("\n");
20
+ record({ event: "request", lastRole: last?.role, text: text.slice(0, 2000), tools: getCurrentTools(context.messages).map((tool) => tool.name) });
21
+ let code: string | undefined;
22
+ if (last?.role !== "toolResult") {
23
+ if (text.includes("SMOKE_START")) code = `const a = await tools.background_task({ action: "start", command: "sleep 9; printf 'BUILD SUCCESSFUL\\\\n'", description: "Release build", timeout: 30 });
24
+ const b = await tools.background_task({ action: "start", command: "sleep 300", description: "Cancel this task", timeout: 300 });
25
+ const c = await tools.background_task({ action: "start", command: "printf third" });
26
+ text({ first: a.task.id, second: b.task.id, third: c });`;
27
+ else if (text.includes("SMOKE_CANCEL")) code = `const list = await tools.background_task({ action: "list" });
28
+ const task = list.tasks.find(t => t.description === "Cancel this task");
29
+ text(await tools.background_task({ action: "cancel", id: task.id }));`;
30
+ else if (text.includes("SMOKE_OUTPUT")) code = `const list = await tools.background_task({ action: "list" });
31
+ const task = list.tasks.find(t => t.description === "Release build");
32
+ const result = await tools.background_task({ action: "output", id: task.id });
33
+ text({ status: result.task.status, output: result.output });`;
34
+ else if (text.includes("SMOKE_LONG")) code = `text(await tools.background_task({ action: "start", command: "echo $$ > shell.pid; sleep 300 & echo $! > child.pid; wait", description: "Lifecycle cleanup check", timeout: 300 }));`;
35
+ }
36
+ const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: code ? "toolUse" : "stop",
37
+ usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
38
+ stream.push({ type: "start", partial: message });
39
+ if (code) {
40
+ message.content.push({ type: "toolCall", id: `fixture-${++sequence}`, name: "codemode", arguments: { code } });
41
+ stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
42
+ stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: message.content[0], partial: message });
43
+ } else {
44
+ const reply = text.includes("[Background task finished]") ? "BACKGROUND_FOLLOWUP_RECEIVED" : "Main agent is ready; accepted background tasks keep running.";
45
+ message.content.push({ type: "text", text: reply });
46
+ stream.push({ type: "text_start", contentIndex: 0, partial: message });
47
+ stream.push({ type: "text_delta", contentIndex: 0, delta: reply, partial: message });
48
+ stream.push({ type: "text_end", contentIndex: 0, content: reply, partial: message });
49
+ }
50
+ stream.push({ type: "done", reason: message.stopReason, message });
51
+ });
52
+ return stream;
53
+ },
54
+ });
55
+ pi.on("session_start", (_event, ctx) => {
56
+ record({ event: "loaded", tools: pi.getAllTools().map((tool) => tool.name) });
57
+ if (ctx.hasUI) ctx.ui.setTitle("Pi Background Tasks Smoke");
58
+ });
59
+ pi.on("tool_execution_end", (event) => record({ event: "tool_end", name: event.toolName, isError: event.isError, result: event.result }));
60
+ pi.on("message_start", (event) => {
61
+ if (event.message.role === "custom") record({ event: "custom", type: event.message.customType, details: event.message.details });
62
+ });
63
+ }