Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- 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.
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ 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

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.

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

Rows whose text is exactly `/compact`, `/compact <instructions>` or `/reload` are command rows. They execute the Pi command instead of becoming an LLM message:
Expand Down Expand Up @@ -124,9 +130,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 delivery, editing, command rows, resource expansion, recovery, images and editor composition. Check TUI changes in a real 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

Expand Down
81 changes: 49 additions & 32 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -252,6 +253,15 @@ export default function queueSteerExtension(pi: ExtensionAPI) {
followUp: settingsManager?.getFollowUpMode() ?? "one-at-a-time",
});

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];
Expand Down Expand Up @@ -341,10 +351,19 @@ export default function queueSteerExtension(pi: ExtensionAPI) {
items: QueuedMessage<ImageContent>[],
): Promise<boolean> => {
if (items.length === 0) return false;
let prepared: QueuedMessage<ImageContent>[];
try {
const commands = pi.getCommands();
prepared = items.map((item) => ({ ...item, text: expandQueuedInput(item.text, commands) }));
} 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
Expand Down Expand Up @@ -426,6 +445,33 @@ export default function queueSteerExtension(pi: ExtensionAPI) {
return true;
};

const sendHeadMessage = (ctx: ExtensionContext, lane: QueueLane, deliverAs?: QueueLane): boolean => {
const head = queue.peek(lane);
if (!head) return false;
let prepared: QueuedMessage<ImageContent>;
try {
prepared = { ...head, text: expandQueuedInput(head.text, pi.getCommands()) };
} catch (error) {
pauseAfterPreparationFailure(ctx, lane, error);
return false;
}
queue.shift(lane);
paused = false;
renderQueue(ctx);
try {
pi.sendUserMessage(userContent(prepared), deliverAs ? { deliverAs } : undefined);
return true;
} catch (error) {
queue.prepend(head);
renderQueue(ctx);
ctx.ui.notify(
`Could not send queued ${laneLabel(lane)}: ${error instanceof Error ? error.message : String(error)}`,
"error",
);
return false;
}
};

const dispatchFromIdle = (ctx: ExtensionContext): boolean => {
activeContext = ctx;
if (commandRunning) {
Expand All @@ -443,22 +489,7 @@ export default function queueSteerExtension(pi: ExtensionAPI) {
}
const head = queue.peek(lane);
if (head && parseQueuedCommand(head.text)) return executeCommandRow(ctx, lane);
const next = queue.shift(lane);
if (!next) return false;
paused = false;
renderQueue(ctx);
try {
pi.sendUserMessage(userContent(next));
return true;
} catch (error) {
queue.prepend(next);
renderQueue(ctx);
ctx.ui.notify(
`Could not send queued ${laneLabel(lane)}: ${error instanceof Error ? error.message : String(error)}`,
"error",
);
return false;
}
return sendHeadMessage(ctx, lane);
};

const sendFollowUpNow = (ctx: ExtensionContext): boolean => {
Expand All @@ -472,21 +503,7 @@ export default function queueSteerExtension(pi: ExtensionAPI) {
}
return executeCommandRow(ctx, "followUp");
}
const next = queue.shift("followUp");
if (!next) return false;
renderQueue(ctx);
try {
pi.sendUserMessage(userContent(next), 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 = (
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"index.ts",
"editor-render.ts",
"queue-state.ts",
"queued-input.ts",
"README.md",
"LICENSE",
"CHANGELOG.md",
Expand Down
86 changes: 86 additions & 0 deletions queued-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { readFileSync } from "node:fs";
import { dirname } from "node:path";
import {
parseFrontmatter,
stripFrontmatter,
type SlashCommandInfo,
} from "@earendil-works/pi-coding-agent";

// 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",
]);

// Pi does not export its prompt argument parser or substitution helper.
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;
}

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] ?? "";
},
);
}

export function expandQueuedInput(text: string, commands: readonly SlashCommandInfo[]): string {
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(`/${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(args));
}

const skillName = command.name.slice("skill:".length);
const baseDir = dirname(command.sourceInfo.path);
const body = stripFrontmatter(source).trim();
const skillBlock = `<skill name="${skillName}" location="${command.sourceInfo.path}">\nReferences are relative to ${baseDir}.\n\n${body}\n</skill>`;
const skillArgs = args.trim();
return skillArgs ? `${skillBlock}\n\n${skillArgs}` : skillBlock;
}
87 changes: 86 additions & 1 deletion test/queue-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, Handler[]>();
const sent: Array<{ content: unknown; options: any }> = [];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -638,3 +644,82 @@ 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,
});
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), /<skill name="bro"/);
assert.match(String(harness.sent[0]?.content), /make this clearer$/);

harness.clearPending();
await harness.emit("agent_end");
assert.deepEqual(harness.sent[1]?.content, [
{ type: "text", text: "Review this and simplify it." },
image,
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("an expansion failure restores and pauses an entire all-mode batch", async () => {
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 });
}
});
Loading