@andares/pi-toolkits 0.9.6 → 0.9.7

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.
@@ -1,10 +1,19 @@
1
1
  /**
2
- * ask feature — read-only Q&A mode.
2
+ * ask feature — read-only consult mode.
3
3
  *
4
- * Design notes (see PLAN.md):
5
- * - Hard guarantee: write/edit/powershell are removed from the active tool set
6
- * via pi.setActiveTools(); pi's runtime rejects calls to non-active tools
7
- * ("Tool not found") so nothing executes.
4
+ * Design notes (see PLAN.md and AGENTS.md):
5
+ * - Project writes stay impossible: write/edit/powershell are removed from the
6
+ * active tool set via pi.setActiveTools(), and pi's runtime rejects calls to
7
+ * non-active tools ("Tool not found"). A gate blocks them again as defence in
8
+ * depth, in case anything re-enables them.
9
+ * - The mode is not "no writes at all": a consult session may need somewhere to
10
+ * keep an outline or a checklist, and confiscating temp files made the mode
11
+ * hostile enough that it went unused. One write channel is allowed —
12
+ * `scratchpad`, which takes a relative note name and can only reach
13
+ * `<os.tmpdir()>/pi-ask/<session>`. It is registered `defaultActive: false`
14
+ * and activated for the duration, so no path ever arrives from the model and
15
+ * the bash sandbox needs no write allowance. When no writable temp dir
16
+ * exists, the tool stays inactive and ask mode degrades to pure read-only.
8
17
  * - Read-only helper tools (grep/find/ls) are activated for the duration so
9
18
  * the model gets proper search tools; the entry snapshot restores exactly.
10
19
  * - bash stays available but is gated by a read-only sandbox
@@ -13,14 +22,20 @@
13
22
  * replacing it: each feature owns its own concern and the call is blocked
14
23
  * when either objects.
15
24
  * - Mode banner appended to the system prompt via before_agent_start
16
- * chained systemPrompt (see prompt.ts), listing blocked -> alternative.
25
+ * (see prompt.ts). It is short on purpose: a mode line, the scratch rule, and
26
+ * the exit path. Everything else is learned from tool declarations and from
27
+ * rejection reasons, which cost tokens only when they apply.
17
28
  */
18
29
  import type {
19
30
  ExtensionAPI,
20
31
  ExtensionContext,
21
32
  } from "@earendil-works/pi-coding-agent";
22
33
  import type { GateHub } from "../../lib/gates.js";
