Skip to content

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. 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 '<prompt>' claude [options] [command] [prompt]; “starts an interactive session by default, use -p/–print” for one-shot
Codex codex '<prompt>' codex [OPTIONS] [PROMPT]; “[PROMPT] Optional user prompt to start the session”; codex exec is one-shot
Cursor cursor-agent '<prompt>' agent [options] [command] [prompt...]; “prompt Initial prompt for the agent”; -p/--print is one-shot
OpenCode opencode --prompt '<prompt>' default TUI command’s “–prompt prompt to use”; the positional is a PROJECT PATH; opencode run is one-shot
Pi pi '<prompt>' 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 '<prompt>' EXAMPLES section, same two entries as pi (omp "..." interactive, omp -p "..." one-shot)
Antigravity agy -i '<prompt>' --prompt is documented as “Alias for –print”; -i/--prompt-interactive is “Run an initial prompt interactively and continue the session”
Copilot copilot -i '<prompt>' -i, --interactive <prompt> “Start interactive mode and automatically execute this prompt”; -p/--prompt is “non-interactive”
Gemini gemini -i '<prompt>' -p/--prompt is “non-interactive (headless) mode”; -i/--prompt-interactive is “Execute the provided prompt and continue in interactive mode”

So <binary> --prompt '<text>', 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.

Spawning on a model

ccmux spawn --model <name> passes the value through as the agent’s own flag, declared per agent in AgentDef.modelFlag and spliced directly after the launcher binary (claude --model opus 'prompt'; on a resume it is appended, codex resume <id> --model gpt-5). Read from each CLI’s --help on a machine with all nine installed: every built-in takes --model <value> (Claude, Cursor, Pi, omp, Copilot, and Antigravity’s agy as --model; Codex, OpenCode, and Gemini as -m, --model; codex resume carries it too). The long form is declared everywhere, and src/lib/agents.test.ts pins it on all nine. Pi and omp fuzzy-match the value and accept a :<thinking> suffix; OpenCode wants provider/model.

The value lands unquoted in the shell command, so it must match MODEL_PATTERN (spawn-command.ts): letters, digits, ., _, :, /, -, never a leading -. An agent with no modelFlag, including every custom agent that has not declared one in ccmux.json, refuses the spawn with a 400 rather than guessing.

Forking a session

