@tensor.chat/companion 0.4.6 → 0.5.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/package.json +1 -1
- package/src/daemon.js +17 -2
- package/src/toolExecutor.js +11 -1
- package/src/toolRunner.js +14 -4
- package/src/toolWorker.js +37 -1
- package/src/tools/codex.js +218 -0
- package/src/tools/codexTurn.js +171 -0
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -17,6 +17,9 @@ const VERSION = require('../package.json').version;
|
|
|
17
17
|
const RECONNECT_MIN_MS = 1000;
|
|
18
18
|
const RECONNECT_MAX_MS = 60000;
|
|
19
19
|
const MAX_CONCURRENT_CALLS = 4;
|
|
20
|
+
// A Codex turn holds no call slot: it waits on the server most of the time, and the
|
|
21
|
+
// tools its model calls may need the slots on this very computer.
|
|
22
|
+
const MAX_CODEX_TURNS = 2;
|
|
20
23
|
const SLOW_SPAWN_MS = 2000;
|
|
21
24
|
|
|
22
25
|
// What a call works on, for the log: the command, the path or the address
|
|
@@ -62,6 +65,9 @@ function systemInfo(shell, localAuthority = false, approver = null) {
|
|
|
62
65
|
cwd: process.cwd(),
|
|
63
66
|
localAuthority: localAuthority ? 1 : null,
|
|
64
67
|
approver: approver === 'app' || approver === 'terminal' ? approver : null,
|
|
68
|
+
// which version of the codex tool (tools/codex.js) this app answers; absent = none
|
|
69
|
+
// 2: also runs chat turns (action "turn")
|
|
70
|
+
codex: 2,
|
|
65
71
|
};
|
|
66
72
|
}
|
|
67
73
|
|
|
@@ -110,6 +116,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
110
116
|
const localApprovals = typeof approvedRoots === 'function' ? approvedRoots : null;
|
|
111
117
|
const localShellApproval = typeof approvedShell === 'function' ? approvedShell : null;
|
|
112
118
|
const running = new Map(); // call id → AbortController
|
|
119
|
+
const turns = new Map(); // call id of a Codex turn → answer(key, value)
|
|
113
120
|
const tools = new ToolRunner({ shell, slowSpawnMs, workerPath: toolWorkerPath });
|
|
114
121
|
tools.on('log', say);
|
|
115
122
|
const unsentExits = []; // job endings the server has not heard yet (offline at the time)
|
|
@@ -164,12 +171,14 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
164
171
|
emitter.emit('result', { id, tool, ok: false });
|
|
165
172
|
return;
|
|
166
173
|
}
|
|
167
|
-
|
|
174
|
+
const turn = tool === 'codex' && msg.args && msg.args.action === 'turn';
|
|
175
|
+
if (turn ? turns.size >= MAX_CODEX_TURNS : running.size - turns.size >= MAX_CONCURRENT_CALLS) {
|
|
168
176
|
send({ t: 'result', id, ok: false, text: '(the companion device is busy with other calls — try again in a moment)' });
|
|
169
177
|
return;
|
|
170
178
|
}
|
|
171
179
|
const ctrl = new AbortController();
|
|
172
180
|
running.set(id, ctrl);
|
|
181
|
+
if (turn) turns.set(id, null);
|
|
173
182
|
const finished = stalls.started(tool, callSubject(tool, msg.args));
|
|
174
183
|
emitter.emit('call', { id, tool: msg.tool, args: msg.args });
|
|
175
184
|
let r;
|
|
@@ -180,12 +189,17 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
180
189
|
const scoped = authorizeRequestedRoots(msg.roots, msg.labels, approved);
|
|
181
190
|
callRoots = new Roots(scoped.roots, scoped.labels);
|
|
182
191
|
}
|
|
183
|
-
|
|
192
|
+
const talk = turn ? {
|
|
193
|
+
onEvent: (ev) => send({ t: 'call_event', id, ev }),
|
|
194
|
+
ready: (answer) => turns.set(id, answer),
|
|
195
|
+
} : null;
|
|
196
|
+
r = await tools.run(tool, msg.args, ctrl.signal, Number(msg.timeoutMs) || undefined, callRoots, talk);
|
|
184
197
|
} catch (e) {
|
|
185
198
|
r = { ok: false, text: `(tool error on the companion device: ${e && e.message ? e.message : e})` };
|
|
186
199
|
}
|
|
187
200
|
finished();
|
|
188
201
|
running.delete(id);
|
|
202
|
+
turns.delete(id);
|
|
189
203
|
send({ t: 'result', id, ...r });
|
|
190
204
|
emitter.emit('result', { id, tool: msg.tool, ok: r.ok });
|
|
191
205
|
}
|
|
@@ -217,6 +231,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
217
231
|
case 'config': applyConfig(msg); break;
|
|
218
232
|
case 'call': onCall(msg); break;
|
|
219
233
|
case 'cancel': { const c = running.get(String(msg.id)); if (c) c.abort(); break; }
|
|
234
|
+
case 'call_input': { const answer = turns.get(String(msg.id)); if (answer) answer(String(msg.key), msg.value); break; }
|
|
220
235
|
case 'approval': emitter.emit('approval', msg); break;
|
|
221
236
|
case 'approval_resolved': emitter.emit('approval_resolved', msg); break;
|
|
222
237
|
case 'local_approval_request': confirmIfCovered(msg); break;
|
package/src/toolExecutor.js
CHANGED
|
@@ -6,6 +6,8 @@ const { fetchUrl } = require('./tools/fetch');
|
|
|
6
6
|
const { extractTouched } = require('./tools/touched');
|
|
7
7
|
const { outsideFolders } = require('./tools/outside');
|
|
8
8
|
const { Jobs } = require('./tools/jobs');
|
|
9
|
+
const codex = require('./tools/codex');
|
|
10
|
+
const codexTurn = require('./tools/codexTurn');
|
|
9
11
|
|
|
10
12
|
const BACKGROUND_FIRST_LOOK_MS = 1500;
|
|
11
13
|
const FOREGROUND_DEFAULT_MS = 600000;
|
|
@@ -13,7 +15,9 @@ const FOREGROUND_DEFAULT_MS = 600000;
|
|
|
13
15
|
function createToolExecutor({ shell, say = () => {}, slowSpawnMs = 2000 } = {}) {
|
|
14
16
|
const jobs = new Jobs();
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
// io: what a call that talks with the server while it runs (a Codex turn) needs -
|
|
19
|
+
// emit(ev) sends an event, input(key) waits for the server's answer to one.
|
|
20
|
+
async function runTool(tool, args, signal, timeoutMs, callRoots, requestedAt = Date.now(), cancelled = () => false, io = null) {
|
|
17
21
|
const a = args && typeof args === 'object' ? args : {};
|
|
18
22
|
if (signal.aborted || cancelled()) throw new Error('call cancelled before execution');
|
|
19
23
|
switch (tool) {
|
|
@@ -75,6 +79,12 @@ function createToolExecutor({ shell, say = () => {}, slowSpawnMs = 2000 } = {})
|
|
|
75
79
|
case 'renamepath': return files.renamepath(callRoots, a.from, a.to);
|
|
76
80
|
case 'statpath': return files.statpath(callRoots, a.path);
|
|
77
81
|
case 'fetch': return fetchUrl(a.url, { signal, raw: a.raw === true });
|
|
82
|
+
case 'codex':
|
|
83
|
+
if (a.action === 'turn') {
|
|
84
|
+
if (!io) return { ok: false, text: '(a Codex turn needs a connection to the server)' };
|
|
85
|
+
return codexTurn.runTurn(a, { emit: io.emit, toolResult: io.input, signal });
|
|
86
|
+
}
|
|
87
|
+
return codex.run(a, { signal, timeoutMs });
|
|
78
88
|
default: return { ok: false, text: `(unknown tool "${tool}" on the companion device)` };
|
|
79
89
|
}
|
|
80
90
|
}
|
package/src/toolRunner.js
CHANGED
|
@@ -23,7 +23,9 @@ class ToolRunner extends EventEmitter {
|
|
|
23
23
|
if (this.failure) throw this.failure;
|
|
24
24
|
if (Atomics.load(this.stopping, 0)) throw new Error('tool worker is stopped');
|
|
25
25
|
if (this.worker) return;
|
|
26
|
-
|
|
26
|
+
// The worker's env is named for the same reason as in tools/codex.js: by default it
|
|
27
|
+
// copies the env of the Node process, which under jest is not this module's.
|
|
28
|
+
const worker = new Worker(this.workerPath, { env: process.env, workerData: { ...this.options, stopping: this.stopping.buffer } });
|
|
27
29
|
this.worker = worker;
|
|
28
30
|
this.closed = new Promise((resolve) => worker.once('exit', resolve));
|
|
29
31
|
const fail = (error) => {
|
|
@@ -34,6 +36,10 @@ class ToolRunner extends EventEmitter {
|
|
|
34
36
|
worker.on('message', (msg) => {
|
|
35
37
|
if (msg.t === 'log') this.emit('log', msg.line);
|
|
36
38
|
else if (msg.t === 'job_exit') this.emit('exit', msg.report);
|
|
39
|
+
else if (msg.t === 'event') {
|
|
40
|
+
const p = this.pending.get(msg.id);
|
|
41
|
+
if (p && p.onEvent) p.onEvent(msg.ev);
|
|
42
|
+
}
|
|
37
43
|
else if (msg.t === 'result') {
|
|
38
44
|
const p = this.pending.get(msg.id);
|
|
39
45
|
if (!p) return;
|
|
@@ -48,7 +54,10 @@ class ToolRunner extends EventEmitter {
|
|
|
48
54
|
});
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
// talk: for a call that talks with the server while it runs. onEvent(ev) gets what
|
|
58
|
+
// the call sends out; answer(key, value) - handed over through talk.ready - sends it
|
|
59
|
+
// what the server answered.
|
|
60
|
+
async run(tool, args, signal, timeoutMs, roots, talk = null) {
|
|
52
61
|
this.start();
|
|
53
62
|
const id = this.nextId++;
|
|
54
63
|
const cancelled = new Int32Array(new SharedArrayBuffer(4));
|
|
@@ -63,9 +72,10 @@ class ToolRunner extends EventEmitter {
|
|
|
63
72
|
throw new Error('call cancelled before execution');
|
|
64
73
|
}
|
|
65
74
|
return await new Promise((resolve, reject) => {
|
|
66
|
-
this.pending.set(id, { resolve, reject });
|
|
75
|
+
this.pending.set(id, { resolve, reject, onEvent: talk && talk.onEvent });
|
|
76
|
+
if (talk && talk.ready) talk.ready((key, value) => this.worker.postMessage({ t: 'input', id, key, value }));
|
|
67
77
|
try {
|
|
68
|
-
this.worker.postMessage({ t: 'call', id, tool, args, timeoutMs, entries: roots.entries, requestedAt: Date.now(), cancelled: cancelled.buffer });
|
|
78
|
+
this.worker.postMessage({ t: 'call', id, tool, args, timeoutMs, entries: roots.entries, requestedAt: Date.now(), cancelled: cancelled.buffer, talks: !!talk });
|
|
69
79
|
} catch (e) {
|
|
70
80
|
this.pending.delete(id);
|
|
71
81
|
reject(e);
|
package/src/toolWorker.js
CHANGED
|
@@ -11,6 +11,28 @@ const executor = createToolExecutor({
|
|
|
11
11
|
say: (line) => parentPort.postMessage({ t: 'log', line }),
|
|
12
12
|
});
|
|
13
13
|
const running = new Map();
|
|
14
|
+
// coverage:host-only this file runs only as the tool worker thread, which coverage does not see; _codexLocalTurn.js drives every Codex turn through this worker for real (daemon -> worker -> codexTurn) and runs the same executor call in its own process
|
|
15
|
+
// A call that talks with the server while it runs (a Codex turn): the answers it
|
|
16
|
+
// waits for, by key, and those that came before it asked.
|
|
17
|
+
const inputs = new Map();
|
|
18
|
+
|
|
19
|
+
function ioFor(id, signal) {
|
|
20
|
+
const box = { waiting: new Map(), early: new Map() };
|
|
21
|
+
inputs.set(id, box);
|
|
22
|
+
signal.addEventListener('abort', () => {
|
|
23
|
+
for (const done of box.waiting.values()) done({ ok: false, text: '(cancelled)' });
|
|
24
|
+
box.waiting.clear();
|
|
25
|
+
}, { once: true });
|
|
26
|
+
return {
|
|
27
|
+
emit: (ev) => parentPort.postMessage({ t: 'event', id, ev }),
|
|
28
|
+
input: (key) => new Promise((resolve) => {
|
|
29
|
+
if (box.early.has(key)) { resolve(box.early.get(key)); box.early.delete(key); return; }
|
|
30
|
+
if (signal.aborted) { resolve({ ok: false, text: '(cancelled)' }); return; }
|
|
31
|
+
box.waiting.set(key, resolve);
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// coverage:host-only-end
|
|
14
36
|
executor.jobs.on('exit', (report) => parentPort.postMessage({ t: 'job_exit', report }));
|
|
15
37
|
|
|
16
38
|
parentPort.on('message', async (msg) => {
|
|
@@ -19,6 +41,15 @@ parentPort.on('message', async (msg) => {
|
|
|
19
41
|
if (ctrl) ctrl.abort();
|
|
20
42
|
return;
|
|
21
43
|
}
|
|
44
|
+
// coverage:host-only the tool worker thread (see above): the server's answers to a running Codex turn
|
|
45
|
+
if (msg.t === 'input') {
|
|
46
|
+
const box = inputs.get(msg.id);
|
|
47
|
+
if (!box) return;
|
|
48
|
+
const done = box.waiting.get(msg.key);
|
|
49
|
+
if (done) { box.waiting.delete(msg.key); done(msg.value); } else box.early.set(msg.key, msg.value);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// coverage:host-only-end
|
|
22
53
|
if (msg.t === 'stop') {
|
|
23
54
|
for (const ctrl of running.values()) ctrl.abort();
|
|
24
55
|
executor.jobs.killAll();
|
|
@@ -37,11 +68,16 @@ parentPort.on('message', async (msg) => {
|
|
|
37
68
|
const roots = new Roots([]);
|
|
38
69
|
// This is the authorized per-call snapshot, never the worker's previous roots.
|
|
39
70
|
roots.entries = msg.entries;
|
|
40
|
-
|
|
71
|
+
// coverage:host-only the tool worker thread (see above): a Codex turn gets its line to the server
|
|
72
|
+
result = await executor.runTool(msg.tool, msg.args, ctrl.signal, msg.timeoutMs, roots, msg.requestedAt, isCancelled, msg.talks ? ioFor(msg.id, ctrl.signal) : null);
|
|
73
|
+
// coverage:host-only-end
|
|
41
74
|
} catch (e) {
|
|
42
75
|
result = { ok: false, text: `(tool error on the companion device: ${e.message})` };
|
|
43
76
|
} finally {
|
|
44
77
|
running.delete(msg.id);
|
|
78
|
+
// coverage:host-only the tool worker thread (see above)
|
|
79
|
+
inputs.delete(msg.id);
|
|
80
|
+
// coverage:host-only-end
|
|
45
81
|
}
|
|
46
82
|
parentPort.postMessage({ t: 'result', id: msg.id, result });
|
|
47
83
|
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Codex on this computer: is it installed, is it signed in, and how much of the
|
|
3
|
+
// ChatGPT plan's Codex usage is used. The answer comes from the Codex app-server, the
|
|
4
|
+
// interface Codex offers its own clients (https://learn.chatgpt.com/docs/app-server).
|
|
5
|
+
// This only reads: no thread, no turn, no login, no change to Codex's own settings.
|
|
6
|
+
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { killTree } = require('./bash');
|
|
11
|
+
|
|
12
|
+
const VERSION = require('../../package.json').version;
|
|
13
|
+
const WIN = process.platform === 'win32';
|
|
14
|
+
const STATUS_TIMEOUT_MS = 20000;
|
|
15
|
+
// How this client names itself to Codex and, through Codex, to OpenAI.
|
|
16
|
+
const CLIENT_NAME = 'tensor.chat';
|
|
17
|
+
|
|
18
|
+
// The codex command on PATH: the npm shim (codex.cmd) or a native codex.exe on
|
|
19
|
+
// Windows, an executable `codex` elsewhere. TENSOR_CODEX_BIN names another one
|
|
20
|
+
// (a .js file runs with this Node).
|
|
21
|
+
function findCodex(env = process.env) {
|
|
22
|
+
if (env.TENSOR_CODEX_BIN) return fs.existsSync(env.TENSOR_CODEX_BIN) ? env.TENSOR_CODEX_BIN : null;
|
|
23
|
+
const names = WIN ? ['codex.exe', 'codex.cmd'] : ['codex'];
|
|
24
|
+
for (const dir of String(env.PATH || env.Path || '').split(path.delimiter)) {
|
|
25
|
+
if (!dir) continue;
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
const file = path.join(dir, name);
|
|
28
|
+
try {
|
|
29
|
+
fs.accessSync(file, WIN ? fs.constants.F_OK : fs.constants.X_OK);
|
|
30
|
+
if (fs.statSync(file).isFile()) return file;
|
|
31
|
+
} catch (_) {}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// How to start `<bin> app-server`. A .cmd shim needs cmd.exe; the arguments are fixed,
|
|
38
|
+
// so nothing from the server reaches that command line.
|
|
39
|
+
function commandFor(bin) {
|
|
40
|
+
if (/\.js$/i.test(bin)) return { cmd: process.execPath, args: [bin, 'app-server'] };
|
|
41
|
+
if (WIN && /\.cmd$/i.test(bin)) return { cmd: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${bin}" app-server`], verbatim: true };
|
|
42
|
+
return { cmd: bin, args: ['app-server'] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NOT_SUPPORTED = { code: -32601, message: 'not supported by this client' };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One app-server process and its line protocol (JSON-RPC, one message per line).
|
|
49
|
+
* Throws when the process cannot be started.
|
|
50
|
+
* @param {string} bin
|
|
51
|
+
* @param {object} [o]
|
|
52
|
+
* @param {boolean} [o.experimental] ask for Codex's experimental methods and fields;
|
|
53
|
+
* a chat turn needs them for dynamicTools, the status does not
|
|
54
|
+
* @param {(m: object) => (object|undefined|Promise<object|undefined>)} [o.onRequest]
|
|
55
|
+
* answers a request from Codex with a result; undefined answers "not supported"
|
|
56
|
+
* @param {(m: object) => void} [o.onNotification]
|
|
57
|
+
* @returns {{request: Function, ready: Promise<object>, ended: Promise<Error>, kill: Function}}
|
|
58
|
+
* ready: the initialize result, once Codex took the client; ended: why the process stopped
|
|
59
|
+
*/
|
|
60
|
+
function openSession(bin, { experimental = false, onRequest, onNotification } = {}) {
|
|
61
|
+
const { cmd, args, verbatim } = commandFor(bin);
|
|
62
|
+
// env named, not inherited: a spawn without it takes the env of the Node process,
|
|
63
|
+
// and under jest that is not the one this module sees. The originator is set at
|
|
64
|
+
// start too: Codex fetches its model list before initialize, and that request
|
|
65
|
+
// would otherwise go out under the plain CLI's name.
|
|
66
|
+
const env = { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: CLIENT_NAME, ...process.env };
|
|
67
|
+
const child = spawn(cmd, args, { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, windowsVerbatimArguments: !!verbatim, detached: !WIN });
|
|
68
|
+
const waiting = new Map();
|
|
69
|
+
let nextId = 1;
|
|
70
|
+
let buf = '';
|
|
71
|
+
let stderr = '';
|
|
72
|
+
let endedWith = null;
|
|
73
|
+
let onEnd;
|
|
74
|
+
const ended = new Promise((resolve) => { onEnd = resolve; });
|
|
75
|
+
const end = (err) => {
|
|
76
|
+
if (endedWith) return;
|
|
77
|
+
endedWith = err;
|
|
78
|
+
for (const done of waiting.values()) done({ error: { code: -32000, message: err.message } });
|
|
79
|
+
waiting.clear();
|
|
80
|
+
onEnd(err);
|
|
81
|
+
};
|
|
82
|
+
const send = (m) => { try { child.stdin.write(JSON.stringify(m) + '\n'); } catch (_) {} };
|
|
83
|
+
const request = (method, params) => new Promise((done) => {
|
|
84
|
+
if (endedWith) { done({ error: { code: -32000, message: endedWith.message } }); return; }
|
|
85
|
+
const id = nextId++;
|
|
86
|
+
waiting.set(id, done);
|
|
87
|
+
send({ id, method, params });
|
|
88
|
+
});
|
|
89
|
+
const answer = async (m) => {
|
|
90
|
+
let result;
|
|
91
|
+
try { result = onRequest ? await onRequest(m) : undefined; } catch (e) {
|
|
92
|
+
send({ id: m.id, error: { code: -32000, message: String(e && e.message ? e.message : e) } });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
send(result === undefined ? { id: m.id, error: NOT_SUPPORTED } : { id: m.id, result });
|
|
96
|
+
};
|
|
97
|
+
// A write after Codex stopped reading fails on the stream (EPIPE), not in send():
|
|
98
|
+
// without a listener that error would take the whole worker down. 'close' says why.
|
|
99
|
+
child.stdin.on('error', () => {});
|
|
100
|
+
child.on('error', (e) => end(e));
|
|
101
|
+
child.on('close', (code) => end(new Error(`Codex app-server ended (exit ${code})${stderr.trim() ? `: ${stderr.trim().slice(-300)}` : ''}`)));
|
|
102
|
+
child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
|
|
103
|
+
child.stdout.on('data', (d) => {
|
|
104
|
+
buf += d;
|
|
105
|
+
let i;
|
|
106
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
107
|
+
const line = buf.slice(0, i).trim();
|
|
108
|
+
buf = buf.slice(i + 1);
|
|
109
|
+
if (!line) continue;
|
|
110
|
+
let m;
|
|
111
|
+
try { m = JSON.parse(line); } catch (_) { continue; }
|
|
112
|
+
if (m.id != null && m.method) { answer(m); continue; }
|
|
113
|
+
if (m.id != null) {
|
|
114
|
+
const done = waiting.get(m.id);
|
|
115
|
+
if (done) { waiting.delete(m.id); done(m); }
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (m.method && onNotification) onNotification(m);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
const ready = (async () => {
|
|
122
|
+
const init = await request('initialize', {
|
|
123
|
+
clientInfo: { name: CLIENT_NAME, title: CLIENT_NAME, version: VERSION },
|
|
124
|
+
capabilities: { experimentalApi: !!experimental, requestAttestation: false },
|
|
125
|
+
});
|
|
126
|
+
if (init.error) throw new Error(`Codex refused the connection: ${init.error.message || 'unknown error'}`);
|
|
127
|
+
send({ method: 'initialized' });
|
|
128
|
+
return init.result || {};
|
|
129
|
+
})();
|
|
130
|
+
ready.catch(() => {});
|
|
131
|
+
return { request, ready, ended, kill: () => killTree(child) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// One short conversation with the app-server: initialize, then the questions, then
|
|
135
|
+
// the process ends. Resolves with the answers by method; rejects on a broken start.
|
|
136
|
+
function ask(bin, questions, { signal, timeoutMs = STATUS_TIMEOUT_MS } = {}) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
let session;
|
|
139
|
+
try { session = openSession(bin); } catch (e) { reject(e); return; }
|
|
140
|
+
let settled = false;
|
|
141
|
+
const finish = (err, value) => {
|
|
142
|
+
if (settled) return;
|
|
143
|
+
settled = true;
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
146
|
+
session.kill();
|
|
147
|
+
if (err) reject(err); else resolve(value);
|
|
148
|
+
};
|
|
149
|
+
const timer = setTimeout(() => finish(new Error(`Codex did not answer within ${Math.round(timeoutMs / 1000)} s`)), timeoutMs);
|
|
150
|
+
const onAbort = () => finish(new Error('cancelled'));
|
|
151
|
+
if (signal) {
|
|
152
|
+
if (signal.aborted) { finish(new Error('cancelled')); return; }
|
|
153
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
154
|
+
}
|
|
155
|
+
session.ended.then((e) => finish(e));
|
|
156
|
+
(async () => {
|
|
157
|
+
const answers = { initialize: await session.ready };
|
|
158
|
+
for (const [method, params] of questions) {
|
|
159
|
+
const r = await session.request(method, params);
|
|
160
|
+
answers[method] = r.error ? { error: r.error } : (r.result || {});
|
|
161
|
+
}
|
|
162
|
+
finish(null, answers);
|
|
163
|
+
})().catch((e) => finish(e));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const toWindow = (w) => (w && typeof w.usedPercent === 'number'
|
|
168
|
+
? { usedPercent: w.usedPercent, windowMins: Number.isFinite(w.windowDurationMins) ? w.windowDurationMins : null, resetsAt: Number.isFinite(w.resetsAt) ? w.resetsAt : null }
|
|
169
|
+
: null);
|
|
170
|
+
|
|
171
|
+
// The models this Codex offers its user, with the reasoning levels each takes: the
|
|
172
|
+
// server sends a GPT turn here only for a model on this list.
|
|
173
|
+
function modelsOf(list) {
|
|
174
|
+
const data = list && !list.error && Array.isArray(list.data) ? list.data : [];
|
|
175
|
+
return data.filter((m) => m && typeof m.id === 'string' && m.id).slice(0, 50).map((m) => ({
|
|
176
|
+
id: m.id,
|
|
177
|
+
efforts: (Array.isArray(m.supportedReasoningEfforts) ? m.supportedReasoningEfforts : [])
|
|
178
|
+
.map((e) => (e && typeof e === 'object' ? e.reasoningEffort : e)).filter((e) => typeof e === 'string'),
|
|
179
|
+
images: Array.isArray(m.inputModalities) ? m.inputModalities.includes('image') : true,
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// What the server is told. The account's e-mail stays on this computer.
|
|
184
|
+
async function status({ signal, timeoutMs, env } = {}) {
|
|
185
|
+
const bin = findCodex(env);
|
|
186
|
+
if (!bin) return { ok: true, text: 'Codex is not installed on this computer', codex: { installed: false } };
|
|
187
|
+
let a;
|
|
188
|
+
try {
|
|
189
|
+
a = await ask(bin, [['account/read', {}], ['account/rateLimits/read', {}], ['model/list', { includeHidden: false }]], { signal, timeoutMs });
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return { ok: false, text: `(Codex did not answer: ${e.message})`, codex: { installed: true, error: e.message } };
|
|
192
|
+
}
|
|
193
|
+
const ua = String(a.initialize.userAgent || '');
|
|
194
|
+
const version = (/\/(\d+\.\d+\.\d+[\w.-]*)/.exec(ua) || [])[1] || null;
|
|
195
|
+
const account = a['account/read'] && a['account/read'].account;
|
|
196
|
+
const signedIn = !!(account && account.type);
|
|
197
|
+
const rl = a['account/rateLimits/read'];
|
|
198
|
+
const snap = rl && !rl.error ? rl.rateLimits : null;
|
|
199
|
+
const codex = {
|
|
200
|
+
installed: true,
|
|
201
|
+
version,
|
|
202
|
+
signedIn,
|
|
203
|
+
authMode: signedIn ? String(account.type) : null,
|
|
204
|
+
planType: signedIn && account.planType ? String(account.planType) : null,
|
|
205
|
+
usageAllowed: rl && !rl.error && typeof rl.ordinaryUsageAllowed === 'boolean' ? rl.ordinaryUsageAllowed : null,
|
|
206
|
+
limits: snap ? { primary: toWindow(snap.primary), secondary: toWindow(snap.secondary) } : null,
|
|
207
|
+
models: modelsOf(a['model/list']),
|
|
208
|
+
};
|
|
209
|
+
return { ok: true, text: signedIn ? `Codex ${version || ''} is signed in (${codex.planType || codex.authMode})`.replace(' ', ' ') : 'Codex is installed but not signed in', codex };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function run(args, opts = {}) {
|
|
213
|
+
const action = String((args && args.action) || 'status');
|
|
214
|
+
if (action === 'status') return status(opts);
|
|
215
|
+
return { ok: false, text: `(unknown codex action "${action}")` };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { run, status, findCodex, commandFor, openSession };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// One chat turn of tensor.chat through Codex on this computer, signed in with the
|
|
3
|
+
// user's own ChatGPT account. tensor.chat sends the conversation and its tools; Codex
|
|
4
|
+
// runs the model; every tool the model calls goes back to tensor.chat and runs there,
|
|
5
|
+
// with the same executor as on every other route. Codex itself gets no tool of its
|
|
6
|
+
// own: no shell, no file access, no web search, no sub-agents, nothing about this
|
|
7
|
+
// computer. The settings for that are fixed here - the server cannot change them.
|
|
8
|
+
//
|
|
9
|
+
// Measured 02.11.2026 with Codex 0.164 against a local stand-in for the model API:
|
|
10
|
+
// with LOCKED below the request carries our base instructions, the history we
|
|
11
|
+
// inject and our tools, and nothing else. dynamicTools need experimentalApi
|
|
12
|
+
// ("thread/start.dynamicTools requires experimentalApi capability").
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { findCodex, openSession } = require('./codex');
|
|
17
|
+
|
|
18
|
+
// A folder with nothing in it, as Codex's working folder: it is never shown to the
|
|
19
|
+
// model (include_environment_context is off), and nothing can be read in it anyway.
|
|
20
|
+
function emptyDir() {
|
|
21
|
+
const dir = path.join(os.tmpdir(), 'tensor-codex-turn');
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const OFF = {
|
|
27
|
+
shell_tool: false, unified_exec: false, view_image: false, multi_agent: false, goals: false,
|
|
28
|
+
apps: false, plugins: false, image_generation: false, sleep_tool: false, skill_search: false,
|
|
29
|
+
tool_suggest: false, memories: false, hooks: false, computer_use: false, browser_use: false,
|
|
30
|
+
in_app_browser: false,
|
|
31
|
+
};
|
|
32
|
+
const LOCKED = {
|
|
33
|
+
ephemeral: true,
|
|
34
|
+
approvalPolicy: 'never',
|
|
35
|
+
sandbox: 'read-only',
|
|
36
|
+
config: {
|
|
37
|
+
features: OFF,
|
|
38
|
+
web_search: 'disabled',
|
|
39
|
+
include_environment_context: false,
|
|
40
|
+
include_permissions_instructions: false,
|
|
41
|
+
include_apps_instructions: false,
|
|
42
|
+
include_skills_usage_instructions: false,
|
|
43
|
+
include_plugin_usage_instructions: false,
|
|
44
|
+
skills: { include_instructions: false },
|
|
45
|
+
tools: { experimental_request_user_input: { enabled: false } },
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const EFFORTS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
|
|
50
|
+
const ITEM_TYPES = new Set(['message', 'function_call', 'function_call_output']);
|
|
51
|
+
const MAX_TOOLS = 200;
|
|
52
|
+
const MAX_ITEMS = 5000;
|
|
53
|
+
|
|
54
|
+
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
|
55
|
+
|
|
56
|
+
// What the server sent, in the shapes Codex takes - anything else is dropped.
|
|
57
|
+
function checkTurn(a) {
|
|
58
|
+
const model = str(a && a.model, 100);
|
|
59
|
+
if (!/^[\w.:-]+$/.test(model)) throw new Error('no valid model');
|
|
60
|
+
const tools = (Array.isArray(a.tools) ? a.tools : []).slice(0, MAX_TOOLS)
|
|
61
|
+
.filter((t) => t && /^[\w.-]{1,128}$/.test(String(t.name || '')))
|
|
62
|
+
.map((t) => ({ name: String(t.name), description: str(t.description, 20000), inputSchema: t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : { type: 'object', properties: {} } }));
|
|
63
|
+
const items = (Array.isArray(a.items) ? a.items : []).slice(-MAX_ITEMS)
|
|
64
|
+
.filter((i) => i && typeof i === 'object' && ITEM_TYPES.has(i.type));
|
|
65
|
+
if (!items.length) throw new Error('nothing to answer');
|
|
66
|
+
return {
|
|
67
|
+
model,
|
|
68
|
+
effort: EFFORTS.has(a.effort) ? a.effort : null,
|
|
69
|
+
instructions: str(a.instructions, 4 * 1024 * 1024),
|
|
70
|
+
tools,
|
|
71
|
+
items,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A tool result from the server, as Codex wants it back.
|
|
76
|
+
function contentItems(r) {
|
|
77
|
+
const out = [{ type: 'inputText', text: str(r && r.text, 4 * 1024 * 1024) || '(no output)' }];
|
|
78
|
+
for (const url of (r && Array.isArray(r.images) ? r.images : []).slice(0, 20)) {
|
|
79
|
+
if (typeof url === 'string' && /^data:image\//.test(url)) out.push({ type: 'inputImage', imageUrl: url });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run the turn.
|
|
86
|
+
* @param {object} args from the server: model, effort, instructions, tools, items
|
|
87
|
+
* @param {object} io
|
|
88
|
+
* @param {(ev: object) => void} io.emit an event for the server: {k:'text'|'reasoning', d},
|
|
89
|
+
* {k:'tool', callId, tool, args}, {k:'usage', usage}, {k:'error', message, willRetry}
|
|
90
|
+
* @param {(callId: string) => Promise<object>} io.toolResult waits for the server's
|
|
91
|
+
* answer to a tool call: {ok, text, images?}
|
|
92
|
+
* @param {AbortSignal} [io.signal]
|
|
93
|
+
* @param {object} [io.env]
|
|
94
|
+
* @returns {Promise<{ok: boolean, text: string, turn?: {status: string, usage: object}}>}
|
|
95
|
+
*/
|
|
96
|
+
async function runTurn(args, { emit, toolResult, signal, env } = {}) {
|
|
97
|
+
let turn;
|
|
98
|
+
try { turn = checkTurn(args || {}); } catch (e) { return { ok: false, text: `(${e.message})` }; }
|
|
99
|
+
const bin = findCodex(env);
|
|
100
|
+
if (!bin) return { ok: false, text: '(Codex is not installed on this computer)', codex: { installed: false } };
|
|
101
|
+
let threadId = null;
|
|
102
|
+
let turnId = null;
|
|
103
|
+
let usage = null;
|
|
104
|
+
let finished;
|
|
105
|
+
const done = new Promise((resolve) => { finished = resolve; });
|
|
106
|
+
const onNotification = (m) => {
|
|
107
|
+
const p = m.params || {};
|
|
108
|
+
if (threadId && p.threadId && p.threadId !== threadId) return;
|
|
109
|
+
switch (m.method) {
|
|
110
|
+
case 'turn/started': turnId = (p.turn && p.turn.id) || turnId; break;
|
|
111
|
+
case 'item/agentMessage/delta': if (p.delta) emit({ k: 'text', d: String(p.delta) }); break;
|
|
112
|
+
case 'item/reasoning/summaryTextDelta':
|
|
113
|
+
case 'item/reasoning/textDelta': if (p.delta) emit({ k: 'reasoning', d: String(p.delta) }); break;
|
|
114
|
+
case 'item/reasoning/summaryPartAdded': emit({ k: 'reasoning', d: '\n\n' }); break;
|
|
115
|
+
case 'thread/tokenUsage/updated': usage = p.tokenUsage || usage; if (usage) emit({ k: 'usage', usage }); break;
|
|
116
|
+
case 'error': emit({ k: 'error', message: str(p.error && p.error.message, 2000), willRetry: !!p.willRetry }); break;
|
|
117
|
+
case 'turn/completed': finished({ status: String((p.turn && p.turn.status) || 'completed'), error: p.turn && p.turn.error ? str(p.turn.error.message, 2000) : null }); break;
|
|
118
|
+
default: break;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
// Codex asks the client only for what the model wants: our tools. Everything else
|
|
122
|
+
// - approvals, questions to the user, logins - this client never takes.
|
|
123
|
+
const onRequest = async (m) => {
|
|
124
|
+
if (m.method !== 'item/tool/call') return undefined;
|
|
125
|
+
const p = m.params || {};
|
|
126
|
+
const callId = String(p.callId || '');
|
|
127
|
+
emit({ k: 'tool', callId, tool: String(p.tool || ''), args: p.arguments == null ? {} : p.arguments });
|
|
128
|
+
const r = await toolResult(callId);
|
|
129
|
+
return { success: !!(r && r.ok), contentItems: contentItems(r) };
|
|
130
|
+
};
|
|
131
|
+
const session = openSession(bin, { experimental: true, onRequest, onNotification });
|
|
132
|
+
const onAbort = () => {
|
|
133
|
+
if (threadId && turnId) session.request('turn/interrupt', { threadId, turnId });
|
|
134
|
+
// Codex answers an interrupt with turn/completed; one that does not is not waited for.
|
|
135
|
+
setTimeout(() => finished({ status: 'interrupted' }), 3000).unref();
|
|
136
|
+
};
|
|
137
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
138
|
+
try {
|
|
139
|
+
const stopped = session.ended.then((e) => ({ status: 'failed', error: e.message }));
|
|
140
|
+
const step = async (method, params) => {
|
|
141
|
+
const r = await Promise.race([session.request(method, params), stopped.then((s) => ({ error: { message: s.error } }))]);
|
|
142
|
+
if (r.error) throw new Error(`${method}: ${r.error.message || 'failed'}`);
|
|
143
|
+
return r.result || {};
|
|
144
|
+
};
|
|
145
|
+
await Promise.race([session.ready, stopped.then((s) => { throw new Error(s.error); })]);
|
|
146
|
+
if (signal && signal.aborted) return { ok: false, text: '(cancelled)', turn: { status: 'interrupted', usage } };
|
|
147
|
+
const thread = await step('thread/start', {
|
|
148
|
+
...LOCKED,
|
|
149
|
+
cwd: emptyDir(),
|
|
150
|
+
model: turn.model,
|
|
151
|
+
baseInstructions: turn.instructions,
|
|
152
|
+
dynamicTools: turn.tools.map((t) => ({ type: 'function', ...t })),
|
|
153
|
+
});
|
|
154
|
+
threadId = thread.thread && thread.thread.id;
|
|
155
|
+
if (!threadId) throw new Error('thread/start: no thread');
|
|
156
|
+
await step('thread/inject_items', { threadId, items: turn.items });
|
|
157
|
+
// The question is the last injected item: the turn starts with nothing new.
|
|
158
|
+
const started = await step('turn/start', { threadId, input: [], ...(turn.effort ? { effort: turn.effort } : {}) });
|
|
159
|
+
turnId = (started.turn && started.turn.id) || turnId;
|
|
160
|
+
const end = await Promise.race([done, stopped]);
|
|
161
|
+
const ok = end.status === 'completed';
|
|
162
|
+
return { ok, text: ok ? 'Codex finished the turn' : `(Codex ${end.status}${end.error ? `: ${end.error}` : ''})`, turn: { status: end.status, usage } };
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return { ok: false, text: `(Codex: ${e.message})`, turn: { status: 'failed', usage } };
|
|
165
|
+
} finally {
|
|
166
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
167
|
+
session.kill();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { runTurn, checkTurn, contentItems, LOCKED };
|