@danieltmn/tamnora 0.7.0 → 0.8.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 CHANGED
@@ -5,6 +5,44 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.8.0] - 2026-10-06
9
+
10
+ ### Documentacion
11
+
12
+ - **Revision de docs y sitio**: se audito cada afirmacion contra el codigo y se
13
+ corrigieron varias inexactas (las API keys **no** se cifran —van en texto plano
14
+ en SQLite—; `--max-steps` no tiene default `8`; "nada se sube a terceros" es
15
+ falso si se elige un proveedor remoto). Se sumaron al sitio y a la guia las
16
+ features de 0.7.1 (tablas markdown, saltos de scroll, agente autonomo y MCP
17
+ con entorno confinado/redaccion). Actualiza `README.md`, `document.md`,
18
+ `AGENTS.md`, `SECURITY.md`, `website/` y la landing.
19
+
20
+ ## [0.7.1] - 2026-10-06
21
+
22
+ ### Agregado
23
+
24
+ - **Tablas markdown**: la consola renderiza tablas con `|` (encabezado, alineación
25
+ `:--`/`:-:`/`--:`, celdas con `inline` y escape de HTML y de `\|`).
26
+ - **Saltos de scroll**: botones flotantes para ir al inicio o al final de la
27
+ conversación, visibles solo cuando hay scroll.
28
+
29
+ ### Corregido
30
+
31
+ - **Vista previa maximizada**: cubre toda la pantalla. El `backdrop-filter` del
32
+ panel creaba un *containing block* que dejaba el `position: fixed` encerrado en
33
+ la barra; ahora el contenedor se mueve a `body` al maximizar y vuelve a su lugar
34
+ al restaurar (también al cerrar el explorador).
35
+ - **Arranque de la consola**: `scrollNavFrame` se declaraba después de
36
+ `initLayout()` y disparaba un `ReferenceError` (TDZ) al cargar.
37
+
38
+ ### Cambiado
39
+
40
+ - **Indicador de actividad en el footer**: la animación de "pensando/trabajando"
41
+ deja de quedar pegada en cada respuesta; ahora hay una sola en el footer
42
+ (`#sb-activity`) para la sesión activa.
43
+ - **Acciones del mensaje**: *copiar / regenerar / editar / borrar desde acá* se
44
+ agrupan en un menú de **tres puntos** (`⋮`) en vez de mostrarse siempre.
45
+
8
46
  ## [0.7.0] - 2026-10-05
9
47
 
10
48
  ### Seguridad
package/README.md CHANGED
@@ -84,6 +84,10 @@ menu "mas" (los tres puntitos). En el chat:
84
84
  - **Actividad colapsable**: las tools de lectura (`read`, `list`, `glob`, `grep`,
85
85
  `fetch_url`, `git_*`) salen **plegadas** y al hacer click se ve el detalle
86
86
  (args + resultado); el volcado completo se pide bajo demanda.
87
+ - **Markdown con tablas**: las respuestas renderizan tablas (`|`, con alineacion
88
+ y celdas saneadas) y botones flotantes para saltar al inicio/final del turno.
89
+ - **Acciones por mensaje**: copiar, editar, regenerar y borrar se agrupan en un
90
+ menu `⋮`; el indicador de "pensando/trabajando" vive en el pie (`#sb-activity`).
87
91
  - **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
88
92
  volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
89
93
  el boton de detener (o `POST /api/sessions/:id/stop`).
@@ -149,6 +153,11 @@ stdio. Las tools se exponen como `mcp__<servidor>__<tool>` y, si el servidor los
149
153
  anuncia, tambien sus **recursos** y **prompts** (`list_resources`,
150
154
  `read_resource`, `list_prompts`, `get_prompt`).
151
155
 
156
+ Los MCP stdio **no heredan `process.env`**: reciben un baseline minimo mas el
157
+ `env` declarado (`"KEY": "env:VAR"`), y sus resultados pasan por `redactSecrets`
158
+ (ver [`SECURITY.md`](SECURITY.md)). Para un servidor de confianza que necesite el
159
+ entorno completo, `"inheritEnv": true`.
160
+
152
161
  ## Tools
153
162
 
154
163
  Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
package/SECURITY.md CHANGED
@@ -15,12 +15,17 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
15
15
  - **Local (loopback)**: quien ya tiene tu sesion de usuario ya puede correr
16
16
  comandos; el server no agrega una barrera adicional mas alla del login.
17
17
  - **Acceso remoto por tunel (tunnelmole)**: la URL publica es accesible desde
