ai: add ask_user tool extension
Assisted-by: GLM-5.3 via pi
diff --git a/ai/pi/extensions/README.md b/ai/pi/extensions/README.md
index 439f7063..dc08ef67 100644
--- a/ai/pi/extensions/README.md
+++ b/ai/pi/extensions/README.md
@@ -27,6 +27,24 @@ pi has no built-in permission prompt, so `permission-gate.ts` emits
`agent-state:blocked` events on the shared extension event bus around its
dialogs, and this extension records `blocked` for their duration.
+# Ask User
+
+Gives the agent an `ask_user` tool: a question shows as a dialog mid-turn,
+and the answer returns as the tool result, so the agent keeps going instead
+of ending its turn and waiting for a chat reply.
+
+## How it works
+
+Built on the `ctx.ui` dialogs rather than a custom component, so it works
+in the TUI and over RPC. With `options`, the user picks from a list with
+an "Other…" escape hatch that opens a text input; without, the user types
+a free-form answer. Dismissing either dialog cancels the question and the
+tool reports that the user didn't answer. In headless modes (JSON, print)
+the tool returns an error telling the agent to ask in its reply instead.
+
+`permission-gate.ts` allowlists `ask_user` — gating a question behind a
+confirmation dialog would just be a second question.
+
# Permission Gate
Requires explicit user confirmation before every tool call, with
@@ -40,7 +58,8 @@ directories, and pi documentation.
**Bash tool** — auto-allowed only if the command matches the allowlist
and contains no shell chaining (`|`, `&&`, `;`, `` ` ``, `$()`).
-Everything else prompts for confirmation.
+Everything else prompts for confirmation, except `ask_user` (dialog-only,
+no side effects).
## Base Allowlist
diff --git a/ai/pi/extensions/ask-user.ts b/ai/pi/extensions/ask-user.ts
new file mode 100644
index 00000000..f3e6971d
--- /dev/null
+++ b/ai/pi/extensions/ask-user.ts
@@ -0,0 +1,135 @@
+/**
+ * ask_user - let the agent ask the user a question mid-turn.
+ *
+ * The question appears as a dialog instead of ending the turn in chat, so
+ * the answer comes back as a tool result and the agent continues where it
+ * left off. With options, the user picks from a list (with an "Other…"
+ * escape hatch to type instead); without, the user types a free-form reply.
+ *
+ * Uses the built-in ctx.ui dialogs rather than a custom component, so it
+ * also works over RPC (headless modes get an error result instead).
+ */
+
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { Text } from "@earendil-works/pi-tui";
+import { Type } from "typebox";
+
+interface AskUserDetails {
+ question: string;
+ options: string[];
+ answer: string | null;
+ typed?: boolean;
+}
+
+const OTHER = "Other…";
+
+export default function askUser(pi: ExtensionAPI) {
+ pi.registerTool({
+ name: "ask_user",
+ label: "Ask user",
+ description:
+ "Ask the user a question and wait for their answer. Use whenever you " +
+ "need information or a decision from the user before continuing; the " +
+ "answer returns as the tool result and you keep going in the same " +
+ "turn. Pass options to offer a choice (the user can still type a " +
+ "different answer); omit them for a free-text answer. Prefer this " +
+ "over ending your turn with a question in plain text.",
+ parameters: Type.Object({
+ question: Type.String({ description: "The question to ask" }),
+ options: Type.Optional(
+ Type.Array(Type.String(), {
+ description: "Choices to offer; omit for a free-text answer",
+ }),
+ ),
+ }),
+ executionMode: "sequential",
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
+
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
+ const details = (answer: string | null, typed = false): AskUserDetails => ({
+ question: params.question,
+ options: params.options ?? [],
+ answer,
+ typed,
+ });
+
+ if (!ctx.hasUI) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: "Error: ask_user has no UI in this mode. Ask the question in your reply instead.",
+ },
+ ],
+ isError: true,
+ details: details(null),
+ };
+ }
+
+ // Free-form question: go straight to the input dialog.
+ if (!params.options || params.options.length === 0) {
+ const answer = await ctx.ui.input(params.question, "Your answer…", { signal });
+ if (answer === undefined) {
+ return {
+ content: [{ type: "text", text: "User dismissed the question without answering." }],
+ details: details(null, true),
+ };
+ }
+ return {
+ content: [{ type: "text", text: `User answered: ${answer}` }],
+ details: details(answer, true),
+ };
+ }
+
+ const labels = params.options.filter((o) => o !== OTHER);
+ const choice = await ctx.ui.select(params.question, [...labels, OTHER], { signal });
+ if (choice === undefined) {
+ return {
+ content: [{ type: "text", text: "User dismissed the question without answering." }],
+ details: details(null),
+ };
+ }
+
+ if (choice === OTHER) {
+ const answer = await ctx.ui.input(params.question, "Your answer…", { signal });
+ if (answer === undefined) {
+ return {
+ content: [{ type: "text", text: "User dismissed the question without answering." }],
+ details: details(null, true),
+ };
+ }
+ return {
+ content: [{ type: "text", text: `User answered: ${answer}` }],
+ details: details(answer, true),
+ };
+ }
+
+ return {
+ content: [{ type: "text", text: `User selected: ${choice}` }],
+ details: details(choice),
+ };
+ },
+
+ renderCall(args, theme) {
+ let text = theme.fg("toolTitle", theme.bold("ask_user ")) + theme.fg("muted", args.question);
+ if (args.options?.length) {
+ const numbered = [...args.options, OTHER].map((o, i) => `${i + 1}. ${o}`);
+ text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
+ }
+ return new Text(text, 0, 0);
+ },
+
+ renderResult(result, _options, theme) {
+ const details = result.details as AskUserDetails | undefined;
+ if (!details) {
+ const text = result.content[0];
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
+ }
+ if (details.answer === null) {
+ return new Text(theme.fg("warning", "Dismissed"), 0, 0);
+ }
+ const prefix = details.typed ? "✓ (typed) " : "✓ ";
+ return new Text(theme.fg("success", prefix) + theme.fg("accent", details.answer), 0, 0);
+ },
+ });
+}
diff --git a/ai/pi/extensions/permission-gate.ts b/ai/pi/extensions/permission-gate.ts
index 3c350d7f..fbb7d0dc 100644
--- a/ai/pi/extensions/permission-gate.ts
+++ b/ai/pi/extensions/permission-gate.ts
@@ -280,6 +280,9 @@ function isTrackedFile(filePath: string, ctx: ExtensionContext): boolean {
}
function isAllowed(toolName: string, input: Record<string, unknown>, ctx: ExtensionContext): boolean {
+ // Dialog-only, no side effects; prompting to ask a prompt would be absurd.
+ if (toolName === "ask_user") return true;
+
if (toolName === "read") {
const filePath = path.resolve(ctx.cwd, String(input.path ?? ""));
// Resolve symlinks so a tracked symlink pointing outside the repo