diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-20 23:12:49 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-20 23:12:49 +0200 |
| commit | e2b7e0a4d85974f2dc1042f3398671fc4bfdd635 (patch) | |
| tree | 37907323655aa5d3922e61f39ae74f0c280f3ccd /pi/agent/extensions/ask-mode | |
| parent | 129dcd81dd1a929b03ba88ad8bc2b852fefb39eb (diff) | |
mode on this
Diffstat (limited to 'pi/agent/extensions/ask-mode')
| -rw-r--r-- | pi/agent/extensions/ask-mode/README.md | 84 | ||||
| -rw-r--r-- | pi/agent/extensions/ask-mode/index.ts | 183 | ||||
| -rw-r--r-- | pi/agent/extensions/ask-mode/utils.ts | 94 |
3 files changed, 361 insertions, 0 deletions
diff --git a/pi/agent/extensions/ask-mode/README.md b/pi/agent/extensions/ask-mode/README.md new file mode 100644 index 0000000..2c0d17c --- /dev/null +++ b/pi/agent/extensions/ask-mode/README.md @@ -0,0 +1,84 @@ +# Ask Mode + +Exploration-only mode for Pi. + +This extension adds a session-scoped `/ask` mode that turns Pi into a read-only +investigation assistant. It is meant for understanding a codebase, debugging, +reading logs, or answering questions without making changes. + +## What It Does + +- `/ask` enters ask mode +- `/ask <prompt>` enters ask mode and immediately sends the prompt +- `/ask-exit` leaves ask mode +- `/ask-status` shows whether ask mode is active +- limits tools to `read`, `bash`, `grep`, `find`, and `ls` +- blocks unsafe bash commands even though `bash` stays enabled +- injects per-turn instructions telling the model to inspect and explain, not implement + +## Usage Flows + +### Flow 1: Enter ask mode first, then explore + +```text +/ask +``` + +Then ask questions naturally: + +```text +Why does VM2 fail to reach readiness on the first create attempt? +``` + +### Flow 2: Enter ask mode and ask immediately + +```text +/ask Compare the fresh-subagent extension behavior with what the README claims. +``` + +### Flow 3: Leave ask mode + +```text +/ask-exit +``` + +That restores the previously active tool set. + +### Flow 4: Check whether you are still in ask mode + +```text +/ask-status +``` + +## Safety Model + +Ask mode is meant for exploration only. + +- `edit` and `write` are removed from the active tool set +- custom tools outside the ask-mode allowlist are blocked +- `bash` remains available, but only for safe read-only commands + +Examples of the kind of bash commands ask mode allows: + +- `rg foo src` +- `git diff` +- `ls -la` +- `sed -n '1,120p' file` +- `curl http://host/...` + +Examples it blocks: + +- `rm` +- `touch` +- `mkdir` +- `git commit` +- `npm install` +- `sudo ...` +- shell redirection that writes files + +## Notes And Limits + +- This is session-scoped and restores on resume if the session was left in ask mode. +- It is intended for investigation, not planning or implementation. +- If you ask for a change while ask mode is active, Pi should explain what would + need to change instead of making the change. diff --git a/pi/agent/extensions/ask-mode/index.ts b/pi/agent/extensions/ask-mode/index.ts new file mode 100644 index 0000000..4f19815 --- /dev/null +++ b/pi/agent/extensions/ask-mode/index.ts @@ -0,0 +1,183 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { TextContent } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { isSafeAskModeCommand } from "./utils.js"; + +const ASK_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"]; +const STATE_TYPE = "ask-mode"; +const CONTEXT_TYPE = "ask-mode-context"; + +interface AskModeState { + enabled: boolean; + normalTools: string[]; +} + +function hasAskModeMarker(message: AgentMessage): boolean { + const customMessage = message as AgentMessage & { customType?: string }; + if (customMessage.customType === CONTEXT_TYPE) return true; + + if (message.role !== "user") return false; + if (typeof message.content === "string") return message.content.includes("[ASK MODE ACTIVE]"); + if (!Array.isArray(message.content)) return false; + + return message.content.some( + (block) => block.type === "text" && (block as TextContent).text?.includes("[ASK MODE ACTIVE]"), + ); +} + +export default function askModeExtension(pi: ExtensionAPI): void { + let askModeEnabled = false; + let normalTools: string[] = []; + + function persistState(): void { + pi.appendEntry<AskModeState>(STATE_TYPE, { + enabled: askModeEnabled, + normalTools, + }); + } + + function updateStatus(ctx: ExtensionContext): void { + if (!askModeEnabled) { + ctx.ui.setStatus("ask-mode", undefined); + ctx.ui.setWidget("ask-mode", undefined); + return; + } + + ctx.ui.setStatus("ask-mode", ctx.ui.theme.fg("warning", "⏸ ask")); + ctx.ui.setWidget("ask-mode", [ + ctx.ui.theme.fg("warning", "Ask mode"), + "Exploration only", + "Files are read-only", + "Bash is restricted to safe read-only commands", + ]); + } + + function enterAskMode(ctx: ExtensionContext): void { + if (askModeEnabled) { + updateStatus(ctx); + return; + } + + normalTools = pi.getActiveTools(); + askModeEnabled = true; + pi.setActiveTools(ASK_MODE_TOOLS); + ctx.ui.notify(`Ask mode enabled. Tools: ${ASK_MODE_TOOLS.join(", ")}`, "info"); + updateStatus(ctx); + persistState(); + } + + function exitAskMode(ctx: ExtensionContext): void { + if (!askModeEnabled) { + ctx.ui.notify("Ask mode is not active.", "info"); + updateStatus(ctx); + return; + } + + askModeEnabled = false; + pi.setActiveTools(normalTools.length > 0 ? normalTools : ["read", "bash", "edit", "write"]); + ctx.ui.notify("Ask mode disabled. Previous tools restored.", "info"); + updateStatus(ctx); + persistState(); + } + + pi.registerCommand("ask", { + description: "Enter ask mode for exploration-only work. Optional prompt sends a question immediately.", + handler: async (args, ctx) => { + const prompt = args.trim(); + enterAskMode(ctx); + if (prompt) { + pi.sendUserMessage(prompt); + if (!ctx.hasUI) { + await ctx.waitForIdle(); + } + } + }, + }); + + pi.registerCommand("ask-exit", { + description: "Leave ask mode and restore the previous tool set", + handler: async (_args, ctx) => exitAskMode(ctx), + }); + + pi.registerCommand("ask-status", { + description: "Show whether ask mode is active", + handler: async (_args, ctx) => { + const message = askModeEnabled + ? `Ask mode active. Tools: ${ASK_MODE_TOOLS.join(", ")}` + : "Ask mode is not active."; + if (!ctx.hasUI) { + process.stdout.write(`${message}\n`); + return; + } + ctx.ui.notify(message, "info"); + }, + }); + + pi.on("tool_call", async (event) => { + if (!askModeEnabled) return; + + if (!ASK_MODE_TOOLS.includes(event.toolName)) { + return { + block: true, + reason: `Ask mode: tool "${event.toolName}" is disabled. Use /ask-exit before modifying files or using other tools.`, + }; + } + + if (event.toolName === "bash") { + const command = String(event.input.command ?? ""); + if (!isSafeAskModeCommand(command)) { + return { + block: true, + reason: `Ask mode: bash command blocked (not recognized as safe read-only exploration).\nCommand: ${command}`, + }; + } + } + }); + + pi.on("context", async (event) => { + if (askModeEnabled) return; + return { + messages: event.messages.filter((message) => !hasAskModeMarker(message as AgentMessage)), + }; + }); + + pi.on("before_agent_start", async () => { + if (!askModeEnabled) return; + + return { + message: { + customType: CONTEXT_TYPE, + content: `[ASK MODE ACTIVE] +You are in ask mode: exploration only. + +Rules: +- Do not modify files. +- Do not use edit or write tools. +- Use read, grep, find, ls, and only safe read-only bash commands. +- Inspect, explain, compare, summarize, and answer questions. +- If a requested action would require a file change, say so explicitly instead of doing it. + +Focus on observation and analysis, not implementation.`, + display: false, + }, + }; + }); + + pi.on("session_start", async (_event, ctx) => { + const entries = ctx.sessionManager.getEntries(); + const latestState = entries + .filter((entry: { type: string; customType?: string }) => entry.type === "custom" && entry.customType === STATE_TYPE) + .pop() as { data?: AskModeState } | undefined; + + if (latestState?.data) { + askModeEnabled = latestState.data.enabled ?? askModeEnabled; + normalTools = latestState.data.normalTools ?? normalTools; + } + + if (askModeEnabled) { + pi.setActiveTools(ASK_MODE_TOOLS); + } + + updateStatus(ctx); + }); +} diff --git a/pi/agent/extensions/ask-mode/utils.ts b/pi/agent/extensions/ask-mode/utils.ts new file mode 100644 index 0000000..db8c889 --- /dev/null +++ b/pi/agent/extensions/ask-mode/utils.ts @@ -0,0 +1,94 @@ +const DESTRUCTIVE_PATTERNS = [ + /\brm\b/i, + /\brmdir\b/i, + /\bmv\b/i, + /\bcp\b/i, + /\bmkdir\b/i, + /\btouch\b/i, + /\bchmod\b/i, + /\bchown\b/i, + /\bchgrp\b/i, + /\bln\b/i, + /\btee\b/i, + /\btruncate\b/i, + /\bdd\b/i, + /\bshred\b/i, + /(^|[^<])>(?!>)/, + />>/, + /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, + /\byarn\s+(add|remove|install|publish)/i, + /\bpnpm\s+(add|remove|install|publish)/i, + /\bpip\s+(install|uninstall)/i, + /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, + /\bbrew\s+(install|uninstall|upgrade)/i, + /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, + /\bsudo\b/i, + /\bsu\b/i, + /\bkill\b/i, + /\bpkill\b/i, + /\bkillall\b/i, + /\breboot\b/i, + /\bshutdown\b/i, + /\bsystemctl\s+(start|stop|restart|enable|disable)/i, + /\bservice\s+\S+\s+(start|stop|restart)/i, + /\b(vim?|nano|emacs|code|subl)\b/i, +]; + +const SAFE_PATTERNS = [ + /^\s*cat\b/, + /^\s*head\b/, + /^\s*tail\b/, + /^\s*less\b/, + /^\s*more\b/, + /^\s*grep\b/, + /^\s*find\b/, + /^\s*ls\b/, + /^\s*pwd\b/, + /^\s*echo\b/, + /^\s*printf\b/, + /^\s*wc\b/, + /^\s*sort\b/, + /^\s*uniq\b/, + /^\s*diff\b/, + /^\s*file\b/, + /^\s*stat\b/, + /^\s*du\b/, + /^\s*df\b/, + /^\s*tree\b/, + /^\s*which\b/, + /^\s*whereis\b/, + /^\s*type\b/, + /^\s*env\b/, + /^\s*printenv\b/, + /^\s*uname\b/, + /^\s*whoami\b/, + /^\s*id\b/, + /^\s*date\b/, + /^\s*cal\b/, + /^\s*uptime\b/, + /^\s*ps\b/, + /^\s*top\b/, + /^\s*htop\b/, + /^\s*free\b/, + /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, + /^\s*git\s+ls-/i, + /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, + /^\s*yarn\s+(list|info|why|audit)/i, + /^\s*node\s+--version/i, + /^\s*python\s+--version/i, + /^\s*curl\s/i, + /^\s*wget\s+-O\s*-/i, + /^\s*jq\b/, + /^\s*sed\s+-n/i, + /^\s*awk\b/, + /^\s*rg\b/, + /^\s*fd\b/, + /^\s*bat\b/, + /^\s*exa\b/, +]; + +export function isSafeAskModeCommand(command: string): boolean { + const isDestructive = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command)); + const isSafe = SAFE_PATTERNS.some((pattern) => pattern.test(command)); + return !isDestructive && isSafe; +} |
