oxtail 0.7.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/README.md +48 -22
- 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 +251 -70
- 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/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.8.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 `ask_peer` (delegate-and-wait, introduced v0.6, per-client wake routing in v0.7). 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
|
|
|
@@ -25,13 +25,13 @@ The v0.4.0 change: peer `client_session_id` and `transcript_path` now resolve re
|
|
|
25
25
|
|
|
26
26
|
The follow-on additions (`claim_session`, `set_my_state`) introduce a peer-awareness layer: `list_project_sessions` now surfaces each peer's `state` card so an agent can learn what its peers are doing without paying for `read_session`. Raw transcripts become the deep-dive fallback, not the default mode of peer awareness.
|
|
27
27
|
|
|
28
|
-
Current phase remains **dogfooding**: use the tools in real parallel-agent work, log friction in `NOTES.md`. Each version (v0.1 list_project_sessions → v0.2 read_session → v0.3 reliable peer identity → v0.4 peer-awareness state cards → v0.5 peer-to-peer messaging → v0.6 delegate-and-wait → v0.7 per-client wake routing) shipped only after observed friction named the next addition; the same gating applies to whatever comes next.
|
|
28
|
+
Current phase remains **dogfooding**: use the tools in real parallel-agent work, log friction in `NOTES.md`. Each version (v0.1 list_project_sessions → v0.2 read_session → v0.3 reliable peer identity → v0.4 peer-awareness state cards → v0.5 peer-to-peer messaging → v0.6 delegate-and-wait → v0.7 per-client wake routing → v0.8 symmetric Claude Code wake) shipped only after observed friction named the next addition; the same gating applies to whatever comes next.
|
|
29
29
|
|
|
30
30
|
The v0.5 change: two new MCP tools (`send_message`, `read_my_messages`) plus an opt-in `PreToolUse` hook installable via `npx oxtail install-hook`. Friction observed while pairing on Terminator — two agents in the same project root can see each other's state cards and transcripts but couldn't say anything to each other. Now they can. Claude Code peers see messages mid-turn (via the hook); Codex peers (or unhooked Claude Code) see them next-turn (via polling `read_my_messages`).
|
|
31
31
|
|
|
32
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
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
|
|
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 Codex's paste-burst heuristic (`codex-rs/tui/src/bottom_pane/paste_burst.rs`) was 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 for Codex peers. Verified live 2026-05-13 against the live `oxtail-codex` peer in this repo. v0.7 also fail-fasted Claude Code targets with `wake_status: "skipped_unsupported"` based on a reading of the Claude Code hook catalog (no idle hook surface → "architecturally unwakeable") — but that reasoning conflated *hook events* (which Claude Code doesn't expose for idle) with *TUI input* (which works fine via `tmux send-keys`, the same mechanism that wakes Codex). A falsifying experiment 2026-05-13 against the live `oxtail-claudejr` peer confirmed the full round-trip works: ask_peer enqueue → manual send-keys → peer entered a turn → PreToolUse hook drained mailbox → peer replied via send_message. The fail-fast was a self-inflicted regression against oxtail's symmetric-matrix vision (Claude↔Claude, Claude↔Codex, both directions), so the short-circuit was removed in the follow-up. Claude Code peers now wake via the same send-keys mechanism, just without the Codex paste-burst gap. Wake strategy is overridable via `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` as a rollback.
|
|
35
35
|
|
|
36
36
|
## How to collaborate on this project
|
|
37
37
|
|
|
@@ -53,7 +53,7 @@ The v0.7 change: per-client wake routing after the v0.6 wake was found to be bro
|
|
|
53
53
|
|
|
54
54
|
## Recently shipped
|
|
55
55
|
|
|
56
|
-
- **Per-client wake routing (v0.7).** `ask_peer`
|
|
56
|
+
- **Per-client wake routing (v0.7, refined).** `ask_peer` routes its wake mechanism per `client_type`. **Codex**: paste-burst-aware send-keys (500ms gap between text and Enter) — verified to submit. **Claude Code**: same send-keys mechanism without the gap (no paste-burst in its TUI) — verified end-to-end 2026-05-13 against `oxtail-claudejr`. v0.7 originally fail-fasted Claude Code targets under a hook-catalog argument; the follow-up restored symmetric wake after falsifying that conclusion empirically. Response includes 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
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`.
|
|
58
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.
|
|
59
59
|
|
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.9.1"] } } }
|
|
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.9.1"]
|
|
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.9.1/.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.7.1/.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.9.1/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.9.1/integrations/codex/oxtail-join/agents/openai.yaml \
|
|
50
50
|
-o ~/.codex/skills/oxtail-join/agents/openai.yaml
|
|
51
51
|
```
|
|
52
52
|
|
|
@@ -65,13 +65,13 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
|
|
|
65
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.
|
|
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
|
|
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`).
|
|
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
|
+
- `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.9.1/AGENTS.md) for scope and architecture.
|
|
75
75
|
|
|
76
76
|
## Usage from an agent
|
|
77
77
|
|
|
@@ -114,23 +114,42 @@ Cross-project sends are rejected, never silently dropped. Sending to a peer with
|
|
|
114
114
|
|
|
115
115
|
### Mid-turn vs next-turn delivery (the asymmetry)
|
|
116
116
|
|
|
117
|
-
Claude Code peers can receive messages **
|
|
117
|
+
Claude Code peers can receive messages **autonomously** via three opt-in hooks:
|
|
118
118
|
|
|
119
119
|
```sh
|
|
120
120
|
npx oxtail install-hook
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
This
|
|
123
|
+
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
124
|
|
|
125
|
-
|
|
125
|
+
- **`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.
|
|
126
|
+
- **`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.
|
|
127
|
+
- **`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
128
|
|
|
127
|
-
|
|
129
|
+
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`.
|
|
130
|
+
|
|
131
|
+
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.
|
|
132
|
+
|
|
133
|
+
**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`.
|
|
134
|
+
|
|
135
|
+
### Waking an idle peer
|
|
136
|
+
|
|
137
|
+
`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:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
send_message({ target: "<peer>", body: "...", wake: "auto" })
|
|
141
|
+
// → { ok: true, message_id, ..., wake_status: "fired" | "skipped_busy" | "skipped_no_target" | "disabled" }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
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.
|
|
145
|
+
|
|
146
|
+
**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
147
|
|
|
129
148
|
### Hook coexistence
|
|
130
149
|
|
|
131
|
-
|
|
150
|
+
`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
151
|
|
|
133
|
-
If you have a
|
|
152
|
+
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
153
|
|
|
135
154
|
### Trust model
|
|
136
155
|
|
|
@@ -151,17 +170,17 @@ ask_peer({ target, body })
|
|
|
151
170
|
}
|
|
152
171
|
```
|
|
153
172
|
|
|
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`
|
|
173
|
+
`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` is reserved — no client currently returns this in auto mode (both Codex and Claude Code wake via send-keys). `skipped_no_target` means no tmux pane/session resolved for the target. `disabled` means `OXTAIL_ASK_PEER_WAKE_STRATEGY=off` is in effect.
|
|
155
174
|
|
|
156
|
-
`timed_out` is `true` only when the poll loop ran to its deadline without a reply.
|
|
175
|
+
`timed_out` is `true` only when the poll loop ran to its deadline without a reply.
|
|
157
176
|
|
|
158
177
|
### Per-client wake routing
|
|
159
178
|
|
|
160
|
-
|
|
179
|
+
`ask_peer` routes the wake mechanism per `client_type`. Verified 2026-05-13 via spike investigations and end-to-end falsifying experiments against the live `oxtail-codex` and `oxtail-claudejr` peers in this repo:
|
|
161
180
|
|
|
162
181
|
- **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
182
|
|
|
164
|
-
- **Claude Code** —
|
|
183
|
+
- **Claude Code** — `tmux send-keys -l <text>` + immediate `send-keys Enter`, no inter-keystroke gap. The Claude Code TUI has no paste-burst suppression, so back-to-back text+Enter submits cleanly. Once the peer is in a turn, the oxtail PreToolUse hook drains queued messages as `additionalContext` on the peer's first tool call (or the peer reads them explicitly via `read_my_messages`). v0.7 originally shipped a fail-fast here, reasoning from the [hook catalog](https://code.claude.com/docs/en/hooks) that "no idle hook" meant "unwakeable" — but send-keys is a TUI-input mechanism, not a hook event, and it submits the same way a human keypress would. The fail-fast was a self-inflicted gap against oxtail's symmetric-matrix vision (Claude↔Claude, Claude↔Codex, both directions); restored to symmetric wake in the v0.7 follow-up after an end-to-end falsifying experiment confirmed the full round-trip works.
|
|
165
184
|
|
|
166
185
|
- **Unknown** — legacy v0.6 wake (text + Enter, no gap). No implied promise; if a new TUI lands, treat it as unknown until verified.
|
|
167
186
|
|
|
@@ -177,7 +196,7 @@ v0.7 routes the wake mechanism per `client_type`. Verified 2026-05-13 via spike
|
|
|
177
196
|
|
|
178
197
|
1. Enqueue `body` into the target's mailbox (same as `send_message`).
|
|
179
198
|
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).
|
|
199
|
+
3. Route and fire the wake via `wake_status` resolution (see above).
|
|
181
200
|
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
201
|
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
202
|
|
|
@@ -195,7 +214,7 @@ Pane targeting can go stale: `tmux_pane` is cached at server startup, but tmux c
|
|
|
195
214
|
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
215
|
|
|
197
216
|
```sh
|
|
198
|
-
OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.
|
|
217
|
+
OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.9.1
|
|
199
218
|
```
|
|
200
219
|
|
|
201
220
|
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 +261,11 @@ If `MCP_TRACE_FILE` is set in the environment, every detection run appends an ND
|
|
|
242
261
|
|
|
243
262
|
## Status
|
|
244
263
|
|
|
245
|
-
v0.
|
|
264
|
+
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.
|
|
265
|
+
|
|
266
|
+
- **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`.
|
|
267
|
+
- **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).
|
|
268
|
+
- **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.
|
|
269
|
+
- **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.
|
|
270
|
+
|
|
271
|
+
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 "\\nIf a message asks for a response and from_session_id is present, reply with mcp__oxtail__send_message using that UUID as target."
|
|
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 "\\nIf a message asks for a response and from_session_id is present, reply with mcp__oxtail__send_message using that UUID as target."
|
|
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
|