specpi 0.18.1 → 0.19.1

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.1 - 2026-09-08
4
+
5
+ - Allow Pi's exact public Copilot catalog identification headers during delegation, fixing Copilot → Anthropic → Copilot switches that were incorrectly rejected as runtime provider overrides. Reconstruct headers independently in the child; retain runtime-auth, extension-provider and other header restrictions. Restart Pi after updating the harness.
6
+
7
+ ## 0.19.0 - 2026-09-07
8
+
9
+ - Add session-owned background commands with exact interactive approvals, shared Command Guard admission, bounded logs, and best-effort process cleanup.
10
+ - Install and verify the background tools with an offline doctor smoke; document shell, environment, output-retention, and process-tree limits.
11
+ - Clarify repository guidance for concise commits, pull requests, and necessary risk-focused validation.
12
+
3
13
  ## 0.18.1 - 2026-09-07
4
14
 
5
15
  - 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,7 +49,7 @@ 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.18.1`.
52
+ To pin this release, use `npm install --global specpi@0.19.1`.
52
53
 
53
54
  Delegation is enabled at startup. Use `/delegate off` to turn it off.
54
55
 
@@ -62,7 +63,7 @@ Chat beside your code, attach files, and switch conversations.
62
63
 
63
64
  In VS Code, find **SpecPi Chat** by **tannermidd** in Extensions and install it. Open **SpecPi** in the Activity Bar, then **Connect Pi**.
64
65
 
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.18.1 fixes delegation after 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
+ 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.1 fixes delegation when switching GitHub Copilot models in the same chat, including Luna Opus Luna. Update the harness and use **Restart Pi** to load the fix; this patch does not require a Chat 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
67
 
67
68
  ## Go further
68
69
 
package/SECURITY_MODEL.md CHANGED
@@ -35,7 +35,7 @@ not a new provider-disclosure boundary.
35
35
 
36
36
  Delegation is a native extension discovered through the ordinary Pi package and SpecPi lifecycle. Start `pi` normally; delegation is enabled by default at the first session start of each Pi process, including TUI, RPC, print and JSON modes, after settings, host and Guard checks. Startup launches no workers or model inference, but Pi-owned authentication/OAuth preparation may occur. The model-facing schema is present only while dispatch is enabled. It adds no launcher, separate host process, service or trust override. Parent Pi retains normal resource discovery, trust decisions and proxy policy. Delegation checks required public SDK capabilities, not exact version identifiers. Missing session/runtime/settings/thinking APIs prevent activation; session construction and every request still enforce the tool, model and resource policy. API presence is not proof of every future SDK behavior or a passing integration receipt. Normal installation's minimum Pi version and 0.84.4 bootstrap pin are separate contracts.
37
37
 
38
- Each worker is an SDK `createAgentSession` instance with in-memory session storage. Pi owns its model/tool loop. A fresh Pi `ModelRuntime` uses standard authentication, environment and `models.json` resolution, restoring Pi's persisted model catalogs with catalog network refresh disabled. Child transport and thinking budgets come from configured global settings; project settings are not loaded. Parent model/thinking are explicit with Pi's supported-level clamping. SpecPi does not extract credentials, copy authentication state or inspect private runtime fields. Preflight rejects runtime-only authentication, selected extension-registered provider overrides, model-specific headers, startup proxy configuration and mismatched safe model descriptors because those routes cannot be faithfully reconstructed. These rejections leave parent configuration unchanged.
38
+ Each worker is an SDK `createAgentSession` instance with in-memory session storage. Pi owns its model/tool loop. A fresh Pi `ModelRuntime` uses standard authentication, environment and `models.json` resolution, restoring Pi's persisted model catalogs with catalog network refresh disabled. Child transport and thinking budgets come from configured global settings; project settings are not loaded. Parent model/thinking are explicit with Pi's supported-level clamping. SpecPi does not extract credentials, copy authentication state or inspect private runtime fields. Preflight rejects runtime-only authentication, selected extension-registered provider overrides, unsupported model-specific headers, startup proxy configuration and mismatched safe model descriptors because those routes cannot be faithfully reconstructed. The sole model-header exception is the exact public Copilot catalog set: `User-Agent: GitHubCopilotChat/0.35.0`, `Editor-Version: vscode/1.107.0`, `Editor-Plugin-Version: copilot-chat/0.35.0`, and `Copilot-Integration-Id: vscode-chat`. Both parent and independently reconstructed child must have that entire set, with no extra keys, inherited headers or accessors; header presence must also match. No parent header object is copied or serialized. Changed catalog values require review before admission. These rejections leave parent configuration unchanged.
39
39
 
40
40
  This is not full parent inference parity: parent request hooks, ephemeral runtime settings and session affinity are not automatically inherited. Keep delegation disabled if those inherited controls are required for every request. Children load no ambient extensions, skills, AGENTS files or parent transcript. Their only tools are selected-source list/read/literal-search; shell, write, arbitrary plugin, live-web and nested delegation tools are absent. Trusted parent extensions remain privileged in the same process. This is an application boundary, not an OS sandbox against malicious extensions.
41
41
 
@@ -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.
@@ -74,6 +74,6 @@ Cancellation revokes access and requests SDK abort. Slots remain held until SDK-
74
74
 
75
75
  Activation checks required public SDK capabilities, not an exact version allowlist. The installer retains its separate Pi 0.84.4 bootstrap pin. Restart after updating the runtime.
76
76
 
77
- Children use fresh Pi `ModelRuntime` instances with standard authentication/environment/models resolution and global transport/thinking settings. Project settings are not loaded. Parent model/thinking are explicit and checked through Pi's public APIs. Runtime-only authentication, selected extension-provider overrides, model headers, startup proxy configuration and mismatched model descriptors are rejected rather than silently approximated.
77
+ Children use fresh Pi `ModelRuntime` instances with standard authentication/environment/models resolution and global transport/thinking settings. Project settings are not loaded. Parent model/thinking are explicit and checked through Pi's public APIs. Runtime-only authentication, selected extension-provider overrides, unsupported model headers, startup proxy configuration and mismatched model descriptors are rejected rather than silently approximated. Pi's exact public Copilot client-identification headers are supported when independently reconstructed in the child; all other model headers remain unsupported. The reviewed values and matching rules are listed in [the security model](../../SECURITY_MODEL.md).
78
78
 
79
79
  Parent hooks, ephemeral settings and session affinity are **not inherited**. Keep delegation off if they are required on every request. Stable provider identity cannot certify an unchanged remote service. Trusted extensions still share Pi's privileged process; this is a trusted-local-filesystem contract, not an OS sandbox. See [SECURITY_MODEL.md](../../SECURITY_MODEL.md) for the authoritative boundaries and [THIRD_PARTY.md](../../THIRD_PARTY.md) for dependency compatibility.
@@ -17,8 +17,10 @@ fresh Pi `ModelRuntime`. The parent model and thinking level are passed explicit
17
17
  subject to Pi's clamping. Standard Pi authentication, environment and `models.json`
18
18
  resolution apply. Child transport/thinking budgets come from configured global settings;
19
19
  project settings are not loaded. Runtime-only authentication, selected extension-provider
20
- overrides, model-specific headers, startup proxy configuration and safe model-descriptor
21
- mismatches fail preflight. Parent request hooks,
20
+ overrides, unsupported model-specific headers, startup proxy configuration and safe model-descriptor
21
+ mismatches fail preflight. The exact public Copilot client-identification header set is
22
+ permitted only when independently reconstructed in the child, as specified in the
23
+ [security model](../../SECURITY_MODEL.md). Parent request hooks,
22
24
  ephemeral runtime settings, session affinity and ambient resources are not inherited.
23
25
 
24
26
  Command Guard is optional. Absent and Off states permit activation; an installed
@@ -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
  }
@@ -7,7 +7,7 @@ import { DelegationError } from "./errors.mjs";
7
7
  import { createTimeoutStore } from "./settings.mjs";
8
8
 
9
9
  const stateKey = Symbol.for("specpi.delegation.native.v1");
10
- const revision = 10;
10
+ const revision = 11;
11
11
 
12
12
  export async function withPiCompatibility(sdk, loadCompatibility) {
13
13
  if (typeof sdk.clampThinkingLevel === "function") {
@@ -5,6 +5,14 @@ const MAX_CONTEXT_BYTES = 256 * 1024;
5
5
  const MAX_RUN_CONTEXT_BYTES = budgetLimits(MAX_BUDGET_MULTIPLIER).contextBytes;
6
6
  const MAX_RETAINED_RESPONSE_BYTES = budgetLimits(MAX_BUDGET_MULTIPLIER).retainedResponseBytes;
7
7
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
8
+ // Public client identification from https://pi.dev/api/models/providers/github-copilot.
9
+ // Keep this exception exact: other headers may contain credentials or routing overrides.
10
+ const COPILOT_CATALOG_HEADERS = Object.freeze({
11
+ "User-Agent": "GitHubCopilotChat/0.35.0",
12
+ "Editor-Version": "vscode/1.107.0",
13
+ "Editor-Plugin-Version": "copilot-chat/0.35.0",
14
+ "Copilot-Integration-Id": "vscode-chat",
15
+ });
8
16
  const MODEL_FIELDS = [
9
17
  "id",
10
18
  "provider",
@@ -62,7 +70,41 @@ function closed(value, keys) {
62
70
 
63
71
  function descriptor(model) {
64
72
  // Only public model behavior fields; never serialize a provider, headers, or credentials.
65
- return JSON.parse(bounded(Object.fromEntries(MODEL_FIELDS.map((key) => [key, model[key]])), 16 * 1024));
73
+ return JSON.parse(
74
+ bounded(
75
+ {
76
+ ...Object.fromEntries(MODEL_FIELDS.map((key) => [key, model[key]])),
77
+ hasModelHeaders: model.headers !== undefined,
78
+ },
79
+ 16 * 1024,
80
+ ),
81
+ );
82
+ }
83
+
84
+ function supportedModelHeaders(model) {
85
+ const headers = model.headers;
86
+ if (headers === undefined) {
87
+ return true;
88
+ }
89
+
90
+ if (model.provider !== "github-copilot" || !headers || typeof headers !== "object") {
91
+ return false;
92
+ }
93
+
94
+ try {
95
+ // Check names before touching values; never evaluate header getters or
96
+ // inspect arbitrary authentication headers. Retain no parent header data.
97
+ const keys = Reflect.ownKeys(headers);
98
+
99
+ return (
100
+ Object.getPrototypeOf(headers) === Object.prototype &&
101
+ keys.length === Object.keys(COPILOT_CATALOG_HEADERS).length &&
102
+ keys.every((key) => Object.hasOwn(COPILOT_CATALOG_HEADERS, key)) &&
103
+ keys.every((key) => Object.getOwnPropertyDescriptor(headers, key)?.value === COPILOT_CATALOG_HEADERS[key])
104
+ );
105
+ } catch {
106
+ return false;
107
+ }
66
108
  }
67
109
 
68
110
  function sameValue(left, right) {
@@ -314,7 +356,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
314
356
  !streaming &&
315
357
  (registry.getProviderAuthStatus(model.provider)?.source === "runtime" ||
316
358
  registry.getRegisteredProviderIds().includes(model.provider) ||
317
- model.headers !== undefined)
359
+ !supportedModelHeaders(model))
318
360
  ) {
319
361
  throw new DelegationError("Delegation cannot inherit this parent runtime provider override");
320
362
  }
@@ -351,6 +393,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
351
393
  runtime.getError() ||
352
394
  !runtime.getProvider(model.provider) ||
353
395
  !childModel ||
396
+ !supportedModelHeaders(childModel) ||
354
397
  !sameValue(descriptor(childModel), selected)
355
398
  ) {
356
399
  throw new DelegationError(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.18.1",
3
+ "version": "0.19.1",
4
4
  "description": "An explicit, privacy-conscious, self-improving harness for the Pi coding agent",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -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) {