nio-cat 0.9.7 → 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/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.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) {
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
@@ -76,6 +76,8 @@ class Hub extends EventEmitter {
76
76
  last: lastText ? clip(lastText.text, 160) : '',
77
77
  error: s.state === 'error' ? clip([...s.events].reverse().find((e) => e.kind === 'error')?.text || '', 200) : '',
78
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,
79
81
  };
80
82
  }
81
83
 
@@ -161,6 +163,25 @@ class Hub extends EventEmitter {
161
163
  return s;
162
164
  }
163
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
+
164
185
  remove(sid) {
165
186
  this.stop(sid);
166
187
  this.sessions.delete(sid);
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 };