nio-cat 0.2.0 → 0.3.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
@@ -4,6 +4,7 @@ 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');
7
8
  const { spawn } = require('child_process');
8
9
  const config = require('../src/config');
9
10
  const { Hub } = require('../src/hub');
@@ -21,11 +22,11 @@ for (let i = 0; i < argv.length; i++) {
21
22
  if (a.startsWith('--')) {
22
23
  const [k, v] = a.slice(2).split('=');
23
24
  if (v !== undefined) flags[k] = v;
24
- else if (['port', 'url', 'app'].includes(k)) flags[k] = argv[++i];
25
+ else if (['port', 'url', 'app', 'local-port', 'dir'].includes(k)) flags[k] = argv[++i];
25
26
  else flags[k] = true;
26
27
  } else pos.push(a);
27
28
  }
28
- const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help'].includes(pos[0]) ? pos.shift() : 'start';
29
+ const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh'].includes(pos[0]) ? pos.shift() : 'start';
29
30
 
30
31
  const c = {
31
32
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
@@ -97,6 +98,76 @@ function showPair(publicUrl) {
97
98
  }
98
99
  }
99
100
 
101
+
102
+ // nio ssh usuari@servidor: engega nio al servidor (si no hi és), obre un túnel SSH i obre el panell ja connectat.
103
+ const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
104
+ function remoteScript(port, dir) {
105
+ return `set -e
106
+ command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
107
+ up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
108
+ if ! up; then
109
+ mkdir -p "$HOME/.nio"
110
+ if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
111
+ $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 &
112
+ for i in $(seq 1 80); do up && break; sleep 0.5; done
113
+ up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
114
+ fi
115
+ node -e "console.log('NIO_TOKEN='+require(require('os').homedir()+'/.nio/config.json').token)"
116
+ `;
117
+ }
118
+ function sshBase(target) {
119
+ const a = ['-o', 'ServerAliveInterval=30'];
120
+ if (process.platform !== 'win32') a.push('-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/nio-%C', '-o', 'ControlPersist=10m');
121
+ return a;
122
+ }
123
+ function waitPort(port, ms = 15000) {
124
+ const end = Date.now() + ms;
125
+ return new Promise((resolve) => {
126
+ const t = () => {
127
+ const s = net.connect(port, '127.0.0.1');
128
+ s.once('connect', () => { s.destroy(); resolve(true); });
129
+ s.once('error', () => { s.destroy(); Date.now() > end ? resolve(false) : setTimeout(t, 300); });
130
+ };
131
+ t();
132
+ });
133
+ }
134
+ async function sshStart() {
135
+ const target = pos[0];
136
+ if (!target || !/^[A-Za-z0-9_][\w.@:\-\[\]]*$/.test(target)) {
137
+ console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--local-port 7718] [--dir /ruta/al/servidor]')}\n`);
138
+ process.exit(1);
139
+ }
140
+ const rport = Number(flags.port || 7717), lport = Number(flags['local-port'] || 7718);
141
+ console.log(`\n ${c.b('nio')} ${c.dim('· connectant amb')} ${target}${c.dim('…')}`);
142
+ const out = await new Promise((resolve) => {
143
+ const p = spawn('ssh', [...sshBase(target), '-T', '--', target, 'bash -l -s'], { stdio: ['pipe', 'pipe', 'inherit'] });
144
+ let buf = '';
145
+ p.stdout.on('data', (d) => (buf += d));
146
+ p.on('error', () => resolve({ code: -1, buf }));
147
+ p.on('close', (code) => resolve({ code, buf }));
148
+ p.stdin.end(remoteScript(rport, flags.dir));
149
+ });
150
+ if (out.code === -1) { console.error(c.warn('\n No trobo l’ordre ssh en aquest ordinador.\n')); process.exit(1); }
151
+ 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); }
152
+ 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); }
153
+ 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); }
154
+ const token = (out.buf.match(/NIO_TOKEN=(\S+)/) || [])[1];
155
+ if (!token) { console.error(c.warn('\n No he pogut llegir la clau del servidor.\n')); process.exit(1); }
156
+
157
+ 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'] });
158
+ tun.on('close', () => { console.log(c.warn('\n S’ha tallat el túnel SSH.\n')); process.exit(0); });
159
+ const bye = () => { tun.kill(); process.exit(0); };
160
+ process.on('SIGINT', bye);
161
+ process.on('SIGTERM', bye);
162
+ 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); }
163
+
164
+ const local = `http://127.0.0.1:${lport}`, link = pairLink(local, local, token);
165
+ console.log(` ${c.ok('✓')} Connectat. nio funciona al servidor i el panell és a ${local}`);
166
+ if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell obert al navegador.')}`);
167
+ else console.log(` ${c.dim('Obre aquest enllaç:')} ${link}`);
168
+ 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'));
169
+ }
170
+
100
171
  async function start() {
101
172
  if (flags.port) cfg.port = Number(flags.port);
102
173
  if (flags.app) cfg.app = flags.app;
@@ -178,6 +249,9 @@ async function start() {
178
249
 
179
250
  (async () => {
180
251
  switch (cmd) {
252
+ case 'ssh':
253
+ await sshStart();
254
+ break;
181
255
  case 'doctor':
182
256
  await doctor();
183
257
  break;
@@ -207,7 +281,12 @@ async function start() {
207
281
  --open Obre al navegador la pàgina del codi QR
208
282
  nio pair Mostra el codi QR una altra vegada
209
283
 
210
- Servidor (VPS): nio --url https://la-teva-adreça --no-open
284
+ nio ssh usuari@servidor Fes servir nio en un servidor (VPS) per SSH, sense obrir cap port.
285
+ --port <n> Port de nio al servidor (7717)
286
+ --local-port <n> Port en aquest ordinador (7718)
287
+ --dir <ruta> Carpeta del servidor on s'engega (per defecte, la teva carpeta d'inici)
288
+
289
+ Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
211
290
  Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
212
291
 
213
292
  nio doctor Llista els agents instal·lats
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
package/src/server.js CHANGED
@@ -108,9 +108,32 @@ function listProjects(cfg) {
108
108
  return out;
109
109
  }
110
110
 
111
- function insideRoots(cfg, p) {
112
- const r = path.resolve(p);
113
- return cfg.roots.some((root) => r === root || r.startsWith(root + path.sep));
111
+ // Qualsevol carpeta existent d'aquest ordinador. Qui té la clau ja pot engegar agents, així que no la limitem a les compartides.
112
+ function isDir(p) {
113
+ try {
114
+ return fs.statSync(path.resolve(p)).isDirectory();
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ // Subcarpetes d'una carpeta, per a l'explorador de «Nova sessió».
121
+ function listDir(cfg, raw) {
122
+ const dir = path.resolve(raw || cfg.roots[0] || os.homedir());
123
+ if (!isDir(dir)) throw httpErr(400, 'Aquesta carpeta no existeix');
124
+ let entries = [];
125
+ try {
126
+ entries = fs.readdirSync(dir, { withFileTypes: true });
127
+ } catch {
128
+ throw httpErr(403, 'No tinc permís per veure aquesta carpeta');
129
+ }
130
+ const dirs = entries
131
+ .filter((e) => (e.isDirectory() || (e.isSymbolicLink() && isDir(path.join(dir, e.name)))) && e.name !== 'node_modules')
132
+ .map((e) => ({ name: e.name, path: path.join(dir, e.name), hidden: e.name.startsWith('.') }))
133
+ .sort((a, b) => a.hidden - b.hidden || a.name.localeCompare(b.name, 'ca'))
134
+ .slice(0, 500);
135
+ const parent = path.dirname(dir);
136
+ return { path: dir, parent: parent === dir ? null : parent, home: os.homedir(), git: fs.existsSync(path.join(dir, '.git')), dirs };
114
137
  }
115
138
 
116
139
  // Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
@@ -149,6 +172,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
149
172
  };
150
173
  }
151
174
  if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
175
+ if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
152
176
  if (p === '/api/ollama/models' && method === 'GET') return (await ollama.listModels()) || [];
153
177
  if (p === '/api/ollama' && method === 'GET') {
154
178
  const models = await ollama.listModels();
@@ -222,8 +246,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
222
246
  if (p === '/api/sessions' && method === 'POST') {
223
247
  const b = await readBody(req);
224
248
  const cwd = path.resolve(b.cwd || cfg.roots[0]);
225
- if (!insideRoots(cfg, cwd)) throw httpErr(403, 'Aquesta carpeta no és de les que has compartit en engegar nio');
226
- if (!fs.existsSync(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
249
+ if (!isDir(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
227
250
  const s = hub.create({ agent: b.agent, cwd, title: b.title, mode: b.mode, model: b.model });
228
251
  if (b.text) hub.send(s.id, b.text, hookUrl());
229
252
  return hub.summary(s);
@@ -245,7 +268,6 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
245
268
  if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
246
269
  const s = hub.get(r[1]);
247
270
  if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
248
- if (!insideRoots(cfg, s.cwd)) throw httpErr(403, 'Aquesta carpeta no és de les compartides');
249
271
  if (!r[2] && method === 'GET') return github.gitInfo(s.cwd);
250
272
  if (r[2] === 'commit' && method === 'POST') return github.commit(s.cwd, await readBody(req));
251
273
  if (r[2] === 'pr' && method === 'POST') return github.pr(s.cwd, await readBody(req));
package/web/app/app.js CHANGED
@@ -36,7 +36,6 @@ const MODES = {
36
36
  preguntar: { label: 'Demana permís', short: 'Permís' },
37
37
  tot: { label: 'Via lliure', short: 'Lliure' }
38
38
  };
39
- const DEMO_URL = 'demo:navegador';
40
39
 
41
40
  /* ---------------------------------------------------------------- estat */
42
41
  const state = { route: { name: 'sessions' }, ollama: null, info: null, connected: false, lastError: null, sessions: new Map(), approvals: new Map(), events: new Map(), loaded: new Set() };
@@ -45,13 +44,12 @@ const LS = {
45
44
  set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* emmagatzematge bloquejat */ } }
46
45
  };
47
46
  const store = {
48
- get machines() { return LS.get('nio.machines', []); },
47
+ get machines() { return LS.get('nio.machines', []).filter((m) => m.url !== 'demo:navegador'); },
49
48
  set machines(v) { LS.set('nio.machines', v); },
50
49
  get active() { return Number(LS.get('nio.active', 0)) || 0; },
51
50
  set active(i) { LS.set('nio.active', i); }
52
51
  };
53
52
  const machine = () => store.machines[store.active] || store.machines[0] || null;
54
- const isDemo = () => machine()?.url === DEMO_URL;
55
53
 
56
54
  /* ---------------------------------------------------------------- utilitats */
57
55
  function toast(msg, ms = 2800) {
@@ -102,174 +100,23 @@ function prettyInput(input) {
102
100
  return JSON.stringify(input, null, 2);
103
101
  }
104
102
 
105
- /* ---------------------------------------------------------------- ordinador de prova (dins del navegador) */
106
- const demo = (() => {
107
- const listeners = new Set();
108
- const sessions = new Map(), approvals = new Map(), notices = [];
109
- const runs = new Map();
110
- const omodels = [{ name: 'qwen3:4b', size: 2.5e9, params: '4.0B', quant: 'Q4_K_M' }], opulls = new Map();
111
- const emit = (msg) => listeners.forEach((f) => f(JSON.parse(JSON.stringify(msg))));
112
- const err = (status, message) => Object.assign(new Error(message), { status });
113
- const summary = (s) => {
114
- const last = [...s.events].reverse().find((e) => e.kind === 'text' || e.kind === 'user');
115
- return { id: s.id, agent: s.agent, title: s.title, cwd: s.cwd, mode: s.mode, model: s.model, state: s.state, created: s.created, updated: s.updated, cost: 0, last: last ? last.text.slice(0, 160) : '', pending: [...approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length };
116
- };
117
- const touch = (s) => { s.updated = Date.now(); emit({ type: 'session', session: summary(s) }); };
118
- const push = (s, kind, data = {}) => {
119
- const lastEv = s.events[s.events.length - 1];
120
- if (lastEv && lastEv.open) delete lastEv.open;
121
- const ev = { seq: ++s.seq, t: Date.now(), kind, ...data }; s.events.push(ev);
122
- emit({ type: 'event', session: s.id, event: ev }); touch(s); return ev;
123
- };
124
- const chunk = (s, text) => {
125
- const last = s.events[s.events.length - 1];
126
- if (last && last.kind === 'text' && last.open) { last.text += text; emit({ type: 'event', session: s.id, event: last }); return; }
127
- const ev = push(s, 'text', { text }); ev.open = true;
128
- };
129
- const pub = (a) => { const { resolve, ...r } = a; return r; };
130
- function ask(s, tool, input) {
131
- const a = { id: rid(), session: s.id, agent: s.agent, title: s.title, tool, input, state: 'pending', created: Date.now() };
132
- const p = new Promise((res) => (a.resolve = res));
133
- approvals.set(a.id, a); s.state = 'waiting';
134
- push(s, 'approval', { approval: a.id, tool, input, state: 'pending' });
135
- emit({ type: 'approval', approval: pub(a) });
136
- notices.unshift({ id: rid(4), t: Date.now(), title: 'Demo et demana permís', body: `${tool}: ${input.command || ''}`, session: s.id });
137
- return p;
138
- }
139
- function decide(id, decision) {
140
- const a = approvals.get(id); if (!a || a.state !== 'pending') return null;
141
- a.state = decision === 'allow' ? 'allowed' : 'denied';
142
- const s = sessions.get(a.session);
143
- if (s) { const ev = s.events.find((e) => e.kind === 'approval' && e.approval === id); if (ev) { ev.state = a.state; emit({ type: 'event', session: s.id, event: ev }); } s.state = runs.has(s.id) ? 'running' : 'idle'; touch(s); }
144
- emit({ type: 'approval', approval: pub(a) }); a.resolve(a.state === 'allowed' ? 'allow' : 'deny');
145
- return pub(a);
146
- }
147
- async function run(s, prompt) {
148
- let stopped = false; runs.set(s.id, { stop: () => (stopped = true) });
149
- if (s.agent === 'ollama') {
150
- try {
151
- for (const w of `(Resposta simulada de ${s.model || 'qwen3:4b'}.) Amb Ollama de debò, aquí veuries la resposta del model que funciona al teu ordinador, paraula per paraula.`.split(' ')) { if (stopped) throw 0; chunk(s, w + ' '); await sleep(45); }
152
- push(s, 'result', { ms: 1800 });
153
- } catch { push(s, 'info', { text: 'Has aturat l’agent' }); }
154
- runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 }); return;
155
- }
156
- try {
157
- for (const w of `Entesos. Faig una ullada al projecte per a «${prompt.slice(0, 80)}».`.split(' ')) { if (stopped) throw 0; chunk(s, w + ' '); await sleep(55); }
158
- push(s, 'tool', { id: 't1', name: 'Read', input: { file_path: 'README.md' } }); await sleep(450);
159
- push(s, 'tool_result', { id: 't1', name: 'Read', output: '# Projecte de prova\nAixò és un README d’exemple.' });
160
- if (stopped) throw 0;
161
- const input = { command: 'npm test' };
162
- if (s.mode === 'llegir') push(s, 'text', { text: 'Treballo en mode només lectura, així que no executo res. El següent pas seria `npm test`.' });
163
- else {
164
- const ok = s.mode === 'tot' ? true : (await ask(s, 'Bash', input)) === 'allow';
165
- if (stopped) throw 0;
166
- push(s, 'tool', { id: 't2', name: 'Bash', input }); await sleep(380);
167
- if (ok) { push(s, 'tool_result', { id: 't2', name: 'Bash', output: '✓ 12 tests passen' }); push(s, 'text', { text: 'Fet. Tots els tests passen.' }); }
168
- else { push(s, 'tool_result', { id: 't2', name: 'Bash', output: 'Rebutjat des de nio', error: true }); push(s, 'text', { text: 'D’acord, no l’executo. Què vols que faci?' }); }
169
- }
170
- push(s, 'result', { cost: 0, ms: 2400 });
171
- runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 });
172
- } catch {
173
- runs.delete(s.id); s.state = 'idle'; push(s, 'info', { text: 'Has aturat l’agent' }); push(s, 'status', { state: 'idle', code: 130 });
174
- }
175
- }
176
- const info = () => ({ name: 'nio', version: 'prova', host: 'Ordinador de prova', platform: 'navegador', user: 'tu', publicUrl: DEMO_URL, roots: ['~/projectes'], vapidKey: null,
177
- agents: [
178
- { id: 'demo', name: 'Demo', available: true, version: 'simulat', approvals: true, login: 'No cal instal·lar res' },
179
- { id: 'claude', name: 'Claude Code', available: false, approvals: true, login: 'Instal·la nio al teu ordinador' },
180
- { id: 'codex', name: 'Codex', available: false, login: 'Instal·la nio al teu ordinador' },
181
- { id: 'grok', name: 'Grok', available: false, beta: true, login: 'Instal·la nio al teu ordinador' },
182
- { id: 'antigravity', name: 'Antigravity', available: false, beta: true, login: 'Instal·la nio al teu ordinador' }
183
- ] });
184
- async function request(method, path, body) {
185
- await sleep(60);
186
- const url = new URL(path, 'http://x'), p = url.pathname; let m;
187
- if (p === '/api/info') return info();
188
- if (p === '/api/projects') return [{ path: '~/projectes', name: 'projectes', root: true }, { path: '~/projectes/botiga', name: 'botiga', git: true }, { path: '~/projectes/web', name: 'web', git: true }];
189
- if (p === '/api/ollama/models') return omodels;
190
- if (p === '/api/ollama/chat') {
191
- // Sense model de veritat: proposa un mapa amb les paraules clau de la petició.
192
- const ask = String(body.messages?.at(-1)?.content || '').split('Petició de l’usuari:').pop().trim();
193
- const words = [...new Set(ask.replace(/[^\p{L}\p{N}\s-]/gu, ' ').split(/\s+/).filter((w) => w.length > 3))].slice(0, 7);
194
- await sleep(700);
195
- return { content: JSON.stringify({ reply: 'Model simulat: amb Ollama de debò entendria la petició sencera. T’he fet un esquema amb les paraules clau.', actions: words.length > 1 ? [{ op: 'mindmap', center: words[0], items: words.slice(1) }] : [] }) };
196
- }
197
- if (p === '/api/github') return { available: false, authed: false };
198
- if (/^\/api\/sessions\/[\w-]+\/git$/.test(p)) return { repo: false };
199
- if (p === '/api/pair') return { link: null };
200
- if (p === '/api/ollama') return { available: false, host: 'simulat', models: [], running: [], pulling: [] };
201
- if (p === '/api/ollama/pull' && method === 'POST') {
202
- const name = String(body.name || '').trim(); if (!/^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/.test(name)) throw err(400, 'Aquest nom de model no és vàlid');
203
- if (opulls.has(name) || omodels.some((m) => m.name === name)) return { ok: true, already: true };
204
- const total = 1.2e9 + Math.random() * 3e9, job = { name, status: 'pulling manifest', completed: 0, total };
205
- job.t = setInterval(() => {
206
- job.completed = Math.min(total, job.completed + total / 18); job.status = job.completed < total ? 'pulling' : 'success';
207
- emit({ type: 'ollama', name, status: job.status, completed: job.completed, total });
208
- if (job.completed >= total) { clearInterval(job.t); opulls.delete(name); omodels.push({ name, size: total, params: '', quant: 'Q4_K_M' }); emit({ type: 'ollama', name, done: true }); }
209
- }, 260);
210
- opulls.set(name, job); return { ok: true };
211
- }
212
- if ((m = p.match(/^\/api\/ollama\/pull\/(.+)$/)) && method === 'DELETE') { const j = opulls.get(decodeURIComponent(m[1])); if (j) { clearInterval(j.t); opulls.delete(j.name); emit({ type: 'ollama', name: j.name, done: true, error: 'Descàrrega cancel·lada' }); } return { ok: !!j }; }
213
- if ((m = p.match(/^\/api\/ollama\/models\/(.+)$/)) && method === 'DELETE') { const n = decodeURIComponent(m[1]); const i = omodels.findIndex((x) => x.name === n); if (i >= 0) omodels.splice(i, 1); return { ok: true }; }
214
- if (p === '/api/sessions' && method === 'GET') return [...sessions.values()].sort((a, b) => b.updated - a.updated).map(summary);
215
- if (p === '/api/sessions' && method === 'POST') {
216
- if (body.agent !== 'demo' && body.agent !== 'ollama') throw err(400, 'A l’ordinador de prova només hi ha els agents Demo i Ollama');
217
- const s = { id: rid(), agent: body.agent, cwd: body.cwd || '~/projectes', title: (body.text || '').replace(/\s+/g, ' ').slice(0, 60), mode: body.mode || 'preguntar', model: body.model || '', state: 'idle', created: Date.now(), updated: Date.now(), seq: 0, events: [] };
218
- sessions.set(s.id, s); touch(s);
219
- if (body.text) send(s.id, body.text);
220
- return summary(s);
221
- }
222
- if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) {
223
- const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix');
224
- if (method === 'GET') return { session: summary(s), events: s.events.filter((e) => e.seq > Number(url.searchParams.get('since') || 0)) };
225
- if (method === 'PATCH') { if (body.mode) s.mode = body.mode; if (typeof body.title === 'string') s.title = body.title.slice(0, 120); touch(s); return summary(s); }
226
- if (method === 'DELETE') { runs.get(s.id)?.stop(); sessions.delete(s.id); emit({ type: 'removed', session: s.id }); return { ok: true }; }
227
- }
228
- if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/message$/))) return send(m[1], body.text);
229
- if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/stop$/))) { const r = runs.get(m[1]); r?.stop(); return { stopped: !!r }; }
230
- if (p === '/api/approvals') return [...approvals.values()].filter((a) => a.state === 'pending').map(pub);
231
- if ((m = p.match(/^\/api\/approvals\/([\w-]+)$/))) { const a = decide(m[1], body.decision); if (!a) throw err(404, 'Aquesta petició ja s’ha resolt'); return a; }
232
- if (p === '/api/notices') return notices.slice(0, 5);
233
- if (p.startsWith('/api/push')) throw err(400, 'Els avisos només funcionen amb un ordinador de veritat');
234
- throw err(404, 'Aquesta ruta no existeix');
235
- }
236
- function send(sid, text) {
237
- const s = sessions.get(sid); if (!s) throw err(404, 'Aquesta sessió no existeix');
238
- if (runs.has(sid)) throw err(409, 'L’agent encara està treballant. Espera que acabi o atura’l.');
239
- text = String(text || '').trim(); if (!text) throw err(400, 'El missatge és buit');
240
- if (!s.title) s.title = text.slice(0, 60);
241
- push(s, 'user', { text }); s.state = 'running'; push(s, 'status', { state: 'running' });
242
- run(s, text); return summary(s);
243
- }
244
- function subscribe(fn) {
245
- listeners.add(fn);
246
- fn({ type: 'hello', sessions: [...sessions.values()].map(summary), approvals: [...approvals.values()].filter((a) => a.state === 'pending').map(pub) });
247
- return () => listeners.delete(fn);
248
- }
249
- return { request, subscribe };
250
- })();
251
-
252
103
  /* ---------------------------------------------------------------- API */
253
104
  async function api(path, opts = {}) {
254
105
  const m = machine();
255
106
  if (!m) throw new Error('No hi ha cap ordinador connectat');
256
107
  const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
257
- if (m.url === DEMO_URL) return demo.request(opts.method || 'GET', path, body || {});
258
108
  const res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined });
