oxtail 0.8.0 → 0.10.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/README.md +51 -23
- 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 +468 -253
- package/dist/transcripts.js +263 -50
- 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/transcripts.js
CHANGED
|
@@ -1,7 +1,206 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
|
+
// Defaults are deliberately conservative: a casual read returns at most ~20
|
|
3
|
+
// recent messages and ~24KB of text (~6k tokens). To pull a full transcript,
|
|
4
|
+
// callers explicitly raise `limit` (up to MAX_LIMIT) and `maxBytes` (up to
|
|
5
|
+
// MAX_MAX_BYTES) — an explicit override rather than an easy `full` footgun.
|
|
6
|
+
export const DEFAULT_LIMIT = 20;
|
|
7
|
+
export const MAX_LIMIT = 1000;
|
|
8
|
+
export const DEFAULT_MAX_BYTES = 24_000;
|
|
9
|
+
export const MIN_MAX_BYTES = 256;
|
|
10
|
+
export const MAX_MAX_BYTES = 1_000_000;
|
|
11
|
+
export const DEFAULT_CHUNK_SIZE = 65_536;
|
|
12
|
+
export const MIN_CHUNK_SIZE = 16;
|
|
2
13
|
function clamp(n, lo, hi) {
|
|
3
14
|
return Math.max(lo, Math.min(hi, n));
|
|
4
15
|
}
|
|
16
|
+
// Non-finite inputs (NaN/±Infinity) would slip past clamp() and produce nonsense
|
|
17
|
+
// (e.g. NaN budget → slice(NaN) returns everything, or zero with a bogus
|
|
18
|
+
// truncation flag). Coerce anything non-finite to the supplied default so the
|
|
19
|
+
// exported reader API is robust even when called directly (not just via zod).
|
|
20
|
+
// Per Codex Phase-B hardening note.
|
|
21
|
+
function finiteOr(n, fallback) {
|
|
22
|
+
return typeof n === "number" && Number.isFinite(n) ? n : fallback;
|
|
23
|
+
}
|
|
24
|
+
// Truncate `s` to at most `maxBytes` UTF-8 bytes WITHOUT splitting a multi-byte
|
|
25
|
+
// code point. Iterating the string yields whole code points, so we never emit a
|
|
26
|
+
// partial/garbled character at the boundary.
|
|
27
|
+
function truncateToBytes(s, maxBytes) {
|
|
28
|
+
if (Buffer.byteLength(s, "utf8") <= maxBytes)
|
|
29
|
+
return s;
|
|
30
|
+
let out = "";
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
for (const ch of s) {
|
|
33
|
+
const cb = Buffer.byteLength(ch, "utf8");
|
|
34
|
+
if (bytes + cb > maxBytes)
|
|
35
|
+
break;
|
|
36
|
+
out += ch;
|
|
37
|
+
bytes += cb;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
// Apply the byte budget to an already count-tailed, chronological message list.
|
|
42
|
+
// Walk newest→oldest so the MOST RECENT content is what survives the budget
|
|
43
|
+
// (tail-preserving). The oldest message that crosses the budget is head-
|
|
44
|
+
// truncated with a marker; everything older than it is dropped. Returns the
|
|
45
|
+
// kept messages back in chronological order.
|
|
46
|
+
function applyByteBudget(messages, maxBytes) {
|
|
47
|
+
let remaining = maxBytes;
|
|
48
|
+
let bytesTruncated = false;
|
|
49
|
+
const keptReversed = [];
|
|
50
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
51
|
+
const m = messages[i];
|
|
52
|
+
const tb = Buffer.byteLength(m.text, "utf8");
|
|
53
|
+
if (tb <= remaining) {
|
|
54
|
+
keptReversed.push(m);
|
|
55
|
+
remaining -= tb;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// This message overflows the remaining budget.
|
|
59
|
+
if (remaining > 0) {
|
|
60
|
+
const head = truncateToBytes(m.text, remaining);
|
|
61
|
+
const droppedBytes = tb - Buffer.byteLength(head, "utf8");
|
|
62
|
+
keptReversed.push({ ...m, text: `${head}…[+${droppedBytes}B truncated]` });
|
|
63
|
+
}
|
|
64
|
+
bytesTruncated = true;
|
|
65
|
+
break; // older messages fall outside the budget
|
|
66
|
+
}
|
|
67
|
+
return { kept: keptReversed.reverse(), bytesTruncated };
|
|
68
|
+
}
|
|
69
|
+
// Shared finalize step for both readers: count-tail to `limit`, then apply the
|
|
70
|
+
// byte budget, then gate timestamps. Keeps the two truncation signals distinct.
|
|
71
|
+
function finalize(all, opts) {
|
|
72
|
+
const limit = clamp(Math.floor(finiteOr(opts.limit, DEFAULT_LIMIT)), 1, MAX_LIMIT);
|
|
73
|
+
const maxBytes = clamp(Math.floor(finiteOr(opts.maxBytes, DEFAULT_MAX_BYTES)), MIN_MAX_BYTES, MAX_MAX_BYTES);
|
|
74
|
+
const includeTimestamps = opts.includeTimestamps ?? false;
|
|
75
|
+
const total = all.length;
|
|
76
|
+
const countTruncated = total > limit;
|
|
77
|
+
const tail = countTruncated ? all.slice(-limit) : all.slice();
|
|
78
|
+
const { kept, bytesTruncated } = applyByteBudget(tail, maxBytes);
|
|
79
|
+
const messages = kept.map((m) => ({
|
|
80
|
+
role: m.role,
|
|
81
|
+
text: m.text,
|
|
82
|
+
timestamp: includeTimestamps ? m.timestamp : null,
|
|
83
|
+
}));
|
|
84
|
+
return {
|
|
85
|
+
messages,
|
|
86
|
+
truncated: countTruncated || bytesTruncated,
|
|
87
|
+
count_truncated: countTruncated,
|
|
88
|
+
bytes_truncated: bytesTruncated,
|
|
89
|
+
total_messages: total,
|
|
90
|
+
total_messages_exact: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const EMPTY_RESULT = {
|
|
94
|
+
messages: [],
|
|
95
|
+
truncated: false,
|
|
96
|
+
count_truncated: false,
|
|
97
|
+
bytes_truncated: false,
|
|
98
|
+
total_messages: 0,
|
|
99
|
+
total_messages_exact: true,
|
|
100
|
+
};
|
|
101
|
+
// Split a buffer on the newline byte (0x0A). Safe for UTF-8 because 0x0A never
|
|
102
|
+
// appears inside a multi-byte sequence (continuation/lead bytes are all ≥ 0x80).
|
|
103
|
+
// The trailing segment (after the last newline) is always included, possibly
|
|
104
|
+
// empty. Returned as views; callers copy the one they retain across reads.
|
|
105
|
+
function splitBufferByNewline(buf) {
|
|
106
|
+
const out = [];
|
|
107
|
+
let start = 0;
|
|
108
|
+
for (let i = 0; i < buf.length; i++) {
|
|
109
|
+
if (buf[i] === 0x0a) {
|
|
110
|
+
out.push(buf.subarray(start, i));
|
|
111
|
+
start = i + 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
out.push(buf.subarray(start));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
// Reverse-tail reader: walk the file backward in chunks, decoding only complete
|
|
118
|
+
// lines, until we've collected `limit` messages or reached the start of file.
|
|
119
|
+
// `parseLine` is the same per-line→message logic the full-scan path uses, so the
|
|
120
|
+
// returned messages are byte-identical to a full scan; only the SCAN STRATEGY
|
|
121
|
+
// differs. UTF-8 safety: incomplete leftmost lines are carried as raw BYTES and
|
|
122
|
+
// only decoded once a newline to their left completes them (or BOF is reached),
|
|
123
|
+
// so a multi-byte char split across a chunk boundary is always reassembled
|
|
124
|
+
// before decoding.
|
|
125
|
+
function readTailScan(path, parseLine, opts) {
|
|
126
|
+
const limit = clamp(Math.floor(finiteOr(opts.limit, DEFAULT_LIMIT)), 1, MAX_LIMIT);
|
|
127
|
+
const maxBytes = clamp(Math.floor(finiteOr(opts.maxBytes, DEFAULT_MAX_BYTES)), MIN_MAX_BYTES, MAX_MAX_BYTES);
|
|
128
|
+
const includeTimestamps = opts.includeTimestamps ?? false;
|
|
129
|
+
const chunkSize = Math.max(MIN_CHUNK_SIZE, Math.floor(finiteOr(opts.chunkSize, DEFAULT_CHUNK_SIZE)));
|
|
130
|
+
const newestFirst = [];
|
|
131
|
+
// `hitLimit` — we stopped because we collected `limit` messages, so MORE may
|
|
132
|
+
// exist above the window. Exactness keys on this, NOT on reaching byte-offset
|
|
133
|
+
// 0: a small file fits in one chunk, so we can read every byte yet still cap
|
|
134
|
+
// out mid-chunk having skipped older messages. The total is exact only when we
|
|
135
|
+
// never capped — i.e. we accounted for every message in the file.
|
|
136
|
+
let hitLimit = false;
|
|
137
|
+
const fd = openSync(path, "r");
|
|
138
|
+
try {
|
|
139
|
+
let pos = fstatSync(fd).size;
|
|
140
|
+
let leftover = Buffer.alloc(0); // bytes of the not-yet-complete leftmost line
|
|
141
|
+
while (pos > 0 && !hitLimit) {
|
|
142
|
+
const readSize = Math.min(chunkSize, pos);
|
|
143
|
+
pos -= readSize;
|
|
144
|
+
const chunk = Buffer.allocUnsafe(readSize);
|
|
145
|
+
readSync(fd, chunk, 0, readSize, pos);
|
|
146
|
+
const buf = Buffer.concat([chunk, leftover]);
|
|
147
|
+
const segments = splitBufferByNewline(buf);
|
|
148
|
+
// segments[0] is the new leftmost partial (extends further left, unless we
|
|
149
|
+
// reach BOF next); copy it so we don't retain the whole `buf`.
|
|
150
|
+
leftover = Buffer.from(segments[0]);
|
|
151
|
+
// segments[1..] are complete lines; process right→left so newest first.
|
|
152
|
+
for (let i = segments.length - 1; i >= 1; i--) {
|
|
153
|
+
const line = segments[i].toString("utf8");
|
|
154
|
+
if (!line)
|
|
155
|
+
continue;
|
|
156
|
+
const m = parseLine(line);
|
|
157
|
+
if (m) {
|
|
158
|
+
newestFirst.push(m);
|
|
159
|
+
if (newestFirst.length >= limit) {
|
|
160
|
+
hitLimit = true;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Consumed the whole file without ever capping → the final leftover is the
|
|
167
|
+
// file's first line; process it so the count is complete and exact.
|
|
168
|
+
if (!hitLimit && pos === 0) {
|
|
169
|
+
const line = leftover.toString("utf8");
|
|
170
|
+
if (line) {
|
|
171
|
+
const m = parseLine(line);
|
|
172
|
+
if (m)
|
|
173
|
+
newestFirst.push(m);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
closeSync(fd);
|
|
179
|
+
}
|
|
180
|
+
const exact = !hitLimit; // every message accounted for iff we never capped
|
|
181
|
+
const chronological = newestFirst.slice().reverse();
|
|
182
|
+
const { kept, bytesTruncated } = applyByteBudget(chronological, maxBytes);
|
|
183
|
+
const messages = kept.map((m) => ({
|
|
184
|
+
role: m.role,
|
|
185
|
+
text: m.text,
|
|
186
|
+
timestamp: includeTimestamps ? m.timestamp : null,
|
|
187
|
+
}));
|
|
188
|
+
return {
|
|
189
|
+
messages,
|
|
190
|
+
truncated: !exact || bytesTruncated,
|
|
191
|
+
count_truncated: !exact,
|
|
192
|
+
bytes_truncated: bytesTruncated,
|
|
193
|
+
total_messages: exact ? newestFirst.length : null,
|
|
194
|
+
total_messages_exact: exact,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// A bare number is accepted as a legacy `{ limit }` for backward compat with
|
|
198
|
+
// older call sites/tests that passed a message count positionally.
|
|
199
|
+
function normalizeOptions(opts) {
|
|
200
|
+
if (typeof opts === "number")
|
|
201
|
+
return { limit: opts };
|
|
202
|
+
return opts ?? {};
|
|
203
|
+
}
|
|
5
204
|
function extractTextFromClaudeContent(content) {
|
|
6
205
|
if (typeof content === "string")
|
|
7
206
|
return content;
|
|
@@ -18,36 +217,43 @@ function extractTextFromClaudeContent(content) {
|
|
|
18
217
|
}
|
|
19
218
|
return parts.join("\n");
|
|
20
219
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
220
|
+
// Per-line parse for Claude transcripts. Returns null for any line that isn't a
|
|
221
|
+
// non-empty user/assistant message (malformed JSON, wrong type/role, empty
|
|
222
|
+
// text). Shared by the full-scan and tail-scan paths so they agree exactly.
|
|
223
|
+
function parseClaudeLine(line) {
|
|
224
|
+
let obj;
|
|
225
|
+
try {
|
|
226
|
+
obj = JSON.parse(line);
|
|
24
227
|
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (obj.type !== "user" && obj.type !== "assistant")
|
|
232
|
+
return null;
|
|
233
|
+
const role = obj.message?.role;
|
|
234
|
+
if (role !== "user" && role !== "assistant")
|
|
235
|
+
return null;
|
|
236
|
+
const text = extractTextFromClaudeContent(obj.message?.content);
|
|
237
|
+
if (!text)
|
|
238
|
+
return null;
|
|
239
|
+
return { role, text, timestamp: obj.timestamp ?? null };
|
|
240
|
+
}
|
|
241
|
+
export function readClaudeTranscript(path, opts) {
|
|
242
|
+
const options = normalizeOptions(opts);
|
|
243
|
+
if (!existsSync(path))
|
|
244
|
+
return EMPTY_RESULT;
|
|
245
|
+
if (options.tailScan)
|
|
246
|
+
return readTailScan(path, parseClaudeLine, options);
|
|
25
247
|
const raw = readFileSync(path, "utf8");
|
|
26
248
|
const messages = [];
|
|
27
249
|
for (const line of raw.split("\n")) {
|
|
28
250
|
if (!line)
|
|
29
251
|
continue;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (obj.type !== "user" && obj.type !== "assistant")
|
|
38
|
-
continue;
|
|
39
|
-
const role = obj.message?.role;
|
|
40
|
-
if (role !== "user" && role !== "assistant")
|
|
41
|
-
continue;
|
|
42
|
-
const text = extractTextFromClaudeContent(obj.message?.content);
|
|
43
|
-
if (!text)
|
|
44
|
-
continue;
|
|
45
|
-
messages.push({ role, text, timestamp: obj.timestamp ?? null });
|
|
252
|
+
const m = parseClaudeLine(line);
|
|
253
|
+
if (m)
|
|
254
|
+
messages.push(m);
|
|
46
255
|
}
|
|
47
|
-
|
|
48
|
-
const truncated = messages.length > safeLimit;
|
|
49
|
-
const tail = truncated ? messages.slice(-safeLimit) : messages;
|
|
50
|
-
return { messages: tail, truncated, total_messages: messages.length };
|
|
256
|
+
return finalize(messages, options);
|
|
51
257
|
}
|
|
52
258
|
// Codex CLI injects two kinds of blocks into the first user message of a
|
|
53
259
|
// rollout that look identical to user input at the role/type level:
|
|
@@ -83,37 +289,44 @@ function extractTextFromCodexContent(content) {
|
|
|
83
289
|
}
|
|
84
290
|
return parts.join("\n");
|
|
85
291
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
292
|
+
// Per-line parse for Codex rollouts. Drops non-message response_items, wrong
|
|
293
|
+
// roles, injected AGENTS.md/environment_context blocks, and empty text. Shared
|
|
294
|
+
// by the full-scan and tail-scan paths.
|
|
295
|
+
function parseCodexLine(line) {
|
|
296
|
+
let obj;
|
|
297
|
+
try {
|
|
298
|
+
obj = JSON.parse(line);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return null;
|
|
89
302
|
}
|
|
303
|
+
if (obj.type !== "response_item")
|
|
304
|
+
return null;
|
|
305
|
+
const p = obj.payload;
|
|
306
|
+
if (!p || p.type !== "message")
|
|
307
|
+
return null;
|
|
308
|
+
const role = p.role;
|
|
309
|
+
if (role !== "user" && role !== "assistant")
|
|
310
|
+
return null;
|
|
311
|
+
const text = extractTextFromCodexContent(p.content);
|
|
312
|
+
if (!text)
|
|
313
|
+
return null;
|
|
314
|
+
return { role, text, timestamp: obj.timestamp ?? null };
|
|
315
|
+
}
|
|
316
|
+
export function readCodexTranscript(path, opts) {
|
|
317
|
+
const options = normalizeOptions(opts);
|
|
318
|
+
if (!existsSync(path))
|
|
319
|
+
return EMPTY_RESULT;
|
|
320
|
+
if (options.tailScan)
|
|
321
|
+
return readTailScan(path, parseCodexLine, options);
|
|
90
322
|
const raw = readFileSync(path, "utf8");
|
|
91
323
|
const messages = [];
|
|
92
324
|
for (const line of raw.split("\n")) {
|
|
93
325
|
if (!line)
|
|
94
326
|
continue;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
if (obj.type !== "response_item")
|
|
103
|
-
continue;
|
|
104
|
-
const p = obj.payload;
|
|
105
|
-
if (!p || p.type !== "message")
|
|
106
|
-
continue;
|
|
107
|
-
const role = p.role;
|
|
108
|
-
if (role !== "user" && role !== "assistant")
|
|
109
|
-
continue;
|
|
110
|
-
const text = extractTextFromCodexContent(p.content);
|
|
111
|
-
if (!text)
|
|
112
|
-
continue;
|
|
113
|
-
messages.push({ role, text, timestamp: obj.timestamp ?? null });
|
|
327
|
+
const m = parseCodexLine(line);
|
|
328
|
+
if (m)
|
|
329
|
+
messages.push(m);
|
|
114
330
|
}
|
|
115
|
-
|
|
116
|
-
const truncated = messages.length > safeLimit;
|
|
117
|
-
const tail = truncated ? messages.slice(-safeLimit) : messages;
|
|
118
|
-
return { messages: tail, truncated, total_messages: messages.length };
|
|
331
|
+
return finalize(messages, options);
|
|
119
332
|
}
|
package/package.json
CHANGED
|
@@ -7,12 +7,50 @@ import path from "node:path";
|
|
|
7
7
|
|
|
8
8
|
export const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
9
9
|
export const HOOK_MARKER_KEY = "_oxtailHook";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
// Bumping the version forces existing installs to upgrade (install any newly
|
|
11
|
+
// managed hooks) on the next `npx oxtail install-hook`.
|
|
12
|
+
// v2: added the Stop hook alongside PreToolUse.
|
|
13
|
+
// v3: added the UserPromptSubmit hook (busy/idle activity for wake-routing).
|
|
14
|
+
export const HOOK_MARKER_VERSION = 3;
|
|
15
|
+
|
|
16
|
+
const HOOKS_DIR = path.join(os.homedir(), ".oxtail", "hooks");
|
|
17
|
+
|
|
18
|
+
// Every hook oxtail manages.
|
|
19
|
+
// id — keys the per-hook hash in the settings.json marker
|
|
20
|
+
// event — the Claude Code hook event name
|
|
21
|
+
// asset — shipped script filename under assets/
|
|
22
|
+
// scriptPath — where the script is installed
|
|
23
|
+
// command — the literal settings.json command (stable across installs;
|
|
24
|
+
// only the script file at scriptPath may drift, which is why
|
|
25
|
+
// the marker hashes the script, not the command)
|
|
26
|
+
export const MANAGED_HOOKS = [
|
|
27
|
+
{
|
|
28
|
+
id: "pretooluse",
|
|
29
|
+
event: "PreToolUse",
|
|
30
|
+
asset: "pretooluse.sh",
|
|
31
|
+
scriptPath: path.join(HOOKS_DIR, "pretooluse.sh"),
|
|
32
|
+
command: `"$HOME/.oxtail/hooks/pretooluse.sh"`,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "stop",
|
|
36
|
+
event: "Stop",
|
|
37
|
+
asset: "stop.sh",
|
|
38
|
+
scriptPath: path.join(HOOKS_DIR, "stop.sh"),
|
|
39
|
+
command: `"$HOME/.oxtail/hooks/stop.sh"`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "userpromptsubmit",
|
|
43
|
+
event: "UserPromptSubmit",
|
|
44
|
+
asset: "userpromptsubmit.sh",
|
|
45
|
+
scriptPath: path.join(HOOKS_DIR, "userpromptsubmit.sh"),
|
|
46
|
+
command: `"$HOME/.oxtail/hooks/userpromptsubmit.sh"`,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Back-compat: the original single-hook exports, kept so any external importer
|
|
51
|
+
// keeps resolving. Internally install/uninstall iterate MANAGED_HOOKS.
|
|
52
|
+
export const HOOK_SCRIPT_PATH = MANAGED_HOOKS[0].scriptPath;
|
|
53
|
+
export const HOOK_COMMAND = MANAGED_HOOKS[0].command;
|
|
16
54
|
|
|
17
55
|
export function scriptHash(text) {
|
|
18
56
|
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
package/scripts/install-hook.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Install the oxtail PreToolUse
|
|
2
|
+
// Install the oxtail hooks (PreToolUse + Stop) into ~/.claude/settings.json.
|
|
3
3
|
//
|
|
4
|
-
// Idempotent: re-running on an
|
|
4
|
+
// Idempotent: re-running on an up-to-date system reports "already installed"
|
|
5
5
|
// and exits 0 without writing. Format-preserving: edits use jsonc-parser so
|
|
6
6
|
// unrelated keys, whitespace, and comments survive.
|
|
7
7
|
//
|
|
@@ -16,16 +16,17 @@ import {
|
|
|
16
16
|
SETTINGS_PATH,
|
|
17
17
|
HOOK_MARKER_KEY,
|
|
18
18
|
HOOK_MARKER_VERSION,
|
|
19
|
-
|
|
20
|
-
HOOK_COMMAND,
|
|
19
|
+
MANAGED_HOOKS,
|
|
21
20
|
scriptHash,
|
|
22
21
|
} from "./hook-constants.mjs";
|
|
23
22
|
|
|
24
|
-
const SHIPPED_HOOK_PATH = new URL("../assets/pretooluse.sh", import.meta.url).pathname;
|
|
25
23
|
const FORMATTING = { tabSize: 2, insertSpaces: true };
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// Find an oxtail-managed hook entry by its installed script filename
|
|
26
|
+
// (e.g. "oxtail/hooks/pretooluse.sh"). Loose substring match tolerates the
|
|
27
|
+
// "$HOME/..."-quoted command form. -F (fixed-string) is unsafe.
|
|
28
|
+
function findOxtailHookIndex(parsed, event, asset) {
|
|
29
|
+
const arr = parsed?.hooks?.[event];
|
|
29
30
|
if (!Array.isArray(arr)) return -1;
|
|
30
31
|
return arr.findIndex((entry) => {
|
|
31
32
|
if (!entry || typeof entry !== "object") return false;
|
|
@@ -35,55 +36,64 @@ function findOxtailHookIndex(parsed) {
|
|
|
35
36
|
h &&
|
|
36
37
|
typeof h === "object" &&
|
|
37
38
|
typeof h.command === "string" &&
|
|
38
|
-
|
|
39
|
-
h.command.includes("oxtail/hooks/pretooluse.sh"),
|
|
39
|
+
h.command.includes(`oxtail/hooks/${asset}`),
|
|
40
40
|
);
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export async function install() {
|
|
45
|
-
|
|
46
|
-
const
|
|
45
|
+
// Read shipped scripts + compute hashes up front.
|
|
46
|
+
const shipped = [];
|
|
47
|
+
for (const hook of MANAGED_HOOKS) {
|
|
48
|
+
const shippedPath = new URL(`../assets/${hook.asset}`, import.meta.url).pathname;
|
|
49
|
+
const text = await readFile(shippedPath, "utf8");
|
|
50
|
+
shipped.push({ ...hook, text, hash: scriptHash(text) });
|
|
51
|
+
}
|
|
47
52
|
|
|
48
53
|
let source = "{}\n";
|
|
49
54
|
if (existsSync(SETTINGS_PATH)) source = await readFile(SETTINGS_PATH, "utf8");
|
|
50
55
|
const parsed = parse(source) ?? {};
|
|
51
56
|
|
|
52
57
|
const marker = parsed[HOOK_MARKER_KEY];
|
|
53
|
-
const
|
|
58
|
+
const markerHashes = (marker && typeof marker === "object" && marker.hashes) || {};
|
|
54
59
|
const upToDate =
|
|
55
60
|
marker &&
|
|
56
61
|
typeof marker === "object" &&
|
|
57
62
|
marker.version === HOOK_MARKER_VERSION &&
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
`oxtail hook already installed (v${HOOK_MARKER_VERSION}, hash ${wantHash.slice(0, 8)}). No changes.`,
|
|
63
|
+
shipped.every(
|
|
64
|
+
(h) =>
|
|
65
|
+
markerHashes[h.id] === h.hash &&
|
|
66
|
+
findOxtailHookIndex(parsed, h.event, h.asset) >= 0 &&
|
|
67
|
+
existsSync(h.scriptPath),
|
|
64
68
|
);
|
|
69
|
+
if (upToDate) {
|
|
70
|
+
console.log(`oxtail hooks already installed (v${HOOK_MARKER_VERSION}). No changes.`);
|
|
65
71
|
return;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
// Detect competing
|
|
69
|
-
// Behavior under multi-hook coexistence is determined live
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
// Detect competing hooks on the same events (e.g. Terminator's _terminatorHook).
|
|
75
|
+
// Behavior under multi-hook coexistence is determined live; for now, warn.
|
|
76
|
+
let otherCount = 0;
|
|
77
|
+
for (const h of shipped) {
|
|
78
|
+
const arr = parsed?.hooks?.[h.event] ?? [];
|
|
79
|
+
const mine = findOxtailHookIndex(parsed, h.event, h.asset);
|
|
80
|
+
otherCount += arr.filter((entry, idx) => {
|
|
81
|
+
if (idx === mine) return false;
|
|
82
|
+
if (!entry || !Array.isArray(entry.hooks)) return false;
|
|
83
|
+
return entry.hooks.some(
|
|
84
|
+
(x) => x && typeof x.command === "string" && !x.command.includes("oxtail/hooks/"),
|
|
85
|
+
);
|
|
86
|
+
}).length;
|
|
87
|
+
}
|
|
88
|
+
if (otherCount > 0) {
|
|
79
89
|
console.warn(
|
|
80
|
-
`[oxtail] note: ${
|
|
90
|
+
`[oxtail] note: ${otherCount} other hook(s) already present on managed events. ` +
|
|
81
91
|
`Multi-hook coexistence is supported but install order may matter; ` +
|
|
82
92
|
`see README "Hook coexistence" for details.`,
|
|
83
93
|
);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
// Back up settings.json before mutating it
|
|
96
|
+
// Back up settings.json before mutating it.
|
|
87
97
|
if (existsSync(SETTINGS_PATH)) {
|
|
88
98
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
89
99
|
const backup = `${SETTINGS_PATH}.oxtail-backup.${stamp}`;
|
|
@@ -91,42 +101,42 @@ export async function install() {
|
|
|
91
101
|
console.log(`Backed up existing settings to ${backup}`);
|
|
92
102
|
}
|
|
93
103
|
|
|
94
|
-
// Install
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
// Install each shipped script atomically.
|
|
105
|
+
for (const h of shipped) {
|
|
106
|
+
const hooksDir = path.dirname(h.scriptPath);
|
|
107
|
+
await mkdir(hooksDir, { recursive: true, mode: 0o755 });
|
|
108
|
+
const scriptTmp = `${h.scriptPath}.tmp-${randomBytes(6).toString("hex")}`;
|
|
109
|
+
// writeFile's `mode` only applies on creation; explicit chmod for belt+braces.
|
|
110
|
+
await writeFile(scriptTmp, h.text, { mode: 0o755 });
|
|
111
|
+
await chmod(scriptTmp, 0o755);
|
|
112
|
+
await rename(scriptTmp, h.scriptPath);
|
|
113
|
+
}
|
|
103
114
|
|
|
104
|
-
// Edit settings.json
|
|
115
|
+
// Edit settings.json: replace any prior oxtail entry per event, else append.
|
|
116
|
+
// Re-parse each iteration so indices reflect the prior edit. The two events
|
|
117
|
+
// are independent, but re-parsing keeps append indices correct regardless.
|
|
105
118
|
let text = source;
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
for (const h of shipped) {
|
|
120
|
+
const cur = parse(text) ?? {};
|
|
121
|
+
const existingIdx = findOxtailHookIndex(cur, h.event, h.asset);
|
|
122
|
+
const arr = cur?.hooks?.[h.event];
|
|
123
|
+
const targetIdx = existingIdx >= 0 ? existingIdx : (Array.isArray(arr) ? arr.length : 0);
|
|
124
|
+
const newEntry = { hooks: [{ type: "command", command: h.command }] };
|
|
109
125
|
text = applyEdits(
|
|
110
126
|
text,
|
|
111
|
-
modify(text, ["hooks",
|
|
112
|
-
);
|
|
113
|
-
} else {
|
|
114
|
-
const insertIdx = Array.isArray(arr) ? arr.length : 0;
|
|
115
|
-
text = applyEdits(
|
|
116
|
-
text,
|
|
117
|
-
modify(text, ["hooks", "PreToolUse", insertIdx], newEntry, { formattingOptions: FORMATTING }),
|
|
127
|
+
modify(text, ["hooks", h.event, targetIdx], newEntry, { formattingOptions: FORMATTING }),
|
|
118
128
|
);
|
|
119
129
|
}
|
|
130
|
+
|
|
131
|
+
// Write the marker with per-hook hashes.
|
|
132
|
+
const hashes = {};
|
|
133
|
+
for (const h of shipped) hashes[h.id] = h.hash;
|
|
120
134
|
text = applyEdits(
|
|
121
135
|
text,
|
|
122
136
|
modify(
|
|
123
137
|
text,
|
|
124
138
|
[HOOK_MARKER_KEY],
|
|
125
|
-
{
|
|
126
|
-
version: HOOK_MARKER_VERSION,
|
|
127
|
-
installedAt: new Date().toISOString(),
|
|
128
|
-
scriptHash: wantHash,
|
|
129
|
-
},
|
|
139
|
+
{ version: HOOK_MARKER_VERSION, installedAt: new Date().toISOString(), hashes },
|
|
130
140
|
{ formattingOptions: FORMATTING },
|
|
131
141
|
),
|
|
132
142
|
);
|
|
@@ -137,7 +147,9 @@ export async function install() {
|
|
|
137
147
|
await writeFile(settingsTmp, text, "utf8");
|
|
138
148
|
await rename(settingsTmp, SETTINGS_PATH);
|
|
139
149
|
|
|
140
|
-
console.log(
|
|
150
|
+
console.log(
|
|
151
|
+
`Installed oxtail hooks (${MANAGED_HOOKS.map((h) => h.event).join(", ")}) in ${SETTINGS_PATH}.`,
|
|
152
|
+
);
|
|
141
153
|
console.log("Reverse with: npx oxtail uninstall-hook");
|
|
142
154
|
}
|
|
143
155
|
|