nio-cat 0.6.1 → 0.8.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/README.md CHANGED
@@ -1,14 +1,47 @@
1
1
  # nio
2
2
 
3
- Fes servir els agents d’IA del teu ordinador (Claude Code, Codex, Grok, Antigravity i Ollama) des del mòbil. Segueix-los en directe i aprova cada acció.
3
+ Els teus agents (Claude Code i Codex) als teus ordinadors, controlats des del mòbil. Personal i gratuït: fas servir els teus comptes i les teves claus.
4
+
5
+ ## Instal·lació (una ordre)
4
6
 
5
7
  ```sh
6
- npx nio-cat
8
+ curl -fsSL https://www.nio.cat/install.sh | sh
7
9
  ```
8
10
 
9
- Executa-ho a la carpeta dels teus projectes i escaneja el codi QR que apareix amb la càmera del mòbil.
11
+ Instal·la nio i `cloudflared` al teu usuari, el deixa com a servei (systemd o launchd) i mostra un codi QR. Escaneja’l amb el mòbil a [nio.cat/app](https://www.nio.cat/app). Per provar-lo sense instal·lar res: `npx nio-cat`.
12
+
13
+ ## Què fa
14
+
15
+ - **Sessions** de Claude Code i Codex en directe, a qualsevol dels teus ordinadors.
16
+ - **Aprovacions** amb el motiu, al mòbil, al push o a Telegram, i **registre** de totes les accions (`~/.nio/audit.jsonl`).
17
+ - **Feines**: cua, cadenes («quan acabi, que Codex ho revisi») i programació (cron).
18
+ - **Memòria compartida** a `~/.nio/memory`, amb un servidor MCP (`nio mcp`) registrat a Claude Code i Codex.
19
+ - **Telegram** (el teu bot) i **veu** (ElevenLabs o OpenAI), amb les claus a l’ordinador.
20
+ - **Navegador i escriptori** amb agent-browser i agent-desktop: mirar passa sol, actuar sempre demana permís.
21
+
22
+ ## Arquitectura
23
+
24
+ ```
25
+ Mòbil (nio.cat/app) ──┐ Telegram (long polling, sortida)
26
+ ▼ │
27
+ nio.cat: l’app i l’adreça signada de cada ordinador (res més)
28
+ │
29
+ túnel de Cloudflare (ràpid, o amb nom: nio tunnel setup)
30
+ ▼
31
+ ┌──────────── nio, a cada ordinador ────────────┐
32
+ │ agents: Claude Code · Codex (· altres) │
33
+ │ aprovacions · registre · avisos (Web Push) │
34
+ │ feines: cua · cadenes · cron · regles │
35
+ │ memòria ~/.nio/memory + servidor MCP │
36
+ │ veu · Telegram · agent-browser/agent-desktop │
37
+ └───────────────────────────────────────────────┘
38
+ ```
39
+
40
+ - Cada mòbil s’aparella amb un codi d’un sol ús i en rep una clau pròpia, revocable (`nio devices`).
41
+ - Si el túnel canvia d’adreça, el mòbil la retroba a nio.cat, signada amb la clau Ed25519 de l’ordinador.
42
+
43
+ ## Ordres
10
44
 
11
- - Sense `cloudflared`, et connectes per la wifi de casa.
12
- - Amb `cloudflared` (a macOS: `brew install cloudflared`), des de qualsevol lloc.
45
+ `nio [carpetes…]` · `nio pair` · `nio devices` · `nio service install` · `nio tunnel setup <host>` · `nio memory` · `nio directory on|off` · `nio doctor` · `nio help`
13
46
 
14
- Opcions: `npx nio-cat help`. Més informació a [nio.cat](https://www.nio.cat).
47
+ Privadesa: [nio.cat/privadesa](https://www.nio.cat/privadesa) · Contacte: hola@nio.cat · nio // un projecte de [f.cat](https://f.cat)
package/bin/nio.js CHANGED
@@ -16,6 +16,9 @@ const { Audit } = require('../src/audit');
16
16
  const memory = require('../src/memory');
17
17
  const mcp = require('../src/mcp');
18
18
  const directory = require('../src/directory');
19
+ const voice = require('../src/voice');
20
+ const { Telegram } = require('../src/telegram');
21
+ const { Control } = require('../src/control');
19
22
  const { Jobs } = require('../src/jobs');
20
23
  const { addServer } = require('../src/ssh');
21
24
  const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
@@ -208,8 +211,13 @@ async function start() {
208
211
  const jobs = new Jobs({ cfg, hub, audit, available: () => require('../src/agents').AGENTS.filter((a) => a.id === 'demo' || require('../src/agents').binPath(cfg, a.id)).map((a) => a.id) });
209
212
  jobs.hookUrl = () => `http://127.0.0.1:${cfg.port}/internal/approval`;
210
213
  jobs.startTimer();
211
- const routes = [...memory.routes(), ...jobs.routes()];
212
- const server = createServer({ cfg, hub, auth, audit, routes, getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
214
+ const tg = new Telegram({ cfg, hub, audit, available: jobs.available });
215
+ tg.hookUrl = jobs.hookUrl;
216
+ tg.start();
217
+ if (cfg.telegram?.token) console.log(` ${c.dim('Telegram:')} actiu ${c.dim(`(${(cfg.telegram.allowed || []).length} xats autoritzats)`)}`);
218
+ const control = new Control({ cfg, hub, audit });
219
+ const routes = [...memory.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes()];
220
+ const server = createServer({ cfg, hub, auth, audit, routes, beforeApproval: (t, i) => control.beforeApproval(t, i), info: async () => ({ control: await control.caps().catch(() => null) }), getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
213
221
 
214
222
  server.on('error', (e) => {
215
223
  if (e.code === 'EADDRINUSE') console.error(c.warn(`\n El port ${cfg.port} ja el fa servir un altre programa. Tria\'n un altre: nio --port 7718\n`));
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.6.1",
3
+ "version": "0.8.1",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
- "license": "MIT",
5
+ "license": "UNLICENSED",
6
6
  "author": "f.cat",
7
7
  "homepage": "https://www.nio.cat",
8
8
  "bin": {
@@ -25,10 +25,18 @@ module.exports = {
25
25
  }
26
26
 
27
27
  const env = {};
28
+ const gate = (extra = {}) => {
29
+ // Hook PreToolUse: envia al mòbil el que cal aprovar.
30
+ args.push('--settings', JSON.stringify({ hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: `node "${HOOK}"`, timeout: 900 }] }] } }));
31
+ Object.assign(env, { NIO_HOOK_URL: ctx.hook.url, NIO_HOOK_KEY: ctx.hook.key, NIO_SESSION: session.id }, extra);
32
+ };
33
+ const control = cfg.control?.navegador || cfg.control?.escriptori;
28
34
  if (session.mode === 'llegir') {
29
35
  args.push('--permission-mode', 'plan');
30
36
  } else if (session.mode === 'tot') {
31
37
  args.push('--permission-mode', 'bypassPermissions');
38
+ // Amb el control del navegador o l'escriptori actiu, les ordres que hi actuen sempre es pregunten.
39
+ if (control) gate({ NIO_GATE: 'tot' });
32
40
  } else {
33
41
  // "preguntar": el hook envia cada acció que no sigui de lectura al mòbil.
34
42
  const settings = {
@@ -30,7 +30,13 @@ process.stdin.on('end', async () => {
30
30
  /* entrada buida */
31
31
  }
32
32
  const tool = input.tool_name || 'Eina';
33
+ // Navegador i escriptori (agent-browser, agent-desktop): mirar passa sol; actuar sempre demana permís,
34
+ // fins i tot en «Via lliure» (NIO_GATE=tot), on la resta d'eines passen soles.
35
+ let c = null;
36
+ try { c = tool === 'Bash' ? require('./control').classify(input.tool_input?.command) : null; } catch { c = null; }
37
+ if (process.env.NIO_GATE === 'tot' && !(c && c.act)) return reply('allow', 'nio: via lliure');
33
38
  if (READ_ONLY.has(tool)) return reply('allow', 'nio: només llegeix');
39
+ if (c && !c.act) return reply('allow', `nio: ${c.tool} només mira`);
34
40
 
35
41
  const url = process.env.NIO_HOOK_URL;
36
42
  if (!url) return reply('deny', 'nio no està configurat en aquesta sessió');
package/src/control.js ADDED
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+ // Control del navegador i de l'escriptori. nio no el construeix: Claude Code i Codex fan servir
3
+ // agent-browser (web) i agent-desktop (apps) per shell. nio s'encarrega de:
4
+ // · saber què hi ha instal·lat i activar-ho per ordinador (desactivat per defecte),
5
+ // · posar les guies oficials a l'abast dels agents (skill de Claude Code i AGENTS.md de Codex),
6
+ // · classificar les ordres: les de mirar passen soles; les que actuen demanen aprovació (amb captura),
7
+ // · fer captures sota demanda i l'aturada d'emergència.
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const crypto = require('crypto');
12
+ const { execFile } = require('child_process');
13
+ const { which, childEnv } = require('./proc');
14
+ const config = require('./config');
15
+ const { HOME } = config;
16
+
17
+ const SHOTS = path.join(HOME, 'shots');
18
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
19
+
20
+ // Subordres que només miren. Qualsevol altra cosa actua (clicar, escriure, omplir, pujar fitxers…).
21
+ const READ = {
22
+ 'agent-browser': new Set(['snapshot', 'screenshot', 'status', 'wait', 'open', 'goto', 'navigate', 'get', 'is', 'skills', 'help', '--help', 'version', '--version', 'tabs', 'title', 'url']),
23
+ 'agent-desktop': new Set(['snapshot', 'find', 'get', 'is', 'screenshot', 'list-surfaces', 'list-apps', 'list-windows', 'list-displays', 'list-notifications', 'status', 'version', 'skills', 'wait', 'permissions', 'help', '--help', '--version']),
24
+ };
25
+ const KIND = { 'agent-browser': 'navegador', 'agent-desktop': 'escriptori' };
26
+ const META = /[;&|`$<>(){}\n\r\\]/; // res de redireccions, subordres ni cadenes
27
+
28
+ // Classifica una ordre de shell. null si no és d'aquestes eines (o si és composta: llavors no ens en refiem).
29
+ function classify(command) {
30
+ const cmd = String(command || '').trim();
31
+ const m = cmd.match(/^(?:npx\s+(?:-y\s+)?)?(agent-browser|agent-desktop)(?:\s+(.*))?$/s);
32
+ if (!m) {
33
+ const k = cmd.match(/\b(agent-browser|agent-desktop)\b/);
34
+ return k ? { tool: k[1], kind: KIND[k[1]], act: true, composite: true } : null;
35
+ }
36
+ const tool = m[1];
37
+ const rest = (m[2] || '').trim();
38
+ if (META.test(rest)) return { tool, kind: KIND[tool], act: true, composite: true };
39
+ // Saltem opcions globals (--session x, --headed…) per trobar la subordre.
40
+ const words = rest.match(/"[^"]*"|'[^']*'|\S+/g) || [];
41
+ let i = 0;
42
+ while (i < words.length && words[i].startsWith('-') && !READ[tool].has(words[i])) i += /^--(session|session-name|profile|state|executable-path|app|window-id)$/.test(words[i]) ? 2 : 1;
43
+ const sub = words[i] || 'help';
44
+ let read = READ[tool].has(sub);
45
+ if (tool === 'agent-browser' && sub === 'stream') read = words[i + 1] === 'status';
46
+ return { tool, kind: KIND[tool], sub, act: !read };
47
+ }
48
+
49
+ function run(bin, args, timeout = 15000) {
50
+ return new Promise((resolve) => {
51
+ execFile(bin, args, { timeout, env: childEnv(), maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || err?.message || '') }));
52
+ });
53
+ }
54
+
55
+ class Control {
56
+ constructor({ cfg, hub, audit }) {
57
+ this.cfg = cfg;
58
+ this.hub = hub;
59
+ this.audit = audit || { add() {} };
60
+ this.capsAt = 0;
61
+ fs.mkdirSync(SHOTS, { recursive: true, mode: 0o700 });
62
+ }
63
+
64
+ enabled(kind) {
65
+ return !!this.cfg.control?.[kind];
66
+ }
67
+ any() {
68
+ return this.enabled('navegador') || this.enabled('escriptori');
69
+ }
70
+
71
+ async caps(force) {
72
+ if (!force && this.capsCache && Date.now() - this.capsAt < 60000) return this.capsCache;
73
+ const ab = which('agent-browser'), ad = which('agent-desktop');
74
+ let desk = { installed: !!ad, ready: false, why: ad ? '' : 'No hi és' };
75
+ if (ad) {
76
+ const r = await run(ad, ['status'], 8000);
77
+ let j = null;
78
+ try { j = JSON.parse(r.out); } catch { /* text */ }
79
+ desk.ready = j ? j.ok !== false : r.ok;
80
+ desk.why = desk.ready ? '' : (j?.error?.message || r.err || r.out).split('\n')[0].slice(0, 200);
81
+ }
82
+ const shotTool = process.platform === 'darwin' ? which('screencapture') : which('grim') || which('scrot') || which('gnome-screenshot');
83
+ this.capsCache = {
84
+ navegador: { installed: !!ab, ready: !!ab, enabled: this.enabled('navegador') },
85
+ escriptori: { ...desk, enabled: this.enabled('escriptori'), screenshot: !!shotTool },
86
+ platform: process.platform,
87
+ };
88
+ this.capsAt = Date.now();
89
+ return this.capsCache;
90
+ }
91
+
92
+ // ---------- guies per als agents
93
+ async installGuides(kind) {
94
+ const tool = kind === 'navegador' ? 'agent-browser' : 'agent-desktop';
95
+ const bin = which(tool);
96
+ if (!bin) throw httpErr(400, `${tool} no està instal·lat en aquest ordinador`);
97
+ const parts = [];
98
+ const tries = kind === 'navegador' ? [['skills', 'get', 'core'], ['skills', 'get', 'agent-browser']] : [['skills', 'get', 'desktop'], ['skills', 'get', 'platform']];
99
+ for (const args of tries) {
100
+ const r = await run(bin, args, 20000);
101
+ if (r.ok && r.out.trim()) {
102
+ parts.push(r.out.trim().replace(/^---[\s\S]*?\n---\s*/, ''));
103
+ if (kind === 'navegador') break;
104
+ }
105
+ }
106
+ if (!parts.length) parts.push(`Fes servir \`${tool}\`. Abans de començar, executa \`${tool} skills get ${kind === 'navegador' ? 'core' : 'desktop'}\` i segueix-ne les instruccions.`);
107
+ const rules = `## Normes de nio (obligatòries)
108
+
109
+ - Cada ordre de \`${tool}\` que actua (clicar, escriure, omplir, pujar fitxers, enviar formularis…) l'ha d'aprovar la persona des del mòbil. Les de mirar (snapshot, screenshot, status, wait, open) passen soles.
110
+ - Mai no facis compres, pagaments, transferències, canvis de contrasenya ni esborrats sense que la persona t'ho hagi demanat explícitament i ho aprovi.
111
+ - No escriguis contrasenyes ni dades de targetes. Si cal iniciar sessió, demana-ho.
112
+ - Executa \`${tool}\` sempre com a ordre sola (sense \`&&\`, \`|\`, \`;\` ni redireccions), una per pas, perquè nio la pugui revisar.`;
113
+ const title = kind === 'navegador' ? 'Navegador web (agent-browser)' : 'Apps de l’escriptori (agent-desktop)';
114
+ const body = `# ${title}\n\n${rules}\n\n${parts.join('\n\n')}\n`;
115
+ const name = `nio-${kind}`;
116
+ // Claude Code: skill d'usuari.
117
+ const sdir = path.join(os.homedir(), '.claude', 'skills', name);
118
+ fs.mkdirSync(sdir, { recursive: true });
119
+ fs.writeFileSync(path.join(sdir, 'SKILL.md'), `---\nname: ${name}\ndescription: Controla ${kind === 'navegador' ? 'el navegador web' : 'les apps de l’escriptori'} d'aquest ordinador amb ${tool}, amb les normes d'aprovació de nio. Fes-la servir quan calgui ${kind === 'navegador' ? 'obrir webs, llegir-les o actuar-hi' : 'mirar o fer servir apps de l’ordinador'}.\n---\n\n${body}`);
120
+ // Codex: bloc dins de ~/.codex/AGENTS.md entre marques (es reescriu, no es duplica).
121
+ const cdir = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
122
+ const af = path.join(cdir, 'AGENTS.md');
123
+ let txt = '';
124
+ try { txt = fs.readFileSync(af, 'utf8'); } catch { /* nou */ }
125
+ const start = `<!-- nio:${kind} -->`, end = `<!-- /nio:${kind} -->`;
126
+ const block = `${start}\n${body}${end}`;
127
+ const re = new RegExp(`${start}[\\s\\S]*?${end}`);
128
+ txt = re.test(txt) ? txt.replace(re, block) : `${txt}${txt && !txt.endsWith('\n') ? '\n' : ''}\n${block}\n`;
129
+ fs.mkdirSync(cdir, { recursive: true });
130
+ fs.writeFileSync(af, txt);
131
+ return { claude: path.join(sdir, 'SKILL.md'), codex: af };
132
+ }
133
+
134
+ removeGuides(kind) {
135
+ fs.rmSync(path.join(os.homedir(), '.claude', 'skills', `nio-${kind}`), { recursive: true, force: true });
136
+ const af = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'AGENTS.md');
137
+ try {
138
+ const txt = fs.readFileSync(af, 'utf8').replace(new RegExp(`\\n?<!-- nio:${kind} -->[\\s\\S]*?<!-- /nio:${kind} -->\\n?`), '\n');
139
+ fs.writeFileSync(af, txt);
140
+ } catch { /* no hi era */ }
141
+ }
142
+
143
+ // ---------- captures
144
+ shotPath(id) {
145
+ if (!/^[\w-]{8,40}$/.test(String(id))) throw httpErr(400, 'Captura no vàlida');
146
+ return path.join(SHOTS, `${id}.png`);
147
+ }
148
+ async screenshot(prefer) {
149
+ const id = `${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
150
+ const file = this.shotPath(id);
151
+ let ok = false, from = null;
152
+ const browser = which('agent-browser');
153
+ if ((prefer !== 'escriptori') && this.enabled('navegador') && browser) {
154
+ ok = (await run(browser, ['screenshot', file], 15000)).ok && fs.existsSync(file);
155
+ from = 'navegador';
156
+ }
157
+ if (!ok && this.enabled('escriptori')) {
158
+ const tool = process.platform === 'darwin' ? ['screencapture', ['-x', file]] : which('grim') ? ['grim', [file]] : which('scrot') ? ['scrot', ['-o', file]] : ['gnome-screenshot', ['-f', file]];
159
+ const bin = which(tool[0]);
160
+ if (bin) ok = (await run(bin, tool[1], 15000)).ok && fs.existsSync(file);
161
+ from = 'escriptori';
162
+ }
163
+ if (!ok) throw httpErr(409, this.any() ? 'No he pogut fer la captura (no hi ha navegador obert ni pantalla accessible).' : 'Activa primer el control del navegador o de l’escriptori en aquest ordinador.');
164
+ this.prune();
165
+ this.last = { id, t: Date.now(), from };
166
+ this.hub.emit('out', { type: 'screen', shot: this.last });
167
+ return this.last;
168
+ }
169
+ prune() {
170
+ const files = fs.readdirSync(SHOTS).filter((f) => f.endsWith('.png')).sort();
171
+ for (const f of files.slice(0, Math.max(0, files.length - 200))) fs.rmSync(path.join(SHOTS, f), { force: true });
172
+ }
173
+
174
+ // Abans d'una aprovació d'una ordre que actua: captura (si es pot) per veure-ho al mòbil.
175
+ async beforeApproval(tool, input) {
176
+ if (tool !== 'Bash' || !this.any()) return {};
177
+ const c = classify(input?.command);
178
+ if (!c || !c.act) return {};
179
+ const extra = { control: c.kind };
180
+ try {
181
+ const shot = await Promise.race([this.screenshot(c.kind), new Promise((_, rej) => setTimeout(() => rej(new Error('temps')), 9000))]);
182
+ extra.shot = shot.id;
183
+ } catch { /* sense captura */ }
184
+ return extra;
185
+ }
186
+
187
+ async panic(by) {
188
+ let stopped = 0;
189
+ for (const [sid] of this.hub.runs) if (this.hub.stop(sid)) stopped++;
190
+ for (const a of [...this.hub.approvals.values()]) if (a.state === 'pending') this.hub.decide(a.id, 'deny', 'Aturada d’emergència', by);
191
+ const ab = which('agent-browser');
192
+ if (ab) await run(ab, ['close', '--all'], 10000);
193
+ this.audit.add({ type: 'control', what: `Aturada d’emergència: ${stopped} agents aturats i navegador tancat`, by });
194
+ return { stopped };
195
+ }
196
+
197
+ routes() {
198
+ return [
199
+ { method: 'GET', path: '/api/control', handler: async ({ url }) => this.caps(url.searchParams.has('refresh')) },
200
+ {
201
+ method: 'PUT',
202
+ path: '/api/control',
203
+ handler: async ({ body, device }) => {
204
+ const caps = await this.caps(true);
205
+ this.cfg.control = this.cfg.control || {};
206
+ const notes = [];
207
+ for (const kind of ['navegador', 'escriptori']) {
208
+ if (typeof body[kind] !== 'boolean' || body[kind] === this.enabled(kind)) continue;
209
+ if (body[kind]) {
210
+ if (!caps[kind].installed) throw httpErr(400, kind === 'navegador' ? 'agent-browser no està instal·lat. A l’ordinador: npm i -g agent-browser && agent-browser install' : 'agent-desktop no està instal·lat. A l’ordinador: npm i -g agent-desktop');
211
+ await this.installGuides(kind).catch((e) => notes.push(e.message));
212
+ } else this.removeGuides(kind);
213
+ this.cfg.control[kind] = body[kind];
214
+ this.audit.add({ type: 'control', what: `${kind === 'navegador' ? 'Control del navegador' : 'Control de l’escriptori'} ${body[kind] ? 'activat' : 'desactivat'}`, by: device?.name });
215
+ }
216
+ config.save(this.cfg);
217
+ return { ...(await this.caps(true)), notes };
218
+ },
219
+ },
220
+ { method: 'POST', path: '/api/screen', handler: ({ body }) => this.screenshot(body?.from) },
221
+ { method: 'GET', path: '/api/screen', handler: () => ({ last: this.last || null, enabled: this.any() }) },
222
+ {
223
+ method: 'GET',
224
+ path: /^\/api\/screen\/shot\/([\w-]+)$/,
225
+ handler: ({ res, params }) => {
226
+ const f = this.shotPath(params[0]);
227
+ if (!fs.existsSync(f)) throw httpErr(404, 'Aquesta captura ja no hi és');
228
+ const buf = fs.readFileSync(f);
229
+ res.writeHead(200, { 'content-type': 'image/png', 'content-length': buf.length, 'cache-control': 'private, max-age=3600' });
230
+ res.end(buf);
231
+ },
232
+ },
233
+ { method: 'POST', path: '/api/panic', handler: ({ device }) => this.panic(device?.name) },
234
+ ];
235
+ }
236
+ }
237
+
238
+ module.exports = { Control, classify };
package/src/hub.js CHANGED
@@ -99,7 +99,10 @@ class Hub extends EventEmitter {
99
99
 
100
100
  push(s, kind, data = {}) {
101
101
  const last = s.events[s.events.length - 1];
102
- if (last && last.open && kind !== 'text-chunk') delete last.open;
102
+ if (last && last.open && kind !== 'text-chunk') {
103
+ delete last.open;
104
+ this.emit('out', { type: 'event', session: s.id, event: last });
105
+ }
103
106
  const ev = { seq: ++s.seq, t: Date.now(), kind, ...data };
104
107
  s.events.push(ev);
105
108
  if (s.events.length > MAX_EVENTS) s.events.splice(0, s.events.length - MAX_EVENTS);
package/src/server.js CHANGED
@@ -149,7 +149,7 @@ function matchRoute(routes, method, p) {
149
149
  return null;
150
150
  }
151
151
 
152
- function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = [], info = () => ({}) }) {
152
+ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = [], info = () => ({}), beforeApproval = null }) {
153
153
  const streams = new Map(); // res -> id del dispositiu
154
154
  auth.onRevoke((d) => {
155
155
  for (const [res, dev] of streams) if (dev === d.id) { res.end(); streams.delete(res); }
@@ -361,7 +361,7 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
361
361
  throw httpErr(404, 'Aquesta ruta no existeix');
362
362
  }
363
363
 
364
- const PUBLIC = /^\/(?:$|index\.html$|app(?:\/|$)|test(?:\/|$)|[\w.-]+\.(?:css|js|svg|png|webmanifest|json|ico|txt|html)$)/;
364
+ const PUBLIC = /^\/(?:$|index\.html$|app(?:\/|$)|privadesa$|[\w.-]+\.(?:css|js|svg|png|webmanifest|json|ico|txt|html)$)/;
365
365
  function serveStatic(req, res, url) {
366
366
  let rel = decodeURIComponent(url.pathname);
367
367
  if (!PUBLIC.test(rel) || rel.split('/').some((p) => p.startsWith('.')) || /^\/(daemon|node_modules)\b/.test(rel)) {
@@ -408,7 +408,9 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
408
408
  if (!isLoopbackDirect(req)) throw httpErr(403, 'Només s’accepta des d’aquest ordinador');
409
409
  const b = await readBody(req);
410
410
  if (!hub.checkHookKey(b.session, req.headers['x-nio-key'])) throw httpErr(403, 'La clau no és correcta');
411
- const decision = await hub.requestApproval({ session: b.session, tool: b.tool, input: b.input });
411
+ // Si l'ordre actua sobre el navegador o l'escriptori, hi afegim una captura de la pantalla.
412
+ const extra = beforeApproval ? await beforeApproval(b.tool, b.input).catch(() => ({})) : {};
413
+ const decision = await hub.requestApproval({ session: b.session, tool: b.tool, input: b.input, extra });
412
414
  res.writeHead(200, { 'content-type': 'application/json' });
413
415
  return res.end(JSON.stringify({ decision }));
414
416
  } catch (e) {