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 +4 -2
- package/package.json +1 -1
- package/src/agents/claude.js +2 -0
- package/src/call.js +246 -0
- package/src/hub.js +21 -0
- package/src/server.js +8 -1
- package/src/terminal.js +147 -0
- package/web/app/app.js +149 -38
- package/web/app/call.css +80 -0
- package/web/app/call.js +435 -0
- package/web/app/demo.js +1 -0
- package/web/app/index.html +1 -0
- package/web/app/notes.css +96 -25
- package/web/app/notes.js +275 -32
- package/web/app/style.css +57 -0
- package/web/sw.js +2 -2
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
|
|
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
package/src/agents/claude.js
CHANGED
|
@@ -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 };
|
package/src/terminal.js
ADDED
|
@@ -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 };
|