oxtail 0.10.3 → 0.11.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 CHANGED
@@ -58,6 +58,7 @@ The v0.9/v0.10.1 changes close the public dogfooding gaps found by real peer tra
58
58
 
59
59
  ## Recently shipped
60
60
 
61
+ - **Wake-on-reply (Slice 1, peer-messaging refinement push).** A `send_message` that carries `reply_to` now auto-wakes the original requester **by default** (explicit `wake:"off"` opts out), closing the observed stranding where a peer's async reply to an idle requester forced a human to relay it. The reply path is a separate, stricter gate than the lenient `wake:"auto"` path (`src/autowake.ts`): it fires only for a **fresh-idle** target (idle marker newer than `OXTAIL_AUTOWAKE_FRESH_IDLE_MS`, default 5m) — stale/unknown/missing/busy ⇒ `skipped_no_fresh_idle`, never a best-effort wake — and adds a **per-target rate limit** (`skipped_rate_limited`), a persistent **one-wake dedupe** keyed on `(session_id, reply_to)` (`skipped_deduped`, GC'd by age) to survive duplicate/late hook drains, an `OXTAIL_AUTOWAKE=off` kill-switch, and a best-effort `skipped_store_error` degrade so a broken dedupe store can never turn an already-enqueued reply into a tool error. Target is resolved by `client.session_id` with the pane re-resolved immediately before send-keys (no `server_pid`/stale-pane reuse). Response surfaces `wake_status` + `wake_reason:"reply_to_default"`. **Coverage caveat:** the fresh-idle gate keys on the busy/idle marker that only the Claude Code hooks maintain, so this slice reaches a **hooked Claude Code requester** (the observed case). A Codex / hookless-Claude requester has no idle marker ⇒ `skipped_no_fresh_idle` (reach it with explicit `wake:"auto"`); closing that direction is **Slice 2** (`expects_reply:true` — a requester-side waiter signal), deliberately not faked here with a blind `unknown ⇒ wake` that would reintroduce the active-waiter double-wake.
61
62
  - **Protocol hardening (v0.10.1).** `ask_peer` now stamps outbound messages with `request_id`; reply-to-capable peers answer with `send_message({ reply_to: request_id })`, and the waiter ignores stale same-peer messages. Explicit identity claims are monotonic, so stale automatic detection cannot clobber a real client session id. PreToolUse/Stop hook pushes are body-budgeted and labeled as peer context, not user authority.
62
63
  - **Deliver-on-complete and state-gated wake (v0.9).** The Stop hook delivers waiting messages at turn end, closing the text-only-turn gap left by PreToolUse. `UserPromptSubmit`/`Stop` maintain a busy/idle flag so `send_message({ wake: "auto" })` nudges idle peers without typing into a busy composer. Sticky Codex claim recovery keeps identity across MCP child restarts.
63
64
  - **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.
