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/README.md +43 -17
- package/assets/pretooluse.sh +68 -50
- package/assets/stop.sh +171 -0
- package/assets/userpromptsubmit.sh +55 -0
- package/dist/claims.js +228 -0
- package/dist/clients.js +4 -4
- package/dist/mailbox.js +1 -4
- package/dist/server.js +233 -53
- package/package.json +1 -1
- package/scripts/hook-constants.mjs +44 -6
- package/scripts/install-hook.mjs +69 -57
- package/scripts/uninstall-hook.mjs +40 -32
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"
|
|
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
|
-
|
|
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 });
|