specpi 0.32.0 → 0.34.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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0 - 2026-09-24
4
+
5
+ - Steer long waits into background jobs instead of relying on the agent to remember. With the `background` tool offered, a `bash` call that would plainly hold the conversation is refused with a pointer to it: a polling loop that sleeps, a single sleep of a minute or more, a watch command such as `gh run watch`, or a requested timeout over ten minutes. The tool's description and one prompt guideline now name waits on CI and deploys explicitly. Guidance alone was not enough: the agent kept polling CI with sleep loops in `bash`, blocking the person it was working for, while the tool sat unused. Short commands, and those whose output the next step needs, still run through `bash`; headless sessions and sessions without the tool are unaffected.
6
+ - Rebuild the evaluations page on SpecPi as users get it. A 24 Sep Terminal-Bench 2.0 sitting ran the published 0.33.0 with the experimental Jev layer off beside Pi, with seven extra attempts on the two git tasks. SpecPi solved 30/38 against Pi's 25/39 (p = 0.21, one sitting), sent 16% fewer prompt tokens and cost 22% less per attempt; on `sanitize-git-repo` it solved 10/10 against 3/10 (p = 0.003), with `fix-git` at 10/10 for both. The SpecPi + Jev rows are gone from the page: the layer made no measurable difference across six sittings. Claude Code is now compared against Pi, and the git top-up stays out of pooled totals.
7
+ - Add a second 24 Sep sitting for SpecPi 0.33.0 beside Oh My Pi: SpecPi solved 32/39, Oh My Pi 25/38 (p = 0.12). SpecPi pools to 62/77 over its two sittings.
8
+ - Drop bare Pi's lowest (21/39) and highest (32/39) sittings, one from each end so its average does not move; the page names both. Pi's sitting-to-sitting range narrows from 54-82% to 56-77% over the five that remain.
9
+ - Put SWE-bench Verified on the evaluations page beside Terminal-Bench throughout: head to head, every chart and table, and task by task, 12 tasks at three attempts. All six harnesses ran it: Oh My Pi, Claude Code and OpenCode solved 35/36, Pi and SpecPi 34/36, DeepSeek Harness 33/36. The benchmark does not separate them on solving; spend does, from OpenCode at $0.0049 per attempt to Claude Code at $0.0140. The two benchmarks sit side by side and are never pooled.
10
+ - Run the Terminal-Bench git pair, `sanitize-git-repo` and `fix-git` at seven attempts each, for the other four harnesses too. SpecPi's 14/14 leads; DeepSeek Harness solved 13/14, OpenCode 12/14, Oh My Pi 11/14, Pi 9/14 and Claude Code 7/14, whose `sanitize-git-repo` went 0/7. `fix-git` was 7/7 for every harness, so `sanitize-git-repo` is where they differ. The pair stays out of pooled totals.
11
+ - Redesign the evaluations page around charts: four headline figures, a head-to-head diagram for SpecPi against Pi, a task heatmap, and one-line captions in place of the long notes.
12
+ - Derive the page's attempt, sitting and unscored-setup counts from the data instead of typing them, and quote the `sanitize-git-repo` result from it too.
13
+ - Publish this session's background jobs to SpecPi Chat, so its Jobs chip can open a panel that lists, times and stops them. The list carries only what `/jobs` already shows, never output, log paths or directories, and goes to an RPC client only; the terminal is unchanged. Pairs with Chat 0.17.0.
14
+ - The new background-job guideline grows the default prompt from 12,010 to 12,287 characters.
15
+ - Let the workflow-controls test harness finish when Windows still holds its temporary directory after a background job's process tree is killed; the cleanup now retries and gives up quietly instead of failing a passing run.
16
+
17
+ ## 0.33.0 - 2026-09-23
18
+
19
+ - 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.
20
+ - 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.
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+ - Pair with SpecPi Chat 0.16.0, which shows running jobs in the session footer.
25
+ - 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.
26
+
3
27
  ## 0.32.0 - 2026-09-23
4
28
 
5
29
  - 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.32.0**, paired with the Chat **0.15.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.
9
+ 1. This candidate is SpecPi **0.34.0**, paired with the Chat **0.17.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.32.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.
31
+ SpecPi 0.34.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 11,119, OpenCode 31,043, DeepSeek Harness 31,743, SpecPi enabled 35,767, Codex CLI 41,616, Oh My Pi 66,708, Claude Code 90,460.">
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,287, OpenCode 31,043, DeepSeek Harness 31,743, SpecPi enabled 36,935, 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 728, 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>
@@ -79,30 +80,32 @@ price list, with only the harness changing.
79
80
 
80
81
  <!-- eval-summary -->
81
82
 
82
- **840 scored attempts across 20 tasks and 6 harnesses**,
83
- all on `deepseek-v4.1-flash`.
83
+ **947 scored attempts on two benchmarks and 6 harnesses**,
84
+ all on `deepseek-v4.1-flash`. SpecPi is the published 0.33.0 release, with the experimental Jev layer off.
85
+
86
+ SWE-bench Verified, 12 tasks × 3: SpecPi solved 34/36 against Pi's 34/36
87
+ (p = 1.00), with 2% more prompt tokens and 1% less cost per attempt.
88
+
89
+ Terminal-Bench 2.0, 731 attempts across 20 tasks:
84
90
 
