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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "MIT",
6
6
  "author": "f.cat",
@@ -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.text(b.text);
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; // stream_event, rate_limit_event, etc.
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
- const ev = this.push(s, 'text', { text });
120
- ev.open = true;
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) => this.push(s, 'error', { text: clip(typeof msg === 'string' ? msg : JSON.stringify(msg), 1200) }),
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 només es guarden; el mòbil les mostra plegades.
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
- this.push(s, 'error', { text: `L’agent s’ha aturat amb un error (codi ${code})` });
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
- module.exports = { startTunnel };
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 };