nio-cat 0.3.5 → 0.4.1

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
@@ -4,13 +4,13 @@ const os = require('os');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const crypto = require('crypto');
7
- const net = require('net');
8
7
  const { spawn } = require('child_process');
9
8
  const config = require('../src/config');
10
9
  const { Hub } = require('../src/hub');
11
10
  const { createServer } = require('../src/server');
12
11
  const { detectAll } = require('../src/agents');
13
12
  const { startTunnel } = require('../src/tunnel');
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');
16
16
 
@@ -111,73 +111,26 @@ async function showPair(publicUrl) {
111
111
  }
112
112
  }
113
113
 
114
- // nio ssh usuari@servidor: engega nio al servidor (si no hi és), obre un túnel SSH i obre el panell ja connectat.
115
- const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
116
- function remoteScript(port, dir) {
117
- return `set -e
118
- command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
119
- up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
120
- if ! up; then
121
- mkdir -p "$HOME/.nio"
122
- if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
123
- $S nohup npx --yes nio-cat@latest --no-open --no-tunnel --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
124
- for i in $(seq 1 80); do up && break; sleep 0.5; done
125
- up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
126
- fi
127
- node -e "console.log('NIO_TOKEN='+require(require('os').homedir()+'/.nio/config.json').token)"
128
- `;
129
- }
130
- function sshBase(target) {
131
- const a = ['-o', 'ServerAliveInterval=30'];
132
- if (process.platform !== 'win32') a.push('-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/nio-%C', '-o', 'ControlPersist=10m');
133
- return a;
134
- }
135
- function waitPort(port, ms = 15000) {
136
- const end = Date.now() + ms;
137
- return new Promise((resolve) => {
138
- const t = () => {
139
- const s = net.connect(port, '127.0.0.1');
140
- s.once('connect', () => { s.destroy(); resolve(true); });
141
- s.once('error', () => { s.destroy(); Date.now() > end ? resolve(false) : setTimeout(t, 300); });
142
- };
143
- t();
144
- });
145
- }
114
+ // nio ssh usuari@servidor: engega nio al servidor amb un túnel i obre el panell amb el servidor ja afegit.
146
115
  async function sshStart() {
147
116
  const target = pos[0];
148
- if (!target || !/^[A-Za-z0-9_][\w.@:\-\[\]]*$/.test(target)) {
149
- console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--local-port 7718] [--dir /ruta/al/servidor]')}\n`);
117
+ if (!target) {
118
+ console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--dir /ruta/al/servidor]')}\n`);
150
119
  process.exit(1);
151
120
  }
152
- const rport = Number(flags.port || 7717), lport = Number(flags['local-port'] || 7718);
153
- console.log(`\n ${c.b('nio')} ${c.dim('· connectant amb')} ${target}${c.dim('…')}`);
154
- const out = await new Promise((resolve) => {
155
- const p = spawn('ssh', [...sshBase(target), '-T', '--', target, 'bash -l -s'], { stdio: ['pipe', 'pipe', 'inherit'] });
156
- let buf = '';
157
- p.stdout.on('data', (d) => (buf += d));
158
- p.on('error', () => resolve({ code: -1, buf }));
159
- p.on('close', (code) => resolve({ code, buf }));
160
- p.stdin.end(remoteScript(rport, flags.dir));
161
- });
162
- if (out.code === -1) { console.error(c.warn('\n No trobo l’ordre ssh en aquest ordinador.\n')); process.exit(1); }
163
- if (out.code !== 0 && !out.buf.includes('NIO_TOKEN=')) { console.error(c.warn('\n No he pogut entrar al servidor per SSH.\n')); process.exit(1); }
164
- if (out.buf.includes('NIO_NO_NODE')) { console.error(c.warn('\n Al servidor falta Node.js 18 o més nou. Instal·la’l i torna-ho a provar.\n')); process.exit(1); }
165
- if (out.buf.includes('NIO_NO_START')) { console.error(c.warn('\n nio no s’ha engegat al servidor:\n') + out.buf.replace('NIO_NO_START', '').trim() + '\n'); process.exit(1); }
166
- const token = (out.buf.match(/NIO_TOKEN=(\S+)/) || [])[1];
167
- if (!token) { console.error(c.warn('\n No he pogut llegir la clau del servidor.\n')); process.exit(1); }
168
-
169
- const tun = spawn('ssh', [...sshBase(target), '-N', '-T', '-o', 'ExitOnForwardFailure=yes', '-L', `127.0.0.1:${lport}:127.0.0.1:${rport}`, '--', target], { stdio: ['ignore', 'ignore', 'inherit'] });
170
- tun.on('close', () => { console.log(c.warn('\n S’ha tallat el túnel SSH.\n')); process.exit(0); });
171
- const bye = () => { tun.kill(); process.exit(0); };
172
- process.on('SIGINT', bye);
173
- process.on('SIGTERM', bye);
174
- if (!(await waitPort(lport))) { console.error(c.warn(`\n No puc obrir el port ${lport} en aquest ordinador. Prova amb --local-port 7719.\n`)); tun.kill(); process.exit(1); }
175
-
176
- const local = `http://127.0.0.1:${lport}`, link = pairLink(local, local, token);
177
- console.log(` ${c.ok('✓')} Connectat. nio funciona al servidor i el panell és a ${local}`);
178
- if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell obert al navegador.')}`);
179
- else console.log(` ${c.dim('Obre aquest enllaç:')} ${link}`);
180
- console.log(c.dim('\n Deixa aquesta finestra oberta mentre facis servir el servidor. Amb Ctrl+C es tanca el túnel (nio continua al servidor).\n'));
121
+ console.log(`\n ${c.b('nio')} ${c.dim('· afegint')} ${target}${c.dim('…')}`);
122
+ let r;
123
+ try {
124
+ r = await addServer(target, { port: Number(flags.port || 7717), dir: flags.dir, batch: false, onStep: (t) => console.log(` ${c.dim('·')} ${t}…`) });
125
+ } catch (e) {
126
+ console.error(c.warn(`\n ${e.message}\n`));
127
+ process.exit(1);
128
+ }
129
+ const app = flags.app || cfg.app, link = pairLink(app, r.url, r.token), code = makeCode(r.url, r.token);
130
+ console.log(` ${c.ok('✓')} ${r.host} és a punt${r.claude ? '' : c.warn(' (Claude Code no hi és: instal·la’l i inicia sessió al servidor)')}`);
131
+ console.log(`\n ${c.b('Codi:')} ${code}`);
132
+ console.log(c.dim(' Enganxa’l a nio.cat → Ordinadors → Afegeix, al mòbil o a l’ordinador.\n'));
133
+ if (canOpenBrowser() && openUrl(link)) console.log(c.dim(' He obert nio.cat amb el servidor ja afegit.\n'));
181
134
  }
182
135
 
183
136
  async function start() {
@@ -294,9 +247,8 @@ async function start() {
294
247
  --open Obre al navegador la pàgina del codi QR
295
248
  nio pair Mostra el codi QR una altra vegada
296
249
 
297
- nio ssh usuari@servidor Fes servir nio en un servidor (VPS) per SSH, sense obrir cap port.
250
+ nio ssh usuari@servidor Afegeix un servidor (VPS) per SSH: hi engega nio amb un túnel i el pots fer servir des del mòbil.
298
251
  --port <n> Port de nio al servidor (7717)
299
- --local-port <n> Port en aquest ordinador (7718)
300
252
  --dir <ruta> Carpeta del servidor on s'engega (per defecte, la teva carpeta d'inici)
301
253
 
302
254
  Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
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",
package/src/proc.js CHANGED
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const { spawn, execFile } = require('child_process');
7
7
 
8
8
  const EXTRA_DIRS = [
9
+ path.join(os.homedir(), '.nio', 'bin'),
9
10
  path.join(os.homedir(), '.local', 'bin'),
10
11
  path.join(os.homedir(), '.npm-global', 'bin'),
11
12
  path.join(os.homedir(), '.bun', 'bin'),
package/src/server.js CHANGED
@@ -10,6 +10,7 @@ const ollama = require('./agents/ollama');
10
10
  const { save } = require('./config');
11
11
  const { httpErr } = require('./hub');
12
12
  const github = require('./github');
13
+ const { addServer, knownHosts } = require('./ssh');
13
14
  const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
14
15
 
15
16
  // L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
@@ -172,6 +173,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
172
173
  id: cfg.id,
173
174
  };
174
175
  }