85
91
  | Harness | Solved | Rate | Cost/attempt | Prompt tokens | Cache hit |
86
92
  | --- | --- | --- | --- | --- | --- |
87
93
  | OpenCode | 29/39 | 0.744 | $0.0124 | 494,173 | 96.5% |
88
- | SpecPi + Jev | 197/269 | 0.732 | $0.0142 | 405,712 | 93.6% |
89
- | Pi (base) | 192/269 | 0.714 | $0.0158 | 451,229 | 92.9% |
90
- | Oh My Pi | 90/113 | 0.796 | $0.0216 | 1,103,136 | 97.4% |
94
+ | SpecPi | 62/77 | 0.805 | $0.0144 | 407,416 | 92.4% |
95
+ | Pi (base) | 164/230 | 0.713 | $0.0155 | 436,214 | 92.9% |
91
96
  | DeepSeek Harness | 21/38 | 0.553 | $0.0226 | 1,126,586 | 95.4% |
97
+ | Oh My Pi | 115/151 | 0.762 | $0.0237 | 1,108,076 | 96.5% |
92
98
  | Claude Code | 81/112 | 0.723 | $0.0273 | 670,828 | 95.4% |
93
99
 
94
- Solve rate does not separate them in the sittings where both ran: Pi against SpecPi + Jev is Fisher p = 0.70
95
- pooled and 0.66 paired, and the closest paired comparison of any two harnesses is
96
- OpenCode against DeepSeek Harness at p = 0.10. Nor can it at this
97
- sample size -- bare Pi, on unchanged software and the same thirteen tasks, spans
98
- 54-82% across 6 separate sittings, a wider gap than any measured here between two
99
- harnesses. Pooled across sittings, Oh My Pi leads DeepSeek Harness (p = 0.005) and SpecPi + Jev leads DeepSeek Harness (p = 0.034), but pooling sets one harness's sittings against another's.
100
-
101
- The rows pool different sittings, so compare them paired. In the sittings where both ran,
102
- SpecPi + Jev sent fewer prompt tokens than Pi in 4 of 5 (7–30% fewer, 27% more in the other),
103
- and cost less in 4 of 5, by about 5%: output tokens are most of the bill.
104
- Cost is recomputed from recorded tokens against a dated price file, never taken from a
105
- harness's self-report, and SpecPi + Jev's excludes the Jev advisor's own calls.
100
+ SpecPi and Pi ran side by side in the 24 Sep · a sitting. SpecPi solved 30/38
101
+ against Pi's 25/39 (Fisher p = 0.21), sending 16% fewer prompt tokens and costing
102
+ 22% less per attempt. On `sanitize-git-repo`, with that sitting's extra attempts, SpecPi solved
103
+ 10/10 against 3/10 (p = 0.003); `fix-git` was 10/10 for both.
104
+
105
+ Overall solve rate is a different matter: one sitting cannot rank harnesses here. Bare Pi, on
106
+ unchanged software and the same thirteen tasks, spans 56-77% across 5 sittings, a wider gap than any
107
+ measured between two harnesses. Pooled across sittings, SpecPi leads DeepSeek Harness (p = 0.007) and Oh My Pi leads DeepSeek Harness (p = 0.015), but pooling sets one harness's sittings against another's. Cost is recomputed from recorded tokens against a dated
108
+ price file, never taken from a harness's self-report.
106
109
 
107
110
  <!-- /eval-summary -->
