nio-cat 0.9.7 → 0.9.10

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/bin/nio.js CHANGED
@@ -7,7 +7,7 @@ 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');
@@ -15,6 +15,7 @@ const { Auth } = require('../src/auth');
15
15
  const { Audit } = require('../src/audit');
16
16
  const memory = require('../src/memory');
17
17
  const { Notes } = require('../src/notes');
18
+ const { Call } = require('../src/call');
18
19
  const mcp = require('../src/mcp');
19
20
  const directory = require('../src/directory');
20
21
  const voice = require('../src/voice');
@@ -215,7 +216,8 @@ async function start() {
215
216
  const control = new Control({ cfg, hub, audit });
216
217
  const remote = new Remote({ cfg, audit });
217
218
  const notes = new Notes({ cfg, hub });
218
- const routes = [...memory.routes(), ...notes.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes(), ...remote.routes()];
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()];
219
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 });
220
222
 
221
223
  server.on('error', (e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.7",
3
+ "version": "0.9.10",
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,7 +17,11 @@ 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);
23
+ // Que l'agent posi un títol que resumeixi la feina (eina MCP de nio; el hub el desa).
24
+ args.push('--append-system-prompt', 'Treballes dins una sessió de nio. Quan entenguis la tasca, crida l’eina mcp__nio__sessio_titol amb un títol de 3-7 paraules que la resumeixi, en l’idioma de l’usuari; torna-la a cridar només si la feina canvia de tema.');
21
25
 
22
26
  if (session.mode === 'tot' && process.getuid?.() === 0) {
23
27
  ctx.fail('Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.');
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 };
@@ -6,6 +6,7 @@
6
6
  const READ_ONLY = new Set([
7
7
  'Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'WebSearch', 'WebFetch',
8
8
  'TodoWrite', 'TodoRead', 'Task', 'Agent', 'ToolSearch', 'Skill', 'BashOutput',
9
+ 'mcp__nio__sessio_titol',
9
10
  ]);
10
11
 
11
12
  function reply(decision, reason) {
package/src/hub.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const crypto = require('crypto');
6
6
  const { EventEmitter } = require('events');
7
7
  const { byId, binPath } = require('./agents');
8
+ const TITLE_TOOL = /(^|[._])sessio_titol$/;
8
9
  const { HOME, save } = require('./config');
9
10
  const push = require('./push');
10
11
  const usage = require('./usage');
@@ -76,6 +77,8 @@ class Hub extends EventEmitter {
76
77
  last: lastText ? clip(lastText.text, 160) : '',
77
78
  error: s.state === 'error' ? clip([...s.events].reverse().find((e) => e.kind === 'error')?.text || '', 200) : '',
78
79
  pending: [...this.approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length,
80
+ nativeId: s.nativeId || null,
81
+ fromTerminal: s.fromTerminal || null,
79
82
  };
80
83
  }
81
84
 
@@ -161,6 +164,25 @@ class Hub extends EventEmitter {
161
164
  return s;
162
165
  }
163
166
 
167
+ // Continua una conversa de Claude Code oberta al terminal: n'importa els últims missatges i,
168
+ // la primera vegada en fa una branca (--fork-session) perquè el terminal i el mòbil no escriguin al mateix fitxer.
169
+ resumeTerminal(s, nativeId) {
170
+ const term = require('./terminal');
171
+ s.nativeId = nativeId; s.fromTerminal = nativeId;
172
+ const file = term.findTranscript(nativeId, s.cwd);
173
+ if (file) {
174
+ const { title, msgs } = term.readTranscript(file);
175
+ if (title && !s.title) s.title = title;
176
+ const keep = msgs.slice(-40);
177
+ if (msgs.length > keep.length) this.push(s, 'info', { text: `${msgs.length - keep.length} missatges anteriors al terminal` });
178
+ 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() });
179
+ }
180
+ s.forkNative = true;
181
+ 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.' });
182
+ this.persist(s);
183
+ return s;
184
+ }
185
+
164
186
  remove(sid) {
165
187
  this.stop(sid);
166
188
  this.sessions.delete(sid);
@@ -185,7 +207,7 @@ class Hub extends EventEmitter {
185
207
  const s = this.get(sid);
186
208
  if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
187
209
  if (patch.mode && ['llegir', 'preguntar', 'tot'].includes(patch.mode)) s.mode = patch.mode;
188
- if (typeof patch.title === 'string') s.title = patch.title.slice(0, 120);
210
+ if (typeof patch.title === 'string') { s.title = patch.title.slice(0, 120); s.titleBy = 'user'; } // un nom posat a mà no el canvia l'agent
189
211
  if (typeof patch.model === 'string') s.model = patch.model.slice(0, 80);
190
212
  if (typeof patch.cwd === 'string' && path.resolve(patch.cwd) !== s.cwd) {
191
213
  const cwd = path.resolve(patch.cwd);
@@ -264,6 +286,12 @@ class Hub extends EventEmitter {
264
286
  stream: (t) => this.chunk(s, String(t)),
265
287
  tool: ({ id: tid, name, input }) => {
266
288
  toolNames.set(tid, name);
289
+ // L'agent posa títol a la sessió amb l'eina MCP sessio_titol (Claude: mcp__nio__sessio_titol, Codex: nio.sessio_titol).
290
+ if (TITLE_TOOL.test(name || '') && typeof input === 'string') try { input = JSON.parse(input); } catch { input = {}; }
291
+ if (TITLE_TOOL.test(name || '') && s.titleBy !== 'user') {
292
+ const t = String(input?.titol || '').replace(/\s+/g, ' ').trim().replace(/[.。]$/, '').slice(0, 80);
293
+ if (t && t !== s.title) { s.title = t; s.titleBy = 'agent'; this.touch(s); }
294
+ }
267
295
  const inp = summarizeInput(input);
268
296
  this.push(s, 'tool', { id: tid, name, input: inp });
269
297
  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 });
package/src/mcp.js CHANGED
@@ -50,6 +50,14 @@ const TOOLS = [
50
50
  return `Desat a ${r.path} (${r.size} B).`;
51
51
  },
52
52
  },
53
+ {
54
+ name: 'sessio_titol',
55
+ description: 'Posa títol a la sessió de nio on treballes: 3-7 paraules que resumeixin la feina (p. ex. «Mode fosc a la botiga»), perquè l’usuari la reconegui a la llista. Crida-la quan entenguis la tasca i torna-la a cridar si la feina canvia de tema. Fora de nio no fa res.',
56
+ inputSchema: { type: 'object', properties: { titol: { type: 'string', description: 'Títol curt, sense punt final' } }, required: ['titol'], additionalProperties: false },
57
+ annotations: { readOnlyHint: true },
58
+ // El títol el posa nio quan veu passar aquesta crida (hub.js); aquí només es confirma.
59
+ run: ({ titol }) => (String(titol || '').trim() ? `Títol: ${String(titol).trim().slice(0, 80)}` : 'Falta el títol.'),
60
+ },
53
61
  ];
54
62
 
55
63
  function serve({ input = process.stdin, output = process.stdout } = {}) {
@@ -64,7 +72,7 @@ function serve({ input = process.stdin, output = process.stdout } = {}) {
64
72
  protocolVersion: params?.protocolVersion || '2025-06-18',
65
73
  capabilities: { tools: {} },
66
74
  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.',
75
+ 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. Si treballes dins una sessió de nio, posa-li un títol curt amb sessio_titol.',
68
76
  });
69
77
  case 'ping':
70
78
  return reply({});
package/src/server.js CHANGED
@@ -311,12 +311,19 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
311
311
  return r;
312
312
  }
