From 5ccfbe15a34a3f64a6fde3deac121a4ce71f6cf9 Mon Sep 17 00:00:00 2001 From: Thomas Mustier <6326440+tmustier@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:12:59 +0100 Subject: [PATCH 1/2] Support queued prompts and skills --- CHANGELOG.md | 2 + README.md | 16 ++++- index.ts | 48 ++++++++++++- package.json | 1 + queued-input.ts | 143 ++++++++++++++++++++++++++++++++++++++ test/queue-state.test.ts | 88 ++++++++++++++++++++++- test/queued-input.test.ts | 107 ++++++++++++++++++++++++++++ 7 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 queued-input.ts create mode 100644 test/queued-input.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7227913..6eda4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Expand queued prompt templates and Agent Skills at delivery, including prompt arguments, image attachments and short skill aliases such as `/bro` alongside Pi’s native `/skill:bro` syntax. +- Restore and pause the full affected batch if a queued resource cannot be expanded. - Add command rows: `/compact [instructions]` and `/reload` queue in FIFO position and execute only once the agent is idle, so rows behind them wait — e.g. a queued `continue` delivers after compaction completes. - Queue a mid-run `Enter` on `/reload` instead of surfacing Pi's built-in "wait until the agent finishes" warning; mid-run `Enter` on `/compact` keeps Pi's built-in behaviour. - Restore rows queued behind a `/reload` after the runtime swap. diff --git a/README.md b/README.md index b01b6b1..dee40c9 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,18 @@ The extension keeps Pi’s 2 delivery classes: The extension hands messages back to Pi’s native queues only when their delivery boundary arrives. They remain visible and editable before that point. Pi records delivered rows as normal user messages. +## Prompt templates and Agent Skills + +Discovered prompt templates and Agent Skills can be queued in either lane. They stay as short, editable invocations while visible, then expand when delivered: + +- `/do-less this code` expands the `do-less` prompt template with its arguments +- `/skill:bro` expands the `bro` Agent Skill using Pi’s native command form +- `/bro` is a queue-steer shorthand for `/skill:bro` when no Pi built-in, prompt or extension command already owns `/bro` + +Prompt-template positional arguments, defaults and slices work as they do in Pi. Images remain attached to the expanded prompt. Unknown slash input remains ordinary message text. + +Pi does not expose a public way for extensions to invoke arbitrary extension or built-in commands. A discovered extension command edited into a row therefore stays queued and pauses delivery with an error; edit or remove that row before resuming. `/compact` and `/reload` are the supported built-in exceptions below. + ## Command rows Rows whose text is exactly `/compact`, `/compact ` or `/reload` are command rows. They execute the Pi command instead of becoming an LLM message: @@ -124,9 +136,9 @@ npm run ci pi -e ./index.ts ``` -The automated suite covers both lanes, queue modes, delivery boundaries, stable edits, rollback, removal marks, lane toggles, command-row parsing and batch cuts, abort recovery, image preservation, failed handoffs, editor-frame extraction and editor composition. Check TUI changes in a real interactive Pi session as well. +The automated suite covers both lanes, queue modes, delivery boundaries, stable edits, rollback, removal marks, lane toggles, command-row parsing and batch cuts, prompt-template and Agent Skill expansion, abort recovery, image preservation, failed handoffs, editor-frame extraction and editor composition. Check TUI changes in a real interactive Pi session as well. -Tested with Pi 0.80.9. +Automated against Pi 0.80.9 and smoke-tested interactively with Pi 0.84.1. ## Security diff --git a/index.ts b/index.ts index fde1e18..5d62384 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ import { } from "@earendil-works/pi-coding-agent"; import { matchesKey, truncateToWidth, visibleWidth, type Component, type EditorComponent } from "@earendil-works/pi-tui"; import { extractInlineEditorLines } from "./editor-render.ts"; +import { expandQueuedInput } from "./queued-input.ts"; import { DeliveryQueue, parseQueuedCommand, @@ -252,6 +253,22 @@ export default function queueSteerExtension(pi: ExtensionAPI) { followUp: settingsManager?.getFollowUpMode() ?? "one-at-a-time", }); + const prepareQueuedItems = ( + items: readonly QueuedMessage[], + ): QueuedMessage[] => { + const commands = pi.getCommands(); + return items.map((item) => ({ ...item, text: expandQueuedInput(item.text, commands) })); + }; + + const pauseAfterPreparationFailure = (ctx: ExtensionContext, lane: QueueLane, error: unknown): void => { + paused = true; + renderQueue(ctx); + ctx.ui.notify( + `Could not prepare queued ${laneLabel(lane)}; queue paused: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + }; + const laneIsHeld = (lane: QueueLane): boolean => { if (!editSession) return false; const mode = queueModes()[lane]; @@ -341,10 +358,20 @@ export default function queueSteerExtension(pi: ExtensionAPI) { items: QueuedMessage[], ): Promise => { if (items.length === 0) return false; + let prepared: QueuedMessage[]; + try { + // Resolve every row before sending any of an all-mode batch. A bad + // resource must not cause earlier rows to be sent and then restored. + prepared = prepareQueuedItems(items); + } catch (error) { + queue.prependMany(items); + pauseAfterPreparationFailure(ctx, lane, error); + return false; + } const pendingBefore = ctx.hasPendingMessages(); renderQueue(ctx); try { - for (const item of items) { + for (const item of prepared) { pi.sendUserMessage(userContent(item), { deliverAs: lane }); } // sendUserMessage is fire-and-forget. Keep the awaited boundary @@ -443,12 +470,20 @@ export default function queueSteerExtension(pi: ExtensionAPI) { } const head = queue.peek(lane); if (head && parseQueuedCommand(head.text)) return executeCommandRow(ctx, lane); + if (!head) return false; + let prepared: QueuedMessage; + try { + prepared = { ...head, text: expandQueuedInput(head.text, pi.getCommands()) }; + } catch (error) { + pauseAfterPreparationFailure(ctx, lane, error); + return false; + } const next = queue.shift(lane); if (!next) return false; paused = false; renderQueue(ctx); try { - pi.sendUserMessage(userContent(next)); + pi.sendUserMessage(userContent(prepared)); return true; } catch (error) { queue.prepend(next); @@ -472,11 +507,18 @@ export default function queueSteerExtension(pi: ExtensionAPI) { } return executeCommandRow(ctx, "followUp"); } + let prepared: QueuedMessage; + try { + prepared = { ...head, text: expandQueuedInput(head.text, pi.getCommands()) }; + } catch (error) { + pauseAfterPreparationFailure(ctx, "followUp", error); + return false; + } const next = queue.shift("followUp"); if (!next) return false; renderQueue(ctx); try { - pi.sendUserMessage(userContent(next), ctx.isIdle() ? undefined : { deliverAs: "steer" }); + pi.sendUserMessage(userContent(prepared), ctx.isIdle() ? undefined : { deliverAs: "steer" }); return true; } catch (error) { queue.prepend(next); diff --git a/package.json b/package.json index bbc98a9..02b19bf 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "index.ts", "editor-render.ts", "queue-state.ts", + "queued-input.ts", "README.md", "LICENSE", "CHANGELOG.md", diff --git a/queued-input.ts b/queued-input.ts new file mode 100644 index 0000000..a8e58db --- /dev/null +++ b/queued-input.ts @@ -0,0 +1,143 @@ +import { readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { + parseFrontmatter, + stripFrontmatter, + type SlashCommandInfo, +} from "@earendil-works/pi-coding-agent"; + +interface SlashInvocation { + name: string; + args: string; +} + +// pi.getCommands() intentionally omits built-ins. Keep them ahead of resource +// commands so a short skill alias can never turn /model, /settings, etc. into a +// different prompt. /compact and /reload are handled separately by queue-steer. +const PI_BUILTIN_COMMANDS = new Set([ + "settings", "model", "scoped-models", "export", "import", "share", "copy", "name", "session", + "changelog", "hotkeys", "fork", "clone", "tree", "trust", "login", "logout", "new", "compact", + "resume", "reload", "quit", "debug", "arminsayshi", "dementedelves", +]); + +function parsePromptInvocation(text: string): SlashInvocation | undefined { + if (!text.startsWith("/")) return undefined; + const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + if (!match?.[1]) return undefined; + return { name: match[1], args: match[2] ?? "" }; +} + +function parseCommandInvocation(text: string): SlashInvocation | undefined { + if (!text.startsWith("/")) return undefined; + const spaceIndex = text.indexOf(" "); + const name = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + if (!name) return undefined; + return { name, args: spaceIndex === -1 ? "" : text.slice(spaceIndex + 1) }; +} + +/** Parse template arguments with the same quote handling as Pi. */ +function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: "\"" | "'" | undefined; + for (const character of argsString) { + if (inQuote) { + if (character === inQuote) inQuote = undefined; + else current += character; + } else if (character === "\"" || character === "'") { + inQuote = character; + } else if (/\s/.test(character)) { + if (current) { + args.push(current); + current = ""; + } + } else { + current += character; + } + } + if (current) args.push(current); + return args; +} + +/** Apply Pi prompt-template positional, default and slice substitutions. */ +function substituteArgs(content: string, args: readonly string[]): string { + const allArgs = args.join(" "); + return content.replace( + /\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, + (_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple: string | undefined) => { + if (defaultTarget) { + return args[Number.parseInt(defaultTarget, 10) - 1] || defaultValue; + } + if (sliceStart) { + const start = Math.max(0, Number.parseInt(sliceStart, 10) - 1); + if (sliceLength) { + return args.slice(start, start + Number.parseInt(sliceLength, 10)).join(" "); + } + return args.slice(start).join(" "); + } + if (simple === "ARGUMENTS" || simple === "@") return allArgs; + return args[Number.parseInt(simple ?? "", 10) - 1] ?? ""; + }, + ); +} + +function matchingCommand( + text: string, + commands: readonly SlashCommandInfo[], +): { command: SlashCommandInfo; invocation: SlashInvocation } | undefined { + const commandInvocation = parseCommandInvocation(text); + const promptInvocation = parsePromptInvocation(text); + if (!commandInvocation || !promptInvocation || PI_BUILTIN_COMMANDS.has(commandInvocation.name)) return undefined; + + const commandExact = commands.filter((command) => command.name === commandInvocation.name); + const extension = commandExact.find((command) => command.source === "extension"); + if (extension) return { command: extension, invocation: commandInvocation }; + if (commandInvocation.name.startsWith("skill:")) { + const skill = commandExact.find((command) => command.source === "skill"); + if (skill) return { command: skill, invocation: commandInvocation }; + } + + const prompt = commands.find( + (command) => command.source === "prompt" && command.name === promptInvocation.name, + ); + if (prompt) return { command: prompt, invocation: promptInvocation }; + + // Pi names Agent Skill commands /skill:name. The shorter /name form is a + // queue-steer convenience when it cannot shadow an exact command. + const skillAliases = commands.filter( + (command) => command.source === "skill" && command.name === `skill:${commandInvocation.name}`, + ); + return skillAliases.length === 1 + ? { command: skillAliases[0], invocation: commandInvocation } + : undefined; +} + +/** + * Resolve resource-backed slash input immediately before queue delivery. + * + * Rows stay raw while queued so they remain concise and editable. Unknown slash + * input remains ordinary user text, matching Pi. Extension commands are rejected + * because Pi exposes discovery but no public command invocation API. + */ +export function expandQueuedInput(text: string, commands: readonly SlashCommandInfo[]): string { + const match = matchingCommand(text, commands); + if (!match) return text; + const { command, invocation } = match; + + if (command.source === "extension") { + throw new Error(`/${invocation.name} is an extension command and cannot be run from the queue`); + } + + const source = readFileSync(command.sourceInfo.path, "utf8"); + if (command.source === "prompt") { + const { body } = parseFrontmatter(source); + return substituteArgs(body, parseCommandArgs(invocation.args)); + } + + const skillName = command.name.slice("skill:".length); + const baseDir = dirname(command.sourceInfo.path); + const body = stripFrontmatter(source).trim(); + const skillBlock = `\nReferences are relative to ${baseDir}.\n\n${body}\n`; + const args = invocation.args.trim(); + return args ? `${skillBlock}\n\n${args}` : skillBlock; +} diff --git a/test/queue-state.test.ts b/test/queue-state.test.ts index 51e4ca9..e8b7a12 100644 --- a/test/queue-state.test.ts +++ b/test/queue-state.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import type { SlashCommandInfo } from "@earendil-works/pi-coding-agent"; import { visibleWidth } from "@earendil-works/pi-tui"; import queueSteerExtension from "../index.ts"; import { DeliveryQueue, QueueEditSession, type QueueLane } from "../queue-state.ts"; @@ -156,7 +157,11 @@ class MockEditor { invalidate(): void {} } -function createHarness(options: { cwd?: string; projectTrusted?: boolean } = {}) { +function createHarness(options: { + cwd?: string; + projectTrusted?: boolean; + commands?: SlashCommandInfo[]; +} = {}) { type Handler = (event: any, context: any) => any; const handlers = new Map(); const sent: Array<{ content: unknown; options: any }> = []; @@ -218,6 +223,7 @@ function createHarness(options: { cwd?: string; projectTrusted?: boolean } = {}) sent.push({ content, options }); if (options) pending = true; }, + getCommands: () => options.commands ?? [], }; queueSteerExtension(pi as any); @@ -638,3 +644,83 @@ test("recomposes after another extension installs editor chrome on a later tick" harness.editor.handleInput("alt-up"); assert.equal(harness.editor.getText(), "original"); }); + +test("expands queued prompt templates and short Agent Skill commands at delivery", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-queue-resources-")); + const promptPath = join(dir, "do-less.md"); + const skillPath = join(dir, "SKILL.md"); + writeFileSync(promptPath, "---\ndescription: Do less\n---\nReview $1 and simplify it."); + writeFileSync(skillPath, "---\nname: bro\ndescription: Speak plainly\n---\nSpeak plainly."); + const sourceInfo = (path: string) => ({ + path, + source: "test", + scope: "temporary" as const, + origin: "top-level" as const, + baseDir: dir, + }); + const harness = createHarness({ + commands: [ + { name: "do-less", source: "prompt", sourceInfo: sourceInfo(promptPath) }, + { name: "skill:bro", source: "skill", sourceInfo: sourceInfo(skillPath) }, + ], + }); + const image = { type: "image", source: { type: "base64", mediaType: "image/png", data: "AA==" } }; + try { + await harness.emit("session_start"); + await harness.emit("input", { + source: "interactive", + text: "/do-less this", + images: [image], + streamingBehavior: "followUp", + }); + await enqueue(harness, "steer", "/bro make this clearer"); + + await harness.emit("turn_end", { message: { role: "assistant", stopReason: "toolUse" } }); + assert.match(String(harness.sent[0]?.content), / { + const cwd = mkdtempSync(join(tmpdir(), "pi-queue-expansion-failure-")); + mkdirSync(join(cwd, ".pi")); + writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({ followUpMode: "all" })); + const missingPath = join(cwd, "missing.md"); + const harness = createHarness({ + cwd, + projectTrusted: true, + commands: [{ + name: "missing", + source: "prompt", + sourceInfo: { + path: missingPath, + source: "test", + scope: "temporary", + origin: "top-level", + }, + }], + }); + try { + await harness.emit("session_start"); + await enqueue(harness, "followUp", "sendable first"); + await enqueue(harness, "followUp", "/missing"); + + await harness.emit("agent_end"); + assert.equal(harness.sent.length, 0); + assert.match(renderWidget(harness), /sendable first/); + assert.match(renderWidget(harness), /\/missing/); + assert.match(renderWidget(harness), /paused/); + assert.match(harness.notifications.at(-1)?.message ?? "", /Could not prepare queued follow-up; queue paused/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/test/queued-input.test.ts b/test/queued-input.test.ts new file mode 100644 index 0000000..1d2e4ba --- /dev/null +++ b/test/queued-input.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { SlashCommandInfo } from "@earendil-works/pi-coding-agent"; +import { expandQueuedInput } from "../queued-input.ts"; + +function command( + name: string, + source: SlashCommandInfo["source"], + path: string, + baseDir?: string, +): SlashCommandInfo { + return { + name, + source, + sourceInfo: { path, source: "test", scope: "temporary", origin: "top-level", baseDir }, + }; +} + +test("expands prompt templates with Pi-compatible arguments", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-queue-prompt-")); + const path = join(dir, "review.md"); + writeFileSync(path, [ + "---", + "description: Test prompt", + "---", + "$1|$2|$@|${1:-fallback}|${3:-fallback}|${@:2}|${@:2:1}|$ARGUMENTS|${@:-fallback}", + ].join("\n")); + try { + const review = command("review", "prompt", path); + const expected = "first|two words|first two words|first|fallback|two words|two words|first two words|${@:-fallback}"; + assert.equal(expandQueuedInput('/review first "two words"', [review]), expected); + assert.equal(expandQueuedInput('/review first\n"two words"', [review]), expected); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("expands native and short Agent Skill invocations", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-queue-skill-")); + const path = join(dir, "SKILL.md"); + writeFileSync(path, "---\nname: bro\ndescription: Speak plainly\n---\nSpeak plainly."); + const skill = command("skill:bro", "skill", path, "/wrong/provenance/base"); + const block = `\nReferences are relative to ${dir}.\n\nSpeak plainly.\n`; + try { + assert.equal(expandQueuedInput("/skill:bro", [skill]), block); + assert.equal(expandQueuedInput("/bro simplify this", [skill]), `${block}\n\nsimplify this`); + assert.equal(expandQueuedInput("/skill:bro\tbe direct", [skill]), "/skill:bro\tbe direct"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("exact commands take precedence over short skill aliases", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-queue-collision-")); + const promptPath = join(dir, "bro.md"); + const skillPath = join(dir, "SKILL.md"); + writeFileSync(promptPath, "Prompt wins: $@"); + writeFileSync(skillPath, "---\nname: bro\ndescription: Skill\n---\nSkill body"); + try { + const skill = command("skill:bro", "skill", skillPath, dir); + assert.equal(expandQueuedInput("/bro now", [ + command("bro", "prompt", promptPath), + skill, + ]), "Prompt wins: now"); + const nativeCollision = command("skill:bro", "prompt", promptPath); + assert.match(expandQueuedInput("/skill:bro", [nativeCollision, skill]), / { + const commands = [ + command("skill:bro", "skill", "/one/SKILL.md", "/one"), + command("skill:bro", "skill", "/two/SKILL.md", "/two"), + ]; + assert.equal(expandQueuedInput("/bro", commands), "/bro"); +}); + +test("does not let resources or short skill aliases shadow Pi built-ins", () => { + const commands = [ + command("model", "prompt", "/missing/model.md"), + command("skill:model", "skill", "/missing/SKILL.md"), + command("skill:debug", "skill", "/missing/debug/SKILL.md"), + ]; + assert.equal(expandQueuedInput("/model", commands), "/model"); + assert.equal(expandQueuedInput("/debug", commands), "/debug"); +}); + +test("leaves messages and unknown slash input unchanged", () => { + assert.equal(expandQueuedInput("continue", []), "continue"); + assert.equal(expandQueuedInput("/unknown with args", []), "/unknown with args"); + assert.equal(expandQueuedInput(" /skill:bro", []), " /skill:bro"); +}); + +test("rejects discovered extension commands", () => { + const extension = command("deploy", "extension", "/extension.ts"); + assert.throws( + () => expandQueuedInput("/deploy prod", [extension]), + /extension command.*cannot be run from the queue/, + ); + assert.equal(expandQueuedInput("/deploy\nprod", [extension]), "/deploy\nprod"); +}); From 98094338236074f528a40688678fe5c3022bfa11 Mon Sep 17 00:00:00 2001 From: Thomas Mustier <6326440+tmustier@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:36:04 +0100 Subject: [PATCH 2/2] Simplify queued resource expansion --- CHANGELOG.md | 3 +- README.md | 12 ++---- index.ts | 79 +++++++++++++----------------------- queued-input.ts | 85 +++++++-------------------------------- test/queue-state.test.ts | 1 - test/queued-input.test.ts | 36 ++++------------- 6 files changed, 52 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eda4b2..9252f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,7 @@ ## Unreleased -- Expand queued prompt templates and Agent Skills at delivery, including prompt arguments, image attachments and short skill aliases such as `/bro` alongside Pi’s native `/skill:bro` syntax. -- Restore and pause the full affected batch if a queued resource cannot be expanded. +- Expand queued prompt templates and Agent Skills at delivery, with arguments, images, short aliases such as `/bro`, and full-batch restoration if expansion fails. - Add command rows: `/compact [instructions]` and `/reload` queue in FIFO position and execute only once the agent is idle, so rows behind them wait — e.g. a queued `continue` delivers after compaction completes. - Queue a mid-run `Enter` on `/reload` instead of surfacing Pi's built-in "wait until the agent finishes" warning; mid-run `Enter` on `/compact` keeps Pi's built-in behaviour. - Restore rows queued behind a `/reload` after the runtime swap. diff --git a/README.md b/README.md index dee40c9..5871bdf 100644 --- a/README.md +++ b/README.md @@ -71,15 +71,9 @@ The extension hands messages back to Pi’s native queues only when their delive ## Prompt templates and Agent Skills -Discovered prompt templates and Agent Skills can be queued in either lane. They stay as short, editable invocations while visible, then expand when delivered: +Queued `/do-less this code`, `/skill:bro` and `/bro` rows stay short and editable, then expand when delivered. `/bro` is shorthand for `/skill:bro` unless a built-in, prompt or extension already uses that name. Template arguments and images are preserved; unknown slash input remains ordinary text. -- `/do-less this code` expands the `do-less` prompt template with its arguments -- `/skill:bro` expands the `bro` Agent Skill using Pi’s native command form -- `/bro` is a queue-steer shorthand for `/skill:bro` when no Pi built-in, prompt or extension command already owns `/bro` - -Prompt-template positional arguments, defaults and slices work as they do in Pi. Images remain attached to the expanded prompt. Unknown slash input remains ordinary message text. - -Pi does not expose a public way for extensions to invoke arbitrary extension or built-in commands. A discovered extension command edited into a row therefore stays queued and pauses delivery with an error; edit or remove that row before resuming. `/compact` and `/reload` are the supported built-in exceptions below. +Pi cannot invoke arbitrary commands through its public extension API. `/compact` and `/reload` are the supported built-ins. A queued extension command pauses delivery until you edit or remove it. ## Command rows @@ -136,7 +130,7 @@ npm run ci pi -e ./index.ts ``` -The automated suite covers both lanes, queue modes, delivery boundaries, stable edits, rollback, removal marks, lane toggles, command-row parsing and batch cuts, prompt-template and Agent Skill expansion, abort recovery, image preservation, failed handoffs, editor-frame extraction and editor composition. Check TUI changes in a real interactive Pi session as well. +The automated suite covers delivery, editing, command rows, resource expansion, recovery, images and editor composition. Check TUI changes in a real Pi session as well. Automated against Pi 0.80.9 and smoke-tested interactively with Pi 0.84.1. diff --git a/index.ts b/index.ts index 5d62384..dabcaaa 100644 --- a/index.ts +++ b/index.ts @@ -253,13 +253,6 @@ export default function queueSteerExtension(pi: ExtensionAPI) { followUp: settingsManager?.getFollowUpMode() ?? "one-at-a-time", }); - const prepareQueuedItems = ( - items: readonly QueuedMessage[], - ): QueuedMessage[] => { - const commands = pi.getCommands(); - return items.map((item) => ({ ...item, text: expandQueuedInput(item.text, commands) })); - }; - const pauseAfterPreparationFailure = (ctx: ExtensionContext, lane: QueueLane, error: unknown): void => { paused = true; renderQueue(ctx); @@ -360,9 +353,8 @@ export default function queueSteerExtension(pi: ExtensionAPI) { if (items.length === 0) return false; let prepared: QueuedMessage[]; try { - // Resolve every row before sending any of an all-mode batch. A bad - // resource must not cause earlier rows to be sent and then restored. - prepared = prepareQueuedItems(items); + const commands = pi.getCommands(); + prepared = items.map((item) => ({ ...item, text: expandQueuedInput(item.text, commands) })); } catch (error) { queue.prependMany(items); pauseAfterPreparationFailure(ctx, lane, error); @@ -453,23 +445,8 @@ export default function queueSteerExtension(pi: ExtensionAPI) { return true; }; - const dispatchFromIdle = (ctx: ExtensionContext): boolean => { - activeContext = ctx; - if (commandRunning) { - renderQueue(ctx); - return false; - } - const lane: QueueLane | undefined = queue.laneLength("steer") > 0 - ? "steer" - : queue.laneLength("followUp") > 0 - ? "followUp" - : undefined; - if (!lane || laneIsHeld(lane)) { - renderQueue(ctx); - return false; - } + const sendHeadMessage = (ctx: ExtensionContext, lane: QueueLane, deliverAs?: QueueLane): boolean => { const head = queue.peek(lane); - if (head && parseQueuedCommand(head.text)) return executeCommandRow(ctx, lane); if (!head) return false; let prepared: QueuedMessage; try { @@ -478,15 +455,14 @@ export default function queueSteerExtension(pi: ExtensionAPI) { pauseAfterPreparationFailure(ctx, lane, error); return false; } - const next = queue.shift(lane); - if (!next) return false; + queue.shift(lane); paused = false; renderQueue(ctx); try { - pi.sendUserMessage(userContent(prepared)); + pi.sendUserMessage(userContent(prepared), deliverAs ? { deliverAs } : undefined); return true; } catch (error) { - queue.prepend(next); + queue.prepend(head); renderQueue(ctx); ctx.ui.notify( `Could not send queued ${laneLabel(lane)}: ${error instanceof Error ? error.message : String(error)}`, @@ -496,6 +472,26 @@ export default function queueSteerExtension(pi: ExtensionAPI) { } }; + const dispatchFromIdle = (ctx: ExtensionContext): boolean => { + activeContext = ctx; + if (commandRunning) { + renderQueue(ctx); + return false; + } + const lane: QueueLane | undefined = queue.laneLength("steer") > 0 + ? "steer" + : queue.laneLength("followUp") > 0 + ? "followUp" + : undefined; + if (!lane || laneIsHeld(lane)) { + renderQueue(ctx); + return false; + } + const head = queue.peek(lane); + if (head && parseQueuedCommand(head.text)) return executeCommandRow(ctx, lane); + return sendHeadMessage(ctx, lane); + }; + const sendFollowUpNow = (ctx: ExtensionContext): boolean => { const head = queue.peek("followUp"); if (!head) return false; @@ -507,28 +503,7 @@ export default function queueSteerExtension(pi: ExtensionAPI) { } return executeCommandRow(ctx, "followUp"); } - let prepared: QueuedMessage; - try { - prepared = { ...head, text: expandQueuedInput(head.text, pi.getCommands()) }; - } catch (error) { - pauseAfterPreparationFailure(ctx, "followUp", error); - return false; - } - const next = queue.shift("followUp"); - if (!next) return false; - renderQueue(ctx); - try { - pi.sendUserMessage(userContent(prepared), ctx.isIdle() ? undefined : { deliverAs: "steer" }); - return true; - } catch (error) { - queue.prepend(next); - renderQueue(ctx); - ctx.ui.notify( - `Could not send queued follow-up: ${error instanceof Error ? error.message : String(error)}`, - "error", - ); - return false; - } + return sendHeadMessage(ctx, "followUp", ctx.isIdle() ? undefined : "steer"); }; const finishEditing = ( diff --git a/queued-input.ts b/queued-input.ts index a8e58db..0e766ec 100644 --- a/queued-input.ts +++ b/queued-input.ts @@ -6,36 +6,14 @@ import { type SlashCommandInfo, } from "@earendil-works/pi-coding-agent"; -interface SlashInvocation { - name: string; - args: string; -} - -// pi.getCommands() intentionally omits built-ins. Keep them ahead of resource -// commands so a short skill alias can never turn /model, /settings, etc. into a -// different prompt. /compact and /reload are handled separately by queue-steer. +// getCommands() omits built-ins, which still take precedence over skill aliases. const PI_BUILTIN_COMMANDS = new Set([ "settings", "model", "scoped-models", "export", "import", "share", "copy", "name", "session", "changelog", "hotkeys", "fork", "clone", "tree", "trust", "login", "logout", "new", "compact", - "resume", "reload", "quit", "debug", "arminsayshi", "dementedelves", + "resume", "reload", "quit", ]); -function parsePromptInvocation(text: string): SlashInvocation | undefined { - if (!text.startsWith("/")) return undefined; - const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); - if (!match?.[1]) return undefined; - return { name: match[1], args: match[2] ?? "" }; -} - -function parseCommandInvocation(text: string): SlashInvocation | undefined { - if (!text.startsWith("/")) return undefined; - const spaceIndex = text.indexOf(" "); - const name = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); - if (!name) return undefined; - return { name, args: spaceIndex === -1 ? "" : text.slice(spaceIndex + 1) }; -} - -/** Parse template arguments with the same quote handling as Pi. */ +// Pi does not export its prompt argument parser or substitution helper. function parseCommandArgs(argsString: string): string[] { const args: string[] = []; let current = ""; @@ -59,7 +37,6 @@ function parseCommandArgs(argsString: string): string[] { return args; } -/** Apply Pi prompt-template positional, default and slice substitutions. */ function substituteArgs(content: string, args: readonly string[]): string { const allArgs = args.join(" "); return content.replace( @@ -81,63 +58,29 @@ function substituteArgs(content: string, args: readonly string[]): string { ); } -function matchingCommand( - text: string, - commands: readonly SlashCommandInfo[], -): { command: SlashCommandInfo; invocation: SlashInvocation } | undefined { - const commandInvocation = parseCommandInvocation(text); - const promptInvocation = parsePromptInvocation(text); - if (!commandInvocation || !promptInvocation || PI_BUILTIN_COMMANDS.has(commandInvocation.name)) return undefined; - - const commandExact = commands.filter((command) => command.name === commandInvocation.name); - const extension = commandExact.find((command) => command.source === "extension"); - if (extension) return { command: extension, invocation: commandInvocation }; - if (commandInvocation.name.startsWith("skill:")) { - const skill = commandExact.find((command) => command.source === "skill"); - if (skill) return { command: skill, invocation: commandInvocation }; - } - - const prompt = commands.find( - (command) => command.source === "prompt" && command.name === promptInvocation.name, - ); - if (prompt) return { command: prompt, invocation: promptInvocation }; - - // Pi names Agent Skill commands /skill:name. The shorter /name form is a - // queue-steer convenience when it cannot shadow an exact command. - const skillAliases = commands.filter( - (command) => command.source === "skill" && command.name === `skill:${commandInvocation.name}`, - ); - return skillAliases.length === 1 - ? { command: skillAliases[0], invocation: commandInvocation } - : undefined; -} - -/** - * Resolve resource-backed slash input immediately before queue delivery. - * - * Rows stay raw while queued so they remain concise and editable. Unknown slash - * input remains ordinary user text, matching Pi. Extension commands are rejected - * because Pi exposes discovery but no public command invocation API. - */ export function expandQueuedInput(text: string, commands: readonly SlashCommandInfo[]): string { - const match = matchingCommand(text, commands); - if (!match) return text; - const { command, invocation } = match; + const invocation = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + const name = invocation?.[1]; + if (!name || PI_BUILTIN_COMMANDS.has(name)) return text; + const command = commands.find((candidate) => candidate.name === name) + ?? commands.find((candidate) => candidate.source === "skill" && candidate.name === `skill:${name}`); + if (!command) return text; if (command.source === "extension") { - throw new Error(`/${invocation.name} is an extension command and cannot be run from the queue`); + throw new Error(`/${name} is an extension command and cannot be run from the queue`); } const source = readFileSync(command.sourceInfo.path, "utf8"); + const args = invocation[2] ?? ""; if (command.source === "prompt") { const { body } = parseFrontmatter(source); - return substituteArgs(body, parseCommandArgs(invocation.args)); + return substituteArgs(body, parseCommandArgs(args)); } const skillName = command.name.slice("skill:".length); const baseDir = dirname(command.sourceInfo.path); const body = stripFrontmatter(source).trim(); const skillBlock = `\nReferences are relative to ${baseDir}.\n\n${body}\n`; - const args = invocation.args.trim(); - return args ? `${skillBlock}\n\n${args}` : skillBlock; + const skillArgs = args.trim(); + return skillArgs ? `${skillBlock}\n\n${skillArgs}` : skillBlock; } diff --git a/test/queue-state.test.ts b/test/queue-state.test.ts index e8b7a12..6adb468 100644 --- a/test/queue-state.test.ts +++ b/test/queue-state.test.ts @@ -656,7 +656,6 @@ test("expands queued prompt templates and short Agent Skill commands at delivery source: "test", scope: "temporary" as const, origin: "top-level" as const, - baseDir: dir, }); const harness = createHarness({ commands: [ diff --git a/test/queued-input.test.ts b/test/queued-input.test.ts index 1d2e4ba..cc70dee 100644 --- a/test/queued-input.test.ts +++ b/test/queued-input.test.ts @@ -6,16 +6,11 @@ import test from "node:test"; import type { SlashCommandInfo } from "@earendil-works/pi-coding-agent"; import { expandQueuedInput } from "../queued-input.ts"; -function command( - name: string, - source: SlashCommandInfo["source"], - path: string, - baseDir?: string, -): SlashCommandInfo { +function command(name: string, source: SlashCommandInfo["source"], path: string): SlashCommandInfo { return { name, source, - sourceInfo: { path, source: "test", scope: "temporary", origin: "top-level", baseDir }, + sourceInfo: { path, source: "test", scope: "temporary", origin: "top-level" }, }; } @@ -26,11 +21,11 @@ test("expands prompt templates with Pi-compatible arguments", () => { "---", "description: Test prompt", "---", - "$1|$2|$@|${1:-fallback}|${3:-fallback}|${@:2}|${@:2:1}|$ARGUMENTS|${@:-fallback}", + "$1|$2|$@|${3:-fallback}|${@:2:1}", ].join("\n")); try { const review = command("review", "prompt", path); - const expected = "first|two words|first two words|first|fallback|two words|two words|first two words|${@:-fallback}"; + const expected = "first|two words|first two words|fallback|two words"; assert.equal(expandQueuedInput('/review first "two words"', [review]), expected); assert.equal(expandQueuedInput('/review first\n"two words"', [review]), expected); } finally { @@ -42,59 +37,43 @@ test("expands native and short Agent Skill invocations", () => { const dir = mkdtempSync(join(tmpdir(), "pi-queue-skill-")); const path = join(dir, "SKILL.md"); writeFileSync(path, "---\nname: bro\ndescription: Speak plainly\n---\nSpeak plainly."); - const skill = command("skill:bro", "skill", path, "/wrong/provenance/base"); + const skill = command("skill:bro", "skill", path); const block = `\nReferences are relative to ${dir}.\n\nSpeak plainly.\n`; try { assert.equal(expandQueuedInput("/skill:bro", [skill]), block); assert.equal(expandQueuedInput("/bro simplify this", [skill]), `${block}\n\nsimplify this`); - assert.equal(expandQueuedInput("/skill:bro\tbe direct", [skill]), "/skill:bro\tbe direct"); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("exact commands take precedence over short skill aliases", () => { +test("prompt templates take precedence over short skill aliases", () => { const dir = mkdtempSync(join(tmpdir(), "pi-queue-collision-")); const promptPath = join(dir, "bro.md"); const skillPath = join(dir, "SKILL.md"); writeFileSync(promptPath, "Prompt wins: $@"); writeFileSync(skillPath, "---\nname: bro\ndescription: Skill\n---\nSkill body"); try { - const skill = command("skill:bro", "skill", skillPath, dir); assert.equal(expandQueuedInput("/bro now", [ command("bro", "prompt", promptPath), - skill, + command("skill:bro", "skill", skillPath), ]), "Prompt wins: now"); - const nativeCollision = command("skill:bro", "prompt", promptPath); - assert.match(expandQueuedInput("/skill:bro", [nativeCollision, skill]), / { - const commands = [ - command("skill:bro", "skill", "/one/SKILL.md", "/one"), - command("skill:bro", "skill", "/two/SKILL.md", "/two"), - ]; - assert.equal(expandQueuedInput("/bro", commands), "/bro"); -}); - test("does not let resources or short skill aliases shadow Pi built-ins", () => { const commands = [ command("model", "prompt", "/missing/model.md"), command("skill:model", "skill", "/missing/SKILL.md"), - command("skill:debug", "skill", "/missing/debug/SKILL.md"), ]; assert.equal(expandQueuedInput("/model", commands), "/model"); - assert.equal(expandQueuedInput("/debug", commands), "/debug"); }); test("leaves messages and unknown slash input unchanged", () => { assert.equal(expandQueuedInput("continue", []), "continue"); assert.equal(expandQueuedInput("/unknown with args", []), "/unknown with args"); - assert.equal(expandQueuedInput(" /skill:bro", []), " /skill:bro"); }); test("rejects discovered extension commands", () => { @@ -103,5 +82,4 @@ test("rejects discovered extension commands", () => { () => expandQueuedInput("/deploy prod", [extension]), /extension command.*cannot be run from the queue/, ); - assert.equal(expandQueuedInput("/deploy\nprod", [extension]), "/deploy\nprod"); });