oxtail 0.4.0 → 0.6.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,15 +17,19 @@ 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.4.0 shipped, dogfooding
20
+ ## Status: v0.6.0 shipped, dogfooding
21
21
 
22
- Six MCP tools live: `list_project_sessions`, `read_session`, `claim_session`, `set_my_state`, `register_my_session`, and `get_my_session`. 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`).
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
 
24
24
  The v0.4.0 change: peer `client_session_id` and `transcript_path` now resolve reliably for Claude Code and Codex peers, even though Claude Code strips its session-id env var from MCP children. Detection layers in `src/detect/` — env, then birth-time fingerprint matching of transcript files, with a `claim_session` escape hatch (`register_my_session` is kept for debugging) — see `README.md` for details.
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 (v1 list_project_sessions → v0.2 read_session → v0.3 reliable peer identity → v0.4 peer-awareness state cards) 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) shipped only after observed friction named the next addition; the same gating applies to whatever comes next.
29
+
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
+
32
+ The v0.6 change: one new MCP tool (`ask_peer`) that turns v0.5's async pings into synchronous 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, and an idle receiver doesn't get nudged. `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 to rouse idle peers. The result: an agent talking to its user can delegate to a peer, exchange multiple rounds inside one of its own turns, and report back synthesized findings.
29
33
 
30
34
  ## How to collaborate on this project
31
35
 
@@ -41,9 +45,17 @@ Current phase remains **dogfooding**: use the tools in real parallel-agent work,
41
45
  3. **Both Claude Code and Codex CLI must work** with whatever we build. MCP is the cross-tool protocol; Skills are Claude-specific syntactic sugar that wraps MCP tools, never primary functionality.
42
46
  4. **Minimum viable first.** One MCP tool that's actually used > five speculative ones.
43
47
 
48
+ ## Recently shipped
49
+
50
+ - **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 `tmux send-keys` wake fallback for idle peers. Late replies fall back to the v0.5 hook / poll delivery path. Target must have a registered `client.session_id`.
51
+ - **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.
52
+
44
53
  ## Deliberately deferred
45
54
 
46
55
  - **Output capture** (vs. metadata only). Costs a wrapper layer (`script -F` or pty-mirror). Only worth doing if real friction shows metadata isn't enough.
47
- - **Cross-session messaging** (note from session A to session B). Probably useful eventually; not until real use names the shape.
56
+ - **Codex mid-turn delivery.** Pending Codex CLI exposing a hook surface.
57
+ - **Delivery receipts / read receipts.** Sender learns `{ ok: true, message_id }`; whether the recipient saw it is invisible. Add when real use names the shape.
58
+ - **Broadcast / multi-recipient send_message.** 1:1 only in v0.5.
59
+ - **Orphan mailbox cleanup.** Mailbox files for dead pids accumulate in `~/.oxtail/mailboxes/`. Tiny and harmless; revisit when real waste shows up in `du`.
48
60
  - **Skill set.** Decide after the first MCP tool exists and we know what it feels like to use raw.
49
61
  - **MCP tool naming.** Pick after observation tells us the verbs.
package/README.md CHANGED
@@ -19,7 +19,7 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
19
19
  **Claude Code** — add to `~/.claude.json` (global) or any project's `.mcp.json`:
20
20
 
21
21
  ```jsonc
22
- { "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.4.0"] } } }
22
+ { "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.6.0"] } } }
23
23
  ```
24
24
 
25
25
  **Codex CLI** — add to `~/.codex/config.toml`:
@@ -27,14 +27,14 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
27
27
  ```toml
28
28
  [mcp_servers.oxtail]
29
29
  command = "npx"
30
- args = ["-y", "oxtail@0.4.0"]
30
+ args = ["-y", "oxtail@0.6.0"]
31
31
  ```
32
32
 
33
33
  **Claude slash command** (`/oxtail-join`):
34
34
 
35
35
  ```sh
36
36
  mkdir -p ~/.claude/commands
37
- curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.4.0/.claude/commands/oxtail-join.md \
37
+ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.6.0/.claude/commands/oxtail-join.md \
38
38
  -o ~/.claude/commands/oxtail-join.md
39
39
  ```
