@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.
- package/CHANGELOG.md +95 -0
- package/README.md +9 -0
- package/SECURITY.md +11 -6
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/run.js +1 -1
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/commands/tui.js +1 -1
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +3 -0
- package/harness/packages/core/src/git/index.js +177 -0
- package/harness/packages/core/src/index.js +16 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/procs/kill.js +95 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/procs.test.js +50 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4715 -3669
- package/harness/packages/server/public/index.html +94 -5
- package/harness/packages/server/public/landing/index.html +5 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +196 -23
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +51 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +35 -0
- package/harness/packages/server/src/routes/procs.js +25 -15
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +19 -0
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +25 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +617 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- package/package.json +1 -1
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
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(
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
35
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
19
36
|
} catch {
|
|
20
|
-
|
|
37
|
+
return null;
|
|
21
38
|
}
|
|
39
|
+
},
|
|
40
|
+
write(data) {
|
|
41
|
+
ensureHome();
|
|
22
42
|
try {
|
|
23
|
-
|
|
43
|
+
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
24
44
|
} catch {
|
|
25
|
-
/*
|
|
45
|
+
/* sin estado no detenemos nada */
|
|
26
46
|
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|