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.replacewith 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
shreads it, and every{prompt}must land in a real single-quoted context with the template balanced. Checking only the adjacent characters is not enough: insh -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, andomp -ctake 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 (buildTmuxSpawnArgvand the route’s placement resolution).POST /spawn’s fork path adds no targeting of its own: the caller passessplit/targetexactly 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 checkoutgit worktree list --porcelainreports 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.cwdrefusal was wrong in both directions: it never fired on the path the picker uses (which sends nocwd), 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,/tmpincluded, in both print and interactive mode.The path form is undocumented. It is absent from
claude --helpand 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, settingagents.claude.forkCommandto"{bin} --resume {id} --fork-session"inccmux.jsonrestores 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 whenSession.logPathis 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 (
--worktreealongside--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 ingit worktree listby 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,-3on 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--basestill wins.--with-changesis 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-2instead 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-workingas 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
-
$PPIDis 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 intermediatesh -cwrapper, so$PPIDis a short-lived, tty-less shell; storing it gives the marker a dead pid and a no-tty value,cleanupStaleMarkerspurges 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_WALKtherefore walks up from$PPIDwith the same two arms as Cursor’s: an ancestor whosecomm=isclaudewins, else the first ancestor with a real controlling terminal, else$PPIDso a process-shape surprise self-cleans within a scan cycle. A no-ttyps -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’sunknownsentinel at the tail. On macOS the same Claude version currently runs hooks directly, so$PPIDIS 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
Notificationhook 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, soSTATE_NOTIFY_HOOK_SCRIPTmaps both to marker statewaiting_permission. Claude also does NOT flush the picker’stool_useto 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’sterminalRulesclassify it asattentionType: "question", and the reconciler relabels the marker candidate viacorrectAmbiguousPermissionMarker(gated byAgentDef.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’snotificationActions.answerPreludeis["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.replyOnFinishedopts afinished(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.permissionReplyPreludeis["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 labelspermissionbut 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, never1(which enables auto mode), so Claude’snotificationActions.planApproveis["2"]; the digit submits immediately with no Enter.planDeny/planReplyPreludeare["Escape"]: Escape cancels ExitPlanMode to an empty composer with plan mode still on, where text + Enter sends as a normal user message. Claude’sNotificationhook DOES fire for the ExitPlanMode wait, but withstate: waiting_permissionandpending_tool: null, and the marker wins the cascade, so a live plan wait is STORED asattentionType: "permission"withpendingTool: "ExitPlanMode"(the tool name comes from the log, not the marker). Worse, thatpending_tool: nullmarker combined with a frequently-deferredExitPlanModelogtool_usemeans a live plan wait is USUALLY stored as{ permission, pendingTool: null }, soisPlanApprovalWaitis 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 toplan_approvalso the plan keys (not the permission1= auto mode) are used; the marker/log predicate is only the capture-failure fallback. The plan BODY prefers the transcriptExitPlanModeinput.planwhen 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
PermissionRequestrequires Codex >= 0.122 (rust-v0.122/@openai/codex@0.122.0-alpha.8+). Older Codex silently ignores the entry becauseHooksFiledoes not use#[serde(deny_unknown_fields)];SessionStartandStopstill work.PermissionRequesthook scripts MUSTexit 0with empty stdout on every failure path. Codex interpretsexit 2 + stderras aDenydecision, 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_permissionuntil end of turn. The log adapter infers resolution instead:response_itementries of typefunction_call_output/custom_tool_call_outputare flushed only after the gated tool executed, so they flip a waiting session back to working (applyResponseIteminadapters/codex/log-adapter.ts). Request items andtoken_countare 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 nofs.watchchange event for appends through an open fd (verified under both Bun and Node), soCodexLogAdapterdeclarespollsLogandLogWatcherstat-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 thePermissionRequestmarker’s ownstate_timestamp(threaded in asprev.waitEstablishedAt) with a 250ms jitter slack, falling back to the store’sstatusChangedAtwith 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 nostatusChangedAt, 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 widestatusChangedAtslack. It also covers waiting->waiting swaps, since the marker restamps on every request whilestatusChangedAtonly moves on a status edge. The cascade alone cannot provide this protection: it restoreswaitingat 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: thePermissionRequestpayload carries nocall_id(verified on codex-cli 0.146.0; it hassession_id/turn_id,transcript_path,cwd,tool_name, andtool_inputonly), 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 wrongworking, 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_idset andthread_source: "subagent"(vs."user") — rollout-candidate enumeration for session linking (CodexLogAdapter.parseSessionMetadata, feedingscanCodexRollouts/decideCodexRolloutLinks) excludes it viaisSubagentRollout()(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_hooksto[features] hooks(stable, default-on by 0.130). ccmux’s TOML helper recognizes either name; new installs writehooks = true, and the writer preserves whichever name an existing config already carries, so acodex_hooksuser is never silently rewritten. Installs wrotecodex_hooks = truefor 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 freshccmux setup. Uninstall deliberately leaves whichever flag is present in place (see below). SessionStartfires on first user message in Codex 0.124+, not on agent launch. Sessions appear pane-tracked (nonativeSessionId) until the user submits their first turn; this is expected. Codex 0.122/0.123 fireSessionStarton launch.ccmux setup --uninstall --agent codexdeliberately leaves the codex hooks feature flag in~/.codex/config.tomluntouched (eithercodex_hooksorhooks, whichever is present). Orphan flag is cosmetic (emptyhooks.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. Soapprove: ["Enter"]confirms “Yes, proceed” (the tool runs), anddeny: ["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), nopermissionReplyPrelude/Reply is offered, and Codex has no question wait. The authoritative wait signal is thePermissionRequesthook marker (Codex >= 0.122); the legacy[y/n]prompt interminalRulesis 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!-leadingunsafeReplyPattern(/^\s*!/). - Codex’s unanchored
processMatch(/\bcodex\b/i) also matches the bundled computer-use MCP server (argv[0] basenameCodex), which runs with its cwd inside<CODEX_DIR>/plugins/.discoverAgentProcessesdrops any discovered process whose resolved cwd is under<CODEX_DIR>/plugins/(isCodexPluginHostCwdinprocesses.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 realcodexsharing the pane still populates the session with its own repo cwd. - Codex 0.146 also starts an internal
codex-code-mode-hostprocess 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.discoverAgentProcessesfilters that exact internal executable before wrapper collapsing (isCodexCodeModeHostCommandinprocesses.ts). The eviction is reachable only on theps-tty platforms (Linux): on macOSFD_TTY_DISCOVERYharvests the tty from fds 0/1/2 and the host runs fully piped, so it resolves no tty and thewithTtyfilter drops it first. The filter is defense-in-depth there, and load-bearing on Linux, where keeping the real Codex PID matters:SessionStartmarkers 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 subsequentPermissionRequest/Stopmarker updates. terminalRulescover two widget generations, and apermissionmatch on a hook-enriched session is re-checked against a fresh capture (issue #103). Codex 0.146 wraps its approval prompt in aWould you like to run the following command?/…make the following edits?/…grant these permissions?heading; the olderPress enter to confirm or esc to cancelfooter and theEsc to interruptworking footer both survived into it (re-captured live from a 0.146.0 pane on 2026-08-03 — verify against a real pane, neverstringson 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 inmatchAnyand should be dropped once old Codex stops mattering. Because the rules can now fire again, a matchingpermissionrule is dropped for a hook-enriched session (dropsStalePermissionTerminalUpgradeinstate-reconciler.ts, gated byAgentDef.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 atwaiting_permissionbecause no hook fires on resolve. Without the drop, leftover prompt text (or aterminalRuleCacheentry captured during the wait and replayed after it) would lift the fresher log-derivedworkingback towaitingon 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 toworking) — dropping it outright there would leave the row readingworkingwhile 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 fromcapturePane, and a recapture (which also refreshesterminalRuleCache) 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 theirterminalRulesARE the wait signal.
Cursor-specific caveats
- Hooks require
cursor-agent>= 2026.1.16 (the hooks feature landed in that release). Older versions silently ignorehooks.jsonentries;install()warns but doesn’t block, anddescribeInstallAnomalies()surfaces the same warning at daemon startup. Version gate lives inadapters/cursor/version.ts. processMatch: /^(cursor-agent|agent)$/imatches both the stock binary and the bareagentshim Cursor ships. Anchored on argv[0] basename viafindAgentForProcess, so it won’t collide with arbitrary shell commands that include the word “agent”.--resume <chatId>accepts the payload’sconversation_id(which equalssession_idin every captured payload). Cursor scopes chats per workspace —cursor-agent --resume <id>only restores the transcript when invoked from the originalworkspace_rootsdirectory. 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 -cwrapper, so$PPIDinside the script is a transient shell, not cursor-agent. The scripts walk the process ancestry with two arms: an ancestor whosecomm=iscursor-agent/agentwins, 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 reportscomm=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-ttyps -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$PPIDso the marker self-cleans on the next scan rather than silently no-opping. --resumedoes NOT firesessionStart; onlybeforeSubmitPromptfires on the first submission in a resumed chat. Theccmux-before-submit-prompt.shandccmux-stop.shscripts therefore create the marker if missing (same identity fieldssessionStartwould have written), otherwise resumed chats would be invisible to ccmux.- Hook scripts MUST
exit 0with empty stdout on every failure path. Cursor treatsexit 2 + stderras a “deny the action” signal. The four subscribed events don’t gate execution today, but keeping the contract uniform prevents surprises if we later addpreToolUse. ccmux setup --uninstall --agent cursorremoves only entries whosecommandmatches the exact install-written script paths, and preserves the top-levelversionfield so a user’s hand-authoredhooks.jsonstructure 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: bothescandnopen 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 onlyKEY_SEQUENCE_GAP_MSapart 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-cinterrupts 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 noterminalRulesentry, so it never becomes apermissionwait. - 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 /helpexecuted/help modeland 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 positionalunsafeReplyPattern(/(^|\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 onlynode:fs/promisesso 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, anduninstall()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, andnativeSessionIdfollow the newest-activity or newest-waiting marker. Marker ownership (issue #177). Twoopencodeprocesses 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 ownclient.session.statusreports an entry for:session.listis 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 itslast_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 (paneIdHostingPidinstate-reconciler.ts, backed byfindPaneHostingPid), because thenativeSessionIda row holds can be foreign untiladapters/link.tsheals 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
nativeSessionIdremains (theSessionManagersetter accepts only strings, not null). Inert: status shows idle, click-through still lands in the pane. PID death clears it viacleanupStaleSessions. ccmux setup --uninstall --agent opencodeonly unlinks the plugin file; the daemon’s nextcleanupStaleMarkerssweep 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 newpermission.askedfires. The same gap applies to questions (noquestion.list): a question already open at plugin-load time is invisible to the marker path, which is why the question picker also gets aterminalRulesfallback. - Question waits (issue #137). The
questiontool blocks the turn exactly like a permission (a pending deferred), butsession.statusstaysbusyfor 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 atworking. The plugin subscribes toquestion.asked/question.replied/question.rejected(the unprefixed v1 names are what the plugin bus publishes; thequestion.v2.*family never reaches the hook) and writes marker statewaiting_question, which aggregates towaiting+attentionType: "question". Both resolutions writeworkingand the nextsession.statussettles 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 theworkingwrite 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’sesc dismiss+enter <verb>pair.esc dismissis the anchor doing the work (present in all four sub-modes, absent from the permission dialog, the ctrl+p command palette, the/modelsautocomplete, and the Tab agent-switch);enteronly 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 dismisswith 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, soapprove: ["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), sodeny: ["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 inworking— so it is not used for Deny and nopermissionReplyPrelude/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!-leadingunsafeReplyPattern(/^\s*!/). Known edge (documented, not gated): a composer in a transitional state (e.g. mid/modelsswitch) 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
aggregateOpenCodeMarkerssetsSession.ambiguousWaitwhen MORE THAN ONE marker is waiting (waiting_permissionorwaiting_question) at once, and both the notifier (offer side) andhandleNotificationAction(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, sopsreports the process aspi(notnode .../cli.js).processMatchis anchored/^pi$/i(not\bpi\b) because “pi” is a short, collision-prone token; api-coding-agent/dist/cli.jscommandPatternsentry covers the sub-millisecond window before the title is set and any platform where title rewriting doesn’t reachps. - Pi has no native tool-approval pause (it runs tools immediately), so there is no authoritative
waiting/permissionstate. The marker only ever carriesidle/working, and the no-hooksterminalRulesonly detectworking(keyed on the literalWorking.../Thinking...; Pi’s idle footer contains the word “interrupt”, so unlike codex/gemini we must NOT keyworkingon “interrupt”). Awaitingindicator is only possible if the user installs atool_call-gating extension, which is out of scope. - Pi runs ONE session per process. A session switch (
/new,/resume) emitssession_shutdownfor the old session (removing its marker), reloads extensions, then emitssession_startfor the new one, so markers never overlap and need no OpenCode-style aggregation. - Pi auto-discovers both
*.tsand*.jsextensions (loaded via jiti), so ccmux installs a.jsfile. 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 pishellspi -p "<prompt>"(print mode → final assistant text on stdout).--session <id>resume in print mode is unverified, soresumeArgsis 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
curlexecuted in 0.1s with no prompt — so apermissionwait can never exist and there is nothing for Approve/Deny to drive. Nuance: pi ships anask_questiontool (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-installedtool_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!-leadingunsafeReplyPattern(/^\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 setprocess.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 aspsis concerned. Verified with a two-line script that setsprocess.title = "omp"and sleeps: underbun,psreports commbunand argsbun /tmp/title-test.js; undernode, it reportsompfor both. Pi is unaffected — itscli.jsshebang is#!/usr/bin/env node, so Pi’s title rewrite genuinely reachesps. Two launch shapes therefore have to be covered:- Standalone binary / node-launched — argv[0] basename really is
omp(omp,/usr/local/bin/omp).processMatch: /^omp$/icatches it. Anchored rather than\bomp\bfor 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.findAgentForProcessmatchesprocessMatchagainst the argv[0] basename, so a full path needs nocommandPatternsentry of its own. - npm/mise bun shim (the common install) — the live process is comm
bun, argsbun /Users/x/.local/share/mise/installs/node/26.3.0/bin/omp --model .... Neither comm nor argv[0] is everomp, and argv carries the symlink path (.../bin/omp), never the resolved@oh-my-pi/pi-coding-agent/dist/cli.jstarget. ThecommandPatternsentry/[/\\]bin[/\\]omp(?:\s|$)/iis the only stable signal for this shape. The trailing(?:\s|$)excludes/bin/ompxand/bin/omp-helper; the required/bin/component keeps a bareompword 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,cleanupStaleMarkersdeletes the extension’s valid marker within ~10s, so the marker survives only while the daemon is stopped. - Direct
bun /path/to/@oh-my-pi/pi-coding-agent/dist/cli.js(no shim) — covered by the secondcommandPatternsentry,/oh-my-pi[/\\]pi-coding-agent[/\\]dist[/\\]cli\.js/i. That entry doubles as the cover for the sub-millisecond window beforeprocess.titleis set on the node/standalone path, and it is scoped to theoh-my-pidir so it can never claim upstream pi (see the ordering caveat below).
- Standalone binary / node-launched — argv[0] basename really is
ompmust be ordered BEFOREpiinBUILTIN_AGENTS.findAgentForProcessis first-match-wins, and Pi’scommandPatternsregex (/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 owndist/cli.jspattern additionally requires theoh-my-piscope dir so it can never claim upstream pi, and thebin/omppattern cannot collide either (Pi’s launcher token isbin/pi). All of this is locked in by tests insrc/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/permissionstate and Approve/Deny notification actions. The extension subscribes totool_approval_requested/tool_approval_resolved(ToolApprovalRequestedEvent/ToolApprovalResolvedEventin omp’ssrc/extensibility/extensions/types.tson 17.1.3), whose payload is{type, sessionId, toolCallId, toolName}withapprovalMode,reason, andapprovedriding along. The extension reads onlytoolCallIdandtoolName, 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 whenapprovalCheck.requiredAND 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 defaultyoloapproval mode; it only makes the pauses observable for users who configured an approval mode. Corollary: on ayolo-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
bashcalls, say) each request approval before either resolves. The extension tracks them in a per-session insertion-orderedMapoftoolCallId -> toolNameand keeps the marker atwaiting_permissionuntil the LAST one resolves.pending_toolpublishes 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_resolvedfires 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 writesworkingandagent_endstill delivers the finalidle.agent_endalso clears the pending map defensively, so an aborted turn cannot leave an id that pins the next turn at waiting. PI_CONFIG_DIRis joined VERBATIM, and ccmux is bug-compatible with that. omp resolves its config root aspath.join(homedir(), process.env.PI_CONFIG_DIR || ".omp")(@oh-my-pi/pi-utilsdirs.ts), so an ABSOLUTE override lands under$HOMEanyway: live-verified,PI_CONFIG_DIR=/tmp/absolute-test omp config pathprints~/tmp/absolute-test/agent. Node’sjoin(home, "/tmp/x")reproduces that exactly, soOMP_AGENT_DIRinsrc/lib/config.tsuses the same plain join and the extension installs where omp actually looks. Do not “fix” it withresolve(): that would write the extension somewhere omp never reads. The behavior is locked in upstream by omp’s owntest/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 namedPI_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), theOMP_PROFILE/PI_PROFILEprofile dirs (redirecting to<root>/profiles/<name>/agent), and Linux/macOS XDG redirection when$XDG_DATA_HOME/ompalready exists. Shelling out toomp config pathat setup time would resolve all four at once; deferred because setup must stay fast and offline-safe.Working…(real U+2026, not Pi’s ASCIIWorking...) 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 theworking…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 noThinking…label. The only ASCIIWorking...left is print mode’s stderr line, which never reaches a tracked pane. As with Pi, do NOT keyworkingon “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 literalWorking…. Waiting-first is kept becausematchTerminalRuleis first-match-wins and the label is model-supplied and unpredictable, so the ordering is free insurance. The waiting rule carriespendingTool: nullbecauseTerminalRulematches fixed substrings and cannot capture the name out ofAllow 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
touchverifiably did not run; Approve via Enter: it did). The prompt body isAllow tool: <name>(formatApprovalPromptinsrc/tools/approval.ts) aboveuiContext.select(prompt, ["Approve", "Deny"])with noinitialIndex, soHookSelectorComponentopens on index 0 = Approve andapprove: ["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 hitsmatchesSelectCancel, the select resolvesundefined,approved = choice === "Approve"is false, so omp emitstool_approval_resolved {approved: false}and throwsTool call denied by user: <name>; the gated tool does not run and the turn returns to the composer. NopermissionReplyPrelude: 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.onSubmittrims first thing), so the space defuse neutralizes NEITHER prefix: a defused/newstill 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.tsinvoked an unrelated skill and started an unprompted turn). Slash text containing a space falls through omp’s dispatcher (nameis 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[/!]-leadingunsafeReplyPattern(/^\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/*.jsextensions from<agent dir>/extensions/, both exactly as Pi does, so marker keying and the.jsinstall form are unchanged. - A session switch emits
session_switch, NOT Pi’ssession_shutdown+session_startpair./newand/resumemutate the session in place (src/session/agent-session.ts) and emitsession_before_switchthensession_switch(SessionSwitchEventinsrc/extensibility/shared-events.ts, payload{type, reason: "new" | "resume" | "fork" | "handoff", previousSessionFile}); the extension runner is never reconstructed, so nothing re-firessession_start. Without a handler the old session’s marker would leak and the new session would have none. The extension therefore handlessession_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, sosessionManager.getSessionId()already returns it inside the handler. The daemon needs no change, since the new marker’saddevent drives the existingonMarkerAddedre-link. session_branchis the same id-changing event class and is bound to the same handler./branch(which opens the tree selector) and theapp.session.forkkeybinding both reachcreateBranchedSession(), which mints a fresh id viamintSessionId()and emitssession_branch— neversession_switch. The emit followsrekeyForCurrentSessionId()+resetContextForNewTranscript(), sogetSessionId()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 (cleanupStaleMarkerssees the same live pid and tty, andisSessionStillLiveis unconditionallytrue) and the per-scan link pass cannot heal the row, becausedecideMarkerLinksskips any session already holding an id that matches a marker on its pane. Verified e2e on omp 17.1.3 viadoubleEscapeAction: 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-overlast_prompt. Two deliberate boundaries:session_treeis not handled (it carriesnewLeafIdand moves a leaf within the current session, leaving the id untouched — confirmed live, a tree switch produced no marker churn), andsession_forkis also registered, because upstream pi-mono 0.43.0 renamedsession_branch→session_fork(that changelog ships inside omp’s own bundle). omp 17.1.3 has not taken the rename — it still emitssession_branchat every site, withsession_forkappearing only in the vendored changelog text — but the fork tracks upstream, and subscribing to a never-emitted event costs nothing.omp --versionprintsomp/17.1.3; the default version patterns extract17.1.3, so noversionPatternsoverride. Version inference from a resolved path needs an explicit alias indaemon/version-resolver.tsbecause the package name@oh-my-pi/pi-coding-agentdoes not contain “omp”.ccmux invoke ompshellsomp -p "<prompt>"(print mode → final assistant text on stdout). Resume in print mode is unverified, soresumeArgsis 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, andStop. It does not exposeSessionStart,UserPromptSubmit, orPermissionRequest. The firstPreInvocationtherefore creates the marker if it does not already exist; an untouched idle session remains pane-tracked until its first prompt. PreToolUseis a deny footgun. Itsdecisionoutput key is required, and{}silently denies the tool call. ccmux installs onlyPreInvocationandStop, where{}is inert, and never registersPreToolUseorPostToolUsehandlers.- 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 theccmuxkey and preserves every other key. Hook names dedupe across files with last-one-wins behavior, so another global or workspace hook namedccmuxcan shadow this entry. - Workspace
.agents/hooks.jsondiscovery is unreliable in v1.1.1. Despite the embedded documentation claiming a repository-root walk, the file loads only when--new-projectis 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.jsonlis a future log-adapter candidate. - The
agyname can also be a symlink to the desktop Antigravity.app launcher, whose resolved binary basename isantigravity. Process detection keys on the exactagybasename and the/agycommand path form, so the desktop launcher is not treated as a CLI agent. ~/.gemini/google_accounts.jsonremains{"active": null}even while the CLI is authenticated, so it is not an authentication signal.- Each
agyinvocation writes a new~/.gemini/antigravity-cli/log/cli-YYYYMMDD_HHMMSS.log. Hook execution appears there asjsonhook__<name>_<Event>_<i>_<j>activity. Headlessagy -pinvocations fire the same hooks, so they briefly create markers that the PID-liveness sweep removes after the process exits. - The installed scripts write only
workingandidle. The adapter also mapswaiting_permissionfor forward compatibility, but current permission attention comes from terminal rules matchingRequesting permission for:orDo 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 pressing4verified 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 (adding5.-6. always-deny variants) — so a deny digit cannot be trusted to land on “No”.deny: ["Escape"]uses the constantesc to cancelaffordance: 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 writewaiting_permission), so presses are gated by the pane-tracked staleness tokens. The workspace-trust prompt (Do you trust the contents of this project?) has noterminalRulesentry, so it never becomes apermissionwait 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[/!]-leadingunsafeReplyPattern(/^\s*[/!]/).
Gemini-specific caveats
- Gemini CLI has no hook adapter (and no ccmux-owned files): detection is process matching plus
terminalRulesonly, so sessions are always pane-tracked with nonativeSessionId. - A transcript exists, contrary to earlier docs, and ccmux reads it (
src/daemon/transcript-readers/gemini.ts, behindccmux 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 trackslastUpdated, 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_rootsidecar file (the absolute path, verified verbatim) or the transcript’s ownprojectHash, and the reader does the former: it matches the session’s cwd against each candidate directory’s.project_rootrather than trusting the directory name. Alogs.jsonfile besidechats/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>'?) renders1. 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 noterminalRulesentry 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[/!]-leadingunsafeReplyPattern(/^\s*[/!]/).
Copilot-specific caveats
- Waiting/permission is observed through the
notificationhook, never the decidingpermissionRequesthook. Copilot exposespermissionRequestas 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 offnotificationpayloads withnotification_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. $PPIDis the Copilot PID. Copilot runs each hook command with thecopilotprocess as its DIRECT parent, so the script derivespid=$PPIDandtty=$(ps -p $PPID -o tty=)for pane correlation (verified on v1.0.71). No zsh-wrapper walk is needed (unlike Cursor).sessionStartis deferred and can arrive AFTERuserPromptSubmitted. In interactive mode Copilot firessessionStartat 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-pmodeuserPromptSubmittedwas observed firing beforesessionStart(v1.0.71). The marker script therefore treatssession-starton an existing marker as an identity refresh that never overwrites state, so a racing prompt/notification state can’t be downgraded toidle.- events.jsonl flushes in real time, including mid-wait.
~/.copilot/session-state/<uuid>/events.jsonlis written incrementally and Copilot does NOT hold it open, so the log adapter tails it like a Codex rollout. Critically,permission.requestedis 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 permissiontool_use. Status comes fromuser.message/assistant.turn_start→ working,permission.requested→ waiting (permission;kind: "shell"→ pending tool “Command”),permission.completed→ working, andassistant.turn_end/session.shutdown/abort→ idle. - Native-id discovery via the open
session.db, not events.jsonl. Copilot keepssession-state/<uuid>/session.dbopen (lsof-discoverable) but append-and-closesevents.jsonl, so the no-hooks path recovers the session UUID from the.dbfd (COPILOT_SESSION_FILE_PATTERN; the daemon’s lsof prefilter accepts.dbalongside.jsonl). The log adapter uses its ownsession-state/<uuid>/events.jsonlpattern for the tail. ~/.copilot/hooks/is a shared, auto-discovered drop-in dir. Copilot loads every*.jsonthere. ccmux owns exactly two namespaced files —ccmux-copilot.json(the hooks registration) andccmux-copilot.sh(the marker script, ignored by Copilot’s*.jsonscan) — 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 ownterminalRulesentry (pendingTool: "Url", matchingpermissionToolLabel("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[/!]-leadingunsafeReplyPattern(/^\s*[/!]/). - Only the real binary is matched — never its node wrapper, never
gh copilot. Process detection anchors solely on the argv[0] basenamecopilot(the native SEA binary), with NOcommandPatterns. 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’spidevery scan, tripping the pane-reuse identity reset (which clearsnativeSessionId/logPath/lastPromptas fast as enrichment writes them — found live, v1.0.71 via mise). The legacygh copilot ...extension (argv[0]gh) and thegh-copilotshim 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 theadditionalClaudeConfigDirspreference /CLAUDE_CONFIG_DIR, each watched at<dir>/projects) - Claude Code history:
~/.claude/history.jsonl - Claude Code settings:
~/.claude/settings.json(written byccmux 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 byccmux 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 = truename 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 byccmux 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 behindccmux 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 byccmux setup --agent cursor; user-authoredversionfield 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 behindccmux last/ccmux handoff; the marker’stranscript_pathis what points at it) - Pi sessions:
~/.pi/agent/sessions/--<encoded-cwd>--/<ts>_<uuidv7>.jsonl(read by the transcript reader behindccmux 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.jswhen set, otherwise~/.pi/agent/extensions/ccmux.js(written byccmux setup --agent pi) - omp sessions:
~/.omp/agent/sessions/<encoded-cwd>/<ts>_<uuidv7>.jsonl(read by the transcript reader behindccmux last/ccmux handoff; same append-and-close constraint as Pi) - omp extension:
~/.omp/agent/extensions/ccmux.js(written byccmux setup --agent omp; the.ompcomponent honorsPI_CONFIG_DIR, joined verbatim under$HOME— see the omp caveats above) - Antigravity hooks.json:
~/.gemini/config/hooks.json(merged byccmux setup --agent antigravity; existing files are backed up tohooks.json.backupbefore 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 trackslastUpdated; read by the transcript reader behindccmux 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_rootsidecar) - Copilot sessions:
~/.copilot/session-state/<uuid>/—events.jsonl(real-time JSONL, append-and-close, tailed by the log adapter) plussession.db(held open, used for lsof native-id discovery) andworkspace.yaml - Copilot hooks:
~/.copilot/hooks/ccmux-copilot.json(registration) +~/.copilot/hooks/ccmux-copilot.sh(marker script) (written byccmux 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’sHookManager)