@tensor.chat/companion 0.4.4 → 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/bin/tensor-companion.js +3 -3
- package/package.json +1 -1
- package/src/daemon.js +24 -87
- package/src/toolExecutor.js +95 -0
- package/src/toolRunner.js +95 -0
- package/src/toolWorker.js +83 -0
- package/src/tools/codex.js +218 -0
- package/src/tools/codexTurn.js +171 -0
package/bin/tensor-companion.js
CHANGED
|
@@ -70,11 +70,11 @@ function run() {
|
|
|
70
70
|
});
|
|
71
71
|
daemon.events.on('call', (c) => out(`[${stamp()}] ${c.tool}: ${summarize(c.args)}`));
|
|
72
72
|
daemon.events.on('approval', (a) => out(`[${stamp()}] approval requested for ${a.tool}: ${a.summary || ''} — answer it in the chat`));
|
|
73
|
-
daemon.events.on('unauthorized', () => { out('the server rejected this device — pair again with "npx @tensor.chat/companion pair"'); process.exit(2); });
|
|
74
|
-
daemon.events.on('removed', () => { out('this device was removed in the account settings — exiting'); process.exit(3); });
|
|
73
|
+
daemon.events.on('unauthorized', async () => { out('the server rejected this device — pair again with "npx @tensor.chat/companion pair"'); await daemon.stop(); process.exit(2); });
|
|
74
|
+
daemon.events.on('removed', async () => { out('this device was removed in the account settings — exiting'); await daemon.stop(); process.exit(3); });
|
|
75
75
|
daemon.events.on('local_approval_request', (a) => out(`[${stamp()}] local approval requested: run npx @tensor.chat/companion allow ${a.code}`));
|
|
76
76
|
daemon.start();
|
|
77
|
-
const stop = () => { daemon.stop();
|
|
77
|
+
const stop = async () => { await daemon.stop(); process.exit(0); };
|
|
78
78
|
process.on('SIGINT', stop);
|
|
79
79
|
process.on('SIGTERM', stop);
|
|
80
80
|
}
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -10,19 +10,16 @@ const EventEmitter = require('events');
|
|
|
10
10
|
const WebSocket = require('ws');
|
|
11
11
|
const { Roots } = require('./paths');
|
|
12
12
|
const bash = require('./tools/bash');
|
|
13
|
-
const
|
|
14
|
-
const { fetchUrl } = require('./tools/fetch');
|
|
15
|
-
const { extractTouched } = require('./tools/touched');
|
|
16
|
-
const { outsideFolders } = require('./tools/outside');
|
|
17
|
-
const { Jobs } = require('./tools/jobs');
|
|
13
|
+
const { ToolRunner } = require('./toolRunner');
|
|
18
14
|
const { createStallWatch } = require('./stalls');
|
|
19
15
|
|
|
20
16
|
const VERSION = require('../package.json').version;
|
|
21
17
|
const RECONNECT_MIN_MS = 1000;
|
|
22
18
|
const RECONNECT_MAX_MS = 60000;
|
|
23
19
|
const MAX_CONCURRENT_CALLS = 4;
|
|
24
|
-
|
|
25
|
-
|
|
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;
|
|
26
23
|
const SLOW_SPAWN_MS = 2000;
|
|
27
24
|
|
|
28
25
|
// What a call works on, for the log: the command, the path or the address
|
|
@@ -68,6 +65,9 @@ function systemInfo(shell, localAuthority = false, approver = null) {
|
|
|
68
65
|
cwd: process.cwd(),
|
|
69
66
|
localAuthority: localAuthority ? 1 : null,
|
|
70
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,
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -108,7 +108,7 @@ function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
|
|
|
108
108
|
return { roots, labels: keptLabels, rejected };
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, approver = null, slowSpawnMs = SLOW_SPAWN_MS } = {}) {
|
|
111
|
+
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, approver = null, slowSpawnMs = SLOW_SPAWN_MS, toolWorkerPath } = {}) {
|
|
112
112
|
if (!serverUrl || !token) throw new Error('serverUrl and token are required');
|
|
113
113
|
const emitter = new EventEmitter();
|
|
114
114
|
const say = typeof log === 'function' ? log : () => {};
|
|
@@ -116,7 +116,9 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
116
116
|
const localApprovals = typeof approvedRoots === 'function' ? approvedRoots : null;
|
|
117
117
|
const localShellApproval = typeof approvedShell === 'function' ? approvedShell : null;
|
|
118
118
|
const running = new Map(); // call id → AbortController
|
|
119
|
-
const
|
|
119
|
+
const turns = new Map(); // call id of a Codex turn → answer(key, value)
|
|
120
|
+
const tools = new ToolRunner({ shell, slowSpawnMs, workerPath: toolWorkerPath });
|
|
121
|
+
tools.on('log', say);
|
|
120
122
|
const unsentExits = []; // job endings the server has not heard yet (offline at the time)
|
|
121
123
|
const state = { connected: false, deviceId: null, name: null, mode: null, roots: [], labels: [], reconnectMs: RECONNECT_MIN_MS, stopped: false, ws: null, timer: null, connectedAt: null, lastPingAt: null };
|
|
122
124
|
const stalls = createStallWatch({ say });
|
|
@@ -160,79 +162,6 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
160
162
|
emitter.emit('config', { roots: [...roots.status(), ...authorized.rejected], mode: state.mode, name: state.name });
|
|
161
163
|
}
|
|
162
164
|
|
|
163
|
-
async function runTool(tool, args, signal, timeoutMs, callRoots = roots) {
|
|
164
|
-
const a = args && typeof args === 'object' ? args : {};
|
|
165
|
-
switch (tool) {
|
|
166
|
-
case 'bash': {
|
|
167
|
-
const cwdRes = a.cwd ? callRoots.resolve(a.cwd) : null;
|
|
168
|
-
if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
|
|
169
|
-
const cwd = cwdRes ? cwdRes.abs : callRoots.primary();
|
|
170
|
-
if (!cwd) return { ok: false, text: '(no shared folder is available on this device — the user has to add one in the device settings)' };
|
|
171
|
-
// Starting a process holds this whole process until Windows has it running. A
|
|
172
|
-
// scanner that inspects the program first can make that last long enough for
|
|
173
|
-
// the server to stop hearing from this computer.
|
|
174
|
-
const spawnAt = Date.now();
|
|
175
|
-
const job = jobs.start(String(a.command || ''), { cwd, shell });
|
|
176
|
-
const spawnMs = Date.now() - spawnAt;
|
|
177
|
-
if (spawnMs >= slowSpawnMs) say(`starting the shell took ${Math.round(spawnMs / 1000)} s`);
|
|
178
|
-
// A cancelled call (turn aborted, server timeout) takes its foreground job with
|
|
179
|
-
// it; a background job belongs to the chat, not to the call, and lives on.
|
|
180
|
-
const onAbort = () => { if (!a.background) jobs.kill(job.id); };
|
|
181
|
-
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
182
|
-
// The server cancels a call at timeoutMs; answering a little earlier keeps the job
|
|
183
|
-
// alive as a background job instead of letting the cancel kill it.
|
|
184
|
-
const limit = timeoutMs || FOREGROUND_DEFAULT_MS;
|
|
185
|
-
const waitMs = a.background ? BACKGROUND_FIRST_LOOK_MS : Math.max(1000, limit - Math.min(5000, Math.floor(limit / 4)));
|
|
186
|
-
const ended = await jobs.wait(job, waitMs);
|
|
187
|
-
if (signal) signal.removeEventListener('abort', onAbort);
|
|
188
|
-
if (ended) {
|
|
189
|
-
const r = jobs.result(job);
|
|
190
|
-
r.touched = extractTouched(callRoots, a.command, r.stdout, r.stderr);
|
|
191
|
-
delete r.stdout;
|
|
192
|
-
delete r.stderr;
|
|
193
|
-
jobs.poll(job.id); // the one-shot result already carried every line
|
|
194
|
-
return r;
|
|
195
|
-
}
|
|
196
|
-
// Still running: it becomes (or already was) a background job.
|
|
197
|
-
const p = jobs.poll(job.id);
|
|
198
|
-
return { ok: true, running: true, jobId: job.id, text: p.text, exitCode: null, truncated: false, timedOut: false, touched: [] };
|
|
199
|
-
}
|
|
200
|
-
case 'job': {
|
|
201
|
-
const job = jobs.get(a.jobId);
|
|
202
|
-
if (!job) return { ok: false, text: '(no such job on this device — it may have been forgotten after many newer ones)' };
|
|
203
|
-
if (a.action === 'kill') {
|
|
204
|
-
jobs.kill(job.id);
|
|
205
|
-
await jobs.wait(job, 3000);
|
|
206
|
-
}
|
|
207
|
-
const p = jobs.poll(job.id);
|
|
208
|
-
return { ok: true, jobId: job.id, running: p.status === 'running', status: p.status, exitCode: p.exitCode, jobMs: p.ms, text: p.text, skipped: p.skipped };
|
|
209
|
-
}
|
|
210
|
-
case 'read': return files.read(callRoots, a.path, { lines: a.lines });
|
|
211
|
-
case 'write': return files.write(callRoots, a.path, a.content, { overwrite: a.overwrite === true });
|
|
212
|
-
case 'edit': return files.edit(callRoots, a.path, a.edits);
|
|
213
|
-
case 'glob': return files.glob(callRoots, a.pattern);
|
|
214
|
-
// The server asks before it judges a shell command: what of it lies outside
|
|
215
|
-
// the shared folders, read with this machine's own variables. Nothing runs.
|
|
216
|
-
case 'outside': {
|
|
217
|
-
const at = a.cwd ? callRoots.resolve(a.cwd) : null;
|
|
218
|
-
return outsideFolders(callRoots, a.command, { cwd: at && !at.error ? at.abs : callRoots.primary() || os.homedir() });
|
|
219
|
-
}
|
|
220
|
-
case 'grep': return files.grep(callRoots, a.command);
|
|
221
|
-
case 'ls': return files.ls(callRoots, a.path, { max: a.max });
|
|
222
|
-
case 'fetchfile': return files.fetchfile(callRoots, a.path, { maxBytes: a.maxBytes });
|
|
223
|
-
case 'fetchpart': return files.fetchpart(callRoots, a.path, { offset: a.offset, length: a.length });
|
|
224
|
-
// Das eingehaengte Laufwerk schreibt ueber diese vier zurueck. Sie stehen der KI
|
|
225
|
-
// NICHT offen - sie kommen nur aus dem Adapter, der das Laufwerk bedient.
|
|
226
|
-
case 'writepart': return files.writepart(callRoots, a.path, { offset: a.offset, base64: a.base64, truncate: a.truncate });
|
|
227
|
-
case 'mkdirpath': return files.mkdirpath(callRoots, a.path);
|
|
228
|
-
case 'removepath': return await files.removepath(callRoots, a.path, { endgueltig: a.endgueltig === true });
|
|
229
|
-
case 'renamepath': return files.renamepath(callRoots, a.from, a.to);
|
|
230
|
-
case 'statpath': return files.statpath(callRoots, a.path);
|
|
231
|
-
case 'fetch': return fetchUrl(a.url, { signal, raw: a.raw === true });
|
|
232
|
-
default: return { ok: false, text: `(unknown tool "${tool}" on the companion device)` };
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
165
|
async function onCall(msg) {
|
|
237
166
|
const id = String(msg.id);
|
|
238
167
|
const tool = String(msg.tool || '');
|
|
@@ -242,12 +171,14 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
242
171
|
emitter.emit('result', { id, tool, ok: false });
|
|
243
172
|
return;
|
|
244
173
|
}
|
|
245
|
-
|
|
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) {
|
|
246
176
|
send({ t: 'result', id, ok: false, text: '(the companion device is busy with other calls — try again in a moment)' });
|
|
247
177
|
return;
|
|
248
178
|
}
|
|
249
179
|
const ctrl = new AbortController();
|
|
250
180
|
running.set(id, ctrl);
|
|
181
|
+
if (turn) turns.set(id, null);
|
|
251
182
|
const finished = stalls.started(tool, callSubject(tool, msg.args));
|
|
252
183
|
emitter.emit('call', { id, tool: msg.tool, args: msg.args });
|
|
253
184
|
let r;
|
|
@@ -258,12 +189,17 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
258
189
|
const scoped = authorizeRequestedRoots(msg.roots, msg.labels, approved);
|
|
259
190
|
callRoots = new Roots(scoped.roots, scoped.labels);
|
|
260
191
|
}
|
|
261
|
-
|
|
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);
|
|
262
197
|
} catch (e) {
|
|
263
198
|
r = { ok: false, text: `(tool error on the companion device: ${e && e.message ? e.message : e})` };
|
|
264
199
|
}
|
|
265
200
|
finished();
|
|
266
201
|
running.delete(id);
|
|
202
|
+
turns.delete(id);
|
|
267
203
|
send({ t: 'result', id, ...r });
|
|
268
204
|
emitter.emit('result', { id, tool: msg.tool, ok: r.ok });
|
|
269
205
|
}
|
|
@@ -275,7 +211,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
275
211
|
unsentExits.shift();
|
|
276
212
|
}
|
|
277
213
|
}
|
|
278
|
-
|
|
214
|
+
tools.on('exit', (r) => {
|
|
279
215
|
const m = { t: 'job_exit', jobId: r.jobId, status: r.status, exitCode: r.exitCode, ms: r.ms, tail: r.tail };
|
|
280
216
|
unsentExits.push(m);
|
|
281
217
|
flushExits();
|
|
@@ -295,6 +231,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
295
231
|
case 'config': applyConfig(msg); break;
|
|
296
232
|
case 'call': onCall(msg); break;
|
|
297
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; }
|
|
298
235
|
case 'approval': emitter.emit('approval', msg); break;
|
|
299
236
|
case 'approval_resolved': emitter.emit('approval_resolved', msg); break;
|
|
300
237
|
case 'local_approval_request': confirmIfCovered(msg); break;
|
|
@@ -376,11 +313,11 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
376
313
|
stalls.stop();
|
|
377
314
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
378
315
|
for (const c of running.values()) c.abort();
|
|
379
|
-
|
|
316
|
+
const stopped = tools.stop();
|
|
380
317
|
if (state.ws) { try { state.ws.close(1000, 'daemon stopped'); } catch (_) {} try { state.ws.terminate(); } catch (_) {} state.ws = null; }
|
|
318
|
+
return stopped;
|
|
381
319
|
},
|
|
382
320
|
replyApproval(approvalId, decision) { return send({ t: 'approval_reply', approvalId, decision }); },
|
|
383
|
-
jobs,
|
|
384
321
|
};
|
|
385
322
|
}
|
|
386
323
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const files = require('./tools/files');
|
|
5
|
+
const { fetchUrl } = require('./tools/fetch');
|
|
6
|
+
const { extractTouched } = require('./tools/touched');
|
|
7
|
+
const { outsideFolders } = require('./tools/outside');
|
|
8
|
+
const { Jobs } = require('./tools/jobs');
|
|
9
|
+
const codex = require('./tools/codex');
|
|
10
|
+
const codexTurn = require('./tools/codexTurn');
|
|
11
|
+
|
|
12
|
+
const BACKGROUND_FIRST_LOOK_MS = 1500;
|
|
13
|
+
const FOREGROUND_DEFAULT_MS = 600000;
|
|
14
|
+
|
|
15
|
+
function createToolExecutor({ shell, say = () => {}, slowSpawnMs = 2000 } = {}) {
|
|
16
|
+
const jobs = new Jobs();
|
|
17
|
+
|
|
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) {
|
|
21
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
22
|
+
if (signal.aborted || cancelled()) throw new Error('call cancelled before execution');
|
|
23
|
+
switch (tool) {
|
|
24
|
+
case 'bash': {
|
|
25
|
+
const cwdRes = a.cwd ? callRoots.resolve(a.cwd) : null;
|
|
26
|
+
if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
|
|
27
|
+
const cwd = cwdRes ? cwdRes.abs : callRoots.primary();
|
|
28
|
+
if (!cwd) return { ok: false, text: '(no shared folder is available on this device — the user has to add one in the device settings)' };
|
|
29
|
+
const spawnAt = Date.now();
|
|
30
|
+
const job = jobs.start(String(a.command || ''), { cwd, shell });
|
|
31
|
+
const spawnMs = Date.now() - spawnAt;
|
|
32
|
+
if (spawnMs >= slowSpawnMs) say(`starting the shell took ${Math.round(spawnMs / 1000)} s`);
|
|
33
|
+
// A background job belongs to the chat, not to the call that started it.
|
|
34
|
+
const onAbort = () => { if (!a.background) jobs.kill(job.id); };
|
|
35
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
36
|
+
if (signal.aborted || cancelled()) onAbort();
|
|
37
|
+
const limit = timeoutMs || FOREGROUND_DEFAULT_MS;
|
|
38
|
+
const waitMs = a.background ? BACKGROUND_FIRST_LOOK_MS : Math.max(1000, limit - Math.min(5000, Math.floor(limit / 4)));
|
|
39
|
+
// The server's deadline includes time queued in the worker and time in spawn.
|
|
40
|
+
const ended = await jobs.wait(job, a.background ? waitMs : Math.max(0, waitMs - (Date.now() - requestedAt)));
|
|
41
|
+
signal.removeEventListener('abort', onAbort);
|
|
42
|
+
if (ended) {
|
|
43
|
+
const r = jobs.result(job);
|
|
44
|
+
r.touched = extractTouched(callRoots, a.command, r.stdout, r.stderr);
|
|
45
|
+
delete r.stdout;
|
|
46
|
+
delete r.stderr;
|
|
47
|
+
jobs.poll(job.id);
|
|
48
|
+
return r;
|
|
49
|
+
}
|
|
50
|
+
const p = jobs.poll(job.id);
|
|
51
|
+
return { ok: true, running: true, jobId: job.id, text: p.text, exitCode: null, truncated: false, timedOut: false, touched: [] };
|
|
52
|
+
}
|
|
53
|
+
case 'job': {
|
|
54
|
+
const job = jobs.get(a.jobId);
|
|
55
|
+
if (!job) return { ok: false, text: '(no such job on this device — it may have been forgotten after many newer ones)' };
|
|
56
|
+
if (a.action === 'kill') {
|
|
57
|
+
jobs.kill(job.id);
|
|
58
|
+
await jobs.wait(job, 3000);
|
|
59
|
+
}
|
|
60
|
+
const p = jobs.poll(job.id);
|
|
61
|
+
return { ok: true, jobId: job.id, running: p.status === 'running', status: p.status, exitCode: p.exitCode, jobMs: p.ms, text: p.text, skipped: p.skipped };
|
|
62
|
+
}
|
|
63
|
+
case 'read': return files.read(callRoots, a.path, { lines: a.lines });
|
|
64
|
+
case 'write': return files.write(callRoots, a.path, a.content, { overwrite: a.overwrite === true });
|
|
65
|
+
case 'edit': return files.edit(callRoots, a.path, a.edits);
|
|
66
|
+
case 'glob': return files.glob(callRoots, a.pattern);
|
|
67
|
+
case 'outside': {
|
|
68
|
+
const at = a.cwd ? callRoots.resolve(a.cwd) : null;
|
|
69
|
+
return outsideFolders(callRoots, a.command, { cwd: at && !at.error ? at.abs : callRoots.primary() || os.homedir() });
|
|
70
|
+
}
|
|
71
|
+
case 'grep': return files.grep(callRoots, a.command);
|
|
72
|
+
case 'ls': return files.ls(callRoots, a.path, { max: a.max });
|
|
73
|
+
case 'fetchfile': return files.fetchfile(callRoots, a.path, { maxBytes: a.maxBytes });
|
|
74
|
+
case 'fetchpart': return files.fetchpart(callRoots, a.path, { offset: a.offset, length: a.length });
|
|
75
|
+
// These operations are private to the mounted-drive adapter.
|
|
76
|
+
case 'writepart': return files.writepart(callRoots, a.path, { offset: a.offset, base64: a.base64, truncate: a.truncate });
|
|
77
|
+
case 'mkdirpath': return files.mkdirpath(callRoots, a.path);
|
|
78
|
+
case 'removepath': return files.removepath(callRoots, a.path, { endgueltig: a.endgueltig === true });
|
|
79
|
+
case 'renamepath': return files.renamepath(callRoots, a.from, a.to);
|
|
80
|
+
case 'statpath': return files.statpath(callRoots, a.path);
|
|
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 });
|
|
88
|
+
default: return { ok: false, text: `(unknown tool "${tool}" on the companion device)` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { runTool, jobs };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { createToolExecutor };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { Worker } = require('worker_threads');
|
|
5
|
+
const EventEmitter = require('events');
|
|
6
|
+
|
|
7
|
+
// Even asynchronous spawn can block its calling thread inside Windows. Neither it
|
|
8
|
+
// nor synchronous filesystem walks may share the WebSocket's event loop.
|
|
9
|
+
class ToolRunner extends EventEmitter {
|
|
10
|
+
constructor({ shell, slowSpawnMs, workerPath = path.join(__dirname, 'toolWorker.js') } = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this.options = { shell, slowSpawnMs };
|
|
13
|
+
this.workerPath = workerPath;
|
|
14
|
+
this.worker = null;
|
|
15
|
+
this.pending = new Map();
|
|
16
|
+
this.nextId = 1;
|
|
17
|
+
this.stopping = new Int32Array(new SharedArrayBuffer(4));
|
|
18
|
+
this.failure = null;
|
|
19
|
+
this.closed = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
start() {
|
|
23
|
+
if (this.failure) throw this.failure;
|
|
24
|
+
if (Atomics.load(this.stopping, 0)) throw new Error('tool worker is stopped');
|
|
25
|
+
if (this.worker) return;
|
|
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 } });
|
|
29
|
+
this.worker = worker;
|
|
30
|
+
this.closed = new Promise((resolve) => worker.once('exit', resolve));
|
|
31
|
+
const fail = (error) => {
|
|
32
|
+
this.failure = error;
|
|
33
|
+
for (const p of this.pending.values()) p.reject(error);
|
|
34
|
+
this.pending.clear();
|
|
35
|
+
};
|
|
36
|
+
worker.on('message', (msg) => {
|
|
37
|
+
if (msg.t === 'log') this.emit('log', msg.line);
|
|
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
|
+
}
|
|
43
|
+
else if (msg.t === 'result') {
|
|
44
|
+
const p = this.pending.get(msg.id);
|
|
45
|
+
if (!p) return;
|
|
46
|
+
this.pending.delete(msg.id);
|
|
47
|
+
p.resolve(msg.result);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
worker.on('error', fail);
|
|
51
|
+
worker.on('exit', (code) => {
|
|
52
|
+
// Never replay calls or reset job IDs after a crash: their side effects may exist.
|
|
53
|
+
fail(new Error(`tool worker exited (code ${code}); commands were not retried`));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
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) {
|
|
61
|
+
this.start();
|
|
62
|
+
const id = this.nextId++;
|
|
63
|
+
const cancelled = new Int32Array(new SharedArrayBuffer(4));
|
|
64
|
+
const abort = () => {
|
|
65
|
+
Atomics.store(cancelled, 0, 1);
|
|
66
|
+
this.worker.postMessage({ t: 'cancel', id });
|
|
67
|
+
};
|
|
68
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
69
|
+
try {
|
|
70
|
+
if (signal.aborted) {
|
|
71
|
+
Atomics.store(cancelled, 0, 1);
|
|
72
|
+
throw new Error('call cancelled before execution');
|
|
73
|
+
}
|
|
74
|
+
return await new Promise((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 }));
|
|
77
|
+
try {
|
|
78
|
+
this.worker.postMessage({ t: 'call', id, tool, args, timeoutMs, entries: roots.entries, requestedAt: Date.now(), cancelled: cancelled.buffer, talks: !!talk });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
this.pending.delete(id);
|
|
81
|
+
reject(e);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
} finally {
|
|
85
|
+
signal.removeEventListener('abort', abort);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
stop() {
|
|
90
|
+
if (!Atomics.exchange(this.stopping, 0, 1) && this.worker) this.worker.postMessage({ t: 'stop' });
|
|
91
|
+
return this.closed || Promise.resolve();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { ToolRunner };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parentPort, workerData } = require('worker_threads');
|
|
4
|
+
const { Roots } = require('./paths');
|
|
5
|
+
const { createToolExecutor } = require('./toolExecutor');
|
|
6
|
+
|
|
7
|
+
const stopping = new Int32Array(workerData.stopping);
|
|
8
|
+
const executor = createToolExecutor({
|
|
9
|
+
shell: workerData.shell,
|
|
10
|
+
slowSpawnMs: workerData.slowSpawnMs,
|
|
11
|
+
say: (line) => parentPort.postMessage({ t: 'log', line }),
|
|
12
|
+
});
|
|
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
|
|
36
|
+
executor.jobs.on('exit', (report) => parentPort.postMessage({ t: 'job_exit', report }));
|
|
37
|
+
|
|
38
|
+
parentPort.on('message', async (msg) => {
|
|
39
|
+
if (msg.t === 'cancel') {
|
|
40
|
+
const ctrl = running.get(msg.id);
|
|
41
|
+
if (ctrl) ctrl.abort();
|
|
42
|
+
return;
|
|
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
|
|
53
|
+
if (msg.t === 'stop') {
|
|
54
|
+
for (const ctrl of running.values()) ctrl.abort();
|
|
55
|
+
executor.jobs.killAll();
|
|
56
|
+
await Promise.all(executor.jobs.running().map((job) => executor.jobs.wait(job, 5000)));
|
|
57
|
+
parentPort.close();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (msg.t !== 'call') return;
|
|
61
|
+
const ctrl = new AbortController();
|
|
62
|
+
const cancelled = new Int32Array(msg.cancelled);
|
|
63
|
+
const isCancelled = () => !!Atomics.load(cancelled, 0) || !!Atomics.load(stopping, 0);
|
|
64
|
+
running.set(msg.id, ctrl);
|
|
65
|
+
let result;
|
|
66
|
+
try {
|
|
67
|
+
if (isCancelled()) throw new Error('call cancelled before execution');
|
|
68
|
+
const roots = new Roots([]);
|
|
69
|
+
// This is the authorized per-call snapshot, never the worker's previous roots.
|
|
70
|
+
roots.entries = msg.entries;
|
|
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
|
|
74
|
+
} catch (e) {
|
|
75
|
+
result = { ok: false, text: `(tool error on the companion device: ${e.message})` };
|
|
76
|
+
} finally {
|
|
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
|
|
81
|
+
}
|
|
82
|
+
parentPort.postMessage({ t: 'result', id: msg.id, result });
|
|
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 };
|