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
package/dist/registry.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const REGISTRY_DIR = join(homedir(), ".oxtail", "sessions");
|
|
6
|
+
function ensureDir() {
|
|
7
|
+
if (!existsSync(REGISTRY_DIR)) {
|
|
8
|
+
mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 });
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
// Migration: tighten perms for users upgrading from <0.4.0, where the dir
|
|
12
|
+
// and entries were created at default umask (typically 0o755 / 0o644).
|
|
13
|
+
try {
|
|
14
|
+
chmodSync(REGISTRY_DIR, 0o700);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// not our dir or fs doesn't support; leave it
|
|
18
|
+
}
|
|
19
|
+
for (const file of readdirSync(REGISTRY_DIR)) {
|
|
20
|
+
if (!file.endsWith(".json"))
|
|
21
|
+
continue;
|
|
22
|
+
try {
|
|
23
|
+
chmodSync(join(REGISTRY_DIR, file), 0o600);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// ignore
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function entryPath(pid) {
|
|
31
|
+
return join(REGISTRY_DIR, `${pid}.json`);
|
|
32
|
+
}
|
|
33
|
+
function resolveTmuxSessionFromPane(pane) {
|
|
34
|
+
if (!pane)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
const out = execFileSync("tmux", ["display-message", "-p", "-t", pane, "#{session_name}"], {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
40
|
+
});
|
|
41
|
+
const name = out.trim();
|
|
42
|
+
return name || null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function listTmuxPanePids() {
|
|
49
|
+
try {
|
|
50
|
+
const out = execFileSync("tmux", ["list-panes", "-a", "-F", "#{pane_pid}|#{pane_id}"], {
|
|
51
|
+
encoding: "utf8",
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
});
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const line of out.split("\n")) {
|
|
56
|
+
if (!line)
|
|
57
|
+
continue;
|
|
58
|
+
const [pidStr, paneId] = line.split("|");
|
|
59
|
+
const pid = Number(pidStr);
|
|
60
|
+
if (Number.isFinite(pid) && pid > 0 && paneId)
|
|
61
|
+
map.set(pid, paneId);
|
|
62
|
+
}
|
|
63
|
+
return map;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return new Map();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function listAllPpids() {
|
|
70
|
+
try {
|
|
71
|
+
const out = execFileSync("ps", ["-A", "-o", "pid=,ppid="], {
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
74
|
+
});
|
|
75
|
+
const map = new Map();
|
|
76
|
+
for (const line of out.split("\n")) {
|
|
77
|
+
const trimmed = line.trim();
|
|
78
|
+
if (!trimmed)
|
|
79
|
+
continue;
|
|
80
|
+
const parts = trimmed.split(/\s+/);
|
|
81
|
+
if (parts.length < 2)
|
|
82
|
+
continue;
|
|
83
|
+
const pid = Number(parts[0]);
|
|
84
|
+
const ppid = Number(parts[1]);
|
|
85
|
+
if (Number.isFinite(pid) && Number.isFinite(ppid))
|
|
86
|
+
map.set(pid, ppid);
|
|
87
|
+
}
|
|
88
|
+
return map;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return new Map();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Walk pid → ppid until we hit a process that tmux registered as a pane_pid
|
|
95
|
+
// (typically the shell tmux forked into the pane). Lets us recover tmux_pane
|
|
96
|
+
// when the immediate parent stripped TMUX_PANE from our env — Codex does this,
|
|
97
|
+
// and any future MCP host that scrubs env vars would too.
|
|
98
|
+
export function findTmuxPaneByAncestry(startPid, panePids, ppids) {
|
|
99
|
+
if (panePids.size === 0)
|
|
100
|
+
return null;
|
|
101
|
+
let pid = startPid;
|
|
102
|
+
for (let i = 0; i < 64 && pid !== undefined && pid > 1; i++) {
|
|
103
|
+
const paneId = panePids.get(pid);
|
|
104
|
+
if (paneId)
|
|
105
|
+
return paneId;
|
|
106
|
+
pid = ppids.get(pid);
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
export function resolveTmuxPane(env = process.env, pid = process.pid) {
|
|
111
|
+
if (env.TMUX_PANE)
|
|
112
|
+
return env.TMUX_PANE;
|
|
113
|
+
return findTmuxPaneByAncestry(pid, listTmuxPanePids(), listAllPpids());
|
|
114
|
+
}
|
|
115
|
+
export function buildEntry(client, env = process.env) {
|
|
116
|
+
const tmux_pane = resolveTmuxPane(env);
|
|
117
|
+
return {
|
|
118
|
+
server_pid: process.pid,
|
|
119
|
+
started_at: Math.floor(Date.now() / 1000),
|
|
120
|
+
client,
|
|
121
|
+
tmux_pane,
|
|
122
|
+
tmux_session: resolveTmuxSessionFromPane(tmux_pane),
|
|
123
|
+
state: null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function refreshTmuxBinding(entry) {
|
|
127
|
+
const tmux_pane = resolveTmuxPane();
|
|
128
|
+
entry.tmux_pane = tmux_pane;
|
|
129
|
+
entry.tmux_session = resolveTmuxSessionFromPane(tmux_pane);
|
|
130
|
+
}
|
|
131
|
+
export function register(entry) {
|
|
132
|
+
ensureDir();
|
|
133
|
+
// Temp file + atomic rename. Concurrent peers running readAll() can otherwise
|
|
134
|
+
// catch a torn write, fail JSON.parse, and silently drop the entry until the
|
|
135
|
+
// next write completes.
|
|
136
|
+
const final = entryPath(entry.server_pid);
|
|
137
|
+
const tmp = `${final}.${process.pid}.tmp`;
|
|
138
|
+
try {
|
|
139
|
+
writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 });
|
|
140
|
+
renameSync(tmp, final);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(tmp);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// already gone, fine
|
|
148
|
+
}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function unregister(pid = process.pid) {
|
|
153
|
+
try {
|
|
154
|
+
unlinkSync(entryPath(pid));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// already gone, fine
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function isAlive(pid) {
|
|
161
|
+
try {
|
|
162
|
+
process.kill(pid, 0);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
const err = e;
|
|
167
|
+
return err.code === "EPERM";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export function readAll() {
|
|
171
|
+
if (!existsSync(REGISTRY_DIR))
|
|
172
|
+
return [];
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const file of readdirSync(REGISTRY_DIR)) {
|
|
175
|
+
if (!file.endsWith(".json"))
|
|
176
|
+
continue;
|
|
177
|
+
const full = join(REGISTRY_DIR, file);
|
|
178
|
+
let entry;
|
|
179
|
+
try {
|
|
180
|
+
entry = JSON.parse(readFileSync(full, "utf8"));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!isAlive(entry.server_pid)) {
|
|
186
|
+
try {
|
|
187
|
+
unlinkSync(full);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// ignore
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
out.push(entry);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
export function findByTmuxSession(name) {
|
|
199
|
+
return readAll().filter((e) => e.tmux_session === name);
|
|
200
|
+
}
|