/** * Huddle Extension * * Safe exploration mode with permission gates for file modifications. * Read-only by default; writes require user approval. * * Features: * - /huddle, /holup, or /plan commands to toggle * - Ctrl+H shortcut to toggle * - Bash restricted to allowlisted commands (others prompt for permission) * - edit/write tools prompt for permission during huddle mode * - gather_input tool for structured elicitation during planning */ 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 { Type } from "@sinclair/typebox"; import { GatherInputDialog, type GatherInputDialogResult } from "./lib/gather-input-dialog.js"; import { PermissionDialog, type PermissionDialogResult } from "./lib/permission-dialog.js"; import { isSafeCommand } from "./lib/utils.js"; // Tools to exclude from active tools while huddle is enabled. // // IMPORTANT: This is an EXCLUSION list, not an allowlist. In huddle mode, // edit/write/bash remain available — they are gated via permission dialog // in the `tool_call` hook below, not by tool-list filtering. Other extensions // frequently register tools at runtime (web_search, web_fetch, image-gen, // etc.); using an allowlist here would silently remove them when the user // toggles huddle, which we explicitly do NOT want. // // Add a tool name here only if it should be completely hidden from the model // while huddle is active (e.g., a destructive integration that has no // permission gate). Default: empty — permission gates handle safety. const HUDDLE_EXCLUDED_TOOLS: string[] = []; export default function huddleExtension(pi: ExtensionAPI): void { let huddleEnabled = false; // Snapshot of active tools captured when huddle was enabled, so we can // faithfully restore on exit without clobbering tools registered by other // extensions. Null when huddle is off or when no filtering was applied. let priorActiveTools: string[] | null = null; function applyHuddleTools(): void { if (HUDDLE_EXCLUDED_TOOLS.length === 0) { // Nothing to exclude — leave active tools untouched so other extensions // (web search, etc.) keep working. priorActiveTools = null; return; } priorActiveTools = pi.getActiveTools(); const filtered = priorActiveTools.filter((t) => !HUDDLE_EXCLUDED_TOOLS.includes(t)); pi.setActiveTools(filtered); } function restoreNormalTools(): void { if (priorActiveTools !== null) { pi.setActiveTools(priorActiveTools); priorActiveTools = null; } } pi.registerFlag("huddle", { description: "Start in huddle mode (read-only exploration)", type: "boolean", default: false, }); pi.registerFlag("plan", { description: "Start in huddle mode (alias for --huddle)", type: "boolean", default: false, }); function updateStatus(ctx: ExtensionContext): void { if (huddleEnabled) { ctx.ui.setStatus("huddle", ctx.ui.theme.fg("warning", "⏸ huddle")); } else { ctx.ui.setStatus("huddle", undefined); } ctx.ui.setWidget("plan-todos", undefined); } function toggleHuddle(ctx: ExtensionContext): void { huddleEnabled = !huddleEnabled; if (huddleEnabled) { applyHuddleTools(); ctx.ui.notify(`Huddle mode enabled. Read freely. Edits/writes will prompt for approval.`); } else { restoreNormalTools(); ctx.ui.notify("Huddle mode disabled. Full access restored."); } updateStatus(ctx); } // Primary command pi.registerCommand("huddle", { description: "Toggle huddle mode (read-only exploration + structured elicitation)", handler: async (_args, ctx) => toggleHuddle(ctx), }); // Aliases pi.registerCommand("holup", { description: "Toggle huddle mode (alias for /huddle)", handler: async (_args, ctx) => toggleHuddle(ctx), }); pi.registerCommand("plan", { description: "Toggle huddle mode (alias for /huddle)", handler: async (_args, ctx) => toggleHuddle(ctx), }); pi.registerShortcut("ctrl+h", { description: "Toggle huddle mode", handler: async (ctx) => toggleHuddle(ctx), }); // Gather Input tool - structured elicitation pi.registerTool({ name: "gather_input", label: "Gather Input", description: `Ask the user one to four structured questions before acting. Use for: - Gathering preferences or requirements - Clarifying ambiguous instructions - Choosing between implementation approaches Usage: - Each option needs a short label and a description; mark a recommended choice with "(Recommended)" - Set multiSelect true only when several options can be chosen at once - Users may also type a freeform answer Do not use this tool to request plan approval; in huddle mode the user approves each tool call via the inline permission dialog.`, parameters: Type.Object({ questions: Type.Array( Type.Object({ question: Type.String({ description: "The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: 'Which library should we use for date formatting?' If multiSelect is true, phrase it accordingly, e.g. 'Which features do you want to enable?'", }), header: Type.String({ description: "Very short label displayed as a chip/tag (max 12 chars). Examples: 'Auth method', 'Library', 'Approach'.", }), options: Type.Array( Type.Object({ label: Type.String({ description: "The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.", }), description: Type.String({ description: "Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.", }), markdown: Type.Optional(Type.String({ description: "Optional preview content shown in a monospace box when this option is focused. Use for ASCII mockups, code snippets, or diagrams that help users visually compare options. Supports multi-line text with newlines.", })), }), { minItems: 2, maxItems: 4, } ), multiSelect: Type.Boolean({ default: false, description: "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.", }), }), { minItems: 1, maxItems: 4, description: "Questions to ask the user (1-4 questions)", } ), metadata: Type.Optional(Type.Object({ source: Type.Optional(Type.String({ description: "Optional identifier for the source of this question (e.g., 'remember' for /remember command). Used for analytics tracking.", })), }, { description: "Optional metadata for tracking and analytics purposes. Not displayed to user.", })), }), execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => { const { questions, metadata } = params; const result = await ctx.ui.custom( (tui, theme, _kb, done) => { const dialog = new GatherInputDialog(questions, theme); dialog.onDone = (r) => done(r); return { get focused() { return dialog.focused; }, set focused(v: boolean) { dialog.focused = v; }, render: (w: number) => dialog.render(w), invalidate: () => dialog.invalidate(), handleInput: (data: string) => { dialog.handleInput(data); tui.requestRender(); }, }; }, ); // Cancelled (Esc) if (!result) { return { content: [{ type: "text", text: "User cancelled the question." }], details: { answers: {}, annotations: {}, metadata }, }; } // "Chat about this" if ("chatMode" in result) { return { content: [{ type: "text", text: "The user selected 'Chat about this'. They want to discuss the options before deciding. Respond conversationally." }], details: { chatMode: true, metadata }, }; } // Normal submission const summary = Object.entries(result.answers) .map(([q, a]) => `- ${q}\n → ${a}`) .join("\n"); return { content: [{ type: "text", text: `User answers:\n${summary}` }], details: { ...result, metadata }, }; }, }); // Permission gate for blocked operations in huddle mode pi.on("tool_call", async (event, ctx) => { if (!huddleEnabled) return; const toolName = event.toolName; if (toolName === "write" || toolName === "edit") { const path = event.input.path || event.input.file || "unknown"; const theme = ctx.ui.theme; const title = `${theme.fg("warning", theme.bold("⚠ Huddle Mode"))} — ${theme.fg("accent", toolName)}: ${theme.fg("accent", path)}`; const result = await ctx.ui.custom( (tui, t, _kb, done) => { const dialog = new PermissionDialog(title, undefined, t); dialog.onDone = (r) => done(r); return { get focused() { return dialog.focused; }, set focused(v: boolean) { dialog.focused = v; }, render: (w: number) => dialog.render(w), invalidate: () => dialog.invalidate(), handleInput: (data: string) => { dialog.handleInput(data); tui.requestRender(); }, }; }, ); // Cancelled (Esc) - treat as deny if (!result) { return { block: true, reason: `User cancelled permission prompt for ${toolName} on "${path}" in huddle mode` }; } if (result.allowed) return; const reason = result.feedback ? `User denied ${toolName} on "${path}". Feedback from user: ${result.feedback}` : `User denied ${toolName} on "${path}" in huddle mode (no feedback provided)`; return { block: true, reason }; } if (toolName === "bash") { const command = event.input.command as string; if (!isSafeCommand(command)) { const theme = ctx.ui.theme; const title = `${theme.fg("warning", theme.bold("⚠ Huddle Mode"))} — ${theme.fg("accent", command)}`; const result = await ctx.ui.custom( (tui, t, _kb, done) => { const dialog = new PermissionDialog(title, undefined, t); dialog.onDone = (r) => done(r); return { get focused() { return dialog.focused; }, set focused(v: boolean) { dialog.focused = v; }, render: (w: number) => dialog.render(w), invalidate: () => dialog.invalidate(), handleInput: (data: string) => { dialog.handleInput(data); tui.requestRender(); }, }; }, ); // Cancelled (Esc) - treat as deny if (!result) { return { block: true, reason: `User cancelled permission prompt for bash command in huddle mode: \`${command}\`` }; } if (result.allowed) return; const reason = result.feedback ? `User denied bash command \`${command}\`. Feedback from user: ${result.feedback}` : `User denied bash command \`${command}\` in huddle mode (no feedback provided)`; return { block: true, reason }; } } }); // Filter out stale huddle context when not in huddle mode pi.on("context", async (event) => { if (huddleEnabled) return; return { messages: event.messages.filter((m) => { const msg = m as AgentMessage & { customType?: string }; if (msg.customType === "huddle-context") return false; if (msg.role !== "user") return true; const content = msg.content; if (typeof content === "string") { return !content.includes("[HUDDLE MODE ACTIVE]"); } if (Array.isArray(content)) { return !content.some( (c) => c.type === "text" && (c as TextContent).text?.includes("[HUDDLE MODE ACTIVE]"), ); } return true; }), }; }); // Inject huddle context before agent starts pi.on("before_agent_start", async () => { if (huddleEnabled) { return { message: { customType: "huddle-context", content: `[HUDDLE MODE ACTIVE] You are in huddle mode. All tools are available. The only difference from normal mode is that write operations trigger an inline permission dialog for the user instead of executing immediately. NEVER tell the user to exit huddle mode. They are in huddle mode because they WANT to be asked before each change. Just use the tools you need and let the permission gates handle the asking. When you need to make a change: 1. Just call edit or write as you normally would 2. The permission gate will show the user an [Allow] / [Deny] prompt 3. The user approves or denies inline — no mode switching needed When you need to run a non-allowlisted bash command: 1. Just run it (e.g., npm install, git commit) 2. The user gets a prompt to approve or deny 3. Proceed based on their response Available Tools: - All normally-active tools remain available (read, bash, edit, write, gather_input, plus any tools registered by other extensions such as web_search, web_fetch, etc.). - The only difference: edit/write and non-allowlisted bash commands trigger a permission dialog before running. Safe Bash Commands (no prompt): cat, cd, rg, fd, grep, head, tail, ls, find, git status/log/diff/branch, npm list Other bash commands will prompt for permission. Use the gather_input tool for structured elicitation — gathering requirements, clarifying ambiguity, and getting decisions from the user. Prior-session retrieval guidance: - If you already have a specific session file path, use the session_query tool. - If you need to discover likely session files first, use safe bash search over ~/.pi/agent/sessions: - fd session.jsonl ~/.pi/agent/sessions - rg -n "keyword" ~/.pi/agent/sessions - optionally narrow/inspect with fzf, head, tail, bat. - Prefer targeted lookups over loading huge files into context. You can create a plan AND execute it. The user controls approval per-step via the permission gates, not by toggling modes.`, display: false, }, }; } }); // Restore state on session start/resume pi.on("session_start", async (_event, ctx) => { if (pi.getFlag("huddle") === true || pi.getFlag("plan") === true) { huddleEnabled = true; } const entries = ctx.sessionManager.getEntries(); const huddleEntry = entries .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "huddle") .pop() as { data?: { enabled: boolean } } | undefined; if (huddleEntry?.data) { huddleEnabled = huddleEntry.data.enabled ?? huddleEnabled; } if (huddleEnabled) { applyHuddleTools(); } updateStatus(ctx); }); }