@santori/station 0.0.0-stage → 0.1.2

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/dist/codex.js ADDED
@@ -0,0 +1,438 @@
1
+ // Codex threads on this computer, and the two ways into one.
2
+ //
3
+ // Codex gives every shell command it runs `CODEX_THREAD_ID`, and a Codex
4
+ // process holds `<codex home>/thread-writer-locks/<thread>.lock` open while
5
+ // the thread is live. The nearest `codex` above the caller holding that
6
+ // lock is the thread's host:
7
+ //
8
+ // - Terminal Codex: `codex queue --thread <id> --message=<text>` puts the
9
+ // message in Codex's own queue, first in, first out. The terminal runs
10
+ // it as a turn when its current one allows.
11
+ // - The Codex app (`codex app-server` under ChatGPT.app): its window
12
+ // router at `<codex home>/ipc/ipc.sock` asks the window that has the
13
+ // thread open to start a turn. Only while the thread is idle, so a
14
+ // message waits for the turn under way to end, as it does in Claude Code.
15
+ //
16
+ // Agent Relay (crates/relay-node/src/relay/codex/) reads Codex the same
17
+ // way; queue.rs, ipc.rs and rollout.rs are what this follows. It also
18
+ // installs a Codex hook to deliver inside a running turn; Station leaves
19
+ // the person's Codex settings alone.
20
+ import { execFile, execFileSync } from "node:child_process";
21
+ import { randomUUID } from "node:crypto";
22
+ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from "node:fs";
23
+ import { connect } from "node:net";
24
+ import { homedir } from "node:os";
25
+ import { basename, join } from "node:path";
26
+ import { systemPs } from "./claude.js";
27
+ export const systemHolders = (path) => {
28
+ try {
29
+ return execFileSync("lsof", ["-t", path], {
30
+ encoding: "utf8",
31
+ stdio: ["ignore", "pipe", "ignore"],
32
+ env: { ...process.env, PATH: `${process.env.PATH ?? ""}:/usr/sbin:/usr/bin` },
33
+ })
34
+ .split("\n")
35
+ .map((line) => Number(line.trim()))
36
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
37
+ }
38
+ catch {
39
+ return []; // lsof exits 1 when nobody holds it
40
+ }
41
+ };
42
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
43
+ export function codexHome(env = process.env) {
44
+ return env.CODEX_HOME || join(homedir(), ".codex");
45
+ }
46
+ export const lockPath = (home, thread) => join(home, "thread-writer-locks", `${thread}.lock`);
47
+ /** Which kind of Codex a process is, from its arguments. The Codex app runs
48
+ * its app-server over stdio. Terminal Codex starts a shared one that listens
49
+ * on a socket (`app-server --listen unix:// --managed-daemon`) and holds the
50
+ * thread there; the terminal still takes `codex queue`. */
51
+ export function hostOf(executable, args) {
52
+ // The arguments also carry a terminal Codex's first prompt, so only the
53
+ // subcommand counts: the first word that is not a flag or a flag's value.
54
+ const rest = args.startsWith(executable) ? args.slice(executable.length) : args.replace(/^\S+/, "");
55
+ const words = rest.trim().split(/\s+/).filter(Boolean);
56
+ let subcommand;
57
+ for (let i = 0; i < words.length; i++) {
58
+ if (words[i].startsWith("-")) {
59
+ if (VALUE_FLAGS.has(words[i]))
60
+ i++;
61
+ continue;
62
+ }
63
+ subcommand = words[i];
64
+ break;
65
+ }
66
+ if (subcommand === "app-server")
67
+ return words.some((word) => word.startsWith("--listen")) ? "terminal" : "app";
68
+ if (subcommand === "exec" || subcommand === "e")
69
+ return "exec";
70
+ return "terminal";
71
+ }
72
+ /** Codex's flags that take a value as the next word. */
73
+ const VALUE_FLAGS = new Set([
74
+ "-c",
75
+ "--config",
76
+ "-s",
77
+ "--sandbox",
78
+ "-a",
79
+ "--ask-for-approval",
80
+ "-m",
81
+ "--model",
82
+ "-p",
83
+ "--profile",
84
+ "-C",
85
+ "--cd",
86
+ "-i",
87
+ "--image",
88
+ "--enable",
89
+ "--disable",
90
+ "--remote",
91
+ "--remote-auth-token-env",
92
+ "--add-dir",
93
+ "--local-provider",
94
+ ]);
95
+ function describe(home, thread, pid, ps, holders) {
96
+ const executable = ps(pid, "comm");
97
+ if (!executable || basename(executable) !== "codex")
98
+ return null;
99
+ if (!holders(lockPath(home, thread)).includes(pid))
100
+ return null;
101
+ return { thread, pid, procStart: ps(pid, "lstart"), executable, host: hostOf(executable, ps(pid, "args") ?? "") };
102
+ }
103
+ /** The Codex thread this process runs inside: `CODEX_THREAD_ID`, vouched for
104
+ * by the nearest `codex` above it holding the thread's lock. */
105
+ export function callingThread(home, env = process.env, from = process.ppid, ps = systemPs, holders = systemHolders) {
106
+ const thread = env.CODEX_THREAD_ID?.trim().toLowerCase();
107
+ if (!thread || !UUID.test(thread))
108
+ return null;
109
+ let pid = from;
110
+ for (let hops = 0; hops < 64 && pid > 1; hops++) {
111
+ const executable = ps(pid, "comm");
112
+ if (executable && basename(executable) === "codex")
113
+ return describe(home, thread, pid, ps, holders);
114
+ const parent = Number(ps(pid, "ppid"));
115
+ if (!Number.isInteger(parent) || parent <= 1 || parent === pid)
116
+ return null;
117
+ pid = parent;
118
+ }
119
+ return null;
120
+ }
121
+ /** Why `callingThread` found nothing, step by step, for a process Codex
122
+ * started (it has `CODEX_THREAD_ID`); null when it is not one. */
123
+ export function whyNoThread(home, env = process.env, from = process.ppid, ps = systemPs, holders = systemHolders) {
124
+ const raw = env.CODEX_THREAD_ID;
125
+ if (raw === undefined)
126
+ return null;
127
+ const thread = raw.trim().toLowerCase();
128
+ if (!UUID.test(thread))
129
+ return `CODEX_THREAD_ID is "${raw.slice(0, 60)}", not a thread id.`;
130
+ const chain = [];
131
+ let pid = from;
132
+ for (let hops = 0; hops < 64 && pid > 1; hops++) {
133
+ const executable = ps(pid, "comm");
134
+ if (!executable) {
135
+ chain.push(`${pid} (cannot see it)`);
136
+ return `Station cannot look at the processes above it (${chain.join(" -> ")}). Codex's sandbox may hide them: run the station command again with full access.`;
137
+ }
138
+ chain.push(`${pid} ${basename(executable)}`);
139
+ if (basename(executable) === "codex") {
140
+ const holding = holders(lockPath(home, thread));
141
+ if (!holding.length && !existsSync(lockPath(home, thread)))
142
+ return `No Codex has thread ${thread} open (no ${lockPath(home, thread)}).`;
143
+ if (!holding.length)
144
+ return `Station cannot see who holds ${lockPath(home, thread)}. Codex's sandbox may hide it: run the station command again with full access.`;
145
+ return `The Codex above this command (${chain.join(" -> ")}) does not hold thread ${thread}; pid ${holding.join(", ")} does.`;
146
+ }
147
+ const parent = Number(ps(pid, "ppid"));
148
+ if (!Number.isInteger(parent) || parent <= 1 || parent === pid)
149
+ break;
150
+ pid = parent;
151
+ }
152
+ return `No codex process above this command (${chain.join(" -> ")}).`;
153
+ }
154
+ /** The Codex that holds `thread` now; a thread resumed elsewhere is found
155
+ * again. Null when no Codex has it open. */
156
+ export function liveThread(home, thread, ps = systemPs, holders = systemHolders) {
157
+ for (const pid of holders(lockPath(home, thread))) {
158
+ const session = describe(home, thread, pid, ps, () => [pid]);
159
+ if (session)
160
+ return session;
161
+ }
162
+ return null;
163
+ }
164
+ /** The thread's rollout: `sessions/<yyyy>/<mm>/<dd>/rollout-…-<thread>.jsonl`,
165
+ * newest day first. */
166
+ export function rolloutPath(home, thread) {
167
+ const root = join(home, "sessions");
168
+ const down = (dir) => (existsSync(dir) ? readdirSync(dir).sort().reverse() : []);
169
+ for (const year of down(root))
170
+ for (const month of down(join(root, year)))
171
+ for (const day of down(join(root, year, month))) {
172
+ const dir = join(root, year, month, day);
173
+ const name = down(dir).find((file) => file.startsWith("rollout-") && file.endsWith(`-${thread}.jsonl`));
174
+ if (name)
175
+ return join(dir, name);
176
+ }
177
+ return null;
178
+ }
179
+ const TAIL_BYTES = 4 * 1024 * 1024;
180
+ /** Whether the rollout's last turn is still open: a `task_started` with no
181
+ * `task_complete` or `turn_aborted` after it. A turn ends with one of those
182
+ * near the end of the file, so a tail with neither is a turn still going. */
183
+ export function turnRunning(lines) {
184
+ let open = null;
185
+ for (const line of lines) {
186
+ if (!line.includes('"event_msg"'))
187
+ continue;
188
+ let type;
189
+ try {
190
+ type = JSON.parse(line).payload?.type;
191
+ }
192
+ catch {
193
+ continue;
194
+ }
195
+ if (type === "task_started")
196
+ open = true;
197
+ else if (type === "task_complete" || type === "turn_aborted")
198
+ open = false;
199
+ }
200
+ return open ?? true;
201
+ }
202
+ function tailLines(path) {
203
+ const size = statSync(path).size;
204
+ const start = Math.max(0, size - TAIL_BYTES);
205
+ const buffer = Buffer.alloc(size - start);
206
+ const fd = openSync(path, "r");
207
+ try {
208
+ readSync(fd, buffer, 0, buffer.length, start);
209
+ }
210
+ finally {
211
+ closeSync(fd);
212
+ }
213
+ const lines = buffer.toString("utf8").split("\n");
214
+ return start > 0 ? lines.slice(1) : lines;
215
+ }
216
+ /** Whether the thread is between turns. Unknown counts as busy. */
217
+ export function threadIdle(home, thread) {
218
+ const path = rolloutPath(home, thread);
219
+ if (!path)
220
+ return false;
221
+ try {
222
+ return !turnRunning(tailLines(path));
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
228
+ /** Codex takes text, tabs and newlines; any other control character could
229
+ * edit or submit what it is part of. */
230
+ export const MAX_TEXT_BYTES = 14_336;
231
+ const CUT_NOTE = "\n\n[cut short: the rest is in the room]";
232
+ export function cleanText(text) {
233
+ // eslint-disable-next-line no-control-regex
234
+ let clean = text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F‎‏‪-‮⁦-⁩]/g, "");
235
+ if (Buffer.byteLength(clean) > MAX_TEXT_BYTES) {
236
+ const kept = Buffer.from(clean).subarray(0, MAX_TEXT_BYTES - Buffer.byteLength(CUT_NOTE)).toString("utf8");
237
+ clean = kept.replace(/�+$/, "") + CUT_NOTE;
238
+ }
239
+ return clean;
240
+ }
241
+ // ---- terminal Codex: its own queue ----
242
+ export const queueArgs = (thread, text) => ["queue", "--thread", thread, `--message=${text}`];
243
+ /** Codex prints `Queued message <item> for thread <id>.`; anything else, or
244
+ * a non-zero exit, queued nothing. */
245
+ export function queued(code, stdout) {
246
+ return code === 0 && stdout.includes("Queued message ");
247
+ }
248
+ const QUEUE_TIMEOUT_MS = 20_000;
249
+ function queueMessage(home, session, text) {
250
+ return new Promise((resolve) => {
251
+ execFile(session.executable, queueArgs(session.thread, text), { timeout: QUEUE_TIMEOUT_MS, env: { ...process.env, CODEX_HOME: home }, maxBuffer: 64 * 1024 }, (error, stdout, stderr) => {
252
+ if (error && error.killed) {
253
+ return resolve({ kind: "in-doubt", reason: "codex queue did not finish in time." });
254
+ }
255
+ const code = error ? Number(error.code ?? 1) : 0;
256
+ if (queued(code, String(stdout)))
257
+ return resolve({ kind: "delivered" });
258
+ const line = (String(stderr) || String(stdout)).split("\n").map((l) => l.trim()).find(Boolean) ?? `codex exited with code ${code}`;
259
+ resolve({ kind: "retry", reason: `codex queue: ${line.slice(0, 300)}` });
260
+ });
261
+ });
262
+ }
263
+ // ---- the Codex app: its window router ----
264
+ /** The app refuses frames larger than this. */
265
+ export const MAX_FRAME_BYTES = 268_435_456;
266
+ /** A 4-byte little-endian length, then that many bytes of JSON. */
267
+ export function encodeFrame(message) {
268
+ const payload = Buffer.from(JSON.stringify(message), "utf8");
269
+ if (!payload.length || payload.length > MAX_FRAME_BYTES)
270
+ throw new Error("frame size out of range");
271
+ const header = Buffer.alloc(4);
272
+ header.writeUInt32LE(payload.length, 0);
273
+ return Buffer.concat([header, payload]);
274
+ }
275
+ /** Splits a byte stream into frames; keeps a partial one for later. */
276
+ export function frameReader() {
277
+ let pending = Buffer.alloc(0);
278
+ return (chunk) => {
279
+ pending = Buffer.concat([pending, chunk]);
280
+ const out = [];
281
+ while (pending.length >= 4) {
282
+ const length = pending.readUInt32LE(0);
283
+ if (!length || length > MAX_FRAME_BYTES)
284
+ throw new Error("malformed frame");
285
+ if (pending.length < 4 + length)
286
+ break;
287
+ const body = pending.subarray(4, 4 + length).toString("utf8");
288
+ pending = pending.subarray(4 + length);
289
+ try {
290
+ out.push(JSON.parse(body));
291
+ }
292
+ catch {
293
+ // not JSON: skip it
294
+ }
295
+ }
296
+ return out;
297
+ };
298
+ }
299
+ /** Method versions the app checks exactly (Agent Relay's, from app build 153). */
300
+ const VERSIONS = { initialize: 0, "thread-owner-discovery": 1, "thread-follower-start-turn": 2 };
301
+ export function ipcRequest(method, params, source, requestId, target, timeoutMs) {
302
+ return {
303
+ type: "request",
304
+ requestId,
305
+ sourceClientId: source,
306
+ version: VERSIONS[method],
307
+ method,
308
+ params,
309
+ ...(target ? { targetClientId: target } : {}),
310
+ ...(timeoutMs ? { timeoutMs } : {}),
311
+ };
312
+ }
313
+ export function startTurnParams(thread, text, messageId) {
314
+ return {
315
+ conversationId: thread,
316
+ turnStart: {
317
+ request: { threadId: thread, clientUserMessageId: messageId, input: [{ type: "text", text, text_elements: [] }] },
318
+ context: {},
319
+ },
320
+ };
321
+ }
322
+ /** One connection to the router: send a request, wait for its reply. The
323
+ * router also asks every client what it can handle; Station says nothing. */
324
+ function routerLink(socketPath) {
325
+ let socket = null;
326
+ const waiting = new Map();
327
+ let closed = false;
328
+ const read = frameReader();
329
+ const fail = () => {
330
+ closed = true;
331
+ for (const done of waiting.values())
332
+ done(null);
333
+ waiting.clear();
334
+ };
335
+ const open = () => new Promise((resolve) => {
336
+ const link = connect(socketPath);
337
+ socket = link;
338
+ link.once("connect", () => resolve(true));
339
+ link.once("error", () => {
340
+ fail();
341
+ resolve(false);
342
+ });
343
+ link.on("close", fail);
344
+ link.on("data", (chunk) => {
345
+ let messages;
346
+ try {
347
+ messages = read(chunk);
348
+ }
349
+ catch {
350
+ link.destroy();
351
+ return;
352
+ }
353
+ for (const message of messages) {
354
+ if (message.type === "client-discovery-request" && message.requestId) {
355
+ link.write(encodeFrame({ type: "client-discovery-response", requestId: message.requestId, response: { canHandle: false } }));
356
+ }
357
+ else if (message.type === "response" && message.requestId && waiting.has(message.requestId)) {
358
+ waiting.get(message.requestId)(message);
359
+ waiting.delete(message.requestId);
360
+ }
361
+ }
362
+ });
363
+ });
364
+ const request = (method, params, source, waitMs, target, timeoutMs) => new Promise((resolve) => {
365
+ if (closed || !socket)
366
+ return resolve(null);
367
+ const id = randomUUID();
368
+ const timer = setTimeout(() => {
369
+ waiting.delete(id);
370
+ resolve(null);
371
+ }, waitMs);
372
+ waiting.set(id, (reply) => {
373
+ clearTimeout(timer);
374
+ resolve(reply);
375
+ });
376
+ socket.write(encodeFrame(ipcRequest(method, params, source, id, target, timeoutMs)));
377
+ });
378
+ const close = () => {
379
+ socket?.destroy();
380
+ };
381
+ return { open, request, close };
382
+ }
383
+ const errorText = (reply) => reply ? (typeof reply.error === "string" ? reply.error : JSON.stringify(reply.error ?? reply.resultType)) : "no answer";
384
+ async function hello(link) {
385
+ if (!(await link.open()))
386
+ return { error: "The Codex app is not running." };
387
+ const reply = await link.request("initialize", { clientType: "station-cli" }, "initializing-client", 5_000);
388
+ const client = reply?.resultType === "success" ? reply.result?.clientId : undefined;
389
+ return client ? { client } : { error: `The Codex app did not let Station in: ${errorText(reply)}` };
390
+ }
391
+ /** Asks the window that has the thread open to start a turn with `text`. */
392
+ async function appTurn(home, thread, text, messageId) {
393
+ const link = routerLink(join(home, "ipc", "ipc.sock"));
394
+ try {
395
+ const me = await hello(link);
396
+ if ("error" in me)
397
+ return { kind: "retry", reason: me.error };
398
+ const owner = await link.request("thread-owner-discovery", { hostId: "local", conversationId: thread }, me.client, 12_000);
399
+ if (owner?.resultType !== "success" || !owner.handledByClientId) {
400
+ const detail = owner?.error === "no-client-found" ? "" : ` (${errorText(owner)})`;
401
+ return { kind: "retry", reason: `No Codex window has this chat open.${detail}` };
402
+ }
403
+ const started = await link.request("thread-follower-start-turn", startTurnParams(thread, text, messageId), me.client, 32_000, owner.handledByClientId, 30_000);
404
+ if (!started)
405
+ return { kind: "in-doubt", reason: "The Codex app did not answer the new turn." };
406
+ if (started.resultType !== "success")
407
+ return { kind: "retry", reason: `The Codex app refused the turn: ${errorText(started)}` };
408
+ return { kind: "delivered" };
409
+ }
410
+ finally {
411
+ link.close();
412
+ }
413
+ }
414
+ /** Whether the Codex app's router lets Station in, for `station doctor`. */
415
+ export async function appAnswers(home) {
416
+ const link = routerLink(join(home, "ipc", "ipc.sock"));
417
+ try {
418
+ const me = await hello(link);
419
+ return "error" in me ? me.error : "The Codex app answers.";
420
+ }
421
+ finally {
422
+ link.close();
423
+ }
424
+ }
425
+ /** Puts `text` into the thread as a new user turn. */
426
+ export async function deliverCodex(home, thread, messageId, text, ps = systemPs, holders = systemHolders) {
427
+ const session = liveThread(home, thread, ps, holders);
428
+ if (!session)
429
+ return { kind: "retry", reason: "No Codex has that thread open." };
430
+ const clean = cleanText(text);
431
+ if (session.host === "terminal")
432
+ return queueMessage(home, session, clean);
433
+ if (session.host === "exec")
434
+ return { kind: "retry", reason: "codex exec runs one task and takes no more messages." };
435
+ if (!threadIdle(home, thread))
436
+ return { kind: "retry", reason: "Codex is in the middle of a turn." };
437
+ return appTurn(home, thread, clean, messageId);
438
+ }