40
40
 
@@ -42,9 +42,9 @@ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.4.0/.claude/commands
42
42
 
43
43
  ```sh
44
44
  mkdir -p ~/.codex/skills/oxtail-register/agents
45
- curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.4.0/integrations/codex/oxtail-register/SKILL.md \
45
+ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.6.0/integrations/codex/oxtail-register/SKILL.md \
46
46
  -o ~/.codex/skills/oxtail-register/SKILL.md
47
- curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.4.0/integrations/codex/oxtail-register/agents/openai.yaml \
47
+ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.6.0/integrations/codex/oxtail-register/agents/openai.yaml \
48
48
  -o ~/.codex/skills/oxtail-register/agents/openai.yaml
49
49
  ```
50
50
 
@@ -59,14 +59,17 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
59
59
 
60
60
  ## MCP tools
61
61
 
62
- - `list_project_sessions` — tmux sessions in or under a given project root, enriched with `client_type`, `client_session_id`, and the peer's `state` card for oxtail-aware peers.
63
- - `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.
62
+ - `list_project_sessions` — tmux sessions in or under a given project root, enriched with `client_type`, `client_session_id`, and the peer's `state` card. Returns **one row per registered agent** — rows may share `name` when peers share a tmux session (Terminator multi-window). Disambiguate via `client_session_id`.
63
+ - `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.
64
64
  - `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 }`.
65
65
  - `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).
66
+ - `send_message` — **fire-and-forget** message to a peer. **Does NOT wake an idle peer** — use `ask_peer` for that. 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+)
67
+ - `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+)
68
+ - `ask_peer` — **synchronous delegate-and-wait**. Wakes the peer via `tmux send-keys` and **blocks until they reply** (or the fixed timeout elapses, default 45s, tunable via `OXTAIL_ASK_PEER_TIMEOUT_MS`). Returns the peer's reply body. Use this for delegate-and-wait dynamics; use `send_message` for fire-and-forget. (v0.6+)
66
69
  - `register_my_session` — pin this MCP server's `session_id` directly. Kept for debugging; prefer `claim_session`.
67
70
  - `get_my_session` — return this MCP server's own registry entry plus a per-strategy detection diagnosis. Useful for debugging.
68
71
 
69
- See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.4.0/AGENTS.md) for scope and architecture.
72
+ See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.5.0/AGENTS.md) for scope and architecture.
70
73
 
71
74
  ## Usage from an agent
72
75
 
@@ -77,6 +80,12 @@ list_project_sessions({ project_root: "/path/to/project" })
77
80
  read_session({ name: "primary" }) // auto: transcript if peer registered, else pane
78
81
  read_session({ name: "claude", mode: "transcript", limit: 50 })
79
82
  read_session({ name: "primary", mode: "pane", pane_lines: 500 })
