oxtail 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -17,7 +17,7 @@ Scope is **project-root as the unit**. Sessions in one project root see each oth
17
17
  - **Registry (leaning):** `tmux list-sessions` filtered by project-derived names, rather than a custom JSON registry. Free dead-session detection, free naming, no daemon to maintain. Decision pending real-use signals.
18
18
  - **Project scoping:** project root inferred from session CWD at agent startup.
19
19
 
20
- ## Status: v0.6.0 shipped, dogfooding
20
+ ## Status: v0.7.0 shipped, dogfooding
21
21
 
22
22
  Nine MCP tools live: `list_project_sessions`, `read_session`, `claim_session`, `set_my_state`, `register_my_session`, `get_my_session`, the v0.5 messaging pair `send_message` and `read_my_messages`, and the v0.6 delegate-and-wait primitive `ask_peer`. Registered both project-locally (via `.mcp.json` using `tsx ./src/server.ts` for the dev loop) and globally (in `~/.claude.json` and `~/.codex/config.toml`, pointing at `dist/server.js`).
23
23
 
@@ -31,7 +31,7 @@ The v0.5 change: two new MCP tools (`send_message`, `read_my_messages`) plus an
31
31
 
32
32
  The v0.6 change: one new MCP tool (`ask_peer`) that turns v0.5's async pings into a blocking delegate-and-wait. Friction observed while dogfooding v0.5 — `send_message` lets agents say things to each other, but the sender doesn't stay in-turn waiting for a reply. `ask_peer` blocks server-side until a reply with a matching `from_session_id` lands (or a fixed timeout elapses) and fires a `tmux send-keys` wake against the peer's pane.
33
33
 
