specpi 0.32.0 → 0.33.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 +10 -0
- package/NPM_RELEASE.md +1 -1
- package/README.md +4 -3
- package/SECURITY_MODEL.md +12 -0
- package/THIRD_PARTY.md +1 -1
- package/extensions/workflow-controls/background.mjs +495 -0
- package/extensions/workflow-controls/index.ts +203 -1
- package/package.json +2 -1
- package/scripts/permission-shell-tools.mjs +146 -0
- package/scripts/specpi.mjs +31 -0
- package/templates/AGENTS.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.33.0 - 2026-09-23
|
|
4
|
+
|
|
5
|
+
- Run long commands as background jobs, so a long eval or build no longer holds the conversation. 0.32.0 judged a background-task system not worth building from Terminal-Bench transcripts, which measure task outcomes and cannot see the actual cost: a person unable to say anything until a many-minute command returns. The `background` tool starts a command and returns at once; when the job ends, its exit code and last lines of output arrive as a follow-up message and the agent picks it up. `/jobs` lists jobs, shows recent output and stops them.
|
|
6
|
+
- Keep it one small tool that only starts jobs, 657 characters of schema. Status needs no tool, because the result arrives on its own, and stopping is a human command. Jobs run through Pi's own shell runner with the session's shell settings, are limited to four at once, log to capped owner-only files, and end with the session, which deletes their logs.
|
|
7
|
+
- Apply every `bash` permission rule to background jobs. The installer records `background` in the permission system's `shellTools`, which gates its command through the same stack as `bash`, and removes the entry at uninstall. Before every start the extension checks the effective mapping, including a trusted project's, and refuses without it.
|
|
8
|
+
- Turn background jobs off while the Jev command guard is on, because the guard only checks `bash`. Its saved state is read the way the guard reads it. A guard switched on for one session only is invisible to other extensions, so that case is documented rather than guessed at.
|
|
9
|
+
- Offer the tool only to interactive sessions where a start could succeed, decided before the first request. Eval runs never see it, so their tool surface is unchanged.
|
|
10
|
+
- Pair with SpecPi Chat 0.16.0, which shows running jobs in the session footer.
|
|
11
|
+
- Say plainly in the working agreement that irreversible steps nobody asked for are off the table: rewriting Git history, pruning reflogs or objects, force-pushing, deleting data that cannot be restored. The agent does the reversible part, then says what remains and offers it. On Terminal-Bench's `sanitize-git-repo`, every attempt from either arm that left Git history alone passed, and every failure had rewritten it, destroying the commit the checker compares against. SpecPi's "reversible" wording already halved how often that happened (9 of 18 attempts against Pi's 17 of 18), which is why it led that task 12/18 to 3/18. The explicit rule targets the remaining 9. It is also the better behaviour outside a benchmark: purging keys from history breaks existing clones and needs a force-push, so it is a decision to offer, not to take. It adds 233 characters to every request, and its effect on the benchmark is not yet measured.
|
|
12
|
+
|
|
3
13
|
## 0.32.0 - 2026-09-23
|
|
4
14
|
|
|
5
15
|
- Send less on every request. SpecPi's first request was 14,022 characters against Pi's 5,521, and that overhead rides every turn and is never removed by compaction. Measured again with all eight pins, the interactive default is now 11,119. A headless session drops the two tools below as well, which the measured parts put at about 8,300: SpecPi's overhead falls from 8,501 characters to about 2,800.
|
package/NPM_RELEASE.md
CHANGED
|
@@ -6,7 +6,7 @@ Publishing, tags, deprecation, ownership changes and GitHub Releases require exp
|
|
|
6
6
|
|
|
7
7
|
## Prepare
|
|
8
8
|
|
|
9
|
-
1. This candidate is SpecPi **0.
|
|
9
|
+
1. This candidate is SpecPi **0.33.0**, paired with the Chat **0.16.0** VSIX. Browser QA **0.3.0** is already independently published; do not republish or edit its source as part of this release. Choose an unused version; update `package.json`, the dated `CHANGELOG.md` entry, README, and the website. Check Chat's version and VSIX download links. Preserve historical changelog entries.
|
|
10
10
|
2. Validate:
|
|
11
11
|
|
|
12
12
|
```sh
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
-
SpecPi 0.
|
|
31
|
+
SpecPi 0.33.0 is a small starting point for the [Pi coding agent](https://pi.dev/). It is one opinionated setup for how the agent should work, not a marketplace of plugins.
|
|
32
32
|
|
|
33
33
|
At the center are two built-in extensions. **Scope control** keeps each task to the files it said it would touch. The **improvement loop** turns repeated friction into small, tested changes to the setup, instead of letting prompts and workarounds pile up. Around those are eight hand-picked packages, each locked to an exact version and checked before anything installs, plus **SpecPi Chat**, a VS Code panel for working alongside the agent.
|
|
34
34
|
|
|
@@ -38,6 +38,7 @@ It focuses on five things:
|
|
|
38
38
|
- **Accuracy:** exact version pins, checksums on state, rollback on failure, and proof over promises
|
|
39
39
|
- **Improvement:** local notes become small, checked changes through `/harness-improvement`
|
|
40
40
|
- **Efficiency:** delegation, persistent goals, and browser QA handled by the right tool for the job
|
|
41
|
+
- **Keep talking:** long evals, builds and servers can run as background jobs, so the conversation is not stuck until they finish. The agent hears back when each one ends, and `/jobs` lists or stops them
|
|
41
42
|
- **Lean default:** web access, browser QA, and delegation stay off until you need them. Turn them on for a session with `/webaccess on`, `/browser on`, and `/delegate on` — or let the agent ask when it hits the need, and answer the prompt
|
|
42
43
|
|
|
43
44
|
Everything it touches is written down, versioned, and easy to undo.
|
|
@@ -52,7 +53,7 @@ The solid rows are measured by us, from the request each setup actually sends th
|
|
|
52
53
|
<a href="https://tannermidd.github.io/SpecPi/research/#specpi">
|
|
53
54
|
<picture>
|
|
54
55
|
<source media="(prefers-color-scheme: dark)" srcset="https://tannermidd.github.io/SpecPi/media/context-chart-dark.svg">
|
|
55
|
-
<img src="https://tannermidd.github.io/SpecPi/media/context-chart-light.svg" width="880" alt="Bar chart of characters sent on the first model call: Pi stock 5,521, SpecPi default
|
|
56
|
+
<img src="https://tannermidd.github.io/SpecPi/media/context-chart-light.svg" width="880" alt="Bar chart of characters sent on the first model call: Pi stock 5,521, SpecPi default 12,010, OpenCode 31,043, DeepSeek Harness 31,743, SpecPi enabled 36,658, Codex CLI 41,616, Oh My Pi 66,708, Claude Code 90,460.">
|
|
56
57
|
</picture>
|
|
57
58
|
</a>
|
|
58
59
|
</p>
|
|
@@ -64,7 +65,7 @@ The gap between the two SpecPi bars comes from a few separate switches, so the e
|
|
|
64
65
|
<a href="https://tannermidd.github.io/SpecPi/research/#specpi">
|
|
65
66
|
<picture>
|
|
66
67
|
<source media="(prefers-color-scheme: dark)" srcset="https://tannermidd.github.io/SpecPi/media/capability-chart-dark.svg">
|
|
67
|
-
<img src="https://tannermidd.github.io/SpecPi/media/capability-chart-light.svg" width="880" alt="Bar chart of tool-schema characters each capability adds: Pi built-ins 2,896, Improvement loop 1,601, Capability request 995, Goals 1,315, Browser QA 8,234, Delegation 4,453, Web access 11,298. Browser QA, Delegation, Web access are hidden until switched on.">
|
|
68
|
+
<img src="https://tannermidd.github.io/SpecPi/media/capability-chart-light.svg" width="880" alt="Bar chart of tool-schema characters each capability adds: Pi built-ins 2,896, Improvement loop 1,601, Capability request 995, Background jobs 657, Goals 1,315, Browser QA 8,234, Delegation 4,453, Web access 11,298. Browser QA, Delegation, Web access are hidden until switched on.">
|
|
68
69
|
</picture>
|
|
69
70
|
</a>
|
|
70
71
|
</p>
|
package/SECURITY_MODEL.md
CHANGED
|
@@ -14,6 +14,18 @@ Optional tool groups ship withdrawn. The `request_capability` tool lets the mode
|
|
|
14
14
|
|
|
15
15
|
`/capability allow <name>` records a standing grant so that group is offered without a prompt, and `/capability ask <name>` returns it to prompting. Recording either requires an interactive human command, and a grant that fires still announces itself in the session. Grants live in `<agent-dir>/specpi/capabilities/settings.json`, written atomically with owner-only permissions; links and irregular files are refused, and an unreadable file, a foreign schema or an unknown capability name reads as no grant. A standing grant removes the prompt, not the requirement for a human: headless sessions are still refused, so a granted capability cannot be activated by an unattended run. It also does not offer the group at startup — the model must still ask, and the group stays withdrawn until it does.
|
|
16
16
|
|
|
17
|
+
## Background jobs
|
|
18
|
+
|
|
19
|
+
The `background` tool starts a shell command and returns at once; the job reports its exit code and last lines of output as a follow-up message when it ends. It runs through Pi's own local shell runner with the session's `shellPath` and `shellCommandPrefix`, so the shell, working directory and process-tree cleanup on Pi's exit match the `bash` tool. It adds no new authority, and every start fails closed:
|
|
20
|
+
|
|
21
|
+
- **Permission parity.** The installer records `shellTools.background = { commandArgument: "command" }` in the permission system's global config, so its full `bash` stack — command decomposition, wrapper flooring, path and outside-directory checks, and every `bash:` rule — applies to the command. The entry is merged into the existing file, backed up and rolled back with the rest of the transaction, left alone when the file carries comments, and removed at uninstall only if it is still SpecPi's. Before every start the extension reads the effective mapping (global, then a trusted project's, which could remap it) and refuses unless it gates `command`. An unreadable config refuses too.
|
|
22
|
+
- **Command guard.** `specpi-jev-guard` checks `bash`, `powershell`, `write` and `edit` only, so background jobs are off while it is on. The extension resolves the guard's saved state as the guard does — its default on, the global file, then a trusted project's — and starts a job only when that resolves off. The guard's session-only switch lives in its own memory and no other extension can read it, so a guard switched on with `/jev-guard on` without `--global`, over a saved off, does not stop background jobs. Save the choice with `--global` to cover them.
|
|
23
|
+
- **Interactive only.** Headless sessions are not offered the tool and are refused if they call it; there is nobody to report back to. A Pi that cannot list its commands is refused, because the guard and permission system cannot be detected.
|
|
24
|
+
- **Bounded.** At most four jobs run at once. Each log is capped at 8 MiB under `<agent-dir>/specpi/background/`, owner-only, and deleted when the session ends; directories left by a crashed session are pruned after a day. A completion message carries at most 40 lines and 4,000 characters of output with control characters removed. Output is untrusted text, like any tool result.
|
|
25
|
+
- **Session-owned.** `/jobs stop` and session end kill each job's process tree; Pi's own exit handling also kills tracked children. Process cleanup is best effort: a program that detaches itself from its parent, or a daemon a job starts, can outlive it, exactly as with `bash`.
|
|
26
|
+
|
|
27
|
+
Eval runs overwrite the permission config with their `yoloMode` opt-in, which drops the mapping, so the tool is never offered there and eval rows keep their measured tool surface.
|
|
28
|
+
|
|
17
29
|
## Jev advisor
|
|
18
30
|
|
|
19
31
|
The advisor is the only part of SpecPi that sends anything off this machine, and it ships off. With the master switch off there is no network call, no key read, no consent read and no prompt injection: the harness behaves exactly as it did before the extension existed.
|
package/THIRD_PARTY.md
CHANGED
|
@@ -52,4 +52,4 @@ The separately released `packages/browser-qa` source reuses the retired QA imple
|
|
|
52
52
|
|
|
53
53
|
The website uses the bundled IBM Plex Sans and Plex Mono fonts under the SIL Open Font License 1.1; see [`site/fonts/LICENSE.txt`](https://github.com/TannerMidd/SpecPi/blob/main/site/fonts/LICENSE.txt). The website's scripts and diagrams are first-party code and assets. The README loads public package/license badges from Shields.io and build status from GitHub. Website fonts and media are not included in the npm package.
|
|
54
54
|
|
|
55
|
-
SpecPi Chat 0.
|
|
55
|
+
SpecPi Chat 0.16.0 is a separately packaged VS Code extension with no bundled runtime dependencies. It uses VS Code's host APIs and Pi's RPC protocol. Generic tool output, visible custom messages, widgets, and dialogs stay owned by their upstream packages. Playwright **1.62.1** (Apache-2.0, [Microsoft Playwright](https://github.com/microsoft/playwright)) is a pinned development dependency for Chat's rendering tests; its browser is used for those checks and is not shipped in either artifact. The default Browser QA package independently uses the same pinned Playwright version for its runtime. Chat's global Destructive guard preset is a full replacement configuration reviewed against Permission System 32.0.2's schema and native pattern semantics. No upstream patch, new dependency, policy-layer merger, or independent command evaluator is included. See [the security model](SECURITY_MODEL.md) for its limits.
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// Background jobs: start a long command, keep talking, hear back when it ends.
|
|
2
|
+
//
|
|
3
|
+
// A long eval or build run through `bash` holds the whole conversation until it returns; the person
|
|
4
|
+
// at the keyboard cannot say anything to the agent in the meantime. A background job returns at once
|
|
5
|
+
// and reports its exit code and last lines of output as a follow-up message when it finishes.
|
|
6
|
+
//
|
|
7
|
+
// It is deliberately small, and it never widens what may run:
|
|
8
|
+
//
|
|
9
|
+
// - One tool, and it only starts jobs. Its `command` is registered with the permission system as a
|
|
10
|
+
// shell argument (`shellTools`), so every `bash` rule applies to it at full parity. A job may not
|
|
11
|
+
// start unless that mapping is in effect.
|
|
12
|
+
// - The Jev command guard gates `bash` alone. While it is on, background jobs are off, so a guard
|
|
13
|
+
// user never loses coverage. Only its saved state is visible to another extension; see
|
|
14
|
+
// guardEnabled.
|
|
15
|
+
// - Interactive sessions only. A headless run has nobody to report back to.
|
|
16
|
+
// - Jobs belong to the session: they are stopped, and their logs deleted, when it ends.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
export const BACKGROUND_TOOL = "background";
|
|
24
|
+
export const BACKGROUND_MESSAGE = "specpi-background";
|
|
25
|
+
export const BACKGROUND_STATUS = "specpi-background";
|
|
26
|
+
|
|
27
|
+
/** The `shellTools` entry that makes the permission system gate `command` exactly like `bash`. */
|
|
28
|
+
export const SHELL_TOOL_MAPPING = Object.freeze({ commandArgument: "command" });
|
|
29
|
+
|
|
30
|
+
export const MAX_RUNNING_JOBS = 4;
|
|
31
|
+
export const MAX_LOG_BYTES = 8 * 1024 * 1024;
|
|
32
|
+
const TAIL_BUFFER_BYTES = 64 * 1024;
|
|
33
|
+
const TAIL_LINES = 40;
|
|
34
|
+
const TAIL_CHARS = 4000;
|
|
35
|
+
const COMMAND_PREVIEW_CHARS = 200;
|
|
36
|
+
const STALE_LOG_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
export function agentDirectory() {
|
|
39
|
+
const configured = process.env.PI_CODING_AGENT_DIR;
|
|
40
|
+
|
|
41
|
+
return path.resolve(configured && configured.length > 0 ? configured : path.join(os.homedir(), ".pi", "agent"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function logRoot(agentDir = agentDirectory()) {
|
|
45
|
+
return path.join(agentDir, "specpi", "background");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A fresh log directory for one session's jobs. */
|
|
49
|
+
export function sessionLogDir(agentDir = agentDirectory()) {
|
|
50
|
+
return path.join(logRoot(agentDir), `${process.pid}-${randomUUID().slice(0, 8)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Remove log directories a crashed session left behind. A session deletes its own on exit, so
|
|
55
|
+
* anything older than a day belongs to a process that never got the chance.
|
|
56
|
+
*/
|
|
57
|
+
export function pruneStaleLogs({ root = logRoot(), maxAgeMs = STALE_LOG_MS, now = Date.now() } = {}) {
|
|
58
|
+
let removed = 0;
|
|
59
|
+
let entries = [];
|
|
60
|
+
try {
|
|
61
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
62
|
+
} catch {
|
|
63
|
+
return removed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (!entry.isDirectory() || !/^\d+-[0-9a-f]{8}$/u.test(entry.name)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const dir = path.join(root, entry.name);
|
|
72
|
+
try {
|
|
73
|
+
if (now - fs.statSync(dir).mtimeMs > maxAgeMs) {
|
|
74
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
removed += 1;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Held open or already gone; the next session tries again.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return removed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------------------------
|
|
86
|
+
// Admission
|
|
87
|
+
// ---------------------------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse JSON that may carry `//` and `/* *\/` comments, as the permission system's config does.
|
|
91
|
+
* Comments inside strings are left alone. Returns undefined for a missing file and throws for an
|
|
92
|
+
* unreadable one, so a caller can tell "nothing configured" from "cannot verify".
|
|
93
|
+
*/
|
|
94
|
+
export function readJsonc(file) {
|
|
95
|
+
if (!fs.existsSync(file)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const text = fs.readFileSync(file, "utf8");
|
|
100
|
+
let out = "";
|
|
101
|
+
let inString = false;
|
|
102
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
103
|
+
const char = text[index];
|
|
104
|
+
const next = text[index + 1];
|
|
105
|
+
if (inString) {
|
|
106
|
+
out += char;
|
|
107
|
+
if (char === "\\") {
|
|
108
|
+
out += next ?? "";
|
|
109
|
+
index += 1;
|
|
110
|
+
} else if (char === '"') {
|
|
111
|
+
inString = false;
|
|
112
|
+
}
|
|
113
|
+
} else if (char === '"') {
|
|
114
|
+
inString = true;
|
|
115
|
+
out += char;
|
|
116
|
+
} else if (char === "/" && next === "/") {
|
|
117
|
+
while (index < text.length && text[index] !== "\n") {
|
|
118
|
+
index += 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
out += "\n";
|
|
122
|
+
} else if (char === "/" && next === "*") {
|
|
123
|
+
const end = text.indexOf("*/", index + 2);
|
|
124
|
+
index = end === -1 ? text.length : end + 1;
|
|
125
|
+
} else {
|
|
126
|
+
out += char;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const value = JSON.parse(out);
|
|
131
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
132
|
+
throw new Error(`${file} is not a JSON object`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The guard's saved state, resolved the way the guard resolves it: its own default is on, the
|
|
140
|
+
* global file overrides that, and a trusted project's file overrides the global one.
|
|
141
|
+
*
|
|
142
|
+
* The guard also keeps a session-only switch in memory, and no other extension can read it. So a
|
|
143
|
+
* guard switched on with `/jev-guard on` (without `--global`) is invisible here; that is disclosed
|
|
144
|
+
* rather than guessed at. Saved state is what `/jev-guard on --global`, `setup` and SpecPi's own
|
|
145
|
+
* installer write.
|
|
146
|
+
*/
|
|
147
|
+
export function guardEnabled({ home = os.homedir(), cwd, trusted = false } = {}) {
|
|
148
|
+
let enabled = true;
|
|
149
|
+
let source = "default";
|
|
150
|
+
const files = [["global", path.join(home, ".pi", "jev-guard.json")]];
|
|
151
|
+
if (trusted && cwd) {
|
|
152
|
+
files.push(["project", path.join(cwd, ".pi", "jev-guard.json")]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [scope, file] of files) {
|
|
156
|
+
let value;
|
|
157
|
+
try {
|
|
158
|
+
// Parsed exactly as the guard parses it: a byte-order mark stripped, then strict JSON. A
|
|
159
|
+
// file it cannot parse is absent to it, so its default -- on -- applies; reading one with
|
|
160
|
+
// comments more leniently here would see "off" where the guard sees "on".
|
|
161
|
+
value = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/u, "")) : undefined;
|
|
162
|
+
} catch {
|
|
163
|
+
value = undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (typeof value?.enabled === "boolean") {
|
|
167
|
+
enabled = value.enabled;
|
|
168
|
+
source = scope;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { enabled, source };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function permissionConfigPaths({ agentDir, cwd }) {
|
|
176
|
+
return {
|
|
177
|
+
global: path.join(agentDir, "extensions", "pi-permission-system", "config.json"),
|
|
178
|
+
project: cwd ? path.join(cwd, ".pi", "extensions", "pi-permission-system", "config.json") : undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The `shellTools` mapping the permission system will actually apply to this tool: global, then a
|
|
184
|
+
* trusted project's entry, which replaces it wholesale on a key collision. A trusted repository could
|
|
185
|
+
* point the mapping at another argument, which would leave `command` ungated, so the effective entry
|
|
186
|
+
* is what gets checked, not the one SpecPi wrote.
|
|
187
|
+
*/
|
|
188
|
+
export function effectiveShellMapping({ agentDir, cwd, trusted = false }) {
|
|
189
|
+
const paths = permissionConfigPaths({ agentDir, cwd });
|
|
190
|
+
let mapping;
|
|
191
|
+
for (const file of trusted ? [paths.global, paths.project] : [paths.global]) {
|
|
192
|
+
const config = readJsonc(file);
|
|
193
|
+
const entry = config?.shellTools?.[BACKGROUND_TOOL];
|
|
194
|
+
if (entry !== undefined) {
|
|
195
|
+
mapping = entry;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return mapping;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function mappingGatesCommand(mapping) {
|
|
203
|
+
return (
|
|
204
|
+
Boolean(mapping) &&
|
|
205
|
+
typeof mapping === "object" &&
|
|
206
|
+
mapping.commandArgument === SHELL_TOOL_MAPPING.commandArgument &&
|
|
207
|
+
(mapping.workdirArgument === undefined || typeof mapping.workdirArgument === "string")
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Whether a job may start, and if not, a sentence the model and the human can act on. Every
|
|
213
|
+
* condition fails closed: an unreadable config or an unknown guard state refuses.
|
|
214
|
+
*/
|
|
215
|
+
export function admission({
|
|
216
|
+
interactive,
|
|
217
|
+
commandsKnown = true,
|
|
218
|
+
guardInstalled,
|
|
219
|
+
guard,
|
|
220
|
+
permissionInstalled,
|
|
221
|
+
mapping,
|
|
222
|
+
mappingError,
|
|
223
|
+
running,
|
|
224
|
+
}) {
|
|
225
|
+
if (interactive !== true) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
reason: "Background jobs need an interactive session to report back to. Run the command with bash.",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (commandsKnown !== true) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
reason: "Background jobs are off because this Pi cannot list its commands, so SpecPi cannot tell whether the command guard or the permission system is installed. Run the command with bash.",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (guardInstalled && guard?.enabled !== false) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
reason: "The Jev command guard is on, and it only checks bash, so background jobs are off while it is. Run the command with bash, or switch the guard off with /jev-guard off --global.",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (permissionInstalled) {
|
|
247
|
+
if (mappingError) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
reason: `Background jobs are off because the permission system's config could not be read to confirm bash rules apply: ${mappingError}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!mappingGatesCommand(mapping)) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
reason: 'Background jobs are off because the permission system does not yet apply bash rules to them. Run `specpi update`, or add "shellTools": { "background": { "commandArgument": "command" } } to its global config.',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (Number.isInteger(running) && running >= MAX_RUNNING_JOBS) {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
reason: `${MAX_RUNNING_JOBS} background jobs are already running. Wait for one to finish, or ask the user to stop one with /jobs stop <id>.`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { ok: true };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------------------------
|
|
273
|
+
// Jobs
|
|
274
|
+
// ---------------------------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function sanitize(text) {
|
|
277
|
+
// Keep tab, newline and carriage return; drop other control characters and bidi overrides.
|
|
278
|
+
return String(text).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/gu, "");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function tailOf(text, { lines = TAIL_LINES, chars = TAIL_CHARS } = {}) {
|
|
282
|
+
const kept = sanitize(text).replace(/\r\n?/gu, "\n").replace(/\n+$/u, "").split("\n").slice(-lines).join("\n");
|
|
283
|
+
|
|
284
|
+
return kept.length > chars ? kept.slice(-chars) : kept;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function formatDuration(ms) {
|
|
288
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
289
|
+
const hours = Math.floor(seconds / 3600);
|
|
290
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
291
|
+
const rest = seconds % 60;
|
|
292
|
+
if (hours > 0) {
|
|
293
|
+
return `${hours}h ${minutes}m`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function preview(command) {
|
|
300
|
+
const single = sanitize(command).replace(/\s+/gu, " ").trim();
|
|
301
|
+
|
|
302
|
+
return single.length > COMMAND_PREVIEW_CHARS ? `${single.slice(0, COMMAND_PREVIEW_CHARS - 1)}…` : single;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Run and track a session's jobs.
|
|
307
|
+
*
|
|
308
|
+
* `run(command, cwd, { onData, signal })` resolves `{ exitCode }` when the process ends and rejects
|
|
309
|
+
* when it is aborted; it is Pi's own local bash runner in production, so shell selection, process
|
|
310
|
+
* trees and cleanup on Pi's exit match the `bash` tool. `report(job)` is called once per job with its
|
|
311
|
+
* final state.
|
|
312
|
+
*/
|
|
313
|
+
export function createJobManager({ run, logDir, report, now = () => Date.now() }) {
|
|
314
|
+
const jobs = new Map();
|
|
315
|
+
let nextId = 1;
|
|
316
|
+
let closed = false;
|
|
317
|
+
|
|
318
|
+
const running = () => [...jobs.values()].filter((job) => job.state === "running");
|
|
319
|
+
|
|
320
|
+
function start({ command, cwd, label }) {
|
|
321
|
+
if (closed) {
|
|
322
|
+
throw new Error("This session has ended");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
326
|
+
const id = String(nextId);
|
|
327
|
+
nextId += 1;
|
|
328
|
+
const logPath = path.join(logDir, `job-${id}.log`);
|
|
329
|
+
const log = fs.openSync(logPath, "w", 0o600);
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const job = {
|
|
332
|
+
id,
|
|
333
|
+
label: typeof label === "string" && label.trim() ? preview(label).slice(0, 80) : undefined,
|
|
334
|
+
command: preview(command),
|
|
335
|
+
cwd,
|
|
336
|
+
logPath,
|
|
337
|
+
startedAt: now(),
|
|
338
|
+
state: "running",
|
|
339
|
+
exitCode: undefined,
|
|
340
|
+
endedAt: undefined,
|
|
341
|
+
bytes: 0,
|
|
342
|
+
truncated: false,
|
|
343
|
+
tail: "",
|
|
344
|
+
controller,
|
|
345
|
+
stoppedBy: undefined,
|
|
346
|
+
};
|
|
347
|
+
jobs.set(id, job);
|
|
348
|
+
|
|
349
|
+
const onData = (chunk) => {
|
|
350
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
351
|
+
const text = buffer.toString("utf8");
|
|
352
|
+
job.tail = (job.tail + text).slice(-TAIL_BUFFER_BYTES);
|
|
353
|
+
if (job.bytes < MAX_LOG_BYTES) {
|
|
354
|
+
const room = MAX_LOG_BYTES - job.bytes;
|
|
355
|
+
const slice = buffer.length > room ? buffer.subarray(0, room) : buffer;
|
|
356
|
+
try {
|
|
357
|
+
fs.writeSync(log, slice);
|
|
358
|
+
} catch {
|
|
359
|
+
// A full disk must not take the job down with it; the tail is still kept.
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
job.bytes += slice.length;
|
|
363
|
+
if (buffer.length > room && !job.truncated) {
|
|
364
|
+
job.truncated = true;
|
|
365
|
+
try {
|
|
366
|
+
fs.writeSync(log, `\n[SpecPi: log capped at ${MAX_LOG_BYTES} bytes; the job keeps running]\n`);
|
|
367
|
+
} catch {
|
|
368
|
+
// As above.
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const finish = (state, exitCode) => {
|
|
375
|
+
if (job.state !== "running") {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
job.state = state;
|
|
380
|
+
job.exitCode = exitCode;
|
|
381
|
+
job.endedAt = now();
|
|
382
|
+
try {
|
|
383
|
+
fs.closeSync(log);
|
|
384
|
+
} catch {
|
|
385
|
+
// Already closed.
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!closed) {
|
|
389
|
+
report(job);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
Promise.resolve()
|
|
394
|
+
.then(() => run(command, cwd, { onData, signal: controller.signal }))
|
|
395
|
+
.then(
|
|
396
|
+
(result) => finish("exited", Number.isInteger(result?.exitCode) ? result.exitCode : null),
|
|
397
|
+
(error) => {
|
|
398
|
+
job.error = error?.message;
|
|
399
|
+
finish(controller.signal.aborted ? "stopped" : "failed", undefined);
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
return job;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function stop(id, by = "user") {
|
|
407
|
+
const job = jobs.get(String(id));
|
|
408
|
+
if (!job || job.state !== "running") {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
job.stoppedBy = by;
|
|
413
|
+
job.controller.abort();
|
|
414
|
+
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** End every job and delete this session's logs. Nothing is reported after this. */
|
|
419
|
+
function close() {
|
|
420
|
+
closed = true;
|
|
421
|
+
for (const job of running()) {
|
|
422
|
+
job.stoppedBy = "session-end";
|
|
423
|
+
job.controller.abort();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
fs.rmSync(logDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
428
|
+
} catch {
|
|
429
|
+
// A log still held open by a dying process is removed with the OS temp cleanup instead.
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
start,
|
|
435
|
+
stop,
|
|
436
|
+
close,
|
|
437
|
+
get: (id) => jobs.get(String(id)),
|
|
438
|
+
list: () => [...jobs.values()],
|
|
439
|
+
running: () => running().length,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The follow-up the agent and the human both see when a job ends. */
|
|
444
|
+
export function completionText(job, now = Date.now()) {
|
|
445
|
+
const name = job.label ? `${job.id} (${job.label})` : job.id;
|
|
446
|
+
const took = formatDuration((job.endedAt ?? now) - job.startedAt);
|
|
447
|
+
const outcome =
|
|
448
|
+
job.state === "exited"
|
|
449
|
+
? `finished with exit code ${job.exitCode ?? "unknown"}`
|
|
450
|
+
: job.state === "stopped"
|
|
451
|
+
? `was stopped${job.stoppedBy === "user" ? " by the user" : ""}`
|
|
452
|
+
: `could not run${job.error ? `: ${sanitize(job.error).slice(0, 200)}` : ""}`;
|
|
453
|
+
const tail = tailOf(job.tail);
|
|
454
|
+
|
|
455
|
+
return [
|
|
456
|
+
`Background job ${name} ${outcome} after ${took}.`,
|
|
457
|
+
`Command: ${job.command}`,
|
|
458
|
+
tail ? `Last lines of output:\n\`\`\`\n${tail.replace(/```/gu, "``\u200b`")}\n\`\`\`` : "It printed nothing.",
|
|
459
|
+
`Full log (deleted when this session ends): ${job.logPath}${job.truncated ? " (capped)" : ""}`,
|
|
460
|
+
].join("\n");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function startedText(job) {
|
|
464
|
+
return [
|
|
465
|
+
`Started background job ${job.id}${job.label ? ` (${job.label})` : ""}. It keeps running while you and the user carry on.`,
|
|
466
|
+
"When it ends you will receive its exit code and last lines of output as a message, so do not wait for it or poll it.",
|
|
467
|
+
`Its output is written to ${job.logPath} if you need to look before then. The user can list or stop jobs with /jobs.`,
|
|
468
|
+
].join(" ");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function statusText(jobs) {
|
|
472
|
+
const count = jobs.filter((job) => job.state === "running").length;
|
|
473
|
+
|
|
474
|
+
return count > 0 ? `${count} background job${count === 1 ? "" : "s"} running` : undefined;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function listText(jobs, now = Date.now()) {
|
|
478
|
+
if (jobs.length === 0) {
|
|
479
|
+
return "No background jobs in this session.";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return jobs
|
|
483
|
+
.map((job) => {
|
|
484
|
+
const took = formatDuration((job.endedAt ?? now) - job.startedAt);
|
|
485
|
+
const state =
|
|
486
|
+
job.state === "running"
|
|
487
|
+
? `running ${took}`
|
|
488
|
+
: job.state === "exited"
|
|
489
|
+
? `exit ${job.exitCode ?? "?"} after ${took}`
|
|
490
|
+
: `${job.state} after ${took}`;
|
|
491
|
+
|
|
492
|
+
return `${job.id} ${state} ${job.label ?? job.command}`;
|
|
493
|
+
})
|
|
494
|
+
.join("\n");
|
|
495
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
SettingsManager,
|
|
4
|
+
createLocalBashOperations,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
3
8
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
9
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
10
|
import { Type } from "typebox";
|
|
@@ -14,6 +19,23 @@ import {
|
|
|
14
19
|
missingTools,
|
|
15
20
|
} from "./capabilities.mjs";
|
|
16
21
|
import { allowCapability, askCapability, autoAllowed, loadAutoAllowed, policyPath } from "./capability-policy.mjs";
|
|
22
|
+
import {
|
|
23
|
+
BACKGROUND_MESSAGE,
|
|
24
|
+
BACKGROUND_STATUS,
|
|
25
|
+
BACKGROUND_TOOL,
|
|
26
|
+
admission as backgroundAdmissionFor,
|
|
27
|
+
agentDirectory as backgroundAgentDirectory,
|
|
28
|
+
completionText,
|
|
29
|
+
createJobManager,
|
|
30
|
+
effectiveShellMapping,
|
|
31
|
+
guardEnabled,
|
|
32
|
+
listText,
|
|
33
|
+
pruneStaleLogs,
|
|
34
|
+
sessionLogDir,
|
|
35
|
+
startedText,
|
|
36
|
+
statusText,
|
|
37
|
+
tailOf,
|
|
38
|
+
} from "./background.mjs";
|
|
17
39
|
import {
|
|
18
40
|
canonicalRoot,
|
|
19
41
|
compareWorktreeSnapshots,
|
|
@@ -407,16 +429,104 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
407
429
|
let webAccessEnabled = loadStartupActivation();
|
|
408
430
|
const applyWebAccess = () => syncToolGroup(pi, WEB_TOOL_NAMES, webAccessEnabled);
|
|
409
431
|
|
|
432
|
+
// Background jobs. The admission rules live in background.mjs and are re-checked at every start;
|
|
433
|
+
// the tool is only offered to a session where a start could succeed, decided before the first
|
|
434
|
+
// request so the cached prompt prefix is never disturbed.
|
|
435
|
+
let jobs: ReturnType<typeof createJobManager> | undefined;
|
|
436
|
+
let jobsContext: ExtensionContext | undefined;
|
|
437
|
+
const hasCommand = (names: string[] | undefined, name: string) =>
|
|
438
|
+
names?.some((entry) => entry === name || entry.startsWith(`${name}:`)) === true;
|
|
439
|
+
const backgroundAdmission = (ctx: ExtensionContext, running = 0) => {
|
|
440
|
+
let names: string[] | undefined;
|
|
441
|
+
try {
|
|
442
|
+
names =
|
|
443
|
+
typeof pi.getCommands === "function" ? pi.getCommands().map((command: any) => command.name) : undefined;
|
|
444
|
+
} catch {
|
|
445
|
+
names = undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const trusted = typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted() === true;
|
|
449
|
+
const permissionInstalled = hasCommand(names, "permission-system");
|
|
450
|
+
let mapping;
|
|
451
|
+
let mappingError;
|
|
452
|
+
if (permissionInstalled) {
|
|
453
|
+
try {
|
|
454
|
+
mapping = effectiveShellMapping({ agentDir: backgroundAgentDirectory(), cwd: ctx.cwd, trusted });
|
|
455
|
+
} catch (error) {
|
|
456
|
+
mappingError = safeMessage(error);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return backgroundAdmissionFor({
|
|
461
|
+
interactive: ctx.hasUI === true,
|
|
462
|
+
commandsKnown: names !== undefined,
|
|
463
|
+
guardInstalled: hasCommand(names, "jev-guard"),
|
|
464
|
+
guard: guardEnabled({ cwd: ctx.cwd, trusted }),
|
|
465
|
+
permissionInstalled,
|
|
466
|
+
mapping,
|
|
467
|
+
mappingError,
|
|
468
|
+
running,
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const publishJobStatus = () => {
|
|
473
|
+
try {
|
|
474
|
+
jobsContext?.ui.setStatus(BACKGROUND_STATUS, statusText(jobs?.list() ?? []));
|
|
475
|
+
} catch {
|
|
476
|
+
// A status line is a courtesy; a closed UI must not fail the job.
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const reportJob = (job: any) => {
|
|
481
|
+
publishJobStatus();
|
|
482
|
+
// A job the user stopped is news for the next prompt, not a reason to start a turn.
|
|
483
|
+
const stoppedByUser = job.state === "stopped" && job.stoppedBy === "user";
|
|
484
|
+
pi.sendMessage(
|
|
485
|
+
{
|
|
486
|
+
customType: BACKGROUND_MESSAGE,
|
|
487
|
+
content: completionText(job),
|
|
488
|
+
display: true,
|
|
489
|
+
details: { id: job.id, state: job.state, exitCode: job.exitCode, logPath: job.logPath },
|
|
490
|
+
},
|
|
491
|
+
stoppedByUser ? { deliverAs: "nextTurn" } : { triggerTurn: true, deliverAs: "followUp" },
|
|
492
|
+
);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// Pi's own local runner, with the session's shell settings, so a job starts exactly as `bash`
|
|
496
|
+
// would run it and its process tree is cleaned up the same way when Pi exits.
|
|
497
|
+
const runJob = (command: string, cwd: string, options: any) => {
|
|
498
|
+
const settings = SettingsManager.create(cwd, backgroundAgentDirectory());
|
|
499
|
+
const prefix = settings.getShellCommandPrefix();
|
|
500
|
+
|
|
501
|
+
return createLocalBashOperations({ shellPath: settings.getShellPath() }).exec(
|
|
502
|
+
prefix ? `${prefix}\n${command}` : command,
|
|
503
|
+
cwd,
|
|
504
|
+
options,
|
|
505
|
+
);
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const endJobs = () => {
|
|
509
|
+
jobs?.close();
|
|
510
|
+
jobs = undefined;
|
|
511
|
+
publishJobStatus();
|
|
512
|
+
};
|
|
513
|
+
|
|
410
514
|
pi.on("session_start", (_event, ctx) => {
|
|
411
515
|
webAccessEnabled = loadStartupActivation();
|
|
412
516
|
applyWebAccess();
|
|
413
517
|
applyCapabilityRequest(ctx);
|
|
518
|
+
endJobs();
|
|
519
|
+
jobsContext = ctx;
|
|
520
|
+
pruneStaleLogs();
|
|
521
|
+
syncToolGroup(pi, [BACKGROUND_TOOL], backgroundAdmission(ctx).ok);
|
|
414
522
|
beginRestore(ctx);
|
|
415
523
|
});
|
|
416
524
|
|
|
417
525
|
pi.on("session_tree", (_event, ctx) => beginRestore(ctx));
|
|
418
526
|
|
|
419
527
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
528
|
+
endJobs();
|
|
529
|
+
jobsContext = undefined;
|
|
420
530
|
sessionGeneration += 1;
|
|
421
531
|
snapshots.clear();
|
|
422
532
|
latestSnapshot = undefined;
|
|
@@ -1032,6 +1142,98 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
1032
1142
|
},
|
|
1033
1143
|
});
|
|
1034
1144
|
|
|
1145
|
+
pi.registerTool({
|
|
1146
|
+
name: BACKGROUND_TOOL,
|
|
1147
|
+
label: "Background",
|
|
1148
|
+
description:
|
|
1149
|
+
"Start a long-running shell command (an eval, build or server) without blocking the conversation. It returns at once; the exit code and last lines of output arrive later as a message. Use bash for anything quick, or when the next step needs the output. Bash permission rules apply.",
|
|
1150
|
+
parameters: Type.Object(
|
|
1151
|
+
{
|
|
1152
|
+
command: Type.String({
|
|
1153
|
+
description: "Shell command, run as bash would run it in the session directory",
|
|
1154
|
+
}),
|
|
1155
|
+
label: Type.Optional(Type.String({ maxLength: 80, description: "Short name shown to the user" })),
|
|
1156
|
+
},
|
|
1157
|
+
{ additionalProperties: false },
|
|
1158
|
+
),
|
|
1159
|
+
async execute(_toolCallId, params: any, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
1160
|
+
const verdict = backgroundAdmission(ctx, jobs?.running() ?? 0);
|
|
1161
|
+
if (!verdict.ok) {
|
|
1162
|
+
return {
|
|
1163
|
+
content: [{ type: "text" as const, text: verdict.reason }],
|
|
1164
|
+
isError: true,
|
|
1165
|
+
details: { started: false },
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const command = typeof params.command === "string" ? params.command : "";
|
|
1170
|
+
if (command.trim().length === 0) {
|
|
1171
|
+
return {
|
|
1172
|
+
content: [{ type: "text" as const, text: "Give the command to run." }],
|
|
1173
|
+
isError: true,
|
|
1174
|
+
details: { started: false },
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
jobsContext = ctx;
|
|
1179
|
+
jobs ??= createJobManager({ run: runJob, logDir: sessionLogDir(), report: reportJob });
|
|
1180
|
+
const job = jobs.start({ command, cwd: ctx.cwd, label: params.label });
|
|
1181
|
+
publishJobStatus();
|
|
1182
|
+
|
|
1183
|
+
return {
|
|
1184
|
+
content: [{ type: "text" as const, text: startedText(job) }],
|
|
1185
|
+
details: { started: true, id: job.id, logPath: job.logPath },
|
|
1186
|
+
};
|
|
1187
|
+
},
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
pi.registerCommand("jobs", {
|
|
1191
|
+
description: "List, show or stop this session's background jobs",
|
|
1192
|
+
getArgumentCompletions: (prefix: string) =>
|
|
1193
|
+
["list", "output", "stop"]
|
|
1194
|
+
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
1195
|
+
.map((value) => ({ value, label: value })),
|
|
1196
|
+
handler: async (args, ctx) => {
|
|
1197
|
+
const [actionRaw = "list", target] = args.trim().split(/\s+/u).filter(Boolean);
|
|
1198
|
+
const action = actionRaw.toLowerCase();
|
|
1199
|
+
const all = jobs?.list() ?? [];
|
|
1200
|
+
if (action === "list") {
|
|
1201
|
+
ctx.ui.notify(listText(all), "info");
|
|
1202
|
+
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (action === "output") {
|
|
1207
|
+
const job = target ? jobs?.get(target) : undefined;
|
|
1208
|
+
if (!job) {
|
|
1209
|
+
ctx.ui.notify("Usage: /jobs output <id>. Run /jobs to see the ids.", "error");
|
|
1210
|
+
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
ctx.ui.notify(`${tailOf(job.tail) || "(no output yet)"}\n\nFull log: ${job.logPath}`, "info");
|
|
1215
|
+
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (action === "stop") {
|
|
1220
|
+
const targets =
|
|
1221
|
+
target === "all" ? all.filter((job) => job.state === "running") : [jobs?.get(target ?? "")];
|
|
1222
|
+
const stopped = targets.filter((job) => job && jobs?.stop(job.id, "user")).map((job) => job!.id);
|
|
1223
|
+
ctx.ui.notify(
|
|
1224
|
+
stopped.length > 0
|
|
1225
|
+
? `Stopping background job${stopped.length === 1 ? "" : "s"} ${stopped.join(", ")}.`
|
|
1226
|
+
: "No running job matches. Usage: /jobs stop <id|all>.",
|
|
1227
|
+
stopped.length > 0 ? "info" : "error",
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
ctx.ui.notify("Usage: /jobs [list|output <id>|stop <id|all>]", "error");
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1035
1237
|
pi.registerCommand("capability", {
|
|
1036
1238
|
description: "Show withdrawn tool groups, or stop being asked before granting one",
|
|
1037
1239
|
getArgumentCompletions: (prefix: string) =>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specpi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Scope control and a human-selected harness improvement loop for Pi",
|
|
5
5
|
"author": "Tanner Middleton",
|
|
6
6
|
"repository": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"scripts/lock.mjs",
|
|
30
30
|
"scripts/packages.mjs",
|
|
31
31
|
"scripts/jev-guard.mjs",
|
|
32
|
+
"scripts/permission-shell-tools.mjs",
|
|
32
33
|
"templates",
|
|
33
34
|
"extensions",
|
|
34
35
|
"skills",
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Install-time seam for @gotgenes/pi-permission-system: make it gate SpecPi's `background` tool
|
|
2
|
+
// exactly like `bash`.
|
|
3
|
+
//
|
|
4
|
+
// The permission system gates native `bash` through its full command stack -- decomposition,
|
|
5
|
+
// wrapper flooring, path and outside-directory checks, and every `bash:` rule. An extension tool
|
|
6
|
+
// that runs shell commands under another name gets none of that unless the permission system is
|
|
7
|
+
// told the tool is a shell; its documented `shellTools` setting does exactly that. Without it a
|
|
8
|
+
// background job could run a command a `bash` rule denies.
|
|
9
|
+
//
|
|
10
|
+
// So SpecPi records one entry, `shellTools.background = { commandArgument: "command" }`, in the
|
|
11
|
+
// permission system's global config at install and update, and removes it again at uninstall. The
|
|
12
|
+
// extension checks the effective mapping before every start and refuses without it, so a missing
|
|
13
|
+
// or altered entry turns background jobs off; it never leaves them ungated.
|
|
14
|
+
//
|
|
15
|
+
// Merged, never replaced: every other key in the file is the user's and survives unchanged. A file
|
|
16
|
+
// carrying comments is left alone, because rewriting it as plain JSON would delete them; the run
|
|
17
|
+
// says so and names the one line to add by hand.
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
export const PERMISSION_PACKAGE = "@gotgenes/pi-permission-system";
|
|
23
|
+
export const SHELL_TOOL = "background";
|
|
24
|
+
export const SHELL_TOOL_MAPPING = Object.freeze({ commandArgument: "command" });
|
|
25
|
+
|
|
26
|
+
export function permissionConfigFile(agentDir) {
|
|
27
|
+
return path.join(agentDir, "extensions", "pi-permission-system", "config.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function permissionInstalled(agentDir) {
|
|
31
|
+
try {
|
|
32
|
+
const manifest = path.join(agentDir, "npm", "node_modules", ...PERMISSION_PACKAGE.split("/"), "package.json");
|
|
33
|
+
|
|
34
|
+
return JSON.parse(fs.readFileSync(manifest, "utf8"))?.name === PERMISSION_PACKAGE;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sameMapping(value) {
|
|
41
|
+
return (
|
|
42
|
+
Boolean(value) &&
|
|
43
|
+
typeof value === "object" &&
|
|
44
|
+
!Array.isArray(value) &&
|
|
45
|
+
Object.keys(value).length === 1 &&
|
|
46
|
+
value.commandArgument === SHELL_TOOL_MAPPING.commandArgument
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The file as plain JSON, `undefined` when absent, or `{ unreadable }` when it cannot be rewritten. */
|
|
51
|
+
function readConfig(file) {
|
|
52
|
+
if (!fs.existsSync(file)) {
|
|
53
|
+
return { config: undefined };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const stat = fs.lstatSync(file);
|
|
57
|
+
if (!stat.isFile()) {
|
|
58
|
+
return { unreadable: "it is not a regular file" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const config = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
63
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
64
|
+
return { unreadable: "it is not a JSON object" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { config };
|
|
68
|
+
} catch {
|
|
69
|
+
return {
|
|
70
|
+
unreadable: "it is not plain JSON (comments are allowed there, but SpecPi will not rewrite them away)",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeConfig(file, config, mode) {
|
|
76
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
77
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
78
|
+
fs.writeFileSync(temporary, `${JSON.stringify(config, null, 4)}\n`, { mode });
|
|
79
|
+
fs.renameSync(temporary, file);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function manualInstruction(agentDir) {
|
|
83
|
+
return `add "shellTools": { "${SHELL_TOOL}": { "commandArgument": "command" } } to ${permissionConfigFile(agentDir)} so bash rules apply to background jobs`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Record the mapping. Returns what happened so the installer can report the cases worth reporting:
|
|
88
|
+
* a file it could not rewrite, or an existing `background` entry pointing somewhere else, which it
|
|
89
|
+
* replaces because that entry would have left `command` ungated.
|
|
90
|
+
*/
|
|
91
|
+
export function applyShellToolMapping(agentDir) {
|
|
92
|
+
if (!permissionInstalled(agentDir)) {
|
|
93
|
+
return { applied: false, reason: "not-installed" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const file = permissionConfigFile(agentDir);
|
|
97
|
+
const { config, unreadable } = readConfig(file);
|
|
98
|
+
if (unreadable) {
|
|
99
|
+
return { applied: false, reason: "unreadable", detail: unreadable };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const current = config?.shellTools?.[SHELL_TOOL];
|
|
103
|
+
if (sameMapping(current)) {
|
|
104
|
+
return { applied: false, reason: "already-current" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
config?.shellTools !== undefined &&
|
|
109
|
+
(typeof config.shellTools !== "object" || Array.isArray(config.shellTools))
|
|
110
|
+
) {
|
|
111
|
+
return { applied: false, reason: "unreadable", detail: "its shellTools value is not an object" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const next = {
|
|
115
|
+
...(config ?? {}),
|
|
116
|
+
shellTools: { ...(config?.shellTools ?? {}), [SHELL_TOOL]: { ...SHELL_TOOL_MAPPING } },
|
|
117
|
+
};
|
|
118
|
+
writeConfig(file, next, config ? fs.statSync(file).mode & 0o777 : 0o600);
|
|
119
|
+
|
|
120
|
+
return { applied: true, reason: config ? "updated" : "created", replaced: current !== undefined };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Remove SpecPi's entry, and the file too when that entry was all it held. */
|
|
124
|
+
export function removeShellToolMapping(agentDir) {
|
|
125
|
+
const file = permissionConfigFile(agentDir);
|
|
126
|
+
const { config, unreadable } = readConfig(file);
|
|
127
|
+
if (unreadable || !config || !sameMapping(config.shellTools?.[SHELL_TOOL])) {
|
|
128
|
+
return { removed: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { [SHELL_TOOL]: _removed, ...shellTools } = config.shellTools;
|
|
132
|
+
const next = { ...config, shellTools };
|
|
133
|
+
if (Object.keys(shellTools).length === 0) {
|
|
134
|
+
delete next.shellTools;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (Object.keys(next).length === 0) {
|
|
138
|
+
fs.rmSync(file);
|
|
139
|
+
|
|
140
|
+
return { removed: true, deletedFile: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
writeConfig(file, next, fs.statSync(file).mode & 0o777);
|
|
144
|
+
|
|
145
|
+
return { removed: true, deletedFile: false };
|
|
146
|
+
}
|
package/scripts/specpi.mjs
CHANGED
|
@@ -24,6 +24,12 @@ import { runValidator } from "../extensions/tool-wishlist/validators.mjs";
|
|
|
24
24
|
import { acquireSpecPiLock } from "./lock.mjs";
|
|
25
25
|
import { basePackages, checkBasePackages, installBasePackages, packageChanges, runBrowserQA } from "./packages.mjs";
|
|
26
26
|
import { applyInertConfig as applyGuardConfig, configPath as guardConfigPath } from "./jev-guard.mjs";
|
|
27
|
+
import {
|
|
28
|
+
applyShellToolMapping,
|
|
29
|
+
manualInstruction as shellToolInstruction,
|
|
30
|
+
permissionConfigFile,
|
|
31
|
+
removeShellToolMapping,
|
|
32
|
+
} from "./permission-shell-tools.mjs";
|
|
27
33
|
|
|
28
34
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
35
|
const VERSION = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
|
|
@@ -41,6 +47,7 @@ const resourcePaths = [
|
|
|
41
47
|
"extensions/workflow-controls/web-access.mjs",
|
|
42
48
|
"extensions/workflow-controls/capabilities.mjs",
|
|
43
49
|
"extensions/workflow-controls/capability-policy.mjs",
|
|
50
|
+
"extensions/workflow-controls/background.mjs",
|
|
44
51
|
"extensions/tool-wishlist/index.ts",
|
|
45
52
|
"extensions/tool-wishlist/core.mjs",
|
|
46
53
|
"extensions/tool-wishlist/verification.mjs",
|
|
@@ -195,6 +202,9 @@ function printPlan(options = {}) {
|
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
console.log(` ${agentsPath} (SpecPi marker block only)\n ${manifestPath}`);
|
|
205
|
+
console.log(
|
|
206
|
+
` ${permissionConfigFile(agentDir)} (one shellTools entry, only when the permission system is installed, so bash rules apply to background jobs)`,
|
|
207
|
+
);
|
|
198
208
|
const wanted = new Set(managedFiles().map(([, target]) => target));
|
|
199
209
|
for (const target of Object.keys(manifest?.files || {})) {
|
|
200
210
|
if (!wanted.has(target)) {
|
|
@@ -301,6 +311,9 @@ async function mutate(options, operation) {
|
|
|
301
311
|
const watched = [
|
|
302
312
|
agentsPath,
|
|
303
313
|
manifestPath,
|
|
314
|
+
// The permission system's global config: one shellTools entry, backed up and rolled back
|
|
315
|
+
// with everything else (see permission-shell-tools.mjs).
|
|
316
|
+
permissionConfigFile(agentDir),
|
|
304
317
|
...files.map(([, target]) => target),
|
|
305
318
|
...Object.keys(previous?.files || {}),
|
|
306
319
|
];
|
|
@@ -380,6 +393,24 @@ async function mutate(options, operation) {
|
|
|
380
393
|
};
|
|
381
394
|
}
|
|
382
395
|
|
|
396
|
+
// Tell the permission system that the background tool is a shell, so every bash rule applies
|
|
397
|
+
// to it. Outside the package branch because an existing base keeps its permission system
|
|
398
|
+
// under --skip-package-install too.
|
|
399
|
+
if (operation !== "uninstall") {
|
|
400
|
+
const mapping = applyShellToolMapping(agentDir);
|
|
401
|
+
if (mapping.reason === "unreadable") {
|
|
402
|
+
warnings.push(
|
|
403
|
+
`Background jobs stay off: could not update the permission system's config because ${mapping.detail}. To enable them, ${shellToolInstruction(agentDir)}.`,
|
|
404
|
+
);
|
|
405
|
+
} else if (mapping.replaced) {
|
|
406
|
+
warnings.push(
|
|
407
|
+
`Replaced a "background" shellTools entry in ${permissionConfigFile(agentDir)} that did not gate its command argument; bash rules now apply to background jobs.`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
removeShellToolMapping(agentDir);
|
|
412
|
+
}
|
|
413
|
+
|
|
383
414
|
injectTestFailure("after-settings");
|
|
384
415
|
const records = {};
|
|
385
416
|
const wanted = new Set(files.map(([, target]) => target));
|
package/templates/AGENTS.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
## SpecPi Working Agreement
|
|
2
2
|
|
|
3
3
|
- Keep changes small, testable, reversible, and tied to the human's request. Inspect existing code and guidance before editing; avoid adjacent feature accumulation.
|
|
4
|
+
- Take no irreversible step the request did not ask for, such as rewriting Git history, pruning reflogs or objects, force-pushing, or deleting data that cannot be restored. Do the reversible part, then say what remains and offer it.
|
|
4
5
|
- Prefer direct work and existing project tools. Ask concise questions only when material requirements are unclear.
|
|
5
6
|
- Keep replies brief and human-readable: lead with the answer, use plain language, avoid jargon and repetitive progress updates, and include technical detail only when it helps the human decide or act.
|
|
6
7
|
- Write commits and pull requests the same way: a short, clear title and a brief note on what changed, why, and what was tested.
|