18
- Internet. Es obligatorio (a) una passphrase fuerte, (b) login real en toda la
19
- API, (c) limite de intentos y (d) restringir el picker de carpetas a roots
20
- permitidos. Nunca expongas un server sin passphrase.
21
- - **Credenciales**: las API keys de proveedores se guardan en la casa local del
22
- usuario (fuera del repo) y se cifran con una clave derivada de la passphrase.
23
- Nunca se commitean y se devuelven enmascaradas al cliente.
18
+ Internet. Configura un **usuario** (asi toda `/api/*` pide sesion) con una
19
+ **contrasena fuerte**; nunca expongas un server sin login. El tunnel reenvia
20
+ por loopback, de modo que una vez autenticado se alcanza toda la consola
21
+ (incluidos el explorador de archivos y el selector de carpetas): tratalo como
22
+ acceso completo a tu maquina. Cierra el tunnel (`tamnora bridge --stop`) cuando
23
+ no lo uses.
24
+ - **Credenciales**: las API keys de proveedores se guardan en la base SQLite de
25
+ la casa local (fuera del repo) **en texto plano**; protege el archivo con los
26
+ permisos del sistema. Para no persistirlas, usa `"apiKey": "env:VAR"` y
27
+ define la variable en el entorno. Nunca se commitean y se devuelven
28
+ enmascaradas al cliente (`apiKeyHint`).
24
29
  - **Servidores MCP**: tratalos como codigo de terceros. Los MCP stdio **no heredan
25
30
  `process.env`** por defecto: solo reciben un baseline minimo mas el `env` que
26
31
  declares (`"env": { "TOKEN": "env:TOKEN" }`); `inheritEnv: true` es solo para
@@ -160,7 +160,7 @@ Opciones:
160
160
  --session <id> Continuar una sesión existente
161
161
  -c, --continue Continuar la última sesión
162
162
  --system <texto> Instrucciones extra de sistema
163
- --max-steps <n> Máximo de rondas de herramientas (default: 8)
163
+ --max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
164
164
  --tools <lista> Tools habilitadas (coma), ej. read,list,grep
165
165
  --no-tools Deshabilitar todas las tools
166
166
  -y, --yes Aprobar automáticamente los permisos "ask"
@@ -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
  }
@@ -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';
@@ -94,4 +95,5 @@ export {
94
95
  } from './mcp/registry.js';
95
96
  export { parseTunnelmoleUrls, startTunnelmole, startTunnel } from './tunnel.js';
96
97
  export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
98
+ export { killPort } from './procs/kill.js';
97
99
  export { readPackageVersion } from './version.js';
