@tensor.chat/companion 0.4.4 → 0.4.6
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 +8 -86
- package/src/toolExecutor.js +85 -0
- package/src/toolRunner.js +85 -0
- package/src/toolWorker.js +47 -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,13 @@ 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
|
-
const BACKGROUND_FIRST_LOOK_MS = 1500; // a background start still reports the first lines
|
|
25
|
-
const FOREGROUND_DEFAULT_MS = 600000;
|
|
26
20
|
const SLOW_SPAWN_MS = 2000;
|
|
27
21
|
|
|
28
22
|
// What a call works on, for the log: the command, the path or the address
|
|
@@ -108,7 +102,7 @@ function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
|
|
|
108
102
|
return { roots, labels: keptLabels, rejected };
|
|
109
103
|
}
|
|
110
104
|
|
|
111
|
-
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, approver = null, slowSpawnMs = SLOW_SPAWN_MS } = {}) {
|
|
105
|
+
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, approver = null, slowSpawnMs = SLOW_SPAWN_MS, toolWorkerPath } = {}) {
|
|
112
106
|
if (!serverUrl || !token) throw new Error('serverUrl and token are required');
|
|
113
107
|
const emitter = new EventEmitter();
|
|
114
108
|
const say = typeof log === 'function' ? log : () => {};
|
|
@@ -116,7 +110,8 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
116
110
|
const localApprovals = typeof approvedRoots === 'function' ? approvedRoots : null;
|
|
117
111
|
const localShellApproval = typeof approvedShell === 'function' ? approvedShell : null;
|
|
118
112
|
const running = new Map(); // call id → AbortController
|
|
119
|
-
const
|
|
113
|
+
const tools = new ToolRunner({ shell, slowSpawnMs, workerPath: toolWorkerPath });
|
|
114
|
+
tools.on('log', say);
|
|
120
115
|
const unsentExits = []; // job endings the server has not heard yet (offline at the time)
|
|
121
116
|
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
117
|
const stalls = createStallWatch({ say });
|
|
@@ -160,79 +155,6 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
160
155
|
emitter.emit('config', { roots: [...roots.status(), ...authorized.rejected], mode: state.mode, name: state.name });
|
|
161
156
|
}
|
|
162
157
|
|
|
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
158
|
async function onCall(msg) {
|
|
237
159
|
const id = String(msg.id);
|
|
238
160
|
const tool = String(msg.tool || '');
|
|
@@ -258,7 +180,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
258
180
|
const scoped = authorizeRequestedRoots(msg.roots, msg.labels, approved);
|
|
259
181
|
callRoots = new Roots(scoped.roots, scoped.labels);
|
|
260
182
|
}
|
|
261
|
-
r = await
|
|
183
|
+
r = await tools.run(tool, msg.args, ctrl.signal, Number(msg.timeoutMs) || undefined, callRoots);
|
|
262
184
|
} catch (e) {
|
|
263
185
|
r = { ok: false, text: `(tool error on the companion device: ${e && e.message ? e.message : e})` };
|
|
264
186
|
}
|
|
@@ -275,7 +197,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
275
197
|
unsentExits.shift();
|
|
276
198
|
}
|
|
277
199
|
}
|
|
278
|
-
|
|
200
|
+
tools.on('exit', (r) => {
|
|
279
201
|
const m = { t: 'job_exit', jobId: r.jobId, status: r.status, exitCode: r.exitCode, ms: r.ms, tail: r.tail };
|
|
280
202
|
unsentExits.push(m);
|
|
281
203
|
flushExits();
|
|
@@ -376,11 +298,11 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
376
298
|
stalls.stop();
|
|
377
299
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
378
300
|
for (const c of running.values()) c.abort();
|
|
379
|
-
|
|
301
|
+
const stopped = tools.stop();
|
|
380
302
|
if (state.ws) { try { state.ws.close(1000, 'daemon stopped'); } catch (_) {} try { state.ws.terminate(); } catch (_) {} state.ws = null; }
|
|
303
|
+
return stopped;
|
|
381
304
|
},
|
|
382
305
|
replyApproval(approvalId, decision) { return send({ t: 'approval_reply', approvalId, decision }); },
|
|
383
|
-
jobs,
|
|
384
306
|
};
|
|
385
307
|
}
|
|
386
308
|
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
|
|
10
|
+
const BACKGROUND_FIRST_LOOK_MS = 1500;
|
|
11
|
+
const FOREGROUND_DEFAULT_MS = 600000;
|
|
12
|
+
|
|
13
|
+
function createToolExecutor({ shell, say = () => {}, slowSpawnMs = 2000 } = {}) {
|
|
14
|
+
const jobs = new Jobs();
|
|
15
|
+
|
|
16
|
+
async function runTool(tool, args, signal, timeoutMs, callRoots, requestedAt = Date.now(), cancelled = () => false) {
|
|
17
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
18
|
+
if (signal.aborted || cancelled()) throw new Error('call cancelled before execution');
|
|
19
|
+
switch (tool) {
|
|
20
|
+
case 'bash': {
|
|
21
|
+
const cwdRes = a.cwd ? callRoots.resolve(a.cwd) : null;
|
|
22
|
+
if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
|
|
23
|
+
const cwd = cwdRes ? cwdRes.abs : callRoots.primary();
|
|
24
|
+
if (!cwd) return { ok: false, text: '(no shared folder is available on this device — the user has to add one in the device settings)' };
|
|
25
|
+
const spawnAt = Date.now();
|
|
26
|
+
const job = jobs.start(String(a.command || ''), { cwd, shell });
|
|
27
|
+
const spawnMs = Date.now() - spawnAt;
|
|
28
|
+
if (spawnMs >= slowSpawnMs) say(`starting the shell took ${Math.round(spawnMs / 1000)} s`);
|
|
29
|
+
// A background job belongs to the chat, not to the call that started it.
|
|
30
|
+
const onAbort = () => { if (!a.background) jobs.kill(job.id); };
|
|
31
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
32
|
+
if (signal.aborted || cancelled()) onAbort();
|
|
33
|
+
const limit = timeoutMs || FOREGROUND_DEFAULT_MS;
|
|
34
|
+
const waitMs = a.background ? BACKGROUND_FIRST_LOOK_MS : Math.max(1000, limit - Math.min(5000, Math.floor(limit / 4)));
|
|
35
|
+
// The server's deadline includes time queued in the worker and time in spawn.
|
|
36
|
+
const ended = await jobs.wait(job, a.background ? waitMs : Math.max(0, waitMs - (Date.now() - requestedAt)));
|
|
37
|
+
signal.removeEventListener('abort', onAbort);
|
|
38
|
+
if (ended) {
|
|
39
|
+
const r = jobs.result(job);
|
|
40
|
+
r.touched = extractTouched(callRoots, a.command, r.stdout, r.stderr);
|
|
41
|
+
delete r.stdout;
|
|
42
|
+
delete r.stderr;
|
|
43
|
+
jobs.poll(job.id);
|
|
44
|
+
return r;
|
|
45
|
+
}
|
|
46
|
+
const p = jobs.poll(job.id);
|
|
47
|
+
return { ok: true, running: true, jobId: job.id, text: p.text, exitCode: null, truncated: false, timedOut: false, touched: [] };
|
|
48
|
+
}
|
|
49
|
+
case 'job': {
|
|
50
|
+
const job = jobs.get(a.jobId);
|
|
51
|
+
if (!job) return { ok: false, text: '(no such job on this device — it may have been forgotten after many newer ones)' };
|
|
52
|
+
if (a.action === 'kill') {
|
|
53
|
+
jobs.kill(job.id);
|
|
54
|
+
await jobs.wait(job, 3000);
|
|
55
|
+
}
|
|
56
|
+
const p = jobs.poll(job.id);
|
|
57
|
+
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 };
|
|
58
|
+
}
|
|
59
|
+
case 'read': return files.read(callRoots, a.path, { lines: a.lines });
|
|
60
|
+
case 'write': return files.write(callRoots, a.path, a.content, { overwrite: a.overwrite === true });
|
|
61
|
+
case 'edit': return files.edit(callRoots, a.path, a.edits);
|
|
62
|
+
case 'glob': return files.glob(callRoots, a.pattern);
|
|
63
|
+
case 'outside': {
|
|
64
|
+
const at = a.cwd ? callRoots.resolve(a.cwd) : null;
|
|
65
|
+
return outsideFolders(callRoots, a.command, { cwd: at && !at.error ? at.abs : callRoots.primary() || os.homedir() });
|
|
66
|
+
}
|
|
67
|
+
case 'grep': return files.grep(callRoots, a.command);
|
|
68
|
+
case 'ls': return files.ls(callRoots, a.path, { max: a.max });
|
|
69
|
+
case 'fetchfile': return files.fetchfile(callRoots, a.path, { maxBytes: a.maxBytes });
|
|
70
|
+
case 'fetchpart': return files.fetchpart(callRoots, a.path, { offset: a.offset, length: a.length });
|
|
71
|
+
// These operations are private to the mounted-drive adapter.
|
|
72
|
+
case 'writepart': return files.writepart(callRoots, a.path, { offset: a.offset, base64: a.base64, truncate: a.truncate });
|
|
73
|
+
case 'mkdirpath': return files.mkdirpath(callRoots, a.path);
|
|
74
|
+
case 'removepath': return files.removepath(callRoots, a.path, { endgueltig: a.endgueltig === true });
|
|
75
|
+
case 'renamepath': return files.renamepath(callRoots, a.from, a.to);
|
|
76
|
+
case 'statpath': return files.statpath(callRoots, a.path);
|
|
77
|
+
case 'fetch': return fetchUrl(a.url, { signal, raw: a.raw === true });
|
|
78
|
+
default: return { ok: false, text: `(unknown tool "${tool}" on the companion device)` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { runTool, jobs };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { createToolExecutor };
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
const worker = new Worker(this.workerPath, { workerData: { ...this.options, stopping: this.stopping.buffer } });
|
|
27
|
+
this.worker = worker;
|
|
28
|
+
this.closed = new Promise((resolve) => worker.once('exit', resolve));
|
|
29
|
+
const fail = (error) => {
|
|
30
|
+
this.failure = error;
|
|
31
|
+
for (const p of this.pending.values()) p.reject(error);
|
|
32
|
+
this.pending.clear();
|
|
33
|
+
};
|
|
34
|
+
worker.on('message', (msg) => {
|
|
35
|
+
if (msg.t === 'log') this.emit('log', msg.line);
|
|
36
|
+
else if (msg.t === 'job_exit') this.emit('exit', msg.report);
|
|
37
|
+
else if (msg.t === 'result') {
|
|
38
|
+
const p = this.pending.get(msg.id);
|
|
39
|
+
if (!p) return;
|
|
40
|
+
this.pending.delete(msg.id);
|
|
41
|
+
p.resolve(msg.result);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
worker.on('error', fail);
|
|
45
|
+
worker.on('exit', (code) => {
|
|
46
|
+
// Never replay calls or reset job IDs after a crash: their side effects may exist.
|
|
47
|
+
fail(new Error(`tool worker exited (code ${code}); commands were not retried`));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async run(tool, args, signal, timeoutMs, roots) {
|
|
52
|
+
this.start();
|
|
53
|
+
const id = this.nextId++;
|
|
54
|
+
const cancelled = new Int32Array(new SharedArrayBuffer(4));
|
|
55
|
+
const abort = () => {
|
|
56
|
+
Atomics.store(cancelled, 0, 1);
|
|
57
|
+
this.worker.postMessage({ t: 'cancel', id });
|
|
58
|
+
};
|
|
59
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
60
|
+
try {
|
|
61
|
+
if (signal.aborted) {
|
|
62
|
+
Atomics.store(cancelled, 0, 1);
|
|
63
|
+
throw new Error('call cancelled before execution');
|
|
64
|
+
}
|
|
65
|
+
return await new Promise((resolve, reject) => {
|
|
66
|
+
this.pending.set(id, { resolve, reject });
|
|
67
|
+
try {
|
|
68
|
+
this.worker.postMessage({ t: 'call', id, tool, args, timeoutMs, entries: roots.entries, requestedAt: Date.now(), cancelled: cancelled.buffer });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
this.pending.delete(id);
|
|
71
|
+
reject(e);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
} finally {
|
|
75
|
+
signal.removeEventListener('abort', abort);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
stop() {
|
|
80
|
+
if (!Atomics.exchange(this.stopping, 0, 1) && this.worker) this.worker.postMessage({ t: 'stop' });
|
|
81
|
+
return this.closed || Promise.resolve();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { ToolRunner };
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
executor.jobs.on('exit', (report) => parentPort.postMessage({ t: 'job_exit', report }));
|
|
15
|
+
|
|
16
|
+
parentPort.on('message', async (msg) => {
|
|
17
|
+
if (msg.t === 'cancel') {
|
|
18
|
+
const ctrl = running.get(msg.id);
|
|
19
|
+
if (ctrl) ctrl.abort();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (msg.t === 'stop') {
|
|
23
|
+
for (const ctrl of running.values()) ctrl.abort();
|
|
24
|
+
executor.jobs.killAll();
|
|
25
|
+
await Promise.all(executor.jobs.running().map((job) => executor.jobs.wait(job, 5000)));
|
|
26
|
+
parentPort.close();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (msg.t !== 'call') return;
|
|
30
|
+
const ctrl = new AbortController();
|
|
31
|
+
const cancelled = new Int32Array(msg.cancelled);
|
|
32
|
+
const isCancelled = () => !!Atomics.load(cancelled, 0) || !!Atomics.load(stopping, 0);
|
|
33
|
+
running.set(msg.id, ctrl);
|
|
34
|
+
let result;
|
|
35
|
+
try {
|
|
36
|
+
if (isCancelled()) throw new Error('call cancelled before execution');
|
|
37
|
+
const roots = new Roots([]);
|
|
38
|
+
// This is the authorized per-call snapshot, never the worker's previous roots.
|
|
39
|
+
roots.entries = msg.entries;
|
|
40
|
+
result = await executor.runTool(msg.tool, msg.args, ctrl.signal, msg.timeoutMs, roots, msg.requestedAt, isCancelled);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
result = { ok: false, text: `(tool error on the companion device: ${e.message})` };
|
|
43
|
+
} finally {
|
|
44
|
+
running.delete(msg.id);
|
|
45
|
+
}
|
|
46
|
+
parentPort.postMessage({ t: 'result', id: msg.id, result });
|
|
47
|
+
});
|