oxtail 0.4.0 → 0.5.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 +13 -4
- package/README.md +51 -7
- package/assets/pretooluse.sh +120 -0
- package/dist/mailbox.js +173 -0
- package/dist/registry.js +15 -10
- package/dist/server.js +182 -0
- package/package.json +4 -1
- package/scripts/hook-constants.mjs +19 -0
- package/scripts/install-hook.mjs +152 -0
- package/scripts/uninstall-hook.mjs +101 -0
package/AGENTS.md
CHANGED
|
@@ -17,15 +17,17 @@ 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.5.0 shipped, dogfooding
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Eight MCP tools live: `list_project_sessions`, `read_session`, `claim_session`, `set_my_state`, `register_my_session`, `get_my_session`, plus the v0.5 messaging pair `send_message` and `read_my_messages`. 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 (
|
|
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.
|
|
29
|
+
|
|
30
|
+
The v0.5 change: two new MCP tools (`send_message`, `read_my_messages`) plus an opt-in `PreToolUse` hook installable via `npx oxtail install-hook`. Friction observed while pairing on Terminator — two agents in the same project root can see each other's state cards and transcripts but couldn't say anything to each other. Now they can. Claude Code peers see messages mid-turn (via the hook); Codex peers (or unhooked Claude Code) see them next-turn (via polling `read_my_messages`).
|
|
29
31
|
|
|
30
32
|
## How to collaborate on this project
|
|
31
33
|
|
|
@@ -41,9 +43,16 @@ Current phase remains **dogfooding**: use the tools in real parallel-agent work,
|
|
|
41
43
|
3. **Both Claude Code and Codex CLI must work** with whatever we build. MCP is the cross-tool protocol; Skills are Claude-specific syntactic sugar that wraps MCP tools, never primary functionality.
|
|
42
44
|
4. **Minimum viable first.** One MCP tool that's actually used > five speculative ones.
|
|
43
45
|
|
|
46
|
+
## Recently shipped
|
|
47
|
+
|
|
48
|
+
- **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
|
+
|
|
44
50
|
## Deliberately deferred
|
|
45
51
|
|
|
46
52
|
- **Output capture** (vs. metadata only). Costs a wrapper layer (`script -F` or pty-mirror). Only worth doing if real friction shows metadata isn't enough.
|
|
47
|
-
- **
|
|
53
|
+
- **Codex mid-turn delivery.** Pending Codex CLI exposing a hook surface.
|
|
54
|
+
- **Delivery receipts / read receipts.** Sender learns `{ ok: true, message_id }`; whether the recipient saw it is invisible. Add when real use names the shape.
|
|
55
|
+
- **Broadcast / multi-recipient send_message.** 1:1 only in v0.5.
|
|
56
|
+
- **Orphan mailbox cleanup.** Mailbox files for dead pids accumulate in `~/.oxtail/mailboxes/`. Tiny and harmless; revisit when real waste shows up in `du`.
|
|
48
57
|
- **Skill set.** Decide after the first MCP tool exists and we know what it feels like to use raw.
|
|
49
58
|
- **MCP tool naming.** Pick after observation tells us the verbs.
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
|
|
|
19
19
|
**Claude Code** — add to `~/.claude.json` (global) or any project's `.mcp.json`:
|
|
20
20
|
|
|
21
21
|
```jsonc
|
|
22
|
-
{ "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.
|
|
22
|
+
{ "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.5.0"] } } }
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
**Codex CLI** — add to `~/.codex/config.toml`:
|
|
@@ -27,14 +27,14 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
|
|
|
27
27
|
```toml
|
|
28
28
|
[mcp_servers.oxtail]
|
|
29
29
|
command = "npx"
|
|
30
|
-
args = ["-y", "oxtail@0.
|
|
30
|
+
args = ["-y", "oxtail@0.5.0"]
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
**Claude slash command** (`/oxtail-join`):
|
|
34
34
|
|
|
35
35
|
```sh
|
|
36
36
|
mkdir -p ~/.claude/commands
|
|
37
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
37
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.5.0/.claude/commands/oxtail-join.md \
|
|
38
38
|
-o ~/.claude/commands/oxtail-join.md
|
|
39
39
|
```
|
|
40
40
|
|
|
@@ -42,9 +42,9 @@ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.4.0/.claude/commands
|
|
|
42
42
|
|
|
43
43
|
```sh
|
|
44
44
|
mkdir -p ~/.codex/skills/oxtail-register/agents
|
|
45
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
45
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.5.0/integrations/codex/oxtail-register/SKILL.md \
|
|
46
46
|
-o ~/.codex/skills/oxtail-register/SKILL.md
|
|
47
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
47
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.5.0/integrations/codex/oxtail-register/agents/openai.yaml \
|
|
48
48
|
-o ~/.codex/skills/oxtail-register/agents/openai.yaml
|
|
49
49
|
```
|
|
50
50
|
|
|
@@ -63,10 +63,12 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
|
|
|
63
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
64
|
- `claim_session` — single-shot session registration. The routine path: `Bash echo $CLAUDE_CODE_SESSION_ID` (or `$CODEX_THREAD_ID` for Codex) → `claim_session({ session_id })`. Returns `{ ok, session_id, transcript_path }`.
|
|
65
65
|
- `set_my_state` — write a small "state card" onto this session's registry entry so peers can see what we're doing without reading our transcript. v1 surfaces a single field, `purpose` (≤200 chars).
|
|
66
|
+
- `send_message` — send a short text message to a peer session in the same project root. Target is a tmux session name or a raw `client_session_id` UUID. Body ≤ 8KB. Delivery is async via the peer's mailbox file. (v0.5+)
|
|
67
|
+
- `read_my_messages` — drain this session's mailbox and return any queued messages. Codex peers (and unhooked Claude Code) poll this; Claude Code peers with the PreToolUse hook installed see messages mid-turn instead. (v0.5+)
|
|
66
68
|
- `register_my_session` — pin this MCP server's `session_id` directly. Kept for debugging; prefer `claim_session`.
|
|
67
69
|
- `get_my_session` — return this MCP server's own registry entry plus a per-strategy detection diagnosis. Useful for debugging.
|
|
68
70
|
|
|
69
|
-
See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.
|
|
71
|
+
See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.5.0/AGENTS.md) for scope and architecture.
|
|
70
72
|
|
|
71
73
|
## Usage from an agent
|
|
72
74
|
|
|
@@ -77,6 +79,8 @@ list_project_sessions({ project_root: "/path/to/project" })
|
|
|
77
79
|
read_session({ name: "primary" }) // auto: transcript if peer registered, else pane
|
|
78
80
|
read_session({ name: "claude", mode: "transcript", limit: 50 })
|
|
79
81
|
read_session({ name: "primary", mode: "pane", pane_lines: 500 })
|
|
82
|
+
send_message({ target: "primary", body: "<system-reminder>checking in</system-reminder>" })
|
|
83
|
+
read_my_messages()
|
|
80
84
|
```
|
|
81
85
|
|
|
82
86
|
Omitting `project_root` triggers a best-effort `.git`-ancestor walk from the server's own cwd. The response includes `inferred: true` when this happens. Pass `project_root` explicitly when you can.
|
|
@@ -85,6 +89,46 @@ Omitting `project_root` triggers a best-effort `.git`-ancestor walk from the ser
|
|
|
85
89
|
|
|
86
90
|
The cheapest way to learn what peers are doing is `list_project_sessions`. Each row carries an optional `state` card written by the peer via `set_my_state` — currently `{ purpose, updated_at }`. Reading the card costs almost nothing compared to `read_session`, which spends tokens on the full transcript. Use `read_session` when the card isn't enough.
|
|
87
91
|
|
|
92
|
+
## Peer messaging (v0.5)
|
|
93
|
+
|
|
94
|
+
Two MCP tools let peers in the same project root talk to each other:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
send_message({ target: "<tmux-session-name OR client_session_id UUID>", body: "..." })
|
|
98
|
+
→ { ok: true, message_id, target_session_id, target_server_pid }
|
|
99
|
+
|
|
100
|
+
read_my_messages()
|
|
101
|
+
→ { ok: true, drained: true, count, messages: [...] }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The mailbox lives at `~/.oxtail/mailboxes/<server_pid>.jsonl`, append-only JSONL, drained under an `mkdir`-based advisory lock. The transport is intentionally dumb: 8KB UTF-8 body cap, sender chooses the framing (raw text or pre-wrapped `<system-reminder>...</system-reminder>`).
|
|
105
|
+
|
|
106
|
+
Cross-project sends are rejected, never silently dropped. Sending to a peer with the same tmux session name as another live peer returns `ambiguous-target` with the candidate `client_session_id`s — use the UUID form to disambiguate.
|
|
107
|
+
|
|
108
|
+
### Mid-turn vs next-turn delivery (the asymmetry)
|
|
109
|
+
|
|
110
|
+
Claude Code peers can receive messages **mid-turn** via an opt-in PreToolUse hook:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
npx oxtail install-hook
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
This drops a small bash script at `~/.oxtail/hooks/pretooluse.sh` and adds a `hooks.PreToolUse` entry in `~/.claude/settings.json`. The hook reads each `PreToolUse` event's `session_id` from stdin, locates the matching mailbox, and emits `additionalContext` into the next tool-call boundary. Reverse with `npx oxtail uninstall-hook`.
|
|
117
|
+
|
|
118
|
+
Codex CLI peers and any Claude Code session without the hook installed receive messages **next-turn** by calling `read_my_messages` explicitly. Both clients send messages identically. The asymmetry exists because Claude Code exposes a PreToolUse hook surface that injects `additionalContext`; Codex CLI does not currently expose an equivalent.
|
|
119
|
+
|
|
120
|
+
**Caveat for Claude Code receivers:** PreToolUse fires only before a tool call. A turn that produces only text — no tool calls — never triggers the hook; messages enqueued during that turn surface on the next tool call (or via an explicit `read_my_messages`). For pair-debugging UX, senders should not assume mid-turn delivery is universal.
|
|
121
|
+
|
|
122
|
+
### Hook coexistence
|
|
123
|
+
|
|
124
|
+
The oxtail hook coexists with other `hooks.PreToolUse` entries. **Verified against Terminator's `_terminatorHook` v1 in Claude Code 2.1.139:** both hooks' `additionalContext` envelopes reached the model. Install order: Terminator first, oxtail second — `install-hook.mjs` appends to a non-empty array, which matches the verified configuration. If you reinstall hooks in a different order, you may need to re-test.
|
|
125
|
+
|
|
126
|
+
If you have a PreToolUse hook installed that isn't from Terminator and isn't oxtail, `install-hook` prints a one-line note and proceeds — coexistence behavior with arbitrary third-party hooks is not pre-verified.
|
|
127
|
+
|
|
128
|
+
### Trust model
|
|
129
|
+
|
|
130
|
+
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
|
+
|
|
88
132
|
## Self-registration and the peer registry
|
|
89
133
|
|
|
90
134
|
Each oxtail server, when spawned by an agent, writes a small record to `~/.oxtail/sessions/<pid>.json` containing the client type, session id, transcript path, and tmux pane. Sibling servers read this directory to find peer transcripts. Records auto-clean on process exit and on read (dead PIDs pruned). Sessions whose agents are not oxtail-aware (or are not LLM agents at all — bash, vim, vite dev servers) still show up in `list_project_sessions` and are readable via `read_session` in pane mode.
|
|
@@ -105,4 +149,4 @@ If `MCP_TRACE_FILE` is set in the environment, every detection run appends an ND
|
|
|
105
149
|
|
|
106
150
|
## Status
|
|
107
151
|
|
|
108
|
-
v0.
|
|
152
|
+
v0.5.0. Peer-to-peer messaging is live: `send_message` / `read_my_messages` over a per-pid mailbox file at `~/.oxtail/mailboxes/`. Claude Code peers receive mid-turn via an opt-in PreToolUse hook (`npx oxtail install-hook`); Codex CLI peers poll. Coexistence with Terminator's `_terminatorHook` verified in Claude Code 2.1.139.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# oxtail PreToolUse hook — delivers peer messages mid-turn to Claude Code.
|
|
3
|
+
#
|
|
4
|
+
# Reads ~/.oxtail/mailboxes/<my-server-pid>.jsonl, emits a hookSpecificOutput
|
|
5
|
+
# envelope, and truncates the mailbox under lock. Pure bash + awk; no jq,
|
|
6
|
+
# python, or node. Exits 0 on every error path so it never blocks a tool call.
|
|
7
|
+
#
|
|
8
|
+
# Step 0a verified that Claude Code strips CLAUDE_CODE_SESSION_ID from hook
|
|
9
|
+
# subprocesses but delivers it via stdin JSON. Stdin is the only path; env
|
|
10
|
+
# is dead code and not consulted here.
|
|
11
|
+
|
|
12
|
+
set -u
|
|
13
|
+
|
|
14
|
+
# 1. Read session_id from stdin JSON. Claude Code's PreToolUse contract
|
|
15
|
+
# delivers a single JSON line on stdin: {"session_id":"...", ...}. If
|
|
16
|
+
# stdin is a tty (interactive run), exit silently.
|
|
17
|
+
sid=""
|
|
18
|
+
if [ ! -t 0 ]; then
|
|
19
|
+
payload=$(cat 2>/dev/null || true)
|
|
20
|
+
sid=$(printf '%s' "$payload" | awk '
|
|
21
|
+
{
|
|
22
|
+
p = index($0, "\"session_id\":\"")
|
|
23
|
+
if (p == 0) next
|
|
24
|
+
rest = substr($0, p + 14)
|
|
25
|
+
out = ""
|
|
26
|
+
i = 1; n = length(rest)
|
|
27
|
+
while (i <= n) {
|
|
28
|
+
c = substr(rest, i, 1)
|
|
29
|
+
if (c == "\\") {
|
|
30
|
+
if (i+1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
|
|
31
|
+
} else if (c == "\"") {
|
|
32
|
+
break
|
|
33
|
+
} else {
|
|
34
|
+
out = out c; i += 1
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
print out; exit
|
|
38
|
+
}
|
|
39
|
+
')
|
|
40
|
+
fi
|
|
41
|
+
[ -z "$sid" ] && exit 0
|
|
42
|
+
|
|
43
|
+
sessions_dir="$HOME/.oxtail/sessions"
|
|
44
|
+
mailboxes_dir="$HOME/.oxtail/mailboxes"
|
|
45
|
+
[ -d "$sessions_dir" ] || exit 0
|
|
46
|
+
[ -d "$mailboxes_dir" ] || exit 0
|
|
47
|
+
|
|
48
|
+
# 2. Find this session's MCP-server pid. Registry files are pretty-printed
|
|
49
|
+
# JSON (key/value separated by ": " with a space), so use grep -E with
|
|
50
|
+
# [[:space:]]* to tolerate either form. -F (fixed-string) is unsafe.
|
|
51
|
+
entry_file=$(grep -lE "\"session_id\"[[:space:]]*:[[:space:]]*\"$sid\"" "$sessions_dir"/*.json 2>/dev/null | head -n 1) || true
|
|
52
|
+
[ -z "$entry_file" ] && exit 0
|
|
53
|
+
|
|
54
|
+
pid=$(basename "$entry_file" .json)
|
|
55
|
+
case "$pid" in *[!0-9]*) exit 0 ;; esac
|
|
56
|
+
|
|
57
|
+
mbox="$mailboxes_dir/$pid.jsonl"
|
|
58
|
+
[ -f "$mbox" ] || exit 0
|
|
59
|
+
[ -s "$mbox" ] || exit 0
|
|
60
|
+
|
|
61
|
+
# 3. Acquire mkdir-based lock. Staleness window is 30s; matches
|
|
62
|
+
# src/mailbox.ts:LOCK_STALE_MS. We can't use `find -mmin +0.5` portably —
|
|
63
|
+
# BSD find and `bfs` reject fractional -mmin — so we read mtime via stat.
|
|
64
|
+
# GNU and BSD stat formats differ, so try both.
|
|
65
|
+
LOCK_STALE_SECS=30
|
|
66
|
+
acquired=0
|
|
67
|
+
for i in $(seq 1 50); do
|
|
68
|
+
if mkdir "$mbox.lock" 2>/dev/null; then acquired=1; break; fi
|
|
69
|
+
now=$(date +%s 2>/dev/null || echo 0)
|
|
70
|
+
mtime=$(stat -c %Y "$mbox.lock" 2>/dev/null || stat -f %m "$mbox.lock" 2>/dev/null || echo 0)
|
|
71
|
+
if [ "$mtime" -gt 0 ] && [ $((now - mtime)) -gt "$LOCK_STALE_SECS" ]; then
|
|
72
|
+
rmdir "$mbox.lock" 2>/dev/null
|
|
73
|
+
fi
|
|
74
|
+
sleep 0.01
|
|
75
|
+
done
|
|
76
|
+
[ "$acquired" -eq 1 ] || exit 0
|
|
77
|
+
|
|
78
|
+
# 4. Extract every line's body field (still JSON-encoded), join with literal
|
|
79
|
+
# \n\n separators, emit hookSpecificOutput envelope. Truncating happens
|
|
80
|
+
# after the awk completes; if awk's output never reaches Claude Code we'd
|
|
81
|
+
# rather have the messages still in the box than lost.
|
|
82
|
+
output=$(awk '
|
|
83
|
+
BEGIN { count = 0 }
|
|
84
|
+
{
|
|
85
|
+
p = index($0, "\"body\":\"")
|
|
86
|
+
if (p == 0) next
|
|
87
|
+
rest = substr($0, p + 8)
|
|
88
|
+
out = ""
|
|
89
|
+
i = 1; n = length(rest)
|
|
90
|
+
while (i <= n) {
|
|
91
|
+
c = substr(rest, i, 1)
|
|
92
|
+
if (c == "\\") {
|
|
93
|
+
if (i + 1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
|
|
94
|
+
} else if (c == "\"") {
|
|
95
|
+
break
|
|
96
|
+
} else {
|
|
97
|
+
out = out c
|
|
98
|
+
i += 1
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
bodies[count++] = out
|
|
102
|
+
}
|
|
103
|
+
END {
|
|
104
|
+
if (count == 0) exit 0
|
|
105
|
+
ctx = ""
|
|
106
|
+
for (j = 0; j < count; j++) {
|
|
107
|
+
if (j > 0) ctx = ctx "\\n\\n"
|
|
108
|
+
ctx = ctx bodies[j]
|
|
109
|
+
}
|
|
110
|
+
printf("{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"%s\"}}\n", ctx)
|
|
111
|
+
}
|
|
112
|
+
' < "$mbox")
|
|
113
|
+
|
|
114
|
+
if [ -n "$output" ]; then
|
|
115
|
+
printf '%s' "$output"
|
|
116
|
+
: > "$mbox"
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
rmdir "$mbox.lock" 2>/dev/null || true
|
|
120
|
+
exit 0
|
package/dist/mailbox.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, rmdirSync, statSync, truncateSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { trace } from "./trace.js";
|
|
6
|
+
// Resolved lazily so tests can swap HOME between cases. Each call re-reads
|
|
7
|
+
// homedir(), which on POSIX defers to $HOME.
|
|
8
|
+
function mailboxesDir() {
|
|
9
|
+
return join(homedir(), ".oxtail", "mailboxes");
|
|
10
|
+
}
|
|
11
|
+
// Lock staleness window. The drainer reads the file, builds the JSON envelope,
|
|
12
|
+
// and writes the truncate back to disk all under lock — under slow disks or OS
|
|
13
|
+
// hiccups, a legitimate-but-slow drain can approach the original 5s threshold
|
|
14
|
+
// and let a peer steal the lock. 30s widens the window to make accidental
|
|
15
|
+
// theft very rare; the trade-off is that a genuinely crashed drainer holds the
|
|
16
|
+
// lock 25s longer before recovery. Worth it.
|
|
17
|
+
//
|
|
18
|
+
// Sync this value with assets/pretooluse.sh (find -mmin +0.5 ≈ 30s).
|
|
19
|
+
const LOCK_STALE_MS = 30_000;
|
|
20
|
+
const LOCK_RETRY_LIMIT = 50;
|
|
21
|
+
const LOCK_RETRY_DELAY_MS = 10;
|
|
22
|
+
function mailboxPath(pid) {
|
|
23
|
+
return join(mailboxesDir(), `${pid}.jsonl`);
|
|
24
|
+
}
|
|
25
|
+
function lockPath(pid) {
|
|
26
|
+
return `${mailboxPath(pid)}.lock`;
|
|
27
|
+
}
|
|
28
|
+
function sleepSync(ms) {
|
|
29
|
+
const end = Date.now() + ms;
|
|
30
|
+
while (Date.now() < end) {
|
|
31
|
+
// tight spin — short enough (10ms) that this is acceptable
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function acquireLock(pid) {
|
|
35
|
+
mkdirSync(mailboxesDir(), { recursive: true, mode: 0o700 });
|
|
36
|
+
const lock = lockPath(pid);
|
|
37
|
+
for (let i = 0; i < LOCK_RETRY_LIMIT; i++) {
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
const err = e;
|
|
44
|
+
if (err.code !== "EEXIST")
|
|
45
|
+
throw err;
|
|
46
|
+
// Check staleness. If older than LOCK_STALE_MS, force-clear and retry.
|
|
47
|
+
try {
|
|
48
|
+
const st = statSync(lock);
|
|
49
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
50
|
+
try {
|
|
51
|
+
rmdirSync(lock);
|
|
52
|
+
trace("mailbox_lock_stale_clear", { pid });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// raced with another clearer; fall through to retry
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// stat may race; just retry
|
|
62
|
+
}
|
|
63
|
+
sleepSync(LOCK_RETRY_DELAY_MS);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`could not acquire mailbox lock for pid ${pid}`);
|
|
67
|
+
}
|
|
68
|
+
export function releaseLock(pid) {
|
|
69
|
+
try {
|
|
70
|
+
rmdirSync(lockPath(pid));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// ignore ENOENT / not-empty / EPERM
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Critical: the serialized JSONL line must always begin
|
|
77
|
+
// `{"schema_version":1,"id":"...","body":"`. The awk extractor in
|
|
78
|
+
// assets/pretooluse.sh assumes `"body":"` is the third key. A future refactor
|
|
79
|
+
// that uses Object.assign / spread / inserts a key could silently reorder and
|
|
80
|
+
// break the hook without breaking unit tests that don't check serialization.
|
|
81
|
+
// The runtime regex below catches that.
|
|
82
|
+
const FIELD_ORDER_PREFIX = /^\{"schema_version":1,"id":"[0-9a-f]{16}","body":"/;
|
|
83
|
+
export function enqueue(target_pid, body, from_session_id) {
|
|
84
|
+
const msg = {
|
|
85
|
+
schema_version: 1,
|
|
86
|
+
id: randomBytes(8).toString("hex"),
|
|
87
|
+
body,
|
|
88
|
+
enqueued_at: Math.floor(Date.now() / 1000),
|
|
89
|
+
...(from_session_id ? { from_session_id } : {}),
|
|
90
|
+
};
|
|
91
|
+
// Build the line by inserting keys in the invariant order. Node's
|
|
92
|
+
// JSON.stringify preserves insertion order for non-integer string keys,
|
|
93
|
+
// which the test suite pins.
|
|
94
|
+
const obj = {
|
|
95
|
+
schema_version: msg.schema_version,
|
|
96
|
+
id: msg.id,
|
|
97
|
+
body: msg.body,
|
|
98
|
+
enqueued_at: msg.enqueued_at,
|
|
99
|
+
};
|
|
100
|
+
if (from_session_id)
|
|
101
|
+
obj.from_session_id = from_session_id;
|
|
102
|
+
const line = JSON.stringify(obj) + "\n";
|
|
103
|
+
if (!FIELD_ORDER_PREFIX.test(line)) {
|
|
104
|
+
throw new Error(`mailbox enqueue: serialized line violates field-order invariant. ` +
|
|
105
|
+
`Got prefix: ${line.slice(0, 80)}`);
|
|
106
|
+
}
|
|
107
|
+
acquireLock(target_pid);
|
|
108
|
+
try {
|
|
109
|
+
appendFileSync(mailboxPath(target_pid), line);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
releaseLock(target_pid);
|
|
113
|
+
}
|
|
114
|
+
return msg;
|
|
115
|
+
}
|
|
116
|
+
export function drain(my_pid) {
|
|
117
|
+
acquireLock(my_pid);
|
|
118
|
+
try {
|
|
119
|
+
let raw;
|
|
120
|
+
try {
|
|
121
|
+
raw = readFileSync(mailboxPath(my_pid), "utf8");
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
const err = e;
|
|
125
|
+
if (err.code === "ENOENT")
|
|
126
|
+
return [];
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
if (!raw)
|
|
130
|
+
return [];
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const line of raw.split("\n")) {
|
|
133
|
+
if (!line)
|
|
134
|
+
continue;
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(line);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
trace("mailbox_drain_skip_invalid", { pid: my_pid, line });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (parsed &&
|
|
144
|
+
typeof parsed === "object" &&
|
|
145
|
+
parsed.schema_version === 1 &&
|
|
146
|
+
typeof parsed.id === "string" &&
|
|
147
|
+
typeof parsed.body === "string") {
|
|
148
|
+
out.push(parsed);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
trace("mailbox_drain_skip_invalid", { pid: my_pid, line });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
truncateSync(mailboxPath(my_pid), 0);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
const err = e;
|
|
159
|
+
if (err.code !== "ENOENT")
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
releaseLock(my_pid);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export function mailboxFilePath(pid) {
|
|
169
|
+
return mailboxPath(pid);
|
|
170
|
+
}
|
|
171
|
+
export function mailboxLockPath(pid) {
|
|
172
|
+
return lockPath(pid);
|
|
173
|
+
}
|
package/dist/registry.js
CHANGED
|
@@ -2,25 +2,29 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
|
|
5
|
+
// Lazy so tests can swap HOME between cases; homedir() defers to $HOME on POSIX.
|
|
6
|
+
function registryDir() {
|
|
7
|
+
return join(homedir(), ".oxtail", "sessions");
|
|
8
|
+
}
|
|
6
9
|
function ensureDir() {
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
const dir = registryDir();
|
|
11
|
+
if (!existsSync(dir)) {
|
|
12
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
9
13
|
return;
|
|
10
14
|
}
|
|
11
15
|
// Migration: tighten perms for users upgrading from <0.4.0, where the dir
|
|
12
16
|
// and entries were created at default umask (typically 0o755 / 0o644).
|
|
13
17
|
try {
|
|
14
|
-
chmodSync(
|
|
18
|
+
chmodSync(dir, 0o700);
|
|
15
19
|
}
|
|
16
20
|
catch {
|
|
17
21
|
// not our dir or fs doesn't support; leave it
|
|
18
22
|
}
|
|
19
|
-
for (const file of readdirSync(
|
|
23
|
+
for (const file of readdirSync(dir)) {
|
|
20
24
|
if (!file.endsWith(".json"))
|
|
21
25
|
continue;
|
|
22
26
|
try {
|
|
23
|
-
chmodSync(join(
|
|
27
|
+
chmodSync(join(dir, file), 0o600);
|
|
24
28
|
}
|
|
25
29
|
catch {
|
|
26
30
|
// ignore
|
|
@@ -28,7 +32,7 @@ function ensureDir() {
|
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
function entryPath(pid) {
|
|
31
|
-
return join(
|
|
35
|
+
return join(registryDir(), `${pid}.json`);
|
|
32
36
|
}
|
|
33
37
|
function resolveTmuxSessionFromPane(pane) {
|
|
34
38
|
if (!pane)
|
|
@@ -168,13 +172,14 @@ function isAlive(pid) {
|
|
|
168
172
|
}
|
|
169
173
|
}
|
|
170
174
|
export function readAll() {
|
|
171
|
-
|
|
175
|
+
const dir = registryDir();
|
|
176
|
+
if (!existsSync(dir))
|
|
172
177
|
return [];
|
|
173
178
|
const out = [];
|
|
174
|
-
for (const file of readdirSync(
|
|
179
|
+
for (const file of readdirSync(dir)) {
|
|
175
180
|
if (!file.endsWith(".json"))
|
|
176
181
|
continue;
|
|
177
|
-
const full = join(
|
|
182
|
+
const full = join(dir, file);
|
|
178
183
|
let entry;
|
|
179
184
|
try {
|
|
180
185
|
entry = JSON.parse(readFileSync(full, "utf8"));
|
package/dist/server.js
CHANGED
|
@@ -4,11 +4,33 @@ 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
6
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { dirname, join, sep } from "node:path";
|
|
8
9
|
import { clientFromHandshake, detectClient, enrichWithDiagnosis, transcriptPathFor, } from "./clients.js";
|
|
9
10
|
import { isAbstain } from "./detect/index.js";
|
|
10
11
|
import { trace } from "./trace.js";
|
|
11
12
|
import { buildEntry, findByTmuxSession, readAll, refreshTmuxBinding, register, unregister, } from "./registry.js";
|
|
13
|
+
import * as mailbox from "./mailbox.js";
|
|
14
|
+
// CLI subcommand dispatch must run before any MCP setup so that
|
|
15
|
+
// `npx oxtail install-hook` doesn't open an MCP transport or register a
|
|
16
|
+
// session. Use named exports and await them; calling `await import(...)`
|
|
17
|
+
// alone resolves at module-evaluation but would let process.exit(0) race
|
|
18
|
+
// the script's async work.
|
|
19
|
+
{
|
|
20
|
+
const sub = process.argv[2];
|
|
21
|
+
if (sub === "install-hook") {
|
|
22
|
+
const url = new URL("../scripts/install-hook.mjs", import.meta.url).href;
|
|
23
|
+
const mod = (await import(url));
|
|
24
|
+
await mod.install();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
if (sub === "uninstall-hook") {
|
|
28
|
+
const url = new URL("../scripts/uninstall-hook.mjs", import.meta.url).href;
|
|
29
|
+
const mod = (await import(url));
|
|
30
|
+
await mod.uninstall();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
12
34
|
import { readClaudeTranscript, readCodexTranscript, } from "./transcripts.js";
|
|
13
35
|
const TMUX_LIST_FORMAT = "#{session_name}|#{session_path}|#{session_created}|#{session_attached}|#{session_windows}";
|
|
14
36
|
const TMUX_PANES_FORMAT = "#{session_name}|#{pane_current_path}";
|
|
@@ -555,5 +577,165 @@ server.registerTool("set_my_state", {
|
|
|
555
577
|
],
|
|
556
578
|
};
|
|
557
579
|
});
|
|
580
|
+
function projectRootsMatch(caller, peer) {
|
|
581
|
+
const myRoot = safeRealpath(inferProjectRoot(caller.client.cwd));
|
|
582
|
+
const peerRoot = safeRealpath(inferProjectRoot(peer.client.cwd));
|
|
583
|
+
if (myRoot === peerRoot)
|
|
584
|
+
return true;
|
|
585
|
+
if (isDescendantOrEqual(safeRealpath(peer.client.cwd), myRoot))
|
|
586
|
+
return true;
|
|
587
|
+
if (isDescendantOrEqual(safeRealpath(caller.client.cwd), peerRoot))
|
|
588
|
+
return true;
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
function isAliveLocal(pid) {
|
|
592
|
+
try {
|
|
593
|
+
process.kill(pid, 0);
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
const err = e;
|
|
598
|
+
return err.code === "EPERM";
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function reReadRegistryEntry(server_pid) {
|
|
602
|
+
// PID-reuse guard: re-read the on-disk file and compare started_at to the
|
|
603
|
+
// one we cached in memory at lookup time. A reused pid lands on a freshly
|
|
604
|
+
// written entry with a different started_at.
|
|
605
|
+
const path = join(homedir(), ".oxtail", "sessions", `${server_pid}.json`);
|
|
606
|
+
try {
|
|
607
|
+
const raw = readFileSync(path, "utf8");
|
|
608
|
+
return JSON.parse(raw);
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const UUID_RE = /^[0-9a-f-]{36}$/;
|
|
615
|
+
function resolveTarget(target, caller) {
|
|
616
|
+
const all = readAll();
|
|
617
|
+
let candidates;
|
|
618
|
+
if (UUID_RE.test(target)) {
|
|
619
|
+
candidates = all.filter((e) => e.client.session_id === target);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
candidates = all.filter((e) => e.tmux_session === target);
|
|
623
|
+
}
|
|
624
|
+
// Liveness + PID-reuse guard: keep only entries whose pid is alive AND whose
|
|
625
|
+
// on-disk started_at still matches what readAll() returned. A reused pid
|
|
626
|
+
// would have been overwritten with a different started_at.
|
|
627
|
+
candidates = candidates.filter((e) => {
|
|
628
|
+
if (!isAliveLocal(e.server_pid))
|
|
629
|
+
return false;
|
|
630
|
+
const fresh = reReadRegistryEntry(e.server_pid);
|
|
631
|
+
if (!fresh)
|
|
632
|
+
return false;
|
|
633
|
+
return fresh.started_at === e.started_at;
|
|
634
|
+
});
|
|
635
|
+
if (candidates.length === 0)
|
|
636
|
+
return { ok: false, error: "target-not-found" };
|
|
637
|
+
if (candidates.length > 1) {
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
error: "ambiguous-target",
|
|
641
|
+
candidates: candidates.map((c) => c.client.session_id ?? `pid:${c.server_pid}`),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const peer = candidates[0];
|
|
645
|
+
// Self-send by pid (definitive identity), not by tmux name / session_id.
|
|
646
|
+
if (peer.server_pid === caller.server_pid)
|
|
647
|
+
return { ok: false, error: "self-send" };
|
|
648
|
+
if (!projectRootsMatch(caller, peer))
|
|
649
|
+
return { ok: false, error: "cross-project" };
|
|
650
|
+
return { ok: true, entry: peer };
|
|
651
|
+
}
|
|
652
|
+
server.registerTool("send_message", {
|
|
653
|
+
description: [
|
|
654
|
+
"Send 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).",
|
|
655
|
+
"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).",
|
|
656
|
+
"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
|
+
"Cross-project targets are rejected, never silently dropped.",
|
|
658
|
+
].join(" "),
|
|
659
|
+
inputSchema: {
|
|
660
|
+
target: z
|
|
661
|
+
.string()
|
|
662
|
+
.min(1)
|
|
663
|
+
.describe("tmux session name OR client_session_id (UUID) of the peer."),
|
|
664
|
+
body: z
|
|
665
|
+
.string()
|
|
666
|
+
.min(1)
|
|
667
|
+
.refine((s) => Buffer.byteLength(s, "utf8") <= 8192, {
|
|
668
|
+
message: "body exceeds 8192 UTF-8 bytes",
|
|
669
|
+
})
|
|
670
|
+
.describe("Message body, ≤8KB UTF-8. The sender chooses the framing."),
|
|
671
|
+
},
|
|
672
|
+
}, async ({ target, body }) => {
|
|
673
|
+
const resolved = resolveTarget(target, entry);
|
|
674
|
+
if (!resolved.ok) {
|
|
675
|
+
return {
|
|
676
|
+
content: [
|
|
677
|
+
{
|
|
678
|
+
type: "text",
|
|
679
|
+
text: JSON.stringify({ schema_version: 1, ...resolved }, null, 2),
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
const peer = resolved.entry;
|
|
685
|
+
const fromSessionId = entry.client.session_id ?? undefined;
|
|
686
|
+
const msg = mailbox.enqueue(peer.server_pid, body, fromSessionId);
|
|
687
|
+
return {
|
|
688
|
+
content: [
|
|
689
|
+
{
|
|
690
|
+
type: "text",
|
|
691
|
+
text: JSON.stringify({
|
|
692
|
+
schema_version: 1,
|
|
693
|
+
ok: true,
|
|
694
|
+
message_id: msg.id,
|
|
695
|
+
target_session_id: peer.client.session_id,
|
|
696
|
+
target_server_pid: peer.server_pid,
|
|
697
|
+
}, null, 2),
|
|
698
|
+
},
|
|
699
|
+
],
|
|
700
|
+
};
|
|
701
|
+
});
|
|
702
|
+
server.registerTool("read_my_messages", {
|
|
703
|
+
description: "Drain this session's mailbox and return any messages peers have sent via send_message. Codex peers and any Claude Code peer without the PreToolUse hook installed must poll this tool explicitly; Claude Code peers with the hook installed will see messages mid-turn instead. Always safe to call — returns an empty list when the mailbox is empty.",
|
|
704
|
+
inputSchema: {},
|
|
705
|
+
}, async () => {
|
|
706
|
+
const messages = mailbox.drain(entry.server_pid);
|
|
707
|
+
return {
|
|
708
|
+
content: [
|
|
709
|
+
{
|
|
710
|
+
type: "text",
|
|
711
|
+
text: JSON.stringify({
|
|
712
|
+
schema_version: 1,
|
|
713
|
+
ok: true,
|
|
714
|
+
drained: true,
|
|
715
|
+
count: messages.length,
|
|
716
|
+
messages,
|
|
717
|
+
}, null, 2),
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
};
|
|
721
|
+
});
|
|
722
|
+
// Hook-install hint, emitted once per server startup when no `_oxtailHook`
|
|
723
|
+
// marker is present in ~/.claude/settings.json. Stderr surfacing in Claude
|
|
724
|
+
// Code is a soft assumption; if the hint never reaches the user they miss
|
|
725
|
+
// the prompt and fall back to polling — acceptable.
|
|
726
|
+
function maybeHookHint() {
|
|
727
|
+
if (entry.client.type !== "claude-code")
|
|
728
|
+
return;
|
|
729
|
+
try {
|
|
730
|
+
const settings = readFileSync(join(homedir(), ".claude", "settings.json"), "utf8");
|
|
731
|
+
if (settings.includes("_oxtailHook"))
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
// settings file missing is itself a signal the hook isn't installed
|
|
736
|
+
}
|
|
737
|
+
process.stderr.write("[oxtail] PreToolUse hook not installed — run `npx oxtail install-hook` to enable mid-turn peer messaging.\n");
|
|
738
|
+
}
|
|
558
739
|
const transport = new StdioServerTransport();
|
|
559
740
|
await server.connect(transport);
|
|
741
|
+
maybeHookHint();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxtail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Coordination layer for parallel AI coding agent sessions, exposed over MCP.",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
|
+
"scripts",
|
|
14
|
+
"assets",
|
|
13
15
|
"integrations",
|
|
14
16
|
".claude/commands/oxtail-join.md",
|
|
15
17
|
"AGENTS.md",
|
|
@@ -47,6 +49,7 @@
|
|
|
47
49
|
},
|
|
48
50
|
"dependencies": {
|
|
49
51
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
|
+
"jsonc-parser": "^3.3.1",
|
|
50
53
|
"zod": "^4.4.3"
|
|
51
54
|
},
|
|
52
55
|
"devDependencies": {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared constants for install-hook.mjs / uninstall-hook.mjs.
|
|
2
|
+
// Tiny on purpose — only the things both scripts genuinely need.
|
|
3
|
+
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
export const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
9
|
+
export const HOOK_MARKER_KEY = "_oxtailHook";
|
|
10
|
+
export const HOOK_MARKER_VERSION = 1;
|
|
11
|
+
export const HOOK_SCRIPT_PATH = path.join(os.homedir(), ".oxtail", "hooks", "pretooluse.sh");
|
|
12
|
+
// The literal command string that ends up in settings.json. Stable across
|
|
13
|
+
// installs — only the script file at HOOK_SCRIPT_PATH may drift, which is
|
|
14
|
+
// why we only hash the script (not the command).
|
|
15
|
+
export const HOOK_COMMAND = `"$HOME/.oxtail/hooks/pretooluse.sh"`;
|
|
16
|
+
|
|
17
|
+
export function scriptHash(text) {
|
|
18
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
19
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Install the oxtail PreToolUse hook into ~/.claude/settings.json.
|
|
3
|
+
//
|
|
4
|
+
// Idempotent: re-running on an installed system reports "already installed"
|
|
5
|
+
// and exits 0 without writing. Format-preserving: edits use jsonc-parser so
|
|
6
|
+
// unrelated keys, whitespace, and comments survive.
|
|
7
|
+
//
|
|
8
|
+
// Reverse with: npx oxtail uninstall-hook
|
|
9
|
+
|
|
10
|
+
import { readFile, writeFile, mkdir, rename, chmod } from "node:fs/promises";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
15
|
+
import {
|
|
16
|
+
SETTINGS_PATH,
|
|
17
|
+
HOOK_MARKER_KEY,
|
|
18
|
+
HOOK_MARKER_VERSION,
|
|
19
|
+
HOOK_SCRIPT_PATH,
|
|
20
|
+
HOOK_COMMAND,
|
|
21
|
+
scriptHash,
|
|
22
|
+
} from "./hook-constants.mjs";
|
|
23
|
+
|
|
24
|
+
const SHIPPED_HOOK_PATH = new URL("../assets/pretooluse.sh", import.meta.url).pathname;
|
|
25
|
+
const FORMATTING = { tabSize: 2, insertSpaces: true };
|
|
26
|
+
|
|
27
|
+
function findOxtailHookIndex(parsed) {
|
|
28
|
+
const arr = parsed?.hooks?.PreToolUse;
|
|
29
|
+
if (!Array.isArray(arr)) return -1;
|
|
30
|
+
return arr.findIndex((entry) => {
|
|
31
|
+
if (!entry || typeof entry !== "object") return false;
|
|
32
|
+
if (!Array.isArray(entry.hooks)) return false;
|
|
33
|
+
return entry.hooks.some(
|
|
34
|
+
(h) =>
|
|
35
|
+
h &&
|
|
36
|
+
typeof h === "object" &&
|
|
37
|
+
typeof h.command === "string" &&
|
|
38
|
+
// Loose match: any command referencing our installed script path.
|
|
39
|
+
h.command.includes("oxtail/hooks/pretooluse.sh"),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function install() {
|
|
45
|
+
const shipped = await readFile(SHIPPED_HOOK_PATH, "utf8");
|
|
46
|
+
const wantHash = scriptHash(shipped);
|
|
47
|
+
|
|
48
|
+
let source = "{}\n";
|
|
49
|
+
if (existsSync(SETTINGS_PATH)) source = await readFile(SETTINGS_PATH, "utf8");
|
|
50
|
+
const parsed = parse(source) ?? {};
|
|
51
|
+
|
|
52
|
+
const marker = parsed[HOOK_MARKER_KEY];
|
|
53
|
+
const existingIdx = findOxtailHookIndex(parsed);
|
|
54
|
+
const upToDate =
|
|
55
|
+
marker &&
|
|
56
|
+
typeof marker === "object" &&
|
|
57
|
+
marker.version === HOOK_MARKER_VERSION &&
|
|
58
|
+
marker.scriptHash === wantHash &&
|
|
59
|
+
existingIdx >= 0 &&
|
|
60
|
+
existsSync(HOOK_SCRIPT_PATH);
|
|
61
|
+
if (upToDate) {
|
|
62
|
+
console.log(
|
|
63
|
+
`oxtail hook already installed (v${HOOK_MARKER_VERSION}, hash ${wantHash.slice(0, 8)}). No changes.`,
|
|
64
|
+
);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Detect competing PreToolUse hooks (e.g. Terminator's _terminatorHook).
|
|
69
|
+
// Behavior under multi-hook coexistence is determined live in Step 5
|
|
70
|
+
// case 11 — for now, warn so users know install order may matter.
|
|
71
|
+
const otherHooks = (parsed?.hooks?.PreToolUse ?? []).filter((entry, idx) => {
|
|
72
|
+
if (idx === existingIdx) return false;
|
|
73
|
+
if (!entry || !Array.isArray(entry.hooks)) return false;
|
|
74
|
+
return entry.hooks.some(
|
|
75
|
+
(h) => h && typeof h.command === "string" && !h.command.includes("oxtail/hooks/pretooluse.sh"),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
if (otherHooks.length > 0) {
|
|
79
|
+
console.warn(
|
|
80
|
+
`[oxtail] note: ${otherHooks.length} other PreToolUse hook(s) already installed. ` +
|
|
81
|
+
`Multi-hook coexistence is supported but install order may matter; ` +
|
|
82
|
+
`see README "Hook coexistence" for details.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Back up settings.json before mutating it (Skeptic M4).
|
|
87
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
88
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
89
|
+
const backup = `${SETTINGS_PATH}.oxtail-backup.${stamp}`;
|
|
90
|
+
await writeFile(backup, source, "utf8");
|
|
91
|
+
console.log(`Backed up existing settings to ${backup}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Install the shipped script atomically.
|
|
95
|
+
const hooksDir = path.dirname(HOOK_SCRIPT_PATH);
|
|
96
|
+
await mkdir(hooksDir, { recursive: true, mode: 0o755 });
|
|
97
|
+
const scriptTmp = `${HOOK_SCRIPT_PATH}.tmp-${randomBytes(6).toString("hex")}`;
|
|
98
|
+
await writeFile(scriptTmp, shipped, { mode: 0o755 });
|
|
99
|
+
// writeFile's `mode` option only applies on file creation; an existing
|
|
100
|
+
// tmp file would keep its previous perms. Explicit chmod for belt+braces.
|
|
101
|
+
await chmod(scriptTmp, 0o755);
|
|
102
|
+
await rename(scriptTmp, HOOK_SCRIPT_PATH);
|
|
103
|
+
|
|
104
|
+
// Edit settings.json. Replace any prior oxtail entry; else append.
|
|
105
|
+
let text = source;
|
|
106
|
+
const newEntry = { hooks: [{ type: "command", command: HOOK_COMMAND }] };
|
|
107
|
+
const arr = parsed?.hooks?.PreToolUse;
|
|
108
|
+
if (existingIdx >= 0) {
|
|
109
|
+
text = applyEdits(
|
|
110
|
+
text,
|
|
111
|
+
modify(text, ["hooks", "PreToolUse", existingIdx], newEntry, { formattingOptions: FORMATTING }),
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
const insertIdx = Array.isArray(arr) ? arr.length : 0;
|
|
115
|
+
text = applyEdits(
|
|
116
|
+
text,
|
|
117
|
+
modify(text, ["hooks", "PreToolUse", insertIdx], newEntry, { formattingOptions: FORMATTING }),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
text = applyEdits(
|
|
121
|
+
text,
|
|
122
|
+
modify(
|
|
123
|
+
text,
|
|
124
|
+
[HOOK_MARKER_KEY],
|
|
125
|
+
{
|
|
126
|
+
version: HOOK_MARKER_VERSION,
|
|
127
|
+
installedAt: new Date().toISOString(),
|
|
128
|
+
scriptHash: wantHash,
|
|
129
|
+
},
|
|
130
|
+
{ formattingOptions: FORMATTING },
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Atomic write of settings.json.
|
|
135
|
+
const settingsTmp = `${SETTINGS_PATH}.oxtail-tmp-${randomBytes(6).toString("hex")}`;
|
|
136
|
+
await mkdir(path.dirname(SETTINGS_PATH), { recursive: true });
|
|
137
|
+
await writeFile(settingsTmp, text, "utf8");
|
|
138
|
+
await rename(settingsTmp, SETTINGS_PATH);
|
|
139
|
+
|
|
140
|
+
console.log(`Installed oxtail PreToolUse hook in ${SETTINGS_PATH}.`);
|
|
141
|
+
console.log("Reverse with: npx oxtail uninstall-hook");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const invokedDirectly =
|
|
145
|
+
typeof process.argv[1] === "string" &&
|
|
146
|
+
import.meta.url === new URL(process.argv[1], "file:").href;
|
|
147
|
+
if (invokedDirectly) {
|
|
148
|
+
install().catch((err) => {
|
|
149
|
+
console.error("install-hook failed:", err?.message ?? err);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Remove the oxtail PreToolUse hook entry and marker from
|
|
3
|
+
// ~/.claude/settings.json, and delete the installed ~/.oxtail/hooks/pretooluse.sh.
|
|
4
|
+
//
|
|
5
|
+
// Idempotent: a clean run on an uninstalled system exits 0 with "nothing to do."
|
|
6
|
+
|
|
7
|
+
import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
12
|
+
import {
|
|
13
|
+
SETTINGS_PATH,
|
|
14
|
+
HOOK_MARKER_KEY,
|
|
15
|
+
HOOK_SCRIPT_PATH,
|
|
16
|
+
} from "./hook-constants.mjs";
|
|
17
|
+
|
|
18
|
+
const FORMATTING = { tabSize: 2, insertSpaces: true };
|
|
19
|
+
|
|
20
|
+
function findOxtailHookIndex(parsed) {
|
|
21
|
+
const arr = parsed?.hooks?.PreToolUse;
|
|
22
|
+
if (!Array.isArray(arr)) return -1;
|
|
23
|
+
return arr.findIndex((entry) => {
|
|
24
|
+
if (!entry || typeof entry !== "object") return false;
|
|
25
|
+
if (!Array.isArray(entry.hooks)) return false;
|
|
26
|
+
return entry.hooks.some(
|
|
27
|
+
(h) =>
|
|
28
|
+
h &&
|
|
29
|
+
typeof h === "object" &&
|
|
30
|
+
typeof h.command === "string" &&
|
|
31
|
+
h.command.includes("oxtail/hooks/pretooluse.sh"),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function uninstall() {
|
|
37
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
38
|
+
console.log(`No ${SETTINGS_PATH} — nothing to do.`);
|
|
39
|
+
// Still try to remove the installed script in case it's a leftover.
|
|
40
|
+
if (existsSync(HOOK_SCRIPT_PATH)) {
|
|
41
|
+
try {
|
|
42
|
+
await unlink(HOOK_SCRIPT_PATH);
|
|
43
|
+
console.log(`Removed ${HOOK_SCRIPT_PATH}.`);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn(`Could not remove ${HOOK_SCRIPT_PATH}: ${err?.message ?? err}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const source = await readFile(SETTINGS_PATH, "utf8");
|
|
52
|
+
const parsed = parse(source) ?? {};
|
|
53
|
+
|
|
54
|
+
const idx = findOxtailHookIndex(parsed);
|
|
55
|
+
const hasMarker =
|
|
56
|
+
parsed[HOOK_MARKER_KEY] && typeof parsed[HOOK_MARKER_KEY] === "object";
|
|
57
|
+
|
|
58
|
+
if (idx < 0 && !hasMarker && !existsSync(HOOK_SCRIPT_PATH)) {
|
|
59
|
+
console.log("oxtail hook not installed — nothing to do.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let text = source;
|
|
64
|
+
if (idx >= 0) {
|
|
65
|
+
text = applyEdits(
|
|
66
|
+
text,
|
|
67
|
+
modify(text, ["hooks", "PreToolUse", idx], undefined, { formattingOptions: FORMATTING }),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (hasMarker) {
|
|
71
|
+
text = applyEdits(
|
|
72
|
+
text,
|
|
73
|
+
modify(text, [HOOK_MARKER_KEY], undefined, { formattingOptions: FORMATTING }),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const settingsTmp = `${SETTINGS_PATH}.oxtail-tmp-${randomBytes(6).toString("hex")}`;
|
|
78
|
+
await mkdir(path.dirname(SETTINGS_PATH), { recursive: true });
|
|
79
|
+
await writeFile(settingsTmp, text, "utf8");
|
|
80
|
+
await rename(settingsTmp, SETTINGS_PATH);
|
|
81
|
+
console.log(`Removed oxtail PreToolUse hook from ${SETTINGS_PATH}.`);
|
|
82
|
+
|
|
83
|
+
if (existsSync(HOOK_SCRIPT_PATH)) {
|
|
84
|
+
try {
|
|
85
|
+
await unlink(HOOK_SCRIPT_PATH);
|
|
86
|
+
console.log(`Removed ${HOOK_SCRIPT_PATH}.`);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.warn(`Could not remove ${HOOK_SCRIPT_PATH}: ${err?.message ?? err}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const invokedDirectly =
|
|
94
|
+
typeof process.argv[1] === "string" &&
|
|
95
|
+
import.meta.url === new URL(process.argv[1], "file:").href;
|
|
96
|
+
if (invokedDirectly) {
|
|
97
|
+
uninstall().catch((err) => {
|
|
98
|
+
console.error("uninstall-hook failed:", err?.message ?? err);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
});
|
|
101
|
+
}
|