oxtail 0.7.1 → 0.9.1

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.7.0 shipped, dogfooding
20
+ ## Status: v0.8.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 `ask_peer` (delegate-and-wait, introduced v0.6, per-client wake routing in v0.7). 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
 
@@ -25,13 +25,13 @@ The v0.4.0 change: peer `client_session_id` and `transcript_path` now resolve re
25
25
 
26
26
  The follow-on additions (`claim_session`, `set_my_state`) introduce a peer-awareness layer: `list_project_sessions` now surfaces each peer's `state` card so an agent can learn what its peers are doing without paying for `read_session`. Raw transcripts become the deep-dive fallback, not the default mode of peer awareness.
27
27
 
28
- Current phase remains **dogfooding**: use the tools in real parallel-agent work, log friction in `NOTES.md`. Each version (v0.1 list_project_sessions → v0.2 read_session → v0.3 reliable peer identity → v0.4 peer-awareness state cards → v0.5 peer-to-peer messaging → v0.6 delegate-and-wait → v0.7 per-client wake routing) shipped only after observed friction named the next addition; the same gating applies to whatever comes next.
28
+ Current phase remains **dogfooding**: use the tools in real parallel-agent work, log friction in `NOTES.md`. Each version (v0.1 list_project_sessions → v0.2 read_session → v0.3 reliable peer identity → v0.4 peer-awareness state cards → v0.5 peer-to-peer messaging → v0.6 delegate-and-wait → v0.7 per-client wake routing → v0.8 symmetric Claude Code wake) shipped only after observed friction named the next addition; the same gating applies to whatever comes next.
29
29
 
30
30
  The v0.5 change: two new MCP tools (`send_message`, `read_my_messages`) plus an opt-in `PreToolUse` hook installable via `npx oxtail install-hook`. Friction observed while pairing on Terminator — two agents in the same project root can see each other's state cards and transcripts but couldn't say anything to each other. Now they can. Claude Code peers see messages mid-turn (via the hook); Codex peers (or unhooked Claude Code) see them next-turn (via polling `read_my_messages`).
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
- 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.
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 Codex's paste-burst heuristic (`codex-rs/tui/src/bottom_pane/paste_burst.rs`) was 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 for Codex peers. Verified live 2026-05-13 against the live `oxtail-codex` peer in this repo. v0.7 also fail-fasted Claude Code targets with `wake_status: "skipped_unsupported"` based on a reading of the Claude Code hook catalog (no idle hook surface → "architecturally unwakeable") but that reasoning conflated *hook events* (which Claude Code doesn't expose for idle) with *TUI input* (which works fine via `tmux send-keys`, the same mechanism that wakes Codex). A falsifying experiment 2026-05-13 against the live `oxtail-claudejr` peer confirmed the full round-trip works: ask_peer enqueue → manual send-keys → peer entered a turn → PreToolUse hook drained mailbox → peer replied via send_message. The fail-fast was a self-inflicted regression against oxtail's symmetric-matrix vision (Claude↔Claude, Claude↔Codex, both directions), so the short-circuit was removed in the follow-up. Claude Code peers now wake via the same send-keys mechanism, just without the Codex paste-burst gap. 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,7 @@ The v0.7 change: per-client wake routing after the v0.6 wake was found to be bro
53
53
 
54
54
  ## Recently shipped
55
55
 
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.
56
+ - **Per-client wake routing (v0.7, refined).** `ask_peer` routes its wake mechanism per `client_type`. **Codex**: paste-burst-aware send-keys (500ms gap between text and Enter) — verified to submit. **Claude Code**: same send-keys mechanism without the gap (no paste-burst in its TUI) — verified end-to-end 2026-05-13 against `oxtail-claudejr`. v0.7 originally fail-fasted Claude Code targets under a hook-catalog argument; the follow-up restored symmetric wake after falsifying that conclusion empirically. Response includes 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
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`.
58
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.
59
59
 
package/README.md CHANGED
@@ -21,7 +21,7 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
21
21
  **Claude Code** — add to `~/.claude.json` (global) or any project's `.mcp.json`:
22
22
 
23
23
  ```jsonc
24
- { "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.7.1"] } } }
24
+ { "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.9.1"] } } }
25
25
  ```
26
26
 
27
27
  **Codex CLI** — add to `~/.codex/config.toml`:
@@ -29,14 +29,14 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
29
29
  ```toml
30
30
  [mcp_servers.oxtail]
31
31
  command = "npx"
32
- args = ["-y", "oxtail@0.7.1"]
32
+ args = ["-y", "oxtail@0.9.1"]
33
33
  ```
34
34
 
35
35
  **Claude slash command** (`/oxtail-join`):
36
36
 
37
37
  ```sh
38
38
  mkdir -p ~/.claude/commands