Fork (F in the picker, the context menu’s Fork, ccmux spawn --fork <id>) 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.<name>.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 <id> 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.

    Forking directly into a NEW worktree (--worktree alongside --fork) is supported: the worktree is created first and the fork comes up in it. That was never a resume-scoping problem — a linked worktree is in git worktree list by construction, and the path form does not care regardless. Two things about a fork’s destination differ from an ordinary spawn’s, because a fork carries no prompt and belongs on the source’s own history: the name defaults to <source-branch>-fork (numbered -2, -3 on collision, never opening an existing worktree of that name), and the worktree is cut from the SOURCE checkout’s branch rather than the main checkout’s. An explicit name or --base still wins. --with-changes is refused on a fork: the source session is still running in the checkout the changes would be stashed out of. Verified live on Claude Code 2.1.220: a fork into a fresh worktree came up on the source branch’s history and recalled a phrase planted before the fork, the source transcript stayed byte-identical, a second fork numbered -2 instead of joining the first, and a detached source derived <sha12>-fork.

  • 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

  • $PPID is not always the claude process, so the hook scripts walk the ancestry. On Linux (observed on Claude Code 2.1.250) hooks are invoked from an intermediate sh -c wrapper, so $PPID is a short-lived, tty-less shell; storing it gives the marker a dead pid and a no-tty value, cleanupStaleMarkers purges it on the next scan, and neither the TTY match nor the PID-ancestry fallback can bind the session to a pane (and because installed hooks disable Claude pane-tracking, the session goes invisible entirely). CLAUDE_PID_WALK therefore walks up from $PPID with the same two arms as Cursor’s: an ancestor whose comm= is claude wins, else the first ancestor with a real controlling terminal, else $PPID so a process-shape surprise self-cleans within a scan cycle. A no-tty ps -o tty= value is ?? on macOS/BSD and ? on Linux, so both spellings (plus - and empty) are rejected by the tty arm and normalized to the marker’s unknown sentinel at the tail. On macOS the same Claude version currently runs hooks directly, so $PPID IS the agent and the walk matches at the first hop; it is harmless there.

  • 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 fires no hook when a permission RESOLVES (manual approval or the automatic approval reviewer on newer Codex versions), so the marker stays waiting_permission until end of turn. The log adapter infers resolution instead: response_item entries of type function_call_output / custom_tool_call_output are flushed only after the gated tool executed, so they flip a waiting session back to working (applyResponseItem in adapters/codex/log-adapter.ts). Request items and token_count are deliberately not resolution evidence (they can flush while the prompt is up). That feed only exists because the daemon polls for it: Codex holds the rollout’s file descriptor open for the whole session and macOS emits no fs.watch change event for appends through an open fd (verified under both Bun and Node), so CodexLogAdapter declares pollsLog and LogWatcher stat-polls every linked Codex rollout once a second (LOG_POLL_INTERVAL_MS), dispatching growth through the same debounce a real change event uses; without it the rollout is parsed exactly once, at link time. The flip is gated by recency, anchored on the PermissionRequest marker’s own state_timestamp (threaded in as prev.waitEstablishedAt) with a 250ms jitter slack, falling back to the store’s statusChangedAt with a 2s slack when no waiting marker exists (hookless Codex). An output older than that anchor is a buffered leftover from a prior call and does not flip, and while an anchor exists an entry timestamp that will not parse is not resolution evidence either (only the unanchored case, no marker and no statusChangedAt, still flips on one). The marker anchor is what makes the gate hold under polling: the failed ungated attempt of the sandbox-fail-then-escalate flow used to be parsed before the wait existed, but at 1s polling it is parsed after, and it sits inside the wide statusChangedAt slack. It also covers waiting->waiting swaps, since the marker restamps on every request while statusChangedAt only moves on a status edge. The cascade alone cannot provide this protection: it restores waiting at the next tick, but the transient store write already retracted the delivered desktop notification, and the restore lands inside the notifier’s 60s renotify cooldown, so the banner would be permanently lost. Residual known gaps: (1) during a long-running approved command no rollout entry is flushed until the command finishes, so the row can show waiting for the duration of that one command’s execution; (2) the flip is uncorrelated with the call that established the wait, because no correlation key exists: the PermissionRequest payload carries no call_id (verified on codex-cli 0.146.0; it has session_id/turn_id, transcript_path, cwd, tool_name, and tool_input only), and command-string matching is ambiguous because Codex’s standard sandbox-fail-then-escalate flow reuses the identical command across the ungated attempt and the gated retry. So an unrelated PARALLEL tool’s output flushing while the prompt is up is genuinely newer than the wait and would still clear it early; (3) the marker anchor’s 250ms slack forgives write-order jitter between two files flushed by different processes, and in doing so knowingly admits an output stamped up to 250ms BEFORE the request marker, so the failed attempt’s output in the sandbox-fail-then-escalate flow can land inside that window and clear the new wait early. Gaps (2) and (3) share one blast radius: a transient wrong working, a banner lost to the retract plus the 60s renotify cooldown, and a row the next cascade tick heals.
  • The auto-approval reviewer (Codex >= 0.146) writes its OWN rollout file per launch alongside the user’s, timestamped and cwd-matched near-identically, with session_meta.parent_thread_id set and thread_source: "subagent" (vs. "user") — rollout-candidate enumeration for session linking (CodexLogAdapter.parseSessionMetadata, feeding scanCodexRollouts/decideCodexRolloutLinks) excludes it via isSubagentRollout() (adapters/codex/parse.ts) so a pane never binds to the reviewer’s unrelated thread.
  • 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 hooks = true, and the writer preserves whichever name an existing config already carries, so a codex_hooks user is never silently rewritten. Installs wrote codex_hooks = true for backwards compat until current Codex began emitting a deprecation warning for it — that turned the orphan key from cosmetic into a warning on every Codex run, so the compat write was dropped and pre-0.124 Codex is no longer served by a fresh ccmux setup. 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 <cmd>, 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 <CODEX_DIR>/plugins/. discoverAgentProcesses drops any discovered process whose resolved cwd is under <CODEX_DIR>/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.
  • Codex 0.146 also starts an internal codex-code-mode-host process in the user’s project directory, conditionally, when code mode engages. Its basename matches the same unanchored process rule and it is a same-tty child of the real Codex binary, so ordinary wrapper collapsing would retain the host and discard Codex. discoverAgentProcesses filters that exact internal executable before wrapper collapsing (isCodexCodeModeHostCommand in processes.ts). The eviction is reachable only on the ps-tty platforms (Linux): on macOS FD_TTY_DISCOVERY harvests the tty from fds 0/1/2 and the host runs fully piped, so it resolves no tty and the withTty filter drops it first. The filter is defense-in-depth there, and load-bearing on Linux, where keeping the real Codex PID matters: SessionStart markers record the Codex hook runner’s parent PID, and stale-marker cleanup compares that PID with process discovery; selecting the host would reap every live Codex marker after two scans and disable subsequent PermissionRequest / Stop marker updates.
  • terminalRules cover two widget generations, and a permission match on a hook-enriched session is re-checked against a fresh capture (issue #103). Codex 0.146 wraps its approval prompt in a Would you like to run the following command? / …make the following edits? / …grant these permissions? heading; the older Press enter to confirm or esc to cancel footer and the Esc to interrupt working footer both survived into it (re-captured live from a 0.146.0 pane on 2026-08-03 — verify against a real pane, never strings on the binary, which lists variants the TUI may not render). Both generations’ strings are in the rules. The legacy [y/n] / (y/n) literals are kept only for pre-0.146 Codex and do false-positive on ordinary tool output that happens to echo them; they sit last in matchAny and should be dropped once old Codex stops mattering. Because the rules can now fire again, a matching permission rule is dropped for a hook-enriched session (dropsStalePermissionTerminalUpgrade in state-reconciler.ts, gated by AgentDef.markerOwnsPermissionWait, Codex-only) whenever the log source is strictly fresher than the marker AND has moved on from the wait — i.e. exactly the post-resolution window this file describes above, where the marker is stuck at waiting_permission because no hook fires on resolve. Without the drop, leftover prompt text (or a terminalRuleCache entry captured during the wait and replayed after it) would lift the fresher log-derived working back to waiting on every tick, re-arming a spent Approve button and destroying the delivered banner (retract + 60s renotify cooldown). Those five conditions mark the match suspect, not dead, because they also all hold during the parallel-output gap described above (an unrelated tool’s output flushes while the widget is still on screen and falsely flips the log to working) — dropping it outright there would leave the row reading working while Codex sits blocked on approval, with its notification already retracted. The reconciler settles the two cases with a pane capture taken on the spot: the widget is REMOVED on both approve and deny, so a capture that still matches a permission rule proves the wait is live and the terminal source is kept, while no match proves the earlier one was stale and the drop stands. The capture is this tick’s own when the match came straight from capturePane, and a recapture (which also refreshes terminalRuleCache) when it came from that cache; either way it is confined to this rare branch, so routine ticks spawn no extra subprocess. Hookless Codex is untouched: with no marker there is nothing to drop, and the rules remain its only waiting source. Cursor et al. are excluded by the flag because their hooks have no permission event at all, so their terminalRules ARE the wait signal.

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 <chatId> accepts the payload’s conversation_id (which equals session_id in every captured payload). Cursor scopes chats per workspace — cursor-agent --resume <id> 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 with two arms: an ancestor whose comm= is cursor-agent/agent wins, else the first ancestor with a real controlling terminal. The second arm is load-bearing, not a nicety: cursor-agent 2026.08.25 on Linux reports comm=MainThread (node sets the thread name), so the comm walk misses it entirely and only the tty-bearing ancestor resolves the live pid (the agent runs foreground in its pane and owns the pane’s pts, while every wrapper above the hook is tty-less). A no-tty ps -o tty= value is ?? on macOS/BSD and ? on Linux, so both spellings (plus - and empty) are rejected. If both arms fail 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(<cmd>) 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<version>); 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. Marker ownership (issue #177). Two opencode processes in one directory share OpenCode’s SQLite db (anomalyco/opencode#31307), so both see each other’s sessions. Two rules keep one server’s markers off the other’s row. The plugin seeds a marker at boot only for a session its own client.session.status reports an entry for: session.list is project-wide over that shared db, so the old blanket seed was a false hosting claim that rewrote a sibling process’s live marker under this pid, flipping the row’s pane and dropping its last_prompt. SessionStatus.list() is a per-process in-memory map (idle entries are deleted), so membership is the only proof this server hosts the session. The daemon’s tick then folds only markers whose pid resolves to the row’s OWN pane (paneIdHostingPid in state-reconciler.ts, backed by findPaneHostingPid), because the nativeSessionId a row holds can be foreign until adapters/link.ts heals it, and the marker lookup is by that id; unknown hosting (boot window, or a pid newer than the last process snapshot) fails open. Residual: two processes genuinely hosting the SAME session id (the 31307 shape) is unresolvable with one marker file per session id, and is OpenCode’s bug, not ccmux’s.
  • 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. The same gap applies to questions (no question.list): a question already open at plugin-load time is invisible to the marker path, which is why the question picker also gets a terminalRules fallback.
  • Question waits (issue #137). The question tool blocks the turn exactly like a permission (a pending deferred), but session.status stays busy for the whole wait and fires nothing at ask time (verified live on OpenCode 1.18.15, re-verified on 1.18.19: a 2m49s unanswered question produced no status event at all, so a heartbeat can never clobber the wait), so without dedicated handling the row pins at working. The plugin subscribes to question.asked / question.replied / question.rejected (the unprefixed v1 names are what the plugin bus publishes; the question.v2.* family never reaches the hook) and writes marker state waiting_question, which aggregates to waiting + attentionType: "question". Both resolutions write working and the next session.status settles within ~100ms, but they are not the same event: an ANSWER resumes the turn (the model reasons on from it), while a REJECT ENDS it (working +66ms, idle +152ms, no tool error and no further output), so the working write on a reject is a sub-second transient, not a claim that work resumed. That status event is also the self-heal for a missed reply event. Terminal fallback: the picker footer’s esc dismiss + enter <verb> pair. esc dismiss is the anchor doing the work (present in all four sub-modes, absent from the permission dialog, the ctrl+p command palette, the /models autocomplete, and the Tab agent-switch); enter only avoids a bare single-anchor match. Do not “tighten” the pair to ↑↓ select, which looks more specific and is a regression: the multi-select Confirm tab renders ⇆ tab enter submit esc dismiss with no arrow glyphs at all. The free-text (“Type your own answer”) sub-mode keeps a byte-identical footer and is matched too (the editor is inline, replacing the option’s description line). The footer replaces the composer while the picker is up, so the match never lingers as scrollback. The question rule is deliberately ordered BEFORE the permission rule: model-authored question text can contain the word “reject”, and on the pane-only path a permission misclassification attaches Approve/Deny buttons whose approve key is a bare Enter, which the picker would consume as a selection.
  • 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). Question waits are detected (see above) but carry no Reply either: Escape REJECTS the open question and ends the turn rather than cancelling back to the composer, so a typed reply has no safe abort key. (The picker does accept typed text, but only into its “Type your own answer” option, reachable solely by arrowing down to it, not from a keystroke burst.)
  • 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 (waiting_permission or waiting_question) 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 "<prompt>" (print mode → final assistant text on stdout). --session <id> 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. Not modeled (TODO on the constant): PI_CODING_AGENT_DIR (overrides the agent dir outright), the OMP_PROFILE/PI_PROFILE profile dirs (redirecting to <root>/profiles/<name>/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: <name>; 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: <name> (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: <name>; 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 /<token> 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 <agent dir>/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 "<prompt>" (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/<conversationId>/.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__<name>_<Event>_<i>_<j> 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: <cmd> / 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.
  • A transcript exists, contrary to earlier docs, and ccmux reads it (src/daemon/transcript-readers/gemini.ts, behind ccmux last / ccmux handoff; Gemini has no hooks and therefore no marker, so the reader locates the file from the session’s cwd itself). Gemini writes the whole conversation as one JSON file, rewritten in place on every turn: ~/.gemini/tmp/<cwd-basename>/chats/session-<ts>-<hex>.json (sessionId, projectHash, startTime, lastUpdated, messages[]). The file’s mtime tracks lastUpdated, so it is live-written the same way a Claude JSONL is, just whole-file instead of append-only. The directory key is the cwd’s BASENAME, not an encoded path, with numeric dedupe on collision (ccmux, ccmux2, … for distinct checkouts that share a leaf directory name); resolving a basename back to a specific cwd requires reading the .project_root sidecar file (the absolute path, verified verbatim) or the transcript’s own projectHash, and the reader does the former: it matches the session’s cwd against each candidate directory’s .project_root rather than trusting the directory name. A logs.json file beside chats/ records user prompts only (sessionId, messageId, type: "user", message, timestamp) — no assistant text and no tool activity are recorded anywhere for Gemini. Treated read-only, like every other agent-owned file.
  • Notification actions (Approve/Deny keystrokes). Verified e2e on gemini-cli 0.29.5. The shell-approval picker (Allow execution of: '<cmd>'?) 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/<uuid>/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/<uuid>/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/<uuid>/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/<encoded-path>/<sessionId>.jsonl (plus any extra config dirs from the additionalClaudeConfigDirs preference / CLAUDE_CONFIG_DIR, each watched at <dir>/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] hooks = true; ccmux still recognizes the pre-0.124 [features] codex_hooks = true name on read and preserves it in a config that already has it; 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)
  • OpenCode conversation store: ~/.local/share/opencode/opencode.db (SQLite, WAL, hot; opened READ-ONLY by the transcript reader behind ccmux last / ccmux handoff, which queries it by native session id. Unconditional path with no XDG override, unlike the config dir above)
  • 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/<workspace-slug>/agent-transcripts/<conversation_id>/<conversation_id>.jsonl (read by the transcript reader behind ccmux last / ccmux handoff; the marker’s transcript_path is what points at it)
  • Pi sessions: ~/.pi/agent/sessions/--<encoded-cwd>--/<ts>_<uuidv7>.jsonl (read by the transcript reader behind ccmux last / ccmux handoff; Pi appends-and-closes per entry, so the lsof discovery path never fires, same constraint as Claude)
  • Pi extension: $PI_CODING_AGENT_DIR/extensions/ccmux.js when set, otherwise ~/.pi/agent/extensions/ccmux.js (written by ccmux setup --agent pi)
  • omp sessions: ~/.omp/agent/sessions/<encoded-cwd>/<ts>_<uuidv7>.jsonl (read by the transcript reader behind ccmux last / ccmux handoff; 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)
  • Gemini transcripts: ~/.gemini/tmp/<cwd-basename>/chats/session-<ts>-<hex>.json (whole-file JSON, rewritten in place on every turn; mtime tracks lastUpdated; read by the transcript reader behind ccmux last / ccmux handoff, which locates the file from the session’s cwd since Gemini has no marker — see the Gemini caveats above for the basename-collision dedupe and the .project_root sidecar)
  • Copilot sessions: ~/.copilot/session-state/<uuid>/ — 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-<conversationId>.json
  • Copilot marker: ~/.config/ccmux/session-pids/copilot-<sessionId>.json
  • Markers: ~/.config/ccmux/session-pids/<agent_type>-<session_id>.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)