nio-cat 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nio.js +29 -2
- package/package.json +1 -1
- package/src/agents/claude.js +13 -3
- package/src/hub.js +41 -6
- package/src/server.js +9 -0
- package/src/tunnel.js +22 -1
- package/web/app/app.js +248 -75
- package/web/app/index.html +3 -5
- package/web/app/style.css +92 -9
- package/web/board.css +24 -3
- package/web/board.js +10 -0
- package/web/sw.js +1 -1
package/bin/nio.js
CHANGED
|
@@ -9,7 +9,7 @@ const config = require('../src/config');
|
|
|
9
9
|
const { Hub } = require('../src/hub');
|
|
10
10
|
const { createServer } = require('../src/server');
|
|
11
11
|
const { detectAll } = require('../src/agents');
|
|
12
|
-
const { startTunnel } = require('../src/tunnel');
|
|
12
|
+
const { startTunnel, startNamedTunnel } = require('../src/tunnel');
|
|
13
13
|
const { addServer } = require('../src/ssh');
|
|
14
14
|
const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
|
|
15
15
|
const { which } = require('../src/proc');
|
|
@@ -22,7 +22,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
22
22
|
if (a.startsWith('--')) {
|
|
23
23
|
const [k, v] = a.slice(2).split('=');
|
|
24
24
|
if (v !== undefined) flags[k] = v;
|
|
25
|
-
else if (['port', 'url', 'app', 'local-port', 'dir'].includes(k)) flags[k] = argv[++i];
|
|
25
|
+
else if (['port', 'url', 'app', 'local-port', 'dir', 'tunnel-token'].includes(k)) flags[k] = argv[++i];
|
|
26
26
|
else flags[k] = true;
|
|
27
27
|
} else pos.push(a);
|
|
28
28
|
}
|
|
@@ -153,6 +153,20 @@ async function start() {
|
|
|
153
153
|
console.log(c.dim(' Per connectar-t’hi des de qualsevol lloc, instal·la’l (a macOS: brew install cloudflared) i torna a engegar nio.'));
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// Adreça fixa: túnel amb nom de Cloudflare. Es desa i els reinicis la mantenen.
|
|
157
|
+
if (flags['tunnel-token'] && flags.url) {
|
|
158
|
+
cfg.tunnelToken = flags['tunnel-token'];
|
|
159
|
+
cfg.fixedUrl = flags.url.replace(/\/+$/, '');
|
|
160
|
+
config.save(cfg);
|
|
161
|
+
delete flags.url;
|
|
162
|
+
}
|
|
163
|
+
if (flags['forget-fixed']) {
|
|
164
|
+
delete cfg.tunnelToken;
|
|
165
|
+
delete cfg.fixedUrl;
|
|
166
|
+
config.save(cfg);
|
|
167
|
+
}
|
|
168
|
+
const fixed = cfg.tunnelToken && cfg.fixedUrl && !flags.url && !flags['no-tunnel'] && !flags.lan ? cfg : null;
|
|
169
|
+
|
|
156
170
|
const hub = new Hub(cfg);
|
|
157
171
|
let publicUrl = flags.url || null;
|
|
158
172
|
const host = flags.lan ? '0.0.0.0' : '127.0.0.1';
|
|
@@ -188,6 +202,17 @@ async function start() {
|
|
|
188
202
|
return showPair(publicUrl);
|
|
189
203
|
}
|
|
190
204
|
|
|
205
|
+
if (fixed) {
|
|
206
|
+
const nt = startNamedTunnel(cfg.tunnelToken, () => {
|
|
207
|
+
publicUrl = cfg.fixedUrl;
|
|
208
|
+
console.log(` ${c.dim('Adreça fixa:')} ${publicUrl}`);
|
|
209
|
+
cfg.lastUrl = publicUrl;
|
|
210
|
+
config.save(cfg);
|
|
211
|
+
showPair(publicUrl);
|
|
212
|
+
});
|
|
213
|
+
if (nt) return console.log(c.dim(' Obrint el túnel fix…'));
|
|
214
|
+
}
|
|
215
|
+
|
|
191
216
|
const t = startTunnel(cfg.port, (url) => {
|
|
192
217
|
publicUrl = url;
|
|
193
218
|
console.log(` ${c.dim('Túnel:')} ${url} ${c.dim('(Cloudflare, sense cost)')}`);
|
|
@@ -242,6 +267,8 @@ async function start() {
|
|
|
242
267
|
--lan Connexió dins la mateixa wifi
|
|
243
268
|
--url <adreça> Una adreça pública teva (Tailscale o similar)
|
|
244
269
|
--no-tunnel Només des d'aquest ordinador
|
|
270
|
+
--tunnel-token <t> --url <adreça> Adreça fixa amb un túnel de Cloudflare amb nom (es desa; la propera vegada n'hi ha prou amb nio)
|
|
271
|
+
--forget-fixed Oblida l'adreça fixa i torna al túnel temporal
|
|
245
272
|
--port <n> Port local (7717 si no dius res)
|
|
246
273
|
--no-open No obris el panell al navegador d'aquest ordinador
|
|
247
274
|
--local Obre la còpia del panell d'aquest ordinador (si el navegador bloqueja nio.cat, p. ex. Safari)
|
package/package.json
CHANGED
package/src/agents/claude.js
CHANGED
|
@@ -15,10 +15,15 @@ module.exports = {
|
|
|
15
15
|
|
|
16
16
|
start(ctx) {
|
|
17
17
|
const { session, prompt, cfg } = ctx;
|
|
18
|
-
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
|
|
18
|
+
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
|
|
19
19
|
if (session.nativeId) args.push('--resume', session.nativeId);
|
|
20
20
|
if (session.model) args.push('--model', session.model);
|
|
21
21
|
|
|
22
|
+
if (session.mode === 'tot' && process.getuid?.() === 0) {
|
|
23
|
+
ctx.fail('Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.');
|
|
24
|
+
return { kill() {} };
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
const env = {};
|
|
23
28
|
if (session.mode === 'llegir') {
|
|
24
29
|
args.push('--permission-mode', 'plan');
|
|
@@ -65,9 +70,14 @@ function handle(ctx, line) {
|
|
|
65
70
|
if (ev.model) ctx.meta({ model: ev.model });
|
|
66
71
|
}
|
|
67
72
|
break;
|
|
73
|
+
case 'stream_event': {
|
|
74
|
+
const d = ev.event?.type === 'content_block_delta' ? ev.event.delta : null;
|
|
75
|
+
if (d?.type === 'text_delta' && d.text) ctx.stream(d.text);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
68
78
|
case 'assistant':
|
|
69
79
|
for (const b of ev.message?.content || []) {
|
|
70
|
-
if (b.type === 'text' && b.text) ctx.
|
|
80
|
+
if (b.type === 'text' && b.text) ctx.endStream(b.text);
|
|
71
81
|
else if (b.type === 'tool_use') ctx.tool({ id: b.id, name: b.name, input: b.input });
|
|
72
82
|
}
|
|
73
83
|
break;
|
|
@@ -91,6 +101,6 @@ function handle(ctx, line) {
|
|
|
91
101
|
});
|
|
92
102
|
break;
|
|
93
103
|
default:
|
|
94
|
-
break; //
|
|
104
|
+
break; // rate_limit_event, etc.
|
|
95
105
|
}
|
|
96
106
|
}
|
package/src/hub.js
CHANGED
|
@@ -72,6 +72,7 @@ class Hub extends EventEmitter {
|
|
|
72
72
|
updated: s.updated,
|
|
73
73
|
cost: s.cost || 0,
|
|
74
74
|
last: lastText ? clip(lastText.text, 160) : '',
|
|
75
|
+
error: s.state === 'error' ? clip([...s.events].reverse().find((e) => e.kind === 'error')?.text || '', 200) : '',
|
|
75
76
|
pending: [...this.approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length,
|
|
76
77
|
};
|
|
77
78
|
}
|
|
@@ -116,8 +117,20 @@ class Hub extends EventEmitter {
|
|
|
116
117
|
this.persist(s);
|
|
117
118
|
return;
|
|
118
119
|
}
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
this.push(s, 'text', { text, open: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Tanca el bloc de text en curs amb el text definitiu (o l'afegeix si no hi havia streaming).
|
|
124
|
+
endStream(s, text) {
|
|
125
|
+
const last = s.events[s.events.length - 1];
|
|
126
|
+
if (last && last.kind === 'text' && last.open) {
|
|
127
|
+
last.text = text;
|
|
128
|
+
delete last.open;
|
|
129
|
+
this.emit('out', { type: 'event', session: s.id, event: last });
|
|
130
|
+
this.touch(s);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.push(s, 'text', { text });
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
// ---------- sessions ----------
|
|
@@ -194,6 +207,8 @@ class Hub extends EventEmitter {
|
|
|
194
207
|
const key = id(16);
|
|
195
208
|
const toolNames = new Map();
|
|
196
209
|
let finished = false;
|
|
210
|
+
let reported = false;
|
|
211
|
+
const tail = [];
|
|
197
212
|
const ctx = {
|
|
198
213
|
session: s,
|
|
199
214
|
prompt: text,
|
|
@@ -212,6 +227,7 @@ class Hub extends EventEmitter {
|
|
|
212
227
|
this.touch(s);
|
|
213
228
|
},
|
|
214
229
|
text: (t) => this.push(s, 'text', { text: String(t) }),
|
|
230
|
+
endStream: (t) => this.endStream(s, String(t)),
|
|
215
231
|
stream: (t) => this.chunk(s, String(t)),
|
|
216
232
|
tool: ({ id: tid, name, input }) => {
|
|
217
233
|
toolNames.set(tid, name);
|
|
@@ -223,10 +239,19 @@ class Hub extends EventEmitter {
|
|
|
223
239
|
if (typeof r.cost === 'number') s.cost = (s.cost || 0) + r.cost;
|
|
224
240
|
this.push(s, 'result', r);
|
|
225
241
|
},
|
|
226
|
-
error: (msg) =>
|
|
242
|
+
error: (msg) => {
|
|
243
|
+
reported = true;
|
|
244
|
+
this.push(s, 'error', { text: clip(typeof msg === 'string' ? msg : JSON.stringify(msg), 1200) });
|
|
245
|
+
},
|
|
246
|
+
fail: (msg) => {
|
|
247
|
+
ctx.error(msg);
|
|
248
|
+
queueMicrotask(() => ctx.done(1));
|
|
249
|
+
},
|
|
227
250
|
log: (line) => {
|
|
228
|
-
// Les línies de stderr
|
|
251
|
+
// Les línies de stderr es guarden (el mòbil les mostra plegades) i les últimes expliquen un error.
|
|
229
252
|
if (/^\s*$/.test(line)) return;
|
|
253
|
+
tail.push(line.trim());
|
|
254
|
+
if (tail.length > 4) tail.shift();
|
|
230
255
|
this.push(s, 'log', { text: clip(line, 600) });
|
|
231
256
|
},
|
|
232
257
|
ask: ({ tool, input }) => this.requestApproval({ session: s.id, tool, input }),
|
|
@@ -238,8 +263,9 @@ class Hub extends EventEmitter {
|
|
|
238
263
|
if (a.session === sid && a.state === 'pending') this.decide(a.id, 'deny', 'La sessió s’ha acabat');
|
|
239
264
|
}
|
|
240
265
|
if (err) this.push(s, 'error', { text: `No s’ha pogut engegar: ${err.message}` });
|
|
241
|
-
else if (code && code !== 0 && code !== 130 && code !== 143) {
|
|
242
|
-
|
|
266
|
+
else if (code && code !== 0 && code !== 130 && code !== 143 && !reported) {
|
|
267
|
+
const why = explain(tail);
|
|
268
|
+
this.push(s, 'error', { text: why || `${agentName(s)} s’ha aturat amb un error (codi ${code})${tail.length ? `:\n${tail.join('\n')}` : ''}` });
|
|
243
269
|
}
|
|
244
270
|
s.state = code === 130 || code === 143 ? 'idle' : code && code !== 0 ? 'error' : 'idle';
|
|
245
271
|
this.push(s, 'status', { state: s.state, code });
|
|
@@ -342,6 +368,15 @@ class Hub extends EventEmitter {
|
|
|
342
368
|
}
|
|
343
369
|
}
|
|
344
370
|
|
|
371
|
+
// Causes conegudes d'una sortida amb error, explicades en català.
|
|
372
|
+
function explain(lines) {
|
|
373
|
+
const t = lines.join('\n');
|
|
374
|
+
if (/cannot be used with root/i.test(t)) return 'Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.';
|
|
375
|
+
if (/not logged in|please run \/login|invalid api key|authentication/i.test(t)) return 'L’agent no té la sessió iniciada en aquest ordinador. Entra-hi per terminal i inicia sessió (per exemple, executant «claude»).';
|
|
376
|
+
if (/rate limit|usage limit|quota|overloaded|429/i.test(t)) return `L’agent ha arribat al límit d’ús o està saturat. Torna-ho a provar d’aquí una estona.\n${lines.slice(-1)[0] || ''}`.trim();
|
|
377
|
+
return '';
|
|
378
|
+
}
|
|
379
|
+
|
|
345
380
|
function agentName(s) {
|
|
346
381
|
return (s && byId[s.agent]?.name) || 'L’agent';
|
|
347
382
|
}
|
package/src/server.js
CHANGED
|
@@ -143,6 +143,7 @@ const MODEL_NAME = /^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/;
|
|
|
143
143
|
|
|
144
144
|
function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
145
145
|
const streams = new Set();
|
|
146
|
+
const seenDevices = new Map(); // mòbils que s'han connectat (per avisar l'ordinador que mostra el QR)
|
|
146
147
|
const pulls = new Map(); // descàrregues de models d'Ollama en curs
|
|
147
148
|
|
|
148
149
|
hub.on('out', (msg) => {
|
|
@@ -388,6 +389,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
388
389
|
connection: 'keep-alive',
|
|
389
390
|
});
|
|
390
391
|
res.write(`data: ${JSON.stringify({ type: 'hello', sessions: hub.list(), approvals: hub.pendingApprovals() })}\n\n`);
|
|
392
|
+
const dev = String(url.searchParams.get('d') || '').slice(0, 40), kind = url.searchParams.get('k') === 'phone' ? 'phone' : 'desktop';
|
|
393
|
+
if (dev && kind === 'phone' && Date.now() - (seenDevices.get(dev) || 0) > 10 * 60 * 1000) {
|
|
394
|
+
const data = `data: ${JSON.stringify({ type: 'device', device: dev, kind })}\n\n`;
|
|
395
|
+
for (const o of streams) o.write(data);
|
|
396
|
+
}
|
|
397
|
+
if (dev) seenDevices.set(dev, Date.now());
|
|
391
398
|
streams.add(res);
|
|
392
399
|
req.on('close', () => streams.delete(res));
|
|
393
400
|
return;
|
|
@@ -396,12 +403,14 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
396
403
|
if (url.pathname === '/api/ssh/add' && req.method === 'POST') {
|
|
397
404
|
res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' });
|
|
398
405
|
const send = (o) => res.write(JSON.stringify(o) + '\n');
|
|
406
|
+
const beat = setInterval(() => send({ ping: 1 }), 8000);
|
|
399
407
|
try {
|
|
400
408
|
const b = await readBody(req);
|
|
401
409
|
send({ done: true, ...(await addServer(String(b.target || '').trim(), { onStep: (step) => send({ step }) })) });
|
|
402
410
|
} catch (e) {
|
|
403
411
|
send({ error: e.message });
|
|
404
412
|
}
|
|
413
|
+
clearInterval(beat);
|
|
405
414
|
return res.end();
|
|
406
415
|
}
|
|
407
416
|
try {
|
package/src/tunnel.js
CHANGED
|
@@ -25,4 +25,25 @@ function startTunnel(port, onUrl) {
|
|
|
25
25
|
return child;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Túnel amb nom (compte gratuït de Cloudflare): l'adreça no canvia entre reinicis.
|
|
29
|
+
function startNamedTunnel(token, onReady) {
|
|
30
|
+
const bin = which('cloudflared');
|
|
31
|
+
if (!bin) return null;
|
|
32
|
+
const child = spawn(bin, ['tunnel', '--no-autoupdate', 'run', '--token', token], {
|
|
33
|
+
env: childEnv(),
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
35
|
+
});
|
|
36
|
+
let ready = false;
|
|
37
|
+
const scan = (d) => {
|
|
38
|
+
if (!ready && /Registered tunnel connection/i.test(String(d))) {
|
|
39
|
+
ready = true;
|
|
40
|
+
onReady();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
child.stdout.on('data', scan);
|
|
44
|
+
child.stderr.on('data', scan);
|
|
45
|
+
process.on('exit', () => child.kill());
|
|
46
|
+
return child;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { startTunnel, startNamedTunnel };
|