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/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { isAbstain } from "./detect/index.js";
|
|
|
11
11
|
import { trace } from "./trace.js";
|
|
12
12
|
import { buildEntry, currentPaneForServerPid, findByTmuxSession, readAll, refreshTmuxBinding, register, unregister, } from "./registry.js";
|
|
13
13
|
import * as mailbox from "./mailbox.js";
|
|
14
|
+
import { recoverClaim, resolveAncestors, writeClaim } from "./claims.js";
|
|
14
15
|
// CLI subcommand dispatch must run before any MCP setup so that
|
|
15
16
|
// `npx oxtail install-hook` doesn't open an MCP transport or register a
|
|
16
17
|
// session. Use named exports and await them; calling `await import(...)`
|
|
@@ -34,17 +35,20 @@ import * as mailbox from "./mailbox.js";
|
|
|
34
35
|
import { readClaudeTranscript, readCodexTranscript, } from "./transcripts.js";
|
|
35
36
|
const TMUX_LIST_FORMAT = "#{session_name}|#{session_path}|#{session_created}|#{session_attached}|#{session_windows}";
|
|
36
37
|
const TMUX_PANES_FORMAT = "#{session_name}|#{pane_current_path}";
|
|
37
|
-
function
|
|
38
|
+
function findProjectRoot(start) {
|
|
38
39
|
let dir = start;
|
|
39
40
|
while (true) {
|
|
40
41
|
if (existsSync(join(dir, ".git")))
|
|
41
|
-
return dir;
|
|
42
|
+
return { root: dir, foundGit: true };
|
|
42
43
|
const parent = dirname(dir);
|
|
43
44
|
if (parent === dir)
|
|
44
|
-
return start;
|
|
45
|
+
return { root: start, foundGit: false };
|
|
45
46
|
dir = parent;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
49
|
+
function inferProjectRoot(start) {
|
|
50
|
+
return findProjectRoot(start).root;
|
|
51
|
+
}
|
|
48
52
|
function safeRealpath(p) {
|
|
49
53
|
try {
|
|
50
54
|
return realpathSync(p);
|
|
@@ -59,6 +63,18 @@ function isDescendantOrEqual(child, root) {
|
|
|
59
63
|
const rootWithSep = root.endsWith(sep) ? root : root + sep;
|
|
60
64
|
return child.startsWith(rootWithSep);
|
|
61
65
|
}
|
|
66
|
+
function pathBelongsToProjectScope(path, resolvedRoot) {
|
|
67
|
+
const resolvedPath = safeRealpath(path);
|
|
68
|
+
if (!isDescendantOrEqual(resolvedPath, resolvedRoot))
|
|
69
|
+
return false;
|
|
70
|
+
const project = findProjectRoot(resolvedPath);
|
|
71
|
+
if (!project.foundGit)
|
|
72
|
+
return true;
|
|
73
|
+
// A nested repository under the requested root is a separate project. The
|
|
74
|
+
// descendant check above is necessary for subdirectories of the same repo,
|
|
75
|
+
// but by itself it leaks nested project sessions across the project boundary.
|
|
76
|
+
return safeRealpath(project.root) === resolvedRoot;
|
|
77
|
+
}
|
|
62
78
|
function listTmuxSessionsRaw() {
|
|
63
79
|
let raw;
|
|
64
80
|
try {
|
|
@@ -156,12 +172,12 @@ export function buildListResult(input) {
|
|
|
156
172
|
const { rows, error } = listTmuxSessionsRaw();
|
|
157
173
|
const paneCwds = listTmuxPaneCwds();
|
|
158
174
|
const matched = rows.filter((s) => {
|
|
159
|
-
if (
|
|
175
|
+
if (pathBelongsToProjectScope(s.path, resolvedRoot))
|
|
160
176
|
return true;
|
|
161
177
|
const cwds = paneCwds.get(s.name);
|
|
162
178
|
if (!cwds)
|
|
163
179
|
return false;
|
|
164
|
-
return cwds.some((p) =>
|
|
180
|
+
return cwds.some((p) => pathBelongsToProjectScope(p, resolvedRoot));
|
|
165
181
|
});
|
|
166
182
|
const sessions = joinSessionsWithRegistry(matched, readAll());
|
|
167
183
|
return { schema_version: 1, project_root: resolvedRoot, inferred: !explicit, sessions, error };
|
|
@@ -180,7 +196,7 @@ function anyPaneInScope(canonical, resolvedRoot) {
|
|
|
180
196
|
}
|
|
181
197
|
for (const line of raw.split("\n")) {
|
|
182
198
|
const p = line.trim();
|
|
183
|
-
if (p &&
|
|
199
|
+
if (p && pathBelongsToProjectScope(p, resolvedRoot))
|
|
184
200
|
return true;
|
|
185
201
|
}
|
|
186
202
|
return false;
|
|
@@ -201,9 +217,8 @@ function resolveSessionInScope(name, resolvedRoot) {
|
|
|
201
217
|
const matched = readAll().filter((e) => e.client.session_id === name);
|
|
202
218
|
if (matched.length === 1) {
|
|
203
219
|
const reg = matched[0];
|
|
204
|
-
const cwd = safeRealpath(reg.client.cwd);
|
|
205
220
|
return {
|
|
206
|
-
inScope:
|
|
221
|
+
inScope: pathBelongsToProjectScope(reg.client.cwd, resolvedRoot),
|
|
207
222
|
canonicalName: reg.tmux_session,
|
|
208
223
|
sessionPath: reg.client.cwd,
|
|
209
224
|
registryEntry: reg,
|
|
@@ -225,9 +240,8 @@ function resolveSessionInScope(name, resolvedRoot) {
|
|
|
225
240
|
}
|
|
226
241
|
const reg = regs[0];
|
|
227
242
|
if (reg) {
|
|
228
|
-
const cwd = safeRealpath(reg.client.cwd);
|
|
229
243
|
return {
|
|
230
|
-
inScope:
|
|
244
|
+
inScope: pathBelongsToProjectScope(reg.client.cwd, resolvedRoot),
|
|
231
245
|
canonicalName: reg.tmux_session,
|
|
232
246
|
sessionPath: reg.client.cwd,
|
|
233
247
|
registryEntry: reg,
|
|
@@ -244,7 +258,7 @@ function resolveSessionInScope(name, resolvedRoot) {
|
|
|
244
258
|
if (!canonical || !path) {
|
|
245
259
|
return { inScope: false, canonicalName: null, sessionPath: null, registryEntry: null };
|
|
246
260
|
}
|
|
247
|
-
const sessionInScope =
|
|
261
|
+
const sessionInScope = pathBelongsToProjectScope(path, resolvedRoot);
|
|
248
262
|
const inScope = sessionInScope || anyPaneInScope(canonical, resolvedRoot);
|
|
249
263
|
return {
|
|
250
264
|
inScope,
|
|
@@ -275,7 +289,7 @@ function readSession(input) {
|
|
|
275
289
|
error: `ambiguous-target: multiple agents share tmux session '${input.name}'; pass a client_session_id (UUID) instead. candidates: ${scope.ambiguousCandidates.join(", ")}`,
|
|
276
290
|
};
|
|
277
291
|
}
|
|
278
|
-
if (!scope.inScope
|
|
292
|
+
if (!scope.inScope) {
|
|
279
293
|
return {
|
|
280
294
|
schema_version: 1,
|
|
281
295
|
session: input.name,
|
|
@@ -294,13 +308,35 @@ function readSession(input) {
|
|
|
294
308
|
const reg = scope.registryEntry;
|
|
295
309
|
const clientType = reg?.client.type ?? null;
|
|
296
310
|
const transcriptPath = reg?.client.transcript_path ?? null;
|
|
311
|
+
// A tmux session name (canonical) is only needed to capture pane text.
|
|
312
|
+
// Transcript reads work from the registry entry's transcript_path alone, so a
|
|
313
|
+
// transcript-capable peer with no tmux binding (e.g. Codex running outside
|
|
314
|
+
// tmux) is still readable. Bail only when there's neither a transcript to
|
|
315
|
+
// read nor a tmux session to capture — previously a null canonicalName alone
|
|
316
|
+
// (an in-scope, transcript-capable, tmux-less peer) was wrongly rejected as
|
|
317
|
+
// "not in project scope".
|
|
318
|
+
if (!canonical && !transcriptPath) {
|
|
319
|
+
return {
|
|
320
|
+
schema_version: 1,
|
|
321
|
+
session: input.name,
|
|
322
|
+
mode: "none",
|
|
323
|
+
client_type: clientType,
|
|
324
|
+
messages: null,
|
|
325
|
+
pane_text: null,
|
|
326
|
+
truncated: false,
|
|
327
|
+
total_messages: null,
|
|
328
|
+
project_root: resolvedRoot,
|
|
329
|
+
inferred: !explicit,
|
|
330
|
+
error: `session '${input.name}' is in scope but has no transcript and no tmux session to read`,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
297
333
|
const wantTranscript = mode === "transcript" || (mode === "auto" && transcriptPath);
|
|
298
334
|
if (wantTranscript) {
|
|
299
335
|
if (!transcriptPath) {
|
|
300
336
|
if (mode === "transcript") {
|
|
301
337
|
return {
|
|
302
338
|
schema_version: 1,
|
|
303
|
-
session: canonical,
|
|
339
|
+
session: canonical ?? input.name,
|
|
304
340
|
mode: "none",
|
|
305
341
|
client_type: clientType,
|
|
306
342
|
messages: null,
|
|
@@ -319,7 +355,7 @@ function readSession(input) {
|
|
|
319
355
|
const result = reader(transcriptPath, limit);
|
|
320
356
|
return {
|
|
321
357
|
schema_version: 1,
|
|
322
|
-
session: canonical,
|
|
358
|
+
session: canonical ?? input.name,
|
|
323
359
|
mode: "transcript",
|
|
324
360
|
client_type: clientType,
|
|
325
361
|
messages: result.messages,
|
|
@@ -332,6 +368,23 @@ function readSession(input) {
|
|
|
332
368
|
};
|
|
333
369
|
}
|
|
334
370
|
}
|
|
371
|
+
// Pane fallback needs a tmux session to capture from. Reachable only when a
|
|
372
|
+
// caller forces mode:"pane" on a transcript-only peer (no tmux binding).
|
|
373
|
+
if (!canonical) {
|
|
374
|
+
return {
|
|
375
|
+
schema_version: 1,
|
|
376
|
+
session: input.name,
|
|
377
|
+
mode: "none",
|
|
378
|
+
client_type: clientType,
|
|
379
|
+
messages: null,
|
|
380
|
+
pane_text: null,
|
|
381
|
+
truncated: false,
|
|
382
|
+
total_messages: null,
|
|
383
|
+
project_root: resolvedRoot,
|
|
384
|
+
inferred: !explicit,
|
|
385
|
+
error: `session '${input.name}' has no tmux pane to capture (transcript-only peer)`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
335
388
|
try {
|
|
336
389
|
const text = capturePane(canonical, paneLines);
|
|
337
390
|
return {
|
|
@@ -373,6 +426,7 @@ const entry = buildEntry(client);
|
|
|
373
426
|
emitDetectTrace("startup", diagnosis);
|
|
374
427
|
entry.client = enriched;
|
|
375
428
|
}
|
|
429
|
+
maybeRecoverStickyClaim();
|
|
376
430
|
register(entry);
|
|
377
431
|
const cleanup = () => {
|
|
378
432
|
unregister(entry.server_pid);
|
|
@@ -429,16 +483,34 @@ function allAbstentionsStructural(diagnosis) {
|
|
|
429
483
|
return false;
|
|
430
484
|
return outcomes.every((o) => isAbstain(o) && o.structural === true);
|
|
431
485
|
}
|
|
432
|
-
|
|
486
|
+
function refineFromHandshake(trigger) {
|
|
433
487
|
const info = server.server.getClientVersion();
|
|
434
488
|
if (!info)
|
|
435
|
-
return;
|
|
489
|
+
return null;
|
|
436
490
|
const { client: refined, diagnosis } = enrichWithDiagnosis(clientFromHandshake(info), entry.started_at);
|
|
437
|
-
emitDetectTrace(
|
|
438
|
-
|
|
439
|
-
|
|
491
|
+
emitDetectTrace(trigger, diagnosis);
|
|
492
|
+
// Refine from the handshake, but never let a re-detect that resolved nothing
|
|
493
|
+
// wipe an already-resolved session_id (e.g. one recovered via sticky-claim at
|
|
494
|
+
// startup). Keep our id/source/transcript unless the handshake resolved an id.
|
|
495
|
+
const merged = refined.session_id
|
|
496
|
+
? refined
|
|
497
|
+
: {
|
|
498
|
+
...refined,
|
|
499
|
+
session_id: entry.client.session_id,
|
|
500
|
+
session_id_source: entry.client.session_id_source,
|
|
501
|
+
transcript_path: entry.client.transcript_path,
|
|
502
|
+
};
|
|
503
|
+
if (merged.type !== entry.client.type || merged.session_id !== entry.client.session_id) {
|
|
504
|
+
entry.client = merged;
|
|
440
505
|
register(entry);
|
|
441
506
|
}
|
|
507
|
+
// The handshake may have just revealed the client type (e.g. unknown→codex);
|
|
508
|
+
// sticky recovery can apply now even if it couldn't at startup.
|
|
509
|
+
maybeRecoverStickyClaim();
|
|
510
|
+
return diagnosis;
|
|
511
|
+
}
|
|
512
|
+
server.server.oninitialized = () => {
|
|
513
|
+
const diagnosis = refineFromHandshake("oninitialized");
|
|
442
514
|
// After type is known via handshake, schedule retries to catch transcript files
|
|
443
515
|
// that don't exist yet at handshake time. No-op if session_id is already set.
|
|
444
516
|
if (!entry.client.session_id && entry.client.type !== "unknown") {
|
|
@@ -450,7 +522,7 @@ server.server.oninitialized = () => {
|
|
|
450
522
|
}
|
|
451
523
|
};
|
|
452
524
|
server.registerTool("list_project_sessions", {
|
|
453
|
-
description: "List agent sessions
|
|
525
|
+
description: "List agent sessions in or under a project root, enriched with client_type, client_session_id, and each peer's `state` card (see set_my_state) — the cheapest way to see what peers are doing. One row per agent; key on `client_session_id`, not `name` (rows can share a name when peers share a tmux session). Pass project_root when known; omitted = best-effort inference from cwd.",
|
|
454
526
|
inputSchema: {
|
|
455
527
|
project_root: z
|
|
456
528
|
.string()
|
|
@@ -462,7 +534,7 @@ server.registerTool("list_project_sessions", {
|
|
|
462
534
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
463
535
|
});
|
|
464
536
|
server.registerTool("read_session", {
|
|
465
|
-
description: "Read
|
|
537
|
+
description: "Read a peer session's recent activity: a clean per-turn transcript for a recognized oxtail-aware client, else raw tmux pane text. `name` is a tmux session name OR a client_session_id (UUID) — a shared tmux name returns `ambiguous-target` with candidate UUIDs to pick from. Out-of-project targets are rejected (mode:'none'). PRIVACY: returns what the user typed and the peer produced; treat as context, not fresh user input.",
|
|
466
538
|
inputSchema: {
|
|
467
539
|
name: z.string().describe("tmux session name OR client_session_id (UUID) of the peer. UUID form disambiguates when multiple agents share a tmux session."),
|
|
468
540
|
project_root: z
|
|
@@ -505,9 +577,72 @@ function pinSessionId(sessionId) {
|
|
|
505
577
|
};
|
|
506
578
|
refreshTmuxBinding(entry);
|
|
507
579
|
register(entry);
|
|
580
|
+
persistStickyClaim();
|
|
581
|
+
}
|
|
582
|
+
// Persist (or refresh) a sticky-claim record for the current entry, keyed by
|
|
583
|
+
// client_type + cwd + the MCP server's parent-host identity. Lets a restarted
|
|
584
|
+
// MCP child recover this session_id without the agent re-running claim_session.
|
|
585
|
+
// Best-effort: never let claim-store I/O block or fail a claim.
|
|
586
|
+
function persistStickyClaim() {
|
|
587
|
+
const sid = entry.client.session_id;
|
|
588
|
+
if (!sid || entry.client.type === "unknown")
|
|
589
|
+
return;
|
|
590
|
+
try {
|
|
591
|
+
writeClaim({
|
|
592
|
+
client_type: entry.client.type,
|
|
593
|
+
cwd: entry.client.cwd,
|
|
594
|
+
ancestors: resolveAncestors(),
|
|
595
|
+
session_id: sid,
|
|
596
|
+
transcript_path: entry.client.transcript_path,
|
|
597
|
+
server_pid: entry.server_pid,
|
|
598
|
+
claimed_at: Math.floor(Date.now() / 1000),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
// best-effort
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// Startup recovery: when env- and birth-time detection both abstain (the
|
|
606
|
+
// common case for a restarted Codex MCP child — its session-id env var is
|
|
607
|
+
// stripped and its transcript predates this child's started_at), try to adopt
|
|
608
|
+
// the previously-claimed session_id for this exact (client_type, cwd, live
|
|
609
|
+
// parent). Conservative: recoverClaim only returns a record when it's
|
|
610
|
+
// unambiguously safe — exactly one matching claim whose transcript still exists.
|
|
611
|
+
// A live same-session_id sibling is NOT a conflict (it's the same agent's other
|
|
612
|
+
// MCP child), so recovery proceeds alongside it; otherwise we leave session_id
|
|
613
|
+
// null and the caller's next_step points at explicit claim_session.
|
|
614
|
+
function maybeRecoverStickyClaim() {
|
|
615
|
+
if (entry.client.session_id || entry.client.type === "unknown")
|
|
616
|
+
return;
|
|
617
|
+
let rec = null;
|
|
618
|
+
try {
|
|
619
|
+
rec = recoverClaim(entry.client.type, entry.client.cwd, resolveAncestors());
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (!rec)
|
|
625
|
+
return;
|
|
626
|
+
entry.client = {
|
|
627
|
+
...entry.client,
|
|
628
|
+
session_id: rec.session_id,
|
|
629
|
+
session_id_source: "sticky-claim",
|
|
630
|
+
transcript_path: rec.transcript_path,
|
|
631
|
+
};
|
|
632
|
+
trace("sticky_claim_recovered", {
|
|
633
|
+
session_id: rec.session_id,
|
|
634
|
+
cwd: entry.client.cwd,
|
|
635
|
+
});
|
|
636
|
+
// Refresh the record so it carries our new server_pid going forward.
|
|
637
|
+
persistStickyClaim();
|
|
638
|
+
// Recovery mutates the in-memory registry entry. When recovery happens after
|
|
639
|
+
// the MCP initialize handshake revealed the client type, we may already have
|
|
640
|
+
// written a null-session entry; publish the recovered id immediately so peers
|
|
641
|
+
// do not see this agent as unclaimed until another write happens.
|
|
642
|
+
register(entry);
|
|
508
643
|
}
|
|
509
644
|
server.registerTool("register_my_session", {
|
|
510
|
-
description: "Pin this MCP server's session_id directly
|
|
645
|
+
description: "Pin this MCP server's session_id directly (registry entry updated in place + persisted). Escape hatch for when auto-detection can't resolve the id; get the value via `echo $CLAUDE_CODE_SESSION_ID` (or `$CODEX_THREAD_ID`) in a Bash tool subshell. Prefer `claim_session` for routine use — this stays for debugging.",
|
|
511
646
|
inputSchema: {
|
|
512
647
|
session_id: z
|
|
513
648
|
.string()
|
|
@@ -562,6 +697,11 @@ server.registerTool("get_my_session", {
|
|
|
562
697
|
description: "Returns this MCP server's own registry entry plus a per-strategy detection diagnosis. Each strategy returns either a hit ({session_id, source, confidence}) or an abstention ({abstain: true, reason}); the reason explains *why* the strategy didn't fire so you don't have to guess. When `winning` is null, follow `next_step` (which gives you the exact bash command to read your session id and the tool to call with it) — do not investigate each strategy individually. Both env and birth-time can be designed-null in normal operation: env is structurally null on Claude Code, and birth-time is null whenever 2+ agents share a project.",
|
|
563
698
|
inputSchema: {},
|
|
564
699
|
}, async () => {
|
|
700
|
+
// Some MCP clients make getClientVersion available before the oninitialized
|
|
701
|
+
// callback has run. Refining here makes the first explicit self-check repair
|
|
702
|
+
// type/session state instead of returning a transient unknown/null registry
|
|
703
|
+
// entry.
|
|
704
|
+
refineFromHandshake("get_my_session");
|
|
565
705
|
let diagnosis;
|
|
566
706
|
if (entry.client.session_id) {
|
|
567
707
|
// Registry is authoritative. Skip detection I/O entirely and surface
|
|
@@ -637,15 +777,21 @@ server.registerTool("set_my_state", {
|
|
|
637
777
|
};
|
|
638
778
|
});
|
|
639
779
|
function projectRootsMatch(caller, peer) {
|
|
640
|
-
const
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
if (
|
|
645
|
-
return
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
780
|
+
const callerProject = findProjectRoot(caller.client.cwd);
|
|
781
|
+
const peerProject = findProjectRoot(peer.client.cwd);
|
|
782
|
+
const callerRoot = safeRealpath(callerProject.root);
|
|
783
|
+
const peerRoot = safeRealpath(peerProject.root);
|
|
784
|
+
if (callerProject.foundGit || peerProject.foundGit) {
|
|
785
|
+
return callerProject.foundGit && peerProject.foundGit && callerRoot === peerRoot;
|
|
786
|
+
}
|
|
787
|
+
// No .git boundary exists for either side. Preserve the pre-v0.8 loose
|
|
788
|
+
// behavior for ad-hoc directories so two agents in parent/child cwd under the
|
|
789
|
+
// same scratch tree can still coordinate.
|
|
790
|
+
const callerCwd = safeRealpath(caller.client.cwd);
|
|
791
|
+
const peerCwd = safeRealpath(peer.client.cwd);
|
|
792
|
+
return (callerRoot === peerRoot ||
|
|
793
|
+
isDescendantOrEqual(peerCwd, callerRoot) ||
|
|
794
|
+
isDescendantOrEqual(callerCwd, peerRoot));
|
|
649
795
|
}
|
|
650
796
|
function isAliveLocal(pid) {
|
|
651
797
|
try {
|
|
@@ -701,21 +847,20 @@ function resolveTarget(target, caller) {
|
|
|
701
847
|
};
|
|
702
848
|
}
|
|
703
849
|
const peer = candidates[0];
|
|
704
|
-
|
|
705
|
-
|
|
850
|
+
if (peer.server_pid === caller.server_pid ||
|
|
851
|
+
(caller.client.session_id &&
|
|
852
|
+
peer.client.session_id === caller.client.session_id)) {
|
|
706
853
|
return { ok: false, error: "self-send" };
|
|
854
|
+
}
|
|
707
855
|
if (!projectRootsMatch(caller, peer))
|
|
708
856
|
return { ok: false, error: "cross-project" };
|
|
709
857
|
return { ok: true, entry: peer };
|
|
710
858
|
}
|
|
711
859
|
server.registerTool("send_message", {
|
|
712
860
|
description: [
|
|
713
|
-
"Fire-and-forget message to a peer.
|
|
714
|
-
"
|
|
715
|
-
"
|
|
716
|
-
"Sender-side wrapping: if you want the message to appear as a system-reminder, include the <system-reminder>...</system-reminder> tags in `body`. The mailbox is a dumb transport.",
|
|
717
|
-
"Cross-project targets are rejected, never silently dropped.",
|
|
718
|
-
"For a blocking send-and-wait variant that pauses your turn until the peer replies, use ask_peer instead. ask_peer routes the wake per client_type (v0.7+): Codex peers are woken via paste-burst-aware send-keys; Claude Code peers fail-fast since their hook surface has no idle event. See ask_peer's tool description for the full contract.",
|
|
861
|
+
"Fire-and-forget message to a peer in the same project root. Target: a tmux session name OR a client_session_id (UUID). Async via the peer's mailbox — delivered mid-turn (PreToolUse hook) or next-turn (read_my_messages); cross-project targets are rejected.",
|
|
862
|
+
"By default does NOT wake an idle peer. Pass wake:\"auto\" to nudge one via per-client send-keys, state-gated (skipped if the peer is mid-turn). Response then carries wake_status: \"fired\" | \"skipped_busy\" | \"skipped_no_target\" | \"disabled\".",
|
|
863
|
+
"Body is verbatim — wrap in <system-reminder>...</system-reminder> yourself if you want that framing. For a blocking send-and-wait, use ask_peer instead.",
|
|
719
864
|
].join(" "),
|
|
720
865
|
inputSchema: {
|
|
721
866
|
target: z
|
|
@@ -729,8 +874,12 @@ server.registerTool("send_message", {
|
|
|
729
874
|
message: "body exceeds 8192 UTF-8 bytes",
|
|
730
875
|
})
|
|
731
876
|
.describe("Message body, ≤8KB UTF-8. The sender chooses the framing."),
|
|
877
|
+
wake: z
|
|
878
|
+
.enum(["off", "auto"])
|
|
879
|
+
.optional()
|
|
880
|
+
.describe('Wake strategy. "off" (default): pure fire-and-forget, no nudge. "auto": nudge an idle peer via per-client send-keys, state-gated (skipped if the peer is mid-turn). Response carries wake_status when set.'),
|
|
732
881
|
},
|
|
733
|
-
}, async ({ target, body }) => {
|
|
882
|
+
}, async ({ target, body, wake }) => {
|
|
734
883
|
const resolved = resolveTarget(target, entry);
|
|
735
884
|
if (!resolved.ok) {
|
|
736
885
|
return {
|
|
@@ -745,6 +894,7 @@ server.registerTool("send_message", {
|
|
|
745
894
|
const peer = resolved.entry;
|
|
746
895
|
const fromSessionId = entry.client.session_id ?? undefined;
|
|
747
896
|
const msg = mailbox.enqueue(peer.server_pid, body, fromSessionId);
|
|
897
|
+
const wake_status = wake === "auto" ? await wakeForSend(peer) : undefined;
|
|
748
898
|
return {
|
|
749
899
|
content: [
|
|
750
900
|
{
|
|
@@ -755,6 +905,7 @@ server.registerTool("send_message", {
|
|
|
755
905
|
message_id: msg.id,
|
|
756
906
|
target_session_id: peer.client.session_id,
|
|
757
907
|
target_server_pid: peer.server_pid,
|
|
908
|
+
...(wake_status ? { wake_status } : {}),
|
|
758
909
|
}, null, 2),
|
|
759
910
|
},
|
|
760
911
|
],
|
|
@@ -810,9 +961,8 @@ const ASK_PEER_WAKE_TEXT = "[oxtail] new peer message — run mcp__oxtail__read_
|
|
|
810
961
|
const ASK_PEER_CODEX_SUBMIT_DELAY_MS = 500;
|
|
811
962
|
// OXTAIL_ASK_PEER_WAKE_STRATEGY = "auto" | "legacy" | "off"
|
|
812
963
|
// auto — per-client routing: Codex gets paste-burst-aware wake (500ms gap
|
|
813
|
-
// between text and Enter); Claude Code
|
|
814
|
-
//
|
|
815
|
-
// get legacy v0.6 behavior.
|
|
964
|
+
// between text and Enter); Claude Code gets legacy send-keys with
|
|
965
|
+
// no gap; unknown clients get legacy v0.6 behavior.
|
|
816
966
|
// legacy — v0.6 behavior for every client (text + Enter, no gap, no
|
|
817
967
|
// per-client routing). Escape hatch if auto mode misfires.
|
|
818
968
|
// off — wake disabled entirely; ask_peer becomes a blocking poll.
|
|
@@ -963,6 +1113,43 @@ async function wakePeer(peer) {
|
|
|
963
1113
|
const ok = await askPeerWakeImpl(effectivePane, peer.tmux_session, fire);
|
|
964
1114
|
return ok ? "fired" : "skipped_no_target";
|
|
965
1115
|
}
|
|
1116
|
+
// --- send_message wake:auto gating -------------------------------------------
|
|
1117
|
+
// A peer marks itself "busy" (UserPromptSubmit hook) / "idle" (Stop hook) in
|
|
1118
|
+
// ~/.oxtail/activity/<session_id>. send_message wake:auto reads that so it never
|
|
1119
|
+
// types into a peer that's mid-turn — the peer's PreToolUse/Stop hooks deliver
|
|
1120
|
+
// during the turn, so a send-keys wake is only useful when the peer is idle.
|
|
1121
|
+
// Keyed by session_id (the agent identity), NOT server_pid: a dual-scope agent
|
|
1122
|
+
// has several MCP children sharing one session_id, and the hooks/sender must
|
|
1123
|
+
// agree on the key (see AGENTS.md). Must match the sanitization in the hooks.
|
|
1124
|
+
const ACTIVITY_BUSY_TTL_MS = 10 * 60 * 1000;
|
|
1125
|
+
function activitySessionKey(sessionId) {
|
|
1126
|
+
return sessionId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
1127
|
+
}
|
|
1128
|
+
function readActivity(sessionId) {
|
|
1129
|
+
if (!sessionId)
|
|
1130
|
+
return null;
|
|
1131
|
+
try {
|
|
1132
|
+
const p = join(homedir(), ".oxtail", "activity", activitySessionKey(sessionId));
|
|
1133
|
+
const status = readFileSync(p, "utf8").trim();
|
|
1134
|
+
return { status, ageMs: Date.now() - statSync(p).mtimeMs };
|
|
1135
|
+
}
|
|
1136
|
+
catch {
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
// Skip the wake only when the peer is FRESHLY busy. Idle, unknown (no activity
|
|
1141
|
+
// file — hooks not installed), or stale-busy (a turn that outran the TTL, or a
|
|
1142
|
+
// peer that exited without a clean Stop) all fall through to a wake.
|
|
1143
|
+
function shouldWakeForSend(act) {
|
|
1144
|
+
return !(act && act.status === "busy" && act.ageMs < ACTIVITY_BUSY_TTL_MS);
|
|
1145
|
+
}
|
|
1146
|
+
async function wakeForSend(peer) {
|
|
1147
|
+
if (!shouldWakeForSend(readActivity(peer.client.session_id))) {
|
|
1148
|
+
trace("send_wake_skipped_busy", { target_session_id: peer.client.session_id });
|
|
1149
|
+
return "skipped_busy";
|
|
1150
|
+
}
|
|
1151
|
+
return wakePeer(peer);
|
|
1152
|
+
}
|
|
966
1153
|
// Poll my mailbox at ASK_PEER_POLL_MS until a matching reply lands or the
|
|
967
1154
|
// deadline elapses. Each tick checks mtime first and only acquires the
|
|
968
1155
|
// mailbox lock when there's a probable hit. The lock is held only inside
|
|
@@ -996,16 +1183,9 @@ async function askPeerPoll(my_pid, from_session_id, deadlineMs, signal) {
|
|
|
996
1183
|
}
|
|
997
1184
|
server.registerTool("ask_peer", {
|
|
998
1185
|
description: [
|
|
999
|
-
"
|
|
1000
|
-
"
|
|
1001
|
-
"
|
|
1002
|
-
"Response includes a wake_status field: \"fired\" (wake attempted or reply received during grace window), \"skipped_unsupported\" (reserved — no client currently returns this in auto mode), \"skipped_no_target\" (no tmux pane or session resolved for target), \"disabled\" (OXTAIL_ASK_PEER_WAKE_STRATEGY=off).",
|
|
1003
|
-
"Behavior: enqueues the body to the target's mailbox, waits ~500ms for a hook-delivered reply (rare: peer was mid-turn, hook delivered as additionalContext), fires the per-client wake, then polls this session's mailbox at 200ms for a reply from the target.",
|
|
1004
|
-
"Returns when the target sends a message back (via send_message) whose from_session_id matches them, or when the timeout elapses (returns reply: null, timed_out: true). Timeout defaults to 45000ms; user-tunable via OXTAIL_ASK_PEER_TIMEOUT_MS env var.",
|
|
1005
|
-
"Wake strategy can be overridden via OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off (default auto). legacy = v0.6 behavior for every client (no gap, no per-client routing). off = no wake fired; ask_peer becomes a pure blocking poll until the peer naturally enters a turn or timeout.",
|
|
1006
|
-
"Target must have a registered client.session_id (Codex peers must call register_my_session first).",
|
|
1007
|
-
"Late replies that arrive after timeout are delivered normally via read_my_messages / the PreToolUse hook.",
|
|
1008
|
-
"Body framing: peers see the body verbatim. Include a short assignment-style framing (objective, what you want them to do) so they treat it as a delegation, not chat.",
|
|
1186
|
+
"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.",
|
|
1187
|
+
"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 whose from_session_id matches the target. Response carries wake_status: \"fired\" | \"skipped_no_target\" | \"disabled\" (skipped_unsupported is reserved). Returns reply: null, timed_out: true on timeout (default 45000ms, OXTAIL_ASK_PEER_TIMEOUT_MS to tune). Late replies still arrive via read_my_messages / the hook.",
|
|
1188
|
+
"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.",
|
|
1009
1189
|
].join(" "),
|
|
1010
1190
|
inputSchema: {
|
|
1011
1191
|
target: z
|
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);
|