Presents a categorised preview of pending actions to the user and waits for their selection. Use when a SKILL.md step says 'summarise and confirm', 'present a consolidated preview', or 'ask the user which categories to proceed with'.\n\nIMPORTANT — this is a user-wait GATE, not a regular tool. G4 bypasses the 60 s TOOL_TIMEOUT_MS ceiling for this call and instead races the user's submission against USER_ACK_TIMEOUT_MS (default 15 min). Returns { selected: string[] } — the category ids the user kept checked. Empty array means cancel / dismiss / timeout / zero items.
/** * mcp/skills/presentPreview.ts — present_preview synthetic tool * * Like wait_for_user_ack, this is NOT a normal tool — it is a registration * shim for a first-class G4 gate (the "present-preview" gate) routed * specially in electron/agent/guards/execution.ts:executeStep(). * * Why a tool at all? * ------------------ * Agent plans reference tools by name. Registering present_preview in the * MCP tool registry lets the planner emit it as a plan step and the * execution LLM see its Zod schema (so it passes structured params like * { title, summary, categories }). But the actual `run()` below is never * invoked — G4 detects `meta.isUserWaitGate: true` and routes the step * through runPresentPreviewGate() instead of the normal tool-execution * pipeline. * * Why a dedicated gate (not a blocking run())? * -------------------------------------------- * electron/agent/guards/execution.ts wraps every tool invocation in * Promise.race([skillPromise, timeoutPromise]) against TOOL_TIMEOUT_MS * (default 60 s). A user reading a multi-category preview and deciding * which boxes to keep checked can take minutes — a blocking tool run() * would be force-killed at 60 s and the user's eventual click would be * orphaned. The present-preview gate runs OUTSIDE that Promise.race with * its own USER_ACK_TIMEOUT_MS (default 15 min), resolving cleanly on * submission or on gate timeout. * * When to use * ----------- * Use this in skills with the synthesis-then-confirm pattern: aggregate * findings from multiple diagnostic / dry-run steps, present a consolidated * multi-select preview, run only the categories the user keeps checked. * * Two variants: * - Aggregate-then-confirm (typical) — categories' sizes / item counts / * availability come from prior step outputs. The executor LLM composes * the dynamic numbers from scratchpad at runtime; the planner emits * the static structure (ids, labels, defaultSelected, destructive flags). * - Static-option picker (rare) — categories are pre-defined choices that * don't depend on prior diagnostics. The planner emits the full * structure into Args; no scratchpad reads needed. * * When NOT to use * --------------- * - Single destructive tool needing confirmation → use the existing G4 * consent gate (set `meta.requiresConsent: true` on the tool itself). * - Single acknowledgment with no choice ("press OK to continue") → use * `wait_for_user_ack`. * - Routing disambiguation ("did you mean X or Y skill?") → use the * Skill Router's clarification flow. * * Schema overview * --------------- * Top-level (required): title, summary, categories[] (min 1) * Per category (required): id (stable kebab-case), label, summary * Per category (optional): detail, defaultSelected (defaults true), * destructive (defaults false) * * Returns * ------- * { selected: string[] } * * The string array contains the category ids the user kept checked. * Empty array means cancel / dismiss / timeout / no items checked. * * SKILL.md authors reference this in `inputsFrom: [{ step: N, field: "selected" }]` * for downstream corrective steps, typically with `When:` clauses * (`only if "<id>" is in <preview-step>'s selected`) or `forEach` * (`source: "user-confirmation"`). The category ids on Step N are exactly * the ones authored in params.categories[].id at plan time — stable * kebab-case, NOT derived from labels. * * Category `id` convention: short kebab-case, stable across edits, domain- * meaningful (not slug-of-label), unique within one call. Subsequent steps * reference these ids via `inputsFrom` / `When:` clauses, so they must NOT * be derived from `label` — label edits or localization would silently * break cross-step references. * * Authoring convention in SKILL.md * -------------------------------- * The author writes the step body as TWO visually distinct blocks: * 1. A ```yaml fenced code block carrying the tool-schema fields * (mirrors the Zod schema 1:1). The planner extracts this as `Args:` * on the plan step. * 2. A "Data lineage:" prose bullet list mapping `{placeholder}` tokens * to prior-step outputs. The planner extracts this as `Inputs:` on * the plan step (inputsFrom). * * End-to-end disk-cleanup example (Step 9): * * Call `present_preview` with: * * ```yaml * title: "Cleanup Plan" * summary: "You can recover {totalSize} by cleaning the following:" * categories: * - id: large-files * label: "Large files in Downloads" * summary: "{N} installer files over 50 MB ({size})" * defaultSelected: true * * - id: duplicates * label: "Duplicate photos & videos" * summary: "{N} duplicates sorted by wasted space ({size})" * defaultSelected: true * * - id: browser-cache * label: "Browser caches" * summary: "Chrome, Safari, Edge — rebuild automatically ({size})" * defaultSelected: true * * - id: app-cache * label: "App caches" * summary: "Slack, Spotify, Discord ({size})" * defaultSelected: false * * - id: dev-cache * label: "Dev caches" * summary: "Xcode DerivedData, npm cache ({size})" * destructive: true * defaultSelected: false * * - id: trash * label: "Trash" * summary: "{N} items ({size})" * defaultSelected: true * ``` * * Data lineage (one bullet per placeholder; executor LLM uses these * to substitute tokens at runtime): * * - top-level `{totalSize}` — sum of every category's size, formatted * human-readable (e.g. "8.2 GB") * * - inside large-files.summary: * - `{N}` — `output.fileCount` from Step 3 (`get_large_files`) * - `{size}` — `output.totalBytes` from Step 3, formatted * human-readable * * - inside duplicates.summary: * - `{N}` — `output.duplicateGroupCount` from Step 4 * (`find_duplicate_files`) * - `{size}` — `output.totalWastedBytes` from Step 4, formatted * human-readable * * - inside browser-cache.summary: * - `{size}` — `output.totalSizeBytes` from Step 7 * (`clear_browser_cache` dry-run), formatted human-readable * * - inside app-cache.summary: * - `{size}` — `output.totalSizeBytes` from Step 6 * (`clear_app_cache` dry-run), formatted human-readable * * - inside dev-cache.summary: * - `{size}` — `output.totalSizeBytes` from Step 8 * (`clear_dev_cache` dry-run), formatted human-readable * * - inside trash.summary: * - `{N}` — `output.itemCount` from Step 11 (`empty_trash` dry-run) * - `{size}` — `output.totalSizeBytes` from Step 11, formatted * human-readable * * How the planner translates this prose: * - The YAML block → emitted verbatim on the plan step's `Args:`. * - The "Data lineage:" bullets → emitted on `Inputs:` (one entry per * bullet, structured as `{step: N, field: "...", description: "..."}`). * - At runtime, the executor LLM reads scratchpad entries named in * `Inputs:`, substitutes the `{placeholder}` tokens in the YAML's * summary strings, then invokes the tool with fully populated values. * * Patterns for consuming `selected` * --------------------------------- * Three patterns depending on whether the consuming step is binary, * iterative, or iterative-on-selection. * * Pattern A — Binary corrective step (run-or-don't): * Step 10: clear_browser_cache * Args: {"browser": "all", "dryRun": false} * When: only if "browser-cache" is in Step 9's selected * Inputs: selected from Step 9 (present_preview) * * Pattern B — Iterative on its own list (filtered by selection): * Step 10: delete_files * When: only if "large-files" is in Step 9's selected * ForEach: each file in Step 3's get_large_files output * Inputs: selected from Step 9, files from Step 3 * * Pattern C — Iterative on selection itself (rare): * Step 10: delete_files * ForEach: each file id in Step 9's selected * Inputs: selected from Step 9 (present_preview) * * Edge cases * ---------- * - User cancels / dismisses card → tool returns { selected: [] }. * Subsequent ForEach-iterated steps no-op; When: clauses depending on * the selection evaluate false. The final summary explains the user * opted out. * - User clicks "Clean Selected" with zero items checked → same as cancel. * - USER_ACK_TIMEOUT_MS (15 min) elapses → tool returns { selected: [] }. * - destructive: true + defaultSelected: false on a category → renderer * shows ⚠ icon and warning style; user has to explicitly opt in. * * Safety net * ---------- * If G4's routing is broken or a caller invokes run() directly, the stub * below throws so the mistake is loud, not silently ignored. * * Smoke test * npx tsx -r dotenv/config mcp/skills/presentPreview.ts */import { z } from "zod";// -- Meta ---------------------------------------------------------------------export const meta = { name: "present_preview", description: "Presents a categorised preview of pending actions to the user and " + "waits for their selection. Use when a SKILL.md step says 'summarise " + "and confirm', 'present a consolidated preview', or 'ask the user " + "which categories to proceed with'.\n" + "\n" + "IMPORTANT — this is a user-wait GATE, not a regular tool. G4 bypasses " + "the 60 s TOOL_TIMEOUT_MS ceiling for this call and instead races the " + "user's submission against USER_ACK_TIMEOUT_MS (default 15 min). " + "Returns { selected: string[] } — the category ids the user kept " + "checked. Empty array means cancel / dismiss / timeout / zero items.", riskLevel: "low", destructive: false, requiresConsent: false, supportsDryRun: false, affectedScope: ["user"], auditRequired: true, /** * The routing flag. G4's executeStep reads this and dispatches to * runPresentPreviewGate() — the run() below is never invoked on the * normal path. */ isUserWaitGate: true, schema: { title: z .string() .min(1) .describe( "Short heading shown at the top of the preview card " + "(e.g. 'Cleanup Plan').", ), summary: z .string() .min(1) .describe( "One-sentence framing shown under the title; typically mentions " + "the aggregated total recovery / impact " + "(e.g. 'You can recover 8.2 GB by cleaning the following:'). " + "Author writes the shape with {placeholder} tokens; executor LLM " + "substitutes runtime numbers from scratchpad before invoking.", ), categories: z .array( z.object({ id: z .string() .min(1) .describe( "Stable kebab-case identifier returned in the gate result — " + "subsequent corrective steps reference this id via " + "inputsFrom / When: clauses. Must NOT be derived from " + "`label` (label edits or localization would silently break " + "cross-step references). Unique within one call.", ), label: z .string() .min(1) .describe( "Short user-facing name shown next to the checkbox " + "(e.g. 'Browser caches').", ), summary: z .string() .min(1) .describe( "One-line description rendered next to the label, typically " + "count + size " + "(e.g. 'Chrome, Safari, Edge — 1.2 GB' or " + "'12 installer files over 50 MB (3.4 GB)'). Author writes " + "the shape with {placeholder} tokens; executor LLM " + "substitutes runtime numbers from scratchpad.", ), detail: z .string() .optional() .describe( "Optional extra bullet text shown when the user expands the " + "category. Use sparingly.", ), bytes: z .number() .nonnegative() .optional() .describe( "Reclaimable size of this category in raw bytes (NOT a " + "formatted string). Used by G4 to decide which rows are " + "empty: any category whose `bytes` is 0 is dropped before the " + "card renders, and if every category is 0 (or the array ends " + "up empty) G4 skips the card entirely and treats the run as " + "'nothing to clean'. Omit only for previews that have no " + "meaningful size — those rows are always kept. Populate this " + "from the same source as the row's `{size}` text (e.g. " + "`output.totalBytes`), do NOT round or pre-format it.", ), bytesFromTool: z .string() .min(1) .optional() .describe( "Canonical name of the read-only diagnostic tool whose output " + "determines this category's reclaimable size (e.g. " + "'get_app_cache_info'). Authored in SKILL.md and copied " + "verbatim into the plan — the G4 gate reads it from the PLAN " + "template (not from this executor-supplied value), so it cannot " + "be stripped. The gate looks up the tool's REAL output captured " + "during the run and drops the category when its true reclaimable " + "bytes are 0. This is the deterministic, executor-independent " + "empty-row filter; prefer it over `bytes` for size-based cards. " + "Omit for non-size categories (those rows are always kept).", ), defaultSelected: z .boolean() .optional() .describe( "Whether the checkbox is checked by default. Defaults to " + "true. Set false for destructive or non-obvious categories.", ), destructive: z .boolean() .optional() .describe( "When true, the renderer shows a ⚠ icon and warning style. " + "Combine with defaultSelected: false so the user must " + "explicitly opt in.", ), }), ) .min(1) .describe( "Ordered list of categories the user can pick from. Min 1 entry.", ), },} as const;// -- Exported run function ----------------------------------------------------/** * Safety-net stub. The run() function is never invoked on the normal path — * G4's executeStep() detects meta.isUserWaitGate and routes to * runPresentPreviewGate() in electron/agent/guards/execution.ts, bypassing * the normal tool-execution pipeline. If this throws, the routing is broken. */export async function run(): Promise<never> { throw new Error( "present_preview.run() was invoked directly — this should never happen. " + "G4 is expected to route steps whose tool.meta.isUserWaitGate is true " + "through runPresentPreviewGate() in electron/agent/guards/execution.ts, " + "bypassing the normal tool-execution pipeline. Check G4's executeStep() " + "routing.", );}