package/README.md CHANGED
@@ -65,7 +65,7 @@ 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. 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. 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)). Replies to `ask_peer` should pass `reply_to: "<request_id>"` when the inbound message carries a `request_id`. (v0.5+)
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. A plain message does **not** wake an idle peer; pass `wake: "auto"` to nudge one (state-gated — see [Waking an idle peer](#waking-an-idle-peer)). Replies to `ask_peer` should pass `reply_to: "<request_id>"` when the inbound message carries a `request_id` — and a reply **auto-wakes the requester by default** (strictly gated; `wake: "off"` opts out). (v0.5+)
69
69
  - `read_my_messages` — drain this session's mailbox and return any queued messages. Messages include `from_session_id`, server-stamped `origin: "peer"`, and optional `request_id` / `reply_to`. 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 with a `request_id` and blocks server-side until the peer replies with `send_message({ reply_to: request_id })` or the timeout elapses. Default timeout is 45s (`OXTAIL_ASK_PEER_TIMEOUT_MS`), and each call may pass `timeout_ms`. New peers use strict `reply_to` correlation; legacy/no-capability peers fall back to best-effort first-message matching and the response reports `correlation: "uncorrelated"`. That legacy path may stale-match old same-peer chatter, so callers should treat `uncorrelated` as compatibility-only. 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`.
@@ -147,7 +147,18 @@ send_message({ target: "<peer>", body: "...", wake: "auto" })
147
147
  // → { ok: true, message_id, ..., wake_status: "fired" | "skipped_busy" | "skipped_no_target" | "disabled" }
148
148
  ```
149
149
 
150
- 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.
150
+ 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"` preserves the pure fire-and-forget contract.
151
+
152
+ **Wake-on-reply (the default for replies).** A reply — a `send_message` that carries `reply_to` — auto-wakes the requester **by default**, so an awaited answer doesn't strand an idle peer and force a human to relay it. You don't have to remember `wake: "auto"`; pass `wake: "off"` to opt out.
153
+
154
+ ```js
155
+ send_message({ target: "<requester>", body: "...", reply_to: "<request_id>" })
156
+ // → { ok: true, ..., wake_status: "...", wake_reason: "reply_to_default" }
157
+ ```
158
+
159
+ The reply path is deliberately **stricter** than explicit `wake: "auto"`. It fires only when the target is **freshly idle** — an `idle` activity marker newer than `OXTAIL_AUTOWAKE_FRESH_IDLE_MS` (default 5 min). Stale, unknown, missing, or busy state yields `skipped_no_fresh_idle` (no best-effort wake — typing unprompted into a terminal that may be unattended is the risk we refuse to take). Two more guards bound it: a **per-target rate limit** (`OXTAIL_AUTOWAKE_MIN_INTERVAL_MS`, default 4s → `skipped_rate_limited`) since one wake already drains the whole mailbox, and a **one-wake dedupe** keyed on `(session_id, reply_to)` (`skipped_deduped`) so a duplicate or late hook drain of the same reply can't re-fire. If the dedupe/rate store is somehow unwritable the wake degrades to `skipped_store_error` rather than failing the (already-delivered) message. The env kill-switch `OXTAIL_AUTOWAKE=off` disables reply auto-wake entirely (`wake_status: "disabled"`). Every outcome that reaches the gate surfaces a `wake_status`; the reply path also stamps `wake_reason: "reply_to_default"` (present even on a resolve error like `ambiguous-target`, where there's no single target to wake).
160
+
161
+ **Coverage (which requesters this reaches).** The fresh-idle gate keys on the requester's busy/idle activity marker, which only the Claude Code hooks maintain. So wake-on-reply currently closes the stranding for a **hooked Claude Code requester** (the originally-observed case: a peer's async reply to an idle Claude session). A **Codex** requester — or a Claude requester without the hooks installed — has no idle marker, so a reply with `wake` unset returns `skipped_no_fresh_idle` and is **not** auto-woken; reach it with an explicit `wake: "auto"`, which always takes the lenient wake path (idle/unknown/stale all wake; only a fresh-`busy` peer is skipped) and bypasses the strict fresh-idle gate even for a reply. Closing the Codex/unhooked-requester direction *by default* needs a requester-side waiter signal (`expects_reply`), which is the next slice — a blind `unknown ⇒ wake` default is deliberately avoided because it reintroduces the double-wake-an-active-waiter risk this gate exists to prevent.
151
162
 
152
163
  **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.
153
164
 
