From 359c9f2b30dac1c6719722e4028c33ba62ac05dd Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 00:40:25 +0200 Subject: [PATCH 01/16] feat(later): save prompts and run them later in the session --- .github/workflows/publish-pi-later.yml | 31 +++++++++++ plugins/README.md | 2 + plugins/later/README.md | 20 +++++++ plugins/later/extensions/later.ts | 77 ++++++++++++++++++++++++++ plugins/later/package.json | 29 ++++++++++ 5 files changed, 159 insertions(+) create mode 100644 .github/workflows/publish-pi-later.yml create mode 100644 plugins/later/README.md create mode 100644 plugins/later/extensions/later.ts create mode 100644 plugins/later/package.json diff --git a/.github/workflows/publish-pi-later.yml b/.github/workflows/publish-pi-later.yml new file mode 100644 index 0000000..b4418cc --- /dev/null +++ b/.github/workflows/publish-pi-later.yml @@ -0,0 +1,31 @@ +name: Publish @derogab/pi-later package + +on: + push: + branches: + - master + paths: + - plugins/later/package.json + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: plugins/later + + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + - run: npm pack --dry-run + - run: npm publish --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/plugins/README.md b/plugins/README.md index 108fe3b..2d56081 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -8,6 +8,7 @@ | [exit](./exit/) | Pi | Adds `/exit` as an alias for `/quit` | | [git](./git/) | Pi, Claude Code | Git workflow skills for commits and pull requests | | [inkypal](./inkypal/) | Claude Code | Notifies InkyPal when a task finish | +| [later](./later/) | Pi | Save prompts and run them later in the session | | [sounds](./sounds/) | Claude Code | OS-native sound alerts on events like task completion | ## Install @@ -24,6 +25,7 @@ pi install npm:@derogab/pi-clear pi install npm:@derogab/pi-dev pi install npm:@derogab/pi-exit pi install npm:@derogab/pi-git +pi install npm:@derogab/pi-later ``` ### Claude Code diff --git a/plugins/later/README.md b/plugins/later/README.md new file mode 100644 index 0000000..ad1cf29 --- /dev/null +++ b/plugins/later/README.md @@ -0,0 +1,20 @@ +# later + +A Pi plugin that adds `/later` to save prompts and run them later in the session. + +## Install + +### Pi + +Install the plugin from npm: + +```bash +pi install npm:@derogab/pi-later +``` + +## Usage + +- `/later `: save a prompt for later. +- `/later`: open the list of saved prompts. Pick one to send it to the session and run it; the prompt is removed from the list. + +Saved prompts are stored in the session, so they survive `/reload` and session resume. diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts new file mode 100644 index 0000000..ab5a381 --- /dev/null +++ b/plugins/later/extensions/later.ts @@ -0,0 +1,77 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +const ENTRY_TYPE = "later"; +const MAX_LABEL_LENGTH = 80; + +export default function (pi: ExtensionAPI) { + // Saved prompts, oldest first. Reconstructed from session entries. + let prompts: string[] = []; + + const reconstructState = (ctx: ExtensionContext) => { + prompts = []; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "custom" && entry.customType === ENTRY_TYPE) { + const data = entry.data as { prompts?: string[] } | undefined; + prompts = data?.prompts ?? []; + } + } + }; + + pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + const persist = () => { + pi.appendEntry(ENTRY_TYPE, { prompts: [...prompts] }); + }; + + const toLabel = (prompt: string, index: number) => { + const singleLine = prompt.replace(/\s+/g, " ").trim(); + const truncated = + singleLine.length > MAX_LABEL_LENGTH ? `${singleLine.slice(0, MAX_LABEL_LENGTH)}…` : singleLine; + return `${index + 1}. ${truncated}`; + }; + + pi.registerCommand("later", { + description: "Save a prompt for later, or run a saved prompt", + handler: async (args, ctx) => { + const text = args.trim(); + + // /later : save it for later + if (text) { + prompts.push(text); + persist(); + ctx.ui.notify(`Saved for later (${prompts.length} pending)`, "info"); + return; + } + + // /later: pick a saved prompt to run + if (prompts.length === 0) { + ctx.ui.notify("Nothing saved for later. Use /later to save a prompt.", "info"); + return; + } + + if (!ctx.hasUI) { + ctx.ui.notify("/later requires interactive mode to pick a saved prompt", "error"); + return; + } + + const labels = prompts.map(toLabel); + const choice = await ctx.ui.select("Saved prompts — pick one to run", labels); + if (choice === undefined) return; + + const index = labels.indexOf(choice); + const prompt = prompts[index]; + if (prompt === undefined) return; + + prompts.splice(index, 1); + persist(); + + if (ctx.isIdle()) { + pi.sendUserMessage(prompt); + } else { + pi.sendUserMessage(prompt, { deliverAs: "followUp" }); + ctx.ui.notify("Queued as follow-up", "info"); + } + }, + }); +} diff --git a/plugins/later/package.json b/plugins/later/package.json new file mode 100644 index 0000000..b85ae9b --- /dev/null +++ b/plugins/later/package.json @@ -0,0 +1,29 @@ +{ + "name": "@derogab/pi-later", + "version": "0.1.0", + "description": "Save prompts and run them later in the session", + "type": "module", + "files": [ + "extensions", + "README.md" + ], + "keywords": [ + "pi-package" + ], + "repository": { + "type": "git", + "url": "https://github.com/derogab/agent-kit.git", + "directory": "plugins/later" + }, + "publishConfig": { + "access": "public" + }, + "pi": { + "extensions": [ + "./extensions/later.ts" + ] + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*" + } +} From fe2e0e5e74f363fe9531953190277e226872306a Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 01:00:10 +0200 Subject: [PATCH 02/16] fix(later): copy persisted prompts on state reconstruction getBranch() returns live entry objects, so assigning the persisted array by reference let push/splice mutate the historical session entry. That corrupted the state restored when navigating the session tree or forking. Clone the array instead. --- plugins/later/extensions/later.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index ab5a381..92f5974 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -12,7 +12,8 @@ export default function (pi: ExtensionAPI) { for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === ENTRY_TYPE) { const data = entry.data as { prompts?: string[] } | undefined; - prompts = data?.prompts ?? []; + // Clone: entries are live references, and prompts is mutated in place later + prompts = [...(data?.prompts ?? [])]; } } }; From e6e2a0845df6db2f14fae658d999f64cf8377cfa Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 01:20:15 +0200 Subject: [PATCH 03/16] fix(later): remove saved prompt only after delivery succeeds sendUserMessage() rejects when no turn can start (e.g. no model or provider auth), but the prompt was already spliced and persisted, losing it without running it. Await delivery and keep the prompt in the list on failure. --- plugins/later/extensions/later.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index 92f5974..5bfbaf4 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -64,14 +64,24 @@ export default function (pi: ExtensionAPI) { const prompt = prompts[index]; if (prompt === undefined) return; - prompts.splice(index, 1); - persist(); + // Remove only after delivery is accepted, so a failed run keeps the prompt + try { + if (ctx.isIdle()) { + await pi.sendUserMessage(prompt); + } else { + await pi.sendUserMessage(prompt, { deliverAs: "followUp" }); + ctx.ui.notify("Queued as follow-up", "info"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Could not run saved prompt, kept in list: ${message}`, "error"); + return; + } - if (ctx.isIdle()) { - pi.sendUserMessage(prompt); - } else { - pi.sendUserMessage(prompt, { deliverAs: "followUp" }); - ctx.ui.notify("Queued as follow-up", "info"); + const removeIndex = prompts.indexOf(prompt); + if (removeIndex !== -1) { + prompts.splice(removeIndex, 1); + persist(); } }, }); From 44da703209270e8018fefdc9412adb7a996694ed Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 01:26:24 +0200 Subject: [PATCH 04/16] docs(later): note prompts are lost if a new session exits before the first reply Pi creates the session file only after the first assistant response, so custom entries appended before that live only in memory and cannot survive a resume. --- plugins/later/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/later/README.md b/plugins/later/README.md index ad1cf29..b787499 100644 --- a/plugins/later/README.md +++ b/plugins/later/README.md @@ -18,3 +18,7 @@ pi install npm:@derogab/pi-later - `/later`: open the list of saved prompts. Pick one to send it to the session and run it; the prompt is removed from the list. Saved prompts are stored in the session, so they survive `/reload` and session resume. + +## Limitations + +- Pi writes a new session to disk only after its first assistant response. Prompts saved before that point are kept in memory and lost if Pi exits first. From 9c4898f834fae615bda76abaa133a6083dfe7973 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 02:06:32 +0200 Subject: [PATCH 05/16] fix(later): preserve prompts until delivery succeeds Pi exposes sendUserMessage as fire-and-forget, so awaiting it cannot confirm that an idle turn passed preflight. Track acceptance through the agent lifecycle and flush unpersisted prompts during graceful shutdown. --- plugins/later/README.md | 6 +- plugins/later/extensions/later.ts | 89 ++++++++++++----- plugins/later/package.json | 3 + plugins/later/test/later.test.ts | 157 ++++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 27 deletions(-) create mode 100644 plugins/later/test/later.test.ts diff --git a/plugins/later/README.md b/plugins/later/README.md index b787499..2bee30e 100644 --- a/plugins/later/README.md +++ b/plugins/later/README.md @@ -17,8 +17,4 @@ pi install npm:@derogab/pi-later - `/later `: save a prompt for later. - `/later`: open the list of saved prompts. Pick one to send it to the session and run it; the prompt is removed from the list. -Saved prompts are stored in the session, so they survive `/reload` and session resume. - -## Limitations - -- Pi writes a new session to disk only after its first assistant response. Prompts saved before that point are kept in memory and lost if Pi exits first. +Saved prompts are stored in the session, so they survive `/reload`, graceful exit, and session resume. diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index 5bfbaf4..52b6398 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -1,19 +1,26 @@ +import { writeFileSync } from "node:fs"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; const ENTRY_TYPE = "later"; const MAX_LABEL_LENGTH = 80; +interface SavedPrompt { + text: string; +} + export default function (pi: ExtensionAPI) { // Saved prompts, oldest first. Reconstructed from session entries. - let prompts: string[] = []; + let prompts: SavedPrompt[] = []; + let pendingIdleDelivery: SavedPrompt | undefined; const reconstructState = (ctx: ExtensionContext) => { prompts = []; + pendingIdleDelivery = undefined; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === ENTRY_TYPE) { const data = entry.data as { prompts?: string[] } | undefined; - // Clone: entries are live references, and prompts is mutated in place later - prompts = [...(data?.prompts ?? [])]; + // Use distinct objects so duplicate prompt text still has stable selection identity. + prompts = (data?.prompts ?? []).map((text) => ({ text })); } } }; @@ -22,16 +29,51 @@ export default function (pi: ExtensionAPI) { pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); const persist = () => { - pi.appendEntry(ENTRY_TYPE, { prompts: [...prompts] }); + pi.appendEntry(ENTRY_TYPE, { prompts: prompts.map((prompt) => prompt.text) }); + }; + + const remove = (prompt: SavedPrompt) => { + const index = prompts.indexOf(prompt); + if (index === -1) return; + + prompts.splice(index, 1); + persist(); }; - const toLabel = (prompt: string, index: number) => { - const singleLine = prompt.replace(/\s+/g, " ").trim(); + const toLabel = (prompt: SavedPrompt, index: number) => { + const singleLine = prompt.text.replace(/\s+/g, " ").trim(); const truncated = singleLine.length > MAX_LABEL_LENGTH ? `${singleLine.slice(0, MAX_LABEL_LENGTH)}…` : singleLine; return `${index + 1}. ${truncated}`; }; + pi.on("before_agent_start", async (event) => { + const prompt = pendingIdleDelivery; + if (prompt === undefined || prompt.text !== event.prompt) return; + + pendingIdleDelivery = undefined; + remove(prompt); + }); + + pi.on("session_shutdown", async (event, ctx) => { + if (event.reason !== "quit" || prompts.length === 0) return; + + const sessionFile = ctx.sessionManager.getSessionFile(); + const header = ctx.sessionManager.getHeader(); + if (sessionFile === undefined || header === null) return; + + const entries = [header, ...ctx.sessionManager.getEntries()]; + const contents = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`; + + try { + // Pi normally creates this file on the first assistant response. On an + // earlier graceful exit, create it exclusively so saved prompts survive. + writeFileSync(sessionFile, contents, { encoding: "utf8", flag: "wx" }); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error; + } + }); + pi.registerCommand("later", { description: "Save a prompt for later, or run a saved prompt", handler: async (args, ctx) => { @@ -39,7 +81,7 @@ export default function (pi: ExtensionAPI) { // /later : save it for later if (text) { - prompts.push(text); + prompts.push({ text }); persist(); ctx.ui.notify(`Saved for later (${prompts.length} pending)`, "info"); return; @@ -64,25 +106,28 @@ export default function (pi: ExtensionAPI) { const prompt = prompts[index]; if (prompt === undefined) return; - // Remove only after delivery is accepted, so a failed run keeps the prompt - try { - if (ctx.isIdle()) { - await pi.sendUserMessage(prompt); - } else { - await pi.sendUserMessage(prompt, { deliverAs: "followUp" }); - ctx.ui.notify("Queued as follow-up", "info"); + if (ctx.isIdle()) { + if (ctx.model === undefined) { + ctx.ui.notify("Could not run saved prompt, kept in list: no model selected", "error"); + return; } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ctx.ui.notify(`Could not run saved prompt, kept in list: ${message}`, "error"); + + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); + if (!auth.ok) { + ctx.ui.notify(`Could not run saved prompt, kept in list: ${auth.error}`, "error"); + return; + } + + // ExtensionAPI.sendUserMessage() is fire-and-forget. before_agent_start + // acknowledges that Pi accepted this idle turn after all preflight checks. + pendingIdleDelivery = prompt; + pi.sendUserMessage(prompt.text); return; } - const removeIndex = prompts.indexOf(prompt); - if (removeIndex !== -1) { - prompts.splice(removeIndex, 1); - persist(); - } + pi.sendUserMessage(prompt.text, { deliverAs: "followUp" }); + remove(prompt); + ctx.ui.notify("Queued as follow-up", "info"); }, }); } diff --git a/plugins/later/package.json b/plugins/later/package.json index b85ae9b..92ad9d7 100644 --- a/plugins/later/package.json +++ b/plugins/later/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "description": "Save prompts and run them later in the session", "type": "module", + "scripts": { + "test": "node --experimental-strip-types --test test/*.test.ts" + }, "files": [ "extensions", "README.md" diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts new file mode 100644 index 0000000..4706a40 --- /dev/null +++ b/plugins/later/test/later.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import later from "../extensions/later.ts"; + +interface CustomEntry { + type: "custom"; + customType: string; + data: { prompts: string[] }; + id: string; + parentId: string | null; + timestamp: string; +} + +const setup = (options: { idle?: boolean; sessionFile?: string; authError?: string } = {}) => { + const handlers = new Map Promise>(); + const entries: CustomEntry[] = []; + const notifications: Array<{ message: string; level: string }> = []; + const sent: Array<{ prompt: string; options?: { deliverAs: "followUp" } }> = []; + let selection: string | undefined; + + const sessionManager = { + getBranch: () => entries, + getEntries: () => [...entries], + getHeader: () => ({ type: "session", version: 3, id: "session", timestamp: "2026-08-14T00:00:00.000Z", cwd: "/tmp" }), + getSessionFile: () => options.sessionFile, + }; + const ctx = { + hasUI: true, + isIdle: () => options.idle ?? true, + model: { provider: "test", id: "model" }, + modelRegistry: { + getApiKeyAndHeaders: async () => + options.authError === undefined ? { ok: true } : { ok: false, error: options.authError }, + }, + sessionManager, + ui: { + notify: (message: string, level: string) => notifications.push({ message, level }), + select: async (_title: string, labels: string[]) => selection ?? labels[0], + }, + }; + let command: ((args: string, ctx: any) => Promise) | undefined; + const pi = { + appendEntry: (customType: string, data: { prompts: string[] }) => { + entries.push({ + type: "custom", + customType, + data, + id: String(entries.length + 1), + parentId: entries.at(-1)?.id ?? null, + timestamp: "2026-08-14T00:00:00.000Z", + }); + }, + on: (event: string, handler: (event: any, ctx: any) => Promise) => handlers.set(event, handler), + registerCommand: (_name: string, definition: { handler: (args: string, ctx: any) => Promise }) => { + command = definition.handler; + }, + sendUserMessage: (prompt: string, sendOptions?: { deliverAs: "followUp" }) => { + sent.push({ prompt, options: sendOptions }); + }, + }; + + later(pi as any); + + return { + ctx, + entries, + handlers, + notifications, + sent, + setSelection: (value: string) => { + selection = value; + }, + run: async (args: string) => command!(args, ctx), + }; +}; + +const latestPrompts = (entries: CustomEntry[]) => entries.at(-1)?.data.prompts; + +test("keeps an idle prompt when no model is selected", async () => { + const fixture = setup(); + fixture.ctx.model = undefined; + await fixture.run("keep me"); + await fixture.run(""); + + assert.deepEqual(fixture.sent, []); + assert.deepEqual(latestPrompts(fixture.entries), ["keep me"]); + assert.match(fixture.notifications.at(-1)!.message, /kept in list: no model selected/); +}); + +test("keeps an idle prompt when provider authentication is unavailable", async () => { + const fixture = setup({ authError: "No API key" }); + await fixture.run("keep me"); + await fixture.run(""); + + assert.deepEqual(fixture.sent, []); + assert.deepEqual(latestPrompts(fixture.entries), ["keep me"]); + assert.match(fixture.notifications.at(-1)!.message, /kept in list: No API key/); +}); + +test("removes an idle prompt only when Pi accepts the turn", async () => { + const fixture = setup(); + await fixture.run("run me"); + await fixture.run(""); + + assert.deepEqual(fixture.sent, [{ prompt: "run me", options: undefined }]); + assert.deepEqual(latestPrompts(fixture.entries), ["run me"]); + + await fixture.handlers.get("before_agent_start")!({ prompt: "run me" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("removes the selected duplicate prompt", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("B"); + await fixture.run("A"); + fixture.setSelection("3. A"); + await fixture.run(""); + + assert.deepEqual(fixture.sent, [{ prompt: "A", options: { deliverAs: "followUp" } }]); + assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); +}); + +test("removes the selected duplicate after an idle turn is accepted", async () => { + const fixture = setup(); + await fixture.run("A"); + await fixture.run("B"); + await fixture.run("A"); + fixture.setSelection("3. A"); + await fixture.run(""); + await fixture.handlers.get("before_agent_start")!({ prompt: "A" }, fixture.ctx); + + assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); +}); + +test("writes an unflushed session on graceful exit", async () => { + const directory = mkdtempSync(join(tmpdir(), "pi-later-")); + const sessionFile = join(directory, "session.jsonl"); + + try { + const fixture = setup({ sessionFile }); + await fixture.run("survive exit"); + await fixture.handlers.get("session_shutdown")!({ reason: "quit" }, fixture.ctx); + + const lines = readFileSync(sessionFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(lines[0].type, "session"); + assert.deepEqual(lines.at(-1)!.data.prompts, ["survive exit"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); From 711282a4b62fc7e9778696da9b721dd9ae47e8d4 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 02:34:40 +0200 Subject: [PATCH 06/16] fix(later): handle transformed prompt delivery Input handlers may change the text before delivery acknowledgement, so match the pending idle turn by order instead. Avoid writing Pi's shared session file before later shutdown handlers can append their state. --- plugins/later/README.md | 6 ++++- plugins/later/extensions/later.ts | 26 ++++------------------ plugins/later/test/later.test.ts | 37 +++++++++---------------------- 3 files changed, 19 insertions(+), 50 deletions(-) diff --git a/plugins/later/README.md b/plugins/later/README.md index 2bee30e..019d2b6 100644 --- a/plugins/later/README.md +++ b/plugins/later/README.md @@ -17,4 +17,8 @@ pi install npm:@derogab/pi-later - `/later `: save a prompt for later. - `/later`: open the list of saved prompts. Pick one to send it to the session and run it; the prompt is removed from the list. -Saved prompts are stored in the session, so they survive `/reload`, graceful exit, and session resume. +After the session's first assistant response, saved prompts survive `/reload` and session resume. + +## Limitations + +- Pi creates a new session file only after its first assistant response. Prompts saved before that point remain in memory and are lost if Pi exits or replaces the session with `/new`, `/resume`, or `/fork` first. diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index 52b6398..0819d58 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -1,4 +1,3 @@ -import { writeFileSync } from "node:fs"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; const ENTRY_TYPE = "later"; @@ -47,33 +46,16 @@ export default function (pi: ExtensionAPI) { return `${index + 1}. ${truncated}`; }; - pi.on("before_agent_start", async (event) => { + pi.on("before_agent_start", async () => { const prompt = pendingIdleDelivery; - if (prompt === undefined || prompt.text !== event.prompt) return; + if (prompt === undefined) return; + // The next accepted idle turn is the pending delivery, even if an input + // handler transformed its text before this event. pendingIdleDelivery = undefined; remove(prompt); }); - pi.on("session_shutdown", async (event, ctx) => { - if (event.reason !== "quit" || prompts.length === 0) return; - - const sessionFile = ctx.sessionManager.getSessionFile(); - const header = ctx.sessionManager.getHeader(); - if (sessionFile === undefined || header === null) return; - - const entries = [header, ...ctx.sessionManager.getEntries()]; - const contents = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`; - - try { - // Pi normally creates this file on the first assistant response. On an - // earlier graceful exit, create it exclusively so saved prompts survive. - writeFileSync(sessionFile, contents, { encoding: "utf8", flag: "wx" }); - } catch (error) { - if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error; - } - }); - pi.registerCommand("later", { description: "Save a prompt for later, or run a saved prompt", handler: async (args, ctx) => { diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index 4706a40..50a991a 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -1,7 +1,4 @@ import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import test from "node:test"; import later from "../extensions/later.ts"; @@ -14,7 +11,7 @@ interface CustomEntry { timestamp: string; } -const setup = (options: { idle?: boolean; sessionFile?: string; authError?: string } = {}) => { +const setup = (options: { idle?: boolean; authError?: string } = {}) => { const handlers = new Map Promise>(); const entries: CustomEntry[] = []; const notifications: Array<{ message: string; level: string }> = []; @@ -23,9 +20,6 @@ const setup = (options: { idle?: boolean; sessionFile?: string; authError?: stri const sessionManager = { getBranch: () => entries, - getEntries: () => [...entries], - getHeader: () => ({ type: "session", version: 3, id: "session", timestamp: "2026-08-14T00:00:00.000Z", cwd: "/tmp" }), - getSessionFile: () => options.sessionFile, }; const ctx = { hasUI: true, @@ -112,6 +106,15 @@ test("removes an idle prompt only when Pi accepts the turn", async () => { assert.deepEqual(latestPrompts(fixture.entries), []); }); +test("acknowledges an idle prompt transformed by an input handler", async () => { + const fixture = setup(); + await fixture.run("original"); + await fixture.run(""); + + await fixture.handlers.get("before_agent_start")!({ prompt: "transformed" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + test("removes the selected duplicate prompt", async () => { const fixture = setup({ idle: false }); await fixture.run("A"); @@ -135,23 +138,3 @@ test("removes the selected duplicate after an idle turn is accepted", async () = assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); }); - -test("writes an unflushed session on graceful exit", async () => { - const directory = mkdtempSync(join(tmpdir(), "pi-later-")); - const sessionFile = join(directory, "session.jsonl"); - - try { - const fixture = setup({ sessionFile }); - await fixture.run("survive exit"); - await fixture.handlers.get("session_shutdown")!({ reason: "quit" }, fixture.ctx); - - const lines = readFileSync(sessionFile, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - assert.equal(lines[0].type, "session"); - assert.deepEqual(lines.at(-1)!.data.prompts, ["survive exit"]); - } finally { - rmSync(directory, { recursive: true, force: true }); - } -}); From a649b14ae862d396f4b766d31fa48e0e6133954f Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 02:39:32 +0200 Subject: [PATCH 07/16] feat(later): add confirm or remove choice for saved prompts Picking a saved prompt no longer runs it immediately. A second dialog now offers Confirm to send the prompt or Remove to delete it without running, so saved prompts can be discarded without executing them. --- plugins/later/README.md | 6 ++++- plugins/later/extensions/later.ts | 27 +++++++++++++++++----- plugins/later/test/later.test.ts | 37 ++++++++++++++++++++++++++----- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/plugins/later/README.md b/plugins/later/README.md index 019d2b6..edf1458 100644 --- a/plugins/later/README.md +++ b/plugins/later/README.md @@ -15,7 +15,11 @@ pi install npm:@derogab/pi-later ## Usage - `/later `: save a prompt for later. -- `/later`: open the list of saved prompts. Pick one to send it to the session and run it; the prompt is removed from the list. +- `/later`: open the list of saved prompts. Pick one, then choose what to do with it: + - **Confirm**: send the prompt to the session and run it; the prompt is removed from the list. + - **Remove**: delete the prompt from the list without running it. + + Pressing Esc on either dialog leaves the list unchanged. After the session's first assistant response, saved prompts survive `/reload` and session resume. diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index 0819d58..92069b2 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -2,6 +2,8 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a const ENTRY_TYPE = "later"; const MAX_LABEL_LENGTH = 80; +const ACTION_CONFIRM = "Confirm"; +const ACTION_REMOVE = "Remove"; interface SavedPrompt { text: string; @@ -39,13 +41,13 @@ export default function (pi: ExtensionAPI) { persist(); }; - const toLabel = (prompt: SavedPrompt, index: number) => { - const singleLine = prompt.text.replace(/\s+/g, " ").trim(); - const truncated = - singleLine.length > MAX_LABEL_LENGTH ? `${singleLine.slice(0, MAX_LABEL_LENGTH)}…` : singleLine; - return `${index + 1}. ${truncated}`; + const truncate = (text: string) => { + const singleLine = text.replace(/\s+/g, " ").trim(); + return singleLine.length > MAX_LABEL_LENGTH ? `${singleLine.slice(0, MAX_LABEL_LENGTH)}…` : singleLine; }; + const toLabel = (prompt: SavedPrompt, index: number) => `${index + 1}. ${truncate(prompt.text)}`; + pi.on("before_agent_start", async () => { const prompt = pendingIdleDelivery; if (prompt === undefined) return; @@ -81,13 +83,26 @@ export default function (pi: ExtensionAPI) { } const labels = prompts.map(toLabel); - const choice = await ctx.ui.select("Saved prompts — pick one to run", labels); + const choice = await ctx.ui.select("Saved prompts", labels); if (choice === undefined) return; const index = labels.indexOf(choice); const prompt = prompts[index]; if (prompt === undefined) return; + const action = await ctx.ui.select(`Selected: ${truncate(prompt.text)}`, [ + ACTION_CONFIRM, + ACTION_REMOVE, + ]); + + if (action === ACTION_REMOVE) { + remove(prompt); + ctx.ui.notify(`Removed saved prompt (${prompts.length} pending)`, "info"); + return; + } + + if (action !== ACTION_CONFIRM) return; + if (ctx.isIdle()) { if (ctx.model === undefined) { ctx.ui.notify("Could not run saved prompt, kept in list: no model selected", "error"); diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index 50a991a..f1b2462 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -16,7 +16,7 @@ const setup = (options: { idle?: boolean; authError?: string } = {}) => { const entries: CustomEntry[] = []; const notifications: Array<{ message: string; level: string }> = []; const sent: Array<{ prompt: string; options?: { deliverAs: "followUp" } }> = []; - let selection: string | undefined; + const queuedSelections: Array = []; const sessionManager = { getBranch: () => entries, @@ -32,7 +32,8 @@ const setup = (options: { idle?: boolean; authError?: string } = {}) => { sessionManager, ui: { notify: (message: string, level: string) => notifications.push({ message, level }), - select: async (_title: string, labels: string[]) => selection ?? labels[0], + select: async (_title: string, labels: string[]) => + queuedSelections.length > 0 ? queuedSelections.shift() : labels[0], }, }; let command: ((args: string, ctx: any) => Promise) | undefined; @@ -64,8 +65,8 @@ const setup = (options: { idle?: boolean; authError?: string } = {}) => { handlers, notifications, sent, - setSelection: (value: string) => { - selection = value; + queueSelection: (value: string | undefined) => { + queuedSelections.push(value); }, run: async (args: string) => command!(args, ctx), }; @@ -120,7 +121,7 @@ test("removes the selected duplicate prompt", async () => { await fixture.run("A"); await fixture.run("B"); await fixture.run("A"); - fixture.setSelection("3. A"); + fixture.queueSelection("3. A"); await fixture.run(""); assert.deepEqual(fixture.sent, [{ prompt: "A", options: { deliverAs: "followUp" } }]); @@ -132,9 +133,33 @@ test("removes the selected duplicate after an idle turn is accepted", async () = await fixture.run("A"); await fixture.run("B"); await fixture.run("A"); - fixture.setSelection("3. A"); + fixture.queueSelection("3. A"); await fixture.run(""); await fixture.handlers.get("before_agent_start")!({ prompt: "A" }, fixture.ctx); assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); }); + +test("removes a prompt without running it when Remove is chosen", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("B"); + fixture.queueSelection("2. B"); + fixture.queueSelection("Remove"); + await fixture.run(""); + + assert.deepEqual(fixture.sent, []); + assert.deepEqual(latestPrompts(fixture.entries), ["A"]); + assert.match(fixture.notifications.at(-1)!.message, /Removed saved prompt \(1 pending\)/); +}); + +test("keeps a prompt when the action choice is cancelled", async () => { + const fixture = setup(); + await fixture.run("keep me"); + fixture.queueSelection("1. keep me"); + fixture.queueSelection(undefined); + await fixture.run(""); + + assert.deepEqual(fixture.sent, []); + assert.deepEqual(latestPrompts(fixture.entries), ["keep me"]); +}); From fb45883d43a84375590af6e126e077005d06ff14 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 02:49:37 +0200 Subject: [PATCH 08/16] fix(later): keep queued follow-ups until their turn starts The busy path removed the prompt right after the fire-and-forget sendUserMessage call, so a follow-up that never ran (quit, error, dropped queue) was lost from the persisted list. Track a queue of pending deliveries and remove only when before_agent_start confirms the turn, matching by prompt text so a follow-up overtaken by a user-typed message is not acknowledged early. --- plugins/later/README.md | 2 +- plugins/later/extensions/later.ts | 37 ++++++++++++++++++++++--------- plugins/later/test/later.test.ts | 22 +++++++++++++++++- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/plugins/later/README.md b/plugins/later/README.md index edf1458..7506a37 100644 --- a/plugins/later/README.md +++ b/plugins/later/README.md @@ -16,7 +16,7 @@ pi install npm:@derogab/pi-later - `/later `: save a prompt for later. - `/later`: open the list of saved prompts. Pick one, then choose what to do with it: - - **Confirm**: send the prompt to the session and run it; the prompt is removed from the list. + - **Confirm**: send the prompt to the session and run it; the prompt is removed from the list once its turn actually starts, so a queued prompt that never runs is not lost. - **Remove**: delete the prompt from the list without running it. Pressing Esc on either dialog leaves the list unchanged. diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index 92069b2..8dd3ca8 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -9,14 +9,22 @@ interface SavedPrompt { text: string; } +interface PendingDelivery { + prompt: SavedPrompt; + // An idle send triggers the very next turn, so a text mismatch there means an + // input handler transformed the prompt. A follow-up can be overtaken by a + // user-typed message, so it is acknowledged only by an exact text match. + idle: boolean; +} + export default function (pi: ExtensionAPI) { // Saved prompts, oldest first. Reconstructed from session entries. let prompts: SavedPrompt[] = []; - let pendingIdleDelivery: SavedPrompt | undefined; + let pendingDeliveries: PendingDelivery[] = []; const reconstructState = (ctx: ExtensionContext) => { prompts = []; - pendingIdleDelivery = undefined; + pendingDeliveries = []; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === ENTRY_TYPE) { const data = entry.data as { prompts?: string[] } | undefined; @@ -48,14 +56,19 @@ export default function (pi: ExtensionAPI) { const toLabel = (prompt: SavedPrompt, index: number) => `${index + 1}. ${truncate(prompt.text)}`; - pi.on("before_agent_start", async () => { - const prompt = pendingIdleDelivery; - if (prompt === undefined) return; + pi.on("before_agent_start", async (event) => { + if (pendingDeliveries.length === 0) return; + + // Match by text so a follow-up overtaken by a user-typed message is not + // acknowledged before it is actually delivered. + let index = pendingDeliveries.findIndex((delivery) => delivery.prompt.text === event.prompt); + if (index === -1) { + if (!pendingDeliveries[0].idle) return; + index = 0; + } - // The next accepted idle turn is the pending delivery, even if an input - // handler transformed its text before this event. - pendingIdleDelivery = undefined; - remove(prompt); + const [delivery] = pendingDeliveries.splice(index, 1); + remove(delivery.prompt); }); pi.registerCommand("later", { @@ -117,13 +130,15 @@ export default function (pi: ExtensionAPI) { // ExtensionAPI.sendUserMessage() is fire-and-forget. before_agent_start // acknowledges that Pi accepted this idle turn after all preflight checks. - pendingIdleDelivery = prompt; + pendingDeliveries.push({ prompt, idle: true }); pi.sendUserMessage(prompt.text); return; } + // Keep the prompt in the list until before_agent_start acknowledges the + // follow-up turn, so an undelivered follow-up is not lost. + pendingDeliveries.push({ prompt, idle: false }); pi.sendUserMessage(prompt.text, { deliverAs: "followUp" }); - remove(prompt); ctx.ui.notify("Queued as follow-up", "info"); }, }); diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index f1b2462..fa060e9 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -116,7 +116,7 @@ test("acknowledges an idle prompt transformed by an input handler", async () => assert.deepEqual(latestPrompts(fixture.entries), []); }); -test("removes the selected duplicate prompt", async () => { +test("removes the selected duplicate follow-up when its turn starts", async () => { const fixture = setup({ idle: false }); await fixture.run("A"); await fixture.run("B"); @@ -125,6 +125,10 @@ test("removes the selected duplicate prompt", async () => { await fixture.run(""); assert.deepEqual(fixture.sent, [{ prompt: "A", options: { deliverAs: "followUp" } }]); + // Queueing the follow-up must not remove the prompt before delivery. + assert.deepEqual(latestPrompts(fixture.entries), ["A", "B", "A"]); + + await fixture.handlers.get("before_agent_start")!({ prompt: "A" }, fixture.ctx); assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); }); @@ -140,6 +144,22 @@ test("removes the selected duplicate after an idle turn is accepted", async () = assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); }); +test("keeps a queued follow-up when another turn starts first", async () => { + const fixture = setup({ idle: false }); + await fixture.run("B"); + await fixture.run(""); + + assert.deepEqual(fixture.sent, [{ prompt: "B", options: { deliverAs: "followUp" } }]); + assert.deepEqual(latestPrompts(fixture.entries), ["B"]); + + // A user-typed message overtakes the queued follow-up. + await fixture.handlers.get("before_agent_start")!({ prompt: "typed by user" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["B"]); + + await fixture.handlers.get("before_agent_start")!({ prompt: "B" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + test("removes a prompt without running it when Remove is chosen", async () => { const fixture = setup({ idle: false }); await fixture.run("A"); From c8847e7540f54cbfec25a50b35e7701a69178f55 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 12:15:47 +0200 Subject: [PATCH 09/16] fix(later): distinguish queued follow-up deliveries --- plugins/later/extensions/later.ts | 57 ++++++++++++++++++++----------- plugins/later/test/later.test.ts | 27 ++++++++++----- 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index 8dd3ca8..b275271 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; const ENTRY_TYPE = "later"; @@ -11,10 +12,8 @@ interface SavedPrompt { interface PendingDelivery { prompt: SavedPrompt; - // An idle send triggers the very next turn, so a text mismatch there means an - // input handler transformed the prompt. A follow-up can be overtaken by a - // user-typed message, so it is acknowledged only by an exact text match. idle: boolean; + marker?: string; } export default function (pi: ExtensionAPI) { @@ -49,6 +48,14 @@ export default function (pi: ExtensionAPI) { persist(); }; + const acknowledge = (delivery: PendingDelivery) => { + const index = pendingDeliveries.indexOf(delivery); + if (index === -1) return; + + pendingDeliveries.splice(index, 1); + remove(delivery.prompt); + }; + const truncate = (text: string) => { const singleLine = text.replace(/\s+/g, " ").trim(); return singleLine.length > MAX_LABEL_LENGTH ? `${singleLine.slice(0, MAX_LABEL_LENGTH)}…` : singleLine; @@ -56,19 +63,30 @@ export default function (pi: ExtensionAPI) { const toLabel = (prompt: SavedPrompt, index: number) => `${index + 1}. ${truncate(prompt.text)}`; - pi.on("before_agent_start", async (event) => { - if (pendingDeliveries.length === 0) return; - - // Match by text so a follow-up overtaken by a user-typed message is not - // acknowledged before it is actually delivered. - let index = pendingDeliveries.findIndex((delivery) => delivery.prompt.text === event.prompt); - if (index === -1) { - if (!pendingDeliveries[0].idle) return; - index = 0; - } + pi.on("before_agent_start", async () => { + const delivery = pendingDeliveries.find((delivery) => delivery.idle); + if (delivery !== undefined) acknowledge(delivery); + }); - const [delivery] = pendingDeliveries.splice(index, 1); - remove(delivery.prompt); + pi.on("message_start", async (event) => { + const message = event.message; + if (message.role !== "user" || !("content" in message) || !Array.isArray(message.content)) return; + const content = message.content; + + const delivery = pendingDeliveries.find((delivery) => { + const marker = delivery.marker; + return marker !== undefined && content.some((part) => part.type === "text" && part.text.includes(marker)); + }); + if (delivery === undefined || delivery.marker === undefined) return; + const marker = delivery.marker; + + // sendUserMessage() has no delivery ID for queued follow-ups. The marker + // survives queueing, then is removed before the user message is persisted + // or sent to the model. + message.content = content.map((part) => + part.type === "text" ? { ...part, text: part.text.replace(marker, "") } : part, + ); + acknowledge(delivery); }); pi.registerCommand("later", { @@ -135,10 +153,11 @@ export default function (pi: ExtensionAPI) { return; } - // Keep the prompt in the list until before_agent_start acknowledges the - // follow-up turn, so an undelivered follow-up is not lost. - pendingDeliveries.push({ prompt, idle: false }); - pi.sendUserMessage(prompt.text, { deliverAs: "followUp" }); + // Keep the prompt in the list until its marked follow-up user message + // actually starts, so an undelivered follow-up is not lost. + const delivery = { prompt, idle: false, marker: `\u2063later:${randomUUID()}\u2063` }; + pendingDeliveries.push(delivery); + pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); ctx.ui.notify("Queued as follow-up", "info"); }, }); diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index fa060e9..57fa847 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -24,7 +24,7 @@ const setup = (options: { idle?: boolean; authError?: string } = {}) => { const ctx = { hasUI: true, isIdle: () => options.idle ?? true, - model: { provider: "test", id: "model" }, + model: { provider: "test", id: "model" } as { provider: string; id: string } | undefined, modelRegistry: { getApiKeyAndHeaders: async () => options.authError === undefined ? { ok: true } : { ok: false, error: options.authError }, @@ -74,6 +74,12 @@ const setup = (options: { idle?: boolean; authError?: string } = {}) => { const latestPrompts = (entries: CustomEntry[]) => entries.at(-1)?.data.prompts; +const startUserMessage = async (fixture: ReturnType, text: string) => { + const event = { message: { role: "user", content: [{ type: "text", text }] } }; + await fixture.handlers.get("message_start")!(event, fixture.ctx); + return event; +}; + test("keeps an idle prompt when no model is selected", async () => { const fixture = setup(); fixture.ctx.model = undefined; @@ -124,11 +130,13 @@ test("removes the selected duplicate follow-up when its turn starts", async () = fixture.queueSelection("3. A"); await fixture.run(""); - assert.deepEqual(fixture.sent, [{ prompt: "A", options: { deliverAs: "followUp" } }]); + assert.equal(fixture.sent.length, 1); + assert.equal(fixture.sent[0].prompt.startsWith("A\u2063later:"), true); + assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); // Queueing the follow-up must not remove the prompt before delivery. assert.deepEqual(latestPrompts(fixture.entries), ["A", "B", "A"]); - await fixture.handlers.get("before_agent_start")!({ prompt: "A" }, fixture.ctx); + await startUserMessage(fixture, fixture.sent[0].prompt); assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); }); @@ -144,19 +152,22 @@ test("removes the selected duplicate after an idle turn is accepted", async () = assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); }); -test("keeps a queued follow-up when another turn starts first", async () => { +test("keeps a queued follow-up when a user types the same text", async () => { const fixture = setup({ idle: false }); await fixture.run("B"); await fixture.run(""); - assert.deepEqual(fixture.sent, [{ prompt: "B", options: { deliverAs: "followUp" } }]); + assert.equal(fixture.sent.length, 1); + assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); assert.deepEqual(latestPrompts(fixture.entries), ["B"]); - // A user-typed message overtakes the queued follow-up. - await fixture.handlers.get("before_agent_start")!({ prompt: "typed by user" }, fixture.ctx); + // A user-typed message can overtake the queued follow-up with identical text. + await fixture.handlers.get("before_agent_start")!({ prompt: "B" }, fixture.ctx); + await startUserMessage(fixture, "B"); assert.deepEqual(latestPrompts(fixture.entries), ["B"]); - await fixture.handlers.get("before_agent_start")!({ prompt: "B" }, fixture.ctx); + const event = await startUserMessage(fixture, fixture.sent[0].prompt); + assert.equal(event.message.content[0].text, "B"); assert.deepEqual(latestPrompts(fixture.entries), []); }); From b80fc8c9ad9927cf79e4c545436c080efbe61120 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 12:40:42 +0200 Subject: [PATCH 10/16] fix(later): prevent lost saved prompts Track idle delivery through the input lifecycle and queue a follow-up when authentication makes the idle state stale. --- plugins/later/extensions/later.ts | 47 ++++++++--- plugins/later/test/later.test.ts | 126 ++++++++++++++++++++++++++++-- 2 files changed, 156 insertions(+), 17 deletions(-) diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index b275271..5161975 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -20,10 +20,15 @@ export default function (pi: ExtensionAPI) { // Saved prompts, oldest first. Reconstructed from session entries. let prompts: SavedPrompt[] = []; let pendingDeliveries: PendingDelivery[] = []; + // Tie an idle send to its input event before acknowledging before_agent_start. + let awaitingIdleInput: PendingDelivery | undefined; + let pendingIdleInput: PendingDelivery | undefined; const reconstructState = (ctx: ExtensionContext) => { prompts = []; pendingDeliveries = []; + awaitingIdleInput = undefined; + pendingIdleInput = undefined; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === ENTRY_TYPE) { const data = entry.data as { prompts?: string[] } | undefined; @@ -56,6 +61,12 @@ export default function (pi: ExtensionAPI) { remove(delivery.prompt); }; + const queueFollowUp = (prompt: SavedPrompt) => { + const delivery = { prompt, idle: false, marker: `\u2063later:${randomUUID()}\u2063` }; + pendingDeliveries.push(delivery); + pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); + }; + const truncate = (text: string) => { const singleLine = text.replace(/\s+/g, " ").trim(); return singleLine.length > MAX_LABEL_LENGTH ? `${singleLine.slice(0, MAX_LABEL_LENGTH)}…` : singleLine; @@ -63,9 +74,21 @@ export default function (pi: ExtensionAPI) { const toLabel = (prompt: SavedPrompt, index: number) => `${index + 1}. ${truncate(prompt.text)}`; + pi.on("input", async (event, ctx) => { + const delivery = awaitingIdleInput; + awaitingIdleInput = undefined; + // Any other input invalidates the idle delivery, so its turn cannot + // accidentally acknowledge the saved prompt. + pendingIdleInput = + event.source === "extension" && ctx.isIdle() && delivery?.prompt.text === event.text ? delivery : undefined; + }); + pi.on("before_agent_start", async () => { - const delivery = pendingDeliveries.find((delivery) => delivery.idle); - if (delivery !== undefined) acknowledge(delivery); + // A transformed idle input is only safe to acknowledge when it was the + // sole pending delivery and the input lifecycle identified it. + const delivery = pendingIdleInput; + pendingIdleInput = undefined; + if (pendingDeliveries.length === 1 && delivery?.idle) acknowledge(delivery); }); pi.on("message_start", async (event) => { @@ -146,18 +169,22 @@ export default function (pi: ExtensionAPI) { return; } - // ExtensionAPI.sendUserMessage() is fire-and-forget. before_agent_start - // acknowledges that Pi accepted this idle turn after all preflight checks. - pendingDeliveries.push({ prompt, idle: true }); - pi.sendUserMessage(prompt.text); - return; + // Authentication can yield while another turn begins. Only use an idle + // send if the agent is still idle; otherwise use a race-safe follow-up. + if (ctx.isIdle()) { + // ExtensionAPI.sendUserMessage() is fire-and-forget. before_agent_start + // acknowledges that Pi accepted this idle turn after all preflight checks. + const delivery = { prompt, idle: true }; + pendingDeliveries.push(delivery); + awaitingIdleInput = delivery; + pi.sendUserMessage(prompt.text); + return; + } } // Keep the prompt in the list until its marked follow-up user message // actually starts, so an undelivered follow-up is not lost. - const delivery = { prompt, idle: false, marker: `\u2063later:${randomUUID()}\u2063` }; - pendingDeliveries.push(delivery); - pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); + queueFollowUp(prompt); ctx.ui.notify("Queued as follow-up", "info"); }, }); diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index 57fa847..99c3fab 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -11,29 +11,45 @@ interface CustomEntry { timestamp: string; } -const setup = (options: { idle?: boolean; authError?: string } = {}) => { +const setup = ( + options: { + idle?: boolean; + authError?: string; + getAuth?: () => Promise<{ ok: true } | { ok: false; error: string }>; + initialEntries?: CustomEntry[]; + } = {}, +) => { const handlers = new Map Promise>(); - const entries: CustomEntry[] = []; + const entries = (options.initialEntries ?? []).map((entry) => ({ + ...entry, + data: { prompts: [...entry.data.prompts] }, + })); const notifications: Array<{ message: string; level: string }> = []; + const selections: Array<{ title: string; labels: string[] }> = []; const sent: Array<{ prompt: string; options?: { deliverAs: "followUp" } }> = []; const queuedSelections: Array = []; + let idle = options.idle ?? true; const sessionManager = { getBranch: () => entries, }; const ctx = { hasUI: true, - isIdle: () => options.idle ?? true, + isIdle: () => idle, model: { provider: "test", id: "model" } as { provider: string; id: string } | undefined, modelRegistry: { - getApiKeyAndHeaders: async () => - options.authError === undefined ? { ok: true } : { ok: false, error: options.authError }, + getApiKeyAndHeaders: async () => { + if (options.getAuth !== undefined) return options.getAuth(); + return options.authError === undefined ? { ok: true } : { ok: false, error: options.authError }; + }, }, sessionManager, ui: { notify: (message: string, level: string) => notifications.push({ message, level }), - select: async (_title: string, labels: string[]) => - queuedSelections.length > 0 ? queuedSelections.shift() : labels[0], + select: async (title: string, labels: string[]) => { + selections.push({ title, labels: [...labels] }); + return queuedSelections.length > 0 ? queuedSelections.shift() : labels[0]; + }, }, }; let command: ((args: string, ctx: any) => Promise) | undefined; @@ -64,7 +80,11 @@ const setup = (options: { idle?: boolean; authError?: string } = {}) => { entries, handlers, notifications, + selections, sent, + setIdle: (value: boolean) => { + idle = value; + }, queueSelection: (value: string | undefined) => { queuedSelections.push(value); }, @@ -80,6 +100,10 @@ const startUserMessage = async (fixture: ReturnType, text: string) return event; }; +const submitInput = async (fixture: ReturnType, text: string, source = "extension") => { + await fixture.handlers.get("input")!({ text, source }, fixture.ctx); +}; + test("keeps an idle prompt when no model is selected", async () => { const fixture = setup(); fixture.ctx.model = undefined; @@ -101,6 +125,37 @@ test("keeps an idle prompt when provider authentication is unavailable", async ( assert.match(fixture.notifications.at(-1)!.message, /kept in list: No API key/); }); +test("restores prompts from session state without mutating saved entries", async () => { + const source = setup(); + await source.run("first"); + await source.run("second"); + const persisted = source.entries.map((entry) => [...entry.data.prompts]); + const fixture = setup({ initialEntries: source.entries }); + + await fixture.handlers.get("session_start")!({}, fixture.ctx); + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + fixture.queueSelection("1. first"); + fixture.queueSelection("Remove"); + await fixture.run(""); + + assert.deepEqual(latestPrompts(fixture.entries), ["second"]); + assert.deepEqual( + fixture.entries.slice(0, persisted.length).map((entry) => entry.data.prompts), + persisted, + ); + assert.deepEqual(source.entries.map((entry) => entry.data.prompts), persisted); +}); + +test("truncates labels for long saved prompts", async () => { + const fixture = setup(); + const prompt = "x".repeat(81); + await fixture.run(prompt); + fixture.queueSelection(undefined); + await fixture.run(""); + + assert.deepEqual(fixture.selections, [{ title: "Saved prompts", labels: [`1. ${"x".repeat(80)}…`] }]); +}); + test("removes an idle prompt only when Pi accepts the turn", async () => { const fixture = setup(); await fixture.run("run me"); @@ -109,6 +164,7 @@ test("removes an idle prompt only when Pi accepts the turn", async () => { assert.deepEqual(fixture.sent, [{ prompt: "run me", options: undefined }]); assert.deepEqual(latestPrompts(fixture.entries), ["run me"]); + await submitInput(fixture, "run me"); await fixture.handlers.get("before_agent_start")!({ prompt: "run me" }, fixture.ctx); assert.deepEqual(latestPrompts(fixture.entries), []); }); @@ -118,10 +174,65 @@ test("acknowledges an idle prompt transformed by an input handler", async () => await fixture.run("original"); await fixture.run(""); + await submitInput(fixture, "original"); await fixture.handlers.get("before_agent_start")!({ prompt: "transformed" }, fixture.ctx); assert.deepEqual(latestPrompts(fixture.entries), []); }); +test("keeps an idle delivery when another delivery is pending", async () => { + const fixture = setup(); + await fixture.run("idle prompt"); + await fixture.run(""); + await submitInput(fixture, "idle prompt"); + fixture.setIdle(false); + await fixture.run("queued prompt"); + fixture.queueSelection("2. queued prompt"); + await fixture.run(""); + + await fixture.handlers.get("before_agent_start")!({ prompt: "unrelated" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["idle prompt", "queued prompt"]); +}); + +test("keeps an idle delivery when unrelated input starts first", async () => { + const fixture = setup(); + await fixture.run("idle prompt"); + await fixture.run(""); + await submitInput(fixture, "idle prompt"); + await submitInput(fixture, "unrelated", "interactive"); + + await fixture.handlers.get("before_agent_start")!({ prompt: "unrelated" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["idle prompt"]); +}); + +test("queues a prompt when the agent starts during authentication", async () => { + let authRequested!: () => void; + const requested = new Promise((resolve) => { + authRequested = resolve; + }); + let resolveAuth!: (result: { ok: true }) => void; + const auth = new Promise<{ ok: true }>((resolve) => { + resolveAuth = resolve; + }); + const fixture = setup({ + getAuth: async () => { + authRequested(); + return auth; + }, + }); + await fixture.run("race-safe prompt"); + const confirmation = fixture.run(""); + await requested; + fixture.setIdle(false); + resolveAuth({ ok: true }); + await confirmation; + + assert.equal(fixture.sent.length, 1); + assert.equal(fixture.sent[0].prompt.startsWith("race-safe prompt\u2063later:"), true); + assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); + await fixture.handlers.get("before_agent_start")!({ prompt: "unrelated" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["race-safe prompt"]); +}); + test("removes the selected duplicate follow-up when its turn starts", async () => { const fixture = setup({ idle: false }); await fixture.run("A"); @@ -147,6 +258,7 @@ test("removes the selected duplicate after an idle turn is accepted", async () = await fixture.run("A"); fixture.queueSelection("3. A"); await fixture.run(""); + await submitInput(fixture, "A"); await fixture.handlers.get("before_agent_start")!({ prompt: "A" }, fixture.ctx); assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); From 128ca017d10f16e3f9246ffa5a4f1aa196207190 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 13:23:01 +0200 Subject: [PATCH 11/16] fix(later): keep delivery tracking consistent Track overlapping idle sends and clean up follow-ups removed from Pi's queue so saved prompts are acknowledged only after they actually run. Hide internal delivery identities and preserve live tracking across tree navigation. Gate publishing on the package test suite. --- .github/workflows/publish-pi-later.yml | 1 + plugins/later/extensions/later.ts | 134 ++++++++++++++++++------- plugins/later/test/later.test.ts | 107 +++++++++++++++++--- 3 files changed, 191 insertions(+), 51 deletions(-) diff --git a/.github/workflows/publish-pi-later.yml b/.github/workflows/publish-pi-later.yml index b4418cc..40cd96c 100644 --- a/.github/workflows/publish-pi-later.yml +++ b/.github/workflows/publish-pi-later.yml @@ -25,6 +25,7 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org package-manager-cache: false + - run: npm test - run: npm pack --dry-run - run: npm publish --provenance env: diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index 5161975..b98c9ca 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -5,6 +5,9 @@ const ENTRY_TYPE = "later"; const MAX_LABEL_LENGTH = 80; const ACTION_CONFIRM = "Confirm"; const ACTION_REMOVE = "Remove"; +const MARKER_BOUNDARY = "\u2063"; +const VARIATION_SELECTOR_START = 0xfe00; +const DELIVERY_MARKER_PATTERN = /\u2063[\uFE00-\uFE0F]{32}\u2063/g; interface SavedPrompt { text: string; @@ -13,33 +16,37 @@ interface SavedPrompt { interface PendingDelivery { prompt: SavedPrompt; idle: boolean; - marker?: string; + marker: string; } export default function (pi: ExtensionAPI) { // Saved prompts, oldest first. Reconstructed from session entries. let prompts: SavedPrompt[] = []; let pendingDeliveries: PendingDelivery[] = []; - // Tie an idle send to its input event before acknowledging before_agent_start. + // Tie idle sends to their input lifecycle, including overlapping sends. let awaitingIdleInput: PendingDelivery | undefined; - let pendingIdleInput: PendingDelivery | undefined; + let latestIdleInput: PendingDelivery | undefined; + let pendingIdleInputs: PendingDelivery[] = []; - const reconstructState = (ctx: ExtensionContext) => { - prompts = []; - pendingDeliveries = []; - awaitingIdleInput = undefined; - pendingIdleInput = undefined; + const readPrompts = (ctx: ExtensionContext) => { + let restored: SavedPrompt[] = []; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === ENTRY_TYPE) { const data = entry.data as { prompts?: string[] } | undefined; // Use distinct objects so duplicate prompt text still has stable selection identity. - prompts = (data?.prompts ?? []).map((text) => ({ text })); + restored = (data?.prompts ?? []).map((text) => ({ text })); } } + return restored; }; - pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); - pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + const resetState = (ctx: ExtensionContext) => { + prompts = readPrompts(ctx); + pendingDeliveries = []; + awaitingIdleInput = undefined; + latestIdleInput = undefined; + pendingIdleInputs = []; + }; const persist = () => { pi.appendEntry(ENTRY_TYPE, { prompts: prompts.map((prompt) => prompt.text) }); @@ -58,15 +65,54 @@ export default function (pi: ExtensionAPI) { if (index === -1) return; pendingDeliveries.splice(index, 1); + pendingIdleInputs = pendingIdleInputs.filter((pending) => pending !== delivery); + if (latestIdleInput === delivery) latestIdleInput = undefined; remove(delivery.prompt); }; - const queueFollowUp = (prompt: SavedPrompt) => { - const delivery = { prompt, idle: false, marker: `\u2063later:${randomUUID()}\u2063` }; + const createMarker = () => { + const selectors = [...randomUUID().replaceAll("-", "")] + .map((digit) => String.fromCharCode(VARIATION_SELECTOR_START + Number.parseInt(digit, 16))) + .join(""); + return `${MARKER_BOUNDARY}${selectors}${MARKER_BOUNDARY}`; + }; + + const createDelivery = (prompt: SavedPrompt, idle: boolean): PendingDelivery => { + const delivery = { prompt, idle, marker: createMarker() }; pendingDeliveries.push(delivery); - pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); + return delivery; + }; + + const queueFollowUp = (prompt: SavedPrompt) => { + const delivery = createDelivery(prompt, false); + pi.sendUserMessage(`${delivery.marker}${prompt.text}`, { deliverAs: "followUp" }); }; + const clearDequeuedFollowUps = (ctx: ExtensionContext) => { + if (ctx.hasPendingMessages()) return; + pendingDeliveries = pendingDeliveries.filter((delivery) => delivery.idle); + }; + + const reconstructTreeState = (ctx: ExtensionContext) => { + const restored = readPrompts(ctx); + for (const delivery of pendingDeliveries) { + const previousIndex = prompts.indexOf(delivery.prompt); + if (previousIndex === -1) continue; + + const occurrence = prompts + .slice(0, previousIndex) + .filter((prompt) => prompt.text === delivery.prompt.text).length; + const replacement = restored.filter((prompt) => prompt.text === delivery.prompt.text)[occurrence]; + if (replacement !== undefined) delivery.prompt = replacement; + } + prompts = restored; + clearDequeuedFollowUps(ctx); + }; + + pi.on("session_start", async (_event, ctx) => resetState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructTreeState(ctx)); + pi.on("agent_settled", async (_event, ctx) => clearDequeuedFollowUps(ctx)); + const truncate = (text: string) => { const singleLine = text.replace(/\s+/g, " ").trim(); return singleLine.length > MAX_LABEL_LENGTH ? `${singleLine.slice(0, MAX_LABEL_LENGTH)}…` : singleLine; @@ -77,18 +123,34 @@ export default function (pi: ExtensionAPI) { pi.on("input", async (event, ctx) => { const delivery = awaitingIdleInput; awaitingIdleInput = undefined; - // Any other input invalidates the idle delivery, so its turn cannot - // accidentally acknowledge the saved prompt. - pendingIdleInput = - event.source === "extension" && ctx.isIdle() && delivery?.prompt.text === event.text ? delivery : undefined; + const matchesIdleDelivery = + event.source === "extension" && + ctx.isIdle() && + delivery !== undefined && + event.text.includes(delivery.marker); + if (matchesIdleDelivery) { + pendingIdleInputs.push(delivery); + latestIdleInput = delivery; + return; + } + + latestIdleInput = undefined; + const matchesFollowUp = pendingDeliveries.some( + (pending) => !pending.idle && event.text.includes(pending.marker), + ); + if (!matchesFollowUp) clearDequeuedFollowUps(ctx); }); - pi.on("before_agent_start", async () => { - // A transformed idle input is only safe to acknowledge when it was the - // sole pending delivery and the input lifecycle identified it. - const delivery = pendingIdleInput; - pendingIdleInput = undefined; - if (pendingDeliveries.length === 1 && delivery?.idle) acknowledge(delivery); + pi.on("before_agent_start", async (event, ctx) => { + let delivery = pendingIdleInputs.find((pending) => event.prompt.includes(pending.marker)); + // If another input handler replaced the text, lifecycle order is safe only + // while this is the sole idle input waiting to start. + if (delivery === undefined && pendingIdleInputs.length === 1 && latestIdleInput === pendingIdleInputs[0]) { + delivery = latestIdleInput; + } + if (delivery !== undefined) acknowledge(delivery); + else latestIdleInput = undefined; + clearDequeuedFollowUps(ctx); }); pi.on("message_start", async (event) => { @@ -96,25 +158,22 @@ export default function (pi: ExtensionAPI) { if (message.role !== "user" || !("content" in message) || !Array.isArray(message.content)) return; const content = message.content; - const delivery = pendingDeliveries.find((delivery) => { - const marker = delivery.marker; - return marker !== undefined && content.some((part) => part.type === "text" && part.text.includes(marker)); - }); - if (delivery === undefined || delivery.marker === undefined) return; - const marker = delivery.marker; + const deliveries = pendingDeliveries.filter((delivery) => + content.some((part) => part.type === "text" && part.text.includes(delivery.marker)), + ); - // sendUserMessage() has no delivery ID for queued follow-ups. The marker - // survives queueing, then is removed before the user message is persisted - // or sent to the model. + // Strip even an orphaned marker so internal tracking never reaches session + // history or the model after an extension reload or state reconstruction. message.content = content.map((part) => - part.type === "text" ? { ...part, text: part.text.replace(marker, "") } : part, + part.type === "text" ? { ...part, text: part.text.replace(DELIVERY_MARKER_PATTERN, "") } : part, ); - acknowledge(delivery); + for (const delivery of deliveries) acknowledge(delivery); }); pi.registerCommand("later", { description: "Save a prompt for later, or run a saved prompt", handler: async (args, ctx) => { + clearDequeuedFollowUps(ctx); const text = args.trim(); // /later : save it for later @@ -174,10 +233,9 @@ export default function (pi: ExtensionAPI) { if (ctx.isIdle()) { // ExtensionAPI.sendUserMessage() is fire-and-forget. before_agent_start // acknowledges that Pi accepted this idle turn after all preflight checks. - const delivery = { prompt, idle: true }; - pendingDeliveries.push(delivery); + const delivery = createDelivery(prompt, true); awaitingIdleInput = delivery; - pi.sendUserMessage(prompt.text); + pi.sendUserMessage(`${delivery.marker}${prompt.text}`); return; } } diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index 99c3fab..f9ac3fc 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -27,6 +27,7 @@ const setup = ( const notifications: Array<{ message: string; level: string }> = []; const selections: Array<{ title: string; labels: string[] }> = []; const sent: Array<{ prompt: string; options?: { deliverAs: "followUp" } }> = []; + const pendingMessages: string[] = []; const queuedSelections: Array = []; let idle = options.idle ?? true; @@ -35,6 +36,7 @@ const setup = ( }; const ctx = { hasUI: true, + hasPendingMessages: () => pendingMessages.length > 0, isIdle: () => idle, model: { provider: "test", id: "model" } as { provider: string; id: string } | undefined, modelRegistry: { @@ -70,6 +72,7 @@ const setup = ( }, sendUserMessage: (prompt: string, sendOptions?: { deliverAs: "followUp" }) => { sent.push({ prompt, options: sendOptions }); + if (sendOptions?.deliverAs === "followUp") pendingMessages.push(prompt); }, }; @@ -77,6 +80,11 @@ const setup = ( return { ctx, + dequeueFollowUps: () => pendingMessages.splice(0), + deliverFollowUp: (text: string) => { + const index = pendingMessages.indexOf(text); + if (index !== -1) pendingMessages.splice(index, 1); + }, entries, handlers, notifications, @@ -95,6 +103,7 @@ const setup = ( const latestPrompts = (entries: CustomEntry[]) => entries.at(-1)?.data.prompts; const startUserMessage = async (fixture: ReturnType, text: string) => { + fixture.deliverFollowUp(text); const event = { message: { role: "user", content: [{ type: "text", text }] } }; await fixture.handlers.get("message_start")!(event, fixture.ctx); return event; @@ -104,6 +113,13 @@ const submitInput = async (fixture: ReturnType, text: string, sour await fixture.handlers.get("input")!({ text, source }, fixture.ctx); }; +const assertHasInvisibleMarker = (sent: string, prompt: string) => { + assert.equal(sent.endsWith(prompt), true); + const marker = sent.slice(0, -prompt.length); + assert.notEqual(marker, ""); + assert.doesNotMatch(marker, /[\x20-\x7e]/); +}; + test("keeps an idle prompt when no model is selected", async () => { const fixture = setup(); fixture.ctx.model = undefined; @@ -161,11 +177,14 @@ test("removes an idle prompt only when Pi accepts the turn", async () => { await fixture.run("run me"); await fixture.run(""); - assert.deepEqual(fixture.sent, [{ prompt: "run me", options: undefined }]); + assert.equal(fixture.sent.length, 1); + assertHasInvisibleMarker(fixture.sent[0].prompt, "run me"); assert.deepEqual(latestPrompts(fixture.entries), ["run me"]); - await submitInput(fixture, "run me"); - await fixture.handlers.get("before_agent_start")!({ prompt: "run me" }, fixture.ctx); + await submitInput(fixture, fixture.sent[0].prompt); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); + const event = await startUserMessage(fixture, fixture.sent[0].prompt); + assert.equal(event.message.content[0].text, "run me"); assert.deepEqual(latestPrompts(fixture.entries), []); }); @@ -174,30 +193,47 @@ test("acknowledges an idle prompt transformed by an input handler", async () => await fixture.run("original"); await fixture.run(""); - await submitInput(fixture, "original"); + await submitInput(fixture, fixture.sent[0].prompt); await fixture.handlers.get("before_agent_start")!({ prompt: "transformed" }, fixture.ctx); assert.deepEqual(latestPrompts(fixture.entries), []); }); -test("keeps an idle delivery when another delivery is pending", async () => { +test("acknowledges an idle delivery while a follow-up is pending", async () => { const fixture = setup(); await fixture.run("idle prompt"); await fixture.run(""); - await submitInput(fixture, "idle prompt"); + await submitInput(fixture, fixture.sent[0].prompt); fixture.setIdle(false); await fixture.run("queued prompt"); fixture.queueSelection("2. queued prompt"); await fixture.run(""); + await submitInput(fixture, fixture.sent.at(-1)!.prompt); - await fixture.handlers.get("before_agent_start")!({ prompt: "unrelated" }, fixture.ctx); - assert.deepEqual(latestPrompts(fixture.entries), ["idle prompt", "queued prompt"]); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["queued prompt"]); +}); + +test("acknowledges overlapping idle deliveries independently", async () => { + const fixture = setup(); + await fixture.run("first"); + await fixture.run("second"); + fixture.queueSelection("1. first"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[0].prompt); + fixture.queueSelection("2. second"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[1].prompt); + + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[1].prompt }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), []); }); test("keeps an idle delivery when unrelated input starts first", async () => { const fixture = setup(); await fixture.run("idle prompt"); await fixture.run(""); - await submitInput(fixture, "idle prompt"); + await submitInput(fixture, fixture.sent[0].prompt); await submitInput(fixture, "unrelated", "interactive"); await fixture.handlers.get("before_agent_start")!({ prompt: "unrelated" }, fixture.ctx); @@ -227,7 +263,7 @@ test("queues a prompt when the agent starts during authentication", async () => await confirmation; assert.equal(fixture.sent.length, 1); - assert.equal(fixture.sent[0].prompt.startsWith("race-safe prompt\u2063later:"), true); + assertHasInvisibleMarker(fixture.sent[0].prompt, "race-safe prompt"); assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); await fixture.handlers.get("before_agent_start")!({ prompt: "unrelated" }, fixture.ctx); assert.deepEqual(latestPrompts(fixture.entries), ["race-safe prompt"]); @@ -242,7 +278,7 @@ test("removes the selected duplicate follow-up when its turn starts", async () = await fixture.run(""); assert.equal(fixture.sent.length, 1); - assert.equal(fixture.sent[0].prompt.startsWith("A\u2063later:"), true); + assertHasInvisibleMarker(fixture.sent[0].prompt, "A"); assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); // Queueing the follow-up must not remove the prompt before delivery. assert.deepEqual(latestPrompts(fixture.entries), ["A", "B", "A"]); @@ -258,8 +294,8 @@ test("removes the selected duplicate after an idle turn is accepted", async () = await fixture.run("A"); fixture.queueSelection("3. A"); await fixture.run(""); - await submitInput(fixture, "A"); - await fixture.handlers.get("before_agent_start")!({ prompt: "A" }, fixture.ctx); + await submitInput(fixture, fixture.sent[0].prompt); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); }); @@ -283,6 +319,51 @@ test("keeps a queued follow-up when a user types the same text", async () => { assert.deepEqual(latestPrompts(fixture.entries), []); }); +test("keeps and can rerun a follow-up removed from Pi's queue", async () => { + const fixture = setup({ idle: false }); + await fixture.run("run later"); + await fixture.run(""); + fixture.dequeueFollowUps(); + await fixture.handlers.get("agent_settled")?.({}, fixture.ctx); + + fixture.setIdle(true); + await fixture.run(""); + await submitInput(fixture, fixture.sent[1].prompt); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[1].prompt }, fixture.ctx); + + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("keeps and can rerun a dequeued follow-up after its text is edited", async () => { + const fixture = setup({ idle: false }); + await fixture.run("run later"); + await fixture.run(""); + fixture.dequeueFollowUps(); + await submitInput(fixture, "edited prompt", "interactive"); + await startUserMessage(fixture, "edited prompt"); + assert.deepEqual(latestPrompts(fixture.entries), ["run later"]); + + fixture.setIdle(true); + await fixture.run(""); + await submitInput(fixture, fixture.sent[1].prompt); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[1].prompt }, fixture.ctx); + + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("keeps follow-up delivery tracking across session tree navigation", async () => { + const fixture = setup({ idle: false }); + await fixture.run("queued prompt"); + await fixture.run(""); + const queued = fixture.sent[0].prompt; + + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + const event = await startUserMessage(fixture, queued); + + assert.equal(event.message.content[0].text, "queued prompt"); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + test("removes a prompt without running it when Remove is chosen", async () => { const fixture = setup({ idle: false }); await fixture.run("A"); From 69f39fa3d815ce7e056078f28c4fcf91ba9c7d0d Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 13:55:25 +0200 Subject: [PATCH 12/16] fix(later): preserve prompt delivery semantics Keep saved prompt prefixes available to input extensions and make idle confirmations safe if streaming begins during asynchronous input handling. --- plugins/later/extensions/later.ts | 7 ++++--- plugins/later/test/later.test.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index b98c9ca..b0ae2db 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -85,7 +85,7 @@ export default function (pi: ExtensionAPI) { const queueFollowUp = (prompt: SavedPrompt) => { const delivery = createDelivery(prompt, false); - pi.sendUserMessage(`${delivery.marker}${prompt.text}`, { deliverAs: "followUp" }); + pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); }; const clearDequeuedFollowUps = (ctx: ExtensionContext) => { @@ -232,10 +232,11 @@ export default function (pi: ExtensionAPI) { // send if the agent is still idle; otherwise use a race-safe follow-up. if (ctx.isIdle()) { // ExtensionAPI.sendUserMessage() is fire-and-forget. before_agent_start - // acknowledges that Pi accepted this idle turn after all preflight checks. + // acknowledges an immediate turn, while followUp keeps the send safe if + // streaming starts during asynchronous input handlers. const delivery = createDelivery(prompt, true); awaitingIdleInput = delivery; - pi.sendUserMessage(`${delivery.marker}${prompt.text}`); + pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); return; } } diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index f9ac3fc..7b8a8cb 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -72,7 +72,7 @@ const setup = ( }, sendUserMessage: (prompt: string, sendOptions?: { deliverAs: "followUp" }) => { sent.push({ prompt, options: sendOptions }); - if (sendOptions?.deliverAs === "followUp") pendingMessages.push(prompt); + if (sendOptions?.deliverAs === "followUp" && !idle) pendingMessages.push(prompt); }, }; @@ -114,8 +114,8 @@ const submitInput = async (fixture: ReturnType, text: string, sour }; const assertHasInvisibleMarker = (sent: string, prompt: string) => { - assert.equal(sent.endsWith(prompt), true); - const marker = sent.slice(0, -prompt.length); + assert.equal(sent.startsWith(prompt), true); + const marker = sent.slice(prompt.length); assert.notEqual(marker, ""); assert.doesNotMatch(marker, /[\x20-\x7e]/); }; @@ -179,6 +179,7 @@ test("removes an idle prompt only when Pi accepts the turn", async () => { assert.equal(fixture.sent.length, 1); assertHasInvisibleMarker(fixture.sent[0].prompt, "run me"); + assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); assert.deepEqual(latestPrompts(fixture.entries), ["run me"]); await submitInput(fixture, fixture.sent[0].prompt); @@ -188,6 +189,14 @@ test("removes an idle prompt only when Pi accepts the turn", async () => { assert.deepEqual(latestPrompts(fixture.entries), []); }); +test("preserves a saved prompt prefix for input handlers", async () => { + const fixture = setup({ idle: false }); + await fixture.run("?quick run me"); + await fixture.run(""); + + assertHasInvisibleMarker(fixture.sent[0].prompt, "?quick run me"); +}); + test("acknowledges an idle prompt transformed by an input handler", async () => { const fixture = setup(); await fixture.run("original"); From f2551579fb42eba37581864dbb2b0507e0d7b110 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 14:42:40 +0200 Subject: [PATCH 13/16] fix(later): keep acknowledgement working after duplicate removal Tree navigation to a branch with fewer copies of a delivered prompt left the pending delivery pointing at an object from the replaced prompts array, so acknowledgement's identity lookup missed and the prompt survived delivery. Clamp the remap to the last same-text restored prompt instead of skipping it. --- plugins/later/extensions/later.ts | 5 ++++- plugins/later/test/later.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index b0ae2db..be0fd12 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -102,7 +102,10 @@ export default function (pi: ExtensionAPI) { const occurrence = prompts .slice(0, previousIndex) .filter((prompt) => prompt.text === delivery.prompt.text).length; - const replacement = restored.filter((prompt) => prompt.text === delivery.prompt.text)[occurrence]; + const matches = restored.filter((prompt) => prompt.text === delivery.prompt.text); + // Clamp when navigation shrank the duplicate count: a stale object from the + // old array would make acknowledgement's identity lookup silently miss. + const replacement = matches[occurrence] ?? matches[matches.length - 1]; if (replacement !== undefined) delivery.prompt = replacement; } prompts = restored; diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index 7b8a8cb..9a7ab7f 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -373,6 +373,23 @@ test("keeps follow-up delivery tracking across session tree navigation", async ( assert.deepEqual(latestPrompts(fixture.entries), []); }); +test("acknowledges a delivered duplicate after navigating to a branch with fewer copies", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("A"); + fixture.queueSelection("2. A"); + await fixture.run(""); + const queued = fixture.sent[0].prompt; + assert.deepEqual(latestPrompts(fixture.entries), ["A", "A"]); + + // Navigate to the branch point where only one copy was saved. + fixture.entries.pop(); + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + + await startUserMessage(fixture, queued); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + test("removes a prompt without running it when Remove is chosen", async () => { const fixture = setup({ idle: false }); await fixture.run("A"); From 62bd4b380b1f6dad2fa24ce36069aa39c35b2b06 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 14:57:38 +0200 Subject: [PATCH 14/16] fix(later): stop misattributing foreign turns to idle deliveries A follow-up queued while busy consumes its input event at enqueue time, so when its turn dequeues after an idle send registers itself, before_agent_start fires with no fresh input and the sole-idle-input fallback acknowledged the idle delivery, removing a prompt whose turn never ran. Require that the starting prompt carries no other tracked delivery's marker before using the fallback. --- plugins/later/extensions/later.ts | 10 ++++++++-- plugins/later/test/later.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index be0fd12..8763cea 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -147,8 +147,14 @@ export default function (pi: ExtensionAPI) { pi.on("before_agent_start", async (event, ctx) => { let delivery = pendingIdleInputs.find((pending) => event.prompt.includes(pending.marker)); // If another input handler replaced the text, lifecycle order is safe only - // while this is the sole idle input waiting to start. - if (delivery === undefined && pendingIdleInputs.length === 1 && latestIdleInput === pendingIdleInputs[0]) { + // while this is the sole idle input waiting to start and the turn is not + // another tracked delivery's, e.g. an earlier-queued follow-up dequeued first. + if ( + delivery === undefined && + pendingIdleInputs.length === 1 && + latestIdleInput === pendingIdleInputs[0] && + !pendingDeliveries.some((pending) => event.prompt.includes(pending.marker)) + ) { delivery = latestIdleInput; } if (delivery !== undefined) acknowledge(delivery); diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index 9a7ab7f..6a17645 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -238,6 +238,31 @@ test("acknowledges overlapping idle deliveries independently", async () => { assert.deepEqual(latestPrompts(fixture.entries), []); }); +test("keeps an idle delivery when an earlier-queued follow-up's turn starts first", async () => { + const fixture = setup({ idle: false }); + await fixture.run("queued"); + await fixture.run("idle"); + fixture.queueSelection("1. queued"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[0].prompt); + + // The agent settles with the follow-up still queued; an idle send races in. + fixture.setIdle(true); + fixture.queueSelection("2. idle"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[1].prompt); + + // The earlier-queued follow-up's turn starts before the idle delivery's. + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["queued", "idle"]); + + await startUserMessage(fixture, fixture.sent[0].prompt); + assert.deepEqual(latestPrompts(fixture.entries), ["idle"]); + + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[1].prompt }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + test("keeps an idle delivery when unrelated input starts first", async () => { const fixture = setup(); await fixture.run("idle prompt"); From 446c151373eaebc0a0c8a8f9e3c0903f059b5145 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 15:11:52 +0200 Subject: [PATCH 15/16] fix(later): remap duplicate deliveries to distinct restored prompts Occurrence-based remapping clamped out-of-range duplicates onto the same restored object, so after navigation shrank the copy count only the first acknowledgement removed an entry and later ones no-opped, leaving already-run prompts saved. Claim a distinct same-text copy per tracked prompt instead, while deliveries sharing one prompt object still share a single removal. --- plugins/later/extensions/later.ts | 28 +++++++++++++++++----------- plugins/later/test/later.test.ts | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index 8763cea..527cb34 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -95,18 +95,24 @@ export default function (pi: ExtensionAPI) { const reconstructTreeState = (ctx: ExtensionContext) => { const restored = readPrompts(ctx); + // Each tracked prompt claims a distinct restored copy so every pending + // delivery's acknowledgement still removes its own entry. Deliveries beyond + // the restored count keep a stale object and acknowledge as no-ops, and + // deliveries sharing one prompt object keep sharing it (one removal total). + const available = [...restored]; + const remapped = new Map(); for (const delivery of pendingDeliveries) { - const previousIndex = prompts.indexOf(delivery.prompt); - if (previousIndex === -1) continue; - - const occurrence = prompts - .slice(0, previousIndex) - .filter((prompt) => prompt.text === delivery.prompt.text).length; - const matches = restored.filter((prompt) => prompt.text === delivery.prompt.text); - // Clamp when navigation shrank the duplicate count: a stale object from the - // old array would make acknowledgement's identity lookup silently miss. - const replacement = matches[occurrence] ?? matches[matches.length - 1]; - if (replacement !== undefined) delivery.prompt = replacement; + if (!prompts.includes(delivery.prompt)) continue; + + let replacement = remapped.get(delivery.prompt); + if (replacement === undefined) { + const index = available.findIndex((prompt) => prompt.text === delivery.prompt.text); + if (index === -1) continue; + replacement = available[index]; + available.splice(index, 1); + remapped.set(delivery.prompt, replacement); + } + delivery.prompt = replacement; } prompts = restored; clearDequeuedFollowUps(ctx); diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index 6a17645..ff639d7 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -415,6 +415,27 @@ test("acknowledges a delivered duplicate after navigating to a branch with fewer assert.deepEqual(latestPrompts(fixture.entries), []); }); +test("acknowledges each pending duplicate distinctly after navigating to fewer copies", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("A"); + await fixture.run("A"); + fixture.queueSelection("2. A"); + await fixture.run(""); + fixture.queueSelection("3. A"); + await fixture.run(""); + assert.deepEqual(latestPrompts(fixture.entries), ["A", "A", "A"]); + + // Navigate to the branch point where only two copies were saved. + fixture.entries.pop(); + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + + await startUserMessage(fixture, fixture.sent[0].prompt); + assert.deepEqual(latestPrompts(fixture.entries), ["A"]); + await startUserMessage(fixture, fixture.sent[1].prompt); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + test("removes a prompt without running it when Remove is chosen", async () => { const fixture = setup({ idle: false }); await fixture.run("A"); From 0b21cadcc285c34546bbc8a04f6df21f702acb67 Mon Sep 17 00:00:00 2001 From: Gabriele De Rosa Date: Fri, 14 Aug 2026 15:19:10 +0200 Subject: [PATCH 16/16] fix(later): preserve duplicate occurrence identity across remap Remapping a pending delivery to the first same-text restored copy lost which duplicate it targeted. Re-selecting that same entry after tree reconstruction then held a different prompt object than the pending delivery, so the two deliveries removed two saved entries instead of sharing one removal. Prefer the restored copy at the same same-text occurrence, falling back to an unclaimed copy only when that occurrence no longer exists. --- plugins/later/extensions/later.ts | 9 ++++++++- plugins/later/test/later.test.ts | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts index 527cb34..7b69733 100644 --- a/plugins/later/extensions/later.ts +++ b/plugins/later/extensions/later.ts @@ -106,7 +106,14 @@ export default function (pi: ExtensionAPI) { let replacement = remapped.get(delivery.prompt); if (replacement === undefined) { - const index = available.findIndex((prompt) => prompt.text === delivery.prompt.text); + // Prefer the restored copy at the same same-text occurrence, so a + // delivery for a later duplicate can still share one removal with a + // re-selection of that same entry after reconstruction. + const sameText = (prompt: SavedPrompt) => prompt.text === delivery.prompt.text; + const ordinal = prompts.slice(0, prompts.indexOf(delivery.prompt)).filter(sameText).length; + const occurrence = restored.filter(sameText)[ordinal]; + let index = occurrence === undefined ? -1 : available.indexOf(occurrence); + if (index === -1) index = available.findIndex(sameText); if (index === -1) continue; replacement = available[index]; available.splice(index, 1); diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts index ff639d7..eb88a9e 100644 --- a/plugins/later/test/later.test.ts +++ b/plugins/later/test/later.test.ts @@ -436,6 +436,26 @@ test("acknowledges each pending duplicate distinctly after navigating to fewer c assert.deepEqual(latestPrompts(fixture.entries), []); }); +test("keeps a duplicate's occurrence identity across session tree navigation", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("A"); + fixture.queueSelection("2. A"); + await fixture.run(""); + assert.deepEqual(latestPrompts(fixture.entries), ["A", "A"]); + + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + + // Re-selecting the same second copy must share its pending delivery's + // prompt object, so both deliveries remove one saved entry total. + fixture.queueSelection("2. A"); + await fixture.run(""); + + await startUserMessage(fixture, fixture.sent[0].prompt); + await startUserMessage(fixture, fixture.sent[1].prompt); + assert.deepEqual(latestPrompts(fixture.entries), ["A"]); +}); + test("removes a prompt without running it when Remove is chosen", async () => { const fixture = setup({ idle: false }); await fixture.run("A");