@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.
@@ -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.4.6",
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,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 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;
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 jobs = new Jobs();
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 runTool(tool, msg.args, ctrl.signal, Number(msg.timeoutMs) || undefined, callRoots);
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
- jobs.on('exit', (r) => {
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
- jobs.killAll();
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
+ });