oxtail 0.10.1 → 0.10.2
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/server.js +49 -14
- package/package.json +1 -1
- package/scripts/check-hook-version.mjs +82 -0
- package/scripts/hook-constants.mjs +85 -1
package/dist/server.js
CHANGED
|
@@ -1052,6 +1052,19 @@ const ASK_PEER_TIMEOUT_MS = (() => {
|
|
|
1052
1052
|
})();
|
|
1053
1053
|
const ASK_PEER_GRACE_MS = 500;
|
|
1054
1054
|
const ASK_PEER_POLL_MS = 200;
|
|
1055
|
+
// Ceiling for the per-call `timeout_ms` override. A server-side wait longer
|
|
1056
|
+
// than the CLIENT's own tool-call abort window makes the client kill the
|
|
1057
|
+
// tools/call (a hard error: "tool call failed after Ns") instead of letting
|
|
1058
|
+
// ask_peer return its graceful {reply:null, timed_out:true}. Observed: Codex
|
|
1059
|
+
// aborts around 120s. 100s stays safely under common client limits. Raise via
|
|
1060
|
+
// OXTAIL_ASK_PEER_MAX_TIMEOUT_MS only if your client tolerates longer waits.
|
|
1061
|
+
const ASK_PEER_MAX_TIMEOUT_MS = (() => {
|
|
1062
|
+
const env = process.env.OXTAIL_ASK_PEER_MAX_TIMEOUT_MS;
|
|
1063
|
+
if (!env)
|
|
1064
|
+
return 100_000;
|
|
1065
|
+
const n = Number(env);
|
|
1066
|
+
return Number.isFinite(n) && n > 0 ? n : 100_000;
|
|
1067
|
+
})();
|
|
1055
1068
|
// Typed into the peer's TUI as a synthetic prompt, so it lands in their context
|
|
1056
1069
|
// once per wake — kept terse. For HOOKED Claude Code the delivered envelope
|
|
1057
1070
|
// carries the full reply instruction, but Codex and hookless Claude peers only
|
|
@@ -1301,7 +1314,7 @@ function drainAskPeerReply(my_pid, from_session_id, request_id, require_reply_to
|
|
|
1301
1314
|
server.registerTool("ask_peer", {
|
|
1302
1315
|
description: [
|
|
1303
1316
|
"Delegate-and-wait: enqueue a message to a peer in the same project root, wake them, and block until they reply (via send_message) or the timeout elapses. Use this for back-and-forth; use send_message for fire-and-forget.",
|
|
1304
|
-
"Wakes the peer via per-client tmux send-keys (Codex gets a paste-burst-aware gap, Claude Code doesn't), then polls for a reply. For reply_to-capable peers, only from_session_id + reply_to == request_id satisfies the wait; legacy peers fall back to best-effort from_session_id matching and the response reports correlation:\"uncorrelated\". Response carries wake_status: \"fired\" | \"skipped_no_target\" | \"disabled\" (skipped_unsupported is reserved). Returns reply: null, timed_out: true on timeout (default 45000ms, override per call with timeout_ms, or set OXTAIL_ASK_PEER_TIMEOUT_MS at startup). Late replies still arrive via read_my_messages / the hook.",
|
|
1317
|
+
"Wakes the peer via per-client tmux send-keys (Codex gets a paste-burst-aware gap, Claude Code doesn't), then polls for a reply. For reply_to-capable peers, only from_session_id + reply_to == request_id satisfies the wait; legacy peers fall back to best-effort from_session_id matching and the response reports correlation:\"uncorrelated\". Response carries wake_status: \"fired\" | \"skipped_no_target\" | \"disabled\" (skipped_unsupported is reserved). Returns reply: null, timed_out: true on timeout (default 45000ms, override per call with timeout_ms, or set OXTAIL_ASK_PEER_TIMEOUT_MS at startup). timeout_ms is clamped to a safe ceiling (default 100000ms, env OXTAIL_ASK_PEER_MAX_TIMEOUT_MS) so the wait can't outlast the client's tool-call abort window — exceeding it makes the client hard-fail the call instead of returning graceful timed_out; the response reports timeout_clamped_from_ms when clamped. Late replies still arrive via read_my_messages / the hook.",
|
|
1305
1318
|
"Target must have a registered client.session_id (Codex peers call claim_session first). Body is verbatim — frame it as an assignment (objective + requested action) so it reads as delegation, not chat. Wake overridable via OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off.",
|
|
1306
1319
|
].join(" "),
|
|
1307
1320
|
inputSchema: {
|
|
@@ -1322,7 +1335,10 @@ server.registerTool("ask_peer", {
|
|
|
1322
1335
|
.positive()
|
|
1323
1336
|
.max(300_000)
|
|
1324
1337
|
.optional()
|
|
1325
|
-
.describe("Optional per-call timeout in milliseconds."
|
|
1338
|
+
.describe("Optional per-call timeout in milliseconds. Clamped to a safe ceiling " +
|
|
1339
|
+
"(default 100000ms, env OXTAIL_ASK_PEER_MAX_TIMEOUT_MS) so the wait can't " +
|
|
1340
|
+
"outlast the client's tool-call abort window; the response reports " +
|
|
1341
|
+
"timeout_clamped_from_ms when clamped."),
|
|
1326
1342
|
},
|
|
1327
1343
|
}, async ({ target, body, timeout_ms }, extra) => {
|
|
1328
1344
|
const resolved = resolveTarget(target, entry);
|
|
@@ -1351,7 +1367,12 @@ server.registerTool("ask_peer", {
|
|
|
1351
1367
|
request_id: requestId,
|
|
1352
1368
|
});
|
|
1353
1369
|
const startedAt = Date.now();
|
|
1354
|
-
const
|
|
1370
|
+
const requestedTimeoutMs = timeout_ms ?? ASK_PEER_TIMEOUT_MS;
|
|
1371
|
+
// Clamp below the client tool-call abort window: a longer wait would make
|
|
1372
|
+
// the client hard-fail the tools/call instead of receiving our graceful
|
|
1373
|
+
// timed_out response. Surface the clamp so the caller isn't surprised.
|
|
1374
|
+
const effectiveTimeoutMs = Math.min(requestedTimeoutMs, ASK_PEER_MAX_TIMEOUT_MS);
|
|
1375
|
+
const timeoutClamped = effectiveTimeoutMs < requestedTimeoutMs;
|
|
1355
1376
|
const deadlineMs = startedAt + effectiveTimeoutMs;
|
|
1356
1377
|
trace("ask_peer_start", {
|
|
1357
1378
|
target_session_id: expectedSessionId,
|
|
@@ -1450,25 +1471,39 @@ server.registerTool("ask_peer", {
|
|
|
1450
1471
|
: null,
|
|
1451
1472
|
correlation: reply ? (requireReplyTo ? "correlated" : "uncorrelated") : "none",
|
|
1452
1473
|
timeout_ms: effectiveTimeoutMs,
|
|
1474
|
+
...(timeoutClamped ? { timeout_clamped_from_ms: requestedTimeoutMs } : {}),
|
|
1453
1475
|
timed_out: timedOut,
|
|
1454
1476
|
});
|
|
1455
1477
|
});
|
|
1456
|
-
// Hook-install hint, emitted once per server startup
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
|
|
1478
|
+
// Hook-install hint, emitted once per server startup. Warns in two cases:
|
|
1479
|
+
// - absent: no `_oxtailHook` marker → hooks never installed.
|
|
1480
|
+
// - stale: marker present but an installed hook's hash drifted from what
|
|
1481
|
+
// this package version ships (i.e. the user upgraded oxtail but
|
|
1482
|
+
// never re-ran install-hook, so the OLD script keeps running).
|
|
1483
|
+
// The stale case is the one that bit v0.10.1: a present-but-outdated
|
|
1484
|
+
// pretooluse.sh silently strips request_id and breaks correlated ask/reply,
|
|
1485
|
+
// and the old presence-only check never noticed. Stderr surfacing in Claude
|
|
1486
|
+
// Code is a soft assumption; a missed hint just degrades to polling.
|
|
1487
|
+
async function maybeHookHint() {
|
|
1461
1488
|
if (entry.client.type !== "claude-code")
|
|
1462
1489
|
return;
|
|
1463
1490
|
try {
|
|
1464
|
-
const
|
|
1465
|
-
|
|
1466
|
-
|
|
1491
|
+
const url = new URL("../scripts/hook-constants.mjs", import.meta.url).href;
|
|
1492
|
+
const { assessHookFreshness } = (await import(url));
|
|
1493
|
+
const fresh = assessHookFreshness();
|
|
1494
|
+
if (fresh.status === "absent") {
|
|
1495
|
+
process.stderr.write("[oxtail] PreToolUse hook not installed — run `npx oxtail install-hook` to enable mid-turn peer messaging.\n");
|
|
1496
|
+
}
|
|
1497
|
+
else if (fresh.status === "stale") {
|
|
1498
|
+
process.stderr.write(`[oxtail] installed hooks are out of date (${fresh.driftedHooks.join(", ")} drifted from this version) — ` +
|
|
1499
|
+
"run `npx oxtail install-hook` to upgrade. A stale PreToolUse hook silently breaks correlated " +
|
|
1500
|
+
"ask/reply by not surfacing request_id to the receiving peer.\n");
|
|
1501
|
+
}
|
|
1502
|
+
// "ok" / "unknown" → stay silent.
|
|
1467
1503
|
}
|
|
1468
1504
|
catch {
|
|
1469
|
-
//
|
|
1505
|
+
// Best-effort hint; never block or crash startup on a freshness-check error.
|
|
1470
1506
|
}
|
|
1471
|
-
process.stderr.write("[oxtail] PreToolUse hook not installed — run `npx oxtail install-hook` to enable mid-turn peer messaging.\n");
|
|
1472
1507
|
}
|
|
1473
1508
|
// Importing server.ts (e.g. from a test that needs an exported helper) used
|
|
1474
1509
|
// to await server.connect(transport) at module load — which never resolves
|
|
@@ -1479,5 +1514,5 @@ const invokedDirectly = typeof process.argv[1] === "string" &&
|
|
|
1479
1514
|
if (invokedDirectly) {
|
|
1480
1515
|
const transport = new StdioServerTransport();
|
|
1481
1516
|
await server.connect(transport);
|
|
1482
|
-
maybeHookHint();
|
|
1517
|
+
await maybeHookHint();
|
|
1483
1518
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CI guard: any change to a shipped hook asset (assets/*.sh) MUST bump
|
|
3
|
+
// HOOK_MARKER_VERSION in scripts/hook-constants.mjs. Without the bump, an asset
|
|
4
|
+
// change ships silently and users who upgraded oxtail keep running the OLD hook
|
|
5
|
+
// (nothing re-runs install-hook on upgrade). That is exactly the bug that broke
|
|
6
|
+
// v0.10.1's correlated ask/reply on the receive side: pretooluse.sh gained
|
|
7
|
+
// request_id rendering but the marker version stayed put, so existing installs
|
|
8
|
+
// never refreshed and silently stripped request_id.
|
|
9
|
+
//
|
|
10
|
+
// Usage: node scripts/check-hook-version.mjs [baseRef]
|
|
11
|
+
// baseRef defaults to $GITHUB_BASE_SHA, then origin/main.
|
|
12
|
+
//
|
|
13
|
+
// Deliberately dependency-free (only node:child_process + node:fs) so CI can
|
|
14
|
+
// run it without `npm ci`. Reads both versions by regex rather than importing
|
|
15
|
+
// hook-constants.mjs (which now pulls jsonc-parser).
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
function git(args) {
|
|
21
|
+
return execFileSync("git", args, { encoding: "utf8" }).trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseVersion(text) {
|
|
25
|
+
const m = text.match(/HOOK_MARKER_VERSION\s*=\s*(\d+)/);
|
|
26
|
+
return m ? Number(m[1]) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const base = process.argv[2] || process.env.GITHUB_BASE_SHA || "origin/main";
|
|
30
|
+
const HOOK_ASSET_RE = /^assets\/.*\.sh$/;
|
|
31
|
+
|
|
32
|
+
let changed;
|
|
33
|
+
try {
|
|
34
|
+
changed = git(["diff", "--name-only", `${base}...HEAD`]).split("\n").filter(Boolean);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
const msg = (e && e.message ? String(e.message).split("\n")[0] : String(e));
|
|
37
|
+
console.warn(
|
|
38
|
+
`[check-hook-version] could not diff against base "${base}" (${msg}); skipping guard. ` +
|
|
39
|
+
"Ensure the base ref is fetched (actions/checkout fetch-depth: 0).",
|
|
40
|
+
);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const changedAssets = changed.filter((f) => HOOK_ASSET_RE.test(f));
|
|
45
|
+
if (changedAssets.length === 0) {
|
|
46
|
+
console.log("[check-hook-version] no hook asset changes — OK.");
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const headVersion = parseVersion(readFileSync("scripts/hook-constants.mjs", "utf8"));
|
|
51
|
+
let baseVersion = null;
|
|
52
|
+
try {
|
|
53
|
+
baseVersion = parseVersion(git(["show", `${base}:scripts/hook-constants.mjs`]));
|
|
54
|
+
} catch {
|
|
55
|
+
baseVersion = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (headVersion == null || baseVersion == null) {
|
|
59
|
+
console.error(
|
|
60
|
+
"[check-hook-version] hook asset(s) changed but HOOK_MARKER_VERSION could not be read:\n " +
|
|
61
|
+
changedAssets.join("\n ") +
|
|
62
|
+
`\n(head=${headVersion}, base=${baseVersion}). Verify scripts/hook-constants.mjs and bump the version.`,
|
|
63
|
+
);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (headVersion > baseVersion) {
|
|
68
|
+
console.log(
|
|
69
|
+
`[check-hook-version] OK — ${changedAssets.length} hook asset(s) changed and ` +
|
|
70
|
+
`HOOK_MARKER_VERSION bumped ${baseVersion} → ${headVersion}.`,
|
|
71
|
+
);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.error(
|
|
76
|
+
"[check-hook-version] FAIL — these hook asset(s) changed:\n " +
|
|
77
|
+
changedAssets.join("\n ") +
|
|
78
|
+
`\nbut HOOK_MARKER_VERSION did not increase (base ${baseVersion}, head ${headVersion}).\n` +
|
|
79
|
+
"Bump HOOK_MARKER_VERSION in scripts/hook-constants.mjs so existing installs are forced to " +
|
|
80
|
+
"re-run `npx oxtail install-hook`; otherwise upgraded users silently keep the old hook.",
|
|
81
|
+
);
|
|
82
|
+
process.exit(1);
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// Tiny on purpose — only the things both scripts genuinely need.
|
|
3
3
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
5
6
|
import os from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { parse as parseJsonc } from "jsonc-parser";
|
|
7
10
|
|
|
8
11
|
export const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
9
12
|
export const HOOK_MARKER_KEY = "_oxtailHook";
|
|
@@ -11,7 +14,14 @@ export const HOOK_MARKER_KEY = "_oxtailHook";
|
|
|
11
14
|
// managed hooks) on the next `npx oxtail install-hook`.
|
|
12
15
|
// v2: added the Stop hook alongside PreToolUse.
|
|
13
16
|
// v3: added the UserPromptSubmit hook (busy/idle activity for wake-routing).
|
|
14
|
-
|
|
17
|
+
// v4: pretooluse renders request_id/reply_to/origin + body-budget truncation
|
|
18
|
+
// (v0.10.x correlated ask/reply). A stale pre-v4 pretooluse.sh silently
|
|
19
|
+
// breaks Codex→Claude correlation by stripping request_id from the
|
|
20
|
+
// delivered envelope, so the receiver can't reply_to=request_id.
|
|
21
|
+
// INVARIANT: any change to an assets/*.sh script MUST bump this version, so
|
|
22
|
+
// existing installs are forced to re-install. scripts/check-hook-version.mjs
|
|
23
|
+
// enforces this in CI.
|
|
24
|
+
export const HOOK_MARKER_VERSION = 4;
|
|
15
25
|
|
|
16
26
|
const HOOKS_DIR = path.join(os.homedir(), ".oxtail", "hooks");
|
|
17
27
|
|
|
@@ -55,3 +65,77 @@ export const HOOK_COMMAND = MANAGED_HOOKS[0].command;
|
|
|
55
65
|
export function scriptHash(text) {
|
|
56
66
|
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
57
67
|
}
|
|
68
|
+
|
|
69
|
+
// Directory holding the shipped hook scripts, resolved relative to this module
|
|
70
|
+
// so it works both from src (dev/tests) and dist (published) — scripts/ and
|
|
71
|
+
// assets/ ship side by side in the npm tarball.
|
|
72
|
+
const ASSETS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets");
|
|
73
|
+
|
|
74
|
+
// Hash of each shipped hook asset as it exists in THIS install of the package.
|
|
75
|
+
// Compared against the marker's recorded hashes to detect a stale install.
|
|
76
|
+
// A null entry means the asset couldn't be read (skip it rather than alarm).
|
|
77
|
+
export function shippedHookHashes() {
|
|
78
|
+
const hashes = {};
|
|
79
|
+
for (const h of MANAGED_HOOKS) {
|
|
80
|
+
try {
|
|
81
|
+
hashes[h.id] = scriptHash(readFileSync(path.join(ASSETS_DIR, h.asset), "utf8"));
|
|
82
|
+
} catch {
|
|
83
|
+
hashes[h.id] = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return hashes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Assess whether the installed oxtail hooks match what this package version
|
|
90
|
+
// ships. The flagship failure mode this guards: a package upgrade changes a
|
|
91
|
+
// hook asset, but nothing re-runs install-hook, so the OLD script keeps running
|
|
92
|
+
// (e.g. v0.10.1's pretooluse.sh added request_id rendering; pre-v4 installs
|
|
93
|
+
// silently stripped it and broke correlated ask/reply). install-hook's
|
|
94
|
+
// presence check alone never noticed — a present-but-stale marker looked fine.
|
|
95
|
+
//
|
|
96
|
+
// Never throws; defaults to a silent "unknown"/"ok" on any read/parse failure
|
|
97
|
+
// so server startup never nags spuriously. Returns:
|
|
98
|
+
// status: "ok" — marker present and every shipped hash matches the marker
|
|
99
|
+
// "absent" — no _oxtailHook marker (hooks never installed)
|
|
100
|
+
// "stale" — marker present but one or more script hashes drifted
|
|
101
|
+
// "unknown" — settings unreadable/unparseable; caller should stay quiet
|
|
102
|
+
// driftedHooks — ids whose installed hash != shipped hash
|
|
103
|
+
// versionMismatch — marker.version != HOOK_MARKER_VERSION (informational)
|
|
104
|
+
export function assessHookFreshness(settingsPath = SETTINGS_PATH) {
|
|
105
|
+
let text;
|
|
106
|
+
try {
|
|
107
|
+
text = readFileSync(settingsPath, "utf8");
|
|
108
|
+
} catch {
|
|
109
|
+
// No settings file == hooks were never installed.
|
|
110
|
+
return { status: "absent", driftedHooks: [], versionMismatch: false };
|
|
111
|
+
}
|
|
112
|
+
// Cheap pre-check mirrors the original presence test.
|
|
113
|
+
if (!text.includes(HOOK_MARKER_KEY)) {
|
|
114
|
+
return { status: "absent", driftedHooks: [], versionMismatch: false };
|
|
115
|
+
}
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = parseJsonc(text);
|
|
119
|
+
} catch {
|
|
120
|
+
return { status: "unknown", driftedHooks: [], versionMismatch: false };
|
|
121
|
+
}
|
|
122
|
+
const marker = parsed && typeof parsed === "object" ? parsed[HOOK_MARKER_KEY] : null;
|
|
123
|
+
if (!marker || typeof marker !== "object") {
|
|
124
|
+
return { status: "absent", driftedHooks: [], versionMismatch: false };
|
|
125
|
+
}
|
|
126
|
+
const installedHashes =
|
|
127
|
+
marker.hashes && typeof marker.hashes === "object" ? marker.hashes : {};
|
|
128
|
+
const shipped = shippedHookHashes();
|
|
129
|
+
const driftedHooks = [];
|
|
130
|
+
for (const h of MANAGED_HOOKS) {
|
|
131
|
+
const want = shipped[h.id];
|
|
132
|
+
if (want == null) continue; // can't compare; don't false-alarm
|
|
133
|
+
if (installedHashes[h.id] !== want) driftedHooks.push(h.id);
|
|
134
|
+
}
|
|
135
|
+
const versionMismatch = marker.version !== HOOK_MARKER_VERSION;
|
|
136
|
+
// Trigger "stale" on actual script drift only — a version-only mismatch with
|
|
137
|
+
// identical content is benign bookkeeping (install-hook will refresh the
|
|
138
|
+
// marker) and not worth a startup warning.
|
|
139
|
+
const status = driftedHooks.length > 0 ? "stale" : "ok";
|
|
140
|
+
return { status, driftedHooks, versionMismatch };
|
|
141
|
+
}
|