oxtail 0.11.0 → 0.12.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 +1 -0
- package/README.md +29 -9
- package/dist/diagnose.js +75 -0
- package/dist/registry.js +80 -28
- package/dist/server.js +62 -28
- package/dist/wake-debounce.js +45 -0
- package/package.json +1 -1
- package/scripts/check-codex-paste-burst.mjs +63 -0
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 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`.
|
|
61
62
|
- **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.
|
|
62
63
|
- **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.
|
|
63
64
|
- **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.
|
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.12.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.10.1/.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.12.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.12.0/integrations/codex/oxtail-join/agents/openai.yaml \
|
|
50
50
|
-o ~/.codex/skills/oxtail-join/agents/openai.yaml
|
|
51
51
|
```
|
|
52
52
|
|
|
@@ -71,7 +71,7 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
|
|
|
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.12.0/AGENTS.md) for scope and architecture.
|
|
75
75
|
|
|
76
76
|
## Usage from an agent
|
|
77
77
|
|
|
@@ -172,6 +172,8 @@ If you have a hook installed on a managed event that isn't from Terminator and i
|
|
|
172
172
|
|
|
173
173
|
oxtail trusts any process running as the **same local user** to enqueue messages. The mailbox directory is mode `0o700` (private), so other users on the host cannot read or write. **On a shared-tenancy box (containers, multi-user dev hosts, etc.), do not run oxtail-aware agents:** any local process under your user can inject `<system-reminder>` content directly into a Claude session. The threat boundary is the same as `~/.ssh/` — what your user processes do, you trust.
|
|
174
174
|
|
|
175
|
+
Within that boundary oxtail still *narrows* redirectable side effects, as defense-in-depth rather than a hard boundary: wake keystrokes only go to the pane the process tree confirms hosts the target's `server_pid`, never a self-written `tmux_pane`/`tmux_session` (see [Pane targeting](#pane-targeting-verified)), and an accepted registry entry can't borrow another pid — its `server_pid` must match its own `<pid>.json` filename. So one peer's entry can't masquerade as hosting another agent to redirect that agent's wake. A same-user process can still overwrite any registry file outright (that's the trust boundary above); what it can't do is smuggle a pid mismatch past a reader.
|
|
176
|
+
|
|
175
177
|
## Delegate-and-wait (v0.10.1)
|
|
176
178
|
|
|
177
179
|
`ask_peer` extends v0.5's mailbox transport into a blocking primitive:
|
|
@@ -182,7 +184,7 @@ ask_peer({ target, body })
|
|
|
182
184
|
ok: true,
|
|
183
185
|
message_id,
|
|
184
186
|
request_id,
|
|
185
|
-
wake_status: "fired" | "
|
|
187
|
+
wake_status: "fired" | "skipped_busy" | "skipped_debounced" | "skipped_no_target" | "disabled",
|
|
186
188
|
reply: { id, body, enqueued_at, from_session_id, reply_to, correlation } | null,
|
|
187
189
|
correlation: "correlated" | "uncorrelated" | "none",
|
|
188
190
|
timeout_ms,
|
|
@@ -190,7 +192,7 @@ ask_peer({ target, body })
|
|
|
190
192
|
}
|
|
191
193
|
```
|
|
192
194
|
|
|
193
|
-
`wake_status` distinguishes the
|
|
195
|
+
`wake_status` distinguishes the 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_busy` means the peer is mid-turn (its hooks/poll will deliver — we still poll for the reply). `skipped_debounced` means a wake fired for this peer moments ago and this one was coalesced. `skipped_no_target` means no process-tree-verified pane resolved for the target. `disabled` means `OXTAIL_ASK_PEER_WAKE_STRATEGY=off` is in effect. (`skipped_unsupported` is reserved — no client currently returns it.)
|
|
194
196
|
|
|
195
197
|
`timed_out` is `true` only when the poll loop ran to its deadline without a reply.
|
|
196
198
|
|
|
@@ -220,9 +222,13 @@ ask_peer({ target, body })
|
|
|
220
222
|
4. Poll the caller's mailbox at 200ms. For reply-to-capable peers, only a message with both `from_session_id == target.session_id` and `reply_to == request_id` satisfies the wait; non-matching messages stay in the mailbox untouched. Legacy/no-capability peers are best-effort and are marked `correlation: "uncorrelated"`; this preserves old peers but can stale-match old same-peer chatter.
|
|
221
223
|
5. Return the reply on match, or `{ reply: null, timed_out: true, wake_status, correlation: "none" }` after the timeout. Late replies fall back to the normal v0.5 hook / `read_my_messages` path — never lost, just delivered out of band.
|
|
222
224
|
|
|
223
|
-
### Pane
|
|
225
|
+
### Pane targeting (verified)
|
|
226
|
+
|
|
227
|
+
A peer's cached `tmux_pane` / `tmux_session` are written by the peer into its **own** registry file, so they aren't trustworthy targets for keystrokes — a malicious local peer could point them at someone else's pane. The **only** send-keys target oxtail uses is the pane the live process tree says currently hosts the peer's `server_pid` (resolved at wake-time via `ps`/`tmux` ancestry — unforgeable by editing a JSON file). This also handles pane-id churn for free: the pane is always re-resolved fresh. If `server_pid` can't be bound to any live pane, oxtail **refuses** to wake (`wake_status: "skipped_no_target"`) rather than fall back to a self-written value. `server_pid` itself is self-written too, so registry entries whose `server_pid` doesn't match their own `<pid>.json` filename are rejected — a forged entry can't borrow another process's pane. The pane id that does reach `tmux` is shape-validated (`%\d+`); session names are no longer used as a send-keys target at all. (Hardening from issue #6.)
|
|
228
|
+
|
|
229
|
+
### Wake debouncing
|
|
224
230
|
|
|
225
|
-
|
|
231
|
+
All wake paths funnel through one place, which **coalesces** rapid repeat wakes to the same peer: if a wake fired for a peer within `OXTAIL_WAKE_DEBOUNCE_MS` (default 1s), a follow-up wake is skipped (`wake_status: "skipped_debounced"`) and relies on the still-pending response. This keeps a retried `ask_peer`, two callers racing the same peer, or a polling loop from stacking notification lines into the peer's composer. In-memory and per-process by design. (Issue #5.)
|
|
226
232
|
|
|
227
233
|
### Constraints
|
|
228
234
|
|
|
@@ -281,10 +287,24 @@ When a strategy doesn't fire, it returns an abstention with a `reason` (e.g. `"2
|
|
|
281
287
|
|
|
282
288
|
If `MCP_TRACE_FILE` is set in the environment, every detection run appends an NDJSON record with trigger, winning strategy, per-strategy outcomes, and `next_step`. Useful for diagnosing unresolved `client_session_id`s in the wild.
|
|
283
289
|
|
|
290
|
+
### Diagnosing wakes (`oxtail diagnose`)
|
|
291
|
+
|
|
292
|
+
The same `MCP_TRACE_FILE` also captures a `wake_outcome` record for every wake (which tool drove it and the resulting `wake_status`). Run:
|
|
293
|
+
|
|
294
|
+
```sh
|
|
295
|
+
oxtail diagnose
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
to get a summary — counts by `wake_status`, broken down by tool — so "is the wake mechanism working in my environment?" is one command instead of grepping JSONL. With `MCP_TRACE_FILE` unset it just prints how to enable tracing. (Issue #7.)
|
|
299
|
+
|
|
300
|
+
A scheduled CI job (`.github/workflows/codex-drift.yml`, also runnable on demand) fetches Codex's upstream `PASTE_ENTER_SUPPRESS_WINDOW` and fails if it drifts past oxtail's 500ms Codex wake gap — so a future Codex release that would break the wake surfaces as a red job rather than a silent field regression.
|
|
301
|
+
|
|
284
302
|
## Status
|
|
285
303
|
|
|
286
|
-
v0.
|
|
304
|
+
v0.12.0. Pushes the autonomous peer-messaging matrix toward zero human relay, then hardens the wake path.
|
|
287
305
|
|
|
306
|
+
- **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
|
+
- **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.
|
|
288
308
|
- **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"`.
|
|
289
309
|
- **Identity monotonicity.** `claim_session` / `register_my_session` and sticky-claim recovery are authoritative after they set a session id; later automatic detection cannot clobber a claimed id with stale env data.
|
|
290
310
|
- **Hook push budgeting and provenance.** PreToolUse/Stop delivery stamps `origin: "peer"`, reminds receivers that peer messages are not user authority, and caps hook-injected body text via `OXTAIL_HOOK_MAX_BODY_CHARS`.
|
package/dist/diagnose.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Issue #7 — `oxtail diagnose`.
|
|
2
|
+
//
|
|
3
|
+
// The wake mechanism is environment-sensitive (tmux present? peer in a pane?
|
|
4
|
+
// Codex paste-burst gap still sufficient?). When it silently doesn't work, a
|
|
5
|
+
// user otherwise has to spelunk MCP_TRACE_FILE by hand. This summarizes the
|
|
6
|
+
// `wake_outcome` trace events oxtail emits — counts by wake_status, broken down
|
|
7
|
+
// by which tool drove the wake — so "is wake working here?" is one command.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
// Keep only `wake_outcome` events, newest `limit`, and tally them. Malformed
|
|
10
|
+
// JSONL lines are skipped (a trace file can be concurrently appended).
|
|
11
|
+
export function summarizeWakeOutcomes(lines, limit = 200) {
|
|
12
|
+
const outcomes = [];
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
if (!line.trim())
|
|
15
|
+
continue;
|
|
16
|
+
let rec;
|
|
17
|
+
try {
|
|
18
|
+
rec = JSON.parse(line);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (rec.event === "wake_outcome")
|
|
24
|
+
outcomes.push(rec);
|
|
25
|
+
}
|
|
26
|
+
const recent = limit > 0 ? outcomes.slice(-limit) : outcomes;
|
|
27
|
+
const byStatus = {};
|
|
28
|
+
const byVia = {};
|
|
29
|
+
for (const r of recent) {
|
|
30
|
+
const status = String(r.wake_status ?? "unknown");
|
|
31
|
+
const via = String(r.via ?? "unknown");
|
|
32
|
+
byStatus[status] = (byStatus[status] ?? 0) + 1;
|
|
33
|
+
const viaBucket = (byVia[via] ??= {});
|
|
34
|
+
viaBucket[status] = (viaBucket[status] ?? 0) + 1;
|
|
35
|
+
}
|
|
36
|
+
return { total: recent.length, considered: outcomes.length, byStatus, byVia };
|
|
37
|
+
}
|
|
38
|
+
function sortedCounts(counts) {
|
|
39
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
40
|
+
}
|
|
41
|
+
export function formatWakeSummary(s) {
|
|
42
|
+
if (s.total === 0) {
|
|
43
|
+
return "oxtail diagnose: no wake_outcome events in the trace yet (no ask_peer / wake:auto / reply-default wakes recorded).";
|
|
44
|
+
}
|
|
45
|
+
const lines = [];
|
|
46
|
+
const capped = s.considered > s.total ? ` (newest ${s.total} of ${s.considered})` : ` (${s.total})`;
|
|
47
|
+
lines.push(`oxtail diagnose — wake outcomes${capped}:`);
|
|
48
|
+
for (const [status, n] of sortedCounts(s.byStatus)) {
|
|
49
|
+
lines.push(` ${status}: ${n}`);
|
|
50
|
+
}
|
|
51
|
+
lines.push("by tool:");
|
|
52
|
+
for (const [via, counts] of Object.entries(s.byVia).sort()) {
|
|
53
|
+
const parts = sortedCounts(counts).map(([st, n]) => `${st} ${n}`);
|
|
54
|
+
lines.push(` ${via}: ${parts.join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
// CLI entry. Returns a process exit code; `out` is injectable for tests.
|
|
59
|
+
export function runDiagnose(traceFile, out = console.log) {
|
|
60
|
+
if (!traceFile) {
|
|
61
|
+
out("oxtail diagnose: MCP_TRACE_FILE is not set, so there is no trace data to summarize.");
|
|
62
|
+
out("Set MCP_TRACE_FILE=/path/to/oxtail-trace.jsonl in the oxtail MCP server's env (e.g. in .mcp.json / ~/.claude.json / ~/.codex/config.toml), reproduce some wakes, then re-run `oxtail diagnose`.");
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
let content;
|
|
66
|
+
try {
|
|
67
|
+
content = readFileSync(traceFile, "utf8");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
out(`oxtail diagnose: could not read trace file ${traceFile} (set MCP_TRACE_FILE and reproduce some wakes first).`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
out(formatWakeSummary(summarizeWakeOutcomes(content.split("\n"))));
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
package/dist/registry.js
CHANGED
|
@@ -42,6 +42,73 @@ function ensureDir() {
|
|
|
42
42
|
function entryPath(pid) {
|
|
43
43
|
return join(registryDir(), `${pid}.json`);
|
|
44
44
|
}
|
|
45
|
+
// tmux's own identifiers, used to sanitize registry-sourced values before they
|
|
46
|
+
// reach a `tmux` command. A pane id is always `%<n>`; a session name, per tmux's
|
|
47
|
+
// rules for names we create, is `[A-Za-z0-9_-]+`. Validating defends against a
|
|
48
|
+
// malicious local peer writing a crafted `tmux_pane`/`tmux_session` into its own
|
|
49
|
+
// registry file to redirect or trick our wake send-keys (issue #6).
|
|
50
|
+
export function isValidTmuxPane(s) {
|
|
51
|
+
return /^%\d+$/.test(s);
|
|
52
|
+
}
|
|
53
|
+
export function isValidTmuxSession(s) {
|
|
54
|
+
return /^[A-Za-z0-9_-]+$/.test(s);
|
|
55
|
+
}
|
|
56
|
+
// The ONLY trustworthy send-keys target for waking a peer: the pane the live
|
|
57
|
+
// process tree says currently hosts the peer's `server_pid`. This is computed
|
|
58
|
+
// from `ps`/`tmux` state (currentPaneForServerPid), so it cannot be forged by a
|
|
59
|
+
// peer editing its own `~/.oxtail/sessions/<pid>.json` — unlike the cached
|
|
60
|
+
// `tmux_pane`/`tmux_session` fields, which the peer self-writes. Returns null
|
|
61
|
+
// (caller must refuse to wake) when:
|
|
62
|
+
// - the peer never registered a pane: a legit tmux-hosted peer always does
|
|
63
|
+
// (its session is derived from the pane), so a pane-less/session-only entry
|
|
64
|
+
// is hand-written or spoofed and must never be blind-fired; gating on a
|
|
65
|
+
// registered pane also avoids fishing for a pane from server_pid alone,
|
|
66
|
+
// which in tests can collide with the test runner's own pane.
|
|
67
|
+
// - server_pid isn't under any live tmux pane: we can't bind a trustworthy
|
|
68
|
+
// target, so we refuse rather than fall back to the self-written cached value.
|
|
69
|
+
// - the resolved pane isn't a well-formed pane id (tmux output anomaly).
|
|
70
|
+
// resolvePane is injected in tests; production uses currentPaneForServerPid.
|
|
71
|
+
export function chooseVerifiedWakePane(peer, resolvePane = currentPaneForServerPid) {
|
|
72
|
+
if (!peer.tmux_pane)
|
|
73
|
+
return null;
|
|
74
|
+
const live = resolvePane(peer.server_pid);
|
|
75
|
+
if (!live || !isValidTmuxPane(live))
|
|
76
|
+
return null;
|
|
77
|
+
return live;
|
|
78
|
+
}
|
|
79
|
+
// Extract the pid a registry filename encodes: `<pid>.json` → pid, else null.
|
|
80
|
+
export function filenamePid(file) {
|
|
81
|
+
const m = /^(\d+)\.json$/.exec(file);
|
|
82
|
+
if (!m)
|
|
83
|
+
return null;
|
|
84
|
+
const pid = Number(m[1]);
|
|
85
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
86
|
+
}
|
|
87
|
+
// Read + parse a registry file, enforcing the provenance invariant that a
|
|
88
|
+
// process only ever writes its OWN `<pid>.json`: the parsed `server_pid` MUST
|
|
89
|
+
// equal the pid in the filename. register() always writes them in agreement, so
|
|
90
|
+
// a mismatch means the entry was hand-forged to borrow another process's pid —
|
|
91
|
+
// the #6 redirect where a peer self-writes `server_pid: <victimPid>` so that
|
|
92
|
+
// chooseVerifiedWakePane → currentPaneForServerPid resolves (and wakes) the
|
|
93
|
+
// victim's pane. Such entries, plus non-`<pid>.json` names and parse failures,
|
|
94
|
+
// are rejected (returns null) so no raw-registry reader trusts them. The
|
|
95
|
+
// local-user trust boundary still holds (a same-user process can overwrite any
|
|
96
|
+
// file), but this stops one peer's entry from impersonating another pid.
|
|
97
|
+
export function readEntryFile(dir, file) {
|
|
98
|
+
const fnamePid = filenamePid(file);
|
|
99
|
+
if (fnamePid === null)
|
|
100
|
+
return null;
|
|
101
|
+
let entry;
|
|
102
|
+
try {
|
|
103
|
+
entry = JSON.parse(readFileSync(join(dir, file), "utf8"));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (entry.server_pid !== fnamePid)
|
|
109
|
+
return null;
|
|
110
|
+
return entry;
|
|
111
|
+
}
|
|
45
112
|
function resolveTmuxSessionFromPane(pane) {
|
|
46
113
|
if (!pane)
|
|
47
114
|
return null;
|
|
@@ -120,7 +187,10 @@ export function findTmuxPaneByAncestry(startPid, panePids, ppids) {
|
|
|
120
187
|
return null;
|
|
121
188
|
}
|
|
122
189
|
export function resolveTmuxPane(env = process.env, pid = process.pid) {
|
|
123
|
-
if
|
|
190
|
+
// TMUX_PANE is a peer-controllable env var: only trust it if it has tmux's
|
|
191
|
+
// pane-id shape (%N). A spoofed/malformed value falls through to process-tree
|
|
192
|
+
// ancestry, which can't be forged by editing the environment (issue #6).
|
|
193
|
+
if (env.TMUX_PANE && isValidTmuxPane(env.TMUX_PANE))
|
|
124
194
|
return env.TMUX_PANE;
|
|
125
195
|
return findTmuxPaneByAncestry(pid, listTmuxPanePids(), listAllPpids());
|
|
126
196
|
}
|
|
@@ -194,16 +264,10 @@ function gcDeadSiblings(entry) {
|
|
|
194
264
|
if (!existsSync(dir))
|
|
195
265
|
return;
|
|
196
266
|
for (const file of readdirSync(dir)) {
|
|
197
|
-
|
|
198
|
-
|
|
267
|
+
const other = readEntryFile(dir, file);
|
|
268
|
+
if (!other)
|
|
269
|
+
continue; // skip non-<pid>.json, parse errors, and forged entries
|
|
199
270
|
const full = join(dir, file);
|
|
200
|
-
let other;
|
|
201
|
-
try {
|
|
202
|
-
other = JSON.parse(readFileSync(full, "utf8"));
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
271
|
if (other.server_pid === entry.server_pid)
|
|
208
272
|
continue;
|
|
209
273
|
if (other.client.session_id !== sid)
|
|
@@ -267,16 +331,10 @@ export function readAll() {
|
|
|
267
331
|
return [];
|
|
268
332
|
const live = [];
|
|
269
333
|
for (const file of readdirSync(dir)) {
|
|
270
|
-
|
|
271
|
-
|
|
334
|
+
const entry = readEntryFile(dir, file);
|
|
335
|
+
if (!entry)
|
|
336
|
+
continue; // non-<pid>.json, parse error, or forged server_pid
|
|
272
337
|
const full = join(dir, file);
|
|
273
|
-
let entry;
|
|
274
|
-
try {
|
|
275
|
-
entry = JSON.parse(readFileSync(full, "utf8"));
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
338
|
if (!isAlive(entry.server_pid)) {
|
|
281
339
|
// Reap-deferral: a dead child's mailbox may still hold undrained mail
|
|
282
340
|
// that the session's union-drain (PreToolUse hook + read_my_messages)
|
|
@@ -342,15 +400,9 @@ export function sessionPidsForId(sessionId) {
|
|
|
342
400
|
return [];
|
|
343
401
|
const entries = [];
|
|
344
402
|
for (const file of readdirSync(dir)) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
try {
|
|
349
|
-
e = JSON.parse(readFileSync(join(dir, file), "utf8"));
|
|
350
|
-
}
|
|
351
|
-
catch {
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
403
|
+
const e = readEntryFile(dir, file);
|
|
404
|
+
if (!e)
|
|
405
|
+
continue; // skip non-<pid>.json, parse errors, and forged entries
|
|
354
406
|
if (e.client.session_id === sessionId)
|
|
355
407
|
entries.push(e);
|
|
356
408
|
}
|
package/dist/server.js
CHANGED
|
@@ -10,10 +10,11 @@ import { dirname, join, sep } from "node:path";
|
|
|
10
10
|
import { clientFromHandshake, detectClient, enrichWithDiagnosis, transcriptPathFor, } from "./clients.js";
|
|
11
11
|
import { isAbstain } from "./detect/index.js";
|
|
12
12
|
import { trace } from "./trace.js";
|
|
13
|
-
import { buildEntry,
|
|
13
|
+
import { buildEntry, chooseVerifiedWakePane, 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
16
|
import { decideReplyAutoWake, defaultAutowakeDir } from "./autowake.js";
|
|
17
|
+
import { markWoke, newWakeDebounceStore, recentlyWoke } from "./wake-debounce.js";
|
|
17
18
|
// CLI subcommand dispatch must run before any MCP setup so that
|
|
18
19
|
// `npx oxtail install-hook` doesn't open an MCP transport or register a
|
|
19
20
|
// session. Use named exports and await them; calling `await import(...)`
|
|
@@ -33,6 +34,10 @@ import { decideReplyAutoWake, defaultAutowakeDir } from "./autowake.js";
|
|
|
33
34
|
await mod.uninstall();
|
|
34
35
|
process.exit(0);
|
|
35
36
|
}
|
|
37
|
+
if (sub === "diagnose") {
|
|
38
|
+
const { runDiagnose } = await import("./diagnose.js");
|
|
39
|
+
process.exit(runDiagnose(process.env.MCP_TRACE_FILE));
|
|
40
|
+
}
|
|
36
41
|
}
|
|
37
42
|
import { readClaudeTranscript, readCodexTranscript, } from "./transcripts.js";
|
|
38
43
|
// Single builder for every readSession return so the field set (including the
|
|
@@ -1003,7 +1008,7 @@ function resolveTarget(target, caller) {
|
|
|
1003
1008
|
server.registerTool("send_message", {
|
|
1004
1009
|
description: [
|
|
1005
1010
|
"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.",
|
|
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\".",
|
|
1011
|
+
"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_debounced\" | \"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\".",
|
|
1007
1012
|
"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.",
|
|
1008
1013
|
].join(" "),
|
|
1009
1014
|
inputSchema: {
|
|
@@ -1053,6 +1058,14 @@ server.registerTool("send_message", {
|
|
|
1053
1058
|
source_message_id,
|
|
1054
1059
|
});
|
|
1055
1060
|
const { wake_status, wake_reason } = await resolveSendWake(peer, wake, reply_to);
|
|
1061
|
+
if (wake_status) {
|
|
1062
|
+
trace("wake_outcome", {
|
|
1063
|
+
via: wake_reason === "reply_to_default" ? "reply_default" : "send_message",
|
|
1064
|
+
wake_status,
|
|
1065
|
+
target_session_id: peer.client.session_id,
|
|
1066
|
+
client_type: peer.client.type,
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1056
1069
|
return jsonResult({
|
|
1057
1070
|
schema_version: 1,
|
|
1058
1071
|
ok: true,
|
|
@@ -1251,11 +1264,11 @@ function askPeerDelay(ms, signal) {
|
|
|
1251
1264
|
// parsed as a key event. The -l flag neutralizes any tmux keysequences a
|
|
1252
1265
|
// malicious peer could plant in its registry entry.
|
|
1253
1266
|
//
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1267
|
+
// askPeerWakeImpl keeps a generic pane→sessionName retry for its own unit
|
|
1268
|
+
// tests, but PRODUCTION wakePeer now passes only the process-tree-verified pane
|
|
1269
|
+
// (sessionName = null): a self-written tmux_session is not a trustworthy
|
|
1270
|
+
// send-keys target (issue #6), and pane-id churn is handled by re-resolving the
|
|
1271
|
+
// pane from server_pid on every wake rather than by a session fallback.
|
|
1259
1272
|
async function defaultFireWakeKeystrokes(target, clientType) {
|
|
1260
1273
|
execFileSync("tmux", ["send-keys", "-t", target, "-l", ASK_PEER_WAKE_TEXT], {
|
|
1261
1274
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -1302,46 +1315,61 @@ export async function askPeerWakeImpl(pane, sessionName, fire) {
|
|
|
1302
1315
|
// peer's client_type. Returns the wake_status that should surface in the
|
|
1303
1316
|
// ask_peer response so callers can distinguish "we tried, no answer" from
|
|
1304
1317
|
// "we didn't try because the client can't be woken."
|
|
1318
|
+
// In-memory per-process wake-debounce state, keyed by peer session_id. Coalesces
|
|
1319
|
+
// rapid repeat wakes to the same peer across all wake paths (issue #5).
|
|
1320
|
+
const wakeDebounce = newWakeDebounceStore();
|
|
1305
1321
|
async function wakePeer(peer) {
|
|
1306
1322
|
if (ASK_PEER_WAKE_STRATEGY === "off") {
|
|
1307
1323
|
trace("ask_peer_wake_skipped", { reason: "strategy-off" });
|
|
1308
1324
|
return "disabled";
|
|
1309
1325
|
}
|
|
1310
1326
|
const clientType = peer.client.type;
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
+
// #5: coalesce a rapid repeat wake to the same peer (concurrent/retried
|
|
1328
|
+
// ask_peer, polling loops) so we don't stack a second notification line into
|
|
1329
|
+
// its composer. Keyed on session_id; an unclaimed peer (no id) isn't debounced.
|
|
1330
|
+
const sid = peer.client.session_id;
|
|
1331
|
+
if (sid && recentlyWoke(wakeDebounce, sid, Date.now())) {
|
|
1332
|
+
trace("ask_peer_wake_skipped", { reason: "debounced", target_session_id: sid });
|
|
1333
|
+
return "skipped_debounced";
|
|
1334
|
+
}
|
|
1335
|
+
// Security (#6): tmux_pane / tmux_session come from the peer's OWN registry
|
|
1336
|
+
// file, so a malicious local peer could point them at someone else's pane or
|
|
1337
|
+
// session to redirect our wake keystrokes. The ONLY trustworthy send-keys
|
|
1338
|
+
// target is the pane the live process tree says currently hosts the peer's
|
|
1339
|
+
// server_pid — chooseVerifiedWakePane resolves that and refuses (returns null)
|
|
1340
|
+
// when it can't be verified, instead of falling back to the self-written
|
|
1341
|
+
// cached pane or tmux_session. This also subsumes the old stale-pane re-
|
|
1342
|
+
// resolution race fix: we ALWAYS use the freshly process-tree-resolved pane.
|
|
1343
|
+
const verifiedPane = chooseVerifiedWakePane(peer);
|
|
1344
|
+
if (!verifiedPane) {
|
|
1345
|
+
trace("ask_peer_wake_skipped", {
|
|
1346
|
+
reason: "no-verified-pane",
|
|
1327
1347
|
cached: peer.tmux_pane,
|
|
1328
|
-
live: livePane,
|
|
1329
1348
|
server_pid: peer.server_pid,
|
|
1349
|
+
target_session_id: peer.client.session_id,
|
|
1330
1350
|
});
|
|
1351
|
+
return "skipped_no_target";
|
|
1331
1352
|
}
|
|
1332
|
-
|
|
1333
|
-
trace("
|
|
1353
|
+
if (verifiedPane !== peer.tmux_pane) {
|
|
1354
|
+
trace("ask_peer_wake_pane_refreshed", {
|
|
1334
1355
|
cached: peer.tmux_pane,
|
|
1356
|
+
live: verifiedPane,
|
|
1335
1357
|
server_pid: peer.server_pid,
|
|
1336
1358
|
});
|
|
1337
1359
|
}
|
|
1338
|
-
const effectivePane = livePane ?? peer.tmux_pane;
|
|
1339
1360
|
// Legacy mode bypasses per-client routing: every wake is the v0.6 sequence
|
|
1340
1361
|
// (no inter-keystroke delay). Cast to "unknown" so defaultFireWakeKeystrokes
|
|
1341
1362
|
// skips the Codex delay branch.
|
|
1342
1363
|
const fireType = ASK_PEER_WAKE_STRATEGY === "legacy" ? "unknown" : clientType;
|
|
1343
1364
|
const fire = (target) => defaultFireWakeKeystrokes(target, fireType);
|
|
1344
|
-
|
|
1365
|
+
// #5: stamp the debounce BEFORE the (possibly async, paste-burst-delayed) fire
|
|
1366
|
+
// so a concurrent second wakePeer for this peer — which runs while we're
|
|
1367
|
+
// awaiting send-keys — sees the stamp and coalesces instead of double-firing.
|
|
1368
|
+
if (sid)
|
|
1369
|
+
markWoke(wakeDebounce, sid, Date.now());
|
|
1370
|
+
// No session-name fallback: a self-written tmux_session could target another
|
|
1371
|
+
// session, and the verified pane already handles pane-id churn. Pass null.
|
|
1372
|
+
const ok = await askPeerWakeImpl(verifiedPane, null, fire);
|
|
1345
1373
|
return ok ? "fired" : "skipped_no_target";
|
|
1346
1374
|
}
|
|
1347
1375
|
// --- send_message wake:auto gating -------------------------------------------
|
|
@@ -1562,6 +1590,12 @@ server.registerTool("ask_peer", {
|
|
|
1562
1590
|
// send_message wake:auto. (Codex has no activity file, so it is never
|
|
1563
1591
|
// detected busy and still fires — unchanged for that client.)
|
|
1564
1592
|
wakeStatus = await wakeForSend(peer);
|
|
1593
|
+
trace("wake_outcome", {
|
|
1594
|
+
via: "ask_peer",
|
|
1595
|
+
wake_status: wakeStatus,
|
|
1596
|
+
target_session_id: peer.client.session_id,
|
|
1597
|
+
client_type: peer.client.type,
|
|
1598
|
+
});
|
|
1565
1599
|
if (wakeStatus === "skipped_unsupported") {
|
|
1566
1600
|
// Reserved branch. No client currently returns skipped_unsupported
|
|
1567
1601
|
// in auto mode (Codex and Claude Code both wake via send-keys).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Issue #5 — per-peer wake debouncer.
|
|
2
|
+
//
|
|
3
|
+
// Every wake fires `tmux send-keys` into the peer's composer. When the same peer
|
|
4
|
+
// is woken again within a fraction of a second — a caller retrying ask_peer, two
|
|
5
|
+
// callers targeting the same peer concurrently, or a polling loop — oxtail blasts
|
|
6
|
+
// a second WAKE_TEXT line on top of the first, which (with the Codex paste-burst
|
|
7
|
+
// gap) can land inside an already-active turn. This debouncer coalesces those:
|
|
8
|
+
// if a wake fired for a peer within a short window, subsequent wakes are skipped
|
|
9
|
+
// and rely on the still-pending response.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately in-memory and per-process (state lives on the calling oxtail
|
|
12
|
+
// server): the common burst — one caller hammering one peer — is same-process,
|
|
13
|
+
// and cross-process coordination is out of scope for this slice. All wake paths
|
|
14
|
+
// (ask_peer, send_message wake:"auto", the reply-default wake) funnel through
|
|
15
|
+
// wakePeer, so one check there covers them all.
|
|
16
|
+
function envPosInt(name, def, env = process.env) {
|
|
17
|
+
const v = env[name];
|
|
18
|
+
if (!v)
|
|
19
|
+
return def;
|
|
20
|
+
const n = Number(v);
|
|
21
|
+
return Number.isFinite(n) && n > 0 ? n : def;
|
|
22
|
+
}
|
|
23
|
+
// Default 1s — long enough to swallow a rapid retry / concurrent double-wake,
|
|
24
|
+
// short enough that a genuinely separate follow-up wake a moment later still
|
|
25
|
+
// lands. Tunable via OXTAIL_WAKE_DEBOUNCE_MS.
|
|
26
|
+
export const WAKE_DEBOUNCE_MS = envPosInt("OXTAIL_WAKE_DEBOUNCE_MS", 1000);
|
|
27
|
+
export function newWakeDebounceStore() {
|
|
28
|
+
return new Map();
|
|
29
|
+
}
|
|
30
|
+
// True if a wake fired for this key within the window — i.e. skip this one.
|
|
31
|
+
export function recentlyWoke(store, key, nowMs, windowMs = WAKE_DEBOUNCE_MS) {
|
|
32
|
+
const last = store.get(key);
|
|
33
|
+
return last !== undefined && nowMs - last < windowMs;
|
|
34
|
+
}
|
|
35
|
+
// Record that a wake fired for this key. Opportunistically evicts stale entries
|
|
36
|
+
// so the map can't grow unbounded across many short-lived peers.
|
|
37
|
+
export function markWoke(store, key, nowMs, windowMs = WAKE_DEBOUNCE_MS) {
|
|
38
|
+
store.set(key, nowMs);
|
|
39
|
+
if (store.size > 256) {
|
|
40
|
+
for (const [k, t] of store) {
|
|
41
|
+
if (nowMs - t > windowMs * 10)
|
|
42
|
+
store.delete(k);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Issue #7 — drift detector for Codex's paste-burst window.
|
|
3
|
+
//
|
|
4
|
+
// oxtail's Codex wake inserts a 500ms gap (ASK_PEER_CODEX_SUBMIT_DELAY_MS)
|
|
5
|
+
// between the typed wake text and Enter, to outlast Codex's paste-burst
|
|
6
|
+
// PASTE_ENTER_SUPPRESS_WINDOW — a private constant tested at 120ms. If Codex
|
|
7
|
+
// bumps that window past our gap in a future release, our wake silently
|
|
8
|
+
// regresses to "Enter gets swallowed" with no signal pointing at the cause.
|
|
9
|
+
//
|
|
10
|
+
// This script fetches the upstream constant and exits non-zero if it changed
|
|
11
|
+
// (or moved/renamed). Run on a schedule (see .github/workflows/codex-drift.yml)
|
|
12
|
+
// so drift surfaces as a failing job rather than a silent field regression.
|
|
13
|
+
|
|
14
|
+
const URL =
|
|
15
|
+
"https://raw.githubusercontent.com/openai/codex/main/codex-rs/tui/src/bottom_pane/paste_burst.rs";
|
|
16
|
+
const EXPECTED_MS = 120; // value oxtail's 500ms gap was verified against
|
|
17
|
+
const OUR_GAP_MS = 500; // ASK_PEER_CODEX_SUBMIT_DELAY_MS in src/server.ts
|
|
18
|
+
|
|
19
|
+
async function fetchSource(attempts = 3) {
|
|
20
|
+
let lastErr;
|
|
21
|
+
for (let i = 0; i < attempts; i++) {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(URL);
|
|
24
|
+
if (res.ok) return await res.text();
|
|
25
|
+
lastErr = new Error(`HTTP ${res.status}`);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
lastErr = e;
|
|
28
|
+
}
|
|
29
|
+
await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
|
|
30
|
+
}
|
|
31
|
+
throw lastErr;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let src;
|
|
35
|
+
try {
|
|
36
|
+
src = await fetchSource();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error(`drift-check: could not fetch paste_burst.rs (${e?.message ?? e}). Transient — re-run.`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const m = src.match(/PASTE_ENTER_SUPPRESS_WINDOW[\s\S]{0,120}?from_millis\((\d+)\)/);
|
|
43
|
+
if (!m) {
|
|
44
|
+
console.error(
|
|
45
|
+
"drift-check: PASTE_ENTER_SUPPRESS_WINDOW / from_millis(...) not found upstream — Codex may have renamed or restructured the paste-burst logic. Re-verify oxtail's Codex wake gap (ASK_PEER_CODEX_SUBMIT_DELAY_MS) by hand.",
|
|
46
|
+
);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ms = Number(m[1]);
|
|
51
|
+
if (ms !== EXPECTED_MS) {
|
|
52
|
+
const stillSafe = ms < OUR_GAP_MS;
|
|
53
|
+
console.error(
|
|
54
|
+
`drift-check: PASTE_ENTER_SUPPRESS_WINDOW changed ${EXPECTED_MS}ms -> ${ms}ms. ` +
|
|
55
|
+
`oxtail's gap is ${OUR_GAP_MS}ms — ` +
|
|
56
|
+
(stillSafe
|
|
57
|
+
? "still larger, so wake should still submit, but update EXPECTED_MS here once re-verified."
|
|
58
|
+
: "NO LONGER LARGER: Codex wake will regress (Enter swallowed). Bump ASK_PEER_CODEX_SUBMIT_DELAY_MS in src/server.ts."),
|
|
59
|
+
);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`drift-check: PASTE_ENTER_SUPPRESS_WINDOW still ${ms}ms; oxtail gap ${OUR_GAP_MS}ms — OK.`);
|