313
313
 
314
+ if (p === '/api/claude-sessions' && method === 'GET') {
315
+ const { binPath } = require('./agents');
316
+ const { childEnv } = require('./proc');
317
+ return { sessions: await require('./terminal').listSessions(binPath(cfg, 'claude'), childEnv()) };
318
+ }
319
+
314
320
  if (p === '/api/sessions' && method === 'GET') return hub.list();
315
321
  if (p === '/api/sessions' && method === 'POST') {
316
322
  const b = await readBody(req);
317
323
  const cwd = path.resolve(b.cwd || cfg.roots[0]);
318
324
  if (!isDir(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
319
325
  const s = hub.create({ agent: b.agent, cwd, title: b.title, mode: b.mode, model: b.model });
326
+ if (b.resumeId) hub.resumeTerminal(s, String(b.resumeId));
320
327
  if (b.text) hub.send(s.id, b.text, hookUrl());
321
328
  return hub.summary(s);
322
329
  }
@@ -515,4 +522,4 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
515
522
  return server;
516
523
  }
517
524
 
518
- module.exports = { createServer, originAllowed, readBody };
525
+ module.exports = { createServer, originAllowed, readBody, listProjects };
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+ // Sessions de Claude Code obertes al terminal: quines hi ha, en quin terminal corren
3
+ // i què s'hi ha dit, per poder-les continuar des del mòbil.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { execFile } = require('child_process');
8
+
9
+ const exec = (bin, args, opts = {}) => new Promise((resolve) => {
10
+ execFile(bin, args, { timeout: 8000, maxBuffer: 4 << 20, ...opts }, (err, out) => resolve(err && !out ? '' : String(out || '')));
11
+ });
12
+
13
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
14
+
15
+ // ---------------------------------------------------------------- en quin terminal corre
16
+ const PROGRAMS = {
17
+ Apple_Terminal: 'Terminal', 'iTerm.app': 'iTerm', vscode: 'VS Code', WarpTerminal: 'Warp', ghostty: 'Ghostty',
18
+ WezTerm: 'WezTerm', Hyper: 'Hyper', Tabby: 'Tabby', 'rio': 'Rio', zed: 'Zed', kitty: 'kitty', alacritty: 'Alacritty',
19
+ };
20
+ // Noms de procés (pares) que delaten el terminal quan l'entorn no ho diu.
21
+ const PARENTS = [
22
+ [/^(Cursor|cursor)/, 'Cursor'], [/Code Helper|^code$|\/code$/, 'VS Code'], [/iTerm/, 'iTerm'], [/^Terminal$|Terminal\.app/, 'Terminal'],
23
+ [/[Ww]arp/, 'Warp'], [/ghostty/i, 'Ghostty'], [/wezterm/i, 'WezTerm'], [/kitty/, 'kitty'], [/alacritty/i, 'Alacritty'],
24
+ [/gnome-terminal/, 'GNOME Terminal'], [/konsole/, 'Konsole'], [/xfce4-terminal/, 'Xfce Terminal'], [/tilix/, 'Tilix'],
25
+ [/terminator/, 'Terminator'], [/xterm/, 'xterm'], [/foot$/, 'foot'], [/^zed|Zed/, 'Zed'], [/idea|pycharm|webstorm|goland|jetbrains/i, 'JetBrains'],
26
+ ];
27
+
28
+ async function procEnv(pid) {
29
+ if (process.platform === 'linux') {
30
+ try { return Object.fromEntries(fs.readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').filter(Boolean).map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1)]; })); } catch { return {}; }
31
+ }
32
+ if (process.platform === 'darwin') {
33
+ // `ps eww` afegeix l'entorn a l'ordre (només per a processos del mateix usuari).
34
+ const out = await exec('ps', ['eww', '-o', 'command=', '-p', String(pid)]);
35
+ const env = {};
36
+ for (const m of out.matchAll(/(?:^|\s)([A-Z_][A-Z0-9_]*)=(\S*)/g)) env[m[1]] = m[2];
37
+ return env;
38
+ }
39
+ return {};
40
+ }
41
+ async function parents(pid) {
42
+ const names = [];
43
+ for (let p = pid, i = 0; p > 1 && i < 12; i++) {
44
+ let ppid = 0, comm = '';
45
+ if (process.platform === 'linux') {
46
+ try {
47
+ const st = fs.readFileSync(`/proc/${p}/stat`, 'utf8'), close = st.lastIndexOf(')');
48
+ comm = st.slice(st.indexOf('(') + 1, close); ppid = Number(st.slice(close + 2).split(' ')[1]);
49
+ } catch { break; }
50
+ } else {
51
+ const out = (await exec('ps', ['-o', 'ppid=,comm=', '-p', String(p)])).trim();
52
+ const m = out.match(/^(\d+)\s+(.*)$/); if (!m) break;
53
+ ppid = Number(m[1]); comm = m[2];
54
+ }
55
+ if (p !== pid) names.push(comm);
56
+ p = ppid;
57
+ }
58
+ return names;
59
+ }
60
+ async function detectTerminal(pid) {
61
+ const env = await procEnv(pid), chain = await parents(pid);
62
+ let app = '';
63
+ if (env.CURSOR_TRACE_ID || chain.some((c) => /Cursor/i.test(c))) app = 'Cursor';
64
+ else if (env.TERM_PROGRAM && PROGRAMS[env.TERM_PROGRAM]) app = PROGRAMS[env.TERM_PROGRAM];
65
+ else if (env.TERMINAL_EMULATOR?.includes('JetBrains')) app = 'JetBrains';
66
+ else if (env.KITTY_WINDOW_ID) app = 'kitty';
67
+ else if (env.ALACRITTY_WINDOW_ID || env.ALACRITTY_SOCKET) app = 'Alacritty';
68
+ else if (env.WT_SESSION) app = 'Windows Terminal';
69
+ else if (env.TERM_PROGRAM && env.TERM_PROGRAM !== 'tmux') app = env.TERM_PROGRAM;
70
+ if (!app) for (const c of chain) { const hit = PARENTS.find(([re]) => re.test(c)); if (hit) { app = hit[1]; break; } }
71
+ const tmux = !!env.TMUX || chain.some((c) => /^tmux/.test(c)), screen = !!env.STY;
72
+ const ssh = !!(env.SSH_CONNECTION || env.SSH_TTY) || chain.some((c) => /sshd/.test(c));
73
+ return { app: app || (ssh ? 'SSH' : 'Terminal'), tmux, screen, ssh: ssh && app !== 'SSH' };
74
+ }
75
+
76
+ // ---------------------------------------------------------------- què s'hi ha dit
77
+ const projDir = (cwd) => path.join(os.homedir(), '.claude', 'projects', String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-'));
78
+ function findTranscript(id, cwd) {
79
+ const direct = path.join(projDir(cwd), `${id}.jsonl`);
80
+ if (fs.existsSync(direct)) return direct;
81
+ try {
82
+ const base = path.join(os.homedir(), '.claude', 'projects');
83
+ for (const d of fs.readdirSync(base)) { const f = path.join(base, d, `${id}.jsonl`); if (fs.existsSync(f)) return f; }
84
+ } catch { /* sense carpeta */ }
85
+ return null;
86
+ }
87
+ // Llegeix només el final del fitxer: les converses llargues pesen megues.
88
+ function tailLines(file, bytes) {
89
+ let fd;
90
+ try {
91
+ fd = fs.openSync(file, 'r');
92
+ const size = fs.fstatSync(fd).size, start = Math.max(0, size - bytes), buf = Buffer.alloc(size - start);
93
+ fs.readSync(fd, buf, 0, buf.length, start);
94
+ const lines = buf.toString('utf8').split('\n');
95
+ if (start > 0) lines.shift();
96
+ return lines.filter(Boolean);
97
+ } catch { return []; } finally { if (fd !== undefined) fs.closeSync(fd); }
98
+ }
99
+ const clean = (t) => String(t || '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
100
+ // Missatges de la conversa (persona i Claude), sense metadades, ordres locals ni resultats d'eines.
101
+ function readTranscript(file, bytes = 3 << 20) {
102
+ const msgs = []; let title = '', prompt = '';
103
+ for (const l of tailLines(file, bytes)) {
104
+ let d; try { d = JSON.parse(l); } catch { continue; }
105
+ if (d.type === 'ai-title' && (d.aiTitle || d.title)) title = d.aiTitle || d.title;
106
+ else if (d.type === 'custom-title' && d.customTitle) title = d.customTitle;
107
+ else if (d.type === 'last-prompt' && d.lastPrompt) prompt = d.lastPrompt;
108
+ else if (d.type === 'summary' && d.summary && !title) title = d.summary;
109
+ if (d.isMeta || d.isSidechain || (d.type !== 'user' && d.type !== 'assistant')) continue;
110
+ const c = d.message?.content, t = Date.parse(d.timestamp) || 0;
111
+ if (d.type === 'user') {
112
+ const text = clean(typeof c === 'string' ? c : Array.isArray(c) ? c.filter((b) => b.type === 'text').map((b) => b.text).join('\n') : '');
113
+ if (text && !text.startsWith('<')) msgs.push({ role: 'user', text, t });
114
+ } else if (Array.isArray(c)) {
115
+ const text = clean(c.filter((b) => b.type === 'text').map((b) => b.text).join('\n\n'));
116
+ if (!text) continue;
117
+ const last = msgs[msgs.length - 1];
118
+ if (last?.role === 'assistant') { last.text += `\n\n${text}`; last.t = t; } else msgs.push({ role: 'assistant', text, t });
119
+ }
120
+ }
121
+ return { title, msgs, prompt };
122
+ }
123
+
124
+ // ---------------------------------------------------------------- llista
125
+ async function listSessions(bin, env) {
126
+ if (!bin) return [];
127
+ let list;
128
+ try { list = JSON.parse(await exec(bin, ['agents', '--json'], { env })); } catch { return []; }
129
+ if (!Array.isArray(list)) return [];
130
+ const out = [];
131
+ for (const s of list) {
132
+ if (!s?.sessionId || (s.pid && !alive(s.pid))) continue;
133
+ const file = findTranscript(s.sessionId, s.cwd);
134
+ const tr = file ? readTranscript(file, 512 << 10) : { title: '', msgs: [], prompt: '' };
135
+ const lastUser = [...tr.msgs].reverse().find((m) => m.role === 'user'), last = tr.msgs[tr.msgs.length - 1];
136
+ out.push({
137
+ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || '', kind: s.kind || 'interactive', status: s.status || 'idle',
138
+ name: s.name || '', title: tr.title || '', startedAt: s.startedAt || null, updated: last?.t || s.startedAt || null,
139
+ ask: (lastUser?.text || tr.prompt).slice(0, 200), last: last ? last.text.slice(0, 200) : '',
140
+ terminal: s.kind === 'background' ? { app: 'En segon pla' } : s.pid ? await detectTerminal(s.pid) : { app: 'Terminal' },
141
+ transcript: !!file,
142
+ });
143
+ }
144
+ return out.sort((a, b) => (b.status === 'busy') - (a.status === 'busy') || (b.updated || 0) - (a.updated || 0));
145
+ }
146
+
147
+ module.exports = { listSessions, readTranscript, findTranscript, detectTerminal, alive };