34
- **v0.6 known limitation (tracked in issue #3, scoped for v0.7).** The wake does *not* reliably rouse fully-idle TUI peers. Codex composer pollution is verified (2026-05-13 in `terminal-orchestrator`): the wake's `tmux send-keys ... Enter` lands as typed-but-not-submitted text in Codex's `›` composer, leaving the wake notification visible to the user but never flushing it as a turn. Idle Claude Code peers are also unwoken in practice — the PreToolUse hook only fires inside an existing turn, so idle Claude at its prompt has no polling pathbut the root cause is not yet captured the same way (could be the same `\r`-as-newline issue; could be different). For now, `ask_peer` is reliable only when the target is already in a turn (or enters one on its own via user input) inside the timeout window. Against a fully idle peer with no human at the keyboard, expect `timed_out: true`.
34
+ The v0.7 change: per-client wake routing after the v0.6 wake was found to be broken against idle TUI peers. Spike investigation (issue #3) revealed two distinct constraints, fixed differently. For **Codex**: the root cause was not `\r`-as-newline as initially suspected, but Codex's paste-burst heuristic (`codex-rs/tui/src/bottom_pane/paste_burst.rs`) suppressing Enter for ~120ms after a fast typed burst — `tmux send-keys -l text` + immediate `send-keys Enter` looked like a paste, so the trailing Enter was forcibly converted to newline. Fix: a 500ms gap between the text and the Enter. Verified live 2026-05-13 against the live `oxtail-codex` peer in this repo. For **Claude Code**: idle peers are architecturally unwakeable from outside the process — the documented Claude Code hook surface has no idle event, no polling, no external "start a turn" mechanism (`Notification` is outbound-only; `FileChanged` only fires inside an in-flight turn). v0.7 ask_peer fail-fasts for Claude Code targets with `wake_status: "skipped_unsupported"` rather than burning the 45s timeout. The outbound is still enqueued and delivered next time the peer enters a turn. Wake strategy is overridable via `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` as a rollback.
35
35
 
36
36
  ## How to collaborate on this project
37
37
 
@@ -53,7 +53,8 @@ The v0.6 change: one new MCP tool (`ask_peer`) that turns v0.5's async pings int
53
53
 
54
54
  ## Recently shipped
55
55
 
56
- - **Delegate-and-wait (v0.6).** `ask_peer({ target, body })` blocks server-side until the peer replies (filtered by `from_session_id`) or a fixed timeout elapses, with a best-effort `tmux send-keys` wake attempted against the peer's pane. Late replies fall back to the v0.5 hook / poll delivery path. Target must have a registered `client.session_id`. **Idle-TUI wake is not reliable in v0.6** see the limitation note above and issue #3.
56
+ - **Per-client wake routing (v0.7).** `ask_peer` now routes its wake mechanism per `client_type`. Codex: paste-burst-aware send-keys (500ms gap between text and Enter) verified to actually submit. Claude Code: fail-fast with `wake_status: "skipped_unsupported"` since the hook surface has no idle event. Response gains a `wake_status` field for caller diagnostics. Pre-wake pane re-resolution closes the stale-pane-ID race from v0.6. `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` env override for rollback. Issue #3 has the spike findings.
57
+ - **Delegate-and-wait (v0.6).** `ask_peer({ target, body })` blocks server-side until the peer replies (filtered by `from_session_id`) or a fixed timeout elapses. Late replies fall back to the v0.5 hook / poll delivery path. Target must have a registered `client.session_id`.
57
58
  - **Cross-session messaging (v0.5).** `send_message({ target, body })` + `read_my_messages()`. Mailbox lives at `~/.oxtail/mailboxes/<server_pid>.jsonl`, drained under an `mkdir`-based advisory lock. Opt-in PreToolUse hook (`npx oxtail install-hook`) for mid-turn delivery to Claude Code.
58
59
 
59
60
  ## Deliberately deferred
package/README.md CHANGED
@@ -65,9 +65,9 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
65
65
  - `read_session` — the recent transcript of a peer session, as clean per-turn messages when the peer is oxtail-aware (Claude Code and Codex CLI), or as raw tmux pane text otherwise. Accepts a tmux session name OR a `client_session_id` UUID; an ambiguous tmux name returns `ambiguous-target` with the candidate UUIDs.
66
66
  - `claim_session` — single-shot session registration. The routine path: `Bash echo $CLAUDE_CODE_SESSION_ID` (or `$CODEX_THREAD_ID` for Codex) → `claim_session({ session_id })`. Returns `{ ok, session_id, transcript_path }`.
67
67
  - `set_my_state` — write a small "state card" onto this session's registry entry so peers can see what we're doing without reading our transcript. v1 surfaces a single field, `purpose` (≤200 chars).
68
- - `send_message` — **fire-and-forget** message to a peer. **Does NOT wake an idle peer.** Target is a tmux session name or a raw `client_session_id` UUID. Body ≤ 8KB. Delivery is async via the peer's mailbox file. (v0.5+) (`ask_peer` attempts a wake but the v0.6 implementation does not reliably rouse idle TUI peers — see its description.)
68
+ - `send_message` — **fire-and-forget** message to a peer. **Does NOT wake an idle peer.** Target is a tmux session name or a raw `client_session_id` UUID. Body ≤ 8KB. Delivery is async via the peer's mailbox file. (v0.5+)
69
69
  - `read_my_messages` — drain this session's mailbox and return any queued messages. Codex peers (and unhooked Claude Code) poll this; Claude Code peers with the PreToolUse hook installed see messages mid-turn instead. (v0.5+)
70
- - `ask_peer` — **delegate-and-wait**. Enqueues a message and blocks server-side until the peer replies (or the fixed timeout elapses, default 45s, tunable via `OXTAIL_ASK_PEER_TIMEOUT_MS`). Fires a best-effort `tmux send-keys` wake. **Known limitation (issue #3, planned v0.7):** the wake does not reliably rouse fully-idle TUI peers — see "Delegate-and-wait (v0.6)" below. Reliable when the target is already in a turn; against a fully idle peer expect timeout. Use `send_message` when you don't need a synchronous reply. (v0.6+)
70
+ - `ask_peer` — **delegate-and-wait**. Enqueues a message and blocks server-side until the peer replies (or the fixed timeout elapses, default 45s, tunable via `OXTAIL_ASK_PEER_TIMEOUT_MS`). v0.7 routes the wake per `client_type`: Codex gets a paste-burst-aware `tmux send-keys` wake that actually submits; Claude Code targets fail-fast (no wake surface for idle Claude — see "Delegate-and-wait" below). Response includes `wake_status` so the caller can distinguish "we polled and got nothing" from "this peer can't be woken." Use `send_message` for fire-and-forget. (v0.7+; v0.6 caveats below)
71
71
  - `register_my_session` — pin this MCP server's `session_id` directly. Kept for debugging; prefer `claim_session`.
72
72
  - `get_my_session` — return this MCP server's own registry entry plus a per-strategy detection diagnosis. Useful for debugging.
73
73
 
@@ -136,30 +136,59 @@ If you have a PreToolUse hook installed that isn't from Terminator and isn't oxt
136
136
 
137
137
  oxtail trusts any process running as the **same local user** to enqueue messages. The mailbox directory is mode `0o700` (private), so other users on the host cannot read or write. **On a shared-tenancy box (containers, multi-user dev hosts, etc.), do not run oxtail-aware agents:** any local process under your user can inject `<system-reminder>` content directly into a Claude session. The threat boundary is the same as `~/.ssh/` — what your user processes do, you trust.
138
138
 
139
- ## Delegate-and-wait (v0.6)
139
+ ## Delegate-and-wait (v0.7)
140
140
 
141
141
  `ask_peer` extends v0.5's mailbox transport into a blocking primitive:
142
142
 
143
143
  ```
144
144
  ask_peer({ target, body })
145
- → { ok: true, message_id, reply: { id, body, enqueued_at, from_session_id } | null, timed_out }
145
+ → {
146
+ ok: true,
147
+ message_id,
148
+ wake_status: "fired" | "skipped_unsupported" | "skipped_no_target" | "disabled",
149
+ reply: { id, body, enqueued_at, from_session_id } | null,
150
+ timed_out,
151
+ }
146
152
  ```
147
153
 
148
- > **Known limitation in v0.6 — read this before relying on `ask_peer`.** The wake mechanism does not reliably rouse a fully-idle TUI peer. Verified 2026-05-13 against Codex CLI: the `tmux send-keys ... Enter` lands in Codex's `›` composer as typed-but-not-submitted text, leaving the wake notification visible to the user but never flushing it as a turn. Idle Claude Code peers are also unwoken in practice (no polling at the prompt), though the exact mechanism is not yet root-caused the same way. `ask_peer` is therefore reliable only when the target is already in a turn (or enters one on its own via user input or another tool call's PreToolUse hook) inside the timeout window. Against a fully idle peer with no human at the keyboard, expect `timed_out: true`. Tracked in [issue #3](https://github.com/d4j3y2k/oxtail/issues/3); per-client wake strategy is scoped for v0.7.
154
+ `wake_status` distinguishes the four outcomes a caller may need to handle differently. `fired` means the wake was attempted (or the reply arrived during the grace window, so no wake was needed). `skipped_unsupported` means the target's `client_type` cannot be woken externally currently Claude Code, see "Per-client wake routing" below. `skipped_no_target` means no tmux pane/session resolved for the target. `disabled` means `OXTAIL_ASK_PEER_WAKE_STRATEGY=off` is in effect.
149
155
 
150
- Mechanics:
156
+ `timed_out` is `true` only when the poll loop ran to its deadline without a reply. Fail-fast for `skipped_unsupported` returns `timed_out: false` because no polling was attempted — the message is still enqueued and will be delivered when the peer next enters a turn.
157
+
158
+ ### Per-client wake routing
159
+
160
+ v0.7 routes the wake mechanism per `client_type`. Verified 2026-05-13 via spike investigation and falsifying experiment:
161
+
162
+ - **Codex** — `tmux send-keys -l <text>` followed by `send-keys Enter` is the wake. The keystrokes are split by 500ms because Codex's TUI has a paste-burst heuristic in `codex-rs/tui/src/bottom_pane/paste_burst.rs` (`PASTE_BURST_MIN_CHARS=3`, `PASTE_ENTER_SUPPRESS_WINDOW=120ms`) that converts Enter→newline for ~120ms after a fast typed burst. Without the gap, the wake text accumulates in the composer and Enter is suppressed. With the gap, Codex submits and enters a turn. 500ms is a deliberately generous multiple of the documented window for upstream-drift safety.
163
+
164
+ - **Claude Code** — fail-fast. Claude Code's hook surface (per [docs](https://code.claude.com/docs/en/hooks)) has no event that fires while the agent is idle: no polling hook, no external "start a turn" mechanism, `Notification` is outbound-only, `FileChanged` only fires inside an in-flight turn. An external process cannot rouse an idle Claude Code peer. ask_peer returns immediately with `wake_status: "skipped_unsupported"` rather than burning the timeout. The message is enqueued and delivered to the peer's PreToolUse hook on their next tool call (or via explicit `read_my_messages`).
165
+
166
+ - **Unknown** — legacy v0.6 wake (text + Enter, no gap). No implied promise; if a new TUI lands, treat it as unknown until verified.
167
+
168
+ ### Wake strategy override
169
+
170
+ `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` (default `auto`):
171
+
172
+ - `auto` — per-client routing above.
173
+ - `legacy` — v0.6 behavior for every client (no paste-burst gap, no per-client routing). Escape hatch if auto mode misfires.
174
+ - `off` — wake disabled entirely; ask_peer becomes a pure blocking poll. Response surfaces `wake_status: "disabled"`. Useful as a rollback if a Codex update changes the paste-burst constants and the auto-mode delay no longer covers the window.
175
+
176
+ ### Mechanics
151
177
 
152
178
  1. Enqueue `body` into the target's mailbox (same as `send_message`).
153
179
  2. Wait ~500ms for a hook-delivered reply (rare path — handles the case where the peer was already mid-tool-call and replied immediately).
154
- 3. Fire a best-effort `tmux send-keys` wake against the peer's pane (see known limitation above): a single literal line `[oxtail] new peer message — run mcp__oxtail__read_my_messages and respond via mcp__oxtail__send_message` followed by `Enter`. In a shell prompt this would flush as a command; in a TUI composer it currently lands as composer text.
180
+ 3. Route the wake via `wake_status` resolution (see above). For Claude Code, return immediately. Otherwise fire the wake.
155
181
  4. Poll the caller's mailbox at 200ms for a reply with `from_session_id == target.session_id`. Other peers' messages stay in the mailbox untouched.
156
- 5. Return the reply on match, or `{ reply: null, timed_out: true }` after the fixed timeout. Late replies fall back to the normal v0.5 hook / `read_my_messages` path — never lost, just delivered out of band.
182
+ 5. Return the reply on match, or `{ reply: null, timed_out: true, wake_status }` after the fixed timeout. Late replies fall back to the normal v0.5 hook / `read_my_messages` path — never lost, just delivered out of band.
183
+
184
+ ### Pane staleness
185
+
186
+ Pane targeting can go stale: `tmux_pane` is cached at server startup, but tmux can reuse pane ids after a pane is killed. v0.7 re-resolves the pane from the peer's `server_pid` at wake-time (via process-tree ancestry), preferring the live pane id over the cached one. If the peer is no longer in any tmux pane (orphaned), oxtail falls back to the registered tmux session name. If both targeting attempts fail, `wake_status` returns `skipped_no_target`.
157
187
 
158
- Constraints:
188
+ ### Constraints
159
189
 
160
190
  - The target peer must have a registered `client.session_id`. Codex peers must call `claim_session` / `register_my_session` first; without that, `ask_peer` returns `error: "peer-has-no-session-id"` rather than guessing.
161
191
  - Timeout defaults to 45000ms (conservative under typical MCP-client tool-call abort windows). For longer dialogues, the calling agent chains multiple `ask_peer` calls in one turn rather than configuring a longer single block.
162
- - Pane targeting can go stale: if `tmux send-keys` fails against the cached pane id (Terminator-style window churn can leave the id stale), oxtail retries against the tmux session name (which targets the currently-active pane). Reaching the right pane is a separate concern from the pane-actually-submitting-the-wake limitation above.
163
192
 
164
193
  ### Tuning the timeout
165
194
 
@@ -213,4 +242,4 @@ If `MCP_TRACE_FILE` is set in the environment, every detection run appends an ND
213
242
 
214
243
  ## Status
215
244
 
216
- v0.6.0. Adds `ask_peer` on top of v0.5's mailbox transport: an agent can send a message and block server-side until the peer replies. The implementation attempts a `tmux send-keys` wake against the peer's pane but in practice that wake does not reliably rouse fully-idle TUI peers; `ask_peer` is reliable only when the peer is already in a turn (or enters one on its own inside the timeout). When both sides are mid-turn, the existing PreToolUse hook handles mid-turn delivery cleanly. Codex peers are supported as targets once they've claimed a session. Per-client wake strategy is scoped for v0.7 (issue #3).
245
+ v0.7.0. Replaces v0.6's wake mechanism with per-client routing after a spike investigation found the root cause was Codex's paste-burst heuristic suppressing Enter, not `\r`-as-newline. Codex idle peers now wake reliably via a 500ms-gap send-keys sequence (verified live 2026-05-13). Claude Code idle peers fail-fast with `wake_status: "skipped_unsupported"` Claude Code's hook surface has no idle event so they're architecturally unwakeable from outside; the message is still enqueued and delivered next turn. ask_peer's response gains a `wake_status` field for caller diagnostics. Wake strategy is overridable via `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` as a rollback. See [issue #3](https://github.com/d4j3y2k/oxtail/issues/3) for the spike findings.
package/dist/registry.js CHANGED
@@ -116,6 +116,15 @@ export function resolveTmuxPane(env = process.env, pid = process.pid) {
116
116
  return env.TMUX_PANE;
117
117
  return findTmuxPaneByAncestry(pid, listTmuxPanePids(), listAllPpids());
118
118
  }
119
+ // Resolve the tmux pane currently hosting a given server pid by walking the
120
+ // process tree. Unlike resolveTmuxPane(), this does NOT trust env vars — it
121
+ // queries live tmux + ps state. Used by the ask_peer wake path to detect a
122
+ // stale cached tmux_pane: if a peer's pane was killed and its pane_id reused
123
+ // by an unrelated pane, the cached id no longer points at our peer. Returns
124
+ // null if the server pid is no longer in any tmux pane's process tree.
125
+ export function currentPaneForServerPid(serverPid) {
126
+ return findTmuxPaneByAncestry(serverPid, listTmuxPanePids(), listAllPpids());
127
+ }
119
128
  export function buildEntry(client, env = process.env) {
120
129
  const tmux_pane = resolveTmuxPane(env);
121
130
  return {
package/dist/server.js CHANGED
@@ -9,7 +9,7 @@ import { dirname, join, sep } from "node:path";
9
9
  import { clientFromHandshake, detectClient, enrichWithDiagnosis, transcriptPathFor, } from "./clients.js";
10
10
  import { isAbstain } from "./detect/index.js";
11
11
  import { trace } from "./trace.js";
12
- import { buildEntry, findByTmuxSession, readAll, refreshTmuxBinding, register, unregister, } from "./registry.js";
12
+ import { buildEntry, currentPaneForServerPid, findByTmuxSession, readAll, refreshTmuxBinding, register, unregister, } from "./registry.js";
13
13
  import * as mailbox from "./mailbox.js";
14
14
  // CLI subcommand dispatch must run before any MCP setup so that
15
15
  // `npx oxtail install-hook` doesn't open an MCP transport or register a
@@ -797,6 +797,32 @@ const ASK_PEER_TIMEOUT_MS = (() => {
797
797
  const ASK_PEER_GRACE_MS = 500;
798
798
  const ASK_PEER_POLL_MS = 200;
799
799
  const ASK_PEER_WAKE_TEXT = "[oxtail] new peer message — run mcp__oxtail__read_my_messages and respond via mcp__oxtail__send_message";
800
+ // Codex's TUI has a paste-burst heuristic at codex-rs/tui/src/bottom_pane/
801
+ // paste_burst.rs (PASTE_BURST_MIN_CHARS=3, PASTE_BURST_CHAR_INTERVAL=8ms,
802
+ // PASTE_ENTER_SUPPRESS_WINDOW=120ms). When `tmux send-keys` blasts the
803
+ // literal-text payload followed immediately by Enter, Codex detects the
804
+ // pattern as a paste and forcibly converts Enter→newline for ~120ms,
805
+ // suppressing the submit. Inserting a delay between the text and the Enter
806
+ // keystrokes lets the suppression window expire so Enter is treated as a
807
+ // real keypress. 500ms is a generous multiple of the documented window for
808
+ // upstream-drift safety — Codex point releases may bump the constant.
809
+ // Verified empirically 2026-05-13 against Codex (gpt-5.5 xhigh).
810
+ const ASK_PEER_CODEX_SUBMIT_DELAY_MS = 500;
811
+ // OXTAIL_ASK_PEER_WAKE_STRATEGY = "auto" | "legacy" | "off"
812
+ // auto — per-client routing: Codex gets paste-burst-aware wake (500ms gap
813
+ // between text and Enter); Claude Code is skipped (no idle hook
814
+ // surface — verified via Claude Code hook docs); unknown clients
815
+ // get legacy v0.6 behavior.
816
+ // legacy — v0.6 behavior for every client (text + Enter, no gap, no
817
+ // per-client routing). Escape hatch if auto mode misfires.
818
+ // off — wake disabled entirely; ask_peer becomes a blocking poll.
819
+ // Caller can rely solely on the peer's natural turn cadence.
820
+ const ASK_PEER_WAKE_STRATEGY = (() => {
821
+ const v = (process.env.OXTAIL_ASK_PEER_WAKE_STRATEGY ?? "auto").toLowerCase();
822
+ if (v === "auto" || v === "legacy" || v === "off")
823
+ return v;
824
+ return "auto";
825
+ })();
800
826
  function askPeerDelay(ms, signal) {
801
827
  return new Promise((resolve, reject) => {
802
828
  if (signal.aborted) {
@@ -815,42 +841,58 @@ function askPeerDelay(ms, signal) {
815
841
  signal.addEventListener("abort", onAbort, { once: true });
816
842
  });
817
843
  }
818
- // KNOWN ISSUE (tracked in #3, planned for v0.7): this wake does not actually
819
- // rouse idle TUI peers. Verified 2026-05-13 against Codex CLI — the trailing
820
- // `tmux send-keys ... Enter` lands as composer-newline rather than submit,
821
- // leaving the wake text typed-but-not-flushed in Codex's `›` composer. Idle
822
- // Claude Code peers are also unwoken in practice; the PreToolUse hook only
823
- // fires inside an existing turn, so an idle Claude at its prompt sees nothing
824
- // until the user types. ask_peer is therefore effectively async-only against
825
- // idle peers: replies arrive once the peer enters a turn on its own.
844
+ // Wake routing (v0.7). The wake's job is to nudge an idle peer into a turn so
845
+ // it drains its mailbox. Mechanics differ per client:
846
+ //
847
+ // Codex `tmux send-keys -l <text>` followed by `send-keys Enter` would
848
+ // work, EXCEPT Codex's paste-burst heuristic suppresses Enter for 120ms
849
+ // after a fast typing burst (codex-rs/tui/src/bottom_pane/paste_burst.rs).
850
+ // We insert ASK_PEER_CODEX_SUBMIT_DELAY_MS between the text and the Enter
851
+ // so the suppression window expires. Verified live 2026-05-13.
852
+ //
853
+ // Claude Code — has no hook event that fires while idle (verified via
854
+ // Claude Code's documented hook catalog at code.claude.com/docs/en/hooks;
855
+ // Notification is outbound-only; FileChanged cannot start a turn).
856
+ // No external surface can rouse an idle Claude Code peer. wakePeer()
857
+ // short-circuits with skipped_unsupported for this client_type.
858
+ //
859
+ // Unknown — legacy v0.6 behavior (text + Enter, no gap). No implied
860
+ // promise; if a new TUI lands and breaks, we treat it as unknown until
861
+ // verified.
826
862
  //
827
863
  // Two send-keys calls: the text is interpreted literally (-l) and Enter is
828
864
  // parsed as a key event. The -l flag neutralizes any tmux keysequences a
829
865
  // malicious peer could plant in its registry entry.
830
866
  //
831
- // Pane targeting can go stale: tmux_pane is cached at server startup (registry
832
- // resolveTmuxPane), but Terminator-style window churn can move or close the
833
- // pane after registration. send-keys against a dead pane id errors; if pane
834
- // targeting fails and a sessionName is also available, retry against it
835
- // (targets the session's currently-active pane).
836
- function defaultFireWakeKeystrokes(target) {
867
+ // Pane targeting can go stale: tmux_pane is cached at server startup
868
+ // (registry resolveTmuxPane), but Terminator-style window churn can move or
869
+ // close the pane after registration. send-keys against a dead pane id
870
+ // errors; if pane targeting fails and a sessionName is also available,
871
+ // retry against it (targets the session's currently-active pane).
872
+ async function defaultFireWakeKeystrokes(target, clientType) {
837
873
  execFileSync("tmux", ["send-keys", "-t", target, "-l", ASK_PEER_WAKE_TEXT], {
838
874
  stdio: ["ignore", "pipe", "pipe"],
839
875
  });
876
+ if (clientType === "codex") {
877
+ await new Promise((resolve) => {
878
+ const timer = setTimeout(resolve, ASK_PEER_CODEX_SUBMIT_DELAY_MS);
879
+ timer.unref?.();
880
+ });
881
+ }
840
882
  execFileSync("tmux", ["send-keys", "-t", target, "Enter"], {
841
883
  stdio: ["ignore", "pipe", "pipe"],
842
884
  });
843
885
  }
844
- // Exported for unit testing the retry path; production callers use askPeerWake
845
- // which wires defaultFireWakeKeystrokes.
846
- export function askPeerWakeImpl(pane, sessionName, fire) {
886
+ // Exported for unit testing the retry path; production callers use wakePeer
887
+ // which wires defaultFireWakeKeystrokes via routing.
888
+ export async function askPeerWakeImpl(pane, sessionName, fire) {
847
889
  if (!pane && !sessionName) {
848
890
  trace("ask_peer_wake_skipped", { reason: "no-pane-or-session" });
849
891
  return false;
850
892
  }
851
893
  const primary = pane ?? sessionName;
852
894
  try {
853
- fire(primary);
895
+ await fire(primary);
854
896
  trace("ask_peer_wake_fired", { target: primary });
855
897
  return true;
856
898
  }
@@ -859,7 +901,7 @@ export function askPeerWakeImpl(pane, sessionName, fire) {
859
901
  }
860
902
  if (pane && sessionName && pane !== sessionName) {
861
903
  try {
862
- fire(sessionName);
904
+ await fire(sessionName);
863
905
  trace("ask_peer_wake_fired_retry", { target: sessionName });
864
906
  return true;
865
907
  }
@@ -869,8 +911,55 @@ export function askPeerWakeImpl(pane, sessionName, fire) {
869
911
  }
870
912
  return false;
871
913
  }
872
- function askPeerWake(pane, sessionName) {
873
- return askPeerWakeImpl(pane, sessionName, defaultFireWakeKeystrokes);
914
+ // Route a wake to a peer based on OXTAIL_ASK_PEER_WAKE_STRATEGY and the
915
+ // peer's client_type. Returns the wake_status that should surface in the
916
+ // ask_peer response so callers can distinguish "we tried, no answer" from
917
+ // "we didn't try because the client can't be woken."
918
+ async function wakePeer(peer) {
919
+ if (ASK_PEER_WAKE_STRATEGY === "off") {
920
+ trace("ask_peer_wake_skipped", { reason: "strategy-off" });
921
+ return "disabled";
922
+ }
923
+ const clientType = peer.client.type;
924
+ if (ASK_PEER_WAKE_STRATEGY === "auto" && clientType === "claude-code") {
925
+ trace("ask_peer_wake_skipped", { reason: "client-unsupported", client_type: clientType });
926
+ return "skipped_unsupported";
927
+ }
928
+ if (!peer.tmux_pane && !peer.tmux_session) {
929
+ return "skipped_no_target";
930
+ }
931
+ // Race-fix: tmux_pane is cached at registration but pane ids can be reused
932
+ // by tmux after a pane is killed. If we send-keys against a reused id we
933
+ // wake the wrong shell. When the peer registered WITH a cached pane,
934
+ // re-resolve from its server_pid at wake-time and prefer the live value.
935
+ // If the peer registered without a pane (no TMUX_PANE in env, no ancestry
936
+ // match), skip the re-resolution entirely — fishing for a pane based on
937
+ // server_pid alone is unsafe (server_pid may not even still be alive, and
938
+ // in tests it can coincide with the test runner's process tree).
939
+ const livePane = peer.tmux_pane
940
+ ? currentPaneForServerPid(peer.server_pid)
941
+ : null;
942
+ if (peer.tmux_pane && livePane && livePane !== peer.tmux_pane) {
943
+ trace("ask_peer_wake_pane_refreshed", {
944
+ cached: peer.tmux_pane,
945
+ live: livePane,
946
+ server_pid: peer.server_pid,
947
+ });
948
+ }
949
+ else if (peer.tmux_pane && !livePane) {
950
+ trace("ask_peer_wake_pane_orphaned", {
951
+ cached: peer.tmux_pane,
952
+ server_pid: peer.server_pid,
953
+ });
954
+ }
955
+ const effectivePane = livePane ?? peer.tmux_pane;
956
+ // Legacy mode bypasses per-client routing: every wake is the v0.6 sequence
957
+ // (no inter-keystroke delay). Cast to "unknown" so defaultFireWakeKeystrokes
958
+ // skips the Codex delay branch.
959
+ const fireType = ASK_PEER_WAKE_STRATEGY === "legacy" ? "unknown" : clientType;
960
+ const fire = (target) => defaultFireWakeKeystrokes(target, fireType);
961
+ const ok = await askPeerWakeImpl(effectivePane, peer.tmux_session, fire);
962
+ return ok ? "fired" : "skipped_no_target";
874
963
  }
875
964
  // Poll my mailbox at ASK_PEER_POLL_MS until a matching reply lands or the
876
965
  // deadline elapses. Each tick checks mtime first and only acquires the
@@ -907,9 +996,11 @@ server.registerTool("ask_peer", {
907
996
  description: [
908
997
  "Enqueue a message to a peer and block until they reply (or timeout).",
909
998
  "Use this when you want a back-and-forth with another agent in the same project root, rather than fire-and-forget like send_message.",
910
- "KNOWN LIMITATION (v0.6, tracked in issue #3, planned v0.7): the tmux send-keys wake does NOT reliably rouse idle TUI peers. Codex composer pollution verified 2026-05-13 wake text lands as typed-but-not-submitted input. Idle Claude Code peers are also unwoken in practice. ask_peer reliably returns a reply only if the target enters a turn on its own (user types into it, or another tool call fires its PreToolUse hook) within the timeout. Against a fully idle peer with no human at the keyboard, expect timeout.",
911
- "Behavior: enqueues the body to the target's mailbox, waits ~500ms for a hook-delivered reply, fires a best-effort tmux send-keys wake (see limitation above), then polls this session's mailbox at 200ms for a reply from the target.",
912
- "Returns when the target sends a message back (via send_message) whose from_session_id matches them, or when the timeout elapses (returns reply: null, timed_out: true). Timeout defaults to 45000ms; user-tunable via OXTAIL_ASK_PEER_TIMEOUT_MS env var.",
999
+ "Wake behavior (v0.7) varies per client_type. Codex peers are woken via paste-burst-aware tmux send-keys (literal text + 500ms gap + Enter) so the composer submits. Claude Code peers cannot be woken externally Claude Code's hook surface has no idle event (verified against the documented hook catalog), so ask_peer fails fast for Claude Code targets and returns wake_status: \"skipped_unsupported\" rather than burning the timeout. Unknown clients use legacy send-keys wake.",
1000
+ "Response includes a wake_status field: \"fired\" (wake attempted or reply received during grace window), \"skipped_unsupported\" (target client cannot be woken fail-fast, no poll), \"skipped_no_target\" (no tmux pane or session resolved for target), \"disabled\" (OXTAIL_ASK_PEER_WAKE_STRATEGY=off).",
1001
+ "Behavior: enqueues the body to the target's mailbox, waits ~500ms for a hook-delivered reply (rare: peer was mid-turn, hook delivered as additionalContext), fires the per-client wake, then polls this session's mailbox at 200ms for a reply from the target. Fail-fast for skipped_unsupported skips polling entirely; the message is still enqueued and will be delivered the next time the peer enters a turn.",
1002
+ "Returns when the target sends a message back (via send_message) whose from_session_id matches them, or when the timeout elapses (returns reply: null, timed_out: true). timed_out is false on fail-fast (we didn't actually poll). Timeout defaults to 45000ms; user-tunable via OXTAIL_ASK_PEER_TIMEOUT_MS env var.",
1003
+ "Wake strategy can be overridden via OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off (default auto). legacy = v0.6 behavior for every client (no gap, no per-client routing). off = no wake fired; ask_peer becomes a pure blocking poll until the peer naturally enters a turn or timeout.",
913
1004
  "Target must have a registered client.session_id (Codex peers must call register_my_session first).",
914
1005
  "Late replies that arrive after timeout are delivered normally via read_my_messages / the PreToolUse hook.",
915
1006
  "Body framing: peers see the body verbatim. Include a short assignment-style framing (objective, what you want them to do) so they treat it as a delegation, not chat.",
@@ -984,6 +1075,7 @@ server.registerTool("ask_peer", {
984
1075
  });
985
1076
  let reply = null;
986
1077
  let aborted = false;
1078
+ let wakeStatus = "skipped_no_target";
987
1079
  try {
988
1080
  // Grace window: rare hook-delivery path. If peer was mid-tool-call when
989
1081
  // our outbound arrived, their hook delivered it as additionalContext and
@@ -991,9 +1083,24 @@ server.registerTool("ask_peer", {
991
1083
  await askPeerDelay(ASK_PEER_GRACE_MS, extra.signal);
992
1084
  reply = mailbox.drainMatchingSession(entry.server_pid, expectedSessionId);
993
1085
  if (!reply) {
994
- // Common path: peer was idle; fire wake + poll.
995
- askPeerWake(peer.tmux_pane, peer.tmux_session);
996
- reply = await askPeerPoll(entry.server_pid, expectedSessionId, deadlineMs, extra.signal);
1086
+ // Common path: peer was idle. Route the wake per client_type.
1087
+ wakeStatus = await wakePeer(peer);
1088
+ if (wakeStatus === "skipped_unsupported") {
1089
+ // Claude Code idle has no external wake surface — polling would just
1090
+ // burn the caller's wall-clock budget for no reason. Return fast so
1091
+ // the caller can fall back to send_message + read_my_messages, or
1092
+ // wait until the peer is observed mid-turn via list_project_sessions.
1093
+ // The outbound has been enqueued; it'll be delivered next time the
1094
+ // peer enters a turn (via PreToolUse hook or explicit read_my_messages).
1095
+ }
1096
+ else {
1097
+ reply = await askPeerPoll(entry.server_pid, expectedSessionId, deadlineMs, extra.signal);
1098
+ }
1099
+ }
1100
+ else {
1101
+ // Reply arrived during grace window — peer was already mid-turn and
1102
+ // the hook delivered the outbound to it as additionalContext.
1103
+ wakeStatus = "fired";
997
1104
  }
998
1105
  }
999
1106
  catch (e) {
@@ -1021,11 +1128,18 @@ server.registerTool("ask_peer", {
1021
1128
  // Throw to signal the framework that the request did not complete.
1022
1129
  throw new Error("ask_peer aborted by client");
1023
1130
  }
1131
+ // timed_out is reserved for "we waited and got nothing" — i.e. we actually
1132
+ // polled to the deadline. A fail-fast for an unwakeable client (no poll
1133
+ // attempted) is NOT a timeout; the message has been enqueued and will be
1134
+ // delivered when the peer next enters a turn.
1135
+ const polled = wakeStatus !== "skipped_unsupported";
1136
+ const timedOut = polled && reply === null;
1024
1137
  trace("ask_peer_end", {
1025
1138
  target_session_id: expectedSessionId,
1026
1139
  message_id: msg.id,
1027
1140
  duration_ms: Date.now() - startedAt,
1028
- timed_out: reply === null,
1141
+ wake_status: wakeStatus,
1142
+ timed_out: timedOut,
1029
1143
  });
1030
1144
  return {
1031
1145
  content: [
@@ -1035,6 +1149,7 @@ server.registerTool("ask_peer", {
1035
1149
  schema_version: 1,
1036
1150
  ok: true,
1037
1151
  message_id: msg.id,
1152
+ wake_status: wakeStatus,
1038
1153
  reply: reply
1039
1154
  ? {
1040
1155
  id: reply.id,
@@ -1043,7 +1158,7 @@ server.registerTool("ask_peer", {
1043
1158
  from_session_id: reply.from_session_id ?? null,
1044
1159
  }
1045
1160
  : null,
1046
- timed_out: reply === null,
1161
+ timed_out: timedOut,
1047
1162
  }, null, 2),
1048
1163
  },
1049
1164
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxtail",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Coordination layer for parallel AI coding agent sessions, exposed over MCP.",