specpi 0.24.0 → 0.26.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 +19 -0
- package/README.md +37 -8
- package/SECURITY_MODEL.md +8 -2
- package/THIRD_PARTY.md +1 -1
- package/extensions/workflow-controls/capabilities.mjs +104 -0
- package/extensions/workflow-controls/capability-policy.mjs +110 -0
- package/extensions/workflow-controls/index.ts +311 -2
- package/extensions/workflow-controls/web-access.mjs +107 -0
- package/package.json +1 -1
- package/scripts/packages.mjs +1 -1
- package/scripts/specpi.mjs +4 -1
- package/templates/AGENTS.md +1 -0
- package/templates/settings.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Measure the DeepSeek Harness on the same terms and add it to the first-call chart. Its default session sends 31,743 characters across 25 tools, about 5.7x stock Pi and within 700 characters of OpenCode. `scripts/measure-context.mjs --dsh=<path to its bin>` takes that row, declared through the harness's own patch layer; its auxiliary session-title request is excluded because it carries no tool schema. The Oh My Pi row is carried forward from the same-terms run while its runtime's dependency resolution stays broken upstream.
|
|
6
|
+
|
|
7
|
+
## 0.26.0 - 2026-09-17
|
|
8
|
+
|
|
9
|
+
- Let the agent ask for a withdrawn tool group instead of working around it. Hiding web access and Browser QA keeps 19,344 characters of tool schema out of every request, but it also hides them from the agent, so a session that turns out to need one had no way to say so. The new `request_capability` tool names the withdrawn groups and asks the human, who may decline; accepting offers that group's tools from the agent's next message, for the rest of the session. It grants nothing on its own: it refuses without an interactive human, and a decline leaves the session unchanged. `/webaccess on` and `/browser on` are unchanged. Delegation is not requestable, because its own package requires a human command to bind a model.
|
|
10
|
+
- Stop asking about a capability you always allow. `/capability allow <name>` records a standing grant so `request_capability` offers that group without a prompt, `/capability ask <name>` restores the prompt, and `/capability` shows which groups are offered and which are granted. Recording a grant needs an interactive command, a grant that fires announces itself, and headless sessions are still refused: the grant removes the prompt, not the human.
|
|
11
|
+
- Bump `specpi-browser-qa` to 0.3.0 and move the base pin to match. Its fourteen tools no longer carry `promptSnippet` or `promptGuidelines`; that guidance moved into the tool descriptions, which travel with the schema. Pi rebuilds the system prompt when an activated tool carries prompt metadata, and that rebuild invalidates the provider's cached prefix even where deferred tool schemas are supported — so the metadata made every mid-session activation more expensive than it needed to be. Behavior is unchanged. `pi-web-access` is a third-party package and still carries its own prompt metadata, so activating web access mid-session continues to rebuild the prompt.
|
|
12
|
+
|
|
13
|
+
## 0.25.0 - 2026-09-17
|
|
14
|
+
|
|
15
|
+
- Hide the web access tools until needed. `web_search`, `source_check`, `fetch_content` and `get_search_content` are no longer offered to a session until `/webaccess on`; `/webaccess startup on` saves that choice. The working agreement, security model and wiki say so, and the agent asks the human to run it rather than attempting a hidden tool.
|
|
16
|
+
- Re-measure first-call context from the complete installed base instead of first-party extensions alone. A default session sends 15,069 characters and the enabled profile (`/browser on`, `/delegate on`, `/webaccess on`) sends 40,203. The earlier 10,536-character figure omitted installed guidance and third-party pins and is corrected on the research page. The measurement, pins and method are published as `site/research/context-measurement.json`.
|
|
17
|
+
- Add a capability chart that partitions the enabled profile's tool schema group by group: web access is the largest at 11,298 characters, ahead of Browser QA's fourteen tools at 8,046. Leaving the three opt-in groups hidden keeps 23,797 characters of tool schema out of every request.
|
|
18
|
+
- Re-measure Oh My Pi on the same terms at 65,816 characters and keep it a measured row: HarnessTax covers Claude Code, Codex CLI and Pi only, so the fork was never a study figure.
|
|
19
|
+
- Measure OpenCode on the same terms and add it to the first-call chart. Its default build-agent session sends 31,043 characters across 10 tools, about 5.6x stock Pi and about twice a default SpecPi, landing between a default SpecPi and the enabled profile. `scripts/measure-context.mjs --oc=<path to OpenCode's binary>` takes that row; the session title is pinned so the turn sends exactly one model call. The research page is rewritten in plainer language and the "For this base" section is restructured around subheads.
|
|
20
|
+
- Stop `/scope` from racing the session restore. Restore retires scope immediately but only learns the repository root once `git rev-parse` returns; a contract declared in that window was recorded against the session cwd, and the replay that followed rejected its own entry as belonging elsewhere and silently turned scope off after reporting it set. Scope commands now wait for the restore in flight.
|
|
21
|
+
|
|
3
22
|
## 0.24.0 - 2026-09-16
|
|
4
23
|
|
|
5
24
|
- Replace `pi-subagents@0.67.0` in the default base with first-party `specpi-delegation@0.2.0`, and add `specpi-experiments@0.1.0`. The base is now seven pinned packages; the other five are unchanged.
|
package/README.md
CHANGED
|
@@ -28,18 +28,47 @@
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
-
SpecPi is a small
|
|
31
|
+
SpecPi 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 seven 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
|
|
|
35
|
-
|
|
35
|
+
It focuses on five things:
|
|
36
36
|
|
|
37
|
-
- **Control
|
|
38
|
-
- **Accuracy
|
|
39
|
-
- **Improvement
|
|
40
|
-
- **Efficiency
|
|
37
|
+
- **Control:** clear scope, tool permissions, and lifecycle commands that ask before they change anything
|
|
38
|
+
- **Accuracy:** exact version pins, checksums on state, rollback on failure, and proof over promises
|
|
39
|
+
- **Improvement:** local notes become small, checked changes through `/harness-improvement`
|
|
40
|
+
- **Efficiency:** delegation, persistent goals, and browser QA handled by the right tool for the job
|
|
41
|
+
- **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
|
|
41
42
|
|
|
42
|
-
Everything it
|
|
43
|
+
Everything it touches is written down, versioned, and easy to undo.
|
|
44
|
+
|
|
45
|
+
## Measured context
|
|
46
|
+
|
|
47
|
+
This chart shows first-call context from a clean install: all seven pinned packages, the working agreement, and the skills Pi finds. "Enabled" means browser QA, delegation, and web access are switched on, with no goal, scope, or improvement selection active.
|
|
48
|
+
|
|
49
|
+
The solid rows are measured by us, from the request each setup actually sends through one local test provider. That includes OpenCode, the DeepSeek Harness, and Oh My Pi, all measured as installed. The faded Codex CLI and Claude Code rows come from HarnessTax's published numbers, measured under their own setup. Treat those as a rough reference, not a head-to-head test. These are character counts. They say nothing about tokens, cost, or how well each tool does the job. The research page [breaks down the enabled setup by feature](https://tannermidd.github.io/SpecPi/research/#specpi), so you can see what each switch costs on its own.
|
|
50
|
+
|
|
51
|
+
<p align="center">
|
|
52
|
+
<a href="https://tannermidd.github.io/SpecPi/research/#specpi">
|
|
53
|
+
<picture>
|
|
54
|
+
<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 15,069, OpenCode 31,043, DeepSeek Harness 31,743, SpecPi enabled 40,203, Codex CLI 41,616, Oh My Pi 65,816, Claude Code 90,460.">
|
|
56
|
+
</picture>
|
|
57
|
+
</a>
|
|
58
|
+
</p>
|
|
59
|
+
<p align="center"><sub>Measured tool schemas + system/developer instructions · <code>node scripts/measure-context.mjs --chart --omp=<path to Oh My Pi's cli.js> --oc=<path to OpenCode's binary> --dsh=<path to the DeepSeek Harness bin></code> · <a href="site/research/context-measurement.json">Recorded measurements and package pins</a> · <a href="https://tannermidd.github.io/SpecPi/research/#specpi">Method and caveats</a></sub></p>
|
|
60
|
+
|
|
61
|
+
The gap between the two SpecPi bars comes from a few separate switches, so the enabled tools are also measured group by group. For example, the fourteen browser QA tools add up to less than the four web access tools:
|
|
62
|
+
|
|
63
|
+
<p align="center">
|
|
64
|
+
<a href="https://tannermidd.github.io/SpecPi/research/#specpi">
|
|
65
|
+
<picture>
|
|
66
|
+
<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 4,214, Goals 1,315, Browser QA 8,046, Delegation 4,453, Web access 11,298. Browser QA, Delegation, Web access are hidden until switched on.">
|
|
68
|
+
</picture>
|
|
69
|
+
</a>
|
|
70
|
+
</p>
|
|
71
|
+
<p align="center"><sub>Every tool in the measured request belongs to exactly one group · Leaving all three opt-in groups hidden keeps 23,797 characters of tool schema out of every request</sub></p>
|
|
43
72
|
|
|
44
73
|
## Install
|
|
45
74
|
|
package/SECURITY_MODEL.md
CHANGED
|
@@ -8,9 +8,15 @@ The human declares project-relative paths. Interactive `write` and `edit` calls
|
|
|
8
8
|
|
|
9
9
|
Scope records use Pi's current session branch. Restoring a branch does not create new authority or accept pending drift. An improvement contract can supply paths only through the human's `/scope task` command.
|
|
10
10
|
|
|
11
|
+
## Capability requests
|
|
12
|
+
|
|
13
|
+
Optional tool groups ship withdrawn. The `request_capability` tool lets the model name a withdrawn group instead of silently working around it; it never grants one. Activation requires a human decision, either accepted in the moment or recorded earlier as a standing grant, so the tool refuses in headless sessions and a declined prompt leaves the session unchanged. Activation is additive, offers only the named group's tools, and lasts for the session: it writes no startup preference, and the next session starts from the saved preference as before. Delegation is not requestable, because its own package requires a human command to bind a model. Acceptance offers the tools; it is not a per-call permission, and the Permission System continues to govern what those tools may do.
|
|
14
|
+
|
|
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
|
+
|
|
11
17
|
## Improvement authority and evidence
|
|
12
18
|
|
|
13
|
-
Collection is off by default. Enabling it permits sanitized gap observations, not implementation. Only an exact human `/harness-improvement` selection authorizes a wishlist-sourced change. The selected contract is bound to the gap, source checkout, session, and selection generation.
|
|
19
|
+
Collection is off by default. Enabling it permits sanitized gap observations, not implementation. Only an exact human `/harness-improvement` selection authorizes a wishlist-sourced change. The selected contract is bound to the gap, source checkout, session, and selection generation. Web access tools (`web_search`, `source_check`, `fetch_content`, `get_search_content`) are hidden until a human offers them, through `/webaccess on` or by accepting a `request_capability` prompt.
|
|
14
20
|
|
|
15
21
|
Retirement requires source registry integration, unchanged verification policy, a matching contract, bounded source snapshots, `npm run check`, and closed registered validators. Receipts distinguish machine-observed gates from model-reported acceptance evidence. Stale selections, changed source, missing evidence, and failed checks reject retirement. A validator proves only the behavior it exercises; the human remains responsible for accepting the result. The loop never commits, publishes, or installs a resulting change automatically.
|
|
16
22
|
|
|
@@ -28,7 +34,7 @@ Legacy migration restores only recorded settings ownership, preserves differing
|
|
|
28
34
|
|
|
29
35
|
## Browser QA package
|
|
30
36
|
|
|
31
|
-
`packages/browser-qa` is an independently released Node-native Pi package extracted from the retired browser tools. The installer acquires the immutable `specpi-browser-qa@0.
|
|
37
|
+
`packages/browser-qa` is an independently released Node-native Pi package extracted from the retired browser tools. The installer acquires the immutable `specpi-browser-qa@0.3.0` release through Pi; its source and dependencies are not bundled in SpecPi's npm artifact. Its fourteen tools ship withdrawn: until a human runs `/browser on`, saves `/browser startup on`, or accepts a `request_capability` prompt, Pi is offered no browser tool and the model cannot launch a browser at all. Delegation ships off on the same basis, so neither package's tools reach a default session. Its explicit setup downloads Playwright Chromium, and its doctor performs offline rendering, image-comparison and accessibility smoke checks. It uses package-local dependency resolution and the standard Playwright browser cache rather than SpecPi's retired managed runtime. No Pi settings, credentials or personal profiles are migrated. The ephemeral context is not OS/network isolation; pages can reach localhost and private networks. See the package's [security documentation](packages/browser-qa/SECURITY.md) for artifact retention, best-effort redaction, permission and cleanup limits. This is QA tooling, not general-browser feature parity. BetterWright remains optional/manual. Normal updates restore recorded pre-existing entries and remove only unchanged SpecPi-added BetterWright entries; modified entries, personal browsers, profiles, cookies, and user-owned tools are not migrated or deleted.
|
|
32
38
|
|
|
33
39
|
## VS Code frontend
|
|
34
40
|
|
package/THIRD_PARTY.md
CHANGED
|
@@ -18,7 +18,7 @@ SpecPi requests exact npm dependency saves for these pins and checks installed t
|
|
|
18
18
|
| Package | Version | Upstream |
|
|
19
19
|
| ------------------------------ | ------- | ------------------------------------------------------------------------- |
|
|
20
20
|
| pi-web-access | 0.29.0 | [nicobailon/pi-web-access](https://github.com/nicobailon/pi-web-access) |
|
|
21
|
-
| specpi-browser-qa | 0.
|
|
21
|
+
| specpi-browser-qa | 0.3.0 | [TannerMidd/SpecPi](https://github.com/TannerMidd/SpecPi/tree/browser-qa-v0.3.0/packages/browser-qa) |
|
|
22
22
|
| specpi-delegation | 0.2.0 | [TannerMidd/SpecPi](https://github.com/TannerMidd/SpecPi/tree/main/packages/delegation) |
|
|
23
23
|
| specpi-experiments | 0.1.0 | [TannerMidd/SpecPi](https://github.com/TannerMidd/SpecPi/tree/main/packages/experiments) |
|
|
24
24
|
| pi-goal-x | 0.31.2 | [tmonk/pi-goal-x](https://github.com/tmonk/pi-goal-x) |
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SpecPi withdraws optional tool groups so their schemas stay out of every request. A human
|
|
2
|
+
// normally restores one with /webaccess or /browser, which requires knowing up front that the
|
|
3
|
+
// session will need it. This table lets the model name a withdrawn group instead, so the need
|
|
4
|
+
// surfaces as a confirmation prompt at the moment it arises.
|
|
5
|
+
//
|
|
6
|
+
// Activation remains additive and human-confirmed. This file owns no preference and no state:
|
|
7
|
+
// it maps a capability name to the tool names its owning package registered, and reports what
|
|
8
|
+
// is already active.
|
|
9
|
+
|
|
10
|
+
import { WEB_TOOL_NAMES } from "./web-access.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Browser QA's fourteen tools, fixed by the pinned specpi-browser-qa release. Names absent
|
|
14
|
+
* from Pi's registry are ignored by setActiveTools, so a core-only install degrades to a
|
|
15
|
+
* clear "not installed" result rather than a broken activation.
|
|
16
|
+
*/
|
|
17
|
+
export const BROWSER_TOOL_NAMES = Object.freeze([
|
|
18
|
+
"browser_accessibility",
|
|
19
|
+
"browser_open",
|
|
20
|
+
"browser_set_viewport",
|
|
21
|
+
"browser_snapshot",
|
|
22
|
+
"browser_click",
|
|
23
|
+
"browser_fill",
|
|
24
|
+
"browser_diagnostics",
|
|
25
|
+
"browser_press",
|
|
26
|
+
"browser_select_option",
|
|
27
|
+
"browser_wait_for",
|
|
28
|
+
"browser_screenshot",
|
|
29
|
+
"browser_save_baseline",
|
|
30
|
+
"browser_compare_screenshot",
|
|
31
|
+
"browser_close",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Delegation is deliberately absent. `/delegate on` binds a model and host and refuses
|
|
36
|
+
* without an interactive human, and the delegate tool re-synchronizes the active set from
|
|
37
|
+
* the package's own state when it finishes, so a tool-name activation would undo itself.
|
|
38
|
+
* Delegation needs an activation path inside its own package.
|
|
39
|
+
*/
|
|
40
|
+
export const CAPABILITIES = Object.freeze({
|
|
41
|
+
web: {
|
|
42
|
+
label: "Web access",
|
|
43
|
+
tools: WEB_TOOL_NAMES,
|
|
44
|
+
schemaCost: "about 11 KB of tool schema per request",
|
|
45
|
+
summary: "search the web and fetch page or source content",
|
|
46
|
+
command: "/webaccess",
|
|
47
|
+
},
|
|
48
|
+
browser: {
|
|
49
|
+
label: "Browser QA",
|
|
50
|
+
tools: BROWSER_TOOL_NAMES,
|
|
51
|
+
schemaCost: "about 8.7 KB of tool schema per request",
|
|
52
|
+
summary: "open pages in an isolated browser to verify rendering, behavior and accessibility",
|
|
53
|
+
command: "/browser",
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const CAPABILITY_NAMES = Object.freeze(Object.keys(CAPABILITIES));
|
|
58
|
+
|
|
59
|
+
export function capabilityNames() {
|
|
60
|
+
return [...CAPABILITY_NAMES];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function findCapability(name) {
|
|
64
|
+
if (typeof name !== "string") {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Object.hasOwn(CAPABILITIES, name.trim().toLowerCase())
|
|
69
|
+
? { id: name.trim().toLowerCase(), ...CAPABILITIES[name.trim().toLowerCase()] }
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A capability counts as active only when every tool it owns is active. */
|
|
74
|
+
export function capabilityActive(activeTools, capability) {
|
|
75
|
+
const active = new Set(activeTools);
|
|
76
|
+
|
|
77
|
+
return capability.tools.every((name) => active.has(name));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tools the capability would add. An empty list means the group is already fully active, or
|
|
82
|
+
* that its package is not installed; the caller distinguishes the two by checking the
|
|
83
|
+
* registry.
|
|
84
|
+
*/
|
|
85
|
+
export function missingTools(activeTools, capability) {
|
|
86
|
+
const active = new Set(activeTools);
|
|
87
|
+
|
|
88
|
+
return capability.tools.filter((name) => !active.has(name));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether Pi has the capability's tools registered at all. Registered-but-inactive is the
|
|
93
|
+
* normal withdrawn state; entirely unregistered means the package is not installed.
|
|
94
|
+
*/
|
|
95
|
+
export function capabilityInstalled(allToolNames, capability) {
|
|
96
|
+
const known = new Set(allToolNames);
|
|
97
|
+
|
|
98
|
+
return capability.tools.some((name) => known.has(name));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One catalogue line per capability, for the tool description and its error results. */
|
|
102
|
+
export function describeCapabilities() {
|
|
103
|
+
return CAPABILITY_NAMES.map((id) => `${id}: ${CAPABILITIES[id].summary}`).join("; ");
|
|
104
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A human who always says yes to the same capability prompt is not making a decision, they are
|
|
2
|
+
// clearing an obstacle. This file stores the standing answer: capabilities the human has chosen
|
|
3
|
+
// to grant without being asked again.
|
|
4
|
+
//
|
|
5
|
+
// The preference is SpecPi's own file. It never reads Pi settings, credentials or session state,
|
|
6
|
+
// a missing or unreadable file means "ask about everything", and an unknown capability name in a
|
|
7
|
+
// stored file is ignored rather than trusted.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { CAPABILITY_NAMES } from "./capabilities.mjs";
|
|
14
|
+
|
|
15
|
+
const MAX_SETTINGS_BYTES = 4096;
|
|
16
|
+
|
|
17
|
+
function agentDirectory() {
|
|
18
|
+
const configured = process.env.PI_CODING_AGENT_DIR;
|
|
19
|
+
|
|
20
|
+
return path.resolve(configured && configured.length > 0 ? configured : path.join(os.homedir(), ".pi", "agent"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function settingsFile() {
|
|
24
|
+
return path.join(agentDirectory(), "specpi", "capabilities", "settings.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Refuses links and irregular files so the preference cannot redirect a write. */
|
|
28
|
+
function regularFile(file) {
|
|
29
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
30
|
+
if (!stat) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_SETTINGS_BYTES) {
|
|
35
|
+
throw new Error("Unsupported capability settings file");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Capabilities granted without a prompt. Names that no longer exist are dropped, so retiring a
|
|
43
|
+
* capability cannot leave a stored grant pointing at a different one later.
|
|
44
|
+
*/
|
|
45
|
+
export function loadAutoAllowed() {
|
|
46
|
+
try {
|
|
47
|
+
const file = settingsFile();
|
|
48
|
+
if (!regularFile(file)) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const settings = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
53
|
+
if (settings?.schema !== 1 || !Array.isArray(settings.autoAllow)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return settings.autoAllow.filter((name) => CAPABILITY_NAMES.includes(name));
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function autoAllowed(capabilityId) {
|
|
64
|
+
return loadAutoAllowed().includes(capabilityId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function saveAutoAllowed(names) {
|
|
68
|
+
if (!Array.isArray(names) || names.some((name) => !CAPABILITY_NAMES.includes(name))) {
|
|
69
|
+
throw new Error("Unknown capability");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const file = settingsFile();
|
|
73
|
+
const directory = path.dirname(file);
|
|
74
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
75
|
+
if (fs.existsSync(file)) {
|
|
76
|
+
regularFile(file);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const autoAllow = [...new Set(names)].sort();
|
|
80
|
+
const temporary = path.join(directory, `.settings.${randomUUID()}.tmp`);
|
|
81
|
+
try {
|
|
82
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ schema: 1, autoAllow })}\n`, {
|
|
83
|
+
mode: 0o600,
|
|
84
|
+
flag: "wx",
|
|
85
|
+
});
|
|
86
|
+
fs.renameSync(temporary, file);
|
|
87
|
+
} finally {
|
|
88
|
+
fs.rmSync(temporary, { force: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return autoAllow;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Add one capability to the standing grants. Returns the stored list. */
|
|
95
|
+
export function allowCapability(capabilityId) {
|
|
96
|
+
return saveAutoAllowed([...loadAutoAllowed(), capabilityId]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Return one capability to prompting. Returns the stored list. */
|
|
100
|
+
export function askCapability(capabilityId) {
|
|
101
|
+
if (!CAPABILITY_NAMES.includes(capabilityId)) {
|
|
102
|
+
throw new Error("Unknown capability");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return saveAutoAllowed(loadAutoAllowed().filter((name) => name !== capabilityId));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function policyPath() {
|
|
109
|
+
return settingsFile();
|
|
110
|
+
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
4
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import {
|
|
7
|
+
CAPABILITY_NAMES,
|
|
8
|
+
capabilityActive,
|
|
9
|
+
capabilityInstalled,
|
|
10
|
+
describeCapabilities,
|
|
11
|
+
findCapability,
|
|
12
|
+
missingTools,
|
|
13
|
+
} from "./capabilities.mjs";
|
|
14
|
+
import { allowCapability, askCapability, autoAllowed, loadAutoAllowed, policyPath } from "./capability-policy.mjs";
|
|
4
15
|
import {
|
|
5
16
|
canonicalRoot,
|
|
6
17
|
compareWorktreeSnapshots,
|
|
@@ -12,6 +23,13 @@ import {
|
|
|
12
23
|
} from "./scope.mjs";
|
|
13
24
|
|
|
14
25
|
import { readTaskContract, renderTaskContract } from "./task-contract.mjs";
|
|
26
|
+
import {
|
|
27
|
+
WEB_TOOL_NAMES,
|
|
28
|
+
loadStartupActivation,
|
|
29
|
+
saveStartupActivation,
|
|
30
|
+
settingsPath,
|
|
31
|
+
syncActiveTools as syncToolGroup,
|
|
32
|
+
} from "./web-access.mjs";
|
|
15
33
|
const SCOPE_ENTRY = "specpi-scope-state";
|
|
16
34
|
const SCOPE_STATUS = "specpi-scope";
|
|
17
35
|
const MAX_PENDING_SCOPE = 40;
|
|
@@ -272,6 +290,11 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
272
290
|
persistScope(ctx);
|
|
273
291
|
};
|
|
274
292
|
|
|
293
|
+
// Restore empties scope first and only learns the real root once `git rev-parse` returns, so between those two
|
|
294
|
+
// moments `scope.root` is a guess at the session cwd: not yet canonical, and not yet the enclosing repository.
|
|
295
|
+
// A human command that declared scope in that gap recorded it under the guessed root, and the replay below then
|
|
296
|
+
// rejected its own branch entry as belonging elsewhere, silently retiring a contract the human was told was set.
|
|
297
|
+
// Scope commands therefore wait for `sessionRestore` rather than racing it.
|
|
275
298
|
const restoreSession = async (ctx: ExtensionContext) => {
|
|
276
299
|
sessionGeneration += 1;
|
|
277
300
|
const origin = captureSession(ctx);
|
|
@@ -337,8 +360,24 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
337
360
|
emitScopeStatus(ctx);
|
|
338
361
|
};
|
|
339
362
|
|
|
340
|
-
|
|
341
|
-
|
|
363
|
+
let sessionRestore: Promise<void> = Promise.resolve();
|
|
364
|
+
const beginRestore = (ctx: ExtensionContext) => {
|
|
365
|
+
// Waiters only need to know the restore is over; restoreSession reports its own failures.
|
|
366
|
+
sessionRestore = restoreSession(ctx).catch(() => {});
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// Web access ships hidden. A missing or unreadable preference means off, and the
|
|
370
|
+
// gate only ever touches its own four tool names.
|
|
371
|
+
let webAccessEnabled = loadStartupActivation();
|
|
372
|
+
const applyWebAccess = () => syncToolGroup(pi, WEB_TOOL_NAMES, webAccessEnabled);
|
|
373
|
+
|
|
374
|
+
pi.on("session_start", (_event, ctx) => {
|
|
375
|
+
webAccessEnabled = loadStartupActivation();
|
|
376
|
+
applyWebAccess();
|
|
377
|
+
beginRestore(ctx);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
pi.on("session_tree", (_event, ctx) => beginRestore(ctx));
|
|
342
381
|
|
|
343
382
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
344
383
|
sessionGeneration += 1;
|
|
@@ -517,6 +556,9 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
517
556
|
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
518
557
|
.map((value) => ({ value, label: value })),
|
|
519
558
|
handler: async (args, ctx) => {
|
|
559
|
+
// Read scope only after any restore in flight has settled: before that, both the default action and the
|
|
560
|
+
// root every branch below records are taken from a provisional, pre-Git-lookup guess.
|
|
561
|
+
await sessionRestore;
|
|
520
562
|
const origin = captureSession(ctx);
|
|
521
563
|
const [actionRaw, ...rest] = args.trim().split(/\s+/u).filter(Boolean);
|
|
522
564
|
const action = actionRaw?.toLowerCase() || (scope.active ? "status" : "set");
|
|
@@ -749,4 +791,271 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
749
791
|
}
|
|
750
792
|
},
|
|
751
793
|
});
|
|
794
|
+
|
|
795
|
+
pi.registerCommand("webaccess", {
|
|
796
|
+
description: "Offer or withdraw the web access tools, or choose whether they start offered",
|
|
797
|
+
getArgumentCompletions: (prefix: string) =>
|
|
798
|
+
["on", "off", "status", "startup", "startup on", "startup off"]
|
|
799
|
+
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
800
|
+
.map((value) => ({ value, label: value })),
|
|
801
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
802
|
+
const [action = "status", choice, ...rest] = args.trim().split(/\s+/u).filter(Boolean);
|
|
803
|
+
if (rest.length || (choice && action.toLowerCase() !== "startup")) {
|
|
804
|
+
throw new Error("Usage: /webaccess [on|off|status|startup [on|off]]");
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const verb = action.toLowerCase();
|
|
808
|
+
if (verb === "on" || verb === "off") {
|
|
809
|
+
webAccessEnabled = verb === "on";
|
|
810
|
+
applyWebAccess();
|
|
811
|
+
ctx.ui.notify(
|
|
812
|
+
webAccessEnabled
|
|
813
|
+
? `Web access offered ${WEB_TOOL_NAMES.length} tools to this session (web_search, source_check, fetch_content, get_search_content). They add about 11 KB of tool schema to each request until /webaccess off.`
|
|
814
|
+
: "Web access withdrew its tools from this session. Search and fetch now require /webaccess on.",
|
|
815
|
+
"info",
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (verb === "startup") {
|
|
822
|
+
if (!choice) {
|
|
823
|
+
ctx.ui.notify(
|
|
824
|
+
`Web access starts ${loadStartupActivation() ? "offered" : "withdrawn"}. Preference: ${settingsPath()}`,
|
|
825
|
+
"info",
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (!ctx.hasUI) {
|
|
832
|
+
throw new Error("Startup changes require a human interactive command");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (!["on", "off"].includes(choice.toLowerCase())) {
|
|
836
|
+
throw new Error("Usage: /webaccess startup [on|off]");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
saveStartupActivation(choice.toLowerCase() === "on");
|
|
840
|
+
ctx.ui.notify(
|
|
841
|
+
choice.toLowerCase() === "on"
|
|
842
|
+
? "Web access tools will be offered in new Pi sessions, adding their schemas to every request. This session is unchanged."
|
|
843
|
+
: "Web access tools will start withdrawn in new Pi sessions and their schemas will not be sent. This session is unchanged.",
|
|
844
|
+
"info",
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (verb !== "status") {
|
|
851
|
+
throw new Error("Usage: /webaccess [on|off|status|startup [on|off]]");
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
ctx.ui.notify(
|
|
855
|
+
`Web access is ${webAccessEnabled ? "offering" : "not offering"} its ${WEB_TOOL_NAMES.length} tools to this session, and starts ${loadStartupActivation() ? "offered" : "withdrawn"}.`,
|
|
856
|
+
"info",
|
|
857
|
+
);
|
|
858
|
+
},
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// Withdrawn tool groups are invisible to the model, so a session that turns out to need
|
|
862
|
+
// one has no way to say so. This tool names the withdrawn groups and asks the human to
|
|
863
|
+
// restore one. It grants nothing on its own: without an interactive human it refuses, and
|
|
864
|
+
// a declined prompt leaves the session exactly as it was.
|
|
865
|
+
//
|
|
866
|
+
// Pi records tools added during a tool call and offers them from the next assistant
|
|
867
|
+
// message, so activation must be additive; removing here would cost the cached prefix.
|
|
868
|
+
const activateCapability = (capabilityId: string) => {
|
|
869
|
+
const capability = findCapability(capabilityId);
|
|
870
|
+
if (!capability) {
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (capability.id === "web") {
|
|
875
|
+
webAccessEnabled = true;
|
|
876
|
+
applyWebAccess();
|
|
877
|
+
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
syncToolGroup(pi, capability.tools, true);
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
pi.registerTool({
|
|
885
|
+
name: "request_capability",
|
|
886
|
+
label: "Request Capability",
|
|
887
|
+
description: `Ask the user to restore a withdrawn SpecPi tool group for this session. Available groups — ${describeCapabilities()}. Their tools are hidden to keep each request small, so request a group only when the current task actually needs it, and continue without it if the user declines. The restored tools are usable from your next message and stay available until the session ends. Delegation is not requestable here; ask the user to run /delegate on.`,
|
|
888
|
+
parameters: Type.Object(
|
|
889
|
+
{
|
|
890
|
+
capability: StringEnum(CAPABILITY_NAMES, {
|
|
891
|
+
description: "Withdrawn tool group to request",
|
|
892
|
+
}),
|
|
893
|
+
reason: Type.String({
|
|
894
|
+
minLength: 5,
|
|
895
|
+
maxLength: 200,
|
|
896
|
+
description: "What in the current task needs this group; shown to the user in the prompt",
|
|
897
|
+
}),
|
|
898
|
+
},
|
|
899
|
+
{ additionalProperties: false },
|
|
900
|
+
),
|
|
901
|
+
async execute(_toolCallId, params: any, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
902
|
+
const capability = findCapability(params.capability);
|
|
903
|
+
if (!capability) {
|
|
904
|
+
return {
|
|
905
|
+
content: [
|
|
906
|
+
{
|
|
907
|
+
type: "text" as const,
|
|
908
|
+
text: `Unknown capability. Available: ${CAPABILITY_NAMES.join(", ")}.`,
|
|
909
|
+
},
|
|
910
|
+
],
|
|
911
|
+
isError: true,
|
|
912
|
+
details: { activated: false, capability: String(params.capability ?? "") },
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const active = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
|
|
917
|
+
if (capabilityActive(active, capability)) {
|
|
918
|
+
return {
|
|
919
|
+
content: [
|
|
920
|
+
{
|
|
921
|
+
type: "text" as const,
|
|
922
|
+
text: `${capability.label} is already available. Use its tools directly.`,
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
details: { activated: false, capability: capability.id, alreadyActive: true },
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const registered = typeof pi.getAllTools === "function" ? pi.getAllTools().map((tool) => tool.name) : [];
|
|
930
|
+
if (registered.length > 0 && !capabilityInstalled(registered, capability)) {
|
|
931
|
+
return {
|
|
932
|
+
content: [
|
|
933
|
+
{
|
|
934
|
+
type: "text" as const,
|
|
935
|
+
text: `${capability.label} is not installed in this session, so it cannot be restored. Continue without it.`,
|
|
936
|
+
},
|
|
937
|
+
],
|
|
938
|
+
details: { activated: false, capability: capability.id, installed: false },
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (!ctx.hasUI) {
|
|
943
|
+
return {
|
|
944
|
+
content: [
|
|
945
|
+
{
|
|
946
|
+
type: "text" as const,
|
|
947
|
+
text: `${capability.label} stays withdrawn: restoring it needs an interactive user. Continue without it.`,
|
|
948
|
+
},
|
|
949
|
+
],
|
|
950
|
+
details: { activated: false, capability: capability.id, headless: true },
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// A standing grant replaces the prompt, not the human: it records a decision this
|
|
955
|
+
// human already made, and only an interactive command can record one.
|
|
956
|
+
const standing = autoAllowed(capability.id);
|
|
957
|
+
const pending = missingTools(active, capability);
|
|
958
|
+
const accepted =
|
|
959
|
+
standing ||
|
|
960
|
+
(await ctx.ui.confirm(
|
|
961
|
+
`Allow ${capability.label} for this session?`,
|
|
962
|
+
`The agent asked to ${capability.summary}. Reason given: ${safeMessage(params.reason)}\n\nThis offers ${pending.length} tool${pending.length === 1 ? "" : "s"} for the rest of this session and adds ${capability.schemaCost}. Withdraw them again with ${capability.command} off.`,
|
|
963
|
+
));
|
|
964
|
+
if (!accepted) {
|
|
965
|
+
return {
|
|
966
|
+
content: [
|
|
967
|
+
{
|
|
968
|
+
type: "text" as const,
|
|
969
|
+
text: `The user declined ${capability.label}. Continue the task without it and do not ask again for this task.`,
|
|
970
|
+
},
|
|
971
|
+
],
|
|
972
|
+
details: { activated: false, capability: capability.id, declined: true },
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
activateCapability(capability.id);
|
|
977
|
+
// A standing grant skips the dialog, so say what happened; a capability must never
|
|
978
|
+
// turn itself on without the human seeing it.
|
|
979
|
+
if (standing) {
|
|
980
|
+
ctx.ui.notify(
|
|
981
|
+
`${capability.label} offered to this session by a standing grant. Withdraw it with ${capability.command} off, or stop granting it with /capability ask ${capability.id}.`,
|
|
982
|
+
"info",
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return {
|
|
987
|
+
content: [
|
|
988
|
+
{
|
|
989
|
+
type: "text" as const,
|
|
990
|
+
text: `${capability.label} is available from your next message: ${capability.tools.join(", ")}.`,
|
|
991
|
+
},
|
|
992
|
+
],
|
|
993
|
+
details: { activated: true, capability: capability.id, tools: [...capability.tools], standing },
|
|
994
|
+
};
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
pi.registerCommand("capability", {
|
|
999
|
+
description: "Show withdrawn tool groups, or stop being asked before granting one",
|
|
1000
|
+
getArgumentCompletions: (prefix: string) =>
|
|
1001
|
+
["status", ...CAPABILITY_NAMES.flatMap((name) => [`allow ${name}`, `ask ${name}`])]
|
|
1002
|
+
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
1003
|
+
.map((value) => ({ value, label: value })),
|
|
1004
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
1005
|
+
const [action = "status", name, ...rest] = args.trim().split(/\s+/u).filter(Boolean);
|
|
1006
|
+
const verb = action.toLowerCase();
|
|
1007
|
+
if (rest.length || (name && verb === "status") || (!name && verb !== "status")) {
|
|
1008
|
+
throw new Error(
|
|
1009
|
+
`Usage: /capability [status|allow <name>|ask <name>] (names: ${CAPABILITY_NAMES.join(", ")})`,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (verb === "allow" || verb === "ask") {
|
|
1014
|
+
const capability = findCapability(name);
|
|
1015
|
+
if (!capability) {
|
|
1016
|
+
throw new Error(`Unknown capability. Names: ${CAPABILITY_NAMES.join(", ")}`);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Recording a standing grant is the decision itself, so it needs a human at the
|
|
1020
|
+
// keyboard exactly as the startup preferences do.
|
|
1021
|
+
if (!ctx.hasUI) {
|
|
1022
|
+
throw new Error("Capability policy changes require a human interactive command");
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (verb === "allow") {
|
|
1026
|
+
allowCapability(capability.id);
|
|
1027
|
+
ctx.ui.notify(
|
|
1028
|
+
`${capability.label} will be offered whenever the agent asks for it, without a prompt. This session is unchanged until it asks. Undo with /capability ask ${capability.id}.`,
|
|
1029
|
+
"info",
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
askCapability(capability.id);
|
|
1036
|
+
ctx.ui.notify(
|
|
1037
|
+
`${capability.label} will prompt again before it is offered. Tools already offered to this session stay until ${capability.command} off.`,
|
|
1038
|
+
"info",
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (verb !== "status") {
|
|
1045
|
+
throw new Error(
|
|
1046
|
+
`Usage: /capability [status|allow <name>|ask <name>] (names: ${CAPABILITY_NAMES.join(", ")})`,
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const active = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
|
|
1051
|
+
const granted = loadAutoAllowed();
|
|
1052
|
+
const lines = CAPABILITY_NAMES.map((id) => {
|
|
1053
|
+
const capability = findCapability(id)!;
|
|
1054
|
+
const offered = capabilityActive(active, capability) ? "offered" : "withdrawn";
|
|
1055
|
+
|
|
1056
|
+
return `${id}: ${offered}, ${granted.includes(id) ? "granted without asking" : "asks first"}`;
|
|
1057
|
+
});
|
|
1058
|
+
ctx.ui.notify(`${lines.join(" · ")}. Policy: ${policyPath()}`, "info");
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
752
1061
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// pi-web-access registers four network tools (web_search, source_check, fetch_content,
|
|
2
|
+
// get_search_content). Pi sends every active tool's schema on every request, so a project
|
|
3
|
+
// that never searches would pay for them on every call. SpecPi therefore hides them behind
|
|
4
|
+
// a saved preference that ships off, and `/webaccess on` turns them on for a session.
|
|
5
|
+
//
|
|
6
|
+
// This file is SpecPi's own control: it never modifies the installed package, never reads
|
|
7
|
+
// Pi settings, credentials or session state, and a missing or unreadable file means off.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
export const WEB_TOOL_NAMES = Object.freeze(["web_search", "source_check", "fetch_content", "get_search_content"]);
|
|
15
|
+
|
|
16
|
+
const MAX_SETTINGS_BYTES = 4096;
|
|
17
|
+
|
|
18
|
+
function agentDirectory() {
|
|
19
|
+
const configured = process.env.PI_CODING_AGENT_DIR;
|
|
20
|
+
|
|
21
|
+
return path.resolve(configured && configured.length > 0 ? configured : path.join(os.homedir(), ".pi", "agent"));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function settingsFile() {
|
|
25
|
+
return path.join(agentDirectory(), "specpi", "web-access", "settings.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Refuses links and irregular files so the preference cannot redirect a write. */
|
|
29
|
+
function regularFile(file) {
|
|
30
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
31
|
+
if (!stat) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_SETTINGS_BYTES) {
|
|
36
|
+
throw new Error("Unsupported web access settings file");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadStartupActivation() {
|
|
43
|
+
try {
|
|
44
|
+
const file = settingsFile();
|
|
45
|
+
if (!regularFile(file)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const settings = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
50
|
+
|
|
51
|
+
return settings?.schema === 1 && settings.startupActivation === true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function saveStartupActivation(enabled) {
|
|
58
|
+
if (typeof enabled !== "boolean") {
|
|
59
|
+
throw new Error("Web access startup activation must be on or off.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const file = settingsFile();
|
|
63
|
+
const directory = path.dirname(file);
|
|
64
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
65
|
+
if (fs.existsSync(file)) {
|
|
66
|
+
regularFile(file);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const temporary = path.join(directory, `.settings.${randomUUID()}.tmp`);
|
|
70
|
+
try {
|
|
71
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ schema: 1, startupActivation: enabled })}\n`, {
|
|
72
|
+
mode: 0o600,
|
|
73
|
+
flag: "wx",
|
|
74
|
+
});
|
|
75
|
+
fs.renameSync(temporary, file);
|
|
76
|
+
} finally {
|
|
77
|
+
fs.rmSync(temporary, { force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function settingsPath() {
|
|
82
|
+
return settingsFile();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Add or remove the web tools from Pi's active set without disturbing any other
|
|
87
|
+
* extension's tools, including built-ins and the other first-party gates.
|
|
88
|
+
*/
|
|
89
|
+
export function syncActiveTools(pi, names, enabled) {
|
|
90
|
+
if (typeof pi?.getActiveTools !== "function" || typeof pi?.setActiveTools !== "function") {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const owned = new Set(names);
|
|
95
|
+
const active = pi.getActiveTools();
|
|
96
|
+
const present = active.filter((name) => owned.has(name));
|
|
97
|
+
if (enabled && present.length === owned.size) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!enabled && present.length === 0) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const others = active.filter((name) => !owned.has(name));
|
|
106
|
+
pi.setActiveTools(enabled ? [...others, ...names] : others);
|
|
107
|
+
}
|
package/package.json
CHANGED
package/scripts/packages.mjs
CHANGED
|
@@ -19,7 +19,7 @@ export function runBrowserQA(agentDir, command) {
|
|
|
19
19
|
const installed = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
20
20
|
if (
|
|
21
21
|
installed.name !== "specpi-browser-qa" ||
|
|
22
|
-
installed.version !== "0.
|
|
22
|
+
installed.version !== "0.3.0" ||
|
|
23
23
|
installed.bin?.["specpi-browser-qa"] !== "./bin/browser-qa.mjs"
|
|
24
24
|
) {
|
|
25
25
|
throw new Error("Missing or changed pinned Browser QA bin metadata");
|
package/scripts/specpi.mjs
CHANGED
|
@@ -37,6 +37,9 @@ const resourcePaths = [
|
|
|
37
37
|
"extensions/workflow-controls/scope.mjs",
|
|
38
38
|
"extensions/workflow-controls/task-contract.mjs",
|
|
39
39
|
"extensions/workflow-controls/smoke.mjs",
|
|
40
|
+
"extensions/workflow-controls/web-access.mjs",
|
|
41
|
+
"extensions/workflow-controls/capabilities.mjs",
|
|
42
|
+
"extensions/workflow-controls/capability-policy.mjs",
|
|
40
43
|
"extensions/tool-wishlist/index.ts",
|
|
41
44
|
"extensions/tool-wishlist/core.mjs",
|
|
42
45
|
"extensions/tool-wishlist/verification.mjs",
|
|
@@ -469,7 +472,7 @@ async function doctor() {
|
|
|
469
472
|
const errors = [];
|
|
470
473
|
if (manifest.basePackages?.length) {
|
|
471
474
|
errors.push(...checkBasePackages(agentDir, readJson(settingsPath, {})));
|
|
472
|
-
if (manifest.basePackages.includes("npm:specpi-browser-qa@0.
|
|
475
|
+
if (manifest.basePackages.includes("npm:specpi-browser-qa@0.3.0")) {
|
|
473
476
|
try {
|
|
474
477
|
runBrowserQA(agentDir, "doctor");
|
|
475
478
|
} catch (error) {
|
package/templates/AGENTS.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- Write commits and pull requests the same way: a short, clear title and a brief explanation of what changed, why, and what was tested. Avoid boilerplate, inflated claims, and unnecessary implementation detail.
|
|
7
7
|
- When `/scope` is active, keep outside-scope findings pending until the human allows once, acknowledges them with `/scope accept`, expands scope with `/scope add`, or clears it. Acknowledgement does not widen scope.
|
|
8
8
|
- Treat wishlist observations as leads, not authorization. Start a wishlist-sourced change only from an exact `/harness-improvement` selection and follow `specpi-improve`. Record its contract before editing and retire only after its verification gate passes.
|
|
9
|
+
- Web access and Browser QA tools ship hidden to keep each request small. When the task genuinely needs one, call `request_capability`; it asks the human, who may decline. Continue without the capability if they do, and do not attempt a hidden tool. The human can also run `/webaccess on` or `/browser on` directly. Delegation is not requestable this way; ask the human to run `/delegate on`.
|
|
9
10
|
- Never inspect Pi authentication, provider credentials, trust decisions, sessions, missions, or history to improve the harness. Use only the active extension context and intended local improvement records.
|
|
10
11
|
- Use observed files, diffs, tests, and runtime behavior as evidence. Run relevant checks, inspect the final diff, and obtain fresh read-only review when risk warrants it. Report results and residual risks without claiming more than the checks prove.
|
|
11
12
|
- Do not commit, push, publish, deploy, or alter remote state unless explicitly requested.
|