From 60c0b9e0f5d405891e5ca28145aef0c4b7deeebc Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 22 Sep 2026 19:51:06 -0700 Subject: [PATCH 1/7] feat: add optional Copilot CLI hook state adapter --- README.md | 6 +- src/cli.ts | 31 ++++ src/core/copilot.ts | 318 ++++++++++++++++++++++++++++++++++--- src/core/runtime.ts | 8 +- src/types.ts | 1 + test/copilot-hooks.test.ts | 206 ++++++++++++++++++++++++ 6 files changed, 549 insertions(+), 21 deletions(-) create mode 100644 test/copilot-hooks.test.ts diff --git a/README.md b/README.md index 24da3d4..e698c48 100644 --- a/README.md +++ b/README.md @@ -496,9 +496,11 @@ This means any `kiro-cli` pane can be discovered and switched to without naming ## GitHub Copilot CLI -An interactive `copilot` process (or `copilot.exe` on Windows) in a tmux pane is discovered as Copilot in `list`, the chooser/popup, and `status`. Use `--agent copilot` to filter navigation; `inspect ` shows the command detection reason and confidence. No Copilot hooks or configuration are required. +An interactive `copilot` process (or `copilot.exe` on Windows) in a tmux pane is discovered in `list`, the chooser/popup, and `status`. Use `--agent copilot` to filter navigation. No hooks are required: without them, activity and status remain `unknown` rather than guessing a wait. Title text, arbitrary `copilot-*` commands, and wrapped launches are not detected; generic wrapper support is tracked separately in #19. -Copilot runtime state is deliberately coarse: its session identity comes from the tmux pane, while activity and status remain `unknown`. Merely running the CLI does not mean a turn is running or needs attention, so Copilot panes do not appear in `--busy`, `--running`, or `--waiting` views. Title text, arbitrary `copilot-*` commands, and wrapped launches are not detected; generic wrapper support is tracked separately in #19. +For optional, **local Copilot CLI only** turn and prompt state, preview the configuration with `coding-agents-tmux copilot-hooks-template`, then explicitly run `coding-agents-tmux install-copilot`. This creates only `${COPILOT_HOME:-~/.copilot}/hooks/coding-agents-tmux.json`; other user hooks are untouched. Restart existing Copilot CLI sessions so hooks load. Do not put these hooks in `.github/hooks/` (repository hooks also execute in Copilot cloud agent). The tmux plugin never auto-installs Copilot hooks. + +The adapter correlates `TMUX_PANE` and the Copilot `sessionId`, including concurrent sessions in one directory and `/clear` (which ends the old session and starts another). `userPromptSubmitted` means running; `agentStop` means idle. Only `notification` types `permission_prompt` and `elicitation_dialog` assert waiting-question and waiting-input respectively; automatic permission checks and `preToolUse` never assert a wait. An elicitation notification does not reveal whether its form contains multiple choices, so it is conservatively labeled input. Resolved prompts clear at the next turn boundary (`agentStop`); hooks do not expose an immediate resolution event, so the state can briefly lag after an answer. Hook state expires after 60 seconds without a subsequent event (including an unanswered prompt), reverting to the coarse navigable state instead of retaining a false wait. `inspect` reports hook source, age and expiry or command-only fallback. If hooks are disabled, folder trust blocks them, tmux pane identity is missing, or a hook is missed, no directory-only match is made. `COPILOT_HOME` selects the hook directory; `CODING_AGENTS_TMUX_COPILOT_STATE_DIR` overrides the local state directory. The integration neither grants permissions nor injects context. ## How this compares to Herdr diff --git a/src/cli.ts b/src/cli.ts index f654102..eaa0eec 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,11 @@ import { installCodexIntegration, persistCodexHookState, } from "./core/codex.ts"; +import { + buildCopilotHooksTemplate, + installCopilotIntegration, + persistCopilotHookState, +} from "./core/copilot.ts"; import { notifyIntegration } from "./core/notifications.ts"; import { detectOpenCodeVersion } from "./core/opencode-generation.ts"; import { installOpenCodeIntegration } from "./core/opencode-install.ts"; @@ -642,6 +647,16 @@ async function runInstallClaudeCommand(_options: InstallClaudeOptions): Promise< console.log("Restart Claude Code sessions so new hooks are loaded"); } +async function runCopilotHookStateCommand(eventName: string): Promise { + await persistCopilotHookState(await readStdinText(), { eventName }); +} + +async function runInstallCopilotCommand(): Promise { + const result = installCopilotIntegration(CLI_PATH); + console.log(`Updated ${result.hooksPath}`); + console.log("Restart Copilot CLI sessions so new hooks are loaded"); +} + async function runInstallOpenCodeCommand(_options: InstallOpenCodeOptions): Promise { const detected = await detectOpenCodeVersion(); const result = installOpenCodeIntegration(detected); @@ -1010,6 +1025,22 @@ async function main(): Promise { .description("Install or update Claude Code hook configuration under ~/.claude") .action(runInstallClaudeCommand); + program + .command("copilot-hooks-template") + .description("Print optional user-level Copilot CLI hook configuration") + .action(() => console.log(buildCopilotHooksTemplate(CLI_PATH))); + + program + .command("copilot-hook-state") + .description("Ingest a Copilot CLI hook event from stdin (used by installed hooks)") + .argument("", "Copilot CLI lifecycle event name") + .action(runCopilotHookStateCommand); + + program + .command("install-copilot") + .description("Explicitly install Copilot CLI user-level hooks; never run automatically") + .action(runInstallCopilotCommand); + program .command("popup") .description("Open a tmux popup chooser for switching between discovered coding agent panes") diff --git a/src/core/copilot.ts b/src/core/copilot.ts index a1a914d..5c0a451 100644 --- a/src/core/copilot.ts +++ b/src/core/copilot.ts @@ -1,22 +1,304 @@ -import { basename } from "node:path"; +import { basename, join } from "node:path"; +import { homedir } from "node:os"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; -import type { DiscoveredPane, PaneRuntimeSummary } from "../types.ts"; +import { notifyIntegration } from "./notifications.ts"; +import { getPreferredStateDir } from "../naming.ts"; +import type { DiscoveredPane, PaneRuntimeSummary, RuntimeStatus } from "../types.ts"; -export function attachRuntimeWithCopilot(panes: DiscoveredPane[]): PaneRuntimeSummary[] { - return panes.map((entry) => ({ - ...entry, - runtime: { - activity: "unknown", - status: "unknown", - source: "copilot-command", - match: { strategy: "exact", provider: "copilot", heuristic: false }, - session: { - id: `copilot:${entry.pane.target}`, - directory: entry.pane.currentPath, - title: basename(entry.pane.currentPath) || "Copilot CLI", - timeUpdated: Date.now(), +const STATE_TTL_MS = 60_000; +const CLOCK_SKEW_MS = 5_000; +const EVENTS = [ + "sessionStart", + "userPromptSubmitted", + "agentStop", + "sessionEnd", + "notification", +] as const; +type EventName = (typeof EVENTS)[number]; +interface HookState { + version: 1; + paneId: string; + sessionId: string; + previousSessionId?: string; + directory: string; + status: RuntimeStatus; + event: EventName; + eventAt: number; + updatedAt: number; +} +interface HookOptions { + paneId?: string | null; + stateDir?: string; + now?: number; + notify?: () => Promise; + eventName?: string; +} +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function paneFile(stateDir: string, paneId: string): string { + return join(stateDir, `pane-${Buffer.from(paneId).toString("hex")}.json`); +} +export function getCopilotStateDir(): string { + return getPreferredStateDir({ + env: "CODING_AGENTS_TMUX_COPILOT_STATE_DIR", + subdirectory: "copilot-state", + }); +} +export function getCopilotHooksPath( + home = process.env.COPILOT_HOME ?? join(homedir(), ".copilot"), +): string { + return join(home, "hooks", "coding-agents-tmux.json"); +} +function readState(file: string, paneId: string): HookState | null { + try { + const value: unknown = JSON.parse(readFileSync(file, "utf8")); + if ( + !isRecord(value) || + value.version !== 1 || + value.paneId !== paneId || + typeof value.sessionId !== "string" || + !value.sessionId || + typeof value.directory !== "string" || + !["running", "idle", "waiting-question", "waiting-input", "unknown"].includes( + String(value.status), + ) || + !EVENTS.includes(value.event as EventName) || + typeof value.eventAt !== "number" || + !Number.isFinite(value.eventAt) || + typeof value.updatedAt !== "number" || + !Number.isFinite(value.updatedAt) + ) + return null; + return value as unknown as HookState; + } catch { + return null; + } +} + +// A bounded lock serializes separate hook processes, including detached /clear +// sessionEnd callbacks. Atomic replacement protects concurrent CLI readers. +async function withPaneLock(file: string, action: () => boolean): Promise { + const lock = `${file}.lock`; + for (let i = 0; i < 30; i++) { + try { + mkdirSync(lock); + } catch (error) { + if (!isRecord(error) || error.code !== "EEXIST") throw error; + // A killed hook must not strand subsequent events indefinitely. + try { + if (Date.now() - statSync(lock).mtimeMs > 10_000) + rmSync(lock, { recursive: true, force: true }); + } catch { + /* another writer released it */ + } + await new Promise((resolve) => setTimeout(resolve, 10)); + continue; + } + try { + return action(); + } finally { + rmSync(lock, { recursive: true, force: true }); + } + } + return false; +} +export async function persistCopilotHookState( + raw: string, + options: HookOptions = {}, +): Promise { + const paneId = options.paneId === undefined ? process.env.TMUX_PANE : options.paneId; + if (!paneId || !/^%\d+$/.test(paneId) || (!process.env.TMUX && options.paneId === undefined)) + return; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return; + } + if (!isRecord(value)) return; + const event = options.eventName ?? value.event; + const now = options.now ?? Date.now(); + if ( + !EVENTS.includes(event as EventName) || + typeof value.sessionId !== "string" || + !value.sessionId || + typeof value.cwd !== "string" || + !value.cwd || + typeof value.timestamp !== "number" || + !Number.isFinite(value.timestamp) || + value.timestamp > now + CLOCK_SKEW_MS + ) + return; + if ( + event === "notification" && + !["permission_prompt", "elicitation_dialog"].includes(String(value.notification_type)) + ) + return; + if (event === "sessionStart" && !["startup", "resume", "new"].includes(String(value.source))) + return; + const sessionId = value.sessionId as string; + const directory = value.cwd as string; + const timestamp = value.timestamp as number; + const dir = options.stateDir ?? getCopilotStateDir(); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const file = paneFile(dir, paneId); + const changed = await withPaneLock(file, () => { + const old = readState(file, paneId); + if ( + old && + (sessionId === old.previousSessionId || + timestamp < old.eventAt || + (sessionId !== old.sessionId && + event !== "sessionStart" && + event !== "userPromptSubmitted")) + ) + return false; + // sessionStart can follow the first submitted prompt: don't reset the turn. + const sameSession = old?.sessionId === sessionId; + const status: RuntimeStatus = + event === "sessionEnd" + ? "unknown" + : event === "agentStop" + ? "idle" + : event === "userPromptSubmitted" + ? "running" + : event === "notification" + ? value.notification_type === "permission_prompt" + ? "waiting-question" + : "waiting-input" + : sameSession && old + ? old.status + : "idle"; + const state: HookState = { + version: 1, + paneId, + sessionId, + directory, + status, + event: event as EventName, + eventAt: timestamp, + updatedAt: now, + ...(old && !sameSession + ? { previousSessionId: old.sessionId } + : old?.previousSessionId + ? { previousSessionId: old.previousSessionId } + : {}), + }; + const temp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`; + try { + writeFileSync(temp, JSON.stringify(state), { mode: 0o600 }); + renameSync(temp, file); + } finally { + if (existsSync(temp)) rmSync(temp); + } + return true; + }); + if (changed) await (options.notify ?? notifyIntegration)(); +} + +export function attachRuntimeWithCopilot( + panes: DiscoveredPane[], + options: { stateDir?: string; now?: number } = {}, +): PaneRuntimeSummary[] { + const now = options.now ?? Date.now(); + const dir = options.stateDir ?? getCopilotStateDir(); + return panes.map((entry) => { + const state = readState(paneFile(dir, entry.pane.paneId), entry.pane.paneId); + const matching = + state?.directory === entry.pane.currentPath && state.updatedAt <= now + CLOCK_SKEW_MS + ? state + : null; + const fresh = matching && now - matching.updatedAt < STATE_TTL_MS; + return { + ...entry, + runtime: { + activity: fresh + ? matching.status === "idle" + ? ("idle" as const) + : matching.status === "unknown" + ? ("unknown" as const) + : ("busy" as const) + : ("unknown" as const), + status: fresh ? matching.status : "unknown", + source: fresh ? "copilot-hook" : "copilot-command", + match: { strategy: "exact", provider: "copilot", heuristic: false }, + session: { + id: matching?.sessionId ?? `copilot:${entry.pane.target}`, + directory: entry.pane.currentPath, + title: basename(entry.pane.currentPath) || "Copilot CLI", + timeUpdated: matching?.updatedAt ?? now, + }, + detail: fresh + ? `Copilot ${matching.event} hook (${Math.max(0, now - matching.updatedAt)}ms old); expires after ${STATE_TTL_MS}ms` + : matching + ? `Copilot hook stale (${Math.max(0, now - matching.updatedAt)}ms old); command-only fallback` + : "Copilot CLI process detected; hook state absent or disabled; command-only fallback", }, - detail: "Copilot CLI process detected; turn and attention state unavailable without hooks", - }, - })); + }; + }); +} + +export function buildCopilotHooksTemplate(command: string): string { + const hooks: Record = {}; + for (const event of EVENTS) { + hooks[event] = [ + { + type: "command", + exec: command, + args: ["copilot-hook-state", event], + timeoutSec: 5, + ...(event === "notification" ? { matcher: "permission_prompt|elicitation_dialog" } : {}), + }, + ]; + } + return `${JSON.stringify({ version: 1, hooks }, null, 2)}\n`; +} +export function installCopilotIntegration(command: string, home?: string): { hooksPath: string } { + const path = getCopilotHooksPath(home); + const dir = join(home ?? process.env.COPILOT_HOME ?? join(homedir(), ".copilot"), "hooks"); + const next = buildCopilotHooksTemplate(command); + mkdirSync(dir, { recursive: true }); + let output = next; + if (existsSync(path)) { + const existing = readFileSync(path, "utf8"); + if (existing === next) return { hooksPath: path }; + let parsed: unknown; + try { + parsed = JSON.parse(existing); + } catch { + throw new Error(`Refusing to replace unrecognized hook file: ${path}`); + } + if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.hooks)) + throw new Error(`Refusing to replace unrecognized hook file: ${path}`); + const desired = JSON.parse(next) as { hooks: Record }; + const hooks = { ...parsed.hooks }; + for (const event of EVENTS) { + const entries = hooks[event]; + if (entries !== undefined && !Array.isArray(entries)) + throw new Error(`Refusing to replace malformed hook entries: ${path}`); + hooks[event] = [ + ...(entries ?? []).filter( + (entry: unknown) => + !isRecord(entry) || + !Array.isArray(entry.args) || + entry.args[0] !== "copilot-hook-state", + ), + ...desired.hooks[event], + ]; + } + output = `${JSON.stringify({ ...parsed, hooks }, null, 2)}\n`; + if (output === existing) return { hooksPath: path }; + } + writeFileSync(path, output, { mode: 0o600 }); + return { hooksPath: path }; } diff --git a/src/core/runtime.ts b/src/core/runtime.ts index 5e1ee93..625f61d 100644 --- a/src/core/runtime.ts +++ b/src/core/runtime.ts @@ -7,7 +7,7 @@ import { getPluginStateDir, } from "./opencode.ts"; import { attachRuntimeWithPi } from "./pi.ts"; -import { attachRuntimeWithCopilot } from "./copilot.ts"; +import { attachRuntimeWithCopilot, getCopilotStateDir } from "./copilot.ts"; import { PRIMARY_CLI_NAME } from "../naming.ts"; import type { DiscoveredPane, PaneRuntimeSummary, RuntimeProviderOptions } from "../types.ts"; @@ -67,6 +67,12 @@ export function getRuntimeProviderHelpText(): string { ` Generate settings hooks with: ${PRIMARY_CLI_NAME} claude-hooks-template`, ` Install global Claude hooks with: ${PRIMARY_CLI_NAME} install-claude`, "", + "Copilot CLI hook state (optional, CLI-local):", + ` Default path: ${getCopilotStateDir()}`, + " Override with CODING_AGENTS_TMUX_COPILOT_STATE_DIR.", + ` Preview hooks with: ${PRIMARY_CLI_NAME} copilot-hooks-template`, + ` Explicitly install user hooks with: ${PRIMARY_CLI_NAME} install-copilot`, + "", "Server map:", " Pass --server-map with a JSON object or a path to a JSON file.", " V2 uses one shared endpoint plus exact pane-to-root-session mappings.", diff --git a/src/types.ts b/src/types.ts index 9d47948..9ff9acd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,7 @@ export type RuntimeSource = | "claude-command" | "kiro-preview" | "kiro-command" + | "copilot-hook" | "copilot-command" | "unmapped"; diff --git a/test/copilot-hooks.test.ts b/test/copilot-hooks.test.ts new file mode 100644 index 0000000..370be19 --- /dev/null +++ b/test/copilot-hooks.test.ts @@ -0,0 +1,206 @@ +import { spawnSync } from "node:child_process"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + attachRuntimeWithCopilot, + buildCopilotHooksTemplate, + getCopilotHooksPath, + installCopilotIntegration, + persistCopilotHookState, +} from "../src/core/copilot.ts"; +import type { DiscoveredPane } from "../src/types.ts"; + +const start = 1_790_000_000_000; +function pane(id: string): DiscoveredPane { + return { + pane: { + paneId: id, + target: `work:1.${id === "%20" ? 0 : 1}`, + sessionName: "work", + windowIndex: 1, + paneIndex: id === "%20" ? 0 : 1, + paneTitle: "shell", + currentCommand: "copilot", + currentPath: "/shared", + isActive: true, + tty: "/dev/ttys000", + }, + detection: { agent: "copilot", confidence: "medium", reasons: ["command:copilot"] }, + }; +} +function event(sessionId: string, timestamp: number, extras: Record = {}) { + return JSON.stringify({ sessionId, timestamp, cwd: "/shared", ...extras }); +} + +test("hook events isolate same-directory panes and clear only resolved prompts", async () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-hooks-")); + const ingest = (id: string, payload: string) => + persistCopilotHookState(payload, { + paneId: id, + stateDir: dir, + now: start + 10_000, + notify: async () => {}, + }); + const view = () => + attachRuntimeWithCopilot([pane("%20"), pane("%21")], { stateDir: dir, now: start + 10_000 }); + await ingest("%20", event("s1", start + 10, { event: "userPromptSubmitted" })); + await ingest("%20", event("s1", start + 15, { event: "sessionStart", source: "startup" })); + await ingest("%21", event("s2", start + 20, { event: "sessionStart", source: "startup" })); + assert.deepEqual( + view().map((x) => x.runtime.status), + ["running", "idle"], + ); + await ingest( + "%20", + event("s1", start + 30, { event: "notification", notification_type: "permission_prompt" }), + ); + await ingest( + "%21", + event("s2", start + 35, { event: "notification", notification_type: "elicitation_dialog" }), + ); + assert.deepEqual( + view().map((x) => x.runtime.status), + ["waiting-question", "waiting-input"], + ); + await ingest("%20", event("s1", start + 40, { event: "agentStop" })); + assert.deepEqual( + view().map((x) => x.runtime.status), + ["idle", "waiting-input"], + ); + await ingest("%21", event("s2", start + 45, { event: "agentStop" })); + assert.deepEqual( + view().map((x) => x.runtime.status), + ["idle", "idle"], + ); +}); + +test("automatically approved checks never wait; unmatched notifications do not clear genuine waits", async () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-hooks-")); + const ingest = (payload: string) => + persistCopilotHookState(payload, { + paneId: "%20", + stateDir: dir, + now: start + 500, + notify: async () => {}, + }); + const status = () => + attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, now: start + 500 })[0]!.runtime.status; + await ingest(event("s1", start + 1, { event: "userPromptSubmitted" })); + await ingest(event("s1", start + 2, { event: "preToolUse" })); + await ingest(event("s1", start + 3, { event: "permissionRequest" })); + assert.equal(status(), "running"); + await ingest( + event("s1", start + 4, { event: "notification", notification_type: "permission_prompt" }), + ); + await ingest( + event("s1", start + 5, { event: "notification", notification_type: "shell_completed" }), + ); + assert.equal(status(), "waiting-question"); + await ingest(event("s1", start + 6, { event: "agentStop" })); + assert.equal(status(), "idle"); +}); + +test("clear, late old-session hooks, stale waits, malformed events and missing pane identity fail safely", async () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-hooks-")); + const ingest = (payload: string, paneId: string | null = "%20") => + persistCopilotHookState(payload, { + paneId, + stateDir: dir, + now: start + 500, + notify: async () => {}, + }); + const status = (now = start + 500) => + attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, now })[0]!.runtime; + await ingest( + event("old", start + 1, { event: "notification", notification_type: "permission_prompt" }), + ); + assert.equal(status().status, "waiting-question"); + await ingest(event("old", start + 2, { event: "sessionEnd", reason: "user_exit" })); + assert.equal(status().status, "unknown"); + await ingest(event("new", start + 3, { event: "userPromptSubmitted" })); + await ingest(event("new", start + 4, { event: "sessionStart", source: "new" })); + await ingest(event("old", start + 5, { event: "sessionEnd" })); + assert.equal(status().status, "running"); + assert.equal(status().session?.id, "new"); + await ingest( + event("new", start + 6, { event: "notification", notification_type: "permission_prompt" }), + ); + assert.equal(status(start + 120_000).status, "unknown"); + await ingest("{}", null); + await ingest("not json"); + await ingest( + event("new", start + 7, { event: "notification", notification_type: "permission_prompt" }), + null, + ); + assert.equal(readdirSync(dir).filter((name) => name.endsWith(".json")).length, 1); + assert.equal(status().status, "waiting-question"); +}); + +test("configured CLI hook args ingest real camelCase payloads without emitting decisions", () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-cli-")); + const bin = join(import.meta.dirname, "..", "bin", "coding-agents-tmux"); + const config = JSON.parse(buildCopilotHooksTemplate(bin)); + const hook = config.hooks.notification[0]; + const result = spawnSync(hook.exec, hook.args, { + input: JSON.stringify({ + sessionId: "s1", + timestamp: Date.now(), + cwd: "/shared", + notification_type: "permission_prompt", + }), + env: { + ...process.env, + TMUX: "test,1,0", + TMUX_PANE: "%20", + CODING_AGENTS_TMUX_COPILOT_STATE_DIR: dir, + }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ""); + assert.equal( + attachRuntimeWithCopilot([pane("%20")], { stateDir: dir })[0]!.runtime.status, + "waiting-question", + ); +}); + +test("installer owns one user-level file, preserves unrelated hooks, and is idempotent", () => { + const home = mkdtempSync(join(tmpdir(), "copilot-home-")); + const hooks = join(home, "hooks"); + const path = getCopilotHooksPath(home); + const command = "/example/bin/coding-agents-tmux"; + const template = JSON.parse(buildCopilotHooksTemplate(command)); + assert.equal(template.version, 1); + assert.deepEqual(Object.keys(template.hooks).sort(), [ + "agentStop", + "notification", + "sessionEnd", + "sessionStart", + "userPromptSubmitted", + ]); + installCopilotIntegration(command, home); + mkdirSync(hooks, { recursive: true }); + writeFileSync(join(hooks, "other.json"), '{"version":1,"hooks":{"agentStop":[]}}'); + const first = readFileSync(path, "utf8"); + installCopilotIntegration(command, home); + assert.equal(readFileSync(path, "utf8"), first); + const withExtra = JSON.parse(first); + withExtra.hooks.agentStop.push({ type: "command", exec: "/other" }); + withExtra.custom = true; + writeFileSync(path, JSON.stringify(withExtra)); + installCopilotIntegration(command, home); + const merged = JSON.parse(readFileSync(path, "utf8")); + assert.equal(merged.custom, true); + assert.equal(merged.hooks.agentStop.length, 2); + installCopilotIntegration(command, home); + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), merged); + assert.equal( + readFileSync(join(hooks, "other.json"), "utf8"), + '{"version":1,"hooks":{"agentStop":[]}}', + ); + assert.equal(template.hooks.notification[0].matcher, "permission_prompt|elicitation_dialog"); +}); From b0579619d1c2068632249bfba93ad7676ea05cc5 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 22 Sep 2026 19:55:36 -0700 Subject: [PATCH 2/7] fix: expire Copilot state when hook parent exits --- README.md | 2 +- src/core/copilot.ts | 33 ++++++++++++++++++++++++----- test/copilot-hooks.test.ts | 43 +++++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e698c48..c7deede 100644 --- a/README.md +++ b/README.md @@ -500,7 +500,7 @@ An interactive `copilot` process (or `copilot.exe` on Windows) in a tmux pane is For optional, **local Copilot CLI only** turn and prompt state, preview the configuration with `coding-agents-tmux copilot-hooks-template`, then explicitly run `coding-agents-tmux install-copilot`. This creates only `${COPILOT_HOME:-~/.copilot}/hooks/coding-agents-tmux.json`; other user hooks are untouched. Restart existing Copilot CLI sessions so hooks load. Do not put these hooks in `.github/hooks/` (repository hooks also execute in Copilot cloud agent). The tmux plugin never auto-installs Copilot hooks. -The adapter correlates `TMUX_PANE` and the Copilot `sessionId`, including concurrent sessions in one directory and `/clear` (which ends the old session and starts another). `userPromptSubmitted` means running; `agentStop` means idle. Only `notification` types `permission_prompt` and `elicitation_dialog` assert waiting-question and waiting-input respectively; automatic permission checks and `preToolUse` never assert a wait. An elicitation notification does not reveal whether its form contains multiple choices, so it is conservatively labeled input. Resolved prompts clear at the next turn boundary (`agentStop`); hooks do not expose an immediate resolution event, so the state can briefly lag after an answer. Hook state expires after 60 seconds without a subsequent event (including an unanswered prompt), reverting to the coarse navigable state instead of retaining a false wait. `inspect` reports hook source, age and expiry or command-only fallback. If hooks are disabled, folder trust blocks them, tmux pane identity is missing, or a hook is missed, no directory-only match is made. `COPILOT_HOME` selects the hook directory; `CODING_AGENTS_TMUX_COPILOT_STATE_DIR` overrides the local state directory. The integration neither grants permissions nor injects context. +The adapter correlates `TMUX_PANE`, the hook process's Copilot parent PID, and the Copilot `sessionId`, including concurrent sessions in one directory and `/clear` (which ends the old session and starts another). An exited Copilot process invalidates its hook state even if another CLI starts in the same pane. `userPromptSubmitted` means running; `agentStop` means idle. Only `notification` types `permission_prompt` and `elicitation_dialog` assert waiting-question and waiting-input respectively; automatic permission checks and `preToolUse` never assert a wait. An elicitation notification does not reveal whether its form contains multiple choices, so it is conservatively labeled input. Resolved prompts clear at the next turn boundary (`agentStop`); hooks do not expose an immediate resolution event, so the state can briefly lag after an answer. Hook state expires after 60 seconds without a subsequent event (including an unanswered prompt), reverting to the coarse navigable state instead of retaining a false wait. `inspect` reports hook source, age and expiry or command-only fallback. If hooks are disabled, folder trust blocks them, tmux pane identity is missing, or a hook is missed, no directory-only match is made. `COPILOT_HOME` selects the hook directory; `CODING_AGENTS_TMUX_COPILOT_STATE_DIR` overrides the local state directory. The integration neither grants permissions nor injects context. ## How this compares to Herdr diff --git a/src/core/copilot.ts b/src/core/copilot.ts index 5c0a451..af3ee44 100644 --- a/src/core/copilot.ts +++ b/src/core/copilot.ts @@ -28,6 +28,7 @@ interface HookState { version: 1; paneId: string; sessionId: string; + processPid: number; previousSessionId?: string; directory: string; status: RuntimeStatus; @@ -36,6 +37,7 @@ interface HookState { updatedAt: number; } interface HookOptions { + processPid?: number; paneId?: string | null; stateDir?: string; now?: number; @@ -68,6 +70,9 @@ function readState(file: string, paneId: string): HookState | null { value.paneId !== paneId || typeof value.sessionId !== "string" || !value.sessionId || + typeof value.processPid !== "number" || + !Number.isSafeInteger(value.processPid) || + value.processPid <= 0 || typeof value.directory !== "string" || !["running", "idle", "waiting-question", "waiting-input", "unknown"].includes( String(value.status), @@ -149,11 +154,15 @@ export async function persistCopilotHookState( const sessionId = value.sessionId as string; const directory = value.cwd as string; const timestamp = value.timestamp as number; + const processPid = + options.processPid ?? (options.paneId === undefined ? process.ppid : process.pid); + if (!Number.isSafeInteger(processPid) || processPid <= 0) return; const dir = options.stateDir ?? getCopilotStateDir(); mkdirSync(dir, { recursive: true, mode: 0o700 }); const file = paneFile(dir, paneId); const changed = await withPaneLock(file, () => { - const old = readState(file, paneId); + const stored = readState(file, paneId); + const old = stored?.processPid === processPid ? stored : null; if ( old && (sessionId === old.previousSessionId || @@ -176,12 +185,15 @@ export async function persistCopilotHookState( ? value.notification_type === "permission_prompt" ? "waiting-question" : "waiting-input" - : sameSession && old - ? old.status - : "idle"; + : (value.source === "resume" && old?.event !== "userPromptSubmitted") || + !sameSession || + !old + ? "idle" + : old.status; const state: HookState = { version: 1, paneId, + processPid, sessionId, directory, status, @@ -206,6 +218,15 @@ export async function persistCopilotHookState( if (changed) await (options.notify ?? notifyIntegration)(); } +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return isRecord(error) && error.code === "EPERM"; + } +} + export function attachRuntimeWithCopilot( panes: DiscoveredPane[], options: { stateDir?: string; now?: number } = {}, @@ -215,7 +236,9 @@ export function attachRuntimeWithCopilot( return panes.map((entry) => { const state = readState(paneFile(dir, entry.pane.paneId), entry.pane.paneId); const matching = - state?.directory === entry.pane.currentPath && state.updatedAt <= now + CLOCK_SKEW_MS + state?.directory === entry.pane.currentPath && + isProcessAlive(state.processPid) && + state.updatedAt <= now + CLOCK_SKEW_MS ? state : null; const fresh = matching && now - matching.updatedAt < STATE_TTL_MS; diff --git a/test/copilot-hooks.test.ts b/test/copilot-hooks.test.ts index 370be19..7af439e 100644 --- a/test/copilot-hooks.test.ts +++ b/test/copilot-hooks.test.ts @@ -129,7 +129,9 @@ test("clear, late old-session hooks, stale waits, malformed events and missing p await ingest( event("new", start + 6, { event: "notification", notification_type: "permission_prompt" }), ); - assert.equal(status(start + 120_000).status, "unknown"); + const stale = status(start + 120_000); + assert.equal(stale.status, "unknown"); + assert.equal(stale.source, "copilot-command"); await ingest("{}", null); await ingest("not json"); await ingest( @@ -140,6 +142,45 @@ test("clear, late old-session hooks, stale waits, malformed events and missing p assert.equal(status().status, "waiting-question"); }); +test("resuming a session clears stale waiting state but a late startup event preserves the first prompt", async () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-resume-")); + const ingest = (payload: string) => + persistCopilotHookState(payload, { + paneId: "%20", + stateDir: dir, + now: start + 500, + notify: async () => {}, + }); + const status = () => + attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, now: start + 500 })[0]!.runtime.status; + await ingest( + event("s1", start + 1, { event: "notification", notification_type: "permission_prompt" }), + ); + await ingest(event("s1", start + 2, { event: "sessionStart", source: "resume" })); + assert.equal(status(), "idle"); + await ingest(event("s1", start + 3, { event: "userPromptSubmitted" })); + await ingest(event("s1", start + 4, { event: "sessionStart", source: "startup" })); + assert.equal(status(), "running"); +}); + +test("state from an exited Copilot process cannot attach to a replacement in the same pane", async () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-exit-")); + await persistCopilotHookState( + event("old", start + 1, { event: "notification", notification_type: "permission_prompt" }), + { + paneId: "%20", + stateDir: dir, + now: start + 10, + processPid: 999_999_999, + notify: async () => {}, + }, + ); + const runtime = attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, now: start + 20 })[0]! + .runtime; + assert.equal(runtime.status, "unknown"); + assert.equal(runtime.source, "copilot-command"); +}); + test("configured CLI hook args ingest real camelCase payloads without emitting decisions", () => { const dir = mkdtempSync(join(tmpdir(), "copilot-cli-")); const bin = join(import.meta.dirname, "..", "bin", "coding-agents-tmux"); From b240cde07eab862b47242034add1536097a1ebbf Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 22 Sep 2026 20:00:59 -0700 Subject: [PATCH 3/7] fix: require foreground Copilot process for hook state --- README.md | 2 +- src/core/copilot.ts | 28 ++++++++++++++++-- test/copilot-hooks.test.ts | 58 +++++++++++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c7deede..3ad1736 100644 --- a/README.md +++ b/README.md @@ -500,7 +500,7 @@ An interactive `copilot` process (or `copilot.exe` on Windows) in a tmux pane is For optional, **local Copilot CLI only** turn and prompt state, preview the configuration with `coding-agents-tmux copilot-hooks-template`, then explicitly run `coding-agents-tmux install-copilot`. This creates only `${COPILOT_HOME:-~/.copilot}/hooks/coding-agents-tmux.json`; other user hooks are untouched. Restart existing Copilot CLI sessions so hooks load. Do not put these hooks in `.github/hooks/` (repository hooks also execute in Copilot cloud agent). The tmux plugin never auto-installs Copilot hooks. -The adapter correlates `TMUX_PANE`, the hook process's Copilot parent PID, and the Copilot `sessionId`, including concurrent sessions in one directory and `/clear` (which ends the old session and starts another). An exited Copilot process invalidates its hook state even if another CLI starts in the same pane. `userPromptSubmitted` means running; `agentStop` means idle. Only `notification` types `permission_prompt` and `elicitation_dialog` assert waiting-question and waiting-input respectively; automatic permission checks and `preToolUse` never assert a wait. An elicitation notification does not reveal whether its form contains multiple choices, so it is conservatively labeled input. Resolved prompts clear at the next turn boundary (`agentStop`); hooks do not expose an immediate resolution event, so the state can briefly lag after an answer. Hook state expires after 60 seconds without a subsequent event (including an unanswered prompt), reverting to the coarse navigable state instead of retaining a false wait. `inspect` reports hook source, age and expiry or command-only fallback. If hooks are disabled, folder trust blocks them, tmux pane identity is missing, or a hook is missed, no directory-only match is made. `COPILOT_HOME` selects the hook directory; `CODING_AGENTS_TMUX_COPILOT_STATE_DIR` overrides the local state directory. The integration neither grants permissions nor injects context. +The adapter correlates `TMUX_PANE`, the hook process's Copilot parent PID, and the Copilot `sessionId`, including concurrent sessions in one directory and `/clear` (which ends the old session and starts another). An exited or backgrounded Copilot process invalidates its hook state even if another CLI starts in the same pane. `userPromptSubmitted` means running; `agentStop` means idle. Only `notification` types `permission_prompt` and `elicitation_dialog` assert waiting-question and waiting-input respectively; automatic permission checks and `preToolUse` never assert a wait. An elicitation notification does not reveal whether its form contains multiple choices, so it is conservatively labeled input. Resolved prompts clear at the next turn boundary (`agentStop`); hooks do not expose an immediate resolution event, so the state can briefly lag after an answer. Hook state expires after 60 seconds without a subsequent event (including an unanswered prompt), reverting to the coarse navigable state instead of retaining a false wait. `inspect` reports hook source, age and expiry or command-only fallback. If hooks are disabled, folder trust blocks them, tmux pane identity is missing, or a hook is missed, no directory-only match is made. `COPILOT_HOME` selects the hook directory; `CODING_AGENTS_TMUX_COPILOT_STATE_DIR` overrides the local state directory. The integration neither grants permissions nor injects context. ## How this compares to Herdr diff --git a/src/core/copilot.ts b/src/core/copilot.ts index af3ee44..ba2972d 100644 --- a/src/core/copilot.ts +++ b/src/core/copilot.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import { basename, join } from "node:path"; import { homedir } from "node:os"; import { @@ -227,9 +228,29 @@ function isProcessAlive(pid: number): boolean { } } +function isForegroundProcess(pid: number, tty: string): boolean { + if (!tty.startsWith("/dev/")) return false; + const result = spawnSync("ps", ["-p", String(pid), "-o", "pgid=,tpgid=,tty="], { + encoding: "utf8", + timeout: 500, + }); + if (result.status !== 0) return false; + const match = result.stdout.trim().match(/^(\d+)\s+(\d+)\s+(\S+)$/); + return Boolean( + match && + match[1] === match[2] && + match[2] !== "0" && + (match[3] === tty || `/dev/${match[3]}` === tty), + ); +} + export function attachRuntimeWithCopilot( panes: DiscoveredPane[], - options: { stateDir?: string; now?: number } = {}, + options: { + stateDir?: string; + now?: number; + isForeground?: (pid: number, tty: string) => boolean; + } = {}, ): PaneRuntimeSummary[] { const now = options.now ?? Date.now(); const dir = options.stateDir ?? getCopilotStateDir(); @@ -238,6 +259,7 @@ export function attachRuntimeWithCopilot( const matching = state?.directory === entry.pane.currentPath && isProcessAlive(state.processPid) && + (options.isForeground ?? isForegroundProcess)(state.processPid, entry.pane.tty) && state.updatedAt <= now + CLOCK_SKEW_MS ? state : null; @@ -256,10 +278,10 @@ export function attachRuntimeWithCopilot( source: fresh ? "copilot-hook" : "copilot-command", match: { strategy: "exact", provider: "copilot", heuristic: false }, session: { - id: matching?.sessionId ?? `copilot:${entry.pane.target}`, + id: fresh ? matching.sessionId : `copilot:${entry.pane.target}`, directory: entry.pane.currentPath, title: basename(entry.pane.currentPath) || "Copilot CLI", - timeUpdated: matching?.updatedAt ?? now, + timeUpdated: fresh ? matching.updatedAt : now, }, detail: fresh ? `Copilot ${matching.event} hook (${Math.max(0, now - matching.updatedAt)}ms old); expires after ${STATE_TTL_MS}ms` diff --git a/test/copilot-hooks.test.ts b/test/copilot-hooks.test.ts index 7af439e..4158b40 100644 --- a/test/copilot-hooks.test.ts +++ b/test/copilot-hooks.test.ts @@ -46,7 +46,11 @@ test("hook events isolate same-directory panes and clear only resolved prompts", notify: async () => {}, }); const view = () => - attachRuntimeWithCopilot([pane("%20"), pane("%21")], { stateDir: dir, now: start + 10_000 }); + attachRuntimeWithCopilot([pane("%20"), pane("%21")], { + stateDir: dir, + now: start + 10_000, + isForeground: () => true, + }); await ingest("%20", event("s1", start + 10, { event: "userPromptSubmitted" })); await ingest("%20", event("s1", start + 15, { event: "sessionStart", source: "startup" })); await ingest("%21", event("s2", start + 20, { event: "sessionStart", source: "startup" })); @@ -88,7 +92,11 @@ test("automatically approved checks never wait; unmatched notifications do not c notify: async () => {}, }); const status = () => - attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, now: start + 500 })[0]!.runtime.status; + attachRuntimeWithCopilot([pane("%20")], { + stateDir: dir, + now: start + 500, + isForeground: () => true, + })[0]!.runtime.status; await ingest(event("s1", start + 1, { event: "userPromptSubmitted" })); await ingest(event("s1", start + 2, { event: "preToolUse" })); await ingest(event("s1", start + 3, { event: "permissionRequest" })); @@ -114,7 +122,8 @@ test("clear, late old-session hooks, stale waits, malformed events and missing p notify: async () => {}, }); const status = (now = start + 500) => - attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, now })[0]!.runtime; + attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, now, isForeground: () => true })[0]! + .runtime; await ingest( event("old", start + 1, { event: "notification", notification_type: "permission_prompt" }), ); @@ -152,7 +161,11 @@ test("resuming a session clears stale waiting state but a late startup event pre notify: async () => {}, }); const status = () => - attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, now: start + 500 })[0]!.runtime.status; + attachRuntimeWithCopilot([pane("%20")], { + stateDir: dir, + now: start + 500, + isForeground: () => true, + })[0]!.runtime.status; await ingest( event("s1", start + 1, { event: "notification", notification_type: "permission_prompt" }), ); @@ -175,12 +188,42 @@ test("state from an exited Copilot process cannot attach to a replacement in the notify: async () => {}, }, ); - const runtime = attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, now: start + 20 })[0]! - .runtime; + const runtime = attachRuntimeWithCopilot([pane("%20")], { + stateDir: dir, + now: start + 20, + isForeground: () => true, + })[0]!.runtime; assert.equal(runtime.status, "unknown"); assert.equal(runtime.source, "copilot-command"); }); +test("suspended process state does not classify another foreground Copilot pane occupant", async () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-background-")); + await persistCopilotHookState( + event("s1", start + 1, { event: "notification", notification_type: "permission_prompt" }), + { + paneId: "%20", + stateDir: dir, + now: start + 5, + notify: async () => {}, + }, + ); + const runtime = attachRuntimeWithCopilot([pane("%20")], { + stateDir: dir, + now: start + 10, + isForeground: () => false, + })[0]!.runtime; + assert.equal(runtime.status, "unknown"); + assert.equal(runtime.session?.id, "copilot:work:1.0"); + const stale = attachRuntimeWithCopilot([pane("%20")], { + stateDir: dir, + now: start + 120_000, + isForeground: () => true, + })[0]!.runtime; + assert.equal(stale.session?.id, "copilot:work:1.0"); + assert.equal(stale.session?.timeUpdated, start + 120_000); +}); + test("configured CLI hook args ingest real camelCase payloads without emitting decisions", () => { const dir = mkdtempSync(join(tmpdir(), "copilot-cli-")); const bin = join(import.meta.dirname, "..", "bin", "coding-agents-tmux"); @@ -204,7 +247,8 @@ test("configured CLI hook args ingest real camelCase payloads without emitting d assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, ""); assert.equal( - attachRuntimeWithCopilot([pane("%20")], { stateDir: dir })[0]!.runtime.status, + attachRuntimeWithCopilot([pane("%20")], { stateDir: dir, isForeground: () => true })[0]!.runtime + .status, "waiting-question", ); }); From 2b0eadceb1040947365fe4cd43a6c5b00aceb9c3 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 22 Sep 2026 20:07:34 -0700 Subject: [PATCH 4/7] fix: detect npm-installed Copilot CLI panes --- README.md | 2 +- src/core/tmux.ts | 19 ++++++++++++++++++- test/tmux.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ad1736..d7d1a8d 100644 --- a/README.md +++ b/README.md @@ -496,7 +496,7 @@ This means any `kiro-cli` pane can be discovered and switched to without naming ## GitHub Copilot CLI -An interactive `copilot` process (or `copilot.exe` on Windows) in a tmux pane is discovered in `list`, the chooser/popup, and `status`. Use `--agent copilot` to filter navigation. No hooks are required: without them, activity and status remain `unknown` rather than guessing a wait. Title text, arbitrary `copilot-*` commands, and wrapped launches are not detected; generic wrapper support is tracked separately in #19. +An interactive `copilot` process (or `copilot.exe` on Windows) in a tmux pane is discovered in `list`, the chooser/popup, and `status`. Use `--agent copilot` to filter navigation. No hooks are required: without them, activity and status remain `unknown` rather than guessing a wait. Title text and arbitrary `copilot-*` commands are not detected. The npm-installed CLI's Node wrapper is recognized by its executable path; generic wrapper support is tracked separately in #19. For optional, **local Copilot CLI only** turn and prompt state, preview the configuration with `coding-agents-tmux copilot-hooks-template`, then explicitly run `coding-agents-tmux install-copilot`. This creates only `${COPILOT_HOME:-~/.copilot}/hooks/coding-agents-tmux.json`; other user hooks are untouched. Restart existing Copilot CLI sessions so hooks load. Do not put these hooks in `.github/hooks/` (repository hooks also execute in Copilot cloud agent). The tmux plugin never auto-installs Copilot hooks. diff --git a/src/core/tmux.ts b/src/core/tmux.ts index c642c5c..62f2df9 100644 --- a/src/core/tmux.ts +++ b/src/core/tmux.ts @@ -110,6 +110,18 @@ function processArgsContainCodex(stdoutText: string): boolean { .some((line) => line.split(/\s+/).some((token) => isCodexProcessToken(token))); } +function processArgsContainCopilot(stdoutText: string): boolean { + return stdoutText.split("\n").some((line) => + line.split(/\s+/).some((token) => { + const normalized = token.toLowerCase().replace(/^['"]+|['",;:]+$/g, ""); + return ( + /(?:^|[\\/])\.bin[\\/]copilot(?:\.exe)?$/.test(normalized) || + /(?:^|[\\/])@github[\\/]copilot(?:-[a-z0-9-]+)?[\\/]copilot(?:\.exe)?$/.test(normalized) + ); + }), + ); +} + // Recent Claude Code releases rename their process so tmux reports the version // string (e.g. "2.1.206") as pane_current_command instead of "claude". function isClaudeVersionCommand(command: string): boolean { @@ -382,10 +394,15 @@ async function detectAgentPaneFromProcessArgs(pane: TmuxPane): Promise { + const fakeTmux = installFakeTmux(` +if [ "$1" = "list-panes" ] && [ "$2" = "-a" ]; then + printf 'work\t1\t0\t%%1\tproject\tnode\t/tmp/project\t1\t/dev/ttys001\n' + printf 'work\t1\t1\t%%2\tproject\tnode\t/tmp/project\t0\t/dev/ttys002\n' + exit 0 +fi +exit 1 +`); + const psPath = join(fakeTmux.pathEntry, "ps"); + writeFileSync( + psPath, + `#!/usr/bin/env bash +set -euo pipefail +case "$2" in + /dev/ttys001) + printf '/opt/node /tmp/copilot-spike/node_modules/.bin/copilot --no-auto-update\n' + printf '/tmp/copilot-spike/node_modules/@github/copilot-darwin-arm64/copilot\n' + ;; + /dev/ttys002) + printf '/opt/node /tmp/project/scripts/copilot-notes.js\n' + ;; +esac +`, + "utf8", + ); + chmodSync(psPath, 0o755); + const restoreEnv = setEnv({ PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}` }); + try { + const panes = await discoverAgentPanes(); + assert.deepEqual( + panes.map((pane) => pane.pane.target), + ["work:1.0"], + ); + assert.equal(panes[0]?.detection.agent, "copilot"); + } finally { + restoreEnv(); + } +}); + test("normalizeCapturedPaneLines strips ANSI escapes, expands tabs, and preserves internal blanks", () => { const raw = ["plain\ttext", "\u001b[31mred\u001b[0m", "", " ", "tail", ""].join("\n"); From d0f1d02a2a9861108eac4bec7e20b7e87783544c Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 22 Sep 2026 20:11:32 -0700 Subject: [PATCH 5/7] fix: scope wrapped agent detection to foreground process group --- src/core/tmux.ts | 26 +++++++++++++++++++------- test/tmux.test.ts | 38 +++++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/core/tmux.ts b/src/core/tmux.ts index 62f2df9..2006516 100644 --- a/src/core/tmux.ts +++ b/src/core/tmux.ts @@ -392,16 +392,28 @@ async function detectAgentPaneFromProcessArgs(pane: TmuxPane): Promise { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/); + return match && match[1] === match[2] && match[2] !== "0" ? [match[3]] : []; + }) + .join("\n"); + if (processArgsContainCopilot(foregroundArgs) && processArgsContainCodex(foregroundArgs)) + return null; // Do not guess when competing agents share a foreground group. + if (processArgsContainCopilot(foregroundArgs)) { return { agent: "copilot", confidence: "medium", reasons: ["process:copilot"] }; } - if (!processArgsContainCodex(stdoutText)) return null; + if (!processArgsContainCodex(foregroundArgs)) return null; return { agent: "codex", diff --git a/test/tmux.test.ts b/test/tmux.test.ts index 9ef76e8..fdd61cc 100644 --- a/test/tmux.test.ts +++ b/test/tmux.test.ts @@ -332,15 +332,15 @@ exit 1 set -euo pipefail case "$2" in /dev/ttys001) - printf '/opt/node /Users/example/.npm/bin/codex\n' + printf ' 100 100 /opt/node /Users/example/.npm/bin/codex\n' exit 0 ;; /dev/ttys002) - printf '/opt/node /Users/example/.pnpm/global/5/node_modules/@openai/codex/bin/codex.js\n' + printf ' 200 200 /opt/node /Users/example/.pnpm/global/5/node_modules/@openai/codex/bin/codex.js\n' exit 0 ;; /dev/ttys003) - printf '/opt/node /tmp/codex-project/scripts/dev.js\n' + printf ' 300 300 /opt/node /tmp/codex-project/scripts/dev.js\n' exit 0 ;; esac @@ -383,8 +383,8 @@ exit 1 set -euo pipefail case "$2" in /dev/ttys001) - printf '/opt/node /tmp/copilot-spike/node_modules/.bin/copilot --no-auto-update\n' - printf '/tmp/copilot-spike/node_modules/@github/copilot-darwin-arm64/copilot\n' + printf ' 100 100 /opt/node /tmp/copilot-spike/node_modules/.bin/copilot --no-auto-update\n' + printf ' 100 100 /tmp/copilot-spike/node_modules/@github/copilot-darwin-arm64/copilot\n' ;; /dev/ttys002) printf '/opt/node /tmp/project/scripts/copilot-notes.js\n' @@ -407,6 +407,34 @@ esac } }); +test("background Copilot cannot take over a foreground wrapped Codex pane", async () => { + const fakeTmux = installFakeTmux(` +if [ "$1" = "list-panes" ] && [ "$2" = "-a" ]; then + printf 'work\t1\t0\t%%1\tproject\tnode\t/tmp/project\t1\t/dev/ttys001\n' + exit 0 +fi +exit 1 +`); + const psPath = join(fakeTmux.pathEntry, "ps"); + writeFileSync( + psPath, + `#!/usr/bin/env bash +set -euo pipefail +printf ' 100 200 /opt/node /tmp/node_modules/.bin/copilot\n' +printf ' 200 200 /opt/node /tmp/node_modules/@openai/codex/bin/codex.js\n' +`, + "utf8", + ); + chmodSync(psPath, 0o755); + const restoreEnv = setEnv({ PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}` }); + try { + const panes = await discoverAgentPanes(); + assert.equal(panes[0]?.detection.agent, "codex"); + } finally { + restoreEnv(); + } +}); + test("normalizeCapturedPaneLines strips ANSI escapes, expands tabs, and preserves internal blanks", () => { const raw = ["plain\ttext", "\u001b[31mred\u001b[0m", "", " ", "tail", ""].join("\n"); From 14b7521a665cdf831c1563e73c95bb71be038407 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 22 Sep 2026 20:25:25 -0700 Subject: [PATCH 6/7] Fix Copilot hook resume, home fallback, and foreground ownership --- src/core/copilot.ts | 29 ++++++++--- test/copilot-hooks.test.ts | 99 +++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/src/core/copilot.ts b/src/core/copilot.ts index ba2972d..9f366f9 100644 --- a/src/core/copilot.ts +++ b/src/core/copilot.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { basename, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { homedir } from "node:os"; import { existsSync, @@ -44,6 +44,7 @@ interface HookOptions { now?: number; notify?: () => Promise; eventName?: string; + isForeground?: (pid: number, paneId: string) => boolean; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); @@ -57,10 +58,15 @@ export function getCopilotStateDir(): string { subdirectory: "copilot-state", }); } -export function getCopilotHooksPath( - home = process.env.COPILOT_HOME ?? join(homedir(), ".copilot"), -): string { - return join(home, "hooks", "coding-agents-tmux.json"); +export function getCopilotHooksPath(home = process.env.COPILOT_HOME): string { + return join(home?.trim() || join(homedir(), ".copilot"), "hooks", "coding-agents-tmux.json"); +} +function isForegroundInPane(pid: number, paneId: string): boolean { + const result = spawnSync("tmux", ["display-message", "-p", "-t", paneId, "#{pane_tty}"], { + encoding: "utf8", + timeout: 500, + }); + return result.status === 0 && isForegroundProcess(pid, result.stdout.trim()); } function readState(file: string, paneId: string): HookState | null { try { @@ -163,11 +169,18 @@ export async function persistCopilotHookState( const file = paneFile(dir, paneId); const changed = await withPaneLock(file, () => { const stored = readState(file, paneId); + if ( + stored && + stored.processPid !== processPid && + !(options.isForeground ?? isForegroundInPane)(processPid, paneId) + ) + return false; const old = stored?.processPid === processPid ? stored : null; if ( old && - (sessionId === old.previousSessionId || - timestamp < old.eventAt || + (timestamp < old.eventAt || + (sessionId === old.previousSessionId && + !(event === "sessionStart" && value.source === "resume" && timestamp > old.eventAt)) || (sessionId !== old.sessionId && event !== "sessionStart" && event !== "userPromptSubmitted")) @@ -310,7 +323,7 @@ export function buildCopilotHooksTemplate(command: string): string { } export function installCopilotIntegration(command: string, home?: string): { hooksPath: string } { const path = getCopilotHooksPath(home); - const dir = join(home ?? process.env.COPILOT_HOME ?? join(homedir(), ".copilot"), "hooks"); + const dir = dirname(path); const next = buildCopilotHooksTemplate(command); mkdirSync(dir, { recursive: true }); let output = next; diff --git a/test/copilot-hooks.test.ts b/test/copilot-hooks.test.ts index 4158b40..78914e1 100644 --- a/test/copilot-hooks.test.ts +++ b/test/copilot-hooks.test.ts @@ -1,6 +1,13 @@ import { spawnSync } from "node:child_process"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -176,6 +183,96 @@ test("resuming a session clears stale waiting state but a late startup event pre assert.equal(status(), "running"); }); +test("a newer resume returns to the previous session without accepting its late callbacks", async () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-resume-")); + const ingest = (session: string, offset: number, eventName: string, extras = {}) => + persistCopilotHookState(event(session, start + offset, { event: eventName, ...extras }), { + paneId: "%20", + stateDir: dir, + now: start + 500, + notify: async () => {}, + }); + const runtime = () => + attachRuntimeWithCopilot([pane("%20")], { + stateDir: dir, + now: start + 500, + isForeground: () => true, + })[0]!.runtime; + await ingest("A", 1, "sessionStart", { source: "startup" }); + await ingest("B", 3, "sessionStart", { source: "new" }); + await ingest("A", 4, "agentStop"); + assert.equal(runtime().session?.id, "B"); + await ingest("A", 2, "sessionStart", { source: "resume" }); + assert.equal(runtime().session?.id, "B"); + await ingest("A", 5, "sessionStart", { source: "resume" }); + assert.equal(runtime().session?.id, "A"); + await ingest("A", 6, "userPromptSubmitted"); + assert.equal(runtime().status, "running"); + await ingest("B", 7, "agentStop"); + assert.equal(runtime().session?.id, "A"); + assert.equal(runtime().status, "running"); +}); + +test("a background process cannot replace a different foreground PID's state", async () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-processes-")); + const foregroundPid = process.pid; + const backgroundPid = process.ppid; + const ingest = (pid: number, session: string, offset: number, foreground: boolean) => + persistCopilotHookState( + event(session, start + offset, { + event: "notification", + notification_type: "permission_prompt", + }), + { + paneId: "%20", + stateDir: dir, + now: start + 500, + processPid: pid, + isForeground: () => foreground, + notify: async () => {}, + }, + ); + const runtime = () => + attachRuntimeWithCopilot([pane("%20")], { + stateDir: dir, + now: start + 500, + isForeground: (pid) => pid === foregroundPid, + })[0]!.runtime; + await ingest(backgroundPid, "old", 1, false); + await ingest(foregroundPid, "new", 2, true); + assert.equal(runtime().status, "waiting-question"); + await ingest(backgroundPid, "old", 3, false); + assert.equal(runtime().session?.id, "new"); + assert.equal(runtime().status, "waiting-question"); +}); + +test("blank COPILOT_HOME installs into the default home, not the working directory", () => { + for (const blank of ["", " "]) { + const root = mkdtempSync(join(tmpdir(), "copilot-blank-home-")); + const cwd = join(root, "work"); + const home = join(root, "home"); + mkdirSync(cwd); + mkdirSync(home); + const moduleUrl = new URL("../src/core/copilot.ts", import.meta.url).href; + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + `import { installCopilotIntegration } from ${JSON.stringify(moduleUrl)}; installCopilotIntegration('/example/bin');`, + ], + { + cwd, + env: { ...process.env, HOME: home, COPILOT_HOME: blank }, + encoding: "utf8", + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(join(home, ".copilot", "hooks", "coding-agents-tmux.json")), true); + assert.equal(existsSync(join(cwd, "hooks")), false); + } +}); + test("state from an exited Copilot process cannot attach to a replacement in the same pane", async () => { const dir = mkdtempSync(join(tmpdir(), "copilot-exit-")); await persistCopilotHookState( From 0c0c0a62c5ab9cdfd896e96d8633381d00639366 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 22 Sep 2026 20:40:30 -0700 Subject: [PATCH 7/7] fix: retain retired Copilot sessions across repeated clears --- src/core/copilot.ts | 21 ++++++++++++++------- test/copilot-hooks.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/core/copilot.ts b/src/core/copilot.ts index 9f366f9..0212374 100644 --- a/src/core/copilot.ts +++ b/src/core/copilot.ts @@ -30,7 +30,8 @@ interface HookState { paneId: string; sessionId: string; processPid: number; - previousSessionId?: string; + retiredSessionIds?: string[]; + previousSessionId?: string; // State written before retired-session tracking. directory: string; status: RuntimeStatus; event: EventName; @@ -80,6 +81,9 @@ function readState(file: string, paneId: string): HookState | null { typeof value.processPid !== "number" || !Number.isSafeInteger(value.processPid) || value.processPid <= 0 || + (value.retiredSessionIds !== undefined && + (!Array.isArray(value.retiredSessionIds) || + value.retiredSessionIds.some((id: unknown) => typeof id !== "string" || !id))) || typeof value.directory !== "string" || !["running", "idle", "waiting-question", "waiting-input", "unknown"].includes( String(value.status), @@ -176,10 +180,12 @@ export async function persistCopilotHookState( ) return false; const old = stored?.processPid === processPid ? stored : null; + const retired = + old?.retiredSessionIds ?? (old?.previousSessionId ? [old.previousSessionId] : []); if ( old && (timestamp < old.eventAt || - (sessionId === old.previousSessionId && + (retired.includes(sessionId) && !(event === "sessionStart" && value.source === "resume" && timestamp > old.eventAt)) || (sessionId !== old.sessionId && event !== "sessionStart" && @@ -214,11 +220,12 @@ export async function persistCopilotHookState( event: event as EventName, eventAt: timestamp, updatedAt: now, - ...(old && !sameSession - ? { previousSessionId: old.sessionId } - : old?.previousSessionId - ? { previousSessionId: old.previousSessionId } - : {}), + retiredSessionIds: sameSession + ? retired + : [ + ...retired.filter((id) => id !== sessionId && id !== old?.sessionId), + ...(old ? [old.sessionId] : []), + ], }; const temp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`; try { diff --git a/test/copilot-hooks.test.ts b/test/copilot-hooks.test.ts index 78914e1..34b13bb 100644 --- a/test/copilot-hooks.test.ts +++ b/test/copilot-hooks.test.ts @@ -213,6 +213,35 @@ test("a newer resume returns to the previous session without accepting its late assert.equal(runtime().status, "running"); }); +test("multiple retired sessions cannot overwrite the current session, but can resume explicitly", async () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-retired-")); + const ingest = (session: string, offset: number, eventName: string, extras = {}) => + persistCopilotHookState(event(session, start + offset, { event: eventName, ...extras }), { + paneId: "%20", + stateDir: dir, + now: start + 500, + notify: async () => {}, + }); + const runtime = () => + attachRuntimeWithCopilot([pane("%20")], { + stateDir: dir, + now: start + 500, + isForeground: () => true, + })[0]!.runtime; + await ingest("A", 1, "sessionStart", { source: "new" }); + await ingest("B", 2, "sessionStart", { source: "new" }); + await ingest("C", 3, "sessionStart", { source: "new" }); + await ingest("A", 4, "userPromptSubmitted"); + await ingest("A", 5, "sessionStart", { source: "new" }); + assert.equal(runtime().session?.id, "C"); + await ingest("A", 6, "sessionStart", { source: "resume" }); + assert.equal(runtime().session?.id, "A"); + await ingest("B", 7, "userPromptSubmitted"); + assert.equal(runtime().session?.id, "A"); + await ingest("B", 8, "sessionStart", { source: "resume" }); + assert.equal(runtime().session?.id, "B"); +}); + test("a background process cannot replace a different foreground PID's state", async () => { const dir = mkdtempSync(join(tmpdir(), "copilot-processes-")); const foregroundPid = process.pid;