259
109
  const data = await res.json().catch(() => ({}));
260
110
  if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
111
+ if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
261
112
  return data;
262
113
  }
263
114
 
264
- let streamCtrl = null, unsubDemo = null, retry = 0;
115
+ let streamCtrl = null, retry = 0;
265
116
  async function connect() {
266
- streamCtrl?.abort(); unsubDemo?.(); unsubDemo = null;
117
+ streamCtrl?.abort();
267
118
  const m = machine();
268
119
  if (!m) { state.connected = false; return render(); }
269
- if (m.url === DEMO_URL) {
270
- state.info = await api('/api/info'); state.connected = true; state.lastError = null;
271
- unsubDemo = demo.subscribe(handle); render(); brainsForBoard(); return;
272
- }
273
120
  const ctrl = new AbortController(); streamCtrl = ctrl;
274
121
  try {
275
122
  state.info = await api('/api/info');
@@ -346,12 +193,11 @@ function parsePair(text) {
346
193
  }
347
194
  function addMachine({ url, token, name }) {
348
195
  const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
349
- if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || (url === DEMO_URL ? 'Ordinador de prova' : new URL(url).hostname) }); i = ms.length - 1; }
196
+ if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
350
197
  store.machines = ms; store.active = i;
351
198
  state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
352
199
  connect();
