oxtail 0.5.0 → 0.6.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 +12 -3
- package/README.md +77 -13
- package/dist/mailbox.js +70 -1
- package/dist/registry.js +68 -3
- package/dist/server.js +363 -25
- package/integrations/codex/{oxtail-register → oxtail-join}/SKILL.md +4 -4
- package/integrations/codex/oxtail-join/agents/openai.yaml +10 -0
- package/package.json +1 -1
- package/integrations/codex/oxtail-register/agents/openai.yaml +0 -10
package/AGENTS.md
CHANGED
|
@@ -17,18 +17,22 @@ 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.
|
|
20
|
+
## Status: v0.6.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
|
|
|
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 (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) 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
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
|
+
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
|
+
|
|
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 path — but 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`.
|
|
35
|
+
|
|
32
36
|
## How to collaborate on this project
|
|
33
37
|
|
|
34
38
|
- **Don't add features without observed friction.** Speculative structure locks in design before observation has informed it. The publish-readiness work (LICENSE, README restructure, npm metadata) was the exception, because "ship it so a third party can install it" is itself the observed need.
|
|
@@ -43,8 +47,13 @@ The v0.5 change: two new MCP tools (`send_message`, `read_my_messages`) plus an
|
|
|
43
47
|
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.
|
|
44
48
|
4. **Minimum viable first.** One MCP tool that's actually used > five speculative ones.
|
|
45
49
|
|
|
50
|
+
## Invariants worth defending
|
|
51
|
+
|
|
52
|
+
- **`client.session_id` is the unique agent identity.** Not `server_pid`, not `tmux_session`. One Claude/Codex client can be backed by multiple MCP server children — the documented dual-scope setup (project `.mcp.json` + user `~/.claude.json`) intentionally spawns two oxtail processes per session, and Claude Code/Codex restarts during a long session can leak ghost children. The registry stores one file per `server_pid`, so duplicates per `session_id` are the norm; `readAll()` collapses them by `session_id` (freshest `started_at` wins). Any new code that reasons about peer identity must key on `client.session_id` — adding lookups keyed on `server_pid` or `tmux_session` will reintroduce the bug class where peer reads bail with misleading scope errors (see commit history for the v0.6-era dedupe fix).
|
|
53
|
+
|
|
46
54
|
## Recently shipped
|
|
47
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.
|
|
48
57
|
- **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.
|
|
49
58
|
|
|
50
59
|
## Deliberately deferred
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# oxtail
|
|
2
2
|
|
|
3
|
+
[](https://github.com/d4j3y2k/oxtail/actions/workflows/test.yml)
|
|
4
|
+
|
|
3
5
|
Run two or more coding agents in the same repo and let them see each other. oxtail is a local MCP server that gives parallel Claude Code and Codex CLI sessions peer awareness: each session can list the others running in the same project root, read their state cards, and (when needed) read their transcripts directly. No fixed cap — every oxtail-aware session in the project shows up in `list_project_sessions`.
|
|
4
6
|
|
|
5
7
|
Works for any mix of clients that speak MCP — Claude Code, Codex CLI, or one of each. Scope is **project-root as the unit**: sessions in `/path/to/foo` see each other; sessions in `/path/to/bar` see each other; cross-project there is no visibility, by design.
|
|
@@ -19,7 +21,7 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
|
|
|
19
21
|
**Claude Code** — add to `~/.claude.json` (global) or any project's `.mcp.json`:
|
|
20
22
|
|
|
21
23
|
```jsonc
|
|
22
|
-
{ "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.
|
|
24
|
+
{ "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.6.0"] } } }
|
|
23
25
|
```
|
|
24
26
|
|
|
25
27
|
**Codex CLI** — add to `~/.codex/config.toml`:
|
|
@@ -27,25 +29,25 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
|
|
|
27
29
|
```toml
|
|
28
30
|
[mcp_servers.oxtail]
|
|
29
31
|
command = "npx"
|
|
30
|
-
args = ["-y", "oxtail@0.
|
|
32
|
+
args = ["-y", "oxtail@0.6.0"]
|
|
31
33
|
```
|
|
32
34
|
|
|
33
35
|
**Claude slash command** (`/oxtail-join`):
|
|
34
36
|
|
|
35
37
|
```sh
|
|
36
38
|
mkdir -p ~/.claude/commands
|
|
37
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
39
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.6.0/.claude/commands/oxtail-join.md \
|
|
38
40
|
-o ~/.claude/commands/oxtail-join.md
|
|
39
41
|
```
|
|
40
42
|
|
|
41
|
-
**Codex skill** (`/oxtail-
|
|
43
|
+
**Codex skill** (`/oxtail-join`):
|
|
42
44
|
|
|
43
45
|
```sh
|
|
44
|
-
mkdir -p ~/.codex/skills/oxtail-
|
|
45
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
46
|
-
-o ~/.codex/skills/oxtail-
|
|
47
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
48
|
-
-o ~/.codex/skills/oxtail-
|
|
46
|
+
mkdir -p ~/.codex/skills/oxtail-join/agents
|
|
47
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.6.1/integrations/codex/oxtail-join/SKILL.md \
|
|
48
|
+
-o ~/.codex/skills/oxtail-join/SKILL.md
|
|
49
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.6.1/integrations/codex/oxtail-join/agents/openai.yaml \
|
|
50
|
+
-o ~/.codex/skills/oxtail-join/agents/openai.yaml
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
Floating form (`npx -y oxtail` with no `@`) exists for trying it out; don't pin daily configs to it — it floats end users into whatever the next published version turns out to be.
|
|
@@ -59,12 +61,13 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
|
|
|
59
61
|
|
|
60
62
|
## MCP tools
|
|
61
63
|
|
|
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
|
|
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.
|
|
64
|
+
- `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`.
|
|
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.
|
|
64
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 }`.
|
|
65
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).
|
|
66
|
-
- `send_message` —
|
|
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.)
|
|
67
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+)
|
|
68
71
|
- `register_my_session` — pin this MCP server's `session_id` directly. Kept for debugging; prefer `claim_session`.
|
|
69
72
|
- `get_my_session` — return this MCP server's own registry entry plus a per-strategy detection diagnosis. Useful for debugging.
|
|
70
73
|
|
|
@@ -79,8 +82,12 @@ list_project_sessions({ project_root: "/path/to/project" })
|
|
|
79
82
|
read_session({ name: "primary" }) // auto: transcript if peer registered, else pane
|
|
80
83
|
read_session({ name: "claude", mode: "transcript", limit: 50 })
|
|
81
84
|
read_session({ name: "primary", mode: "pane", pane_lines: 500 })
|
|
85
|
+
read_session({ name: "<peer-uuid>", mode: "transcript" }) // UUID form: needed when peers share a tmux session
|
|
82
86
|
send_message({ target: "primary", body: "<system-reminder>checking in</system-reminder>" })
|
|
87
|
+
send_message({ target: "<peer-uuid>", body: "..." }) // UUID form: same disambiguation
|
|
83
88
|
read_my_messages()
|
|
89
|
+
ask_peer({ target: "primary", body: "[Handoff] please audit X and tell me what you find" })
|
|
90
|
+
// → blocks server-side until the peer replies via send_message, then returns their body
|
|
84
91
|
```
|
|
85
92
|
|
|
86
93
|
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.
|
|
@@ -129,6 +136,63 @@ If you have a PreToolUse hook installed that isn't from Terminator and isn't oxt
|
|
|
129
136
|
|
|
130
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.
|
|
131
138
|
|
|
139
|
+
## Delegate-and-wait (v0.6)
|
|
140
|
+
|
|
141
|
+
`ask_peer` extends v0.5's mailbox transport into a blocking primitive:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
ask_peer({ target, body })
|
|
145
|
+
→ { ok: true, message_id, reply: { id, body, enqueued_at, from_session_id } | null, timed_out }
|
|
146
|
+
```
|
|
147
|
+
|
|
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.
|
|
149
|
+
|
|
150
|
+
Mechanics:
|
|
151
|
+
|
|
152
|
+
1. Enqueue `body` into the target's mailbox (same as `send_message`).
|
|
153
|
+
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.
|
|
155
|
+
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.
|
|
157
|
+
|
|
158
|
+
Constraints:
|
|
159
|
+
|
|
160
|
+
- 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
|
+
- 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
|
+
|
|
164
|
+
### Tuning the timeout
|
|
165
|
+
|
|
166
|
+
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:
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.6.0
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
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.
|
|
173
|
+
|
|
174
|
+
### Recommended permissions for autonomous agent-to-agent collaboration
|
|
175
|
+
|
|
176
|
+
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`:
|
|
177
|
+
|
|
178
|
+
```jsonc
|
|
179
|
+
{
|
|
180
|
+
"permissions": {
|
|
181
|
+
"allow": [
|
|
182
|
+
"mcp__oxtail__ask_peer",
|
|
183
|
+
"mcp__oxtail__send_message",
|
|
184
|
+
"mcp__oxtail__read_my_messages"
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
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.
|
|
191
|
+
|
|
192
|
+
### Body framing
|
|
193
|
+
|
|
194
|
+
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.
|
|
195
|
+
|
|
132
196
|
## Self-registration and the peer registry
|
|
133
197
|
|
|
134
198
|
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.
|
|
@@ -149,4 +213,4 @@ If `MCP_TRACE_FILE` is set in the environment, every detection run appends an ND
|
|
|
149
213
|
|
|
150
214
|
## Status
|
|
151
215
|
|
|
152
|
-
v0.
|
|
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).
|
package/dist/mailbox.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { appendFileSync, mkdirSync, readFileSync, rmdirSync, statSync, truncateSync, } from "node:fs";
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, rmdirSync, statSync, truncateSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { trace } from "./trace.js";
|
|
@@ -165,6 +165,75 @@ export function drain(my_pid) {
|
|
|
165
165
|
releaseLock(my_pid);
|
|
166
166
|
}
|
|
167
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
|
+
}
|
|
168
237
|
export function mailboxFilePath(pid) {
|
|
169
238
|
return mailboxPath(pid);
|
|
170
239
|
}
|
package/dist/registry.js
CHANGED
|
@@ -134,6 +134,13 @@ export function refreshTmuxBinding(entry) {
|
|
|
134
134
|
}
|
|
135
135
|
export function register(entry) {
|
|
136
136
|
ensureDir();
|
|
137
|
+
// Best-effort GC: drop stale entries from dead processes that share our
|
|
138
|
+
// session_id. Happens when oxtail is configured in multiple MCP scopes
|
|
139
|
+
// (user + project), so the same client session has spawned several MCP
|
|
140
|
+
// server children over its lifetime — survivors of crashed prior children
|
|
141
|
+
// accumulate otherwise. Leaves live siblings alone; readAll() collapses
|
|
142
|
+
// those by session_id.
|
|
143
|
+
gcDeadSiblings(entry);
|
|
137
144
|
// Temp file + atomic rename. Concurrent peers running readAll() can otherwise
|
|
138
145
|
// catch a torn write, fail JSON.parse, and silently drop the entry until the
|
|
139
146
|
// next write completes.
|
|
@@ -153,6 +160,38 @@ export function register(entry) {
|
|
|
153
160
|
throw err;
|
|
154
161
|
}
|
|
155
162
|
}
|
|
163
|
+
function gcDeadSiblings(entry) {
|
|
164
|
+
const sid = entry.client.session_id;
|
|
165
|
+
if (!sid)
|
|
166
|
+
return;
|
|
167
|
+
const dir = registryDir();
|
|
168
|
+
if (!existsSync(dir))
|
|
169
|
+
return;
|
|
170
|
+
for (const file of readdirSync(dir)) {
|
|
171
|
+
if (!file.endsWith(".json"))
|
|
172
|
+
continue;
|
|
173
|
+
const full = join(dir, file);
|
|
174
|
+
let other;
|
|
175
|
+
try {
|
|
176
|
+
other = JSON.parse(readFileSync(full, "utf8"));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (other.server_pid === entry.server_pid)
|
|
182
|
+
continue;
|
|
183
|
+
if (other.client.session_id !== sid)
|
|
184
|
+
continue;
|
|
185
|
+
if (isAlive(other.server_pid))
|
|
186
|
+
continue;
|
|
187
|
+
try {
|
|
188
|
+
unlinkSync(full);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// already gone, fine
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
156
195
|
export function unregister(pid = process.pid) {
|
|
157
196
|
try {
|
|
158
197
|
unlinkSync(entryPath(pid));
|
|
@@ -175,7 +214,7 @@ export function readAll() {
|
|
|
175
214
|
const dir = registryDir();
|
|
176
215
|
if (!existsSync(dir))
|
|
177
216
|
return [];
|
|
178
|
-
const
|
|
217
|
+
const live = [];
|
|
179
218
|
for (const file of readdirSync(dir)) {
|
|
180
219
|
if (!file.endsWith(".json"))
|
|
181
220
|
continue;
|
|
@@ -196,9 +235,35 @@ export function readAll() {
|
|
|
196
235
|
}
|
|
197
236
|
continue;
|
|
198
237
|
}
|
|
199
|
-
|
|
238
|
+
live.push(entry);
|
|
239
|
+
}
|
|
240
|
+
return dedupeBySessionId(live);
|
|
241
|
+
}
|
|
242
|
+
// One Claude/Codex session can be backed by multiple MCP server children when
|
|
243
|
+
// oxtail is declared in more than one MCP scope (e.g. user-level config +
|
|
244
|
+
// project `.mcp.json`). Each child registers separately, so the registry ends
|
|
245
|
+
// up with N entries that share the same client.session_id. session_id is the
|
|
246
|
+
// unique agent identity downstream (resolver UUID lookup, peer messaging),
|
|
247
|
+
// so collapse the duplicates here. Keep the freshest by started_at — that's
|
|
248
|
+
// the most likely to have an up-to-date transcript path and tmux binding.
|
|
249
|
+
// Entries with no session_id are left alone: they're either pre-claim
|
|
250
|
+
// (haven't called claim_session yet) or unclaimed peers, and conflating
|
|
251
|
+
// them would be wrong.
|
|
252
|
+
export function dedupeBySessionId(entries) {
|
|
253
|
+
const winnerBySession = new Map();
|
|
254
|
+
const noSession = [];
|
|
255
|
+
for (const e of entries) {
|
|
256
|
+
const sid = e.client.session_id;
|
|
257
|
+
if (!sid) {
|
|
258
|
+
noSession.push(e);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const prior = winnerBySession.get(sid);
|
|
262
|
+
if (!prior || e.started_at > prior.started_at) {
|
|
263
|
+
winnerBySession.set(sid, e);
|
|
264
|
+
}
|
|
200
265
|
}
|
|
201
|
-
return
|
|
266
|
+
return [...winnerBySession.values(), ...noSession];
|
|
202
267
|
}
|
|
203
268
|
export function findByTmuxSession(name) {
|
|
204
269
|
return readAll().filter((e) => e.tmux_session === name);
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import * as z from "zod/v4";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { dirname, join, sep } from "node:path";
|
|
9
9
|
import { clientFromHandshake, detectClient, enrichWithDiagnosis, transcriptPathFor, } from "./clients.js";
|
|
@@ -119,7 +119,37 @@ function listTmuxPaneCwds() {
|
|
|
119
119
|
}
|
|
120
120
|
return out;
|
|
121
121
|
}
|
|
122
|
-
|
|
122
|
+
// Pure join: matched tmux rows × registry entries → one Session row per agent.
|
|
123
|
+
// Extracted from buildListResult so it can be unit-tested without invoking
|
|
124
|
+
// tmux. When N agents share a tmux session, N rows are emitted with identical
|
|
125
|
+
// tmux fields and distinct client_session_id. Tmux sessions with no matching
|
|
126
|
+
// registry entry get a single null-client row so unclaimed peers (Codex
|
|
127
|
+
// pre-claim, stale sessions) remain discoverable.
|
|
128
|
+
export function joinSessionsWithRegistry(matched, registry) {
|
|
129
|
+
const regsByTmux = new Map();
|
|
130
|
+
for (const e of registry) {
|
|
131
|
+
if (!e.tmux_session)
|
|
132
|
+
continue;
|
|
133
|
+
const arr = regsByTmux.get(e.tmux_session);
|
|
134
|
+
if (arr)
|
|
135
|
+
arr.push(e);
|
|
136
|
+
else
|
|
137
|
+
regsByTmux.set(e.tmux_session, [e]);
|
|
138
|
+
}
|
|
139
|
+
return matched.flatMap((s) => {
|
|
140
|
+
const regs = regsByTmux.get(s.name) ?? [];
|
|
141
|
+
if (regs.length === 0) {
|
|
142
|
+
return [{ ...s, client_type: null, client_session_id: null, state: null }];
|
|
143
|
+
}
|
|
144
|
+
return regs.map((reg) => ({
|
|
145
|
+
...s,
|
|
146
|
+
client_type: reg.client.type ?? null,
|
|
147
|
+
client_session_id: reg.client.session_id ?? null,
|
|
148
|
+
state: reg.state ?? null,
|
|
149
|
+
}));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function buildListResult(input) {
|
|
123
153
|
const explicit = typeof input.project_root === "string" && input.project_root.length > 0;
|
|
124
154
|
const root = explicit ? input.project_root : inferProjectRoot(process.cwd());
|
|
125
155
|
const resolvedRoot = safeRealpath(root);
|
|
@@ -133,20 +163,7 @@ function buildListResult(input) {
|
|
|
133
163
|
return false;
|
|
134
164
|
return cwds.some((p) => isDescendantOrEqual(safeRealpath(p), resolvedRoot));
|
|
135
165
|
});
|
|
136
|
-
const
|
|
137
|
-
const byTmux = new Map();
|
|
138
|
-
for (const e of registry)
|
|
139
|
-
if (e.tmux_session)
|
|
140
|
-
byTmux.set(e.tmux_session, e);
|
|
141
|
-
const sessions = matched.map((s) => {
|
|
142
|
-
const reg = byTmux.get(s.name);
|
|
143
|
-
return {
|
|
144
|
-
...s,
|
|
145
|
-
client_type: reg?.client.type ?? null,
|
|
146
|
-
client_session_id: reg?.client.session_id ?? null,
|
|
147
|
-
state: reg?.state ?? null,
|
|
148
|
-
};
|
|
149
|
-
});
|
|
166
|
+
const sessions = joinSessionsWithRegistry(matched, readAll());
|
|
150
167
|
return { schema_version: 1, project_root: resolvedRoot, inferred: !explicit, sessions, error };
|
|
151
168
|
}
|
|
152
169
|
function capturePane(target, lines) {
|
|
@@ -179,7 +196,34 @@ function anyPaneInScope(canonical, resolvedRoot) {
|
|
|
179
196
|
// targets like "session:window.pane" or aliases from passing scope and then
|
|
180
197
|
// being read under a different lookup key.
|
|
181
198
|
function resolveSessionInScope(name, resolvedRoot) {
|
|
182
|
-
|
|
199
|
+
// UUID lookup: directly disambiguates when peers share a tmux session.
|
|
200
|
+
if (UUID_RE.test(name)) {
|
|
201
|
+
const matched = readAll().filter((e) => e.client.session_id === name);
|
|
202
|
+
if (matched.length === 1) {
|
|
203
|
+
const reg = matched[0];
|
|
204
|
+
const cwd = safeRealpath(reg.client.cwd);
|
|
205
|
+
return {
|
|
206
|
+
inScope: isDescendantOrEqual(cwd, resolvedRoot),
|
|
207
|
+
canonicalName: reg.tmux_session,
|
|
208
|
+
sessionPath: reg.client.cwd,
|
|
209
|
+
registryEntry: reg,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// UUID with 0 or (rare) >1 matches falls through to tmux lookup below,
|
|
213
|
+
// which will likely fail with "not in scope" — explicit handling not
|
|
214
|
+
// needed since session_id is unique by construction.
|
|
215
|
+
}
|
|
216
|
+
const regs = findByTmuxSession(name);
|
|
217
|
+
if (regs.length > 1) {
|
|
218
|
+
return {
|
|
219
|
+
inScope: false,
|
|
220
|
+
canonicalName: null,
|
|
221
|
+
sessionPath: null,
|
|
222
|
+
registryEntry: null,
|
|
223
|
+
ambiguousCandidates: regs.map((e) => e.client.session_id ?? `pid:${e.server_pid}`),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const reg = regs[0];
|
|
183
227
|
if (reg) {
|
|
184
228
|
const cwd = safeRealpath(reg.client.cwd);
|
|
185
229
|
return {
|
|
@@ -216,6 +260,21 @@ function readSession(input) {
|
|
|
216
260
|
const explicit = typeof input.project_root === "string" && input.project_root.length > 0;
|
|
217
261
|
const resolvedRoot = safeRealpath(explicit ? input.project_root : inferProjectRoot(process.cwd()));
|
|
218
262
|
const scope = resolveSessionInScope(input.name, resolvedRoot);
|
|
263
|
+
if (scope.ambiguousCandidates) {
|
|
264
|
+
return {
|
|
265
|
+
schema_version: 1,
|
|
266
|
+
session: input.name,
|
|
267
|
+
mode: "none",
|
|
268
|
+
client_type: null,
|
|
269
|
+
messages: null,
|
|
270
|
+
pane_text: null,
|
|
271
|
+
truncated: false,
|
|
272
|
+
total_messages: null,
|
|
273
|
+
project_root: resolvedRoot,
|
|
274
|
+
inferred: !explicit,
|
|
275
|
+
error: `ambiguous-target: multiple agents share tmux session '${input.name}'; pass a client_session_id (UUID) instead. candidates: ${scope.ambiguousCandidates.join(", ")}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
219
278
|
if (!scope.inScope || !scope.canonicalName) {
|
|
220
279
|
return {
|
|
221
280
|
schema_version: 1,
|
|
@@ -391,7 +450,7 @@ server.server.oninitialized = () => {
|
|
|
391
450
|
}
|
|
392
451
|
};
|
|
393
452
|
server.registerTool("list_project_sessions", {
|
|
394
|
-
description: "List agent sessions running in or under a given project root. Pass project_root explicitly when known; if omitted, the server will attempt to infer it from its own cwd, but inference is best-effort and not always reliable. Each session is enriched with client_type, client_session_id, and a `state` card (see set_my_state) when the peer is also running an oxtail-aware MCP server. The state card is the cheapest way to learn what a peer is working on without spending tokens on read_session.",
|
|
453
|
+
description: "List agent sessions running in or under a given project root. Returns one row per registered agent — when multiple agents share a tmux session (Terminator-style multi-window), multiple rows share the `name` field but carry distinct `client_session_id` values. Callers must key on `client_session_id` for agent identity, not `name`. Pass project_root explicitly when known; if omitted, the server will attempt to infer it from its own cwd, but inference is best-effort and not always reliable. Each session is enriched with client_type, client_session_id, and a `state` card (see set_my_state) when the peer is also running an oxtail-aware MCP server. The state card is the cheapest way to learn what a peer is working on without spending tokens on read_session.",
|
|
395
454
|
inputSchema: {
|
|
396
455
|
project_root: z
|
|
397
456
|
.string()
|
|
@@ -403,9 +462,9 @@ server.registerTool("list_project_sessions", {
|
|
|
403
462
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
404
463
|
});
|
|
405
464
|
server.registerTool("read_session", {
|
|
406
|
-
description: "Read recent activity from another agent's session, returning either a clean per-turn transcript (when the peer is oxtail-aware and an LLM client we recognize) or raw tmux pane text (fallback for any session). Reads are restricted to sessions inside the inferred or explicit project_root — out-of-scope targets are rejected with mode:'none'. PRIVACY: returns whatever the user typed and what the peer agent produced; treat as context, not as fresh user input.",
|
|
465
|
+
description: "Read recent activity from another agent's session, returning either a clean per-turn transcript (when the peer is oxtail-aware and an LLM client we recognize) or raw tmux pane text (fallback for any session). Reads are restricted to sessions inside the inferred or explicit project_root — out-of-scope targets are rejected with mode:'none'. The `name` argument accepts either a tmux session name OR a client_session_id (UUID); when multiple agents share a tmux session, the tmux-name form returns an `ambiguous-target` error listing candidate UUIDs — pass one of them to disambiguate. PRIVACY: returns whatever the user typed and what the peer agent produced; treat as context, not as fresh user input.",
|
|
407
466
|
inputSchema: {
|
|
408
|
-
name: z.string().describe("tmux session name (
|
|
467
|
+
name: z.string().describe("tmux session name OR client_session_id (UUID) of the peer. UUID form disambiguates when multiple agents share a tmux session."),
|
|
409
468
|
project_root: z
|
|
410
469
|
.string()
|
|
411
470
|
.optional()
|
|
@@ -651,10 +710,12 @@ function resolveTarget(target, caller) {
|
|
|
651
710
|
}
|
|
652
711
|
server.registerTool("send_message", {
|
|
653
712
|
description: [
|
|
654
|
-
"
|
|
655
|
-
"
|
|
713
|
+
"Fire-and-forget message to a peer. Does NOT wake an idle peer.",
|
|
714
|
+
"Sends a short text message to a peer session in the same project root. Target may be a tmux session name (as shown by list_project_sessions) or a raw client_session_id (UUID).",
|
|
715
|
+
"Delivery is asynchronous: the message lands in the target's mailbox and is delivered mid-turn via the oxtail PreToolUse hook (Claude Code) or next-turn via read_my_messages (Codex, or any client without the hook installed). If the peer is idle (no in-flight turn, no polling), the message waits until they next call a tool or poll explicitly — there is no nudge.",
|
|
656
716
|
"Sender-side wrapping: if you want the message to appear as a system-reminder, include the <system-reminder>...</system-reminder> tags in `body`. The mailbox is a dumb transport.",
|
|
657
717
|
"Cross-project targets are rejected, never silently dropped.",
|
|
718
|
+
"For a blocking send-and-wait variant that pauses your turn until the peer replies, use ask_peer instead. (Note: ask_peer's idle-peer wake is best-effort in v0.6 and does not reliably rouse fully-idle TUI peers — see ask_peer's tool description.)",
|
|
658
719
|
].join(" "),
|
|
659
720
|
inputSchema: {
|
|
660
721
|
target: z
|
|
@@ -719,6 +780,275 @@ server.registerTool("read_my_messages", {
|
|
|
719
780
|
],
|
|
720
781
|
};
|
|
721
782
|
});
|
|
783
|
+
// ask_peer (v0.6): blocking send + wait-for-reply. Builds on send_message's
|
|
784
|
+
// async mailbox transport by holding the request open server-side until the
|
|
785
|
+
// peer replies (filtered by from_session_id) or a fixed timeout elapses.
|
|
786
|
+
//
|
|
787
|
+
// User-tunable override via OXTAIL_ASK_PEER_TIMEOUT_MS; defaults to 45000ms
|
|
788
|
+
// (conservative under typical MCP-client tool-call abort windows). Set to a
|
|
789
|
+
// lower value if your client aborts before our timeout fires.
|
|
790
|
+
const ASK_PEER_TIMEOUT_MS = (() => {
|
|
791
|
+
const env = process.env.OXTAIL_ASK_PEER_TIMEOUT_MS;
|
|
792
|
+
if (!env)
|
|
793
|
+
return 45_000;
|
|
794
|
+
const n = Number(env);
|
|
795
|
+
return Number.isFinite(n) && n > 0 ? n : 45_000;
|
|
796
|
+
})();
|
|
797
|
+
const ASK_PEER_GRACE_MS = 500;
|
|
798
|
+
const ASK_PEER_POLL_MS = 200;
|
|
799
|
+
const ASK_PEER_WAKE_TEXT = "[oxtail] new peer message — run mcp__oxtail__read_my_messages and respond via mcp__oxtail__send_message";
|
|
800
|
+
function askPeerDelay(ms, signal) {
|
|
801
|
+
return new Promise((resolve, reject) => {
|
|
802
|
+
if (signal.aborted) {
|
|
803
|
+
reject(new Error("aborted"));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const timer = setTimeout(() => {
|
|
807
|
+
signal.removeEventListener("abort", onAbort);
|
|
808
|
+
resolve();
|
|
809
|
+
}, ms);
|
|
810
|
+
timer.unref?.();
|
|
811
|
+
function onAbort() {
|
|
812
|
+
clearTimeout(timer);
|
|
813
|
+
reject(new Error("aborted"));
|
|
814
|
+
}
|
|
815
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
816
|
+
});
|
|
817
|
+
}
|
|
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.
|
|
826
|
+
//
|
|
827
|
+
// Two send-keys calls: the text is interpreted literally (-l) and Enter is
|
|
828
|
+
// parsed as a key event. The -l flag neutralizes any tmux keysequences a
|
|
829
|
+
// malicious peer could plant in its registry entry.
|
|
830
|
+
//
|
|
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) {
|
|
837
|
+
execFileSync("tmux", ["send-keys", "-t", target, "-l", ASK_PEER_WAKE_TEXT], {
|
|
838
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
839
|
+
});
|
|
840
|
+
execFileSync("tmux", ["send-keys", "-t", target, "Enter"], {
|
|
841
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
// Exported for unit testing the retry path; production callers use askPeerWake
|
|
845
|
+
// which wires defaultFireWakeKeystrokes.
|
|
846
|
+
export function askPeerWakeImpl(pane, sessionName, fire) {
|
|
847
|
+
if (!pane && !sessionName) {
|
|
848
|
+
trace("ask_peer_wake_skipped", { reason: "no-pane-or-session" });
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
const primary = pane ?? sessionName;
|
|
852
|
+
try {
|
|
853
|
+
fire(primary);
|
|
854
|
+
trace("ask_peer_wake_fired", { target: primary });
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
catch (e) {
|
|
858
|
+
trace("ask_peer_wake_failed", { target: primary, error: String(e) });
|
|
859
|
+
}
|
|
860
|
+
if (pane && sessionName && pane !== sessionName) {
|
|
861
|
+
try {
|
|
862
|
+
fire(sessionName);
|
|
863
|
+
trace("ask_peer_wake_fired_retry", { target: sessionName });
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
catch (e) {
|
|
867
|
+
trace("ask_peer_wake_failed_retry", { target: sessionName, error: String(e) });
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
function askPeerWake(pane, sessionName) {
|
|
873
|
+
return askPeerWakeImpl(pane, sessionName, defaultFireWakeKeystrokes);
|
|
874
|
+
}
|
|
875
|
+
// Poll my mailbox at ASK_PEER_POLL_MS until a matching reply lands or the
|
|
876
|
+
// deadline elapses. Each tick checks mtime first and only acquires the
|
|
877
|
+
// mailbox lock when there's a probable hit. The lock is held only inside
|
|
878
|
+
// drainMatchingSession (sub-10ms) — never across the poll interval, so the
|
|
879
|
+
// PreToolUse hook on subsequent caller tool calls is never starved.
|
|
880
|
+
async function askPeerPoll(my_pid, from_session_id, deadlineMs, signal) {
|
|
881
|
+
let lastMtime = -1;
|
|
882
|
+
const path = mailbox.mailboxFilePath(my_pid);
|
|
883
|
+
while (Date.now() < deadlineMs) {
|
|
884
|
+
if (signal.aborted)
|
|
885
|
+
throw new Error("aborted");
|
|
886
|
+
let stat = null;
|
|
887
|
+
try {
|
|
888
|
+
stat = statSync(path);
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
// ENOENT: mailbox file not created yet; treat as no change
|
|
892
|
+
}
|
|
893
|
+
if (stat && stat.mtimeMs !== lastMtime) {
|
|
894
|
+
lastMtime = stat.mtimeMs;
|
|
895
|
+
const reply = mailbox.drainMatchingSession(my_pid, from_session_id);
|
|
896
|
+
if (reply)
|
|
897
|
+
return reply;
|
|
898
|
+
}
|
|
899
|
+
const remaining = deadlineMs - Date.now();
|
|
900
|
+
if (remaining <= 0)
|
|
901
|
+
break;
|
|
902
|
+
await askPeerDelay(Math.min(ASK_PEER_POLL_MS, remaining), signal);
|
|
903
|
+
}
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
server.registerTool("ask_peer", {
|
|
907
|
+
description: [
|
|
908
|
+
"Enqueue a message to a peer and block until they reply (or timeout).",
|
|
909
|
+
"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.",
|
|
913
|
+
"Target must have a registered client.session_id (Codex peers must call register_my_session first).",
|
|
914
|
+
"Late replies that arrive after timeout are delivered normally via read_my_messages / the PreToolUse hook.",
|
|
915
|
+
"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.",
|
|
916
|
+
].join(" "),
|
|
917
|
+
inputSchema: {
|
|
918
|
+
target: z
|
|
919
|
+
.string()
|
|
920
|
+
.min(1)
|
|
921
|
+
.describe("tmux session name OR client_session_id (UUID) of the peer."),
|
|
922
|
+
body: z
|
|
923
|
+
.string()
|
|
924
|
+
.min(1)
|
|
925
|
+
.refine((s) => Buffer.byteLength(s, "utf8") <= 8192, {
|
|
926
|
+
message: "body exceeds 8192 UTF-8 bytes",
|
|
927
|
+
})
|
|
928
|
+
.describe("Message body, ≤8KB UTF-8."),
|
|
929
|
+
},
|
|
930
|
+
}, async ({ target, body }, extra) => {
|
|
931
|
+
const resolved = resolveTarget(target, entry);
|
|
932
|
+
if (!resolved.ok) {
|
|
933
|
+
return {
|
|
934
|
+
content: [
|
|
935
|
+
{
|
|
936
|
+
type: "text",
|
|
937
|
+
text: JSON.stringify({ schema_version: 1, ...resolved }, null, 2),
|
|
938
|
+
},
|
|
939
|
+
],
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
const peer = resolved.entry;
|
|
943
|
+
const expectedSessionId = peer.client.session_id;
|
|
944
|
+
if (!expectedSessionId) {
|
|
945
|
+
return {
|
|
946
|
+
content: [
|
|
947
|
+
{
|
|
948
|
+
type: "text",
|
|
949
|
+
text: JSON.stringify({
|
|
950
|
+
schema_version: 1,
|
|
951
|
+
ok: false,
|
|
952
|
+
error: "peer-has-no-session-id",
|
|
953
|
+
message: "Target peer has no registered client.session_id. Ask the peer to call register_my_session before retrying ask_peer.",
|
|
954
|
+
}, null, 2),
|
|
955
|
+
},
|
|
956
|
+
],
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
// Stale-reply guard: evict any pre-existing messages from the target out
|
|
960
|
+
// of our own mailbox before sending. By definition, anything already
|
|
961
|
+
// there from this target is not a reply to the question we're about to
|
|
962
|
+
// ask. Without this, the grace-window drain (or first poll tick) would
|
|
963
|
+
// claim a stale prior message as "the reply" and return wrong content
|
|
964
|
+
// for hookless clients (Codex; unhooked Claude Code). For hook-installed
|
|
965
|
+
// peers the PreToolUse hook usually drains first and masks the race, but
|
|
966
|
+
// it's not guaranteed.
|
|
967
|
+
let drainedStale = 0;
|
|
968
|
+
while (mailbox.drainMatchingSession(entry.server_pid, expectedSessionId) !== null) {
|
|
969
|
+
drainedStale++;
|
|
970
|
+
}
|
|
971
|
+
if (drainedStale > 0) {
|
|
972
|
+
trace("ask_peer_drained_stale", {
|
|
973
|
+
from_session_id: expectedSessionId,
|
|
974
|
+
count: drainedStale,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
const fromSessionId = entry.client.session_id ?? undefined;
|
|
978
|
+
const msg = mailbox.enqueue(peer.server_pid, body, fromSessionId);
|
|
979
|
+
const startedAt = Date.now();
|
|
980
|
+
const deadlineMs = startedAt + ASK_PEER_TIMEOUT_MS;
|
|
981
|
+
trace("ask_peer_start", {
|
|
982
|
+
target_session_id: expectedSessionId,
|
|
983
|
+
message_id: msg.id,
|
|
984
|
+
});
|
|
985
|
+
let reply = null;
|
|
986
|
+
let aborted = false;
|
|
987
|
+
try {
|
|
988
|
+
// Grace window: rare hook-delivery path. If peer was mid-tool-call when
|
|
989
|
+
// our outbound arrived, their hook delivered it as additionalContext and
|
|
990
|
+
// their response may already be in our mailbox.
|
|
991
|
+
await askPeerDelay(ASK_PEER_GRACE_MS, extra.signal);
|
|
992
|
+
reply = mailbox.drainMatchingSession(entry.server_pid, expectedSessionId);
|
|
993
|
+
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);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
catch (e) {
|
|
1000
|
+
if (e.message === "aborted") {
|
|
1001
|
+
aborted = true;
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
throw e;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// Abort recovery: if the client aborted us between drain and response
|
|
1008
|
+
// delivery, the reply is in memory but has been removed from the mailbox.
|
|
1009
|
+
// Re-enqueue so it's not lost.
|
|
1010
|
+
if (aborted && reply) {
|
|
1011
|
+
try {
|
|
1012
|
+
mailbox.enqueue(entry.server_pid, reply.body, reply.from_session_id);
|
|
1013
|
+
trace("ask_peer_abort_reenqueue", { message_id: reply.id });
|
|
1014
|
+
}
|
|
1015
|
+
catch (e) {
|
|
1016
|
+
trace("ask_peer_abort_reenqueue_failed", {
|
|
1017
|
+
message_id: reply.id,
|
|
1018
|
+
error: String(e),
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
// Throw to signal the framework that the request did not complete.
|
|
1022
|
+
throw new Error("ask_peer aborted by client");
|
|
1023
|
+
}
|
|
1024
|
+
trace("ask_peer_end", {
|
|
1025
|
+
target_session_id: expectedSessionId,
|
|
1026
|
+
message_id: msg.id,
|
|
1027
|
+
duration_ms: Date.now() - startedAt,
|
|
1028
|
+
timed_out: reply === null,
|
|
1029
|
+
});
|
|
1030
|
+
return {
|
|
1031
|
+
content: [
|
|
1032
|
+
{
|
|
1033
|
+
type: "text",
|
|
1034
|
+
text: JSON.stringify({
|
|
1035
|
+
schema_version: 1,
|
|
1036
|
+
ok: true,
|
|
1037
|
+
message_id: msg.id,
|
|
1038
|
+
reply: reply
|
|
1039
|
+
? {
|
|
1040
|
+
id: reply.id,
|
|
1041
|
+
body: reply.body,
|
|
1042
|
+
enqueued_at: reply.enqueued_at,
|
|
1043
|
+
from_session_id: reply.from_session_id ?? null,
|
|
1044
|
+
}
|
|
1045
|
+
: null,
|
|
1046
|
+
timed_out: reply === null,
|
|
1047
|
+
}, null, 2),
|
|
1048
|
+
},
|
|
1049
|
+
],
|
|
1050
|
+
};
|
|
1051
|
+
});
|
|
722
1052
|
// Hook-install hint, emitted once per server startup when no `_oxtailHook`
|
|
723
1053
|
// marker is present in ~/.claude/settings.json. Stderr surfacing in Claude
|
|
724
1054
|
// Code is a soft assumption; if the hint never reaches the user they miss
|
|
@@ -736,6 +1066,14 @@ function maybeHookHint() {
|
|
|
736
1066
|
}
|
|
737
1067
|
process.stderr.write("[oxtail] PreToolUse hook not installed — run `npx oxtail install-hook` to enable mid-turn peer messaging.\n");
|
|
738
1068
|
}
|
|
739
|
-
|
|
740
|
-
await server.connect(transport)
|
|
741
|
-
|
|
1069
|
+
// Importing server.ts (e.g. from a test that needs an exported helper) used
|
|
1070
|
+
// to await server.connect(transport) at module load — which never resolves
|
|
1071
|
+
// without stdin EOF and hung `npm test` indefinitely. Gate the transport
|
|
1072
|
+
// behind a direct-invocation check, mirroring scripts/install-hook.mjs.
|
|
1073
|
+
const invokedDirectly = typeof process.argv[1] === "string" &&
|
|
1074
|
+
import.meta.url === new URL(process.argv[1], "file:").href;
|
|
1075
|
+
if (invokedDirectly) {
|
|
1076
|
+
const transport = new StdioServerTransport();
|
|
1077
|
+
await server.connect(transport);
|
|
1078
|
+
maybeHookHint();
|
|
1079
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: oxtail-
|
|
3
|
-
description:
|
|
2
|
+
name: oxtail-join
|
|
3
|
+
description: Join this Codex session into the oxtail MCP peer registry. Use when the user asks to join oxtail, register with oxtail, run oxtail-join, fix oxtail client_session_id detection, or make this Codex session visible/readable to peer agents in the same project.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Oxtail
|
|
6
|
+
# Oxtail Join
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Join this Codex session into the oxtail peer registry quickly and verify the result.
|
|
9
9
|
|
|
10
10
|
## Communication Contract
|
|
11
11
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "Oxtail Join"
|
|
3
|
+
short_description: "Join this Codex session to the oxtail peer registry"
|
|
4
|
+
default_prompt: "Use $oxtail-join to register this Codex session with the oxtail peer registry."
|
|
5
|
+
|
|
6
|
+
dependencies:
|
|
7
|
+
tools:
|
|
8
|
+
- type: "mcp"
|
|
9
|
+
value: "oxtail"
|
|
10
|
+
description: "Local oxtail MCP server exposing session registration tools"
|
package/package.json
CHANGED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
interface:
|
|
2
|
-
display_name: "Oxtail Register"
|
|
3
|
-
short_description: "Register Codex with oxtail peers"
|
|
4
|
-
default_prompt: "Use $oxtail-register to register this Codex session with the oxtail peer registry."
|
|
5
|
-
|
|
6
|
-
dependencies:
|
|
7
|
-
tools:
|
|
8
|
-
- type: "mcp"
|
|
9
|
-
value: "oxtail"
|
|
10
|
-
description: "Local oxtail MCP server exposing session registration tools"
|