@@ -0,0 +1,238 @@
1
+ // Slice 1 — wake-on-reply (interim liveness patch).
2
+ //
3
+ // When a `send_message` carries a `reply_to` (i.e. it is answering an earlier
4
+ // ask) and the caller did NOT explicitly pass `wake:"off"`, oxtail auto-wakes
5
+ // the original requester so an awaited answer doesn't strand an idle peer and
6
+ // force a human relay. This module is the GATE that decides whether that
7
+ // reply-default wake is allowed to fire. The actual send-keys is left to the
8
+ // caller (server.ts `wakePeer`) so this module stays free of tmux/process
9
+ // concerns and is unit-testable against a temp directory.
10
+ //
11
+ // The guards are deliberately conservative. A reply auto-wake types into the
12
+ // peer's terminal WITHOUT the human at that terminal having asked for anything
13
+ // this turn, so we only do it when ALL of these hold:
14
+ // 1. kill-switch `OXTAIL_AUTOWAKE` is not "off"
15
+ // 2. the target is FRESH-IDLE — its activity marker says "idle" AND is newer
16
+ // than a max-age threshold. Stale / unknown / missing ⇒ no wake (we do NOT
17
+ // fall back to a best-effort wake the way the lenient wake:auto path does).
18
+ // 3. we have not woken this target too recently (per-target rate limit)
19
+ // 4. we have not already woken for THIS exact (session_id, reply_to) — a
20
+ // one-wake dedupe that survives duplicate / late hook drains.
21
+ //
22
+ // Everything is keyed on the target's `client.session_id` (the agent identity,
23
+ // per AGENTS.md), never server_pid / tmux name.
24
+ import { createHash } from "node:crypto";
25
+ import { closeSync, mkdirSync, openSync, readdirSync, statSync, unlinkSync, utimesSync, writeFileSync, } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+ function envPosInt(name, def, env = process.env) {
29
+ const v = env[name];
30
+ if (!v)
31
+ return def;
32
+ const n = Number(v);
33
+ return Number.isFinite(n) && n > 0 ? n : def;
34
+ }
35
+ // Fresh-idle window: how recently the peer must have gone idle for a reply
36
+ // auto-wake to fire. This is the MAX-AGE threshold the spec calls for, and it
37
+ // is intentionally a SEPARATE, stricter gate than the 10-minute busy-TTL used
38
+ // by the lenient `wake:"auto"` path: that path wakes on idle/unknown/stale, but
39
+ // a reply auto-wake fires unprompted, so we cap how old "idle" may be before we
40
+ // stop trusting that the peer is still sitting at its prompt. The 5-minute
41
+ // default leans conservative (an unprompted wake into a possibly-unattended
42
+ // terminal is the risk) while still covering a normal minute-scale
43
+ // ask→work→reply round-trip; raise it via OXTAIL_AUTOWAKE_FRESH_IDLE_MS if
44
+ // dogfooding shows replies regularly land later.
45
+ export const FRESH_IDLE_MAX_AGE_MS = envPosInt("OXTAIL_AUTOWAKE_FRESH_IDLE_MS", 5 * 60 * 1000);
46
+ // Per-target rate limit: the minimum gap between two reply auto-wakes to the
47
+ // same session_id. A single recent wake already pulls an idle peer into a turn
48
+ // that drains its whole mailbox, so additional keystroke wakes inside this
49
+ // window are redundant noise into a terminal. Conservative by design.
50
+ export const MIN_INTERVAL_MS = envPosInt("OXTAIL_AUTOWAKE_MIN_INTERVAL_MS", 4000);
51
+ // One-wake dedupe lifetime: how long a (session_id, reply_to) wake record is
52
+ // honored before it is GC'd. Comfortably longer than any single ask/reply
53
+ // round-trip so a late/duplicate hook drain of the same reply can't re-wake.
54
+ export const DEDUPE_TTL_MS = envPosInt("OXTAIL_AUTOWAKE_DEDUPE_TTL_MS", 60 * 60 * 1000);
55
+ // The kill-switch. Any casing of "off" disables reply auto-wake entirely.
56
+ export function autowakeKillSwitchOff(env = process.env) {
57
+ return String(env.OXTAIL_AUTOWAKE ?? "").trim().toLowerCase() === "off";
58
+ }
59
+ // FRESH-IDLE gate. Only a recent "idle" marker qualifies. A negative age means
60
+ // the activity file's mtime is in the future (clock skew) — untrusted, treated
61
+ // as not-fresh.
62
+ export function isFreshIdle(act, maxAgeMs = FRESH_IDLE_MAX_AGE_MS) {
63
+ if (!act || act.status !== "idle")
64
+ return false;
65
+ return act.ageMs >= 0 && act.ageMs < maxAgeMs;
66
+ }
67
+ // --- persistent dedupe / rate-limit store ------------------------------------
68
+ // One small file per record under ~/.oxtail/autowake/. mtime is the source of
69
+ // truth (driven by the injected nowMs so the store is deterministic in tests);
70
+ // the body is a debug breadcrumb. GC'd by age.
71
+ export function defaultAutowakeDir() {
72
+ return join(homedir(), ".oxtail", "autowake");
73
+ }
74
+ function hash(s) {
75
+ // reply_to is caller-controlled, so never build a filename from it directly.
76
+ return createHash("sha256").update(s).digest("hex").slice(0, 32);
77
+ }
78
+ function dedupePath(dir, sessionId, replyTo) {
79
+ // JSON-encode the pair so the boundary is unambiguous: reply_to is
80
+ // caller-controlled and could otherwise be crafted to collide with a
81
+ // different (sessionId, replyTo) split under a plain separator.
82
+ return join(dir, `d-${hash(JSON.stringify([sessionId, replyTo]))}`);
83
+ }
84
+ function ratePath(dir, sessionId) {
85
+ return join(dir, `r-${hash(sessionId)}`);
86
+ }
87
+ function setMtime(path, nowMs) {
88
+ const t = nowMs / 1000;
89
+ try {
90
+ utimesSync(path, t, t);
91
+ }
92
+ catch {
93
+ // best effort — mtime drives TTL math, but a failure here only makes the
94
+ // record look fresher/staler by the small real-vs-injected clock delta.
95
+ }
96
+ }
97
+ // Read-only: has a wake for this (session_id, reply_to) happened within the TTL?
98
+ export function isDuplicateWake(dir, sessionId, replyTo, nowMs, ttlMs = DEDUPE_TTL_MS) {
99
+ try {
100
+ const st = statSync(dedupePath(dir, sessionId, replyTo));
101
+ return nowMs - st.mtimeMs < ttlMs;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ // Read-only: have we woken this target within the min-interval window?
108
+ export function isRateLimited(dir, sessionId, nowMs, minIntervalMs = MIN_INTERVAL_MS) {
109
+ try {
110
+ const st = statSync(ratePath(dir, sessionId));
111
+ return nowMs - st.mtimeMs < minIntervalMs;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ function stampRate(dir, sessionId, nowMs) {
118
+ const p = ratePath(dir, sessionId);
119
+ try {
120
+ writeFileSync(p, String(nowMs));
121
+ setMtime(p, nowMs);
122
+ }
123
+ catch {
124
+ // best effort
125
+ }
126
+ }
127
+ // Atomically claim the (session_id, reply_to) wake slot. Returns true if THIS
128
+ // caller won (no fresh record existed) and may proceed to fire; false if a
129
+ // concurrent / duplicate claim already holds it. On a win, also stamps the
130
+ // per-target rate record so distinct replies inside MIN_INTERVAL_MS are
131
+ // suppressed. A stale record (older than TTL) is cleared first so the slot can
132
+ // be reclaimed after the GC horizon.
133
+ export function claimWake(dir, sessionId, replyTo, nowMs, ttlMs = DEDUPE_TTL_MS) {
134
+ mkdirSync(dir, { recursive: true });
135
+ const dpath = dedupePath(dir, sessionId, replyTo);
136
+ try {
137
+ const st = statSync(dpath);
138
+ if (nowMs - st.mtimeMs >= ttlMs)
139
+ unlinkSync(dpath);
140
+ }
141
+ catch (e) {
142
+ // ENOENT = no prior record (the common path) → fine. Any OTHER error (e.g.
143
+ // failing to unlink a STALE record because the store is unhealthy) must
144
+ // propagate so the caller degrades to skipped_store_error — otherwise the
145
+ // imminent openSync("wx") EEXIST on the un-removed stale record would be
146
+ // misreported as a genuine dedupe hit.
147
+ if (e.code !== "ENOENT")
148
+ throw e;
149
+ }
150
+ let won = false;
151
+ try {
152
+ const fd = openSync(dpath, "wx"); // atomic create-exclusive: closes the race
153
+ try {
154
+ writeFileSync(fd, JSON.stringify({ sessionId, replyTo, at: nowMs }));
155
+ }
156
+ finally {
157
+ closeSync(fd);
158
+ }
159
+ setMtime(dpath, nowMs);
160
+ won = true;
161
+ }
162
+ catch (e) {
163
+ // EEXIST: a fresh claim already exists → genuine duplicate (skip, no throw).
164
+ // Any OTHER error means the store itself is unusable (e.g. a permission
165
+ // problem) — don't misreport it as a duplicate; rethrow so the caller can
166
+ // degrade it to a deterministic store-error status instead of silently
167
+ // suppressing a legitimate wake.
168
+ if (e.code === "EEXIST") {
169
+ won = false;
170
+ }
171
+ else {
172
+ throw e;
173
+ }
174
+ }
175
+ if (won)
176
+ stampRate(dir, sessionId, nowMs);
177
+ return won;
178
+ }
179
+ // Remove autowake records older than the dedupe TTL. Cheap, low-volume dir;
180
+ // run opportunistically on each decision so records can't accumulate.
181
+ export function gcAutowake(dir, nowMs, ttlMs = DEDUPE_TTL_MS) {
182
+ let names;
183
+ try {
184
+ names = readdirSync(dir);
185
+ }
186
+ catch {
187
+ return; // dir not created yet
188
+ }
189
+ for (const name of names) {
190
+ if (name[0] !== "d" && name[0] !== "r")
191
+ continue;
192
+ const p = join(dir, name);
193
+ try {
194
+ const st = statSync(p);
195
+ if (nowMs - st.mtimeMs >= ttlMs)
196
+ unlinkSync(p);
197
+ }
198
+ catch {
199
+ // best effort
200
+ }
201
+ }
202
+ }
203
+ // The decision. Pure of tmux/process concerns: given the target identity, the
204
+ // reply_to, a snapshot of the target's activity, the current time, and the
205
+ // store directory, return whether the reply-default wake may fire. The caller
206
+ // performs the actual send-keys when fire === true.
207
+ export function decideReplyAutoWake(input) {
208
+ const { dir, sessionId, replyTo, activity, nowMs } = input;
209
+ if (autowakeKillSwitchOff(input.env))
210
+ return { fire: false, status: "disabled" };
211
+ // Identity is required: dedupe/rate/activity all key on session_id, and
212
+ // without it we cannot confirm fresh-idle. An unclaimed peer is never auto-woken.
213
+ if (!sessionId)
214
+ return { fire: false, status: "skipped_no_fresh_idle" };
215
+ if (!isFreshIdle(activity))
216
+ return { fire: false, status: "skipped_no_fresh_idle" };
217
+ // Wake bookkeeping is best-effort: send_message has ALREADY enqueued the
218
+ // reply by the time we run, so a broken dedupe/rate store (e.g. ~/.oxtail/
219
+ // autowake is a file, or a permission error) must degrade to a deterministic
220
+ // status — NEVER throw, which would surface as a tool error on an already-
221
+ // delivered message and invite a duplicate retry.
222
+ try {
223
+ gcAutowake(dir, nowMs); // opportunistic sweep before we read/claim
224
+ // Read-only dedupe first so a sequential duplicate reply reports the precise
225
+ // reason; then the per-target rate limit; then an atomic claim to close the
226
+ // concurrent-duplicate race (and to stamp the rate record on success).
227
+ if (isDuplicateWake(dir, sessionId, replyTo, nowMs))
228
+ return { fire: false, status: "skipped_deduped" };
229
+ if (isRateLimited(dir, sessionId, nowMs))
230
+ return { fire: false, status: "skipped_rate_limited" };
231
+ if (!claimWake(dir, sessionId, replyTo, nowMs))
232
+ return { fire: false, status: "skipped_deduped" };
233
+ }
234
+ catch {
235
+ return { fire: false, status: "skipped_store_error" };
236
+ }
237
+ return { fire: true };
238
+ }
package/dist/server.js CHANGED
@@ -13,6 +13,7 @@ import { trace } from "./trace.js";
13
13
  import { buildEntry, currentPaneForServerPid, findByTmuxSession, readAll, refreshTmuxBinding, register, sessionPidsForId, unregister, } from "./registry.js";
14
14
  import * as mailbox from "./mailbox.js";
15
15
  import { recoverClaim, resolveAncestors, writeClaim } from "./claims.js";
16
+ import { decideReplyAutoWake, defaultAutowakeDir } from "./autowake.js";
16
17
  // CLI subcommand dispatch must run before any MCP setup so that
17
18
  // `npx oxtail install-hook` doesn't open an MCP transport or register a
18
19
  // session. Use named exports and await them; calling `await import(...)`
@@ -1002,7 +1003,7 @@ function resolveTarget(target, caller) {
1002
1003
  server.registerTool("send_message", {
1003
1004
  description: [
1004
1005
  "Fire-and-forget message to a peer in the same project root. Target: a tmux session name OR a client_session_id (UUID). Async via the peer's mailbox — delivered mid-turn (PreToolUse hook) or next-turn (read_my_messages); cross-project targets are rejected.",
1005
- "By default does NOT wake an idle peer. Pass wake:\"auto\" to nudge one via per-client send-keys, state-gated (skipped if the peer is mid-turn). Response then carries wake_status: \"fired\" | \"skipped_busy\" | \"skipped_no_target\" | \"disabled\".",
1006
+ "A plain message does NOT wake an idle peer. Pass wake:\"auto\" to nudge one via per-client send-keys, state-gated (skipped if the peer is mid-turn). EXCEPTION (wake-on-reply): when you set reply_to, this auto-wakes the requester by default so your answer doesn't strand them idle — pass wake:\"off\" to suppress. The reply-default wake is strictly gated: it fires only for a FRESHLY-IDLE requester (one whose Claude Code hooks maintain a fresh idle marker), with a per-target rate limit and a one-wake dedupe; env kill-switch OXTAIL_AUTOWAKE=off. A requester with no idle marker (Codex, or Claude without the hooks) returns skipped_no_fresh_idle and is NOT auto-woken — use explicit wake:\"auto\" for those. Response carries wake_status (\"fired\" | \"skipped_busy\" | \"skipped_no_fresh_idle\" | \"skipped_rate_limited\" | \"skipped_deduped\" | \"skipped_store_error\" | \"skipped_no_target\" | \"disabled\") and, on the reply path, wake_reason:\"reply_to_default\".",
1006
1007
  "Body is verbatim — wrap in <system-reminder>...</system-reminder> yourself if you want that framing. When replying to ask_peer, include reply_to: request_id from the inbound message. For a blocking send-and-wait, use ask_peer instead.",
1007
1008
  ].join(" "),
1008
1009
  inputSchema: {
@@ -1020,7 +1021,7 @@ server.registerTool("send_message", {
1020
1021
  wake: z
1021
1022
  .enum(["off", "auto"])
1022
1023
  .optional()
1023
- .describe('Wake strategy. "off" (default): pure fire-and-forget, no nudge. "auto": nudge an idle peer via per-client send-keys, state-gated (skipped if the peer is mid-turn). Response carries wake_status when set.'),
1024
+ .describe('Wake strategy. Default (unset): no nudge for a plain message, but a reply (reply_to set) auto-wakes a freshly-idle requester. "off": pure fire-and-forget, no nudge even for a reply. "auto": nudge an idle peer via per-client send-keys, state-gated (skipped if the peer is mid-turn). Response carries wake_status when set.'),
1024
1025
  reply_to: z
1025
1026
  .string()
1026
1027
  .min(1)
@@ -1035,11 +1036,14 @@ server.registerTool("send_message", {
1035
1036
  }, async ({ target, body, wake, reply_to, source_message_id }) => {
1036
1037
  const resolved = resolveTarget(target, entry);
1037
1038
  if (!resolved.ok) {
1038
- const wake_status = wake === "auto" ? resolveErrorWakeStatus(resolved.error) : undefined;
1039
+ const replyDefault = replyAutoWakeTriggered(wake, reply_to);
1040
+ const wakeIntended = wake === "auto" || replyDefault;
1041
+ const wake_status = wakeIntended ? resolveErrorWakeStatus(resolved.error) : undefined;
1039
1042
  return jsonResult({
1040
1043
  schema_version: 1,
1041
1044
  ...resolved,
1042
1045
  ...(wake_status ? { wake_status } : {}),
1046
+ ...(replyDefault ? { wake_reason: "reply_to_default" } : {}),
1043
1047
  });
1044
1048
  }
1045
1049
  const peer = resolved.entry;
@@ -1048,7 +1052,7 @@ server.registerTool("send_message", {
1048
1052
  reply_to,
1049
1053
  source_message_id,
1050
1054
  });
1051
- const wake_status = wake === "auto" ? await wakeForSend(peer) : undefined;
1055
+ const { wake_status, wake_reason } = await resolveSendWake(peer, wake, reply_to);
1052
1056
  return jsonResult({
1053
1057
  schema_version: 1,
1054
1058
  ok: true,
@@ -1056,6 +1060,7 @@ server.registerTool("send_message", {
1056
1060
  target_session_id: peer.client.session_id,
1057
1061
  target_server_pid: peer.server_pid,
1058
1062
  ...(wake_status ? { wake_status } : {}),
1063
+ ...(wake_reason ? { wake_reason } : {}),
1059
1064
  });
1060
1065
  });
1061
1066
  // read_my_messages budget. A session's union drain can return a backlog; cap
@@ -1376,6 +1381,63 @@ async function wakeForSend(peer) {
1376
1381
  }
1377
1382
  return wakePeer(peer);
1378
1383
  }
1384
+ // --- Slice 1: wake-on-reply (reply_to default) -------------------------------
1385
+ // A send_message that carries a reply_to is answering an earlier ask. The wake
1386
+ // arg is a three-way for a reply:
1387
+ // unset → the STRICT reply-default auto-wake (fresh-idle only, rate limit,
1388
+ // one-wake dedupe, env kill-switch — autowake.ts). wake_reason:
1389
+ // "reply_to_default".
1390
+ // "auto" → the caller explicitly opts into the LENIENT wakeForSend path
1391
+ // (idle/unknown/stale all wake; only fresh-busy is skipped). This is
1392
+ // the escape hatch for a requester with no idle marker — a Codex or
1393
+ // hookless-Claude requester that the strict gate skips as
1394
+ // skipped_no_fresh_idle. Not flagged reply_to_default: the caller
1395
+ // asked for it explicitly.
1396
+ // "off" → no wake at all.
1397
+ // Here we just wire identity/activity/time into the strict gate and fire the
1398
+ // existing send-keys path when it says go.
1399
+ //
1400
+ // Note (per Codex's slice-1 correction): the fresh-idle gate makes an explicit
1401
+ // "is the requester actively blocked in ask_peer?" suppression unnecessary —
1402
+ // an active waiter is mid-turn and therefore marked busy, so it never reads as
1403
+ // fresh-idle. That holds only as long as the busy/idle freshness is correct;
1404
+ // it is not an independent proof.
1405
+ //
1406
+ // Triggers the STRICT reply-default path: a reply (reply_to set) with wake
1407
+ // UNSET. Explicit "auto"/"off" opt out of the strict path (auto → lenient,
1408
+ // off → none), so this is false for them.
1409
+ function replyAutoWakeTriggered(wake, replyTo) {
1410
+ return !!replyTo && wake === undefined;
1411
+ }
1412
+ async function autoWakeOnReply(peer, replyTo) {
1413
+ const sid = peer.client.session_id;
1414
+ const decision = decideReplyAutoWake({
1415
+ dir: defaultAutowakeDir(),
1416
+ sessionId: sid ?? null,
1417
+ replyTo,
1418
+ activity: readActivity(sid),
1419
+ nowMs: Date.now(),
1420
+ });
1421
+ if (!decision.fire) {
1422
+ trace("autowake_reply_skipped", { target_session_id: sid, status: decision.status });
1423
+ return decision.status;
1424
+ }
1425
+ trace("autowake_reply_fire", { target_session_id: sid });
1426
+ return wakePeer(peer);
1427
+ }
1428
+ // Resolve the wake for a send_message. The strict reply-default path engages
1429
+ // only for a reply with wake UNSET; an explicit wake:"auto" always means the
1430
+ // lenient wakeForSend path (even for a reply — the Codex/hookless escape hatch),
1431
+ // and wake:"off" means no wake. Returns the status + reason to surface.
1432
+ async function resolveSendWake(peer, wake, replyTo) {
1433
+ if (replyAutoWakeTriggered(wake, replyTo)) {
1434
+ return { wake_status: await autoWakeOnReply(peer, replyTo), wake_reason: "reply_to_default" };
1435
+ }
1436
+ if (wake === "auto") {
1437
+ return { wake_status: await wakeForSend(peer) };
1438
+ }
1439
+ return {};
1440
+ }
1379
1441
  // Poll my mailbox at ASK_PEER_POLL_MS until a matching reply lands or the
1380
1442
  // deadline elapses. Each tick checks mtime first and only acquires the
1381
1443
  // mailbox lock when there's a probable hit. The lock is held only inside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxtail",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Coordination layer for parallel AI coding agent sessions, exposed over MCP.",