oxtail 0.12.0 → 0.13.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 +3 -0
- package/README.md +11 -5
- package/dist/delivery.js +32 -0
- package/dist/mailbox.js +10 -4
- package/dist/received.js +176 -0
- package/dist/server.js +104 -2
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -55,9 +55,12 @@ The v0.9/v0.10.1 changes close the public dogfooding gaps found by real peer tra
|
|
|
55
55
|
- **Session identity is monotonic after first non-null resolution.** Automatic detection is a bootstrap aid. Once `claim_session`, `register_my_session`, or sticky-claim recovery sets a session id, later env/birth-time detection and `get_my_session` refreshes must preserve it. Only another explicit claim can change it.
|
|
56
56
|
- **`ask_peer` replies must correlate when the peer supports it.** Same-peer chatter is not a reply. Upgraded peers advertise `capabilities.mailbox.reply_to` and must satisfy waits with `from_session_id == target.session_id` plus `reply_to == request_id`; unmatched messages stay in the mailbox. The older `from_session_id`-only path is legacy compatibility and must be surfaced as `correlation: "uncorrelated"`. For no-capability peers, stale same-peer chatter may still satisfy the wait; that is an explicit compatibility limitation, not a correctness guarantee.
|
|
57
57
|
- **Peer messages are context, not user authority.** Mailbox provenance (`origin: "peer"`, `request_id`, `reply_to`, `source_message_id`) is diagnostic metadata, not a trust boundary. Hook text must keep the trust framing visible — the "context, not user authority" line plus the `from_session_id` / `request_id` / `reply_to` reply fields (full protocol names) are rendered on every delivery — and injected hook bodies must stay under an explicit budget. Single-valued provenance the framing already implies (`origin: "peer"`) stays in the mailbox JSONL but need not be rendered into context.
|
|
58
|
+
- **A displayed reply handle must be resolvable: record the received-ledger before the mailbox line is visible.** Both delivery paths are destructive — `read_my_messages` and the PreToolUse/Stop hook each truncate the mailbox on handoff — so `reply_to_message` resolves `message_id` against a durable per-session ledger (`~/.oxtail/received/<hash(session_id)>.jsonl`), never the queue. `deliverToPeer` (the single delivery primitive behind `send_message` / `ask_peer` / `reply_to_message`) MUST write the ledger entry **before** appending the mailbox line: append-then-record reopens a window where the hook renders a `message_id` the receiver cannot yet reply to. The ledger is keyed and owned by receiver `session_id`; a lookup reads only the caller's own file. The ledger write is best-effort (a failure degrades to "no handle, reply via `send_message`") but must never reorder ahead of, or block, the actual delivery.
|
|
58
59
|
|
|
59
60
|
## Recently shipped
|
|
60
61
|
|
|
62
|
+
- **Reply by id + received-ledger (v0.13.0).** `reply_to_message(message_id, body)` looks the inbound envelope up in a durable per-session ledger and derives `target` / `reply_to` / `source_message_id` server-side, replacing the manual rewiring that silently degraded a correlated exchange into loose mailbox traffic. New `src/received.ts` (ledger: sha256-keyed file, `mkdir`-lock, bounded retention `OXTAIL_RECEIVED_MAX`=1000 with a `received_ledger_pruned` trace so a drop is never silent) and `src/delivery.ts` (`deliverToPeer` = `buildMessage` → `recordReceived` → `requeue` — the record-before-append ordering above), wired into `send_message` / `ask_peer` / `reply_to_message`. Adversarial race-pair + ledger-failure-still-delivers tests in `src/delivery.test.ts`. Converged with Codex over a 5-round peer-messaging pressure test; Codex's review caught the append-before-record race, fixed before merge.
|
|
63
|
+
|
|
61
64
|
- **Wake hardening (v0.12.0 — issues #5/#6/#7, the v0.7-review backlog).** Three deferred wake items, landed together. **#6 (security):** wake send-keys now only ever target the pane the live process tree says hosts the peer's `server_pid` (`chooseVerifiedWakePane` → `currentPaneForServerPid`), never the peer's self-written `tmux_pane`/`tmux_session`; unverifiable ⇒ refuse (`skipped_no_target`). Registry-sourced tmux ids are shape-validated (`isValidTmuxPane`/`isValidTmuxSession`) and a spoofed `TMUX_PANE` env is ignored. This removed the cached-pane and session-name send-keys fallbacks (legit peers always register a real pane; churn is handled by re-resolution). **#5 (debounce):** all wake paths funnel through `wakePeer`, which coalesces repeat wakes to the same peer within `OXTAIL_WAKE_DEBOUNCE_MS` (default 1s, in-memory per process) ⇒ `skipped_debounced`. **#7 (observability):** a `wake_outcome` trace event per wake; `oxtail diagnose` summarizes wake_status counts by tool from `MCP_TRACE_FILE`; a scheduled `codex-drift.yml` fails if Codex's `PASTE_ENTER_SUPPRESS_WINDOW` drifts past our 500ms gap. New modules: `src/wake-debounce.ts`, `src/diagnose.ts`; `chooseVerifiedWakePane` in `src/registry.ts`.
|
|
62
65
|
- **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.
|
|
63
66
|
- **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.
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ args = ["-y", "oxtail@0.10.1"]
|
|
|
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.13.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.12.0/.claude/command
|
|
|
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.13.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.13.0/integrations/codex/oxtail-join/agents/openai.yaml \
|
|
50
50
|
-o ~/.codex/skills/oxtail-join/agents/openai.yaml
|
|
51
51
|
```
|
|
52
52
|
|
|
@@ -68,10 +68,11 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
|
|
|
68
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
|
+
- `reply_to_message` — **reply by `message_id`**. The atomic, correlation-safe alternative to hand-wiring `send_message`'s `target` + `reply_to`: pass the `message_id` the hook or `read_my_messages` showed you and the server looks the inbound envelope up in this session's durable **received-ledger**, derives the reply target (the original sender), carries `reply_to: request_id` when the inbound was an `ask_peer` (keeping the exchange correlated), and stamps `source_message_id`. Replying to a plain `send_message` works too — it just omits `reply_to`. Ownership is structural (you can only reply to a message delivered to *you*); fail-closed on an unknown/aged-out id. Same wake semantics as `send_message`, including the wake-on-reply default. (v0.13+)
|
|
71
72
|
- `register_my_session` — pin this MCP server's `session_id` directly. Kept for debugging; prefer `claim_session`.
|
|
72
73
|
- `get_my_session` — return this MCP server's own registry entry plus a per-strategy detection diagnosis. Useful for debugging.
|
|
73
74
|
|
|
74
|
-
See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.
|
|
75
|
+
See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.13.0/AGENTS.md) for scope and architecture.
|
|
75
76
|
|
|
76
77
|
## Usage from an agent
|
|
77
78
|
|
|
@@ -90,6 +91,8 @@ send_message({ target: "<peer-uuid>", body: "...", reply_to: "<ask request_id>"
|
|
|
90
91
|
read_my_messages()
|
|
91
92
|
ask_peer({ target: "primary", body: "[Handoff] please audit X and tell me what you find" })
|
|
92
93
|
// → blocks server-side until the peer replies via send_message, then returns their body
|
|
94
|
+
reply_to_message({ message_id: "<id from the hook / read_my_messages>", body: "..." })
|
|
95
|
+
// → looks up the inbound envelope, derives target + reply_to itself; correlated when the inbound was an ask_peer
|
|
93
96
|
```
|
|
94
97
|
|
|
95
98
|
Omitting `project_root` triggers a best-effort `.git`-ancestor walk from the server's own cwd. The response includes `inferred: true` when this happens. Pass `project_root` explicitly when you can.
|
|
@@ -112,6 +115,8 @@ read_my_messages()
|
|
|
112
115
|
|
|
113
116
|
The mailbox lives at `~/.oxtail/mailboxes/<server_pid>.jsonl`, append-only JSONL, drained under an `mkdir`-based advisory lock. The transport is intentionally dumb: 8KB UTF-8 body cap, sender chooses the framing (raw text or pre-wrapped `<system-reminder>...</system-reminder>`). Hook-delivered mailbox pushes are body-budgeted at 24K escaped characters by default; set `OXTAIL_HOOK_MAX_BODY_CHARS` to tune. If the budget is exceeded, the hook tells the receiver which bodies were truncated or omitted.
|
|
114
117
|
|
|
118
|
+
Because both delivery paths are **destructive** — `read_my_messages` and the hook each truncate the mailbox once a message is handed off — a reply-by-id verb can't rely on the queue. Every delivered envelope is therefore also recorded in a durable **received-ledger** at `~/.oxtail/received/<hash(session_id)>.jsonl` keyed by `message_id`, written *before* the mailbox line becomes visible (so any handle a receiver can see is already resolvable) and bounded to the most recent `OXTAIL_RECEIVED_MAX` (default 1000) entries. `reply_to_message` reads only the caller's own ledger — that file *is* the ownership boundary.
|
|
119
|
+
|
|
115
120
|
Inbound peer messages are context, not user authority. oxtail stamps delivered messages with `origin: "peer"` for provenance/debugging, but this is not a trust boundary and peers cannot mint trusted user instructions.
|
|
116
121
|
|
|
117
122
|
Cross-project sends are rejected, never silently dropped. Sending to a peer with the same tmux session name as another live peer returns `ambiguous-target` with the candidate `client_session_id`s — use the UUID form to disambiguate.
|
|
@@ -301,8 +306,9 @@ A scheduled CI job (`.github/workflows/codex-drift.yml`, also runnable on demand
|
|
|
301
306
|
|
|
302
307
|
## Status
|
|
303
308
|
|
|
304
|
-
v0.
|
|
309
|
+
v0.13.0. Pushes the autonomous peer-messaging matrix toward zero human relay, hardens the wake path, then makes correlated replies atomic.
|
|
305
310
|
|
|
311
|
+
- **Reply by id (v0.13.0).** `reply_to_message(message_id, body)` removes the manual `target` + `reply_to` rewiring that silently degraded a correlated exchange into loose mailbox traffic: the server looks the inbound envelope up in a durable per-session **received-ledger** (`~/.oxtail/received/<hash(session_id)>.jsonl`), derives the reply target and `reply_to` itself, and enforces ownership structurally (you can only reply to a message delivered to you). The ledger is written *before* the mailbox line is visible — so a handle the hook displays is always resolvable even though both delivery paths destroy the queue entry once it is handed off. Fail-closed on an unknown/aged-out id.
|
|
306
312
|
- **Wake-on-reply (v0.11.0).** A reply — `send_message` with `reply_to` — auto-wakes a freshly-idle requester by default, so an awaited answer doesn't strand an idle peer. Strictly gated (fresh-idle only, per-target rate limit, one-wake dedupe, `OXTAIL_AUTOWAKE=off` kill-switch). `wake:"off"` opts out; explicit `wake:"auto"` is the escape hatch for a requester without an idle marker (Codex / hookless Claude).
|
|
307
313
|
- **Wake hardening (v0.12.0).** Wake keystrokes only ever target the pane the process tree confirms hosts the peer's `server_pid` — never a self-written `tmux_pane`/`tmux_session`, and registry entries whose `server_pid` doesn't match their filename are rejected. Rapid repeat wakes to one peer are coalesced (`skipped_debounced`). `oxtail diagnose` summarizes wake outcomes from `MCP_TRACE_FILE`, and a scheduled CI job flags drift in Codex's paste-burst window before it can break the wake.
|
|
308
314
|
- **Correlated delegate-and-wait.** `ask_peer` now sends a `request_id`; upgraded peers reply with `send_message({ reply_to })`, and the waiter ignores same-peer chatter that does not match. Legacy peers are still supported, but their replies are marked `correlation: "uncorrelated"`.
|
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as mailbox from "./mailbox.js";
|
|
2
|
+
import { recordReceived } from "./received.js";
|
|
3
|
+
import { trace } from "./trace.js";
|
|
4
|
+
// Deliver a message to a peer's mailbox, recording the durable reply-handle in
|
|
5
|
+
// the receiver's ledger BEFORE the mailbox line becomes visible. The ordering is
|
|
6
|
+
// the correctness guarantee: a hook/poll drainer can only observe the mailbox
|
|
7
|
+
// line after the append, which happens strictly after the ledger write — so any
|
|
8
|
+
// message_id a receiver can drain/render already has a ledger entry behind it.
|
|
9
|
+
// The reverse order (append, then record) left a window where the hook rendered
|
|
10
|
+
// a handle reply_to_message could not yet resolve (the race Codex caught).
|
|
11
|
+
//
|
|
12
|
+
// receiverSessionId may be null/empty (an unclaimed peer): then there is no
|
|
13
|
+
// ledger to own the handle and we skip the record — reply_to_message simply
|
|
14
|
+
// won't find it, which is the documented fall-back-to-send_message path.
|
|
15
|
+
//
|
|
16
|
+
// The ledger write is best-effort: a ledger failure must NEVER drop the actual
|
|
17
|
+
// delivery. Worst case the reply handle is missing and the peer falls back to
|
|
18
|
+
// send_message — never the reverse (a visible line with no handle on success),
|
|
19
|
+
// because record precedes append.
|
|
20
|
+
export function deliverToPeer(receiverSessionId, targetPid, body, fromSessionId, options = {}) {
|
|
21
|
+
const msg = mailbox.buildMessage(body, fromSessionId, options);
|
|
22
|
+
if (receiverSessionId) {
|
|
23
|
+
try {
|
|
24
|
+
recordReceived(receiverSessionId, msg);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
trace("received_ledger_write_failed", { message_id: msg.id, error: String(e) });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
mailbox.requeue(targetPid, msg);
|
|
31
|
+
return msg;
|
|
32
|
+
}
|
package/dist/mailbox.js
CHANGED
|
@@ -107,8 +107,12 @@ export function serializeMailboxLine(msg) {
|
|
|
107
107
|
}
|
|
108
108
|
return line;
|
|
109
109
|
}
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// Mint a message envelope WITHOUT writing it anywhere. Split out from enqueue so
|
|
111
|
+
// a higher layer (delivery.ts) can record the durable received-ledger entry
|
|
112
|
+
// BEFORE the mailbox line becomes visible — the ordering that guarantees any
|
|
113
|
+
// message_id a receiver can drain/render already has a ledger entry behind it.
|
|
114
|
+
export function buildMessage(body, from_session_id, options = {}) {
|
|
115
|
+
return {
|
|
112
116
|
schema_version: 1,
|
|
113
117
|
id: randomBytes(8).toString("hex"),
|
|
114
118
|
body,
|
|
@@ -120,10 +124,12 @@ export function enqueue(target_pid, body, from_session_id, options = {}) {
|
|
|
120
124
|
...(options.reply_to ? { reply_to: options.reply_to } : {}),
|
|
121
125
|
...(options.source_message_id ? { source_message_id: options.source_message_id } : {}),
|
|
122
126
|
};
|
|
123
|
-
|
|
127
|
+
}
|
|
128
|
+
export function enqueue(target_pid, body, from_session_id, options = {}) {
|
|
129
|
+
const msg = buildMessage(body, from_session_id, options);
|
|
124
130
|
acquireLock(target_pid);
|
|
125
131
|
try {
|
|
126
|
-
appendFileSync(mailboxPath(target_pid),
|
|
132
|
+
appendFileSync(mailboxPath(target_pid), serializeMailboxLine(msg));
|
|
127
133
|
}
|
|
128
134
|
finally {
|
|
129
135
|
releaseLock(target_pid);
|
package/dist/received.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { trace } from "./trace.js";
|
|
6
|
+
// The received-message ledger: a durable, per-session index of every inbound
|
|
7
|
+
// envelope, keyed by message_id. It exists because both delivery paths are
|
|
8
|
+
// DESTRUCTIVE — mailbox.drain() truncates the queue to 0 after a read, and the
|
|
9
|
+
// PreToolUse hook does `:> "$m"` after rendering messages into model context.
|
|
10
|
+
// So once a message is delivered, the mailbox no longer holds it. A reply verb
|
|
11
|
+
// (reply_to_message) that looks a message up by id therefore cannot rely on the
|
|
12
|
+
// mailbox; it needs this separate ledger.
|
|
13
|
+
//
|
|
14
|
+
// Correctness hinges on ORDERING, enforced by delivery.ts: the ledger entry is
|
|
15
|
+
// written BEFORE the mailbox line becomes visible. A drainer can only observe
|
|
16
|
+
// the line after the append, which happens strictly after this write — so any
|
|
17
|
+
// message_id a receiver can see has a ledger entry behind it. (The reverse order
|
|
18
|
+
// left a window where the hook rendered a handle reply_to_message couldn't yet
|
|
19
|
+
// resolve — the race Codex caught in review.)
|
|
20
|
+
//
|
|
21
|
+
// Ownership is structural: the ledger lives at received/<hash(session_id)>, and
|
|
22
|
+
// lookups only ever read the caller's own file. You can only reply to a message
|
|
23
|
+
// that was delivered to YOU.
|
|
24
|
+
function receivedDir() {
|
|
25
|
+
// Resolved lazily so tests can swap HOME between cases (mirrors mailbox.ts).
|
|
26
|
+
return join(homedir(), ".oxtail", "received");
|
|
27
|
+
}
|
|
28
|
+
// Hash the session_id into the filename (mirrors claims.ts) so two distinct ids
|
|
29
|
+
// can never collide onto one ledger file — a lossy character-sanitize could map
|
|
30
|
+
// different sessions to the same path. UUIDs are already path-safe; the hash is
|
|
31
|
+
// defensive and collision-free.
|
|
32
|
+
function ledgerKey(sessionId) {
|
|
33
|
+
return createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
|
|
34
|
+
}
|
|
35
|
+
function ledgerPath(sessionId) {
|
|
36
|
+
return join(receivedDir(), `${ledgerKey(sessionId)}.jsonl`);
|
|
37
|
+
}
|
|
38
|
+
function lockPath(sessionId) {
|
|
39
|
+
return `${ledgerPath(sessionId)}.lock`;
|
|
40
|
+
}
|
|
41
|
+
// Lock idiom mirrors mailbox.ts (mkdir-based, staleness-cleared). The ledger
|
|
42
|
+
// read-modify-write is small (bounded by receivedMax() lines) so the lock
|
|
43
|
+
// window is short.
|
|
44
|
+
const LOCK_STALE_MS = 30_000;
|
|
45
|
+
const LOCK_RETRY_LIMIT = 50;
|
|
46
|
+
const LOCK_RETRY_DELAY_MS = 10;
|
|
47
|
+
// Bounded retention: keep at most this many of the most-recent inbound messages
|
|
48
|
+
// per session. Read lazily so tests can tune it per-case. Generous by default so
|
|
49
|
+
// a realistic mailbox burst (read_my_messages budgets 50/drain) can't push a
|
|
50
|
+
// just-displayed handle out of the ledger before the receiver replies; when the
|
|
51
|
+
// cap DOES bite, recordReceived traces the drop so it is never silent.
|
|
52
|
+
export function receivedMax() {
|
|
53
|
+
const n = Number(process.env.OXTAIL_RECEIVED_MAX);
|
|
54
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1000;
|
|
55
|
+
}
|
|
56
|
+
function sleepSync(ms) {
|
|
57
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
58
|
+
}
|
|
59
|
+
function acquireLock(sessionId) {
|
|
60
|
+
mkdirSync(receivedDir(), { recursive: true, mode: 0o700 });
|
|
61
|
+
const lock = lockPath(sessionId);
|
|
62
|
+
for (let i = 0; i < LOCK_RETRY_LIMIT; i++) {
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
const err = e;
|
|
69
|
+
if (err.code !== "EEXIST")
|
|
70
|
+
throw err;
|
|
71
|
+
try {
|
|
72
|
+
const st = statSync(lock);
|
|
73
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
74
|
+
try {
|
|
75
|
+
rmdirSync(lock);
|
|
76
|
+
trace("received_lock_stale_clear", { session_id: sessionId });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// raced with another clearer; fall through to retry
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// stat may race; just retry
|
|
86
|
+
}
|
|
87
|
+
sleepSync(LOCK_RETRY_DELAY_MS);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`could not acquire received-ledger lock for ${sessionId}`);
|
|
91
|
+
}
|
|
92
|
+
function releaseLock(sessionId) {
|
|
93
|
+
try {
|
|
94
|
+
rmdirSync(lockPath(sessionId));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// ignore ENOENT / not-empty / EPERM
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function readLines(sessionId) {
|
|
101
|
+
try {
|
|
102
|
+
const raw = readFileSync(ledgerPath(sessionId), "utf8");
|
|
103
|
+
if (!raw)
|
|
104
|
+
return [];
|
|
105
|
+
return raw.split("\n").filter((l) => l.length > 0);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const err = e;
|
|
109
|
+
if (err.code === "ENOENT")
|
|
110
|
+
return [];
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Append an inbound envelope to the receiver's ledger and prune to receivedMax()
|
|
115
|
+
// (oldest dropped first). Called by delivery.ts BEFORE the mailbox append.
|
|
116
|
+
export function recordReceived(receiverSessionId, msg) {
|
|
117
|
+
if (!receiverSessionId)
|
|
118
|
+
return;
|
|
119
|
+
acquireLock(receiverSessionId);
|
|
120
|
+
try {
|
|
121
|
+
const lines = readLines(receiverSessionId);
|
|
122
|
+
lines.push(JSON.stringify(msg));
|
|
123
|
+
const max = receivedMax();
|
|
124
|
+
let pruned = lines;
|
|
125
|
+
if (lines.length > max) {
|
|
126
|
+
pruned = lines.slice(lines.length - max);
|
|
127
|
+
// No silent caps: a dropped handle becomes reply_to_message
|
|
128
|
+
// "message-not-found", so surface that the bound bit.
|
|
129
|
+
trace("received_ledger_pruned", {
|
|
130
|
+
session_id: receiverSessionId,
|
|
131
|
+
dropped: lines.length - max,
|
|
132
|
+
kept: max,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
writeFileSync(ledgerPath(receiverSessionId), pruned.join("\n") + "\n", {
|
|
136
|
+
mode: 0o600,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
releaseLock(receiverSessionId);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Look up a previously-received envelope by message_id in this session's ledger.
|
|
144
|
+
// Newest-first scan (ids are unique, so the first match is the only match).
|
|
145
|
+
// Returns null when not found / aged out — the fail-closed signal the reply
|
|
146
|
+
// verb turns into message-not-found. Read under the same lock so a concurrent
|
|
147
|
+
// recordReceived rewrite can't be observed half-written.
|
|
148
|
+
export function lookupReceived(receiverSessionId, messageId) {
|
|
149
|
+
if (!receiverSessionId)
|
|
150
|
+
return null;
|
|
151
|
+
acquireLock(receiverSessionId);
|
|
152
|
+
try {
|
|
153
|
+
const lines = readLines(receiverSessionId);
|
|
154
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(lines[i]);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (parsed &&
|
|
163
|
+
typeof parsed === "object" &&
|
|
164
|
+
parsed.id === messageId) {
|
|
165
|
+
return parsed;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
releaseLock(receiverSessionId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
export function receivedFilePath(sessionId) {
|
|
175
|
+
return ledgerPath(sessionId);
|
|
176
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -12,6 +12,8 @@ import { isAbstain } from "./detect/index.js";
|
|
|
12
12
|
import { trace } from "./trace.js";
|
|
13
13
|
import { buildEntry, chooseVerifiedWakePane, findByTmuxSession, readAll, refreshTmuxBinding, register, sessionPidsForId, unregister, } from "./registry.js";
|
|
14
14
|
import * as mailbox from "./mailbox.js";
|
|
15
|
+
import * as received from "./received.js";
|
|
16
|
+
import { deliverToPeer } from "./delivery.js";
|
|
15
17
|
import { recoverClaim, resolveAncestors, writeClaim } from "./claims.js";
|
|
16
18
|
import { decideReplyAutoWake, defaultAutowakeDir } from "./autowake.js";
|
|
17
19
|
import { markWoke, newWakeDebounceStore, recentlyWoke } from "./wake-debounce.js";
|
|
@@ -1053,7 +1055,11 @@ server.registerTool("send_message", {
|
|
|
1053
1055
|
}
|
|
1054
1056
|
const peer = resolved.entry;
|
|
1055
1057
|
const fromSessionId = entry.client.session_id ?? undefined;
|
|
1056
|
-
|
|
1058
|
+
// deliverToPeer records the durable reply-handle in the recipient's ledger
|
|
1059
|
+
// BEFORE the mailbox line is visible, so a later reply_to_message(message_id)
|
|
1060
|
+
// resolves even after the destructive mailbox/hook drain — and never sees a
|
|
1061
|
+
// displayed-but-unrecorded handle (record precedes append).
|
|
1062
|
+
const msg = deliverToPeer(peer.client.session_id, peer.server_pid, body, fromSessionId, {
|
|
1057
1063
|
reply_to,
|
|
1058
1064
|
source_message_id,
|
|
1059
1065
|
});
|
|
@@ -1076,6 +1082,100 @@ server.registerTool("send_message", {
|
|
|
1076
1082
|
...(wake_reason ? { wake_reason } : {}),
|
|
1077
1083
|
});
|
|
1078
1084
|
});
|
|
1085
|
+
server.registerTool("reply_to_message", {
|
|
1086
|
+
description: [
|
|
1087
|
+
"Reply to a specific inbound peer message by its message_id — the atomic, correlation-safe alternative to hand-wiring send_message's target + reply_to. The server looks the message up in this session's durable received-ledger, so you pass only the message_id the PreToolUse hook or read_my_messages already showed you; it derives the reply target (the original sender), carries reply_to=request_id when the inbound was an ask_peer (keeping the exchange correlated), and sets source_message_id for provenance. Replying to a plain send_message works too — it just omits reply_to. Ownership is structural: you can only reply to a message delivered to you.",
|
|
1088
|
+
"Delivery + wake match send_message exactly, including the wake-on-reply default: when the inbound carried a request_id and you leave wake unset, a freshly-idle requester is auto-woken; pass wake:\"auto\" to nudge any idle peer, or wake:\"off\" to suppress. Fail-closed: an unknown or aged-out message_id returns error message-not-found instead of guessing a target.",
|
|
1089
|
+
].join(" "),
|
|
1090
|
+
inputSchema: {
|
|
1091
|
+
message_id: z
|
|
1092
|
+
.string()
|
|
1093
|
+
.min(1)
|
|
1094
|
+
.describe("The message_id of the inbound peer message you are replying to, as shown by the PreToolUse hook or read_my_messages."),
|
|
1095
|
+
body: z
|
|
1096
|
+
.string()
|
|
1097
|
+
.min(1)
|
|
1098
|
+
.refine((s) => Buffer.byteLength(s, "utf8") <= 8192, {
|
|
1099
|
+
message: "body exceeds 8192 UTF-8 bytes",
|
|
1100
|
+
})
|
|
1101
|
+
.describe("Reply body, ≤8KB UTF-8. Verbatim."),
|
|
1102
|
+
wake: z
|
|
1103
|
+
.enum(["off", "auto"])
|
|
1104
|
+
.optional()
|
|
1105
|
+
.describe('Wake strategy, same semantics as send_message. Unset: wake-on-reply default (auto-wakes a freshly-idle requester when the inbound was an ask_peer). "auto": nudge any idle peer. "off": no nudge.'),
|
|
1106
|
+
},
|
|
1107
|
+
}, async ({ message_id, body, wake }) => {
|
|
1108
|
+
const myId = entry.client.session_id;
|
|
1109
|
+
if (!myId) {
|
|
1110
|
+
return jsonResult({
|
|
1111
|
+
schema_version: 1,
|
|
1112
|
+
ok: false,
|
|
1113
|
+
error: "no-session-id",
|
|
1114
|
+
message: "This session has not claimed a session_id, so it has no received-ledger to reply from. Call claim_session first.",
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
const inbound = received.lookupReceived(myId, message_id);
|
|
1118
|
+
if (!inbound) {
|
|
1119
|
+
return jsonResult({
|
|
1120
|
+
schema_version: 1,
|
|
1121
|
+
ok: false,
|
|
1122
|
+
error: "message-not-found",
|
|
1123
|
+
message: `No received message ${message_id} in this session's ledger (it may have aged out of retention, or predates reply_to_message). Fall back to send_message with an explicit target.`,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
const targetSid = inbound.from_session_id;
|
|
1127
|
+
if (!targetSid) {
|
|
1128
|
+
return jsonResult({
|
|
1129
|
+
schema_version: 1,
|
|
1130
|
+
ok: false,
|
|
1131
|
+
error: "no-reply-target",
|
|
1132
|
+
message: `Inbound message ${message_id} has no from_session_id, so there is no peer to reply to.`,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
const replyTo = inbound.request_id; // undefined when the inbound was a plain send_message
|
|
1136
|
+
const resolved = resolveTarget(targetSid, entry);
|
|
1137
|
+
if (!resolved.ok) {
|
|
1138
|
+
const replyDefault = replyAutoWakeTriggered(wake, replyTo);
|
|
1139
|
+
const wakeIntended = wake === "auto" || replyDefault;
|
|
1140
|
+
const wake_status = wakeIntended ? resolveErrorWakeStatus(resolved.error) : undefined;
|
|
1141
|
+
return jsonResult({
|
|
1142
|
+
schema_version: 1,
|
|
1143
|
+
...resolved,
|
|
1144
|
+
in_reply_to_message_id: message_id,
|
|
1145
|
+
original_from_session_id: targetSid,
|
|
1146
|
+
...(wake_status ? { wake_status } : {}),
|
|
1147
|
+
...(replyDefault ? { wake_reason: "reply_to_default" } : {}),
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
const peer = resolved.entry;
|
|
1151
|
+
const fromSessionId = entry.client.session_id ?? undefined;
|
|
1152
|
+
// Record the reply itself into the original asker's ledger (record-before-
|
|
1153
|
+
// append) so replies can be replied to in turn — chained correlation.
|
|
1154
|
+
const msg = deliverToPeer(peer.client.session_id, peer.server_pid, body, fromSessionId, {
|
|
1155
|
+
reply_to: replyTo,
|
|
1156
|
+
source_message_id: message_id,
|
|
1157
|
+
});
|
|
1158
|
+
const { wake_status, wake_reason } = await resolveSendWake(peer, wake, replyTo);
|
|
1159
|
+
if (wake_status) {
|
|
1160
|
+
trace("wake_outcome", {
|
|
1161
|
+
via: wake_reason === "reply_to_default" ? "reply_default" : "reply_to_message",
|
|
1162
|
+
wake_status,
|
|
1163
|
+
target_session_id: peer.client.session_id,
|
|
1164
|
+
client_type: peer.client.type,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
return jsonResult({
|
|
1168
|
+
schema_version: 1,
|
|
1169
|
+
ok: true,
|
|
1170
|
+
message_id: msg.id,
|
|
1171
|
+
in_reply_to_message_id: message_id,
|
|
1172
|
+
target_session_id: peer.client.session_id,
|
|
1173
|
+
target_server_pid: peer.server_pid,
|
|
1174
|
+
correlation: replyTo ? "correlated" : "uncorrelated",
|
|
1175
|
+
...(wake_status ? { wake_status } : {}),
|
|
1176
|
+
...(wake_reason ? { wake_reason } : {}),
|
|
1177
|
+
});
|
|
1178
|
+
});
|
|
1079
1179
|
// read_my_messages budget. A session's union drain can return a backlog; cap
|
|
1080
1180
|
// how much one call hands back so a flood (or a peer spamming near-8KB bodies)
|
|
1081
1181
|
// can't blow the caller's context in a single drain. Overflow is NOT dropped or
|
|
@@ -1556,7 +1656,9 @@ server.registerTool("ask_peer", {
|
|
|
1556
1656
|
const requestId = randomBytes(8).toString("hex");
|
|
1557
1657
|
const requireReplyTo = peerSupportsReplyTo(peer);
|
|
1558
1658
|
const fromSessionId = entry.client.session_id ?? undefined;
|
|
1559
|
-
|
|
1659
|
+
// Record-before-append (mirrors send_message): lets the peer answer with
|
|
1660
|
+
// reply_to_message(message_id) instead of hand-wiring target + reply_to.
|
|
1661
|
+
const msg = deliverToPeer(expectedSessionId, peer.server_pid, body, fromSessionId, {
|
|
1560
1662
|
request_id: requestId,
|
|
1561
1663
|
});
|
|
1562
1664
|
const startedAt = Date.now();
|