nio-cat 0.5.1 → 0.8.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/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 };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+ // Adreça recordada a nio.cat: l'ordinador publica la seva adreça actual (la del túnel), signada
3
+ // amb una clau Ed25519 pròpia. Si l'adreça canvia, el mòbil la troba allà i comprova la signatura.
4
+ // nio.cat no rep cap altra dada. Es desactiva amb `nio directory off` (cfg.directory = false).
5
+ const crypto = require('crypto');
6
+ const config = require('./config');
7
+
8
+ const b64u = (b) => Buffer.from(b).toString('base64url');
9
+
10
+ function identity(cfg) {
11
+ if (!cfg.identity) {
12
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
13
+ cfg.identity = { pub: publicKey.export({ format: 'jwk' }).x, priv: privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64url') };
14
+ config.save(cfg);
15
+ }
16
+ const pub = cfg.identity.pub;
17
+ const loc = b64u(crypto.createHash('sha256').update(Buffer.from(pub, 'base64url')).digest().subarray(0, 16));
18
+ return { pub, loc };
19
+ }
20
+
21
+ function sign(cfg, url, t = Date.now()) {
22
+ const { pub, loc } = identity(cfg);
23
+ const key = crypto.createPrivateKey({ key: Buffer.from(cfg.identity.priv, 'base64url'), format: 'der', type: 'pkcs8' });
24
+ const sig = b64u(crypto.sign(null, Buffer.from(`nio-node-v1\n${loc}\n${url}\n${t}`), key));
25
+ return { pub, url, t, sig };
26
+ }
27
+
28
+ const publicUrl = (u) => /^https:\/\//.test(u || '') && !/^https:\/\/(127\.|localhost|\[)/.test(u);
29
+
30
+ let current = null;
31
+ let timer = null;
32
+ let retry = 0;
33
+
34
+ async function publish(cfg, url, log = () => {}) {
35
+ if (cfg.directory === false || !publicUrl(url)) return false;
36
+ current = url.replace(/\/$/, '');
37
+ clearTimeout(timer);
38
+ const base = (cfg.directoryUrl || 'https://www.nio.cat').replace(/\/$/, '');
39
+ try {
40
+ const res = await fetch(`${base}/api/node`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sign(cfg, current)), signal: AbortSignal.timeout(10000) });
41
+ if (res.status === 503) return false; // nio.cat encara no té la llibreta configurada
42
+ if (!res.ok && res.status !== 409) throw new Error(`nio.cat ${res.status}`);
43
+ retry = 0;
44
+ timer = setTimeout(() => publish(cfg, current, log), 6 * 3600 * 1000); // renova abans que caduqui
45
+ if (timer.unref) timer.unref();
46
+ return true;
47
+ } catch (e) {
48
+ const wait = Math.min(30 * 60 * 1000, 5000 * 2 ** retry++);
49
+ if (retry === 1) log(`No he pogut desar l’adreça a nio.cat (${e.message}). Ho torno a provar.`);
50
+ timer = setTimeout(() => current === url.replace(/\/$/, '') && publish(cfg, current, log), wait);
51
+ if (timer.unref) timer.unref();
52
+ return false;
53
+ }
54
+ }
55
+
56
+ module.exports = { identity, sign, publish };
package/src/hub.js CHANGED
@@ -19,9 +19,10 @@ const clip = (s, n = MAX_OUT) => {
19
19
  };
20
20
 
