From 9374bbf8cede0cd39a20428a2889294b2dce7f93 Mon Sep 17 00:00:00 2001
From: Ben Newman <158209000+BenNewman100@users.noreply.github.com>
Date: Fri, 21 Aug 2026 16:24:51 -0400
Subject: [PATCH 1/2] feat(annotate-last): --exclude-active-turn ignores the
launching turn
When an agent launches annotate-last from its own tool call (rather than
a !-inline slash command), it can still write to the transcript after the
process starts, and its acknowledgement becomes "the last message". The
Codex path already excludes the active turn unconditionally; Claude Code
did not.
- --exclude-active-turn (opt-in, Claude Code path): select and list only
assistant messages before the newest human prompt on the active branch
- Fail open to file order when the cutoff empties the branch (fresh
/compact), mirroring the existing empty-branch fallback
- A cutoff that empties the right log is reported and stops the candidate
walk instead of drifting to an older session
- Flag accepted and inert on the stdin, Codex, Droid, and Copilot paths
---
apps/hook/server/cli.test.ts | 6 +-
apps/hook/server/cli.ts | 10 +-
apps/hook/server/index.ts | 30 ++++--
apps/hook/server/session-log.test.ts | 144 ++++++++++++++++++++++++++-
apps/hook/server/session-log.ts | 82 +++++++++++++--
5 files changed, 255 insertions(+), 17 deletions(-)
diff --git a/apps/hook/server/cli.test.ts b/apps/hook/server/cli.test.ts
index 97c1f399c..50bc662e2 100644
--- a/apps/hook/server/cli.test.ts
+++ b/apps/hook/server/cli.test.ts
@@ -33,7 +33,7 @@ describe("CLI top-level help", () => {
expect(output).toContain("plannotator review [--git | --gitbutler] [--tailscale] [PR_URL]");
expect(output).toContain("plannotator annotate ");
expect(output).toContain("[--markdown] [--no-jina]");
- expect(output).toContain("plannotator annotate-last [--stdin]");
+ expect(output).toContain("plannotator annotate-last [--stdin] [--exclude-active-turn]");
expect(output).toContain("plannotator copilot-last [--gate] [--json] [--hook]");
expect(output).toContain("plannotator setup-goal ");
expect(output).toContain("plannotator uninstall [--purge] [--yes]");
@@ -115,6 +115,10 @@ describe("CLI subcommand help", () => {
expect(formatSubcommandHelp("annotate-last")).not.toContain(
"--require-approval",
);
+ expect(formatSubcommandHelp("annotate-last")).toContain("--exclude-active-turn");
+ expect(formatSubcommandHelp("annotate-last")).toContain(
+ "plannotator last [--stdin] [--exclude-active-turn]",
+ );
expect(formatSubcommandHelp("sessions")).toContain("--open [N]");
expect(formatSubcommandHelp("uninstall")).toContain(
"Local plans, history, drafts",
diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts
index a76e95629..abbfa8bcb 100644
--- a/apps/hook/server/cli.ts
+++ b/apps/hook/server/cli.ts
@@ -143,7 +143,7 @@ export function formatTopLevelHelp(): string {
" plannotator [--browser ]",
" plannotator review [--git | --gitbutler] [--tailscale] [PR_URL]",
" plannotator annotate [--markdown] [--no-jina] [--tailscale] [--gate] [--json] [--hook] [--require-approval] [--result-file ]",
- " plannotator annotate-last [--stdin] [--tailscale] [--gate] [--json] [--hook]",
+ " plannotator annotate-last [--stdin] [--exclude-active-turn] [--tailscale] [--gate] [--json] [--hook]",
" plannotator copilot-last [--gate] [--json] [--hook]",
" plannotator setup-goal [--json]",
" plannotator last",
@@ -211,13 +211,17 @@ const SUBCOMMAND_HELP: Record = {
].join("\n"),
"annotate-last": [
"Usage:",
- " plannotator annotate-last [--stdin] [--tailscale] [--gate] [--json] [--hook]",
- " plannotator last [--stdin] [--tailscale] [--gate] [--json] [--hook]",
+ " plannotator annotate-last [--stdin] [--exclude-active-turn] [--tailscale] [--gate] [--json] [--hook]",
+ " plannotator last [--stdin] [--exclude-active-turn] [--tailscale] [--gate] [--json] [--hook]",
"",
"Annotate the last assistant message from the current agent session.",
"",
"Options:",
" --stdin Read the message content from stdin instead of session logs",
+ " --exclude-active-turn",
+ " Ignore messages from the turn that launched this command. Pass it when",
+ " the launching agent can write to the transcript after the command starts",
+ " (Claude Code; other agents ignore it)",
" --tailscale Publish the loopback session over your tailnet via tailscale serve (HTTPS)",
" --gate Add an Approve button (review-gate UX)",
" --json Emit a structured decision JSON on stdout",
diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts
index 965792b03..1e31b4f32 100644
--- a/apps/hook/server/index.ts
+++ b/apps/hook/server/index.ts
@@ -157,6 +157,7 @@ import {
findSessionLogsByAncestorWalk,
findSessionLogsForCwd,
getRecentRenderedMessages,
+ getRecentRenderedMessagesDetailed,
resolveDroidSessionLogForCwd,
resolveSessionLogByAncestorPids,
resolveSessionLogByCwdScan,
@@ -319,6 +320,12 @@ const hookIdx = args.indexOf("--hook");
const hookFlag = hookIdx !== -1;
if (hookFlag) args.splice(hookIdx, 1);
if (hookFlag) gateFlag = true;
+// annotate-last: --exclude-active-turn drops messages from the turn that
+// launched the command (Claude Code path). Accepted everywhere so launchers
+// need not know the vendor; Codex already applies the cutoff unconditionally.
+const excludeActiveTurnIdx = args.indexOf("--exclude-active-turn");
+const excludeActiveTurnFlag = excludeActiveTurnIdx !== -1;
+if (excludeActiveTurnFlag) args.splice(excludeActiveTurnIdx, 1);
const renderHtmlIdx = args.indexOf("--render-html");
const renderHtmlFlag = renderHtmlIdx !== -1;
if (renderHtmlFlag) args.splice(renderHtmlIdx, 1);
@@ -1364,6 +1371,10 @@ if (args[0] === "sessions") {
const RECENT_MESSAGES_LIMIT = 25;
let lastMessage: RenderedMessage | null = null;
let recentMessages: RenderedMessage[] = [];
+ // Set when --exclude-active-turn found the right log but nothing before
+ // the current turn. Distinct from "no log yielded a message": the
+ // candidate walk must stop there rather than drift to an older session.
+ let emptiedByActiveTurn = false;
// Copilot CLI sets no env fingerprint, so detection matches ancestor pids
// against session-state inuse locks (spawns ps). Only attempted when no
@@ -1465,7 +1476,7 @@ if (args[0] === "sessions") {
/** Try each log path, return the first that yields a message. */
function tryLogCandidates(label: string, getPaths: () => string[]): void {
- if (lastMessage) return;
+ if (lastMessage || emptiedByActiveTurn) return;
const paths = getPaths();
if (process.env.PLANNOTATOR_DEBUG) {
console.error(`[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`);
@@ -1474,12 +1485,17 @@ if (args[0] === "sessions") {
// Claude Code transcripts are trees: `/rewind` re-parents the next
// message rather than truncating, so a file-order read returns
// orphaned messages. Follow the id chain instead.
- const recent = getRecentRenderedMessages(logPath, RECENT_MESSAGES_LIMIT, {
+ const result = getRecentRenderedMessagesDetailed(logPath, RECENT_MESSAGES_LIMIT, {
activeBranchOnly: true,
+ excludeActiveTurn: excludeActiveTurnFlag,
});
- if (recent.length > 0) {
- recentMessages = recent;
- lastMessage = recent[0];
+ if (result.messages.length > 0) {
+ recentMessages = result.messages;
+ lastMessage = result.messages[0];
+ return;
+ }
+ if (result.emptiedByActiveTurn) {
+ emptiedByActiveTurn = true;
return;
}
}
@@ -1503,7 +1519,9 @@ if (args[0] === "sessions") {
if (!lastMessage) {
console.error(stdinFlag
? "No message content received on stdin."
- : "No rendered assistant message found in session logs.");
+ : emptiedByActiveTurn
+ ? "No assistant message precedes the current turn (--exclude-active-turn)."
+ : "No rendered assistant message found in session logs.");
process.exit(1);
}
diff --git a/apps/hook/server/session-log.test.ts b/apps/hook/server/session-log.test.ts
index 458a1562f..c471d272a 100644
--- a/apps/hook/server/session-log.test.ts
+++ b/apps/hook/server/session-log.test.ts
@@ -7,7 +7,7 @@
* Each test builds a minimal log and verifies the extraction logic.
*/
-import { describe, expect, test } from "bun:test";
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
parseSessionLog,
isHumanPrompt,
@@ -15,6 +15,8 @@ import {
extractLastRenderedMessage,
extractRecentRenderedMessages,
getRecentRenderedMessages,
+ getRecentRenderedMessagesDetailed,
+ findActiveTurnStartIndex,
resolveActiveBranchIndices,
findDroidSessionLogsForCwd,
resolveDroidSessionLogForCwd,
@@ -990,6 +992,146 @@ describe("getRecentRenderedMessages — after a /compact", () => {
});
});
+describe("getRecentRenderedMessagesDetailed — excludeActiveTurn", () => {
+ // A launcher that runs the CLI from the agent's own tool call (not a `!`
+ // slash command) leaves the agent free to write to the transcript before
+ // and after the process starts. The active turn — everything from the
+ // newest human prompt onward — must be invisible to selection.
+ let dir: string;
+ let logPath: string;
+ beforeEach(() => {
+ dir = join(tmpdir(), `plannotator-active-turn-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
+ mkdirSync(dir, { recursive: true });
+ logPath = join(dir, "session.jsonl");
+ });
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
+
+ const read = (log: string, excludeActiveTurn = true) => {
+ writeFileSync(logPath, log);
+ return getRecentRenderedMessagesDetailed(logPath, 25, {
+ activeBranchOnly: true,
+ excludeActiveTurn,
+ });
+ };
+ const ids = (r: { messages: { messageId: string }[] }) => r.messages.map((m) => m.messageId);
+
+ test("an acknowledgement written after launch is excluded", () => {
+ const result = read(buildLog(
+ userPrompt("write the plan"),
+ assistantText("msg_plan", "Here is the plan."),
+ userPrompt("/open-for-review last"),
+ assistantToolUse("msg_launch", "Bash"),
+ assistantText("msg_ack", "Opened."),
+ ));
+ expect(ids(result)).toEqual(["msg_plan"]);
+ expect(result.emptiedByActiveTurn).toBe(false);
+ });
+
+ test("a preamble written before the tool call is excluded", () => {
+ const result = read(buildLog(
+ userPrompt("write the plan"),
+ assistantText("msg_plan", "Here is the plan."),
+ userPrompt("/open-for-review last"),
+ assistantText("msg_preamble", "Opening your last message..."),
+ assistantToolUse("msg_launch", "Bash"),
+ ));
+ expect(ids(result)).toEqual(["msg_plan"]);
+ });
+
+ test("without the option the acknowledgement still wins (unchanged default)", () => {
+ const result = read(buildLog(
+ userPrompt("write the plan"),
+ assistantText("msg_plan", "Here is the plan."),
+ userPrompt("/open-for-review last"),
+ assistantText("msg_ack", "Opened."),
+ ), false);
+ expect(ids(result)).toEqual(["msg_ack", "msg_plan"]);
+ });
+
+ test("a multi-chunk message right before the turn is concatenated whole", () => {
+ const result = read(buildLog(
+ userPrompt("write the plan"),
+ assistantText("msg_plan", "Part one."),
+ assistantText("msg_plan", "Part two."),
+ userPrompt("/open-for-review last"),
+ assistantText("msg_ack", "Opened."),
+ ));
+ expect(ids(result)).toEqual(["msg_plan"]);
+ expect(result.messages[0].text).toBe("Part one.\nPart two.");
+ });
+
+ test("the cutoff is computed on the active branch after a rewind", () => {
+ // The orphaned assistant message is newest in file order; the anchor and
+ // the selection must both ignore it.
+ writeFileSync(logPath, buildRewoundLog({
+ kept: [
+ userPrompt("write the plan"),
+ assistantText("msg_plan", "Here is the plan."),
+ ],
+ abandoned: [
+ userPrompt("actually, rewrite it"),
+ assistantText("msg_orphan", "Rewritten plan."),
+ ],
+ resumed: [
+ userPrompt("/open-for-review last"),
+ assistantText("msg_ack", "Opened."),
+ ],
+ }));
+ const result = getRecentRenderedMessagesDetailed(logPath, 25, {
+ activeBranchOnly: true,
+ excludeActiveTurn: true,
+ });
+ expect(ids(result)).toEqual(["msg_plan"]);
+ });
+
+ test("no human prompt on the branch: behaves as without the option", () => {
+ const result = read(buildLog(
+ assistantText("msg_only", "Summary after compaction."),
+ ));
+ expect(ids(result)).toEqual(["msg_only"]);
+ expect(result.emptiedByActiveTurn).toBe(false);
+ });
+
+ test("a fresh /compact still falls open before the cutoff applies", () => {
+ const preCompact = linkChain([
+ userPrompt("early question"),
+ assistantText("msg_pre", "Pre-compaction answer"),
+ ]);
+ const boundary = JSON.stringify({
+ type: "system",
+ subtype: "compact_boundary",
+ uuid: "u-compact",
+ parentUuid: null,
+ });
+ const post = linkChain([
+ userPrompt("/open-for-review last"),
+ assistantText("msg_ack", "Opened."),
+ ], "u-compact");
+ const result = read([...preCompact, boundary, ...post].join("\n"));
+ expect(ids(result)).toEqual(["msg_pre"]);
+ });
+
+ test("nothing before the turn is reported, not treated as a wrong file", () => {
+ const result = read(buildLog(
+ userPrompt("/open-for-review last"),
+ assistantText("msg_ack", "Opened."),
+ ));
+ expect(result.messages).toEqual([]);
+ expect(result.emptiedByActiveTurn).toBe(true);
+ });
+
+ test("findActiveTurnStartIndex skips tool results and respects the branch set", () => {
+ const entries = parseSessionLog(buildLog(
+ userPrompt("first"),
+ assistantToolUse("msg_tool", "Read"),
+ userToolResult("tu_1", "contents"),
+ assistantText("msg_a", "Done."),
+ ));
+ expect(findActiveTurnStartIndex(entries)).toBe(0);
+ expect(findActiveTurnStartIndex(entries, new Set([3]))).toBe(-1);
+ });
+});
+
describe("parseSessionLog", () => {
test("skips malformed lines", () => {
const log = '{"type":"user"}\nnot json\n{"type":"assistant"}';
diff --git a/apps/hook/server/session-log.ts b/apps/hook/server/session-log.ts
index e209e462f..7fb19f117 100644
--- a/apps/hook/server/session-log.ts
+++ b/apps/hook/server/session-log.ts
@@ -862,24 +862,94 @@ export function extractRecentRenderedMessages(
export function getRecentRenderedMessages(
logPath: string,
limit: number,
- opts: { activeBranchOnly?: boolean } = {},
+ opts: RecentRenderedMessagesOptions = {},
): RenderedMessage[] {
+ return getRecentRenderedMessagesDetailed(logPath, limit, opts).messages;
+}
+
+export interface RecentRenderedMessagesOptions {
+ activeBranchOnly?: boolean;
+ /**
+ * Drop every message from the active turn — the entries from the newest
+ * human prompt onward. For launchers that run the CLI from the agent's own
+ * tool call: the agent can still write to the transcript after the process
+ * starts, and its acknowledgement would otherwise become "the last message".
+ * Without a human prompt in range the option is a no-op.
+ */
+ excludeActiveTurn?: boolean;
+}
+
+export interface RecentRenderedMessagesResult {
+ messages: RenderedMessage[];
+ /**
+ * True when the log did hold assistant messages but `excludeActiveTurn`
+ * removed all of them. Callers must distinguish this from "wrong log file":
+ * it is the right file with nothing before the current turn.
+ */
+ emptiedByActiveTurn: boolean;
+}
+
+/**
+ * Index of the newest human prompt — the start of the active turn — or -1.
+ * Restricted to `branchIndices` when given, so a `/rewind` orphan can't anchor.
+ */
+export function findActiveTurnStartIndex(
+ entries: SessionLogEntry[],
+ branchIndices: Set | null = null,
+): number {
+ for (let i = entries.length - 1; i >= 0; i--) {
+ if (branchIndices && !branchIndices.has(i)) continue;
+ const entry = entries[i];
+ if (entry && isHumanPrompt(entry)) return i;
+ }
+ return -1;
+}
+
+/**
+ * `getRecentRenderedMessages` with the cutoff outcome exposed. The
+ * active-turn cutoff is applied after the fail-open-on-empty-branch fallback,
+ * so a fresh `/compact` transcript still degrades to file order first; the
+ * anchor is then looked up in whichever index set the messages came from.
+ */
+export function getRecentRenderedMessagesDetailed(
+ logPath: string,
+ limit: number,
+ opts: RecentRenderedMessagesOptions = {},
+): RecentRenderedMessagesResult {
try {
const content = readFileSync(logPath, "utf-8");
const entries = parseSessionLog(content);
- const branchIndices = opts.activeBranchOnly
+ let branchIndices = opts.activeBranchOnly
? resolveActiveBranchIndices(entries)
: null;
- const messages = extractRecentRenderedMessages(entries, entries.length, limit, {
+ let messages = extractRecentRenderedMessages(entries, entries.length, limit, {
branchIndices,
});
if (messages.length === 0 && branchIndices) {
// Fail open, never fail empty: an empty active branch (fresh /compact)
// must not make this log look like the wrong file.
- return extractRecentRenderedMessages(entries, entries.length, limit);
+ branchIndices = null;
+ messages = extractRecentRenderedMessages(entries, entries.length, limit);
}
- return messages;
+ if (!opts.excludeActiveTurn || messages.length === 0) {
+ return { messages, emptiedByActiveTurn: false };
+ }
+ const turnStart = findActiveTurnStartIndex(entries, branchIndices);
+ if (turnStart === -1) {
+ return { messages, emptiedByActiveTurn: false };
+ }
+ let before = extractRecentRenderedMessages(entries, turnStart, limit, {
+ branchIndices,
+ });
+ if (before.length === 0 && branchIndices) {
+ // Same fail-open as above, one step later: right after a /compact the
+ // branch holds the launching turn and nothing before it, while the
+ // messages the user means sit on the far side of the boundary.
+ const fileOrderStart = findActiveTurnStartIndex(entries);
+ before = extractRecentRenderedMessages(entries, fileOrderStart, limit);
+ }
+ return { messages: before, emptiedByActiveTurn: before.length === 0 };
} catch {
- return [];
+ return { messages: [], emptiedByActiveTurn: false };
}
}
From ffc3ee37b39ea6829603ac90fa3b10b8e6285a1b Mon Sep 17 00:00:00 2001
From: Ben Newman <158209000+BenNewman100@users.noreply.github.com>
Date: Fri, 21 Aug 2026 16:26:13 -0400
Subject: [PATCH 2/2] docs(annotate-last): document --exclude-active-turn and
when to pass it
---
.../src/content/docs/commands/annotate-last.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/apps/marketing/src/content/docs/commands/annotate-last.md b/apps/marketing/src/content/docs/commands/annotate-last.md
index 95b19a113..1e8a4fb0d 100644
--- a/apps/marketing/src/content/docs/commands/annotate-last.md
+++ b/apps/marketing/src/content/docs/commands/annotate-last.md
@@ -77,6 +77,18 @@ The annotation UI in `annotate-last` mode works the same as `/plannotator-annota
`plannotator annotate-last` accepts the same `--gate`, `--json`, and `--hook` flags as `plannotator annotate`. See [Annotate → Flags](/docs/commands/annotate/#flags) for the full matrix.
+### `--exclude-active-turn`
+
+Pass this when the agent launches `annotate-last` itself — from a shell tool call inside a skill, say — rather than through a `!`-inline slash command. In that setup the agent can still write to the transcript after the command starts ("Opened it for you."), and that acknowledgement would otherwise become the "last message".
+
+With the flag, the Claude Code parser ignores the **active turn**: everything from the newest human prompt onward, on the active (post-`/rewind`) branch. The message opened, and the messages offered in the picker, all come from before that prompt — so neither a preamble before the launch nor an acknowledgement after it can be selected. Right after a `/compact` the parser falls back to file order, so the pre-compaction messages are still offered.
+
+```bash
+plannotator annotate-last --exclude-active-turn --json
+```
+
+Leave it off for the `/plannotator-last` slash command: Claude Code records that command's prompt only after the command finishes, so at read time the "active turn" would be the previous one and the cutoff would skip the message you want. The flag is accepted on every harness; Codex already excludes the active turn unconditionally, and the other harnesses ignore it. If nothing precedes the current turn, the command exits non-zero with `No assistant message precedes the current turn` instead of opening an older session's log.
+
The common use case for `--gate` on annotate-last is a turn-by-turn review gate wired to a Stop hook:
```bash