From 7cd9026fc7280edc15780370100c066e013e9196 Mon Sep 17 00:00:00 2001 From: Markus Arndt Date: Wed, 29 Jul 2026 13:29:01 +0200 Subject: [PATCH 1/4] fix(hook): add Copilot session lock detection Copilot CLI exports no identifying environment variable, so nothing distinguishes a Copilot session from a plain shell. Match ancestor pids against session-state inuse locks to find the live session, and only accept a match when the lock owner still names a copilot process, since locks can outlive their session and pids get reused. --- apps/hook/server/copilot-session.test.ts | 225 +++++++++++++++++++++++ apps/hook/server/copilot-session.ts | 118 +++++++++++- apps/hook/server/session-log.ts | 2 +- 3 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 apps/hook/server/copilot-session.test.ts diff --git a/apps/hook/server/copilot-session.test.ts b/apps/hook/server/copilot-session.test.ts new file mode 100644 index 000000000..90707e564 --- /dev/null +++ b/apps/hook/server/copilot-session.test.ts @@ -0,0 +1,225 @@ +/** + * Copilot Session Lock Detection Tests + * + * Run: bun test apps/hook/server/copilot-session.test.ts + * + * Uses temp dirs mirroring the real ~/.copilot/session-state// + * layout with synthetic inuse..lock files. + */ + +import { describe, expect, test, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + matchCopilotSessionLockToPids, + findCopilotSessionByAncestorPids, +} from "./copilot-session"; + +// --- Fixture Helpers --- + +let tempDirs: string[] = []; + +function makeSessionStateDir(): string { + const dir = mkdtempSync(join(tmpdir(), "plannotator-copilot-test-")); + tempDirs.push(dir); + return dir; +} + +function addSession( + sessionStateDir: string, + name: string, + files: string[] = [], +): string { + const dir = join(sessionStateDir, name); + mkdirSync(dir, { recursive: true }); + for (const f of files) { + writeFileSync(join(dir, f), ""); + } + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- Tests --- + +describe("matchCopilotSessionLockToPids", () => { + test("matches a pid in the chain against its session lock", () => { + const stateDir = makeSessionStateDir(); + const session = addSession(stateDir, "aaaa-1111", [ + "inuse.4242.lock", + "events.jsonl", + "workspace.yaml", + ]); + + const result = matchCopilotSessionLockToPids(stateDir, [100, 4242]); + expect(result).toEqual({ sessionDir: session, pid: 4242 }); + }); + + test("returns null when no pid owns a lock", () => { + const stateDir = makeSessionStateDir(); + addSession(stateDir, "aaaa-1111", ["inuse.5555.lock"]); + + expect(matchCopilotSessionLockToPids(stateDir, [100, 200])).toBeNull(); + }); + + test("returns null for an empty pid list", () => { + const stateDir = makeSessionStateDir(); + addSession(stateDir, "aaaa-1111", ["inuse.100.lock"]); + + expect(matchCopilotSessionLockToPids(stateDir, [])).toBeNull(); + }); + + test("returns null when the session-state dir does not exist", () => { + const stateDir = makeSessionStateDir(); + const missing = join(stateDir, "no-such-dir"); + + expect(matchCopilotSessionLockToPids(missing, [100])).toBeNull(); + }); + + test("picks the correct session among several active ones", () => { + const stateDir = makeSessionStateDir(); + addSession(stateDir, "aaaa-1111", ["inuse.100.lock"]); + const wanted = addSession(stateDir, "bbbb-2222", ["inuse.200.lock"]); + addSession(stateDir, "cccc-3333", ["inuse.300.lock"]); + + const result = matchCopilotSessionLockToPids(stateDir, [200]); + expect(result).toEqual({ sessionDir: wanted, pid: 200 }); + }); + + test("pid order decides when several chain pids hold locks", () => { + const stateDir = makeSessionStateDir(); + const first = addSession(stateDir, "aaaa-1111", ["inuse.100.lock"]); + const second = addSession(stateDir, "bbbb-2222", ["inuse.300.lock"]); + + expect(matchCopilotSessionLockToPids(stateDir, [100, 200, 300])).toEqual({ + sessionDir: first, + pid: 100, + }); + expect(matchCopilotSessionLockToPids(stateDir, [300, 200, 100])).toEqual({ + sessionDir: second, + pid: 300, + }); + }); + + test("ignores malformed lock names and unrelated files", () => { + const stateDir = makeSessionStateDir(); + addSession(stateDir, "aaaa-1111", [ + "inuse.lock", + "inuse.abc.lock", + "inuse.12x34.lock", + "notinuse.100.lock", + "events.jsonl", + ]); + expect(matchCopilotSessionLockToPids(stateDir, [100])).toBeNull(); + + const valid = addSession(stateDir, "bbbb-2222", ["inuse.100.lock"]); + expect(matchCopilotSessionLockToPids(stateDir, [100])).toEqual({ + sessionDir: valid, + pid: 100, + }); + }); +}); + +describe("findCopilotSessionByAncestorPids", () => { + test("resolves the session locked by a copilot ancestor", () => { + const stateDir = makeSessionStateDir(); + const session = addSession(stateDir, "aaaa-1111", ["inuse.300.lock"]); + const parents: Record = { 100: 200, 200: 300 }; + const names: Record = { 300: "/usr/local/bin/copilot" }; + + const result = findCopilotSessionByAncestorPids({ + startPid: 100, + sessionStateDir: stateDir, + getParentPid: (p) => parents[p] ?? null, + getProcessName: (p) => names[p] ?? null, + }); + expect(result).toBe(session); + }); + + test("returns null when no ancestor holds a lock", () => { + const stateDir = makeSessionStateDir(); + addSession(stateDir, "aaaa-1111", ["inuse.9999.lock"]); + const parents: Record = { 100: 200 }; + + const result = findCopilotSessionByAncestorPids({ + startPid: 100, + sessionStateDir: stateDir, + getParentPid: (p) => parents[p] ?? null, + getProcessName: () => "copilot", + }); + expect(result).toBeNull(); + }); + + test("rejects a lock whose owner is not a copilot process", () => { + const stateDir = makeSessionStateDir(); + addSession(stateDir, "aaaa-1111", ["inuse.300.lock"]); + const parents: Record = { 100: 200, 200: 300 }; + + const result = findCopilotSessionByAncestorPids({ + startPid: 100, + sessionStateDir: stateDir, + getParentPid: (p) => parents[p] ?? null, + getProcessName: () => "node", + }); + expect(result).toBeNull(); + }); + + test("rejects every match when the process name lookup fails", () => { + const stateDir = makeSessionStateDir(); + addSession(stateDir, "aaaa-1111", ["inuse.100.lock"]); + + const result = findCopilotSessionByAncestorPids({ + startPid: 100, + sessionStateDir: stateDir, + getParentPid: () => null, + getProcessName: () => null, + }); + expect(result).toBeNull(); + }); + + test("skips a stale lock and keeps walking the chain", () => { + // Pid 100 was reused after its copilot session died; its stale lock + // must not shadow the live session locked by pid 300. + const stateDir = makeSessionStateDir(); + addSession(stateDir, "aaaa-1111", ["inuse.100.lock"]); + const live = addSession(stateDir, "bbbb-2222", ["inuse.300.lock"]); + const parents: Record = { 100: 200, 200: 300 }; + const names: Record = { 100: "bash", 300: "copilot" }; + + const result = findCopilotSessionByAncestorPids({ + startPid: 100, + sessionStateDir: stateDir, + getParentPid: (p) => parents[p] ?? null, + getProcessName: (p) => names[p] ?? null, + }); + expect(result).toBe(live); + }); + + test("respects COPILOT_HOME for the default session-state dir", () => { + const home = mkdtempSync(join(tmpdir(), "plannotator-copilot-home-")); + tempDirs.push(home); + const stateDir = join(home, "session-state"); + mkdirSync(stateDir, { recursive: true }); + const session = addSession(stateDir, "aaaa-1111", ["inuse.300.lock"]); + const parents: Record = { 100: 200, 200: 300 }; + + const prev = process.env.COPILOT_HOME; + process.env.COPILOT_HOME = home; + try { + const result = findCopilotSessionByAncestorPids({ + startPid: 100, + getParentPid: (p) => parents[p] ?? null, + getProcessName: () => "copilot", + }); + expect(result).toBe(session); + } finally { + if (prev === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = prev; + } + }); +}); diff --git a/apps/hook/server/copilot-session.ts b/apps/hook/server/copilot-session.ts index 8934d171c..a3d3b9dbb 100644 --- a/apps/hook/server/copilot-session.ts +++ b/apps/hook/server/copilot-session.ts @@ -4,7 +4,10 @@ * Extracts recent assistant messages and plan content from a Copilot CLI session. * Copilot CLI stores sessions at ~/.copilot/session-state// * - * Detection: The COPILOT_CLI=1 environment variable is set in Copilot CLI sessions. + * Detection: Copilot CLI sets no identifying environment variable. Instead, + * each live session holds a session-state//inuse..lock file, where + * is the copilot process. Matching lock pids against our ancestor pids + * identifies the session this process was spawned from. * * Session directory contents: * events.jsonl — All session events (JSONL format) @@ -21,8 +24,10 @@ */ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { join, basename } from "node:path"; import { homedir } from "node:os"; +import { getAncestorPids, createDefaultGetParentPid } from "./session-log"; // --- Types --- @@ -109,6 +114,115 @@ export function findCopilotSessionForCwd(cwd: string): string | null { ); } +// --- Session Lock Detection --- + +/** + * Match a pid chain against the lock files under `sessionStateDir` + * (`/inuse..lock`). Returns the first pid in `pids` that owns a + * lock, with its session directory. Malformed lock names and unreadable + * session dirs are skipped; a missing `sessionStateDir` returns null. + */ +export function matchCopilotSessionLockToPids( + sessionStateDir: string, + pids: number[], +): { sessionDir: string; pid: number } | null { + if (pids.length === 0) return null; + + let entries; + try { + entries = readdirSync(sessionStateDir, { withFileTypes: true }); + } catch { + return null; + } + + const lockOwners = new Map(); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dirPath = join(sessionStateDir, entry.name); + let files: string[]; + try { + files = readdirSync(dirPath); + } catch { + continue; + } + for (const f of files) { + const lockPid = f.match(/^inuse\.(\d+)\.lock$/)?.[1]; + if (!lockPid) continue; + const pid = parseInt(lockPid, 10); + if (!lockOwners.has(pid)) lockOwners.set(pid, dirPath); + } + } + + for (const pid of pids) { + const sessionDir = lockOwners.get(pid); + if (sessionDir) return { sessionDir, pid }; + } + return null; +} + +/** + * Resolve the Copilot session that spawned this process by walking up the + * pid chain and matching each ancestor against session lock files. + * + * Locks can outlive their session and pids get reused, so a match only + * counts if the matched pid still names a copilot process. Returns null when + * no ancestor holds a live lock, or where the process table or `ps` is + * unavailable. + */ +export function findCopilotSessionByAncestorPids( + opts: { + startPid?: number; + sessionStateDir?: string; + getParentPid?: (pid: number) => number | null; + getProcessName?: (pid: number) => string | null; + maxHops?: number; + } = {}, +): string | null { + const startPid = opts.startPid ?? process.pid; + if (!startPid) return null; + const copilotHome = process.env.COPILOT_HOME || join(homedir(), ".copilot"); + const sessionStateDir = + opts.sessionStateDir ?? join(copilotHome, "session-state"); + const getParent = opts.getParentPid ?? createDefaultGetParentPid(); + const getProcessName = opts.getProcessName ?? getProcessCommand; + const maxHops = opts.maxHops ?? 8; + + let pids = getAncestorPids(startPid, maxHops, getParent); + while (pids.length > 0) { + const match = matchCopilotSessionLockToPids(sessionStateDir, pids); + if (!match) return null; + if (isCopilotProcessName(getProcessName(match.pid))) { + return match.sessionDir; + } + // Stale lock or reused pid: drop it and retry with the remaining chain + pids = pids.filter((p) => p !== match.pid); + } + return null; +} + +function isCopilotProcessName(command: string | null): boolean { + if (!command) return false; + return basename(command.trim()).startsWith("copilot"); +} + +/** + * `ps -o comm=` for one pid. Null on any failure; without a process name the + * pid-reuse guard rejects every match, so platforms lacking `ps` degrade to + * no detection. + */ +function getProcessCommand(pid: number): string | null { + try { + const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], { + encoding: "utf-8", + timeout: 2000, + }); + if (result.status !== 0) return null; + return result.stdout.trim() || null; + } catch { + return null; + } +} + // --- Plan Content Discovery --- /** diff --git a/apps/hook/server/session-log.ts b/apps/hook/server/session-log.ts index d052e4901..e1c151add 100644 --- a/apps/hook/server/session-log.ts +++ b/apps/hook/server/session-log.ts @@ -316,7 +316,7 @@ function snapshotProcessTable(): Map { * on first call and caches it for the lifetime of the closure, so walking * up to `maxHops` ancestors costs a single spawn instead of one per hop. */ -function createDefaultGetParentPid(): (pid: number) => number | null { +export function createDefaultGetParentPid(): (pid: number) => number | null { let table: Map | null = null; return (pid: number) => { if (table === null) table = snapshotProcessTable(); From 81b9cf17d119452d9c121f131572465b2de683e0 Mon Sep 17 00:00:00 2001 From: Markus Arndt Date: Wed, 29 Jul 2026 13:29:15 +0200 Subject: [PATCH 2/4] fix(hook): route annotate-last to the live Copilot session Under Copilot CLI, annotate-last silently fell back to the default transcript reader and annotated a message from a different tool. Take the Copilot branch when an ancestor process holds a session lock, or when PLANNOTATOR_ORIGIN=copilot-cli is set with the cwd heuristic as fallback, and report origin copilot-cli to the annotate server. --- apps/hook/server/index.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 7f1298e6b..79abd3478 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -132,7 +132,7 @@ import { type RenderedMessage, } from "./session-log"; import { findCodexRolloutByThreadId, getLatestCodexPlan, getRecentCodexMessages } from "./codex-session"; -import { findCopilotPlanContent, findCopilotSessionForCwd, getRecentCopilotMessages } from "./copilot-session"; +import { findCopilotPlanContent, findCopilotSessionByAncestorPids, findCopilotSessionForCwd, getRecentCopilotMessages } from "./copilot-session"; import { formatInteractiveNoArgClarification, formatSubcommandHelp, @@ -1122,6 +1122,7 @@ if (args[0] === "sessions") { const codexThreadId = process.env.CODEX_THREAD_ID; const isCodex = !!codexThreadId; const isDroid = detectedOrigin === "droid"; + const isCopilot = detectedOrigin === "copilot-cli"; // Collect up to N recent assistant messages so the user can pick the right // one — defaults to the same selection as the legacy "last message" @@ -1133,6 +1134,18 @@ if (args[0] === "sessions") { let lastMessage: RenderedMessage | null = null; let recentMessages: RenderedMessage[] = []; + // Copilot CLI sets no env fingerprint, so detection matches ancestor pids + // against session-state inuse locks (spawns ps). Only attempted when no + // earlier branch claims the invocation. + let copilotLockSessionDir: string | null = null; + let copilotSessionDir: string | null = null; + if (!stdinFlag && !isCodex && !isDroid) { + copilotLockSessionDir = findCopilotSessionByAncestorPids(); + copilotSessionDir = copilotLockSessionDir ?? + (isCopilot ? findCopilotSessionForCwd(projectRoot) : null); + } + const copilotDetected = isCopilot || copilotSessionDir !== null; + if (stdinFlag) { const text = (await Bun.stdin.text()).trim(); if (text) { @@ -1183,6 +1196,20 @@ if (args[0] === "sessions") { recentMessages = getRecentRenderedMessages(droidLog, RECENT_MESSAGES_LIMIT); lastMessage = recentMessages[0] ?? null; } + } else if (copilotDetected) { + // Copilot path: prefer the session whose inuse lock an ancestor copilot + // process holds; with the origin override and no lock match, fall back + // to the cwd heuristic. + if (process.env.PLANNOTATOR_DEBUG) { + console.error(`[DEBUG] Copilot detected, project root: ${projectRoot}`); + console.error(`[DEBUG] Copilot ancestor lock session: ${copilotLockSessionDir ?? "(none)"}`); + console.error(`[DEBUG] Copilot selected session: ${copilotSessionDir ?? "(none)"}`); + } + if (copilotSessionDir) { + recentMessages = getRecentCopilotMessages(copilotSessionDir, RECENT_MESSAGES_LIMIT) + .map((m) => ({ messageId: m.messageId, text: m.text, lineNumbers: [], timestamp: m.timestamp })); + lastMessage = recentMessages[0] ?? null; + } } else { // Claude Code path: resolve session log // @@ -1260,7 +1287,7 @@ if (args[0] === "sessions") { const server = await startAnnotateServer({ markdown: annotatedMessage.text, filePath: "last-message", - origin: detectedOrigin, + origin: copilotDetected ? "copilot-cli" : detectedOrigin, mode: "annotate-last", sharingEnabled, shareBaseUrl, From c9561ee55142a17a3a7e3b6fe7e3b79294799191 Mon Sep 17 00:00:00 2001 From: Markus Arndt Date: Wed, 29 Jul 2026 13:29:15 +0200 Subject: [PATCH 3/4] fix(hook): prefer ancestor lock match in copilot-last The cwd heuristic can pick a stale session when several exist for one repo. Resolve the session locked by an ancestor copilot process first and keep the heuristic as fallback. --- apps/hook/server/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 79abd3478..a26cf5d2e 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1757,10 +1757,17 @@ if (args[0] === "sessions") { const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`); + console.error(`[DEBUG] Copilot CLI detected, project root: ${projectRoot}`); } - const sessionDir = findCopilotSessionForCwd(projectRoot); + // Prefer the session locked by an ancestor copilot process; the cwd + // heuristic can pick a stale session when several exist for one repo. + const lockSessionDir = findCopilotSessionByAncestorPids(); + if (process.env.PLANNOTATOR_DEBUG) { + console.error(`[DEBUG] Ancestor lock session: ${lockSessionDir ?? "(none)"}`); + } + + const sessionDir = lockSessionDir ?? findCopilotSessionForCwd(projectRoot); if (!sessionDir) { console.error("No Copilot CLI session found."); From 80f3c201a45d9f133070ef928bcc31764a48e8f2 Mon Sep 17 00:00:00 2001 From: Markus Arndt Date: Wed, 29 Jul 2026 13:29:19 +0200 Subject: [PATCH 4/4] docs(cli): document copilot-last in help The subcommand worked but was missing from the top-level usage and the per-subcommand help map. --- apps/hook/server/cli.test.ts | 2 ++ apps/hook/server/cli.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/hook/server/cli.test.ts b/apps/hook/server/cli.test.ts index f180dc99d..0424285bd 100644 --- a/apps/hook/server/cli.test.ts +++ b/apps/hook/server/cli.test.ts @@ -30,6 +30,7 @@ describe("CLI top-level help", () => { expect(output).toContain("plannotator annotate "); expect(output).toContain("[--markdown] [--no-jina]"); expect(output).toContain("plannotator annotate-last [--stdin]"); + expect(output).toContain("plannotator copilot-last [--gate] [--json] [--hook]"); expect(output).toContain("plannotator setup-goal "); expect(output).toContain("Run 'plannotator --help' for command-specific usage."); expect(output).toContain("running 'plannotator' without arguments is for hook integration"); @@ -78,6 +79,7 @@ describe("CLI subcommand help", () => { // advertised "run 'plannotator --help'" contract holds. for (const sub of [ "annotate", + "copilot-last", "setup-goal", "archive", "sessions", diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index 4ade5d4ab..106bb7b82 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -94,6 +94,7 @@ export function formatTopLevelHelp(): string { " plannotator review [--git | --gitbutler] [PR_URL]", " plannotator annotate [--markdown] [--no-jina] [--gate] [--json] [--hook] [--require-approval] [--result-file ]", " plannotator annotate-last [--stdin] [--gate] [--json] [--hook]", + " plannotator copilot-last [--gate] [--json] [--hook]", " plannotator setup-goal [--json]", " plannotator last", " plannotator archive", @@ -164,6 +165,19 @@ const SUBCOMMAND_HELP: Record = { " --json Emit a structured decision JSON on stdout", " --hook Emit hook-native JSON (block/pass) for PostToolUse/Stop hooks", ].join("\n"), + "copilot-last": [ + "Usage:", + " plannotator copilot-last [--gate] [--json] [--hook]", + "", + "Annotate the last assistant message from the live GitHub Copilot CLI session,", + "read from its session-state events.jsonl. Normally invoked by the Copilot", + "plugin's /plannotator-last command.", + "", + "Options:", + " --gate Add an Approve button (review-gate UX)", + " --json Emit a structured decision JSON on stdout", + " --hook Emit hook-native JSON (block/pass) for PostToolUse/Stop hooks", + ].join("\n"), "setup-goal": [ "Usage:", " plannotator setup-goal [--json]",