# Agent adapters Per-agent hook/plugin quirks and the agent-owned files ccmux reads. **Read the relevant section before touching an adapter** — most of these are load-bearing workarounds for a specific agent's behavior, not incidental notes. For the general hook flow (marker shape, per-agent pane-correlation strategy, install lifecycle, OpenCode aggregation), see [`docs/architecture.md#hook-lifecycle`](./architecture.md#hook-lifecycle). The single-source-of-truth adapter factory is `createBuiltinHookAdapters()` in `src/daemon/adapters/index.ts`, consumed by both the daemon and `ccmux setup`. ## Adapter module map | Agent | Adapter + primitives | | :---------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Claude | `adapters/claude/hook-adapter.ts` | | Codex | `adapters/codex/hook-adapter.ts`, `hook-scripts.ts` (bash generators), `toml.ts` (hand-rolled `[features]` flag editor) | | Cursor | `adapters/cursor/hook-adapter.ts`, `hook-scripts.ts` (bash generators), `version.ts` (`cursor-agent --version` gate) | | OpenCode | `adapters/opencode/plugin-adapter.ts`, `plugin-script.ts` (install-time renderer), `aggregate.ts` (pure many→one fold), authored plugin `src/plugins/opencode/plugin.js` | | Pi | `adapters/pi/hook-adapter.ts`, `extension-script.ts` (install-time renderer), authored extension `src/plugins/pi/ccmux.js` | | omp | `adapters/omp/hook-adapter.ts`, `extension-script.ts` (install-time renderer), authored extension `src/plugins/omp/ccmux.js` | | Antigravity | `adapters/antigravity/hook-adapter.ts`, `hook-scripts.ts` (bash generators) | | Copilot | `adapters/copilot/hook-adapter.ts`, `hook-scripts.ts` (marker script + hooks-JSON generators), `log-adapter.ts`, `parse.ts` (events.jsonl parsing) | The startup-race closer for markers written before the first scan created the pane-tracked session is the shared, agent-agnostic `reconcileSessionMarkerLinks()` in `adapters/link.ts` (keyed off `adapter.agentType`; it also re-derives native-id ownership each scan so a mis-linked id heals). Used by Cursor, OpenCode, Pi, omp, Antigravity, and Copilot (`Daemon.linkPiSessions` / `Daemon.linkOmpSessions` / `Daemon.linkAntigravitySessions` / `Daemon.linkCopilotSessions`). ## Spawning with an initial prompt `POST /spawn` / `ccmux spawn --prompt` must start an **interactive** session with the prompt submitted. There is no shared flag for that, and the wrong choice fails silently: a print/one-shot flag still runs, it just exits after one turn instead of leaving a session behind. Each agent's shape is declared in `AgentDef.promptCommand` (`src/lib/agents.ts`) and pinned by a table test in `src/daemon/spawn-command.test.ts`. Verified by reading each CLI's own `--help` on a machine with all nine installed: | Agent | Interactive-with-prompt | What the help says | | :---------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Claude | `claude ''` | `claude [options] [command] [prompt]`; "starts an interactive session by default, use -p/--print" for one-shot | | Codex | `codex ''` | `codex [OPTIONS] [PROMPT]`; "[PROMPT] Optional user prompt to start the session"; `codex exec` is one-shot | | Cursor | `cursor-agent ''` | `agent [options] [command] [prompt...]`; "prompt Initial prompt for the agent"; `-p/--print` is one-shot | | OpenCode | `opencode --prompt ''` | default TUI command's "--prompt prompt to use"; the positional is a PROJECT PATH; `opencode run` is one-shot | | Pi | `pi ''` | EXAMPLES section spells it out: "# Interactive mode with initial prompt / `pi "List all .ts files in src/"`" vs "# Non-interactive mode (process and exit) / `pi -p "..."`"; no `--prompt` flag exists | | omp | `omp ''` | EXAMPLES section, same two entries as pi (`omp "..."` interactive, `omp -p "..."` one-shot) | | Antigravity | `agy -i ''` | **`--prompt` is documented as "Alias for --print"**; `-i/--prompt-interactive` is "Run an initial prompt interactively and continue the session" | | Copilot | `copilot -i ''` | `-i, --interactive ` "Start interactive mode and automatically execute this prompt"; `-p/--prompt` is "non-interactive" | | Gemini | `gemini -i ''` | `-p/--prompt` is "non-interactive (headless) mode"; `-i/--prompt-interactive` is "Execute the provided prompt and continue in interactive mode" | So ` --prompt ''`, which ccmux emitted for every agent before this table existed, was correct for exactly one of the nine (OpenCode). For Antigravity, Copilot, and Gemini it silently selected one-shot print mode; for the other five `--prompt` is not a flag at all. OpenCode is the one row the help text alone does not settle: its `--prompt` is listed under the default TUI command's options with no explicit "interactive" wording, so it was confirmed by spawning it — the TUI came up, the prompt was submitted and answered, and the session stayed interactive afterwards. Claude (positional) and Gemini (`-i`) were spawned the same way, which live-covers both template shapes. Only `{prompt}` (mandatory) and `{bin}` (optional, the resolved launcher) are substituted, and every occurrence of each is replaced. Agents with no `promptCommand` — including every custom agent that has not declared one — refuse prompt spawns with a 400 rather than guessing. Two quoting rules are enforced in `spawn-command.ts`, both because prompt text is attacker-shaped input that ends up in a shell command typed into a pane: - Substitution never goes through `String.replace` with a string replacement, which expands `$&`, `` $` ``, `$'`, and `$$` **in the replacement**. A prompt of ``$`; touch ./PWNED; #`` would otherwise splice the preceding template text back in, reopen the quoted word, and execute. Substitution is split/join instead. - The template's quoting is parsed the way `sh` reads it, and every `{prompt}` must land in a real single-quoted context with the template balanced. Checking only the adjacent characters is not enough: in `sh -c "{bin} '{prompt}'"` the placeholder looks single-quoted but the enclosing word is double-quoted, where `'` is ordinary, `"` closes the word, and `$(...)` is expanded. Double quotes, backticks, `$(`, backslashes, and `$'` are refused outright rather than modelled, and the refusal names which one it found. The last two were live bypasses: a backslash desynchronizes any scan from the shell (`{bin} '{prompt}' \{prompt}` swallowed the `{` of the second placeholder and emitted an unquoted copy of the prompt), and `$'...'` is bash/zsh ANSI-C quoting, where the `'\''` escape idiom is interpreted instead of literal. ## Forking a session Fork (`F` in the picker, the context menu's **Fork**, `ccmux spawn --fork `) starts a new session whose conversation continues an existing one's history while leaving that session untouched. It is declared per agent in `AgentDef.forkCommand`, built by `buildAgentForkCommand` in `src/daemon/spawn-command.ts`. A template names the source with `{path}` (its transcript file) or `{id}` (its native session id), plus an optional `{bin}` for the resolved launcher; `{path}` is escaped and quote-checked exactly like `{prompt}` (see the section above), while `{id}` is inert by pattern and needs no quoting. **Claude Code is the only built-in that declares one**, `claude --resume '{path}' --fork-session`. Its `--help` is explicit: "--fork-session: When resuming, create a new session ID instead of reusing the original" (Claude Code 2.1.220). Verified live against a running original: the fork came up with the replayed history under a NEW session id and a new transcript file, and the two then diverged cleanly, with each side's next answer landing only in its own transcript and neither seeing the other's. The forked pane is tracked like any other spawn (its own marker, its own row) once it takes its first turn. Fork is deliberately not enabled for the rest, and adding one is not a matter of pattern-matching the resume flag: - A bare `--resume` (Codex, Cursor, Copilot) re-opens **the same session id**, and none of the three documents a fork-style "new id" flag. Whether two live processes on one rollout/session file interleave safely or the second writer simply wins is untested, and the losing side of that bet is the session the user asked to preserve. - `opencode --continue`, `pi -c`, and `omp -c` take no id at all: they mean "the most recent session", which is not necessarily the row the user forked from. So an agent earns a `forkCommand` only after someone has checked, against a **live** original, that the new process gets a distinct session id and that the original is unharmed. Until then the picker hides Fork for it and the route returns a 400 (there is no guess-and-hope path). A user who has verified an agent themselves can set `agents..forkCommand` in `ccmux.json`. Structural notes for anyone extending this: - Command construction (`buildAgentForkCommand`) is kept separate from pane placement (`buildTmuxSpawnArgv` and the route's placement resolution). `POST /spawn`'s fork path adds no targeting of its own: the caller passes `split`/`target` exactly as for any other spawn. The command half is genuinely reusable. - **Resume by transcript path, because `--resume ` is REPO-scoped.** An earlier version of this document claimed the id form was directory-scoped and that a fork therefore had to run in the source's own cwd. It is not. Claude resolves an id against the project directory for the launch cwd AND, failing that, against every checkout `git worktree list --porcelain` reports from there. Verified live on Claude Code 2.1.220 across seven runs: resume succeeds from the main repo for a worktree-anchored session, from a worktree for a main-anchored session, between sibling worktrees, and from any subdirectory. It fails only from outside the repo entirely. So the old `cwd !== session.cwd` refusal was wrong in both directions: it never fired on the path the picker uses (which sends no `cwd`), and where it did fire it refused destinations that would have worked. It is gone. The built-in template instead passes the transcript's **absolute path**, which bypasses directory resolution altogether and resumes from anywhere, `/tmp` included, in both print and interactive mode. **The path form is undocumented.** It is absent from `claude --help` and from the public docs, though it is deliberate code with its own file-loading and error paths. Verified on Claude Code 2.1.218 through 2.1.220; treat a future break as a real possibility. That is why `{id}` remains a first-class placeholder: if the path form ever stops working, setting `agents.claude.forkCommand` to `"{bin} --resume {id} --fork-session"` in `ccmux.json` restores id-based resume with no ccmux change, at the cost of being confined to the source's repo. Because the command needs a real file, a `{path}` template is refused when `Session.logPath` is missing, relative, not a `.jsonl`, or unreadable, **before** any pane is created. The alternative is the failure the path form exists to prevent: a live pane that found no conversation, which nothing in ccmux can detect. Nothing is ever copied or written; the fork only reads the source's transcript. One combination is still refused: forking directly into a NEW worktree (`--worktree` alongside `--fork`). Not a resume-scoping problem any more, since a linked worktree is in `git worktree list` by construction and the path form does not care regardless. It is simply unshipped: that request creates the destination, and the combination has not been checked against a live agent. Refused before the worktree is created, so nothing is left behind. - Forking mid-turn is safe for Claude and is deliberately not gated on the source being idle. Verified against a source in the middle of a long answer: the source finished its turn normally and stayed `idle`-after-`working` as usual, and the fork came up with the history as of fork time, which includes the in-flight turn's user message but not the answer that had not been written yet. The fork does not auto-run that pending turn; it sits at an empty composer. The two processes only ever share a file one of them reads. ## Claude-specific caveats - **AskUserQuestion looks exactly like a permission prompt to the hook.** Claude fires the `Notification` hook for its AskUserQuestion option picker with the EXACT same payload as a real permission prompt (`{"message":"Claude needs your permission","notification_type":"permission_prompt"}`, verified on Claude Code 2.1.209/2.1.210). There is no distinguishing signal in the payload, so `STATE_NOTIFY_HOOK_SCRIPT` maps both to marker state `waiting_permission`. Claude also does NOT flush the picker's `tool_use` to the JSONL during the wait (same deferred-write behavior as permission-gated tools), so the transcript is silent about it too. **The pane is the only source that distinguishes the two:** the picker renders numbered options plus a "Type something." choice and an "Enter to select" footer, and shows NEITHER "requires approval" nor "Do you want to proceed?". Claude's `terminalRules` classify it as `attentionType: "question"`, and the reconciler relabels the marker candidate via `correctAmbiguousPermissionMarker` (gated by `AgentDef.ambiguousPermissionMarker`) before the cascade fold. The notifier repeats the same pane check at delivery time to cover the one-scan race (`buildNotificationContext` → `reclassifyAs`). - **The picker ignores typed literal text.** Typing an answer into the AskUserQuestion picker does nothing (verified); only Enter on the highlighted option submits, and option digit positions vary. Pressing **Escape cancels the tool cleanly** (`[Request interrupted by user for tool use]`) and returns to the normal composer, where typed text + Enter sends as a user message Claude treats as the answer. This is why Claude's `notificationActions.answerPrelude` is `["Escape"]`: the notification reply sends Escape (plus a settle delay) before the literal text (`handleNotificationAction`). - **A finished-notification Reply sends NO prelude (do not send Escape here).** `notificationActions.replyOnFinished` opts a `finished` (idle) notification into an inline Reply, but the text is typed straight into the idle composer with no prelude keystroke. Escape at Claude's idle composer clears a half-typed draft, and double-Escape opens history rewind, so a prelude here would be destructive, the opposite of the AskUserQuestion case above. Draft-merge caveat: a draft the user already half-typed in the composer merges with the reply text and submits as one combined message, which Claude accepts. This mirrors Approve's risk posture (the button drives the pane blind, trusting the staleness token to reject a state that moved on). - **Reply on a permission prompt is a deny-with-feedback.** `notificationActions.permissionReplyPrelude` is `["Escape"]`: the reply sends Escape (cancelling the pending tool) and a settle delay before the literal text, which then lands as the next user message. Verified on Claude Code 2.1.211 across the Bash approval, Edit/Write diff, and MCP-tool prompts (each footer reads "Esc to cancel", Escape drops to an empty composer that accepts text + Enter). A side effect of shipping this: a Reply press on a wait the store still labels `permission` but the pane has revealed to be an AskUserQuestion question now lands via the same Escape prelude, instead of 409ing until the next-scan marker correction. - **Plan approvals (ExitPlanMode) render a DIFFERENT picker, and the Notification hook fires for them.** Verified on Claude Code 2.1.211. Unlike a permission prompt, the ExitPlanMode picker offers, in order: **1. "Yes, and use auto mode"** (AUTO / bypass, edits stop being gated), **2. "Yes, manually approve edits"** (PLAIN approve), 3. refine with Ultraplan on the web, 4. tell Claude what to change. The plain-approve digit is **`2`, never `1`** (which enables auto mode), so Claude's `notificationActions.planApprove` is `["2"]`; the digit submits immediately with no Enter. `planDeny`/`planReplyPrelude` are `["Escape"]`: Escape cancels ExitPlanMode to an empty composer with plan mode still on, where text + Enter sends as a normal user message. Claude's `Notification` hook DOES fire for the ExitPlanMode wait, but with `state: waiting_permission` and `pending_tool: null`, and the marker wins the cascade, so a live plan wait is STORED as `attentionType: "permission"` with `pendingTool: "ExitPlanMode"` (the tool name comes from the log, not the marker). Worse, that `pending_tool: null` marker combined with a frequently-deferred `ExitPlanMode` log `tool_use` means a live plan wait is USUALLY stored as `{ permission, pendingTool: null }`, so `isPlanApprovalWait` is false in the common window. ccmux therefore treats the PANE as authoritative for the plan-vs-permission split at both notify and press time (`classifyClaudePromptPane`), promoting to `plan_approval` so the plan keys (not the permission `1` = auto mode) are used; the marker/log predicate is only the capture-failure fallback. The plan BODY prefers the transcript `ExitPlanMode` `input.plan` when present (complete and clean), but because that tool_use is often deferred it falls back to extracting the pane's plan box (anchored on the "Here is Claude's plan:" header, reading down to the bottom box rule past the ~10 blank padding lines). ## Codex-specific caveats - `PermissionRequest` requires Codex >= 0.122 (`rust-v0.122` / `@openai/codex@0.122.0-alpha.8+`). Older Codex silently ignores the entry because `HooksFile` does not use `#[serde(deny_unknown_fields)]`; `SessionStart` and `Stop` still work. - `PermissionRequest` hook scripts MUST `exit 0` with empty stdout on every failure path. Codex interprets `exit 2 + stderr` as a `Deny` decision, which would silently block tool approvals. - Codex 0.124+ renamed the feature flag from `[features] codex_hooks` to `[features] hooks` (stable, default-on by 0.130). ccmux's TOML helper recognizes either name; new installs write `codex_hooks = true` for backwards compat with older Codex, which is a harmless orphan key on 0.124+. Uninstall deliberately leaves whichever flag is present in place (see below). - `SessionStart` fires on first user message in Codex 0.124+, not on agent launch. Sessions appear pane-tracked (no `nativeSessionId`) until the user submits their first turn; this is expected. Codex 0.122/0.123 fire `SessionStart` on launch. - `ccmux setup --uninstall --agent codex` deliberately leaves the codex hooks feature flag in `~/.codex/config.toml` untouched (either `codex_hooks` or `hooks`, whichever is present). Orphan flag is cosmetic (empty `hooks.json` = zero handlers fire); flipping it off would be a footgun for users who enabled it independently. - **Notification actions (Approve/Deny keystrokes).** Verified e2e on **codex-cli 0.144.5**. The permission picker renders `1. Yes, proceed (y) / 2. Yes, and don't ask again (p) / 3. No, and tell Codex what to do differently (esc)` under a "Press enter to confirm or esc to cancel" footer, with option 1 initially highlighted. So `approve: ["Enter"]` confirms "Yes, proceed" (the tool runs), and `deny: ["Escape"]` selects option 3 — it cancels the request (`✗ You canceled the request to run `, the tool does NOT run) and returns Codex to its idle composer. Escape interrupts the turn but does NOT kill the session, which is the desired Deny; because Escape interrupts (rather than cancelling-to-composer with the tool still pending), no `permissionReplyPrelude`/Reply is offered, and Codex has no question wait. The authoritative wait signal is the `PermissionRequest` hook marker (Codex >= 0.122); the legacy `[y/n]` prompt in `terminalRules` is a pre-0.122 shape this Enter/Escape map does not claim to cover. - **Finished-notification Reply** (`replyOnFinished`). Verified e2e on **codex-cli 0.144.5** (issue #35). A leading space defuses `/`, but `!` is NOT space-defusable: Codex keys shell mode on the first non-whitespace character, and Enter RUNS the text as a shell command with no approval and no LLM turn. A mid-text `!` is inert. Hence the `!`-leading `unsafeReplyPattern` (`/^\s*!/`). - Codex's unanchored `processMatch` (`/\bcodex\b/i`) also matches the bundled computer-use MCP server (argv[0] basename `Codex`), which runs with its cwd inside `/plugins/`. `discoverAgentProcesses` drops any discovered process whose resolved cwd is under `/plugins/` (`isCodexPluginHostCwd` in `processes.ts`), so the plugin host never becomes a session. Without it the host would group by its version dir (e.g. "1.0.793"), collapsing unrelated panes. The filter targets the process (by cwd), not a session, so a real `codex` sharing the pane still populates the session with its own repo cwd. ## Cursor-specific caveats - Hooks require `cursor-agent` >= 2026.1.16 (the hooks feature landed in that release). Older versions silently ignore `hooks.json` entries; `install()` warns but doesn't block, and `describeInstallAnomalies()` surfaces the same warning at daemon startup. Version gate lives in `adapters/cursor/version.ts`. - `processMatch: /^(cursor-agent|agent)$/i` matches both the stock binary and the bare `agent` shim Cursor ships. Anchored on argv[0] basename via `findAgentForProcess`, so it won't collide with arbitrary shell commands that include the word "agent". - `--resume ` accepts the payload's `conversation_id` (which equals `session_id` in every captured payload). Cursor scopes chats per workspace — `cursor-agent --resume ` only restores the transcript when invoked from the original `workspace_roots` directory. ccmux resumes inside the pane's own shell, which preserves cwd, so this is fine in practice. - Cursor invokes hook commands through a `/bin/zsh -c` wrapper, so `$PPID` inside the script is a transient shell, not cursor-agent. The scripts walk the process ancestry via `ps -o comm=` to find the real cursor-agent PID; if the walk fails they fall back to `$PPID` so the marker self-cleans on the next scan rather than silently no-opping. - `--resume` does NOT fire `sessionStart`; only `beforeSubmitPrompt` fires on the first submission in a resumed chat. The `ccmux-before-submit-prompt.sh` and `ccmux-stop.sh` scripts therefore create the marker if missing (same identity fields `sessionStart` would have written), otherwise resumed chats would be invisible to ccmux. - Hook scripts MUST `exit 0` with empty stdout on every failure path. Cursor treats `exit 2 + stderr` as a "deny the action" signal. The four subscribed events don't gate execution today, but keeping the contract uniform prevents surprises if we later add `preToolUse`. - `ccmux setup --uninstall --agent cursor` removes only entries whose `command` matches the exact install-written script paths, and preserves the top-level `version` field so a user's hand-authored `hooks.json` structure stays intact. - **Notification actions (Approve/Deny keystrokes).** Verified e2e on **cursor-agent 2026.07.01-41b2de7**. The "Run this command?" overlay is a navigable list: `→ Run (once) (y) / Add Shell() to allowlist? (tab) / Run Everything (shift+tab) / Skip (esc or n)`. `approve: ["y"]` is the absolute "Run (once)" selector (curl ran, HTTP/2 200) — deliberately NOT Enter, since Enter selects the movable highlight. Deny is `["C-c"]`, and the reason is a real footgun: both `esc` and `n` open a "Reason for rejection (Enter to submit, Esc to cancel)" TEXT sub-dialog (a 2026.04.17+ change), so there is no single-key skip, and the obvious `["Escape", "Enter"]` is UNSAFE — the keys path fires keys only `KEY_SEQUENCE_GAP_MS` apart with no settle/recheck, and an Escape immediately followed by Enter can coalesce (Alt+Enter) or land on the still-live overlay, selecting the highlighted "Run (once)". A deny press was reproduced SILENTLY APPROVING and running curl. `C-c` interrupts the turn (command does NOT run, verified 3/3 with cleared scrollback) and structurally cannot select "Run (once)", so it can never mis-approve; Cursor's Auto-review may re-request approval afterward (a fresh permission the user can deny again), which is Cursor behavior, not the keystroke. No waiting-state Reply is offered (the reject-reason sub-dialog can't be driven safely from the prelude path; Cursor has no question wait). The workspace-trust prompt is out of scope — it has no `terminalRules` entry, so it never becomes a `permission` wait. - **Finished-notification Reply** (`replyOnFinished`). Verified e2e on **cursor-agent 2026.07.16-899851b** (issue #35). `!` is not a Cursor composer trigger; the hazard is Cursor's slash autocomplete, which is POSITIONAL: any leading or whitespace-preceded `/token` (a defusing space included) opens a fuzzy popup, and when its query matches a real command the popup SWALLOWS the submitting Enter and executes the highlighted command instead (`try running /help` executed `/help model` and opened the model picker). Path slashes (`src/main.ts`) and matchless queries submit fine. An Escape-then-Enter popup dismissal was rejected for the same Escape/Enter coalescing footgun as Deny, so the positional `unsafeReplyPattern` (`/(^|\s)\/\S/`) refuses any leading or whitespace-preceded slash token. That over-blocks prose like "run /help", which is the accepted trade. ## OpenCode-specific caveats - The OpenCode adapter installs a single JS plugin at `~/.config/opencode/plugin/ccmux.js` (OpenCode auto-discovery). The plugin authors are careful to use only `node:fs/promises` so the same file runs under both Bun and Node, whichever OpenCode was launched with. The first line is a sentinel (`// ccmux-plugin v`); `install()` refuses to overwrite any same-named file missing the sentinel, and `uninstall()` refuses to delete anything lacking it. - One OpenCode server can host many sessions. The plugin writes one marker per server-side session; the adapter folds all markers sharing a server PID into the single ccmux Session for the hosting tmux pane. Status is worst-of (waiting > working > idle); `attentionType`, `pendingTool`, `cwd`, and `nativeSessionId` follow the newest-activity or newest-waiting marker. - Pane correlation uses PID ancestry (`HookManagerContext.getPaneHostingPid`) because OpenCode markers carry no TTY (no per-session TTY exists). OpenCode launched outside a tmux pane is out of scope. - When all sessions on a live server are deleted, the adapter resets the ccmux Session's status to idle but the stale `nativeSessionId` remains (the `SessionManager` setter accepts only strings, not null). Inert: status shows idle, click-through still lands in the pane. PID death clears it via `cleanupStaleSessions`. - `ccmux setup --uninstall --agent opencode` only unlinks the plugin file; the daemon's next `cleanupStaleMarkers` sweep removes any leftover markers when their server PIDs die. - The JS SDK does not expose `permission.list`. If ccmux is installed while OpenCode is already waiting on a tool approval, the pending permission is invisible until the user responds or a new `permission.asked` fires. - **Notification actions (Approve/Deny keystrokes).** Verified e2e on **OpenCode 1.18.3**. The permission dialog is a horizontal option row (`Allow once Allow always Reject`) navigated with Left/Right arrows; Enter confirms the highlighted option. The dialog ALWAYS opens with "Allow once" (approve) initially highlighted, so `approve: ["Enter"]` is safe — Reject is never the initial highlight. There is no absolute selector: digits, single letters, Home/End, and Tab are all inert (only arrows move the highlight), so `deny: ["Right", "Right", "Enter"]` navigates from the initial highlight to Reject and confirms. Escape is NOT a clean reject — it interrupts the whole turn and leaves the session hung in `working` — so it is not used for Deny and no `permissionReplyPrelude`/Reply is offered (a reply would have no safe cancel-to-composer key). No question detection exists, so no question Reply either. - **Finished-notification Reply** (`replyOnFinished`). Verified e2e on **OpenCode 1.18.3** (issue #35). A leading space defuses `/`, but OpenCode trims the leading space in front of `!` and enters SHELL MODE, where Enter EXECUTES the text as a real shell command. Hence the `!`-leading `unsafeReplyPattern` (`/^\s*!/`). Known edge (documented, not gated): a composer in a transitional state (e.g. mid `/models` switch) can pop a "Select variant" autocomplete that intercepts keystrokes; a finished reply lands at a steady idle composer, and the staleness token rejects the press once the session leaves idle. - **Aggregation ambiguity (button suppression).** One OpenCode server folds N server-side sessions into one ccmux row. A notification keystroke lands on whichever dialog the shared pane currently renders, which can belong to a different server-side session than the one the notification described, and the staleness tokens cannot catch it (same ccmux session, same edge). So `aggregateOpenCodeMarkers` sets `Session.ambiguousWait` when MORE THAN ONE marker is `waiting_permission` at once, and both the notifier (offer side) and `handleNotificationAction` (press side) suppress Approve/Deny while it is true — the notification ships informational-only. Buttons attach only when exactly one server-side session is waiting. ## Pi-specific caveats - Pi sets `process.title = "pi"` at the top of its CLI entrypoint, so `ps` reports the process as `pi` (not `node .../cli.js`). `processMatch` is anchored `/^pi$/i` (not `\bpi\b`) because "pi" is a short, collision-prone token; a `pi-coding-agent/dist/cli.js` `commandPatterns` entry covers the sub-millisecond window before the title is set and any platform where title rewriting doesn't reach `ps`. - Pi has **no native tool-approval pause** (it runs tools immediately), so there is no authoritative `waiting`/`permission` state. The marker only ever carries `idle`/`working`, and the no-hooks `terminalRules` only detect `working` (keyed on the literal `Working...` / `Thinking...`; Pi's idle footer contains the word "interrupt", so unlike codex/gemini we must NOT key `working` on "interrupt"). A `waiting` indicator is only possible if the user installs a `tool_call`-gating extension, which is out of scope. - Pi runs ONE session per process. A session switch (`/new`, `/resume`) emits `session_shutdown` for the old session (removing its marker), reloads extensions, then emits `session_start` for the new one, so markers never overlap and need no OpenCode-style aggregation. - Pi auto-discovers both `*.ts` and `*.js` extensions (loaded via jiti), so ccmux installs a `.js` file. That keeps the authored template out of ccmux's own TypeScript build (mirrors the OpenCode plugin) while still being picked up by Pi. - `ccmux invoke pi` shells `pi -p ""` (print mode → final assistant text on stdout). `--session ` resume in print mode is unverified, so `resumeArgs` is not exposed (sessionId resume is rejected at the daemon, like gemini). - **Notification actions: no Approve/Deny, on purpose (issue #26 decision, re-verified live on pi 0.79.9).** Pi has no tool-approval pause — a driven `curl` executed in 0.1s with no prompt — so a `permission` wait can never exist and there is nothing for Approve/Deny to drive. Nuance: pi ships an `ask_question` tool (excludable via `--exclude-tools ask_question`) that can pause a session on a model-initiated question, but ccmux has no pi waiting detection (the extension marker only writes working/idle; terminal rules only detect working), so no waiting notification ever fires and no waiting-state Reply can attach. Revisit the Approve/Deny half only if a pi release adds an approval gate (a user-installed `tool_call`-gating extension is out of scope) or if pi question detection lands. - **Finished-notification Reply** (`replyOnFinished`). Verified e2e on **pi 0.79.9** (issue #35, the deferred "decide during implementation" case). The extension marker tracks idle correctly, and a leading space defuses `/`. But pi strips leading whitespace before its `!` bash-trigger detection and EXECUTES the text as a shell command with no LLM turn. Hence the `!`-leading `unsafeReplyPattern` (`/^\s*!/`). ## omp-specific caveats oh-my-pi (`omp`) is a hard fork of Pi that kept Pi's extension API, so its adapter, extension, and AgentDef mirror Pi's. Everything below is what DIFFERS. Verified against **omp 17.1.3** (source at `$(npm root -g)/@oh-my-pi/pi-coding-agent`) unless noted. - **Detection cannot rely on `process.title`, because omp runs under Bun.** omp does set `process.title = APP_NAME` (`"omp"`) at the top of its CLI entrypoint, but its npm bin shim's shebang is `#!/usr/bin/env bun`, and **under Bun the assignment is a no-op as far as `ps` is concerned**. Verified with a two-line script that sets `process.title = "omp"` and sleeps: under `bun`, `ps` reports comm `bun` and args `bun /tmp/title-test.js`; under `node`, it reports `omp` for both. Pi is unaffected — its `cli.js` shebang is `#!/usr/bin/env node`, so Pi's title rewrite genuinely reaches `ps`. Two launch shapes therefore have to be covered: 1. **Standalone binary / node-launched** — argv[0] basename really is `omp` (`omp`, `/usr/local/bin/omp`). `processMatch: /^omp$/i` catches it. Anchored rather than `\bomp\b` for the same reason as Pi's `/^pi$/i`: the token is short, and a loose match would claim unrelated commands that merely contain the word. `findAgentForProcess` matches `processMatch` against the argv[0] basename, so a full path needs no `commandPatterns` entry of its own. 2. **npm/mise bun shim (the common install)** — the live process is comm `bun`, args `bun /Users/x/.local/share/mise/installs/node/26.3.0/bin/omp --model ...`. Neither comm nor argv[0] is ever `omp`, and argv carries the **symlink** path (`.../bin/omp`), never the resolved `@oh-my-pi/pi-coding-agent/dist/cli.js` target. The `commandPatterns` entry `/[/\\]bin[/\\]omp(?:\s|$)/i` is the only stable signal for this shape. The trailing `(?:\s|$)` excludes `/bin/ompx` and `/bin/omp-helper`; the required `/bin/` component keeps a bare `omp` word in a prompt or flag from matching. It is one process, not a wrapper plus a child (the shebang exec replaces bun's argv), so unlike Copilot's node wrapper it cannot double-detect the pane. Missing this is not a cosmetic bug: with the pid absent from the detected agent set, `cleanupStaleMarkers` deletes the extension's valid marker within ~10s, so the marker survives only while the daemon is stopped. 3. **Direct `bun /path/to/@oh-my-pi/pi-coding-agent/dist/cli.js` (no shim)** — covered by the second `commandPatterns` entry, `/oh-my-pi[/\\]pi-coding-agent[/\\]dist[/\\]cli\.js/i`. That entry doubles as the cover for the sub-millisecond window before `process.title` is set on the node/standalone path, and it is scoped to the `oh-my-pi` dir so it can never claim upstream pi (see the ordering caveat below). - **`omp` must be ordered BEFORE `pi` in `BUILTIN_AGENTS`.** `findAgentForProcess` is first-match-wins, and Pi's `commandPatterns` regex (`/pi-coding-agent[/\\]dist[/\\]cli\.js/i`) also matches omp's resolved launcher path, because the fork kept the npm package name (`@oh-my-pi/pi-coding-agent`). If Pi were evaluated first, an omp process launched by that path would be labeled Pi. omp's own `dist/cli.js` pattern additionally requires the `oh-my-pi` scope dir so it can never claim upstream pi, and the `bin/omp` pattern cannot collide either (Pi's launcher token is `bin/pi`). All of this is locked in by tests in `src/lib/agents.test.ts`. - **omp DOES have a tool-approval pause** (the one behavioral divergence from Pi that matters here), so it gets a real `waiting`/`permission` state and Approve/Deny notification actions. The extension subscribes to `tool_approval_requested` / `tool_approval_resolved` (`ToolApprovalRequestedEvent` / `ToolApprovalResolvedEvent` in omp's `src/extensibility/extensions/types.ts` on 17.1.3), whose payload is `{type, sessionId, toolCallId, toolName}` with `approvalMode`, `reason`, and `approved` riding along. The extension reads only `toolCallId` and `toolName`, and takes the session id from the context rather than the payload. - **The approval events are `hasHandlers`-gated, and subscribing does not create pauses.** omp emits the pair only when `approvalCheck.required` AND some extension has a handler for one of them (`src/extensibility/extensions/wrapper.ts`). Installing the ccmux extension therefore does NOT start gating tool calls on the default `yolo` approval mode; it only makes the pauses observable for users who configured an approval mode. Corollary: on a `yolo`-mode machine the omp permission path is dormant and there is nothing to e2e. - **Overlapping approvals resolve OLDEST-first.** One assistant turn can gate several tool calls, and shared-concurrency tools (two non-pty `bash` calls, say) each request approval before either resolves. The extension tracks them in a per-session insertion-ordered `Map` of `toolCallId -> toolName` and keeps the marker at `waiting_permission` until the LAST one resolves. `pending_tool` publishes the OLDEST outstanding entry, because omp's dialog surface is FIFO: the prompt actually on screen is the first one requested, so newest-wins would make ccmux's Approve/Deny notification name a tool the user cannot see. Each resolve re-publishes the marker with the new head's name. Insertion order is trustworthy because omp awaits each handler, so requests arrive in on-screen order. `tool_approval_resolved` fires for BOTH approve and deny (and for omp's fail-closed no-UI path), and the agent loop resumes either way, so the last resolve writes `working` and `agent_end` still delivers the final `idle`. `agent_end` also clears the pending map defensively, so an aborted turn cannot leave an id that pins the next turn at waiting. - **`PI_CONFIG_DIR` is joined VERBATIM, and ccmux is bug-compatible with that.** omp resolves its config root as `path.join(homedir(), process.env.PI_CONFIG_DIR || ".omp")` (`@oh-my-pi/pi-utils` `dirs.ts`), so an ABSOLUTE override lands under `$HOME` anyway: live-verified, `PI_CONFIG_DIR=/tmp/absolute-test omp config path` prints `~/tmp/absolute-test/agent`. Node's `join(home, "/tmp/x")` reproduces that exactly, so `OMP_AGENT_DIR` in `src/lib/config.ts` uses the same plain join and the extension installs where omp actually looks. Do not "fix" it with `resolve()`: that would write the extension somewhere omp never reads. The behavior is locked in upstream by omp's own `test/discovery/pi-config-dir.test.ts`, so it is a stable contract rather than a bug that might be patched out from under us. Note the env var really is named `PI_CONFIG_DIR` (fork inheritance); upstream pi does NOT honor it, so the `PI_*` constants stay unconditional `~/.pi`. Not modeled (TODO on the constant): `PI_CODING_AGENT_DIR` (overrides the agent dir outright), the `OMP_PROFILE`/`PI_PROFILE` profile dirs (redirecting to `/profiles//agent`), and Linux/macOS XDG redirection when `$XDG_DATA_HOME/omp` already exists. Shelling out to `omp config path` at setup time would resolve all four at once; deferred because setup must stay fast and offline-safe. - **`Working…` (real U+2026, not Pi's ASCII `Working...`) is only the DEFAULT loader label.** Once the model streams an intent, `#updateWorkingMessageFromIntent` (`src/modes/controllers/event-controller.ts`) swaps the label for that intent plus the interrupt hint and the literal is gone for the rest of the turn, so the `working…` terminal rule really only covers the pre-intent window. Nothing broader is matched because the replacement text is model-supplied and the hint's brackets are theme glyphs. There is no `Thinking…` label. The only ASCII `Working...` left is print mode's stderr line, which never reaches a tracked pane. As with Pi, do NOT key `working` on "interrupt". - **The terminal rules are weak on purpose; lean on the marker.** The waiting rule (`allow tool:`) is listed FIRST, but not because the two states co-occur: e2e showed that during an approval wait the pane carries a spinner with a **per-intent label** (`⠧ Run touch command ⟦esc⟧`), not the literal `Working…`. Waiting-first is kept because `matchTerminalRule` is first-match-wins and the label is model-supplied and unpredictable, so the ordering is free insurance. The waiting rule carries `pendingTool: null` because `TerminalRule` matches fixed substrings and cannot capture the name out of `Allow tool: `; the marker path supplies the real name. - **Notification actions (Approve/Deny keystrokes).** Read from source and driven e2e on omp 17.1.3 (Deny via Escape: the gated `touch` verifiably did not run; Approve via Enter: it did). The prompt body is `Allow tool: ` (`formatApprovalPrompt` in `src/tools/approval.ts`) above `uiContext.select(prompt, ["Approve", "Deny"])` with no `initialIndex`, so `HookSelectorComponent` opens on index 0 = Approve and `approve: ["Enter"]` confirms exactly that. There are no digit shortcuts to mis-fire on: printable keys feed the selector's fuzzy search box and only Enter commits. `deny: ["Escape"]` is fail-closed — Escape hits `matchesSelectCancel`, the select resolves `undefined`, `approved = choice === "Approve"` is false, so omp emits `tool_approval_resolved {approved: false}` and throws `Tool call denied by user: `; the gated tool does not run and the turn returns to the composer. No `permissionReplyPrelude`: Escape here IS the deny, not a cancel-to-composer that leaves the tool pending. - **Finished-notification Reply** (`replyOnFinished`). Verified e2e on **omp 17.1.3** with the daemon's exact delivery sequence (`send-keys -l`, 150ms, Enter). omp's composer TRIMS the submitted text before BOTH trigger checks (`editor.onSubmit` trims first thing), so the space defuse neutralizes NEITHER prefix: a defused ` /new` still dispatched and DESTROYED the session ("✔ New session started", history gone), and a spaceless ` /` left the slash autocomplete's fuzzy selector open so the submitting Enter SELECTED an arbitrary fuzzy match (` /Users/epilande/nonexistent.ts` invoked an unrelated skill and started an unprompted turn). Slash text containing a space falls through omp's dispatcher (`name` is split at whitespace/colon, an unknown name sends as a plain message), but which `/`-shape is destructive depends on the user's installed commands and skills, so the guard refuses all of it. Hence the `[/!]`-leading `unsafeReplyPattern` (`/^\s*[/!]/`), same as Antigravity/Gemini/Copilot. (`!` half inherited from Pi: trimmed, then executes as a shell command.) - omp runs ONE session per process and auto-discovers `*.ts`/`*.js` extensions from `/extensions/`, both exactly as Pi does, so marker keying and the `.js` install form are unchanged. - **A session switch emits `session_switch`, NOT Pi's `session_shutdown` + `session_start` pair.** `/new` and `/resume` mutate the session in place (`src/session/agent-session.ts`) and emit `session_before_switch` then `session_switch` (`SessionSwitchEvent` in `src/extensibility/shared-events.ts`, payload `{type, reason: "new" | "resume" | "fork" | "handoff", previousSessionFile}`); the extension runner is never reconstructed, so nothing re-fires `session_start`. Without a handler the old session's marker would leak and the new session would have none. The extension therefore handles `session_switch`: it removes the marker and in-memory state of every tracked id other than the current one, then writes a fresh idle marker for the new id. Both emit sites run after the new session id is installed, so `sessionManager.getSessionId()` already returns it inside the handler. The daemon needs no change, since the new marker's `add` event drives the existing `onMarkerAdded` re-link. - **`session_branch` is the same id-changing event class and is bound to the same handler.** `/branch` (which opens the tree selector) and the `app.session.fork` keybinding both reach `createBranchedSession()`, which mints a fresh id via `mintSessionId()` and emits `session_branch` — never `session_switch`. The emit follows `rekeyForCurrentSessionId()` + `resetContextForNewTranscript()`, so `getSessionId()` already returns the new id inside the handler, exactly as for a switch; omp's own UI binds both events to one handler for the same reason. Missing it leaks the old marker for the life of the process (`cleanupStaleMarkers` sees the same live pid and tty, and `isSessionStillLive` is unconditionally `true`) and the per-scan link pass **cannot** heal the row, because `decideMarkerLinks` skips any session already holding an id that matches a marker on its pane. Verified e2e on omp 17.1.3 via `doubleEscapeAction: branch` → "Branch from Message": the pre-branch marker is unlinked and a fresh idle marker appears under the new id on the same pid, carrying the new transcript path and no carried-over `last_prompt`. Two deliberate boundaries: `session_tree` is **not** handled (it carries `newLeafId` and moves a leaf within the current session, leaving the id untouched — confirmed live, a tree switch produced no marker churn), and `session_fork` **is** also registered, because upstream pi-mono 0.43.0 renamed `session_branch` → `session_fork` (that changelog ships inside omp's own bundle). omp 17.1.3 has not taken the rename — it still emits `session_branch` at every site, with `session_fork` appearing only in the vendored changelog text — but the fork tracks upstream, and subscribing to a never-emitted event costs nothing. - `omp --version` prints `omp/17.1.3`; the default version patterns extract `17.1.3`, so no `versionPatterns` override. Version inference from a resolved path needs an explicit alias in `daemon/version-resolver.ts` because the package name `@oh-my-pi/pi-coding-agent` does not contain "omp". - `ccmux invoke omp` shells `omp -p ""` (print mode → final assistant text on stdout). Resume in print mode is unverified, so `resumeArgs` is not exposed (sessionId resume is rejected at the daemon, like Pi and gemini). ## Antigravity-specific caveats - Antigravity CLI v1.1.1 exposes exactly five configurable hook events: `PreToolUse`, `PostToolUse`, `PreInvocation`, `PostInvocation`, and `Stop`. It does not expose `SessionStart`, `UserPromptSubmit`, or `PermissionRequest`. The first `PreInvocation` therefore creates the marker if it does not already exist; an untouched idle session remains pane-tracked until its first prompt. - `PreToolUse` is a deny footgun. Its `decision` output key is required, and `{}` silently denies the tool call. ccmux installs only `PreInvocation` and `Stop`, where `{}` is inert, and never registers `PreToolUse` or `PostToolUse` handlers. - Hooks run synchronously and block the agent loop, with a 30-second default timeout per handler. The ccmux scripts only read stdin, write one marker with tmp+rename, print `{}`, and exit 0. - Antigravity v1.1.1 passes the full parent environment to hook commands, including `GEMINI_API_KEY`. Hook scripts must never print or log the environment. The ccmux scripts extract only the camelCase payload fields they need. - The reliable global hook file is `~/.gemini/config/hooks.json`. Its top-level keys are named hooks; ccmux owns exactly the `ccmux` key and preserves every other key. Hook names dedupe across files with last-one-wins behavior, so another global or workspace hook named `ccmux` can shadow this entry. - Workspace `.agents/hooks.json` discovery is unreliable in v1.1.1. Despite the embedded documentation claiming a repository-root walk, the file loads only when `--new-project` is passed on that specific invocation. A previously registered project is not enough, so ccmux installs into the global config file instead. - Conversations are stored in SQLite, so Antigravity has no log adapter in v1. The per-conversation JSONL at `~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript_full.jsonl` is a future log-adapter candidate. - The `agy` name can also be a symlink to the desktop Antigravity.app launcher, whose resolved binary basename is `antigravity`. Process detection keys on the exact `agy` basename and the `/agy` command path form, so the desktop launcher is not treated as a CLI agent. - `~/.gemini/google_accounts.json` remains `{"active": null}` even while the CLI is authenticated, so it is not an authentication signal. - Each `agy` invocation writes a new `~/.gemini/antigravity-cli/log/cli-YYYYMMDD_HHMMSS.log`. Hook execution appears there as `jsonhook_____` activity. Headless `agy -p` invocations fire the same hooks, so they briefly create markers that the PID-liveness sweep removes after the process exits. - The installed scripts write only `working` and `idle`. The adapter also maps `waiting_permission` for forward compatibility, but current permission attention comes from terminal rules matching `Requesting permission for:` or `Do you want to proceed?`. Those specific strings avoid misclassifying Antigravity's unrelated CSAT survey (`How's the CLI experience so far?`) as a permission prompt. - **Notification actions (Approve/Deny keystrokes).** Verified e2e on **agy 1.1.1**. The permission prompt is a numbered list under `Requesting permission for: / Do you want to proceed?` where digits are absolute select-and-submit: `approve: ["1"]` selected "Yes" and ran the gated curl immediately, no trailing Enter. Deny is deliberately NOT `["4"]` even though pressing `4` verified as "User declined the tool call": the option list is DYNAMIC — the same prompt rendered 4 options on one wait (`1. Yes / 2.-3. always-allow variants / 4. No`) and 6 on the next (adding `5.-6. always-deny variants`) — so a deny digit cannot be trusted to land on "No". `deny: ["Escape"]` uses the constant `esc to cancel` affordance: the tool does NOT run and the turn interrupts to the composer (`Interrupted · What should Antigravity CLI do instead?`), which structurally cannot approve. No waiting-state Reply (no question wait; Escape interrupts the turn rather than cancelling to a composer with the tool pending). The wait signal is terminal-rules-only (the hook scripts never write `waiting_permission`), so presses are gated by the pane-tracked staleness tokens. The workspace-trust prompt (`Do you trust the contents of this project?`) has no `terminalRules` entry, so it never becomes a `permission` wait and is out of scope. - **Finished-notification Reply** (`replyOnFinished`). Verified e2e on **agy 1.1.4** (issue #35). Antigravity TRIMS leading whitespace on submit and re-parses the prefixes, so the space defuse neutralizes NEITHER: ` /help ...` executed /help and DISCARDED the trailing text, and ` !...` entered shell mode. Hence the `[/!]`-leading `unsafeReplyPattern` (`/^\s*[/!]/`). ## Gemini-specific caveats - Gemini CLI has no hook adapter (and no ccmux-owned files): detection is process matching plus `terminalRules` only, so sessions are always pane-tracked with no `nativeSessionId`. - **Notification actions (Approve/Deny keystrokes).** Verified e2e on **gemini-cli 0.29.5**. The shell-approval picker (`Allow execution of: ''?`) renders `1. Allow once / 2. Allow for this session / 3. No, suggest changes (esc)`, and digits are absolute select-and-submit: `approve: ["1"]` selected "Allow once" and ran the gated curl immediately, no trailing Enter. `deny: ["Escape"]` maps to option 3: "Request cancelled", the tool does NOT run, and the turn ends back at the composer. Both actions are single keys, so the no-settle keys path has no coalescing surface. No waiting-state Reply (no question wait; no verified cancel-to-composer prelude from the picker). The first-launch folder-trust picker (`Do you trust this folder?`) has no `terminalRules` entry and is out of scope. - **Finished-notification Reply** (`replyOnFinished`). Verified e2e on **gemini-cli 0.29.5** (issue #35). Gemini TRIMS leading whitespace before its trigger detection, so the space defuse neutralizes NEITHER prefix: ` /help ...` executed the /help panel on Enter, and ` !...` flipped shell mode before Enter. Hence the `[/!]`-leading `unsafeReplyPattern` (`/^\s*[/!]/`). ## Copilot-specific caveats - **Waiting/permission is observed through the `notification` hook, never the deciding `permissionRequest` hook.** Copilot exposes `permissionRequest` as a DECIDING hook: its output can allow or deny the pending tool call, and a crashing/empty response would silently affect the user's approval. ccmux registers only observational events (`sessionStart`, `userPromptSubmitted`, `notification`, `agentStop`, `sessionEnd`) and reads permission attention off `notification` payloads with `notification_type: "permission_prompt"` or `"elicitation_dialog"` (other types — `agent_idle`, `agent_completed`, `shell_completed`, ... — are ignored). The single marker script exits 0 with empty stdout on every path, so it can never emit a decision. - **`$PPID` is the Copilot PID.** Copilot runs each hook command with the `copilot` process as its DIRECT parent, so the script derives `pid=$PPID` and `tty=$(ps -p $PPID -o tty=)` for pane correlation (verified on v1.0.71). No zsh-wrapper walk is needed (unlike Cursor). - **`sessionStart` is deferred and can arrive AFTER `userPromptSubmitted`.** In interactive mode Copilot fires `sessionStart` at the first prompt submission, not at UI launch (the pre-prompt phase, including the folder-trust dialog, is covered by terminal rules only), and in `-p` mode `userPromptSubmitted` was observed firing before `sessionStart` (v1.0.71). The marker script therefore treats `session-start` on an existing marker as an identity refresh that never overwrites state, so a racing prompt/notification state can't be downgraded to `idle`. - **events.jsonl flushes in real time, including mid-wait.** `~/.copilot/session-state//events.jsonl` is written incrementally and Copilot does NOT hold it open, so the log adapter tails it like a Codex rollout. Critically, `permission.requested` is flushed to disk WHILE the permission dialog is up (verified live), so the log source can catch a permission wait even without hooks — unlike Claude's deferred permission `tool_use`. Status comes from `user.message` / `assistant.turn_start` → working, `permission.requested` → waiting (permission; `kind: "shell"` → pending tool "Command"), `permission.completed` → working, and `assistant.turn_end` / `session.shutdown` / `abort` → idle. - **Native-id discovery via the open `session.db`, not events.jsonl.** Copilot keeps `session-state//session.db` open (lsof-discoverable) but append-and-closes `events.jsonl`, so the no-hooks path recovers the session UUID from the `.db` fd (`COPILOT_SESSION_FILE_PATTERN`; the daemon's lsof prefilter accepts `.db` alongside `.jsonl`). The log adapter uses its own `session-state//events.jsonl` pattern for the tail. - **`~/.copilot/hooks/` is a shared, auto-discovered drop-in dir.** Copilot loads every `*.json` there. ccmux owns exactly two namespaced files — `ccmux-copilot.json` (the hooks registration) and `ccmux-copilot.sh` (the marker script, ignored by Copilot's `*.json` scan) — and never touches other files or `~/.copilot/settings.json`. Uninstall removes only those two. - **Notification actions (Approve/Deny keystrokes).** Verified e2e on **Copilot CLI 1.0.71**. Copilot has three permission pickers — shell (`Do you want to run this command?`, 3 options), URL access (`Do you want to allow this access?`, 4 options), and folder trust (`Do you trust the files in this folder?`, 3 options) — and digits are absolute select-and-submit: `approve: ["1"]` selected the position-stable "1. Yes" and ran the gated curl with no trailing Enter. The deny row MOVES across pickers (3 on shell/trust, 4 on URL access), so deny is `["Escape"]`, the constant "esc to cancel" affordance: verified the tool did not run ("✗ Shell ... The user rejected this tool call.") and the turn ended at the composer. A single tool call can chain two dialogs (URL access first, then shell); each raises its own wait, and one press answers exactly one dialog. No waiting-state Reply: option 3/4's "tell Copilot what to do differently" flow is Esc-adjacent and unverified as a prelude, and Copilot has no question wait. The URL-access dialog also has its own `terminalRules` entry (`pendingTool: "Url"`, matching `permissionToolLabel("url")` on the log path) so a URL-only wait is visible to the pane-tracked path too. - **Finished-notification Reply** (`replyOnFinished`). Verified e2e on **Copilot CLI 1.0.71** (issue #35). Copilot TRIMS a leading space on submit and re-parses the prefixes: ` /help ...` opened the help overlay, and ` !echo ...` EXECUTED as a shell command with no permission prompt (Auto mode). Hence the `[/!]`-leading `unsafeReplyPattern` (`/^\s*[/!]/`). - **Only the real binary is matched — never its node wrapper, never `gh copilot`.** Process detection anchors solely on the argv[0] basename `copilot` (the native SEA binary), with NO `commandPatterns`. The npm/mise/npx wrapper (`node .../bin/copilot`) spawns the real binary as a child on the same pane tty, so matching the wrapper too double-detects the pane and flip-flops the pane session's `pid` every scan, tripping the pane-reuse identity reset (which clears `nativeSessionId`/`logPath`/`lastPrompt` as fast as enrichment writes them — found live, v1.0.71 via mise). The legacy `gh copilot ...` extension (argv[0] `gh`) and the `gh-copilot` shim also match nothing. ## File paths ### Agent-owned (read-only except during `ccmux setup`) - Claude Code logs: `~/.claude/projects//.jsonl` (plus any extra config dirs from the `additionalClaudeConfigDirs` preference / `CLAUDE_CONFIG_DIR`, each watched at `/projects`) - Claude Code history: `~/.claude/history.jsonl` - Claude Code settings: `~/.claude/settings.json` (written by `ccmux setup --agent claude`) - Claude Code hooks: `~/.claude/hooks/ccmux-session-start.sh`, `ccmux-session-end.sh`, `ccmux-state-notify.sh` - Codex sessions: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` (honors `$CODEX_HOME`) - Codex hooks.json: `~/.codex/hooks.json` (written by `ccmux setup --agent codex`) - Codex config.toml: `~/.codex/config.toml` (codex hooks feature flag written by install as `[features] codex_hooks = true` for backwards compat with older Codex; ccmux recognizes the 0.124+ `[features] hooks = true` rename on read; uninstall never touches whichever name is present) - Codex hooks: `~/.codex/hooks/ccmux-session-start.sh`, `ccmux-stop.sh`, `ccmux-permission-request.sh` - OpenCode logs: `~/.local/share/opencode/log/` (daily-rotated, plain-text) - OpenCode plugin: `~/.config/opencode/plugin/ccmux.js` (written by `ccmux setup --agent opencode`; honors `$XDG_CONFIG_HOME`) - Cursor hooks.json: `~/.cursor/hooks.json` (written by `ccmux setup --agent cursor`; user-authored `version` field and unrelated entries preserved on uninstall) - Cursor hooks: `~/.cursor/hooks/ccmux-session-start.sh`, `ccmux-session-end.sh`, `ccmux-before-submit-prompt.sh`, `ccmux-stop.sh` - Cursor transcripts: `~/.cursor/projects//agent-transcripts//.jsonl` (ccmux does not parse these in v1) - Pi sessions: `~/.pi/agent/sessions/----/_.jsonl` (ccmux does not parse these in v1; Pi appends-and-closes per entry, so the lsof discovery path never fires, same constraint as Claude) - Pi extension: `~/.pi/agent/extensions/ccmux.js` (written by `ccmux setup --agent pi`; Pi resolves `~/.pi/agent` with no XDG/env override) - omp sessions: `~/.omp/agent/sessions//_.jsonl` (ccmux does not parse these in v1; same append-and-close constraint as Pi) - omp extension: `~/.omp/agent/extensions/ccmux.js` (written by `ccmux setup --agent omp`; the `.omp` component honors `PI_CONFIG_DIR`, joined verbatim under `$HOME` — see the omp caveats above) - Antigravity hooks.json: `~/.gemini/config/hooks.json` (merged by `ccmux setup --agent antigravity`; existing files are backed up to `hooks.json.backup` before modification) - Antigravity hooks: `~/.gemini/config/hooks/ccmux-preinvocation.sh`, `ccmux-stop.sh` - Antigravity app data: `~/.gemini/antigravity-cli/` (read-only; conversations, transcripts, settings, and per-invocation logs) - Copilot sessions: `~/.copilot/session-state//` — `events.jsonl` (real-time JSONL, append-and-close, tailed by the log adapter) plus `session.db` (held open, used for lsof native-id discovery) and `workspace.yaml` - Copilot hooks: `~/.copilot/hooks/ccmux-copilot.json` (registration) + `~/.copilot/hooks/ccmux-copilot.sh` (marker script) (written by `ccmux setup --agent copilot`; shared drop-in dir, only these two files owned) ### ccmux-owned - Antigravity marker: `~/.config/ccmux/session-pids/antigravity-.json` - Copilot marker: `~/.config/ccmux/session-pids/copilot-.json` - Markers: `~/.config/ccmux/session-pids/-.json` (written by hook scripts for Claude/Codex/Cursor/Antigravity/Copilot, the bundled plugin for OpenCode, or the bundled extensions for Pi and omp; consumed by the daemon's `HookManager`)