oxtail 0.9.1 → 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 +15 -13
- package/assets/pretooluse.sh +1 -1
- package/assets/stop.sh +1 -1
- package/dist/server.js +256 -221
- package/dist/transcripts.js +263 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
|
|
|
21
21
|
**Claude Code** — add to `~/.claude.json` (global) or any project's `.mcp.json`:
|
|
22
22
|
|
|
23
23
|
```jsonc
|
|
24
|
-
{ "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.
|
|
24
|
+
{ "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.10.0"] } } }
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
**Codex CLI** — add to `~/.codex/config.toml`:
|
|
@@ -29,14 +29,14 @@ End users — paste into your MCP config and oxtail is fetched from npm on first
|
|
|
29
29
|
```toml
|
|
30
30
|
[mcp_servers.oxtail]
|
|
31
31
|
command = "npx"
|
|
32
|
-
args = ["-y", "oxtail@0.
|
|
32
|
+
args = ["-y", "oxtail@0.10.0"]
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
**Claude slash command** (`/oxtail-join`):
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
38
|
mkdir -p ~/.claude/commands
|
|
39
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
39
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.10.0/.claude/commands/oxtail-join.md \
|
|
40
40
|
-o ~/.claude/commands/oxtail-join.md
|
|
41
41
|
```
|
|
42
42
|
|
|
@@ -44,9 +44,9 @@ curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.9.1/.claude/commands
|
|
|
44
44
|
|
|
45
45
|
```sh
|
|
46
46
|
mkdir -p ~/.codex/skills/oxtail-join/agents
|
|
47
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
47
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.10.0/integrations/codex/oxtail-join/SKILL.md \
|
|
48
48
|
-o ~/.codex/skills/oxtail-join/SKILL.md
|
|
49
|
-
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.
|
|
49
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.10.0/integrations/codex/oxtail-join/agents/openai.yaml \
|
|
50
50
|
-o ~/.codex/skills/oxtail-join/agents/openai.yaml
|
|
51
51
|
```
|
|
52
52
|
|
|
@@ -61,8 +61,8 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
|
|
|
61
61
|
|
|
62
62
|
## MCP tools
|
|
63
63
|
|
|
64
|
-
- `list_project_sessions` — tmux sessions in or under a given project root, enriched with `client_type`, `client_session_id`, and the peer's `state` card. Returns **one row per registered agent** — rows may share `name` when peers share a tmux session (Terminator multi-window). Disambiguate via `client_session_id`.
|
|
65
|
-
- `read_session` — the recent transcript of a peer session, as clean per-turn messages when the peer is oxtail-aware (Claude Code and Codex CLI), or as raw tmux pane text otherwise. Accepts a tmux session name OR a `client_session_id` UUID; an ambiguous tmux name returns `ambiguous-target` with the candidate UUIDs.
|
|
64
|
+
- `list_project_sessions` — tmux sessions in or under a given project root, enriched with `client_type`, `client_session_id`, and the peer's `state` card. Returns **one row per registered agent** — rows may share `name` when peers share a tmux session (Terminator multi-window). Disambiguate via `client_session_id`. Pass `compact: true` for a de-duplicated `tmux_sessions[]` shape that hoists the shared tmux fields and nests agents (smaller when several agents share a session); the default flat `sessions[]` shape is unchanged.
|
|
65
|
+
- `read_session` — the recent transcript of a peer session, as clean per-turn messages when the peer is oxtail-aware (Claude Code and Codex CLI), or as raw tmux pane text otherwise. Accepts a tmux session name OR a `client_session_id` UUID; an ambiguous tmux name returns `ambiguous-target` with the candidate UUIDs. Transcript reads are **budgeted** so a casual read can't blow your context window: by default the last 20 messages and ~24KB of text (newest-first), per-message ISO timestamps omitted. `count_truncated` / `bytes_truncated` say which budget bit; raise `limit` + `max_bytes` to pull more, set `include_timestamps: true` to keep timestamps, and pass `tail_scan: true` to read the file tail without parsing the whole transcript (qualifies `total_messages` via `total_messages_exact`).
|
|
66
66
|
- `claim_session` — single-shot session registration. The routine path: `Bash echo $CLAUDE_CODE_SESSION_ID` (or `$CODEX_THREAD_ID` for Codex) → `claim_session({ session_id })`. Returns `{ ok, session_id, transcript_path }`.
|
|
67
67
|
- `set_my_state` — write a small "state card" onto this session's registry entry so peers can see what we're doing without reading our transcript. v1 surfaces a single field, `purpose` (≤200 chars).
|
|
68
68
|
- `send_message` — **fire-and-forget** message to a peer. Target is a tmux session name or a raw `client_session_id` UUID. Body ≤ 8KB. Delivery is async via the peer's mailbox file. By default does **not** wake an idle peer; pass `wake: "auto"` to nudge one (state-gated — see [Waking an idle peer](#waking-an-idle-peer)). (v0.5+)
|
|
@@ -71,7 +71,7 @@ Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm i
|
|
|
71
71
|
- `register_my_session` — pin this MCP server's `session_id` directly. Kept for debugging; prefer `claim_session`.
|
|
72
72
|
- `get_my_session` — return this MCP server's own registry entry plus a per-strategy detection diagnosis. Useful for debugging.
|
|
73
73
|
|
|
74
|
-
See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.
|
|
74
|
+
See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.10.0/AGENTS.md) for scope and architecture.
|
|
75
75
|
|
|
76
76
|
## Usage from an agent
|
|
77
77
|
|
|
@@ -79,9 +79,11 @@ See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.9.1/AGENTS.md)
|
|
|
79
79
|
claim_session({ session_id: "<uuid from $CLAUDE_CODE_SESSION_ID or $CODEX_THREAD_ID>" })
|
|
80
80
|
set_my_state({ purpose: "wiring up state cards" })
|
|
81
81
|
list_project_sessions({ project_root: "/path/to/project" })
|
|
82
|
-
read_session({ name: "primary" }) // auto: transcript if peer registered, else pane
|
|
83
|
-
read_session({ name: "claude", mode: "transcript", limit: 50 })
|
|
84
|
-
read_session({ name: "
|
|
82
|
+
read_session({ name: "primary" }) // auto: transcript if peer registered, else pane (budgeted: last 20 msgs, ~24KB)
|
|
83
|
+
read_session({ name: "claude", mode: "transcript", limit: 50, max_bytes: 60000 }) // pull more
|
|
84
|
+
read_session({ name: "claude", mode: "transcript", include_timestamps: true }) // keep ISO timestamps
|
|
85
|
+
read_session({ name: "claude", mode: "transcript", tail_scan: true }) // fast tail read on huge transcripts
|
|
86
|
+
read_session({ name: "primary", mode: "pane", pane_lines: 500, pane_max_chars: 40000 })
|
|
85
87
|
read_session({ name: "<peer-uuid>", mode: "transcript" }) // UUID form: needed when peers share a tmux session
|
|
86
88
|
send_message({ target: "primary", body: "<system-reminder>checking in</system-reminder>" })
|
|
87
89
|
send_message({ target: "<peer-uuid>", body: "..." }) // UUID form: same disambiguation
|
|
@@ -94,7 +96,7 @@ Omitting `project_root` triggers a best-effort `.git`-ancestor walk from the ser
|
|
|
94
96
|
|
|
95
97
|
## Peer awareness without raw transcripts
|
|
96
98
|
|
|
97
|
-
The cheapest way to learn what peers are doing is `list_project_sessions`. Each row carries an optional `state` card written by the peer via `set_my_state` — currently `{ purpose, updated_at }`. Reading the card costs almost nothing compared to `read_session`, which spends tokens on
|
|
99
|
+
The cheapest way to learn what peers are doing is `list_project_sessions`. Each row carries an optional `state` card written by the peer via `set_my_state` — currently `{ purpose, updated_at }`. Reading the card costs almost nothing compared to `read_session`, which — even budgeted (last 20 messages / ~24KB by default) — spends real tokens on transcript content. Use `read_session` when the card isn't enough.
|
|
98
100
|
|
|
99
101
|
## Peer messaging (v0.5)
|
|
100
102
|
|
|
@@ -214,7 +216,7 @@ Pane targeting can go stale: `tmux_pane` is cached at server startup, but tmux c
|
|
|
214
216
|
If `ask_peer` returns an abort error before its built-in 45s timeout fires, your MCP client's tool-call ceiling is lower than 45s. Override the bound at server startup:
|
|
215
217
|
|
|
216
218
|
```sh
|
|
217
|
-
OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.
|
|
219
|
+
OXTAIL_ASK_PEER_TIMEOUT_MS=30000 npx -y oxtail@0.10.0
|
|
218
220
|
```
|
|
219
221
|
|
|
220
222
|
The server reads the env var once at boot and uses it as the fixed timeout for all `ask_peer` calls in that session. Values must be positive numbers; anything else falls back to the 45000ms default.
|
package/assets/pretooluse.sh
CHANGED
|
@@ -113,7 +113,7 @@ output=$(awk '
|
|
|
113
113
|
END {
|
|
114
114
|
if (count == 0) exit 0
|
|
115
115
|
ctx = "<system-reminder>\\n[oxtail] You have " count " new peer message(s)."
|
|
116
|
-
ctx = ctx "\\
|
|
116
|
+
ctx = ctx "\\nReply to any that need it via mcp__oxtail__send_message (target = the from_session_id below)."
|
|
117
117
|
for (j = 0; j < count; j++) {
|
|
118
118
|
ctx = ctx "\\n\\n--- message " (j + 1) " ---"
|
|
119
119
|
if (ids[j] != "") ctx = ctx "\\nmessage_id: " ids[j]
|
package/assets/stop.sh
CHANGED
|
@@ -143,7 +143,7 @@ output=$(awk '
|
|
|
143
143
|
END {
|
|
144
144
|
if (count == 0) exit 0
|
|
145
145
|
r = "[oxtail] " count " new peer message(s) arrived as you finished your turn. Read them and respond before stopping."
|
|
146
|
-
r = r "\\
|
|
146
|
+
r = r "\\nReply to any that need it via mcp__oxtail__send_message (target = the from_session_id below)."
|
|
147
147
|
for (j = 0; j < count; j++) {
|
|
148
148
|
r = r "\\n\\n--- message " (j + 1) " ---"
|
|
149
149
|
if (ids[j] != "") r = r "\\nmessage_id: " ids[j]
|
package/dist/server.js
CHANGED
|
@@ -33,6 +33,27 @@ import { recoverClaim, resolveAncestors, writeClaim } from "./claims.js";
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
import { readClaudeTranscript, readCodexTranscript, } from "./transcripts.js";
|
|
36
|
+
// Single builder for every readSession return so the field set (including the
|
|
37
|
+
// truncation flags) is always complete and consistent across the ~9 exit paths.
|
|
38
|
+
// Callers pass only what differs from the defaults.
|
|
39
|
+
function makeReadResult(o) {
|
|
40
|
+
return {
|
|
41
|
+
schema_version: 1,
|
|
42
|
+
session: o.session,
|
|
43
|
+
mode: o.mode ?? "none",
|
|
44
|
+
client_type: o.client_type ?? null,
|
|
45
|
+
messages: o.messages ?? null,
|
|
46
|
+
pane_text: o.pane_text ?? null,
|
|
47
|
+
truncated: o.truncated ?? false,
|
|
48
|
+
count_truncated: o.count_truncated ?? false,
|
|
49
|
+
bytes_truncated: o.bytes_truncated ?? false,
|
|
50
|
+
total_messages: o.total_messages ?? null,
|
|
51
|
+
total_messages_exact: o.total_messages_exact ?? false,
|
|
52
|
+
project_root: o.project_root,
|
|
53
|
+
inferred: o.inferred,
|
|
54
|
+
error: o.error ?? null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
36
57
|
const TMUX_LIST_FORMAT = "#{session_name}|#{session_path}|#{session_created}|#{session_attached}|#{session_windows}";
|
|
37
58
|
const TMUX_PANES_FORMAT = "#{session_name}|#{pane_current_path}";
|
|
38
59
|
function findProjectRoot(start) {
|
|
@@ -182,10 +203,72 @@ export function buildListResult(input) {
|
|
|
182
203
|
const sessions = joinSessionsWithRegistry(matched, readAll());
|
|
183
204
|
return { schema_version: 1, project_root: resolvedRoot, inferred: !explicit, sessions, error };
|
|
184
205
|
}
|
|
206
|
+
// Opt-in compact shape: hoist the tmux fields that are byte-identical across
|
|
207
|
+
// every agent sharing a session (name/path/attached/created_at/windows) into one
|
|
208
|
+
// group, with the per-agent fields nested under `agents`. Kills the per-row
|
|
209
|
+
// duplication that grows with the agent matrix (and the redundant per-row `path`
|
|
210
|
+
// that usually equals project_root). The DEFAULT response keeps the flat
|
|
211
|
+
// `sessions[]` shape — backward compatible; callers ask for this with
|
|
212
|
+
// compact:true. An unclaimed tmux session (no oxtail-aware agent) becomes a group
|
|
213
|
+
// with an empty `agents` array.
|
|
214
|
+
export function toCompactList(r) {
|
|
215
|
+
const groups = new Map();
|
|
216
|
+
const order = [];
|
|
217
|
+
for (const s of r.sessions) {
|
|
218
|
+
let g = groups.get(s.name);
|
|
219
|
+
if (!g) {
|
|
220
|
+
g = {
|
|
221
|
+
name: s.name,
|
|
222
|
+
path: s.path,
|
|
223
|
+
attached: s.attached,
|
|
224
|
+
created_at: s.created_at,
|
|
225
|
+
windows: s.windows,
|
|
226
|
+
agents: [],
|
|
227
|
+
};
|
|
228
|
+
groups.set(s.name, g);
|
|
229
|
+
order.push(s.name);
|
|
230
|
+
}
|
|
231
|
+
// joinSessionsWithRegistry emits a single all-null row for a tmux session
|
|
232
|
+
// with no registry match; don't materialize that as a phantom agent.
|
|
233
|
+
if (s.client_type !== null || s.client_session_id !== null || s.state !== null) {
|
|
234
|
+
g.agents.push({
|
|
235
|
+
client_type: s.client_type,
|
|
236
|
+
client_session_id: s.client_session_id,
|
|
237
|
+
state: s.state,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
schema_version: 1,
|
|
243
|
+
project_root: r.project_root,
|
|
244
|
+
inferred: r.inferred,
|
|
245
|
+
tmux_sessions: order.map((n) => groups.get(n)),
|
|
246
|
+
error: r.error,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
185
249
|
function capturePane(target, lines) {
|
|
186
250
|
const safe = Math.max(20, Math.min(2000, Math.floor(lines)));
|
|
187
251
|
return execFileSync("tmux", ["capture-pane", "-p", "-J", "-t", target, "-S", `-${safe}`, "-E", "-"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
188
252
|
}
|
|
253
|
+
// pane_lines bounds how many ROWS tmux captures, but a single row can be
|
|
254
|
+
// arbitrarily wide, so the joined blob is still unbounded by characters. This
|
|
255
|
+
// caps the returned text and is tail-preserving — the most recent terminal
|
|
256
|
+
// output is at the bottom, which is what a peer-watcher actually wants.
|
|
257
|
+
const DEFAULT_PANE_MAX_CHARS = 20_000;
|
|
258
|
+
const MIN_PANE_MAX_CHARS = 500;
|
|
259
|
+
const MAX_PANE_MAX_CHARS = 200_000;
|
|
260
|
+
export function tailChars(text, maxChars) {
|
|
261
|
+
// Fast path: code-unit length is an upper bound on code-point count, so if it
|
|
262
|
+
// already fits there's nothing to do (and we skip the Array.from allocation).
|
|
263
|
+
if (text.length <= maxChars)
|
|
264
|
+
return { text, truncated: false };
|
|
265
|
+
// Slice by code points so we never split a surrogate pair at the boundary.
|
|
266
|
+
const cps = Array.from(text);
|
|
267
|
+
if (cps.length <= maxChars)
|
|
268
|
+
return { text, truncated: false };
|
|
269
|
+
const tail = cps.slice(cps.length - maxChars).join("");
|
|
270
|
+
return { text: `…[pane truncated to last ${maxChars} chars]\n${tail}`, truncated: true };
|
|
271
|
+
}
|
|
189
272
|
function anyPaneInScope(canonical, resolvedRoot) {
|
|
190
273
|
let raw;
|
|
191
274
|
try {
|
|
@@ -269,40 +352,39 @@ function resolveSessionInScope(name, resolvedRoot) {
|
|
|
269
352
|
}
|
|
270
353
|
function readSession(input) {
|
|
271
354
|
const mode = input.mode ?? "auto";
|
|
272
|
-
const limit = input.limit ?? 100;
|
|
273
355
|
const paneLines = input.pane_lines ?? 240;
|
|
356
|
+
// Mirror the transcript budgets' finite-number hardening: a non-finite
|
|
357
|
+
// pane_max_chars (only reachable via a direct call, never through zod) coerces
|
|
358
|
+
// to the default rather than producing a NaN cap. Per Codex Phase-C note.
|
|
359
|
+
const paneMaxChars = Math.max(MIN_PANE_MAX_CHARS, Math.min(MAX_PANE_MAX_CHARS, Math.floor(Number.isFinite(input.pane_max_chars)
|
|
360
|
+
? input.pane_max_chars
|
|
361
|
+
: DEFAULT_PANE_MAX_CHARS)));
|
|
274
362
|
const explicit = typeof input.project_root === "string" && input.project_root.length > 0;
|
|
275
363
|
const resolvedRoot = safeRealpath(explicit ? input.project_root : inferProjectRoot(process.cwd()));
|
|
364
|
+
// The reader applies its own conservative defaults (DEFAULT_LIMIT /
|
|
365
|
+
// DEFAULT_MAX_BYTES) and clamps; we just forward whatever the caller set.
|
|
366
|
+
const readerOpts = {
|
|
367
|
+
limit: input.limit,
|
|
368
|
+
maxBytes: input.max_bytes,
|
|
369
|
+
includeTimestamps: input.include_timestamps,
|
|
370
|
+
tailScan: input.tail_scan,
|
|
371
|
+
};
|
|
276
372
|
const scope = resolveSessionInScope(input.name, resolvedRoot);
|
|
277
373
|
if (scope.ambiguousCandidates) {
|
|
278
|
-
return {
|
|
279
|
-
schema_version: 1,
|
|
374
|
+
return makeReadResult({
|
|
280
375
|
session: input.name,
|
|
281
|
-
mode: "none",
|
|
282
|
-
client_type: null,
|
|
283
|
-
messages: null,
|
|
284
|
-
pane_text: null,
|
|
285
|
-
truncated: false,
|
|
286
|
-
total_messages: null,
|
|
287
376
|
project_root: resolvedRoot,
|
|
288
377
|
inferred: !explicit,
|
|
289
378
|
error: `ambiguous-target: multiple agents share tmux session '${input.name}'; pass a client_session_id (UUID) instead. candidates: ${scope.ambiguousCandidates.join(", ")}`,
|
|
290
|
-
};
|
|
379
|
+
});
|
|
291
380
|
}
|
|
292
381
|
if (!scope.inScope) {
|
|
293
|
-
return {
|
|
294
|
-
schema_version: 1,
|
|
382
|
+
return makeReadResult({
|
|
295
383
|
session: input.name,
|
|
296
|
-
mode: "none",
|
|
297
|
-
client_type: null,
|
|
298
|
-
messages: null,
|
|
299
|
-
pane_text: null,
|
|
300
|
-
truncated: false,
|
|
301
|
-
total_messages: null,
|
|
302
384
|
project_root: resolvedRoot,
|
|
303
385
|
inferred: !explicit,
|
|
304
386
|
error: `session '${input.name}' not in project scope`,
|
|
305
|
-
};
|
|
387
|
+
});
|
|
306
388
|
}
|
|
307
389
|
const canonical = scope.canonicalName;
|
|
308
390
|
const reg = scope.registryEntry;
|
|
@@ -316,107 +398,81 @@ function readSession(input) {
|
|
|
316
398
|
// (an in-scope, transcript-capable, tmux-less peer) was wrongly rejected as
|
|
317
399
|
// "not in project scope".
|
|
318
400
|
if (!canonical && !transcriptPath) {
|
|
319
|
-
return {
|
|
320
|
-
schema_version: 1,
|
|
401
|
+
return makeReadResult({
|
|
321
402
|
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
403
|
project_root: resolvedRoot,
|
|
329
404
|
inferred: !explicit,
|
|
405
|
+
client_type: clientType,
|
|
330
406
|
error: `session '${input.name}' is in scope but has no transcript and no tmux session to read`,
|
|
331
|
-
};
|
|
407
|
+
});
|
|
332
408
|
}
|
|
333
409
|
const wantTranscript = mode === "transcript" || (mode === "auto" && transcriptPath);
|
|
334
410
|
if (wantTranscript) {
|
|
335
411
|
if (!transcriptPath) {
|
|
336
412
|
if (mode === "transcript") {
|
|
337
|
-
return {
|
|
338
|
-
schema_version: 1,
|
|
413
|
+
return makeReadResult({
|
|
339
414
|
session: canonical ?? input.name,
|
|
340
|
-
mode: "none",
|
|
341
|
-
client_type: clientType,
|
|
342
|
-
messages: null,
|
|
343
|
-
pane_text: null,
|
|
344
|
-
truncated: false,
|
|
345
|
-
total_messages: null,
|
|
346
415
|
project_root: resolvedRoot,
|
|
347
416
|
inferred: !explicit,
|
|
417
|
+
client_type: clientType,
|
|
348
418
|
error: "no registry entry with transcript path; agent may not be oxtail-aware",
|
|
349
|
-
};
|
|
419
|
+
});
|
|
350
420
|
}
|
|
351
421
|
// fall through to pane
|
|
352
422
|
}
|
|
353
423
|
else {
|
|
354
424
|
const reader = clientType === "codex" ? readCodexTranscript : readClaudeTranscript;
|
|
355
|
-
const result = reader(transcriptPath,
|
|
356
|
-
return {
|
|
357
|
-
schema_version: 1,
|
|
425
|
+
const result = reader(transcriptPath, readerOpts);
|
|
426
|
+
return makeReadResult({
|
|
358
427
|
session: canonical ?? input.name,
|
|
428
|
+
project_root: resolvedRoot,
|
|
429
|
+
inferred: !explicit,
|
|
359
430
|
mode: "transcript",
|
|
360
431
|
client_type: clientType,
|
|
361
432
|
messages: result.messages,
|
|
362
|
-
pane_text: null,
|
|
363
433
|
truncated: result.truncated,
|
|
434
|
+
count_truncated: result.count_truncated,
|
|
435
|
+
bytes_truncated: result.bytes_truncated,
|
|
364
436
|
total_messages: result.total_messages,
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
error: null,
|
|
368
|
-
};
|
|
437
|
+
total_messages_exact: result.total_messages_exact,
|
|
438
|
+
});
|
|
369
439
|
}
|
|
370
440
|
}
|
|
371
441
|
// Pane fallback needs a tmux session to capture from. Reachable only when a
|
|
372
442
|
// caller forces mode:"pane" on a transcript-only peer (no tmux binding).
|
|
373
443
|
if (!canonical) {
|
|
374
|
-
return {
|
|
375
|
-
schema_version: 1,
|
|
444
|
+
return makeReadResult({
|
|
376
445
|
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
446
|
project_root: resolvedRoot,
|
|
384
447
|
inferred: !explicit,
|
|
448
|
+
client_type: clientType,
|
|
385
449
|
error: `session '${input.name}' has no tmux pane to capture (transcript-only peer)`,
|
|
386
|
-
};
|
|
450
|
+
});
|
|
387
451
|
}
|
|
388
452
|
try {
|
|
389
|
-
const
|
|
390
|
-
return {
|
|
391
|
-
schema_version: 1,
|
|
453
|
+
const captured = tailChars(capturePane(canonical, paneLines), paneMaxChars);
|
|
454
|
+
return makeReadResult({
|
|
392
455
|
session: canonical,
|
|
393
|
-
mode: "pane",
|
|
394
|
-
client_type: clientType,
|
|
395
|
-
messages: null,
|
|
396
|
-
pane_text: text,
|
|
397
|
-
truncated: false,
|
|
398
|
-
total_messages: null,
|
|
399
456
|
project_root: resolvedRoot,
|
|
400
457
|
inferred: !explicit,
|
|
401
|
-
|
|
402
|
-
|
|
458
|
+
mode: "pane",
|
|
459
|
+
client_type: clientType,
|
|
460
|
+
pane_text: captured.text,
|
|
461
|
+
// Pane mode has no message-count/byte-budget split; `truncated` is the
|
|
462
|
+
// catch-all signal that the char cap shortened the captured text.
|
|
463
|
+
truncated: captured.truncated,
|
|
464
|
+
});
|
|
403
465
|
}
|
|
404
466
|
catch (err) {
|
|
405
467
|
const e = err;
|
|
406
468
|
const stderr = e.stderr ? e.stderr.toString() : "";
|
|
407
|
-
return {
|
|
408
|
-
schema_version: 1,
|
|
469
|
+
return makeReadResult({
|
|
409
470
|
session: canonical,
|
|
410
|
-
mode: "none",
|
|
411
|
-
client_type: clientType,
|
|
412
|
-
messages: null,
|
|
413
|
-
pane_text: null,
|
|
414
|
-
truncated: false,
|
|
415
|
-
total_messages: null,
|
|
416
471
|
project_root: resolvedRoot,
|
|
417
472
|
inferred: !explicit,
|
|
473
|
+
client_type: clientType,
|
|
418
474
|
error: stderr.trim() || e.message || "pane capture failed",
|
|
419
|
-
};
|
|
475
|
+
});
|
|
420
476
|
}
|
|
421
477
|
}
|
|
422
478
|
const client = detectClient();
|
|
@@ -442,6 +498,18 @@ process.on("SIGTERM", () => {
|
|
|
442
498
|
});
|
|
443
499
|
const pkgVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
444
500
|
const server = new McpServer({ name: "oxtail", version: pkgVersion });
|
|
501
|
+
// All MCP tool responses are JSON-encoded text that lands directly in a peer
|
|
502
|
+
// agent's context window. They are minified, never pretty-printed: indentation
|
|
503
|
+
// is pure whitespace cost that recurs on every call for the life of a session,
|
|
504
|
+
// and every consumer (tests, hooks) parses structurally — none depend on the
|
|
505
|
+
// indented form. On-disk registry/claim writes stay pretty (human-debuggable
|
|
506
|
+
// artifacts, not agent context). Single source of truth for response encoding.
|
|
507
|
+
// `payload` is constrained to object/array (never a bare primitive) so the
|
|
508
|
+
// encoder can't silently yield a non-string — JSON.stringify(undefined) returns
|
|
509
|
+
// undefined, which would violate the text-content contract. Per Codex review.
|
|
510
|
+
function jsonResult(payload) {
|
|
511
|
+
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
512
|
+
}
|
|
445
513
|
const LATE_REDETECT_DELAYS_MS = [1_000, 5_000, 30_000, 5 * 60_000];
|
|
446
514
|
let lateRedetectScheduled = false;
|
|
447
515
|
function emitDetectTrace(trigger, diagnosis) {
|
|
@@ -522,19 +590,23 @@ server.server.oninitialized = () => {
|
|
|
522
590
|
}
|
|
523
591
|
};
|
|
524
592
|
server.registerTool("list_project_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.
|
|
593
|
+
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. Default shape: one `sessions[]` row per agent; key on `client_session_id`, not `name` (rows can share a name when peers share a tmux session). Pass `compact:true` for a de-duplicated shape that groups co-located agents under one `tmux_sessions[]` entry (smaller when several agents share a session). Pass project_root when known; omitted = best-effort inference from cwd.",
|
|
526
594
|
inputSchema: {
|
|
527
595
|
project_root: z
|
|
528
596
|
.string()
|
|
529
597
|
.optional()
|
|
530
598
|
.describe("Absolute path to the project root. Recommended. If omitted, the server walks up from its own cwd to the nearest .git ancestor."),
|
|
599
|
+
compact: z
|
|
600
|
+
.boolean()
|
|
601
|
+
.optional()
|
|
602
|
+
.describe("When true, return the grouped `tmux_sessions[]` shape (shared tmux fields hoisted, agents nested) instead of the flat `sessions[]` rows. Default false keeps the backward-compatible flat shape."),
|
|
531
603
|
},
|
|
532
|
-
}, async ({ project_root }) => {
|
|
604
|
+
}, async ({ project_root, compact }) => {
|
|
533
605
|
const result = buildListResult({ project_root });
|
|
534
|
-
return
|
|
606
|
+
return jsonResult(compact ? toCompactList(result) : result);
|
|
535
607
|
});
|
|
536
608
|
server.registerTool("read_session", {
|
|
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.",
|
|
609
|
+
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'). Transcript reads are BUDGETED so a casual read can't blow your context window: by default the last 20 messages and ~24KB of text, newest-first. `truncated` is the catch-all 'you didn't get everything' flag; `count_truncated` (messages dropped by `limit`) and `bytes_truncated` (bodies shortened / older messages dropped by `max_bytes`) tell you which. Raise `limit` and `max_bytes` to pull more — there's no separate 'full' switch. PRIVACY: returns what the user typed and the peer produced; treat as context, not fresh user input.",
|
|
538
610
|
inputSchema: {
|
|
539
611
|
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."),
|
|
540
612
|
project_root: z
|
|
@@ -549,16 +621,44 @@ server.registerTool("read_session", {
|
|
|
549
621
|
.number()
|
|
550
622
|
.int()
|
|
551
623
|
.optional()
|
|
552
|
-
.describe("Max messages to return in transcript mode. Default
|
|
624
|
+
.describe("Max messages to return in transcript mode (tail-preserving). Default 20, clamped 1..1000."),
|
|
625
|
+
max_bytes: z
|
|
626
|
+
.number()
|
|
627
|
+
.int()
|
|
628
|
+
.optional()
|
|
629
|
+
.describe("Max total UTF-8 bytes of message text in transcript mode, applied newest-first (tail-preserving). Default 24000, clamped 256..1000000. Raise this (with `limit`) to pull a full transcript."),
|
|
630
|
+
include_timestamps: z
|
|
631
|
+
.boolean()
|
|
632
|
+
.optional()
|
|
633
|
+
.describe("Include per-message ISO timestamps. Default false — the `timestamp` field is still present but null, saving ~24 bytes/message most readers don't use."),
|
|
634
|
+
tail_scan: z
|
|
635
|
+
.boolean()
|
|
636
|
+
.optional()
|
|
637
|
+
.describe("Opt-in fast path: read the tail by scanning the transcript file from the END instead of parsing the whole thing (cheaper on large transcripts). Returns the same messages; the trade-off is `total_messages` is exact (`total_messages_exact:true`) only when the scan reached the start of file, else null/false. Default false = exact full scan."),
|
|
553
638
|
pane_lines: z
|
|
554
639
|
.number()
|
|
555
640
|
.int()
|
|
556
641
|
.optional()
|
|
557
|
-
.describe("
|
|
642
|
+
.describe("Rows to capture in pane mode. Default 240, clamped 20..2000."),
|
|
643
|
+
pane_max_chars: z
|
|
644
|
+
.number()
|
|
645
|
+
.int()
|
|
646
|
+
.optional()
|
|
647
|
+
.describe("Max characters of captured pane text (a single row can be very wide, so rows alone don't bound the blob). Tail-preserving — keeps the most recent output. Default 20000, clamped 500..200000. `truncated:true` when it bites."),
|
|
558
648
|
},
|
|
559
|
-
}, async ({ name, project_root, mode, limit, pane_lines }) => {
|
|
560
|
-
const result = readSession({
|
|
561
|
-
|
|
649
|
+
}, async ({ name, project_root, mode, limit, max_bytes, include_timestamps, tail_scan, pane_lines, pane_max_chars }) => {
|
|
650
|
+
const result = readSession({
|
|
651
|
+
name,
|
|
652
|
+
project_root,
|
|
653
|
+
mode,
|
|
654
|
+
limit,
|
|
655
|
+
max_bytes,
|
|
656
|
+
include_timestamps,
|
|
657
|
+
tail_scan,
|
|
658
|
+
pane_lines,
|
|
659
|
+
pane_max_chars,
|
|
660
|
+
});
|
|
661
|
+
return jsonResult(result);
|
|
562
662
|
});
|
|
563
663
|
// Pin a session_id onto our own registry entry and persist it. Shared by
|
|
564
664
|
// register_my_session (full entry dump in response) and claim_session (compact
|
|
@@ -651,23 +751,16 @@ server.registerTool("register_my_session", {
|
|
|
651
751
|
},
|
|
652
752
|
}, async ({ session_id }) => {
|
|
653
753
|
pinSessionId(session_id);
|
|
654
|
-
return {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
tmux_session: entry.tmux_session,
|
|
665
|
-
client: entry.client,
|
|
666
|
-
},
|
|
667
|
-
}, null, 2),
|
|
668
|
-
},
|
|
669
|
-
],
|
|
670
|
-
};
|
|
754
|
+
return jsonResult({
|
|
755
|
+
schema_version: 1,
|
|
756
|
+
ok: true,
|
|
757
|
+
entry: {
|
|
758
|
+
server_pid: entry.server_pid,
|
|
759
|
+
started_at: entry.started_at,
|
|
760
|
+
tmux_session: entry.tmux_session,
|
|
761
|
+
client: entry.client,
|
|
762
|
+
},
|
|
763
|
+
});
|
|
671
764
|
});
|
|
672
765
|
server.registerTool("claim_session", {
|
|
673
766
|
description: "Single-shot replacement for register_my_session + get_my_session. Pins the session_id and returns the compact verification: { ok, session_id, transcript_path }. Use this in slash commands and skills; the routine ceremony is `Bash echo $CLAUDE_CODE_SESSION_ID` (or `$CODEX_THREAD_ID`) → claim_session. Saves a round-trip and avoids dumping the full entry into the agent's context.",
|
|
@@ -679,19 +772,12 @@ server.registerTool("claim_session", {
|
|
|
679
772
|
},
|
|
680
773
|
}, async ({ session_id }) => {
|
|
681
774
|
pinSessionId(session_id);
|
|
682
|
-
return {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
ok: true,
|
|
689
|
-
session_id: entry.client.session_id,
|
|
690
|
-
transcript_path: entry.client.transcript_path,
|
|
691
|
-
}, null, 2),
|
|
692
|
-
},
|
|
693
|
-
],
|
|
694
|
-
};
|
|
775
|
+
return jsonResult({
|
|
776
|
+
schema_version: 1,
|
|
777
|
+
ok: true,
|
|
778
|
+
session_id: entry.client.session_id,
|
|
779
|
+
transcript_path: entry.client.transcript_path,
|
|
780
|
+
});
|
|
695
781
|
});
|
|
696
782
|
server.registerTool("get_my_session", {
|
|
697
783
|
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.",
|
|
@@ -731,25 +817,18 @@ server.registerTool("get_my_session", {
|
|
|
731
817
|
}
|
|
732
818
|
diagnosis = live ?? { per_strategy: {}, winning: null, next_step: null };
|
|
733
819
|
}
|
|
734
|
-
return {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
state: entry.state,
|
|
747
|
-
},
|
|
748
|
-
detect_diagnosis: diagnosis,
|
|
749
|
-
}, null, 2),
|
|
750
|
-
},
|
|
751
|
-
],
|
|
752
|
-
};
|
|
820
|
+
return jsonResult({
|
|
821
|
+
schema_version: 1,
|
|
822
|
+
entry: {
|
|
823
|
+
server_pid: entry.server_pid,
|
|
824
|
+
started_at: entry.started_at,
|
|
825
|
+
tmux_pane: entry.tmux_pane,
|
|
826
|
+
tmux_session: entry.tmux_session,
|
|
827
|
+
client: entry.client,
|
|
828
|
+
state: entry.state,
|
|
829
|
+
},
|
|
830
|
+
detect_diagnosis: diagnosis,
|
|
831
|
+
});
|
|
753
832
|
});
|
|
754
833
|
server.registerTool("set_my_state", {
|
|
755
834
|
description: "Write a small state card onto this MCP server's registry entry so peers can see what we're doing without reading our transcript. Currently surfaces a single field, `purpose` (≤200 chars) — a one-sentence \"what is this agent working on right now\" line. Other fields will be added if real friction surfaces. State is visible in `list_project_sessions` rows. Calling with no fields is a touch: bumps `updated_at` without changing content.",
|
|
@@ -767,14 +846,7 @@ server.registerTool("set_my_state", {
|
|
|
767
846
|
};
|
|
768
847
|
entry.state = next;
|
|
769
848
|
register(entry);
|
|
770
|
-
return {
|
|
771
|
-
content: [
|
|
772
|
-
{
|
|
773
|
-
type: "text",
|
|
774
|
-
text: JSON.stringify({ schema_version: 1, ok: true, state: next }, null, 2),
|
|
775
|
-
},
|
|
776
|
-
],
|
|
777
|
-
};
|
|
849
|
+
return jsonResult({ schema_version: 1, ok: true, state: next });
|
|
778
850
|
});
|
|
779
851
|
function projectRootsMatch(caller, peer) {
|
|
780
852
|
const callerProject = findProjectRoot(caller.client.cwd);
|
|
@@ -882,54 +954,33 @@ server.registerTool("send_message", {
|
|
|
882
954
|
}, async ({ target, body, wake }) => {
|
|
883
955
|
const resolved = resolveTarget(target, entry);
|
|
884
956
|
if (!resolved.ok) {
|
|
885
|
-
return {
|
|
886
|
-
content: [
|
|
887
|
-
{
|
|
888
|
-
type: "text",
|
|
889
|
-
text: JSON.stringify({ schema_version: 1, ...resolved }, null, 2),
|
|
890
|
-
},
|
|
891
|
-
],
|
|
892
|
-
};
|
|
957
|
+
return jsonResult({ schema_version: 1, ...resolved });
|
|
893
958
|
}
|
|
894
959
|
const peer = resolved.entry;
|
|
895
960
|
const fromSessionId = entry.client.session_id ?? undefined;
|
|
896
961
|
const msg = mailbox.enqueue(peer.server_pid, body, fromSessionId);
|
|
897
962
|
const wake_status = wake === "auto" ? await wakeForSend(peer) : undefined;
|
|
898
|
-
return {
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
target_session_id: peer.client.session_id,
|
|
907
|
-
target_server_pid: peer.server_pid,
|
|
908
|
-
...(wake_status ? { wake_status } : {}),
|
|
909
|
-
}, null, 2),
|
|
910
|
-
},
|
|
911
|
-
],
|
|
912
|
-
};
|
|
963
|
+
return jsonResult({
|
|
964
|
+
schema_version: 1,
|
|
965
|
+
ok: true,
|
|
966
|
+
message_id: msg.id,
|
|
967
|
+
target_session_id: peer.client.session_id,
|
|
968
|
+
target_server_pid: peer.server_pid,
|
|
969
|
+
...(wake_status ? { wake_status } : {}),
|
|
970
|
+
});
|
|
913
971
|
});
|
|
914
972
|
server.registerTool("read_my_messages", {
|
|
915
973
|
description: "Drain this session's mailbox and return any messages peers have sent via send_message. Codex peers and any Claude Code peer without the PreToolUse hook installed must poll this tool explicitly; Claude Code peers with the hook installed will see messages mid-turn instead. Always safe to call — returns an empty list when the mailbox is empty.",
|
|
916
974
|
inputSchema: {},
|
|
917
975
|
}, async () => {
|
|
918
976
|
const messages = mailbox.drain(entry.server_pid);
|
|
919
|
-
return {
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
drained: true,
|
|
927
|
-
count: messages.length,
|
|
928
|
-
messages,
|
|
929
|
-
}, null, 2),
|
|
930
|
-
},
|
|
931
|
-
],
|
|
932
|
-
};
|
|
977
|
+
return jsonResult({
|
|
978
|
+
schema_version: 1,
|
|
979
|
+
ok: true,
|
|
980
|
+
drained: true,
|
|
981
|
+
count: messages.length,
|
|
982
|
+
messages,
|
|
983
|
+
});
|
|
933
984
|
});
|
|
934
985
|
// ask_peer (v0.6): blocking send + wait-for-reply. Builds on send_message's
|
|
935
986
|
// async mailbox transport by holding the request open server-side until the
|
|
@@ -947,7 +998,12 @@ const ASK_PEER_TIMEOUT_MS = (() => {
|
|
|
947
998
|
})();
|
|
948
999
|
const ASK_PEER_GRACE_MS = 500;
|
|
949
1000
|
const ASK_PEER_POLL_MS = 200;
|
|
950
|
-
|
|
1001
|
+
// Typed into the peer's TUI as a synthetic prompt, so it lands in their context
|
|
1002
|
+
// once per wake — kept terse. For HOOKED Claude Code the delivered envelope
|
|
1003
|
+
// carries the full reply instruction, but Codex and hookless Claude peers only
|
|
1004
|
+
// get raw mailbox JSON from read_my_messages — so the wake itself must preserve
|
|
1005
|
+
// the reply path (read → reply via send_message). Per Codex Phase-D review.
|
|
1006
|
+
export const ASK_PEER_WAKE_TEXT = "[oxtail] peer msg — read_my_messages; reply via mcp__oxtail__send_message if asked";
|
|
951
1007
|
// Codex's TUI has a paste-burst heuristic at codex-rs/tui/src/bottom_pane/
|
|
952
1008
|
// paste_burst.rs (PASTE_BURST_MIN_CHARS=3, PASTE_BURST_CHAR_INTERVAL=8ms,
|
|
953
1009
|
// PASTE_ENTER_SUPPRESS_WINDOW=120ms). When `tmux send-keys` blasts the
|
|
@@ -1203,31 +1259,17 @@ server.registerTool("ask_peer", {
|
|
|
1203
1259
|
}, async ({ target, body }, extra) => {
|
|
1204
1260
|
const resolved = resolveTarget(target, entry);
|
|
1205
1261
|
if (!resolved.ok) {
|
|
1206
|
-
return {
|
|
1207
|
-
content: [
|
|
1208
|
-
{
|
|
1209
|
-
type: "text",
|
|
1210
|
-
text: JSON.stringify({ schema_version: 1, ...resolved }, null, 2),
|
|
1211
|
-
},
|
|
1212
|
-
],
|
|
1213
|
-
};
|
|
1262
|
+
return jsonResult({ schema_version: 1, ...resolved });
|
|
1214
1263
|
}
|
|
1215
1264
|
const peer = resolved.entry;
|
|
1216
1265
|
const expectedSessionId = peer.client.session_id;
|
|
1217
1266
|
if (!expectedSessionId) {
|
|
1218
|
-
return {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
ok: false,
|
|
1225
|
-
error: "peer-has-no-session-id",
|
|
1226
|
-
message: "Target peer has no registered client.session_id. Ask the peer to call register_my_session before retrying ask_peer.",
|
|
1227
|
-
}, null, 2),
|
|
1228
|
-
},
|
|
1229
|
-
],
|
|
1230
|
-
};
|
|
1267
|
+
return jsonResult({
|
|
1268
|
+
schema_version: 1,
|
|
1269
|
+
ok: false,
|
|
1270
|
+
error: "peer-has-no-session-id",
|
|
1271
|
+
message: "Target peer has no registered client.session_id. Ask the peer to call register_my_session before retrying ask_peer.",
|
|
1272
|
+
});
|
|
1231
1273
|
}
|
|
1232
1274
|
// Stale-reply guard: evict any pre-existing messages from the target out
|
|
1233
1275
|
// of our own mailbox before sending. By definition, anything already
|
|
@@ -1322,28 +1364,21 @@ server.registerTool("ask_peer", {
|
|
|
1322
1364
|
wake_status: wakeStatus,
|
|
1323
1365
|
timed_out: timedOut,
|
|
1324
1366
|
});
|
|
1325
|
-
return {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
}
|
|
1341
|
-
: null,
|
|
1342
|
-
timed_out: timedOut,
|
|
1343
|
-
}, null, 2),
|
|
1344
|
-
},
|
|
1345
|
-
],
|
|
1346
|
-
};
|
|
1367
|
+
return jsonResult({
|
|
1368
|
+
schema_version: 1,
|
|
1369
|
+
ok: true,
|
|
1370
|
+
message_id: msg.id,
|
|
1371
|
+
wake_status: wakeStatus,
|
|
1372
|
+
reply: reply
|
|
1373
|
+
? {
|
|
1374
|
+
id: reply.id,
|
|
1375
|
+
body: reply.body,
|
|
1376
|
+
enqueued_at: reply.enqueued_at,
|
|
1377
|
+
from_session_id: reply.from_session_id ?? null,
|
|
1378
|
+
}
|
|
1379
|
+
: null,
|
|
1380
|
+
timed_out: timedOut,
|
|
1381
|
+
});
|
|
1347
1382
|
});
|
|
1348
1383
|
// Hook-install hint, emitted once per server startup when no `_oxtailHook`
|
|
1349
1384
|
// marker is present in ~/.claude/settings.json. Stderr surfacing in Claude
|
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
|
}
|