39
- curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.7.1/.claude/commands/oxtail-join.md \
39
+ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.9.1/.claude/commands/oxtail-join.md \
40
40
  -o ~/.claude/commands/oxtail-join.md
41
41
  ```
42
42
 
@@ -44,9 +44,9 @@ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.7.1/.claude/commands
44
44
 
45
45
  ```sh
46
46
  mkdir -p ~/.codex/skills/oxtail-join/agents
47
- curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.7.1/integrations/codex/oxtail-join/SKILL.md \
47
+ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.9.1/integrations/codex/oxtail-join/SKILL.md \
48
48
  -o ~/.codex/skills/oxtail-join/SKILL.md
49
- curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.7.1/integrations/codex/oxtail-join/agents/openai.yaml \
49
+ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.9.1/integrations/codex/oxtail-join/agents/openai.yaml \
50
50
  -o ~/.codex/skills/oxtail-join/agents/openai.yaml
51
51
  ```
52
52
 
@@ -65,13 +65,13 @@ 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+)
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`). 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)
68
+ - `send_message` — **fire-and-forget** message to a 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. By default does **not** wake an idle peer; pass `wake: "auto"` to nudge one (state-gated — see [Waking an idle peer](#waking-an-idle-peer)). (v0.5+)
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 hooks installed see messages mid-turn (PreToolUse) or at turn end (Stop) 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`). Routes the wake per `client_type`: Codex gets a paste-burst-aware `tmux send-keys` wake (500ms gap before Enter to defeat the paste-burst heuristic); Claude Code gets the same send-keys mechanism without the gap (its TUI has no paste-burst). Response includes `wake_status` so the caller can distinguish "we polled and got nothing" from "no tmux pane resolved." Use `send_message` for fire-and-forget. (v0.7+)
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
 
74
- See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.5.0/AGENTS.md) for scope and architecture.
74
+ See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.9.1/AGENTS.md) for scope and architecture.
75
75
 
76
76
  ## Usage from an agent
77
77
 
@@ -114,23 +114,42 @@ Cross-project sends are rejected, never silently dropped. Sending to a peer with
114
114
 
115
115
  ### Mid-turn vs next-turn delivery (the asymmetry)
116
116
 
117
- Claude Code peers can receive messages **mid-turn** via an opt-in PreToolUse hook:
117
+ Claude Code peers can receive messages **autonomously** via three opt-in hooks:
118
118
 
119
119
  ```sh
120
120
  npx oxtail install-hook
121
121
  ```
122
122
 
123
- This drops a small bash script at `~/.oxtail/hooks/pretooluse.sh` and adds a `hooks.PreToolUse` entry in `~/.claude/settings.json`. The hook reads each `PreToolUse` event's `session_id` from stdin, locates the matching mailbox, and emits `additionalContext` into the next tool-call boundary. Reverse with `npx oxtail uninstall-hook`.
123
+ This installs three small bash scripts under `~/.oxtail/hooks/` and adds matching entries to `~/.claude/settings.json` (tracked by a `_oxtailHook` marker). Reverse with `npx oxtail uninstall-hook`:
124
124
 
125
- Codex CLI peers and any Claude Code session without the hook installed receive messages **next-turn** by calling `read_my_messages` explicitly. Both clients send messages identically. The asymmetry exists because Claude Code exposes a PreToolUse hook surface that injects `additionalContext`; Codex CLI does not currently expose an equivalent.
125
+ - **`hooks.PreToolUse`** `pretooluse.sh` delivers **mid-turn**. It reads each `PreToolUse` event's `session_id` from stdin, locates the matching mailbox, and emits the queued messages as `additionalContext` on the next tool-call boundary.
126
+ - **`hooks.Stop`** → `stop.sh` — delivers **at turn end** (deliver-on-complete). When the agent finishes a turn with messages still waiting, it emits a `decision: "block"` envelope so the agent continues and reads + responds before going idle, instead of leaving the messages until the next turn.
127
+ - **`hooks.UserPromptSubmit`** → `userpromptsubmit.sh` — no delivery; it maintains a **busy/idle activity flag** in `~/.oxtail/activity/<session_id>` (busy on a turn start, idle on a real Stop). A sender consults this so `send_message({ wake: "auto" })` only fires a send-keys wake when the peer is actually idle (see [Waking an idle peer](#waking-an-idle-peer)).
126
128
 
127
- **Caveat for Claude Code receivers:** PreToolUse fires only before a tool call. A turn that produces only text no tool calls — never triggers the hook; messages enqueued during that turn surface on the next tool call (or via an explicit `read_my_messages`). For pair-debugging UX, senders should not assume mid-turn delivery is universal.
129
+ The PreToolUse and Stop hooks include the message body plus `message_id` and `from_session_id` metadata when the sender is registered, so a receiver can reply with `send_message({ target: "<from_session_id>", body: "..." })` even when the sender is not visible in `list_project_sessions`.
130
+
131
+ Codex CLI peers and any Claude Code session without the hooks installed receive messages **next-turn** by calling `read_my_messages` explicitly. Both clients send messages identically. The asymmetry exists because Claude Code exposes PreToolUse/Stop/UserPromptSubmit hook surfaces that inject context or fire on lifecycle events; Codex CLI does not currently expose an equivalent.
132
+
133
+ **Coverage and its edges.** PreToolUse fires only before a tool call, so a turn that produces only text — no tool calls — never triggers it; the Stop hook closes that gap by delivering at turn end. One deliberate edge remains: the Stop hook honors the `stop_hook_active` flag and exits without blocking on a re-entry, so `decision: "block"` can never loop — which means a message that arrives *during* a Stop-blocked continuation waits for the next turn rather than extending the current one. A truly idle peer (no turn in flight) is reached by `send_message({ wake: "auto" })` or `ask_peer` (both fire an external wake), or by an explicit `read_my_messages`.
134
+
135
+ ### Waking an idle peer
136
+
137
+ `send_message` is fire-and-forget by default. Pass `wake: "auto"` to also nudge an **idle** peer into a turn so it drains its mailbox promptly:
138
+
139
+ ```js
140
+ send_message({ target: "<peer>", body: "...", wake: "auto" })
141
+ // → { ok: true, message_id, ..., wake_status: "fired" | "skipped_busy" | "skipped_no_target" | "disabled" }
142
+ ```
143
+
144
+ It is **state-gated** off the activity flag above: if the peer is mid-turn (`busy`), the wake is skipped (`skipped_busy`) because its PreToolUse/Stop hooks will deliver during the turn — no point typing into a busy composer. Idle, unknown (hooks not installed), or stale-busy peers get a per-client `tmux send-keys` wake (Codex gets the paste-burst-aware gap; Claude Code does not). `wake: "off"` (the default) preserves the pure fire-and-forget contract.
145
+
146
+ **Codex and the wake matrix.** The send-keys wake needs a tmux pane. A Codex peer running **outside tmux** has none, so it returns `wake_status: "skipped_no_target"` — its idle delivery stays poll-based (`read_my_messages`). Run Codex **inside a tmux pane** to get symmetric idle-wake; the routing already handles the Codex paste-burst case.
128
147
 
129
148
  ### Hook coexistence
130
149
 
131
- The oxtail hook coexists with other `hooks.PreToolUse` entries. **Verified against Terminator's `_terminatorHook` v1 in Claude Code 2.1.139:** both hooks' `additionalContext` envelopes reached the model. Install order: Terminator first, oxtail second — `install-hook.mjs` appends to a non-empty array, which matches the verified configuration. If you reinstall hooks in a different order, you may need to re-test.
150
+ `install-hook` manages three events (`PreToolUse`, `Stop`, `UserPromptSubmit`); on each it replaces any prior oxtail entry in place and otherwise appends, so existing third-party entries are preserved. **The PreToolUse path is verified against Terminator's `_terminatorHook` v1 in Claude Code 2.1.139:** both hooks' `additionalContext` envelopes reached the model (install order: Terminator first, oxtail second — which is what `install-hook.mjs` produces by appending). Coexistence of the Stop and UserPromptSubmit hooks with third-party entries on those events uses the same append logic but is not separately verified.
132
151
 
133
- If you have a PreToolUse hook installed that isn't from Terminator and isn't oxtail, `install-hook` prints a one-line note and proceeds — coexistence behavior with arbitrary third-party hooks is not pre-verified.
152
+ If you have a hook installed on a managed event that isn't from Terminator and isn't oxtail, `install-hook` prints a one-line note and proceeds — coexistence behavior with arbitrary third-party hooks is not pre-verified.
134
153
 
135
154
  ### Trust model
136
155
 
@@ -151,17 +170,17 @@ ask_peer({ target, body })
151
170
  }
152
171
  ```
153
172
 
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.
173
+ `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` is reserved no client currently returns this in auto mode (both Codex and Claude Code wake via send-keys). `skipped_no_target` means no tmux pane/session resolved for the target. `disabled` means `OXTAIL_ASK_PEER_WAKE_STRATEGY=off` is in effect.
155
174
 
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.
175
+ `timed_out` is `true` only when the poll loop ran to its deadline without a reply.
157
176
 
158
177
  ### Per-client wake routing
159
178
 
160
- v0.7 routes the wake mechanism per `client_type`. Verified 2026-05-13 via spike investigation and falsifying experiment:
179
+ `ask_peer` routes the wake mechanism per `client_type`. Verified 2026-05-13 via spike investigations and end-to-end falsifying experiments against the live `oxtail-codex` and `oxtail-claudejr` peers in this repo:
161
180
 
162
181
  - **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
182
 
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`).
183
+ - **Claude Code** — `tmux send-keys -l <text>` + immediate `send-keys Enter`, no inter-keystroke gap. The Claude Code TUI has no paste-burst suppression, so back-to-back text+Enter submits cleanly. Once the peer is in a turn, the oxtail PreToolUse hook drains queued messages as `additionalContext` on the peer's first tool call (or the peer reads them explicitly via `read_my_messages`). v0.7 originally shipped a fail-fast here, reasoning from the [hook catalog](https://code.claude.com/docs/en/hooks) that "no idle hook" meant "unwakeable" but send-keys is a TUI-input mechanism, not a hook event, and it submits the same way a human keypress would. The fail-fast was a self-inflicted gap against oxtail's symmetric-matrix vision (Claude↔Claude, Claude↔Codex, both directions); restored to symmetric wake in the v0.7 follow-up after an end-to-end falsifying experiment confirmed the full round-trip works.
165
184
 
166
185
  - **Unknown** — legacy v0.6 wake (text + Enter, no gap). No implied promise; if a new TUI lands, treat it as unknown until verified.
167
186
 
@@ -177,7 +196,7 @@ v0.7 routes the wake mechanism per `client_type`. Verified 2026-05-13 via spike
177
196
 
178
197
  1. Enqueue `body` into the target's mailbox (same as `send_message`).
179
198
  2. Wait ~500ms for a hook-delivered reply (rare path — handles the case where the peer was already mid-tool-call and replied immediately).
180
- 3. Route the wake via `wake_status` resolution (see above). For Claude Code, return immediately. Otherwise fire the wake.
199
+ 3. Route and fire the wake via `wake_status` resolution (see above).
181
200
  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.
182
201
  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
202
 
@@ -195,7 +214,7 @@ Pane targeting can go stale: `tmux_pane` is cached at server startup, but tmux c
195
214
  If `ask_peer` returns an abort error before its built-in 45s timeout fires, your MCP client's tool-call ceiling is lower than 45s. Override the bound at server startup:
196
215
 
197
216
  ```sh
198
- OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.7.1
217
+ OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.9.1
199
218
  ```
200
219
 
201
220
  The server reads the env var once at boot and uses it as the fixed timeout for all `ask_peer` calls in that session. Values must be positive numbers; anything else falls back to the 45000ms default.
@@ -242,4 +261,11 @@ If `MCP_TRACE_FILE` is set in the environment, every detection run appends an ND
242
261
 
243
262
  ## Status
244
263
 
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.
264
+ v0.9.0. Completes the autonomous peer-messaging matrix: a message reaches a Claude Code peer whether it's mid-turn, finishing, or fully idle in both directions, with no human relay.
265
+
266
+ - **Deliver-on-complete (Stop hook).** PreToolUse only fires before a tool call, so a text-only turn never triggered it. The new `Stop` hook closes that gap: a message that lands as the agent finishes a turn blocks the stop and is read + answered before it goes idle. Loop-safe via `stop_hook_active`.
267
+ - **State-gated idle wake.** `send_message({ wake: "auto" })` nudges an idle peer via per-client `tmux send-keys`, gated off a busy/idle activity flag maintained by the `UserPromptSubmit`/`Stop` hooks — so it never types into a peer that's mid-turn. Returns `wake_status: fired | skipped_busy | skipped_no_target | disabled`. A Codex peer must be inside a tmux pane to be idle-woken (otherwise `skipped_no_target`, and delivery stays poll-based).
268
+ - **Sticky Codex claim.** A restarted Codex MCP child — whose `CODEX_THREAD_ID` is stripped from its subprocess env — recovers its `session_id` from a persisted claim keyed by client type + cwd + a bounded process-ancestor chain, so identity survives an MCP restart without a manual re-claim.
269
+ - **Identity hardening.** Hooks and activity key on `client.session_id` (never `server_pid`), so a dual-scope agent's sibling MCP children act as one identity; delivery hooks drain all sibling mailboxes; nested git repos are treated as separate projects for scope matching.
270
+
271
+ Builds on v0.7's per-client wake routing (verified live 2026-05-13): Codex peers wake via a 500ms-gap send-keys sequence that defeats their TUI's paste-burst heuristic; Claude Code peers wake via the same mechanism without the gap (no paste-burst in its TUI). `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` remains as a rollback. See [issue #3](https://github.com/d4j3y2k/oxtail/issues/3) for the v0.7 spike findings.
@@ -1,19 +1,21 @@
1
1
  #!/usr/bin/env bash
2
2
  # oxtail PreToolUse hook — delivers peer messages mid-turn to Claude Code.
3
3
  #
4
- # Reads ~/.oxtail/mailboxes/<my-server-pid>.jsonl, emits a hookSpecificOutput
5
- # envelope, and truncates the mailbox under lock. Pure bash + awk; no jq,
6
- # python, or node. Exits 0 on every error path so it never blocks a tool call.
4
+ # Reads this session's mailbox(es), emits a hookSpecificOutput envelope, and
5
+ # truncates under lock. Pure bash + awk (bash 3.2-compatible — no mapfile); no
6
+ # jq, python, or node. Exits 0 on every error path so it never blocks a tool call.
7
+ #
8
+ # Identity is keyed by session_id, never server_pid (see AGENTS.md). A dual-scope
9
+ # agent runs several MCP children sharing one session_id; the session's inbox is
10
+ # the UNION of those children's mailboxes, so this drains ALL of them rather than
11
+ # guessing one (the send side enqueues to readAll()'s freshest sibling).
7
12
  #
8
13
  # Step 0a verified that Claude Code strips CLAUDE_CODE_SESSION_ID from hook
9
- # subprocesses but delivers it via stdin JSON. Stdin is the only path; env
10
- # is dead code and not consulted here.
14
+ # subprocesses but delivers it via stdin JSON. Stdin is the only path.
11
15
 
12
16
  set -u
13
17
 
14
- # 1. Read session_id from stdin JSON. Claude Code's PreToolUse contract
15
- # delivers a single JSON line on stdin: {"session_id":"...", ...}. If
16
- # stdin is a tty (interactive run), exit silently.
18
+ # 1. Read session_id from stdin JSON. If stdin is a tty (interactive run), exit.
17
19
  sid=""
18
20
  if [ ! -t 0 ]; then
19
21
  payload=$(cat 2>/dev/null || true)
@@ -45,46 +47,45 @@ mailboxes_dir="$HOME/.oxtail/mailboxes"
45
47
  [ -d "$sessions_dir" ] || exit 0
46
48
  [ -d "$mailboxes_dir" ] || exit 0
47
49
 
48
- # 2. Find this session's MCP-server pid. Registry files are pretty-printed
49
- # JSON (key/value separated by ": " with a space), so use grep -E with
50
- # [[:space:]]* to tolerate either form. -F (fixed-string) is unsafe.
51
- entry_file=$(grep -lE "\"session_id\"[[:space:]]*:[[:space:]]*\"$sid\"" "$sessions_dir"/*.json 2>/dev/null | head -n 1) || true
52
- [ -z "$entry_file" ] && exit 0
53
-
54
- pid=$(basename "$entry_file" .json)
55
- case "$pid" in *[!0-9]*) exit 0 ;; esac
50
+ # 2. Collect every non-empty sibling mailbox for this session_id. Registry files
51
+ # are pretty-printed JSON, so grep -E with [[:space:]]* tolerates the form.
52
+ mboxes=()
53
+ while IFS= read -r f; do
54
+ [ -z "$f" ] && continue
55
+ pid=$(basename "$f" .json)
56
+ case "$pid" in *[!0-9]*) continue ;; esac
57
+ m="$mailboxes_dir/$pid.jsonl"
58
+ if [ -f "$m" ] && [ -s "$m" ]; then mboxes+=("$m"); fi
59
+ done < <(grep -lE "\"session_id\"[[:space:]]*:[[:space:]]*\"$sid\"" "$sessions_dir"/*.json 2>/dev/null)
56
60
 
57
- mbox="$mailboxes_dir/$pid.jsonl"
58
- [ -f "$mbox" ] || exit 0
59
- [ -s "$mbox" ] || exit 0
61
+ [ "${#mboxes[@]}" -eq 0 ] && exit 0
60
62
 
61
- # 3. Acquire mkdir-based lock. Staleness window is 30s; matches
62
- # src/mailbox.ts:LOCK_STALE_MS. We can't use `find -mmin +0.5` portably —
63
- # BSD find and `bfs` reject fractional -mmin — so we read mtime via stat.
64
- # GNU and BSD stat formats differ, so try both.
65
- LOCK_STALE_SECS=30
66
- acquired=0
67
- for i in $(seq 1 50); do
68
- if mkdir "$mbox.lock" 2>/dev/null; then acquired=1; break; fi
69
- now=$(date +%s 2>/dev/null || echo 0)
70
- mtime=$(stat -c %Y "$mbox.lock" 2>/dev/null || stat -f %m "$mbox.lock" 2>/dev/null || echo 0)
71
- if [ "$mtime" -gt 0 ] && [ $((now - mtime)) -gt "$LOCK_STALE_SECS" ]; then
72
- rmdir "$mbox.lock" 2>/dev/null
73
- fi
74
- sleep 0.01
63
+ # 3. Acquire each mailbox's mkdir-based lock (best-effort; 30s staleness window,
64
+ # matching src/mailbox.ts:LOCK_STALE_MS). GNU and BSD stat formats differ.
65
+ locked=()
66
+ for m in "${mboxes[@]}"; do
67
+ for i in $(seq 1 50); do
68
+ if mkdir "$m.lock" 2>/dev/null; then locked+=("$m"); break; fi
69
+ now=$(date +%s 2>/dev/null || echo 0)
70
+ mtime=$(stat -c %Y "$m.lock" 2>/dev/null || stat -f %m "$m.lock" 2>/dev/null || echo 0)
71
+ if [ "$mtime" -gt 0 ] && [ $((now - mtime)) -gt 30 ]; then
72
+ rmdir "$m.lock" 2>/dev/null
73
+ fi
74
+ sleep 0.01
75
+ done
75
76
  done
76
- [ "$acquired" -eq 1 ] || exit 0
77
+ [ "${#locked[@]}" -eq 0 ] && exit 0
77
78
 
78
- # 4. Extract every line's body field (still JSON-encoded), join with literal
79
- # \n\n separators, emit hookSpecificOutput envelope. Truncating happens
80
- # after the awk completes; if awk's output never reaches Claude Code we'd
81
- # rather have the messages still in the box than lost.
79
+ # 4. Extract every line's body + reply metadata across all locked mailboxes,
80
+ # join into one system-reminder envelope. Truncation happens only after awk
81
+ # produces a valid payload if the output never reaches Claude Code we'd
82
+ # rather leave the messages in the box than lose them.
82
83
  output=$(awk '
83
- BEGIN { count = 0 }
84
- {
85
- p = index($0, "\"body\":\"")
86
- if (p == 0) next
87
- rest = substr($0, p + 8)
84
+ function json_string_field(line, key, needle, p, rest, out, i, n, c) {
85
+ needle = "\"" key "\":\""
86
+ p = index(line, needle)
87
+ if (p == 0) return ""
88
+ rest = substr(line, p + length(needle))
88
89
  out = ""
89
90
  i = 1; n = length(rest)
90
91
  while (i <= n) {
@@ -98,23 +99,40 @@ output=$(awk '
98
99
  i += 1
99
100
  }
100
101
  }
101
- bodies[count++] = out
102
+ return out
103
+ }
104
+ BEGIN { count = 0 }
105
+ {
106
+ body = json_string_field($0, "body")
107
+ if (body == "") next
108
+ bodies[count] = body
109
+ ids[count] = json_string_field($0, "id")
110
+ froms[count] = json_string_field($0, "from_session_id")
111
+ count++
102
112
  }
103
113
  END {
104
114
  if (count == 0) exit 0
105
- ctx = ""
115
+ ctx = "<system-reminder>\\n[oxtail] You have " count " new peer message(s)."
116
+ ctx = ctx "\\nIf a message asks for a response and from_session_id is present, reply with mcp__oxtail__send_message using that UUID as target."
106
117
  for (j = 0; j < count; j++) {
107
- if (j > 0) ctx = ctx "\\n\\n"
108
- ctx = ctx bodies[j]
118
+ ctx = ctx "\\n\\n--- message " (j + 1) " ---"
119
+ if (ids[j] != "") ctx = ctx "\\nmessage_id: " ids[j]
120
+ if (froms[j] != "") {
121
+ ctx = ctx "\\nfrom_session_id: " froms[j]
122
+ } else {
123
+ ctx = ctx "\\nfrom_session_id: unknown"
124
+ }
125
+ ctx = ctx "\\nbody:\\n" bodies[j]
109
126
  }
127
+ ctx = ctx "\\n</system-reminder>"
110
128
  printf("{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"%s\"}}\n", ctx)
111
129
  }
112
- ' < "$mbox")
130
+ ' "${locked[@]}")
113
131
 
114
132
  if [ -n "$output" ]; then
115
133
  printf '%s' "$output"
116
- : > "$mbox"
134
+ for m in "${locked[@]}"; do : > "$m"; done
117
135
  fi
118
136
 
119
- rmdir "$mbox.lock" 2>/dev/null || true
137
+ for m in "${locked[@]}"; do rmdir "$m.lock" 2>/dev/null || true; done
120
138
  exit 0
package/assets/stop.sh ADDED
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bash
2
+ # oxtail Stop hook — two jobs at turn end:
3
+ # 1. Wake-routing: mark this session "idle" in ~/.oxtail/activity/<session_id>
4
+ # on a real stop (no pending messages). When it instead BLOCKS to deliver
5
+ # messages the turn continues, so it leaves the "busy" mark (set by the
6
+ # UserPromptSubmit hook) in place.
7
+ # 2. Delivery: if peer messages landed as the turn finished, emit a
8
+ # {"decision":"block","reason":...} envelope so Claude reads + responds
9
+ # before going idle, and truncate the mailbox(es) under lock.
10
+ #
11
+ # Identity is keyed by session_id, never server_pid (see AGENTS.md). A dual-scope
12
+ # agent runs several MCP children sharing one session_id; the session's inbox is
13
+ # the UNION of those children's mailboxes, so delivery drains ALL of them rather
14
+ # than guessing one. Activity is written under the session_id directly.
15
+ #
16
+ # Pure bash + awk (bash 3.2-compatible — no mapfile); no jq/python/node. Exits 0
17
+ # on every error path so it never wedges the agent. stop_hook_active: on a
18
+ # re-entry (already continuing from a prior block) this is a real stop — mark
19
+ # idle and exit so decision:block can never loop.
20
+
21
+ set -u
22
+
23
+ # 1. Read the full stdin payload once. tty / empty → nothing to do.
24
+ payload=""
25
+ if [ ! -t 0 ]; then
26
+ payload=$(cat 2>/dev/null || true)
27
+ fi
28
+ [ -z "$payload" ] && exit 0
29
+
30
+ # 2. Extract session_id (same scanner as pretooluse.sh).
31
+ sid=$(printf '%s' "$payload" | awk '
32
+ {
33
+ p = index($0, "\"session_id\":\"")
34
+ if (p == 0) next
35
+ rest = substr($0, p + 14)
36
+ out = ""
37
+ i = 1; n = length(rest)
38
+ while (i <= n) {
39
+ c = substr(rest, i, 1)
40
+ if (c == "\\") {
41
+ if (i+1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
42
+ } else if (c == "\"") {
43
+ break
44
+ } else {
45
+ out = out c; i += 1
46
+ }
47
+ }
48
+ print out; exit
49
+ }
50
+ ')
51
+ [ -z "$sid" ] && exit 0
52
+
53
+ activity_dir="$HOME/.oxtail/activity"
54
+ # Sanitize to a safe filename (UUIDs pass through). Must match the server's
55
+ # activitySessionKey() so reads and writes agree on the path.
56
+ safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9_-' '_')
57
+ mark_idle() {
58
+ [ -z "$safe_sid" ] && return 0
59
+ mkdir -p "$activity_dir" 2>/dev/null || true
60
+ printf 'idle' > "$activity_dir/$safe_sid" 2>/dev/null || true
61
+ }
62
+
63
+ # 3. Loop guard: a re-entry is a real stop → mark idle, allow the stop.
64
+ if printf '%s' "$payload" | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
65
+ mark_idle
66
+ exit 0
67
+ fi
68
+
69
+ sessions_dir="$HOME/.oxtail/sessions"
70
+ mailboxes_dir="$HOME/.oxtail/mailboxes"
71
+ # Can't locate siblings → it's still a real stop; mark idle and allow it.
72
+ if [ ! -d "$sessions_dir" ] || [ ! -d "$mailboxes_dir" ]; then
73
+ mark_idle
74
+ exit 0
75
+ fi
76
+
77
+ # 4. Collect every non-empty sibling mailbox for this session_id.
78
+ mboxes=()
79
+ while IFS= read -r f; do
80
+ [ -z "$f" ] && continue
81
+ pid=$(basename "$f" .json)
82
+ case "$pid" in *[!0-9]*) continue ;; esac
83
+ m="$mailboxes_dir/$pid.jsonl"
84
+ if [ -f "$m" ] && [ -s "$m" ]; then mboxes+=("$m"); fi
85
+ done < <(grep -lE "\"session_id\"[[:space:]]*:[[:space:]]*\"$sid\"" "$sessions_dir"/*.json 2>/dev/null)
86
+
87
+ # Nothing to deliver → real stop.
88
+ if [ "${#mboxes[@]}" -eq 0 ]; then
89
+ mark_idle
90
+ exit 0
91
+ fi
92
+
93
+ # 5. Lock each non-empty mailbox (best-effort; 30s staleness window).
94
+ locked=()
95
+ for m in "${mboxes[@]}"; do
96
+ for i in $(seq 1 50); do
97
+ if mkdir "$m.lock" 2>/dev/null; then locked+=("$m"); break; fi
98
+ now=$(date +%s 2>/dev/null || echo 0)
99
+ mt=$(stat -c %Y "$m.lock" 2>/dev/null || stat -f %m "$m.lock" 2>/dev/null || echo 0)
100
+ if [ "$mt" -gt 0 ] && [ $((now - mt)) -gt 30 ]; then rmdir "$m.lock" 2>/dev/null; fi
101
+ sleep 0.01
102
+ done
103
+ done
104
+ # Couldn't lock anything → leave messages for next time. This still allows the
105
+ # turn to stop, so mark idle; otherwise wake:auto will suppress a wake for a
106
+ # peer that is no longer actually busy.
107
+ if [ "${#locked[@]}" -eq 0 ]; then
108
+ mark_idle
109
+ exit 0
110
+ fi
111
+
112
+ # 6. Build the decision:block reason from every locked mailbox's lines.
113
+ output=$(awk '
114
+ function json_string_field(line, key, needle, p, rest, out, i, n, c) {
115
+ needle = "\"" key "\":\""
116
+ p = index(line, needle)
117
+ if (p == 0) return ""
118
+ rest = substr(line, p + length(needle))
119
+ out = ""
120
+ i = 1; n = length(rest)
121
+ while (i <= n) {
122
+ c = substr(rest, i, 1)
123
+ if (c == "\\") {
124
+ if (i + 1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
125
+ } else if (c == "\"") {
126
+ break
127
+ } else {
128
+ out = out c
129
+ i += 1
130
+ }
131
+ }
132
+ return out
133
+ }
134
+ BEGIN { count = 0 }
135
+ {
136
+ body = json_string_field($0, "body")
137
+ if (body == "") next
138
+ bodies[count] = body
139
+ ids[count] = json_string_field($0, "id")
140
+ froms[count] = json_string_field($0, "from_session_id")
141
+ count++
142
+ }
143
+ END {
144
+ if (count == 0) exit 0
145
+ r = "[oxtail] " count " new peer message(s) arrived as you finished your turn. Read them and respond before stopping."
146
+ r = r "\\nIf a message asks for a response and from_session_id is present, reply with mcp__oxtail__send_message using that UUID as target."
147
+ for (j = 0; j < count; j++) {
148
+ r = r "\\n\\n--- message " (j + 1) " ---"
149
+ if (ids[j] != "") r = r "\\nmessage_id: " ids[j]
150
+ if (froms[j] != "") {
151
+ r = r "\\nfrom_session_id: " froms[j]
152
+ } else {
153
+ r = r "\\nfrom_session_id: unknown"
154
+ }
155
+ r = r "\\nbody:\\n" bodies[j]
156
+ }
157
+ printf("{\"decision\":\"block\",\"reason\":\"%s\"}\n", r)
158
+ }
159
+ ' "${locked[@]}")
160
+
161
+ if [ -n "$output" ]; then
162
+ # Blocking: the turn continues, so leave the "busy" mark in place.
163
+ printf '%s' "$output"
164
+ for m in "${locked[@]}"; do : > "$m"; done
165
+ else
166
+ # Nothing deliverable → real stop.
167
+ mark_idle
168
+ fi
169
+
170
+ for m in "${locked[@]}"; do rmdir "$m.lock" 2>/dev/null || true; done
171
+ exit 0
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # oxtail UserPromptSubmit hook — marks this session "busy" for wake-routing.
3
+ #
4
+ # Writes "busy" to ~/.oxtail/activity/<session_id> whenever a turn starts (the
5
+ # user — or a peer's send-keys wake — submits a prompt). The Stop hook writes
6
+ # "idle" when a turn ends. A sender consults this file so send_message with
7
+ # wake:"auto" only fires a send-keys wake when the peer is NOT mid-turn — the
8
+ # PreToolUse/Stop hooks deliver during a turn, so waking then would type into a
9
+ # busy composer.
10
+ #
11
+ # Keyed by session_id (the agent identity), NOT server_pid: a dual-scope agent
12
+ # runs several MCP children that share one session_id, and the sender reads this
13
+ # by the peer's session_id (see AGENTS.md — never key peer identity on
14
+ # server_pid). No registry/pid lookup needed; the session_id comes straight from
15
+ # the hook payload. Pure bash; no jq/python/node. Exits 0 on every path.
16
+
17
+ set -u
18
+
19
+ # Read session_id from stdin JSON (Claude Code delivers it the same way it does
20
+ # for PreToolUse). tty / no payload → nothing to do.
21
+ sid=""
22
+ if [ ! -t 0 ]; then
23
+ payload=$(cat 2>/dev/null || true)
24
+ sid=$(printf '%s' "$payload" | awk '
25
+ {
26
+ p = index($0, "\"session_id\":\"")
27
+ if (p == 0) next
28
+ rest = substr($0, p + 14)
29
+ out = ""
30
+ i = 1; n = length(rest)
31
+ while (i <= n) {
32
+ c = substr(rest, i, 1)
33
+ if (c == "\\") {
34
+ if (i+1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
35
+ } else if (c == "\"") {
36
+ break
37
+ } else {
38
+ out = out c; i += 1
39
+ }
40
+ }
41
+ print out; exit
42
+ }
43
+ ')
44
+ fi
45
+ [ -z "$sid" ] && exit 0
46
+
47
+ # Sanitize to a safe filename (UUIDs pass through unchanged). Must match the
48
+ # server's activitySessionKey() so reads and writes agree on the path.
49
+ safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9_-' '_')
50
+ [ -z "$safe_sid" ] && exit 0
51
+
52
+ activity_dir="$HOME/.oxtail/activity"
53
+ mkdir -p "$activity_dir" 2>/dev/null || true
54
+ printf 'busy' > "$activity_dir/$safe_sid" 2>/dev/null || true
55
+ exit 0