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.
@@ -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
+ }