@sassywares/pi-extensions 0.3.18 → 0.3.19

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 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,26 @@ 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`. `/modes` shows the verdict, and the footer shows `⏸ ask · ro(sandbox-exec)`, `⏸ ask · bash off`, or `▶ work`.
153
+
154
+ - `--ask` starts a session in ask mode; the mode persists across resume.
155
+ - `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.
156
+ - 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.
157
+ - **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.
158
+
137
159
  ## Configuration
138
160
 
139
161
  | Variable | Default | Effect |
@@ -141,6 +163,8 @@ A watchdog kills a subagent when it emits no output for two minutes, which catch
141
163
  | `PI_AUTO_TITLE` | unset | Set to `0` to disable automatic titles. `/title` still works. |
142
164
  | `EXA_API_KEY` | unset | Use the direct Exa API instead of the public Exa MCP endpoint. |
143
165
  | `PI_AGENTS_DIR` | `~/.pi/agent/agents` | Target directory for `scripts/install-agents.sh`. |
166
+ | `PI_MODES_BASH` | `auto` | Set to `off` to skip backend detection. Ask mode then runs without bash. |
167
+ | `PI_MODES_ASK_TOOLS` | unset | Comma-separated extra tool names to allow in ask mode. |
144
168
  | `PI_SUBAGENT_CONCURRENCY` | `2` | Maximum parallel subagent processes. DeepSeek Flash handled 4; MiniMax M3.1 Flash stalled with 3. |
145
169
  | `PI_SUBAGENT_IDLE_TIMEOUT_MS` | `120000` | Kill a subagent after this many milliseconds with no stdout/stderr events. Set to `0` to disable. |
146
170
  | `PI_SUBAGENT_TELEMETRY` | `~/.pi/agent/subagent-runs.jsonl` | Telemetry JSONL path, or `0`/`off` to disable. |
@@ -153,6 +177,7 @@ Extensions run inside the pi process with the same OS permissions as pi. Review
153
177
  - `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
178
  - `/handoff` sends the conversation and your goal to the current model to generate the continuation prompt.
155
179
  - `subagent` starts additional pi processes. Their permissions match the tools allowed by their agent file and the pi process that spawned them.
180
+ - `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
181
  - No credentials are stored by this package. `EXA_API_KEY` is read from the environment at call time.
157
182
 
158
183
  ## Provenance
@@ -167,6 +192,9 @@ sassy-pi-extensions/
167
192
  │ ├── auto-title.ts
168
193
  │ ├── handoff.ts
169
194
  │ ├── web-search.ts
195
+ │ ├── modes/
196
+ │ │ ├── backends.ts
197
+ │ │ └── index.ts
170
198
  │ └── subagent/
171
199
  │ ├── agents.ts
