nio-cat 0.9.6 → 0.9.9

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
@@ -14,6 +14,7 @@ Instal·la nio i `cloudflared` al teu usuari, el deixa com a servei (systemd o l
14
14
 
15
15
  - **Sessions** de Claude Code i Codex en directe, a qualsevol dels teus ordinadors.
16
16
  - **Aprovacions** amb el motiu, al mòbil, al push o a Telegram, i **registre** de totes les accions (`~/.nio/audit.jsonl`).
17
+ - **Notes** com Google Keep: targetes, llistes, colors i etiquetes. Grava amb el mòbil i Whisper les transcriu a l’ordinador. Un model local (Ollama), Claude Code o Codex les resumeix i les organitza. Es desen a `~/.nio/notes`.
17
18
  - **Feines**: cua, cadenes («quan acabi, que Codex ho revisi») i programació (cron).
18
19
  - **Memòria compartida** a `~/.nio/memory`, amb un servidor MCP (`nio mcp`) registrat a Claude Code i Codex.
19
20
  - **Telegram** (el teu bot) i **veu**: dictat gratis amb Whisper a l’ordinador (whisper.cpp + ffmpeg; l’àudio no en surt), o ElevenLabs i OpenAI amb les teves claus.
package/bin/nio.js CHANGED
@@ -7,13 +7,15 @@ const crypto = require('crypto');
7
7
  const { spawn } = require('child_process');
8
8
  const config = require('../src/config');
9
9
  const { Hub } = require('../src/hub');
10
- const { createServer } = require('../src/server');
10
+ const { createServer, listProjects } = require('../src/server');
11
11
  const { detectAll } = require('../src/agents');
12
12
  const { startTunnel, startNamedTunnel, startConfiguredTunnel, setupNamedTunnel } = require('../src/tunnel');
13
13
  const service = require('../src/service');
14
14
  const { Auth } = require('../src/auth');
15
15
  const { Audit } = require('../src/audit');
16
16
  const memory = require('../src/memory');
17
+ const { Notes } = require('../src/notes');
18
+ const { Call } = require('../src/call');
17
19
  const mcp = require('../src/mcp');
18
20
  const directory = require('../src/directory');
19
21
  const voice = require('../src/voice');
@@ -213,7 +215,9 @@ async function start() {
213
215
  if (cfg.telegram?.token) row('Telegram', 'actiu', `(${(cfg.telegram.allowed || []).length} xats autoritzats)`);
214
216
  const control = new Control({ cfg, hub, audit });
215
217
  const remote = new Remote({ cfg, audit });
216
- const routes = [...memory.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes(), ...remote.routes()];
218
+ const notes = new Notes({ cfg, hub });
219
+ const call = new Call({ cfg, hub, audit, projects: () => listProjects(cfg), hookUrl: jobs.hookUrl, terminal: () => require('../src/terminal').listSessions(require('../src/agents').binPath(cfg, 'claude'), require('../src/proc').childEnv()) });
220
+ const routes = [...memory.routes(), ...notes.routes(), ...call.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes(), ...remote.routes()];
217
221
  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 });
218
222
 
