@danieltmn/tamnora 0.7.1 → 0.9.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +9 -0
  3. package/SECURITY.md +11 -6
  4. package/harness/packages/cli/src/cli.js +1 -1
  5. package/harness/packages/cli/src/commands/bridge.js +180 -119
  6. package/harness/packages/cli/src/commands/run.js +1 -1
  7. package/harness/packages/cli/src/commands/server.js +26 -50
  8. package/harness/packages/cli/src/commands/tui.js +1 -1
  9. package/harness/packages/cli/src/lib/daemon.js +92 -40
  10. package/harness/packages/cli/test/release.test.js +9 -0
  11. package/harness/packages/core/src/agent/modes.js +1 -1
  12. package/harness/packages/core/src/agent/runAgent.js +3 -0
  13. package/harness/packages/core/src/git/index.js +177 -0
  14. package/harness/packages/core/src/index.js +16 -1
  15. package/harness/packages/core/src/open.js +43 -0
  16. package/harness/packages/core/src/pid.js +35 -0
  17. package/harness/packages/core/src/procs/kill.js +95 -0
  18. package/harness/packages/core/src/tools/executors.js +16 -0
  19. package/harness/packages/core/src/tools/labels.js +2 -0
  20. package/harness/packages/core/src/tools/permissions.js +1 -0
  21. package/harness/packages/core/src/tools/schema.js +17 -0
  22. package/harness/packages/core/src/tts/edge.js +232 -0
  23. package/harness/packages/core/src/tunnel.js +180 -19
  24. package/harness/packages/core/test/open.test.js +19 -0
  25. package/harness/packages/core/test/procs.test.js +50 -0
  26. package/harness/packages/core/test/tools.test.js +12 -0
  27. package/harness/packages/core/test/tts.test.js +41 -0
  28. package/harness/packages/server/public/app.js +4715 -3669
  29. package/harness/packages/server/public/index.html +94 -5
  30. package/harness/packages/server/public/landing/index.html +5 -5
  31. package/harness/packages/server/public/reconcile.js +121 -0
  32. package/harness/packages/server/public/relayClient.js +170 -0
  33. package/harness/packages/server/public/runStream.js +207 -0
  34. package/harness/packages/server/public/styles.css +196 -23
  35. package/harness/packages/server/src/auth.js +29 -5
  36. package/harness/packages/server/src/lib/agentRun.js +77 -0
  37. package/harness/packages/server/src/lib/announcer.js +92 -0
  38. package/harness/packages/server/src/lib/approvals.js +25 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  40. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  41. package/harness/packages/server/src/lib/procs.js +3 -18
  42. package/harness/packages/server/src/lib/runManager.js +24 -2
  43. package/harness/packages/server/src/lib/sessionView.js +43 -0
  44. package/harness/packages/server/src/lib/tasks.js +4 -9
  45. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  46. package/harness/packages/server/src/lib/workspace.js +37 -1
  47. package/harness/packages/server/src/routes/chat.js +51 -47
  48. package/harness/packages/server/src/routes/events.js +35 -0
  49. package/harness/packages/server/src/routes/files.js +22 -26
  50. package/harness/packages/server/src/routes/folders.js +9 -17
  51. package/harness/packages/server/src/routes/git.js +35 -0
  52. package/harness/packages/server/src/routes/procs.js +25 -15
  53. package/harness/packages/server/src/routes/sessions.js +12 -19
  54. package/harness/packages/server/src/routes/shell.js +19 -0
  55. package/harness/packages/server/src/routes/state.js +32 -0
  56. package/harness/packages/server/src/routes/tts.js +82 -0
  57. package/harness/packages/server/src/server.js +25 -1
  58. package/harness/packages/server/test/announcer.test.js +51 -0
  59. package/harness/packages/server/test/reconcile.test.js +146 -0
  60. package/harness/packages/server/test/relay.test.js +219 -0
  61. package/harness/packages/server/test/runStream.test.js +245 -0
  62. package/harness/packages/server/test/security.test.js +252 -0
  63. package/harness/packages/server/test/server.test.js +617 -1
  64. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  65. package/harness/packages/server/test/tts.test.js +133 -0
  66. package/package.json +1 -1
