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/README.md +39 -6
- package/bin/nio.js +158 -9
- package/package.json +3 -2
- package/src/agents/claude.js +8 -0
- package/src/audit.js +55 -0
- package/src/auth.js +175 -0
- package/src/claude-hook.js +6 -0
- package/src/control.js +238 -0
- package/src/directory.js +56 -0
- package/src/hub.js +33 -12
- package/src/jobs.js +287 -0
- package/src/mcp.js +142 -0
- package/src/memory.js +131 -0
- package/src/proc.js +1 -1
- package/src/push.js +52 -18
- package/src/server.js +97 -33
- package/src/service.js +123 -0
- package/src/telegram.js +311 -0
- package/src/tunnel.js +150 -34
- package/src/voice.js +164 -0
- package/web/app/app.js +693 -86
- package/web/app/style.css +68 -7
- package/web/board.css +3 -2
- package/web/board.js +15 -14
- package/web/index.html +35 -17
- package/web/privadesa.html +148 -0
- package/web/sw.js +13 -9
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/directory.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|