nio-cat 0.5.0 → 0.6.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/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 }
@@ -163,12 +164,38 @@ class Hub extends EventEmitter {
163
164
  this.emit('out', { type: 'removed', session: sid });
164
165
  }
165
166
 
167
+ // Codex no té /status en mode headless: ho resolem aquí amb el que sí que sap dir.
168
+ codexStatus(s, bp) {
169
+ const { execFile } = require('child_process');
170
+ execFile(bp, ['login', 'status'], { timeout: 8000 }, (err, out, errOut) => {
171
+ const rows = String(out || errOut || '').split('\n').map((l) => l.trim()).filter(Boolean), login = rows.find((l) => /logged|log in|login/i.test(l) && !/^warning/i.test(l)) || rows[0] || (err ? 'No he pogut llegir el compte' : '');
172
+ const lines = [`**Compte:** ${login}`, `**Model:** ${s.model || s.modelUsed || 'el de per defecte'}`, `**Carpeta:** \`${s.cwd}\``, `**Permisos:** ${s.mode}`, `**Conversa:** ${s.nativeId ? 'continua la de Codex' : 'encara no ha començat'}`];
173
+ this.push(s, 'text', { text: lines.join('\n\n') });
174
+ this.touch(s);
175
+ });
176
+ return this.summary(s);
177
+ }
178
+
166
179
  update(sid, patch) {
167
180
  const s = this.get(sid);
168
181
  if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
169
182
  if (patch.mode && ['llegir', 'preguntar', 'tot'].includes(patch.mode)) s.mode = patch.mode;
170
183
  if (typeof patch.title === 'string') s.title = patch.title.slice(0, 120);
171
184
  if (typeof patch.model === 'string') s.model = patch.model.slice(0, 80);
185
+ if (typeof patch.cwd === 'string' && path.resolve(patch.cwd) !== s.cwd) {
186
+ const cwd = path.resolve(patch.cwd);
187
+ if (s.state === 'running' || s.state === 'waiting') throw httpErr(409, 'Espera que l’agent acabi o atura’l abans de canviar de carpeta');
188
+ let ok = false;
189
+ try { ok = fs.statSync(cwd).isDirectory(); } catch { /* no existeix */ }
190
+ if (!ok) throw httpErr(400, 'Aquesta carpeta no existeix');
191
+ // Claude Code desa cada conversa dins la carpeta del projecte: la copiem perquè l'agent la recordi.
192
+ if (s.agent === 'claude' && s.nativeId) {
193
+ const enc = (d) => path.join(require('os').homedir(), '.claude', 'projects', d.replace(/[^a-zA-Z0-9]/g, '-'), `${s.nativeId}.jsonl`);
194
+ try { fs.mkdirSync(path.dirname(enc(cwd)), { recursive: true }); fs.copyFileSync(enc(s.cwd), enc(cwd)); } catch { s.nativeId = null; }
195
+ }
196
+ s.cwd = cwd;
197
+ this.push(s, 'info', { text: `Carpeta canviada a ${cwd}` });
198
+ }
172
199
  this.touch(s);
173
200
  return this.summary(s);
174
201
  }
@@ -201,6 +228,7 @@ class Hub extends EventEmitter {
201
228
 
202
229
  if (!s.title) s.title = text.replace(/\s+/g, ' ').slice(0, 60);
203
230
  this.push(s, 'user', { text });
231
+ if (s.agent === 'codex' && /^\/status\s*$/i.test(text)) return this.codexStatus(s, bp);
204
232
  s.state = 'running';
205
233
  this.push(s, 'status', { state: 'running' });
206
234
 
@@ -231,7 +259,9 @@ class Hub extends EventEmitter {
231
259
  stream: (t) => this.chunk(s, String(t)),
232
260
  tool: ({ id: tid, name, input }) => {
233
261
  toolNames.set(tid, name);
234
- 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 });
235
265
  },
236
266
  toolResult: ({ id: tid, output, error }) =>
237
267
  this.push(s, 'tool_result', { id: tid, name: toolNames.get(tid), output: clip(output), error: !!error }),
