diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d319f940..d4217f38 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -235,7 +235,7 @@ jobs:
# regressions of the same class.
# Claude Code slash commands — `.md` files with `!`\`plannotator ...\`` invocation syntax
$cmdDir = "$env:USERPROFILE\.claude\commands"
- foreach ($file in @("plannotator-review.md", "plannotator-annotate.md", "plannotator-last.md")) {
+ foreach ($file in @("plannotator-review.md", "plannotator-annotate.md", "plannotator-last.md", "plannotator-copy-last.md")) {
$path = Join-Path $cmdDir $file
if (-not (Test-Path $path)) {
throw "Expected slash command file missing: $path"
@@ -245,7 +245,7 @@ jobs:
throw "Slash command file $file is missing the '!' shell-invocation prefix. Content: $content"
}
}
- Write-Host "✓ All three Claude Code slash command files contain the '!' prefix"
+ Write-Host "✓ All Claude Code slash command files contain the '!' prefix"
# Gemini slash commands — `.toml` files with `!{plannotator ...}` invocation syntax.
# Same `^^!` cmd-escape class as the Claude Code files. The earlier integration
diff --git a/apps/copilot/commands/plannotator-copy-last.md b/apps/copilot/commands/plannotator-copy-last.md
new file mode 100644
index 00000000..863cc36c
--- /dev/null
+++ b/apps/copilot/commands/plannotator-copy-last.md
@@ -0,0 +1,6 @@
+---
+description: Copy the last rendered assistant message to the clipboard
+allowed-tools: shell(plannotator:*)
+---
+
+!`plannotator copilot-copy-last`
diff --git a/apps/hook/commands/plannotator-copy-last.md b/apps/hook/commands/plannotator-copy-last.md
new file mode 100644
index 00000000..a4bda5c4
--- /dev/null
+++ b/apps/hook/commands/plannotator-copy-last.md
@@ -0,0 +1,6 @@
+---
+description: Copy the last rendered assistant message to the clipboard
+allowed-tools: Bash(plannotator:*)
+---
+
+!`plannotator copy-last`
diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts
index b39ef544..66fb0aaf 100644
--- a/apps/hook/server/cli.ts
+++ b/apps/hook/server/cli.ts
@@ -17,6 +17,7 @@ export function formatTopLevelHelp(): string {
" plannotator review [PR_URL]",
" plannotator annotate [--no-jina]",
" plannotator last",
+ " plannotator copy-last",
" plannotator archive",
" plannotator sessions",
" plannotator improve-context",
@@ -35,6 +36,7 @@ export function formatInteractiveNoArgClarification(): string {
" plannotator review",
" plannotator annotate ",
" plannotator last",
+ " plannotator copy-last",
" plannotator archive",
" plannotator sessions",
"",
diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts
index 89284c5e..468ae6e2 100644
--- a/apps/hook/server/index.ts
+++ b/apps/hook/server/index.ts
@@ -37,7 +37,11 @@
* - Annotate the last assistant message from a Copilot CLI session
* - Parses events.jsonl from session state
*
- * 8. Improve Context (`plannotator improve-context`):
+ * 8. Copy Last (`plannotator copy-last` / `plannotator copilot-copy-last`):
+ * - Copy the last rendered assistant message to the system clipboard
+ * - Same lookup as annotate-last, but no UI is opened
+ *
+ * 9. Improve Context (`plannotator improve-context`):
* - Spawned by PreToolUse hook on EnterPlanMode
* - Reads improvement hook file from ~/.plannotator/hooks/
* - Returns additionalContext or silently passes through
@@ -76,6 +80,7 @@ import { statSync, rmSync, realpathSync, existsSync } from "fs";
import { parseRemoteUrl } from "@plannotator/shared/repo";
import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions";
import { openBrowser } from "@plannotator/server/browser";
+import { copyToClipboard } from "@plannotator/server/clipboard";
import { detectProjectName } from "@plannotator/server/project";
import { hostnameOrFallback } from "@plannotator/shared/project";
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";
@@ -729,6 +734,86 @@ if (args[0] === "sessions") {
}
process.exit(0);
+} else if (args[0] === "copy-last") {
+ // ============================================
+ // COPY LAST MESSAGE TO CLIPBOARD MODE
+ // ============================================
+ //
+ // Finds the last rendered assistant message (same lookup as annotate-last)
+ // and writes it to the system clipboard. No UI, no server — just copy.
+
+ const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
+ const codexThreadId = process.env.CODEX_THREAD_ID;
+
+ let lastMessage: RenderedMessage | null = null;
+
+ if (codexThreadId) {
+ const rolloutPath = findCodexRolloutByThreadId(codexThreadId);
+ if (rolloutPath) {
+ const msg = getLastCodexMessage(rolloutPath);
+ if (msg) {
+ lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] };
+ }
+ }
+ } else {
+ function tryLogCandidates(getPaths: () => string[]): void {
+ if (lastMessage) return;
+ for (const logPath of getPaths()) {
+ lastMessage = getLastRenderedMessage(logPath);
+ if (lastMessage) return;
+ }
+ }
+
+ const ppidLog = resolveSessionLogByPpid();
+ tryLogCandidates(() => ppidLog ? [ppidLog] : []);
+ tryLogCandidates(() => findSessionLogsForCwd(projectRoot));
+ tryLogCandidates(() => findSessionLogsByAncestorWalk(projectRoot));
+ }
+
+ if (!lastMessage) {
+ console.error("No rendered assistant message found in session logs.");
+ process.exit(1);
+ }
+
+ const result = await copyToClipboard(lastMessage.text);
+
+ if (result.ok) {
+ console.log(`Copied last message to clipboard (${lastMessage.text.length} chars).`);
+ process.exit(0);
+ } else {
+ console.error(`Failed to copy to clipboard: ${result.error}`);
+ process.exit(1);
+ }
+
+} else if (args[0] === "copilot-copy-last") {
+ // ============================================
+ // COPILOT CLI COPY LAST MESSAGE TO CLIPBOARD MODE
+ // ============================================
+
+ const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
+ const sessionDir = findCopilotSessionForCwd(projectRoot);
+
+ if (!sessionDir) {
+ console.error("No Copilot CLI session found.");
+ process.exit(1);
+ }
+
+ const msg = getLastCopilotMessage(sessionDir);
+ if (!msg) {
+ console.error("No assistant message found in Copilot CLI session.");
+ process.exit(1);
+ }
+
+ const result = await copyToClipboard(msg.text);
+
+ if (result.ok) {
+ console.log(`Copied last message to clipboard (${msg.text.length} chars).`);
+ process.exit(0);
+ } else {
+ console.error(`Failed to copy to clipboard: ${result.error}`);
+ process.exit(1);
+ }
+
} else if (args[0] === "archive") {
// ============================================
// ARCHIVE BROWSER MODE
diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md
index 3f53ea77..915d1de9 100644
--- a/apps/pi-extension/README.md
+++ b/apps/pi-extension/README.md
@@ -178,6 +178,10 @@ Run `/plannotator-annotate ` to open any markdown file in the annotatio
Run `/plannotator-last` to annotate the agent's most recent response. The message opens in the annotation UI where you can highlight text, add comments, and send structured feedback back to the agent.
+### Copy last message
+
+Run `/plannotator-copy-last` to copy the agent's most recent response to the system clipboard. No UI opens — the message is written directly via `pbcopy` (macOS), `clip.exe` (Windows/WSL), or `wl-copy` / `xclip` / `xsel` (Linux).
+
### Archive browser
The Plannotator archive browser is available through the shared event API as `archive`, which opens the saved plan/decision browser for future callers. The orchestrator does not expose a dedicated archive command yet.
@@ -196,6 +200,7 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr
| `/plannotator-review` | Open code review UI for current changes |
| `/plannotator-annotate ` | Open markdown file in annotation UI |
| `/plannotator-last` | Annotate the last assistant message |
+| `/plannotator-copy-last` | Copy the last assistant message to the clipboard |
## Flags
diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts
index afe9f472..b3df3c9f 100644
--- a/apps/pi-extension/index.ts
+++ b/apps/pi-extension/index.ts
@@ -39,6 +39,7 @@ import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
import { urlToMarkdown } from "./generated/url-to-markdown.js";
import { loadConfig, resolveUseJina } from "./generated/config.js";
+import { copyToClipboard } from "./generated/clipboard.js";
import {
getLastAssistantMessageText,
hasPlanBrowserHtml,
@@ -518,6 +519,30 @@ export default function plannotator(pi: ExtensionAPI): void {
},
});
+ pi.registerCommand("plannotator-copy-last", {
+ description: "Copy the last assistant message to the clipboard",
+ handler: async (_args, ctx) => {
+ const lastText = await getLastAssistantMessageText(ctx);
+ if (!lastText) {
+ ctx.ui.notify("No assistant message found in session.", "error");
+ return;
+ }
+
+ const result = await copyToClipboard(lastText);
+ if (result.ok) {
+ ctx.ui.notify(
+ `Copied last message to clipboard (${lastText.length} chars).`,
+ "info",
+ );
+ } else {
+ ctx.ui.notify(
+ `Failed to copy to clipboard: ${result.error}`,
+ "error",
+ );
+ }
+ },
+ });
+
pi.registerCommand("plannotator-archive", {
description: "Browse saved plan decisions",
handler: async (_args, ctx) => {
diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh
index e7dfce03..ab507072 100755
--- a/apps/pi-extension/vendor.sh
+++ b/apps/pi-extension/vendor.sh
@@ -21,6 +21,12 @@ for f in codex-review claude-review path-utils; do
> "generated/$f.ts"
done
+# Vendor runtime-agnostic server utilities (no Bun APIs).
+for f in clipboard; do
+ src="../../packages/server/$f.ts"
+ printf '// @generated — DO NOT EDIT. Source: packages/server/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
+done
+
for f in index types provider session-manager endpoints context base-session; do
src="../../packages/ai/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts"
diff --git a/packages/server/clipboard.ts b/packages/server/clipboard.ts
new file mode 100644
index 00000000..4032ce32
--- /dev/null
+++ b/packages/server/clipboard.ts
@@ -0,0 +1,91 @@
+/**
+ * Cross-platform clipboard utility. Runtime-agnostic (Node + Bun).
+ *
+ * Writes text to the system clipboard. Returns `{ ok: true, tool }` on success
+ * or `{ ok: false, error }` when no clipboard tool is available or all attempts
+ * failed.
+ *
+ * Platform support:
+ * - macOS: pbcopy
+ * - Windows: clip.exe
+ * - WSL: clip.exe (forwards to the Windows host clipboard)
+ * - Linux: wl-copy (Wayland) → xclip → xsel (first that exists wins)
+ */
+
+import { spawn } from "node:child_process";
+import { readFileSync } from "node:fs";
+import os from "node:os";
+
+export interface ClipboardResult {
+ ok: boolean;
+ /** Tool name used for the successful write (e.g. "pbcopy", "clip.exe"). */
+ tool?: string;
+ error?: string;
+}
+
+function isWSL(): boolean {
+ if (os.platform() !== "linux") return false;
+ if (os.release().toLowerCase().includes("microsoft")) return true;
+ try {
+ const content = readFileSync("/proc/version", "utf-8").toLowerCase();
+ return content.includes("wsl") || content.includes("microsoft");
+ } catch {
+ return false;
+ }
+}
+
+function pipeToCommand(cmd: string, args: string[], text: string): Promise {
+ return new Promise((resolve) => {
+ try {
+ const proc = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
+ proc.on("error", () => resolve(false));
+ proc.on("exit", (code) => resolve(code === 0));
+ proc.stdin.end(text);
+ } catch {
+ resolve(false);
+ }
+ });
+}
+
+export async function copyToClipboard(text: string): Promise {
+ const platform = os.platform();
+
+ if (platform === "darwin") {
+ const ok = await pipeToCommand("pbcopy", [], text);
+ return ok
+ ? { ok: true, tool: "pbcopy" }
+ : { ok: false, error: "pbcopy failed" };
+ }
+
+ if (platform === "win32") {
+ const ok = await pipeToCommand("clip.exe", [], text);
+ return ok
+ ? { ok: true, tool: "clip.exe" }
+ : { ok: false, error: "clip.exe failed" };
+ }
+
+ if (platform === "linux") {
+ if (isWSL()) {
+ const ok = await pipeToCommand("clip.exe", [], text);
+ if (ok) return { ok: true, tool: "clip.exe" };
+ }
+
+ const candidates: { cmd: string; args: string[] }[] = [
+ { cmd: "wl-copy", args: [] },
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
+ { cmd: "xsel", args: ["--clipboard", "--input"] },
+ ];
+
+ for (const { cmd, args } of candidates) {
+ const ok = await pipeToCommand(cmd, args, text);
+ if (ok) return { ok: true, tool: cmd };
+ }
+
+ return {
+ ok: false,
+ error: "No clipboard tool found. Install wl-copy, xclip, or xsel.",
+ };
+ }
+
+ return { ok: false, error: `Unsupported platform: ${platform}` };
+}
diff --git a/packages/server/package.json b/packages/server/package.json
index 10d09ccc..8da30aa1 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -11,6 +11,7 @@
"./annotate": "./annotate.ts",
"./remote": "./remote.ts",
"./browser": "./browser.ts",
+ "./clipboard": "./clipboard.ts",
"./storage": "./storage.ts",
"./git": "./git.ts",
"./p4": "./p4.ts",
diff --git a/scripts/install.cmd b/scripts/install.cmd
index a4f347cb..2b65f617 100644
--- a/scripts/install.cmd
+++ b/scripts/install.cmd
@@ -454,6 +454,17 @@ echo Address the annotation feedback above. The user has reviewed your last mess
echo Installed /plannotator-last command to !CLAUDE_COMMANDS_DIR!\plannotator-last.md
+(
+echo ---
+echo description: Copy the last rendered assistant message to the clipboard
+echo allowed-tools: Bash^(plannotator:*^)
+echo ---
+echo.
+echo ^^!`plannotator copy-last`
+) > "!CLAUDE_COMMANDS_DIR!\plannotator-copy-last.md"
+
+echo Installed /plannotator-copy-last command to !CLAUDE_COMMANDS_DIR!\plannotator-copy-last.md
+
REM Install skills (requires git)
where git >nul 2>&1
if !ERRORLEVEL! equ 0 (
@@ -591,7 +602,7 @@ echo Then install the Claude Code plugin:
echo /plugin marketplace add backnotprop/plannotator
echo /plugin install plannotator@plannotator
echo.
-echo The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready to use!
+echo The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready to use!
REM Warn if plannotator is configured in both settings.json hooks AND the plugin (causes double execution)
REM Only warn when the plugin is installed — manual-only users won't have overlap
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index d521a376..f5a97081 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -338,6 +338,18 @@ Address the annotation feedback above. The user has reviewed your last message a
Write-Host "Installed /plannotator-last command to $claudeCommandsDir\plannotator-last.md"
+# Install Claude Code /plannotator-copy-last slash command
+@'
+---
+description: Copy the last rendered assistant message to the clipboard
+allowed-tools: Bash(plannotator:*)
+---
+
+!`plannotator copy-last`
+'@ | Set-Content -Path "$claudeCommandsDir\plannotator-copy-last.md"
+
+Write-Host "Installed /plannotator-copy-last command to $claudeCommandsDir\plannotator-copy-last.md"
+
# Install OpenCode slash command
$opencodeCommandsDir = "$env:USERPROFILE\.config\opencode\command"
New-Item -ItemType Directory -Force -Path $opencodeCommandsDir | Out-Null
@@ -532,7 +544,7 @@ Write-Host "Add the plugin to your opencode.json:"
Write-Host ""
Write-Host ' "plugin": ["@plannotator/opencode@latest"]'
Write-Host ""
-Write-Host "Then restart OpenCode. The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready!"
+Write-Host "Then restart OpenCode. The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready!"
Write-Host ""
Write-Host "=========================================="
Write-Host " PI USERS"
@@ -550,7 +562,7 @@ Write-Host "Install the Claude Code plugin:"
Write-Host " /plugin marketplace add backnotprop/plannotator"
Write-Host " /plugin install plannotator@plannotator"
Write-Host ""
-Write-Host "The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready to use after you restart Claude Code!"
+Write-Host "The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready to use after you restart Claude Code!"
# Warn if plannotator is configured in both settings.json hooks AND the plugin (causes double execution)
# Only warn when the plugin is installed — manual-only users won't have overlap
diff --git a/scripts/install.sh b/scripts/install.sh
index 69fa110b..a26ee3e6 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -407,6 +407,18 @@ COMMAND_EOF
echo "Installed /plannotator-last command to ${CLAUDE_COMMANDS_DIR}/plannotator-last.md"
+# Install /plannotator-copy-last slash command for Claude Code
+cat > "$CLAUDE_COMMANDS_DIR/plannotator-copy-last.md" << 'COMMAND_EOF'
+---
+description: Copy the last rendered assistant message to the clipboard
+allowed-tools: Bash(plannotator:*)
+---
+
+!`plannotator copy-last`
+COMMAND_EOF
+
+echo "Installed /plannotator-copy-last command to ${CLAUDE_COMMANDS_DIR}/plannotator-copy-last.md"
+
# Install OpenCode slash command
OPENCODE_COMMANDS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command"
mkdir -p "$OPENCODE_COMMANDS_DIR"
@@ -593,7 +605,7 @@ echo "Add the plugin to your opencode.json:"
echo ""
echo ' "plugin": ["@plannotator/opencode@latest"]'
echo ""
-echo "Then restart OpenCode. The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready!"
+echo "Then restart OpenCode. The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready!"
echo ""
echo "=========================================="
echo " PI USERS"
@@ -624,7 +636,7 @@ echo "Install the Claude Code plugin:"
echo " /plugin marketplace add backnotprop/plannotator"
echo " /plugin install plannotator@plannotator"
echo ""
-echo "The /plannotator-review, /plannotator-annotate, and /plannotator-last commands are ready to use after you restart Claude Code!"
+echo "The /plannotator-review, /plannotator-annotate, /plannotator-last, and /plannotator-copy-last commands are ready to use after you restart Claude Code!"
# Warn if plannotator is configured in both settings.json hooks AND the plugin (causes double execution)
# Only warn when the plugin is installed — manual-only users won't have overlap
diff --git a/scripts/install.test.ts b/scripts/install.test.ts
index e9d0d8f4..b1eb7a8a 100644
--- a/scripts/install.test.ts
+++ b/scripts/install.test.ts
@@ -64,6 +64,7 @@ describe("install.sh", () => {
expect(script).toContain("plannotator-review.md");
expect(script).toContain("plannotator-annotate.md");
expect(script).toContain("plannotator-last.md");
+ expect(script).toContain("plannotator-copy-last.md");
expect(script).toContain("CLAUDE_COMMANDS_DIR");
expect(script).toContain("OPENCODE_COMMANDS_DIR");
});
@@ -133,6 +134,7 @@ describe("install.ps1", () => {
expect(script).toContain("plannotator-review.md");
expect(script).toContain("plannotator-annotate.md");
expect(script).toContain("plannotator-last.md");
+ expect(script).toContain("plannotator-copy-last.md");
});
});
@@ -194,6 +196,7 @@ describe("install.cmd", () => {
expect(script).toContain("plannotator-review.md");
expect(script).toContain("plannotator-annotate.md");
expect(script).toContain("plannotator-last.md");
+ expect(script).toContain("plannotator-copy-last.md");
});
test("Gemini settings merge uses || idiom (issue #506 regression)", () => {