34
+ // The jailed-tool registry lives here too: ask marks `scratchpad` while the mode
35
+ // is active so the boundary gate (`permission`) knows it has no path to judge.
23
36
  import {
37
+ clearJailedTool,
38
+ markJailedTool,
24
39
  restoreActiveTools,
25
40
  snapshotActiveTools,
26
41
  withoutTools,
@@ -28,17 +43,76 @@ import {
28
43
  import {
29
44
  ASK_BLOCKED_TOOLS,
30
45
  ASK_READ_ONLY_HELPERS,
46
+ ASK_SCRATCH_TOOL,
31
47
  ASK_STATUS_KEY,
32
48
  ASK_STATUS_LABEL,
33
49
  } from "./constants.js";
34
50
  import { isReadOnlyCommand } from "./bash-sandbox.js";
35
51
  import { appendAskBanner } from "./prompt.js";
36
52
  import { blockedBashReason, blockedToolReason } from "./reasons.js";
53
+ import { createScratchStore, type ScratchStore } from "./scratch.js";
54
+ import { registerScratchTool } from "./scratch-tool.js";
37
55
 
38
- export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
56
+ export interface AskOptions {
57
+ /**
58
+ * Base directory for the scratch space. Default: the OS temp dir. Tests
59
+ * inject a fresh directory so they never touch the real temp space.
60
+ */
61
+ readonly scratchBaseDir?: string;
62
+ }
63
+
64
+ /** Session id, or a process-scoped fallback when the host exposes none. */
65
+ function sessionIdOf(ctx: ExtensionContext): string {
66
+ try {
67
+ return ctx.sessionManager.getSessionId();
68
+ } catch {
69
+ return `pid-${process.pid}`;
70
+ }
71
+ }
72
+
73
+ export function registerAsk(
74
+ pi: ExtensionAPI,
75
+ gates: GateHub,
76
+ options: AskOptions = {},
77
+ ): void {
39
78
  let enabled = false;
40
79
  /** Active tools before ask mode was entered (for exact restore on exit). */
41
80
  let snapshot: string[] = [];
81
+ /** Scratch space of this session, or undefined when unavailable. */
82
+ let scratch: ScratchStore | undefined;
83
+ /** Session id the scratch store is bound to (rebound on session switch). */
84
+ let scratchSessionId: string | undefined;
85
+
86
+ // The tool is registered up front but stays inactive: activating it on entry
87
+ // is what puts its declaration (and guidelines) into the request.
88
+ registerScratchTool(pi, () => scratch);
89
+
90
+ /**
91
+ * Create the scratch store for `sessionId` and bind the state. On failure the
92
+ * mode runs degraded (no scratch tool), with a warning.
93
+ */
94
+ function bindScratch(ctx: ExtensionContext, sessionId: string): void {
95
+ try {
96
+ scratch = createScratchStore({
97
+ sessionId,
98
+ baseDir: options.scratchBaseDir,
99
+ });
100
+ scratchSessionId = sessionId;
101
+ // Self-jailed: permission has no boundary to judge for this tool.
102
+ markJailedTool(ASK_SCRATCH_TOOL);
103
+ } catch (error) {
104
+ // No usable temp dir (read-only TMPDIR, locked-down container, …): fall
105
+ // back to the fully read-only mode rather than half-opening one.
106
+ scratch = undefined;
107
+ scratchSessionId = undefined;
108
+ clearJailedTool(ASK_SCRATCH_TOOL);
109
+ const reason = error instanceof Error ? error.message : String(error);
110
+ ctx.ui.notify(
111
+ `Ask mode: no scratch space (${reason}) — fully read-only.`,
112
+ "warning",
113
+ );
114
+ }
115
+ }
42
116
 
43
117
  /** Gray footer status while ask mode is active; cleared on exit. */
44
118
  function updateStatus(ctx: ExtensionContext): void {
@@ -48,30 +122,42 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
48
122
  );
49
123
  }
50
124
 
51
- /** Read-only helpers currently active (drives the banner's search hint). */
52
- function activeHelperTools(): string[] {
53
- const active = new Set(pi.getActiveTools());
54
- return ASK_READ_ONLY_HELPERS.filter((name) => active.has(name));
125
+ /**
126
+ * The tool set ask mode pins: entry snapshot minus the confiscated tools, plus
127
+ * the read-only helpers and the scratch tool.
128
+ *
129
+ * Recomputed (not just applied on entry) because `setActiveTools()` during
130
+ * `session_start` — which is how autostart enters the mode — is overwritten
131
+ * by the host's loadout preparation for the first run: the mode banner still
132
+ * appears, but the run would not declare `scratchpad` (or the helpers).
133
+ * Re-asserting in `before_agent_start` makes the mode's tool surface
134
+ * deterministic for every run.
135
+ */
136
+ function askToolSet(): string[] {
137
+ const registered = new Set(pi.getAllTools().map((tool) => tool.name));
138
+ const helpers = ASK_READ_ONLY_HELPERS.filter(
139
+ (name) => registered.has(name) && !snapshot.includes(name),
140
+ );
141
+ const tools = [...withoutTools(snapshot, ASK_BLOCKED_TOOLS), ...helpers];
142
+ if (scratch !== undefined && registered.has(ASK_SCRATCH_TOOL)) {
143
+ tools.push(ASK_SCRATCH_TOOL);
144
+ }
145
+ return tools;
55
146
  }
56
147
 
148
+ // Synchronous on purpose: creating the scratch store is sync (see scratch.ts),
149
+ // so a `/ask` dispatched from `session_start` completes before the first run.
57
150
  function enter(ctx: ExtensionContext): void {
58
151
  if (enabled) return;
59
152
  enabled = true;
60
153
  snapshot = snapshotActiveTools(pi);
61
- // Activate grep/find/ls when the host has them registered and they were
62
- // not already active. Names must be registered; unknown names are ignored
63
- // by setActiveTools, but filtering here keeps the intent explicit.
64
- const registered = new Set(pi.getAllTools().map((tool) => tool.name));
65
- const helpers = ASK_READ_ONLY_HELPERS.filter(
66
- (name) => registered.has(name) && !snapshot.includes(name),
67
- );
68
- pi.setActiveTools([
69
- ...withoutTools(snapshot, ASK_BLOCKED_TOOLS),
70
- ...helpers,
71
- ]);
154
+ bindScratch(ctx, sessionIdOf(ctx));
155
+ pi.setActiveTools(askToolSet());
72
156
  updateStatus(ctx);
73
157
  ctx.ui.notify(
74
- "Ask mode ON — read-only (write/edit/powershell disabled, bash sandboxed). /ask to exit.",
158
+ scratch === undefined
159
+ ? "Ask mode ON — read-only, no scratch space. /ask to exit."
160
+ : `Ask mode ON — read-only; notes go to ${scratch.root}. /ask to exit.`,
75
161
  "info",
76
162
  );
77
163
  }
@@ -79,6 +165,9 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
79
165
  function exit(ctx: ExtensionContext): void {
80
166
  if (!enabled) return;
81
167
  enabled = false;
168
+ clearJailedTool(ASK_SCRATCH_TOOL);
169
+ scratch = undefined;
170
+ scratchSessionId = undefined;
82
171
  restoreActiveTools(pi, snapshot);
83
172
  snapshot = [];
84
173
  updateStatus(ctx);
@@ -94,16 +183,36 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
94
183
  }
95
184
 
96
185
  pi.registerCommand("ask", {
97
- description: "Toggle ask mode (read-only Q&A)",
186
+ description: "Toggle ask mode (consult mode: no project writes, scratch notes allowed)",
98
187
  handler: async (_args, ctx) => {
99
188
  toggle(ctx);
100
189
  },
101
190
  });
102
191
 
192
+ // The scratch space is per session: an in-process session switch (/new,
193
+ // /resume, fork) while the mode is on rebinds it to the new session id, so
194
+ // notes do not keep landing in the previous session's directory. A repeated
195
+ // emission for the same session (startup rebind) is a no-op.
196
+ pi.on("session_start", (_event, ctx) => {
197
+ if (!enabled) return;
198
+ const sessionId = sessionIdOf(ctx);
199
+ if (sessionId === scratchSessionId) return;
200
+ bindScratch(ctx, sessionId);
201
+ pi.setActiveTools(askToolSet());
202
+ if (scratch !== undefined) {
203
+ ctx.ui.notify(
204
+ `Ask mode: notes now go to ${scratch.root}.`,
205
+ "info",
206
+ );
207
+ }
208
+ });
209
+
103
210
  // Defense-in-depth gate while ask mode is active:
104
211
  // - write/edit/powershell are already removed from the active set (runtime
105
212
  // rejects them); this blocks them again in case anything re-enables them.
106
213
  // - bash is kept available but only read-only commands pass the sandbox.
214
+ // Scratch notes are written by the `scratchpad` tool, not by bash, so the
215
+ // sandbox needs no write allowance and no path analysis.
107
216
  // The rejection `reason` reaches the model as the tool result, so it names
108
217
  // the nearest allowed alternative (see reasons.ts).
109
218
  //
@@ -121,7 +230,9 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
121
230
  if (ASK_BLOCKED_TOOLS.has(event.toolName)) {
122
231
  return {
123
232
  kind: "block",
124
- reason: blockedToolReason(event.toolName),
233
+ reason: blockedToolReason(event.toolName, {
234
+ scratch: scratch !== undefined,
235
+ }),
125
236
  };
126
237
  }
127
238
 
@@ -129,7 +240,12 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
129
240
  const command =
130
241
  typeof event.input.command === "string" ? event.input.command : "";
131
242
  if (!isReadOnlyCommand(command)) {
132
- return { kind: "block", reason: blockedBashReason(command) };
243
+ return {
244
+ kind: "block",
245
+ reason: blockedBashReason(command, {
246
+ scratch: scratch !== undefined,
247
+ }),
248
+ };
133
249
  }
134
250
  }
135
251
  return undefined;
@@ -139,12 +255,20 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
139
255
  // Mode banner: chained append to the current system prompt while enabled.
140
256
  pi.on("before_agent_start", (event) => {
141
257
  if (!enabled) return;
258
+ // Re-assert the mode's tool surface: entering from `session_start` (the
259
+ // autostart path) is followed by the host's loadout preparation for the
260
+ // first run, which would otherwise drop the tools set on entry.
261
+ // Documented contract: a handler may call setActiveTools() and the live
262
+ // loadout wins over the prepared selection.
263
+ pi.setActiveTools(askToolSet());
142
264
  // event.systemPrompt is the current (possibly already modified by other
143
265
  // extensions) system prompt — append, don't replace.
144
266
  return {
145
267
  systemPrompt: appendAskBanner(event.systemPrompt, {
146
- helperTools: activeHelperTools(),
268
+ scratch: scratch !== undefined,
147
269
  }),
148
270
  };
149
271
  });
150
272
  }
273
+ // (No other write path exists in ask mode: bash is read-only, and the scratch
274
+ // tool is the only thing that can create a file — inside its own temp dir.)
@@ -1,45 +1,57 @@
1
1
  /**
2
- * Tests for the ask-mode banner: current state + blocked -> alternative hints.
2
+ * Tests for the ask-mode banner.
3
+ *
4
+ * The banner is part of every request while the mode is on, so the contract has
5
+ * two halves: it must state the mode, the scratch allowance and the exit path,
6
+ * and it must stay small and byte-stable (the details live in the tool
7
+ * declaration and in rejection reasons).
3
8
  */
4
9
  import { describe, expect, it } from "vitest";
5
10
  import { appendAskBanner, buildAskBanner } from "./prompt.js";
6
11
 
7
12
  describe("buildAskBanner", () => {
8
- it("states the hard-blocked tools and the exit path", () => {
9
- const banner = buildAskBanner();
10
- expect(banner).toContain("[ASK MODE — read-only]");
11
- expect(banner).toContain("write, edit, powershell");
13
+ it("states the mode, the scratch channel and the exit path", () => {
14
+ const banner = buildAskBanner({ scratch: true });
15
+ expect(banner).toContain("[ASK MODE]");
16
+ expect(banner).toContain("do not implement");
17
+ expect(banner).toContain("scratchpad");
18
+ expect(banner).toContain("is a deliverable");
12
19
  expect(banner).toContain("/ask");
13
20
  });
14
21
 
15
- it("names the alternatives for blocked commands", () => {
16
- const banner = buildAskBanner();
17
- expect(banner).toContain("`read` tool");
18
- expect(banner).toContain("`curl -s <url>`");
19
- expect(banner).toContain("cat/sed tricks");
22
+ it("drops the scratch line when the session has no scratch space", () => {
23
+ const banner = buildAskBanner({ scratch: false });
24
+ expect(banner).not.toContain("scratchpad");
25
+ expect(banner).toContain("[ASK MODE]");
26
+ expect(banner).toContain("/ask");
20
27
  });
21
28
 
22
- it("lists the read-only git families", () => {
23
- const banner = buildAskBanner();
24
- expect(banner).toContain("blame");
25
- expect(banner).toContain("reflog show");
26
- expect(banner).toContain("stash list");
29
+ it("does not restate the bash allowlist", () => {
30
+ // The sandbox and reasons.ts own that list; per-request prose was the
31
+ // bulk of the old banner's ~300 tokens.
32
+ const banner = buildAskBanner({ scratch: true });
33
+ expect(banner).not.toContain("rev-parse");
34
+ expect(banner).not.toContain("sed -n");
35
+ expect(banner).not.toContain("allowlist");
27
36
  });
28
37
 
29
- it("adds the helper-tool hint only when helpers are active", () => {
30
- const withHelpers = buildAskBanner({ helperTools: ["grep", "find", "ls"] });
31
- expect(withHelpers).toContain("`grep`/`find`/`ls` tools");
38
+ it("stays within the token budget", () => {
39
+ expect(buildAskBanner({ scratch: true }).length).toBeLessThan(500);
40
+ });
32
41
 
33
- const withoutHelpers = buildAskBanner();
34
- expect(withoutHelpers).not.toContain("active during ask mode");
42
+ it("is byte-stable for the same state", () => {
43
+ // Anything per-turn here would break provider prompt caching.
44
+ expect(buildAskBanner({ scratch: true })).toBe(
45
+ buildAskBanner({ scratch: true }),
46
+ );
35
47
  });
36
48
  });
37
49
 
38
50
  describe("appendAskBanner", () => {
39
51
  it("appends instead of replacing the existing system prompt", () => {
40
52
  const original = "You are a helpful coding agent.";
41
- const combined = appendAskBanner(original, { helperTools: ["grep"] });
53
+ const combined = appendAskBanner(original, { scratch: true });
42
54
  expect(combined.startsWith(original)).toBe(true);
43
- expect(combined).toContain("[ASK MODE — read-only]");
55
+ expect(combined).toContain("[ASK MODE]");
44
56
  });
45
57
  });
@@ -2,54 +2,41 @@
2
2
  * ask feature — system prompt banner.
3
3
  *
4
4
  * Appended to the system prompt (chained append, not replacement) while ask
5
- * mode is active so the model knows the current mode, what it may still use,
6
- * and — for everything blocked — what to use instead. Do NOT use the removed
7
- * `systemPromptAppend` field or wholesale systemPrompt replacement (see
8
- * PLAN.md).
5
+ * mode is active. It is deliberately short: the system prompt is part of every
6
+ * request in a run, so the mode line is kept to a few sentences that stay
7
+ * byte-identical for the whole session (no paths, no timestamps, nothing that
8
+ * changes per turn) and therefore remains a stable, cacheable prefix. Details
9
+ * live where they are cheaper and more timely:
10
+ *
11
+ * - the scratchpad tool's own declaration and `promptGuidelines` carry the
12
+ * "notes, not deliverables" rule, and only while the tool is active;
13
+ * - rejected calls carry the allowed alternative (see reasons.ts), so a model
14
+ * that guesses wrong pays for the detail only on the turn it needs it;
15
+ * - the read-only bash allowlist is *not* spelled out here. That list used to
16
+ * be ~300 tokens per request restating what the sandbox already enforces.
17
+ *
18
+ * Do NOT use the removed `systemPromptAppend` field or wholesale systemPrompt
19
+ * replacement (see PLAN.md).
9
20
  */
10
21
 
11
22
  export interface AskBannerOptions {
12
- /** Read-only helper tools active in this session (subset of grep/find/ls). */
13
- readonly helperTools?: readonly string[];
23
+ /** Whether the session has a usable scratch directory (tool active). */
24
+ readonly scratch?: boolean;
14
25
  }
15
26
 
16
- const BANNER_HEADER = `[ASK MODE — read-only]
17
- You are in ask mode: answer questions and read code; nothing may be modified.
18
-
19
- Hard-blocked (calls fail — no workaround): write, edit, powershell.
20
- If fulfilling a request needs a change, say exactly what to change and tell the
21
- user to exit ask mode with /ask first.
27
+ const BANNER_HEADER = `[ASK MODE] Consult only — read, answer, plan; do not implement.`;
22
28
 
23
- bash runs under a read-only allowlist. Working examples:
24
- - files: ls, cat, head, tail, sed -n, wc, file, stat, diff, tree
25
- - search: grep, rg, fd, find (without -exec), jq
26
- - git: status, log, diff, show, branch, remote, blame, grep, rev-parse,
27
- describe, shortlog, reflog show, stash list, tag -l, cat-file
28
- - network: curl -s <url>, wget -O - <url>
29
- - packages: npm view, pnpm view, pip show/list, python3 -m json.tool
29
+ const SCRATCH_LINE = `Throwaway notes go through the \`scratchpad\` tool (a temp directory outside the project); nothing written there is a deliverable.`;
30
30
 
31
- Blocked without exception: redirection (> >>), tee, dd, rm/mv/cp/mkdir, sed -i,
32
- installs/builds, git mutations (add/commit/checkout/reset/stash drop/...), and
33
- command substitution $(...) / backticks.`;
31
+ const BANNER_FOOTER = `Any other write is blocked, and a blocked call comes back with the allowed alternative — do not retry variants.
32
+ Exit: the operator runs /ask.`;
34
33
 
35
- const BANNER_FOOTER = `Never write a file, not even a temporary one.`;
36
-
37
- /** Build the banner, adapting the search hint to the active helper tools. */
34
+ /** Build the banner, dropping the scratch line when no scratch space exists. */
38
35
  export function buildAskBanner(options: AskBannerOptions = {}): string {
39
- const helpers = options.helperTools ?? [];
40
- const helperLabel = helpers.map((name) => `\`${name}\``).join("/");
41
- const helperLine =
42
- helpers.length > 0
43
- ? `\n- search with the ${helperLabel} tools (active during ask mode)`
44
- : "";
45
- return `${BANNER_HEADER}
46
-
47
- If a bash command is rejected, do not retry variants or invent workarounds:
48
- - read file contents with the \`read\` tool instead of cat/sed tricks${helperLine}
49
- - fetch a URL with \`curl -s <url>\` instead of wget
50
- - otherwise tell the user which command is blocked and why
51
-
52
- ${BANNER_FOOTER}`;
36
+ const lines = [BANNER_HEADER];
37
+ if (options.scratch === true) lines.push(SCRATCH_LINE);
38
+ lines.push(BANNER_FOOTER);
39
+ return lines.join("\n");
53
40
  }
54
41
 
55
42
  /** Append the ask-mode banner to an existing system prompt. */
@@ -1,18 +1,28 @@
1
1
  /**
2
- * Tests for ask-mode rejection messages: each one must point at a next step.
2
+ * Tests for ask-mode rejection messages: each one must point at a next step,
3
+ * and the write-shaped ones must point at `scratchpad` rather than at the exit
4
+ * command alone.
3
5
  */
4
6
  import { describe, expect, it } from "vitest";
5
7
  import { blockedBashReason, blockedToolReason } from "./reasons.js";
6
8
 
7
9
  describe("blockedToolReason", () => {
8
- it.each(["write", "edit", "powershell"])(
9
- "names the exit path for %s",
10
+ it.each(["write", "edit"])(
11
+ "points %s at the scratch tool and the exit path",
10
12
  (tool) => {
11
13
  const reason = blockedToolReason(tool);
12
14
  expect(reason).toContain(tool);
15
+ expect(reason).toContain("scratchpad");
13
16
  expect(reason).toContain("/ask");
14
17
  },
15
18
  );
19
+
20
+ it("names the alternatives for powershell", () => {
21
+ const reason = blockedToolReason("powershell");
22
+ expect(reason).toContain("powershell");
23
+ expect(reason).toContain("scratchpad");
24
+ expect(reason).toContain("read-only bash");
25
+ });
16
26
  });
17
27
 
18
28
  describe("blockedBashReason", () => {
@@ -29,7 +39,36 @@ describe("blockedBashReason", () => {
29
39
  expect(blockedBashReason(command)).toContain(needle);
30
40
  });
31
41
 
42
+ it("points write-shaped rejections at the scratch tool", () => {
43
+ expect(blockedBashReason("cat a > b")).toContain("scratchpad");
44
+ expect(blockedBashReason("tee out.txt")).toContain("scratchpad");
45
+ });
46
+
32
47
  it("always echoes the blocked command", () => {
33
48
  expect(blockedBashReason("git push")).toContain("Command: git push");
34
49
  });
35
50
  });
51
+
52
+ describe("degraded mode (no scratch space)", () => {
53
+ it("does not point write/edit at the unavailable scratch tool", () => {
54
+ for (const tool of ["write", "edit", "powershell"]) {
55
+ const reason = blockedToolReason(tool, { scratch: false });
56
+ expect(reason).not.toContain("scratchpad");
57
+ expect(reason).toContain("no scratch space");
58
+ expect(reason).toContain("/ask");
59
+ }
60
+ });
61
+
62
+ it("does not point bash rejections at the unavailable scratch tool", () => {
63
+ for (const command of ["cat a > b", "tee out.txt", "wget https://x", "vim f"]) {
64
+ expect(blockedBashReason(command, { scratch: false })).not.toContain(
65
+ "scratchpad",
66
+ );
67
+ }
68
+ });
69
+
70
+ it("keeps pointing at scratchpad by default (scratch available)", () => {
71
+ expect(blockedToolReason("write")).toContain("scratchpad");
72
+ expect(blockedBashReason("cat a > b")).toContain("scratchpad");
73
+ });
74
+ });
@@ -4,27 +4,60 @@
4
4
  * The `tool_call` hook's `reason` is delivered to the model as the tool result
5
5
  * (verified against pi 1.3.0: `extensions/runner.js` returns the `{block,
6
6
  * reason}` result unchanged and pi-agent-core's `agent-loop.js` turns it into
7
- * `createErrorToolResult(reason)`; both paths are byte-identical to 1.2.0), so it
8
- * is the best place to name the nearest allowed alternative and stop the model
9
- * from retrying variants of a blocked command.
7
+ * `createErrorToolResult(reason)`; both paths are byte-identical to 1.2.0), so
8
+ * it is the best place to name the nearest allowed alternative and stop the model
9
+ * from retrying variants of a blocked command. That is also why the mode banner
10
+ * does not carry the allowlist: this path costs tokens only when it runs.
11
+ *
12
+ * When the session has no scratch space (degraded mode), the reasons must not
13
+ * point at `scratchpad` — the tool is inactive then, and sending the model to
14
+ * it would cost one wasted call per discovery.
10
15
  */
11
16
  import { classifyBlockedCommand } from "./bash-sandbox.js";
12
17
 
18
+ export interface ReasonOptions {
19
+ /**
20
+ * Whether the scratch tool is available. Default true: only the degraded
21
+ * no-temp-dir mode passes false, and only then is the scratch hint omitted.
22
+ */
23
+ readonly scratch?: boolean;
24
+ }
25
+
26
+ function scratchClause(options: ReasonOptions, fallback: string): string {
27
+ return options.scratch === false
28
+ ? `${fallback} This session has no scratch space, so notes cannot be saved either.`
29
+ : `${fallback} For throwaway planning notes use \`scratchpad\`.`;
30
+ }
31
+
13
32
  /** Reason for a hard-blocked tool call (write / edit / powershell). */
14
- export function blockedToolReason(toolName: string): string {
33
+ export function blockedToolReason(
34
+ toolName: string,
35
+ options: ReasonOptions = {},
36
+ ): string {
15
37
  if (toolName === "powershell") {
16
- return "Ask mode: powershell is disabled — ask mode is read-only. " +
17
- "Inspect files with the read/grep/find/ls tools or the read-only bash " +
18
- "allowlist; if a command must change something, tell the user and ask " +
19
- "them to exit ask mode with /ask.";
38
+ return (
39
+ "Ask mode: powershell is disabled — ask mode does not change project " +
40
+ "files. Inspect files with the read/grep/find/ls tools or the read-only " +
41
+ "bash allowlist. " +
42
+ scratchClause(options, "") +
43
+ " If a real change is needed, tell the operator and ask them to exit ask " +
44
+ "mode with /ask."
45
+ );
20
46
  }
21
- return `Ask mode: ${toolName} is disabled — ask mode is read-only. ` +
22
- "If a change is needed, explain what to change and ask the user to exit " +
23
- "ask mode with /ask.";
47
+ return (
48
+ `Ask mode: ${toolName} is disabled — ask mode does not change project ` +
49
+ "files. " +
50
+ scratchClause(options, "") +
51
+ " If the answer needs a real change, explain what to change and ask the " +
52
+ "operator to exit ask mode with /ask."
53
+ );
24
54
  }
25
55
 
26
56
  /** Reason for a rejected bash command, classified by intent. */
27
- export function blockedBashReason(command: string): string {
57
+ export function blockedBashReason(
58
+ command: string,
59
+ options: ReasonOptions = {},
60
+ ): string {
28
61
  const suffix = `\nCommand: ${command}`;
29
62
  switch (classifyBlockedCommand(command)) {
30
63
  case "substitution":
@@ -32,9 +65,12 @@ export function blockedBashReason(command: string): string {
32
65
  "blocked because it can run arbitrary commands. Run the inner " +
33
66
  "command directly instead." + suffix;
34
67
  case "redirect":
35
- return "Ask mode: output redirection is blocked — ask mode cannot " +
36
- "write files, not even temporary ones. Use the read/grep/find/ls " +
37
- "tools to inspect files instead." + suffix;
68
+ return "Ask mode: output redirection is blocked — ask mode does not " +
69
+ "write project files. " +
70
+ (options.scratch === false
71
+ ? "This session has no scratch space either; inspect files with the read/grep/find/ls tools."
72
+ : "Keep throwaway output as a note with `scratchpad` instead, and inspect files with the read/grep/find/ls tools.") +
73
+ suffix;
38
74
  case "exec":
39
75
  return "Ask mode: this command can run another program (git grep -O, " +
40
76
  "git --ext-diff, find -exec/-ok, fd -x, rg --pre, env CMD). Only " +
@@ -42,27 +78,36 @@ export function blockedBashReason(command: string): string {
42
78
  "command directly instead." + suffix;
43
79
  case "write":
44
80
  return "Ask mode: this command writes to disk (curl -o/-O, wget -O, " +
45
- "dd of=, tee, sed -i, git --output, ...). Nothing can be written in " +
46
- "ask mode. To read a URL without saving it use `curl -s <url>`; to " +
47
- "read files use the `read` tool." + suffix;
81
+ "dd of=, tee, sed -i, git --output, ...). " +
82
+ (options.scratch === false
83
+ ? "There is no writable place in this session."
84
+ : "The only writable place is `scratchpad`, and it is written by the tool, not by bash.") +
85
+ " To read a URL without saving it use `curl -s <url>`; to read files " +
86
+ "use the `read` tool." +
87
+ suffix;
48
88
  case "download":
49
89
  return "Ask mode: downloads to disk are blocked. Use `curl -s <url>` " +
50
- "or `wget -O - <url>` to read a URL to stdout instead." + suffix;
90
+ "or `wget -O - <url>` to read a URL to stdout instead" +
91
+ (options.scratch === false ? "." : ", and keep notes with `scratchpad`.") +
92
+ suffix;
51
93
  case "git":
52
94
  return "Ask mode: only read-only git commands are allowed (status, " +
53
95
  "log, diff, show, blame, grep, rev-parse, describe, shortlog, " +
54
96
  "reflog show, stash list, tag -l, cat-file). To change the " +
55
- "repository, ask the user to exit ask mode with /ask." + suffix;
97
+ "repository, ask the operator to exit ask mode with /ask." + suffix;
56
98
  case "install":
57
99
  return "Ask mode: install/build commands are blocked. Read-only " +
58
100
  "package queries (npm view, pnpm view, pip show/list) are allowed; " +
59
- "to actually install or build, ask the user to exit ask mode with " +
101
+ "to actually install or build, ask the operator to exit ask mode with " +
60
102
  "/ask." + suffix;
61
103
  default:
62
104
  return "Ask mode: this command is not on the read-only allowlist " +
63
105
  "(or is classified as destructive), so it was not executed — it may " +
64
106
  "itself be read-only. Do not retry variants: use the " +
65
- "read/grep/find/ls tools, a recognised read-only bash command, or " +
66
- "tell the user the command is blocked." + suffix;
107
+ "read/grep/find/ls tools" +
108
+ (options.scratch === false
109
+ ? ", a recognised read-only bash command, or tell the operator the command is blocked."
110
+ : ", `scratchpad` for notes, a recognised read-only bash command, or tell the operator the command is blocked.") +
111
+ suffix;
67
112
  }
68
113
  }