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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/hook/server/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe("CLI top-level help", () => {
expect(output).toContain("plannotator review [--git | --gitbutler] [--tailscale] [PR_URL]");
expect(output).toContain("plannotator annotate <file.md | file.txt | file.html | https://... | folder/>");
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 <interview|facts>");
expect(output).toContain("plannotator uninstall [--purge] [--yes]");
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export function formatTopLevelHelp(): string {
" plannotator [--browser <name>]",
" plannotator review [--git | --gitbutler] [--tailscale] [PR_URL]",
" plannotator annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--tailscale] [--gate] [--json] [--hook] [--require-approval] [--result-file <path>]",
" 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 <interview|facts> <bundle.json | -> [--json]",
" plannotator last",
Expand Down Expand Up @@ -211,13 +211,17 @@ const SUBCOMMAND_HELP: Record<string, string> = {
].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",
Expand Down
30 changes: 24 additions & 6 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ import {
findSessionLogsByAncestorWalk,
findSessionLogsForCwd,
getRecentRenderedMessages,
getRecentRenderedMessagesDetailed,
resolveDroidSessionLogForCwd,
resolveSessionLogByAncestorPids,
resolveSessionLogByCwdScan,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"}`);
Expand All @@ -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;
}
}
Expand All @@ -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);
}

Expand Down
144 changes: 143 additions & 1 deletion apps/hook/server/session-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
* 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,
findAnchorIndex,
extractLastRenderedMessage,
extractRecentRenderedMessages,
getRecentRenderedMessages,
getRecentRenderedMessagesDetailed,
findActiveTurnStartIndex,
resolveActiveBranchIndices,
findDroidSessionLogsForCwd,
resolveDroidSessionLogForCwd,
Expand Down Expand Up @@ -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"}';
Expand Down
82 changes: 76 additions & 6 deletions apps/hook/server/session-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> | 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 };
}
}
Loading