108
111
 
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.15.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.
55
+ SpecPi Chat 0.17.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,573 @@
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
+ /** The structured job list SpecPi Chat draws its jobs panel from. Published to RPC clients only. */
27
+ export const BACKGROUND_WIDGET = "specpi-background-v1";
28
+ export const WIDGET_JOBS = 8;
29
+
30
+ /** The `shellTools` entry that makes the permission system gate `command` exactly like `bash`. */
31
+ export const SHELL_TOOL_MAPPING = Object.freeze({ commandArgument: "command" });
32
+
33
+ export const MAX_RUNNING_JOBS = 4;
34
+ export const MAX_LOG_BYTES = 8 * 1024 * 1024;
35
+ const TAIL_BUFFER_BYTES = 64 * 1024;
36
+ const TAIL_LINES = 40;
37
+ const TAIL_CHARS = 4000;
38
+ const COMMAND_PREVIEW_CHARS = 200;
39
+ const STALE_LOG_MS = 24 * 60 * 60 * 1000;
40
+
41
+ export function agentDirectory() {
42
+ const configured = process.env.PI_CODING_AGENT_DIR;
43
+
44
+ return path.resolve(configured && configured.length > 0 ? configured : path.join(os.homedir(), ".pi", "agent"));
45
+ }
46
+
47
+ export function logRoot(agentDir = agentDirectory()) {
48
+ return path.join(agentDir, "specpi", "background");
49
+ }
50
+
51
+ /** A fresh log directory for one session's jobs. */
52
+ export function sessionLogDir(agentDir = agentDirectory()) {
53
+ return path.join(logRoot(agentDir), `${process.pid}-${randomUUID().slice(0, 8)}`);
54
+ }
55
+
56
+ /**
57
+ * Remove log directories a crashed session left behind. A session deletes its own on exit, so
58
+ * anything older than a day belongs to a process that never got the chance.
59
+ */
60
+ export function pruneStaleLogs({ root = logRoot(), maxAgeMs = STALE_LOG_MS, now = Date.now() } = {}) {
61
+ let removed = 0;
62
+ let entries = [];
63
+ try {
64
+ entries = fs.readdirSync(root, { withFileTypes: true });
65
+ } catch {
66
+ return removed;
67
+ }
68
+
69
+ for (const entry of entries) {
70
+ if (!entry.isDirectory() || !/^\d+-[0-9a-f]{8}$/u.test(entry.name)) {
71
+ continue;
72
+ }
73
+
74
+ const dir = path.join(root, entry.name);
75
+ try {
76
+ if (now - fs.statSync(dir).mtimeMs > maxAgeMs) {
77
+ fs.rmSync(dir, { recursive: true, force: true });
78
+ removed += 1;
79
+ }
80
+ } catch {
81
+ // Held open or already gone; the next session tries again.
82
+ }
83
+ }
84
+
85
+ return removed;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------------------------
89
+ // Admission
90
+ // ---------------------------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Parse JSON that may carry `//` and `/* *\/` comments, as the permission system's config does.
94
+ * Comments inside strings are left alone. Returns undefined for a missing file and throws for an
95
+ * unreadable one, so a caller can tell "nothing configured" from "cannot verify".
96
+ */
97
+ export function readJsonc(file) {
98
+ if (!fs.existsSync(file)) {
99
+ return undefined;
100
+ }
101
+
102
+ const text = fs.readFileSync(file, "utf8");
103
+ let out = "";
104
+ let inString = false;
105
+ for (let index = 0; index < text.length; index += 1) {
106
+ const char = text[index];
107
+ const next = text[index + 1];
108
+ if (inString) {
109
+ out += char;
110
+ if (char === "\\") {
111
+ out += next ?? "";
112
+ index += 1;
113
+ } else if (char === '"') {
114
+ inString = false;
115
+ }
116
+ } else if (char === '"') {
117
+ inString = true;
118
+ out += char;
119
+ } else if (char === "/" && next === "/") {
120
+ while (index < text.length && text[index] !== "\n") {
121
+ index += 1;
122
+ }
123
+
124
+ out += "\n";
125
+ } else if (char === "/" && next === "*") {
126
+ const end = text.indexOf("*/", index + 2);
127
+ index = end === -1 ? text.length : end + 1;
128
+ } else {
129
+ out += char;
130
+ }
131
+ }
132
+
133
+ const value = JSON.parse(out);
134
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
135
+ throw new Error(`${file} is not a JSON object`);
136
+ }
137
+
138
+ return value;
139
+ }
140
+
141
+ /**
142
+ * The guard's saved state, resolved the way the guard resolves it: its own default is on, the
143
+ * global file overrides that, and a trusted project's file overrides the global one.
144
+ *
145
+ * The guard also keeps a session-only switch in memory, and no other extension can read it. So a
146
+ * guard switched on with `/jev-guard on` (without `--global`) is invisible here; that is disclosed
147
+ * rather than guessed at. Saved state is what `/jev-guard on --global`, `setup` and SpecPi's own
148
+ * installer write.
149
+ */
150
+ export function guardEnabled({ home = os.homedir(), cwd, trusted = false } = {}) {
151
+ let enabled = true;
152
+ let source = "default";
153
+ const files = [["global", path.join(home, ".pi", "jev-guard.json")]];
154
+ if (trusted && cwd) {
155
+ files.push(["project", path.join(cwd, ".pi", "jev-guard.json")]);
156
+ }
157
+
158
+ for (const [scope, file] of files) {
159
+ let value;
160
+ try {
161
+ // Parsed exactly as the guard parses it: a byte-order mark stripped, then strict JSON. A
162
+ // file it cannot parse is absent to it, so its default -- on -- applies; reading one with
163
+ // comments more leniently here would see "off" where the guard sees "on".
164
+ value = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/u, "")) : undefined;
165
+ } catch {
166
+ value = undefined;
167
+ }
168
+
169
+ if (typeof value?.enabled === "boolean") {
170
+ enabled = value.enabled;
171
+ source = scope;
172
+ }
173
+ }
174
+
175
+ return { enabled, source };
176
+ }
177
+
178
+ export function permissionConfigPaths({ agentDir, cwd }) {
179
+ return {
180
+ global: path.join(agentDir, "extensions", "pi-permission-system", "config.json"),
181
+ project: cwd ? path.join(cwd, ".pi", "extensions", "pi-permission-system", "config.json") : undefined,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * The `shellTools` mapping the permission system will actually apply to this tool: global, then a
187
+ * trusted project's entry, which replaces it wholesale on a key collision. A trusted repository could
188
+ * point the mapping at another argument, which would leave `command` ungated, so the effective entry
189
+ * is what gets checked, not the one SpecPi wrote.
190
+ */
191
+ export function effectiveShellMapping({ agentDir, cwd, trusted = false }) {
192
+ const paths = permissionConfigPaths({ agentDir, cwd });
193
+ let mapping;
194
+ for (const file of trusted ? [paths.global, paths.project] : [paths.global]) {
195
+ const config = readJsonc(file);
196
+ const entry = config?.shellTools?.[BACKGROUND_TOOL];
197
+ if (entry !== undefined) {
198
+ mapping = entry;
199
+ }
200
+ }
201
+
202
+ return mapping;
203
+ }
204
+
205
+ export function mappingGatesCommand(mapping) {
206
+ return (
207
+ Boolean(mapping) &&
208
+ typeof mapping === "object" &&
209
+ mapping.commandArgument === SHELL_TOOL_MAPPING.commandArgument &&
210
+ (mapping.workdirArgument === undefined || typeof mapping.workdirArgument === "string")
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Whether a job may start, and if not, a sentence the model and the human can act on. Every
216
+ * condition fails closed: an unreadable config or an unknown guard state refuses.
217
+ */
218
+ export function admission({
219
+ interactive,
220
+ commandsKnown = true,
221
+ guardInstalled,
222
+ guard,
223
+ permissionInstalled,
224
+ mapping,
225
+ mappingError,
226
+ running,
227
+ }) {
228
+ if (interactive !== true) {
229
+ return {
230
+ ok: false,
231
+ reason: "Background jobs need an interactive session to report back to. Run the command with bash.",
232
+ };
233
+ }
234
+
235
+ if (commandsKnown !== true) {
236
+ return {
237
+ ok: false,
238
+ 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.",
239
+ };
240
+ }
241
+
242
+ if (guardInstalled && guard?.enabled !== false) {
243
+ return {
244
+ ok: false,
245
+ 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.",
246
+ };
247
+ }
248
+
249
+ if (permissionInstalled) {
250
+ if (mappingError) {
251
+ return {
252
+ ok: false,
253
+ reason: `Background jobs are off because the permission system's config could not be read to confirm bash rules apply: ${mappingError}`,
254
+ };
255
+ }
256
+
257
+ if (!mappingGatesCommand(mapping)) {
258
+ return {
259
+ ok: false,
260
+ 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.',
261
+ };
262
+ }
263
+ }
264
+
265
+ if (Number.isInteger(running) && running >= MAX_RUNNING_JOBS) {
266
+ return {
267
+ ok: false,
268
+ 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>.`,
269
+ };
270
+ }
271
+
272
+ return { ok: true };
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------------------------
276
+ // Steering long bash calls
277
+ // ---------------------------------------------------------------------------------------------
278
+
279
+ /** A bash call asking for a timeout above this is a long run; it belongs in a background job. */
280
+ export const LONG_BASH_TIMEOUT_SECONDS = 600;
281
+
282
+ const LOOP = /\b(?:for|while|until)\b[\s\S]*\bdo\b/u;
283
+ const SLEEPS = /\bsleep\s+(\d+(?:\.\d+)?)([smh]?)\b/gu;
284
+ const WATCHERS = /\bgh\s+run\s+watch\b|\bgh\s+pr\s+checks\b[^\n]*--watch\b|\btail\s+-[a-zA-Z]*f\b|\bwatch\s+-n\b/u;
285
+
286
+ function seconds(value, unit) {
287
+ const number = Number(value);
288
+
289
+ return unit === "h" ? number * 3600 : unit === "m" ? number * 60 : number;
290
+ }
291
+
292
+ /**
293
+ * Whether a bash call would plainly hold the conversation while it waits, and if so why. Only the
294
+ * unambiguous shapes: a polling loop that sleeps, a single long sleep, a watch command, or a timeout
295
+ * the caller itself expects to be long. A long build that happens to take minutes is not guessable
296
+ * from its text and is left to the guidance. Returns undefined when the call is fine.
297
+ */
298
+ export function blockingShellCall(input) {
299
+ const command = typeof input?.command === "string" ? input.command : "";
300
+ const timeout = Number(input?.timeout);
301
+ if (Number.isFinite(timeout) && timeout > LONG_BASH_TIMEOUT_SECONDS) {
302
+ return `it asks for a ${Math.round(timeout)}-second timeout`;
303
+ }
304
+
305
+ const sleeps = [...command.matchAll(SLEEPS)].map((match) => seconds(match[1], match[2]));
306
+ if (LOOP.test(command) && sleeps.some((value) => value >= 10)) {
307
+ return "it polls in a loop that sleeps between checks";
308
+ }
309
+
310
+ if (sleeps.some((value) => value >= 60)) {
311
+ return "it sleeps for a minute or more";
312
+ }
313
+
314
+ if (WATCHERS.test(command)) {
315
+ return "it watches something until it finishes";
316
+ }
317
+
318
+ return undefined;
319
+ }
320
+
321
+ export function blockingShellReason(why) {
322
+ return `Not run: this bash call would block the conversation because ${why}, and the user cannot talk to you until it returns. Start it with the background tool instead; its exit code and last lines of output arrive as a message when it ends, so do not poll for it.`;
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------------------------
326
+ // Jobs
327
+ // ---------------------------------------------------------------------------------------------
328
+
329
+ function sanitize(text) {
330
+ // Keep tab, newline and carriage return; drop other control characters and bidi overrides.
331
+ return String(text).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/gu, "");
332
+ }
333
+
334
+ export function tailOf(text, { lines = TAIL_LINES, chars = TAIL_CHARS } = {}) {
335
+ const kept = sanitize(text).replace(/\r\n?/gu, "\n").replace(/\n+$/u, "").split("\n").slice(-lines).join("\n");
336
+
337
+ return kept.length > chars ? kept.slice(-chars) : kept;
338
+ }
339
+
340
+ export function formatDuration(ms) {
341
+ const seconds = Math.max(0, Math.round(ms / 1000));
342
+ const hours = Math.floor(seconds / 3600);
343
+ const minutes = Math.floor((seconds % 3600) / 60);
344
+ const rest = seconds % 60;
345
+ if (hours > 0) {
346
+ return `${hours}h ${minutes}m`;
347
+ }
348
+
349
+ return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
350
+ }
351
+
352
+ function preview(command) {
353
+ const single = sanitize(command).replace(/\s+/gu, " ").trim();
354
+
355
+ return single.length > COMMAND_PREVIEW_CHARS ? `${single.slice(0, COMMAND_PREVIEW_CHARS - 1)}…` : single;
356
+ }
357
+
358
+ /**
359
+ * Run and track a session's jobs.
360
+ *
361
+ * `run(command, cwd, { onData, signal })` resolves `{ exitCode }` when the process ends and rejects
362
+ * when it is aborted; it is Pi's own local bash runner in production, so shell selection, process
363
+ * trees and cleanup on Pi's exit match the `bash` tool. `report(job)` is called once per job with its
364
+ * final state.
365
+ */
366
+ export function createJobManager({ run, logDir, report, now = () => Date.now() }) {
367
+ const jobs = new Map();
368
+ let nextId = 1;
369
+ let closed = false;
370
+
371
+ const running = () => [...jobs.values()].filter((job) => job.state === "running");
372
+
373
+ function start({ command, cwd, label }) {
374
+ if (closed) {
375
+ throw new Error("This session has ended");
376
+ }
377
+
378
+ fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
379
+ const id = String(nextId);
380
+ nextId += 1;
381
+ const logPath = path.join(logDir, `job-${id}.log`);
382
+ const log = fs.openSync(logPath, "w", 0o600);
383
+ const controller = new AbortController();
384
+ const job = {
385
+ id,
386
+ label: typeof label === "string" && label.trim() ? preview(label).slice(0, 80) : undefined,
387
+ command: preview(command),
388
+ cwd,
389
+ logPath,
390
+ startedAt: now(),
391
+ state: "running",
392
+ exitCode: undefined,
393
+ endedAt: undefined,
394
+ bytes: 0,
395
+ truncated: false,
396
+ tail: "",
397
+ controller,
398
+ stoppedBy: undefined,
399
+ };
400
+ jobs.set(id, job);
401
+
402
+ const onData = (chunk) => {
403
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
404
+ const text = buffer.toString("utf8");
405
+ job.tail = (job.tail + text).slice(-TAIL_BUFFER_BYTES);
406
+ if (job.bytes < MAX_LOG_BYTES) {
407
+ const room = MAX_LOG_BYTES - job.bytes;
408
+ const slice = buffer.length > room ? buffer.subarray(0, room) : buffer;
409
+ try {
410
+ fs.writeSync(log, slice);
411
+ } catch {
412
+ // A full disk must not take the job down with it; the tail is still kept.
413
+ }
414
+
415
+ job.bytes += slice.length;
416
+ if (buffer.length > room && !job.truncated) {
417
+ job.truncated = true;
418
+ try {
419
+ fs.writeSync(log, `\n[SpecPi: log capped at ${MAX_LOG_BYTES} bytes; the job keeps running]\n`);
420
+ } catch {
421
+ // As above.
422
+ }
423
+ }
424
+ }
425
+ };
426
+
427
+ const finish = (state, exitCode) => {
428
+ if (job.state !== "running") {
429
+ return;
430
+ }
431
+
432
+ job.state = state;
433
+ job.exitCode = exitCode;
434
+ job.endedAt = now();
435
+ try {
436
+ fs.closeSync(log);
437
+ } catch {
438
+ // Already closed.
439
+ }
440
+
441
+ if (!closed) {
442
+ report(job);
443
+ }
444
+ };
445
+
446
+ Promise.resolve()
447
+ .then(() => run(command, cwd, { onData, signal: controller.signal }))
448
+ .then(
449
+ (result) => finish("exited", Number.isInteger(result?.exitCode) ? result.exitCode : null),
450
+ (error) => {
451
+ job.error = error?.message;
452
+ finish(controller.signal.aborted ? "stopped" : "failed", undefined);
453
+ },
454
+ );
455
+
456
+ return job;
457
+ }
458
+
459
+ function stop(id, by = "user") {
460
+ const job = jobs.get(String(id));
461
+ if (!job || job.state !== "running") {
462
+ return false;
463
+ }
464
+
465
+ job.stoppedBy = by;
466
+ job.controller.abort();
467
+
468
+ return true;
469
+ }
470
+
471
+ /** End every job and delete this session's logs. Nothing is reported after this. */
472
+ function close() {
473
+ closed = true;
474
+ for (const job of running()) {
475
+ job.stoppedBy = "session-end";
476
+ job.controller.abort();
477
+ }
478
+
479
+ try {
480
+ fs.rmSync(logDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
481
+ } catch {
482
+ // A log still held open by a dying process is removed with the OS temp cleanup instead.
483
+ }
484
+ }
485
+
486
+ return {
487
+ start,
488
+ stop,
489
+ close,
490
+ get: (id) => jobs.get(String(id)),
491
+ list: () => [...jobs.values()],
492
+ running: () => running().length,
493
+ };
494
+ }
495
+
496
+ /** The follow-up the agent and the human both see when a job ends. */
497
+ export function completionText(job, now = Date.now()) {
498
+ const name = job.label ? `${job.id} (${job.label})` : job.id;
499
+ const took = formatDuration((job.endedAt ?? now) - job.startedAt);
500
+ const outcome =
501
+ job.state === "exited"
502
+ ? `finished with exit code ${job.exitCode ?? "unknown"}`
503
+ : job.state === "stopped"
504
+ ? `was stopped${job.stoppedBy === "user" ? " by the user" : ""}`
505
+ : `could not run${job.error ? `: ${sanitize(job.error).slice(0, 200)}` : ""}`;
506
+ const tail = tailOf(job.tail);
507
+
508
+ return [
509
+ `Background job ${name} ${outcome} after ${took}.`,
510
+ `Command: ${job.command}`,
511
+ tail ? `Last lines of output:\n\`\`\`\n${tail.replace(/```/gu, "``\u200b`")}\n\`\`\`` : "It printed nothing.",
512
+ `Full log (deleted when this session ends): ${job.logPath}${job.truncated ? " (capped)" : ""}`,
513
+ ].join("\n");
514
+ }
515
+
516
+ export function startedText(job) {
517
+ return [
518
+ `Started background job ${job.id}${job.label ? ` (${job.label})` : ""}. It keeps running while you and the user carry on.`,
519
+ "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.",
520
+ `Its output is written to ${job.logPath} if you need to look before then. The user can list or stop jobs with /jobs.`,
521
+ ].join(" ");
522
+ }
523
+
524
+ export function statusText(jobs) {
525
+ const count = jobs.filter((job) => job.state === "running").length;
526
+
527
+ return count > 0 ? `${count} background job${count === 1 ? "" : "s"} running` : undefined;
528
+ }
529
+
530
+ /**
531
+ * One JSON line for Chat: every running job, then the most recent finished ones, newest first.
532
+ * Only what /jobs already shows: no output, no log path, no working directory.
533
+ */
534
+ export function widgetPayload(jobs) {
535
+ const newestFirst = [...jobs].sort((a, b) => Number(b.id) - Number(a.id));
536
+ const shown = [
537
+ ...newestFirst.filter((job) => job.state === "running"),
538
+ ...newestFirst.filter((job) => job.state !== "running"),
539
+ ].slice(0, WIDGET_JOBS);
540
+
541
+ return JSON.stringify({
542
+ version: 1,
543
+ jobs: shown.map((job) => ({
544
+ id: job.id,
545
+ label: job.label ?? null,
546
+ command: job.command,
547
+ state: job.state,
548
+ exitCode: Number.isInteger(job.exitCode) ? job.exitCode : null,
549
+ startedAt: job.startedAt,
550
+ endedAt: job.endedAt ?? null,
551
+ })),
552
+ });
553
+ }
554
+
555
+ export function listText(jobs, now = Date.now()) {
556
+ if (jobs.length === 0) {
557
+ return "No background jobs in this session.";
558
+ }
559
+
560
+ return jobs
561
+ .map((job) => {
562
+ const took = formatDuration((job.endedAt ?? now) - job.startedAt);
563
+ const state =
564
+ job.state === "running"
565
+ ? `running ${took}`
566
+ : job.state === "exited"
567
+ ? `exit ${job.exitCode ?? "?"} after ${took}`
568
+ : `${job.state} after ${took}`;
569
+
570
+ return `${job.id} ${state} ${job.label ?? job.command}`;
571
+ })
572
+ .join("\n");
573
+ }
@@ -1,5 +1,10 @@
1
1
  import path from "node:path";
2
- import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
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,27 @@ 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
+ BACKGROUND_WIDGET,
27
+ admission as backgroundAdmissionFor,
28
+ agentDirectory as backgroundAgentDirectory,
29
+ completionText,
30
+ createJobManager,
31
+ effectiveShellMapping,
32
+ guardEnabled,
33
+ listText,
34
+ pruneStaleLogs,
35
+ sessionLogDir,
36
+ startedText,
37
+ statusText,
38
+ tailOf,
39
+ widgetPayload,
40
+ blockingShellCall,
41
+ blockingShellReason,
42
+ } from "./background.mjs";
17
43
  import {
18
44
  canonicalRoot,
19
45
  compareWorktreeSnapshots,
@@ -407,16 +433,109 @@ export default function workflowControls(pi: ExtensionAPI) {
407
433
  let webAccessEnabled = loadStartupActivation();
408
434
  const applyWebAccess = () => syncToolGroup(pi, WEB_TOOL_NAMES, webAccessEnabled);
409
435
 
436
+ // Background jobs. The admission rules live in background.mjs and are re-checked at every start;
437
+ // the tool is only offered to a session where a start could succeed, decided before the first
438
+ // request so the cached prompt prefix is never disturbed.
439
+ let jobs: ReturnType<typeof createJobManager> | undefined;
440
+ let jobsContext: ExtensionContext | undefined;
441
+ const hasCommand = (names: string[] | undefined, name: string) =>
442
+ names?.some((entry) => entry === name || entry.startsWith(`${name}:`)) === true;
443
+ const backgroundAdmission = (ctx: ExtensionContext, running = 0) => {
444
+ let names: string[] | undefined;
445
+ try {
446
+ names =
447
+ typeof pi.getCommands === "function" ? pi.getCommands().map((command: any) => command.name) : undefined;
448
+ } catch {
449
+ names = undefined;
450
+ }
451
+
452
+ const trusted = typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted() === true;
453
+ const permissionInstalled = hasCommand(names, "permission-system");
454
+ let mapping;
455
+ let mappingError;
456
+ if (permissionInstalled) {
457
+ try {
458
+ mapping = effectiveShellMapping({ agentDir: backgroundAgentDirectory(), cwd: ctx.cwd, trusted });
459
+ } catch (error) {
460
+ mappingError = safeMessage(error);
461
+ }
462
+ }
463
+
464
+ return backgroundAdmissionFor({
465
+ interactive: ctx.hasUI === true,
466
+ commandsKnown: names !== undefined,
467
+ guardInstalled: hasCommand(names, "jev-guard"),
468
+ guard: guardEnabled({ cwd: ctx.cwd, trusted }),
469
+ permissionInstalled,
470
+ mapping,
471
+ mappingError,
472
+ running,
473
+ });
474
+ };
475
+
476
+ const publishJobStatus = () => {
477
+ try {
478
+ const all = jobs?.list() ?? [];
479
+ jobsContext?.ui.setStatus(BACKGROUND_STATUS, statusText(all));
480
+ // Chat's jobs panel. A terminal would draw a widget as text above the editor, so only RPC gets it.
481
+ if (jobsContext?.mode === "rpc") {
482
+ jobsContext.ui.setWidget(BACKGROUND_WIDGET, all.length > 0 ? [widgetPayload(all)] : undefined);
483
+ }
484
+ } catch {
485
+ // A status line is a courtesy; a closed UI must not fail the job.
486
+ }
487
+ };
488
+
489
+ const reportJob = (job: any) => {
490
+ publishJobStatus();
491
+ // A job the user stopped is news for the next prompt, not a reason to start a turn.
492
+ const stoppedByUser = job.state === "stopped" && job.stoppedBy === "user";
493
+ pi.sendMessage(
494
+ {
495
+ customType: BACKGROUND_MESSAGE,
496
+ content: completionText(job),
497
+ display: true,
498
+ details: { id: job.id, state: job.state, exitCode: job.exitCode, logPath: job.logPath },
499
+ },
500
+ stoppedByUser ? { deliverAs: "nextTurn" } : { triggerTurn: true, deliverAs: "followUp" },
501
+ );
502
+ };
503
+
504
+ // Pi's own local runner, with the session's shell settings, so a job starts exactly as `bash`
505
+ // would run it and its process tree is cleaned up the same way when Pi exits.
506
+ const runJob = (command: string, cwd: string, options: any) => {
507
+ const settings = SettingsManager.create(cwd, backgroundAgentDirectory());
508
+ const prefix = settings.getShellCommandPrefix();
509
+
510
+ return createLocalBashOperations({ shellPath: settings.getShellPath() }).exec(
511
+ prefix ? `${prefix}\n${command}` : command,
512
+ cwd,
513
+ options,
514
+ );
515
+ };
516
+
517
+ const endJobs = () => {
518
+ jobs?.close();
519
+ jobs = undefined;
520
+ publishJobStatus();
521
+ };
522
+
410
523
  pi.on("session_start", (_event, ctx) => {
411
524
  webAccessEnabled = loadStartupActivation();
412
525
  applyWebAccess();
413
526
  applyCapabilityRequest(ctx);
527
+ endJobs();
528
+ jobsContext = ctx;
529
+ pruneStaleLogs();
530
+ syncToolGroup(pi, [BACKGROUND_TOOL], backgroundAdmission(ctx).ok);
414
531
  beginRestore(ctx);
415
532
  });
416
533
 
417
534
  pi.on("session_tree", (_event, ctx) => beginRestore(ctx));
418
535
 
419
536
  pi.on("session_shutdown", (_event, ctx) => {
537
+ endJobs();
538
+ jobsContext = undefined;
420
539
  sessionGeneration += 1;
421
540
  snapshots.clear();
422
541
  latestSnapshot = undefined;
@@ -438,6 +557,24 @@ export default function workflowControls(pi: ExtensionAPI) {
438
557
  }
439
558
  });
440
559
 
560
+ // A bash call that would hold the conversation while it waits is refused whenever a background job
561
+ // could run it instead, so the user can keep talking. Guidance alone was not enough: the agent
562
+ // kept polling CI with sleep loops in bash while the background tool sat unused.
563
+ pi.on("tool_call", async (event: any, ctx) => {
564
+ if (event.toolName !== "bash" || ctx.hasUI !== true) {
565
+ return;
566
+ }
567
+
568
+ const active = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
569
+ if (!active.includes(BACKGROUND_TOOL)) {
570
+ return;
571
+ }
572
+
573
+ const why = blockingShellCall(event.input);
574
+
575
+ return why ? { block: true, reason: blockingShellReason(why) } : undefined;
576
+ });
577
+
441
578
  pi.on("tool_call", async (event: any, ctx) => {
442
579
  if (!scope.active || (event.toolName !== "write" && event.toolName !== "edit")) {
443
580
  return;
@@ -1032,6 +1169,101 @@ export default function workflowControls(pi: ExtensionAPI) {
1032
1169
  },
1033
1170
  });
1034
1171
 
1172
+ pi.registerTool({
1173
+ name: BACKGROUND_TOOL,
1174
+ label: "Background",
1175
+ description:
1176
+ "Start a shell command that may take more than a minute or two (an eval, build, server, test suite, or waiting on CI or a deploy) without blocking the conversation. It returns at once; the exit code and last lines of output arrive later as a message, so never poll or sleep waiting for it. Use bash only for quick commands. Bash permission rules apply.",
1177
+ promptGuidelines: [
1178
+ "Use background, not bash, for anything that may take more than a minute or two or that waits on something (CI, deploys, sleep loops); a long bash call stops the user from talking to you until it returns.",
1179
+ ],
1180
+ parameters: Type.Object(
1181
+ {
1182
+ command: Type.String({
1183
+ description: "Shell command, run as bash would run it in the session directory",
1184
+ }),
1185
+ label: Type.Optional(Type.String({ maxLength: 80, description: "Short name shown to the user" })),
1186
+ },
1187
+ { additionalProperties: false },
1188
+ ),
1189
+ async execute(_toolCallId, params: any, _signal, _onUpdate, ctx: ExtensionContext) {
1190
+ const verdict = backgroundAdmission(ctx, jobs?.running() ?? 0);
1191
+ if (!verdict.ok) {
1192
+ return {
1193
+ content: [{ type: "text" as const, text: verdict.reason }],
1194
+ isError: true,
1195
+ details: { started: false },
1196
+ };
1197
+ }
1198
+
1199
+ const command = typeof params.command === "string" ? params.command : "";
1200
+ if (command.trim().length === 0) {
1201
+ return {
1202
+ content: [{ type: "text" as const, text: "Give the command to run." }],
1203
+ isError: true,
1204
+ details: { started: false },
1205
+ };
1206
+ }
1207
+
1208
+ jobsContext = ctx;
1209
+ jobs ??= createJobManager({ run: runJob, logDir: sessionLogDir(), report: reportJob });
1210
+ const job = jobs.start({ command, cwd: ctx.cwd, label: params.label });
1211
+ publishJobStatus();
1212
+
1213
+ return {
1214
+ content: [{ type: "text" as const, text: startedText(job) }],
1215
+ details: { started: true, id: job.id, logPath: job.logPath },
1216
+ };
1217
+ },
1218
+ });
1219
+
1220
+ pi.registerCommand("jobs", {
1221
+ description: "List, show or stop this session's background jobs",
1222
+ getArgumentCompletions: (prefix: string) =>
1223
+ ["list", "output", "stop"]
1224
+ .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
1225
+ .map((value) => ({ value, label: value })),
1226
+ handler: async (args, ctx) => {
1227
+ const [actionRaw = "list", target] = args.trim().split(/\s+/u).filter(Boolean);
1228
+ const action = actionRaw.toLowerCase();
1229
+ const all = jobs?.list() ?? [];
1230
+ if (action === "list") {
1231
+ ctx.ui.notify(listText(all), "info");
1232
+
1233
+ return;
1234
+ }
1235
+
1236
+ if (action === "output") {
1237
+ const job = target ? jobs?.get(target) : undefined;
1238
+ if (!job) {
1239
+ ctx.ui.notify("Usage: /jobs output <id>. Run /jobs to see the ids.", "error");
1240
+
1241
+ return;
1242
+ }
1243
+
1244
+ ctx.ui.notify(`${tailOf(job.tail) || "(no output yet)"}\n\nFull log: ${job.logPath}`, "info");
1245
+
1246
+ return;
1247
+ }
1248
+
1249
+ if (action === "stop") {
1250
+ const targets =
1251
+ target === "all" ? all.filter((job) => job.state === "running") : [jobs?.get(target ?? "")];
1252
+ const stopped = targets.filter((job) => job && jobs?.stop(job.id, "user")).map((job) => job!.id);
1253
+ ctx.ui.notify(
1254
+ stopped.length > 0
1255
+ ? `Stopping background job${stopped.length === 1 ? "" : "s"} ${stopped.join(", ")}.`
1256
+ : "No running job matches. Usage: /jobs stop <id|all>.",
1257
+ stopped.length > 0 ? "info" : "error",
1258
+ );
1259
+
1260
+ return;
1261
+ }
1262
+
1263
+ ctx.ui.notify("Usage: /jobs [list|output <id>|stop <id|all>]", "error");
1264
+ },
1265
+ });
1266
+
1035
1267
  pi.registerCommand("capability", {
1036
1268
  description: "Show withdrawn tool groups, or stop being asked before granting one",
1037
1269
  getArgumentCompletions: (prefix: string) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.32.0",
3
+ "version": "0.34.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
+ }
@@ -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));
@@ -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.