nio-cat 0.9.6 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/notes.js ADDED
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+ // Notes: com Google Keep, però a l'ordinador. Es desen a ~/.nio/notes/notes.json i l'àudio a ~/.nio/notes/audio.
3
+ // · Transcripció: el mòbil (o el navegador) grava i l'ordinador transcriu amb Whisper (gratis i local).
4
+ // · Resums i ordre: amb un model local d'Ollama, o amb Claude Code o Codex d'aquest ordinador.
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { spawn } = require('child_process');
10
+ const { HOME } = require('./config');
11
+ const { childEnv } = require('./proc');
12
+ const voice = require('./voice');
13
+ const ollama = require('./agents/ollama');
14
+
15
+ const DIR = path.join(HOME, 'notes');
16
+ const FILE = path.join(DIR, 'notes.json');
17
+ const AUDIO = path.join(DIR, 'audio');
18
+ const MAX_AUDIO = 25 * 1024 * 1024;
19
+ const TRASH_DAYS = 30;
20
+ const COLORS = ['', 'lemon', 'peach', 'moss', 'sky', 'lilac', 'rose', 'sand'];
21
+ const rid = () => crypto.randomBytes(6).toString('base64url');
22
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
23
+ const extOf = (mime) => (/ogg/.test(mime) ? 'ogg' : /mp4|m4a|aac/.test(mime) ? 'm4a' : /mpeg|mp3/.test(mime) ? 'mp3' : /wav/.test(mime) ? 'wav' : 'webm');
24
+ // «751.63M» → 0,75 · «8.2B» → 8,2 (mil milions de paràmetres)
25
+ const billions = (p) => { const m = String(p || '').match(/([\d.]+)\s*([MB])/i); return m ? parseFloat(m[1]) / (m[2].toUpperCase() === 'M' ? 1000 : 1) : 0; };
26
+ const str = (v, max) => String(v ?? '').slice(0, max);
27
+
28
+ // Només es deixen passar els camps coneguts, amb límits.
29
+ function clean(b = {}) {
30
+ const o = {};
31
+ if ('title' in b) o.title = str(b.title, 200);
32
+ if ('text' in b) o.text = str(b.text, 200000);
33
+ if ('items' in b) {
34
+ o.items = b.items === null ? null : (Array.isArray(b.items) ? b.items : []).slice(0, 500).map((i) => ({ id: str(i?.id, 20) || rid(), text: str(i?.text, 500), done: !!i?.done }));
35
+ }
36
+ if ('color' in b) o.color = COLORS.includes(b.color) ? b.color : '';
37
+ for (const k of ['pinned', 'archived']) if (k in b) o[k] = !!b[k];
38
+ if ('labels' in b) o.labels = [...new Set((Array.isArray(b.labels) ? b.labels : []).map((l) => str(l, 30).trim()).filter(Boolean))].slice(0, 12);
39
+ if ('trashed' in b) o.trashed = b.trashed ? Date.now() : 0;
40
+ return o;
41
+ }
42
+
43
+ class Notes {
44
+ constructor({ cfg, hub }) {
45
+ this.cfg = cfg;
46
+ this.hub = hub;
47
+ this.notes = [];
48
+ this.timer = null;
49
+ try { this.notes = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { /* encara no n'hi ha */ }
50
+ const old = Date.now() - TRASH_DAYS * 864e5;
51
+ this.notes = this.notes.filter((n) => !n.trashed || n.trashed > old);
52
+ }
53
+
54
+ save() {
55
+ clearTimeout(this.timer);
56
+ this.timer = setTimeout(() => {
57
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
58
+ const tmp = `${FILE}.tmp`;
59
+ fs.writeFile(tmp, JSON.stringify(this.notes), (e) => { if (!e) fs.rename(tmp, FILE, () => {}); });
60
+ }, 250);
61
+ this.timer.unref?.();
62
+ }
63
+
64
+ emit(note) { this.hub.emit('out', { type: 'note', note }); }
65
+ get(id) { const n = this.notes.find((x) => x.id === id); if (!n) throw httpErr(404, 'Aquesta nota no existeix'); return n; }
66
+
67
+ create(body) {
68
+ const now = Date.now();
69
+ const n = { id: rid(), title: '', text: '', items: null, color: '', pinned: false, archived: false, trashed: 0, labels: [], audio: [], created: now, updated: now, ...clean(body) };
70
+ this.notes.unshift(n);
71
+ this.save();
72
+ this.emit(n);
73
+ return n;
74
+ }
75
+
76
+ patch(id, body) {
77
+ const n = this.get(id);
78
+ Object.assign(n, clean(body), { updated: Date.now() });
79
+ this.save();
80
+ this.emit(n);
81
+ return n;
82
+ }
83
+
84
+ remove(id, forever) {
85
+ const n = this.get(id);
86
+ if (!n.trashed && !forever) return this.patch(id, { trashed: true });
87
+ for (const a of n.audio) fs.rm(this.audioFile(n, a), { force: true }, () => {});
88
+ this.notes = this.notes.filter((x) => x !== n);
89
+ this.save();
90
+ this.hub.emit('out', { type: 'note-gone', id });
91
+ return { ok: true };
92
+ }
93
+
94
+ audioFile(n, a) { return path.join(AUDIO, `${n.id}-${a.id}.${extOf(a.mime)}`); }
95
+
96
+ // Desa l'àudio i el transcriu en segon pla; el resultat arriba per «note» (flux d'esdeveniments).
97
+ addAudio(id, buf, mime, dur) {
98
+ const n = this.get(id);
99
+ if (!buf?.length || buf.length < 1500) throw httpErr(400, 'No he sentit res. Prova-ho una altra vegada.');
100
+ const a = { id: rid(), mime, size: buf.length, dur: Number(dur) > 0 ? Math.round(Number(dur)) : 0, at: Date.now(), state: 'transcribing', text: '', error: '' };
101
+ fs.mkdirSync(AUDIO, { recursive: true, mode: 0o700 });
102
+ fs.writeFileSync(this.audioFile(n, a), buf);
103
+ n.audio.push(a);
104
+ n.updated = Date.now();
105
+ this.save();
106
+ this.emit(n);
107
+ this.transcribe(n, a, buf);
108
+ return n;
109
+ }
110
+
111
+ retry(id, aid) {
112
+ const n = this.get(id), a = n.audio.find((x) => x.id === aid);
113
+ if (!a) throw httpErr(404, 'Aquest àudio no existeix');
114
+ if (a.state === 'transcribing') return n;
115
+ a.state = 'transcribing'; a.error = '';
116
+ this.emit(n);
117
+ this.transcribe(n, a, fs.readFileSync(this.audioFile(n, a)));
118
+ return n;
119
+ }
120
+
121
+ async transcribe(n, a, buf) {
122
+ try {
123
+ const { text } = await voice.transcribe(this.cfg, buf, a.mime);
124
+ const t = String(text || '').trim();
125
+ if (!t) throw new Error('No he entès res en aquest àudio.');
126
+ a.text = t; a.state = 'ok'; a.error = '';
127
+ if (n.items) {
128
+ const parts = t.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
129
+ n.items.push(...parts.map((s) => ({ id: rid(), text: s.slice(0, 500), done: false })));
130
+ } else {
131
+ n.text = n.text ? `${n.text.trimEnd()}\n\n${t}` : t;
132
+ }
133
+ } catch (e) {
134
+ a.state = 'error'; a.error = String(e.message || e).slice(0, 300);
135
+ }
136
+ if (this.notes.includes(n)) { n.updated = Date.now(); this.save(); this.emit(n); }
137
+ }
138
+
139
+ // ---------- Motors per resumir i organitzar
140
+ async engines() {
141
+ const { binPath } = require('./agents');
142
+ const out = [];
143
+ const models = await ollama.listModels();
144
+ // Un model «:cloud» d'Ollama s'executa als seus servidors: no compta com a local. Els locals, de petit a gran.
145
+ for (const m of [...(models || [])].sort((a, b) => (/cloud/.test(a.name) || !a.size) - (/cloud/.test(b.name) || !b.size) || a.size - b.size)) {
146
+ const cloud = /:cloud$|-cloud$/.test(m.name) || !m.size;
147
+ out.push({ id: `ollama:${m.name}`, label: `${m.name}${m.params ? ` · ${m.params}` : ''}${cloud ? ' · núvol' : ''}`, local: !cloud, group: cloud ? 'Ollama (núvol)' : 'Local (Ollama)', size: m.size, params: billions(m.params) });
148
+ }
149
+ if (binPath(this.cfg, 'claude')) out.push({ id: 'claude:haiku', label: 'Claude Code · ràpid', local: false, group: 'Claude Code' }, { id: 'claude', label: 'Claude Code · model per defecte', local: false, group: 'Claude Code' });
150
+ if (binPath(this.cfg, 'codex')) out.push({ id: 'codex', label: 'Codex', local: false, group: 'Codex' });
151
+ return out;
152
+ }
153
+
154
+ async ai({ id, action, engine, prompt }) {
155
+ const n = this.get(id);
156
+ const body = n.items ? `${n.text ? `${n.text}\n\n` : ''}${n.items.map((i) => `- [${i.done ? 'x' : ' '}] ${i.text}`).join('\n')}` : n.text;
157
+ if (!`${n.title}${body}`.trim()) throw httpErr(400, 'La nota és buida');
158
+ const spec = ACTIONS[action];
159
+ if (!spec) throw httpErr(400, 'Acció desconeguda');
160
+ if (action === 'custom' && !String(prompt || '').trim()) throw httpErr(400, 'Escriu què vols que faci');
161
+ const list = await this.engines();
162
+ // Per defecte: el model local més petit de 3 mil milions de paràmetres o més; si no, Claude ràpid; si no, el primer.
163
+ const auto = list.filter((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3)[0] || list.find((e) => e.id === 'claude:haiku') || list[0];
164
+ const pick = list.find((e) => e.id === engine) || auto;
165
+ if (!pick) throw httpErr(400, 'No hi ha cap model en aquest ordinador. Instal·la Ollama amb un model (ollama pull qwen3:4b) o Claude Code.');
166
+ // La nota va primer i la instrucció al final: els models petits segueixen millor l'última cosa que llegeixen.
167
+ const user = `--- NOTA ---\nTítol: ${n.title || '(sense títol)'}\n\n${body.slice(0, 40000)}\n--- FI DE LA NOTA ---\n\n${spec.task(String(prompt || '').slice(0, 1000))}`;
168
+ // Els models locals petits «pensen» en veu alta quan responen en text lliure (minuts). En mode JSON surten directes.
169
+ const local = pick.id.startsWith('ollama:'), wrap = !spec.json && local;
170
+ const raw = await runModel(this.cfg, pick.id, SYSTEM, wrap ? `${user}\n\nTorna només JSON amb aquest format: {"text":"…"}. El text pot tenir salts de línia.` : user, spec.json || wrap);
171
+ const result = spec.json ? parseJson(raw) : wrap ? { text: String(parseJson(raw).text ?? '').trim() } : { text: raw.trim() };
172
+ if (typeof result.text === 'string' && /--- (FI DE LA )?NOTA ---|Torna només JSON/.test(result.text)) throw httpErr(502, 'El model ha respost malament. Torna-ho a provar o tria un model més gran.');
173
+ if (typeof result.text === 'string') result.text = result.text.replace(/^\s*T[ií]tol:[^\n]*\n+/i, '').trim(); // els models petits repeteixen el títol
174
+ return { engine: pick.id, local: pick.local, action, result: spec.shape(result, n) };
175
+ }
176
+
177
+ routes() {
178
+ const R = (method, path, handler, extra = {}) => ({ method, path, handler, ...extra });
179
+ return [
180
+ R('GET', '/api/notes', () => ({ notes: this.notes })),
181
+ R('POST', '/api/notes', ({ body }) => this.create(body), { limit: 1024 * 1024 }),
182
+ R('GET', '/api/notes/engines', () => this.engines().then((engines) => ({ engines }))),
183
+ R('POST', '/api/notes/ai', ({ body }) => this.ai(body || {})),
184
+ R('PATCH', /^\/api\/notes\/([\w-]+)$/, ({ params, body }) => this.patch(params[0], body), { limit: 1024 * 1024 }),
185
+ R('DELETE', /^\/api\/notes\/([\w-]+)$/, ({ params, url }) => this.remove(params[0], url.searchParams.get('forever') === '1')),
186
+ R('POST', /^\/api\/notes\/([\w-]+)\/audio$/, async ({ req, params, url }) => this.addAudio(params[0], await readRaw(req), String(req.headers['content-type'] || 'audio/webm').split(';')[0], url.searchParams.get('dur')), { raw: true }),
187
+ R('POST', /^\/api\/notes\/([\w-]+)\/audio\/([\w-]+)\/retry$/, ({ params }) => this.retry(params[0], params[1])),
188
+ R('GET', /^\/api\/notes\/([\w-]+)\/audio\/([\w-]+)$/, ({ res, params }) => {
189
+ const n = this.get(params[0]), a = n.audio.find((x) => x.id === params[1]);
190
+ if (!a) throw httpErr(404, 'Aquest àudio no existeix');
191
+ const buf = fs.readFileSync(this.audioFile(n, a));
192
+ res.writeHead(200, { 'content-type': a.mime, 'content-length': buf.length, 'cache-control': 'no-store' });
193
+ res.end(buf);
194
+ }),
195
+ ];
196
+ }
197
+ }
198
+
199
+ function readRaw(req, limit = MAX_AUDIO) {
200
+ return new Promise((resolve, reject) => {
201
+ const chunks = [];
202
+ let size = 0;
203
+ req.on('data', (c) => { size += c.length; if (size > limit) { reject(httpErr(413, 'L’àudio és massa llarg')); req.destroy(); } else chunks.push(c); });
204
+ req.on('end', () => resolve(Buffer.concat(chunks)));
205
+ req.on('error', reject);
206
+ });
207
+ }
208
+
209
+ // ---------- Instruccions per a cada acció
210
+ const SYSTEM = 'Ets l’assistent de notes de nio. Treballes només amb el text de la nota que et donen. No hi afegeixes res que no hi sigui. Respon en l’idioma de la nota (si dubtes, en català). No facis servir cap eina.';
211
+ const ACTIONS = {
212
+ summary: {
213
+ json: false,
214
+ task: () => 'Escriu un resum de 3 a 5 punts curts amb les idees principals de la nota, amb altres paraules (no copiïs el text). Cada punt comença amb «- ». Només els punts, sense introducció ni títol.',
215
+ shape: (r) => ({ text: r.text }),
216
+ },
217
+ clean: {
218
+ json: false,
219
+ task: () => 'La nota és una transcripció de veu. Posa-hi puntuació i majúscules, divideix-la en paràgrafs i treu els «eh», repeticions i falsos començaments. No canviïs el sentit ni resumeixis. Torna només el text net, sense el títol.',
220
+ shape: (r) => ({ text: r.text }),
221
+ },
222
+ tasks: {
223
+ json: true,
224
+ task: () => 'Extreu les tasques pendents de la nota. Torna només JSON amb aquest format: {"items":["tasca 1","tasca 2"]}. Cada tasca és una frase curta i en forma d’acció. Si no n’hi ha cap, {"items":[]}.',
225
+ shape: (r) => ({ items: (Array.isArray(r.items) ? r.items : []).map((s) => str(s, 300).trim()).filter(Boolean).slice(0, 50) }),
226
+ },
227
+ organize: {
228
+ json: true,
229
+ task: () => 'Organitza la nota: dona-li un títol curt, ordena el contingut en paràgrafs o llistes amb Markdown senzill i proposa 1 a 3 etiquetes d’una paraula en minúscula. Conserva tot el contingut important. Torna només JSON amb aquest format: {"title":"…","text":"…","labels":["…"]}.',
230
+ shape: (r, n) => ({ title: str(r.title, 200).trim() || n.title, text: str(r.text, 200000).trim(), labels: (Array.isArray(r.labels) ? r.labels : []).map((l) => str(l, 30).trim().toLowerCase()).filter(Boolean).slice(0, 3) }),
231
+ },
232
+ title: {
233
+ json: true,
234
+ task: () => 'Proposa un títol de menys de 8 paraules per a la nota. Torna només JSON amb aquest format: {"title":"…"}.',
235
+ shape: (r) => ({ title: str(r.title, 200).trim() }),
236
+ },
237
+ custom: {
238
+ json: false,
239
+ task: (p) => `Fes això amb la nota: ${p}\nTorna només el resultat, en text.`,
240
+ shape: (r) => ({ text: r.text }),
241
+ },
242
+ };
243
+
244
+ function parseJson(raw) {
245
+ const t = String(raw || '').replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
246
+ try { return JSON.parse(t); } catch { /* potser hi ha text al voltant */ }
247
+ const i = t.indexOf('{'), j = t.lastIndexOf('}');
248
+ if (i >= 0 && j > i) { try { return JSON.parse(t.slice(i, j + 1)); } catch { /* res */ } }
249
+ throw httpErr(502, 'El model no ha tornat una resposta que pugui entendre. Prova-ho amb un altre model.');
250
+ }
251
+
252
+ // ---------- Execució d'un model, d'un sol cop
253
+ function runCli(bin, args, input, { cwd, timeout = 180000 } = {}) {
254
+ return new Promise((resolve, reject) => {
255
+ const p = spawn(bin, args, { cwd, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
256
+ let out = '', err = '';
257
+ const t = setTimeout(() => { p.kill('SIGKILL'); reject(httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.')); }, timeout);
258
+ p.stdout.on('data', (d) => { out += d; });
259
+ p.stderr.on('data', (d) => { err += d; });
260
+ p.on('error', (e) => { clearTimeout(t); reject(httpErr(500, `No he pogut engegar ${path.basename(bin)}: ${e.message}`)); });
261
+ p.on('close', (code) => { clearTimeout(t); code === 0 ? resolve(out) : reject(httpErr(502, (err || out || `Error ${code}`).trim().slice(-300))); });
262
+ p.stdin.on('error', () => {});
263
+ p.stdin.end(input);
264
+ });
265
+ }
266
+
267
+ async function runModel(cfg, engine, system, user, json) {
268
+ const { binPath } = require('./agents');
269
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-nota-'));
270
+ try {
271
+ if (engine.startsWith('ollama:')) {
272
+ // Qwen3 «pensa» abans de respondre (minuts en un model petit): «/no_think» ho desactiva. Si la resposta porta un bloc <think>, es treu.
273
+ const model = engine.slice(7), soft = /qwen3/i.test(model) ? '\n\n/no_think' : '';
274
+ const out = await ollama.chat({ model, messages: [{ role: 'system', content: system }, { role: 'user', content: user + soft }], format: json ? 'json' : undefined, think: false, maxTokens: 3000, signal: AbortSignal.timeout(150000) }).catch((e) => { throw e.name === 'TimeoutError' ? httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.') : e; });
275
+ return out.replace(/^[\s\S]*?<\/think>\s*/i, '').trim();
276
+ }
277
+ if (engine === 'claude' || engine.startsWith('claude:')) {
278
+ const args = ['-p', '--tools', '', '--no-session-persistence', '--output-format', 'text', '--system-prompt', system];
279
+ if (engine.startsWith('claude:')) args.push('--model', engine.slice(7));
280
+ return await runCli(binPath(cfg, 'claude'), args, user, { cwd: tmp });
281
+ }
282
+ if (engine === 'codex') {
283
+ const outFile = path.join(tmp, 'resposta.txt');
284
+ await runCli(binPath(cfg, 'codex'), ['exec', '--skip-git-repo-check', '--ephemeral', '-s', 'read-only', '-C', tmp, '-o', outFile, '-'], `${system}\n\n${user}`, { cwd: tmp });
285
+ return fs.readFileSync(outFile, 'utf8');
286
+ }
287
+ throw httpErr(400, 'Motor desconegut');
288
+ } finally {
289
+ fs.rm(tmp, { recursive: true, force: true }, () => {});
290
+ }
291
+ }
292
+
293
+ module.exports = { Notes, DIR, clean, parseJson, ACTIONS, COLORS };
package/src/server.js CHANGED
@@ -14,6 +14,7 @@ const { addServer, knownHosts } = require('./ssh');
14
14
  const { agentCommands } = require('./commands');
15
15
  const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
16
16
  const directory = require('./directory');
17
+ const usage = require('./usage');
17
18
 
18
19
  // L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
19
20
  const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
@@ -160,9 +161,24 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
160
161
  const seenDevices = new Map(); // mòbils que s'han connectat (per avisar l'ordinador que mostra el QR)
161
162
  const pulls = new Map(); // descàrregues de models d'Ollama en curs
162
163
 
163
- hub.on('out', (msg) => {
164
+ // Els trossos de text surten de seguida el primer cop i després s'agrupen cada 40 ms: menys paquets i menys repintats al mòbil.
165
+ const writeOut = (msg) => {
164
166
  const data = `data: ${JSON.stringify(msg)}\n\n`;
165
167
  for (const res of streams.keys()) res.write(data);
168
+ };
169
+ let pendDelta = null, deltaWin = 0;
170
+ const flushDelta = () => { if (pendDelta) { const d = pendDelta; pendDelta = null; writeOut(d); } };
171
+ hub.on('out', (msg) => {
172
+ if (msg.type === 'delta') {
173
+ if (pendDelta && (pendDelta.session !== msg.session || pendDelta.seq !== msg.seq)) flushDelta();
174
+ if (deltaWin) { if (pendDelta) pendDelta.text += msg.text; else pendDelta = { ...msg }; return; }
175
+ writeOut(msg);
176
+ deltaWin = setTimeout(() => { deltaWin = 0; flushDelta(); }, 40);
177
+ deltaWin.unref?.();
178
+ return;
179
+ }
180
+ flushDelta();
181
+ writeOut(msg);
166
182
  });
167
183
  setInterval(() => {
168
184
  for (const res of streams.keys()) res.write(': ping\n\n');
@@ -181,6 +197,7 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
181
197
  return hit.r.handler({ req, res, url, params: hit.params, body, device });
182
198
  }
183
199
 
200
+ if (p === '/api/usage' && method === 'GET') return usage.all();
184
201
  if (p === '/api/info' && method === 'GET') {
185
202
  return {
186
203
  name: 'nio',
@@ -294,12 +311,19 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
294
311
  return r;
295
312
  }
296
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
+
297
320
  if (p === '/api/sessions' && method === 'GET') return hub.list();
298
321
  if (p === '/api/sessions' && method === 'POST') {
299
322
  const b = await readBody(req);
300
323
  const cwd = path.resolve(b.cwd || cfg.roots[0]);
301
324
  if (!isDir(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
302
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));
303
327
  if (b.text) hub.send(s.id, b.text, hookUrl());
304
328
  return hub.summary(s);
305
329
  }
@@ -453,7 +477,7 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
453
477
  connection: 'keep-alive',
454
478
  });
455
479
  req.socket?.setNoDelay(true);
456
- res.write(`data: ${JSON.stringify({ type: 'hello', sessions: hub.list(), approvals: hub.pendingApprovals() })}\n\n`);
480
+ res.write(`data: ${JSON.stringify({ type: 'hello', sessions: hub.list(), approvals: hub.pendingApprovals(), usage: usage.all() })}\n\n`);
457
481
  const dev = String(url.searchParams.get('d') || '').slice(0, 40), kind = url.searchParams.get('k') === 'phone' ? 'phone' : 'desktop';
458
482
  if (dev && kind === 'phone' && Date.now() - (seenDevices.get(dev) || 0) > 10 * 60 * 1000) {
459
483
  const data = `data: ${JSON.stringify({ type: 'device', device: dev, kind })}\n\n`;
@@ -498,4 +522,4 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
498
522
  return server;
499
523
  }
500
524
 
501
- 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 };
package/src/usage.js ADDED
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ // Ús de la subscripció (finestra de 5 h i de la setmana). Claude el diu al flux (rate_limit_event);
3
+ // Codex el desa als seus registres (~/.codex/sessions). No es llegeix cap clau ni cap compte.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { HOME } = require('./config');
8
+
9
+ const FILE = path.join(HOME, 'usage.json');
10
+ const CODEX_DIR = path.join(os.homedir(), '.codex', 'sessions');
11
+ const saved = {}; // agent -> { windows: [{ label, used, resetsAt }], at }
12
+ try { Object.assign(saved, JSON.parse(fs.readFileSync(FILE, 'utf8'))); } catch { /* encara res */ }
13
+
14
+ const LABELS = { five_hour: '5 h', seven_day: 'setmana' };
15
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
16
+ const label = (min) => (min === 300 ? '5 h' : min === 10080 ? 'setmana' : min % 60 === 0 ? `${min / 60} h` : `${min} min`);
17
+
18
+ // Claude: { unifiedWindows: { five_hour: { utilization: 0..1, resetsAt: segons }, seven_day: … } }
19
+ function setClaude(info) {
20
+ const windows = [];
21
+ for (const [k, w] of Object.entries(info?.unifiedWindows || {})) {
22
+ const used = num(w?.utilization);
23
+ if (used === null || !LABELS[k]) continue;
24
+ windows.push({ label: LABELS[k], used: Math.min(1, Math.max(0, used)), resetsAt: num(w.resetsAt) });
25
+ }
26
+ if (!windows.length) return false;
27
+ windows.sort((a, b) => (a.label === '5 h' ? -1 : 1) - (b.label === '5 h' ? -1 : 1));
28
+ const prev = JSON.stringify(saved.claude?.windows);
29
+ saved.claude = { windows, at: Date.now() };
30
+ if (JSON.stringify(windows) === prev) return false;
31
+ fs.writeFile(FILE, JSON.stringify({ claude: saved.claude }), () => {});
32
+ return true;
33
+ }
34
+
35
+ // Codex: l'últim `token_count` de l'últim registre porta rate_limits.{primary,secondary}.
36
+ const desc = (dir) => { try { return fs.readdirSync(dir).sort().reverse(); } catch { return []; } };
37
+ function codexFiles(max = 3) {
38
+ const out = [];
39
+ for (const y of desc(CODEX_DIR)) for (const m of desc(path.join(CODEX_DIR, y))) for (const d of desc(path.join(CODEX_DIR, y, m))) {
40
+ for (const f of desc(path.join(CODEX_DIR, y, m, d))) { if (f.endsWith('.jsonl')) out.push(path.join(CODEX_DIR, y, m, d, f)); if (out.length >= max) return out; }
41
+ }
42
+ return out;
43
+ }
44
+ function tailLimits(file) {
45
+ let fd;
46
+ try {
47
+ fd = fs.openSync(file, 'r');
48
+ const size = fs.fstatSync(fd).size, len = Math.min(size, 256 * 1024), buf = Buffer.alloc(len);
49
+ fs.readSync(fd, buf, 0, len, size - len);
50
+ const lines = buf.toString('utf8').split('\n');
51
+ for (let i = lines.length - 1; i >= 0; i--) {
52
+ if (!lines[i].includes('"rate_limits"')) continue;
53
+ let o; try { o = JSON.parse(lines[i]); } catch { continue; }
54
+ const rl = o?.payload?.rate_limits; if (!rl) continue;
55
+ const windows = ['primary', 'secondary'].map((k) => rl[k]).filter(Boolean).map((w) => ({ label: label(w.window_minutes), used: Math.min(1, Math.max(0, (num(w.used_percent) || 0) / 100)), resetsAt: num(w.resets_at) }));
56
+ if (windows.length) return { windows, at: Date.parse(o.timestamp) || Date.now() };
57
+ }
58
+ } catch { /* sense registre */ } finally { if (fd !== undefined) fs.closeSync(fd); }
59
+ return null;
60
+ }
61
+ let codexCache = { t: 0, v: null };
62
+ function codex() {
63
+ if (Date.now() - codexCache.t < 5000) return codexCache.v;
64
+ let v = null;
65
+ for (const f of codexFiles()) if ((v = tailLimits(f))) break;
66
+ codexCache = { t: Date.now(), v };
67
+ return v;
68
+ }
69
+
70
+ // Una finestra que ja s'ha reiniciat torna a 0 fins que l'agent n'informi de nou.
71
+ const fresh = (u) => u && { windows: u.windows.map((w) => (w.resetsAt && w.resetsAt * 1000 <= Date.now() ? { label: w.label, used: 0, resetsAt: null } : w)), at: u.at };
72
+ function all() {
73
+ const out = {}, c = fresh(saved.claude), x = fresh(codex());
74
+ if (c) out.claude = c;
75
+ if (x) out.codex = x;
76
+ return out;
77
+ }
78
+
79
+ module.exports = { setClaude, all };