@@ -260,7 +290,7 @@ class Hub extends EventEmitter {
260
290
  finished = true;
261
291
  this.runs.delete(sid);
262
292
  for (const a of this.approvals.values()) {
263
- 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');
264
294
  }
265
295
  if (err) this.push(s, 'error', { text: `No s’ha pogut engegar: ${err.message}` });
266
296
  else if (code && code !== 0 && code !== 130 && code !== 143 && !reported) {
@@ -301,7 +331,15 @@ class Hub extends EventEmitter {
301
331
  return a.length === b.length && crypto.timingSafeEqual(a, b);
302
332
  }
303
333
 
304
- 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 }) {
305
343
  const s = this.get(session);
306
344
  const a = {
307
345
  id: id(),
@@ -310,29 +348,38 @@ class Hub extends EventEmitter {
310
348
  title: s?.title,
311
349
  tool,
312
350
  input: summarizeInput(input),
351
+ reason: this.reasonFor(s, input),
313
352
  state: 'pending',
314
353
  created: Date.now(),
354
+ ...(extra || {}),
315
355
  };
316
356
  const p = new Promise((resolve) => {
317
357
  a.resolve = resolve;
318
- 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);
319
359
  });
320
360
  this.approvals.set(a.id, a);
321
361
  if (s) {
322
362
  s.state = 'waiting';
323
- 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' });
324
364
  }
325
365
  this.emit('out', { type: 'approval', approval: publicApproval(a) });
326
- 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 });
327
367
  return p;
328
368
  }
329
369
 
330
- decide(aid, decision, reason) {
370
+ // `by`: qui ho ha decidit (nom del dispositiu, «Telegram · …», «temps esgotat»…).
371
+ decide(aid, decision, reason, by) {
331
372
  const a = this.approvals.get(aid);
332
373
  if (!a || a.state !== 'pending') return null;
333
374
  clearTimeout(a.timer);
334
375
  a.state = decision === 'allow' ? 'allowed' : 'denied';
335
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
+ });
336
383
  const s = this.get(a.session);