219
223
  server.on('error', (e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.6",
3
+ "version": "0.9.9",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
@@ -17,6 +17,8 @@ module.exports = {
17
17
  const { session, prompt, cfg } = ctx;
18
18
  const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
19
19
  if (session.nativeId) args.push('--resume', session.nativeId);
20
+ // Conversa del terminal: el primer missatge en fa una branca; després ja continua la pròpia.
21
+ if (session.nativeId && session.forkNative) { args.push('--fork-session'); delete session.forkNative; }
20
22
  if (session.model) args.push('--model', session.model);
21
23
 
22
24
  if (session.mode === 'tot' && process.getuid?.() === 0) {
@@ -110,7 +112,10 @@ function handle(ctx, line) {
110
112
  error: ev.is_error ? ev.result || ev.subtype : null,
111
113
  });
112
114
  break;
115
+ case 'rate_limit_event':
116
+ ctx.limits?.(ev.rate_limit_info);
117
+ break;
113
118
  default:
114
- break; // rate_limit_event, etc.
119
+ break;
115
120
  }
116
121
  }
@@ -70,11 +70,12 @@ async function pull(name, onProgress, signal) {
70
70
  }
71
71
 
72
72
  // Una resposta d'un sol cop (sense flux), per exemple per a la pissarra.
73
- async function chat({ model, messages, format }) {
73
+ async function chat({ model, messages, format, think, maxTokens, signal }) {
74
74
  const res = await fetch(`${base()}/api/chat`, {
75
75
  method: 'POST',
76
76
  headers: { 'content-type': 'application/json' },
77
- body: JSON.stringify({ model, messages, stream: false, ...(format ? { format } : {}), options: { temperature: 0.2 } }),
77
+ body: JSON.stringify({ model, messages, stream: false, ...(format ? { format } : {}), ...(think === false ? { think: false } : {}), options: { temperature: 0.2, ...(maxTokens ? { num_predict: maxTokens } : {}) } }),
78
+ ...(signal ? { signal } : {}),
78
79
  });
79
80
  if (!res.ok) throw new Error(`Ollama ha tornat un error ${res.status}: ${await res.text()}`);
80
81
  const data = await res.json();
package/src/call.js ADDED
@@ -0,0 +1,246 @@
1
+ 'use strict';
2
+ // Trucada amb en Nio: una conversa de veu en temps real amb un agent d'ElevenLabs (Agents Platform).
3
+ // · La clau d'ElevenLabs es queda en aquest ordinador: el mòbil només rep un URL signat que caduca.
4
+ // · L'agent es crea sol al compte de l'usuari la primera vegada (i es refà si en canviem la definició).
5
+ // · A cada trucada li passem l'estat d'aquest ordinador (agents, sessions, aprovacions) i unes eines
6
+ // que el navegador reenvia aquí: llegir una sessió, encarregar feina, enviar un missatge o aprovar.
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const config = require('./config');
10
+ const { describe } = require('./hub');
11
+
12
+ const API = 'https://api.elevenlabs.io/v1/convai';
13
+ const AGENT_V = 1; // puja'l quan canviïn les eines o la configuració de l'agent
14
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
15
+ const clip = (s, n) => { s = String(s || '').replace(/\s+/g, ' ').trim(); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
16
+ // «Café» → «cafe»: per trobar carpetes i sessions dites de paraula.
17
+ const norm = (s) => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
18
+ const ago = (t) => {
19
+ const m = Math.round((Date.now() - t) / 60000);
20
+ if (m < 1) return 'ara mateix';
21
+ if (m < 60) return `fa ${m} minut${m > 1 ? 's' : ''}`;
22
+ const h = Math.round(m / 60);
23
+ return h < 24 ? `fa ${h} hor${h > 1 ? 'es' : 'a'}` : `fa ${Math.round(h / 24)} dies`;
24
+ };
25
+
26
+ const TOOLS = [
27
+ { type: 'client', name: 'estat', description: 'Torna l’estat actual de l’ordinador: agents instal·lats, sessions i com van, aprovacions pendents i carpetes. Fes-la servir quan et preguntin com va tot o abans de respondre sobre una cosa que pot haver canviat.', expects_response: true, parameters: { type: 'object', properties: {} } },
28
+ { type: 'client', name: 'llegeix_sessio', description: 'Llegeix els últims missatges d’una sessió per explicar què ha fet o què diu l’agent.', expects_response: true, parameters: { type: 'object', properties: { sessio: { type: 'string', description: 'Títol, carpeta o agent de la sessió, tal com l’ha dit la persona.' } }, required: ['sessio'] } },
29
+ { type: 'client', name: 'nova_sessio', description: 'Encarrega una feina nova a un agent en una carpeta. Abans de cridar-la, repeteix agent, carpeta i tasca i espera que la persona digui que sí.', expects_response: true, parameters: { type: 'object', properties: { agent: { type: 'string', description: 'Agent: claude, codex, grok, antigravity o ollama.' }, carpeta: { type: 'string', description: 'Nom de la carpeta del projecte.' }, tasca: { type: 'string', description: 'La instrucció completa per a l’agent, ben redactada.' } }, required: ['agent', 'carpeta', 'tasca'] } },
30
+ { type: 'client', name: 'envia_missatge', description: 'Envia un missatge a una sessió que ja existeix (per continuar la feina). Confirma el text abans d’enviar-lo.', expects_response: true, parameters: { type: 'object', properties: { sessio: { type: 'string', description: 'Títol, carpeta o agent de la sessió.' }, text: { type: 'string', description: 'El missatge per a l’agent.' } }, required: ['sessio', 'text'] } },
31
+ { type: 'client', name: 'decideix_aprovacio', description: 'Aprova o rebutja una acció que un agent espera. Explica abans què vol fer l’agent i demana confirmació explícita.', expects_response: true, parameters: { type: 'object', properties: { decisio: { type: 'string', enum: ['aprova', 'rebutja'], description: 'aprova o rebutja' }, quina: { type: 'string', description: 'Si n’hi ha més d’una: l’agent, la carpeta o l’acció.' } }, required: ['decisio'] } },
32
+ { type: 'client', name: 'penja', description: 'Acaba la trucada quan la persona s’acomiada o diu que ja està.', expects_response: false, parameters: { type: 'object', properties: {} } },
33
+ ];
34
+
35
+ const PROMPT = (ctx) => `Ets en Nio, l’assistent de veu de nio. Parles per telèfon amb la persona que fa servir nio: des del mòbil controla els agents d’IA del seu ordinador (Claude Code, Codex…).
36
+
37
+ Com parles:
38
+ - Sempre en català col·loquial i proper, amb frases curtes. Una o dues frases per torn, tres com a molt.
39
+ - És una trucada: res de Markdown, llistes, emojis, rutes ni identificadors. Els noms de carpeta, dits com a paraules.
40
+ - Si hi ha moltes coses, diu les dues o tres més importants i pregunta si en vol més.
41
+ - No t’inventis res: si no ho saps, fes servir les eines.
42
+
43
+ Què pots fer:
44
+ - Explicar quins agents té, com van les sessions i què espera aprovació (eina «estat» per tenir-ho al dia, «llegeix_sessio» per als detalls).
45
+ - Encarregar feina nova («nova_sessio»), continuar una sessió («envia_missatge») i aprovar o rebutjar accions («decideix_aprovacio»). Abans de fer qualsevol d’aquestes tres, repeteix en una frase què faràs i espera un sí clar.
46
+ - Quan s’acomiadi, acomiada’t breument i fes servir «penja».
47
+
48
+ Mentre dura la trucada et poden arribar avisos de context (una sessió acaba, un agent demana permís): explica’ls breument quan sigui oportú.
49
+
50
+ Estat de l’ordinador ara mateix:
51
+ ${ctx}`;
52
+
53
+ class Call {
54
+ constructor({ cfg, hub, audit, projects, hookUrl, terminal }) {
55
+ Object.assign(this, { cfg, hub, audit: audit || { add() {} }, projects, hookUrl, terminal });
56
+ }
57
+
58
+ key() { return this.cfg.voice?.keys?.elevenlabs || ''; }
59
+ status() { return { ready: !!this.key(), provider: 'elevenlabs', agent: !!this.cfg.voice?.call?.agentId }; }
60
+
61
+ async el(method, p, body) {
62
+ const r = await fetch(`${API}${p}`, { method, headers: { 'xi-api-key': this.key(), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(20000) });
63
+ const t = await r.text();
64
+ let j = null; try { j = JSON.parse(t); } catch { /* text */ }
65
+ if (!r.ok) {
66
+ const d = j?.detail;
67
+ const msg = (typeof d === 'string' ? d : d?.message || (Array.isArray(d) ? d.map((x) => x.msg).join('; ') : '') || j?.message || t || `Error ${r.status}`);
68
+ throw Object.assign(httpErr(r.status === 401 ? 400 : 502, `ElevenLabs: ${clip(msg, 220)}`), { upstream: r.status });
69
+ }
70
+ return j;
71
+ }
72
+
73
+ // L'agent viu al compte d'ElevenLabs de l'usuari. Si no hi és (o és d'una versió antiga), se'n fa un de nou.
74
+ async ensureAgent() {
75
+ const c = this.cfg.voice.call || {};
76
+ if (c.agentId && c.v === AGENT_V) return c;
77
+ if (c.agentId) this.el('DELETE', `/agents/${c.agentId}`).catch(() => {});
78
+ const overrides = { conversation_config_override: { agent: { prompt: { prompt: true }, first_message: true, language: true }, tts: { voice_id: true } } };
79
+ const make = (lang, model, tools) => ({
80
+ name: `nio · ${os.hostname()}`,
81
+ conversation_config: {
82
+ agent: { first_message: 'Hola!', language: lang, prompt: { prompt: 'Ets en Nio, l’assistent de veu de nio.', ...(tools ? { tools: TOOLS } : {}) } },
83
+ ...(model ? { tts: { model_id: model } } : {}),
84
+ conversation: { max_duration_seconds: 1200 },
85
+ },
86
+ platform_settings: { overrides },
87
+ });
88
+ // De més a menys exigent: si el compte o l'API no accepten alguna part, es prova sense.
89
+ const tries = [['ca', 'eleven_flash_v2_5', true], ['ca', null, true], ['es', 'eleven_flash_v2_5', true], ['ca', null, false], ['en', null, false]];
90
+ let last;
91
+ for (const [lang, model, tools] of tries) {
92
+ try {
93
+ const r = await this.el('POST', '/agents/create', make(lang, model, tools));
94
+ this.cfg.voice.call = { agentId: r.agent_id, v: AGENT_V, lang, tools };
95
+ config.save(this.cfg);
96
+ this.audit.add({ type: 'call', what: `Agent de veu creat a ElevenLabs (${lang}${tools ? '' : ', sense eines'})` });
97
+ return this.cfg.voice.call;
98
+ } catch (e) {
99
+ last = e;
100
+ if (e.upstream === 401 || e.upstream === 403) break; // clau dolenta o sense permís: no cal insistir
101
+ }
102
+ }
103
+ throw last.upstream === 401 ? httpErr(400, 'La clau d’ElevenLabs no és vàlida. Canvia-la a Ajustos → Veu.') : last;
104
+ }
105
+
106
+ // ---------------------------------------------------------------- què li expliquem
107
+ async snapshot() {
108
+ const { detectAll } = require('./agents');
109
+ const agents = (await detectAll(this.cfg).catch(() => [])).filter((a) => a.available && a.id !== 'demo');
110
+ const sessions = this.hub.list();
111
+ const approvals = this.hub.pendingApprovals();
112
+ let projects = [];
113
+ try { projects = this.projects().filter((p) => !p.root).map((p) => p.name); } catch { /* sense carpetes */ }
114
+ let term = [];
115
+ try { term = await this.terminal?.(); } catch { /* sense terminal */ }
116
+ return { agents, sessions, approvals, projects, term: term || [] };
117
+ }
118
+ stateLabel(s) {
119
+ if (s.pending || s.state === 'waiting') return 'espera el teu permís';
120
+ if (s.state === 'running') return 'treballant';
121
+ if (s.state === 'error') return `ha fallat${s.error ? ` (${clip(s.error, 80)})` : ''}`;
122
+ return `acabada ${ago(s.updated)}`;
123
+ }
124
+ agentName(id) { return require('./agents').byId?.[id]?.name || { claude: 'Claude Code', codex: 'Codex' }[id] || id; }
125
+ contextText(snap) {
126
+ const L = [];
127
+ L.push(`Ordinador: ${os.hostname()}.`);
128
+ L.push(snap.agents.length ? `Agents instal·lats: ${snap.agents.map((a) => a.name).join(', ')}.` : 'No hi ha cap agent instal·lat.');
129
+ const recent = snap.sessions.filter((s) => Date.now() - s.updated < 3 * 864e5 || s.state === 'running' || s.pending).slice(0, 10);
130
+ const running = snap.sessions.filter((s) => s.state === 'running').length;
131
+ L.push(`Sessions: ${snap.sessions.length} en total, ${running} treballant ara.`);
132
+ for (const s of recent) L.push(`- «${clip(s.title || 'sense títol', 70)}», ${this.agentName(s.agent)} a la carpeta ${path.basename(s.cwd)}: ${this.stateLabel(s)}.${s.last ? ` Últim: ${clip(s.last, 140)}` : ''}`);
133
+ if (snap.approvals.length) {
134
+ L.push(`Aprovacions pendents: ${snap.approvals.length}.`);
135
+ for (const a of snap.approvals.slice(0, 5)) L.push(`- ${a.agent ? this.agentName(a.agent) : 'Un agent'}${a.title ? ` («${clip(a.title, 50)}»)` : ''} vol ${clip(`${a.tool}: ${describe(a.tool, a.input)}`, 160)}.`);
136
+ } else L.push('Cap aprovació pendent.');
137
+ if (snap.term.length) L.push(`Al terminal hi ha ${snap.term.length} Claude Code obert${snap.term.length > 1 ? 's' : ''}: ${snap.term.slice(0, 4).map((t) => `«${clip(t.title || t.name || path.basename(t.cwd), 50)}» (${t.status === 'busy' ? 'treballant' : 'esperant'})`).join(', ')}.`);
138
+ if (snap.projects.length) L.push(`Carpetes de projectes: ${snap.projects.slice(0, 40).join(', ')}.`);
139
+ return L.join('\n');
140
+ }
141
+ greeting(snap) {
142
+ const n = snap.agents.map((a) => a.name);
143
+ const list = n.length > 1 ? `${n.slice(0, -1).join(', ')} i ${n[n.length - 1]}` : n[0];
144
+ const running = snap.sessions.filter((s) => s.state === 'running').length, ap = snap.approvals.length;
145
+ let s = `Hola! Sóc en Nio, et parlo des de l’ordinador ${os.hostname()}. ${n.length ? `Tens ${list}.` : 'Encara no tens cap agent instal·lat.'}`;
146
+ if (ap) s += ` ${ap === 1 ? 'Hi ha una acció' : `Hi ha ${ap} accions`} esperant el teu permís.`;
147
+ else if (running) s += ` ${running === 1 ? 'Una sessió està treballant' : `${running} sessions estan treballant`} ara mateix.`;
148
+ return `${s} Què vols fer?`;
149
+ }
150
+
151
+ async start() {
152
+ if (!this.key()) throw httpErr(400, 'Per trucar cal una clau d’ElevenLabs. Posa-la a Ajustos → Veu.');
153
+ this.cfg.voice = this.cfg.voice || {};
154
+ let a = await this.ensureAgent();
155
+ const url = async () => (await this.el('GET', `/conversation/get-signed-url?agent_id=${encodeURIComponent(a.agentId)}`)).signed_url;
156
+ let signed;
157
+ try { signed = await url(); } catch (e) {
158
+ if (e.upstream !== 404) throw e;
159
+ delete this.cfg.voice.call; a = await this.ensureAgent(); signed = await url(); // l'han esborrat al seu tauler
160
+ }
161
+ const snap = await this.snapshot();
162
+ const voiceId = this.cfg.voice.voice?.elevenlabs?.voice;
163
+ const agentOv = { prompt: { prompt: PROMPT(this.contextText(snap)) }, first_message: this.greeting(snap) };
164
+ if (a.lang === 'ca') agentOv.language = 'ca';
165
+ this.audit.add({ type: 'call', what: 'Trucada amb en Nio' });
166
+ return {
167
+ signedUrl: signed,
168
+ tools: !!a.tools,
169
+ host: os.hostname(),
170
+ init: { type: 'conversation_initiation_client_data', conversation_config_override: { agent: agentOv, ...(voiceId ? { tts: { voice_id: voiceId } } : {}) } },
171
+ };
172
+ }
173
+
174
+ // ---------------------------------------------------------------- eines (les crida el navegador)
175
+ findSession(q) {
176
+ const list = this.hub.list(), w = norm(q);
177
+ if (!list.length) return null;
178
+ if (!w) return list[0];
179
+ const score = (s) => {
180
+ const hay = `${norm(s.title)} ${norm(path.basename(s.cwd))} ${norm(this.agentName(s.agent))} ${norm(s.agent)}`;
181
+ return w.split(' ').filter((t) => t.length > 1 && hay.includes(t)).length * 10 + (s.state === 'running' || s.pending ? 2 : 0);
182
+ };
183
+ const best = list.map((s) => [score(s), s]).sort((a, b) => b[0] - a[0] || b[1].updated - a[1].updated)[0];
184
+ return best[0] > 0 ? best[1] : null;
185
+ }
186
+ findFolder(q) {
187
+ const list = this.projects(), w = norm(q);
188
+ const exact = list.find((p) => norm(p.name) === w);
189
+ if (exact) return exact.path;
190
+ const part = list.filter((p) => !p.root && (norm(p.name).includes(w) || w.includes(norm(p.name)))).sort((a, b) => a.name.length - b.name.length)[0];
191
+ return part?.path || null;
192
+ }
193
+ async tool(name, p = {}) {
194
+ if (name === 'estat') return this.contextText(await this.snapshot());
195
+ if (name === 'llegeix_sessio') {
196
+ const s = this.findSession(p.sessio);
197
+ if (!s) return 'No he trobat cap sessió amb aquest nom.';
198
+ const full = this.hub.get(s.id);
199
+ const msgs = full.events.filter((e) => e.kind === 'user' || e.kind === 'text' || e.kind === 'error').slice(-6)
200
+ .map((e) => `${e.kind === 'user' ? 'Persona' : e.kind === 'error' ? 'Error' : this.agentName(s.agent)}: ${clip(e.text, 500)}`);
201
+ return `Sessió «${s.title || 'sense títol'}», ${this.agentName(s.agent)} a ${path.basename(s.cwd)}, ${this.stateLabel(s)}.\n${msgs.join('\n') || 'Encara no hi ha missatges.'}`;
202
+ }
203
+ if (name === 'nova_sessio') {
204
+ const { detectAll } = require('./agents');
205
+ const avail = (await detectAll(this.cfg)).filter((a) => a.available && a.id !== 'demo');
206
+ const wa = norm(p.agent);
207
+ const agent = avail.find((a) => a.id === wa || norm(a.name).includes(wa) || wa.includes(a.id)) || (wa ? null : avail[0]);
208
+ if (!agent) return `No tinc l’agent «${p.agent}». Hi ha: ${avail.map((a) => a.name).join(', ')}.`;
209
+ const cwd = this.findFolder(p.carpeta);
210
+ if (!cwd) return `No trobo la carpeta «${p.carpeta}». Pregunta-li quina és de les que hi ha.`;
211
+ const task = String(p.tasca || '').trim();
212
+ if (!task) return 'Falta la tasca.';
213
+ const s = this.hub.create({ agent: agent.id, cwd, mode: 'preguntar' });
214
+ this.hub.send(s.id, task, this.hookUrl());
215
+ this.audit.add({ type: 'call', what: `Feina encarregada per veu a ${agent.name} (${path.basename(cwd)})` });
216
+ return `Fet: ${agent.name} ja treballa a ${path.basename(cwd)}, amb permisos de «demana permís».`;
217
+ }
218
+ if (name === 'envia_missatge') {
219
+ const s = this.findSession(p.sessio);
220
+ if (!s) return 'No he trobat aquesta sessió.';
221
+ try { this.hub.send(s.id, String(p.text || ''), this.hookUrl()); } catch (e) { return `No s’ha pogut enviar: ${e.message}`; }
222
+ return `Enviat a «${s.title || path.basename(s.cwd)}». ${this.agentName(s.agent)} s’hi posa.`;
223
+ }
224
+ if (name === 'decideix_aprovacio') {
225
+ const list = this.hub.pendingApprovals();
226
+ if (!list.length) return 'No hi ha cap aprovació pendent.';
227
+ const w = norm(p.quina);
228
+ const a = list.length === 1 || !w ? list[0] : list.find((x) => `${norm(x.title)} ${norm(x.agent)} ${norm(x.tool)} ${norm(describe(x.tool, x.input))}`.includes(w)) || null;
229
+ if (!a) return 'N’hi ha més d’una: pregunta quina.';
230
+ const allow = p.decisio === 'aprova';
231
+ this.hub.decide(a.id, allow ? 'allow' : 'deny', allow ? '' : 'Rebutjat per veu', 'Trucada');
232
+ return allow ? `Aprovat: ${clip(describe(a.tool, a.input), 100)}.` : 'Rebutjat.';
233
+ }
234
+ return `No conec l’eina «${name}».`;
235
+ }
236
+
237
+ routes() {
238
+ return [
239
+ { method: 'GET', path: '/api/call', handler: () => this.status() },
240
+ { method: 'POST', path: '/api/call/start', handler: () => this.start() },
241
+ { method: 'POST', path: '/api/call/tool', handler: async ({ body }) => ({ result: await this.tool(String(body?.name || ''), body?.parameters || {}) }) },
242
+ ];
243
+ }
244
+ }
245
+
246
+ module.exports = { Call, TOOLS, norm, AGENT_V };
package/src/hub.js CHANGED
@@ -7,6 +7,7 @@ const { EventEmitter } = require('events');
7
7
  const { byId, binPath } = require('./agents');
8
8
  const { HOME, save } = require('./config');
9
9
  const push = require('./push');
10
+ const usage = require('./usage');
10
11
 
11
12
  const MAX_EVENTS = 3000;
12
13
  const MAX_OUT = 4000;
@@ -75,6 +76,8 @@ class Hub extends EventEmitter {
75
76
  last: lastText ? clip(lastText.text, 160) : '',
76
77
  error: s.state === 'error' ? clip([...s.events].reverse().find((e) => e.kind === 'error')?.text || '', 200) : '',
77
78
  pending: [...this.approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length,
79
+ nativeId: s.nativeId || null,
80
+ fromTerminal: s.fromTerminal || null,
78
81
  };
79
82
  }
80
83
 
@@ -160,6 +163,25 @@ class Hub extends EventEmitter {
160
163
  return s;
161
164
  }
162
165
 
166
+ // Continua una conversa de Claude Code oberta al terminal: n'importa els últims missatges i,
167
+ // la primera vegada en fa una branca (--fork-session) perquè el terminal i el mòbil no escriguin al mateix fitxer.
168
+ resumeTerminal(s, nativeId) {
169
+ const term = require('./terminal');
170
+ s.nativeId = nativeId; s.fromTerminal = nativeId;
171
+ const file = term.findTranscript(nativeId, s.cwd);
172
+ if (file) {
173
+ const { title, msgs } = term.readTranscript(file);
174
+ if (title && !s.title) s.title = title;
175
+ const keep = msgs.slice(-40);
176
+ if (msgs.length > keep.length) this.push(s, 'info', { text: `${msgs.length - keep.length} missatges anteriors al terminal` });
177
+ for (const m of keep) this.push(s, m.role === 'user' ? 'user' : 'text', { text: m.text.length > 20000 ? `${m.text.slice(0, 20000)}…` : m.text, t: m.t || Date.now() });
178
+ }
179
+ s.forkNative = true;
180
+ this.push(s, 'info', { text: file ? 'Continua la conversa del terminal. El terminal no es toca: el que facis aquí va per una branca pròpia.' : 'Continua la conversa del terminal.' });
181
+ this.persist(s);
182
+ return s;
183
+ }
184
+
163
185
  remove(sid) {
164
186
  this.stop(sid);
165
187
  this.sessions.delete(sid);
@@ -269,6 +291,7 @@ class Hub extends EventEmitter {
269
291
  },
270
292
  toolResult: ({ id: tid, output, error }) =>
271
293
  this.push(s, 'tool_result', { id: tid, name: toolNames.get(tid), output: clip(output), error: !!error }),
294
+ limits: (info) => { if (usage.setClaude(info)) this.emit('out', { type: 'usage', usage: usage.all() }); },
272
295
  result: (r) => {
273
296
  if (typeof r.cost === 'number') s.cost = (s.cost || 0) + r.cost;
274
297
  this.push(s, 'result', r);