oxtail 0.8.0 → 0.10.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/README.md +51 -23
- package/assets/pretooluse.sh +68 -50
- package/assets/stop.sh +171 -0
- package/assets/userpromptsubmit.sh +55 -0
- package/dist/claims.js +228 -0
- package/dist/clients.js +4 -4
- package/dist/mailbox.js +1 -4
- package/dist/server.js +468 -253
- package/dist/transcripts.js +263 -50
- package/package.json +1 -1
- package/scripts/hook-constants.mjs +44 -6
- package/scripts/install-hook.mjs +69 -57
- package/scripts/uninstall-hook.mjs +40 -32
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
|
|
|
21
21
|
**Claude Code** — add to `~/.claude.json` (global) or any project's `.mcp.json`:
|
|
22
22
|
|
|
23
23
|
```jsonc
|
|
24
|
-
{ "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.
|
|
24
|
+
{ "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.10.0"] } } }
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
**Codex CLI** — add to `~/.codex/config.toml`:
|
|
@@ -29,14 +29,14 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
|
|
|
29
29
|
```toml
|
|
30
30
|
[mcp_servers.oxtail]
|
|
31
31
|
command = "npx"
|
|
32
|
-
args = ["-y", "oxtail@0.
|
|
32
|
+
args = ["-y", "oxtail@0.10.0"]
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
**Claude slash command** (`/oxtail-join`):
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
38
|
mkdir -p ~/.claude/commands
|
|
39
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
39
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.10.0/.claude/commands/oxtail-join.md \
|
|
40
40
|
-o ~/.claude/commands/oxtail-join.md
|
|
41
41
|
```
|
|
42
42
|
|
|
@@ -44,9 +44,9 @@ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.8.0/.claude/commands
|
|
|
44
44
|
|
|
45
45
|
```sh
|
|
46
46
|
mkdir -p ~/.codex/skills/oxtail-join/agents
|
|
47
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
47
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.10.0/integrations/codex/oxtail-join/SKILL.md \
|
|
48
48
|
-o ~/.codex/skills/oxtail-join/SKILL.md
|
|
49
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
49
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.10.0/integrations/codex/oxtail-join/agents/openai.yaml \
|
|
50
50
|
-o ~/.codex/skills/oxtail-join/agents/openai.yaml
|
|
51
51
|
```
|
|
52
52
|
|
|
@@ -61,17 +61,17 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
|
|
|
61
61
|
|
|
62
62
|
## MCP tools
|
|
63
63
|
|
|
64
|
-
- `list_project_sessions` — tmux sessions in or under a given project root, enriched with `client_type`, `client_session_id`, and the peer's `state` card. Returns **one row per registered agent** — rows may share `name` when peers share a tmux session (Terminator multi-window). Disambiguate via `client_session_id`.
|
|
65
|
-
- `read_session` — the recent transcript of a peer session, as clean per-turn messages when the peer is oxtail-aware (Claude Code and Codex CLI), or as raw tmux pane text otherwise. Accepts a tmux session name OR a `client_session_id` UUID; an ambiguous tmux name returns `ambiguous-target` with the candidate UUIDs.
|
|
64
|
+
- `list_project_sessions` — tmux sessions in or under a given project root, enriched with `client_type`, `client_session_id`, and the peer's `state` card. Returns **one row per registered agent** — rows may share `name` when peers share a tmux session (Terminator multi-window). Disambiguate via `client_session_id`. Pass `compact: true` for a de-duplicated `tmux_sessions[]` shape that hoists the shared tmux fields and nests agents (smaller when several agents share a session); the default flat `sessions[]` shape is unchanged.
|
|
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. Transcript reads are **budgeted** so a casual read can't blow your context window: by default the last 20 messages and ~24KB of text (newest-first), per-message ISO timestamps omitted. `count_truncated` / `bytes_truncated` say which budget bit; raise `limit` + `max_bytes` to pull more, set `include_timestamps: true` to keep timestamps, and pass `tail_scan: true` to read the file tail without parsing the whole transcript (qualifies `total_messages` via `total_messages_exact`).
|
|
66
66
|
- `claim_session` — single-shot session registration. The routine path: `Bash echo $CLAUDE_CODE_SESSION_ID` (or `$CODEX_THREAD_ID` for Codex) → `claim_session({ session_id })`. Returns `{ ok, session_id, transcript_path }`.
|
|
67
67
|
- `set_my_state` — write a small "state card" onto this session's registry entry so peers can see what we're doing without reading our transcript. v1 surfaces a single field, `purpose` (≤200 chars).
|
|
68
|
-
- `send_message` — **fire-and-forget** message to a peer.
|
|
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
|
|
68
|
+
- `send_message` — **fire-and-forget** message to a peer. Target is a tmux session name or a raw `client_session_id` UUID. Body ≤ 8KB. Delivery is async via the peer's mailbox file. By default does **not** wake an idle peer; pass `wake: "auto"` to nudge one (state-gated — see [Waking an idle peer](#waking-an-idle-peer)). (v0.5+)
|
|
69
|
+
- `read_my_messages` — drain this session's mailbox and return any queued messages. Codex peers (and unhooked Claude Code) poll this; Claude Code peers with the hooks installed see messages mid-turn (PreToolUse) or at turn end (Stop) instead. (v0.5+)
|
|
70
70
|
- `ask_peer` — **delegate-and-wait**. Enqueues a message and blocks server-side until the peer replies (or the fixed timeout elapses, default 45s, tunable via `OXTAIL_ASK_PEER_TIMEOUT_MS`). Routes the wake per `client_type`: Codex gets a paste-burst-aware `tmux send-keys` wake (500ms gap before Enter to defeat the paste-burst heuristic); Claude Code gets the same send-keys mechanism without the gap (its TUI has no paste-burst). Response includes `wake_status` so the caller can distinguish "we polled and got nothing" from "no tmux pane resolved." Use `send_message` for fire-and-forget. (v0.7+)
|
|
71
71
|
- `register_my_session` — pin this MCP server's `session_id` directly. Kept for debugging; prefer `claim_session`.
|
|
72
72
|
- `get_my_session` — return this MCP server's own registry entry plus a per-strategy detection diagnosis. Useful for debugging.
|
|
73
73
|
|
|
74
|
-
See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.
|
|
74
|
+
See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.10.0/AGENTS.md) for scope and architecture.
|
|
75
75
|
|
|
76
76
|
## Usage from an agent
|
|
77
77
|
|
|
@@ -79,9 +79,11 @@ See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.5.0/AGENTS.md)
|
|
|
79
79
|
claim_session({ session_id: "<uuid from $CLAUDE_CODE_SESSION_ID or $CODEX_THREAD_ID>" })
|
|
80
80
|
set_my_state({ purpose: "wiring up state cards" })
|
|
81
81
|
list_project_sessions({ project_root: "/path/to/project" })
|
|
82
|
-
read_session({ name: "primary" }) // auto: transcript if peer registered, else pane
|
|
83
|
-
read_session({ name: "claude", mode: "transcript", limit: 50 })
|
|
84
|
-
read_session({ name: "
|
|
82
|
+
read_session({ name: "primary" }) // auto: transcript if peer registered, else pane (budgeted: last 20 msgs, ~24KB)
|
|
83
|
+
read_session({ name: "claude", mode: "transcript", limit: 50, max_bytes: 60000 }) // pull more
|
|
84
|
+
read_session({ name: "claude", mode: "transcript", include_timestamps: true }) // keep ISO timestamps
|
|
85
|
+
read_session({ name: "claude", mode: "transcript", tail_scan: true }) // fast tail read on huge transcripts
|
|
86
|
+
read_session({ name: "primary", mode: "pane", pane_lines: 500, pane_max_chars: 40000 })
|
|
85
87
|
read_session({ name: "<peer-uuid>", mode: "transcript" }) // UUID form: needed when peers share a tmux session
|
|
86
88
|
send_message({ target: "primary", body: "<system-reminder>checking in</system-reminder>" })
|
|
87
89
|
send_message({ target: "<peer-uuid>", body: "..." }) // UUID form: same disambiguation
|
|
@@ -94,7 +96,7 @@ Omitting `project_root` triggers a best-effort `.git`-ancestor walk from the ser
|
|
|
94
96
|
|
|
95
97
|
## Peer awareness without raw transcripts
|
|
96
98
|
|
|
97
|
-
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
|
|
99
|
+
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 — even budgeted (last 20 messages / ~24KB by default) — spends real tokens on transcript content. Use `read_session` when the card isn't enough.
|
|
98
100
|
|
|
99
101
|
## Peer messaging (v0.5)
|
|
100
102
|
|
|
@@ -114,23 +116,42 @@ Cross-project sends are rejected, never silently dropped. Sending to a peer with
|
|
|
114
116
|
|
|
115
117
|
### Mid-turn vs next-turn delivery (the asymmetry)
|
|
116
118
|
|
|
117
|
-
Claude Code peers can receive messages **
|
|
119
|
+
Claude Code peers can receive messages **autonomously** via three opt-in hooks:
|
|
118
120
|
|
|
119
121
|
```sh
|
|
120
122
|
npx oxtail install-hook
|
|
121
123
|
```
|
|
122
124
|
|
|
123
|
-
This
|
|
125
|
+
This installs three small bash scripts under `~/.oxtail/hooks/` and adds matching entries to `~/.claude/settings.json` (tracked by a `_oxtailHook` marker). Reverse with `npx oxtail uninstall-hook`:
|
|
124
126
|
|
|
125
|
-
|
|
127
|
+
- **`hooks.PreToolUse`** → `pretooluse.sh` — delivers **mid-turn**. It reads each `PreToolUse` event's `session_id` from stdin, locates the matching mailbox, and emits the queued messages as `additionalContext` on the next tool-call boundary.
|
|
128
|
+
- **`hooks.Stop`** → `stop.sh` — delivers **at turn end** (deliver-on-complete). When the agent finishes a turn with messages still waiting, it emits a `decision: "block"` envelope so the agent continues and reads + responds before going idle, instead of leaving the messages until the next turn.
|
|
129
|
+
- **`hooks.UserPromptSubmit`** → `userpromptsubmit.sh` — no delivery; it maintains a **busy/idle activity flag** in `~/.oxtail/activity/<session_id>` (busy on a turn start, idle on a real Stop). A sender consults this so `send_message({ wake: "auto" })` only fires a send-keys wake when the peer is actually idle (see [Waking an idle peer](#waking-an-idle-peer)).
|
|
126
130
|
|
|
127
|
-
|
|
131
|
+
The PreToolUse and Stop hooks include the message body plus `message_id` and `from_session_id` metadata when the sender is registered, so a receiver can reply with `send_message({ target: "<from_session_id>", body: "..." })` even when the sender is not visible in `list_project_sessions`.
|
|
132
|
+
|
|
133
|
+
Codex CLI peers and any Claude Code session without the hooks installed receive messages **next-turn** by calling `read_my_messages` explicitly. Both clients send messages identically. The asymmetry exists because Claude Code exposes PreToolUse/Stop/UserPromptSubmit hook surfaces that inject context or fire on lifecycle events; Codex CLI does not currently expose an equivalent.
|
|
134
|
+
|
|
135
|
+
**Coverage and its edges.** PreToolUse fires only before a tool call, so a turn that produces only text — no tool calls — never triggers it; the Stop hook closes that gap by delivering at turn end. One deliberate edge remains: the Stop hook honors the `stop_hook_active` flag and exits without blocking on a re-entry, so `decision: "block"` can never loop — which means a message that arrives *during* a Stop-blocked continuation waits for the next turn rather than extending the current one. A truly idle peer (no turn in flight) is reached by `send_message({ wake: "auto" })` or `ask_peer` (both fire an external wake), or by an explicit `read_my_messages`.
|
|
136
|
+
|
|
137
|
+
### Waking an idle peer
|
|
138
|
+
|
|
139
|
+
`send_message` is fire-and-forget by default. Pass `wake: "auto"` to also nudge an **idle** peer into a turn so it drains its mailbox promptly:
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
send_message({ target: "<peer>", body: "...", wake: "auto" })
|
|
143
|
+
// → { ok: true, message_id, ..., wake_status: "fired" | "skipped_busy" | "skipped_no_target" | "disabled" }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It is **state-gated** off the activity flag above: if the peer is mid-turn (`busy`), the wake is skipped (`skipped_busy`) because its PreToolUse/Stop hooks will deliver during the turn — no point typing into a busy composer. Idle, unknown (hooks not installed), or stale-busy peers get a per-client `tmux send-keys` wake (Codex gets the paste-burst-aware gap; Claude Code does not). `wake: "off"` (the default) preserves the pure fire-and-forget contract.
|
|
147
|
+
|
|
148
|
+
**Codex and the wake matrix.** The send-keys wake needs a tmux pane. A Codex peer running **outside tmux** has none, so it returns `wake_status: "skipped_no_target"` — its idle delivery stays poll-based (`read_my_messages`). Run Codex **inside a tmux pane** to get symmetric idle-wake; the routing already handles the Codex paste-burst case.
|
|
128
149
|
|
|
129
150
|
### Hook coexistence
|
|
130
151
|
|
|
131
|
-
|
|
152
|
+
`install-hook` manages three events (`PreToolUse`, `Stop`, `UserPromptSubmit`); on each it replaces any prior oxtail entry in place and otherwise appends, so existing third-party entries are preserved. **The PreToolUse path is verified against Terminator's `_terminatorHook` v1 in Claude Code 2.1.139:** both hooks' `additionalContext` envelopes reached the model (install order: Terminator first, oxtail second — which is what `install-hook.mjs` produces by appending). Coexistence of the Stop and UserPromptSubmit hooks with third-party entries on those events uses the same append logic but is not separately verified.
|
|
132
153
|
|
|
133
|
-
If you have a
|
|
154
|
+
If you have a hook installed on a managed event that isn't from Terminator and isn't oxtail, `install-hook` prints a one-line note and proceeds — coexistence behavior with arbitrary third-party hooks is not pre-verified.
|
|
134
155
|
|
|
135
156
|
### Trust model
|
|
136
157
|
|
|
@@ -177,7 +198,7 @@ ask_peer({ target, body })
|
|
|
177
198
|
|
|
178
199
|
1. Enqueue `body` into the target's mailbox (same as `send_message`).
|
|
179
200
|
2. Wait ~500ms for a hook-delivered reply (rare path — handles the case where the peer was already mid-tool-call and replied immediately).
|
|
180
|
-
3. Route the wake via `wake_status` resolution (see above).
|
|
201
|
+
3. Route and fire the wake via `wake_status` resolution (see above).
|
|
181
202
|
4. Poll the caller's mailbox at 200ms for a reply with `from_session_id == target.session_id`. Other peers' messages stay in the mailbox untouched.
|
|
182
203
|
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
204
|
|
|
@@ -195,7 +216,7 @@ Pane targeting can go stale: `tmux_pane` is cached at server startup, but tmux c
|
|
|
195
216
|
If `ask_peer` returns an abort error before its built-in 45s timeout fires, your MCP client's tool-call ceiling is lower than 45s. Override the bound at server startup:
|
|
196
217
|
|
|
197
218
|
```sh
|
|
198
|
-
OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.
|
|
219
|
+
OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.10.0
|
|
199
220
|
```
|
|
200
221
|
|
|
201
222
|
The server reads the env var once at boot and uses it as the fixed timeout for all `ask_peer` calls in that session. Values must be positive numbers; anything else falls back to the 45000ms default.
|
|
@@ -242,4 +263,11 @@ If `MCP_TRACE_FILE` is set in the environment, every detection run appends an ND
|
|
|
242
263
|
|
|
243
264
|
## Status
|
|
244
265
|
|
|
245
|
-
v0.
|
|
266
|
+
v0.9.0. Completes the autonomous peer-messaging matrix: a message reaches a Claude Code peer whether it's mid-turn, finishing, or fully idle — in both directions, with no human relay.
|
|
267
|
+
|
|
268
|
+
- **Deliver-on-complete (Stop hook).** PreToolUse only fires before a tool call, so a text-only turn never triggered it. The new `Stop` hook closes that gap: a message that lands as the agent finishes a turn blocks the stop and is read + answered before it goes idle. Loop-safe via `stop_hook_active`.
|
|
269
|
+
- **State-gated idle wake.** `send_message({ wake: "auto" })` nudges an idle peer via per-client `tmux send-keys`, gated off a busy/idle activity flag maintained by the `UserPromptSubmit`/`Stop` hooks — so it never types into a peer that's mid-turn. Returns `wake_status: fired | skipped_busy | skipped_no_target | disabled`. A Codex peer must be inside a tmux pane to be idle-woken (otherwise `skipped_no_target`, and delivery stays poll-based).
|
|
270
|
+
- **Sticky Codex claim.** A restarted Codex MCP child — whose `CODEX_THREAD_ID` is stripped from its subprocess env — recovers its `session_id` from a persisted claim keyed by client type + cwd + a bounded process-ancestor chain, so identity survives an MCP restart without a manual re-claim.
|
|
271
|
+
- **Identity hardening.** Hooks and activity key on `client.session_id` (never `server_pid`), so a dual-scope agent's sibling MCP children act as one identity; delivery hooks drain all sibling mailboxes; nested git repos are treated as separate projects for scope matching.
|
|
272
|
+
|
|
273
|
+
Builds on v0.7's per-client wake routing (verified live 2026-05-13): Codex peers wake via a 500ms-gap send-keys sequence that defeats their TUI's paste-burst heuristic; Claude Code peers wake via the same mechanism without the gap (no paste-burst in its TUI). `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` remains as a rollback. See [issue #3](https://github.com/d4j3y2k/oxtail/issues/3) for the v0.7 spike findings.
|
package/assets/pretooluse.sh
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# oxtail PreToolUse hook — delivers peer messages mid-turn to Claude Code.
|
|
3
3
|
#
|
|
4
|
-
# Reads
|
|
5
|
-
#
|
|
6
|
-
# python, or node. Exits 0 on every error path so it never blocks a tool call.
|
|
4
|
+
# Reads this session's mailbox(es), emits a hookSpecificOutput envelope, and
|
|
5
|
+
# truncates under lock. Pure bash + awk (bash 3.2-compatible — no mapfile); no
|
|
6
|
+
# jq, python, or node. Exits 0 on every error path so it never blocks a tool call.
|
|
7
|
+
#
|
|
8
|
+
# Identity is keyed by session_id, never server_pid (see AGENTS.md). A dual-scope
|
|
9
|
+
# agent runs several MCP children sharing one session_id; the session's inbox is
|
|
10
|
+
# the UNION of those children's mailboxes, so this drains ALL of them rather than
|
|
11
|
+
# guessing one (the send side enqueues to readAll()'s freshest sibling).
|
|
7
12
|
#
|
|
8
13
|
# Step 0a verified that Claude Code strips CLAUDE_CODE_SESSION_ID from hook
|
|
9
|
-
# subprocesses but delivers it via stdin JSON. Stdin is the only path
|
|
10
|
-
# is dead code and not consulted here.
|
|
14
|
+
# subprocesses but delivers it via stdin JSON. Stdin is the only path.
|
|
11
15
|
|
|
12
16
|
set -u
|
|
13
17
|
|
|
14
|
-
# 1. Read session_id from stdin JSON.
|
|
15
|
-
# delivers a single JSON line on stdin: {"session_id":"...", ...}. If
|
|
16
|
-
# stdin is a tty (interactive run), exit silently.
|
|
18
|
+
# 1. Read session_id from stdin JSON. If stdin is a tty (interactive run), exit.
|
|
17
19
|
sid=""
|
|
18
20
|
if [ ! -t 0 ]; then
|
|
19
21
|
payload=$(cat 2>/dev/null || true)
|
|
@@ -45,46 +47,45 @@ mailboxes_dir="$HOME/.oxtail/mailboxes"
|
|
|
45
47
|
[ -d "$sessions_dir" ] || exit 0
|
|
46
48
|
[ -d "$mailboxes_dir" ] || exit 0
|
|
47
49
|
|
|
48
|
-
# 2.
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
[ -z "$
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
# 2. Collect every non-empty sibling mailbox for this session_id. Registry files
|
|
51
|
+
# are pretty-printed JSON, so grep -E with [[:space:]]* tolerates the form.
|
|
52
|
+
mboxes=()
|
|
53
|
+
while IFS= read -r f; do
|
|
54
|
+
[ -z "$f" ] && continue
|
|
55
|
+
pid=$(basename "$f" .json)
|
|
56
|
+
case "$pid" in *[!0-9]*) continue ;; esac
|
|
57
|
+
m="$mailboxes_dir/$pid.jsonl"
|
|
58
|
+
if [ -f "$m" ] && [ -s "$m" ]; then mboxes+=("$m"); fi
|
|
59
|
+
done < <(grep -lE "\"session_id\"[[:space:]]*:[[:space:]]*\"$sid\"" "$sessions_dir"/*.json 2>/dev/null)
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
[ -f "$mbox" ] || exit 0
|
|
59
|
-
[ -s "$mbox" ] || exit 0
|
|
61
|
+
[ "${#mboxes[@]}" -eq 0 ] && exit 0
|
|
60
62
|
|
|
61
|
-
# 3. Acquire mkdir-based lock
|
|
62
|
-
# src/mailbox.ts:LOCK_STALE_MS.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
sleep 0.01
|
|
63
|
+
# 3. Acquire each mailbox's mkdir-based lock (best-effort; 30s staleness window,
|
|
64
|
+
# matching src/mailbox.ts:LOCK_STALE_MS). GNU and BSD stat formats differ.
|
|
65
|
+
locked=()
|
|
66
|
+
for m in "${mboxes[@]}"; do
|
|
67
|
+
for i in $(seq 1 50); do
|
|
68
|
+
if mkdir "$m.lock" 2>/dev/null; then locked+=("$m"); break; fi
|
|
69
|
+
now=$(date +%s 2>/dev/null || echo 0)
|
|
70
|
+
mtime=$(stat -c %Y "$m.lock" 2>/dev/null || stat -f %m "$m.lock" 2>/dev/null || echo 0)
|
|
71
|
+
if [ "$mtime" -gt 0 ] && [ $((now - mtime)) -gt 30 ]; then
|
|
72
|
+
rmdir "$m.lock" 2>/dev/null
|
|
73
|
+
fi
|
|
74
|
+
sleep 0.01
|
|
75
|
+
done
|
|
75
76
|
done
|
|
76
|
-
[ "$
|
|
77
|
+
[ "${#locked[@]}" -eq 0 ] && exit 0
|
|
77
78
|
|
|
78
|
-
# 4. Extract every line's body
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
# rather
|
|
79
|
+
# 4. Extract every line's body + reply metadata across all locked mailboxes,
|
|
80
|
+
# join into one system-reminder envelope. Truncation happens only after awk
|
|
81
|
+
# produces a valid payload — if the output never reaches Claude Code we'd
|
|
82
|
+
# rather leave the messages in the box than lose them.
|
|
82
83
|
output=$(awk '
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
p = index(
|
|
86
|
-
if (p == 0)
|
|
87
|
-
rest = substr(
|
|
84
|
+
function json_string_field(line, key, needle, p, rest, out, i, n, c) {
|
|
85
|
+
needle = "\"" key "\":\""
|
|
86
|
+
p = index(line, needle)
|
|
87
|
+
if (p == 0) return ""
|
|
88
|
+
rest = substr(line, p + length(needle))
|
|
88
89
|
out = ""
|
|
89
90
|
i = 1; n = length(rest)
|
|
90
91
|
while (i <= n) {
|
|
@@ -98,23 +99,40 @@ output=$(awk '
|
|
|
98
99
|
i += 1
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
|
-
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
BEGIN { count = 0 }
|
|
105
|
+
{
|
|
106
|
+
body = json_string_field($0, "body")
|
|
107
|
+
if (body == "") next
|
|
108
|
+
bodies[count] = body
|
|
109
|
+
ids[count] = json_string_field($0, "id")
|
|
110
|
+
froms[count] = json_string_field($0, "from_session_id")
|
|
111
|
+
count++
|
|
102
112
|
}
|
|
103
113
|
END {
|
|
104
114
|
if (count == 0) exit 0
|
|
105
|
-
ctx = ""
|
|
115
|
+
ctx = "<system-reminder>\\n[oxtail] You have " count " new peer message(s)."
|
|
116
|
+
ctx = ctx "\\nReply to any that need it via mcp__oxtail__send_message (target = the from_session_id below)."
|
|
106
117
|
for (j = 0; j < count; j++) {
|
|
107
|
-
|
|
108
|
-
ctx = ctx
|
|
118
|
+
ctx = ctx "\\n\\n--- message " (j + 1) " ---"
|
|
119
|
+
if (ids[j] != "") ctx = ctx "\\nmessage_id: " ids[j]
|
|
120
|
+
if (froms[j] != "") {
|
|
121
|
+
ctx = ctx "\\nfrom_session_id: " froms[j]
|
|
122
|
+
} else {
|
|
123
|
+
ctx = ctx "\\nfrom_session_id: unknown"
|
|
124
|
+
}
|
|
125
|
+
ctx = ctx "\\nbody:\\n" bodies[j]
|
|
109
126
|
}
|
|
127
|
+
ctx = ctx "\\n</system-reminder>"
|
|
110
128
|
printf("{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"%s\"}}\n", ctx)
|
|
111
129
|
}
|
|
112
|
-
'
|
|
130
|
+
' "${locked[@]}")
|
|
113
131
|
|
|
114
132
|
if [ -n "$output" ]; then
|
|
115
133
|
printf '%s' "$output"
|
|
116
|
-
: > "$
|
|
134
|
+
for m in "${locked[@]}"; do : > "$m"; done
|
|
117
135
|
fi
|
|
118
136
|
|
|
119
|
-
rmdir "$
|
|
137
|
+
for m in "${locked[@]}"; do rmdir "$m.lock" 2>/dev/null || true; done
|
|
120
138
|
exit 0
|
package/assets/stop.sh
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# oxtail Stop hook — two jobs at turn end:
|
|
3
|
+
# 1. Wake-routing: mark this session "idle" in ~/.oxtail/activity/<session_id>
|
|
4
|
+
# on a real stop (no pending messages). When it instead BLOCKS to deliver
|
|
5
|
+
# messages the turn continues, so it leaves the "busy" mark (set by the
|
|
6
|
+
# UserPromptSubmit hook) in place.
|
|
7
|
+
# 2. Delivery: if peer messages landed as the turn finished, emit a
|
|
8
|
+
# {"decision":"block","reason":...} envelope so Claude reads + responds
|
|
9
|
+
# before going idle, and truncate the mailbox(es) under lock.
|
|
10
|
+
#
|
|
11
|
+
# Identity is keyed by session_id, never server_pid (see AGENTS.md). A dual-scope
|
|
12
|
+
# agent runs several MCP children sharing one session_id; the session's inbox is
|
|
13
|
+
# the UNION of those children's mailboxes, so delivery drains ALL of them rather
|
|
14
|
+
# than guessing one. Activity is written under the session_id directly.
|
|
15
|
+
#
|
|
16
|
+
# Pure bash + awk (bash 3.2-compatible — no mapfile); no jq/python/node. Exits 0
|
|
17
|
+
# on every error path so it never wedges the agent. stop_hook_active: on a
|
|
18
|
+
# re-entry (already continuing from a prior block) this is a real stop — mark
|
|
19
|
+
# idle and exit so decision:block can never loop.
|
|
20
|
+
|
|
21
|
+
set -u
|
|
22
|
+
|
|
23
|
+
# 1. Read the full stdin payload once. tty / empty → nothing to do.
|
|
24
|
+
payload=""
|
|
25
|
+
if [ ! -t 0 ]; then
|
|
26
|
+
payload=$(cat 2>/dev/null || true)
|
|
27
|
+
fi
|
|
28
|
+
[ -z "$payload" ] && exit 0
|
|
29
|
+
|
|
30
|
+
# 2. Extract session_id (same scanner as pretooluse.sh).
|
|
31
|
+
sid=$(printf '%s' "$payload" | awk '
|
|
32
|
+
{
|
|
33
|
+
p = index($0, "\"session_id\":\"")
|
|
34
|
+
if (p == 0) next
|
|
35
|
+
rest = substr($0, p + 14)
|
|
36
|
+
out = ""
|
|
37
|
+
i = 1; n = length(rest)
|
|
38
|
+
while (i <= n) {
|
|
39
|
+
c = substr(rest, i, 1)
|
|
40
|
+
if (c == "\\") {
|
|
41
|
+
if (i+1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
|
|
42
|
+
} else if (c == "\"") {
|
|
43
|
+
break
|
|
44
|
+
} else {
|
|
45
|
+
out = out c; i += 1
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
print out; exit
|
|
49
|
+
}
|
|
50
|
+
')
|
|
51
|
+
[ -z "$sid" ] && exit 0
|
|
52
|
+
|
|
53
|
+
activity_dir="$HOME/.oxtail/activity"
|
|
54
|
+
# Sanitize to a safe filename (UUIDs pass through). Must match the server's
|
|
55
|
+
# activitySessionKey() so reads and writes agree on the path.
|
|
56
|
+
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9_-' '_')
|
|
57
|
+
mark_idle() {
|
|
58
|
+
[ -z "$safe_sid" ] && return 0
|
|
59
|
+
mkdir -p "$activity_dir" 2>/dev/null || true
|
|
60
|
+
printf 'idle' > "$activity_dir/$safe_sid" 2>/dev/null || true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# 3. Loop guard: a re-entry is a real stop → mark idle, allow the stop.
|
|
64
|
+
if printf '%s' "$payload" | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
|
|
65
|
+
mark_idle
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
sessions_dir="$HOME/.oxtail/sessions"
|
|
70
|
+
mailboxes_dir="$HOME/.oxtail/mailboxes"
|
|
71
|
+
# Can't locate siblings → it's still a real stop; mark idle and allow it.
|
|
72
|
+
if [ ! -d "$sessions_dir" ] || [ ! -d "$mailboxes_dir" ]; then
|
|
73
|
+
mark_idle
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# 4. Collect every non-empty sibling mailbox for this session_id.
|
|
78
|
+
mboxes=()
|
|
79
|
+
while IFS= read -r f; do
|
|
80
|
+
[ -z "$f" ] && continue
|
|
81
|
+
pid=$(basename "$f" .json)
|
|
82
|
+
case "$pid" in *[!0-9]*) continue ;; esac
|
|
83
|
+
m="$mailboxes_dir/$pid.jsonl"
|
|
84
|
+
if [ -f "$m" ] && [ -s "$m" ]; then mboxes+=("$m"); fi
|
|
85
|
+
done < <(grep -lE "\"session_id\"[[:space:]]*:[[:space:]]*\"$sid\"" "$sessions_dir"/*.json 2>/dev/null)
|
|
86
|
+
|
|
87
|
+
# Nothing to deliver → real stop.
|
|
88
|
+
if [ "${#mboxes[@]}" -eq 0 ]; then
|
|
89
|
+
mark_idle
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# 5. Lock each non-empty mailbox (best-effort; 30s staleness window).
|
|
94
|
+
locked=()
|
|
95
|
+
for m in "${mboxes[@]}"; do
|
|
96
|
+
for i in $(seq 1 50); do
|
|
97
|
+
if mkdir "$m.lock" 2>/dev/null; then locked+=("$m"); break; fi
|
|
98
|
+
now=$(date +%s 2>/dev/null || echo 0)
|
|
99
|
+
mt=$(stat -c %Y "$m.lock" 2>/dev/null || stat -f %m "$m.lock" 2>/dev/null || echo 0)
|
|
100
|
+
if [ "$mt" -gt 0 ] && [ $((now - mt)) -gt 30 ]; then rmdir "$m.lock" 2>/dev/null; fi
|
|
101
|
+
sleep 0.01
|
|
102
|
+
done
|
|
103
|
+
done
|
|
104
|
+
# Couldn't lock anything → leave messages for next time. This still allows the
|
|
105
|
+
# turn to stop, so mark idle; otherwise wake:auto will suppress a wake for a
|
|
106
|
+
# peer that is no longer actually busy.
|
|
107
|
+
if [ "${#locked[@]}" -eq 0 ]; then
|
|
108
|
+
mark_idle
|
|
109
|
+
exit 0
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
# 6. Build the decision:block reason from every locked mailbox's lines.
|
|
113
|
+
output=$(awk '
|
|
114
|
+
function json_string_field(line, key, needle, p, rest, out, i, n, c) {
|
|
115
|
+
needle = "\"" key "\":\""
|
|
116
|
+
p = index(line, needle)
|
|
117
|
+
if (p == 0) return ""
|
|
118
|
+
rest = substr(line, p + length(needle))
|
|
119
|
+
out = ""
|
|
120
|
+
i = 1; n = length(rest)
|
|
121
|
+
while (i <= n) {
|
|
122
|
+
c = substr(rest, i, 1)
|
|
123
|
+
if (c == "\\") {
|
|
124
|
+
if (i + 1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
|
|
125
|
+
} else if (c == "\"") {
|
|
126
|
+
break
|
|
127
|
+
} else {
|
|
128
|
+
out = out c
|
|
129
|
+
i += 1
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
BEGIN { count = 0 }
|
|
135
|
+
{
|
|
136
|
+
body = json_string_field($0, "body")
|
|
137
|
+
if (body == "") next
|
|
138
|
+
bodies[count] = body
|
|
139
|
+
ids[count] = json_string_field($0, "id")
|
|
140
|
+
froms[count] = json_string_field($0, "from_session_id")
|
|
141
|
+
count++
|
|
142
|
+
}
|
|
143
|
+
END {
|
|
144
|
+
if (count == 0) exit 0
|
|
145
|
+
r = "[oxtail] " count " new peer message(s) arrived as you finished your turn. Read them and respond before stopping."
|
|
146
|
+
r = r "\\nReply to any that need it via mcp__oxtail__send_message (target = the from_session_id below)."
|
|
147
|
+
for (j = 0; j < count; j++) {
|
|
148
|
+
r = r "\\n\\n--- message " (j + 1) " ---"
|
|
149
|
+
if (ids[j] != "") r = r "\\nmessage_id: " ids[j]
|
|
150
|
+
if (froms[j] != "") {
|
|
151
|
+
r = r "\\nfrom_session_id: " froms[j]
|
|
152
|
+
} else {
|
|
153
|
+
r = r "\\nfrom_session_id: unknown"
|
|
154
|
+
}
|
|
155
|
+
r = r "\\nbody:\\n" bodies[j]
|
|
156
|
+
}
|
|
157
|
+
printf("{\"decision\":\"block\",\"reason\":\"%s\"}\n", r)
|
|
158
|
+
}
|
|
159
|
+
' "${locked[@]}")
|
|
160
|
+
|
|
161
|
+
if [ -n "$output" ]; then
|
|
162
|
+
# Blocking: the turn continues, so leave the "busy" mark in place.
|
|
163
|
+
printf '%s' "$output"
|
|
164
|
+
for m in "${locked[@]}"; do : > "$m"; done
|
|
165
|
+
else
|
|
166
|
+
# Nothing deliverable → real stop.
|
|
167
|
+
mark_idle
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
for m in "${locked[@]}"; do rmdir "$m.lock" 2>/dev/null || true; done
|
|
171
|
+
exit 0
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# oxtail UserPromptSubmit hook — marks this session "busy" for wake-routing.
|
|
3
|
+
#
|
|
4
|
+
# Writes "busy" to ~/.oxtail/activity/<session_id> whenever a turn starts (the
|
|
5
|
+
# user — or a peer's send-keys wake — submits a prompt). The Stop hook writes
|
|
6
|
+
# "idle" when a turn ends. A sender consults this file so send_message with
|
|
7
|
+
# wake:"auto" only fires a send-keys wake when the peer is NOT mid-turn — the
|
|
8
|
+
# PreToolUse/Stop hooks deliver during a turn, so waking then would type into a
|
|
9
|
+
# busy composer.
|
|
10
|
+
#
|
|
11
|
+
# Keyed by session_id (the agent identity), NOT server_pid: a dual-scope agent
|
|
12
|
+
# runs several MCP children that share one session_id, and the sender reads this
|
|
13
|
+
# by the peer's session_id (see AGENTS.md — never key peer identity on
|
|
14
|
+
# server_pid). No registry/pid lookup needed; the session_id comes straight from
|
|
15
|
+
# the hook payload. Pure bash; no jq/python/node. Exits 0 on every path.
|
|
16
|
+
|
|
17
|
+
set -u
|
|
18
|
+
|
|
19
|
+
# Read session_id from stdin JSON (Claude Code delivers it the same way it does
|
|
20
|
+
# for PreToolUse). tty / no payload → nothing to do.
|
|
21
|
+
sid=""
|
|
22
|
+
if [ ! -t 0 ]; then
|
|
23
|
+
payload=$(cat 2>/dev/null || true)
|
|
24
|
+
sid=$(printf '%s' "$payload" | awk '
|
|
25
|
+
{
|
|
26
|
+
p = index($0, "\"session_id\":\"")
|
|
27
|
+
if (p == 0) next
|
|
28
|
+
rest = substr($0, p + 14)
|
|
29
|
+
out = ""
|
|
30
|
+
i = 1; n = length(rest)
|
|
31
|
+
while (i <= n) {
|
|
32
|
+
c = substr(rest, i, 1)
|
|
33
|
+
if (c == "\\") {
|
|
34
|
+
if (i+1 <= n) { out = out substr(rest, i, 2); i += 2 } else { i += 1 }
|
|
35
|
+
} else if (c == "\"") {
|
|
36
|
+
break
|
|
37
|
+
} else {
|
|
38
|
+
out = out c; i += 1
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
print out; exit
|
|
42
|
+
}
|
|
43
|
+
')
|
|
44
|
+
fi
|
|
45
|
+
[ -z "$sid" ] && exit 0
|
|
46
|
+
|
|
47
|
+
# Sanitize to a safe filename (UUIDs pass through unchanged). Must match the
|
|
48
|
+
# server's activitySessionKey() so reads and writes agree on the path.
|
|
49
|
+
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9_-' '_')
|
|
50
|
+
[ -z "$safe_sid" ] && exit 0
|
|
51
|
+
|
|
52
|
+
activity_dir="$HOME/.oxtail/activity"
|
|
53
|
+
mkdir -p "$activity_dir" 2>/dev/null || true
|
|
54
|
+
printf 'busy' > "$activity_dir/$safe_sid" 2>/dev/null || true
|
|
55
|
+
exit 0
|