353
200
  }
354
- function tryDemo() { addMachine({ url: DEMO_URL, token: 'demo', name: 'Ordinador de prova' }); toast('Connectat a l’ordinador de prova'); go('#/sessions'); }
355
201
  function checkPairHash() {
356
202
  const pair = parsePair(location.hash); if (!pair) return false;
357
203
  history.replaceState(null, '', location.pathname + '#/sessions');
@@ -368,6 +214,11 @@ const VIEWS = {
368
214
  mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
369
215
  pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
370
216
  };
217
+ const NOMACHINE = {
218
+ approvals: { title: 'Aprovacions', lead: 'Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.' },
219
+ models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
220
+ maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
221
+ };
371
222
  const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
372
223
  function parseRoute() {
373
224
  const h = location.hash.replace(/^#\/?/, '');
@@ -417,7 +268,7 @@ function render() {
417
268
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
418
269
  else {
419
270
  const html = r.name === 'mobil' ? viewLocal() : null;
420
- view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : !machine() && r.name !== 'maquina' ? viewConnect() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'models' ? viewModels() : viewMachine()}</div>`;
271
+ 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>`;
421
272
  if (r.name === 'mobil') bindLocal();
422
273
  if (r.name === 'models' && machine()) loadOllama();
423
274
  }
@@ -427,29 +278,46 @@ function render() {
427
278
  }
428
279
 
429
280
  /* ---------- Connecta */
430
- function viewConnect(compact = false) {
431
- const CMD = 'npx nio-cat';
432
- const main = `<div class="panel conn-main">
433
- <ol class="stepper">
434
- <li><span class="n">1</span><div><b>Engega nio</b><p>Al terminal, des de la carpeta dels teus projectes.</p></div></li>
435
- <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>
436
- <li><span class="n">3</span><div><b>Dona-li feina</b><p>Tria un agent i una carpeta, i explica què vols.</p></div></li>
437
- </ol>
438
- <div class="cmd big"><code>${CMD}</code><button class="btn sm" data-copy="${CMD}" type="button">${svg(ICON.copy)}Copia</button></div>
439
- <div class="reqs"><span class="chip">Node.js 18 o més nou</span><span class="chip ghost">cloudflared · opcional</span></div>
440
- <p class="muted small">Sense <span class="mono">cloudflared</span> et connectes per la wifi de casa. Amb ell, des de qualsevol lloc (a macOS: <span class="mono">brew install cloudflared</span>).</p>
441
- <details class="paste" ${compact ? 'open' : ''}><summary>Ja tens l’enllaç del terminal?</summary>
442
- <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://www.nio.cat/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
443
- <p class="muted small">El trobaràs sota el codi QR. Va bé si fas servir nio des de la pantalla d’inici.</p>
444
- </details>
445
- </div>`;
446
- if (compact) return main;
281
+ // La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
282
+ const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
283
+ function viewNoMachine(what) {
284
+ return `<div class="top"><div><h1>${esc(what.title)}</h1><p>${esc(what.lead)}</p></div></div>
285
+ <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>`;
286
+ }
287
+ function pasteForm(open) {
288
+ return `<details class="paste" ${open ? 'open' : ''}><summary>Ja tens l’enllaç de connexió?</summary>
289
+ <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://www.nio.cat/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
290
+ <p class="muted small">Surt al terminal, sota el codi QR.</p></details>`;
291
+ }
292
+ function viewConnect() {
293
+ 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>`;
294
+ if (isPhone()) {
295
+ return `<section class="connect">
296
+ <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>
297
+ <div class="panel conn-main">
298
+ <ol class="stepper">
299
+ <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>
300
+ <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>
301
+ </ol>
302
+ ${cmd(CMD)}
303
+ <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>
304
+ ${pasteForm(false)}
305
+ </div>
306
+ </section>`;
307
+ }
447
308
  return `<section class="connect">
448
- <div class="connect-head"><h1>Connecta el teu ordinador</h1><p class="lead">Els agents treballen al teu 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>
449
- <div class="connect-grid">
450
- ${main}
451
- <aside class="panel try">${bot('happy')}<h2>Prova-ho sense instal·lar res</h2><p class="muted small">Un ordinador simulat dins del navegador, amb un agent de demostració que llegeix, et demana permís i executa.</p><button class="btn ink block" data-act="demo" type="button">Obre l’ordinador simulat</button><a class="btn ghost block" href="#/mobil">O fes servir el xat local</a></aside>
309
+ <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>
310
+ <div class="choice-grid conn-main">
311
+ <section class="panel choice"><header><h2>Aquest ordinador</h2></header><div class="body stack">
312
+ <p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
313
+ <p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
314
+ <section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
315
+ <p class="small">Engega nio a l’altre ordinador o a un servidor. Copia l’enllaç que mostra i enganxa’l aquí.</p>
316
+ ${pasteForm(true)}
317
+ <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')}
318
+ <p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
452
319
  </div>
320
+ <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>
453
321
  </section>`;
454
322
  }
455
323
 
@@ -648,8 +516,8 @@ function renderComposer() {
648
516
  }
649
517
 
650
518
  /* ---------- Fulls */
651
- function openSheet(html, onMount) {
652
- const d = $('#sheet'); $('#sheetIn').innerHTML = `<div class="grab"></div>${html}`;
519
+ function openSheet(html, onMount, cls) {
520
+ const d = $('#sheet'); d.classList.toggle('wiz', cls === 'wiz'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls === 'wiz' ? '' : `<div class="grab"></div>${html}`;
653
521
  if (!d.open) d.showModal(); if (T.on) $('#tour').hidden = true;
654
522
  onMount?.($('#sheetIn'));
655
523
  }
@@ -664,85 +532,122 @@ function modeHelp(agentId, mode) {
664
532
  if (agentId === 'codex') return 'Pot editar dins la carpeta, sense accés a internet. Codex no pot fer preguntes en aquest mode.';
665
533
  return 'Aquest agent encara no pot demanar permís: les accions que el necessitarien es rebutgen.';
666
534
  }
535
+ // Assistent de 4 passos: agent, carpeta exacta, opcions i tasca. Capçalera i peu fixos; només el cos fa scroll.
667
536
  async function newSessionSheet(preset = {}) {
668
537
  if (!machine()) return go('#/sessions');
669
538
  if (!state.connected) return toast('Encara no hi ha connexió amb l’ordinador');
670
539
  const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
671
540
  let projects = [];
672
541
  try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
673
- const first = (isDemo() && agents.find((a) => a.id === 'demo')) || agents.find((a) => a.available && a.id !== 'demo') || agents.find((a) => a.available);
674
- const sel = { agent: first?.id || 'demo', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path, mode: 'preguntar', model: '', ...preset };
675
- openSheet(`<h2>Nova sessió</h2>
676
- <div class="stack"><div class="section-title" style="margin:0">Agent</div><div class="picker" id="agentPick"></div></div>
677
- <div class="stack"><div class="row between"><div class="section-title" style="margin:0">Carpeta</div><button class="chip" type="button" id="ghOpen">Clona de GitHub</button></div><div class="proj-list" id="projPick"></div><div class="stack" id="ghWrap" hidden></div></div>
678
- <div class="stack" id="modeWrap"><div class="section-title" style="margin:0">Permisos</div><div class="seg" id="modePick" role="group">${Object.entries(MODES).map(([k, m]) => `<button type="button" data-mode="${k}">${m.label}</button>`).join('')}</div><p class="muted small" id="modeHelp"></p></div>
679
- <div id="modelWrap"></div>
680
- <label class="f"><span>Què ha de fer?</span><textarea class="field" id="firstMsg" rows="3" placeholder="Per exemple: troba per què fallen els tests i arregla-ho"></textarea></label>
681
- <div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="startBtn" type="button">Comença</button></div>`, (sh) => {
682
- const paint = () => {
683
- $('#agentPick', sh).innerHTML = 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('');
684
- $('#projPick', sh).innerHTML = projects.map((p) => `<button type="button" class="proj" data-path="${esc(p.path)}" aria-pressed="${p.path === sel.cwd}"><span class="ic">${p.git ? 'git' : '·'}</span><span class="grow"><div>${esc(p.name)}</div><div class="p">${esc(p.path)}</div></span></button>`).join('') || '<p class="muted small">No hi ha carpetes compartides.</p>';
685
- const ag = agents.find((a) => a.id === sel.agent);
686
- $('#modeWrap', sh).hidden = !!ag?.noModes;
687
- $$('#modePick button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.mode === sel.mode));
688
- $('#modeHelp', sh).textContent = modeHelp(sel.agent, sel.mode);
542
+ const first = agents.find((a) => a.available);
543
+ const sel = { agent: first?.id || '', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: 'preguntar', model: '', text: '', ...preset };
544
+ const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
545
+ let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
546
+ const agentOf = () => agents.find((a) => a.id === sel.agent);
547
+
548
+ openSheet('', (sh) => {
549
+ const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
550
+ const head = () => {
551
+ const last = step === STEPS.length - 1;
552
+ $('#wizHead', sh).innerHTML = `<div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Nova sessió</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div>
553
+ <div class="wiz-dots">${STEPS.map((n, i) => `<button type="button" class="wiz-dot ${i === step ? 'on' : i < step ? 'done' : ''}" data-step="${i}" ${i < step ? '' : 'disabled'} aria-label="${i + 1}. ${n}"></button>`).join('')}</div>
554
+ <p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
555
+ $('#wizFoot', sh).innerHTML = `<button class="btn ghost" type="button" ${step ? 'data-back' : 'data-close'}>${step ? 'Enrere' : 'Cancel·la'}</button><button class="btn ink" type="button" ${last ? 'data-start' : 'data-next'}>${last ? 'Comença' : 'Continua'}</button>`;
689
556
  };
690
- const paintModel = async () => {
691
- const w = $('#modelWrap', sh);
692
- if (sel.agent === 'ollama') {
693
- const models = await api('/api/ollama/models').catch(() => []);
694
- if (!sel.model) sel.model = models[0]?.name || '';
695
- w.innerHTML = `<label class="f"><span>Model</span><select id="modelSel">${models.map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || '<option value="">Cap model (ollama pull qwen3:4b)</option>'}</select></label>`;
696
- $('#modelSel', sh).onchange = (e) => (sel.model = e.target.value);
697
- } else if (sel.agent === 'demo') w.innerHTML = '';
698
- else {
699
- w.innerHTML = '<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>';
700
- $('#modelIn', sh).value = sel.model; $('#modelIn', sh).oninput = (e) => (sel.model = e.target.value.trim());
701
- }
557
+ const draw = () => {
558
+ if (!$('#wizBody', sh)) sh.innerHTML = '<div class="wiz-head" id="wizHead"></div><div class="wiz-body" id="wizBody"></div><div class="wiz-foot" id="wizFoot"></div>';
559
+ head(); body();
560
+ $('#wizBody', sh).scrollTop = 0;
702
561
  };
703
- let ghRepos = null;
704
- const ghPaint = (q = '') => {
705
- const w = $('#ghList', sh); if (!w) return;
706
- const qq = q.trim().toLowerCase(), list = (ghRepos || []).filter((r) => !qq || r.repo.toLowerCase().includes(qq) || r.description.toLowerCase().includes(qq)).slice(0, 40);
707
- w.innerHTML = list.map((r) => `<button type="button" class="proj" data-repo="${esc(r.repo)}"><span class="ic">git</span><span class="grow"><div>${esc(r.repo)}${r.private ? ' <span class="chip beta">privat</span>' : ''}</div>${r.description ? `<div class="p">${esc(r.description)}</div>` : ''}</span></button>`).join('') || '<p class="muted small">No hi ha cap repositori amb aquest nom.</p>';
562
+ const after = () => {
563
+ if (step === 1 && !fsd) fsGo(sel.cwd);
564
+ if (step === 2 && sel.agent === 'ollama' && models == null) api('/api/ollama/models').catch(() => []).then((m) => { models = m; if (!sel.model) sel.model = m[0]?.name || ''; if (step === 2) body(); });
565
+ if (step === 3 && !isTouch) setTimeout(() => $('#firstMsg', sh)?.focus(), 60);
708
566
  };
709
- const ghOpen = async () => {
710
- const w = $('#ghWrap', sh); if (!w.hidden) { w.hidden = true; return; }
711
- w.hidden = false; w.innerHTML = '<p class="muted small">Connectant amb GitHub…</p>';
712
- let st; try { st = await api('/api/github'); } catch (e) { w.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
713
- if (!st.available) { w.innerHTML = '<p class="muted small">No trobo l’eina <span class="mono">gh</span> en aquest ordinador. Instal·la-la des de <a href="https://cli.github.com" target="_blank" rel="noopener">cli.github.com</a> i inicia sessió amb <span class="mono">gh auth login</span>.</p>'; return; }
714
- if (!st.authed) { w.innerHTML = '<p class="muted small">Falta iniciar sessió a GitHub. Al terminal de l’ordinador, executa <span class="mono">gh auth login</span>.</p>'; return; }
715
- w.innerHTML = `<input type="search" id="ghQ" placeholder="Cerca entre els teus repositoris${st.user ? ' (' + esc(st.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list" id="ghList"><p class="muted small">Carregant repositoris…</p></div>`;
716
- $('#ghQ', sh).oninput = (e) => ghPaint(e.target.value);
717
- try { ghRepos = await api('/api/github/repos'); ghPaint(); } catch (e) { $('#ghList', sh).innerHTML = `<p class="muted small">${esc(e.message)}</p>`; }
567
+
568
+ /* --- carpeta: la carpeta oberta és la triada */
569
+ const fsGo = async (dir) => {
570
+ try { fsd = await api(`/api/fs?path=${encodeURIComponent(dir || '')}`); sel.cwd = fsd.path; gh = null; }
571
+ catch (e) { toast(e.message, 4200); if (!fsd) fsd = { path: sel.cwd, parent: null, home: '', dirs: [] }; }
572
+ if (step === 1) body();
573
+ };
574
+ const ghList = () => {
575
+ const q = (gh.q || '').trim().toLowerCase(), list = (gh.repos || []).filter((r) => !q || r.repo.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)).slice(0, 40);
576
+ return list.map((r) => `<button type="button" class="proj" data-repo="${esc(r.repo)}"><span class="ic">git</span><span class="grow"><div>${esc(r.repo)}${r.private ? ' <span class="chip beta">privat</span>' : ''}</div>${r.description ? `<div class="p">${esc(r.description)}</div>` : ''}</span></button>`).join('') || '<p class="muted small">No hi ha cap repositori amb aquest nom.</p>';
577
+ };
578
+ const ghStart = async () => {
579
+ gh = { msg: 'Connectant amb GitHub…', repos: null, q: '' }; body();
580
+ let st; try { st = await api('/api/github'); } catch (e) { gh.msg = esc(e.message); return body(); }
581
+ if (!st.available) gh.msg = 'No trobo l’eina <span class="mono">gh</span> en aquest ordinador. Instal·la-la des de <a href="https://cli.github.com" target="_blank" rel="noopener">cli.github.com</a> i inicia sessió amb <span class="mono">gh auth login</span>.';
582
+ else if (!st.authed) gh.msg = 'Falta iniciar sessió a GitHub. Al terminal de l’ordinador, executa <span class="mono">gh auth login</span>.';
583
+ else { gh.user = st.user; gh.msg = 'Carregant repositoris…'; body(); try { gh.repos = await api('/api/github/repos'); gh.msg = ''; } catch (e) { gh.msg = esc(e.message); } }
584
+ if (gh && step === 1) body();
718
585
  };
719
- $('#ghOpen', sh).onclick = ghOpen;
720
586
  const ghClone = async (repo, btn) => {
721
587
  btn.disabled = true; btn.querySelector('.p, div')?.insertAdjacentHTML('beforeend', ' <span class="muted">· clonant…</span>');
722
- try {
723
- const r = await api('/api/github/clone', { method: 'POST', body: { repo } });
724
- if (!projects.some((p) => p.path === r.path)) projects.unshift({ path: r.path, name: r.path.split(/[\\/]/).pop(), root: false, git: true });
725
- sel.cwd = r.path; $('#ghWrap', sh).hidden = true; paint(); toast(r.existed ? 'Ja el tenies. L’he seleccionat.' : 'Repositori clonat');
726
- } catch (e) { btn.disabled = false; toast(e.message, 6000); }
588
+ try { const r = await api('/api/github/clone', { method: 'POST', body: { repo } }); toast(r.existed ? 'Ja el tenies. L’he seleccionat.' : 'Repositori clonat'); await fsGo(r.path); }
589
+ catch (e) { btn.disabled = false; toast(e.message, 6000); }
590
+ };
591
+
592
+ const VIEWS_ = [
593
+ () => `<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>`,
594
+ () => {
595
+ const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
596
+ const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
597
+ return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
598
+ <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>
599
+ <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>
600
+ <div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
601
+ ${gh ? (gh.msg ? `<p class="muted small">${gh.msg}</p>` : '') + (gh.repos ? `<input type="search" id="ghQ" value="${esc(gh.q)}" placeholder="Cerca entre els teus repositoris${gh.user ? ' (' + esc(gh.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list fs-list" id="ghList">${ghList()}</div>` : '')
602
+ : !fsd ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(fsd.parent || '')}" ${fsd.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(fsd.home)}">Inici</button><span class="fs-path mono small" title="${esc(fsd.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
603
+ <div class="proj-list fs-list">${fsd.dirs.map((d) => `<button type="button" class="proj" data-fs="${esc(d.path)}"><span class="ic">›</span><span class="grow"><div ${d.hidden ? 'class="muted"' : ''}>${esc(d.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`}`;
604
+ },
605
+ () => {
606
+ const ag = agentOf();
607
+ const modelHtml = sel.agent === 'ollama'
608
+ ? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
609
+ : `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
610
+ return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
611
+ },
612
+ () => `<p class="wiz-q">Què ha de fer?</p>
613
+ <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
614
+ <label class="f"><span>Explica-li la tasca</span><textarea class="field" id="firstMsg" rows="5" placeholder="Per exemple: troba per què fallen els tests i arregla-ho">${esc(sel.text)}</textarea></label>`
615
+ ];
616
+
617
+ const start = async () => {
618
+ if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
619
+ const btn = $('[data-start]', sh); btn.disabled = true;
620
+ 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}`); }
621
+ catch (e) { toast(e.message, 4200); btn.disabled = false; }
727
622
  };
728
623
  sh.onclick = (e) => {
729
- const rp = e.target.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
730
- if (e.target.closest('[data-close]')) return closeSheet();
731
- const ag = e.target.closest('[data-agent-id]');
732
- if (ag) { const a = agents.find((x) => x.id === ag.dataset.agentId); if (!a.available) return toast(`Primer instal·la’l a l’ordinador: ${a.login}`, 4200); sel.agent = a.id; sel.model = ''; paint(); paintModel(); }
733
- const pj = e.target.closest('[data-path]'); if (pj) { sel.cwd = pj.dataset.path; paint(); }
734
- const mo = e.target.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; paint(); }
624
+ const t = e.target;
625
+ if (t.closest('[data-close]')) return closeSheet();
626
+ if (t.closest('[data-back]')) { step = Math.max(0, step - 1); return draw(); }
627
+ if (t.closest('[data-next]')) {
628
+ if (step === 0 && !sel.agent) return toast('Tria un agent');
629
+ if (step === 1 && !sel.cwd) return toast('Tria una carpeta');
630
+ step++; return draw();
631
+ }
632
+ if (t.closest('[data-start]')) return start();
633
+ const st = t.closest('[data-step]'); if (st && !st.disabled) { step = +st.dataset.step; return draw(); }
634
+ const ag = t.closest('[data-agent-id]');
635
+ if (ag) { const a = agents.find((x) => x.id === ag.dataset.agentId); if (!a.available) return toast(`Primer instal·la’l a l’ordinador: ${a.login}`, 4200); if (a.id !== sel.agent) { sel.agent = a.id; sel.model = ''; models = null; } step = 1; return draw(); }
636
+ const fp = t.closest('[data-fs]'); if (fp && !fp.disabled) return fsGo(fp.dataset.fs);
637
+ if (t.closest('[data-gh]')) { if (gh) { gh = null; return body(); } return ghStart(); }
638
+ const rp = t.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
639
+ const mo = t.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; return body(); }
735
640
  };
736
- paint(); paintModel();
737
- $('#startBtn', sh).onclick = async () => {
738
- const text = $('#firstMsg', sh).value.trim();
739
- if (!text) { $('#firstMsg', sh).focus(); return toast('Explica a l’agent què ha de fer'); }
740
- $('#startBtn', sh).disabled = true;
741
- try { const s = await api('/api/sessions', { method: 'POST', body: { ...sel, text } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
742
- catch (e) { toast(e.message, 4200); $('#startBtn', sh).disabled = false; }
641
+ sh.onsubmit = (e) => { e.preventDefault(); if (e.target.id === 'fsForm') fsGo($('#fsIn', sh).value.trim()); };
642
+ sh.oninput = (e) => {
643
+ const id = e.target.id;
644
+ if (id === 'firstMsg') sel.text = e.target.value;
645
+ else if (id === 'modelIn') sel.model = e.target.value.trim();
646
+ else if (id === 'ghQ' && gh) { gh.q = e.target.value; $('#ghList', sh).innerHTML = ghList(); }
743
647
  };
744
- if (!isTouch) setTimeout(() => $('#firstMsg', sh)?.focus(), 80);
745
- });
648
+ sh.onchange = (e) => { if (e.target.id === 'modelSel') sel.model = e.target.value; };
649
+ draw();
650
+ }, 'wiz');
746
651
  }
747
652
  function modeSheet(s) {
748
653
  openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
@@ -753,7 +658,6 @@ function modeSheet(s) {
753
658
  });
754
659
  }
755
660
  /* ---------- Ordinadors i mòbil */
756
- const isRealMachine = () => { const m = machine(); return !!m && m.url !== DEMO_URL; };
757
661
  const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
758
662
  function mobileHint() {
759
663
  if (!isLocalMachine() || !state.connected || LS.get('nio.mobHint.off', false)) return '';
@@ -761,7 +665,7 @@ function mobileHint() {
761
665
  }
762
666
  function machinesSheet() {
763
667
  const ms = store.machines;
764
- 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 === DEMO_URL ? 'Dins del navegador' : x.url)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
668
+ 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('')}
765
669
  <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; });
766
670
  }
767
671
  function addMachineSheet() {
@@ -769,7 +673,8 @@ function addMachineSheet() {
769
673
  openSheet(`<h2>Connecta un altre ordinador</h2>
770
674
  <div class="stack-lg">
771
675
  <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>
772
- <div class="stack"><b>Un servidor (VPS)</b><p class="muted small">Al servidor, amb l’adreça pública 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> perquè no s’aturi. Copia l’enllaç que mostra i enganxa’l aquí.</p></div>
676
+ <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>
677
+ <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 l’enllaç que mostra i enganxa’l aquí.</p></div>
773
678
  <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://…/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
774
679
  </div>`);
775
680
  }
@@ -826,7 +731,7 @@ function sessionOptions(id) {
826
731
  openSheet(`<h2>${esc(s.title || 'Sessió')}</h2>
827
732
  <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[s.agent] || s.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(s.cwd)}</dd><dt>Permisos</dt><dd>${esc(MODES[s.mode]?.label || '')}</dd><dt>Creada</dt><dd>${new Date(s.created).toLocaleString('ca')}</dd></dl>
828
733
  <label class="f"><span>Nom de la sessió</span><input type="text" id="renameIn" value="${esc(s.title)}" maxlength="120"></label>
829
- ${isRealMachine() ? '<button class="btn" data-act="git" type="button">Git i GitHub…</button>' : ''}
734
+ <button class="btn" data-act="git" type="button">Git i GitHub…</button>
830
735
  <div class="row between"><button class="btn danger" id="delBtn" type="button">Esborra la sessió</button><button class="btn ink" id="renameBtn" type="button">Desa</button></div>`, (sh) => {
831
736
  $('#renameBtn', sh).onclick = async () => { const t = $('#renameIn', sh).value.trim(); const sum = await api(`/api/sessions/${id}`, { method: 'PATCH', body: { title: t } }).catch((e) => toast(e.message)); if (sum) state.sessions.set(id, sum); closeSheet(); renderSessionHead(); };
832
737
  $('#delBtn', sh).onclick = async () => { if (!confirm('S’esborrarà la conversa d’aquesta sessió. Els fitxers del projecte no es toquen.')) return; await api(`/api/sessions/${id}`, { method: 'DELETE' }).catch((e) => toast(e.message)); closeSheet(); go('#/sessions'); };
@@ -836,8 +741,7 @@ function sessionOptions(id) {
836
741
  /* ---------- Ordinador */
837
742
  function viewMachine() {
838
743
  const m = machine(), info = state.info, ms = store.machines;
839
- if (!m) return viewConnect();
840
- 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><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button></div>
744
+ 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>
841
745
  <div class="grid2">
842
746
  <div class="stack">
843
747
  <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>
@@ -845,9 +749,9 @@ function viewMachine() {
845
749
  </div>
846
750
  <div class="stack">
847
751
  <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>
848
- ${m.url !== DEMO_URL ? '<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>' : ''}
849
- <section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url === DEMO_URL ? 'Dins del navegador' : 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>
850
- <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 === DEMO_URL ? 'Dins del navegador' : x.url)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
752
+ ${'<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>'}
753
+ <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>
754
+ <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('')}
851
755
  <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>
852
756
  </div>
853
757
  </div>`;
@@ -939,7 +843,7 @@ function onOllama(msg) {
939
843
  window.NioBrains = {
940
844
  list() {
941
845
  const out = [{ id: 'rules', label: 'Ordres ràpides' }];
942
- if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}${isDemo() ? ' (simulat)' : ''}` }));
846
+ if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
943
847
  if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
944
848
  return out;
945
849
  },
@@ -966,7 +870,6 @@ async function brainsForBoard() {
966
870
  function b64uToU8(s) { const b = atob((s + '='.repeat((4 - (s.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from(b, (c) => c.charCodeAt(0)); }
967
871
  async function enablePush() {
968
872
  try {
969
- if (isDemo()) throw new Error('Els avisos només funcionen amb un ordinador de veritat');
970
873
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error(isIOS ? 'Afegeix nio a la pantalla d’inici i obre’l des d’allà' : 'Aquest navegador no admet avisos');
971
874
  if ((await Notification.requestPermission()) !== 'granted') throw new Error('Cal que permetis les notificacions');
972
875
  const reg = await navigator.serviceWorker.ready;
@@ -976,7 +879,7 @@ async function enablePush() {
976
879
  } catch (e) { toast(e.message, 4200); }
977
880
  }
978
881
  async function syncServiceWorker() {
979
- const m = machine(); if (!m || m.url === DEMO_URL || !('serviceWorker' in navigator)) return;
882
+ const m = machine(); if (!m || !('serviceWorker' in navigator)) return;
980
883
  try { (await navigator.serviceWorker.ready).active?.postMessage({ type: 'nio-cfg', url: m.url, token: m.token }); } catch { /* sense service worker */ }
981
884
  }
982
885
 
@@ -1085,7 +988,6 @@ document.addEventListener('click', async (e) => {
1085
988
  case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
1086
989
  case 'ollama-del': return deleteModel(a.dataset.name);
1087
990
  case 'ollama-chat': try { state.info = await api('/api/info?refresh=1'); } catch { /* res */ } return newSessionSheet({ agent: 'ollama', model: a.dataset.name });
1088
- case 'demo': return tryDemo();
1089
991
  case 'retry': return connect();
1090
992
  case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
1091
993
  case 'session-more': return sessionOptions(state.route.id);
@@ -1100,7 +1002,7 @@ document.addEventListener('click', async (e) => {
1100
1002
  case 'forget': {
1101
1003
  if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
1102
1004
  const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
1103
- streamCtrl?.abort(); unsubDemo?.(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
1005
+ streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
1104
1006
  connect(); return render();
1105
1007
  }
1106
1008
  case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
@@ -1126,7 +1028,6 @@ function commands() {
1126
1028
  const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
1127
1029
  if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
1128
1030
  [...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}`) }));
1129
- if (!isDemo()) c.push({ label: 'Prova l’ordinador simulat', hint: 'Prova', run: tryDemo });
1130
1031
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
1131
1032
  return c;
1132
1033
  }
@@ -1202,8 +1103,7 @@ const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
1202
1103
  const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
1203
1104
  const TSTEPS = [
1204
1105
  { 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' },
1205
- { 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. Si encara no el tens a mà, pots provar-ho amb un ordinador simulat.') },
1206
- { route: 'sessions', target: () => (machine() ? null : '.try'), m: () => (machine() ? null : '.try [data-act=demo]'), skipIf: () => !!machine(), title: 'Prova-ho sense instal·lar res', text: 'L’ordinador simulat té un agent de demostració que llegeix, et demana permís i executa. Ideal per veure com funciona.' },
1106
+ { 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.') },
1207
1107
  { 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.' },
1208
1108
  { 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.' },
1209
1109
  { 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.' },
@@ -1305,7 +1205,7 @@ if ('serviceWorker' in navigator) {
1305
1205
  navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
1306
1206
  }
1307
1207
  addEventListener('hashchange', route);
1308
- document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected && !isDemo()) connect(); });
1208
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected) connect(); });
1309
1209
  setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
1310
1210
  const qs = new URLSearchParams(location.search).get('s');
1311
1211
  if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
package/web/app/style.css CHANGED
@@ -148,7 +148,7 @@ button.chip:hover { border-color: var(--fg); }
148
148
  .connect { display: grid; gap: 24px; min-width: 0; }
149
149
  .connect-head h1 { font-size: clamp(30px, 4vw, 40px); font-weight: 500; letter-spacing: -.05em; line-height: 1.08; }
150
150
  .connect-head .lead { color: var(--fg-muted); font-size: 16px; max-width: 58ch; margin-top: 10px; }
151
- .connect-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr); gap: 16px; align-items: start; }
151
+ .connect-grid { display: grid; grid-template-columns: minmax(0, 720px); gap: 16px; align-items: start; }
152
152
  .conn-main { padding: 22px; display: grid; gap: 16px; min-width: 0; }
153
153
  .stepper { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
154
154
  .stepper li { display: flex; gap: 10px; align-items: flex-start; }
@@ -379,3 +379,41 @@ body.board-mode .view-enter { animation: none; }
379
379
  .qr-box { width: min(260px, 100%); margin: 0 auto; padding: 14px; border-radius: 16px; background: #fffefd; border: 1px solid var(--border); }
380
380
  .qr-box svg { display: block; width: 100%; height: auto; }
381
381
  #ghWrap input[type="search"] { margin-bottom: 4px; }
382
+
383
+ /* Connexió: dues opcions a l'ordinador; explorador de carpetes a Nova sessió */
384
+ .choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
385
+ .choice .body { min-width: 0; }
386
+ @media (max-width: 820px) { .choice-grid { grid-template-columns: 1fr; } }
387
+ .fs-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
388
+ .fs-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
389
+ .fs-list { max-height: 240px; overflow: auto; }
390
+
391
+ /* Assistent «Nova sessió»: drawer fix al telèfon, capçalera i peu fixos, cos amb scroll */
392
+ body:has(dialog.sheet[open]) { overflow: hidden; }
393
+ dialog.sheet { overscroll-behavior: contain; }
394
+ dialog.sheet.wiz { height: min(680px, 88dvh); overflow: hidden; }
395
+ dialog.sheet.wiz[open] { display: flex; flex-direction: column; }
396
+ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; }
397
+ .wiz-head { flex: none; display: grid; gap: 8px; padding: 20px 24px 12px; }
398
+ .wiz-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 6px 24px 18px; display: grid; gap: 14px; align-content: start; }
399
+ .wiz-foot { flex: none; display: flex; justify-content: space-between; gap: 10px; padding: 12px 24px calc(16px + var(--safe-b)); border-top: 1px solid var(--border); background: var(--bg); }
400
+ .wiz-foot .btn { min-width: 110px; justify-content: center; }
401
+ .wiz-q { font-size: 18px; font-weight: 500; letter-spacing: -.03em; }
402
+ .wiz-dots { display: flex; gap: 6px; }
403
+ .wiz-dot { flex: 1; height: 18px; border: 0; background: var(--border) content-box; background-clip: content-box; padding: 7px 0; border-radius: 4px; }
404
+ .wiz-dot.on { background-color: var(--fg); }
405
+ .wiz-dot.done { background-color: var(--moss); }
406
+ .wiz-dot:disabled { cursor: default; }
407
+ .wiz .card[aria-pressed="true"] { border-color: var(--fg); box-shadow: inset 0 0 0 1px var(--fg); }
408
+ .fs-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 12px; border-radius: 10px; background: var(--bg-2); }
409
+ .fs-pick b { overflow-wrap: anywhere; }
410
+ .fs-list { max-height: none; }
411
+ @media (max-width: 820px) {
412
+ dialog.sheet.wiz { height: min(92dvh, calc(var(--vvh, 100dvh) - 8px)); }
413
+ .wiz-head { padding: 10px 18px 8px; }
414
+ .wiz-body { padding: 4px 18px 14px; }
415
+ .wiz-foot { padding: 12px 18px calc(14px + var(--safe-b)); }
416
+ .wiz-foot .btn { flex: 1; }
417
+ }
418
+ #fsForm { display: flex; gap: 8px; }
419
+ #fsForm input { flex: 1; min-width: 0; }
package/web/index.html CHANGED
@@ -116,11 +116,6 @@ main strong { font-weight: 600; }
116
116
  <h2 id="limits">Tu decideixes</h2>
117
117
  <p>Cada sessió té el seu nivell de confiança: <strong>només llegir</strong>, <strong>demana permís</strong> abans d’editar o executar, o <strong>via lliure</strong>. Quan l’agent et necessita, t’arriba un avís i ho resols amb un toc.</p>
118
118
  </section>
119
-
120
- <section aria-labelledby="prova">
121
- <h2 id="prova">Prova-ho</h2>
122
- <p>Sense instal·lar res, amb un ordinador simulat: <a href="/app">obre el panell</a>. També hi trobaràs els teus models d’Ollama i un model petit que funciona dins del mateix mòbil.</p>
123
- </section>
124
119
  </main>
125
120
 
126
121
  <footer class="footer">