176
+ if (p === '/api/ssh/hosts' && method === 'GET') return knownHosts();
175
177
  if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
176
178
  if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
177
179
  if (p === '/api/ollama/models' && method === 'GET') return (await ollama.listModels()) || [];
@@ -361,6 +363,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
361
363
  }
362
364
  }
363
365
 
366
+ // Sense clau: només diu que nio és aquí (la web ho fa servir per detectar «Aquest ordinador»).
367
+ if (url.pathname === '/api/hello' && req.method === 'GET') {
368
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
369
+ return res.end(JSON.stringify({ nio: true, id: cfg.id, name: os.hostname() }));
370
+ }
371
+
364
372
  if (url.pathname.startsWith('/api/')) {
365
373
  if (!tokenOk(cfg, req)) {
366
374
  res.writeHead(401, { 'content-type': 'application/json' });
@@ -378,6 +386,18 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
378
386
  req.on('close', () => streams.delete(res));
379
387
  return;
380
388
  }
389
+ // Afegir un servidor per SSH: el progrés va en línies JSON (una per pas) i l'última és el resultat.
390
+ if (url.pathname === '/api/ssh/add' && req.method === 'POST') {
391
+ res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' });
392
+ const send = (o) => res.write(JSON.stringify(o) + '\n');
393
+ try {
394
+ const b = await readBody(req);
395
+ send({ done: true, ...(await addServer(String(b.target || '').trim(), { onStep: (step) => send({ step }) })) });
396
+ } catch (e) {
397
+ send({ error: e.message });
398
+ }
399
+ return res.end();
400
+ }
381
401
  try {
382
402
  const out = await api(req, res, url);
383
403
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
package/src/ssh.js ADDED
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ // Afegeix un servidor (VPS) per SSH: hi entra, hi engega nio amb un túnel de Cloudflare i torna l'adreça i la clau.
3
+ // El servidor queda connectat directament al mòbil i al panell; aquest ordinador no ha de continuar enmig.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawn } = require('child_process');
8
+
9
+ const TARGET = /^[A-Za-z0-9_][\w.@:\-\[\]]*$/;
10
+ const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
11
+
12
+ function remoteScript(port, dir) {
13
+ return `set -e
14
+ export PATH="$HOME/.nio/bin:$HOME/.local/bin:$PATH"
15
+ echo "NIO_STEP=Entrant al servidor"
16
+ command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
17
+ up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
18
+ mkdir -p "$HOME/.nio/bin"
19
+ if ! command -v cloudflared >/dev/null 2>&1 && [ "$(uname -s)" = Linux ]; then
20
+ echo "NIO_STEP=Instal·lant cloudflared"
21
+ case "$(uname -m)" in x86_64) A=amd64;; aarch64|arm64) A=arm64;; *) A=;; esac
22
+ if [ -n "$A" ]; then
23
+ U="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$A"
24
+ { curl -fsSL -o "$HOME/.nio/bin/cloudflared" "$U" 2>/dev/null || wget -qO "$HOME/.nio/bin/cloudflared" "$U" 2>/dev/null; } && chmod +x "$HOME/.nio/bin/cloudflared" || rm -f "$HOME/.nio/bin/cloudflared"
25
+ fi
26
+ fi
27
+ if ! up; then
28
+ echo "NIO_STEP=Engegant nio"
29
+ node -e "const f=require('os').homedir()+'/.nio/config.json',fs=require('fs');try{const c=JSON.parse(fs.readFileSync(f));delete c.lastUrl;fs.writeFileSync(f,JSON.stringify(c,null,2))}catch{}"
30
+ if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
31
+ $S nohup npx --yes nio-cat@latest --no-open --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
32
+ for i in $(seq 1 80); do up && break; sleep 0.5; done
33
+ up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
34
+ fi
35
+ echo "NIO_STEP=Obrint el túnel"
36
+ URL=
37
+ for i in $(seq 1 60); do
38
+ URL=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.nio/config.json')).lastUrl||'')}catch{}")
39
+ [ -n "$URL" ] && break
40
+ sleep 1
41
+ done
42
+ [ -n "$URL" ] || { echo NIO_NO_TUNNEL; exit 0; }
43
+ node -e "console.log('NIO_TOKEN='+JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.nio/config.json')).token)"
44
+ echo "NIO_URL=$URL"
45
+ echo "NIO_HOST=$(hostname)"
46
+ if command -v claude >/dev/null 2>&1; then echo NIO_CLAUDE=1; else echo NIO_CLAUDE=0; fi
47
+ `;
48
+ }
49
+
50
+ // batch: sense preguntes (des del panell). Sense batch (terminal), ssh pot demanar la contrasenya.
51
+ function addServer(target, { port = 7717, dir, batch = true, onStep = () => {} } = {}) {
52
+ return new Promise((resolve, reject) => {
53
+ if (!TARGET.test(String(target || ''))) return reject(new Error('Escriu l’usuari i el servidor, per exemple root@el-meu-servidor.com'));
54
+ const args = ['-o', 'ConnectTimeout=12', '-o', 'StrictHostKeyChecking=accept-new', ...(batch ? ['-o', 'BatchMode=yes'] : []), '-T', '--', target, 'bash -l -s'];
55
+ let p;
56
+ try { p = spawn('ssh', args, { stdio: ['pipe', 'pipe', batch ? 'pipe' : 'inherit'] }); } catch { return reject(new Error('No trobo l’ordre ssh en aquest ordinador')); }
57
+ let buf = '', err = '', done = false;
58
+ const out = {};
59
+ const line = (l) => {
60
+ const m = l.match(/^NIO_(STEP|TOKEN|URL|HOST|CLAUDE)=(.*)$/);
61
+ if (!m) return;
62
+ if (m[1] === 'STEP') onStep(m[2]);
63
+ else out[m[1].toLowerCase()] = m[2].trim();
64
+ };
65
+ p.stdout.on('data', (d) => {
66
+ buf += d;
67
+ let i;
68
+ while ((i = buf.indexOf('\n')) >= 0) { line(buf.slice(0, i)); buf = buf.slice(i + 1); }
69
+ if (/NIO_NO_(NODE|START|TUNNEL)/.test(buf + '')) out.flag = (buf.match(/NIO_NO_\w+/) || [])[0];
70
+ });
71
+ if (batch) p.stderr.on('data', (d) => (err += d));
72
+ const fail = (m) => { if (!done) { done = true; reject(new Error(m)); } };
73
+ p.on('error', () => fail('No trobo l’ordre ssh en aquest ordinador'));
74
+ p.on('close', (code) => {
75
+ if (buf) line(buf);
76
+ if (done) return;
77
+ done = true;
78
+ const text = `${err}\n${buf}`;
79
+ if (/NIO_NO_NODE/.test(text)) return reject(new Error('Al servidor falta Node.js 18 o més nou. Instal·la’l i torna-ho a provar.'));
80
+ if (/NIO_NO_START/.test(text)) return reject(new Error('nio no s’ha pogut engegar al servidor. Mira ~/.nio/daemon.log allà.'));
81
+ if (/NIO_NO_TUNNEL/.test(text)) return reject(new Error('El túnel no s’ha obert. Al servidor cal Linux amb curl o wget (per instal·lar cloudflared), o una adreça pròpia amb --url.'));
82
+ if (out.token && out.url) return resolve({ url: out.url, token: out.token, host: out.host || target, claude: out.claude === '1' });
83
+ if (/Permission denied|publickey/i.test(err)) return reject(new Error(`No puc entrar sense contrasenya. Al terminal d’aquest ordinador executa: ssh-copy-id ${target}`));
84
+ if (/Could not resolve|Connection (refused|timed out)|No route|timed out/i.test(err)) return reject(new Error('No arribo al servidor. Comprova l’adreça i que el SSH hi està obert.'));
85
+ reject(new Error((err.trim().split('\n').pop() || 'No he pogut afegir el servidor') + (code ? ` (ssh ${code})` : '')));
86
+ });
87
+ p.stdin.end(remoteScript(port, dir));
88
+ });
89
+ }
90
+
91
+ // Servidors que ja tens a ~/.ssh/config (sense comodins), perquè el panell els ofereixi i no calgui escriure'ls.
92
+ function knownHosts() {
93
+ let txt = '';
94
+ try { txt = fs.readFileSync(path.join(os.homedir(), '.ssh', 'config'), 'utf8'); } catch { return []; }
95
+ const out = [];
96
+ let cur = null;
97
+ for (const raw of txt.split('\n')) {
98
+ const l = raw.trim();
99
+ const h = l.match(/^Host\s+(.+)$/i);
100
+ if (h) {
101
+ cur = null;
102
+ for (const name of h[1].split(/\s+/)) if (TARGET.test(name) && !/[*?!]/.test(name)) { cur = { target: name, host: '' }; out.push(cur); }
103
+ continue;
104
+ }
105
+ const hn = l.match(/^HostName\s+(\S+)/i);
106
+ if (hn && cur) cur.host = hn[1];
107
+ }
108
+ return out.slice(0, 30);
109
+ }
110
+
111
+ module.exports = { addServer, remoteScript, knownHosts, TARGET };
package/web/app/app.js CHANGED
@@ -28,6 +28,8 @@ const ICON = {
28
28
  more: '<circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/>',
29
29
  refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/>', back: '<path d="M15 5l-7 7 7 7"/>',
30
30
  bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
31
+ server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
32
+ scan: '<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/>',
31
33
  check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
32
34
  copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
33
35
  };
@@ -217,13 +219,32 @@ function parseCode(text) {
217
219
  }
218
220
  // Un codi o un enllaç de connexió (#u=…&t=…).
219
221
  const pairFrom = (text) => parsePair(text) || parseCode(text);
220
- function addMachine({ url, token, name }) {
222
+ // Cap pantalla ensenya adreces: cada ordinador és un nom, una icona i un estat.
223
+ const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
224
+ const isLoop = (u) => ['127.0.0.1', 'localhost', '[::1]'].includes(hostOf(u));
225
+ const machineIcon = (m) => (m.kind === 'vps' ? ICON.server : ICON.machine);
226
+ const machineSub = (m) => (m.kind === 'vps' ? `Servidor${m.via ? ` · ${m.via}` : ''}` : isLoop(m.url) ? 'Aquest ordinador' : 'Ordinador');
227
+ function addMachine({ url, token, name, kind, via }) {
221
228
  const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
222
- if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
229
+ if (i >= 0) { ms[i].token = token; if (kind) ms[i].kind = kind; if (via) ms[i].via = via; }
230
+ else { ms.push({ url, token, name: name || hostOf(url), ...(kind ? { kind } : {}), ...(via ? { via } : {}) }); i = ms.length - 1; }
223
231
  store.machines = ms; store.active = i;
224
232
  state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
225
233
  connect();
226
234
  }
235
+ // Passa a un altre ordinador de la llista i es queda a la mateixa secció.
236
+ function switchMachine(i) {
237
+ closeSheet(); store.active = i; streamCtrl?.abort();
238
+ state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
239
+ connect();
240
+ if (state.route.name === 'session') go('#/sessions'); else render();
241
+ }
242
+ function forgetMachine(i) {
243
+ const ms = store.machines, was = i === store.active; ms.splice(i, 1); store.machines = ms;
244
+ if (was) store.active = 0; else if (i < store.active) store.active = store.active - 1;
245
+ if (was) { streamCtrl?.abort(); state.connected = false; state.info = null; state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); connect(); }
246
+ if (!ms.length) go('#/ordinadors'); else render();
247
+ }
227
248
  function checkPairHash() {
228
249
  const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
229
250
  history.replaceState(null, '', location.pathname + '#/sessions');
@@ -235,20 +256,18 @@ function checkPairHash() {
235
256
  const VIEWS = {
236
257
  sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
237
258
  approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
238
- models: { label: 'Models', icon: ICON.models, group: 'Ordinador' },
239
- maquina: { label: 'Ordinador', icon: ICON.machine, group: 'Ordinador' },
259
+ ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
260
+ models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
240
261
  mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
241
262
  pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
242
263
  };
243
- const NOMACHINE = {
244
- approvals: { title: 'Aprovacions', lead: 'Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.' },
245
- models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
246
- maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
247
- };
248
- const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
264
+ const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra' };
265
+ const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
249
266
  function parseRoute() {
250
267
  const h = location.hash.replace(/^#\/?/, '');
251
268
  if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
269
+ if (h === 'ordinador') return { name: 'ordinadors' }; // adreces antigues
270
+ if (h === 'mobil') return { name: 'mobil' };
252
271
  const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
253
272
  return { name: k || 'sessions' };
254
273
  }
@@ -257,6 +276,8 @@ let lastName = null;
257
276
  function route() {
258
277
  if (checkPairHash()) return;
259
278
  state.route = parseRoute();
279
+ // Sense cap ordinador, tot porta al mateix lloc: la pàgina Ordinadors.
280
+ if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/ordinadors'); state.route = { name: 'ordinadors' }; }
260
281
  if (state.route.name === 'session') openSession(state.route.id); else render();
261
282
  }
262
283
 
@@ -269,10 +290,13 @@ function renderChrome() {
269
290
  prev = v.group;
270
291
  return `${head}<a href="${HASH[k]}" style="--i:${i}" title="${v.label}" ${k === active ? 'aria-current="page"' : ''}>${svg(v.icon)}<span class="l">${v.label}</span>${k === 'approvals' && n ? `<span class="badge">${n}</span>` : ''}</a>`;
271
292
  }).join('');
272
- const m = machine();
273
- const many = store.machines.length > 1, sc = $('#sideConn');
274
- sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(state.connected ? m.name : state.lastError ? 'Sense connexió' : 'Connectant…')}</span>${many ? svg(ICON.chev) : ''}` : '<i class="dot"></i><span>Cap ordinador connectat</span>';
275
- sc.classList.toggle('switch', many); many ? (sc.setAttribute('role', 'button'), sc.tabIndex = 0, sc.dataset.act = 'machines', sc.title = 'Canvia d’ordinador') : (sc.removeAttribute('role'), sc.removeAttribute('tabindex'), delete sc.dataset.act, sc.removeAttribute('title'));
293
+ const m = machine(), sc = $('#sideConn');
294
+ sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(m.name)}</span>${svg(ICON.chev)}` : `<i class="dot"></i><span>Afegeix un ordinador</span>${svg(ICON.plus)}`;
295
+ sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
296
+ const more = ['models', 'mobil', 'pissarra'].includes(r.name);
297
+ $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
298
+ .map(([k, ic, l, on]) => `<a href="${HASH[k]}" ${on ? 'aria-current="page"' : ''}>${svg(ic)}<span>${l}</span>${k === 'approvals' && n ? `<b class="badge">${n}</b>` : ''}</a>`).join('')
299
+ + `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
276
300
  const s = r.name === 'session' ? state.sessions.get(r.id) : null;
277
301
  $('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
278
302
  $('#mRight').innerHTML = r.name === 'session'
@@ -286,6 +310,7 @@ function render() {
286
310
  renderChrome();
287
311
  document.body.classList.toggle('board-mode', board);
288
312
  document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
313
+ document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && !!local.engine));
289
314
  if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
290
315
  if (board && lastName === 'pissarra' && $('#boardRoot')) return;
291
316
  const changed = lastName !== r.name || r.name === 'session';
@@ -294,7 +319,7 @@ function render() {
294
319
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
295
320
  else {
296
321
  const html = r.name === 'mobil' ? viewLocal() : null;
297
- view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'sessions' ? (machine() ? viewSessions() : viewConnect()) : !machine() ? viewNoMachine(NOMACHINE[r.name]) : r.name === 'approvals' ? viewApprovals() : r.name === 'models' ? viewModels() : viewMachine()}</div>`;
322
+ view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : viewModels()}</div>`;
298
323
  if (r.name === 'mobil') bindLocal();
299
324
  if (r.name === 'models' && machine()) loadOllama();
300
325
  }
@@ -303,48 +328,164 @@ function render() {
303
328
  tourUpdate();
304
329
  }
305
330
 
306
- /* ---------- Connecta */
307
- // La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
331
+ /* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
308
332
  const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
309
- function viewNoMachine(what) {
310
- return `<div class="top"><div><h1>${esc(what.title)}</h1><p>${esc(what.lead)}</p></div></div>
311
- <div class="panel empty">${bot('sleep')}<h2>Sembla que no hi ha cap sessió iniciada</h2><p>Crea’n una de nova a la pàgina Sessions. Allà connectes l’ordinador i hi dones feina.</p><a class="btn ink" href="#/sessions">Ves a Sessions</a></div>`;
312
- }
313
- function pasteForm(open) {
314
- return `<details class="paste" ${open ? 'open' : ''}><summary>Tens un codi?</summary>
315
- <form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
316
- <p class="muted small">Surt al terminal, sota el codi QR. Copia’l sencer.</p></details>`;
317
- }
318
- function viewConnect() {
319
- const CMD = 'npx nio-cat', cmd = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
320
- if (isPhone()) {
321
- return `<section class="connect">
322
- <div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen al teu ordinador, amb els teus comptes i els teus fitxers. Des del mòbil els dones feina i aproves el que facin.</p></div>
323
- <div class="panel conn-main">
324
- <ol class="stepper">
325
- <li><span class="n">1</span><div><b>Engega nio a l’ordinador</b><p>Al terminal, des de la carpeta dels teus projectes.</p></div></li>
326
- <li><span class="n">2</span><div><b>Escaneja el QR</b><p>Amb la càmera del mòbil. S’obrirà nio connectat.</p></div></li>
327
- </ol>
328
- ${cmd(CMD)}
329
- <p class="muted small">Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span>. Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>
330
- ${pasteForm(false)}
331
- </div>
332
- </section>`;
333
+ const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
334
+ function computerCard(m, i) {
335
+ const on = i === store.active;
336
+ const st = on ? (state.connected ? '<span class="chip ok"><i class="dot on"></i>Connectat</span>' : `<span class="chip">${esc(state.lastError ? 'Sense connexió' : 'Connectant…')}</span>`) : '';
337
+ return `<div class="card computer ${on ? 'active' : ''}">
338
+ <div class="row" style="flex-wrap:nowrap;gap:12px;align-items:flex-start"><span class="c-ico">${svg(machineIcon(m))}</span><div class="grow"><div class="row between"><b>${esc(m.name)}</b>${st}</div><div class="muted small">${esc(machineSub(m))}</div></div></div>
339
+ <div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button></div>
340
+ </div>`;
341
+ }
342
+ // Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
343
+ function helpPanel(open) {
344
+ const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
345
+ return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
346
+ ${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa <span class="mono">npx nio-cat</span>. S’obre nio.cat ja connectat. Cal Node.js 18 o més nou.</p>`)}
347
+ ${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi <span class="mono">nio-…</span>.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi.</p><p>Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span> a l’ordinador (<span class="mono">brew install cloudflared</span>). Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>`)}
348
+ ${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu <span class="mono">usuari@servidor</span>. L’ordinador hi entra, hi engega nio i el servidor surt a la llista.</p><p>Després, prem <b>Continua al mòbil</b> amb el servidor actiu per fer-lo servir des del mòbil.</p>`)}
349
+ ${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa <span class="mono">ssh-copy-id usuari@servidor</span> a l’ordinador.</li><li>Node.js 18 o més nou.</li><li>Claude Code instal·lat i amb sessió iniciada: entra al servidor i executa <span class="mono">claude</span> un cop.</li><li>Linux amb <span class="mono">curl</span> o <span class="mono">wget</span> (nio hi instal·la <span class="mono">cloudflared</span> sol).</li></ul>`)}
350
+ ${q('No es connecta', `<ul><li><b>«No arribo a l’ordinador»</b>: comprova que nio continua engegat al terminal.</li><li><b>El servidor ha deixat de respondre</b>: si s’ha reiniciat, l’adreça canvia. Prem <b>Torna a connectar</b> a la seva targeta.</li><li><b>Safari no connecta</b>: obre nio amb <span class="mono">npx nio-cat --local</span>, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou.</li></ul>`)}
351
+ ${q('D’on es gasta', `<p>nio no fa cap crida pel seu compte. Cada agent treballa a l’ordinador o al servidor amb el teu compte (Claude, Codex, Grok…). Ollama funciona al teu ordinador i no gasta res.</p>`)}
352
+ <div class="row"><button class="btn sm" type="button" data-act="tour">Fes el tutorial</button></div>
353
+ </div></section>`;
354
+ }
355
+ function viewComputers() {
356
+ const ms = store.machines, m = machine(), info = state.info;
357
+ const head = `<div class="top"><div><h1>Ordinadors</h1><p>On treballen els agents: el teu ordinador o un servidor. Tria’n un i dona-li feina.</p></div><button class="btn ink" id="addComputer" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>`;
358
+ if (!ms.length) {
359
+ const steps = isPhone()
360
+ ? [['Engega nio a l’ordinador', 'Al terminal: <span class="mono">npx nio-cat</span>.'], ['Escaneja el QR', 'O escriu el codi <span class="mono">nio-…</span> que surt al terminal.'], ['Dona feina a un agent', 'Crea una sessió i segueix-la en directe.']]
361
+ : [['Engega nio', 'Al terminal: <span class="mono">npx nio-cat</span>. S’obre aquesta web ja connectada.'], ['O afegeix un servidor', 'Amb SSH: <span class="mono">usuari@servidor</span>. nio hi fa la resta.'], ['Continua al mòbil', 'Amb un QR o un codi, quan vulguis.']];
362
+ return `${head}<div class="panel empty">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>${helpPanel()}`;
333
363
  }
334
- return `<section class="connect">
335
- <div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen a l’ordinador, amb els teus comptes i els teus fitxers. Des d’aquí els dones feina, els veus treballar i aproves el que facin.</p></div>
336
- <div class="choice-grid conn-main">
337
- <section class="panel choice"><header><h2>Aquest ordinador</h2></header><div class="body stack">
338
- <p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
339
- <p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
340
- <section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
341
- <p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
342
- ${pasteForm(true)}
343
- <p class="small"><b>Un servidor (VPS) per SSH.</b> Des d'aquest ordinador, sense obrir cap port:</p>${cmd('npx nio-cat ssh usuari@servidor')}
344
- <p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
364
+ const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
365
+ <div class="grid2">
366
+ <div class="stack">
367
+ <section class="panel"><header><h2>Agents</h2><button class="btn sm ghost" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar</button></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Carregant…</p>'}</div></section>
368
+ ${info ? `<section class="panel"><header><h2>Sistema</h2></header><div class="body"><dl class="kv"><dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió de nio</dt><dd>${esc(info.version)}</dd></dl></div></section>` : ''}
345
369
  </div>
346
- <p class="muted small" style="margin-top:14px">Després, al mòbil, tria l’ordinador i escaneja el codi QR que mostra el panell.</p>
347
- </section>`;
370
+ <div class="stack">
371
+ <section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
372
+ </div>
373
+ </div>` : '';
374
+ return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${helpPanel()}`;
375
+ }
376
+
377
+ /* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
378
+ async function probeLocal() {
379
+ try {
380
+ const c = new AbortController(); setTimeout(() => c.abort(), 1500);
381
+ const d = await (await fetch('http://127.0.0.1:7717/api/hello', { signal: c.signal, cache: 'no-store' })).json();
382
+ return d?.nio ? d : null;
383
+ } catch { return null; }
384
+ }
385
+ // Afegeix un servidor per SSH des de l'ordinador actiu. El progrés arriba línia a línia.
386
+ async function sshAdd(target, onStep) {
387
+ const m = machine(); if (!m) throw new Error('Primer connecta un ordinador');
388
+ const res = await fetch(`${m.url}/api/ssh/add`, { method: 'POST', headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json' }, body: JSON.stringify({ target }) });
389
+ if (!res.ok || !res.body) throw new Error((await res.json().catch(() => ({}))).error || 'Aquest ordinador no pot afegir servidors. Actualitza nio.');
390
+ const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '', last = null;
391
+ for (;;) {
392
+ const { value, done } = await reader.read(); if (done) break;
393
+ buf += dec.decode(value, { stream: true }); let i;
394
+ while ((i = buf.indexOf('\n')) >= 0) {
395
+ const o = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
396
+ if (o.step) onStep(o.step); else last = o;
397
+ }
398
+ }
399
+ if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
400
+ return last;
401
+ }
402
+ async function startScan(video, onText) {
403
+ const det = new BarcodeDetector({ formats: ['qr_code'] });
404
+ const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
405
+ video.srcObject = stream; await video.play(); let alive = true;
406
+ (async () => { while (alive) { try { const r = await det.detect(video); if (r[0]) onText(r[0].rawValue); } catch { /* quadre sense QR */ } await sleep(250); } })();
407
+ return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
408
+ }
409
+ function addComputerSheet(tab, opts = {}) {
410
+ const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
411
+ const TABS = phone ? [['code', 'Codi o QR'], ['vps', 'Servidor (SSH)']] : [['local', 'Aquest ordinador'], ['vps', 'Servidor (SSH)'], ['code', 'Tinc un codi']];
412
+ let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
413
+ openSheet('', (sh) => {
414
+ sh.innerHTML = `<div class="wiz-head"><div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Afegeix un ordinador</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div><div class="seg" id="addTabs" role="group">${TABS.map(([k, l]) => `<button type="button" data-tab="${k}">${l}</button>`).join('')}</div></div><div class="wiz-body" id="addBody"></div>`;
415
+ const body = $('#addBody', sh);
416
+ const stop = () => { clearTimeout(poll); scan?.stop(); scan = null; shown = ''; };
417
+ const set = (key, html) => { if (key === shown) return false; shown = key; body.innerHTML = html; return true; };
418
+ const draw = () => {
419
+ stop(); if (cur === 'vps' && !hosts.length && machine()) api('/api/ssh/hosts').then((h) => { hosts = h; if (cur === 'vps' && shown === 'vps') { shown = ''; vps(); } }).catch(() => {});
420
+ $$('#addTabs button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.tab === cur));
421
+ body.scrollTop = 0;
422
+ if (cur === 'local') local(); else if (cur === 'vps') vps(); else code();
423
+ };
424
+ const local = async () => {
425
+ set('wait', '<p class="muted small">Mirant si nio funciona en aquest ordinador…</p>');
426
+ const d = await probeLocal(); if (cur !== 'local') return;
427
+ const saved = d && store.machines.findIndex((x) => x.id === d.id);
428
+ if (d && saved >= 0) set('saved', `<p class="wiz-q">Aquest ordinador ja és a la llista</p><p class="muted small">${esc(d.name)}</p><div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre</button></div>`);
429
+ else if (d) set('found', `<p class="wiz-q">nio funciona en aquest ordinador</p><p class="muted small">Aquest navegador encara no hi té accés. Torna a engegar nio perquè obri el panell ja connectat:</p>${cmdBox('npx nio-cat')}`);
430
+ else set('none', `<p class="wiz-q">Engega nio en aquest ordinador</p><p class="muted small">Al terminal, des de la carpeta dels teus projectes:</p>${cmdBox('npx nio-cat')}<p class="muted small">S’obrirà aquest panell ja connectat. Cal Node.js 18 o més nou.</p>`);
431
+ if (saved < 0 || !d) poll = setTimeout(local, 3000);
432
+ };
433
+ const runSsh = async (target) => {
434
+ const steps = $('#sshSteps', sh), btn = $('#sshForm button', sh); if (!steps) return; btn.disabled = true; steps.innerHTML = '';
435
+ const add = (t) => { $$('li.on', steps).forEach((l) => { l.className = 'ok'; }); steps.insertAdjacentHTML('beforeend', `<li class="on">${esc(t)}…</li>`); };
436
+ try {
437
+ const r = await sshAdd(target, add);
438
+ LS.set('nio.lastSsh', target);
439
+ const old = store.machines.findIndex((x) => x.via === target && x.kind === 'vps');
440
+ if (old >= 0) { const ms = store.machines; ms.splice(old, 1); store.machines = ms; store.active = Math.min(store.active, Math.max(0, ms.length - 1)); }
441
+ addMachine({ url: r.url, token: r.token, name: r.host, kind: 'vps', via: target });
442
+ shown = 'vps-ok';
443
+ body.innerHTML = `<p class="wiz-q">${esc(r.host)} és a punt</p>${r.claude ? '<p class="muted small">Ja el pots fer servir: crea-hi una sessió amb Claude Code.</p>' : '<p class="muted small">Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.</p>'}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a><button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button></div>`;
444
+ } catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; }
445
+ };
446
+ const vps = () => {
447
+ const m = machine();
448
+ if (!m) return set('vps-no', `<p class="wiz-q">Primer, un ordinador</p><p class="muted small">El servidor s’afegeix des d’un ordinador que ja tinguis connectat, perquè és ell qui hi entra per SSH.</p>${phone ? '' : '<button class="btn" type="button" data-tab="local">Afegeix aquest ordinador</button>'}`);
449
+ set('vps', `<p class="wiz-q">Un servidor amb Claude Code</p>
450
+ <p class="muted small">${esc(m.name)} hi entra per SSH, hi engega nio amb un túnel segur i el deixa llest per al mòbil.</p>
451
+ ${hosts.length ? `<div class="row">${hosts.map((h) => `<button class="chip" type="button" data-host="${esc(h.target)}" title="${esc(h.host)}">${esc(h.target)}</button>`).join('')}</div>` : ''}
452
+ <form class="pair-form" id="sshForm"><input type="text" id="sshIn" value="${esc(opts.target || LS.get('nio.lastSsh', ''))}" placeholder="usuari@servidor" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Usuari i servidor"><button class="btn ink" type="submit">Afegeix</button></form>
453
+ <ul class="steps" id="sshSteps"></ul>
454
+ <p class="muted small">Cal entrar-hi sense contrasenya (clau SSH) i que el servidor tingui Node.js 18 o més nou. Claude Code hi ha d’estar instal·lat i amb sessió iniciada.</p>`);
455
+ };
456
+ const code = () => {
457
+ set('code', `<p class="wiz-q">${phone ? 'Escaneja o escriu el codi' : 'Escriu el codi'}</p>
458
+ <form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
459
+ ${canScan ? `<button class="btn" type="button" data-scan>${svg(ICON.scan)}Escaneja un QR</button><video class="scan" id="scanVideo" playsinline muted hidden></video>` : phone ? '<p class="muted small">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}
460
+ <p class="muted small">El codi surt al terminal, sota el QR, o a «Continua al mòbil».</p>`);
461
+ };
462
+ sh.onclick = async (e) => {
463
+ const t = e.target.closest('[data-tab]'); if (t) { cur = t.dataset.tab; return draw(); }
464
+ const hb = e.target.closest('[data-host]'); if (hb) { $('#sshIn', sh).value = hb.dataset.host; return $('#sshForm', sh).requestSubmit(); }
465
+ const os = e.target.closest('[data-open-saved]'); if (os) { switchMachine(+os.dataset.openSaved); return go('#/sessions'); }
466
+ if (e.target.closest('[data-close]')) { stop(); return closeSheet(); }
467
+ if (e.target.closest('[data-scan]')) {
468
+ const v = $('#scanVideo', sh); v.hidden = false;
469
+ try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions'); } }); }
470
+ catch { v.hidden = true; toast('No puc obrir la càmera. Escriu el codi.'); }
471
+ }
472
+ };
473
+ sh.onsubmit = async (e) => {
474
+ if (e.target.id !== 'sshForm') return;
475
+ e.preventDefault(); e.stopPropagation();
476
+ runSsh($('#sshIn', sh).value.trim());
477
+ };
478
+ draw();
479
+ if (opts.auto && cur === 'vps') setTimeout(() => runSsh(opts.target), 50);
480
+ }, 'wiz');
481
+ }
482
+ function machinesSheet() {
483
+ const ms = store.machines;
484
+ openSheet(`<h2>Ordinadors</h2>${ms.length ? `<div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><span class="c-ico">${svg(machineIcon(x))}</span><span class="grow"><b>${esc(x.name)}</b><div class="muted small">${esc(machineSub(x))}</div></span>${i === store.active ? `<span class="chip ok"><i class="dot ${state.connected ? 'on' : ''}"></i>Actiu</span>` : ''}</button>`).join('')}</div>` : '<p class="muted small">Encara no hi ha cap ordinador.</p>'}
485
+ <div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
486
+ }
487
+ function moreSheet() {
488
+ openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
348
489
  }