172
200
  │ └── 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,263 @@
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
+
21
+ type Mode = "work" | "ask";
22
+
23
+ /** Tools that may run in ask mode. Everything else is hidden and blocked. */
24
+ const ASK_TOOLS = ["read", "grep", "find", "ls", "bash", "web_search", "web_fetch", "questionnaire"];
25
+
26
+ const CONTEXT_CUSTOM_TYPE = "modes-ask-context";
27
+ const CONTEXT_MARKER = "[ASK MODE ACTIVE]";
28
+ const STATE_CUSTOM_TYPE = "modes";
29
+ const SHORTCUT = "alt+m";
30
+
31
+ interface PersistedState {
32
+ mode?: Mode;
33
+ }
34
+
35
+ const ASK_PROMPT = `${CONTEXT_MARKER}
36
+ You are in ask mode: reading, searching, and answering only.
37
+
38
+ Rules:
39
+ - Do not modify files. The edit and write tools are not available.
40
+ - 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.
41
+ - Answer from evidence: cite file paths with line numbers, and URLs for web results.
42
+ - If a change is needed, describe it precisely and tell the user to switch to work mode (/work or alt+m).`;
43
+
44
+ export default function modesExtension(pi: ExtensionAPI): void {
45
+ // Ask mode depends on an OS-level sandbox that Windows does not provide.
46
+ // Rather than ship a weaker guardrail, the extension does nothing there.
47
+ if (process.platform === "win32") return;
48
+
49
+ let mode: Mode = "work";
50
+ let backend: SandboxBackend | undefined;
51
+ let backendReason = "not detected yet";
52
+ let backendChecked = false;
53
+ let workTools: string[] | undefined;
54
+ const wrappedCalls = new Set<string>();
55
+
56
+ function configuredAskTools(): string[] {
57
+ const extra = (process.env.PI_MODES_ASK_TOOLS ?? "")
58
+ .split(",")
59
+ .map((name) => name.trim())
60
+ .filter((name) => name.length > 0);
61
+ return [...new Set([...ASK_TOOLS, ...extra])];
62
+ }
63
+
64
+ /** Ask toolset, restricted to tools that actually exist in this session. */
65
+ function askToolset(): string[] {
66
+ const registered = new Set(pi.getAllTools().map((tool) => tool.name));
67
+ return configuredAskTools().filter((name) => registered.has(name));
68
+ }
69
+
70
+ function persist(): void {
71
+ pi.appendEntry(STATE_CUSTOM_TYPE, { mode } satisfies PersistedState);
72
+ }
73
+
74
+ function updateStatus(ctx: ExtensionContext): void {
75
+ if (mode === "work") {
76
+ ctx.ui.setStatus("modes", ctx.ui.theme.fg("accent", "▶ work"));
77
+ return;
78
+ }
79
+ const text = backend ? `⏸ ask · ro(${backend.id})` : "⏸ ask · bash off";
80
+ ctx.ui.setStatus("modes", ctx.ui.theme.fg(backend ? "warning" : "error", text));
81
+ }
82
+
83
+ function describe(): string {
84
+ const lines = [
85
+ `Mode: ${mode}`,
86
+ `Read-only bash: ${backend ? backend.id : "unavailable"}`,
87
+ `Backend check: ${backendReason}`,
88
+ ];
89
+ if (mode === "ask") {
90
+ lines.push(`Allowed tools: ${askToolset().join(", ") || "(none)"}`);
91
+ }
92
+ lines.push("", "Toggle: alt+m, /ask, /work");
93
+ return lines.join("\n");
94
+ }
95
+
96
+ /** Detect a backend at most once per session, on first use. */
97
+ async function ensureBackend(cwd: string): Promise<void> {
98
+ if (backendChecked) return;
99
+ backendChecked = true;
100
+ const detected = await detectBackend(cwd);
101
+ backend = detected.backend;
102
+ backendReason = detected.reason;
103
+ }
104
+
105
+ async function setMode(next: Mode, ctx: ExtensionContext): Promise<void> {
106
+ if (next === mode) {
107
+ updateStatus(ctx);
108
+ return;
109
+ }
110
+
111
+ if (next === "ask") {
112
+ await ensureBackend(ctx.cwd);
113
+ mode = "ask";
114
+ workTools = pi.getActiveTools();
115
+ pi.setActiveTools(askToolset());
116
+ updateStatus(ctx);
117
+ persist();
118
+ if (backend) {
119
+ ctx.ui.notify(`Ask mode: no changes. bash is read-only (${backend.id}).`, "info");
120
+ } else {
121
+ ctx.ui.notify(`Ask mode: no changes, and bash is disabled. ${backendReason}`, "warning");
122
+ }
123
+ return;
124
+ }
125
+
126
+ mode = "work";
127
+ pi.setActiveTools(workTools ?? pi.getActiveTools());
128
+ workTools = undefined;
129
+ updateStatus(ctx);
130
+ persist();
131
+ ctx.ui.notify("Work mode: full access restored.", "info");
132
+ }
133
+
134
+ pi.registerFlag("ask", {
135
+ description: "Start in ask mode (read-only)",
136
+ type: "boolean",
137
+ default: false,
138
+ });
139
+
140
+ pi.registerCommand("ask", {
141
+ description: "Switch to ask mode (read-only)",
142
+ handler: async (_args, ctx) => setMode("ask", ctx),
143
+ });
144
+
145
+ pi.registerCommand("work", {
146
+ description: "Switch to work mode (full access)",
147
+ handler: async (_args, ctx) => setMode("work", ctx),
148
+ });
149
+
150
+ pi.registerCommand("modes", {
151
+ description: "Show the current mode and sandbox backend",
152
+ handler: async (_args, ctx) => ctx.ui.notify(describe(), "info"),
153
+ });
154
+
155
+ pi.registerShortcut(SHORTCUT, {
156
+ description: "Toggle ask/work mode",
157
+ handler: async (ctx) => setMode(mode === "ask" ? "work" : "ask", ctx),
158
+ });
159
+
160
+ // Bash is the one tool that survives ask mode, so it is wrapped at the call
161
+ // site instead of being replaced. Each call id is wrapped once.
162
+ pi.on("tool_call", async (event) => {
163
+ if (mode !== "ask") return undefined;
164
+
165
+ if (event.toolName === "bash") {
166
+ const input = event.input as { command?: unknown };
167
+ if (typeof input.command !== "string") return undefined;
168
+ if (!backend) {
169
+ return {
170
+ block: true,
171
+ reason:
172
+ "Ask mode: bash is disabled because no read-only sandbox backend is available. Use /work to leave ask mode.",
173
+ };
174
+ }
175
+ if (wrappedCalls.has(event.toolCallId)) return undefined;
176
+ wrappedCalls.add(event.toolCallId);
177
+ if (wrappedCalls.size > 512) wrappedCalls.clear();
178
+ input.command = backend.wrap(input.command);
179
+ return undefined;
180
+ }
181
+
182
+ if (configuredAskTools().includes(event.toolName)) return undefined;
183
+
184
+ return {
185
+ block: true,
186
+ reason: `Ask mode: ${event.toolName} is not available, because it can change files or run arbitrary code. Use /work to leave ask mode.`,
187
+ };
188
+ });
189
+
190
+ // `!command` bypasses the bash tool and therefore the tool_call handler.
191
+ pi.on("user_bash", () => {
192
+ if (mode !== "ask") return undefined;
193
+ if (!backend) {
194
+ return {
195
+ result: {
196
+ output:
197
+ "Ask mode: bash is disabled because no read-only sandbox backend is available. Use /work to leave ask mode.",
198
+ exitCode: 1,
199
+ cancelled: false,
200
+ truncated: false,
201
+ },
202
+ };
203
+ }
204
+ return { operations: createWrappedOperations(backend) };
205
+ });
206
+
207
+ pi.on("before_agent_start", async (event) => {
208
+ if (mode !== "ask") return undefined;
209
+ if (event.messageType !== "user") return undefined;
210
+ return {
211
+ message: {
212
+ customType: CONTEXT_CUSTOM_TYPE,
213
+ content: ASK_PROMPT,
214
+ display: false,
215
+ },
216
+ };
217
+ });
218
+
219
+ // Drop ask-mode context once the session leaves ask mode.
220
+ pi.on("context", async (event) => {
221
+ if (mode === "ask") return undefined;
222
+ return { messages: event.messages.filter((message) => !isAskContext(message)) };
223
+ });
224
+
225
+ pi.on("session_start", async (_event, ctx) => {
226
+ const entries = ctx.sessionManager.getEntries();
227
+ const state = entries
228
+ .filter(
229
+ (entry: { type: string; customType?: string }) =>
230
+ entry.type === "custom" && entry.customType === STATE_CUSTOM_TYPE,
231
+ )
232
+ .pop() as { data?: PersistedState } | undefined;
233
+
234
+ mode = state?.data?.mode ?? "work";
235
+ if (pi.getFlag("ask") === true) mode = "ask";
236
+
237
+ workTools = undefined;
238
+ if (mode === "ask") {
239
+ await ensureBackend(ctx.cwd);
240
+ workTools = pi.getActiveTools();
241
+ pi.setActiveTools(askToolset());
242
+ }
243
+ updateStatus(ctx);
244
+ });
245
+ }
246
+
247
+ /** True for the hidden ask-mode prompt and for user messages that carry it. */
248
+ function isAskContext(message: AgentMessage): boolean {
249
+ const candidate = message as AgentMessage & { customType?: string };
250
+ if (candidate.customType === CONTEXT_CUSTOM_TYPE) return true;
251
+ if (candidate.role === "assistant") return false;
252
+ const content = (candidate as { content?: unknown }).content;
253
+ if (typeof content === "string") return content.includes(CONTEXT_MARKER);
254
+ if (!Array.isArray(content)) return false;
255
+ return content.some(
256
+ (part) =>
257
+ !!part &&
258
+ typeof part === "object" &&
259
+ (part as { type?: unknown }).type === "text" &&
260
+ typeof (part as { text?: unknown }).text === "string" &&
261
+ (part as { text: string }).text.includes(CONTEXT_MARKER),
262
+ );
263
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sassywares/pi-extensions",
3
- "version": "0.3.18",
4
- "description": "A zesty power pack for pi: auto-titled sessions, real web access, clean handoffs, and isolated subagents.",
3
+ "version": "0.3.19",
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",
@@ -42,7 +43,8 @@
42
43
  "./extensions/auto-title.ts",
43
44
  "./extensions/web-search.ts",
44
45
  "./extensions/handoff.ts",
45
- "./extensions/subagent/index.ts"
46
+ "./extensions/subagent/index.ts",
47
+ "./extensions/modes/index.ts"
46
48
  ]
47
49
  }
48
50
  }