specpi 0.18.1 → 0.19.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 +6 -0
- package/README.md +11 -2
- package/SECURITY_MODEL.md +15 -1
- package/extensions/background-tasks/core.d.mts +90 -0
- package/extensions/background-tasks/core.mjs +413 -0
- package/extensions/background-tasks/index.ts +262 -0
- package/extensions/background-tasks/smoke.mjs +33 -0
- package/extensions/background-tasks/supervisor.mjs +33 -0
- package/extensions/command-guard/core.d.mts +12 -0
- package/extensions/command-guard/index.ts +74 -0
- package/package.json +1 -1
- package/scripts/specpi.mjs +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.0 - 2026-09-07
|
|
4
|
+
|
|
5
|
+
- Add session-owned background commands with exact interactive approvals, shared Command Guard admission, bounded logs, and best-effort process cleanup.
|
|
6
|
+
- Install and verify the background tools with an offline doctor smoke; document shell, environment, output-retention, and process-tree limits.
|
|
7
|
+
- Clarify repository guidance for concise commits, pull requests, and necessary risk-focused validation.
|
|
8
|
+
|
|
3
9
|
## 0.18.1 - 2026-09-07
|
|
4
10
|
|
|
5
11
|
- Restore Pi's cached model catalogs during delegation setup so switching to catalog-added or updated models resumes workers without a manual toggle or restart. Catalog network refresh stays disabled; exact-model checks, safety revocations and spent quotas remain enforced.
|
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ SpecPi extends Pi with chat, review tools, and focused delegation. Its principle
|
|
|
30
30
|
## What it adds
|
|
31
31
|
|
|
32
32
|
- **Focused delegation.** One agent makes changes. Up to two read-only subagents help investigate and review. [Research and design](https://tannermidd.github.io/SpecPi/single-agent/)
|
|
33
|
+
- **Background tasks.** Start an approved dev server, test suite, or watch build, inspect bounded output, and stop it without blocking other work.
|
|
33
34
|
- **Review as you work.** Track changed files, inspect diffs, check pages in a browser, and review risky commands.
|
|
34
35
|
- **Improvements you choose.** Record recurring problems, select one with `/harness-improvement`, and test the change before calling it done.
|
|
35
36
|
|
|
@@ -48,12 +49,20 @@ specpi doctor
|
|
|
48
49
|
|
|
49
50
|
Preview the changes, confirm the install, then check the setup. Restart Pi when finished.
|
|
50
51
|
|
|
51
|
-
To pin this release, use `npm install --global specpi@0.
|
|
52
|
+
To pin this release, use `npm install --global specpi@0.19.0`.
|
|
52
53
|
|
|
53
54
|
Delegation is enabled at startup. Use `/delegate off` to turn it off.
|
|
54
55
|
|
|
55
56
|
[Setup, updates & removal](https://tannermidd.github.io/SpecPi/wiki/#getting-started) · [Delegation settings](docs/delegation/README.md)
|
|
56
57
|
|
|
58
|
+
## Background tasks
|
|
59
|
+
|
|
60
|
+
Ask Pi to start a long-running command with `background_start`, then use `background_list`, `background_logs`, and `background_stop` to observe and clean up tasks. `background_start` accepts `command`, optional `cwd`/`label`, and `timeoutSeconds` (1–28,800; default 1,800). It requires interactive approval even with Guard off; Guard denials and locks still apply. Headless starts are denied.
|
|
61
|
+
|
|
62
|
+
Commands use `/bin/sh` on POSIX or system `cmd.exe` on Windows, not Pi's configured Bash. They inherit the process environment except `NODE_OPTIONS` and `NODE_PATH`, which are cleared to keep the supervisor's startup predictable. No PTY or interactive stdin is provided. Four active tasks are allowed; output is capped at 256 KiB per task, reads at 64 KiB, and completed records at 32. Log offsets are absolute bytes in the UTF-8 stream including stdout/stderr markers, before terminal-control escaping; responses report the next cursor and lost bytes.
|
|
63
|
+
|
|
64
|
+
Stop tasks when finished. Session replacement, reload, tree navigation, and shutdown attempt bounded cleanup. `cleanup: confirmed` means the owned root/group termination was observed, not that escaped descendants are contained; unconfirmed cleanup retains its slot and may need manual process inspection. Spawn success is not service readiness. Output is memory-only in the extension, but returned text may enter Pi conversation/provider retention. See the [security boundary](SECURITY_MODEL.md#background-task-execution).
|
|
65
|
+
|
|
57
66
|
## SpecPi Chat for VS Code
|
|
58
67
|
|
|
59
68
|
Chat beside your code, attach files, and switch conversations.
|
|
@@ -62,7 +71,7 @@ Chat beside your code, attach files, and switch conversations.
|
|
|
62
71
|
|
|
63
72
|
In VS Code, find **SpecPi Chat** by **tannermidd** in Extensions and install it. Open **SpecPi** in the Activity Bar, then **Connect Pi**.
|
|
64
73
|
|
|
65
|
-
Chat 0.4.1 shows a compact delegation strip only while workers run or settle, and opens workspace image links in the image viewer. SpecPi 0.
|
|
74
|
+
Chat 0.4.1 shows a compact delegation strip only while workers run or settle, and opens workspace image links in the image viewer. SpecPi 0.19.0 includes the delegation fix for switching models in the same chat. Update both packages, reload VS Code for the Chat update, and use **Restart Pi** to load the harness update. Later model switches do not require a restart. New Chat sessions start with Guard off; use `/guard guard` or `/guard strict` to enable it.
|
|
66
75
|
|
|
67
76
|
## Go further
|
|
68
77
|
|
package/SECURITY_MODEL.md
CHANGED
|
@@ -111,7 +111,7 @@ Global package installations, upstream package-manager effects, downloaded cache
|
|
|
111
111
|
|
|
112
112
|
## Command Guard
|
|
113
113
|
|
|
114
|
-
Command Guard is a pre-execution policy layer for model-initiated Pi tool calls. It covers Pi's documented `bash`, `powershell`, `read`, `write`, and `edit` tools. Other extensions, custom or MCP tools, direct user `!command` and `!!command` escapes, approved scripts, and process execution outside these seams are not contained.
|
|
114
|
+
Command Guard is a pre-execution policy layer for model-initiated Pi tool calls. It covers Pi's documented `bash`, `powershell`, `read`, `write`, and `edit` tools. Other extensions, custom or MCP tools, direct user `!command` and `!!command` escapes, approved scripts, and process execution outside these seams are not contained. SpecPi's background tools are an explicit reviewed integration: starts request Guard's shared admission policy again inside execution, while observation and owned-task cleanup remain available under locks.
|
|
115
115
|
|
|
116
116
|
Interactive top-level sessions choose one session-only mode:
|
|
117
117
|
|
|
@@ -129,6 +129,20 @@ The guard keeps mode, lock, counters, parser cache, and approval hashes only in
|
|
|
129
129
|
|
|
130
130
|
Static analysis cannot resolve every alias, generated command, script body, plugin, encoding, runtime expansion, symlink race, or interpreter behavior. Recognized private-path reads receive narrow protection, but arbitrary scripts or shell syntax can still read credentials. An allowed or approved command runs with the user's full permissions. `specpi doctor` verifies installed checksums and deterministic policy smoke behavior, not universal command safety.
|
|
131
131
|
|
|
132
|
+
## Background task execution
|
|
133
|
+
|
|
134
|
+
`background_start` executes an approved noninteractive command under the user's permissions, using `/bin/sh` on POSIX or system `cmd.exe /d /s /c` on Windows. It does not inherit Pi's Bash configuration or spawn hooks. POSIX commands are classified by Guard's Bash parser but executed by `/bin/sh` (often dash); the dialect mismatch can change parsing or runtime behavior. Classification is defense in depth, not proof of shell equivalence. The process environment is inherited except `NODE_OPTIONS` and `NODE_PATH`, cleared to prevent ambient Node startup hooks in the bundled supervisor. No provider authentication resolution, private Pi store inspection, or session-environment injection is performed. Approved processes can still access ambient secrets and same-user files; this is not credential isolation.
|
|
135
|
+
|
|
136
|
+
Starts require an active session and interactive UI, even for cached approvals and when Guard is Off. A synchronous event requests Guard's current mode, generation, and command decision. Guard/Strict critical denials and locks cannot be approved away; uncertainty is shown in the feature's approval prompt. An absent Guard falls back to the shared Guard-mode command policy. Unready, old, or ambiguous Guard responders fail closed. Trusted extensions and the shared event bus remain privileged, not an adversarial isolation boundary. Guard exempts the four background tools from its outer tool-call gate only when Pi's current tool-registration metadata identifies the sibling `background-tasks/index.ts` entry point. Missing, replaced, or foreign registrations retain ordinary Strict and locked checks; matching a name alone is insufficient. Genuine starts recheck admission inside execution, while observation/stop validate their own inputs and only address the extension's opaque task IDs. Registration provenance is not containment of a malicious trusted extension or a modified installed entry point.
|
|
137
|
+
|
|
138
|
+
Background approval is deliberately binary and session-scoped: v1 offers no allow-once option, unlike Guard's general approval menu. The confirmation explicitly grants the exact execution for the session. Approvals bind exact command, canonical cwd, shell policy, timeout, label, session generation, and Guard decision/generation. Inputs and policy are checked again immediately before launch, including cached approvals. Lifecycle and Guard changes invalidate pending prompts and approval state. Commands and approvals are not persisted by the extension. Directory replacement, changed scripts/executables, inherited environment changes, and filesystem races cannot be fully resolved by an exact command fingerprint; approval grants arbitrary execution, not an immutable executable snapshot.
|
|
139
|
+
|
|
140
|
+
The runner reserves at most four active/unconfirmed tasks before spawning. Command/cwd/label input limits are 16 KiB/4 KiB/128 UTF-8 bytes, approvals are capped at 128, and completed records at 32. Combined stdout/stderr retention is capped at 256 KiB per task with 64 KiB raw-output reads. Stream markers count toward absolute UTF-8 byte cursors; terminal controls are escaped after slicing, so serialized responses can be larger (bounded below 512 KiB). Lost bytes and truncation are explicit. Output and previews are untrusted; preview redaction is best effort, not a secrecy guarantee. There is no extension-owned command/output journal. Tool results can enter normal Pi conversation storage and model-provider retention, and executed programs can write files themselves. These caps do not bound child CPU, memory, disk, or network usage.
|
|
141
|
+
|
|
142
|
+
A bundled Node supervisor keeps the owned root alive after shell exit until cleanup. Deadlines (30 minutes by default, at most eight hours) initiate termination, not a guarantee of stopped work. POSIX uses a dedicated process group with a five-second SIGTERM grace then SIGKILL for running commands. If the command's exit or spawn failure was already observed when cleanup began, it skips that grace and cleans up the remaining group immediately; the supervisor still holds the root identity until escalation. Windows uses fixed-argv system `taskkill /T /F`. Cleanup observes the root exit and, on POSIX, process-group disappearance. It does not signal an observed-dead root again, reducing PID-reuse risk without eliminating OS races. Windows tree discovery can miss descendants after intermediate parent exit; detached/escaped descendants, orphaned zombies, crashes, denied signals, and forced host termination remain limitations. A confirmed cleanup covers only this supported root/group evidence, not universal descendant death. Failed/unconfirmed cleanup retains its slot and record while the extension instance lives.
|
|
143
|
+
|
|
144
|
+
Start cancellation attempts cleanup and returns its task outcome; cancelling log reads does not stop registered work. Shutdown (including reload/new/resume/fork) and tree navigation revoke approvals and attempt bounded cleanup. The UI reports uncertainty with blocking task IDs and recovery steps: starts stay disabled in that runtime, so retry `background_stop` for those IDs, then `/reload` after cleanup confirms. Old-instance memory and IDs do not survive replacement or a crash; inspect any remaining processes manually if the runtime has already changed. Stop remains available under policy locks and is idempotent. No automatic restart, persistence, or follow-up model turn is provided. Doctor checks installed checksums before running the bundled offline smoke; fixture success proves supported behavior, not OS containment.
|
|
145
|
+
|
|
132
146
|
## Local improvement state
|
|
133
147
|
|
|
134
148
|
Capability-gap collection is disabled until the user makes an explicit local on/off choice. When enabled, SpecPi stores bounded sanitized summaries and salted hashes used to measure distinct tasks, sessions, and projects. It does not read prompts, source files, sessions, history, credentials, provider authentication, or trust decisions to construct reports, and it never uploads wishlist state.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type StartSpec = Readonly<{
|
|
4
|
+
command: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
label: string;
|
|
7
|
+
timeoutSeconds: number;
|
|
8
|
+
shell: string;
|
|
9
|
+
dialect: "bash" | "cmd";
|
|
10
|
+
}>;
|
|
11
|
+
export type TaskSummary = {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
command: string;
|
|
15
|
+
status: "starting" | "running" | "stopping" | "cleanup-unconfirmed" | "failed" | "exited" | "killed";
|
|
16
|
+
elapsedMs: number;
|
|
17
|
+
exitCode: number | null;
|
|
18
|
+
exitSignal: string | null;
|
|
19
|
+
reason: string | null;
|
|
20
|
+
cleanup: "pending" | "confirmed" | "unconfirmed";
|
|
21
|
+
generation: number;
|
|
22
|
+
};
|
|
23
|
+
export type OwnedRoot = {
|
|
24
|
+
child?: Pick<ChildProcess, "pid">;
|
|
25
|
+
rootExited: boolean;
|
|
26
|
+
exitCode?: number | null;
|
|
27
|
+
};
|
|
28
|
+
export type Task = OwnedRoot & {
|
|
29
|
+
id: string;
|
|
30
|
+
spec: StartSpec;
|
|
31
|
+
generation: number;
|
|
32
|
+
startedAt: number;
|
|
33
|
+
endedAt?: number;
|
|
34
|
+
status: TaskSummary["status"];
|
|
35
|
+
cleanup: TaskSummary["cleanup"];
|
|
36
|
+
ring: OutputRing;
|
|
37
|
+
child?: ChildProcess;
|
|
38
|
+
exitSignal?: string | null;
|
|
39
|
+
reason?: string;
|
|
40
|
+
failed?: boolean;
|
|
41
|
+
stopping?: Promise<TaskSummary>;
|
|
42
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
43
|
+
};
|
|
44
|
+
export declare const LIMITS: Readonly<
|
|
45
|
+
Record<"command" | "cwd" | "label" | "active" | "completed" | "approvals" | "buffer" | "read" | "timeout", number>
|
|
46
|
+
>;
|
|
47
|
+
export declare function safeText(value: unknown): string;
|
|
48
|
+
export declare function preview(value: unknown, limit?: number): string;
|
|
49
|
+
export declare function record(value: unknown, keys: string[]): void;
|
|
50
|
+
export declare function shellSpec(platform?: NodeJS.Platform): Pick<StartSpec, "shell" | "dialect">;
|
|
51
|
+
export declare function normalizeStart(input: unknown, cwd: string): StartSpec;
|
|
52
|
+
export declare class OutputRing {
|
|
53
|
+
constructor(capacity?: number);
|
|
54
|
+
capacity: number;
|
|
55
|
+
bytes: Buffer;
|
|
56
|
+
end: number;
|
|
57
|
+
append(stream: "stdout" | "stderr", chunk: Buffer, final?: boolean): void;
|
|
58
|
+
append(stream: "stdout" | "stderr", chunk: undefined, final: true): void;
|
|
59
|
+
read(offset?: number): {
|
|
60
|
+
output: string;
|
|
61
|
+
offset: number;
|
|
62
|
+
nextOffset: number;
|
|
63
|
+
endOffset: number;
|
|
64
|
+
lostBytes: number;
|
|
65
|
+
truncated: boolean;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export declare function terminateOwned(
|
|
69
|
+
task: OwnedRoot,
|
|
70
|
+
options?: { graceMs?: number; observeMs?: number },
|
|
71
|
+
): Promise<boolean>;
|
|
72
|
+
export declare class TaskRunner {
|
|
73
|
+
constructor(options?: {
|
|
74
|
+
spawnProcess?: typeof spawn;
|
|
75
|
+
terminate?: (task: Task) => Promise<boolean>;
|
|
76
|
+
startupMs?: number;
|
|
77
|
+
});
|
|
78
|
+
tasks: Map<string, Task>;
|
|
79
|
+
closed: boolean;
|
|
80
|
+
spawnProcess: typeof spawn;
|
|
81
|
+
terminate: (task: Task) => Promise<boolean>;
|
|
82
|
+
startupMs: number;
|
|
83
|
+
get(id: string): Task;
|
|
84
|
+
summary(task: Task): TaskSummary;
|
|
85
|
+
list(): TaskSummary[];
|
|
86
|
+
evict(): void;
|
|
87
|
+
start(spec: StartSpec, generation: number, signal?: AbortSignal): Promise<TaskSummary>;
|
|
88
|
+
stop(id: string, reason?: string): Promise<TaskSummary>;
|
|
89
|
+
shutdown(): Promise<TaskSummary[]>;
|
|
90
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { spawn, execFile } from "node:child_process";
|
|
5
|
+
import { StringDecoder } from "node:string_decoder";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { redactCommand } from "../command-guard/redact.mjs";
|
|
8
|
+
|
|
9
|
+
export const LIMITS = Object.freeze({
|
|
10
|
+
command: 16384,
|
|
11
|
+
cwd: 4096,
|
|
12
|
+
label: 128,
|
|
13
|
+
active: 4,
|
|
14
|
+
completed: 32,
|
|
15
|
+
approvals: 128,
|
|
16
|
+
buffer: 262144,
|
|
17
|
+
read: 65536,
|
|
18
|
+
timeout: 28800,
|
|
19
|
+
});
|
|
20
|
+
const supervisor = fileURLToPath(new URL("./supervisor.mjs", import.meta.url));
|
|
21
|
+
const terminal = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu;
|
|
22
|
+
export function safeText(value) {
|
|
23
|
+
return String(value).replace(
|
|
24
|
+
terminal,
|
|
25
|
+
(character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function preview(value, limit = 320) {
|
|
30
|
+
return safeText(redactCommand(value, limit));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function record(value, keys) {
|
|
34
|
+
if (
|
|
35
|
+
!value ||
|
|
36
|
+
typeof value !== "object" ||
|
|
37
|
+
Array.isArray(value) ||
|
|
38
|
+
Object.keys(value).some((key) => !keys.includes(key))
|
|
39
|
+
) {
|
|
40
|
+
throw new Error("Invalid background task input.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function text(value, limit, name) {
|
|
45
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0") || Buffer.byteLength(value) > limit) {
|
|
46
|
+
throw new Error(`Invalid or oversized ${name}.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function shellSpec(platform = process.platform) {
|
|
53
|
+
if (platform === "win32") {
|
|
54
|
+
return { shell: path.join(process.env.SystemRoot || "C:\\Windows", "System32", "cmd.exe"), dialect: "cmd" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { shell: "/bin/sh", dialect: "bash" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeStart(input, cwd) {
|
|
61
|
+
record(input, ["command", "cwd", "label", "timeoutSeconds"]);
|
|
62
|
+
const command = text(input.command, LIMITS.command, "command");
|
|
63
|
+
const requestedCwd = text(input.cwd ?? cwd, LIMITS.cwd, "cwd");
|
|
64
|
+
let resolved;
|
|
65
|
+
try {
|
|
66
|
+
resolved = fs.realpathSync(path.resolve(cwd, requestedCwd));
|
|
67
|
+
if (!fs.statSync(resolved).isDirectory()) {
|
|
68
|
+
throw new Error();
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error("Background cwd must be an existing directory.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
text(resolved, LIMITS.cwd, "resolved cwd");
|
|
75
|
+
const label = input.label === undefined ? "" : text(input.label, LIMITS.label, "label");
|
|
76
|
+
const timeoutSeconds = input.timeoutSeconds ?? 1800;
|
|
77
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > LIMITS.timeout) {
|
|
78
|
+
throw new Error("timeoutSeconds must be an integer from 1 to 28800.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Object.freeze({ command, cwd: resolved, label, timeoutSeconds, ...shellSpec() });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class OutputRing {
|
|
85
|
+
constructor(capacity = LIMITS.buffer) {
|
|
86
|
+
this.capacity = capacity;
|
|
87
|
+
this.bytes = Buffer.alloc(0);
|
|
88
|
+
this.end = 0;
|
|
89
|
+
this.decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
|
|
90
|
+
}
|
|
91
|
+
append(stream, chunk, final = false) {
|
|
92
|
+
const decoded = final ? this.decoders[stream].end() : this.decoders[stream].write(chunk);
|
|
93
|
+
if (!decoded) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const next = Buffer.from(`[${stream}] ${decoded}`);
|
|
98
|
+
this.end += next.length;
|
|
99
|
+
if (next.length >= this.capacity) {
|
|
100
|
+
this.bytes = Buffer.from(next.subarray(next.length - this.capacity));
|
|
101
|
+
} else {
|
|
102
|
+
this.bytes = Buffer.concat([
|
|
103
|
+
this.bytes.subarray(Math.max(0, this.bytes.length + next.length - this.capacity)),
|
|
104
|
+
next,
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let skip = 0;
|
|
109
|
+
while (skip < this.bytes.length && (this.bytes[skip] & 0xc0) === 0x80) {
|
|
110
|
+
skip += 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.bytes = this.bytes.subarray(skip);
|
|
114
|
+
}
|
|
115
|
+
read(offset = 0) {
|
|
116
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > this.end) {
|
|
117
|
+
throw new Error("Invalid or future output cursor.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const oldest = this.end - this.bytes.length;
|
|
121
|
+
let start = Math.max(offset, oldest) - oldest;
|
|
122
|
+
while (start < this.bytes.length && (this.bytes[start] & 0xc0) === 0x80) {
|
|
123
|
+
start += 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let end = Math.min(this.bytes.length, start + LIMITS.read);
|
|
127
|
+
while (end < this.bytes.length && end > start && (this.bytes[end] & 0xc0) === 0x80) {
|
|
128
|
+
end -= 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
output: safeText(this.bytes.subarray(start, end).toString("utf8")),
|
|
133
|
+
offset: oldest + start,
|
|
134
|
+
nextOffset: oldest + end,
|
|
135
|
+
endOffset: this.end,
|
|
136
|
+
lostBytes: oldest + start - offset,
|
|
137
|
+
truncated: oldest + start > offset || end < this.bytes.length,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function delay(milliseconds) {
|
|
143
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function groupAlive(pid) {
|
|
147
|
+
try {
|
|
148
|
+
process.kill(-pid, 0);
|
|
149
|
+
|
|
150
|
+
return true;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return error.code !== "ESRCH";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function terminateOwned(task, { graceMs = 5000, observeMs = 1000 } = {}) {
|
|
157
|
+
if (!task.child?.pid) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pid = task.child.pid;
|
|
162
|
+
if (process.platform === "win32") {
|
|
163
|
+
// Never taskkill a PID after observing that the owned supervisor exited.
|
|
164
|
+
if (task.rootExited) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const helper = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe");
|
|
169
|
+
const ok = await new Promise((resolve) => {
|
|
170
|
+
execFile(
|
|
171
|
+
helper,
|
|
172
|
+
["/PID", String(pid), "/T", "/F"],
|
|
173
|
+
{ windowsHide: true, timeout: 5000, maxBuffer: 16384 },
|
|
174
|
+
(error) => resolve(!error),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
const end = Date.now() + observeMs;
|
|
178
|
+
while (!task.rootExited && Date.now() < end) {
|
|
179
|
+
await delay(20);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return ok && task.rootExited;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Preserve grace for a running command/tree. An observed command exit (including
|
|
186
|
+
// a null exit code for a signal/failure) needs only the remaining group cleanup.
|
|
187
|
+
// Keep the supervisor alive until escalation; never signal an observed-dead root.
|
|
188
|
+
const commandExited = task.exitCode !== undefined;
|
|
189
|
+
if (!task.rootExited) {
|
|
190
|
+
try {
|
|
191
|
+
process.kill(-pid, "SIGTERM");
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.code !== "ESRCH") {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!commandExited) {
|
|
199
|
+
await delay(graceMs);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!task.rootExited) {
|
|
203
|
+
try {
|
|
204
|
+
process.kill(-pid, "SIGKILL");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error.code !== "ESRCH") {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const end = Date.now() + observeMs;
|
|
214
|
+
while ((!task.rootExited || groupAlive(pid)) && Date.now() < end) {
|
|
215
|
+
await delay(20);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return task.rootExited && !groupAlive(pid);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export class TaskRunner {
|
|
222
|
+
constructor({ spawnProcess = spawn, terminate = terminateOwned, startupMs = 10000 } = {}) {
|
|
223
|
+
this.tasks = new Map();
|
|
224
|
+
this.spawnProcess = spawnProcess;
|
|
225
|
+
this.terminate = terminate;
|
|
226
|
+
this.startupMs = startupMs;
|
|
227
|
+
this.closed = false;
|
|
228
|
+
}
|
|
229
|
+
get(id) {
|
|
230
|
+
if (typeof id !== "string" || !this.tasks.has(id)) {
|
|
231
|
+
throw new Error("Unknown background task ID.");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return this.tasks.get(id);
|
|
235
|
+
}
|
|
236
|
+
summary(task) {
|
|
237
|
+
return {
|
|
238
|
+
id: task.id,
|
|
239
|
+
label: preview(task.spec.label, LIMITS.label),
|
|
240
|
+
command: preview(task.spec.command),
|
|
241
|
+
status: task.status,
|
|
242
|
+
elapsedMs: (task.endedAt ?? Date.now()) - task.startedAt,
|
|
243
|
+
exitCode: task.exitCode ?? null,
|
|
244
|
+
exitSignal: task.exitSignal ?? null,
|
|
245
|
+
reason: task.reason ?? null,
|
|
246
|
+
cleanup: task.cleanup,
|
|
247
|
+
generation: task.generation,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
list() {
|
|
251
|
+
return [...this.tasks.values()].map((task) => this.summary(task));
|
|
252
|
+
}
|
|
253
|
+
evict() {
|
|
254
|
+
const completed = [...this.tasks.values()].filter((task) => task.cleanup === "confirmed");
|
|
255
|
+
while (completed.length > LIMITS.completed) {
|
|
256
|
+
this.tasks.delete(completed.shift().id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async start(spec, generation, signal) {
|
|
260
|
+
signal?.throwIfAborted();
|
|
261
|
+
if (
|
|
262
|
+
this.closed ||
|
|
263
|
+
[...this.tasks.values()].filter((task) => task.cleanup !== "confirmed").length >= LIMITS.active
|
|
264
|
+
) {
|
|
265
|
+
throw new Error("Background admission closed or four active/unconfirmed tasks already exist.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const task = {
|
|
269
|
+
id: randomUUID(),
|
|
270
|
+
spec,
|
|
271
|
+
generation,
|
|
272
|
+
startedAt: Date.now(),
|
|
273
|
+
status: "starting",
|
|
274
|
+
cleanup: "pending",
|
|
275
|
+
ring: new OutputRing(),
|
|
276
|
+
rootExited: false,
|
|
277
|
+
};
|
|
278
|
+
// Reserve synchronously before any await, including supervisor startup.
|
|
279
|
+
this.tasks.set(task.id, task);
|
|
280
|
+
let settle;
|
|
281
|
+
const started = new Promise((resolve) => {
|
|
282
|
+
settle = resolve;
|
|
283
|
+
});
|
|
284
|
+
const abort = () => {
|
|
285
|
+
void this.stop(task.id, "start cancelled");
|
|
286
|
+
settle(false);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
290
|
+
try {
|
|
291
|
+
task.child = this.spawnProcess(process.execPath, [supervisor], {
|
|
292
|
+
cwd: spec.cwd,
|
|
293
|
+
env: { ...process.env, NODE_OPTIONS: "", NODE_PATH: "" },
|
|
294
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
295
|
+
detached: process.platform !== "win32",
|
|
296
|
+
windowsHide: true,
|
|
297
|
+
});
|
|
298
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
299
|
+
task.child[stream].on("data", (chunk) => task.ring.append(stream, chunk));
|
|
300
|
+
task.child[stream].once("end", () => task.ring.append(stream, undefined, true));
|
|
301
|
+
task.child[stream].on("error", () => {});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
task.child.once("error", () => {
|
|
305
|
+
task.failed = true;
|
|
306
|
+
task.rootExited = true;
|
|
307
|
+
settle(false);
|
|
308
|
+
void this.stop(task.id, "spawn failed");
|
|
309
|
+
});
|
|
310
|
+
task.child.once("exit", () => {
|
|
311
|
+
task.rootExited = true;
|
|
312
|
+
settle(false);
|
|
313
|
+
if (!task.stopping) {
|
|
314
|
+
void this.stop(task.id, "supervisor exited unexpectedly");
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
task.child.on("message", (message) => {
|
|
318
|
+
if (message?.event === "ready" && task.status === "starting") {
|
|
319
|
+
const args =
|
|
320
|
+
process.platform === "win32" ? ["/d", "/s", "/c", `"${spec.command}"`] : ["-c", spec.command];
|
|
321
|
+
task.child.send({ operation: "start", shell: spec.shell, args, cwd: spec.cwd }, (error) => {
|
|
322
|
+
if (error) {
|
|
323
|
+
settle(false);
|
|
324
|
+
void this.stop(task.id, "startup failed");
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
} else if (message?.event === "started") {
|
|
328
|
+
if (task.status === "starting") {
|
|
329
|
+
task.status = "running";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
settle(true);
|
|
333
|
+
} else if (message?.event === "exited" || message?.event === "failed") {
|
|
334
|
+
task.exitCode = Number.isInteger(message.code) ? message.code : null;
|
|
335
|
+
task.exitSignal = typeof message.signal === "string" ? message.signal.slice(0, 32) : null;
|
|
336
|
+
task.failed = message.event === "failed";
|
|
337
|
+
settle(!task.failed);
|
|
338
|
+
void this.stop(task.id, task.failed ? "command spawn failed" : "command exited");
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
task.timer = setTimeout(() => {
|
|
342
|
+
void this.stop(task.id, "timeout");
|
|
343
|
+
}, spec.timeoutSeconds * 1000);
|
|
344
|
+
const startupTimer = setTimeout(() => settle(false), this.startupMs);
|
|
345
|
+
const success = await started;
|
|
346
|
+
clearTimeout(startupTimer);
|
|
347
|
+
if (!success || signal?.aborted || this.closed) {
|
|
348
|
+
await this.stop(task.id, signal?.aborted ? "start cancelled" : "startup failed");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return this.summary(task);
|
|
352
|
+
} catch {
|
|
353
|
+
task.failed = true;
|
|
354
|
+
await this.stop(task.id, "spawn failed");
|
|
355
|
+
|
|
356
|
+
return this.summary(task);
|
|
357
|
+
} finally {
|
|
358
|
+
signal?.removeEventListener("abort", abort);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async stop(id, reason = "stop requested") {
|
|
362
|
+
const task = this.get(id);
|
|
363
|
+
if (task.cleanup === "confirmed") {
|
|
364
|
+
return this.summary(task);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (task.stopping) {
|
|
368
|
+
return task.stopping;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
task.status = "stopping";
|
|
372
|
+
task.reason ??= reason;
|
|
373
|
+
clearTimeout(task.timer);
|
|
374
|
+
task.stopping = (async () => {
|
|
375
|
+
let confirmed = false;
|
|
376
|
+
try {
|
|
377
|
+
confirmed = await this.terminate(task);
|
|
378
|
+
} catch {
|
|
379
|
+
// Failure is evidence of uncertainty, never evidence of death.
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
task.cleanup = confirmed ? "confirmed" : "unconfirmed";
|
|
383
|
+
task.status = !confirmed
|
|
384
|
+
? "cleanup-unconfirmed"
|
|
385
|
+
: task.failed
|
|
386
|
+
? "failed"
|
|
387
|
+
: task.reason === "command exited"
|
|
388
|
+
? "exited"
|
|
389
|
+
: "killed";
|
|
390
|
+
if (confirmed) {
|
|
391
|
+
task.endedAt = Date.now();
|
|
392
|
+
task.child?.stdout?.destroy();
|
|
393
|
+
task.child?.stderr?.destroy();
|
|
394
|
+
if (task.child?.connected) {
|
|
395
|
+
task.child.disconnect();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
this.evict();
|
|
400
|
+
|
|
401
|
+
return this.summary(task);
|
|
402
|
+
})();
|
|
403
|
+
const result = await task.stopping;
|
|
404
|
+
task.stopping = undefined;
|
|
405
|
+
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
async shutdown() {
|
|
409
|
+
this.closed = true;
|
|
410
|
+
|
|
411
|
+
return Promise.all([...this.tasks.keys()].map((id) => this.stop(id, "session cleanup")));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { decideCommand } from "../command-guard/core.mjs";
|
|
5
|
+
import { LIMITS, TaskRunner, normalizeStart, preview, record } from "./core.mjs";
|
|
6
|
+
|
|
7
|
+
function fingerprint(value: unknown): string {
|
|
8
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function result(value: unknown) {
|
|
12
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: {} };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function registerBackgroundTasks(
|
|
16
|
+
pi: ExtensionAPI,
|
|
17
|
+
dependencies: {
|
|
18
|
+
runner?: Pick<TaskRunner, "closed" | "list" | "shutdown" | "start" | "get" | "stop">;
|
|
19
|
+
approvalMs?: number;
|
|
20
|
+
} = {},
|
|
21
|
+
) {
|
|
22
|
+
let runner = dependencies.runner ?? new TaskRunner();
|
|
23
|
+
let generation = 0;
|
|
24
|
+
let active = false;
|
|
25
|
+
let pending = 0;
|
|
26
|
+
const approvals = new Set<string>();
|
|
27
|
+
let lifecycle = new AbortController();
|
|
28
|
+
const invalidate = () => {
|
|
29
|
+
generation += 1;
|
|
30
|
+
approvals.clear();
|
|
31
|
+
lifecycle.abort();
|
|
32
|
+
lifecycle = new AbortController();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let unsubscribe: (() => void) | undefined;
|
|
36
|
+
const subscribe = () => {
|
|
37
|
+
unsubscribe ??= pi.events.on("specpi:guard-policy-changed", invalidate);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
subscribe();
|
|
41
|
+
const admission = (spec: any, hasUI: boolean) => {
|
|
42
|
+
let stateReplies = 0;
|
|
43
|
+
pi.events.emit("specpi:guard-state", {
|
|
44
|
+
reply() {
|
|
45
|
+
stateReplies += 1;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const replies: any[] = [];
|
|
49
|
+
pi.events.emit("specpi:background-admission", {
|
|
50
|
+
input: { command: spec.command },
|
|
51
|
+
cwd: spec.cwd,
|
|
52
|
+
shell: spec.dialect,
|
|
53
|
+
hasUI,
|
|
54
|
+
reply(value: unknown) {
|
|
55
|
+
replies.push(value);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (stateReplies === 0 && replies.length === 0) {
|
|
59
|
+
const decision = decideCommand(spec.command, {
|
|
60
|
+
mode: "guard",
|
|
61
|
+
shell: spec.dialect,
|
|
62
|
+
cwd: spec.cwd,
|
|
63
|
+
platform: process.platform,
|
|
64
|
+
hasUI,
|
|
65
|
+
cache: false,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return { mode: "absent-guard-policy", generation: 0, action: decision.action, reason: decision.reason };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const policy = replies[0];
|
|
72
|
+
if (
|
|
73
|
+
stateReplies !== 1 ||
|
|
74
|
+
replies.length !== 1 ||
|
|
75
|
+
!["guard", "strict", "off", "locked"].includes(policy?.mode) ||
|
|
76
|
+
!Number.isSafeInteger(policy?.generation) ||
|
|
77
|
+
!["allow", "ask", "deny"].includes(policy?.action)
|
|
78
|
+
) {
|
|
79
|
+
throw new Error("Background Guard policy is unavailable or ambiguous; start denied.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return policy;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const cleanup = async (_event: unknown, ctx: any) => {
|
|
86
|
+
active = false;
|
|
87
|
+
invalidate();
|
|
88
|
+
const outcomes = await runner.shutdown();
|
|
89
|
+
const unconfirmed = outcomes.filter((task: any) => task.cleanup !== "confirmed");
|
|
90
|
+
if (unconfirmed.length) {
|
|
91
|
+
ctx.ui.notify(
|
|
92
|
+
`Background cleanup unconfirmed for ${unconfirmed.map((task: any) => task.id).join(", ")}. Processes may still be running. background_start is disabled in this runtime: retry background_stop for these IDs, then /reload after cleanup confirms. IDs do not survive runtime replacement; inspect any remaining processes manually.`,
|
|
93
|
+
"warning",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
99
|
+
await cleanup(event, ctx);
|
|
100
|
+
unsubscribe?.();
|
|
101
|
+
unsubscribe = undefined;
|
|
102
|
+
});
|
|
103
|
+
pi.on("session_start", async (event, ctx) => {
|
|
104
|
+
subscribe();
|
|
105
|
+
if (runner.list().length) {
|
|
106
|
+
await cleanup(event, ctx);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (runner.list().some((task: any) => task.cleanup !== "confirmed")) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (runner.closed) {
|
|
114
|
+
runner = dependencies.runner ?? new TaskRunner();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
invalidate();
|
|
118
|
+
active = true;
|
|
119
|
+
});
|
|
120
|
+
pi.on("session_tree", async (event, ctx) => {
|
|
121
|
+
await cleanup(event, ctx);
|
|
122
|
+
if (!runner.list().some((task: any) => task.cleanup !== "confirmed")) {
|
|
123
|
+
runner = dependencies.runner ?? new TaskRunner();
|
|
124
|
+
active = true;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.registerTool({
|
|
129
|
+
name: "background_start",
|
|
130
|
+
label: "Start background task",
|
|
131
|
+
description:
|
|
132
|
+
"Start a session-owned noninteractive command after explicit approval. Uses /bin/sh on POSIX and system cmd.exe on Windows, not Pi Bash configuration. Four active tasks; 30 minute default timeout, eight hour maximum. Inherits the process environment. Not a sandbox or proof of service readiness.",
|
|
133
|
+
promptSnippet: "Start an approved long-running command while other work continues",
|
|
134
|
+
promptGuidelines: [
|
|
135
|
+
"Never use background_start to reroute a denied command. Use background_logs to observe progress and background_stop when a task is no longer needed. Spawn success is not readiness or completion evidence.",
|
|
136
|
+
],
|
|
137
|
+
parameters: Type.Object(
|
|
138
|
+
{
|
|
139
|
+
command: Type.String({ minLength: 1, maxLength: LIMITS.command }),
|
|
140
|
+
cwd: Type.Optional(Type.String({ minLength: 1, maxLength: LIMITS.cwd })),
|
|
141
|
+
label: Type.Optional(Type.String({ minLength: 1, maxLength: LIMITS.label })),
|
|
142
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: LIMITS.timeout })),
|
|
143
|
+
},
|
|
144
|
+
{ additionalProperties: false },
|
|
145
|
+
),
|
|
146
|
+
async execute(_id, input, signal, _update, ctx) {
|
|
147
|
+
if (!active || !ctx.hasUI || pending >= LIMITS.active) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
"Background starts require an active session, approval UI, and an available admission slot.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pending += 1;
|
|
154
|
+
try {
|
|
155
|
+
const spec = normalizeStart(input, ctx.cwd);
|
|
156
|
+
const policy = admission(spec, ctx.hasUI);
|
|
157
|
+
if (policy.action === "deny") {
|
|
158
|
+
throw new Error(preview(policy.reason));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const epoch = generation;
|
|
162
|
+
const key = fingerprint({ spec, policy, generation: epoch });
|
|
163
|
+
const abort = AbortSignal.any([lifecycle.signal, ...(signal ? [signal] : [])]);
|
|
164
|
+
abort.throwIfAborted();
|
|
165
|
+
if (!approvals.has(key)) {
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
const promptSignal = AbortSignal.any([abort, controller.signal]);
|
|
168
|
+
const timer = setTimeout(() => controller.abort(), dependencies.approvalMs ?? 600000);
|
|
169
|
+
let cancel: () => void = () => {};
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const cancelled = new Promise<boolean>((resolve) => {
|
|
173
|
+
cancel = () => resolve(false);
|
|
174
|
+
promptSignal.addEventListener("abort", cancel, { once: true });
|
|
175
|
+
});
|
|
176
|
+
const confirmed = await Promise.race([
|
|
177
|
+
ctx.ui.confirm(
|
|
178
|
+
"Start background command for this session?",
|
|
179
|
+
`Shell: ${preview(spec.shell)}\nCwd: ${preview(spec.cwd, LIMITS.cwd)}\nCommand: ${preview(spec.command, LIMITS.command)}\nTimeout: ${spec.timeoutSeconds}s\nGuard: ${preview(policy.mode)} — ${preview(policy.reason)}\nRuns with your permissions and inherited environment. Not a sandbox. Approves this exact execution for this session; output may enter conversation/provider retention.`,
|
|
180
|
+
{ signal: promptSignal },
|
|
181
|
+
),
|
|
182
|
+
cancelled,
|
|
183
|
+
]);
|
|
184
|
+
if (!confirmed || promptSignal.aborted) {
|
|
185
|
+
throw new Error("Background command was not approved.");
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
promptSignal.removeEventListener("abort", cancel);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
abort.throwIfAborted();
|
|
194
|
+
const current = normalizeStart(input, ctx.cwd);
|
|
195
|
+
const currentPolicy = admission(current, ctx.hasUI);
|
|
196
|
+
if (
|
|
197
|
+
!active ||
|
|
198
|
+
!ctx.hasUI ||
|
|
199
|
+
epoch !== generation ||
|
|
200
|
+
currentPolicy.action === "deny" ||
|
|
201
|
+
fingerprint({ spec: current, policy: currentPolicy, generation }) !== key
|
|
202
|
+
) {
|
|
203
|
+
throw new Error("Background input or policy changed during approval; start denied.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
while (approvals.size >= LIMITS.approvals && !approvals.has(key)) {
|
|
207
|
+
approvals.delete(approvals.values().next().value!);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
approvals.add(key);
|
|
211
|
+
|
|
212
|
+
return result(await runner.start(current, generation, abort));
|
|
213
|
+
} finally {
|
|
214
|
+
pending -= 1;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
pi.registerTool({
|
|
219
|
+
name: "background_list",
|
|
220
|
+
label: "List background tasks",
|
|
221
|
+
description:
|
|
222
|
+
"List at most four active and 32 completed session tasks with bounded redacted previews and cleanup outcomes.",
|
|
223
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
224
|
+
async execute(_id, input) {
|
|
225
|
+
record(input, []);
|
|
226
|
+
|
|
227
|
+
return result(runner.list());
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
pi.registerTool({
|
|
231
|
+
name: "background_logs",
|
|
232
|
+
label: "Read background output",
|
|
233
|
+
description:
|
|
234
|
+
"Read untrusted memory-only task output after an absolute byte offset. At most 64 KiB raw output per read from a 256 KiB ring; stream markers count toward offsets. Terminal controls are escaped; truncation and lost bytes are explicit. Returned text may enter conversation/provider retention.",
|
|
235
|
+
parameters: Type.Object(
|
|
236
|
+
{
|
|
237
|
+
id: Type.String({ minLength: 1, maxLength: 36 }),
|
|
238
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })),
|
|
239
|
+
},
|
|
240
|
+
{ additionalProperties: false },
|
|
241
|
+
),
|
|
242
|
+
async execute(_id, input, signal) {
|
|
243
|
+
record(input, ["id", "offset"]);
|
|
244
|
+
signal?.throwIfAborted();
|
|
245
|
+
const task = runner.get(input.id);
|
|
246
|
+
|
|
247
|
+
return result({ id: task.id, status: task.status, ...task.ring.read(input.offset) });
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
pi.registerTool({
|
|
251
|
+
name: "background_stop",
|
|
252
|
+
label: "Stop background task",
|
|
253
|
+
description:
|
|
254
|
+
"Idempotently request bounded termination of an owned task. Available under Guard locks. Reports confirmed or unconfirmed cleanup; escaped descendants are not contained.",
|
|
255
|
+
parameters: Type.Object({ id: Type.String({ minLength: 1, maxLength: 36 }) }, { additionalProperties: false }),
|
|
256
|
+
async execute(_id, input) {
|
|
257
|
+
record(input, ["id"]);
|
|
258
|
+
|
|
259
|
+
return result(await runner.stop(input.id));
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { TaskRunner, OutputRing, normalizeStart, terminateOwned } from "./core.mjs";
|
|
3
|
+
|
|
4
|
+
const ring = new OutputRing(32);
|
|
5
|
+
ring.append("stdout", Buffer.from("x".repeat(100)));
|
|
6
|
+
assert.equal(ring.read().lostBytes > 0, true);
|
|
7
|
+
const runner = new TaskRunner({ terminate: (task) => terminateOwned(task, { graceMs: 20, observeMs: 2000 }) });
|
|
8
|
+
try {
|
|
9
|
+
const spec = normalizeStart(
|
|
10
|
+
{ command: `"${process.execPath}" -e "process.stdout.write('background-smoke');setInterval(()=>{},1000)"` },
|
|
11
|
+
process.cwd(),
|
|
12
|
+
);
|
|
13
|
+
const started = await runner.start(spec, 1);
|
|
14
|
+
assert.equal(started.status, "running");
|
|
15
|
+
const task = runner.get(started.id);
|
|
16
|
+
await new Promise((resolve, reject) => {
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
clearInterval(poll);
|
|
19
|
+
reject(new Error("Smoke output deadline exceeded"));
|
|
20
|
+
}, 10000);
|
|
21
|
+
const poll = setInterval(() => {
|
|
22
|
+
if (task.ring.read().output.includes("background-smoke")) {
|
|
23
|
+
clearInterval(poll);
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolve();
|
|
26
|
+
}
|
|
27
|
+
}, 20);
|
|
28
|
+
});
|
|
29
|
+
assert.equal((await runner.stop(started.id)).cleanup, "confirmed");
|
|
30
|
+
console.log("BACKGROUND_TASKS_SMOKE=passed");
|
|
31
|
+
} finally {
|
|
32
|
+
await runner.shutdown();
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// An owned root stays alive until the parent completes bounded tree cleanup.
|
|
2
|
+
// Never load project modules or configuration here. Commands arrive once over IPC.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
let started = false;
|
|
6
|
+
process.on("message", (message) => {
|
|
7
|
+
if (started || message?.operation !== "start") {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
started = true;
|
|
12
|
+
try {
|
|
13
|
+
const child = spawn(message.shell, message.args, {
|
|
14
|
+
cwd: message.cwd,
|
|
15
|
+
env: process.env,
|
|
16
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
windowsVerbatimArguments: process.platform === "win32",
|
|
19
|
+
});
|
|
20
|
+
child.once("spawn", () => process.send?.({ event: "started" }));
|
|
21
|
+
child.once("error", () => process.send?.({ event: "failed" }));
|
|
22
|
+
child.once("exit", (code, signal) => process.send?.({ event: "exited", code, signal }));
|
|
23
|
+
} catch {
|
|
24
|
+
process.send?.({ event: "failed" });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
// Keep the root alive during POSIX group grace so its identity cannot be reused.
|
|
28
|
+
process.on("SIGTERM", () => {});
|
|
29
|
+
process.on("disconnect", () => {
|
|
30
|
+
// A crashed parent cannot provide reliable tree cleanup. Do not persist a daemon.
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
33
|
+
process.send?.({ event: "ready" });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Narrow surface used by the background extension's scoped type gate, not the full Guard API.
|
|
2
|
+
export declare function decideCommand(
|
|
3
|
+
command: unknown,
|
|
4
|
+
options?: {
|
|
5
|
+
mode?: "guard" | "strict" | "off" | "locked";
|
|
6
|
+
shell?: "bash" | "cmd" | "powershell";
|
|
7
|
+
cwd?: string;
|
|
8
|
+
platform?: NodeJS.Platform;
|
|
9
|
+
hasUI?: boolean;
|
|
10
|
+
cache?: boolean;
|
|
11
|
+
},
|
|
12
|
+
): { action: "allow" | "ask" | "deny"; reason: string };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { clearAnalysisCache, decideCommand, decidePath } from "./core.mjs";
|
|
5
6
|
import { boundedReason } from "./redact.mjs";
|
|
@@ -182,8 +183,54 @@ export default function registerCommandGuard(
|
|
|
182
183
|
rules: {},
|
|
183
184
|
};
|
|
184
185
|
state.onModeChanged = () => pi.events?.emit("specpi:guard-policy-changed", { reason: "guard policy changed" });
|
|
186
|
+
let backgroundSubscription: (() => void) | undefined;
|
|
185
187
|
let guardStateSubscription: (() => void) | undefined;
|
|
186
188
|
const subscribeGuardState = () => {
|
|
189
|
+
if (!backgroundSubscription) {
|
|
190
|
+
backgroundSubscription = pi.events?.on?.("specpi:background-admission", (request: any) => {
|
|
191
|
+
if (typeof request?.reply !== "function") {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const unavailable = !state.ready || state.startupFailed || state.mode === "locked";
|
|
196
|
+
if (
|
|
197
|
+
unavailable ||
|
|
198
|
+
!validCommandInput(request.input) ||
|
|
199
|
+
typeof request.cwd !== "string" ||
|
|
200
|
+
!["bash", "cmd"].includes(request.shell)
|
|
201
|
+
) {
|
|
202
|
+
request.reply({
|
|
203
|
+
mode: state.mode,
|
|
204
|
+
generation: state.generation,
|
|
205
|
+
action: "deny",
|
|
206
|
+
reason: "Command guard is locked, unavailable, or received invalid background input.",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const decision = decideCommand(request.input.command, {
|
|
213
|
+
mode: state.mode,
|
|
214
|
+
shell: request.shell,
|
|
215
|
+
cwd: request.cwd,
|
|
216
|
+
platform: process.platform,
|
|
217
|
+
hasUI: request.hasUI === true,
|
|
218
|
+
cache: false,
|
|
219
|
+
});
|
|
220
|
+
recordDecision(state, decision);
|
|
221
|
+
if (decision.action === "deny") {
|
|
222
|
+
deny(state, decision.reason, decision.lockSession === true);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
request.reply({
|
|
226
|
+
mode: state.mode,
|
|
227
|
+
generation: state.generation,
|
|
228
|
+
action: decision.action,
|
|
229
|
+
reason: decision.reason,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
187
234
|
if (!guardStateSubscription) {
|
|
188
235
|
guardStateSubscription = pi.events?.on?.("specpi:guard-state", (request: any) => {
|
|
189
236
|
request.reply({ mode: state.ready && !state.startupFailed ? state.mode : undefined });
|
|
@@ -192,6 +239,24 @@ export default function registerCommandGuard(
|
|
|
192
239
|
};
|
|
193
240
|
|
|
194
241
|
subscribeGuardState();
|
|
242
|
+
const backgroundSource = fileURLToPath(new URL("../background-tasks/index.ts", import.meta.url));
|
|
243
|
+
const ownsBackgroundTool = (name: string): boolean => {
|
|
244
|
+
if (!["background_start", "background_list", "background_logs", "background_stop"].includes(name)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const matches = pi.getAllTools?.().filter((tool) => tool.name === name) ?? [];
|
|
249
|
+
const source = matches.length === 1 ? matches[0].sourceInfo?.path : undefined;
|
|
250
|
+
if (typeof source !== "string" || !path.isAbsolute(source)) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const actual = path.resolve(source);
|
|
255
|
+
const expected = path.resolve(backgroundSource);
|
|
256
|
+
|
|
257
|
+
return process.platform === "win32" ? actual.toLowerCase() === expected.toLowerCase() : actual === expected;
|
|
258
|
+
};
|
|
259
|
+
|
|
195
260
|
const delegationPolicy = (input: unknown): { fingerprint: string; summary: string } | undefined => {
|
|
196
261
|
let replies = 0;
|
|
197
262
|
let policy: any;
|
|
@@ -285,6 +350,8 @@ export default function registerCommandGuard(
|
|
|
285
350
|
});
|
|
286
351
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
287
352
|
reset();
|
|
353
|
+
backgroundSubscription?.();
|
|
354
|
+
backgroundSubscription = undefined;
|
|
288
355
|
if (typeof guardStateSubscription === "function") {
|
|
289
356
|
guardStateSubscription();
|
|
290
357
|
guardStateSubscription = undefined;
|
|
@@ -443,6 +510,13 @@ export default function registerCommandGuard(
|
|
|
443
510
|
return deny(state, "Malformed tool call.");
|
|
444
511
|
}
|
|
445
512
|
|
|
513
|
+
// Check Pi's current registration provenance, not a tool name or cached handshake.
|
|
514
|
+
// Genuine background tools validate again in execute; cleanup remains usable under a lock.
|
|
515
|
+
// A missing or replaced registration retains normal Strict/locked enforcement.
|
|
516
|
+
if (ownsBackgroundTool(name)) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
446
520
|
if (!state.ready || state.startupFailed) {
|
|
447
521
|
return deny(state, "Command guard is not initialized; protected tool calls are denied.");
|
|
448
522
|
}
|
package/package.json
CHANGED
package/scripts/specpi.mjs
CHANGED
|
@@ -46,6 +46,8 @@ const browserRuntimeSourceDir = path.join(repoRoot, "browser-runtime");
|
|
|
46
46
|
const browserRuntimeDir = path.join(stateDir, "browser-runtime");
|
|
47
47
|
const browserRuntimeMarker = path.join(browserRuntimeDir, "specpi-runtime.json");
|
|
48
48
|
const browserSmokePath = path.join(agentDir, "extensions", "browser", "smoke.mjs");
|
|
49
|
+
const backgroundFiles = ["index.ts", "core.mjs", "supervisor.mjs", "smoke.mjs"];
|
|
50
|
+
const backgroundRoot = path.join(agentDir, "extensions", "background-tasks");
|
|
49
51
|
const capabilityRegistryPath = path.join(agentDir, "extensions", "tool-wishlist", "capabilities.json");
|
|
50
52
|
const managedToolsDir = path.join(stateDir, "optional-tools");
|
|
51
53
|
const managedBinDir = path.join(stateDir, "bin");
|
|
@@ -752,6 +754,11 @@ function desiredSettingsOperations() {
|
|
|
752
754
|
|
|
753
755
|
function managedFiles(includeShell) {
|
|
754
756
|
const files = [
|
|
757
|
+
...backgroundFiles.map((name) => [
|
|
758
|
+
path.join(repoRoot, "extensions", "background-tasks", name),
|
|
759
|
+
path.join(backgroundRoot, name),
|
|
760
|
+
0o644,
|
|
761
|
+
]),
|
|
755
762
|
[path.join(repoRoot, "extensions", "spec.ts"), path.join(agentDir, "extensions", "spec.ts"), 0o644],
|
|
756
763
|
[
|
|
757
764
|
path.join(repoRoot, "extensions", "spec", "core.mjs"),
|
|
@@ -1134,6 +1141,7 @@ async function confirm(message, yes) {
|
|
|
1134
1141
|
|
|
1135
1142
|
function assertSources() {
|
|
1136
1143
|
const required = [
|
|
1144
|
+
...backgroundFiles.map((name) => `extensions/background-tasks/${name}`),
|
|
1137
1145
|
"extensions/spec.ts",
|
|
1138
1146
|
"extensions/spec/core.mjs",
|
|
1139
1147
|
"extensions/ui-refresh/index.ts",
|
|
@@ -1937,6 +1945,29 @@ async function doctor() {
|
|
|
1937
1945
|
}
|
|
1938
1946
|
}
|
|
1939
1947
|
|
|
1948
|
+
const backgroundIntegrity = backgroundFiles.every((name) => {
|
|
1949
|
+
const target = path.join(backgroundRoot, name);
|
|
1950
|
+
const record = manifest.files?.[target];
|
|
1951
|
+
|
|
1952
|
+
return record && fs.existsSync(target) && sha256(fs.readFileSync(target)) === record.installedHash;
|
|
1953
|
+
});
|
|
1954
|
+
if (!backgroundIntegrity || !commandGuardIntegrity) {
|
|
1955
|
+
errors.push("Background task smoke skipped: installed background/Guard checksum integrity failed.");
|
|
1956
|
+
} else {
|
|
1957
|
+
const smoke = spawnSync(process.execPath, [path.join(backgroundRoot, "smoke.mjs")], {
|
|
1958
|
+
cwd: agentDir,
|
|
1959
|
+
encoding: "utf8",
|
|
1960
|
+
timeout: 30000,
|
|
1961
|
+
maxBuffer: 32768,
|
|
1962
|
+
env: { ...process.env, NODE_OPTIONS: "", NODE_PATH: "" },
|
|
1963
|
+
});
|
|
1964
|
+
if (smoke.status === 0 && smoke.stdout.includes("BACKGROUND_TASKS_SMOKE=passed")) {
|
|
1965
|
+
console.log("BACKGROUND_TASKS_SMOKE=passed");
|
|
1966
|
+
} else {
|
|
1967
|
+
errors.push("Installed background task smoke failed or timed out.");
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1940
1971
|
const runtimeStatus = browserRuntimeStatus();
|
|
1941
1972
|
let browserSmoke;
|
|
1942
1973
|
if (!runtimeStatus.installed) {
|