@tensor.chat/companion 0.4.6 → 0.5.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensor.chat/companion",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
4
4
  "description": "Lets your tensor.chat AI use this machine: files, shell and internet through your own connection.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://tensor.chat",
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
- if (running.size >= MAX_CONCURRENT_CALLS) {
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
- r = await tools.run(tool, msg.args, ctrl.signal, Number(msg.timeoutMs) || undefined, callRoots);
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;
@@ -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
- async function runTool(tool, args, signal, timeoutMs, callRoots, requestedAt = Date.now(), cancelled = () => false) {
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
- const worker = new Worker(this.workerPath, { workerData: { ...this.options, stopping: this.stopping.buffer } });
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
- async run(tool, args, signal, timeoutMs, roots) {
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
- result = await executor.runTool(msg.tool, msg.args, ctrl.signal, msg.timeoutMs, roots, msg.requestedAt, isCancelled);
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,173 @@
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 SUMMARIES = new Set(['auto', 'concise', 'detailed', 'none']);
51
+ const ITEM_TYPES = new Set(['message', 'function_call', 'function_call_output']);
52
+ const MAX_TOOLS = 200;
53
+ const MAX_ITEMS = 5000;
54
+
55
+ const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
56
+
57
+ // What the server sent, in the shapes Codex takes - anything else is dropped.
58
+ function checkTurn(a) {
59
+ const model = str(a && a.model, 100);
60
+ if (!/^[\w.:-]+$/.test(model)) throw new Error('no valid model');
61
+ const tools = (Array.isArray(a.tools) ? a.tools : []).slice(0, MAX_TOOLS)
62
+ .filter((t) => t && /^[\w.-]{1,128}$/.test(String(t.name || '')))
63
+ .map((t) => ({ name: String(t.name), description: str(t.description, 20000), inputSchema: t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : { type: 'object', properties: {} } }));
64
+ const items = (Array.isArray(a.items) ? a.items : []).slice(-MAX_ITEMS)
65
+ .filter((i) => i && typeof i === 'object' && ITEM_TYPES.has(i.type));
66
+ if (!items.length) throw new Error('nothing to answer');
67
+ return {
68
+ model,
69
+ effort: EFFORTS.has(a.effort) ? a.effort : null,
70
+ summary: SUMMARIES.has(a.summary) ? a.summary : null,
71
+ instructions: str(a.instructions, 4 * 1024 * 1024),
72
+ tools,
73
+ items,
74
+ };
75
+ }
76
+
77
+ // A tool result from the server, as Codex wants it back.
78
+ function contentItems(r) {
79
+ const out = [{ type: 'inputText', text: str(r && r.text, 4 * 1024 * 1024) || '(no output)' }];
80
+ for (const url of (r && Array.isArray(r.images) ? r.images : []).slice(0, 20)) {
81
+ if (typeof url === 'string' && /^data:image\//.test(url)) out.push({ type: 'inputImage', imageUrl: url });
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Run the turn.
88
+ * @param {object} args from the server: model, effort, instructions, tools, items
89
+ * @param {object} io
90
+ * @param {(ev: object) => void} io.emit an event for the server: {k:'text'|'reasoning', d},
91
+ * {k:'tool', callId, tool, args}, {k:'usage', usage}, {k:'error', message, willRetry}
92
+ * @param {(callId: string) => Promise<object>} io.toolResult waits for the server's
93
+ * answer to a tool call: {ok, text, images?}
94
+ * @param {AbortSignal} [io.signal]
95
+ * @param {object} [io.env]
96
+ * @returns {Promise<{ok: boolean, text: string, turn?: {status: string, usage: object}}>}
97
+ */
98
+ async function runTurn(args, { emit, toolResult, signal, env } = {}) {
99
+ let turn;
100
+ try { turn = checkTurn(args || {}); } catch (e) { return { ok: false, text: `(${e.message})` }; }
101
+ const bin = findCodex(env);
102
+ if (!bin) return { ok: false, text: '(Codex is not installed on this computer)', codex: { installed: false } };
103
+ let threadId = null;
104
+ let turnId = null;
105
+ let usage = null;
106
+ let finished;
107
+ const done = new Promise((resolve) => { finished = resolve; });
108
+ const onNotification = (m) => {
109
+ const p = m.params || {};
110
+ if (threadId && p.threadId && p.threadId !== threadId) return;
111
+ switch (m.method) {
112
+ case 'turn/started': turnId = (p.turn && p.turn.id) || turnId; break;
113
+ case 'item/agentMessage/delta': if (p.delta) emit({ k: 'text', d: String(p.delta) }); break;
114
+ case 'item/reasoning/summaryTextDelta':
115
+ case 'item/reasoning/textDelta': if (p.delta) emit({ k: 'reasoning', d: String(p.delta) }); break;
116
+ case 'item/reasoning/summaryPartAdded': emit({ k: 'reasoning', d: '\n\n' }); break;
117
+ case 'thread/tokenUsage/updated': usage = p.tokenUsage || usage; if (usage) emit({ k: 'usage', usage }); break;
118
+ case 'error': emit({ k: 'error', message: str(p.error && p.error.message, 2000), willRetry: !!p.willRetry }); break;
119
+ 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;
120
+ default: break;
121
+ }
122
+ };
123
+ // Codex asks the client only for what the model wants: our tools. Everything else
124
+ // - approvals, questions to the user, logins - this client never takes.
125
+ const onRequest = async (m) => {
126
+ if (m.method !== 'item/tool/call') return undefined;
127
+ const p = m.params || {};
128
+ const callId = String(p.callId || '');
129
+ emit({ k: 'tool', callId, tool: String(p.tool || ''), args: p.arguments == null ? {} : p.arguments });
130
+ const r = await toolResult(callId);
131
+ return { success: !!(r && r.ok), contentItems: contentItems(r) };
132
+ };
133
+ const session = openSession(bin, { experimental: true, onRequest, onNotification });
134
+ const onAbort = () => {
135
+ if (threadId && turnId) session.request('turn/interrupt', { threadId, turnId });
136
+ // Codex answers an interrupt with turn/completed; one that does not is not waited for.
137
+ setTimeout(() => finished({ status: 'interrupted' }), 3000).unref();
138
+ };
139
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
140
+ try {
141
+ const stopped = session.ended.then((e) => ({ status: 'failed', error: e.message }));
142
+ const step = async (method, params) => {
143
+ const r = await Promise.race([session.request(method, params), stopped.then((s) => ({ error: { message: s.error } }))]);
144
+ if (r.error) throw new Error(`${method}: ${r.error.message || 'failed'}`);
145
+ return r.result || {};
146
+ };
147
+ await Promise.race([session.ready, stopped.then((s) => { throw new Error(s.error); })]);
148
+ if (signal && signal.aborted) return { ok: false, text: '(cancelled)', turn: { status: 'interrupted', usage } };
149
+ const thread = await step('thread/start', {
150
+ ...LOCKED,
151
+ cwd: emptyDir(),
152
+ model: turn.model,
153
+ baseInstructions: turn.instructions,
154
+ dynamicTools: turn.tools.map((t) => ({ type: 'function', ...t })),
155
+ });
156
+ threadId = thread.thread && thread.thread.id;
157
+ if (!threadId) throw new Error('thread/start: no thread');
158
+ await step('thread/inject_items', { threadId, items: turn.items });
159
+ // The question is the last injected item: the turn starts with nothing new.
160
+ const started = await step('turn/start', { threadId, input: [], ...(turn.effort ? { effort: turn.effort } : {}), ...(turn.summary ? { summary: turn.summary } : {}) });
161
+ turnId = (started.turn && started.turn.id) || turnId;
162
+ const end = await Promise.race([done, stopped]);
163
+ const ok = end.status === 'completed';
164
+ return { ok, text: ok ? 'Codex finished the turn' : `(Codex ${end.status}${end.error ? `: ${end.error}` : ''})`, turn: { status: end.status, usage } };
165
+ } catch (e) {
166
+ return { ok: false, text: `(Codex: ${e.message})`, turn: { status: 'failed', usage } };
167
+ } finally {
168
+ if (signal) signal.removeEventListener('abort', onAbort);
169
+ session.kill();
170
+ }
171
+ }
172
+
173
+ module.exports = { runTurn, checkTurn, contentItems, LOCKED };