@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.
@@ -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(); setTimeout(() => process.exit(0), 200); };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensor.chat/companion",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
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
@@ -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 files = require('./tools/files');
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
- const BACKGROUND_FIRST_LOOK_MS = 1500; // a background start still reports the first lines
25
- const FOREGROUND_DEFAULT_MS = 600000;
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 jobs = new Jobs();
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
- 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) {
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
- r = await runTool(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);
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
- jobs.on('exit', (r) => {
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
- jobs.killAll();
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 };