@tensor.chat/companion 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tensor.chat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @tensor.chat/companion
2
+
3
+ Lets your tensor.chat AI use this machine: read and edit files in folders you share,
4
+ run shell commands, and fetch web pages through your own internet connection.
5
+
6
+ The daemon only ever connects **outbound** to tensor.chat over a WebSocket. Nothing
7
+ listens on your machine, no port has to be opened.
8
+
9
+ ## Install and pair
10
+
11
+ ```
12
+ npx @tensor.chat/companion pair
13
+ ```
14
+
15
+ The command prints a code such as `AB3D-7KQ2`. In tensor.chat open
16
+ **Settings → Companion devices → Pair a device** and enter it. Then keep the daemon
17
+ running:
18
+
19
+ ```
20
+ npx @tensor.chat/companion run
21
+ ```
22
+
23
+ In any chat, switch **Agentic** on and check the computer under **Connected computers**.
24
+ It then appears as the folder `Devices/<name>/` in that chat's file tree.
25
+
26
+ ## What the AI can do, and where
27
+
28
+ - **Shared folders** are set in the device settings on tensor.chat. Read, Write, Edit,
29
+ Glob and Grep only work inside them. Paths outside, including symlinks that point
30
+ outside, are refused on this machine.
31
+ - **Shell commands** stay in tensor.chat's server sandbox by default. Only when you switch
32
+ **Shell on the device** on for a chat does the AI get a shell on this machine: it runs
33
+ in the first shared folder with your own rights and full network, and it is not
34
+ sandboxed — that is the point of a companion device. Git Bash is used on Windows when
35
+ it is installed, PowerShell otherwise; `tensor-companion shell <path>` overrides it.
36
+ A long-running command can be started as a background job and looked at later.
37
+ - **Fetch** downloads a page or file through your connection and hands the text to the AI.
38
+
39
+ ## Approval
40
+
41
+ Every device has an approval mode, chosen on tensor.chat:
42
+
43
+ | Mode | Behaviour |
44
+ |---|---|
45
+ | Safety check decides (default) | A small model sorts each action into allow / ask / block. Anything unclear blocks. |
46
+ | Ask me every time | Every write, edit, shell command and fetch of a new site waits for your OK in the chat. |
47
+ | Allow everything | No questions. Only for machines where nothing can go wrong. |
48
+
49
+ Reads (Read, Glob, Grep) never ask. An approval request shows up in the chat on every
50
+ device you are logged in on and expires after five minutes; a denied action comes back
51
+ to the AI as a failed tool call.
52
+
53
+ ## Files
54
+
55
+ - `~/.tensor-companion/config.json` holds the pairing token (mode 0600). `TENSOR_COMPANION_HOME`
56
+ moves the folder.
57
+ - `tensor-companion status` shows the pairing, `tensor-companion unpair` forgets it.
58
+ Remove the device on tensor.chat as well — that is what actually revokes the token.
59
+
60
+ ## Server-side install (headless)
61
+
62
+ The same package runs on any Linux box with Node 20+:
63
+
64
+ ```
65
+ npm install -g @tensor.chat/companion
66
+ tensor-companion pair --server https://tensor.chat
67
+ tensor-companion run
68
+ ```
69
+
70
+ Wrap `run` in a systemd unit or `nohup` to keep it alive. Remember: the AI then acts
71
+ with the rights of the user the daemon runs as. Do not run it as root.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // tensor-companion — pair this machine with your tensor.chat account and keep it
4
+ // available for the AI. Commands: pair, run, status, shell, unpair.
5
+
6
+ const os = require('os');
7
+ const config = require('../src/config');
8
+ const { createDaemon, VERSION } = require('../src/daemon');
9
+
10
+ const args = process.argv.slice(2);
11
+ const cmd = args[0] || 'help';
12
+
13
+ function opt(name, fallback) {
14
+ const i = args.indexOf(name);
15
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
16
+ }
17
+
18
+ function out(s) { process.stdout.write(s + '\n'); }
19
+
20
+ async function postJson(base, route, body) {
21
+ const res = await fetch(base.replace(/\/+$/, '') + route, {
22
+ method: 'POST',
23
+ headers: { 'content-type': 'application/json' },
24
+ body: JSON.stringify(body || {}),
25
+ });
26
+ let data = null;
27
+ try { data = await res.json(); } catch (_) {}
28
+ if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
29
+ return data;
30
+ }
31
+
32
+ async function pair() {
33
+ const server = opt('--server', config.load().serverUrl || config.DEFAULT_SERVER);
34
+ const name = opt('--name', os.hostname());
35
+ const start = await postJson(server, '/api/companion/pair/start', { platform: process.platform, hostname: os.hostname() });
36
+ const pretty = start.code.slice(0, 4) + '-' + start.code.slice(4);
37
+ out('');
38
+ out(` Pairing code: ${pretty}`);
39
+ out('');
40
+ out(` Open ${server} → Settings → Companion devices → "Pair a device" and enter the code.`);
41
+ out(` This code expires in ${Math.round(start.expiresInSec / 60)} minutes.`);
42
+ out('');
43
+ const deadline = Date.now() + start.expiresInSec * 1000;
44
+ while (Date.now() < deadline) {
45
+ await new Promise((r) => setTimeout(r, 3000));
46
+ const poll = await postJson(server, '/api/companion/pair/poll', { pollSecret: start.pollSecret });
47
+ if (poll.status === 'confirmed') {
48
+ const cfg = { ...config.load(), serverUrl: server, token: poll.token, deviceId: poll.deviceId, name: poll.name || name };
49
+ config.save(cfg);
50
+ out(` Paired as "${cfg.name}". Config saved to ${config.configPath()}`);
51
+ out(' Start the daemon with: npx @tensor.chat/companion run');
52
+ return;
53
+ }
54
+ if (poll.status === 'expired') break;
55
+ }
56
+ throw new Error('the pairing code expired — run "npx @tensor.chat/companion pair" again');
57
+ }
58
+
59
+ function run() {
60
+ const cfg = config.load();
61
+ if (!cfg.token || !cfg.serverUrl) throw new Error('not paired yet — run "npx @tensor.chat/companion pair" first');
62
+ const stamp = () => new Date().toISOString().slice(11, 19);
63
+ const daemon = createDaemon({ serverUrl: cfg.serverUrl, token: cfg.token, shell: cfg.shell, log: (m) => out(`[${stamp()}] ${m}`) });
64
+ daemon.events.on('welcome', (w) => out(`[${stamp()}] ready as "${w.name || cfg.name}" (device ${w.deviceId})`));
65
+ daemon.events.on('config', (c) => {
66
+ const bad = c.roots.filter((r) => !r.ok);
67
+ out(`[${stamp()}] mode=${c.mode} shared folders: ${c.roots.length ? c.roots.map((r) => r.path + (r.ok ? '' : ` (${r.error})`)).join(', ') : '(none yet — add one in the device settings)'}`);
68
+ if (bad.length) out(`[${stamp()}] ${bad.length} shared folder(s) are not usable on this machine`);
69
+ });
70
+ daemon.events.on('call', (c) => out(`[${stamp()}] ${c.tool}: ${summarize(c.args)}`));
71
+ daemon.events.on('approval', (a) => out(`[${stamp()}] approval requested for ${a.tool}: ${a.summary || ''} — answer it in the chat`));
72
+ daemon.events.on('unauthorized', () => { out('the server rejected this device — pair again with "npx @tensor.chat/companion pair"'); process.exit(2); });
73
+ daemon.events.on('removed', () => { out('this device was removed in the account settings — exiting'); process.exit(3); });
74
+ daemon.start();
75
+ const stop = () => { daemon.stop(); setTimeout(() => process.exit(0), 200); };
76
+ process.on('SIGINT', stop);
77
+ process.on('SIGTERM', stop);
78
+ }
79
+
80
+ function summarize(a) {
81
+ if (!a || typeof a !== 'object') return '';
82
+ const s = a.command || a.path || a.pattern || a.url || '';
83
+ return String(s).replace(/\s+/g, ' ').slice(0, 100);
84
+ }
85
+
86
+ function status() {
87
+ const cfg = config.load();
88
+ out(`tensor-companion ${VERSION}`);
89
+ out(`config: ${config.configPath()}`);
90
+ out(`server: ${cfg.serverUrl || '(not paired)'}`);
91
+ out(`device: ${cfg.deviceId || '(not paired)'} ${cfg.name ? '"' + cfg.name + '"' : ''}`);
92
+ out(`shell: ${cfg.shell || '(auto)'}`);
93
+ }
94
+
95
+ function shell() {
96
+ const cfg = config.load();
97
+ const value = args[1];
98
+ if (!value) throw new Error('usage: tensor-companion shell <path-to-shell|auto>');
99
+ if (value === 'auto') delete cfg.shell; else cfg.shell = value;
100
+ config.save(cfg);
101
+ out(`shell set to ${cfg.shell || '(auto)'}`);
102
+ }
103
+
104
+ function unpair() {
105
+ const cfg = config.load();
106
+ delete cfg.token;
107
+ delete cfg.deviceId;
108
+ config.save(cfg);
109
+ out('local pairing removed — also remove the device in the account settings');
110
+ }
111
+
112
+ function help() {
113
+ out(`tensor-companion ${VERSION}
114
+
115
+ tensor-companion pair [--server URL] [--name NAME] pair this machine with your account
116
+ tensor-companion run stay connected and run tools for the AI
117
+ tensor-companion status show the current pairing
118
+ tensor-companion shell <path|auto> choose the shell Bash commands run in
119
+ tensor-companion unpair forget the local pairing`);
120
+ }
121
+
122
+ (async () => {
123
+ try {
124
+ if (cmd === 'pair') await pair();
125
+ else if (cmd === 'run') run();
126
+ else if (cmd === 'status') status();
127
+ else if (cmd === 'shell') shell();
128
+ else if (cmd === 'unpair') unpair();
129
+ else help();
130
+ } catch (e) {
131
+ process.stderr.write(`error: ${e.message}\n`);
132
+ process.exit(1);
133
+ }
134
+ })();
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@tensor.chat/companion",
3
+ "version": "0.1.0",
4
+ "description": "Lets your tensor.chat AI use this machine: files, shell and internet through your own connection.",
5
+ "license": "MIT",
6
+ "homepage": "https://tensor.chat",
7
+ "keywords": [
8
+ "tensor.chat",
9
+ "ai",
10
+ "companion",
11
+ "agent",
12
+ "remote-tools"
13
+ ],
14
+ "bin": {
15
+ "tensor-companion": "bin/tensor-companion.js"
16
+ },
17
+ "main": "src/daemon.js",
18
+ "files": [
19
+ "bin",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "start": "node bin/tensor-companion.js run"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "ws": "^8.21.3"
35
+ }
36
+ }
package/src/config.js ADDED
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+ // Where the daemon keeps its pairing: ~/.tensor-companion/config.json (0600).
3
+ // TENSOR_COMPANION_HOME overrides the folder so several daemons or tests can coexist.
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const DEFAULT_SERVER = 'https://tensor.chat';
10
+
11
+ function homeDir() {
12
+ return process.env.TENSOR_COMPANION_HOME || path.join(os.homedir(), '.tensor-companion');
13
+ }
14
+
15
+ function configPath() {
16
+ return path.join(homeDir(), 'config.json');
17
+ }
18
+
19
+ function load() {
20
+ try {
21
+ const cfg = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
22
+ return cfg && typeof cfg === 'object' ? cfg : {};
23
+ } catch (_) {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ function save(cfg) {
29
+ const dir = homeDir();
30
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
31
+ const tmp = configPath() + '.tmp';
32
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
33
+ fs.renameSync(tmp, configPath());
34
+ }
35
+
36
+ module.exports = { DEFAULT_SERVER, homeDir, configPath, load, save };
package/src/daemon.js ADDED
@@ -0,0 +1,249 @@
1
+ 'use strict';
2
+ // The Companion daemon: one outbound WebSocket to tensor.chat, tools executed on this
3
+ // machine on request. The server decides WHAT runs (approval mode, budgets); this
4
+ // side decides WHERE (the shared folders) and enforces the output caps.
5
+
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const EventEmitter = require('events');
9
+ const WebSocket = require('ws');
10
+ const { Roots } = require('./paths');
11
+ const bash = require('./tools/bash');
12
+ const files = require('./tools/files');
13
+ const { fetchUrl } = require('./tools/fetch');
14
+ const { extractTouched } = require('./tools/touched');
15
+ const { Jobs } = require('./tools/jobs');
16
+
17
+ const VERSION = require('../package.json').version;
18
+ const RECONNECT_MIN_MS = 1000;
19
+ const RECONNECT_MAX_MS = 60000;
20
+ const MAX_CONCURRENT_CALLS = 4;
21
+ const BACKGROUND_FIRST_LOOK_MS = 1500; // a background start still reports the first lines
22
+ const FOREGROUND_DEFAULT_MS = 600000;
23
+
24
+ function wsUrlFor(serverUrl) {
25
+ const u = new URL(serverUrl);
26
+ u.protocol = u.protocol === 'http:' ? 'ws:' : 'wss:';
27
+ u.pathname = u.pathname.replace(/\/+$/, '') + '/api/companion/ws';
28
+ u.search = '';
29
+ u.hash = '';
30
+ return u.href;
31
+ }
32
+
33
+ function isRoot() {
34
+ try { return typeof process.getuid === 'function' && process.getuid() === 0; } catch (_) { return false; }
35
+ }
36
+
37
+ function systemInfo(shell) {
38
+ let username = null;
39
+ try { username = os.userInfo().username; } catch (_) {}
40
+ return {
41
+ t: 'hello',
42
+ platform: process.platform,
43
+ release: os.release(),
44
+ arch: process.arch,
45
+ hostname: os.hostname(),
46
+ username,
47
+ isRoot: isRoot(),
48
+ version: VERSION,
49
+ node: process.version,
50
+ shell: bash.shellFor(shell).cmd,
51
+ shellKind: bash.shellFor(shell).kind,
52
+ cwd: process.cwd(),
53
+ };
54
+ }
55
+
56
+ function createDaemon({ serverUrl, token, shell, log } = {}) {
57
+ if (!serverUrl || !token) throw new Error('serverUrl and token are required');
58
+ const emitter = new EventEmitter();
59
+ const say = typeof log === 'function' ? log : () => {};
60
+ const roots = new Roots([]);
61
+ const running = new Map(); // call id → AbortController
62
+ const jobs = new Jobs();
63
+ const unsentExits = []; // job endings the server has not heard yet (offline at the time)
64
+ const state = { connected: false, deviceId: null, name: null, mode: null, roots: [], labels: [], reconnectMs: RECONNECT_MIN_MS, stopped: false, ws: null, timer: null };
65
+
66
+ function send(msg) {
67
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
68
+ try { state.ws.send(JSON.stringify(msg)); return true; } catch (_) { return false; }
69
+ }
70
+
71
+ function applyConfig(msg) {
72
+ state.roots = Array.isArray(msg.roots) ? msg.roots.map(String) : [];
73
+ state.labels = Array.isArray(msg.labels) ? msg.labels.map(String) : [];
74
+ state.mode = msg.mode || null;
75
+ state.name = msg.name || state.name;
76
+ roots.set(state.roots, state.labels);
77
+ send({ t: 'roots_status', roots: roots.status() });
78
+ emitter.emit('config', { roots: roots.status(), mode: state.mode, name: state.name });
79
+ }
80
+
81
+ async function runTool(tool, args, signal, timeoutMs) {
82
+ const a = args && typeof args === 'object' ? args : {};
83
+ switch (tool) {
84
+ case 'bash': {
85
+ const cwdRes = a.cwd ? roots.resolve(a.cwd) : null;
86
+ if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
87
+ const cwd = cwdRes ? cwdRes.abs : roots.primary();
88
+ if (!cwd) return { ok: false, text: '(no shared folder is available on this device — the user has to add one in the device settings)' };
89
+ const job = jobs.start(String(a.command || ''), { cwd, shell });
90
+ // A cancelled call (turn aborted, server timeout) takes its foreground job with
91
+ // it; a background job belongs to the chat, not to the call, and lives on.
92
+ const onAbort = () => { if (!a.background) jobs.kill(job.id); };
93
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
94
+ // The server cancels a call at timeoutMs; answering a little earlier keeps the job
95
+ // alive as a background job instead of letting the cancel kill it.
96
+ const limit = timeoutMs || FOREGROUND_DEFAULT_MS;
97
+ const waitMs = a.background ? BACKGROUND_FIRST_LOOK_MS : Math.max(1000, limit - Math.min(5000, Math.floor(limit / 4)));
98
+ const ended = await jobs.wait(job, waitMs);
99
+ if (signal) signal.removeEventListener('abort', onAbort);
100
+ if (ended) {
101
+ const r = jobs.result(job);
102
+ r.touched = extractTouched(roots, a.command, r.stdout, r.stderr);
103
+ delete r.stdout;
104
+ delete r.stderr;
105
+ jobs.poll(job.id); // the one-shot result already carried every line
106
+ return r;
107
+ }
108
+ // Still running: it becomes (or already was) a background job.
109
+ const p = jobs.poll(job.id);
110
+ return { ok: true, running: true, jobId: job.id, text: p.text, exitCode: null, truncated: false, timedOut: false, touched: [] };
111
+ }
112
+ case 'job': {
113
+ const job = jobs.get(a.jobId);
114
+ if (!job) return { ok: false, text: '(no such job on this device — it may have been forgotten after many newer ones)' };
115
+ if (a.action === 'kill') {
116
+ jobs.kill(job.id);
117
+ await jobs.wait(job, 3000);
118
+ }
119
+ const p = jobs.poll(job.id);
120
+ 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 };
121
+ }
122
+ case 'read': return files.read(roots, a.path, { lines: a.lines });
123
+ case 'write': return files.write(roots, a.path, a.content, { overwrite: a.overwrite === true });
124
+ case 'edit': return files.edit(roots, a.path, a.edits);
125
+ case 'glob': return files.glob(roots, a.pattern);
126
+ case 'grep': return files.grep(roots, a.command);
127
+ case 'ls': return files.ls(roots, a.path);
128
+ case 'fetchfile': return files.fetchfile(roots, a.path, { maxBytes: a.maxBytes });
129
+ case 'fetch': return fetchUrl(a.url, { signal });
130
+ default: return { ok: false, text: `(unknown tool "${tool}" on the companion device)` };
131
+ }
132
+ }
133
+
134
+ async function onCall(msg) {
135
+ const id = String(msg.id);
136
+ if (running.size >= MAX_CONCURRENT_CALLS) {
137
+ send({ t: 'result', id, ok: false, text: '(the companion device is busy with other calls — try again in a moment)' });
138
+ return;
139
+ }
140
+ const ctrl = new AbortController();
141
+ running.set(id, ctrl);
142
+ emitter.emit('call', { id, tool: msg.tool, args: msg.args });
143
+ let r;
144
+ try {
145
+ r = await runTool(String(msg.tool || ''), msg.args, ctrl.signal, Number(msg.timeoutMs) || undefined);
146
+ } catch (e) {
147
+ r = { ok: false, text: `(tool error on the companion device: ${e && e.message ? e.message : e})` };
148
+ }
149
+ running.delete(id);
150
+ send({ t: 'result', id, ...r });
151
+ emitter.emit('result', { id, tool: msg.tool, ok: r.ok });
152
+ }
153
+
154
+ function flushExits() {
155
+ while (unsentExits.length) {
156
+ const m = unsentExits[0];
157
+ if (!send(m)) return;
158
+ unsentExits.shift();
159
+ }
160
+ }
161
+ jobs.on('exit', (r) => {
162
+ const m = { t: 'job_exit', jobId: r.jobId, status: r.status, exitCode: r.exitCode, ms: r.ms, tail: r.tail };
163
+ unsentExits.push(m);
164
+ flushExits();
165
+ emitter.emit('job_exit', r);
166
+ });
167
+
168
+ function onMessage(data) {
169
+ let msg = null;
170
+ try { msg = JSON.parse(data.toString('utf8')); } catch (_) { return; }
171
+ if (!msg || typeof msg.t !== 'string') return;
172
+ switch (msg.t) {
173
+ case 'welcome':
174
+ state.deviceId = msg.deviceId || state.deviceId;
175
+ applyConfig(msg);
176
+ emitter.emit('welcome', { deviceId: state.deviceId, name: state.name });
177
+ break;
178
+ case 'config': applyConfig(msg); break;
179
+ case 'call': onCall(msg); break;
180
+ case 'cancel': { const c = running.get(String(msg.id)); if (c) c.abort(); break; }
181
+ case 'approval': emitter.emit('approval', msg); break;
182
+ case 'approval_resolved': emitter.emit('approval_resolved', msg); break;
183
+ default: break;
184
+ }
185
+ }
186
+
187
+ function scheduleReconnect() {
188
+ if (state.stopped || state.timer) return;
189
+ const wait = state.reconnectMs;
190
+ state.reconnectMs = Math.min(RECONNECT_MAX_MS, Math.round(state.reconnectMs * 2));
191
+ state.timer = setTimeout(() => { state.timer = null; connect(); }, wait);
192
+ say(`reconnecting in ${Math.round(wait / 1000)} s`);
193
+ }
194
+
195
+ function connect() {
196
+ if (state.stopped) return;
197
+ const url = wsUrlFor(serverUrl);
198
+ let ws;
199
+ try {
200
+ ws = new WebSocket(url, { headers: { authorization: `Bearer ${token}` }, handshakeTimeout: 15000 });
201
+ } catch (e) {
202
+ say(`connect failed: ${e.message}`);
203
+ scheduleReconnect();
204
+ return;
205
+ }
206
+ state.ws = ws;
207
+ ws.on('open', () => {
208
+ state.connected = true;
209
+ state.reconnectMs = RECONNECT_MIN_MS;
210
+ send(systemInfo(shell));
211
+ flushExits();
212
+ say(`connected to ${serverUrl}`);
213
+ emitter.emit('connected');
214
+ });
215
+ ws.on('message', onMessage);
216
+ ws.on('unexpected-response', (_req, res) => {
217
+ const code = res && res.statusCode;
218
+ say(`server refused the connection (HTTP ${code})`);
219
+ if (code === 401) { state.stopped = true; emitter.emit('unauthorized'); }
220
+ });
221
+ ws.on('error', (e) => { say(`socket error: ${e.message}`); });
222
+ ws.on('close', (code, reason) => {
223
+ const was = state.connected;
224
+ state.connected = false;
225
+ state.ws = null;
226
+ for (const c of running.values()) c.abort();
227
+ if (was) emitter.emit('disconnected', { code, reason: reason ? reason.toString() : '' });
228
+ if (code === 4002) { say('this device was removed on the server'); state.stopped = true; emitter.emit('removed'); return; }
229
+ scheduleReconnect();
230
+ });
231
+ }
232
+
233
+ return {
234
+ events: emitter,
235
+ state,
236
+ start() { state.stopped = false; connect(); return this; },
237
+ stop() {
238
+ state.stopped = true;
239
+ if (state.timer) { clearTimeout(state.timer); state.timer = null; }
240
+ for (const c of running.values()) c.abort();
241
+ jobs.killAll();
242
+ if (state.ws) { try { state.ws.close(1000, 'daemon stopped'); } catch (_) {} try { state.ws.terminate(); } catch (_) {} state.ws = null; }
243
+ },
244
+ replyApproval(approvalId, decision) { return send({ t: 'approval_reply', approvalId, decision }); },
245
+ jobs,
246
+ };
247
+ }
248
+
249
+ module.exports = { createDaemon, wsUrlFor, systemInfo, VERSION };
package/src/paths.js ADDED
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+ // Shared folders ("roots") and the one rule every file tool obeys: a path is only
3
+ // usable when its real location lies inside a shared folder. Symlinks are resolved
4
+ // on the existing part of the path, so a link that points out of the folder is
5
+ // refused the same way a ../ escape is.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const WIN = process.platform === 'win32';
11
+ const norm = (p) => (WIN ? String(p).toLowerCase() : String(p));
12
+
13
+ function realpathOrNull(p) {
14
+ try { return fs.realpathSync.native(p); } catch (_) { return null; }
15
+ }
16
+
17
+ // Real path of the deepest existing ancestor, with the missing tail re-appended —
18
+ // so a file that does not exist yet still resolves to where it would be created.
19
+ function realExisting(abs) {
20
+ let cur = abs;
21
+ const tail = [];
22
+ for (;;) {
23
+ const rp = realpathOrNull(cur);
24
+ if (rp) return tail.length ? path.join(rp, ...tail.reverse()) : rp;
25
+ const parent = path.dirname(cur);
26
+ if (parent === cur) return null;
27
+ tail.push(path.basename(cur));
28
+ cur = parent;
29
+ }
30
+ }
31
+
32
+ function isInside(abs, root) {
33
+ const rel = path.relative(norm(root), norm(abs));
34
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
35
+ }
36
+
37
+ class Roots {
38
+ constructor(list, labels) { this.set(list, labels); }
39
+
40
+ // labels: the tree prefix per root (server-assigned, e.g. "Devices/Devbox/game");
41
+ // a root without one is shown by its absolute path.
42
+ set(list, labels) {
43
+ this.entries = [];
44
+ const ls = Array.isArray(labels) ? labels : [];
45
+ let i = -1;
46
+ for (const raw of Array.isArray(list) ? list : []) {
47
+ i++;
48
+ const abs = path.resolve(String(raw));
49
+ const real = realpathOrNull(abs);
50
+ let ok = false;
51
+ let error = null;
52
+ try {
53
+ ok = !!real && fs.statSync(real).isDirectory();
54
+ if (!ok) error = real ? 'not a folder' : 'does not exist';
55
+ } catch (e) {
56
+ error = e.code === 'ENOENT' ? 'does not exist' : e.message;
57
+ }
58
+ this.entries.push({ path: abs, real, ok, error, label: ls[i] ? String(ls[i]).replace(/\/+$/, '') : null });
59
+ }
60
+ }
61
+
62
+ status() { return this.entries.map((e) => ({ path: e.path, ok: e.ok, error: e.error })); }
63
+
64
+ okRoots() { return this.entries.filter((e) => e.ok); }
65
+
66
+ primary() { const r = this.okRoots()[0]; return r ? r.real : null; }
67
+
68
+ // {abs, root} for a usable path, {error} otherwise. Relative paths count from the
69
+ // first shared folder.
70
+ resolve(p) {
71
+ const roots = this.okRoots();
72
+ if (!roots.length) return { error: 'no shared folder is available on this device — the user has to add one in the device settings' };
73
+ const raw = String(p == null ? '' : p).trim().replace(/[\u0000-\u001f]/g, '');
74
+ if (!raw) return { error: 'empty path' };
75
+ const viaTree = this.fromTree(raw);
76
+ if (!viaTree && /^devices\//i.test(raw.replace(/\\/g, '/')) && roots.some((r) => r.label)) return { error: `"${raw}" is not one of this computer's shared folders (${roots.map((r) => r.label || r.path).join(', ')})` };
77
+ const abs = viaTree ? viaTree.abs : path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(roots[0].real, raw);
78
+ const real = realExisting(abs);
79
+ if (!real) return { error: 'invalid path' };
80
+ for (const r of roots) if (isInside(real, r.real)) return { abs: real, root: r.real };
81
+ return { error: `"${raw}" is outside the shared folders (${roots.map((r) => r.path).join(', ')})` };
82
+ }
83
+
84
+ // How a path is shown to the model: as its tree path (label + relative part) when
85
+ // the root has a label, else relative inside the first folder, else absolute.
86
+ display(abs) {
87
+ for (const r of this.okRoots()) {
88
+ if (!isInside(abs, r.real)) continue;
89
+ const rel = path.relative(r.real, abs).split(path.sep).join('/');
90
+ if (r.label) return rel ? r.label + '/' + rel : r.label;
91
+ if (r === this.okRoots()[0]) return rel || '.';
92
+ }
93
+ return abs;
94
+ }
95
+
96
+ // The root a tree path belongs to (by label) → {abs} for the file it names.
97
+ fromTree(treePath) {
98
+ const p = String(treePath == null ? '' : treePath).trim().replace(/\\/g, '/');
99
+ for (const r of this.okRoots()) {
100
+ if (!r.label) continue;
101
+ if (p === r.label) return { abs: r.real, root: r.real };
102
+ if (p.startsWith(r.label + '/')) return { abs: path.join(r.real, ...p.slice(r.label.length + 1).split('/').filter(Boolean)), root: r.real };
103
+ }
104
+ return null;
105
+ }
106
+ }
107
+
108
+ module.exports = { Roots, isInside, realExisting };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+ // Which shell a command runs in on this machine, how its process tree is ended and how
3
+ // its result reads. jobs.js starts the process. Nothing here is a sandbox: a command runs
4
+ // with the daemon's own rights, which is the whole point of a companion device. The
5
+ // fences are elsewhere — the approval mode on the server and the output cap here.
6
+
7
+ const { spawn } = require('child_process');
8
+ const fs = require('fs');
9
+
10
+ const OUTPUT_CAP = 64 * 1024;
11
+ const WIN = process.platform === 'win32';
12
+
13
+ // Git Bash when it is installed (any drive, PATH first), else PowerShell. The
14
+ // System32 and WindowsApps bash.exe are WSL launchers with another filesystem.
15
+ function findGitBash() {
16
+ const path = require('path');
17
+ const seen = new Set();
18
+ const candidates = [];
19
+ for (const dir of String(process.env.PATH || '').split(';')) {
20
+ if (!dir || /\\(System32|WindowsApps)\\?$/i.test(dir)) continue;
21
+ candidates.push(path.join(dir, 'bash.exe'));
22
+ }
23
+ const bases = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs')];
24
+ for (const base of bases) {
25
+ if (base) candidates.push(path.join(base, 'Git', 'bin', 'bash.exe'), path.join(base, 'Git', 'usr', 'bin', 'bash.exe'));
26
+ }
27
+ for (const c of candidates) {
28
+ const key = c.toLowerCase();
29
+ if (seen.has(key)) continue;
30
+ seen.add(key);
31
+ if (fs.existsSync(c)) return c;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function defaultShell() {
37
+ if (WIN) {
38
+ const gitBash = findGitBash();
39
+ if (gitBash) return { cmd: gitBash, args: ['-c'], kind: 'bash' };
40
+ return { cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command'], kind: 'powershell' };
41
+ }
42
+ for (const c of ['/bin/bash', '/usr/bin/bash', '/bin/sh']) if (fs.existsSync(c)) return { cmd: c, args: ['-c'], kind: 'bash' };
43
+ return { cmd: 'sh', args: ['-c'], kind: 'sh' };
44
+ }
45
+
46
+ function shellFor(configured) {
47
+ if (configured) {
48
+ const isPs = /powershell|pwsh/i.test(configured);
49
+ return { cmd: configured, args: isPs ? ['-NoProfile', '-NonInteractive', '-Command'] : ['-c'], kind: isPs ? 'powershell' : 'bash' };
50
+ }
51
+ return defaultShell();
52
+ }
53
+
54
+ function killTree(child) {
55
+ if (!child.pid) return;
56
+ if (WIN) {
57
+ try { spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch (_) {}
58
+ return;
59
+ }
60
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (_) {
61
+ try { child.kill('SIGKILL'); } catch (_) {}
62
+ }
63
+ }
64
+
65
+ function formatResult(r) {
66
+ let text = r.stdout;
67
+ if (r.stderr) text += (text ? '\n' : '') + '--- stderr ---\n' + r.stderr;
68
+ const notes = [];
69
+ if (r.exitCode !== 0 && r.exitCode != null) notes.push(`exit ${r.exitCode}`);
70
+ if (r.timedOut) notes.push('command hit the time limit and was killed');
71
+ if (r.truncated) notes.push('output truncated to the size cap');
72
+ if (notes.length) text += (text ? '\n' : '') + `(${notes.join('; ')})`;
73
+ return text || '(no output)';
74
+ }
75
+
76
+ module.exports = { shellFor, defaultShell, formatResult, killTree, OUTPUT_CAP };
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+ // Fetches a URL through this machine's own internet connection and returns a text
3
+ // view: HTML is reduced to its visible text, JSON is pretty-printed, everything else
4
+ // is shown as text when it is text at all.
5
+
6
+ const MAX_BYTES = 2 * 1024 * 1024;
7
+ const MAX_CHARS = 50000;
8
+ const TIMEOUT_MS = 20000;
9
+
10
+ function htmlToText(html) {
11
+ return String(html)
12
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
13
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
14
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
15
+ .replace(/<!--[\s\S]*?-->/g, ' ')
16
+ .replace(/<\s*(br|p|div|li|tr|h[1-6]|section|article|header|footer|pre|blockquote)[^>]*>/gi, '\n')
17
+ .replace(/<[^>]+>/g, ' ')
18
+ .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
19
+ .replace(/[ \t]+/g, ' ')
20
+ .replace(/\n\s*\n\s*\n+/g, '\n\n')
21
+ .trim();
22
+ }
23
+
24
+ async function fetchUrl(rawUrl, { signal, timeoutMs } = {}) {
25
+ let url;
26
+ try { url = new URL(String(rawUrl || '').trim()); } catch (_) { return { ok: false, text: '(invalid URL)' }; }
27
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return { ok: false, text: '(only http and https URLs can be fetched)' };
28
+ const ctrl = new AbortController();
29
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs || TIMEOUT_MS);
30
+ const onAbort = () => ctrl.abort();
31
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
32
+ try {
33
+ const res = await fetch(url, {
34
+ signal: ctrl.signal,
35
+ redirect: 'follow',
36
+ headers: { 'user-agent': 'tensor-companion/0.1 (+https://tensor.chat)', accept: 'text/html,application/xhtml+xml,application/json,text/plain,*/*;q=0.8' },
37
+ });
38
+ const type = String(res.headers.get('content-type') || '').toLowerCase();
39
+ const reader = res.body ? res.body.getReader() : null;
40
+ const chunks = [];
41
+ let size = 0;
42
+ let truncated = false;
43
+ if (reader) {
44
+ for (;;) {
45
+ const { done, value } = await reader.read();
46
+ if (done) break;
47
+ size += value.length;
48
+ if (size > MAX_BYTES) { chunks.push(value.subarray(0, value.length - (size - MAX_BYTES))); truncated = true; try { await reader.cancel(); } catch (_) {} break; }
49
+ chunks.push(value);
50
+ }
51
+ }
52
+ const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
53
+ if (buf.includes(0) && !/text|json|xml|html|javascript/.test(type)) {
54
+ return { ok: true, text: `${url.href}\nHTTP ${res.status} ${type || 'unknown type'}\n(binary content, ${size} bytes — not shown)` };
55
+ }
56
+ let body = buf.toString('utf8');
57
+ if (/html/.test(type) || /^\s*<(!doctype|html)/i.test(body)) body = htmlToText(body);
58
+ else if (/json/.test(type)) { try { body = JSON.stringify(JSON.parse(body), null, 2); } catch (_) {} }
59
+ if (body.length > MAX_CHARS) { body = body.slice(0, MAX_CHARS) + `\n… [truncated to ${MAX_CHARS} chars]`; truncated = true; }
60
+ return { ok: res.ok, text: `${url.href}\nHTTP ${res.status} ${type || ''}${truncated ? ' (truncated)' : ''}\n\n${body}` };
61
+ } catch (e) {
62
+ const why = ctrl.signal.aborted ? 'timed out' : (e && e.cause && e.cause.code) || (e && e.message) || 'failed';
63
+ return { ok: false, text: `(fetch of ${url.href} ${why})` };
64
+ } finally {
65
+ clearTimeout(timer);
66
+ if (signal) signal.removeEventListener('abort', onAbort);
67
+ }
68
+ }
69
+
70
+ module.exports = { fetchUrl, htmlToText, MAX_BYTES, MAX_CHARS };
@@ -0,0 +1,349 @@
1
+ 'use strict';
2
+ // Read / Write / Edit / Glob / Grep inside the shared folders. The wording of results
3
+ // mirrors the server's file tools so the model sees one dialect whether a file lives
4
+ // on the server or on this machine.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const READ_CHAR_CAP = 50000;
10
+ const WRITE_CHAR_CAP = 200000;
11
+ const GREP_FILE_MAX_BYTES = 2 * 1024 * 1024;
12
+ const GREP_MAX_MATCHES = 200;
13
+ const GREP_RESULT_CAP = 6000;
14
+ const GLOB_MAX_RESULTS = 500;
15
+ const GLOB_MAX_VISITED = 50000;
16
+ const GLOB_MAX_DEPTH = 16;
17
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn', '__pycache__', '.cache', '.idea', '.vs']);
18
+
19
+ const TEXT_EXT = new Set([
20
+ '.txt', '.md', '.markdown', '.csv', '.tsv', '.json', '.jsonl', '.xml',
21
+ '.yml', '.yaml', '.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.ts',
22
+ '.tsx', '.jsx', '.py', '.rb', '.sh', '.bat', '.ps1', '.sql', '.ini',
23
+ '.cfg', '.conf', '.toml', '.log', '.srt', '.vtt', '.tex', '.c', '.h',
24
+ '.cpp', '.hpp', '.cc', '.cs', '.java', '.kt', '.go', '.rs', '.php',
25
+ '.lua', '.swift', '.m', '.gradle', '.properties', '.env', '.gitignore',
26
+ '.dockerfile', '.vue', '.svelte', '.scss', '.less', '.graphql', '.proto',
27
+ ]);
28
+
29
+ function isTextName(name) {
30
+ const ext = path.extname(name).toLowerCase();
31
+ return !ext || TEXT_EXT.has(ext);
32
+ }
33
+
34
+ const ok = (text, extra) => ({ ok: true, text, ...(extra || {}) });
35
+ const err = (text, extra) => ({ ok: false, text, ...(extra || {}) });
36
+
37
+ function parseLineRange(spec) {
38
+ if (spec == null || spec === '') return null;
39
+ const m = /^\s*(\d+)\s*(?:-\s*(\d+)?)?\s*$/.exec(String(spec));
40
+ if (!m) return null;
41
+ const from = Math.max(1, parseInt(m[1], 10));
42
+ const to = m[2] ? Math.max(from, parseInt(m[2], 10)) : (String(spec).includes('-') ? Infinity : from);
43
+ return { from, to };
44
+ }
45
+
46
+ function renderTextView(content, range, label) {
47
+ const lines = content.split('\n');
48
+ const total = lines.length;
49
+ if (range) {
50
+ const from = Math.min(range.from, total);
51
+ const to = Math.min(range.to, total);
52
+ const slice = lines.slice(from - 1, to).join('\n');
53
+ const head = `(lines ${from}-${to} of ${total}, ${label})\n`;
54
+ return head + (slice.length > READ_CHAR_CAP ? slice.slice(0, READ_CHAR_CAP) + `\n… [truncated to ${READ_CHAR_CAP} chars — read a smaller range]` : slice);
55
+ }
56
+ if (content.length <= READ_CHAR_CAP) return content;
57
+ let used = 0;
58
+ let n = 0;
59
+ while (n < total && used + lines[n].length + 1 <= READ_CHAR_CAP) { used += lines[n].length + 1; n++; }
60
+ return lines.slice(0, n).join('\n') + `\n… [showing lines 1-${n} of ${total} — read more with lines="${n + 1}-${Math.min(total, n + 400)}"]`;
61
+ }
62
+
63
+ function read(roots, p, { lines } = {}) {
64
+ const r = roots.resolve(p);
65
+ if (r.error) return err(`(cannot read "${p}" — ${r.error})`);
66
+ let st = null;
67
+ try { st = fs.statSync(r.abs); } catch (_) {}
68
+ if (!st) return err(`(no such file: ${roots.display(r.abs)} — run Glob for the live file tree)`);
69
+ if (st.isDirectory()) return err(`(${roots.display(r.abs)} is a folder — use Glob to list it)`);
70
+ const touched = [r.abs];
71
+ if (!isTextName(r.abs)) return err(`(binary file, ${st.size} bytes — only text files can be read on a companion device)`, { touched, bytes: st.size });
72
+ if (st.size > 20 * 1024 * 1024) return err(`(file too large to read: ${st.size} bytes)`, { touched, bytes: st.size });
73
+ const content = fs.readFileSync(r.abs, 'utf8');
74
+ if (content.includes('\u0000')) return err(`(binary file, ${st.size} bytes — only text files can be read on a companion device)`, { touched, bytes: st.size });
75
+ if (!content.length) return ok('(empty file)', { touched });
76
+ return ok(renderTextView(content, parseLineRange(lines), roots.display(r.abs)), { touched });
77
+ }
78
+
79
+ function write(roots, p, content, { overwrite } = {}) {
80
+ const raw = String(p == null ? '' : p);
81
+ const r = roots.resolve(raw);
82
+ if (r.error) return err(`(cannot write "${raw}" — ${r.error})`);
83
+ if (/[\\/]$/.test(raw.trim())) {
84
+ fs.mkdirSync(r.abs, { recursive: true });
85
+ return ok(`(folder ${roots.display(r.abs)}/ is ready)`);
86
+ }
87
+ if (!isTextName(r.abs)) return err(`(cannot write "${raw}" — text formats only)`);
88
+ const body = String(content == null ? '' : content);
89
+ if (body.length > WRITE_CHAR_CAP) return err(`(content too long — max ${WRITE_CHAR_CAP} chars per Write)`);
90
+ let exists = false;
91
+ try { exists = fs.statSync(r.abs).isFile(); } catch (_) {}
92
+ if (exists && !overwrite) return err(`(${roots.display(r.abs)} already exists — nothing was written)`, { exists: true });
93
+ fs.mkdirSync(path.dirname(r.abs), { recursive: true });
94
+ fs.writeFileSync(r.abs, body, 'utf8');
95
+ return ok(`(${exists ? 'overwrote' : 'wrote'} ${roots.display(r.abs)}: ${body.length} chars, ${body.split('\n').length} lines)`, { touched: [r.abs] });
96
+ }
97
+
98
+ function countOccurrences(haystack, needle) {
99
+ if (!needle) return 0;
100
+ let n = 0;
101
+ let i = -1;
102
+ while ((i = haystack.indexOf(needle, i + 1)) !== -1) n++;
103
+ return n;
104
+ }
105
+
106
+ function edit(roots, p, edits) {
107
+ const r = roots.resolve(p);
108
+ if (r.error) return err(`(cannot edit "${p}" — ${r.error})`);
109
+ let st = null;
110
+ try { st = fs.statSync(r.abs); } catch (_) {}
111
+ if (!st || !st.isFile()) return err(`(no such file: ${roots.display(r.abs)})`);
112
+ if (!isTextName(r.abs)) return err(`(cannot edit ${roots.display(r.abs)} — text files only)`);
113
+ if (!Array.isArray(edits) || !edits.length) return err('(no edits given)');
114
+ const original = fs.readFileSync(r.abs, 'utf8');
115
+ if (original.includes('\u0000')) return err(`(cannot edit ${roots.display(r.abs)} — binary content)`);
116
+ const eol = original.includes('\r\n') ? '\r\n' : '\n';
117
+ let next = original;
118
+ let i = 0;
119
+ for (const e of edits) {
120
+ i++;
121
+ const search = String(e && e.search != null ? e.search : '');
122
+ const replace = String(e && e.replace != null ? e.replace : '');
123
+ const searchN = eol === '\r\n' ? search.replace(/\r?\n/g, '\r\n') : search;
124
+ const replaceN = eol === '\r\n' ? replace.replace(/\r?\n/g, '\r\n') : replace;
125
+ if (!searchN) return err(`(edit ${i}: empty search text)`);
126
+ const n = countOccurrences(next, searchN);
127
+ if (n === 0) return err(`(edit ${i}: search text not found in ${roots.display(r.abs)} — Read the file and copy the current text exactly)`);
128
+ if (n > 1) return err(`(edit ${i}: search text occurs ${n} times in ${roots.display(r.abs)} — include more context so it is unique)`);
129
+ next = next.replace(searchN, () => replaceN);
130
+ }
131
+ fs.writeFileSync(r.abs, next, 'utf8');
132
+ return ok(`(edited ${roots.display(r.abs)}: ${edits.length} replacement${edits.length === 1 ? '' : 's'})`, { touched: [r.abs] });
133
+ }
134
+
135
+ function globToRegex(pattern) {
136
+ let re = '';
137
+ const p = String(pattern).replace(/\\/g, '/');
138
+ for (let i = 0; i < p.length; i++) {
139
+ const c = p[i];
140
+ if (c === '*') {
141
+ if (p[i + 1] === '*') {
142
+ i++;
143
+ if (p[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
144
+ } else re += '[^/]*';
145
+ } else if (c === '?') re += '[^/]';
146
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
147
+ }
148
+ return new RegExp('^' + re + '$', process.platform === 'win32' ? 'i' : '');
149
+ }
150
+
151
+ function walk(root, onEntry) {
152
+ let visited = 0;
153
+ const rec = (dir, depth) => {
154
+ if (depth > GLOB_MAX_DEPTH || visited > GLOB_MAX_VISITED) return;
155
+ let entries = [];
156
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
157
+ for (const e of entries) {
158
+ visited++;
159
+ if (visited > GLOB_MAX_VISITED) return;
160
+ const abs = path.join(dir, e.name);
161
+ if (e.isDirectory()) {
162
+ if (SKIP_DIRS.has(e.name)) continue;
163
+ onEntry(abs, true);
164
+ rec(abs, depth + 1);
165
+ } else if (e.isFile()) onEntry(abs, false);
166
+ }
167
+ };
168
+ rec(root, 0);
169
+ return visited;
170
+ }
171
+
172
+ // A pattern written as the tree shows it ("Devices/Devbox/game/**", or just the device
173
+ // folder "Devices/Devbox/**") narrows to that shared folder and continues relative to
174
+ // it; anything else is matched relative to every shared folder, as before.
175
+ function splitTreePattern(okRoots, pattern) {
176
+ const p = pattern.replace(/\\/g, '/');
177
+ const low = p.toLowerCase();
178
+ for (const r of okRoots) {
179
+ if (!r.label) continue;
180
+ const parent = r.label.slice(0, r.label.lastIndexOf('/'));
181
+ for (const [prefix, only] of [[r.label, [r]], [parent, okRoots]]) {
182
+ if (!prefix) continue;
183
+ if (low === prefix.toLowerCase()) return { roots: only, pattern: '*' };
184
+ if (low.startsWith(prefix.toLowerCase() + '/')) return { roots: only, pattern: p.slice(prefix.length + 1) || '*' };
185
+ }
186
+ }
187
+ return { roots: okRoots, pattern: p };
188
+ }
189
+
190
+ function glob(roots, rawPattern) {
191
+ if (!roots.okRoots().length) return err('(no shared folder is available on this device — the user has to add one in the device settings)');
192
+ let pattern = String(rawPattern == null ? '*' : rawPattern).trim();
193
+ let sortByName = false;
194
+ let offset = 0;
195
+ pattern = pattern.replace(/\s+--sort=name\b/, () => { sortByName = true; return ''; })
196
+ .replace(/\s+--offset=(\d+)\b/, (_, n) => { offset = parseInt(n, 10) || 0; return ''; }).trim() || '*';
197
+ const scoped = splitTreePattern(roots.okRoots(), pattern);
198
+ const okRoots = scoped.roots;
199
+ pattern = scoped.pattern;
200
+ const all = pattern === '*' || pattern === '**' || pattern === '**/*';
201
+ const re = all ? null : globToRegex(pattern.replace(/^\.\//, ''));
202
+ const hits = [];
203
+ for (const root of okRoots) {
204
+ walk(root.real, (abs, isDir) => {
205
+ const rel = path.relative(root.real, abs).split(path.sep).join('/');
206
+ if (!all && !re.test(rel) && !re.test(path.basename(abs))) return;
207
+ let st = null;
208
+ try { st = fs.statSync(abs); } catch (_) { return; }
209
+ hits.push({ abs, isDir, mtime: st.mtimeMs, size: st.size, label: roots.display(abs) + (isDir ? '/' : '') });
210
+ });
211
+ }
212
+ hits.sort(sortByName ? (a, b) => a.label.localeCompare(b.label) : (a, b) => b.mtime - a.mtime);
213
+ const total = hits.length;
214
+ const page = hits.slice(offset, offset + GLOB_MAX_RESULTS);
215
+ const header = `(shared folders: ${okRoots.map((r) => r.label || r.path).join(', ')}; ${total} match${total === 1 ? '' : 'es'}${total > page.length + offset ? `, showing ${offset + 1}-${offset + page.length}, page on with --offset=${offset + page.length}` : ''})`;
216
+ if (!total) return ok(header + '\n(no matches)');
217
+ return ok(header + '\n' + page.map((h) => h.label).join('\n'));
218
+ }
219
+
220
+ function splitArgs(cmd) {
221
+ const out = [];
222
+ const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
223
+ let m;
224
+ while ((m = re.exec(cmd))) out.push(m[1] != null ? m[1].replace(/\\(["\\])/g, '$1') : (m[2] != null ? m[2] : m[3]));
225
+ return out;
226
+ }
227
+
228
+ function grep(roots, command) {
229
+ const cmd = String(command == null ? '' : command).trim().replace(/^grep\s+/i, '');
230
+ if (/\|/.test(cmd)) return err('(pipes are not supported in Grep on a companion device — use Bash for that)');
231
+ const args = splitArgs(cmd);
232
+ const flags = { i: false, n: false, l: false, c: false, F: false, v: false };
233
+ let offset = 0;
234
+ const include = [];
235
+ const positional = [];
236
+ for (const a of args) {
237
+ if (/^--offset=\d+$/.test(a)) offset = parseInt(a.slice(9), 10);
238
+ else if (/^--include=/.test(a)) include.push(globToRegex(a.slice(10)));
239
+ else if (/^-[a-zA-Z]+$/.test(a)) {
240
+ for (const f of a.slice(1)) {
241
+ if (f in flags) flags[f] = true;
242
+ else if (f !== 'r' && f !== 'R' && f !== 'E' && f !== 'H') return err(`(unsupported grep flag -${f} — use Bash for the full grep)`);
243
+ }
244
+ } else positional.push(a);
245
+ }
246
+ if (!positional.length) return err('(grep needs a pattern)');
247
+ const patternRaw = positional.shift();
248
+ let re;
249
+ try { re = new RegExp(flags.F ? patternRaw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : patternRaw, flags.i ? 'i' : ''); } catch (e) { return err(`(invalid pattern: ${e.message})`); }
250
+ const targets = positional.length ? positional : ['.'];
251
+ const files = [];
252
+ for (const t of targets) {
253
+ const r = roots.resolve(t);
254
+ if (r.error) return err(`(cannot search "${t}" — ${r.error})`);
255
+ let st = null;
256
+ try { st = fs.statSync(r.abs); } catch (_) { return err(`(no such file or folder: ${t})`); }
257
+ if (st.isDirectory()) walk(r.abs, (abs, isDir) => { if (!isDir) files.push(abs); });
258
+ else files.push(r.abs);
259
+ }
260
+ const lines = [];
261
+ let matches = 0;
262
+ let filesWithHits = 0;
263
+ for (const abs of files) {
264
+ if (!isTextName(abs)) continue;
265
+ if (include.length && !include.some((x) => x.test(path.basename(abs)))) continue;
266
+ let st = null;
267
+ try { st = fs.statSync(abs); } catch (_) { continue; }
268
+ if (st.size > GREP_FILE_MAX_BYTES) continue;
269
+ let content = '';
270
+ try { content = fs.readFileSync(abs, 'utf8'); } catch (_) { continue; }
271
+ if (content.includes('\u0000')) continue;
272
+ const label = roots.display(abs);
273
+ let fileHits = 0;
274
+ const fileLines = content.split('\n');
275
+ for (let i = 0; i < fileLines.length; i++) {
276
+ const hit = re.test(fileLines[i]);
277
+ if (hit === flags.v) continue;
278
+ fileHits++;
279
+ matches++;
280
+ if (flags.l || flags.c) continue;
281
+ if (matches <= offset) continue;
282
+ if (lines.length < GREP_MAX_MATCHES) lines.push(`${label}${flags.n ? ':' + (i + 1) : ''}:${fileLines[i].length > 300 ? fileLines[i].slice(0, 300) + '…' : fileLines[i]}`);
283
+ }
284
+ if (fileHits) {
285
+ filesWithHits++;
286
+ if (flags.l) lines.push(label);
287
+ else if (flags.c) lines.push(`${label}:${fileHits}`);
288
+ }
289
+ }
290
+ if (!matches) return ok('(no matches)', { hits: 0 });
291
+ let text = lines.join('\n');
292
+ if (text.length > GREP_RESULT_CAP) text = text.slice(0, GREP_RESULT_CAP) + '\n… [truncated — narrow the pattern or page with --offset=N]';
293
+ const shown = flags.l || flags.c ? filesWithHits : Math.min(lines.length, GREP_MAX_MATCHES);
294
+ text += `\n(${matches} match${matches === 1 ? '' : 'es'} in ${filesWithHits} file${filesWithHits === 1 ? '' : 's'}${!flags.l && !flags.c && matches - offset > shown ? `, showing ${offset + 1}-${offset + shown}, page on with --offset=${offset + shown}` : ''})`;
295
+ return ok(text, { hits: matches });
296
+ }
297
+
298
+ // ---- Explorer + session copy (server-side mirror of what the AI touched) ----
299
+ // `ls` feeds the browser's live tree of the shared folders; `fetchfile` hands the
300
+ // server ONE small text file so it can keep a copy in the chat. Nothing walks the
301
+ // whole tree here — the user's folders may hold terabytes.
302
+ const LS_MAX_ENTRIES = 2000;
303
+ const FETCH_MAX_BYTES_DEFAULT = 2 * 1024 * 1024;
304
+ const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
305
+
306
+ function ls(roots, p) {
307
+ const okRoots = roots.okRoots();
308
+ const raw = String(p == null ? '' : p).trim();
309
+ if (!raw) {
310
+ return ok('', { entries: okRoots.map((r) => ({ name: r.label ? r.label.split('/').pop() : (path.basename(r.real) || r.real), path: r.real, dir: true, size: null, mtime: null })) });
311
+ }
312
+ const r = roots.resolve(raw);
313
+ if (r.error) return err(`(cannot list "${raw}" — ${r.error})`);
314
+ let names = [];
315
+ try { names = fs.readdirSync(r.abs, { withFileTypes: true }); } catch (e) {
316
+ return err(`(cannot list ${roots.display(r.abs)} — ${e.code === 'ENOENT' ? 'no such folder' : e.code === 'ENOTDIR' ? 'not a folder' : e.message})`);
317
+ }
318
+ const entries = [];
319
+ let truncated = false;
320
+ for (const d of names) {
321
+ if (entries.length >= LS_MAX_ENTRIES) { truncated = true; break; }
322
+ const abs = path.join(r.abs, d.name);
323
+ let st = null;
324
+ try { st = fs.statSync(abs); } catch (_) { continue; }
325
+ if (!st.isDirectory() && !st.isFile()) continue;
326
+ entries.push({ name: d.name, path: abs, dir: st.isDirectory(), size: st.isFile() ? st.size : null, mtime: Math.round(st.mtimeMs) });
327
+ }
328
+ entries.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
329
+ return ok('', { entries, truncated });
330
+ }
331
+
332
+ function fetchfile(roots, p, { maxBytes } = {}) {
333
+ const cap = Number(maxBytes) > 0 ? Number(maxBytes) : FETCH_MAX_BYTES_DEFAULT;
334
+ const r = roots.resolve(p);
335
+ if (r.error) return err(`(cannot fetch "${p}" — ${r.error})`);
336
+ let st = null;
337
+ try { st = fs.statSync(r.abs); } catch (_) {}
338
+ if (!st || !st.isFile()) return err(`(no such file: ${roots.display(r.abs)})`);
339
+ if (st.size > cap) return err(`(file too large to copy: ${st.size} bytes, limit ${cap})`, { bytes: st.size, tooLarge: true });
340
+ if (IMAGE_EXT.has(path.extname(r.abs).toLowerCase())) {
341
+ return ok('', { base64: fs.readFileSync(r.abs).toString('base64'), bytes: st.size, mtime: Math.round(st.mtimeMs), binary: true });
342
+ }
343
+ if (!isTextName(r.abs)) return err(`(binary file, ${st.size} bytes — not copied)`, { bytes: st.size, binary: true });
344
+ const content = fs.readFileSync(r.abs, 'utf8');
345
+ if (content.includes('\u0000')) return err(`(binary file, ${st.size} bytes — not copied)`, { bytes: st.size, binary: true });
346
+ return ok('', { content, bytes: st.size, mtime: Math.round(st.mtimeMs) });
347
+ }
348
+
349
+ module.exports = { read, write, edit, glob, grep, ls, fetchfile, isTextName, parseLineRange, globToRegex, READ_CHAR_CAP, WRITE_CHAR_CAP, LS_MAX_ENTRIES, FETCH_MAX_BYTES_DEFAULT };
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+ // Shell commands as jobs. Every command starts as a job; a foreground call just waits
3
+ // for it. A command that outlives its wait is NOT killed — it keeps running and the
4
+ // caller gets its id, so a dev server or a long build survives the tool's time limit.
5
+ // Output is kept in one ordered log (stdout and stderr interleaved) with a cursor per
6
+ // job, so "what is new since I last looked" is a slice, never a re-read.
7
+
8
+ const { spawn } = require('child_process');
9
+ const EventEmitter = require('events');
10
+ const { shellFor, killTree, formatResult, OUTPUT_CAP } = require('./bash');
11
+
12
+ const LOG_CAP = 256 * 1024; // per job, oldest output falls off the front
13
+ const TAIL_CHARS = 4000; // what an exit report carries along
14
+ const MAX_JOBS = 32; // finished jobs are forgotten first, running ones never
15
+
16
+ class Jobs extends EventEmitter {
17
+ constructor() {
18
+ super();
19
+ this.jobs = new Map();
20
+ this.nextId = 1;
21
+ }
22
+
23
+ start(command, { cwd, shell } = {}) {
24
+ const sh = shellFor(shell);
25
+ const id = this.nextId++;
26
+ const job = {
27
+ id, command: String(command), startedAt: Date.now(), endedAt: null,
28
+ status: 'running', exitCode: null, killed: false,
29
+ stdout: '', stderr: '', log: '', dropped: 0, cursor: 0, truncated: false,
30
+ child: null, waiters: [],
31
+ };
32
+ this.forgetOld();
33
+ this.jobs.set(id, job);
34
+ try {
35
+ job.child = spawn(sh.cmd, [...sh.args, job.command], {
36
+ cwd: cwd || undefined,
37
+ detached: process.platform !== 'win32',
38
+ windowsHide: true,
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ env: process.env,
41
+ });
42
+ } catch (e) {
43
+ this.finish(job, null, `(could not start the shell "${sh.cmd}": ${e.message})`);
44
+ return job;
45
+ }
46
+ const collect = (key) => (chunk) => {
47
+ const s = chunk.toString('utf8');
48
+ if (job[key].length < OUTPUT_CAP) {
49
+ job[key] += s.slice(0, OUTPUT_CAP - job[key].length);
50
+ if (job[key].length >= OUTPUT_CAP) job.truncated = true;
51
+ }
52
+ job.log += s;
53
+ if (job.log.length > LOG_CAP) {
54
+ const cut = job.log.length - LOG_CAP;
55
+ job.log = job.log.slice(cut);
56
+ job.dropped += cut;
57
+ }
58
+ };
59
+ job.child.stdout.on('data', collect('stdout'));
60
+ job.child.stderr.on('data', collect('stderr'));
61
+ job.child.on('error', (e) => this.finish(job, null, `(shell error: ${e.message})`));
62
+ job.child.on('close', (code, sig) => this.finish(job, code == null ? (sig ? 137 : null) : code));
63
+ return job;
64
+ }
65
+
66
+ finish(job, exitCode, errText) {
67
+ if (job.status !== 'running') return;
68
+ if (errText) { job.stderr += (job.stderr ? '\n' : '') + errText; job.log += (job.log ? '\n' : '') + errText; }
69
+ job.status = job.killed ? 'killed' : 'exited';
70
+ job.exitCode = exitCode;
71
+ job.endedAt = Date.now();
72
+ for (const w of job.waiters.splice(0)) w();
73
+ this.emit('exit', this.report(job));
74
+ }
75
+
76
+ // Resolves true when the job ended, false when the wait ran out first.
77
+ wait(job, ms) {
78
+ if (job.status !== 'running') return Promise.resolve(true);
79
+ return new Promise((resolve) => {
80
+ const timer = setTimeout(() => { job.waiters = job.waiters.filter((w) => w !== done); resolve(false); }, Math.max(0, ms));
81
+ const done = () => { clearTimeout(timer); resolve(true); };
82
+ job.waiters.push(done);
83
+ });
84
+ }
85
+
86
+ kill(id) {
87
+ const job = this.jobs.get(Number(id));
88
+ if (!job) return null;
89
+ if (job.status === 'running') {
90
+ job.killed = true;
91
+ killTree(job.child);
92
+ }
93
+ return job;
94
+ }
95
+
96
+ get(id) { return this.jobs.get(Number(id)) || null; }
97
+
98
+ // New output since the last look, plus the job's state. Advances the cursor.
99
+ poll(id) {
100
+ const job = this.get(id);
101
+ if (!job) return null;
102
+ const from = Math.max(0, job.cursor - job.dropped);
103
+ const text = job.log.slice(from);
104
+ const skipped = job.cursor < job.dropped ? job.dropped - job.cursor : 0;
105
+ job.cursor = job.dropped + job.log.length;
106
+ return { ...this.report(job), text, skipped };
107
+ }
108
+
109
+ report(job) {
110
+ return {
111
+ jobId: job.id, command: job.command, status: job.status, exitCode: job.exitCode,
112
+ ms: (job.endedAt || Date.now()) - job.startedAt,
113
+ tail: job.log.slice(-TAIL_CHARS),
114
+ };
115
+ }
116
+
117
+ // The one-shot result of a finished job, for a command that ran in the foreground.
118
+ result(job) {
119
+ const timedOut = false;
120
+ const r = { stdout: job.stdout, stderr: job.stderr, exitCode: job.exitCode, truncated: job.truncated, timedOut };
121
+ return { ok: job.status === 'exited' && job.exitCode === 0, text: formatResult(r), exitCode: job.exitCode, truncated: job.truncated, timedOut, stdout: job.stdout, stderr: job.stderr };
122
+ }
123
+
124
+ list() { return [...this.jobs.values()].map((j) => this.report(j)); }
125
+
126
+ running() { return [...this.jobs.values()].filter((j) => j.status === 'running'); }
127
+
128
+ forgetOld() {
129
+ if (this.jobs.size < MAX_JOBS) return;
130
+ for (const [id, j] of this.jobs) {
131
+ if (j.status !== 'running') { this.jobs.delete(id); if (this.jobs.size < MAX_JOBS) return; }
132
+ }
133
+ }
134
+
135
+ killAll() { for (const j of this.running()) this.kill(j.id); }
136
+ }
137
+
138
+ module.exports = { Jobs, LOG_CAP, TAIL_CHARS, MAX_JOBS };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+ // Which files did a shell command touch? Every path-looking token in the command and
3
+ // its output that resolves to an existing file inside a shared folder counts. Not
4
+ // every model uses the file tools, so `cat notes.md` must register the file too.
5
+
6
+ const fs = require('fs');
7
+
8
+ const MAX = 50;
9
+ const TOKEN_RE = /(?:[A-Za-z]:\\[^\s"'<>|*?]+|\/[^\s"'<>|:*?]+|(?:\.{1,2}\/)?[\w.\-]+(?:[\\/][\w.\-]+)*\.[A-Za-z0-9]{1,8})/g;
10
+
11
+ function extractTouched(roots, ...texts) {
12
+ const seen = new Set();
13
+ const out = [];
14
+ for (const text of texts) {
15
+ const s = String(text == null ? '' : text);
16
+ if (!s) continue;
17
+ let m;
18
+ TOKEN_RE.lastIndex = 0;
19
+ let scanned = 0;
20
+ while ((m = TOKEN_RE.exec(s)) && scanned++ < 2000) {
21
+ const tok = m[0].replace(/[.,;:)\]]+$/, '');
22
+ if (!tok || tok.length > 1024) continue;
23
+ const r = roots.resolve(tok);
24
+ if (r.error || seen.has(r.abs)) continue;
25
+ seen.add(r.abs);
26
+ let st = null;
27
+ try { st = fs.statSync(r.abs); } catch (_) { continue; }
28
+ if (!st.isFile()) continue;
29
+ out.push(r.abs);
30
+ if (out.length >= MAX) return out;
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ module.exports = { extractTouched, MAX };