83
+ read_session({ name: "<peer-uuid>", mode: "transcript" }) // UUID form: needed when peers share a tmux session
84
+ send_message({ target: "primary", body: "<system-reminder>checking in</system-reminder>" })
85
+ send_message({ target: "<peer-uuid>", body: "..." }) // UUID form: same disambiguation
86
+ read_my_messages()
87
+ ask_peer({ target: "primary", body: "[Handoff] please audit X and tell me what you find" })
88
+ // → blocks server-side until the peer replies via send_message, then returns their body
80
89
  ```
81
90
 
82
91
  Omitting `project_root` triggers a best-effort `.git`-ancestor walk from the server's own cwd. The response includes `inferred: true` when this happens. Pass `project_root` explicitly when you can.
@@ -85,6 +94,101 @@ Omitting `project_root` triggers a best-effort `.git`-ancestor walk from the ser
85
94
 
86
95
  The cheapest way to learn what peers are doing is `list_project_sessions`. Each row carries an optional `state` card written by the peer via `set_my_state` — currently `{ purpose, updated_at }`. Reading the card costs almost nothing compared to `read_session`, which spends tokens on the full transcript. Use `read_session` when the card isn't enough.
87
96
 
97
+ ## Peer messaging (v0.5)
98
+
99
+ Two MCP tools let peers in the same project root talk to each other:
100
+
101
+ ```
102
+ send_message({ target: "<tmux-session-name OR client_session_id UUID>", body: "..." })
103
+ → { ok: true, message_id, target_session_id, target_server_pid }
104
+
105
+ read_my_messages()
106
+ → { ok: true, drained: true, count, messages: [...] }
107
+ ```
108
+
109
+ The mailbox lives at `~/.oxtail/mailboxes/<server_pid>.jsonl`, append-only JSONL, drained under an `mkdir`-based advisory lock. The transport is intentionally dumb: 8KB UTF-8 body cap, sender chooses the framing (raw text or pre-wrapped `<system-reminder>...</system-reminder>`).
110
+
111
+ Cross-project sends are rejected, never silently dropped. Sending to a peer with the same tmux session name as another live peer returns `ambiguous-target` with the candidate `client_session_id`s — use the UUID form to disambiguate.
112
+
113
+ ### Mid-turn vs next-turn delivery (the asymmetry)
114
+
115
+ Claude Code peers can receive messages **mid-turn** via an opt-in PreToolUse hook:
116
+
117
+ ```sh
118
+ npx oxtail install-hook
119
+ ```
120
+
121
+ 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`.
122
+
123
+ 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.
124
+
125
+ **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.
126
+
127
+ ### Hook coexistence
128
+
129
+ 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.
130
+
131
+ 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.
132
+
133
+ ### Trust model
134
+
135
+ 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.
136
+
137
+ ## Delegate-and-wait (v0.6)
138
+
139
+ `ask_peer` extends v0.5's mailbox transport into a synchronous primitive:
140
+
141
+ ```
142
+ ask_peer({ target, body })
143
+ → { ok: true, message_id, reply: { id, body, enqueued_at, from_session_id } | null, timed_out }
144
+ ```
145
+
146
+ Mechanics:
147
+
148
+ 1. Enqueue `body` into the target's mailbox (same as `send_message`).
149
+ 2. Wait ~500ms for a hook-delivered reply (rare path — handles the case where the peer was already mid-tool-call and replied immediately).
150
+ 3. Fire a `tmux send-keys` wake against the peer's pane: a single literal line `[oxtail] new peer message — run mcp__oxtail__read_my_messages and respond via mcp__oxtail__send_message` followed by Enter. This nudges idle peers without requiring the human at the other end to type.
151
+ 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.
152
+ 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.
153
+
154
+ Constraints:
155
+
156
+ - 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.
157
+ - 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.
158
+ - The wake is best-effort. 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). If both fail, the peer may still respond on its own via polling — the only loss is the immediacy of the nudge.
159
+
160
+ ### Tuning the timeout
161
+
162
+ 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:
163
+
164
+ ```sh
165
+ OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.6.0
166
+ ```
167
+
168
+ 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.
169
+
170
+ ### Recommended permissions for autonomous agent-to-agent collaboration
171
+
172
+ The user-approval prompt on every `ask_peer` call interrupts the back-and-forth dynamic. To allow agents to initiate delegation without per-call prompts, add to `~/.claude/settings.json`:
173
+
174
+ ```jsonc
175
+ {
176
+ "permissions": {
177
+ "allow": [
178
+ "mcp__oxtail__ask_peer",
179
+ "mcp__oxtail__send_message",
180
+ "mcp__oxtail__read_my_messages"
181
+ ]
182
+ }
183
+ }
184
+ ```
185
+
186
+ Without an allowlist, Claude Code prompts on first use of each MCP tool with an "always allow" option — pick that once per project to get the same effect.
187
+
188
+ ### Body framing
189
+
190
+ Peers see the body verbatim. A handoff is naturally read as an assignment, not chat, when framed that way — include an objective and a requested next action. The repo doesn't ship a fixed envelope convention yet; convention will follow real use.
191
+
88
192
  ## Self-registration and the peer registry
