@tensor.chat/companion 0.4.0 → 0.4.2
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/package.json +1 -1
- package/src/daemon.js +40 -4
- package/src/stalls.js +53 -0
- package/src/tools/files.js +13 -3
- package/src/tools/outside.js +44 -15
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -15,6 +15,7 @@ const { fetchUrl } = require('./tools/fetch');
|
|
|
15
15
|
const { extractTouched } = require('./tools/touched');
|
|
16
16
|
const { outsideFolders } = require('./tools/outside');
|
|
17
17
|
const { Jobs } = require('./tools/jobs');
|
|
18
|
+
const { createStallWatch } = require('./stalls');
|
|
18
19
|
|
|
19
20
|
const VERSION = require('../package.json').version;
|
|
20
21
|
const RECONNECT_MIN_MS = 1000;
|
|
@@ -22,6 +23,17 @@ const RECONNECT_MAX_MS = 60000;
|
|
|
22
23
|
const MAX_CONCURRENT_CALLS = 4;
|
|
23
24
|
const BACKGROUND_FIRST_LOOK_MS = 1500; // a background start still reports the first lines
|
|
24
25
|
const FOREGROUND_DEFAULT_MS = 600000;
|
|
26
|
+
const SLOW_SPAWN_MS = 2000;
|
|
27
|
+
|
|
28
|
+
// What a call works on, for the log: the command, the path or the address
|
|
29
|
+
function callSubject(tool, args) {
|
|
30
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
31
|
+
if (tool === 'bash' || tool === 'grep') return a.command;
|
|
32
|
+
if (tool === 'glob') return a.pattern;
|
|
33
|
+
if (tool === 'fetch') return a.url;
|
|
34
|
+
if (tool === 'renamepath') return a.from;
|
|
35
|
+
return a.path;
|
|
36
|
+
}
|
|
25
37
|
|
|
26
38
|
function wsUrlFor(serverUrl) {
|
|
27
39
|
const u = new URL(serverUrl);
|
|
@@ -83,7 +95,7 @@ function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
|
|
|
83
95
|
return { roots, labels: keptLabels, rejected };
|
|
84
96
|
}
|
|
85
97
|
|
|
86
|
-
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell } = {}) {
|
|
98
|
+
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, slowSpawnMs = SLOW_SPAWN_MS } = {}) {
|
|
87
99
|
if (!serverUrl || !token) throw new Error('serverUrl and token are required');
|
|
88
100
|
const emitter = new EventEmitter();
|
|
89
101
|
const say = typeof log === 'function' ? log : () => {};
|
|
@@ -93,7 +105,8 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
93
105
|
const running = new Map(); // call id → AbortController
|
|
94
106
|
const jobs = new Jobs();
|
|
95
107
|
const unsentExits = []; // job endings the server has not heard yet (offline at the time)
|
|
96
|
-
const state = { connected: false, deviceId: null, name: null, mode: null, roots: [], labels: [], reconnectMs: RECONNECT_MIN_MS, stopped: false, ws: null, timer: null };
|
|
108
|
+
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 };
|
|
109
|
+
const stalls = createStallWatch({ say });
|
|
97
110
|
|
|
98
111
|
function send(msg) {
|
|
99
112
|
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
|
|
@@ -122,7 +135,13 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
122
135
|
if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
|
|
123
136
|
const cwd = cwdRes ? cwdRes.abs : callRoots.primary();
|
|
124
137
|
if (!cwd) return { ok: false, text: '(no shared folder is available on this device — the user has to add one in the device settings)' };
|
|
138
|
+
// Starting a process holds this whole process until Windows has it running. A
|
|
139
|
+
// scanner that inspects the program first can make that last long enough for
|
|
140
|
+
// the server to stop hearing from this computer.
|
|
141
|
+
const spawnAt = Date.now();
|
|
125
142
|
const job = jobs.start(String(a.command || ''), { cwd, shell });
|
|
143
|
+
const spawnMs = Date.now() - spawnAt;
|
|
144
|
+
if (spawnMs >= slowSpawnMs) say(`starting the shell took ${Math.round(spawnMs / 1000)} s`);
|
|
126
145
|
// A cancelled call (turn aborted, server timeout) takes its foreground job with
|
|
127
146
|
// it; a background job belongs to the chat, not to the call, and lives on.
|
|
128
147
|
const onAbort = () => { if (!a.background) jobs.kill(job.id); };
|
|
@@ -196,6 +215,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
196
215
|
}
|
|
197
216
|
const ctrl = new AbortController();
|
|
198
217
|
running.set(id, ctrl);
|
|
218
|
+
const finished = stalls.started(tool, callSubject(tool, msg.args));
|
|
199
219
|
emitter.emit('call', { id, tool: msg.tool, args: msg.args });
|
|
200
220
|
let r;
|
|
201
221
|
try {
|
|
@@ -209,6 +229,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
209
229
|
} catch (e) {
|
|
210
230
|
r = { ok: false, text: `(tool error on the companion device: ${e && e.message ? e.message : e})` };
|
|
211
231
|
}
|
|
232
|
+
finished();
|
|
212
233
|
running.delete(id);
|
|
213
234
|
send({ t: 'result', id, ...r });
|
|
214
235
|
emitter.emit('result', { id, tool: msg.tool, ok: r.ok });
|
|
@@ -248,6 +269,16 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
248
269
|
}
|
|
249
270
|
}
|
|
250
271
|
|
|
272
|
+
// 1006 without a reason is a connection that just ended: the server stopped hearing
|
|
273
|
+
// pings, or the network went. The last ping says which side went quiet first.
|
|
274
|
+
function closeLine(code, reason, at = Date.now()) {
|
|
275
|
+
const why = String(reason).trim();
|
|
276
|
+
const secs = (ms) => `${Math.round(ms / 1000)} s`;
|
|
277
|
+
return `disconnected: code ${code}${why ? ` "${why}"` : ''}, connected for ${secs(at - state.connectedAt)}`
|
|
278
|
+
+ `, last ping from the server ${state.lastPingAt ? `${secs(at - state.lastPingAt)} ago` : 'never'}`
|
|
279
|
+
+ (running.size ? `, ${running.size} call(s) running` : '');
|
|
280
|
+
}
|
|
281
|
+
|
|
251
282
|
function scheduleReconnect() {
|
|
252
283
|
if (state.stopped || state.timer) return;
|
|
253
284
|
const wait = state.reconnectMs;
|
|
@@ -270,6 +301,8 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
270
301
|
state.ws = ws;
|
|
271
302
|
ws.on('open', () => {
|
|
272
303
|
state.connected = true;
|
|
304
|
+
state.connectedAt = Date.now();
|
|
305
|
+
state.lastPingAt = null;
|
|
273
306
|
state.reconnectMs = RECONNECT_MIN_MS;
|
|
274
307
|
send(systemInfo(shell, !!localApprovals && !!localShellApproval));
|
|
275
308
|
flushExits();
|
|
@@ -277,6 +310,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
277
310
|
emitter.emit('connected');
|
|
278
311
|
});
|
|
279
312
|
ws.on('message', onMessage);
|
|
313
|
+
ws.on('ping', () => { state.lastPingAt = Date.now(); });
|
|
280
314
|
ws.on('unexpected-response', (_req, res) => {
|
|
281
315
|
const code = res && res.statusCode;
|
|
282
316
|
say(`server refused the connection (HTTP ${code})`);
|
|
@@ -290,6 +324,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
290
324
|
ws.on('error', (e) => { say(`socket error: ${e.message}`); });
|
|
291
325
|
ws.on('close', (code, reason) => {
|
|
292
326
|
const was = state.connected;
|
|
327
|
+
if (was) say(closeLine(code, reason));
|
|
293
328
|
state.connected = false;
|
|
294
329
|
state.ws = null;
|
|
295
330
|
for (const c of running.values()) c.abort();
|
|
@@ -302,9 +337,10 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
302
337
|
return {
|
|
303
338
|
events: emitter,
|
|
304
339
|
state,
|
|
305
|
-
start() { state.stopped = false; connect(); return this; },
|
|
340
|
+
start() { state.stopped = false; stalls.start(); connect(); return this; },
|
|
306
341
|
stop() {
|
|
307
342
|
state.stopped = true;
|
|
343
|
+
stalls.stop();
|
|
308
344
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
309
345
|
for (const c of running.values()) c.abort();
|
|
310
346
|
jobs.killAll();
|
|
@@ -315,4 +351,4 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
315
351
|
};
|
|
316
352
|
}
|
|
317
353
|
|
|
318
|
-
module.exports = { createDaemon, wsUrlFor, systemInfo, authorizeRequestedRoots, VERSION };
|
|
354
|
+
module.exports = { createDaemon, wsUrlFor, systemInfo, authorizeRequestedRoots, callSubject, VERSION };
|
package/src/stalls.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// A process that is stuck answers no ping, and the server drops the computer as gone.
|
|
3
|
+
// Nothing in the process can say so while it is stuck, so this says it afterwards: how
|
|
4
|
+
// long, and which calls were under way in that time. A computer asleep stops the process
|
|
5
|
+
// the same way; the words leave both open (the desktop app logs the sleep itself).
|
|
6
|
+
|
|
7
|
+
const TICK_MS = 1000;
|
|
8
|
+
const STUCK_MS = 5000;
|
|
9
|
+
const KEEP = 30;
|
|
10
|
+
|
|
11
|
+
function createStallWatch({ say, tickMs = TICK_MS, stuckMs = STUCK_MS, now = Date.now } = {}) {
|
|
12
|
+
const calls = []; // the last KEEP calls, finished or not
|
|
13
|
+
let last = now();
|
|
14
|
+
let timer = null;
|
|
15
|
+
|
|
16
|
+
function started(tool, what) {
|
|
17
|
+
const call = { tool: String(tool), what: String(what || '').replace(/\s+/g, ' ').trim().slice(0, 80), start: now(), end: null };
|
|
18
|
+
calls.push(call);
|
|
19
|
+
if (calls.length > KEEP) calls.shift();
|
|
20
|
+
return () => { call.end = now(); };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A call that finished during the stall counts too: the work that held the process
|
|
24
|
+
// usually is that call, and it is done by the time the timer can run again.
|
|
25
|
+
function during(from, to) {
|
|
26
|
+
return calls
|
|
27
|
+
.filter((c) => c.start <= to && (c.end == null || c.end >= from))
|
|
28
|
+
.map((c) => `${c.tool}${c.what ? ` "${c.what}"` : ''} (${c.end == null ? 'still running' : `took ${Math.round((c.end - c.start) / 1000)} s`})`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tick() {
|
|
32
|
+
const t = now();
|
|
33
|
+
const gap = t - last;
|
|
34
|
+
last = t;
|
|
35
|
+
if (gap - tickMs < stuckMs) return;
|
|
36
|
+
const what = during(t - gap, t);
|
|
37
|
+
say(`this process did not run for ${Math.round(gap / 1000)} s (busy, or the computer slept)${what.length ? ` - calls in that time: ${what.join('; ')}` : ''}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
started,
|
|
42
|
+
tick,
|
|
43
|
+
start() {
|
|
44
|
+
if (timer) return;
|
|
45
|
+
last = now();
|
|
46
|
+
timer = setInterval(tick, tickMs);
|
|
47
|
+
if (timer.unref) timer.unref();
|
|
48
|
+
},
|
|
49
|
+
stop() { if (timer) { clearInterval(timer); timer = null; } },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { createStallWatch, TICK_MS, STUCK_MS };
|
package/src/tools/files.js
CHANGED
|
@@ -114,20 +114,30 @@ function edit(roots, p, edits) {
|
|
|
114
114
|
const original = fs.readFileSync(r.abs, 'utf8');
|
|
115
115
|
if (original.includes('\u0000')) return err(`(cannot edit ${roots.display(r.abs)} — binary content)`);
|
|
116
116
|
const eol = original.includes('\r\n') ? '\r\n' : '\n';
|
|
117
|
+
// All edits or none: every edit is checked, and every failing one is named, so
|
|
118
|
+
// the model can fix them in one retry and knows nothing was written.
|
|
119
|
+
const problems = [];
|
|
120
|
+
let notFound = false;
|
|
117
121
|
let next = original;
|
|
118
122
|
let i = 0;
|
|
119
123
|
for (const e of edits) {
|
|
120
124
|
i++;
|
|
125
|
+
const label = edits.length === 1 ? '' : `Edit ${i}: `;
|
|
121
126
|
const search = String(e && e.search != null ? e.search : '');
|
|
122
127
|
const replace = String(e && e.replace != null ? e.replace : '');
|
|
123
128
|
const searchN = eol === '\r\n' ? search.replace(/\r?\n/g, '\r\n') : search;
|
|
124
129
|
const replaceN = eol === '\r\n' ? replace.replace(/\r?\n/g, '\r\n') : replace;
|
|
125
|
-
if (!searchN)
|
|
130
|
+
if (!searchN) { problems.push(`${label}search text is empty`); continue; }
|
|
126
131
|
const n = countOccurrences(next, searchN);
|
|
127
|
-
if (n === 0)
|
|
128
|
-
if (n > 1)
|
|
132
|
+
if (n === 0) { problems.push(`${label}search text not found`); notFound = true; continue; }
|
|
133
|
+
if (n > 1) { problems.push(`${label}search text occurs ${n} times — include more context so it is unique`); continue; }
|
|
129
134
|
next = next.replace(searchN, () => replaceN);
|
|
130
135
|
}
|
|
136
|
+
if (problems.length) {
|
|
137
|
+
const shown = roots.display(r.abs);
|
|
138
|
+
const head = edits.length === 1 ? `The edit was not applied to ${shown}: ` : `None of the ${edits.length} edits were applied to ${shown}. `;
|
|
139
|
+
return err(`(${head}${problems.join('; ')}.${notFound ? ' Read the file and copy the current text exactly.' : ''})`);
|
|
140
|
+
}
|
|
131
141
|
fs.writeFileSync(r.abs, next, 'utf8');
|
|
132
142
|
return ok(`(edited ${roots.display(r.abs)}: ${edits.length} replacement${edits.length === 1 ? '' : 's'})`, { touched: [r.abs] });
|
|
133
143
|
}
|
package/src/tools/outside.js
CHANGED
|
@@ -24,17 +24,32 @@ const WHOLE_PATH = /^(?:[A-Za-z]:[\\/]|\\\\|\.\.(?:[\\/]|$)|\/)/;
|
|
|
24
24
|
|
|
25
25
|
// The command as this machine reads it: PowerShell's $env:NAME, cmd's %NAME%, the
|
|
26
26
|
// POSIX $NAME and ${NAME}, and ~ for the home folder. A name it does not know stays.
|
|
27
|
-
|
|
27
|
+
// A variable that holds a list of folders (PATH and its kind) stays as written too: a
|
|
28
|
+
// list names no one place, and most commands only add to it - put in, every folder on
|
|
29
|
+
// the list read as named, and a test run was asked about ten folders. Its entries go to
|
|
30
|
+
// `lists`, under the name the command wrote.
|
|
31
|
+
function expand(command, { env, home, platform }, lists = []) {
|
|
28
32
|
const keys = Object.keys(env);
|
|
33
|
+
const sep = platform === 'win32' ? ';' : ':';
|
|
34
|
+
const absolute = platform === 'win32' ? path.win32.isAbsolute : path.posix.isAbsolute;
|
|
29
35
|
const get = (name) => {
|
|
30
36
|
const key = platform === 'win32' ? keys.find((k) => k.toLowerCase() === name.toLowerCase()) : (name in env ? name : null);
|
|
31
37
|
if (key != null && env[key] != null) return String(env[key]);
|
|
32
38
|
return /^(home|userprofile)$/i.test(name) ? home : null;
|
|
33
39
|
};
|
|
40
|
+
const put = (m, name) => {
|
|
41
|
+
const value = get(name);
|
|
42
|
+
if (value == null) return m;
|
|
43
|
+
// A list holds at least two paths of this system ("C:\x" is no list where ":" separates)
|
|
44
|
+
const entries = value.split(sep).filter(Boolean);
|
|
45
|
+
if (entries.filter(absolute).length < 2) return value;
|
|
46
|
+
if (!lists.some((l) => l.name === name)) lists.push({ name, entries });
|
|
47
|
+
return m;
|
|
48
|
+
};
|
|
34
49
|
return String(command == null ? '' : command)
|
|
35
|
-
.replace(/\$\{env:([A-Za-z_][\w()]*)\}|\$env:([A-Za-z_]\w*)/gi, (m, a, b) =>
|
|
36
|
-
.replace(/%([A-Za-z_][\w()]*)%/g, (m, n) =>
|
|
37
|
-
.replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (m, a, b) =>
|
|
50
|
+
.replace(/\$\{env:([A-Za-z_][\w()]*)\}|\$env:([A-Za-z_]\w*)/gi, (m, a, b) => put(m, a || b))
|
|
51
|
+
.replace(/%([A-Za-z_][\w()]*)%/g, (m, n) => put(m, n))
|
|
52
|
+
.replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (m, a, b) => put(m, a || b))
|
|
38
53
|
.replace(/(^|[\s"'=(])~(?=[\\/]|$|[\s"')])/g, (m, pre) => pre + home);
|
|
39
54
|
}
|
|
40
55
|
|
|
@@ -78,22 +93,36 @@ function folderOf(word, cwd) {
|
|
|
78
93
|
|
|
79
94
|
function outsideFolders(roots, command, { cwd, env = process.env, home = os.homedir(), platform = process.platform } = {}) {
|
|
80
95
|
const shared = roots.okRoots().map((r) => r.real);
|
|
96
|
+
const outside = (folder) => !shared.some((r) => isInside(realExisting(folder) || folder, r));
|
|
97
|
+
const same = (a, b) => isInside(a, b) && isInside(b, a);
|
|
81
98
|
const found = [];
|
|
82
|
-
|
|
99
|
+
const lists = [];
|
|
100
|
+
for (const word of words(expand(command, { env, home, platform }, lists), platform)) {
|
|
83
101
|
const folder = folderOf(word, cwd || home);
|
|
84
|
-
if (!folder) continue;
|
|
85
|
-
|
|
86
|
-
if (shared.some((r) => isInside(real, r))) continue;
|
|
87
|
-
if (!found.some((f) => isInside(folder, f) && isInside(f, folder))) found.push(folder);
|
|
102
|
+
if (!folder || !outside(folder)) continue;
|
|
103
|
+
if (!found.some((f) => same(folder, f))) found.push(folder);
|
|
88
104
|
}
|
|
89
105
|
// A folder inside another one found is covered by it
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
const named = found.filter((f) => !found.some((g) => g !== f && isInside(f, g) && !isInside(g, f)));
|
|
107
|
+
// A list's folders come after the named ones and cover none of them: whatever the
|
|
108
|
+
// server makes of the list, a folder the command names is asked for by itself
|
|
109
|
+
const listed = [];
|
|
110
|
+
for (const { name, entries } of lists) {
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const st = path.isAbsolute(entry) ? statOf(entry) : null;
|
|
113
|
+
if (!st || !st.isDirectory() || !outside(entry)) continue;
|
|
114
|
+
if (![...named, ...listed.map((l) => l.path)].some((f) => same(entry, f))) listed.push({ path: entry, from: name });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// A whole drive, the home folder or what holds it is too much to share in one click
|
|
118
|
+
const broad = (f) => path.parse(f).root === f || isInside(home, f);
|
|
119
|
+
const folders = [
|
|
120
|
+
...named.map((f) => ({ path: f, broad: broad(f) })),
|
|
121
|
+
...listed.map((l) => ({ path: l.path, broad: broad(l.path), from: l.from })),
|
|
122
|
+
].slice(0, MAX_FOLDERS);
|
|
123
|
+
const said = (f) => (f.from ? `${f.path} (from ${f.from})` : f.path);
|
|
95
124
|
const text = folders.length
|
|
96
|
-
? `(outside the shared folders: ${folders.map(
|
|
125
|
+
? `(outside the shared folders: ${folders.map(said).join(', ')})`
|
|
97
126
|
: '(nothing outside the shared folders)';
|
|
98
127
|
return { ok: true, text, folders };
|
|
99
128
|
}
|