nomoremeatproxy 0.0.0-stage → 0.1.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,158 @@
1
+ // Claude adapter. Uses `claude -p` for the turn and the official `--desktop` opener to list
2
+ // the session in the Claude desktop app.
3
+ import { spawn } from 'node:child_process';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { isIP } from 'node:net';
6
+ import { claudeProjectsDir } from '../history/claude.mjs';
7
+ import { claudeUsage } from '../usage.mjs';
8
+ import { canWrite } from '../writable.mjs';
9
+ import { runOpener } from './opener.mjs';
10
+
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const TOKEN = /^[A-Za-z0-9._:-]+$/;
13
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
14
+ // A call without write access gets the reading tools only. That makes "read-only" hold
15
+ // whatever the owner's own permission settings are, and it cuts the fixed load of a call:
16
+ // measured here, a one-word answer took 58,193 input tokens with every tool defined and
17
+ // 23,218 with these three.
18
+ const READ_ONLY_TOOLS = 'Read,Grep,Glob';
19
+
20
+ function requireMatch(value, pattern, name) {
21
+ if (!pattern.test(String(value))) throw new Error(`invalid ${name}: ${JSON.stringify(value)}`);
22
+ return String(value);
23
+ }
24
+
25
+ // Without a model (null or undefined) the vendor's own default applies.
26
+ export function buildClaudeArgs({ sessionId, resume, title, model, effort, write = false }) {
27
+ const id = requireMatch(sessionId, UUID, 'Claude session id');
28
+ return [
29
+ '-p',
30
+ '--output-format',
31
+ 'json',
32
+ ...(model == null ? [] : ['--model', requireMatch(model, TOKEN, 'model')]),
33
+ ...(effort ? ['--effort', requireMatch(effort, TOKEN, 'effort')] : []),
34
+ ...(write ? [] : ['--tools', READ_ONLY_TOOLS]),
35
+ ...(resume ? ['--resume', id] : ['--session-id', id]),
36
+ ...(!resume && title ? ['--name', title] : []),
37
+ ...(write ? ['--permission-mode', 'acceptEdits'] : []),
38
+ ];
39
+ }
40
+
41
+ export function parseClaudeResult(stdout) {
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(stdout);
45
+ } catch {
46
+ throw new Error(`Claude returned output that is not JSON: ${stdout.slice(0, 200)}`);
47
+ }
48
+ const result = Array.isArray(parsed) ? parsed.findLast((entry) => entry?.type === 'result') : parsed;
49
+ if (result?.type !== 'result') throw new Error('Claude output had no result entry');
50
+ if (result.is_error) {
51
+ throw new Error(`Claude turn failed: ${result.result ?? result.subtype ?? 'no detail'} (session ${result.session_id})`);
52
+ }
53
+ const usage = claudeUsage(result);
54
+ return { threadId: result.session_id, reply: result.result ?? '', ...(usage ? { usage } : {}) };
55
+ }
56
+
57
+ const notSent = (message) => Object.assign(new Error(message), { notSent: true });
58
+ const WAY_OUT = 'nothing was sent. Run the call outside the sandbox, or have a Claude chat in this project listen as this peer (`nomeat listen`) so that the call is delivered to it';
59
+ const NO_FOLDER = `Claude cannot run here: the folder it keeps its sessions in (under CLAUDE_CONFIG_DIR, or .claude in the home folder) is not writable; ${WAY_OUT}`;
60
+ const NO_NETWORK = `Claude cannot run here: the host it talks to could not be looked up, so there is no network; ${WAY_OUT}`;
61
+ // Includes starting the child below. Limit: a resolver slower than this fails the call.
62
+ const LOOKUP_TIMEOUT_MS = 3000;
63
+ const PROXY_VARIABLES = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
64
+ const LOOKUP_SCRIPT = "require('node:dns').lookup(process.argv[1], (error) => process.exit(error ? 1 : 0))";
65
+
66
+ // The system's own lookup, so every name the system knows counts. dns.lookup cannot be
67
+ // cancelled, so it runs in a child process that is ended at the limit and leaves nothing in
68
+ // this one. Costs one short child process per headless Claude call. True only on exit code 0;
69
+ // a child that does not start, fails or runs out of time means not looked up.
70
+ export function lookupInChild(host, timeoutMs, start = spawn) {
71
+ return new Promise((resolve) => {
72
+ let child;
73
+ try {
74
+ child = start(process.execPath, ['-e', LOOKUP_SCRIPT, '--', host], { stdio: 'ignore', timeout: timeoutMs, windowsHide: true });
75
+ } catch {
76
+ resolve(false);
77
+ return;
78
+ }
79
+ child.once('error', () => resolve(false));
80
+ child.once('exit', (code) => resolve(code === 0));
81
+ });
82
+ }
83
+
84
+ // Run before `claude` starts, so that a call that cannot work fails at once, as not sent.
85
+ // Inside Codex's sandbox on Linux and macOS there is no network and Claude's folder is
86
+ // read-only; there the command line hung instead of failing.
87
+ export async function checkClaudeCanRun({ env = process.env, resolve = lookupInChild, writable = canWrite, timeoutMs = LOOKUP_TIMEOUT_MS } = {}) {
88
+ if (!writable(claudeProjectsDir(env))) throw notSent(NO_FOLDER);
89
+ // Limit: behind a proxy a name lookup can fail while the command line still works, so the
90
+ // lookup is skipped there and a missing network is then not caught.
91
+ if (PROXY_VARIABLES.some((name) => env[name])) return;
92
+ // Limit: with Bedrock or Vertex the command line talks to other hosts; this one is still looked up.
93
+ const host = URL.parse(env.ANTHROPIC_BASE_URL ?? '')?.hostname || 'api.anthropic.com';
94
+ // An address or localhost needs no lookup.
95
+ if (isIP(host.replace(/[[\]]/g, '')) || host === 'localhost') return;
96
+ if (!(await resolve(host, timeoutMs))) throw notSent(NO_NETWORK);
97
+ }
98
+
99
+ // No shell: the arguments are passed as an array and the message goes in on stdin.
100
+ function runClaude(args, { input, cwd, timeoutMs, start }) {
101
+ return new Promise((resolve, reject) => {
102
+ const child = start('claude', args, { cwd, stdio: 'pipe', windowsHide: true });
103
+ let stdout = '';
104
+ let stderr = '';
105
+ const timer = setTimeout(() => child.kill(), timeoutMs);
106
+ child.stdout.on('data', (chunk) => (stdout += chunk));
107
+ child.stderr.on('data', (chunk) => (stderr += chunk));
108
+ child.on('error', (error) => {
109
+ clearTimeout(timer);
110
+ reject(error);
111
+ });
112
+ child.on('close', (code) => {
113
+ clearTimeout(timer);
114
+ resolve({ code, stdout, stderr });
115
+ });
116
+ child.stdin.end(input);
117
+ });
118
+ }
119
+
120
+ export async function callClaude({ threadId, message, title, model, effort, write = false, cwd = process.cwd(), onThread = () => {}, timeoutMs = DEFAULT_TIMEOUT_MS, canRun = checkClaudeCanRun, start = spawn }) {
121
+ const sessionId = threadId ?? randomUUID();
122
+ const args = buildClaudeArgs({ sessionId, resume: Boolean(threadId), title, model, effort, write });
123
+ await canRun();
124
+ // The id is chosen here, so it is known before anything is sent.
125
+ onThread(sessionId);
126
+ const { code, stdout, stderr } = await runClaude(args, { input: message, cwd, timeoutMs, start });
127
+ if (!stdout.trim()) {
128
+ throw new Error(`Claude exited with code ${code} and no output: ${stderr.trim().slice(0, 300)} (session ${sessionId})`);
129
+ }
130
+ return { ...parseClaudeResult(stdout), delivery: 'headless', access: write ? 'write' : 'read-only', ...(effort ? { effort } : {}) };
131
+ }
132
+
133
+ // On Linux the vendor's opener says that `--desktop` works on macOS and Windows only.
134
+ const NO_OPENER = 'the vendor offers no opener for the Claude app on this platform, so nothing was attempted';
135
+ // A Mac run listed the chat within 3 seconds; this leaves room for a slow start.
136
+ const OPENER_TIMEOUT_MS = 20 * 1000;
137
+
138
+ // The command that runs the official opener, or the reason it is not run. The opener refuses
139
+ // to run without a terminal, so it is given one.
140
+ function openerCommand(platform, id) {
141
+ const opener = ['claude', '--desktop', '--resume', id];
142
+ // On Windows it gets a console window of its own. Exit code 0 here only shows that this
143
+ // window was launched, not that the chat was listed: `start` does not wait for the opener.
144
+ if (platform === 'win32') return { command: ['cmd.exe', ['/c', 'start', '/min', ...opener]] };
145
+ // On macOS it runs under a pseudo-terminal with no window, and its own exit code comes back.
146
+ if (platform === 'darwin') return { command: ['script', ['-q', '/dev/null', ...opener]] };
147
+ return { reason: NO_OPENER };
148
+ }
149
+
150
+ // Runs the opener and waits for it to exit (see runOpener). A failure to start, another exit
151
+ // code or running out of time is reported, never thrown.
152
+ // `supported: false` only where no opener exists, so that callers can tell it from a failure.
153
+ export function openInClaudeApp(sessionId, { cwd = process.cwd(), platform = process.platform, start = spawn, timeoutMs = OPENER_TIMEOUT_MS } = {}) {
154
+ const id = requireMatch(sessionId, UUID, 'Claude session id');
155
+ const { command, reason } = openerCommand(platform, id);
156
+ if (!command) return Promise.resolve({ opened: false, supported: false, reason });
157
+ return runOpener(command, { cwd, start, timeoutMs }).catch((error) => ({ opened: false, reason: `the opener did not start: ${error.message}` }));
158
+ }
@@ -0,0 +1,58 @@
1
+ // Opens a Codex thread in the Codex desktop app by its link, `codex://threads/<id>`, which the
2
+ // vendor documents as the link that opens an existing local chat. The running app does not list
3
+ // a thread the bridge created until it re-reads its list (hiccup 40). Seen twice on Windows:
4
+ // after the link was opened, the app showed and listed the chat without a relaunch, once after
5
+ // a delay that was not timed and once within seconds. On macOS and Linux the same commands, run
6
+ // by hand once each, made the app list the chat within seconds; see the usage notes.
7
+ import { spawn } from 'node:child_process';
8
+ import { SERVER_STILL_RUNNING } from './codex-transport.mjs';
9
+ import { runOpener } from './opener.mjs';
10
+
11
+ // Codex thread ids seen so far are UUIDs. Checked before anything starts, since cmd.exe reads
12
+ // its own command line: an id of another shape is never put into a link.
13
+ const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
+ // These openers hand the link to the system and return; the limit is for one that does not.
15
+ // Not measured.
16
+ const OPENER_TIMEOUT_MS = 10 * 1000;
17
+ const NO_OPENER = 'no opener for links is known on this platform, so nothing was attempted';
18
+
19
+ function openerCommand(platform, link) {
20
+ // The empty argument reaches cmd.exe as "", which `start` takes as the window title, so the
21
+ // link is what it opens. `start` exits once the system has taken the link.
22
+ if (platform === 'win32') return ['cmd.exe', ['/c', 'start', '', link]];
23
+ if (platform === 'darwin') return ['open', [link]];
24
+ if (platform === 'linux') return ['xdg-open', [link]];
25
+ return null;
26
+ }
27
+
28
+ // `opened: true` means only that the opener exited with code 0, so the system took the link; it
29
+ // does not show that the app listed the chat. Never throws: a failure to open must not fail a
30
+ // call that was answered. `supported: false` where there is no opener: an unknown platform, or
31
+ // an opener program that is not installed.
32
+ export function openInCodexApp(threadId, { platform = process.platform, start = spawn, timeoutMs = OPENER_TIMEOUT_MS } = {}) {
33
+ if (!THREAD_ID.test(String(threadId))) {
34
+ return Promise.resolve({ opened: false, reason: 'the thread id is not a UUID, so nothing was opened' });
35
+ }
36
+ const command = openerCommand(platform, `codex://threads/${threadId}`);
37
+ if (!command) return Promise.resolve({ opened: false, supported: false, reason: NO_OPENER });
38
+ return runOpener(command, { start, timeoutMs }).catch((error) =>
39
+ error.code === 'ENOENT'
40
+ ? { opened: false, supported: false, reason: `${command[0]} was not found, so nothing was opened` }
41
+ : { opened: false, reason: `the opener did not start: ${error.message}` });
42
+ }
43
+
44
+ // After a call: opens only a thread the call created, never one it continued. While the bridge's
45
+ // own server runs it holds the thread, and a call returns only once that server has ended
46
+ // (withCodexServer), up to a limit. Past the limit the call carries SERVER_STILL_RUNNING as its
47
+ // warning, and the link is opened anyway. A call sent without waiting, or through the app's
48
+ // queue, never creates a thread. `optOut` is `--no-open`.
49
+ export async function openNewCodexThread({ outcome, optOut, ...options }) {
50
+ if (optOut || outcome.state !== 'answered' || outcome.continued !== false) return;
51
+ const app = await openInCodexApp(outcome.threadId, options);
52
+ if (outcome.warning !== SERVER_STILL_RUNNING) {
53
+ outcome.app = app;
54
+ return;
55
+ }
56
+ const early = `${SERVER_STILL_RUNNING}, so the chat may not come up in the app until its link is opened again`;
57
+ outcome.app = { ...app, reason: app.reason ? `${early}; ${app.reason}` : early };
58
+ }
@@ -0,0 +1,38 @@
1
+ // Runs the Codex CLI without a shell, so free text can be passed as an argument safely.
2
+ import { execFile, execFileSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ const whereCodex = () => execFileSync('where.exe', ['codex'], { encoding: 'utf8' }).split(/\r?\n/).filter(Boolean);
7
+
8
+ // npm installs Codex on Windows as a .cmd shim, which only a shell can start. The shim's
9
+ // Node entry point is run directly instead. Windows path rules apply whatever the host is.
10
+ export function resolveCodexCommand({ platform = process.platform, where = whereCodex, exists = existsSync, node = process.execPath } = {}) {
11
+ if (platform !== 'win32') return { file: 'codex', prefix: [] };
12
+ const found = where();
13
+ const executable = found.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
14
+ if (executable) return { file: executable, prefix: [] };
15
+ for (const shim of found) {
16
+ const entry = path.win32.join(path.win32.dirname(shim), 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
17
+ if (exists(entry)) return { file: node, prefix: [entry] };
18
+ }
19
+ throw new Error('could not find a Codex executable that runs without a shell');
20
+ }
21
+
22
+ // A failure carries `notStarted: true` only when the command never ran: no executable was
23
+ // found, or it could not be spawned. After that, a failure or time-out may have done its work.
24
+ export async function runCodexCli(args, { timeoutMs = 60000 } = {}) {
25
+ let command;
26
+ try {
27
+ command = resolveCodexCommand();
28
+ } catch (error) {
29
+ throw Object.assign(error, { notStarted: true });
30
+ }
31
+ return new Promise((resolve, reject) => {
32
+ execFile(command.file, [...command.prefix, ...args], { timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
33
+ if (!error) return resolve({ stdout, stderr });
34
+ const notStarted = typeof error.syscall === 'string' && error.syscall.startsWith('spawn');
35
+ reject(Object.assign(new Error(`codex ${args[0]} failed: ${stderr.trim().split('\n').at(-1) || error.message}`), { notStarted }));
36
+ });
37
+ });
38
+ }
@@ -0,0 +1,47 @@
1
+ // Makes sure Codex's local daemon runs before a message is queued that nothing else may run.
2
+ // A queued message is only stored; the daemon loads the chat and runs it. Only `--help`,
3
+ // `version` and `start` are ever issued: nothing here stops, restarts, updates or configures it.
4
+ const VERSION_TIMEOUT_MS = 10_000;
5
+ const START_TIMEOUT_MS = 30_000;
6
+
7
+ export const DAEMON_NOT_STARTED =
8
+ 'queued, but the daemon could not be started, so the message may not run until the chat is opened in the Codex app';
9
+
10
+ const daemon = (command) => ['app-server', 'daemon', command];
11
+
12
+ // Whether `codex app-server daemon` offers `start` and `version`, asked once per runner.
13
+ const offered = new WeakMap();
14
+ function offersDaemon(runCli) {
15
+ if (!offered.has(runCli)) {
16
+ const listed = ({ stdout = '' } = {}) => /^\s+start\s/m.test(stdout) && /^\s+version\s/m.test(stdout);
17
+ offered.set(runCli, runCli(daemon('--help')).then(listed, () => false));
18
+ }
19
+ return offered.get(runCli);
20
+ }
21
+
22
+ // Seen live: a running daemon answers one JSON object with `"status":"running"`; a stopped one
23
+ // makes the command fail with a connection error. Anything else counts as not running, which
24
+ // only costs a `start`, and that leaves a running daemon alone.
25
+ async function isRunning(runCli) {
26
+ try {
27
+ const { stdout = '' } = (await runCli(daemon('version'), { timeoutMs: VERSION_TIMEOUT_MS })) ?? {};
28
+ return JSON.parse(stdout)?.status === 'running';
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ // True when the daemon runs, or was started, or a concurrent sender started it first.
35
+ // Never throws: nothing is queued yet, so a failure here must not count as a delivery.
36
+ // A failed start is checked again with `version`: the wording of a lost race was not seen,
37
+ // and a start that hands its output pipes to the daemon may only end at its time limit.
38
+ export async function ensureDaemon(runCli) {
39
+ if (!(await offersDaemon(runCli))) return false;
40
+ if (await isRunning(runCli)) return true;
41
+ try {
42
+ await runCli(daemon('start'), { timeoutMs: START_TIMEOUT_MS });
43
+ return true;
44
+ } catch (error) {
45
+ return /already.?running/i.test(error?.message ?? '') || isRunning(runCli);
46
+ }
47
+ }
@@ -0,0 +1,90 @@
1
+ // Client for `codex app-server` over stdio: one JSON object per line. Requests carry an id;
2
+ // notifications do not. This is the entry point the Codex desktop app itself uses.
3
+ import { spawn } from 'node:child_process';
4
+ import readline from 'node:readline';
5
+
6
+ const CLIENT_INFO = Object.freeze({ name: 'nomoremeatproxy', title: 'NOMOREMEATPROXY', version: '0.1.0' });
7
+ // Seen on Windows: the server was gone 15 to 25 ms after it was told to stop.
8
+ const EXIT_TIMEOUT_MS = 5000;
9
+
10
+ export const SERVER_STILL_RUNNING = `the bridge's own Codex server had not exited ${EXIT_TIMEOUT_MS / 1000} seconds after it was told to stop`;
11
+
12
+ function connect({ onNotification, onClosed, start }) {
13
+ // Constant command line: payloads travel as JSON on stdin, never through the shell.
14
+ const server = start('codex app-server', { shell: true, stdio: ['pipe', 'pipe', 'ignore'] });
15
+ // 'close', not 'exit': the server runs under a shell, which exits at the stop signal while the
16
+ // server itself is still running. Its output closes only when every process holding it has
17
+ // ended. Seen on Windows: shell, then Codex's Node launcher, then the server.
18
+ const ended = new Promise((resolve) => server.once('close', resolve));
19
+ const pending = new Map();
20
+ let nextId = 1;
21
+ const write = (message) => server.stdin.write(`${JSON.stringify(message)}\n`);
22
+ const closed = (error) => {
23
+ for (const waiter of pending.values()) waiter.reject(error);
24
+ pending.clear();
25
+ onClosed(error);
26
+ };
27
+ server.on('error', closed);
28
+ server.on('exit', () => closed(new Error('codex app-server exited before the work completed')));
29
+
30
+ readline.createInterface({ input: server.stdout }).on('line', (line) => {
31
+ let message;
32
+ try {
33
+ message = JSON.parse(line);
34
+ } catch {
35
+ return;
36
+ }
37
+ if (message.method === undefined) {
38
+ const waiter = pending.get(message.id);
39
+ if (!waiter) return;
40
+ pending.delete(message.id);
41
+ if (message.error) waiter.reject(new Error(`Codex rejected the request: ${JSON.stringify(message.error)}`));
42
+ else waiter.resolve(message.result);
43
+ } else if (message.id === undefined) {
44
+ onNotification(message.method, message.params ?? {});
45
+ } else {
46
+ // An approval or question for a human. Nobody is there, so decline instead of hanging.
47
+ write({ id: message.id, error: { code: -32601, message: 'nomeat cannot answer interactive requests' } });
48
+ }
49
+ });
50
+
51
+ return {
52
+ request: (method, params) =>
53
+ new Promise((resolve, reject) => {
54
+ const id = nextId;
55
+ nextId += 1;
56
+ pending.set(id, { resolve, reject });
57
+ write({ id, method, params });
58
+ }),
59
+ notify: (method) => write({ method }),
60
+ // Stops the server and waits for it to end, up to the limit. True when it ended in time.
61
+ // Never throws, so that it cannot replace the result of the work.
62
+ close: async (timeoutMs) => {
63
+ try {
64
+ server.stdin.end();
65
+ server.kill();
66
+ } catch {
67
+ // Waited for below like any other stop.
68
+ }
69
+ let timer;
70
+ const limit = new Promise((resolve) => (timer = setTimeout(resolve, timeoutMs, false)));
71
+ const exited = await Promise.race([ended.then(() => true), limit]);
72
+ clearTimeout(timer);
73
+ return exited;
74
+ },
75
+ };
76
+ }
77
+
78
+ // Starts a server, completes the handshake, runs `work(client)` and always shuts the server down.
79
+ // It returns once the server has ended, so that nothing holds a thread the work used; a server
80
+ // that has not ended at the limit is left and reported to `onStillRunning`.
81
+ export async function withCodexServer(work, { onNotification = () => {}, onClosed = () => {}, onStillRunning = () => {}, start = spawn, exitTimeoutMs = EXIT_TIMEOUT_MS } = {}) {
82
+ const client = connect({ onNotification, onClosed, start });
83
+ try {
84
+ await client.request('initialize', { clientInfo: CLIENT_INFO });
85
+ client.notify('initialized');
86
+ return await work(client);
87
+ } finally {
88
+ if (!(await client.close(exitTimeoutMs))) onStillRunning();
89
+ }
90
+ }
@@ -0,0 +1,268 @@
1
+ // Codex adapter for calls. Threads are created through `codex app-server`, so they are listed
2
+ // in the Codex desktop app; threads from `codex exec` are not. A thread the app holds open
3
+ // accepts only one writer, so messages for it go through Codex's queue and the app runs them.
4
+ // Sections: turn state (pure) / direct turn / queued turn / callCodex.
5
+ import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { codexBaseline, codexUsage } from '../usage.mjs';
7
+ import { runCodexCli } from './codex-cli.mjs';
8
+ import { DAEMON_NOT_STARTED, ensureDaemon } from './codex-daemon.mjs';
9
+ import { SERVER_STILL_RUNNING, withCodexServer } from './codex-transport.mjs';
10
+
11
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
12
+ const POLL_MS = 3000;
13
+ const ACTIVE_WRITER = /already has an active writer/;
14
+
15
+ const notSent = (message) => Object.assign(new Error(message), { notSent: true });
16
+
17
+ // --- turn state (pure) ---
18
+
19
+ export const newTurnState = () => ({ threadId: null, turnId: null, replies: [], done: false, status: null, error: null, usageBefore: null, usageNow: null });
20
+
21
+ // One server reports every thread it runs, a helper agent's threads included. An event names its
22
+ // thread (`threadId`) and turn (`turnId`, or `turn.id` on turn/completed); it counts only when
23
+ // those match this call's. The turn id is known once turn/start answers, so an event of this
24
+ // thread that comes before that is still taken. An event that names neither counts, as before.
25
+ function ownEvent(state, params) {
26
+ const turnId = params.turnId ?? params.turn?.id;
27
+ if (params.threadId == null && turnId == null) return true;
28
+ if (params.threadId != null && params.threadId !== state.threadId) return false;
29
+ return turnId == null || state.turnId == null || turnId === state.turnId;
30
+ }
31
+
32
+ export function foldTurnEvent(state, method, params) {
33
+ if (!ownEvent(state, params)) return state;
34
+ if (method === 'item/completed' && params.item?.type === 'agentMessage') {
35
+ return { ...state, replies: [...state.replies, params.item] };
36
+ }
37
+ if (method === 'thread/tokenUsage/updated' && params.tokenUsage?.total) {
38
+ return { ...state, usageBefore: state.usageBefore ?? codexBaseline(params.tokenUsage), usageNow: params.tokenUsage.total };
39
+ }
40
+ if (method === 'turn/completed') {
41
+ const turn = params.turn ?? {};
42
+ return { ...state, done: true, status: turn.status ?? 'completed', error: turn.error?.message ?? null };
43
+ }
44
+ return state;
45
+ }
46
+
47
+ const endTurn = (state, status, error) => (state.done ? state : { ...state, done: true, status, error });
48
+
49
+ // A turn's answer, from its agent messages in order. Codex marks interim status lines with the
50
+ // phase `commentary`, and answer text with `final_answer` or, by its schema, `partial_answer`
51
+ // (not seen so far). Where no message is marked as answer, as in a turn that did not finish or
52
+ // from a model that sets no phase, the last message stands.
53
+ const ANSWER = new Set(['final_answer', 'partial_answer']);
54
+
55
+ export function turnAnswer(messages) {
56
+ const answer = messages.filter((message) => ANSWER.has(message.phase));
57
+ return (answer.length ? answer : messages.slice(-1)).map((message) => message.text).join('\n\n');
58
+ }
59
+
60
+ const carries = (item, marker) =>
61
+ item.type === 'userMessage' && (item.content ?? []).some((part) => part.type === 'text' && part.text.includes(marker));
62
+
63
+ // The turn that carries this request's marker, or null when the thread does not have it yet.
64
+ // Read from outside, a turn the app is still running reports `interrupted`, so only
65
+ // `completed` and `failed` are final.
66
+ export function findRequestTurn(turns, marker) {
67
+ const turn = turns.findLast((candidate) => (candidate.items ?? []).some((item) => carries(item, marker)));
68
+ if (!turn) return null;
69
+ const reply = turnAnswer(turn.items.filter((item) => item.type === 'agentMessage'));
70
+ return { status: turn.status, reply, error: turn.error?.message ?? null };
71
+ }
72
+
73
+ async function readTurns(client, threadId) {
74
+ const { thread } = await client.request('thread/read', { threadId, includeTurns: true });
75
+ return thread.turns ?? [];
76
+ }
77
+
78
+ async function readRequestTurn(client, { threadId, marker }) {
79
+ return findRequestTurn(await readTurns(client, threadId), marker);
80
+ }
81
+
82
+ // For a retry after an uncertain delivery: the reply, if the thread already holds a finished one.
83
+ export async function recoverCodexReply({ threadId, marker }) {
84
+ if (!threadId) return null;
85
+ const turn = await withCodexServer((client) => readRequestTurn(client, { threadId, marker }));
86
+ return turn?.status === 'completed' ? { reply: turn.reply } : null;
87
+ }
88
+
89
+ // Where a request stands in its thread, for following up on a message sent without waiting.
90
+ // A request not in the thread yet is `waiting`. `idle` is true only when the thread's newest
91
+ // turn is seen finished, so nothing is running there; without turns nothing is seen.
92
+ export function describeRequestTurn(turns, marker) {
93
+ const turn = findRequestTurn(turns, marker);
94
+ if (!turn) return { state: 'waiting', idle: ['completed', 'failed'].includes(turns.at(-1)?.status) };
95
+ if (turn.status === 'completed') return { state: 'answered', reply: turn.reply };
96
+ if (turn.status === 'failed') return { state: 'failed', error: turn.error };
97
+ return { state: 'running' };
98
+ }
99
+
100
+ export async function checkCodexRequest({ threadId, marker }) {
101
+ return describeRequestTurn(await withCodexServer((client) => readTurns(client, threadId)), marker);
102
+ }
103
+
104
+ // --- direct turn ---
105
+
106
+ // Without a model (null or undefined) none is sent, so Codex applies its own default.
107
+ export const modelParam = (model) => (model == null ? {} : { model });
108
+
109
+ // A new thread gets the bridge's policy and a title. An existing thread keeps its own
110
+ // settings, which may belong to a thread the owner uses in the app; only `write` changes them.
111
+ async function openThread(client, { threadId, title, model, write, cwd }) {
112
+ if (threadId) {
113
+ const resumed = await client.request('thread/resume', { threadId, ...(write ? { sandbox: 'workspace-write' } : {}) });
114
+ return resumed.thread.id;
115
+ }
116
+ const policy = { cwd, ...modelParam(model), sandbox: write ? 'workspace-write' : 'read-only', approvalPolicy: 'never' };
117
+ const started = await client.request('thread/start', policy);
118
+ // The title is cosmetic; a refusal must not fail the call.
119
+ if (title) await client.request('thread/name/set', { threadId: started.thread.id, name: title }).catch(() => {});
120
+ return started.thread.id;
121
+ }
122
+
123
+ // What a direct turn applied. A resumed thread keeps the access it had unless `write` raised it.
124
+ export const appliedSettings = ({ threadId, write, effort }) => ({
125
+ ...(write ? { access: 'write' } : threadId ? {} : { access: 'read-only' }),
126
+ ...(effort ? { effort } : {}),
127
+ });
128
+
129
+ async function runTurn({ threadId, message, title, model, effort, write, cwd, onThread, timeoutMs, withServer }) {
130
+ let turn = newTurnState();
131
+ let settle;
132
+ const finished = new Promise((resolve) => {
133
+ settle = resolve;
134
+ });
135
+ const finish = (next) => {
136
+ turn = next;
137
+ if (turn.done) settle();
138
+ };
139
+ const timer = setTimeout(() => finish(endTurn(turn, 'timed-out', `no completed turn within ${timeoutMs} ms`)), timeoutMs);
140
+ let stillRunning = false;
141
+ try {
142
+ const result = await withServer(
143
+ async (client) => {
144
+ const id = await openThread(client, { threadId, title, model, write, cwd }).catch((error) => {
145
+ // Nothing has been sent yet, so a retry cannot duplicate anything.
146
+ error.notSent = true;
147
+ throw error;
148
+ });
149
+ turn = { ...turn, threadId: id };
150
+ onThread(id);
151
+ const started = await client.request('turn/start', {
152
+ threadId: id,
153
+ input: [{ type: 'text', text: message }],
154
+ ...modelParam(model),
155
+ ...(effort ? { effort } : {}),
156
+ });
157
+ turn = { ...turn, turnId: started?.turn?.id ?? null };
158
+ await finished;
159
+ if (turn.status !== 'completed') {
160
+ throw new Error(`Codex turn ${turn.status}: ${turn.error ?? 'no detail'} (thread ${id})`);
161
+ }
162
+ const usage = codexUsage(turn.usageBefore, turn.usageNow);
163
+ return { threadId: id, reply: turnAnswer(turn.replies), delivery: 'direct', ...appliedSettings({ threadId, write, effort }), ...(usage ? { usage } : {}) };
164
+ },
165
+ {
166
+ onNotification: (method, params) => finish(foldTurnEvent(turn, method, params)),
167
+ onClosed: (error) => finish(endTurn(turn, 'failed', error.message)),
168
+ onStillRunning: () => (stillRunning = true),
169
+ },
170
+ );
171
+ // The answer stands; the server may still hold the thread.
172
+ return stillRunning ? { ...result, warning: SERVER_STILL_RUNNING } : result;
173
+ } finally {
174
+ clearTimeout(timer);
175
+ }
176
+ }
177
+
178
+ // --- queued turn ---
179
+
180
+ // Whether `codex queue` offers `--resume`, asked once per runner: once per process for the real one.
181
+ const resumeOffered = new WeakMap();
182
+ function offersResume(runCli) {
183
+ if (!resumeOffered.has(runCli)) {
184
+ resumeOffered.set(runCli, runCli(['queue', '--help']).then(({ stdout = '' } = {}) => /--resume\b/.test(stdout), () => false));
185
+ }
186
+ return resumeOffered.get(runCli);
187
+ }
188
+
189
+ // Hands the message to Codex's queue, exactly once. A queued message is only stored; `--resume`
190
+ // asks the local daemon to load the chat and run its queue. A failed resume warns on stderr
191
+ // but succeeds, so the delivery stands and the warning goes back to the caller. Codex also
192
+ // prints routine warnings on stderr (seen: one about PATH aliases), so only lines that mention
193
+ // resuming count. Limit: the wording of a failed resume was not seen; one that does not
194
+ // mention resuming is missed.
195
+ const resumeWarning = (stderr) => stderr.split(/\r?\n/).filter((line) => /resum/i.test(line)).join('\n').trim();
196
+
197
+ // A failure of the queue command counts as not sent only when it never ran (`notStarted`).
198
+ // Otherwise the message may already be queued, for example when a resume hangs past the time
199
+ // limit, so the error is left as possibly delivered and a retry of the request does not queue again.
200
+ async function queueMessage({ threadId, message, runCli }) {
201
+ const resume = (await offersResume(runCli)) ? ['--resume'] : [];
202
+ const ran = await runCli(['queue', '--thread', threadId, '--message', message, ...resume]).catch((error) => {
203
+ error.notSent = error.notStarted === true;
204
+ throw error;
205
+ });
206
+ const { stderr = '' } = ran ?? {};
207
+ const warning = resume.length ? resumeWarning(stderr) : '';
208
+ return warning ? { warning } : {};
209
+ }
210
+
211
+ // The app that holds the thread runs the turn with the thread's own model and settings, so the
212
+ // daemon is not started here. Seen live: Codex warns that it could not resume such a thread
213
+ // although the app runs it, so the warning is left out of an answer and kept when none arrives.
214
+ async function runQueuedTurn({ threadId, message, marker, onThread, timeoutMs, runCli, withServer }) {
215
+ const warned = await queueMessage({ threadId, message, runCli });
216
+ onThread(threadId);
217
+ const deadline = Date.now() + timeoutMs;
218
+ return withServer(async (client) => {
219
+ while (Date.now() < deadline) {
220
+ const turn = await readRequestTurn(client, { threadId, marker });
221
+ if (turn?.status === 'completed') return { threadId, reply: turn.reply, delivery: 'queued' };
222
+ if (turn?.status === 'failed') throw new Error(`Codex turn failed: ${turn.error ?? 'no detail'} (thread ${threadId})`);
223
+ await sleep(POLL_MS);
224
+ }
225
+ const warning = warned.warning ? `; codex warned: ${warned.warning}` : '';
226
+ throw new Error(`the message is queued for thread ${threadId} but no finished reply appeared within ${timeoutMs} ms; it has not finished, cause unknown${warning}`);
227
+ });
228
+ }
229
+
230
+ // --- callCodex ---
231
+
232
+ // The app runs a queued message with the thread's own access, which `write` cannot raise.
233
+ function refuseQueuedWrite({ write, threadAccess }) {
234
+ if (write && threadAccess !== 'write') {
235
+ throw notSent(
236
+ "the Codex app holds this thread and runs queued messages with the thread's own access, which the bridge did not set to write; send without --write if that chat already has write access in the app, or start a thread with --new --write",
237
+ );
238
+ }
239
+ }
240
+
241
+ // Hands the message to Codex's queue and returns, without knowing when it will run. Nothing may
242
+ // have the chat loaded, so Codex's daemon is started first. Without `--resume` nothing asks the
243
+ // daemon to run the message, so it is left alone and the message is queued as before.
244
+ async function queueOnly({ threadId, message, onThread, write, threadAccess, runCli }) {
245
+ if (!threadId) throw notSent('sending without waiting needs an existing Codex thread; name it with --thread or call once with waiting first');
246
+ refuseQueuedWrite({ write, threadAccess });
247
+ const daemonUp = (await offersResume(runCli)) ? await ensureDaemon(runCli) : true;
248
+ const { warning = '' } = await queueMessage({ threadId, message, runCli });
249
+ onThread(threadId);
250
+ // Seen live: for a chat the app holds, Codex warns that the thread already has an active
251
+ // writer, and the app runs the message. That line is left out; any other is passed back.
252
+ const codexWarning = warning.split('\n').filter((line) => !ACTIVE_WRITER.test(line)).join('\n');
253
+ if (daemonUp) return { threadId, delivery: 'queued', ...(codexWarning ? { warning: codexWarning } : {}) };
254
+ const codexLine = codexWarning ? `; codex warned: ${codexWarning}` : '';
255
+ return { threadId, delivery: 'queued', warning: `${DAEMON_NOT_STARTED}${codexLine}` };
256
+ }
257
+
258
+ // `threadAccess` is what the ledger recorded for the thread, or null when unknown.
259
+ export async function callCodex({ threadId, message, marker, title, model, effort, write = false, threadAccess = null, cwd = process.cwd(), onThread = () => {}, timeoutMs = DEFAULT_TIMEOUT_MS, noWait = false, runCli = runCodexCli, withServer = withCodexServer }) {
260
+ if (noWait) return queueOnly({ threadId, message, onThread, write, threadAccess, runCli });
261
+ try {
262
+ return await runTurn({ threadId, message, title, model, effort, write, cwd, onThread, timeoutMs, withServer });
263
+ } catch (error) {
264
+ if (!threadId || !ACTIVE_WRITER.test(error.message)) throw error;
265
+ refuseQueuedWrite({ write, threadAccess });
266
+ return runQueuedTurn({ threadId, message, marker, onThread, timeoutMs, runCli, withServer });
267
+ }
268
+ }