337
384
  if (s) {
338
385
  const ev = s.events.find((e) => e.kind === 'approval' && e.approval === aid);
@@ -354,12 +401,13 @@ class Hub extends EventEmitter {
354
401
  }
355
402
 
356
403
  // ---------- avisos (push) ----------
357
- notice(s, title, body) {
358
- 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 };
359
406
  this.notices.unshift(n);
360
407
  this.notices.length = Math.min(this.notices.length, 20);
408
+ this.emit('notice', n, s);
361
409
  if (!this.cfg.pushSubs?.length) return;
362
- push.notifyAll(this.cfg).then((dead) => {
410
+ push.notifyAll(this.cfg, n).then((dead) => {
363
411
  if (dead.length) {
364
412
  this.cfg.pushSubs = this.cfg.pushSubs.filter((x) => !dead.includes(x.endpoint));
365
413
  save(this.cfg);
@@ -407,4 +455,4 @@ function httpErr(status, message) {
407
455
  return e;
408
456
  }
409
457
 
410
- module.exports = { Hub, httpErr };
458
+ module.exports = { Hub, httpErr, describe };
package/src/jobs.js ADDED
@@ -0,0 +1,287 @@
1
+ 'use strict';
2
+ // Orquestrador lleuger: cua de feines, cadenes i programació. No fa la feina: obre sessions de
3
+ // Claude Code o Codex (via el hub) i les va seguint. Tot es desa a ~/.nio/jobs.json.
4
+ //
5
+ // Estats: pendent → en curs → feta | fallada (o cancel·lada).
6
+ // · Cadena: `after` = id d'una altra feina. Comença quan aquella acaba bé (o sempre, amb `always`).
7
+ // El text «{{anterior}}» del missatge es canvia per l'última resposta de la feina anterior.
8
+ // · Programació: `at` (una vegada) o `cron` (periòdica, 5 camps). Una programació periòdica és una
9
+ // plantilla: cada vegada que toca, crea una feina nova a la cua.
10
+ // · Encaminament: amb agent «auto», la primera regla de cfg.routing que coincideix tria l'agent.
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const crypto = require('crypto');
14
+ const { HOME } = require('./config');
15
+
16
+ const FILE = path.join(HOME, 'jobs.json');
17
+ const ACTIVE = new Set(['pendent', 'en curs']);
18
+ const rid = () => crypto.randomBytes(6).toString('base64url');
19
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
20
+
21
+ // ---------- cron (minut hora dia-del-mes mes dia-de-la-setmana)
22
+ function field(spec, min, max) {
23
+ const out = new Set();
24
+ for (const part of String(spec).split(',')) {
25
+ const m = part.match(/^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/);
26
+ if (!m) throw httpErr(400, `Programació no vàlida: «${part}»`);
27
+ let lo = m[1] === '*' ? min : Number(m[2]);
28
+ let hi = m[1] === '*' ? max : m[3] != null ? Number(m[3]) : m[4] ? max : lo;
29
+ const step = m[4] ? Number(m[4]) : 1;
30
+ if (lo < min || hi > max || lo > hi || step < 1) throw httpErr(400, `Programació fora de límits: «${part}»`);
31
+ for (let v = lo; v <= hi; v += step) out.add(v);
32
+ }
33
+ return out;
34
+ }
35
+ function parseCron(expr) {
36
+ const f = String(expr || '').trim().split(/\s+/);
37
+ if (f.length !== 5) throw httpErr(400, 'La programació ha de tenir 5 camps: minut hora dia mes dia-de-la-setmana');
38
+ const dow = field(f[4], 0, 7);
39
+ if (dow.has(7)) dow.add(0);
40
+ return { min: field(f[0], 0, 59), hour: field(f[1], 0, 23), dom: field(f[2], 1, 31), mon: field(f[3], 1, 12), dow, domAny: f[2] === '*', dowAny: f[4] === '*' };
41
+ }
42
+ // Pròxima hora (hora local de l'ordinador) estrictament posterior a `from`.
43
+ function nextCron(expr, from = Date.now()) {
44
+ const c = typeof expr === 'string' ? parseCron(expr) : expr;
45
+ const d = new Date(from);
46
+ d.setSeconds(0, 0);
47
+ d.setMinutes(d.getMinutes() + 1);
48
+ for (let i = 0; i < 60 * 24 * 366; i++) {
49
+ const dayOk = c.domAny && c.dowAny ? true : c.domAny ? c.dow.has(d.getDay()) : c.dowAny ? c.dom.has(d.getDate()) : c.dom.has(d.getDate()) || c.dow.has(d.getDay());
50
+ if (!c.mon.has(d.getMonth() + 1) || !dayOk) { d.setHours(24, 0, 0, 0); continue; }
51
+ if (!c.hour.has(d.getHours())) { d.setHours(d.getHours() + 1, 0, 0, 0); continue; }
52
+ if (c.min.has(d.getMinutes())) return d.getTime();
53
+ d.setMinutes(d.getMinutes() + 1);
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // ---------- encaminament
59
+ function route(cfg, prompt, available) {
60
+ for (const r of cfg.routing || []) {
61
+ try {
62
+ if (r.agent && available.includes(r.agent) && new RegExp(r.match, 'i').test(prompt)) return { agent: r.agent, cwd: r.cwd, rule: r.match };
63
+ } catch {
64
+ /* regla mal escrita: la saltem */
65
+ }
66
+ }
67
+ const def = ['claude', 'codex'].find((a) => available.includes(a)) || available[0];
68
+ return { agent: def, rule: null };
69
+ }
70
+
71
+ class Jobs {
72
+ constructor({ cfg, hub, audit, available = () => ['claude', 'codex'], file = FILE }) {
73
+ this.cfg = cfg;
74
+ this.hub = hub;
75
+ this.audit = audit || { add() {} };
76
+ this.available = available;
77
+ this.file = file;
78
+ this.jobs = [];
79
+ try {
80
+ this.jobs = JSON.parse(fs.readFileSync(file, 'utf8'));
81
+ } catch {
82
+ /* primer cop */
83
+ }
84
+ // Si nio s'ha reiniciat amb feines en curs, la sessió ja no corre: les donem per fallades.
85
+ for (const j of this.jobs) if (j.state === 'en curs') this.finish(j, 'fallada', 'nio s’ha reiniciat mentre treballava', true);
86
+ hub.on('out', (m) => this.onHub(m));
87
+ }
88
+
89
+ save() {
90
+ const tmp = `${this.file}.tmp`;
91
+ fs.writeFileSync(tmp, JSON.stringify(this.jobs.slice(-500), null, 2), { mode: 0o600 });
92
+ fs.renameSync(tmp, this.file);
93
+ }
94
+
95
+ emit(j) {
96
+ this.hub.emit('out', { type: 'job', job: j });
97
+ }
98
+
99
+ list() {
100
+ return [...this.jobs].sort((a, b) => b.created - a.created);
101
+ }
102
+
103
+ get(id) {
104
+ return this.jobs.find((j) => j.id === id);
105
+ }
106
+
107
+ add(b) {
108
+ const prompt = String(b.prompt || '').trim();
109
+ if (!prompt) throw httpErr(400, 'Explica què ha de fer la feina');
110
+ const avail = this.available();
111
+ let agent = b.agent || 'auto', cwd = b.cwd, rule = null;
112
+ if (agent === 'auto') {
113
+ const r = route(this.cfg, prompt, avail);
114
+ agent = r.agent;
115
+ cwd = cwd || r.cwd;
116
+ rule = r.rule;
117
+ }
118
+ if (!['claude', 'codex', 'demo', 'grok', 'antigravity', 'ollama'].includes(agent)) throw httpErr(400, 'Tria Claude Code, Codex o «automàtic»');
119
+ if (!cwd) cwd = this.cfg.roots?.[0];
120
+ if (!cwd || !fs.existsSync(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
121
+ if (b.after && !this.get(b.after)) throw httpErr(400, 'La feina anterior no existeix');
122
+ let at = b.at ? Number(new Date(b.at)) : null;
123
+ if (b.at && !Number.isFinite(at)) throw httpErr(400, 'La data no és vàlida');
124
+ if (b.cron) parseCron(b.cron);
125
+ const j = {
126
+ id: rid(),
127
+ title: String(b.title || prompt.replace(/\s+/g, ' ').slice(0, 70)),
128
+ prompt: prompt.slice(0, 20000),
129
+ agent,
130
+ cwd: path.resolve(cwd),
131
+ mode: ['llegir', 'preguntar', 'tot'].includes(b.mode) ? b.mode : 'preguntar',
132
+ model: String(b.model || '').slice(0, 80),
133
+ rule,
134
+ after: b.after || null,
135
+ always: !!b.always,
136
+ cron: b.cron || null,
137
+ state: b.cron ? 'programada' : 'pendent',
138
+ runAt: b.cron ? nextCron(b.cron) : at,
139
+ created: Date.now(),
140
+ };
141
+ this.jobs.push(j);
142
+ this.save();
143
+ this.emit(j);
144
+ this.audit.add({ type: 'job', what: `Feina ${j.cron ? 'programada' : 'a la cua'}: ${j.title}`, agent: j.agent, why: rule ? `Regla «${rule}»` : undefined, job: j.id });
145
+ this.tick();
146
+ return j;
147
+ }
148
+
149
+ cancel(id) {
150
+ const j = this.get(id);
151
+ if (!j) throw httpErr(404, 'Aquesta feina no existeix');
152
+ if (j.state === 'en curs' && j.session) this.hub.stop(j.session);
153
+ if (j.state === 'programada' || ACTIVE.has(j.state)) this.finish(j, 'cancel·lada', 'L’has cancel·lada');
154
+ return j;
155
+ }
156
+
157
+ remove(id) {
158
+ const j = this.get(id);
159
+ if (!j) throw httpErr(404, 'Aquesta feina no existeix');
160
+ if (ACTIVE.has(j.state) || j.state === 'programada') this.cancel(id);
161
+ this.jobs = this.jobs.filter((x) => x.id !== id);
162
+ this.save();
163
+ this.hub.emit('out', { type: 'job', removed: id });
164
+ return { ok: true };
165
+ }
166
+
167
+ runNow(id) {
168
+ const j = this.get(id);
169
+ if (!j) throw httpErr(404, 'Aquesta feina no existeix');
170
+ if (j.state === 'programada') return this.spawnFrom(j);
171
+ if (j.state !== 'pendent') throw httpErr(409, 'Aquesta feina ja ha començat');
172
+ j.runAt = null;
173
+ j.after = null;
174
+ this.tick();
175
+ return j;
176
+ }
177
+
178
+ // Una programació periòdica crea una feina nova cada vegada que toca.
179
+ spawnFrom(t) {
180
+ const j = { ...t, id: rid(), cron: null, from: t.id, state: 'pendent', runAt: null, created: Date.now() };
181
+ this.jobs.push(j);
182
+ this.emit(j);
183
+ this.save();
184
+ this.tick();
185
+ return j;
186
+ }
187
+
188
+ tick(now = Date.now()) {
189
+ let changed = false;
190
+ for (const t of this.jobs) {
191
+ if (t.state === 'programada' && t.runAt && t.runAt <= now) {
192
+ t.runAt = nextCron(t.cron, now);
193
+ t.lastFired = now;
194
+ this.spawnFrom(t);
195
+ changed = true;
196
+ }
197
+ }
198
+ const running = this.jobs.filter((j) => j.state === 'en curs').length;
199
+ let free = Math.max(0, (this.cfg.jobs?.max || 2) - running);
200
+ for (const j of this.jobs) {
201
+ if (j.state !== 'pendent' || free <= 0) continue;
202
+ if (j.runAt && j.runAt > now) continue;
203
+ if (j.after) {
204
+ const p = this.get(j.after);
205
+ if (!p || p.state === 'cancel·lada' || (p.state === 'fallada' && !j.always)) {
206
+ this.finish(j, 'cancel·lada', p ? `La feina anterior (${p.title}) no ha acabat bé` : 'La feina anterior ja no hi és');
207
+ changed = true;
208
+ continue;
209
+ }
210
+ if (p.state !== 'feta' && p.state !== 'fallada') continue;
211
+ }
212
+ this.start(j);
213
+ free--;
214
+ changed = true;
215
+ }
216
+ if (changed) this.save();
217
+ }
218
+
219
+ start(j) {
220
+ const prev = j.after ? this.get(j.after) : null;
221
+ const text = j.prompt.replace(/\{\{\s*anterior\s*\}\}/g, prev?.result || '(la feina anterior no ha deixat cap resposta)');
222
+ try {
223
+ const s = this.hub.create({ agent: j.agent, cwd: j.cwd, title: `⚙ ${j.title}`, mode: j.mode, model: j.model });
224
+ j.session = s.id;
225
+ j.state = 'en curs';
226
+ j.started = Date.now();
227
+ this.emit(j);
228
+ this.hub.send(s.id, text, this.hookUrl ? this.hookUrl() : undefined);
229
+ } catch (e) {
230
+ this.finish(j, 'fallada', e.message);
231
+ }
232
+ }
233
+
234
+ onHub(m) {
235
+ if (m.type !== 'event' || m.event?.kind !== 'status') return;
236
+ const j = this.jobs.find((x) => x.session === m.session && x.state === 'en curs');
237
+ if (!j || m.event.state === 'running') return;
238
+ const s = this.hub.get(m.session);
239
+ const last = s ? [...s.events].reverse().find((e) => e.kind === 'text') : null;
240
+ j.result = last ? String(last.text).slice(0, 8000) : '';
241
+ if (m.event.state === 'error') this.finish(j, 'fallada', s ? [...s.events].reverse().find((e) => e.kind === 'error')?.text : 'Error');
242
+ else if (m.event.code === 130 || m.event.code === 143) this.finish(j, 'cancel·lada', 'L’has aturat');
243
+ else this.finish(j, 'feta');
244
+ setImmediate(() => this.tick());
245
+ }
246
+
247
+ finish(j, state, error, quiet) {
248
+ j.state = state;
249
+ j.finished = Date.now();
250
+ if (error) j.error = String(error).slice(0, 500);
251
+ this.emit(j);
252
+ this.save();
253
+ if (quiet) return;
254
+ this.audit.add({ type: 'job', what: `${state === 'feta' ? 'Feina feta' : state === 'fallada' ? 'Feina fallada' : 'Feina cancel·lada'}: ${j.title}`, agent: j.agent, why: j.error, session: j.session, job: j.id });
255
+ if (state !== 'cancel·lada') this.hub.notice(this.hub.get(j.session), state === 'feta' ? 'Feina feta' : 'Una feina ha fallat', j.title, { job: j.id });
256
+ }
257
+
258
+ routes() {
259
+ return [
260
+ { method: 'GET', path: '/api/jobs', handler: () => ({ jobs: this.list(), routing: this.cfg.routing || [], max: this.cfg.jobs?.max || 2 }) },
261
+ { method: 'POST', path: '/api/jobs', handler: ({ body }) => this.add(body) },
262
+ { method: 'POST', path: /^\/api\/jobs\/([\w-]+)\/run$/, handler: ({ params }) => this.runNow(params[0]) },
263
+ { method: 'POST', path: /^\/api\/jobs\/([\w-]+)\/cancel$/, handler: ({ params }) => this.cancel(params[0]) },
264
+ { method: 'DELETE', path: /^\/api\/jobs\/([\w-]+)$/, handler: ({ params }) => this.remove(params[0]) },
265
+ {
266
+ method: 'PUT',
267
+ path: '/api/jobs/routing',
268
+ handler: ({ body }) => {
269
+ const rules = (Array.isArray(body.rules) ? body.rules : []).slice(0, 50).map((r) => ({ match: String(r.match || '').slice(0, 200), agent: String(r.agent || ''), cwd: r.cwd ? String(r.cwd) : undefined }));
270
+ for (const r of rules) {
271
+ try { new RegExp(r.match, 'i'); } catch { throw httpErr(400, `Regla no vàlida: «${r.match}»`); }
272
+ }
273
+ this.cfg.routing = rules;
274
+ require('./config').save(this.cfg);
275
+ return { routing: rules };
276
+ },
277
+ },
278
+ ];
279
+ }
280
+
281
+ startTimer() {
282
+ this.timer = setInterval(() => this.tick(), 15000);
283
+ this.timer.unref();
284
+ }
285
+ }
286
+
287
+ module.exports = { Jobs, parseCron, nextCron, route };
package/src/mcp.js ADDED
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+ // Servidor MCP (stdio) de nio: la memòria compartida per a Claude Code i Codex.
3
+ // Claude Code i Codex l'engeguen amb `nio mcp`; parla JSON-RPC 2.0, un missatge per línia.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawnSync } = require('child_process');
8
+ const memory = require('./memory');
9
+
10
+ const TOOLS = [
11
+ {
12
+ name: 'memoria_llista',
13
+ description: 'Llista els fitxers de la memòria compartida de nio (markdown). Fes-la servir per saber què hi ha abans de llegir o escriure.',
14
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
15
+ run: () => {
16
+ const files = memory.list();
17
+ return files.length ? files.map((f) => `${f.path} (${f.size} B)`).join('\n') : 'La memòria és buida.';
18
+ },
19
+ },
20
+ {
21
+ name: 'memoria_llegeix',
22
+ description: 'Llegeix un fitxer de la memòria compartida, p. ex. «preferencies.md» o «projectes/web.md».',
23
+ inputSchema: { type: 'object', properties: { fitxer: { type: 'string', description: 'Ruta relativa, acabada en .md' } }, required: ['fitxer'], additionalProperties: false },
24
+ run: ({ fitxer }) => memory.read(fitxer).content,
25
+ },
26
+ {
27
+ name: 'memoria_cerca',
28
+ description: 'Busca text a tota la memòria compartida (no distingeix majúscules ni accents). Retorna fitxer, línia i text.',
29
+ inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false },
30
+ run: ({ text }) => {
31
+ const hits = memory.search(text);
32
+ return hits.length ? hits.map((h) => `${h.path}:${h.line}: ${h.text}`).join('\n') : 'Cap resultat.';
33
+ },
34
+ },
35
+ {
36
+ name: 'memoria_escriu',
37
+ description: 'Desa informació útil per a més endavant a la memòria compartida (la llegiran també els altres agents). Per defecte afegeix al final; amb mode «substitueix» reescriu el fitxer sencer. Desa-hi fets durables (preferències, decisions, context de projectes), no converses.',
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ fitxer: { type: 'string', description: 'Ruta relativa acabada en .md, p. ex. «projectes/botiga.md»' },
42
+ text: { type: 'string' },
43
+ mode: { type: 'string', enum: ['afegeix', 'substitueix'], default: 'afegeix' },
44
+ },
45
+ required: ['fitxer', 'text'],
46
+ additionalProperties: false,
47
+ },
48
+ run: ({ fitxer, text, mode }) => {
49
+ const r = memory.write(fitxer, text, { append: mode !== 'substitueix' });
50
+ return `Desat a ${r.path} (${r.size} B).`;
51
+ },
52
+ },
53
+ ];
54
+
55
+ function serve({ input = process.stdin, output = process.stdout } = {}) {
56
+ const send = (msg) => output.write(`${JSON.stringify({ jsonrpc: '2.0', ...msg })}\n`);
57
+ const handle = (msg) => {
58
+ const { id, method, params } = msg;
59
+ const reply = (result) => id !== undefined && send({ id, result });
60
+ const fail = (code, message) => id !== undefined && send({ id, error: { code, message } });
61
+ switch (method) {
62
+ case 'initialize':
63
+ return reply({
64
+ protocolVersion: params?.protocolVersion || '2025-06-18',
65
+ capabilities: { tools: {} },
66
+ serverInfo: { name: 'nio', version: require('../package.json').version },
67
+ instructions: 'Memòria compartida de nio (fitxers markdown en aquest ordinador). Consulta-la a l’inici d’una tasca (memoria_cerca o memoria_llegeix preferencies.md) i desa-hi el que valgui la pena recordar.',
68
+ });
69
+ case 'ping':
70
+ return reply({});
71
+ case 'tools/list':
72
+ return reply({ tools: TOOLS.map(({ run, ...t }) => t) });
73
+ case 'tools/call': {
74
+ const tool = TOOLS.find((t) => t.name === params?.name);
75
+ if (!tool) return fail(-32602, `Eina desconeguda: ${params?.name}`);
76
+ try {
77
+ return reply({ content: [{ type: 'text', text: String(tool.run(params.arguments || {})) }] });
78
+ } catch (e) {
79
+ return reply({ content: [{ type: 'text', text: e.message }], isError: true });
80
+ }
81
+ }
82
+ default:
83
+ if (method && method.startsWith('notifications/')) return undefined;
84
+ return fail(-32601, `Mètode desconegut: ${method}`);
85
+ }
86
+ };
87
+ let buf = '';
88
+ input.setEncoding('utf8');
89
+ input.on('data', (d) => {
90
+ buf += d;
91
+ let i;
92
+ while ((i = buf.indexOf('\n')) >= 0) {
93
+ const line = buf.slice(0, i).trim();
94
+ buf = buf.slice(i + 1);
95
+ if (!line) continue;
96
+ let msg;
97
+ try {
98
+ msg = JSON.parse(line);
99
+ } catch {
100
+ send({ id: null, error: { code: -32700, message: 'JSON no vàlid' } });
101
+ continue;
102
+ }
103
+ for (const m of Array.isArray(msg) ? msg : [msg]) handle(m);
104
+ }
105
+ });
106
+ }
107
+
108
+ // ---------- registre a Claude Code i Codex (un sol cop, sense duplicar)
109
+ function command() {
110
+ const entry = fs.realpathSync(path.join(__dirname, '..', 'bin', 'nio.js'));
111
+ return { cmd: process.execPath, args: [entry, 'mcp'], unstable: entry.includes(`${path.sep}_npx${path.sep}`) };
112
+ }
113
+
114
+ function registerClaude(bin, { force } = {}) {
115
+ if (!bin) return 'Claude Code no hi és';
116
+ const { cmd, args } = command();
117
+ if (!force && spawnSync(bin, ['mcp', 'get', 'nio'], { encoding: 'utf8', timeout: 15000 }).status === 0) return 'ja hi era';
118
+ spawnSync(bin, ['mcp', 'remove', '--scope', 'user', 'nio'], { timeout: 15000 });
119
+ const env = process.env.NIO_HOME ? ['-e', `NIO_HOME=${process.env.NIO_HOME}`] : [];
120
+ const r = spawnSync(bin, ['mcp', 'add', 'nio', '--scope', 'user', ...env, '--', cmd, ...args], { encoding: 'utf8', timeout: 15000 });
121
+ return r.status === 0 ? 'afegit' : `no s’ha pogut afegir: ${(r.stderr || r.stdout || '').trim().split('\n').pop()}`;
122
+ }
123
+
124
+ function registerCodex() {
125
+ const dir = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'));
126
+ const file = path.join(dir, 'config.toml');
127
+ let txt = '';
128
+ try {
129
+ txt = fs.readFileSync(file, 'utf8');
130
+ } catch {
131
+ /* encara no hi és */
132
+ }
133
+ if (/^\s*\[mcp_servers\.nio\]/m.test(txt)) return 'ja hi era';
134
+ const { cmd, args } = command();
135
+ const q = (s) => JSON.stringify(s);
136
+ const block = `\n# Memòria compartida de nio (afegit per nio; ho pots treure quan vulguis)\n[mcp_servers.nio]\ncommand = ${q(cmd)}\nargs = [${args.map(q).join(', ')}]\n${process.env.NIO_HOME ? `env = { NIO_HOME = ${q(process.env.NIO_HOME)} }\n` : ''}`;
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ fs.appendFileSync(file, `${txt && !txt.endsWith('\n') ? '\n' : ''}${block}`);
139
+ return 'afegit';
140
+ }
141
+
142
+ module.exports = { serve, TOOLS, registerClaude, registerCodex, command };