@sassywares/pi-extensions 0.3.18 → 0.3.20
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/README.md +30 -0
- package/extensions/modes/backends.ts +201 -0
- package/extensions/modes/footer.ts +164 -0
- package/extensions/modes/index.ts +318 -0
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ The package is `@sassywares/pi-extensions`; the repo is `sassy-pi-extensions`.
|
|
|
15
15
|
- [Web search and fetch](#web-search-and-fetch)
|
|
16
16
|
- [Handoff](#handoff)
|
|
17
17
|
- [Subagent](#subagent)
|
|
18
|
+
- [Modes](#modes)
|
|
18
19
|
- [Configuration](#configuration)
|
|
19
20
|
- [Security](#security)
|
|
20
21
|
- [Provenance](#provenance)
|
|
@@ -77,6 +78,7 @@ pi -e ./sassy-pi-extensions
|
|
|
77
78
|
| **`web-search`** | `web_search`, `web_fetch` | Searches the web via Exa and fetches readable page text with local-address protection. |
|
|
78
79
|
| **`handoff`** | `/handoff <goal>` | Summarizes the session into a continuation prompt and opens a fresh session with it in the editor for you to submit. |
|
|
79
80
|
| **`subagent`** | `subagent` tool | Runs single, parallel, or chained tasks in isolated pi processes and returns compact results. |
|
|
81
|
+
| **`modes`** | `alt+m`, `/ask`, `/work` | Ask mode removes the write tools and runs bash under an OS-level read-only sandbox. Work mode is pi as usual. |
|
|
80
82
|
|
|
81
83
|
### Auto-title
|
|
82
84
|
|
|
@@ -134,6 +136,28 @@ Parallel dispatch defaults to two concurrent subagents. A three-worker MiniMax M
|
|
|
134
136
|
|
|
135
137
|
A watchdog kills a subagent when it emits no output for two minutes, which catches provider streams that hang after a partial response. Completed runs append one JSON line to `~/.pi/agent/subagent-runs.jsonl` with model, thinking level, wall time, model time, tool time, turns, error turns, retries, token usage, cost, and the watchdog outcome. Use it to compare models instead of reconstructing runs from session files.
|
|
136
138
|
|
|
139
|
+
### Modes
|
|
140
|
+
|
|
141
|
+
Two modes, toggled with `alt+m` or `/ask` and `/work`. Ask mode makes it safe to think out loud; work mode is pi as usual.
|
|
142
|
+
|
|
143
|
+
Ask mode enforces read-only access on two levels:
|
|
144
|
+
|
|
145
|
+
1. **Toolset.** `edit`, `write`, `codemode`, `subagent`, and any other tool that can change files or run arbitrary code are removed from the model's active toolset, so it never sees them. A `tool_call` gate blocks them anyway, which also covers nested calls made by tools that stay active.
|
|
146
|
+
2. **Sandboxed bash.** `read`, `grep`, `find`, `ls`, `bash`, `web_search`, and `web_fetch` stay available, because searching and reading is how the model answers honestly. Every bash command, including `!` input, is wrapped in an OS-level sandbox that denies filesystem writes:
|
|
147
|
+
- macOS: `sandbox-exec` with a `deny file-write*` profile.
|
|
148
|
+
- Linux: `bwrap` with `--ro-bind / /`.
|
|
149
|
+
|
|
150
|
+
Writes do not fail quietly and they are not matched by pattern. `rm`, `sed -i`, `tee`, `>` redirection, `git checkout`, and a nested `bash -c` all fail at the OS level with `Operation not permitted`, while `git log`, `git diff`, `rg`, and network calls keep working. `/dev` and the temp directory stay writable so ordinary tools behave.
|
|
151
|
+
|
|
152
|
+
A backend is only trusted after it proves both halves of the contract: reads inside the working directory must succeed **and** a probe write there must fail. A backend that is installed but cannot enforce (for example bubblewrap on a distribution that blocks unprivileged user namespaces) is discarded, and ask mode continues without bash rather than falling back to a command allowlist. The working directory always wins over the temp allowance: if the project itself lives under the temp directory, temp writes are denied too and `/modes` reports `temp dir is read-only`.
|
|
153
|
+
|
|
154
|
+
`/modes` shows the verdict. While ask mode is active, the footer is replaced by a compact one that draws the badge on the working-directory/session line — `⏸ ask · ro(sandbox-exec)` or `⏸ ask · bash off` — instead of taking a row of its own; other extension statuses keep their line. Leaving ask mode restores pi's native footer.
|
|
155
|
+
|
|
156
|
+
- `--ask` starts a session in ask mode; the mode persists across resume.
|
|
157
|
+
- `shift+tab` is not used, because pi binds it to the thinking level. To match an editor that uses it for modes, bind `app.thinking.cycle` to something else in `~/.pi/agent/keybindings.json` and add a shortcut there.
|
|
158
|
+
- Ask mode is a guardrail for local changes, not a jail. Network access stays open, so a command can still change remote state if it holds a credential. Use pi's containerized setup when the boundary has to hold against a hostile model.
|
|
159
|
+
- **Windows is unsupported by design.** Ask mode needs an OS-level read-only sandbox that Windows does not offer cheaply, so the extension registers nothing there. `/ask` and `/work` do not exist, and every tool behaves as before.
|
|
160
|
+
|
|
137
161
|
## Configuration
|
|
138
162
|
|
|
139
163
|
| Variable | Default | Effect |
|
|
@@ -141,6 +165,8 @@ A watchdog kills a subagent when it emits no output for two minutes, which catch
|
|
|
141
165
|
| `PI_AUTO_TITLE` | unset | Set to `0` to disable automatic titles. `/title` still works. |
|
|
142
166
|
| `EXA_API_KEY` | unset | Use the direct Exa API instead of the public Exa MCP endpoint. |
|
|
143
167
|
| `PI_AGENTS_DIR` | `~/.pi/agent/agents` | Target directory for `scripts/install-agents.sh`. |
|
|
168
|
+
| `PI_MODES_BASH` | `auto` | Set to `off` to skip backend detection. Ask mode then runs without bash. |
|
|
169
|
+
| `PI_MODES_ASK_TOOLS` | unset | Comma-separated extra tool names to allow in ask mode. |
|
|
144
170
|
| `PI_SUBAGENT_CONCURRENCY` | `2` | Maximum parallel subagent processes. DeepSeek Flash handled 4; MiniMax M3.1 Flash stalled with 3. |
|
|
145
171
|
| `PI_SUBAGENT_IDLE_TIMEOUT_MS` | `120000` | Kill a subagent after this many milliseconds with no stdout/stderr events. Set to `0` to disable. |
|
|
146
172
|
| `PI_SUBAGENT_TELEMETRY` | `~/.pi/agent/subagent-runs.jsonl` | Telemetry JSONL path, or `0`/`off` to disable. |
|
|
@@ -153,6 +179,7 @@ Extensions run inside the pi process with the same OS permissions as pi. Review
|
|
|
153
179
|
- `auto-title` sends a short transcript of the opening turns to the model already selected for the session. Provider terms and data handling apply.
|
|
154
180
|
- `/handoff` sends the conversation and your goal to the current model to generate the continuation prompt.
|
|
155
181
|
- `subagent` starts additional pi processes. Their permissions match the tools allowed by their agent file and the pi process that spawned them.
|
|
182
|
+
- `modes` ask mode denies local filesystem writes through the OS sandbox described above. It does not restrict network access, so a sandboxed command can still change remote state if it can authenticate. Work mode has no restrictions.
|
|
156
183
|
- No credentials are stored by this package. `EXA_API_KEY` is read from the environment at call time.
|
|
157
184
|
|
|
158
185
|
## Provenance
|
|
@@ -167,6 +194,9 @@ sassy-pi-extensions/
|
|
|
167
194
|
│ ├── auto-title.ts
|
|
168
195
|
│ ├── handoff.ts
|
|
169
196
|
│ ├── web-search.ts
|
|
197
|
+
│ ├── modes/
|
|
198
|
+
│ │ ├── backends.ts
|
|
199
|
+
│ │ └── index.ts
|
|
170
200
|
│ └── subagent/
|
|
171
201
|
│ ├── agents.ts
|
|
172
202
|
│ └── index.ts
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only bash backends for ask mode.
|
|
3
|
+
*
|
|
4
|
+
* A backend rewrites a shell command so that it runs under an OS-level sandbox
|
|
5
|
+
* that denies every filesystem write except /dev and the temp directory. There
|
|
6
|
+
* is deliberately no regex or command-allowlist fallback: a backend either
|
|
7
|
+
* proves that it blocks a write inside the working directory, or ask mode runs
|
|
8
|
+
* without bash at all.
|
|
9
|
+
*
|
|
10
|
+
* When the working directory itself sits inside the temp directory, the temp
|
|
11
|
+
* allowance is dropped instead of the working directory one: the repository is
|
|
12
|
+
* always read-only, at the cost of a read-only temp directory in that case.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, realpathSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { createLocalBashOperations, type BashOperations } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
export type BackendId = "sandbox-exec" | "bwrap";
|
|
20
|
+
|
|
21
|
+
export interface SandboxBackend {
|
|
22
|
+
/** Short identifier shown in the status bar. */
|
|
23
|
+
id: BackendId;
|
|
24
|
+
/** Rewrite a command so it executes under the read-only sandbox. */
|
|
25
|
+
wrap: (command: string) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DetectResult {
|
|
29
|
+
backend?: SandboxBackend;
|
|
30
|
+
/** Why the backend was accepted or rejected. Shown by `/modes`. */
|
|
31
|
+
reason: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PROBE_TIMEOUT_SECONDS = 5;
|
|
35
|
+
const PROBE_FILE = ".pi-modes-probe";
|
|
36
|
+
|
|
37
|
+
/** macOS paths that stay writable. `/private/var/folders` is behind `$TMPDIR`. */
|
|
38
|
+
const MACOS_WRITE_ALLOW = ["/dev", "/private/tmp", "/private/var/folders"];
|
|
39
|
+
|
|
40
|
+
/** Linux: bind the whole filesystem read-only, then give back /dev and a scratch /tmp. */
|
|
41
|
+
const LINUX_TEMP = "/tmp";
|
|
42
|
+
|
|
43
|
+
/** POSIX single-quote escaping, so an arbitrary command survives one more shell layer. */
|
|
44
|
+
export function shellQuote(value: string): string {
|
|
45
|
+
return `'${value.split("'").join(`'\\''`)}'`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function realPath(value: string): string {
|
|
49
|
+
try {
|
|
50
|
+
return realpathSync(value);
|
|
51
|
+
} catch {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isInside(path: string, root: string): boolean {
|
|
57
|
+
const resolvedPath = realPath(path);
|
|
58
|
+
const resolvedRoot = realPath(root);
|
|
59
|
+
const prefix = resolvedRoot.endsWith("/") ? resolvedRoot : `${resolvedRoot}/`;
|
|
60
|
+
return resolvedPath === resolvedRoot || resolvedPath.startsWith(prefix);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function macProfile(cwd: string): string {
|
|
64
|
+
const allowed = MACOS_WRITE_ALLOW.filter((root) => !isInside(cwd, root));
|
|
65
|
+
const lines = ["(version 1)", "(allow default)", "(deny file-write*)"];
|
|
66
|
+
if (allowed.length > 0) {
|
|
67
|
+
lines.push("(allow file-write*");
|
|
68
|
+
allowed.forEach((root, index) => {
|
|
69
|
+
const close = index === allowed.length - 1 ? ")" : "";
|
|
70
|
+
lines.push(` (subpath "${root}")${close}`);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function linuxArgs(cwd: string): string[] {
|
|
77
|
+
const args = ["bwrap", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"];
|
|
78
|
+
// Do not shadow the working directory with a fresh /tmp.
|
|
79
|
+
if (!isInside(cwd, LINUX_TEMP)) args.push("--tmpfs", LINUX_TEMP);
|
|
80
|
+
args.push("--setenv", "TMPDIR", LINUX_TEMP, "--die-with-parent", "--");
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createBackend(id: BackendId, cwd: string): SandboxBackend {
|
|
85
|
+
if (id === "sandbox-exec") {
|
|
86
|
+
const profile = shellQuote(macProfile(cwd));
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
wrap: (command) => `sandbox-exec -p ${profile} /bin/bash -c ${shellQuote(command)}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const prefix = linuxArgs(cwd).map(shellQuote).join(" ");
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
wrap: (command) => `${prefix} /bin/bash -c ${shellQuote(command)}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run a command through a backend using pi's own shell configuration, so the
|
|
102
|
+
* probe exercises exactly the execution path ask mode will use.
|
|
103
|
+
*/
|
|
104
|
+
export async function runIn(
|
|
105
|
+
backend: SandboxBackend,
|
|
106
|
+
command: string,
|
|
107
|
+
cwd: string,
|
|
108
|
+
timeoutSeconds = PROBE_TIMEOUT_SECONDS,
|
|
109
|
+
): Promise<{ output: string; exitCode: number | null }> {
|
|
110
|
+
const operations = createLocalBashOperations();
|
|
111
|
+
let output = "";
|
|
112
|
+
try {
|
|
113
|
+
const { exitCode } = await operations.exec(backend.wrap(command), cwd, {
|
|
114
|
+
onData: (chunk) => {
|
|
115
|
+
output += chunk.toString();
|
|
116
|
+
},
|
|
117
|
+
timeout: timeoutSeconds,
|
|
118
|
+
});
|
|
119
|
+
return { output, exitCode };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
return { output: `${output}${message}`, exitCode: null };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Backend operations for `!command` input, which bypasses the `bash` tool and
|
|
128
|
+
* therefore the `tool_call` handler.
|
|
129
|
+
*/
|
|
130
|
+
export function createWrappedOperations(backend: SandboxBackend): BashOperations {
|
|
131
|
+
const local = createLocalBashOperations();
|
|
132
|
+
return {
|
|
133
|
+
exec: (command, cwd, options) => local.exec(backend.wrap(command), cwd, options),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Prove both halves of the contract before trusting a backend: reads must
|
|
139
|
+
* succeed, and a write inside the working directory must fail. A backend that
|
|
140
|
+
* "exists" but cannot enforce (for example bubblewrap on a distribution that
|
|
141
|
+
* blocks unprivileged user namespaces) fails here.
|
|
142
|
+
*
|
|
143
|
+
* Temp-directory writability is reported but not required, because the working
|
|
144
|
+
* directory always wins.
|
|
145
|
+
*/
|
|
146
|
+
async function verify(backend: SandboxBackend, cwd: string): Promise<{ ok: boolean; reason: string }> {
|
|
147
|
+
const read = await runIn(backend, `printf pi-modes-ok && ls ${shellQuote(cwd)} > /dev/null`, cwd);
|
|
148
|
+
if (read.exitCode !== 0 || !read.output.includes("pi-modes-ok")) {
|
|
149
|
+
return { ok: false, reason: `${backend.id}: reads do not work (${firstLine(read.output) || `exit ${read.exitCode}`})` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const probePath = join(cwd, PROBE_FILE);
|
|
153
|
+
const wrote = await runIn(backend, `printf x > ${shellQuote(probePath)}`, cwd);
|
|
154
|
+
if (wrote.exitCode === 0) {
|
|
155
|
+
if (existsSync(probePath)) {
|
|
156
|
+
try {
|
|
157
|
+
unlinkSync(probePath);
|
|
158
|
+
} catch {
|
|
159
|
+
// Best effort: the probe already failed, do not mask the real reason.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { ok: false, reason: `${backend.id}: write inside the working directory was allowed` };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const tmpProbe = '"${TMPDIR:-/tmp}/pi-modes-probe-$$"';
|
|
166
|
+
const tmp = await runIn(backend, `printf x > ${tmpProbe} && printf tmp-ok && rm -f ${tmpProbe}`, cwd);
|
|
167
|
+
const tmpWritable = tmp.exitCode === 0 && tmp.output.includes("tmp-ok");
|
|
168
|
+
|
|
169
|
+
const detail = tmpWritable ? "reads work, writes are denied" : "reads work, writes are denied, temp dir is read-only";
|
|
170
|
+
return { ok: true, reason: `${backend.id}: ${detail}` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function firstLine(value: string): string {
|
|
174
|
+
return (value.split("\n").find((line) => line.trim().length > 0) ?? "").trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pick a backend for this platform and verify it. `PI_MODES_BASH=off` skips
|
|
179
|
+
* detection entirely, which leaves ask mode with no bash.
|
|
180
|
+
*/
|
|
181
|
+
export async function detectBackend(cwd: string): Promise<DetectResult> {
|
|
182
|
+
if ((process.env.PI_MODES_BASH ?? "auto").toLowerCase() === "off") {
|
|
183
|
+
return { reason: "bash disabled by PI_MODES_BASH=off" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const candidates: BackendId[] =
|
|
187
|
+
process.platform === "darwin" ? ["sandbox-exec"] : process.platform === "linux" ? ["bwrap"] : [];
|
|
188
|
+
if (candidates.length === 0) {
|
|
189
|
+
return { reason: `no read-only bash backend for ${process.platform}` };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const failures: string[] = [];
|
|
193
|
+
for (const id of candidates) {
|
|
194
|
+
const backend = createBackend(id, cwd);
|
|
195
|
+
const verdict = await verify(backend, cwd);
|
|
196
|
+
if (verdict.ok) return { backend, reason: verdict.reason };
|
|
197
|
+
failures.push(verdict.reason);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { reason: failures.join("; ") };
|
|
201
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask-mode footer.
|
|
3
|
+
*
|
|
4
|
+
* The built-in footer puts every `ctx.ui.setStatus()` entry on its own line
|
|
5
|
+
* below the token stats, which leaves stale blank space above the model line
|
|
6
|
+
* and pushes the transcript up by one row. This footer is installed only while
|
|
7
|
+
* ask mode is active, and draws the mode badge on the working-directory/session
|
|
8
|
+
* line instead, right-aligned into space that is otherwise empty.
|
|
9
|
+
*
|
|
10
|
+
* It is deliberately a subset of pi's footer: working directory, git branch,
|
|
11
|
+
* session name, token totals, cost, context usage, and model. Any other
|
|
12
|
+
* extension statuses are kept on their own line.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { relative, resolve, sep } from "node:path";
|
|
17
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
18
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
19
|
+
import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
export interface ModeBadge {
|
|
22
|
+
text: string;
|
|
23
|
+
tone: "warning" | "error";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Usage {
|
|
27
|
+
input?: number;
|
|
28
|
+
output?: number;
|
|
29
|
+
cost?: { total?: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Totals {
|
|
33
|
+
input: number;
|
|
34
|
+
output: number;
|
|
35
|
+
cost: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const EMPTY_TOTALS: Totals = { input: 0, output: 0, cost: 0 };
|
|
39
|
+
|
|
40
|
+
export function formatTokens(count: number): string {
|
|
41
|
+
if (count < 1000) return count.toString();
|
|
42
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
43
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
44
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
45
|
+
return `${Math.round(count / 1000000)}M`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatCwd(cwd: string, home = homedir()): string {
|
|
49
|
+
const resolvedCwd = resolve(cwd);
|
|
50
|
+
const resolvedHome = resolve(home);
|
|
51
|
+
const delta = relative(resolvedHome, resolvedCwd);
|
|
52
|
+
const insideHome = delta === "" || (delta !== ".." && !delta.startsWith(`..${sep}`) && !isAbsolutePath(delta));
|
|
53
|
+
if (!insideHome) return cwd;
|
|
54
|
+
return delta === "" ? "~" : `~${sep}${delta}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isAbsolutePath(value: string): boolean {
|
|
58
|
+
return value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Left text on the left edge, right text on the right edge, one space minimum between. */
|
|
62
|
+
function justify(left: string, right: string, width: number): string {
|
|
63
|
+
const leftWidth = visibleWidth(left);
|
|
64
|
+
const rightWidth = visibleWidth(right);
|
|
65
|
+
if (leftWidth + rightWidth + 1 <= width) {
|
|
66
|
+
return left + " ".repeat(width - leftWidth - rightWidth) + right;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const availableLeft = width - rightWidth - 1;
|
|
70
|
+
if (availableLeft >= 12) {
|
|
71
|
+
const shortened = truncateToWidth(left, availableLeft, "…");
|
|
72
|
+
const shortenedWidth = visibleWidth(shortened);
|
|
73
|
+
return shortened + " ".repeat(Math.max(0, width - shortenedWidth - rightWidth)) + right;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (rightWidth + 1 <= width) {
|
|
77
|
+
return " ".repeat(Math.max(0, width - rightWidth)) + right;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return truncateToWidth(right, width, "…");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function addUsage(totals: Totals, usage: Usage | undefined): void {
|
|
84
|
+
if (!usage) return;
|
|
85
|
+
totals.input += usage.input ?? 0;
|
|
86
|
+
totals.output += usage.output ?? 0;
|
|
87
|
+
totals.cost += usage.cost?.total ?? 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createModesFooter(
|
|
91
|
+
ctx: ExtensionContext,
|
|
92
|
+
theme: Theme,
|
|
93
|
+
footerData: ReadonlyFooterDataProvider,
|
|
94
|
+
badge: () => ModeBadge,
|
|
95
|
+
): Component & { dispose?(): void } {
|
|
96
|
+
let cacheKey = "";
|
|
97
|
+
let cache: Totals = EMPTY_TOTALS;
|
|
98
|
+
|
|
99
|
+
function totals(): Totals {
|
|
100
|
+
const leaf = ctx.sessionManager.getLeafId() ?? "";
|
|
101
|
+
const entries = ctx.sessionManager.getEntries();
|
|
102
|
+
const key = `${leaf}:${entries.length}`;
|
|
103
|
+
if (key === cacheKey) return cache;
|
|
104
|
+
|
|
105
|
+
const next: Totals = { input: 0, output: 0, cost: 0 };
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const record = entry as { type: string; usage?: Usage; message?: { role?: string; usage?: Usage } };
|
|
108
|
+
if (record.type === "usage") {
|
|
109
|
+
addUsage(next, record.usage);
|
|
110
|
+
} else if (record.type === "message") {
|
|
111
|
+
addUsage(next, record.message?.usage);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
cacheKey = key;
|
|
115
|
+
cache = next;
|
|
116
|
+
return next;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function render(width: number): string[] {
|
|
120
|
+
const model = ctx.model;
|
|
121
|
+
const branch = footerData.getGitBranch();
|
|
122
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
123
|
+
|
|
124
|
+
let place = formatCwd(ctx.sessionManager.getCwd());
|
|
125
|
+
if (branch) place += ` (${branch})`;
|
|
126
|
+
if (sessionName) place += ` • ${sessionName}`;
|
|
127
|
+
|
|
128
|
+
const current = badge();
|
|
129
|
+
const lines = [justify(theme.fg("dim", place), theme.fg(current.tone, current.text), width)];
|
|
130
|
+
|
|
131
|
+
const used = totals();
|
|
132
|
+
const stats: string[] = [];
|
|
133
|
+
if (used.input) stats.push(`↑${formatTokens(used.input)}`);
|
|
134
|
+
if (used.output) stats.push(`↓${formatTokens(used.output)}`);
|
|
135
|
+
if (used.cost) stats.push(`$${used.cost.toFixed(3)}`);
|
|
136
|
+
const context = ctx.getContextUsage();
|
|
137
|
+
if (context) {
|
|
138
|
+
const percent = context.percent === null ? "?" : `${context.percent.toFixed(1)}%`;
|
|
139
|
+
stats.push(`${percent}/${formatTokens(context.contextWindow ?? 0)}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let modelText = model?.id ?? "no-model";
|
|
143
|
+
if (model?.reasoning) modelText += ` • ${ctx.thinkingLevel ?? "off"}`;
|
|
144
|
+
if (stats.length > 0) {
|
|
145
|
+
lines.push(justify(theme.fg("dim", stats.join(" ")), theme.fg("dim", modelText), width));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const statuses = [...footerData.getExtensionStatuses().entries()]
|
|
149
|
+
.filter(([key]) => key !== "modes")
|
|
150
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
151
|
+
.map(([, text]) => text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim())
|
|
152
|
+
.filter((text) => text.length > 0);
|
|
153
|
+
if (statuses.length > 0) {
|
|
154
|
+
lines.push(truncateToWidth(statuses.join(" "), width, "…"));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
invalidate() {},
|
|
162
|
+
render,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modes - ask and work
|
|
3
|
+
*
|
|
4
|
+
* Ask mode is read-only: the write tools are removed from the model's toolset,
|
|
5
|
+
* and bash runs under an OS-level sandbox that denies filesystem writes (see
|
|
6
|
+
* backends.ts). Work mode is pi as usual.
|
|
7
|
+
*
|
|
8
|
+
* Toggle with `alt+m`, `/ask`, `/work`, or start in ask mode with `--ask`.
|
|
9
|
+
*
|
|
10
|
+
* Configuration:
|
|
11
|
+
* - PI_MODES_BASH=auto|off Detect a read-only bash backend, or skip detection.
|
|
12
|
+
* - PI_MODES_ASK_TOOLS=a,b Extra tool names to allow in ask mode.
|
|
13
|
+
*
|
|
14
|
+
* Windows is intentionally unsupported: no modes are registered there.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { createWrappedOperations, detectBackend, type SandboxBackend } from "./backends.ts";
|
|
20
|
+
import { createModesFooter, type ModeBadge } from "./footer.ts";
|
|
21
|
+
|
|
22
|
+
type Mode = "work" | "ask";
|
|
23
|
+
|
|
24
|
+
/** Tools that may run in ask mode. Everything else is hidden and blocked. */
|
|
25
|
+
const ASK_TOOLS = ["read", "grep", "find", "ls", "bash", "web_search", "web_fetch", "questionnaire"];
|
|
26
|
+
|
|
27
|
+
const CONTEXT_CUSTOM_TYPE = "modes-ask-context";
|
|
28
|
+
const CONTEXT_MARKER = "[ASK MODE ACTIVE]";
|
|
29
|
+
const STATE_CUSTOM_TYPE = "modes";
|
|
30
|
+
const SHORTCUT = "alt+m";
|
|
31
|
+
|
|
32
|
+
interface PersistedState {
|
|
33
|
+
mode?: Mode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ASK_PROMPT = `${CONTEXT_MARKER}
|
|
37
|
+
You are in ask mode: reading, searching, and answering only.
|
|
38
|
+
|
|
39
|
+
Rules:
|
|
40
|
+
- Do not modify files. The edit and write tools are not available.
|
|
41
|
+
- bash runs inside a read-only sandbox. Reading, searching, git history, and network access work; anything that writes fails with a permission error. Do not retry a failed write - report the limitation instead.
|
|
42
|
+
- Answer from evidence: cite file paths with line numbers, and URLs for web results.
|
|
43
|
+
- If a change is needed, describe it precisely and tell the user to switch to work mode (/work or alt+m).`;
|
|
44
|
+
|
|
45
|
+
export default function modesExtension(pi: ExtensionAPI): void {
|
|
46
|
+
// Ask mode depends on an OS-level sandbox that Windows does not provide.
|
|
47
|
+
// Rather than ship a weaker guardrail, the extension does nothing there.
|
|
48
|
+
if (process.platform === "win32") return;
|
|
49
|
+
|
|
50
|
+
let mode: Mode = "work";
|
|
51
|
+
let backend: SandboxBackend | undefined;
|
|
52
|
+
let backendReason = "not detected yet";
|
|
53
|
+
let backendChecked = false;
|
|
54
|
+
let workTools: string[] | undefined;
|
|
55
|
+
let footerTui: { requestRender(): void } | undefined;
|
|
56
|
+
let footerOwned = false;
|
|
57
|
+
const wrappedCalls = new Set<string>();
|
|
58
|
+
|
|
59
|
+
function configuredAskTools(): string[] {
|
|
60
|
+
const extra = (process.env.PI_MODES_ASK_TOOLS ?? "")
|
|
61
|
+
.split(",")
|
|
62
|
+
.map((name) => name.trim())
|
|
63
|
+
.filter((name) => name.length > 0);
|
|
64
|
+
return [...new Set([...ASK_TOOLS, ...extra])];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Ask toolset, restricted to tools that actually exist in this session. */
|
|
68
|
+
function askToolset(): string[] {
|
|
69
|
+
const registered = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
70
|
+
return configuredAskTools().filter((name) => registered.has(name));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function persist(): void {
|
|
74
|
+
pi.appendEntry(STATE_CUSTOM_TYPE, { mode } satisfies PersistedState);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
78
|
+
ctx.ui.setStatus("modes", undefined);
|
|
79
|
+
|
|
80
|
+
if (mode !== "ask") return;
|
|
81
|
+
|
|
82
|
+
// In the TUI the badge lives in the footer's first line; other modes have
|
|
83
|
+
// no footer, so they keep the plain status line.
|
|
84
|
+
if (ctx.mode === "tui") {
|
|
85
|
+
footerTui?.requestRender();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const badge = badgeSpec();
|
|
90
|
+
ctx.ui.setStatus("modes", ctx.ui.theme.fg(badge.tone, badge.text));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function badgeSpec(): ModeBadge {
|
|
94
|
+
if (backend) return { text: `⏸ ask · ro(${backend.id})`, tone: "warning" };
|
|
95
|
+
return { text: "⏸ ask · bash off", tone: "error" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Ask mode replaces the built-in footer so the badge can sit on the
|
|
100
|
+
* directory/session line instead of taking a row of its own. Work mode
|
|
101
|
+
* restores pi's native footer.
|
|
102
|
+
*/
|
|
103
|
+
function installAskFooter(ctx: ExtensionContext): void {
|
|
104
|
+
if (ctx.mode !== "tui" || footerOwned) return;
|
|
105
|
+
try {
|
|
106
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
107
|
+
footerTui = tui;
|
|
108
|
+
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
109
|
+
return {
|
|
110
|
+
...createModesFooter(ctx, theme, footerData, badgeSpec),
|
|
111
|
+
dispose() {
|
|
112
|
+
unsubscribe();
|
|
113
|
+
footerTui = undefined;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
footerOwned = true;
|
|
118
|
+
} catch {
|
|
119
|
+
// Fall back to the status line rendered by updateStatus().
|
|
120
|
+
ctx.ui.setFooter(undefined);
|
|
121
|
+
footerOwned = false;
|
|
122
|
+
footerTui = undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function removeAskFooter(ctx: ExtensionContext): void {
|
|
127
|
+
if (ctx.mode !== "tui" || !footerOwned) return;
|
|
128
|
+
ctx.ui.setFooter(undefined);
|
|
129
|
+
footerOwned = false;
|
|
130
|
+
footerTui = undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function describe(): string {
|
|
134
|
+
const lines = [
|
|
135
|
+
`Mode: ${mode}`,
|
|
136
|
+
`Read-only bash: ${backend ? backend.id : "unavailable"}`,
|
|
137
|
+
`Backend check: ${backendReason}`,
|
|
138
|
+
];
|
|
139
|
+
if (mode === "ask") {
|
|
140
|
+
lines.push(`Allowed tools: ${askToolset().join(", ") || "(none)"}`);
|
|
141
|
+
}
|
|
142
|
+
lines.push("", "Toggle: alt+m, /ask, /work");
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Detect a backend at most once per session, on first use. */
|
|
147
|
+
async function ensureBackend(cwd: string): Promise<void> {
|
|
148
|
+
if (backendChecked) return;
|
|
149
|
+
backendChecked = true;
|
|
150
|
+
const detected = await detectBackend(cwd);
|
|
151
|
+
backend = detected.backend;
|
|
152
|
+
backendReason = detected.reason;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function setMode(next: Mode, ctx: ExtensionContext): Promise<void> {
|
|
156
|
+
if (next === mode) {
|
|
157
|
+
updateStatus(ctx);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (next === "ask") {
|
|
162
|
+
await ensureBackend(ctx.cwd);
|
|
163
|
+
mode = "ask";
|
|
164
|
+
workTools = pi.getActiveTools();
|
|
165
|
+
pi.setActiveTools(askToolset());
|
|
166
|
+
installAskFooter(ctx);
|
|
167
|
+
updateStatus(ctx);
|
|
168
|
+
persist();
|
|
169
|
+
if (backend) {
|
|
170
|
+
ctx.ui.notify(`Ask mode: no changes. bash is read-only (${backend.id}).`, "info");
|
|
171
|
+
} else {
|
|
172
|
+
ctx.ui.notify(`Ask mode: no changes, and bash is disabled. ${backendReason}`, "warning");
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
mode = "work";
|
|
178
|
+
pi.setActiveTools(workTools ?? pi.getActiveTools());
|
|
179
|
+
workTools = undefined;
|
|
180
|
+
removeAskFooter(ctx);
|
|
181
|
+
updateStatus(ctx);
|
|
182
|
+
persist();
|
|
183
|
+
ctx.ui.notify("Work mode: full access restored.", "info");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
pi.registerFlag("ask", {
|
|
187
|
+
description: "Start in ask mode (read-only)",
|
|
188
|
+
type: "boolean",
|
|
189
|
+
default: false,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
pi.registerCommand("ask", {
|
|
193
|
+
description: "Switch to ask mode (read-only)",
|
|
194
|
+
handler: async (_args, ctx) => setMode("ask", ctx),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
pi.registerCommand("work", {
|
|
198
|
+
description: "Switch to work mode (full access)",
|
|
199
|
+
handler: async (_args, ctx) => setMode("work", ctx),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
pi.registerCommand("modes", {
|
|
203
|
+
description: "Show the current mode and sandbox backend",
|
|
204
|
+
handler: async (_args, ctx) => ctx.ui.notify(describe(), "info"),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
pi.registerShortcut(SHORTCUT, {
|
|
208
|
+
description: "Toggle ask/work mode",
|
|
209
|
+
handler: async (ctx) => setMode(mode === "ask" ? "work" : "ask", ctx),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Bash is the one tool that survives ask mode, so it is wrapped at the call
|
|
213
|
+
// site instead of being replaced. Each call id is wrapped once.
|
|
214
|
+
pi.on("tool_call", async (event) => {
|
|
215
|
+
if (mode !== "ask") return undefined;
|
|
216
|
+
|
|
217
|
+
if (event.toolName === "bash") {
|
|
218
|
+
const input = event.input as { command?: unknown };
|
|
219
|
+
if (typeof input.command !== "string") return undefined;
|
|
220
|
+
if (!backend) {
|
|
221
|
+
return {
|
|
222
|
+
block: true,
|
|
223
|
+
reason:
|
|
224
|
+
"Ask mode: bash is disabled because no read-only sandbox backend is available. Use /work to leave ask mode.",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (wrappedCalls.has(event.toolCallId)) return undefined;
|
|
228
|
+
wrappedCalls.add(event.toolCallId);
|
|
229
|
+
if (wrappedCalls.size > 512) wrappedCalls.clear();
|
|
230
|
+
input.command = backend.wrap(input.command);
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (configuredAskTools().includes(event.toolName)) return undefined;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
block: true,
|
|
238
|
+
reason: `Ask mode: ${event.toolName} is not available, because it can change files or run arbitrary code. Use /work to leave ask mode.`,
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// `!command` bypasses the bash tool and therefore the tool_call handler.
|
|
243
|
+
pi.on("user_bash", () => {
|
|
244
|
+
if (mode !== "ask") return undefined;
|
|
245
|
+
if (!backend) {
|
|
246
|
+
return {
|
|
247
|
+
result: {
|
|
248
|
+
output:
|
|
249
|
+
"Ask mode: bash is disabled because no read-only sandbox backend is available. Use /work to leave ask mode.",
|
|
250
|
+
exitCode: 1,
|
|
251
|
+
cancelled: false,
|
|
252
|
+
truncated: false,
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return { operations: createWrappedOperations(backend) };
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
pi.on("before_agent_start", async (event) => {
|
|
260
|
+
if (mode !== "ask") return undefined;
|
|
261
|
+
if (event.messageType !== "user") return undefined;
|
|
262
|
+
return {
|
|
263
|
+
message: {
|
|
264
|
+
customType: CONTEXT_CUSTOM_TYPE,
|
|
265
|
+
content: ASK_PROMPT,
|
|
266
|
+
display: false,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Drop ask-mode context once the session leaves ask mode.
|
|
272
|
+
pi.on("context", async (event) => {
|
|
273
|
+
if (mode === "ask") return undefined;
|
|
274
|
+
return { messages: event.messages.filter((message) => !isAskContext(message)) };
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
278
|
+
const entries = ctx.sessionManager.getEntries();
|
|
279
|
+
const state = entries
|
|
280
|
+
.filter(
|
|
281
|
+
(entry: { type: string; customType?: string }) =>
|
|
282
|
+
entry.type === "custom" && entry.customType === STATE_CUSTOM_TYPE,
|
|
283
|
+
)
|
|
284
|
+
.pop() as { data?: PersistedState } | undefined;
|
|
285
|
+
|
|
286
|
+
mode = state?.data?.mode ?? "work";
|
|
287
|
+
if (pi.getFlag("ask") === true) mode = "ask";
|
|
288
|
+
|
|
289
|
+
workTools = undefined;
|
|
290
|
+
if (mode === "ask") {
|
|
291
|
+
await ensureBackend(ctx.cwd);
|
|
292
|
+
workTools = pi.getActiveTools();
|
|
293
|
+
pi.setActiveTools(askToolset());
|
|
294
|
+
installAskFooter(ctx);
|
|
295
|
+
} else {
|
|
296
|
+
removeAskFooter(ctx);
|
|
297
|
+
}
|
|
298
|
+
updateStatus(ctx);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** True for the hidden ask-mode prompt and for user messages that carry it. */
|
|
303
|
+
function isAskContext(message: AgentMessage): boolean {
|
|
304
|
+
const candidate = message as AgentMessage & { customType?: string };
|
|
305
|
+
if (candidate.customType === CONTEXT_CUSTOM_TYPE) return true;
|
|
306
|
+
if (candidate.role === "assistant") return false;
|
|
307
|
+
const content = (candidate as { content?: unknown }).content;
|
|
308
|
+
if (typeof content === "string") return content.includes(CONTEXT_MARKER);
|
|
309
|
+
if (!Array.isArray(content)) return false;
|
|
310
|
+
return content.some(
|
|
311
|
+
(part) =>
|
|
312
|
+
!!part &&
|
|
313
|
+
typeof part === "object" &&
|
|
314
|
+
(part as { type?: unknown }).type === "text" &&
|
|
315
|
+
typeof (part as { text?: unknown }).text === "string" &&
|
|
316
|
+
(part as { text: string }).text.includes(CONTEXT_MARKER),
|
|
317
|
+
);
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sassywares/pi-extensions",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "A zesty power pack for pi: auto-titled sessions, real web access, clean handoffs, and
|
|
3
|
+
"version": "0.3.20",
|
|
4
|
+
"description": "A zesty power pack for pi: auto-titled sessions, real web access, clean handoffs, isolated subagents, and ask/work modes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Sassywares",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"auto-title",
|
|
21
21
|
"web-search",
|
|
22
22
|
"handoff",
|
|
23
|
-
"subagent"
|
|
23
|
+
"subagent",
|
|
24
|
+
"modes"
|
|
24
25
|
],
|
|
25
26
|
"files": [
|
|
26
27
|
"extensions",
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
38
|
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"@earendil-works/pi-tui": "*",
|
|
38
40
|
"typebox": "*"
|
|
39
41
|
},
|
|
40
42
|
"pi": {
|
|
@@ -42,7 +44,8 @@
|
|
|
42
44
|
"./extensions/auto-title.ts",
|
|
43
45
|
"./extensions/web-search.ts",
|
|
44
46
|
"./extensions/handoff.ts",
|
|
45
|
-
"./extensions/subagent/index.ts"
|
|
47
|
+
"./extensions/subagent/index.ts",
|
|
48
|
+
"./extensions/modes/index.ts"
|
|
46
49
|
]
|
|
47
50
|
}
|
|
48
51
|
}
|