349
490
 
350
491
  /* ---------- Sessions */
@@ -361,7 +502,7 @@ function viewSessions() {
361
502
  const hint = mobileHint();
362
503
  const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
363
504
  if (!state.connected && !list.length) {
364
- return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>Comprova que nio continua engegat a l’ordinador i que el túnel està obert.</p><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>` : ''}</div>`;
505
+ return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
365
506
  }
366
507
  return `${head}${list.length ? `<div class="list">${list.map((s) => `
367
508
  <a class="card session-card" href="#/s/${esc(s.id)}">
@@ -573,7 +714,11 @@ async function newSessionSheet(preset = {}) {
573
714
  let projects = [];
574
715
  try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
575
716
  const first = agents.find((a) => a.available);
576
- const sel = { agent: first?.id || '', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: 'preguntar', model: '', text: '', ...preset };
717
+ // Continuïtat: l'última tria d'aquest ordinador (agent, carpeta, permisos) i les carpetes recents.
718
+ const lastKey = `nio.last.${machine()?.id || machine()?.url}`, last = LS.get(lastKey, {});
719
+ const lastAgent = agents.find((a) => a.id === last.agent && a.available);
720
+ const sel = { agent: lastAgent?.id || first?.id || '', cwd: last.cwd || projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: last.mode || 'preguntar', model: '', text: '', ...preset };
721
+ const recents = (last.recent || []).filter((p) => p);
577
722
  const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
578
723
  let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
579
724
  const agentOf = () => agents.find((a) => a.id === sel.agent);
@@ -626,7 +771,7 @@ async function newSessionSheet(preset = {}) {
626
771
  () => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${agents.map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
627
772
  () => {
628
773
  const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
629
- const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
774
+ const quick = [...recents.map((p) => ({ path: p, name: basename(p) })), ...projects.filter((p) => !recents.includes(p.path))].slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
630
775
  return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
631
776
  <form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
632
777
  <div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}</div>
@@ -650,7 +795,7 @@ async function newSessionSheet(preset = {}) {
650
795
  const start = async () => {
651
796
  if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
652
797
  const btn = $('[data-start]', sh); btn.disabled = true;
653
- try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
798
+ try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); LS.set(lastKey, { agent: sel.agent, cwd: sel.cwd, mode: sel.mode, recent: [sel.cwd, ...recents.filter((p) => p !== sel.cwd)].slice(0, 6) }); closeSheet(); go(`#/s/${s.id}`); }
654
799
  catch (e) { toast(e.message, 4200); btn.disabled = false; }
655
800
  };
656
801
  sh.onclick = (e) => {
@@ -715,24 +860,10 @@ function modeSheet(s) {
715
860
  /* ---------- Ordinadors i mòbil */
716
861
  const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
717
862
  function mobileHint() {
718
- if (!isLocalMachine() || !state.connected || LS.get('nio.mobHint.off', false)) return '';
863
+ const m = machine(), key = `nio.mobHint.${m?.id || m?.url}`;
864
+ if (!m || isPhone() || !state.connected || LS.get(key, false)) return '';
719
865
  return `<div class="panel hint"><div class="row between" style="flex-wrap:wrap;gap:12px"><div><b>Vols fer-lo servir al mòbil?</b><p class="muted small" style="margin-top:2px">Escaneja un codi i continues des del telèfon, amb les mateixes sessions.</p></div><div class="row"><button class="btn ghost" type="button" data-act="mobile-hint-off">Ara no</button><button class="btn ink" type="button" data-act="mobile-qr">Mostra el QR</button></div></div></div>`;
720
866
  }
721
- function machinesSheet() {
722
- const ms = store.machines;
723
- openSheet(`<h2>Ordinadors</h2><div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
724
- <div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-act="add-machine">${svg(ICON.plus)}Connecta’n un altre</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
725
- }
726
- function addMachineSheet() {
727
- const cmd = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
728
- openSheet(`<h2>Connecta un altre ordinador</h2>
729
- <div class="stack-lg">
730
- <div class="stack"><b>Un ordinador</b><p class="muted small">Al terminal de l’altre ordinador, des de la carpeta dels projectes:</p>${cmd('npx nio-cat')}<p class="muted small">Escaneja el QR que surt, o enganxa aquí sota l’enllaç.</p></div>
731
- <div class="stack"><b>Un servidor (VPS) per SSH</b><p class="muted small">Des de l’ordinador on tens aquest panell. No cal obrir cap port al servidor, només Node.js 18 o més nou:</p>${cmd('npx nio-cat ssh usuari@servidor')}<p class="muted small">Engega nio al servidor, obre un túnel SSH i et mostra el panell connectat.</p></div>
732
- <div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia aquí el codi que mostra.</p></div>
733
- <form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-… o enllaç" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi o enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
734
- </div>`);
735
- }
736
867
  let qrTimer = 0;
737
868
  function mobileQrSheet() {
738
869
  clearTimeout(qrTimer);
@@ -741,11 +872,11 @@ function mobileQrSheet() {
741
872
  let r; try { r = await api('/api/pair'); } catch (e) { box.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
742
873
  if (!$('#qrBox')) return;
743
874
  if (!r.link) {
744
- box.innerHTML = `<p class="muted small">Encara no hi ha cap adreça per al mòbil.${n < 8 ? ' Estic esperant el túnel…' : ''}</p><p class="muted small">Engega nio amb <span class="mono">--lan</span> (mateixa wifi) o instal·la <span class="mono">cloudflared</span> per connectar-t’hi des de qualsevol lloc.</p>`;
875
+ box.innerHTML = n < 8 ? '<p class="muted small">Esperant el túnel…</p>' : `<p class="wiz-q">Falta una adreça per al mòbil</p><p class="muted small">Per connectar-t’hi des de qualsevol xarxa, instal·la <span class="mono">cloudflared</span> a l’ordinador i torna a engegar nio:</p>${cmdBox('brew install cloudflared')}<p class="muted small">Sense ell, el mòbil només es connecta per la mateixa wifi: engega nio amb <span class="mono">npx nio-cat --lan</span>.</p>`;
745
876
  if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
746
877
  return;
747
878
  }
748
- box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">Si no pots escanejar, enganxa el codi a Sessions → Tria un ordinador.</p>`;
879
+ box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">Al mòbil: www.nio.cat/app → Ordinadors → Afegeix ordinador. Escaneja el QR o enganxa el codi.</p>`;
749
880
  };
750
881
  openSheet(`<h2>Continua al mòbil</h2><div class="stack" id="qrBox"><p class="muted small">Preparant el codi…</p></div><div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
751
882
  paint();
@@ -793,25 +924,6 @@ function sessionOptions(id) {
793
924
  });
794
925
  }
795
926
 
796
- /* ---------- Ordinador */
797
- function viewMachine() {
798
- const m = machine(), info = state.info, ms = store.machines;
799
- return `<div class="top"><div><h1>${esc(m.name)}</h1><p class="conn"><i class="dot ${state.connected ? 'on' : ''}"></i><span>${state.connected ? 'Connectat' : esc(state.lastError || 'Connectant…')}</span></p></div><div class="row"><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div></div>
800
- <div class="grid2">
801
- <div class="stack">
802
- <section class="panel"><header><h2>Agents</h2></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Sense connexió.</p>'}</div></section>
803
- ${info?.roots?.length ? `<section class="panel"><header><h2>Carpetes compartides</h2></header><div class="body mono small">${info.roots.map(esc).join('<br>')}</div></section>` : ''}
804
- </div>
805
- <div class="stack">
806
- <section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
807
- ${'<section class="panel"><header><h2>Continua al mòbil</h2></header><div class="body stack"><p class="small">Escaneja un codi i obre aquest ordinador des del telèfon.</p><div class="row"><button class="btn ink grow" data-act="mobile-qr" type="button">Mostra el QR</button></div></div></section>'}
808
- <section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url)}</dd>${info ? `<dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió</dt><dd>${esc(info.version)}</dd>` : ''}</dl></div></section>
809
- <section class="panel"><header><h2>Ordinadors</h2></header><div class="body stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
810
- <div class="row"><button class="btn" data-act="add-machine" type="button">${svg(ICON.plus)}Connecta’n un altre</button><button class="btn danger" data-act="forget" type="button">Oblida aquest</button></div></div></section>
811
- </div>
812
- </div>`;
813
- }
814
-
815
927
  /* ---------- Models d'Ollama */
816
928
  const OLLAMA_PICKS = [
817
929
  { name: 'qwen3:4b', size: '2,5 GB', note: 'Ràpid i bo per a gairebé tot.' },
@@ -1030,7 +1142,7 @@ async function sendLocal() {
1030
1142
 
1031
1143
  /* ---------------------------------------------------------------- accions */
1032
1144
  document.addEventListener('click', async (e) => {
1033
- const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-lm]');
1145
+ const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
1034
1146
  if (!a) return;
1035
1147
  if (a.dataset.copy) {
1036
1148
  try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
@@ -1042,7 +1154,9 @@ document.addEventListener('click', async (e) => {
1042
1154
  return;
1043
1155
  }
1044
1156
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
1045
- if (a.dataset.machine != null) { closeSheet(); store.active = Number(a.dataset.machine); state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; connect(); return render(); }
1157
+ if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
1158
+ if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
1159
+ if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
1046
1160
  if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
1047
1161
  switch (a.dataset.act) {
1048
1162
  case 'new': return newSessionSheet();
@@ -1058,17 +1172,14 @@ document.addEventListener('click', async (e) => {
1058
1172
  case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
1059
1173
  case 'push': return enablePush();
1060
1174
  case 'push-test': try { const r = await api('/api/push/test', { method: 'POST' }); toast(r.subs ? 'Enviat. Arribarà en uns segons.' : 'Primer activa els avisos'); } catch (err) { toast(err.message); } return;
1061
- case 'add-machine': return addMachineSheet();
1175
+ case 'add-computer': return addComputerSheet();
1176
+ case 'open-sessions': return go('#/sessions');
1177
+ case 'help': go('#/ordinadors'); setTimeout(() => { const h = $('#help'); h?.scrollIntoView({ block: 'start' }); $('details', h)?.setAttribute('open', ''); }, 60); return;
1178
+ case 'more': return moreSheet();
1062
1179
  case 'machines': return machinesSheet();
1063
1180
  case 'mobile-qr': return mobileQrSheet();
1064
- case 'mobile-hint-off': LS.set('nio.mobHint.off', true); return render();
1181
+ case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
1065
1182
  case 'git': return gitSheet(state.route.id);
1066
- case 'forget': {
1067
- if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
1068
- const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
1069
- streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
1070
- connect(); return render();
1071
- }
1072
1183
  case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
1073
1184
  case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; return render();
1074
1185
  case 'lm-clear': local.messages = []; return render();
@@ -1091,7 +1202,9 @@ let palSel = 0;
1091
1202
  function commands() {
1092
1203
  const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
1093
1204
  if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
1205
+ c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
1094
1206
  [...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
1207
+ c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
1095
1208
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
1096
1209
  return c;
1097
1210
  }
@@ -1139,42 +1252,22 @@ $('#scrim').addEventListener('click', () => setNav(false));
1139
1252
  $('#side').addEventListener('click', (e) => { if (e.target.closest('a, button')) setNav(false); });
1140
1253
  addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
1141
1254
  mq.addEventListener('change', () => setNav(false));
1142
- (() => {
1143
- const side = $('#side'); let x0 = null, y0 = 0, dx = 0, mode = null;
1144
- addEventListener('touchstart', (e) => {
1145
- if (!mq.matches || $('#sheet').open || state.route.name === 'pissarra') return;
1146
- const t = e.touches[0], open = document.body.classList.contains('nav-open');
1147
- if (open || t.clientX < 22) { x0 = t.clientX; y0 = t.clientY; dx = 0; mode = open ? 'close' : 'open'; }
1148
- }, { passive: true });
1149
- addEventListener('touchmove', (e) => {
1150
- if (x0 == null) return;
1151
- const t = e.touches[0]; dx = t.clientX - x0;
1152
- if (Math.abs(t.clientY - y0) > Math.abs(dx) && Math.abs(dx) < 12) { x0 = null; return; }
1153
- const w = side.offsetWidth, pos = mode === 'open' ? Math.min(0, -w + Math.max(0, dx)) : Math.min(0, dx);
1154
- side.classList.add('dragging'); side.style.transform = `translateX(${pos}px)`;
1155
- }, { passive: true });
1156
- addEventListener('touchend', () => {
1157
- if (x0 == null) return;
1158
- side.classList.remove('dragging'); side.style.transform = '';
1159
- if (mode === 'open' && dx > 60) setNav(true); else if (mode === 'close' && dx < -60) setNav(false);
1160
- x0 = null;
1161
- });
1162
- })();
1163
-
1164
1255
  /* ---------------------------------------------------------------- tutorial */
1165
1256
  const TOUR_KEY = 'nio.tour.v2';
1166
1257
  const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
1167
1258
  const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
1168
1259
  const TSTEPS = [
1169
- { center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA del teu ordinador. En un minut t’ensenyo on és cada cosa.', next: 'Comencem' },
1170
- { route: 'sessions', target: () => (machine() ? '.top [data-act=new]' : '.conn-main'), m: () => (machine() ? '.top [data-act=new]' : '.conn-main .cmd'), title: () => (machine() ? 'Dona feina a un agent' : 'Primer, connecta l’ordinador'), text: () => (machine() ? 'Cada sessió és una conversa amb un agent dins d’una carpeta. Tria l’agent, els permisos i explica-li què ha de fer.' : 'Executa aquesta ordre al terminal de l’ordinador i escaneja el codi QR que apareix.') },
1171
- { route: 'approvals', target: '.side nav a[href="#/aprovacions"]', m: '#mRight .icon-btn', title: 'Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Si actives els avisos, t’arriba al mòbil.' },
1172
- { route: 'mobil', target: '.side nav a[href="#/mobil"]', m: '.page .model-card', title: 'Un model sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil.' },
1173
- { route: 'models', target: '.side nav a[href="#/models"]', m: '.page .top h1, .connect-head h1', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models, veus quins tens i obres sessions amb el que vulguis. Res no surt de casa.' },
1260
+ { center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA (Claude Code, Codex…) del teu ordinador o d’un servidor. En un minut t’ensenyo com es fa.', next: 'Comencem' },
1261
+ { route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '1. Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (<span class="mono">npx nio-cat</span>), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
1262
+ { route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '2. Continua al mòbil', text: 'A la targeta de l’ordinador prem «Continua al mòbil»: surt un QR i un codi. Al mòbil, obre www.nio.cat/app, Ordinadors, i escaneja el QR o enganxa el codi.' },
1263
+ { route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: '3. Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (qualsevol de l’ordinador), els permisos i li expliques què ha de fer.' },
1264
+ { route: 'approvals', skipIf: () => !machine(), target: '.side nav a[href="#/aprovacions"]', m: '#tabbar a[href="#/aprovacions"]', title: '4. Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Si actives els avisos, t’arriba al mòbil.' },
1265
+ { route: 'models', skipIf: () => !machine(), target: '.side nav a[href="#/models"]', m: '#tabbar [data-act=more]', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models i veus quins tens. Res no surt de casa i no gasta res.' },
1266
+ { route: 'mobil', target: '.side nav a[href="#/xat-local"]', m: '.page .model-card', title: 'Un model sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil.' },
1174
1267
  { route: 'pissarra', target: '.side nav a[href="#/pissarra"]', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama o amb el model del mòbil.' },
1175
- { route: 'maquina', target: '.side nav a[href="#/ordinador"]', m: '.page .top h1, .connect-head h1', title: 'El teu ordinador', text: 'Aquí veus quins agents hi ha instal·lats, actives els avisos i connectes més ordinadors.' },
1176
- { target: '[data-act=palette]', m: '#burger', title: () => (mq.matches ? 'El menú' : 'Un últim truc'), text: () => (mq.matches ? 'Toca aquí per anar a qualsevol secció. També pots lliscar des de la vora esquerra de la pantalla.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
1177
- { center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al menú.', next: 'Finalitza' }
1268
+ { route: 'ordinadors', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Com es fa» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
1269
+ { target: '[data-act=palette]', m: '#tabbar [data-act=more]', title: () => (mq.matches ? 'Més coses' : 'Un últim truc'), text: () => (mq.matches ? 'A «Més» tens els models, el xat local, la pissarra, l’ajuda i aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
1270
+ { center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al menú i l’ajuda a Ordinadors.', next: 'Finalitza' }
1178
1271
  ];
1179
1272
  const tval = (v) => (typeof v === 'function' ? v() : v);
1180
1273
  function tourStart() { T.on = true; T.seen = true; tourGo(0); }
@@ -51,6 +51,7 @@
51
51
  <nav id="nav" aria-label="Seccions"></nav>
52
52
  <div class="foot">
53
53
  <div class="conn" id="sideConn"></div>
54
+ <a class="kbd" href="#/ordinadors" data-act="help" title="Com es fa"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><span class="l">Com es fa</span></a>
54
55
  <button class="kbd" data-act="tour" type="button" title="Tutorial"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><span class="l">Tutorial</span></button>
55
56
  <button class="kbd" data-act="palette" type="button" title="Cerca o fes una acció (⌘K)"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><span class="l">Cerca o fes una acció</span><kbd>⌘K</kbd></button>
56
57
  </div>
@@ -67,6 +68,7 @@
67
68
  <div class="tour-spot" id="tourSpot"></div>
68
69
  <div class="tour-card" id="tourCard" role="dialog" aria-modal="false" aria-labelledby="tourT"></div>
69
70
  </div>
71
+ <nav class="tabbar" id="tabbar" aria-label="Navegació"></nav>
70
72
  <div class="toasts" id="toasts" aria-live="polite"></div>
71
73
 
72
74
  <script src="/bot.js"></script>
package/web/app/style.css CHANGED
@@ -427,3 +427,45 @@ a.card, a.card:hover { text-decoration: none; color: inherit; }
427
427
  .copied .tick { stroke-dasharray: 24; stroke-dashoffset: 24; animation: tick .38s var(--ease) forwards; }
428
428
  @keyframes tick { to { stroke-dashoffset: 0; } }
429
429
  @media (prefers-reduced-motion: reduce) { .copied .tick { animation: none; stroke-dashoffset: 0; } }
430
+
431
+ /* Ordinadors: targetes per nom, estat i accions; passos d'un servidor; barra inferior al mòbil */
432
+ .computers { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; margin-bottom: 8px; }
433
+ .card.computer { display: grid; gap: 14px; cursor: default; }
434
+ .card.computer.active { border-color: var(--fg); box-shadow: inset 0 0 0 1px var(--fg); }
435
+ .c-ico { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: var(--bg-2); }
436
+ .c-actions { gap: 8px; }
437
+ .chip.ok { color: var(--moss); }
438
+ .sec { font-size: 18px; font-weight: 500; letter-spacing: -.03em; margin: 28px 0 12px; }
439
+ .steps { list-style: none; display: grid; gap: 6px; font-size: 14px; }
440
+ .steps li { display: flex; gap: 8px; align-items: baseline; color: var(--fg-muted); }
441
+ .steps li::before { content: '✓'; color: var(--moss); font-weight: 700; }
442
+ .steps li.on { color: var(--fg); }
443
+ .steps li.on::before { content: '…'; color: var(--fg-muted); }
444
+ .steps li.err { color: var(--red); }
445
+ .steps li.err::before { content: '✕'; color: var(--red); }
446
+ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14px; background: #000; }
447
+ .tabbar { display: none; }
448
+ @media (max-width: 820px) {
449
+ .burger { display: none; }
450
+ .tabbar { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; padding: 6px 8px calc(6px + var(--safe-b)); background: color-mix(in srgb, var(--bg) 94%, transparent); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); border-top: 1px solid var(--border); }
451
+ .tabbar > * { flex: 1; position: relative; display: grid; justify-items: center; gap: 2px; padding: 6px 0; border: 0; background: none; border-radius: 10px; font-size: 11px; font-weight: 600; color: var(--fg-muted); text-decoration: none; }
452
+ .tabbar [aria-current="page"] { color: var(--fg); }
453
+ .tabbar .badge { position: absolute; top: 2px; left: calc(50% + 6px); min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 10px; line-height: 16px; text-align: center; }
454
+ body:not(.no-tabs):not(.kb) .main { padding-bottom: calc(64px + var(--safe-b)); }
455
+ body.no-tabs .tabbar, body.kb .tabbar { display: none; }
456
+ .computers { grid-template-columns: 1fr; }
457
+ }
458
+
459
+ /* Ajuda («Com es fa») */
460
+ .help { margin-top: 28px; }
461
+ .help details { border-bottom: 1px solid var(--border); }
462
+ .help details:last-of-type { border-bottom: 0; margin-bottom: 14px; }
463
+ .help summary { cursor: pointer; padding: 12px 0; font-weight: 600; list-style: none; display: flex; justify-content: space-between; gap: 12px; }
464
+ .help summary::-webkit-details-marker { display: none; }
465
+ .help summary::after { content: '+'; color: var(--fg-muted); font-weight: 400; }
466
+ .help details[open] summary::after { content: '−'; }
467
+ .help-body { display: grid; gap: 8px; padding: 0 0 14px; font-size: 14px; color: var(--fg-muted); }
468
+ .help-body ul { padding-left: 18px; display: grid; gap: 4px; }
469
+ .help-body b { color: var(--fg); font-weight: 600; }
470
+ .empty .stepper { text-align: left; max-width: 640px; margin: 16px auto 18px; }
471
+ a.kbd { text-decoration: none; color: inherit; }
package/web/index.html CHANGED
@@ -107,9 +107,11 @@ main strong { font-weight: 600; }
107
107
 
108
108
  <section aria-labelledby="com">
109
109
  <h2 id="com">Com funciona</h2>
110
- <p>Obre el terminal a la carpeta dels teus projectes i executa:</p>
110
+ <p><strong>1. Al teu ordinador.</strong> Obre el terminal a la carpeta dels teus projectes i executa:</p>
111
111
  <div class="cmd"><code id="cmd">npx nio-cat</code><button class="copy" type="button" data-copy="npx nio-cat" aria-label="Copia l’ordre"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg><svg class="ok" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button></div>
112
- <p>Escaneja el codi QR que apareix i ja estàs connectat. A casa, per la wifi; amb <code>cloudflared</code>, des de qualsevol lloc. Només cal tenir <a href="https://nodejs.org">Node.js</a>, i cap dada no passa per servidors nostres.</p>
112
+ <p>S’obre nio.cat ja connectat. Només cal <a href="https://nodejs.org">Node.js</a>.</p>
113
+ <p><strong>2. Amb un servidor (opcional).</strong> A nio.cat, <em>Ordinadors → Afegeix ordinador → Servidor (SSH)</em>, escrius <code>usuari@servidor</code> i nio hi engega Claude Code i la resta. No cal obrir ports.</p>
114
+ <p><strong>3. Al mòbil.</strong> A l’ordinador, prem <em>Continua al mòbil</em>. Al mòbil, obre <a href="/app">nio.cat/app</a>, escaneja el QR o enganxa el codi, i ja ho controles. Des de qualsevol xarxa amb <code>cloudflared</code>; sense ell, per la mateixa wifi. Cap dada no passa per servidors nostres.</p>
113
115
  </section>
114
116
 
115
117
  <section aria-labelledby="limits">
package/web/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v2';
2
+ const SHELL = 'nio-shell-v3';
3
3
  const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {