oxtail 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -3
- package/README.md +50 -17
- package/dist/registry.js +77 -3
- package/dist/server.js +163 -31
- 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,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.
|
|
20
|
+
## Status: v0.7.0 shipped, dogfooding
|
|
21
21
|
|
|
22
22
|
Nine MCP tools live: `list_project_sessions`, `read_session`, `claim_session`, `set_my_state`, `register_my_session`, `get_my_session`, the v0.5 messaging pair `send_message` and `read_my_messages`, and the v0.6 delegate-and-wait primitive `ask_peer`. Registered both project-locally (via `.mcp.json` using `tsx ./src/server.ts` for the dev loop) and globally (in `~/.claude.json` and `~/.codex/config.toml`, pointing at `dist/server.js`).
|
|
23
23
|
|
|
@@ -29,7 +29,9 @@ Current phase remains **dogfooding**: use the tools in real parallel-agent work,
|
|
|
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
|
|
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
|
+
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.
|
|
33
35
|
|
|
34
36
|
## How to collaborate on this project
|
|
35
37
|
|
|
@@ -45,9 +47,14 @@ The v0.6 change: one new MCP tool (`ask_peer`) that turns v0.5's async pings int
|
|
|
45
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.
|
|
46
48
|
4. **Minimum viable first.** One MCP tool that's actually used > five speculative ones.
|
|
47
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
|
+
|
|
48
54
|
## Recently shipped
|
|
49
55
|
|
|
50
|
-
- **
|
|
56
|
+
- **Per-client wake routing (v0.7).** `ask_peer` now routes its wake mechanism per `client_type`. Codex: paste-burst-aware send-keys (500ms gap between text and Enter) — verified to actually submit. Claude Code: fail-fast with `wake_status: "skipped_unsupported"` since the hook surface has no idle event. Response gains a `wake_status` field for caller diagnostics. Pre-wake pane re-resolution closes the stale-pane-ID race from v0.6. `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` env override for rollback. Issue #3 has the spike findings.
|
|
57
|
+
- **Delegate-and-wait (v0.6).** `ask_peer({ target, body })` blocks server-side until the peer replies (filtered by `from_session_id`) or a fixed timeout elapses. Late replies fall back to the v0.5 hook / poll delivery path. Target must have a registered `client.session_id`.
|
|
51
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.
|
|
52
59
|
|
|
53
60
|
## 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.
|
|
@@ -38,14 +40,14 @@ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.6.0/.claude/commands
|
|
|
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.6.
|
|
46
|
-
-o ~/.codex/skills/oxtail-
|
|
47
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.6.
|
|
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.
|
|
@@ -63,9 +65,9 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
|
|
|
63
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` — **fire-and-forget** message to a peer. **Does NOT wake an idle peer
|
|
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+)
|
|
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+)
|
|
68
|
-
- `ask_peer` — **
|
|
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)
|
|
69
71
|
- `register_my_session` — pin this MCP server's `session_id` directly. Kept for debugging; prefer `claim_session`.
|
|
70
72
|
- `get_my_session` — return this MCP server's own registry entry plus a per-strategy detection diagnosis. Useful for debugging.
|
|
71
73
|
|
|
@@ -134,28 +136,59 @@ If you have a PreToolUse hook installed that isn't from Terminator and isn't oxt
|
|
|
134
136
|
|
|
135
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.
|
|
136
138
|
|
|
137
|
-
## Delegate-and-wait (v0.
|
|
139
|
+
## Delegate-and-wait (v0.7)
|
|
138
140
|
|
|
139
|
-
`ask_peer` extends v0.5's mailbox transport into a
|
|
141
|
+
`ask_peer` extends v0.5's mailbox transport into a blocking primitive:
|
|
140
142
|
|
|
141
143
|
```
|
|
142
144
|
ask_peer({ target, body })
|
|
143
|
-
→ {
|
|
145
|
+
→ {
|
|
146
|
+
ok: true,
|
|
147
|
+
message_id,
|
|
148
|
+
wake_status: "fired" | "skipped_unsupported" | "skipped_no_target" | "disabled",
|
|
149
|
+
reply: { id, body, enqueued_at, from_session_id } | null,
|
|
150
|
+
timed_out,
|
|
151
|
+
}
|
|
144
152
|
```
|
|
145
153
|
|
|
146
|
-
|
|
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.
|
|
155
|
+
|
|
156
|
+
`timed_out` is `true` only when the poll loop ran to its deadline without a reply. Fail-fast for `skipped_unsupported` returns `timed_out: false` because no polling was attempted — the message is still enqueued and will be delivered when the peer next enters a turn.
|
|
157
|
+
|
|
158
|
+
### Per-client wake routing
|
|
159
|
+
|
|
160
|
+
v0.7 routes the wake mechanism per `client_type`. Verified 2026-05-13 via spike investigation and falsifying experiment:
|
|
161
|
+
|
|
162
|
+
- **Codex** — `tmux send-keys -l <text>` followed by `send-keys Enter` is the wake. The keystrokes are split by 500ms because Codex's TUI has a paste-burst heuristic in `codex-rs/tui/src/bottom_pane/paste_burst.rs` (`PASTE_BURST_MIN_CHARS=3`, `PASTE_ENTER_SUPPRESS_WINDOW=120ms`) that converts Enter→newline for ~120ms after a fast typed burst. Without the gap, the wake text accumulates in the composer and Enter is suppressed. With the gap, Codex submits and enters a turn. 500ms is a deliberately generous multiple of the documented window for upstream-drift safety.
|
|
163
|
+
|
|
164
|
+
- **Claude Code** — fail-fast. Claude Code's hook surface (per [docs](https://code.claude.com/docs/en/hooks)) has no event that fires while the agent is idle: no polling hook, no external "start a turn" mechanism, `Notification` is outbound-only, `FileChanged` only fires inside an in-flight turn. An external process cannot rouse an idle Claude Code peer. ask_peer returns immediately with `wake_status: "skipped_unsupported"` rather than burning the timeout. The message is enqueued and delivered to the peer's PreToolUse hook on their next tool call (or via explicit `read_my_messages`).
|
|
165
|
+
|
|
166
|
+
- **Unknown** — legacy v0.6 wake (text + Enter, no gap). No implied promise; if a new TUI lands, treat it as unknown until verified.
|
|
167
|
+
|
|
168
|
+
### Wake strategy override
|
|
169
|
+
|
|
170
|
+
`OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` (default `auto`):
|
|
171
|
+
|
|
172
|
+
- `auto` — per-client routing above.
|
|
173
|
+
- `legacy` — v0.6 behavior for every client (no paste-burst gap, no per-client routing). Escape hatch if auto mode misfires.
|
|
174
|
+
- `off` — wake disabled entirely; ask_peer becomes a pure blocking poll. Response surfaces `wake_status: "disabled"`. Useful as a rollback if a Codex update changes the paste-burst constants and the auto-mode delay no longer covers the window.
|
|
175
|
+
|
|
176
|
+
### Mechanics
|
|
147
177
|
|
|
148
178
|
1. Enqueue `body` into the target's mailbox (same as `send_message`).
|
|
149
179
|
2. Wait ~500ms for a hook-delivered reply (rare path — handles the case where the peer was already mid-tool-call and replied immediately).
|
|
150
|
-
3.
|
|
180
|
+
3. Route the wake via `wake_status` resolution (see above). For Claude Code, return immediately. Otherwise fire the wake.
|
|
151
181
|
4. Poll the caller's mailbox at 200ms for a reply with `from_session_id == target.session_id`. Other peers' messages stay in the mailbox untouched.
|
|
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.
|
|
182
|
+
5. Return the reply on match, or `{ reply: null, timed_out: true, wake_status }` after the fixed timeout. Late replies fall back to the normal v0.5 hook / `read_my_messages` path — never lost, just delivered out of band.
|
|
183
|
+
|
|
184
|
+
### Pane staleness
|
|
185
|
+
|
|
186
|
+
Pane targeting can go stale: `tmux_pane` is cached at server startup, but tmux can reuse pane ids after a pane is killed. v0.7 re-resolves the pane from the peer's `server_pid` at wake-time (via process-tree ancestry), preferring the live pane id over the cached one. If the peer is no longer in any tmux pane (orphaned), oxtail falls back to the registered tmux session name. If both targeting attempts fail, `wake_status` returns `skipped_no_target`.
|
|
153
187
|
|
|
154
|
-
Constraints
|
|
188
|
+
### Constraints
|
|
155
189
|
|
|
156
190
|
- The target peer must have a registered `client.session_id`. Codex peers must call `claim_session` / `register_my_session` first; without that, `ask_peer` returns `error: "peer-has-no-session-id"` rather than guessing.
|
|
157
191
|
- Timeout defaults to 45000ms (conservative under typical MCP-client tool-call abort windows). For longer dialogues, the calling agent chains multiple `ask_peer` calls in one turn rather than configuring a longer single block.
|
|
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
192
|
|
|
160
193
|
### Tuning the timeout
|
|
161
194
|
|
|
@@ -209,4 +242,4 @@ If `MCP_TRACE_FILE` is set in the environment, every detection run appends an ND
|
|
|
209
242
|
|
|
210
243
|
## Status
|
|
211
244
|
|
|
212
|
-
v0.
|
|
245
|
+
v0.7.0. Replaces v0.6's wake mechanism with per-client routing after a spike investigation found the root cause was Codex's paste-burst heuristic suppressing Enter, not `\r`-as-newline. Codex idle peers now wake reliably via a 500ms-gap send-keys sequence (verified live 2026-05-13). Claude Code idle peers fail-fast with `wake_status: "skipped_unsupported"` — Claude Code's hook surface has no idle event so they're architecturally unwakeable from outside; the message is still enqueued and delivered next turn. ask_peer's response gains a `wake_status` field for caller diagnostics. Wake strategy is overridable via `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` as a rollback. See [issue #3](https://github.com/d4j3y2k/oxtail/issues/3) for the spike findings.
|
package/dist/registry.js
CHANGED
|
@@ -116,6 +116,15 @@ export function resolveTmuxPane(env = process.env, pid = process.pid) {
|
|
|
116
116
|
return env.TMUX_PANE;
|
|
117
117
|
return findTmuxPaneByAncestry(pid, listTmuxPanePids(), listAllPpids());
|
|
118
118
|
}
|
|
119
|
+
// Resolve the tmux pane currently hosting a given server pid by walking the
|
|
120
|
+
// process tree. Unlike resolveTmuxPane(), this does NOT trust env vars — it
|
|
121
|
+
// queries live tmux + ps state. Used by the ask_peer wake path to detect a
|
|
122
|
+
// stale cached tmux_pane: if a peer's pane was killed and its pane_id reused
|
|
123
|
+
// by an unrelated pane, the cached id no longer points at our peer. Returns
|
|
124
|
+
// null if the server pid is no longer in any tmux pane's process tree.
|
|
125
|
+
export function currentPaneForServerPid(serverPid) {
|
|
126
|
+
return findTmuxPaneByAncestry(serverPid, listTmuxPanePids(), listAllPpids());
|
|
127
|
+
}
|
|
119
128
|
export function buildEntry(client, env = process.env) {
|
|
120
129
|
const tmux_pane = resolveTmuxPane(env);
|
|
121
130
|
return {
|
|
@@ -134,6 +143,13 @@ export function refreshTmuxBinding(entry) {
|
|
|
134
143
|
}
|
|
135
144
|
export function register(entry) {
|
|
136
145
|
ensureDir();
|
|
146
|
+
// Best-effort GC: drop stale entries from dead processes that share our
|
|
147
|
+
// session_id. Happens when oxtail is configured in multiple MCP scopes
|
|
148
|
+
// (user + project), so the same client session has spawned several MCP
|
|
149
|
+
// server children over its lifetime — survivors of crashed prior children
|
|
150
|
+
// accumulate otherwise. Leaves live siblings alone; readAll() collapses
|
|
151
|
+
// those by session_id.
|
|
152
|
+
gcDeadSiblings(entry);
|
|
137
153
|
// Temp file + atomic rename. Concurrent peers running readAll() can otherwise
|
|
138
154
|
// catch a torn write, fail JSON.parse, and silently drop the entry until the
|
|
139
155
|
// next write completes.
|
|
@@ -153,6 +169,38 @@ export function register(entry) {
|
|
|
153
169
|
throw err;
|
|
154
170
|
}
|
|
155
171
|
}
|
|
172
|
+
function gcDeadSiblings(entry) {
|
|
173
|
+
const sid = entry.client.session_id;
|
|
174
|
+
if (!sid)
|
|
175
|
+
return;
|
|
176
|
+
const dir = registryDir();
|
|
177
|
+
if (!existsSync(dir))
|
|
178
|
+
return;
|
|
179
|
+
for (const file of readdirSync(dir)) {
|
|
180
|
+
if (!file.endsWith(".json"))
|
|
181
|
+
continue;
|
|
182
|
+
const full = join(dir, file);
|
|
183
|
+
let other;
|
|
184
|
+
try {
|
|
185
|
+
other = JSON.parse(readFileSync(full, "utf8"));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (other.server_pid === entry.server_pid)
|
|
191
|
+
continue;
|
|
192
|
+
if (other.client.session_id !== sid)
|
|
193
|
+
continue;
|
|
194
|
+
if (isAlive(other.server_pid))
|
|
195
|
+
continue;
|
|
196
|
+
try {
|
|
197
|
+
unlinkSync(full);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// already gone, fine
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
156
204
|
export function unregister(pid = process.pid) {
|
|
157
205
|
try {
|
|
158
206
|
unlinkSync(entryPath(pid));
|
|
@@ -175,7 +223,7 @@ export function readAll() {
|
|
|
175
223
|
const dir = registryDir();
|
|
176
224
|
if (!existsSync(dir))
|
|
177
225
|
return [];
|
|
178
|
-
const
|
|
226
|
+
const live = [];
|
|
179
227
|
for (const file of readdirSync(dir)) {
|
|
180
228
|
if (!file.endsWith(".json"))
|
|
181
229
|
continue;
|
|
@@ -196,9 +244,35 @@ export function readAll() {
|
|
|
196
244
|
}
|
|
197
245
|
continue;
|
|
198
246
|
}
|
|
199
|
-
|
|
247
|
+
live.push(entry);
|
|
248
|
+
}
|
|
249
|
+
return dedupeBySessionId(live);
|
|
250
|
+
}
|
|
251
|
+
// One Claude/Codex session can be backed by multiple MCP server children when
|
|
252
|
+
// oxtail is declared in more than one MCP scope (e.g. user-level config +
|
|
253
|
+
// project `.mcp.json`). Each child registers separately, so the registry ends
|
|
254
|
+
// up with N entries that share the same client.session_id. session_id is the
|
|
255
|
+
// unique agent identity downstream (resolver UUID lookup, peer messaging),
|
|
256
|
+
// so collapse the duplicates here. Keep the freshest by started_at — that's
|
|
257
|
+
// the most likely to have an up-to-date transcript path and tmux binding.
|
|
258
|
+
// Entries with no session_id are left alone: they're either pre-claim
|
|
259
|
+
// (haven't called claim_session yet) or unclaimed peers, and conflating
|
|
260
|
+
// them would be wrong.
|
|
261
|
+
export function dedupeBySessionId(entries) {
|
|
262
|
+
const winnerBySession = new Map();
|
|
263
|
+
const noSession = [];
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
const sid = e.client.session_id;
|
|
266
|
+
if (!sid) {
|
|
267
|
+
noSession.push(e);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const prior = winnerBySession.get(sid);
|
|
271
|
+
if (!prior || e.started_at > prior.started_at) {
|
|
272
|
+
winnerBySession.set(sid, e);
|
|
273
|
+
}
|
|
200
274
|
}
|
|
201
|
-
return
|
|
275
|
+
return [...winnerBySession.values(), ...noSession];
|
|
202
276
|
}
|
|
203
277
|
export function findByTmuxSession(name) {
|
|
204
278
|
return readAll().filter((e) => e.tmux_session === name);
|
package/dist/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import { dirname, join, sep } from "node:path";
|
|
|
9
9
|
import { clientFromHandshake, detectClient, enrichWithDiagnosis, transcriptPathFor, } from "./clients.js";
|
|
10
10
|
import { isAbstain } from "./detect/index.js";
|
|
11
11
|
import { trace } from "./trace.js";
|
|
12
|
-
import { buildEntry, findByTmuxSession, readAll, refreshTmuxBinding, register, unregister, } from "./registry.js";
|
|
12
|
+
import { buildEntry, currentPaneForServerPid, findByTmuxSession, readAll, refreshTmuxBinding, register, unregister, } from "./registry.js";
|
|
13
13
|
import * as mailbox from "./mailbox.js";
|
|
14
14
|
// CLI subcommand dispatch must run before any MCP setup so that
|
|
15
15
|
// `npx oxtail install-hook` doesn't open an MCP transport or register a
|
|
@@ -715,7 +715,7 @@ server.registerTool("send_message", {
|
|
|
715
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.",
|
|
716
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.",
|
|
717
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
|
|
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.)",
|
|
719
719
|
].join(" "),
|
|
720
720
|
inputSchema: {
|
|
721
721
|
target: z
|
|
@@ -797,6 +797,32 @@ const ASK_PEER_TIMEOUT_MS = (() => {
|
|
|
797
797
|
const ASK_PEER_GRACE_MS = 500;
|
|
798
798
|
const ASK_PEER_POLL_MS = 200;
|
|
799
799
|
const ASK_PEER_WAKE_TEXT = "[oxtail] new peer message — run mcp__oxtail__read_my_messages and respond via mcp__oxtail__send_message";
|
|
800
|
+
// Codex's TUI has a paste-burst heuristic at codex-rs/tui/src/bottom_pane/
|
|
801
|
+
// paste_burst.rs (PASTE_BURST_MIN_CHARS=3, PASTE_BURST_CHAR_INTERVAL=8ms,
|
|
802
|
+
// PASTE_ENTER_SUPPRESS_WINDOW=120ms). When `tmux send-keys` blasts the
|
|
803
|
+
// literal-text payload followed immediately by Enter, Codex detects the
|
|
804
|
+
// pattern as a paste and forcibly converts Enter→newline for ~120ms,
|
|
805
|
+
// suppressing the submit. Inserting a delay between the text and the Enter
|
|
806
|
+
// keystrokes lets the suppression window expire so Enter is treated as a
|
|
807
|
+
// real keypress. 500ms is a generous multiple of the documented window for
|
|
808
|
+
// upstream-drift safety — Codex point releases may bump the constant.
|
|
809
|
+
// Verified empirically 2026-05-13 against Codex (gpt-5.5 xhigh).
|
|
810
|
+
const ASK_PEER_CODEX_SUBMIT_DELAY_MS = 500;
|
|
811
|
+
// OXTAIL_ASK_PEER_WAKE_STRATEGY = "auto" | "legacy" | "off"
|
|
812
|
+
// auto — per-client routing: Codex gets paste-burst-aware wake (500ms gap
|
|
813
|
+
// between text and Enter); Claude Code is skipped (no idle hook
|
|
814
|
+
// surface — verified via Claude Code hook docs); unknown clients
|
|
815
|
+
// get legacy v0.6 behavior.
|
|
816
|
+
// legacy — v0.6 behavior for every client (text + Enter, no gap, no
|
|
817
|
+
// per-client routing). Escape hatch if auto mode misfires.
|
|
818
|
+
// off — wake disabled entirely; ask_peer becomes a blocking poll.
|
|
819
|
+
// Caller can rely solely on the peer's natural turn cadence.
|
|
820
|
+
const ASK_PEER_WAKE_STRATEGY = (() => {
|
|
821
|
+
const v = (process.env.OXTAIL_ASK_PEER_WAKE_STRATEGY ?? "auto").toLowerCase();
|
|
822
|
+
if (v === "auto" || v === "legacy" || v === "off")
|
|
823
|
+
return v;
|
|
824
|
+
return "auto";
|
|
825
|
+
})();
|
|
800
826
|
function askPeerDelay(ms, signal) {
|
|
801
827
|
return new Promise((resolve, reject) => {
|
|
802
828
|
if (signal.aborted) {
|
|
@@ -815,34 +841,58 @@ function askPeerDelay(ms, signal) {
|
|
|
815
841
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
816
842
|
});
|
|
817
843
|
}
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
844
|
+
// Wake routing (v0.7). The wake's job is to nudge an idle peer into a turn so
|
|
845
|
+
// it drains its mailbox. Mechanics differ per client:
|
|
846
|
+
//
|
|
847
|
+
// Codex — `tmux send-keys -l <text>` followed by `send-keys Enter` would
|
|
848
|
+
// work, EXCEPT Codex's paste-burst heuristic suppresses Enter for 120ms
|
|
849
|
+
// after a fast typing burst (codex-rs/tui/src/bottom_pane/paste_burst.rs).
|
|
850
|
+
// We insert ASK_PEER_CODEX_SUBMIT_DELAY_MS between the text and the Enter
|
|
851
|
+
// so the suppression window expires. Verified live 2026-05-13.
|
|
852
|
+
//
|
|
853
|
+
// Claude Code — has no hook event that fires while idle (verified via
|
|
854
|
+
// Claude Code's documented hook catalog at code.claude.com/docs/en/hooks;
|
|
855
|
+
// Notification is outbound-only; FileChanged cannot start a turn).
|
|
856
|
+
// No external surface can rouse an idle Claude Code peer. wakePeer()
|
|
857
|
+
// short-circuits with skipped_unsupported for this client_type.
|
|
858
|
+
//
|
|
859
|
+
// Unknown — legacy v0.6 behavior (text + Enter, no gap). No implied
|
|
860
|
+
// promise; if a new TUI lands and breaks, we treat it as unknown until
|
|
861
|
+
// verified.
|
|
862
|
+
//
|
|
863
|
+
// Two send-keys calls: the text is interpreted literally (-l) and Enter is
|
|
864
|
+
// parsed as a key event. The -l flag neutralizes any tmux keysequences a
|
|
865
|
+
// malicious peer could plant in its registry entry.
|
|
822
866
|
//
|
|
823
|
-
// Pane targeting can go stale: tmux_pane is cached at server startup
|
|
824
|
-
// resolveTmuxPane), but Terminator-style window churn can move or
|
|
825
|
-
// pane after registration. send-keys against a dead pane id
|
|
826
|
-
// targeting fails and a sessionName is also available,
|
|
827
|
-
// (targets the session's currently-active pane).
|
|
828
|
-
function defaultFireWakeKeystrokes(target) {
|
|
867
|
+
// Pane targeting can go stale: tmux_pane is cached at server startup
|
|
868
|
+
// (registry resolveTmuxPane), but Terminator-style window churn can move or
|
|
869
|
+
// close the pane after registration. send-keys against a dead pane id
|
|
870
|
+
// errors; if pane targeting fails and a sessionName is also available,
|
|
871
|
+
// retry against it (targets the session's currently-active pane).
|
|
872
|
+
async function defaultFireWakeKeystrokes(target, clientType) {
|
|
829
873
|
execFileSync("tmux", ["send-keys", "-t", target, "-l", ASK_PEER_WAKE_TEXT], {
|
|
830
874
|
stdio: ["ignore", "pipe", "pipe"],
|
|
831
875
|
});
|
|
876
|
+
if (clientType === "codex") {
|
|
877
|
+
await new Promise((resolve) => {
|
|
878
|
+
const timer = setTimeout(resolve, ASK_PEER_CODEX_SUBMIT_DELAY_MS);
|
|
879
|
+
timer.unref?.();
|
|
880
|
+
});
|
|
881
|
+
}
|
|
832
882
|
execFileSync("tmux", ["send-keys", "-t", target, "Enter"], {
|
|
833
883
|
stdio: ["ignore", "pipe", "pipe"],
|
|
834
884
|
});
|
|
835
885
|
}
|
|
836
|
-
// Exported for unit testing the retry path; production callers use
|
|
837
|
-
// which wires defaultFireWakeKeystrokes.
|
|
838
|
-
export function askPeerWakeImpl(pane, sessionName, fire) {
|
|
886
|
+
// Exported for unit testing the retry path; production callers use wakePeer
|
|
887
|
+
// which wires defaultFireWakeKeystrokes via routing.
|
|
888
|
+
export async function askPeerWakeImpl(pane, sessionName, fire) {
|
|
839
889
|
if (!pane && !sessionName) {
|
|
840
890
|
trace("ask_peer_wake_skipped", { reason: "no-pane-or-session" });
|
|
841
891
|
return false;
|
|
842
892
|
}
|
|
843
893
|
const primary = pane ?? sessionName;
|
|
844
894
|
try {
|
|
845
|
-
fire(primary);
|
|
895
|
+
await fire(primary);
|
|
846
896
|
trace("ask_peer_wake_fired", { target: primary });
|
|
847
897
|
return true;
|
|
848
898
|
}
|
|
@@ -851,7 +901,7 @@ export function askPeerWakeImpl(pane, sessionName, fire) {
|
|
|
851
901
|
}
|
|
852
902
|
if (pane && sessionName && pane !== sessionName) {
|
|
853
903
|
try {
|
|
854
|
-
fire(sessionName);
|
|
904
|
+
await fire(sessionName);
|
|
855
905
|
trace("ask_peer_wake_fired_retry", { target: sessionName });
|
|
856
906
|
return true;
|
|
857
907
|
}
|
|
@@ -861,8 +911,55 @@ export function askPeerWakeImpl(pane, sessionName, fire) {
|
|
|
861
911
|
}
|
|
862
912
|
return false;
|
|
863
913
|
}
|
|
864
|
-
|
|
865
|
-
|
|
914
|
+
// Route a wake to a peer based on OXTAIL_ASK_PEER_WAKE_STRATEGY and the
|
|
915
|
+
// peer's client_type. Returns the wake_status that should surface in the
|
|
916
|
+
// ask_peer response so callers can distinguish "we tried, no answer" from
|
|
917
|
+
// "we didn't try because the client can't be woken."
|
|
918
|
+
async function wakePeer(peer) {
|
|
919
|
+
if (ASK_PEER_WAKE_STRATEGY === "off") {
|
|
920
|
+
trace("ask_peer_wake_skipped", { reason: "strategy-off" });
|
|
921
|
+
return "disabled";
|
|
922
|
+
}
|
|
923
|
+
const clientType = peer.client.type;
|
|
924
|
+
if (ASK_PEER_WAKE_STRATEGY === "auto" && clientType === "claude-code") {
|
|
925
|
+
trace("ask_peer_wake_skipped", { reason: "client-unsupported", client_type: clientType });
|
|
926
|
+
return "skipped_unsupported";
|
|
927
|
+
}
|
|
928
|
+
if (!peer.tmux_pane && !peer.tmux_session) {
|
|
929
|
+
return "skipped_no_target";
|
|
930
|
+
}
|
|
931
|
+
// Race-fix: tmux_pane is cached at registration but pane ids can be reused
|
|
932
|
+
// by tmux after a pane is killed. If we send-keys against a reused id we
|
|
933
|
+
// wake the wrong shell. When the peer registered WITH a cached pane,
|
|
934
|
+
// re-resolve from its server_pid at wake-time and prefer the live value.
|
|
935
|
+
// If the peer registered without a pane (no TMUX_PANE in env, no ancestry
|
|
936
|
+
// match), skip the re-resolution entirely — fishing for a pane based on
|
|
937
|
+
// server_pid alone is unsafe (server_pid may not even still be alive, and
|
|
938
|
+
// in tests it can coincide with the test runner's process tree).
|
|
939
|
+
const livePane = peer.tmux_pane
|
|
940
|
+
? currentPaneForServerPid(peer.server_pid)
|
|
941
|
+
: null;
|
|
942
|
+
if (peer.tmux_pane && livePane && livePane !== peer.tmux_pane) {
|
|
943
|
+
trace("ask_peer_wake_pane_refreshed", {
|
|
944
|
+
cached: peer.tmux_pane,
|
|
945
|
+
live: livePane,
|
|
946
|
+
server_pid: peer.server_pid,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
else if (peer.tmux_pane && !livePane) {
|
|
950
|
+
trace("ask_peer_wake_pane_orphaned", {
|
|
951
|
+
cached: peer.tmux_pane,
|
|
952
|
+
server_pid: peer.server_pid,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
const effectivePane = livePane ?? peer.tmux_pane;
|
|
956
|
+
// Legacy mode bypasses per-client routing: every wake is the v0.6 sequence
|
|
957
|
+
// (no inter-keystroke delay). Cast to "unknown" so defaultFireWakeKeystrokes
|
|
958
|
+
// skips the Codex delay branch.
|
|
959
|
+
const fireType = ASK_PEER_WAKE_STRATEGY === "legacy" ? "unknown" : clientType;
|
|
960
|
+
const fire = (target) => defaultFireWakeKeystrokes(target, fireType);
|
|
961
|
+
const ok = await askPeerWakeImpl(effectivePane, peer.tmux_session, fire);
|
|
962
|
+
return ok ? "fired" : "skipped_no_target";
|
|
866
963
|
}
|
|
867
964
|
// Poll my mailbox at ASK_PEER_POLL_MS until a matching reply lands or the
|
|
868
965
|
// deadline elapses. Each tick checks mtime first and only acquires the
|
|
@@ -897,10 +994,13 @@ async function askPeerPoll(my_pid, from_session_id, deadlineMs, signal) {
|
|
|
897
994
|
}
|
|
898
995
|
server.registerTool("ask_peer", {
|
|
899
996
|
description: [
|
|
900
|
-
"
|
|
901
|
-
"Use this when you want a
|
|
902
|
-
"
|
|
903
|
-
"
|
|
997
|
+
"Enqueue a message to a peer and block until they reply (or timeout).",
|
|
998
|
+
"Use this when you want a back-and-forth with another agent in the same project root, rather than fire-and-forget like send_message.",
|
|
999
|
+
"Wake behavior (v0.7) varies per client_type. Codex peers are woken via paste-burst-aware tmux send-keys (literal text + 500ms gap + Enter) so the composer submits. Claude Code peers cannot be woken externally — Claude Code's hook surface has no idle event (verified against the documented hook catalog), so ask_peer fails fast for Claude Code targets and returns wake_status: \"skipped_unsupported\" rather than burning the timeout. Unknown clients use legacy send-keys wake.",
|
|
1000
|
+
"Response includes a wake_status field: \"fired\" (wake attempted or reply received during grace window), \"skipped_unsupported\" (target client cannot be woken — fail-fast, no poll), \"skipped_no_target\" (no tmux pane or session resolved for target), \"disabled\" (OXTAIL_ASK_PEER_WAKE_STRATEGY=off).",
|
|
1001
|
+
"Behavior: enqueues the body to the target's mailbox, waits ~500ms for a hook-delivered reply (rare: peer was mid-turn, hook delivered as additionalContext), fires the per-client wake, then polls this session's mailbox at 200ms for a reply from the target. Fail-fast for skipped_unsupported skips polling entirely; the message is still enqueued and will be delivered the next time the peer enters a turn.",
|
|
1002
|
+
"Returns when the target sends a message back (via send_message) whose from_session_id matches them, or when the timeout elapses (returns reply: null, timed_out: true). timed_out is false on fail-fast (we didn't actually poll). Timeout defaults to 45000ms; user-tunable via OXTAIL_ASK_PEER_TIMEOUT_MS env var.",
|
|
1003
|
+
"Wake strategy can be overridden via OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off (default auto). legacy = v0.6 behavior for every client (no gap, no per-client routing). off = no wake fired; ask_peer becomes a pure blocking poll until the peer naturally enters a turn or timeout.",
|
|
904
1004
|
"Target must have a registered client.session_id (Codex peers must call register_my_session first).",
|
|
905
1005
|
"Late replies that arrive after timeout are delivered normally via read_my_messages / the PreToolUse hook.",
|
|
906
1006
|
"Body framing: peers see the body verbatim. Include a short assignment-style framing (objective, what you want them to do) so they treat it as a delegation, not chat.",
|
|
@@ -975,6 +1075,7 @@ server.registerTool("ask_peer", {
|
|
|
975
1075
|
});
|
|
976
1076
|
let reply = null;
|
|
977
1077
|
let aborted = false;
|
|
1078
|
+
let wakeStatus = "skipped_no_target";
|
|
978
1079
|
try {
|
|
979
1080
|
// Grace window: rare hook-delivery path. If peer was mid-tool-call when
|
|
980
1081
|
// our outbound arrived, their hook delivered it as additionalContext and
|
|
@@ -982,9 +1083,24 @@ server.registerTool("ask_peer", {
|
|
|
982
1083
|
await askPeerDelay(ASK_PEER_GRACE_MS, extra.signal);
|
|
983
1084
|
reply = mailbox.drainMatchingSession(entry.server_pid, expectedSessionId);
|
|
984
1085
|
if (!reply) {
|
|
985
|
-
// Common path: peer was idle
|
|
986
|
-
|
|
987
|
-
|
|
1086
|
+
// Common path: peer was idle. Route the wake per client_type.
|
|
1087
|
+
wakeStatus = await wakePeer(peer);
|
|
1088
|
+
if (wakeStatus === "skipped_unsupported") {
|
|
1089
|
+
// Claude Code idle has no external wake surface — polling would just
|
|
1090
|
+
// burn the caller's wall-clock budget for no reason. Return fast so
|
|
1091
|
+
// the caller can fall back to send_message + read_my_messages, or
|
|
1092
|
+
// wait until the peer is observed mid-turn via list_project_sessions.
|
|
1093
|
+
// The outbound has been enqueued; it'll be delivered next time the
|
|
1094
|
+
// peer enters a turn (via PreToolUse hook or explicit read_my_messages).
|
|
1095
|
+
}
|
|
1096
|
+
else {
|
|
1097
|
+
reply = await askPeerPoll(entry.server_pid, expectedSessionId, deadlineMs, extra.signal);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
// Reply arrived during grace window — peer was already mid-turn and
|
|
1102
|
+
// the hook delivered the outbound to it as additionalContext.
|
|
1103
|
+
wakeStatus = "fired";
|
|
988
1104
|
}
|
|
989
1105
|
}
|
|
990
1106
|
catch (e) {
|
|
@@ -1012,11 +1128,18 @@ server.registerTool("ask_peer", {
|
|
|
1012
1128
|
// Throw to signal the framework that the request did not complete.
|
|
1013
1129
|
throw new Error("ask_peer aborted by client");
|
|
1014
1130
|
}
|
|
1131
|
+
// timed_out is reserved for "we waited and got nothing" — i.e. we actually
|
|
1132
|
+
// polled to the deadline. A fail-fast for an unwakeable client (no poll
|
|
1133
|
+
// attempted) is NOT a timeout; the message has been enqueued and will be
|
|
1134
|
+
// delivered when the peer next enters a turn.
|
|
1135
|
+
const polled = wakeStatus !== "skipped_unsupported";
|
|
1136
|
+
const timedOut = polled && reply === null;
|
|
1015
1137
|
trace("ask_peer_end", {
|
|
1016
1138
|
target_session_id: expectedSessionId,
|
|
1017
1139
|
message_id: msg.id,
|
|
1018
1140
|
duration_ms: Date.now() - startedAt,
|
|
1019
|
-
|
|
1141
|
+
wake_status: wakeStatus,
|
|
1142
|
+
timed_out: timedOut,
|
|
1020
1143
|
});
|
|
1021
1144
|
return {
|
|
1022
1145
|
content: [
|
|
@@ -1026,6 +1149,7 @@ server.registerTool("ask_peer", {
|
|
|
1026
1149
|
schema_version: 1,
|
|
1027
1150
|
ok: true,
|
|
1028
1151
|
message_id: msg.id,
|
|
1152
|
+
wake_status: wakeStatus,
|
|
1029
1153
|
reply: reply
|
|
1030
1154
|
? {
|
|
1031
1155
|
id: reply.id,
|
|
@@ -1034,7 +1158,7 @@ server.registerTool("ask_peer", {
|
|
|
1034
1158
|
from_session_id: reply.from_session_id ?? null,
|
|
1035
1159
|
}
|
|
1036
1160
|
: null,
|
|
1037
|
-
timed_out:
|
|
1161
|
+
timed_out: timedOut,
|
|
1038
1162
|
}, null, 2),
|
|
1039
1163
|
},
|
|
1040
1164
|
],
|
|
@@ -1057,6 +1181,14 @@ function maybeHookHint() {
|
|
|
1057
1181
|
}
|
|
1058
1182
|
process.stderr.write("[oxtail] PreToolUse hook not installed — run `npx oxtail install-hook` to enable mid-turn peer messaging.\n");
|
|
1059
1183
|
}
|
|
1060
|
-
|
|
1061
|
-
await server.connect(transport)
|
|
1062
|
-
|
|
1184
|
+
// Importing server.ts (e.g. from a test that needs an exported helper) used
|
|
1185
|
+
// to await server.connect(transport) at module load — which never resolves
|
|
1186
|
+
// without stdin EOF and hung `npm test` indefinitely. Gate the transport
|
|
1187
|
+
// behind a direct-invocation check, mirroring scripts/install-hook.mjs.
|
|
1188
|
+
const invokedDirectly = typeof process.argv[1] === "string" &&
|
|
1189
|
+
import.meta.url === new URL(process.argv[1], "file:").href;
|
|
1190
|
+
if (invokedDirectly) {
|
|
1191
|
+
const transport = new StdioServerTransport();
|
|
1192
|
+
await server.connect(transport);
|
|
1193
|
+
maybeHookHint();
|
|
1194
|
+
}
|
|
@@ -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"
|