@@ -0,0 +1,95 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const RUN_TIMEOUT_MS = 8000;
4
+
5
+ /** Run a command, resolving `{ code, stdout }` (never rejects). */
6
+ function run(command, args, { timeout = RUN_TIMEOUT_MS } = {}) {
7
+ return new Promise((resolve) => {
8
+ let child;
9
+ try {
10
+ child = spawn(command, args, { windowsHide: true });
11
+ } catch {
12
+ resolve({ code: -1, stdout: '' });
13
+ return;
14
+ }
15
+ let stdout = '';
16
+ let settled = false;
17
+ const finish = (result) => {
18
+ if (settled) return;
19
+ settled = true;
20
+ clearTimeout(timer);
21
+ resolve(result);
22
+ };
23
+ const timer = setTimeout(() => {
24
+ try {
25
+ child.kill();
26
+ } catch {
27
+ // already gone
28
+ }
29
+ finish({ code: -1, stdout });
30
+ }, timeout);
31
+ child.stdout?.on('data', (chunk) => {
32
+ if (stdout.length < 20000) stdout += chunk.toString();
33
+ });
34
+ child.on('error', () => finish({ code: -1, stdout }));
35
+ child.on('close', (code) => finish({ code: code ?? -1, stdout }));
36
+ });
37
+ }
38
+
39
+ function uniquePids(values) {
40
+ return [...new Set(values.map(Number).filter((pid) => Number.isInteger(pid) && pid > 0))];
41
+ }
42
+
43
+ /** Find PIDs listening on a TCP/UDP port (Windows `netstat`). */
44
+ async function pidsOnWindows(port) {
45
+ const { stdout } = await run('netstat', ['-ano']);
46
+ const pids = [];
47
+ for (const line of stdout.split(/\r?\n/)) {
48
+ const fields = line.trim().split(/\s+/);
49
+ if (fields.length < 4) continue;
50
+ if (fields[0].toUpperCase() !== 'TCP' && fields[0].toUpperCase() !== 'UDP') continue;
51
+ if (!fields[1].endsWith(`:${port}`)) continue;
52
+ pids.push(fields[fields.length - 1]);
53
+ }
54
+ return uniquePids(pids);
55
+ }
56
+
57
+ /** Find PIDs listening on a port (Unix: lsof, fuser or ss). */
58
+ async function pidsOnUnix(port) {
59
+ const lsof = await run('lsof', ['-ti', `tcp:${port}`]);
60
+ if (lsof.code === 0 && lsof.stdout.trim()) {
61
+ return uniquePids(lsof.stdout.split(/\s+/));
62
+ }
63
+ const fuser = await run('fuser', [`${port}/tcp`]);
64
+ if (fuser.code === 0 && fuser.stdout.trim()) {
65
+ return uniquePids(fuser.stdout.match(/\d+/g) ?? []);
66
+ }
67
+ const ss = await run('ss', ['-ltnp', `sport = :${port}`]);
68
+ if (ss.code === 0 && ss.stdout.trim()) {
69
+ return uniquePids([...ss.stdout.matchAll(/pid=(\d+)/g)].map((match) => match[1]));
70
+ }
71
+ return [];
72
+ }
73
+
74
+ /**
75
+ * Kill every process listening on `port`.
76
+ * @returns {Promise<{port:number,pids:number[],killed:number[],errors:string[]}>}
77
+ */
78
+ export async function killPort(port) {
79
+ const value = Number(port);
80
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
81
+ throw new Error('Puerto inválido (usá 1-65535).');
82
+ }
83
+ const pids = process.platform === 'win32' ? await pidsOnWindows(value) : await pidsOnUnix(value);
84
+ const killed = [];
85
+ const errors = [];
86
+ for (const pid of pids) {
87
+ const result =
88
+ process.platform === 'win32'
89
+ ? await run('taskkill', ['/PID', String(pid), '/T', '/F'])
90
+ : await run('kill', ['-9', String(pid)]);
91
+ if (result.code === 0) killed.push(pid);
92
+ else errors.push(String(pid));
93
+ }
94
+ return { port: value, pids, killed, errors };
95
+ }
@@ -1,9 +1,59 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import net from 'node:net';
3
5
  import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
6
  import { tmpdir } from 'node:os';
5
7
  import path from 'node:path';
6
8
  import { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from '../src/procs/detect.js';
9
+ import { killPort } from '../src/procs/kill.js';
10
+
11
+ async function waitForPort(port, attempts = 50) {
12
+ for (let i = 0; i < attempts; i++) {
13
+ const ok = await new Promise((resolve) => {
14
+ const socket = net.connect({ port, host: '127.0.0.1' });
15
+ socket.once('connect', () => {
16
+ socket.destroy();
17
+ resolve(true);
18
+ });
19
+ socket.once('error', () => resolve(false));
20
+ });
21
+ if (ok) return;
22
+ await new Promise((resolve) => setTimeout(resolve, 100));
23
+ }
24
+ throw new Error('el puerto no abrió');
25
+ }
26
+
27
+ test('killPort rejects invalid ports', async () => {
28
+ await assert.rejects(() => killPort(0), /Puerto inválido/);
29
+ await assert.rejects(() => killPort(70000), /Puerto inválido/);
30
+ await assert.rejects(() => killPort('abc'), /Puerto inválido/);
31
+ });
32
+
33
+ test('killPort terminates the process listening on a port', async (t) => {
34
+ const port = 47000 + Math.floor(Math.random() * 1000);
35
+ const script = `const http=require('node:http');http.createServer((q,r)=>r.end('ok')).listen(${port},'127.0.0.1');setInterval(()=>{},1000);`;
36
+ const child = spawn(process.execPath, ['-e', script], { stdio: 'ignore', windowsHide: true });
37
+ t.after(() => {
38
+ try {
39
+ child.kill('SIGKILL');
40
+ } catch {
41
+ // already gone
42
+ }
43
+ });
44
+ await waitForPort(port);
45
+
46
+ const result = await killPort(port);
47
+ assert.ok(result.pids.length >= 1, 'debería encontrar el PID');
48
+ assert.ok(result.killed.length >= 1, 'debería matar el proceso');
49
+
50
+ await new Promise((resolve) => {
51
+ if (child.exitCode !== null || child.signalCode) return resolve();
52
+ child.once('exit', resolve);
53
+ setTimeout(resolve, 3000);
54
+ });
55
+ assert.ok(child.exitCode !== null || child.signalCode, 'el hijo debería haber terminado');
56
+ });
7
57
 
8
58
  test('detectProcs finds node dev scripts with the right package manager', async () => {
9
59
  const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));