oxtail 0.15.0 → 0.16.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/dist/claims.js CHANGED
@@ -155,9 +155,6 @@ function compareClaimScores(a, b) {
155
155
  }
156
156
  return a.claimed_at - b.claimed_at;
157
157
  }
158
- function scoresTie(a, b) {
159
- return compareClaimScores(a, b) === 0;
160
- }
161
158
  function claimKey(clientType, cwd, sessionId) {
162
159
  return createHash("sha256")
163
160
  .update(`${clientType} ${cwd} ${sessionId}`)
@@ -172,7 +169,14 @@ function claimPath(key) {
172
169
  // Atomic temp+rename so a concurrent reader never sees a torn write.
173
170
  export function writeClaim(input) {
174
171
  ensureClaimsDir();
175
- gcStaleClaims();
172
+ // Age-only sweep on this hot path. writeClaim can run concurrently with another
173
+ // agent's writeClaim (dual-scope, or two sessions in one project); the
174
+ // transcript-existence check is racy — a transcript that momentarily fails to
175
+ // stat would unlink a sibling's just-written claim (M6). Age is monotonic and
176
+ // race-free, so reclaim only by age here. recoverClaim already skips records
177
+ // whose transcript is gone, and the full (transcript-aware) sweep remains
178
+ // available via a direct gcStaleClaims() call.
179
+ gcStaleClaims(Date.now(), { ageOnly: true });
176
180
  const rec = {
177
181
  schema_version: 1,
178
182
  client_type: input.client_type,
@@ -245,14 +249,30 @@ export function recoverClaim(clientType, cwd, ancestors, deps = {}) {
245
249
  matches.sort((a, b) => compareClaimScores(b.score, a.score));
246
250
  const best = matches[0];
247
251
  const second = matches[1];
248
- if (second && scoresTie(best.score, second.score))
252
+ // Abstain on cross-session ambiguity. Two DISTINCT sessions that overlap the
253
+ // live chain equally (same overlap_count) AND at the same live-chain depth
254
+ // (same nearest_overlap_current) share liveness only at a common ancestor —
255
+ // the shared terminal/login-shell. The remaining tiebreakers (record-side
256
+ // depth, recency) do NOT correlate with which child actually restarted, so
257
+ // adopting either would risk cross-session misrouting (H1) — the very
258
+ // split-identity class this store exists to prevent. Return null so the caller
259
+ // falls back to the explicit claim_session next_step. (This strictly subsumes
260
+ // the old exact-tie check, which had equal overlap_count and nearest_current
261
+ // by definition.) A same-session second-best routes to the same identity and
262
+ // so can never split-route — defensive only, since the per-session claim key
263
+ // means two records can't share a session_id.
264
+ if (second &&
265
+ best.rec.session_id !== second.rec.session_id &&
266
+ best.score.overlap_count === second.score.overlap_count &&
267
+ best.score.nearest_overlap_current === second.score.nearest_overlap_current) {
249
268
  return null;
269
+ }
250
270
  return best.rec;
251
271
  }
252
272
  // Drop records that are clearly dead: transcript gone, or older than the max
253
273
  // age. Best-effort; never throws. A dead process pid alone is NOT grounds for
254
274
  // removal — that's exactly the restart case recovery exists to serve.
255
- export function gcStaleClaims(nowMs = Date.now()) {
275
+ export function gcStaleClaims(nowMs = Date.now(), opts = {}) {
256
276
  const dir = claimsDir();
257
277
  if (!existsSync(dir))
258
278
  return;
@@ -274,7 +294,7 @@ export function gcStaleClaims(nowMs = Date.now()) {
274
294
  catch {
275
295
  continue;
276
296
  }
277
- const transcriptGone = !rec.transcript_path || !existsSync(rec.transcript_path);
297
+ const transcriptGone = !opts.ageOnly && (!rec.transcript_path || !existsSync(rec.transcript_path));
278
298
  const tooOld = nowMs - rec.claimed_at * 1000 > CLAIM_MAX_AGE_MS;
279
299
  if (transcriptGone || tooOld) {
280
300
  try {
@@ -2,6 +2,12 @@ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  const FIVE_MIN_MS = 5 * 60 * 1000;
5
+ // started_at is whole-second granularity (Math.floor(Date.now()/1000)*1000)
6
+ // while a transcript's birth_ms is real-millisecond, so a transcript
7
+ // legitimately created in the same second can land slightly BEFORE started_at
8
+ // (delta in [-1000, 0]). Allow one second of grace below zero so the unique
9
+ // candidate isn't dropped on pure rounding (M7).
10
+ const ONE_SECOND_MS = 1000;
5
11
  const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/;
6
12
  // Returns the unique post-start candidate inside the window, or null if there
7
13
  // are zero or multiple. Multiple positive-delta candidates means another
@@ -10,7 +16,7 @@ const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/
10
16
  export function pickByDelta(candidates, startedAtMs, windowMs = FIVE_MIN_MS) {
11
17
  const ranked = candidates
12
18
  .map((c) => ({ ...c, delta: c.birth_ms - startedAtMs }))
13
- .filter((c) => c.delta > 0 && c.delta <= windowMs);
19
+ .filter((c) => c.delta > -ONE_SECOND_MS && c.delta <= windowMs);
14
20
  if (ranked.length !== 1)
15
21
  return null;
16
22
  return { session_id: ranked[0].session_id, birth_ms: ranked[0].birth_ms };
@@ -141,7 +147,7 @@ function abstainReason(type, candidates, startedAtMs) {
141
147
  }
142
148
  const ranked = candidates
143
149
  .map((c) => ({ ...c, delta: c.birth_ms - startedAtMs }))
144
- .filter((c) => c.delta > 0 && c.delta <= FIVE_MIN_MS);
150
+ .filter((c) => c.delta > -ONE_SECOND_MS && c.delta <= FIVE_MIN_MS);
145
151
  if (ranked.length === 0) {
146
152
  return {
147
153
  abstain: true,
package/dist/locks.js CHANGED
@@ -41,8 +41,13 @@ import { trace } from "./trace.js";
41
41
  // ALWAYS the single-winner `mkdir(lock)`, so even redundant clears can never
42
42
  // produce two owners — the worst they do is race to recreate the lock, which
43
43
  // exactly one wins.
44
- const LOCK_RETRY_LIMIT = 50;
45
44
  const LOCK_RETRY_DELAY_MS = 10;
45
+ // Total acquire budget is wall-clock, NOT a fixed retry count: a successful
46
+ // stale-clear retries mkdir immediately (no sleep) so it must not consume the
47
+ // budget without time passing — a count-based budget threw "could not acquire
48
+ // lock" spuriously under contention (H2). 2s is ample for the tiny mailbox/
49
+ // ledger critical sections and well under any caller-level timeout.
50
+ const LOCK_ACQUIRE_TIMEOUT_MS = 2_000;
46
51
  function sleepSync(ms) {
47
52
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
48
53
  }
@@ -166,7 +171,8 @@ export function clearStaleLock(lock, staleMs, traceEvent, traceCtx) {
166
171
  // releaseDirLock. The caller is responsible for creating the parent directory.
167
172
  export function acquireDirLock(lock, staleMs, traceEvent, traceCtx) {
168
173
  const token = mintToken();
169
- for (let i = 0; i < LOCK_RETRY_LIMIT; i++) {
174
+ const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
175
+ for (;;) {
170
176
  try {
171
177
  mkdirSync(lock, { mode: 0o700 });
172
178
  writeOwner(lock, token);
@@ -176,12 +182,19 @@ export function acquireDirLock(lock, staleMs, traceEvent, traceCtx) {
176
182
  const err = e;
177
183
  if (err.code !== "EEXIST")
178
184
  throw err;
179
- if (clearStaleLock(lock, staleMs, traceEvent, traceCtx))
180
- continue;
181
- sleepSync(LOCK_RETRY_DELAY_MS);
185
+ // A successful stale-clear means the lock is gone: loop straight back to
186
+ // mkdir WITHOUT sleeping, to grab it before another contender (this retry
187
+ // must not consume the budget without time passing). Otherwise — a fresh
188
+ // holder or a lost steal — back off before retrying.
189
+ if (!clearStaleLock(lock, staleMs, traceEvent, traceCtx)) {
190
+ sleepSync(LOCK_RETRY_DELAY_MS);
191
+ }
192
+ }
193
+ // Wall-clock budget so the no-sleep stale-clear path cannot spin forever.
194
+ if (Date.now() >= deadline) {
195
+ throw new Error(`could not acquire lock at ${lock}`);
182
196
  }
183
197
  }
184
- throw new Error(`could not acquire lock at ${lock}`);
185
198
  }
186
199
  // Release the lock — but only if we PROVABLY still own it (owner === our token).
187
200
  // A holder that stalled past the stale window and was stolen from sees a
@@ -192,7 +205,13 @@ export function acquireDirLock(lock, staleMs, traceEvent, traceCtx) {
192
205
  // stale lock and is reclaimed by clearStaleLock, strictly safer than a stomp.
193
206
  export function releaseDirLock(lock, token) {
194
207
  if (!token) {
195
- removeLock(lock); // no token to verify (defensive/legacy) best-effort
208
+ // No token to prove ownership. An empty token reaches here only from a
209
+ // lockTokens Map miss (an acquire that threw, or a future same-key nested
210
+ // release), so removing would stomp whatever lock currently exists —
211
+ // possibly a DIFFERENT owner's fresh one. Leave it: a genuinely leaked lock
212
+ // ages into a stale lock and is reclaimed by clearStaleLock, strictly safer
213
+ // than a stomp (H3).
214
+ trace("lock_release_skipped_no_token", { lock });
196
215
  return;
197
216
  }
198
217
  const owner = readOwner(lock);
package/dist/mailbox.js CHANGED
@@ -188,6 +188,13 @@ export function requeueMany(target_pid, msgs) {
188
188
  // two unioned sibling mailboxes. Both copies are drained (so neither lingers) but
189
189
  // the message is returned ONCE. message_id is a unique per-message nonce, so this
190
190
  // only ever collapses true duplicates, never two distinct messages.
191
+ // Union-drain a session's mailboxes (one per server_pid it has used), deduping
192
+ // by message_id so a migrate crash-window duplicate (same id in two sibling
193
+ // mailboxes) is delivered once. INVARIANT: every unioned pid is drained (and so
194
+ // truncated) before returning — do NOT add a budget/early-exit short-circuit
195
+ // here. The dedup is per-call only, so a duplicate left in an un-drained sibling
196
+ // would re-surface on a later call with no cross-call dedup (M5). Budgeting
197
+ // belongs in the caller, applied to the already-fully-drained result.
191
198
  export function drainMany(pids) {
192
199
  const out = [];
193
200
  const seenPids = new Set();
@@ -265,8 +272,42 @@ export function migrateMailbox(fromPid, toPid) {
265
272
  }
266
273
  if (!raw || !raw.trim())
267
274
  return 0;
268
- const block = raw.endsWith("\n") ? raw : raw + "\n";
269
- const count = raw.split("\n").filter((l) => l.trim().length > 0).length;
275
+ // Migrate only VALID records, reserialized canonically. A crash mid-append
276
+ // into the source can leave a torn final line; copying raw bytes would glue
277
+ // a synthesized newline onto that fragment, promoting garbage into a
278
+ // standalone (unparseable) line in the dest AND over-counting it (H4). Parse
279
+ // each line with the same guard drain uses, drop torn/invalid ones, and
280
+ // rebuild a clean block so the count reflects real, deliverable messages.
281
+ const valid = [];
282
+ for (const line of raw.split("\n")) {
283
+ if (!line.trim())
284
+ continue;
285
+ let parsed;
286
+ try {
287
+ parsed = JSON.parse(line);
288
+ }
289
+ catch {
290
+ trace("mailbox_migrate_skip_invalid", { fromPid, toPid, line });
291
+ continue;
292
+ }
293
+ if (parsed &&
294
+ typeof parsed === "object" &&
295
+ parsed.schema_version === 1 &&
296
+ typeof parsed.id === "string" &&
297
+ typeof parsed.body === "string") {
298
+ valid.push(parsed);
299
+ }
300
+ else {
301
+ trace("mailbox_migrate_skip_invalid", { fromPid, toPid, line });
302
+ }
303
+ }
304
+ if (valid.length === 0) {
305
+ // Only torn/garbage lines — clear the source and report nothing migrated.
306
+ truncateSync(src, 0);
307
+ return 0;
308
+ }
309
+ // serializeMailboxLine already terminates each line with "\n", so join("").
310
+ const block = valid.map((m) => serializeMailboxLine(m)).join("");
270
311
  acquireLock(toPid);
271
312
  try {
272
313
  appendLines(mailboxPath(toPid), block);
@@ -276,7 +317,7 @@ export function migrateMailbox(fromPid, toPid) {
276
317
  }
277
318
  // Append succeeded → clear the source (still under the source lock).
278
319
  truncateSync(src, 0);
279
- return count;
320
+ return valid.length;
280
321
  }
281
322
  finally {
282
323
  releaseLock(fromPid);
package/dist/received.js CHANGED
@@ -88,24 +88,43 @@ function readLines(sessionId) {
88
88
  throw err;
89
89
  }
90
90
  }
91
+ // The message_id of a serialized ledger line, or null if unparseable. Used to
92
+ // keep recordReceived idempotent without fully deserializing every envelope.
93
+ function lineMessageId(line) {
94
+ try {
95
+ const parsed = JSON.parse(line);
96
+ return typeof parsed.id === "string" ? parsed.id : null;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
91
102
  // Append an inbound envelope to the receiver's ledger and prune to receivedMax()
92
103
  // (oldest dropped first). Called by delivery.ts BEFORE the mailbox append.
104
+ // Idempotent by message_id: re-recording an id replaces its prior line.
93
105
  export function recordReceived(receiverSessionId, msg) {
94
106
  if (!receiverSessionId)
95
107
  return;
96
108
  acquireLock(receiverSessionId);
97
109
  try {
98
110
  const lines = readLines(receiverSessionId);
99
- lines.push(JSON.stringify(msg));
111
+ // Idempotent by message_id: a re-record (ask_peer abort recovery, chained
112
+ // re-delivery) must not append a duplicate ledger line. Duplicates waste the
113
+ // receivedMax prune budget and can evict still-needed handles early,
114
+ // surfacing as spurious reply_to_message "message-not-found" (M4). Drop any
115
+ // prior line for this id, then append the latest. lookupReceived already
116
+ // returns first-match newest-first, so behavior is unchanged for callers.
117
+ const deduped = msg.id ? lines.filter((l) => lineMessageId(l) !== msg.id) : lines;
118
+ deduped.push(JSON.stringify(msg));
100
119
  const max = receivedMax();
101
- let pruned = lines;
102
- if (lines.length > max) {
103
- pruned = lines.slice(lines.length - max);
120
+ let pruned = deduped;
121
+ if (deduped.length > max) {
122
+ pruned = deduped.slice(deduped.length - max);
104
123
  // No silent caps: a dropped handle becomes reply_to_message
105
124
  // "message-not-found", so surface that the bound bit.
106
125
  trace("received_ledger_pruned", {
107
126
  session_id: receiverSessionId,
108
- dropped: lines.length - max,
127
+ dropped: deduped.length - max,
109
128
  kept: max,
110
129
  });
111
130
  }
@@ -136,6 +155,8 @@ export function lookupReceived(receiverSessionId, messageId) {
136
155
  }
137
156
  if (parsed &&
138
157
  typeof parsed === "object" &&
158
+ parsed.schema_version === 1 &&
159
+ typeof parsed.body === "string" &&
139
160
  parsed.id === messageId) {
140
161
  return parsed;
141
162
  }
package/dist/registry.js CHANGED
@@ -68,9 +68,21 @@ export function isValidTmuxSession(s) {
68
68
  // target, so we refuse rather than fall back to the self-written cached value.
69
69
  // - the resolved pane isn't a well-formed pane id (tmux output anomaly).
70
70
  // resolvePane is injected in tests; production uses currentPaneForServerPid.
71
- export function chooseVerifiedWakePane(peer, resolvePane = currentPaneForServerPid) {
71
+ export function chooseVerifiedWakePane(peer, resolvePane = currentPaneForServerPid, resolveSig = processStartSig) {
72
72
  if (!peer.tmux_pane)
73
73
  return null;
74
+ // PID-reuse guard: if the entry recorded the server process's start-time
75
+ // signature, confirm the live pid is STILL that process before resolving and
76
+ // waking its pane. Otherwise an OS-recycled pid — now an unrelated process
77
+ // that happens to sit under a different tmux pane — would resolve to, and get
78
+ // our wake keystrokes typed into, a stranger's pane (M3). Only refuse on a
79
+ // positively-different signature; an empty reading (transient ps failure)
80
+ // falls through to pane resolution, which fails closed for a truly dead pid.
81
+ if (peer.proc_sig) {
82
+ const liveSig = resolveSig(peer.server_pid);
83
+ if (liveSig && liveSig !== peer.proc_sig)
84
+ return null;
85
+ }
74
86
  const live = resolvePane(peer.server_pid);
75
87
  if (!live || !isValidTmuxPane(live))
76
88
  return null;
@@ -203,11 +215,36 @@ export function resolveTmuxPane(env = process.env, pid = process.pid) {
203
215
  export function currentPaneForServerPid(serverPid) {
204
216
  return findTmuxPaneByAncestry(serverPid, listTmuxPanePids(), listAllPpids());
205
217
  }
218
+ // The OS start-time signature (lstart) of a process, or "" if it can't be read
219
+ // (dead pid, or ps unavailable). Same provenance signal claims.ts uses on
220
+ // ancestor pids: an OS-recycled pid yields a DIFFERENT start time, so comparing
221
+ // a live pid's signature against one captured at register time detects pid reuse
222
+ // — distinguishing "our process is still alive" from "the pid now belongs to an
223
+ // unrelated process."
224
+ export function processStartSig(pid) {
225
+ try {
226
+ return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
227
+ encoding: "utf8",
228
+ stdio: ["ignore", "pipe", "pipe"],
229
+ }).trim();
230
+ }
231
+ catch {
232
+ return "";
233
+ }
234
+ }
235
+ // A process's start time never changes, so capture our own once and reuse it.
236
+ let cachedSelfProcSig;
237
+ function selfProcSig() {
238
+ if (cachedSelfProcSig === undefined)
239
+ cachedSelfProcSig = processStartSig(process.pid);
240
+ return cachedSelfProcSig;
241
+ }
206
242
  export function buildEntry(client, env = process.env) {
207
243
  const tmux_pane = resolveTmuxPane(env);
208
244
  return {
209
245
  server_pid: process.pid,
210
246
  started_at: Math.floor(Date.now() / 1000),
247
+ proc_sig: selfProcSig(),
211
248
  client,
212
249
  tmux_pane,
213
250
  tmux_session: resolveTmuxSessionFromPane(tmux_pane),
package/dist/server.js CHANGED
@@ -10,7 +10,7 @@ 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, chooseVerifiedWakePane, findByTmuxSession, readAll, refreshTmuxBinding, register, sessionPidsForId, unregister, } from "./registry.js";
13
+ import { buildEntry, chooseVerifiedWakePane, findByTmuxSession, processStartSig, readAll, refreshTmuxBinding, register, sessionPidsForId, unregister, } from "./registry.js";
14
14
  import * as mailbox from "./mailbox.js";
15
15
  import * as received from "./received.js";
16
16
  import { deliverExistingToPeer, deliverToPeer } from "./delivery.js";
@@ -638,6 +638,11 @@ function refineFromHandshake(trigger) {
638
638
  return diagnosis;
639
639
  }
640
640
  server.server.oninitialized = () => {
641
+ // Sweep pending-ask records orphaned by a prior session (an ask that timed out,
642
+ // was never answered, and whose owner went away). gcPendingAsk otherwise only
643
+ // runs on a later ask_peer timeout, so this startup sweep keeps the dir from
644
+ // accumulating stale records. Best-effort; never throws.
645
+ gcPendingAsk(defaultPendingAskDir(), Date.now());
641
646
  const diagnosis = refineFromHandshake("oninitialized");
642
647
  // After type is known via handshake, schedule retries to catch transcript files
643
648
  // that don't exist yet at handshake time. No-op if session_id is already set.
@@ -855,12 +860,16 @@ server.registerTool("get_my_session", {
855
860
  // strategy mirrors session_id_source so callers can still see whether
856
861
  // env / birth-time / self-register resolved this entry.
857
862
  const source = entry.client.session_id_source ?? "self-register";
863
+ // Report confidence honestly per source: env and explicit self-register
864
+ // (claim_session) are authoritative ("high"); inferred sources (birth-time,
865
+ // sticky-claim) are "medium" — matching what the detect strategies return.
866
+ const confidence = source === "env" || source === "self-register" ? "high" : "medium";
858
867
  diagnosis = {
859
868
  per_strategy: {},
860
869
  winning: {
861
870
  session_id: entry.client.session_id,
862
871
  source,
863
- confidence: "high",
872
+ confidence,
864
873
  strategy: source,
865
874
  },
866
875
  next_step: null,
@@ -973,7 +982,20 @@ function resolveTarget(target, caller) {
973
982
  const fresh = reReadRegistryEntry(e.server_pid);
974
983
  if (!fresh)
975
984
  return false;
976
- return fresh.started_at === e.started_at;
985
+ if (fresh.started_at !== e.started_at)
986
+ return false;
987
+ // PID-reuse: started_at is the original registration time and lives on the
988
+ // stale on-disk entry, so a recycled pid (alive, file untouched) passes the
989
+ // check above. If the entry recorded the process start-time signature,
990
+ // confirm the live pid is still that same process; a recycled pid reads a
991
+ // different signature and is rejected (M3). Empty reading → indeterminate,
992
+ // leave it to downstream (the pane wake gate re-verifies before keystrokes).
993
+ if (fresh.proc_sig) {
994
+ const liveSig = processStartSig(e.server_pid);
995
+ if (liveSig && liveSig !== fresh.proc_sig)
996
+ return false;
997
+ }
998
+ return true;
977
999
  });
978
1000
  if (candidates.length === 0)
979
1001
  return { ok: false, error: "target-not-found" };
@@ -1474,6 +1496,14 @@ async function wakePeer(peer) {
1474
1496
  // No session-name fallback: a self-written tmux_session could target another
1475
1497
  // session, and the verified pane already handles pane-id churn. Pass null.
1476
1498
  const ok = await askPeerWakeImpl(verifiedPane, null, fire);
1499
+ if (!ok && sid) {
1500
+ // The fire failed (e.g. the pane vanished between verification and the
1501
+ // send-keys), so no keystroke landed. Clear the debounce stamp set pre-fire
1502
+ // above — otherwise a genuine retry within WAKE_DEBOUNCE_MS is suppressed as
1503
+ // "debounced" even though the peer was never actually woken (M1). The
1504
+ // pre-stamp only needs to survive a SUCCESSFUL fire's async paste gap.
1505
+ wakeDebounce.delete(sid);
1506
+ }
1477
1507
  return ok ? "fired" : "skipped_no_target";
1478
1508
  }
1479
1509
  // --- send_message wake:auto gating -------------------------------------------
@@ -30,7 +30,14 @@ export function newWakeDebounceStore() {
30
30
  // True if a wake fired for this key within the window — i.e. skip this one.
31
31
  export function recentlyWoke(store, key, nowMs, windowMs = WAKE_DEBOUNCE_MS) {
32
32
  const last = store.get(key);
33
- return last !== undefined && nowMs - last < windowMs;
33
+ if (last === undefined)
34
+ return false;
35
+ const delta = nowMs - last;
36
+ // A backwards clock step (NTP correction, laptop resume) makes delta negative
37
+ // and < windowMs, which would wrongly suppress every wake to this peer until
38
+ // the clock catches back up. Treat a negative delta as "not recent" (mirrors
39
+ // the ageMs >= 0 guard in isFreshIdle).
40
+ return delta >= 0 && delta < windowMs;
34
41
  }
35
42
  // Record that a wake fired for this key. Opportunistically evicts stale entries
36
43
  // so the map can't grow unbounded across many short-lived peers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxtail",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Coordination layer for parallel AI coding agent sessions, exposed over MCP.",