summaryrefslogtreecommitdiff
path: root/pi/agent/extensions/ask-mode
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-20 23:12:49 +0200
committerPaul Buetow <paul@buetow.org>2026-03-20 23:12:49 +0200
commite2b7e0a4d85974f2dc1042f3398671fc4bfdd635 (patch)
tree37907323655aa5d3922e61f39ae74f0c280f3ccd /pi/agent/extensions/ask-mode
parent129dcd81dd1a929b03ba88ad8bc2b852fefb39eb (diff)
mode on this
Diffstat (limited to 'pi/agent/extensions/ask-mode')
-rw-r--r--pi/agent/extensions/ask-mode/README.md84
-rw-r--r--pi/agent/extensions/ask-mode/index.ts183
-rw-r--r--pi/agent/extensions/ask-mode/utils.ts94
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;
+}