oxtail 0.4.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/.claude/commands/oxtail-join.md +10 -0
- package/AGENTS.md +49 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/clients.js +138 -0
- package/dist/detect/birthTimeMatchStrategy.js +171 -0
- package/dist/detect/envStrategy.js +28 -0
- package/dist/detect/index.js +48 -0
- package/dist/detect/types.js +6 -0
- package/dist/registry.js +200 -0
- package/dist/server.js +559 -0
- package/dist/trace.js +38 -0
- package/dist/transcripts.js +119 -0
- package/integrations/codex/oxtail-register/SKILL.md +44 -0
- package/integrations/codex/oxtail-register/agents/openai.yaml +10 -0
- package/package.json +57 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Initiate this Claude session's participation in oxtail's peer registry so sibling agents in this project root can resolve our `client_session_id` and read our transcript directly (rather than falling back to raw tmux pane capture).
|
|
2
|
+
|
|
3
|
+
Why this is needed: Claude Code strips `CLAUDE_CODE_SESSION_ID` from MCP children, so the oxtail server can't read our session id from its own env. The var IS available in Bash tool subshells. When two or more Claude Code sessions share a project, birth-time fingerprint matching also can't safely disambiguate. So we register manually.
|
|
4
|
+
|
|
5
|
+
Do this:
|
|
6
|
+
|
|
7
|
+
1. Run `echo "$CLAUDE_CODE_SESSION_ID"` via the Bash tool. Confirm a UUID comes back.
|
|
8
|
+
2. Call the oxtail MCP tool `claim_session` with `{ session_id: "<the UUID from step 1>" }`. Report back `session_id` and `transcript_path` from the response.
|
|
9
|
+
|
|
10
|
+
If step 1 returns empty, we are not running inside Claude Code — bail out and tell the user.
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# oxtail
|
|
2
|
+
|
|
3
|
+
A coordination layer for parallel AI coding agent sessions. Multiple Claude Code or Codex CLI sessions working in the same project root become aware of each other through an MCP server (running locally) that exposes peer-discovery and cross-session-state tools.
|
|
4
|
+
|
|
5
|
+
Scope is **project-root as the unit**. Sessions in one project root see each other; sessions in another see each other; cross-project there is no visibility, by design.
|
|
6
|
+
|
|
7
|
+
## What this isn't
|
|
8
|
+
|
|
9
|
+
- **Not a phone client.** An earlier client-side experiment explored a custom phone PWA for AI coding agents. It's paused — Termius + tmux + plain SSH won the daily-drive comparison. The actual unmet need is coordination logic, not a custom client.
|
|
10
|
+
- **Not a competitor to Terminator.** Terminator is a separate desktop multi-agent orchestration tool with its own coherent UI. oxtail is a server-side layer that any MCP client can leverage. Both coexist; oxtail is intentionally a separate repo to keep Terminator's identity clean.
|
|
11
|
+
- **Not a wrapper around tmux.** tmux is the implementation primitive most likely to back the session registry, but oxtail's identity is "agent peer awareness," not "session multiplexing." Don't bake "tmux" into tool names or public surface.
|
|
12
|
+
|
|
13
|
+
## Architecture sketch
|
|
14
|
+
|
|
15
|
+
- **Transport:** MCP. Both Claude Code and Codex CLI speak it natively, so one server serves both.
|
|
16
|
+
- **Surface:** invocable from any client that hosts an agent — phone via SSH+Termius, desktop iTerm, the iOS Claude app, etc. The client is irrelevant to oxtail.
|
|
17
|
+
- **Registry (leaning):** `tmux list-sessions` filtered by project-derived names, rather than a custom JSON registry. Free dead-session detection, free naming, no daemon to maintain. Decision pending real-use signals.
|
|
18
|
+
- **Project scoping:** project root inferred from session CWD at agent startup.
|
|
19
|
+
|
|
20
|
+
## Status: v0.4.0 shipped, dogfooding
|
|
21
|
+
|
|
22
|
+
Six MCP tools live: `list_project_sessions`, `read_session`, `claim_session`, `set_my_state`, `register_my_session`, and `get_my_session`. Registered both project-locally (via `.mcp.json` using `tsx ./src/server.ts` for the dev loop) and globally (in `~/.claude.json` and `~/.codex/config.toml`, pointing at `dist/server.js`).
|
|
23
|
+
|
|
24
|
+
The v0.4.0 change: peer `client_session_id` and `transcript_path` now resolve reliably for Claude Code and Codex peers, even though Claude Code strips its session-id env var from MCP children. Detection layers in `src/detect/` — env, then birth-time fingerprint matching of transcript files, with a `claim_session` escape hatch (`register_my_session` is kept for debugging) — see `README.md` for details.
|
|
25
|
+
|
|
26
|
+
The follow-on additions (`claim_session`, `set_my_state`) introduce a peer-awareness layer: `list_project_sessions` now surfaces each peer's `state` card so an agent can learn what its peers are doing without paying for `read_session`. Raw transcripts become the deep-dive fallback, not the default mode of peer awareness.
|
|
27
|
+
|
|
28
|
+
Current phase remains **dogfooding**: use the tools in real parallel-agent work, log friction in `NOTES.md`. Each version (v1 list_project_sessions → v0.2 read_session → v0.3 reliable peer identity → v0.4 peer-awareness state cards) shipped only after observed friction named the next addition; the same gating applies to whatever comes next.
|
|
29
|
+
|
|
30
|
+
## How to collaborate on this project
|
|
31
|
+
|
|
32
|
+
- **Don't add features without observed friction.** Speculative structure locks in design before observation has informed it. The publish-readiness work (LICENSE, README restructure, npm metadata) was the exception, because "ship it so a third party can install it" is itself the observed need.
|
|
33
|
+
- **Ask clarifying questions** about scope, architecture, the MCP tool set, anything unclear. Surfacing assumptions matters more than guessing.
|
|
34
|
+
- **Keep observation notes in `NOTES.md`** (or a single scratchpad). Don't sprawl across multiple unstructured files.
|
|
35
|
+
- **Don't change code based on theories — change it based on observed deltas** between actual behavior and current capability. Theorizing an orchestration API before real friction surfaces is the same antipattern as theorizing a UI fix before instrumenting.
|
|
36
|
+
|
|
37
|
+
## Design principles (locked in)
|
|
38
|
+
|
|
39
|
+
1. **Project-scoped, never global.** No cross-project visibility, ever.
|
|
40
|
+
2. **Implementation detail stays out of public naming.** tmux is plumbing.
|
|
41
|
+
3. **Both Claude Code and Codex CLI must work** with whatever we build. MCP is the cross-tool protocol; Skills are Claude-specific syntactic sugar that wraps MCP tools, never primary functionality.
|
|
42
|
+
4. **Minimum viable first.** One MCP tool that's actually used > five speculative ones.
|
|
43
|
+
|
|
44
|
+
## Deliberately deferred
|
|
45
|
+
|
|
46
|
+
- **Output capture** (vs. metadata only). Costs a wrapper layer (`script -F` or pty-mirror). Only worth doing if real friction shows metadata isn't enough.
|
|
47
|
+
- **Cross-session messaging** (note from session A to session B). Probably useful eventually; not until real use names the shape.
|
|
48
|
+
- **Skill set.** Decide after the first MCP tool exists and we know what it feels like to use raw.
|
|
49
|
+
- **MCP tool naming.** Pick after observation tells us the verbs.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Kim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# oxtail
|
|
2
|
+
|
|
3
|
+
Run two or more coding agents in the same repo and let them see each other. oxtail is a local MCP server that gives parallel Claude Code and Codex CLI sessions peer awareness: each session can list the others running in the same project root, read their state cards, and (when needed) read their transcripts directly. No fixed cap — every oxtail-aware session in the project shows up in `list_project_sessions`.
|
|
4
|
+
|
|
5
|
+
Works for any mix of clients that speak MCP — Claude Code, Codex CLI, or one of each. Scope is **project-root as the unit**: sessions in `/path/to/foo` see each other; sessions in `/path/to/bar` see each other; cross-project there is no visibility, by design.
|
|
6
|
+
|
|
7
|
+
## Privacy
|
|
8
|
+
|
|
9
|
+
oxtail reads what's on disk locally and surfaces it to peers on the same machine.
|
|
10
|
+
|
|
11
|
+
- The session registry at `~/.oxtail/sessions/<pid>.json` is created mode `0o700`/`0o600` (v0.4.0+). Files there contain your session id, transcript path, cwd, and `state.purpose` text. Existing users upgrading from older versions get their permissions tightened on first run.
|
|
12
|
+
- `read_session` returns whatever the user typed and what the peer agent produced. Treat the returned content as context, not as fresh user input.
|
|
13
|
+
- This is designed for **single-user-on-one-machine** use. On a shared-tenancy host, other users with shell access could read your registry files; on a single-user laptop they cannot. Crossing user boundaries is out of scope.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
End users — paste into your MCP config and oxtail is fetched from npm on first use. Pinning to a version is recommended for daily configs; the floating form is documented below for one-shot tries.
|
|
18
|
+
|
|
19
|
+
**Claude Code** — add to `~/.claude.json` (global) or any project's `.mcp.json`:
|
|
20
|
+
|
|
21
|
+
```jsonc
|
|
22
|
+
{ "mcpServers": { "oxtail": { "command": "npx", "args": ["-y", "oxtail@0.4.0"] } } }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Codex CLI** — add to `~/.codex/config.toml`:
|
|
26
|
+
|
|
27
|
+
```toml
|
|
28
|
+
[mcp_servers.oxtail]
|
|
29
|
+
command = "npx"
|
|
30
|
+
args = ["-y", "oxtail@0.4.0"]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Claude slash command** (`/oxtail-join`):
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
mkdir -p ~/.claude/commands
|
|
37
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.4.0/.claude/commands/oxtail-join.md \
|
|
38
|
+
-o ~/.claude/commands/oxtail-join.md
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Codex skill** (`/oxtail-register`):
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
mkdir -p ~/.codex/skills/oxtail-register/agents
|
|
45
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.4.0/integrations/codex/oxtail-register/SKILL.md \
|
|
46
|
+
-o ~/.codex/skills/oxtail-register/SKILL.md
|
|
47
|
+
curl -L https://raw.githubusercontent.com/d4j3y2k/oxtail/v0.4.0/integrations/codex/oxtail-register/agents/openai.yaml \
|
|
48
|
+
-o ~/.codex/skills/oxtail-register/agents/openai.yaml
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Floating form (`npx -y oxtail` with no `@`) exists for trying it out; don't pin daily configs to it — it floats end users into whatever the next published version turns out to be.
|
|
52
|
+
|
|
53
|
+
Contributing? `git clone https://github.com/d4j3y2k/oxtail && cd oxtail && npm install && npm test`.
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
- `tmux` on `PATH`
|
|
58
|
+
- Node 20+
|
|
59
|
+
|
|
60
|
+
## MCP tools
|
|
61
|
+
|
|
62
|
+
- `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 for oxtail-aware peers.
|
|
63
|
+
- `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.
|
|
64
|
+
- `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 }`.
|
|
65
|
+
- `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).
|
|
66
|
+
- `register_my_session` — pin this MCP server's `session_id` directly. Kept for debugging; prefer `claim_session`.
|
|
67
|
+
- `get_my_session` — return this MCP server's own registry entry plus a per-strategy detection diagnosis. Useful for debugging.
|
|
68
|
+
|
|
69
|
+
See [design principles](https://github.com/d4j3y2k/oxtail/blob/v0.4.0/AGENTS.md) for scope and architecture.
|
|
70
|
+
|
|
71
|
+
## Usage from an agent
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
claim_session({ session_id: "<uuid from $CLAUDE_CODE_SESSION_ID or $CODEX_THREAD_ID>" })
|
|
75
|
+
set_my_state({ purpose: "wiring up state cards" })
|
|
76
|
+
list_project_sessions({ project_root: "/path/to/project" })
|
|
77
|
+
read_session({ name: "primary" }) // auto: transcript if peer registered, else pane
|
|
78
|
+
read_session({ name: "claude", mode: "transcript", limit: 50 })
|
|
79
|
+
read_session({ name: "primary", mode: "pane", pane_lines: 500 })
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Omitting `project_root` triggers a best-effort `.git`-ancestor walk from the server's own cwd. The response includes `inferred: true` when this happens. Pass `project_root` explicitly when you can.
|
|
83
|
+
|
|
84
|
+
## Peer awareness without raw transcripts
|
|
85
|
+
|
|
86
|
+
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 the full transcript. Use `read_session` when the card isn't enough.
|
|
87
|
+
|
|
88
|
+
## Self-registration and the peer registry
|
|
89
|
+
|
|
90
|
+
Each oxtail server, when spawned by an agent, writes a small record to `~/.oxtail/sessions/<pid>.json` containing the client type, session id, transcript path, and tmux pane. Sibling servers read this directory to find peer transcripts. Records auto-clean on process exit and on read (dead PIDs pruned). Sessions whose agents are not oxtail-aware (or are not LLM agents at all — bash, vim, vite dev servers) still show up in `list_project_sessions` and are readable via `read_session` in pane mode.
|
|
91
|
+
|
|
92
|
+
## How session_id resolution works (v0.4.0)
|
|
93
|
+
|
|
94
|
+
Claude Code does not propagate `CLAUDE_CODE_SESSION_ID` to MCP child processes — and a process-tree spike confirmed it isn't recoverable via parent-env inspection either: the var only lives in Bash tool subshells. The MCP `initialize` handshake also carries no session id. So oxtail uses a layered detection strategy:
|
|
95
|
+
|
|
96
|
+
1. **`env`** — direct read of `CLAUDE_CODE_SESSION_ID` / `CODEX_THREAD_ID`. Structurally null on Claude Code today; fires on Codex when `CODEX_THREAD_ID` is present in the MCP env.
|
|
97
|
+
2. **`birth-time`** — match the MCP server's `started_at` against `*.jsonl` birth times in the project transcript dir. Resolves only when there is exactly one post-start candidate within a 5-minute window. Two or more in-window candidates means another agent is sharing this project, in which case birth-time abstains rather than guess.
|
|
98
|
+
3. **`register_my_session`** — designed escape hatch. The agent reads its own session id from a Bash tool subshell (`echo $CLAUDE_CODE_SESSION_ID`) and pins it.
|
|
99
|
+
|
|
100
|
+
Detection runs on startup, again at MCP handshake (`oninitialized`), and is retried at +1s/+5s/+30s/+5min via `unref`'d timers — covering the case where the transcript file doesn't exist yet at handshake time.
|
|
101
|
+
|
|
102
|
+
When a strategy doesn't fire, it returns an abstention with a `reason` (e.g. `"2 post-start transcripts in 5min window — ambiguous"`), and `get_my_session` adds a top-level `next_step` block carrying the exact bash command to run for the escape hatch. A fresh agent can act in one round trip without investigating each null.
|
|
103
|
+
|
|
104
|
+
If `MCP_TRACE_FILE` is set in the environment, every detection run appends an NDJSON record with trigger, winning strategy, per-strategy outcomes, and `next_step`. Useful for diagnosing unresolved `client_session_id`s in the wild.
|
|
105
|
+
|
|
106
|
+
## Status
|
|
107
|
+
|
|
108
|
+
v0.4.0. Reliable peer identity: `client_session_id` resolves automatically for Claude Code and Codex via filesystem fingerprint matching, with a self-register escape hatch for ambiguous cases. Project-local and global registrations both supported.
|
package/dist/clients.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { diagnoseDetect } from "./detect/index.js";
|
|
5
|
+
import { codexSessionIdFromEnv } from "./detect/envStrategy.js";
|
|
6
|
+
function encodeCwdForClaudeProjects(cwd) {
|
|
7
|
+
return cwd.replace(/\//g, "-");
|
|
8
|
+
}
|
|
9
|
+
function claudeTranscriptPath(sessionId, cwd) {
|
|
10
|
+
return join(homedir(), ".claude", "projects", encodeCwdForClaudeProjects(cwd), `${sessionId}.jsonl`);
|
|
11
|
+
}
|
|
12
|
+
export function transcriptPathFor(type, sessionId, cwd) {
|
|
13
|
+
if (type === "claude-code")
|
|
14
|
+
return claudeTranscriptPath(sessionId, cwd);
|
|
15
|
+
if (type === "codex")
|
|
16
|
+
return findCodexTranscriptPath(sessionId);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
// Run the detection composer to fill in `session_id` and `transcript_path`
|
|
20
|
+
// when env-based detection couldn't get them. Returns the (possibly updated)
|
|
21
|
+
// client plus the per-strategy diagnosis (or null if detection was skipped
|
|
22
|
+
// because the client was already resolved or its type is unknown).
|
|
23
|
+
export function enrichWithDiagnosis(client, started_at, env = process.env) {
|
|
24
|
+
if (client.session_id || client.type === "unknown") {
|
|
25
|
+
return { client, diagnosis: null };
|
|
26
|
+
}
|
|
27
|
+
const diagnosis = diagnoseDetect({
|
|
28
|
+
type: client.type,
|
|
29
|
+
cwd: client.cwd,
|
|
30
|
+
started_at,
|
|
31
|
+
env,
|
|
32
|
+
});
|
|
33
|
+
if (!diagnosis.winning)
|
|
34
|
+
return { client, diagnosis };
|
|
35
|
+
return {
|
|
36
|
+
client: {
|
|
37
|
+
...client,
|
|
38
|
+
session_id: diagnosis.winning.session_id,
|
|
39
|
+
transcript_path: transcriptPathFor(client.type, diagnosis.winning.session_id, client.cwd),
|
|
40
|
+
session_id_source: diagnosis.winning.source,
|
|
41
|
+
},
|
|
42
|
+
diagnosis,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Convenience wrapper when the caller doesn't need the diagnosis.
|
|
46
|
+
export function enrichSessionId(client, started_at, env = process.env) {
|
|
47
|
+
return enrichWithDiagnosis(client, started_at, env).client;
|
|
48
|
+
}
|
|
49
|
+
// Codex stores transcripts at ~/.codex/sessions/<Y>/<M>/<D>/rollout-<iso>-<uuid>.jsonl
|
|
50
|
+
// where the UUID in the filename matches the session_id. We don't know which date
|
|
51
|
+
// dir to look in, so search the most recent few days in both UTC and local time.
|
|
52
|
+
function findCodexTranscriptPath(sessionId) {
|
|
53
|
+
const base = join(homedir(), ".codex", "sessions");
|
|
54
|
+
if (!existsSync(base))
|
|
55
|
+
return null;
|
|
56
|
+
const dirs = recentCodexDateDirs(base, 3);
|
|
57
|
+
for (const dir of dirs) {
|
|
58
|
+
if (!existsSync(dir))
|
|
59
|
+
continue;
|
|
60
|
+
let entries;
|
|
61
|
+
try {
|
|
62
|
+
entries = readdirSync(dir);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const f of entries) {
|
|
68
|
+
if (f.endsWith(".jsonl") && f.includes(sessionId)) {
|
|
69
|
+
return join(dir, f);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function recentCodexDateDirs(base, days) {
|
|
76
|
+
const out = new Set();
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
for (let i = 0; i < days; i++) {
|
|
79
|
+
const d = new Date(now - i * 86_400_000);
|
|
80
|
+
for (const utc of [true, false]) {
|
|
81
|
+
const y = utc ? d.getUTCFullYear() : d.getFullYear();
|
|
82
|
+
const m = (utc ? d.getUTCMonth() : d.getMonth()) + 1;
|
|
83
|
+
const day = utc ? d.getUTCDate() : d.getDate();
|
|
84
|
+
const yyyy = String(y);
|
|
85
|
+
const mm = String(m).padStart(2, "0");
|
|
86
|
+
const dd = String(day).padStart(2, "0");
|
|
87
|
+
out.add(join(base, yyyy, mm, dd));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return Array.from(out);
|
|
91
|
+
}
|
|
92
|
+
export function detectClient(env = process.env, cwd = process.cwd()) {
|
|
93
|
+
if (env.CLAUDECODE === "1" && env.CLAUDE_CODE_SESSION_ID) {
|
|
94
|
+
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
95
|
+
return {
|
|
96
|
+
type: "claude-code",
|
|
97
|
+
session_id: sessionId,
|
|
98
|
+
transcript_path: claudeTranscriptPath(sessionId, cwd),
|
|
99
|
+
session_id_source: "env",
|
|
100
|
+
cwd,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (env.CODEX_HOME || env.CODEX_THREAD_ID || env.CODEX_COMPANION_SESSION_ID || env.CODEX_RUNTIME) {
|
|
104
|
+
const sessionId = codexSessionIdFromEnv(env);
|
|
105
|
+
return {
|
|
106
|
+
type: "codex",
|
|
107
|
+
session_id: sessionId,
|
|
108
|
+
transcript_path: sessionId ? findCodexTranscriptPath(sessionId) : null,
|
|
109
|
+
session_id_source: sessionId ? "env" : null,
|
|
110
|
+
cwd,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { type: "unknown", session_id: null, transcript_path: null, session_id_source: null, cwd };
|
|
114
|
+
}
|
|
115
|
+
export function clientFromHandshake(info, env = process.env, cwd = process.cwd()) {
|
|
116
|
+
const name = info?.name?.toLowerCase() ?? "";
|
|
117
|
+
if (name.includes("claude")) {
|
|
118
|
+
const sessionId = env.CLAUDE_CODE_SESSION_ID ?? null;
|
|
119
|
+
return {
|
|
120
|
+
type: "claude-code",
|
|
121
|
+
session_id: sessionId,
|
|
122
|
+
transcript_path: sessionId ? claudeTranscriptPath(sessionId, cwd) : null,
|
|
123
|
+
session_id_source: sessionId ? "env" : null,
|
|
124
|
+
cwd,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (name.includes("codex")) {
|
|
128
|
+
const sessionId = codexSessionIdFromEnv(env);
|
|
129
|
+
return {
|
|
130
|
+
type: "codex",
|
|
131
|
+
session_id: sessionId,
|
|
132
|
+
transcript_path: sessionId ? findCodexTranscriptPath(sessionId) : null,
|
|
133
|
+
session_id_source: sessionId ? "env" : null,
|
|
134
|
+
cwd,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return detectClient(env, cwd);
|
|
138
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
5
|
+
const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/;
|
|
6
|
+
// Returns the unique post-start candidate inside the window, or null if there
|
|
7
|
+
// are zero or multiple. Multiple positive-delta candidates means another
|
|
8
|
+
// Claude Code is sharing this project; we can't safely guess which transcript
|
|
9
|
+
// belongs to us, so we fall through to register_my_session.
|
|
10
|
+
export function pickByDelta(candidates, startedAtMs, windowMs = FIVE_MIN_MS) {
|
|
11
|
+
const ranked = candidates
|
|
12
|
+
.map((c) => ({ ...c, delta: c.birth_ms - startedAtMs }))
|
|
13
|
+
.filter((c) => c.delta > 0 && c.delta <= windowMs);
|
|
14
|
+
if (ranked.length !== 1)
|
|
15
|
+
return null;
|
|
16
|
+
return { session_id: ranked[0].session_id, birth_ms: ranked[0].birth_ms };
|
|
17
|
+
}
|
|
18
|
+
function fileBirthMs(path) {
|
|
19
|
+
try {
|
|
20
|
+
const s = statSync(path);
|
|
21
|
+
return s.birthtimeMs > 0 ? s.birthtimeMs : s.mtimeMs;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function encodeCwdForClaudeProjects(cwd) {
|
|
28
|
+
return cwd.replace(/\//g, "-");
|
|
29
|
+
}
|
|
30
|
+
export function listClaudeCandidates(cwd, base = homedir()) {
|
|
31
|
+
const dir = join(base, ".claude", "projects", encodeCwdForClaudeProjects(cwd));
|
|
32
|
+
if (!existsSync(dir))
|
|
33
|
+
return [];
|
|
34
|
+
const out = [];
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = readdirSync(dir);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
for (const f of entries) {
|
|
43
|
+
if (!f.endsWith(".jsonl"))
|
|
44
|
+
continue;
|
|
45
|
+
const session_id = f.slice(0, -".jsonl".length);
|
|
46
|
+
out.push({ session_id, birth_ms: fileBirthMs(join(dir, f)) });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
// Reads up to 4KB from the start of the file — enough to capture the first
|
|
51
|
+
// JSONL line without slurping multi-MB transcripts.
|
|
52
|
+
function readFirstLine(path, maxBytes = 4096) {
|
|
53
|
+
let fd;
|
|
54
|
+
try {
|
|
55
|
+
fd = openSync(path, "r");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const buf = Buffer.alloc(maxBytes);
|
|
62
|
+
const n = readSync(fd, buf, 0, maxBytes, 0);
|
|
63
|
+
const text = buf.toString("utf8", 0, n);
|
|
64
|
+
const nl = text.indexOf("\n");
|
|
65
|
+
return nl === -1 ? text : text.slice(0, nl);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function firstLineCwd(path) {
|
|
75
|
+
const line = readFirstLine(path);
|
|
76
|
+
if (!line)
|
|
77
|
+
return null;
|
|
78
|
+
try {
|
|
79
|
+
const obj = JSON.parse(line);
|
|
80
|
+
const c = obj?.payload?.cwd;
|
|
81
|
+
return typeof c === "string" ? c : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function listCodexCandidatesIn(dirs, cwd) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const dir of dirs) {
|
|
90
|
+
if (!existsSync(dir))
|
|
91
|
+
continue;
|
|
92
|
+
let entries;
|
|
93
|
+
try {
|
|
94
|
+
entries = readdirSync(dir);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
for (const f of entries) {
|
|
100
|
+
if (!f.endsWith(".jsonl"))
|
|
101
|
+
continue;
|
|
102
|
+
const m = f.match(UUID_RE);
|
|
103
|
+
if (!m)
|
|
104
|
+
continue;
|
|
105
|
+
const path = join(dir, f);
|
|
106
|
+
const fileCwd = firstLineCwd(path);
|
|
107
|
+
if (fileCwd !== cwd)
|
|
108
|
+
continue;
|
|
109
|
+
out.push({ session_id: m[1], birth_ms: fileBirthMs(path) });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
export function recentCodexDateDirs(base, days = 3) {
|
|
115
|
+
const out = new Set();
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
for (let i = 0; i < days; i++) {
|
|
118
|
+
const d = new Date(now - i * 86_400_000);
|
|
119
|
+
for (const utc of [true, false]) {
|
|
120
|
+
const y = utc ? d.getUTCFullYear() : d.getFullYear();
|
|
121
|
+
const m = (utc ? d.getUTCMonth() : d.getMonth()) + 1;
|
|
122
|
+
const day = utc ? d.getUTCDate() : d.getDate();
|
|
123
|
+
out.add(join(base, String(y), String(m).padStart(2, "0"), String(day).padStart(2, "0")));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return Array.from(out);
|
|
127
|
+
}
|
|
128
|
+
export function listCodexCandidates(cwd, base = homedir()) {
|
|
129
|
+
const sessionsBase = join(base, ".codex", "sessions");
|
|
130
|
+
if (!existsSync(sessionsBase))
|
|
131
|
+
return [];
|
|
132
|
+
return listCodexCandidatesIn(recentCodexDateDirs(sessionsBase), cwd);
|
|
133
|
+
}
|
|
134
|
+
function abstainReason(type, candidates, startedAtMs) {
|
|
135
|
+
if (candidates.length === 0) {
|
|
136
|
+
const where = type === "claude-code" ? "~/.claude/projects/<encoded-cwd>" : "~/.codex/sessions/<recent>";
|
|
137
|
+
return {
|
|
138
|
+
abstain: true,
|
|
139
|
+
reason: `no transcript files in ${where} for this cwd; agent may not have started a transcript yet.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const ranked = candidates
|
|
143
|
+
.map((c) => ({ ...c, delta: c.birth_ms - startedAtMs }))
|
|
144
|
+
.filter((c) => c.delta > 0 && c.delta <= FIVE_MIN_MS);
|
|
145
|
+
if (ranked.length === 0) {
|
|
146
|
+
return {
|
|
147
|
+
abstain: true,
|
|
148
|
+
reason: `${candidates.length} transcript(s) in dir but none post-date this MCP server's started_at; transcript hasn't been created yet (retries scheduled).`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
abstain: true,
|
|
153
|
+
structural: true,
|
|
154
|
+
reason: `${ranked.length} post-start transcripts in 5min window — ambiguous (multiple agents in this project). Cannot safely guess which is ours; call register_my_session.`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export const birthTimeMatchStrategy = (ctx) => {
|
|
158
|
+
if (ctx.type === "unknown") {
|
|
159
|
+
return {
|
|
160
|
+
abstain: true,
|
|
161
|
+
reason: "client type unknown — no transcript directory to scan.",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const startedAtMs = ctx.started_at * 1000;
|
|
165
|
+
const candidates = ctx.type === "claude-code" ? listClaudeCandidates(ctx.cwd) : listCodexCandidates(ctx.cwd);
|
|
166
|
+
const pick = pickByDelta(candidates, startedAtMs);
|
|
167
|
+
if (pick) {
|
|
168
|
+
return { session_id: pick.session_id, source: "birth-time", confidence: "medium" };
|
|
169
|
+
}
|
|
170
|
+
return abstainReason(ctx.type, candidates, startedAtMs);
|
|
171
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function codexSessionIdFromEnv(env) {
|
|
2
|
+
return env.CODEX_THREAD_ID || env.CODEX_COMPANION_SESSION_ID || null;
|
|
3
|
+
}
|
|
4
|
+
export const envStrategy = (ctx) => {
|
|
5
|
+
if (ctx.type === "claude-code") {
|
|
6
|
+
const id = ctx.env.CLAUDE_CODE_SESSION_ID;
|
|
7
|
+
if (id)
|
|
8
|
+
return { session_id: id, source: "env", confidence: "high" };
|
|
9
|
+
return {
|
|
10
|
+
abstain: true,
|
|
11
|
+
structural: true,
|
|
12
|
+
reason: "CLAUDE_CODE_SESSION_ID not in MCP env. Claude Code strips it from MCP children (verified across the full process tree); this is structural, not a bug. The var IS available inside Bash tool subshells.",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (ctx.type === "codex") {
|
|
16
|
+
const id = codexSessionIdFromEnv(ctx.env);
|
|
17
|
+
if (id)
|
|
18
|
+
return { session_id: id, source: "env", confidence: "high" };
|
|
19
|
+
return {
|
|
20
|
+
abstain: true,
|
|
21
|
+
reason: "CODEX_THREAD_ID is not in MCP env.",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
abstain: true,
|
|
26
|
+
reason: "client type unknown — no env var configured for this client.",
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { birthTimeMatchStrategy } from "./birthTimeMatchStrategy.js";
|
|
2
|
+
import { envStrategy } from "./envStrategy.js";
|
|
3
|
+
import { isHit, } from "./types.js";
|
|
4
|
+
export { isAbstain, isHit } from "./types.js";
|
|
5
|
+
export { birthTimeMatchStrategy } from "./birthTimeMatchStrategy.js";
|
|
6
|
+
export { envStrategy } from "./envStrategy.js";
|
|
7
|
+
export function composeDetectors(strategies) {
|
|
8
|
+
return (ctx) => {
|
|
9
|
+
for (const strategy of strategies) {
|
|
10
|
+
const result = strategy(ctx);
|
|
11
|
+
if (isHit(result))
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export const detectSessionId = composeDetectors([envStrategy, birthTimeMatchStrategy]);
|
|
18
|
+
const NAMED_STRATEGIES = [
|
|
19
|
+
["env", envStrategy],
|
|
20
|
+
["birth-time", birthTimeMatchStrategy],
|
|
21
|
+
];
|
|
22
|
+
function nextStepFor(ctx) {
|
|
23
|
+
const varName = ctx.type === "codex" ? "CODEX_THREAD_ID" : "CLAUDE_CODE_SESSION_ID";
|
|
24
|
+
return {
|
|
25
|
+
tool: "register_my_session",
|
|
26
|
+
instruction: `Read your own session id from a Bash tool subshell, then call register_my_session({ session_id }).`,
|
|
27
|
+
bash_command: `echo $${varName}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// Runs every built-in strategy and reports each outcome. When no strategy
|
|
31
|
+
// resolved the session id, includes a `next_step` that points the caller at
|
|
32
|
+
// the register_my_session escape hatch with the exact bash command to run —
|
|
33
|
+
// so a fresh agent doesn't have to investigate why each strategy abstained.
|
|
34
|
+
export function diagnoseDetect(ctx) {
|
|
35
|
+
const per_strategy = {};
|
|
36
|
+
let winning = null;
|
|
37
|
+
for (const [name, strat] of NAMED_STRATEGIES) {
|
|
38
|
+
const result = strat(ctx);
|
|
39
|
+
per_strategy[name] = result;
|
|
40
|
+
if (isHit(result) && !winning)
|
|
41
|
+
winning = { ...result, strategy: name };
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
per_strategy,
|
|
45
|
+
winning,
|
|
46
|
+
next_step: winning ? null : nextStepFor(ctx),
|
|
47
|
+
};
|
|
48
|
+
}
|