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 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 effectiveTimeoutMs = timeout_ms ?? ASK_PEER_TIMEOUT_MS;
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 when no `_oxtailHook`
1457
- // marker is present in ~/.claude/settings.json. Stderr surfacing in Claude
1458
- // Code is a soft assumption; if the hint never reaches the user they miss
1459
- // the prompt and fall back to polling acceptable.
1460
- function maybeHookHint() {
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 settings = readFileSync(join(homedir(), ".claude", "settings.json"), "utf8");
1465
- if (settings.includes("_oxtailHook"))
1466
- return;
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
- // settings file missing is itself a signal the hook isn't installed
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxtail",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Coordination layer for parallel AI coding agent sessions, exposed over MCP.",
@@ -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
- export const HOOK_MARKER_VERSION = 3;
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
+ }