89
193
 
90
194
  Each oxtail server, when spawned by an agent, writes a small record to `~/.oxtail/sessions/<pid>.json` containing the client type, session id, transcript path, and tmux pane. Sibling servers read this directory to find peer transcripts. Records auto-clean on process exit and on read (dead PIDs pruned). Sessions whose agents are not oxtail-aware (or are not LLM agents at all — bash, vim, vite dev servers) still show up in `list_project_sessions` and are readable via `read_session` in pane mode.
@@ -105,4 +209,4 @@ If `MCP_TRACE_FILE` is set in the environment, every detection run appends an ND
105
209
 
106
210
  ## Status
107
211
 
108
- v0.4.0. Reliable peer identity: `client_session_id` resolves automatically for Claude Code and Codex via filesystem fingerprint matching, with a self-register escape hatch for ambiguous cases. Project-local and global registrations both supported.
212
+ v0.6.0. Adds `ask_peer` on top of v0.5's mailbox transport: an agent can send a message and block until the peer replies, with an automatic `tmux send-keys` wake for idle peers. Combined with the existing PreToolUse hook, two Claude Code sessions can now sustain a back-and-forth handoff inside a single turn of the delegating agent. Codex peers are supported as targets once they've claimed a session.
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bash
2
+ # oxtail PreToolUse hook — delivers peer messages mid-turn to Claude Code.
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.
7
+ #
8
+ # 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.
11
+
12
+ set -u
13
+
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.
17
+ sid=""
18
+ if [ ! -t 0 ]; then
19
+ payload=$(cat 2>/dev/null || true)
20
+ sid=$(printf '%s' "$payload" | awk '
21
+ {
22
+ p = index($0, "\"session_id\":\"")
23
+ if (p == 0) next
24
+ rest = substr($0, p + 14)
25
+ out = ""
26
+ i = 1; n = length(rest)
27
+ while (i <= n) {
28
+ c = substr(rest, i, 1)
29
+ if (c == "\\") {
30
+ if (i+1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
31
+ } else if (c == "\"") {
32
+ break
33
+ } else {
34
+ out = out c; i += 1
35
+ }
36
+ }
37
+ print out; exit
38
+ }
39
+ ')
40
+ fi
41
+ [ -z "$sid" ] && exit 0
42
+
43
+ sessions_dir="$HOME/.oxtail/sessions"
44
+ mailboxes_dir="$HOME/.oxtail/mailboxes"
45
+ [ -d "$sessions_dir" ] || exit 0
46
+ [ -d "$mailboxes_dir" ] || exit 0
47
+
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
56
+
57
+ mbox="$mailboxes_dir/$pid.jsonl"
58
+ [ -f "$mbox" ] || exit 0
59
+ [ -s "$mbox" ] || exit 0
60
+
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
75
+ done
76
+ [ "$acquired" -eq 1 ] || exit 0
77
+
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.
82
+ output=$(awk '
83
+ BEGIN { count = 0 }
84
+ {
85
+ p = index($0, "\"body\":\"")
86
+ if (p == 0) next
87
+ rest = substr($0, p + 8)
88
+ out = ""
89
+ i = 1; n = length(rest)
90
+ while (i <= n) {
91
+ c = substr(rest, i, 1)
92
+ if (c == "\\") {
93
+ if (i + 1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
94
+ } else if (c == "\"") {
95
+ break
96
+ } else {
97
+ out = out c
98
+ i += 1
99
+ }
100
+ }
101
+ bodies[count++] = out
102
+ }
103
+ END {
104
+ if (count == 0) exit 0
105
+ ctx = ""
106
+ for (j = 0; j < count; j++) {
107
+ if (j > 0) ctx = ctx "\\n\\n"
108
+ ctx = ctx bodies[j]
109
+ }
110
+ printf("{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"%s\"}}\n", ctx)
111
+ }
112
+ ' < "$mbox")
113
+
114
+ if [ -n "$output" ]; then
115
+ printf '%s' "$output"
116
+ : > "$mbox"
117
+ fi
118
+
119
+ rmdir "$mbox.lock" 2>/dev/null || true
120
+ exit 0
@@ -0,0 +1,242 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { appendFileSync, mkdirSync, readFileSync, rmdirSync, statSync, truncateSync, writeFileSync, } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { trace } from "./trace.js";
6
+ // Resolved lazily so tests can swap HOME between cases. Each call re-reads
7
+ // homedir(), which on POSIX defers to $HOME.
8
+ function mailboxesDir() {
9
+ return join(homedir(), ".oxtail", "mailboxes");
10
+ }
11
+ // Lock staleness window. The drainer reads the file, builds the JSON envelope,
12
+ // and writes the truncate back to disk all under lock — under slow disks or OS
13
+ // hiccups, a legitimate-but-slow drain can approach the original 5s threshold
14
+ // and let a peer steal the lock. 30s widens the window to make accidental
15
+ // theft very rare; the trade-off is that a genuinely crashed drainer holds the
16
+ // lock 25s longer before recovery. Worth it.
17
+ //
18
+ // Sync this value with assets/pretooluse.sh (find -mmin +0.5 ≈ 30s).
19
+ const LOCK_STALE_MS = 30_000;
20
+ const LOCK_RETRY_LIMIT = 50;
21
+ const LOCK_RETRY_DELAY_MS = 10;
22
+ function mailboxPath(pid) {
23
+ return join(mailboxesDir(), `${pid}.jsonl`);
24
+ }
25
+ function lockPath(pid) {
26
+ return `${mailboxPath(pid)}.lock`;
27
+ }
28
+ function sleepSync(ms) {
29
+ const end = Date.now() + ms;
30
+ while (Date.now() < end) {
31
+ // tight spin — short enough (10ms) that this is acceptable
32
+ }
33
+ }
34
+ export function acquireLock(pid) {
35
+ mkdirSync(mailboxesDir(), { recursive: true, mode: 0o700 });
36
+ const lock = lockPath(pid);
37
+ for (let i = 0; i < LOCK_RETRY_LIMIT; i++) {
38
+ try {
39
+ mkdirSync(lock, { mode: 0o700 });
40
+ return;
41
+ }
42
+ catch (e) {
43
+ const err = e;
44
+ if (err.code !== "EEXIST")
45
+ throw err;
46
+ // Check staleness. If older than LOCK_STALE_MS, force-clear and retry.
47
+ try {
48
+ const st = statSync(lock);
49
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
50
+ try {
51
+ rmdirSync(lock);
52
+ trace("mailbox_lock_stale_clear", { pid });
53
+ }
54
+ catch {
55
+ // raced with another clearer; fall through to retry
56
+ }
57
+ continue;
58
+ }
59
+ }
60
+ catch {
61
+ // stat may race; just retry
62
+ }
63
+ sleepSync(LOCK_RETRY_DELAY_MS);
64
+ }
65
+ }
66
+ throw new Error(`could not acquire mailbox lock for pid ${pid}`);
67
+ }
68
+ export function releaseLock(pid) {
69
+ try {
70
+ rmdirSync(lockPath(pid));
71
+ }
72
+ catch {
73
+ // ignore ENOENT / not-empty / EPERM
74
+ }
75
+ }
76
+ // Critical: the serialized JSONL line must always begin
77
+ // `{"schema_version":1,"id":"...","body":"`. The awk extractor in
78
+ // assets/pretooluse.sh assumes `"body":"` is the third key. A future refactor
79
+ // that uses Object.assign / spread / inserts a key could silently reorder and
80
+ // break the hook without breaking unit tests that don't check serialization.
81
+ // The runtime regex below catches that.
82
+ const FIELD_ORDER_PREFIX = /^\{"schema_version":1,"id":"[0-9a-f]{16}","body":"/;
83
+ export function enqueue(target_pid, body, from_session_id) {
84
+ const msg = {
85
+ schema_version: 1,
86
+ id: randomBytes(8).toString("hex"),
87
+ body,
88
+ enqueued_at: Math.floor(Date.now() / 1000),
89
+ ...(from_session_id ? { from_session_id } : {}),
90
+ };
91
+ // Build the line by inserting keys in the invariant order. Node's
92
+ // JSON.stringify preserves insertion order for non-integer string keys,
93
+ // which the test suite pins.
94
+ const obj = {
95
+ schema_version: msg.schema_version,
96
+ id: msg.id,
97
+ body: msg.body,
98
+ enqueued_at: msg.enqueued_at,
99
+ };
100
+ if (from_session_id)
101
+ obj.from_session_id = from_session_id;
102
+ const line = JSON.stringify(obj) + "\n";
103
+ if (!FIELD_ORDER_PREFIX.test(line)) {
104
+ throw new Error(`mailbox enqueue: serialized line violates field-order invariant. ` +
105
+ `Got prefix: ${line.slice(0, 80)}`);
106
+ }
107
+ acquireLock(target_pid);
108
+ try {
109
+ appendFileSync(mailboxPath(target_pid), line);
110
+ }
111
+ finally {
112
+ releaseLock(target_pid);
113
+ }
114
+ return msg;
115
+ }
116
+ export function drain(my_pid) {
117
+ acquireLock(my_pid);
118
+ try {
119
+ let raw;
120
+ try {
121
+ raw = readFileSync(mailboxPath(my_pid), "utf8");
122
+ }
123
+ catch (e) {
124
+ const err = e;
125
+ if (err.code === "ENOENT")
126
+ return [];
127
+ throw err;
128
+ }
129
+ if (!raw)
130
+ return [];
131
+ const out = [];
132
+ for (const line of raw.split("\n")) {
133
+ if (!line)
134
+ continue;
135
+ let parsed;
136
+ try {
137
+ parsed = JSON.parse(line);
138
+ }
139
+ catch {
140
+ trace("mailbox_drain_skip_invalid", { pid: my_pid, line });
141
+ continue;
142
+ }
143
+ if (parsed &&
144
+ typeof parsed === "object" &&
145
+ parsed.schema_version === 1 &&
146
+ typeof parsed.id === "string" &&
147
+ typeof parsed.body === "string") {
148
+ out.push(parsed);
149
+ }
150
+ else {
151
+ trace("mailbox_drain_skip_invalid", { pid: my_pid, line });
152
+ }
153
+ }
154
+ try {
155
+ truncateSync(mailboxPath(my_pid), 0);
156
+ }
157
+ catch (e) {
158
+ const err = e;
159
+ if (err.code !== "ENOENT")
160
+ throw err;
161
+ }
162
+ return out;
163
+ }
164
+ finally {
165
+ releaseLock(my_pid);
166
+ }
167
+ }
168
+ // Drain the first message in this mailbox whose from_session_id matches
169
+ // `from_session_id`, leaving any preceding and following messages untouched.
170
+ // Used by ask_peer to consume exactly the reply it's waiting on without
171
+ // stealing messages from concurrent peers.
172
+ //
173
+ // Critical invariant: surviving raw lines are written back byte-exact. The
174
+ // awk extractor in assets/pretooluse.sh assumes the FIELD_ORDER_PREFIX layout;
175
+ // re-serializing via JSON.stringify could reorder keys and silently break the
176
+ // hook for messages that stay in the mailbox.
177
+ export function drainMatchingSession(my_pid, from_session_id) {
178
+ acquireLock(my_pid);
179
+ try {
180
+ let raw;
181
+ try {
182
+ raw = readFileSync(mailboxPath(my_pid), "utf8");
183
+ }
184
+ catch (e) {
185
+ const err = e;
186
+ if (err.code === "ENOENT")
187
+ return null;
188
+ throw err;
189
+ }
190
+ if (!raw)
191
+ return null;
192
+ const lines = raw.split("\n").filter((l) => l.length > 0);
193
+ let matchIdx = -1;
194
+ let matchedMsg = null;
195
+ for (let i = 0; i < lines.length; i++) {
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(lines[i]);
199
+ }
200
+ catch {
201
+ continue;
202
+ }
203
+ if (parsed &&
204
+ typeof parsed === "object" &&
205
+ parsed.schema_version === 1 &&
206
+ parsed.from_session_id === from_session_id) {
207
+ matchIdx = i;
208
+ matchedMsg = parsed;
209
+ break;
210
+ }
211
+ }
212
+ if (matchIdx < 0 || !matchedMsg)
213
+ return null;
214
+ const surviving = [
215
+ ...lines.slice(0, matchIdx),
216
+ ...lines.slice(matchIdx + 1),
217
+ ];
218
+ if (surviving.length === 0) {
219
+ try {
220
+ truncateSync(mailboxPath(my_pid), 0);
221
+ }
222
+ catch (e) {
223
+ const err = e;
224
+ if (err.code !== "ENOENT")
225
+ throw err;
226
+ }
227
+ }
228
+ else {
229
+ writeFileSync(mailboxPath(my_pid), surviving.join("\n") + "\n");
230
+ }
231
+ return matchedMsg;
232
+ }
233
+ finally {
234
+ releaseLock(my_pid);
235
+ }
236
+ }
237
+ export function mailboxFilePath(pid) {
238
+ return mailboxPath(pid);
239
+ }
240
+ export function mailboxLockPath(pid) {
241
+ return lockPath(pid);
242
+ }
package/dist/registry.js CHANGED
@@ -2,25 +2,29 @@ import { execFileSync } from "node:child_process";
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- const REGISTRY_DIR = join(homedir(), ".oxtail", "sessions");
5
+ // Lazy so tests can swap HOME between cases; homedir() defers to $HOME on POSIX.
6
+ function registryDir() {
7
+ return join(homedir(), ".oxtail", "sessions");
8
+ }
6
9
  function ensureDir() {
7
- if (!existsSync(REGISTRY_DIR)) {
8
- mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 });
10
+ const dir = registryDir();
11
+ if (!existsSync(dir)) {
12
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
9
13
  return;
10
14
  }
11
15
  // Migration: tighten perms for users upgrading from <0.4.0, where the dir
12
16
  // and entries were created at default umask (typically 0o755 / 0o644).
13
17
  try {
14
- chmodSync(REGISTRY_DIR, 0o700);
18
+ chmodSync(dir, 0o700);
15
19
  }
16
20
  catch {
17
21
  // not our dir or fs doesn't support; leave it
18
22
  }
19
- for (const file of readdirSync(REGISTRY_DIR)) {
23
+ for (const file of readdirSync(dir)) {
20
24
  if (!file.endsWith(".json"))
21
25
  continue;
22
26
  try {
23
- chmodSync(join(REGISTRY_DIR, file), 0o600);
27
+ chmodSync(join(dir, file), 0o600);
24
28
  }
25
29
  catch {
26
30
  // ignore
@@ -28,7 +32,7 @@ function ensureDir() {
28
32
  }
29
33
  }
30
34
  function entryPath(pid) {
31
- return join(REGISTRY_DIR, `${pid}.json`);
35
+ return join(registryDir(), `${pid}.json`);
32
36
  }
33
37
  function resolveTmuxSessionFromPane(pane) {
34
38
  if (!pane)
@@ -168,13 +172,14 @@ function isAlive(pid) {
168
172
  }
169
173
  }
170
174
  export function readAll() {
171
- if (!existsSync(REGISTRY_DIR))
175
+ const dir = registryDir();
176
+ if (!existsSync(dir))
172
177
  return [];
173
178
  const out = [];
174
- for (const file of readdirSync(REGISTRY_DIR)) {
179
+ for (const file of readdirSync(dir)) {
175
180
  if (!file.endsWith(".json"))
176
181
  continue;
177
- const full = join(REGISTRY_DIR, file);
182
+ const full = join(dir, file);
178
183
  let entry;
179
184
  try {
180
185
  entry = JSON.parse(readFileSync(full, "utf8"));