@@ -1,8 +1,6 @@
1
- import { spawn } from 'node:child_process';
2
- import { appendFileSync, writeFileSync, readFileSync } from 'node:fs';
1
+ import { writeFileSync, readFileSync } from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import {
5
- setHome,
6
4
  homeDir,
7
5
  ensureHome,
8
6
  homeExists,
@@ -12,10 +10,12 @@ import {
12
10
  databasePath,
13
11
  pidPath,
14
12
  serverLogPath,
13
+ openBrowser,
15
14
  } from '../../../core/src/index.js';
16
15
  import { createTamnoraServer } from '../../../server/src/index.js';
17
16
  import { parse, num } from '../lib/util.js';
18
17
  import {
18
+ applyHomeOverride,
19
19
  killPid,
20
20
  pidAlive,
21
21
  readRuntime,
@@ -23,6 +23,9 @@ import {
23
23
  clearRuntime,
24
24
  stopRunningServer,
25
25
  portHint,
26
+ createFileLogger,
27
+ spawnDetached,
28
+ waitForState,
26
29
  tailLines,
27
30
  } from '../lib/daemon.js';
28
31
 
@@ -42,29 +45,16 @@ const OPTIONS = {
42
45
  dir: { type: 'string' },
43
46
  home: { type: 'string' },
44
47
  force: { type: 'boolean' },
48
+ 'no-open': { type: 'boolean' },
45
49
  yes: { type: 'boolean', short: 'y' },
46
50
  help: { type: 'boolean', short: 'h' },
47
51
  };
48
52
 
49
53
  function applyHome(values) {
50
- const override = values.home ? path.resolve(values.home) : process.env.TAMNORA_HOME || null;
51
- if (values.home) {
52
- setHome(values.home);
53
- process.env.TAMNORA_HOME = override;
54
- }
55
- return override;
54
+ applyHomeOverride(values);
56
55
  }
57
56
 
58
- function log(line) {
59
- const text = `[${new Date().toISOString()}] ${line}`;
60
- try {
61
- ensureHome();
62
- appendFileSync(serverLogPath(), `${text}\n`);
63
- } catch {
64
- /* sin log no detenemos nada */
65
- }
66
- console.log(text);
67
- }
57
+ const log = createFileLogger(serverLogPath());
68
58
 
69
59
  export async function serverCommand(argv) {
70
60
  const { values } = parse(argv, OPTIONS);
@@ -118,34 +108,29 @@ async function cmdStart(values) {
118
108
  const isChild = Boolean(process.env.TAMNORA_DETACHED);
119
109
  const foreground = Boolean(values.stream || values.foreground) || isChild;
120
110
 
121
- if (!isChild) {
111
+ if (!isChild) {
122
112
  const cur = readRuntime();
123
113
  if (cur && pidAlive(cur.pid)) {
124
114
  console.log(`Tamnora ya esta corriendo (pid ${cur.pid}).`);
125
115
  printStatus(cur);
116
+ // Aprovechamos que ya esta sirviendo para abrir la consola.
117
+ if (!values['no-open']) await openBrowser(cur.url, { log: (line) => console.warn(line) });
126
118
  return 0;
127
119
  }
128
120
  }
129
121
 
130
- if (!foreground) {
122
+ if (!foreground) {
131
123
  const bin = process.argv[1];
132
124
  const args = [bin, 'server', '--stream', '--port', String(port), '--host', host];
133
125
  if (values.dir) args.push('--dir', cwd);
134
126
  if (process.env.TAMNORA_HOME) args.push('--home', process.env.TAMNORA_HOME);
135
127
  if (values.yes) args.push('--yes');
136
- const child = spawn(process.execPath, args, {
137
- detached: true,
138
- stdio: 'ignore',
139
- windowsHide: true,
140
- env: { ...process.env, TAMNORA_DETACHED: '1' },
141
- });
142
- child.unref();
143
- let dead = false;
144
- child.on('exit', () => {
145
- dead = true;
146
- });
128
+ // El hijo es quien.listen() y por lo tanto quien abre el navegador: el flag
129
+ // tiene que viajar con el o `--no-open` no serviria de nada.
130
+ if (values['no-open']) args.push('--no-open');
131
+ const { child, isDead } = spawnDetached(args, { envFlag: 'TAMNORA_DETACHED' });
147
132
  console.log(`Tamnora arrancando en segundo plano (pid ${child.pid})...`);
148
- const rt = await waitForRuntime(child.pid, () => dead, 30000);
133
+ const rt = await waitForState(() => readRuntime(), { childPid: child.pid, isDead, timeoutMs: 30000, tickMs: 400 });
149
134
  const tail = rt ? '' : tailLines(serverLogPath(), 8);
150
135
  if (tail) {
151
136
  console.log('');
@@ -202,10 +187,13 @@ async function runForeground({ values, port, host, cwd }) {
202
187
  } catch {
203
188
  /* nada */
204
189
  }
205
- log(`Tamnora escuchando en ${info.url} (workspace ${cwd})`);
206
- if (!instance.ctx.authEnabled) {
190
+ log(`Tamnora escuchando en ${info.url} (workspace ${cwd})`);
191
+ if (!instance.ctx.authEnabled) {
207
192
  log('aviso: sin usuario configurado; el server no pide login. Crea uno: tamnora user --name <n> --password <p>');
208
193
  }
194
+ // Abrir la consola es lo esperable al arrancar. Solo cuando queda escuchando:
195
+ // si hubo EADDRINUSE ya hay una consola abierta sirviendo.
196
+ if (!values['no-open']) await openBrowser(info.url, { log: (line) => log(line) });
209
197
 
210
198
  return new Promise((resolve) => {
211
199
  let closing = false;
@@ -247,8 +235,9 @@ async function cmdStop() {
247
235
  }
248
236
 
249
237
  async function cmdReload(values) {
238
+ const previous = readRuntime();
250
239
  await cmdStop();
251
- for (let i = 0; i < 25 && pidAlive(readPid()); i++) {
240
+ for (let i = 0; i < 25 && previous?.pid && pidAlive(previous.pid); i++) {
252
241
  await new Promise((r) => setTimeout(r, 200));
253
242
  }
254
243
  return cmdStart(values);
@@ -308,20 +297,6 @@ function readPid() {
308
297
  }
309
298
  }
310
299
 
311
- function waitForRuntime(childPid, isDead, timeoutMs) {
312
- return new Promise((resolve) => {
313
- const start = Date.now();
314
- const tick = () => {
315
- if (isDead()) return resolve(null);
316
- const rt = readRuntime();
317
- if (rt && rt.pid && pidAlive(rt.pid) && (!childPid || rt.pid === childPid)) return resolve(rt);
318
- if (Date.now() - start > timeoutMs) return resolve(null);
319
- setTimeout(tick, 400);
320
- };
321
- tick();
322
- });
323
- }
324
-
325
300
  function printHelp() {
326
301
  console.log(`Uso: tamnora server [accion] [opciones]
327
302
 
@@ -341,5 +316,6 @@ Opciones:
341
316
  --host <addr> Interfaz (default: 127.0.0.1)
342
317
  --dir <path> Workspace del server
343
318
  --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
319
+ --no-open No abrir la consola en el navegador al arrancar
344
320
  -y, --yes Auto-aprobar tools con permiso "ask"`);
345
321
  }
@@ -113,7 +113,7 @@ Opciones:
113
113
  --model <id> Modelo (por defecto: el activo)
114
114
  --mode <id> Modo de agente: agent | plan
115
115
  --dir <path> Directorio del proyecto
116
- --max-steps <n> Máximo de rondas de herramientas
116
+ --max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
117
117
  --tools <lista> Tools habilitadas (coma) --no-tools
118
118
  -y, --yes Auto-aprobar tools con permiso "ask"`);
119
119
  }
@@ -1,69 +1,121 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { appendFileSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
3
+ import path from 'node:path';
3
4
  import {
5
+ setHome,
6
+ ensureHome,
4
7
  runtimePath,
5
8
  pidPath,
6
- bridgeLogPath,
9
+ bridgeStatePath,
7
10
  serverLogPath,
11
+ bridgeLogPath,
12
+ killPidTree,
13
+ pidAlive,
8
14
  } from '../../../core/src/index.js';
9
15
 
10
16
  /** Mata un proceso y su arbol de hijos (taskkill en Windows; grupo en POSIX). */
11
17
  export function killPid(pid) {
12
- if (!pid) return;
13
- try {
14
- if (process.platform === 'win32') {
15
- spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
16
- } else {
18
+ killPidTree(pid);
19
+ }
20
+
21
+ export { pidAlive };
22
+
23
+ /** Crea `setHome` + env TAMNORA_HOME a partir de `--home` (comun a CLI commands). */
24
+ export function applyHomeOverride(values) {
25
+ if (!values?.home) return;
26
+ setHome(values.home);
27
+ process.env.TAMNORA_HOME = path.resolve(values.home);
28
+ }
29
+
30
+ /** read/write/clear de un archivo JSON de estado (runtime del server, bridge). */
31
+ function jsonStateIO(file, { extraClear = [] } = {}) {
32
+ return {
33
+ read() {
17
34
  try {
18
- process.kill(-pid, 'SIGTERM');
35
+ return JSON.parse(readFileSync(file, 'utf8'));
19
36
  } catch {
20
- /* sin grupo propio */
37
+ return null;
21
38
  }
39
+ },
40
+ write(data) {
41
+ ensureHome();
22
42
  try {
23
- process.kill(pid, 'SIGTERM');
43
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
24
44
  } catch {
25
- /* ya no esta */
45
+ /* sin estado no detenemos nada */
26
46
  }
27
- }
28
- } catch {
29
- /* ya no esta */
30
- }
47
+ },
48
+ clear() {
49
+ for (const f of [file, ...extraClear]) {
50
+ try {
51
+ unlinkSync(f);
52
+ } catch {
53
+ /* nada */
54
+ }
55
+ }
56
+ },
57
+ };
31
58
  }
32
59
 
33
- export function pidAlive(pid) {
34
- if (!pid) return false;
35
- try {
36
- process.kill(pid, 0);
37
- return true;
38
- } catch {
39
- return false;
40
- }
41
- }
60
+ export const runtimeState = jsonStateIO(runtimePath(), { extraClear: [pidPath()] });
61
+ export const bridgeState = jsonStateIO(bridgeStatePath());
42
62
 
43
63
  export function readRuntime() {
44
- try {
45
- return JSON.parse(readFileSync(runtimePath(), 'utf8'));
46
- } catch {
47
- return null;
48
- }
64
+ return runtimeState.read();
49
65
  }
50
-
51
66
  export function writeRuntime(data) {
52
- try {
53
- writeFileSync(runtimePath(), `${JSON.stringify(data, null, 2)}\n`);
54
- } catch {
55
- /* sin runtime no detenemos nada */
56
- }
67
+ runtimeState.write(data);
57
68
  }
58
-
59
69
  export function clearRuntime() {
60
- for (const file of [runtimePath(), pidPath()]) {
70
+ runtimeState.clear();
71
+ }
72
+
73
+ /** Logger con timestamp a archivo + consola (server, bridge, tunnel). */
74
+ export function createFileLogger(logPath) {
75
+ return (line) => {
76
+ const text = `[${new Date().toISOString()}] ${line}`;
61
77
  try {
62
- unlinkSync(file);
78
+ ensureHome();
79
+ appendFileSync(logPath, `${text}\n`);
63
80
  } catch {
64
- /* nada */
81
+ /* sin log no detenemos nada */
65
82
  }
66
- }
83
+ console.log(text);
84
+ };
85
+ }
86
+
87
+ /** Hijo detached con un flag de env que marca que es el proceso child. */
88
+ export function spawnDetached(args, { envFlag, env = process.env } = {}) {
89
+ const child = spawn(process.execPath, args, {
90
+ detached: true,
91
+ stdio: 'ignore',
92
+ windowsHide: true,
93
+ env: envFlag ? { ...env, [envFlag]: '1' } : { ...env },
94
+ });
95
+ child.unref();
96
+ let dead = false;
97
+ child.on('exit', () => {
98
+ dead = true;
99
+ });
100
+ return { child, isDead: () => dead };
101
+ }
102
+
103
+ /**
104
+ * Espera a que `readFn()` devuelva un estado cuyo pid este vivo (y sea el
105
+ * child esperado, si hay). Null si el child murio o vencio el timeout.
106
+ */
107
+ export function waitForState(readFn, { childPid, isDead = () => false, timeoutMs = 30000, tickMs = 400 } = {}) {
108
+ return new Promise((resolve) => {
109
+ const start = Date.now();
110
+ const tick = () => {
111
+ if (isDead()) return resolve(null);
112
+ const state = readFn();
113
+ if (state?.pid && pidAlive(state.pid) && (!childPid || state.pid === childPid)) return resolve(state);
114
+ if (Date.now() - start > timeoutMs) return resolve(null);
115
+ setTimeout(tick, tickMs);
116
+ };
117
+ tick();
118
+ });
67
119
  }
68
120
 
69
121
  /** PIDs escuchando en un puerto TCP (netstat en Windows; lsof en el resto). */
@@ -32,3 +32,12 @@ test('renderChangelog promotes the Unreleased section', () => {
32
32
  assert.doesNotMatch(out, /## \[Unreleased\]/);
33
33
  assert.match(out, /## \[1\.0\.0\]/);
34
34
  });
35
+
36
+ test('renderChangelog promotes the [No publicado] section', () => {
37
+ const cl = '# Changelog\n\n## [No publicado]\n\n### Agregado\n\n- algo\n\n## [1.0.0] - 2026-01-01\n\n- viejo\n';
38
+ const out = renderChangelog(cl, '1.1.0', '- nota', '2026-02-02');
39
+ assert.match(out, /## \[1\.1\.0\] - 2026-02-02/);
40
+ assert.match(out, /- algo/);
41
+ assert.doesNotMatch(out, /## \[No publicado\]/);
42
+ assert.match(out, /## \[1\.0\.0\]/);
43
+ });
@@ -13,7 +13,7 @@ export const MODES = {
13
13
  id: 'plan',
14
14
  label: 'Plan',
15
15
  description: 'Solo lectura: investiga y propone un plan, sin modificar nada.',
16
- tools: ['read', 'list', 'glob', 'grep', 'fetch_url', 'git_status', 'git_diff', 'git_log'],
16
+ tools: ['read', 'list', 'glob', 'grep', 'fetch_url', 'speak', 'git_status', 'git_diff', 'git_log'],
17
17
  systemPrompt:
18
18
  'Estás en modo PLAN: no modifiques archivos ni ejecutes comandos. Investigá el proyecto con las herramientas de solo lectura y respondé con un plan concreto y accionable.',
19
19
  },
@@ -150,6 +150,9 @@ export async function runAgent({
150
150
  }
151
151
 
152
152
  const tokens = accumulateUsage(result.usage ?? null);
153
+ // Uso de la ronda para el medidor de contexto en vivo (last_prompt_tokens
154
+ // es el tamano real de contexto de esta ronda).
155
+ if (result.usage) onEvent?.({ type: 'usage', usage: { ...usageTotal } });
153
156
 
154
157
  const calls = result.toolCalls ?? [];
155
158
  if (calls.length === 0) {
@@ -0,0 +1,177 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ const GIT_TIMEOUT_MS = 8000;
7
+ const MAX_DIFF_CHARS = 200_000;
8
+ const MAX_STDERR_CHARS = 20_000;
9
+
10
+ /** Run `git <args>` in `root`. Never rejects; resolves `{ code, stdout, stderr }`. */
11
+ function runGit(root, args, { timeout = GIT_TIMEOUT_MS } = {}) {
12
+ return new Promise((resolve) => {
13
+ let child;
14
+ try {
15
+ child = spawn('git', args, { cwd: root, windowsHide: true });
16
+ } catch (error) {
17
+ resolve({ code: -1, stdout: '', stderr: `Error ejecutando git: ${error.message}` });
18
+ return;
19
+ }
20
+ let stdout = '';
21
+ let stderr = '';
22
+ let settled = false;
23
+ const finish = (result) => {
24
+ if (settled) return;
25
+ settled = true;
26
+ clearTimeout(timer);
27
+ resolve(result);
28
+ };
29
+ const timer = setTimeout(() => {
30
+ try {
31
+ child.kill();
32
+ } catch {
33
+ // already gone
34
+ }
35
+ finish({ code: -1, stdout, stderr: stderr || 'git: tiempo de espera agotado' });
36
+ }, timeout);
37
+ child.stdout?.on('data', (chunk) => {
38
+ if (stdout.length < MAX_DIFF_CHARS) stdout += chunk.toString();
39
+ });
40
+ child.stderr?.on('data', (chunk) => {
41
+ if (stderr.length < MAX_STDERR_CHARS) stderr += chunk.toString();
42
+ });
43
+ child.on('error', (error) => {
44
+ finish({ code: -1, stdout, stderr: `Error ejecutando git: ${error.message}` });
45
+ });
46
+ child.on('close', (code) => finish({ code: code ?? -1, stdout, stderr }));
47
+ });
48
+ }
49
+
50
+ /** Fast synchronous check for a `.git` entry in the workspace root. */
51
+ export function isGitRepo(root) {
52
+ return Boolean(root) && fs.existsSync(path.join(root, '.git'));
53
+ }
54
+
55
+ function parseBranch(text) {
56
+ let value = String(text ?? '').trim();
57
+ let ahead = 0;
58
+ let behind = 0;
59
+ const flags = value.match(/\[(.+?)\]\s*$/);
60
+ if (flags) {
61
+ const a = flags[1].match(/ahead (\d+)/);
62
+ const b = flags[1].match(/behind (\d+)/);
63
+ if (a) ahead = Number(a[1]);
64
+ if (b) behind = Number(b[1]);
65
+ value = value.slice(0, flags.index).trim();
66
+ }
67
+ const dots = value.indexOf('...');
68
+ if (dots !== -1) value = value.slice(0, dots);
69
+ value = value.replace(/^No commits yet on /, '').trim();
70
+ return { branch: value || '(sin rama)', ahead, behind };
71
+ }
72
+
73
+ function classify(index, worktree) {
74
+ const pair = `${index}${worktree}`;
75
+ if (pair === '??') return 'untracked';
76
+ if (pair === '!!') return 'ignored';
77
+ if (index === 'U' || worktree === 'U' || pair === 'AA' || pair === 'DD') return 'conflicted';
78
+ if (index === 'R' || worktree === 'R') return 'renamed';
79
+ if (index === 'A' || worktree === 'A') return 'added';
80
+ if (index === 'D' || worktree === 'D') return 'deleted';
81
+ if (index === 'M' || worktree === 'M') return 'modified';
82
+ return 'modified';
83
+ }
84
+
85
+ /** Parse `git status --porcelain=v1 -z --branch` output. */
86
+ function parseStatus(raw) {
87
+ const tokens = String(raw ?? '').split('\0').filter((token) => token.length > 0);
88
+ let branch = null;
89
+ let ahead = 0;
90
+ let behind = 0;
91
+ const entries = [];
92
+ for (let i = 0; i < tokens.length; i++) {
93
+ const token = tokens[i];
94
+ if (token.startsWith('## ')) {
95
+ ({ branch, ahead, behind } = parseBranch(token.slice(3)));
96
+ continue;
97
+ }
98
+ const index = token[0] ?? ' ';
99
+ const worktree = token[1] ?? ' ';
100
+ let origPath = null;
101
+ if (index === 'R' || worktree === 'R') {
102
+ origPath = tokens[i + 1] ?? null;
103
+ i++;
104
+ }
105
+ entries.push({
106
+ path: token.slice(3),
107
+ origPath,
108
+ index,
109
+ worktree,
110
+ status: classify(index, worktree),
111
+ });
112
+ }
113
+ return { isRepo: true, branch, ahead, behind, entries };
114
+ }
115
+
116
+ /**
117
+ * Working-tree status as structured data.
118
+ * @returns {Promise<{isRepo:boolean,branch:string|null,ahead:number,behind:number,entries:Array}>}
119
+ */
120
+ export async function gitStatus(root) {
121
+ const result = await runGit(root, ['status', '--porcelain=v1', '-z', '--branch']);
122
+ if (result.code !== 0) {
123
+ return { isRepo: false, branch: null, ahead: 0, behind: 0, entries: [] };
124
+ }
125
+ return parseStatus(result.stdout);
126
+ }
127
+
128
+ /** Build a `new file` unified diff for an untracked path (no `--no-index` needed). */
129
+ async function untrackedDiff(root, target) {
130
+ let stat;
131
+ try {
132
+ stat = await fsp.stat(path.resolve(root, target));
133
+ } catch {
134
+ return `# ${target}: no se pudo leer el archivo.\n`;
135
+ }
136
+ if (stat.isDirectory()) return `# ${target}: carpeta sin seguimiento (sin diff)\n`;
137
+ if (stat.size > MAX_DIFF_CHARS) return `# ${target}: archivo demasiado grande para mostrar el diff.\n`;
138
+ let buffer;
139
+ try {
140
+ buffer = await fsp.readFile(path.resolve(root, target));
141
+ } catch (error) {
142
+ return `# ${target}: ${error.message}\n`;
143
+ }
144
+ if (buffer.includes(0)) return `# ${target}: archivo binario (sin diff).\n`;
145
+ const lines = buffer.toString('utf8').replace(/\r\n/g, '\n').split('\n');
146
+ if (lines[lines.length - 1] === '') lines.pop();
147
+ const header =
148
+ `diff --git a/${target} b/${target}\n` +
149
+ `new file mode 100644\n--- /dev/null\n+++ b/${target}\n` +
150
+ `@@ -0,0 +1,${lines.length} @@\n`;
151
+ return header + lines.map((line) => `+${line}`).join('\n') + '\n';
152
+ }
153
+
154
+ /**
155
+ * Unified diff for a single path (or the whole tree if `path` is empty).
156
+ * @returns {Promise<{ok:boolean,diff:string,staged:boolean,untracked:boolean,error?:string}>}
157
+ */
158
+ export async function gitDiff(root, { path: filePath, staged = false } = {}) {
159
+ const target = String(filePath ?? '').trim();
160
+ const args = ['diff', '--no-color'];
161
+ if (staged) args.push('--staged');
162
+ if (target) {
163
+ const status = await gitStatus(root);
164
+ if (!status.isRepo) return { ok: false, error: 'No es un repositorio git.' };
165
+ const entry = status.entries.find((e) => e.path === target || e.origPath === target);
166
+ if (entry?.status === 'untracked') {
167
+ const diff = (await untrackedDiff(root, target)).slice(0, MAX_DIFF_CHARS);
168
+ return { ok: true, diff, staged: false, untracked: true };
169
+ }
170
+ }
171
+ if (target) args.push('--', target);
172
+ const result = await runGit(root, args);
173
+ if (result.code !== 0 && !result.stdout) {
174
+ return { ok: false, error: result.stderr.trim() || 'git diff falló.' };
175
+ }
176
+ return { ok: true, diff: result.stdout.slice(0, MAX_DIFF_CHARS), staged, untracked: false };
177
+ }
@@ -13,6 +13,7 @@ export { ensureUtf8Console } from './console.js';
13
13
  export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
14
14
  export { estimateTokens, trimContext } from './agent/context.js';
15
15
  export { diffLines } from './tools/diff.js';
16
+ export { isGitRepo, gitStatus, gitDiff } from './git/index.js';
16
17
  export { summarizeToolCall, previewToolResult } from './tools/labels.js';
17
18
  export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
18
19
  export { withRetry, isRetryableError, isAbortError } from './providers/resilience.js';
@@ -92,6 +93,20 @@ export {
92
93
  registryServerToConfig,
93
94
  normalizeMcpConfig,
94
95
  } from './mcp/registry.js';
95
- export { parseTunnelmoleUrls, startTunnelmole, startTunnel } from './tunnel.js';
96
+ export { parseTunnelmoleUrls, startTunnelmole, startRelay, startTunnel, tunnelState, tunnelActive } from './tunnel.js';
97
+ export {
98
+ TRUSTED_CLIENT_TOKEN,
99
+ EDGE_VERSION,
100
+ FORMATO_POR_DEFECTO,
101
+ MAX_TEXTO,
102
+ VOZ_POR_DEFECTO,
103
+ generarSecMsGec,
104
+ generarSsml,
105
+ sintetizar,
106
+ listarVoces,
107
+ } from './tts/edge.js';
108
+ export { pidAlive, killPidTree } from './pid.js';
109
+ export { openBrowser, openerFor } from './open.js';
96
110
  export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
111
+ export { killPort } from './procs/kill.js';
97
112
  export { readPackageVersion } from './version.js';
@@ -0,0 +1,43 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Abre una URL en el navegador predeterminado.
5
+ *
6
+ * Es best-effort y nunca falla el comando que la llama: si no hay navegador
7
+ * (contenedor, servicio, CI) solo se avisa y se sigue. Por eso devuelve una
8
+ * promesa que resuelve siempre.
9
+ */
10
+ export function openBrowser(url, { platform = process.platform, log = console.warn } = {}) {
11
+ const [command, args] = openerFor(url, platform);
12
+ if (!command) {
13
+ log(`No se pudo abrir el navegador automaticamente: ${url}`);
14
+ return Promise.resolve(false);
15
+ }
16
+ return new Promise((resolve) => {
17
+ try {
18
+ const child = spawn(command, args, {
19
+ // Sin shell: una URL con query (&, ?) no debe pasar por cmd.
20
+ shell: false,
21
+ detached: true,
22
+ stdio: 'ignore',
23
+ windowsHide: true,
24
+ });
25
+ child.once('error', (error) => {
26
+ log(`No se pudo abrir el navegador (${error.message}). Entra a ${url}`);
27
+ resolve(false);
28
+ });
29
+ child.unref();
30
+ resolve(true);
31
+ } catch (error) {
32
+ log(`No se pudo abrir el navegador (${error.message}). Entra a ${url}`);
33
+ resolve(false);
34
+ }
35
+ });
36
+ }
37
+
38
+ /** El comando nativo de cada plataforma; `rundll32` evita el shell en Windows. */
39
+ export function openerFor(url, platform = process.platform) {
40
+ if (platform === 'win32') return ['rundll32.exe', ['url.dll,FileProtocolHandler', url]];
41
+ if (platform === 'darwin') return ['open', [url]];
42
+ return ['xdg-open', [url]];
43
+ }
@@ -0,0 +1,35 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /** True si el proceso con ese pid esta vivo. */
4
+ export function pidAlive(pid) {
5
+ if (!pid) return false;
6
+ try {
7
+ process.kill(pid, 0);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ /** Mata un proceso y su arbol de hijos (taskkill /T en Windows; grupo + SIGTERM en POSIX). */
15
+ export function killPidTree(pid) {
16
+ if (!pid) return;
17
+ try {
18
+ if (process.platform === 'win32') {
19
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
20
+ } else {
21
+ try {
22
+ process.kill(-pid, 'SIGTERM');
23
+ } catch {
24
+ /* sin grupo propio */
25
+ }
26
+ try {
27
+ process.kill(pid, 'SIGTERM');
28
+ } catch {
29
+ /* ya no esta */
30
+ }
31
+ }
32
+ } catch {
33
+ /* ya no esta */
34
+ }
35
+ }