21
21
  class Hub extends EventEmitter {
22
- constructor(cfg) {
22
+ constructor(cfg, { audit } = {}) {
23
23
  super();
24
24
  this.cfg = cfg;
25
+ this.audit = audit || { add() {} };
25
26
  this.sessions = new Map();
26
27
  this.runs = new Map(); // sessionId -> { kill, key }
27
28
  this.approvals = new Map(); // approvalId -> { ..., resolve }
@@ -258,7 +259,9 @@ class Hub extends EventEmitter {
258
259
  stream: (t) => this.chunk(s, String(t)),
259
260
  tool: ({ id: tid, name, input }) => {
260
261
  toolNames.set(tid, name);
261
- this.push(s, 'tool', { id: tid, name, input: summarizeInput(input) });
262
+ const inp = summarizeInput(input);
263
+ this.push(s, 'tool', { id: tid, name, input: inp });
264
+ this.audit.add({ type: 'tool', session: s.id, agent: s.agent, title: s.title, cwd: s.cwd, tool: name, what: describe(name, inp), mode: s.mode });
262
265
  },
263
266
  toolResult: ({ id: tid, output, error }) =>
264
267
  this.push(s, 'tool_result', { id: tid, name: toolNames.get(tid), output: clip(output), error: !!error }),
@@ -287,7 +290,7 @@ class Hub extends EventEmitter {
287
290
  finished = true;
288
291
  this.runs.delete(sid);
289
292
  for (const a of this.approvals.values()) {
290
- if (a.session === sid && a.state === 'pending') this.decide(a.id, 'deny', 'La sessió s’ha acabat');
293
+ if (a.session === sid && a.state === 'pending') this.decide(a.id, 'deny', 'La sessió s’ha acabat', 'sessió acabada');
291
294
  }
292
295
  if (err) this.push(s, 'error', { text: `No s’ha pogut engegar: ${err.message}` });
293
296
  else if (code && code !== 0 && code !== 130 && code !== 143 && !reported) {
@@ -328,7 +331,15 @@ class Hub extends EventEmitter {
328
331
  return a.length === b.length && crypto.timingSafeEqual(a, b);
329
332
  }
330
333
 
331
- requestApproval({ session, tool, input }) {
334
+ // El motiu: la descripció que dona l'agent (p. ex. el camp «description» de Bash a Claude Code)
335
+ // o, si no n'hi ha, l'últim que ha dit abans de demanar-ho.
336
+ reasonFor(s, input) {
337
+ if (input && typeof input.description === 'string' && input.description.trim()) return clip(input.description, 400);
338
+ const last = s ? [...s.events].reverse().find((e) => e.kind === 'text' || e.kind === 'user') : null;
339
+ return last && last.kind === 'text' ? clip(last.text.trim(), 400) : '';
340
+ }
341
+
342
+ requestApproval({ session, tool, input, extra }) {
332
343
  const s = this.get(session);
333
344
  const a = {
334
345
  id: id(),
@@ -337,29 +348,38 @@ class Hub extends EventEmitter {
337
348
  title: s?.title,
338
349
  tool,
339
350
  input: summarizeInput(input),
351
+ reason: this.reasonFor(s, input),
340
352
  state: 'pending',
341
353
  created: Date.now(),
354
+ ...(extra || {}),
342
355
  };
343
356
  const p = new Promise((resolve) => {
344
357
  a.resolve = resolve;
345
- a.timer = setTimeout(() => this.decide(a.id, 'deny', 'Ningú no ha respost en 15 minuts'), APPROVAL_TIMEOUT);
358
+ a.timer = setTimeout(() => this.decide(a.id, 'deny', 'Ningú no ha respost en 15 minuts', 'temps esgotat'), APPROVAL_TIMEOUT);
346
359
  });
347
360
  this.approvals.set(a.id, a);
348
361
  if (s) {
349
362
  s.state = 'waiting';
350
- this.push(s, 'approval', { approval: a.id, tool, input: a.input, state: 'pending' });
363
+ this.push(s, 'approval', { approval: a.id, tool, input: a.input, reason: a.reason, shot: a.shot, state: 'pending' });
351
364
  }
352
365
  this.emit('out', { type: 'approval', approval: publicApproval(a) });
353
- this.notice(s, `${agentName(s)} et demana permís`, `${tool}: ${describe(tool, a.input)}`);
366
+ this.notice(s, `${agentName(s)} et demana permís`, `${tool}: ${describe(tool, a.input)}`, { approval: a.id });
354
367
  return p;
355
368
  }
356
369
 
357
- decide(aid, decision, reason) {
370
+ // `by`: qui ho ha decidit (nom del dispositiu, «Telegram · …», «temps esgotat»…).
371
+ decide(aid, decision, reason, by) {
358
372
  const a = this.approvals.get(aid);
359
373
  if (!a || a.state !== 'pending') return null;
360
374
  clearTimeout(a.timer);
361
375
  a.state = decision === 'allow' ? 'allowed' : 'denied';
362
376
  a.decided = Date.now();
377
+ a.by = by || 'nio';
378
+ this.audit.add({
379
+ type: 'approval', approval: a.id, session: a.session, agent: a.agent, title: a.title,
380
+ tool: a.tool, what: describe(a.tool, a.input), why: a.reason, decision: a.state, by: a.by,
381
+ asked: a.created, note: reason || undefined,
382
+ });
363
383
  const s = this.get(a.session);
364
384
  if (s) {
365
385
  const ev = s.events.find((e) => e.kind === 'approval' && e.approval === aid);
@@ -381,12 +401,13 @@ class Hub extends EventEmitter {
381
401
  }
382
402
 
383
403
  // ---------- avisos (push) ----------
384
- notice(s, title, body) {
385
- const n = { id: id(6), t: Date.now(), title, body: clip(body || '', 180), session: s?.id };
404
+ notice(s, title, body, extra = {}) {
405
+ const n = { id: id(6), t: Date.now(), title, body: clip(body || '', 180), session: s?.id, node: this.cfg.id, ...extra };
386
406
  this.notices.unshift(n);
387
407
  this.notices.length = Math.min(this.notices.length, 20);
408
+ this.emit('notice', n, s);
388
409
  if (!this.cfg.pushSubs?.length) return;
389
- push.notifyAll(this.cfg).then((dead) => {
410
+ push.notifyAll(this.cfg, n).then((dead) => {
390
411
  if (dead.length) {
391
412
  this.cfg.pushSubs = this.cfg.pushSubs.filter((x) => !dead.includes(x.endpoint));
392
413
  save(this.cfg);
@@ -434,4 +455,4 @@ function httpErr(status, message) {
434
455
  return e;
435
456
  }
436
457
 
437
- module.exports = { Hub, httpErr };
458
+ module.exports = { Hub, httpErr, describe };