oxtail 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/claims.js ADDED
@@ -0,0 +1,228 @@
1
+ // Sticky-claim store. A Codex MCP child restarts with session_id=null (its
2
+ // CODEX_THREAD_ID is stripped from the MCP subprocess env, same structural
3
+ // stripping as Claude Code's CLAUDE_CODE_SESSION_ID), so without help the agent
4
+ // must re-run `echo $CODEX_THREAD_ID` → claim_session after every restart.
5
+ //
6
+ // On claim we persist a small record keyed by client_type + cwd + the MCP
7
+ // server's ANCESTOR CHAIN (nearest-first, bounded, each ancestor tagged with a
8
+ // start-time signature). On a later startup, when env- and birth-time detection
9
+ // both fail, we recover the prior session_id by finding a record whose stored
10
+ // ancestor chain still shares a live process with the current child's chain —
11
+ // i.e. the same agent host is still running above us.
12
+ //
13
+ // Why the chain and not a single parent pid: the MCP server's immediate parent
14
+ // is often a transient launcher (npx/tsx/a shell) that is re-spawned per start,
15
+ // so process.ppid alone is not stable across a restart. The agent HOST, a few
16
+ // levels up, is. Matching on a shared (pid, signature) anywhere in the bounded
17
+ // chain finds that host through whatever launchers sit beneath it. The
18
+ // signature (process start time) means a reused pid can't masquerade as the
19
+ // original ancestor.
20
+ //
21
+ // Why not cwd alone: two agent sessions can share a project root, so a cwd-only
22
+ // key collides. Why not birth-time on restart: the transcript predates the
23
+ // restarted child's started_at, so the positive-delta birth-time rule abstains.
24
+ //
25
+ // Recovery is conservative: it adopts ONLY when exactly one record matches the
26
+ // live ancestry and the recorded transcript still exists. Any ambiguity (zero
27
+ // or multiple matching claims) → null → the caller falls back to the explicit
28
+ // claim_session next_step rather than guessing.
29
+ //
30
+ // A live registry entry that already holds the recovered session_id is NOT a
31
+ // conflict: per the AGENTS.md invariant, session_id IS the agent identity, so a
32
+ // same-session_id sibling is the same agent's other MCP child (the documented
33
+ // dual-scope setup), not an impostor. Recovery proceeds alongside it;
34
+ // readAll()/dedupeBySessionId collapses the duplicates downstream.
35
+ import { execFileSync } from "node:child_process";
36
+ import { createHash } from "node:crypto";
37
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
38
+ import { homedir } from "node:os";
39
+ import { join } from "node:path";
40
+ // How far up the process tree to look for a shared host. Deep enough to clear
41
+ // launcher(s) between the host and the MCP server; if it also catches a shared
42
+ // terminal/login-shell, the "exactly one match" guard still keeps recovery safe
43
+ // (ambiguity → abstain → explicit claim).
44
+ const ANCESTRY_DEPTH = 8;
45
+ // Records older than this with no live evidence are GC'd on the next write.
46
+ const CLAIM_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
47
+ // Lazy so tests can swap HOME between cases; homedir() defers to $HOME on POSIX.
48
+ export function claimsDir() {
49
+ return join(homedir(), ".oxtail", "claims");
50
+ }
51
+ function ensureClaimsDir() {
52
+ const dir = claimsDir();
53
+ if (!existsSync(dir))
54
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
55
+ }
56
+ // One `ps` call → pid -> { ppid, sig }, where sig is the process start time
57
+ // (lstart). lstart carries spaces, so it's everything after the first two
58
+ // columns. Empty map if ps is unavailable (recovery then simply abstains).
59
+ function snapshotProcs() {
60
+ const map = new Map();
61
+ try {
62
+ const out = execFileSync("ps", ["-A", "-o", "pid=,ppid=,lstart="], {
63
+ encoding: "utf8",
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ });
66
+ for (const line of out.split("\n")) {
67
+ const t = line.trim();
68
+ if (!t)
69
+ continue;
70
+ const parts = t.split(/\s+/);
71
+ if (parts.length < 3)
72
+ continue;
73
+ const pid = Number(parts[0]);
74
+ const ppid = Number(parts[1]);
75
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid))
76
+ continue;
77
+ map.set(pid, { ppid, sig: parts.slice(2).join(" ") });
78
+ }
79
+ }
80
+ catch {
81
+ // ps unavailable — leave map empty
82
+ }
83
+ return map;
84
+ }
85
+ // The MCP server's ancestor chain, nearest-first, bounded to ANCESTRY_DEPTH.
86
+ // Stops at pid <= 1 (init/launchd). Each ancestor carries a start-time sig.
87
+ export function resolveAncestors(startPpid = process.ppid, procs = snapshotProcs(), depth = ANCESTRY_DEPTH) {
88
+ const out = [];
89
+ let pid = startPpid;
90
+ for (let i = 0; i < depth && pid > 1; i++) {
91
+ const node = procs.get(pid);
92
+ out.push({ pid, sig: node?.sig ?? "" });
93
+ if (!node)
94
+ break;
95
+ pid = node.ppid;
96
+ }
97
+ return out;
98
+ }
99
+ // True if the chains share a live process — same pid AND start-time signature.
100
+ // An empty sig never matches (degraded ps output must not produce false hits).
101
+ function chainsOverlap(a, b) {
102
+ for (const x of a) {
103
+ if (!x.sig)
104
+ continue;
105
+ for (const y of b) {
106
+ if (x.pid === y.pid && x.sig === y.sig)
107
+ return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+ function claimKey(clientType, cwd, sessionId) {
113
+ return createHash("sha256")
114
+ .update(`${clientType} ${cwd} ${sessionId}`)
115
+ .digest("hex")
116
+ .slice(0, 32);
117
+ }
118
+ function claimPath(key) {
119
+ return join(claimsDir(), `${key}.json`);
120
+ }
121
+ // Persist (or refresh) the sticky claim. Keyed by session, so re-claiming the
122
+ // same session overwrites in place while distinct sessions in one cwd coexist.
123
+ // Atomic temp+rename so a concurrent reader never sees a torn write.
124
+ export function writeClaim(input) {
125
+ ensureClaimsDir();
126
+ gcStaleClaims();
127
+ const rec = {
128
+ schema_version: 1,
129
+ client_type: input.client_type,
130
+ cwd: input.cwd,
131
+ ancestors: input.ancestors,
132
+ session_id: input.session_id,
133
+ transcript_path: input.transcript_path,
134
+ claimed_at: input.claimed_at,
135
+ server_pid: input.server_pid,
136
+ };
137
+ const final = claimPath(claimKey(input.client_type, input.cwd, input.session_id));
138
+ const tmp = `${final}.${process.pid}.tmp`;
139
+ try {
140
+ writeFileSync(tmp, JSON.stringify(rec, null, 2), { mode: 0o600 });
141
+ renameSync(tmp, final);
142
+ }
143
+ catch {
144
+ try {
145
+ unlinkSync(tmp);
146
+ }
147
+ catch {
148
+ // already gone
149
+ }
150
+ }
151
+ }
152
+ // Recover the previously-claimed session for this (client_type, cwd) whose
153
+ // stored ancestry still shares a live process with `ancestors`. Returns the
154
+ // record only when exactly one record is an unambiguously safe match; otherwise
155
+ // null (caller falls back to explicit claim_session).
156
+ export function recoverClaim(clientType, cwd, ancestors, deps = {}) {
157
+ const exists = deps.transcriptExists ?? existsSync;
158
+ const dir = claimsDir();
159
+ if (!existsSync(dir))
160
+ return null;
161
+ let files;
162
+ try {
163
+ files = readdirSync(dir);
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ const matches = [];
169
+ for (const f of files) {
170
+ if (!f.endsWith(".json"))
171
+ continue;
172
+ let rec;
173
+ try {
174
+ rec = JSON.parse(readFileSync(join(dir, f), "utf8"));
175
+ }
176
+ catch {
177
+ continue;
178
+ }
179
+ if (rec.client_type !== clientType || rec.cwd !== cwd)
180
+ continue;
181
+ if (!rec.session_id || !rec.transcript_path)
182
+ continue;
183
+ if (!Array.isArray(rec.ancestors) || !chainsOverlap(rec.ancestors, ancestors))
184
+ continue;
185
+ if (!exists(rec.transcript_path))
186
+ continue;
187
+ matches.push(rec);
188
+ }
189
+ // Exactly one safe match adopts; zero or ambiguous (>1) → abstain.
190
+ return matches.length === 1 ? matches[0] : null;
191
+ }
192
+ // Drop records that are clearly dead: transcript gone, or older than the max
193
+ // age. Best-effort; never throws. A dead process pid alone is NOT grounds for
194
+ // removal — that's exactly the restart case recovery exists to serve.
195
+ export function gcStaleClaims(nowMs = Date.now()) {
196
+ const dir = claimsDir();
197
+ if (!existsSync(dir))
198
+ return;
199
+ let files;
200
+ try {
201
+ files = readdirSync(dir);
202
+ }
203
+ catch {
204
+ return;
205
+ }
206
+ for (const f of files) {
207
+ if (!f.endsWith(".json"))
208
+ continue;
209
+ const full = join(dir, f);
210
+ let rec;
211
+ try {
212
+ rec = JSON.parse(readFileSync(full, "utf8"));
213
+ }
214
+ catch {
215
+ continue;
216
+ }
217
+ const transcriptGone = !rec.transcript_path || !existsSync(rec.transcript_path);
218
+ const tooOld = nowMs - rec.claimed_at * 1000 > CLAIM_MAX_AGE_MS;
219
+ if (transcriptGone || tooOld) {
220
+ try {
221
+ unlinkSync(full);
222
+ }
223
+ catch {
224
+ // already gone
225
+ }
226
+ }
227
+ }
228
+ }
package/dist/clients.js CHANGED
@@ -90,13 +90,13 @@ function recentCodexDateDirs(base, days) {
90
90
  return Array.from(out);
91
91
  }
92
92
  export function detectClient(env = process.env, cwd = process.cwd()) {
93
- if (env.CLAUDECODE === "1" && env.CLAUDE_CODE_SESSION_ID) {
94
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
93
+ if (env.CLAUDECODE === "1") {
94
+ const sessionId = env.CLAUDE_CODE_SESSION_ID ?? null;
95
95
  return {
96
96
  type: "claude-code",
97
97
  session_id: sessionId,
98
- transcript_path: claudeTranscriptPath(sessionId, cwd),
99
- session_id_source: "env",
98
+ transcript_path: sessionId ? claudeTranscriptPath(sessionId, cwd) : null,
99
+ session_id_source: sessionId ? "env" : null,
100
100
  cwd,
101
101
  };
102
102
  }
package/dist/mailbox.js CHANGED
@@ -26,10 +26,7 @@ function lockPath(pid) {
26
26
  return `${mailboxPath(pid)}.lock`;
27
27
  }
28
28
  function sleepSync(ms) {
29
- const end = Date.now() + ms;
30
- while (Date.now() < end) {
31
- // tight spin — short enough (10ms) that this is acceptable
32
- }
29
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
33
30
  }
34
31
  export function acquireLock(pid) {
35
32
  mkdirSync(mailboxesDir(), { recursive: true, mode: 0o700 });