@danieltmn/tamnora 0.16.1 → 0.17.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,13 @@ 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.17.0] - 2026-10-11
9
+
10
+ ### Cambios
11
+
12
+ - docs: procesos de desarrollo e interrumpir el turno
13
+ - feat: procesos por proyecto, /port y /portkill, e interrumpir el turno
14
+
8
15
  ## [0.16.1] - 2026-10-10
9
16
 
10
17
  ### Cambios
package/README.md CHANGED
@@ -117,6 +117,10 @@ menu "mas" (los tres puntitos). En el chat:
117
117
  volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
118
118
  el boton de detener (o `POST /api/sessions/:id/stop`), que ademas corta el stream
119
119
  del modelo y mata las tools de larga duracion (p. ej. `bash`) en curso.
120
+ - **Encolar o interrumpir**: si el agente esta trabajando, lo que envias queda en
121
+ una **cola** visible y se manda al terminar. Para cortarlo y mandar el mensaje
122
+ **ya**, usa el boton **interrumpir** (⚡) o **Ctrl/Cmd+Enter**: cancela el turno en
123
+ curso y arranca el nuevo apenas se desenrolla.
120
124
  - **Solo lo pedido (estricto)**: el toggle de la pestaña Agente (o `PUT
121
125
  /api/settings` con `strictScope`) hace que el agente se limite a lo solicitado,
122
126
  sin mejoras ni cambios extra; se combina con Plan o Build.
@@ -128,7 +132,8 @@ menu "mas" (los tres puntitos). En el chat:
128
132
  - **Actualizar**: cuando npm tiene una version nueva aparece un banner para
129
133
  actualizar con un click; el server se reinicia solo.
130
134
  - **Comandos**: el input muestra un menu al escribir `/` (`/new`, `/init`, `/goal`,
131
- `/resumen`, `/audioresumen`, `/compact`, `/clear`, `/cwd`, `/kill`, `/help`).
135
+ `/resumen`, `/audioresumen`, `/compact`, `/clear`, `/cwd`, `/port`, `/portkill`,
136
+ `/help`).
132
137
  - **Capturas**: el tool `screenshot` muestra la imagen inline en su tarjeta.
133
138
 
134
139
  ### Memoria, reglas y automatizacion
@@ -174,6 +179,21 @@ Laravel, PHP) y abrir un tunel al puerto para verlo desde el celular. Para sitio
174
179
  **estaticos** (un `index.html` sin script de dev) ofrece un chip "sitio estatico"
175
180
  que los sirve con un servidor sin dependencias (`harness/scripts/static-server.mjs`).
176
181
 
182
+ En un **monorepo** donde cada parte arranca distinto, el proyecto declara sus
183
+ procesos en `.tamnora/procs.json` (relativo al proyecto seleccionado); salen
184
+ primero, se suman a los detectados y **solo** se pueden lanzar los que pasan por
185
+ la allowlist `processes.allow`:
186
+
187
+ ```jsonc
188
+ { "processes": [
189
+ { "label": "backend", "dir": "backend", "command": "node", "args": ["index.js"], "port": 3000 },
190
+ { "label": "frontend", "dir": "frontend", "command": "npm", "args": ["run", "dev"], "port": 5173 }
191
+ ] }
192
+ ```
193
+
194
+ Los puertos se consultan y se liberan desde el chat: `/port <puerto>` dice si esta
195
+ activo (y que PID lo usa) y `/portkill <puerto>` mata el proceso que lo escucha.
196
+
177
197
  El visor de archivos tiene **vista previa**: `.html`/`.svg` se sirven con sus CSS,
178
198
  JS e imagenes relativas, y `.md` se renderiza como markdown. Ambas vistas suman
179
199
  selector de tamano (movil/tablet/laptop/pc/adaptable) y boton para maximizar.
@@ -157,8 +157,8 @@ export {
157
157
  export { reconocerGoogle, wavToPcm } from './stt/google.js';
158
158
  export { pidAlive, killPidTree } from './pid.js';
159
159
  export { openBrowser, openerFor } from './open.js';
160
- export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
161
- export { killPort } from './procs/kill.js';
160
+ export { detectProcs, loadProjectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip, PROCS_FILE } from './procs/detect.js';
161
+ export { killPort, portInfo, pidsOnPort } from './procs/kill.js';
162
162
  export {
163
163
  ROOT_PACKAGE_NAME,
164
164
  readPackageVersion,
@@ -24,12 +24,53 @@ const SCRIPT_ORDER = ['dev', 'start', 'serve', 'preview'];
24
24
  const STATIC_SERVER = fileURLToPath(new URL('../../../../scripts/static-server.mjs', import.meta.url));
25
25
  const STATIC_PORT = 8080;
26
26
 
27
+ /** Archivo por proyecto (relativo a su raiz) que define sus procesos de dev. */
28
+ export const PROCS_FILE = '.tamnora/procs.json';
29
+
27
30
  function findStaticRoot(dir) {
28
31
  if (existsSync(path.join(dir, 'index.html'))) return '.';
29
32
  if (existsSync(path.join(dir, 'public', 'index.html'))) return 'public';
30
33
  return null;
31
34
  }
32
35
 
36
+ /** True si `target` es `root` o vive dentro de el (sin salirse por `..`). */
37
+ function isInside(root, target) {
38
+ const rel = path.relative(root, target);
39
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
40
+ }
41
+
42
+ /**
43
+ * Procesos definidos a mano por el proyecto en `<root>/.tamnora/procs.json`.
44
+ * Formato: `{ "processes": [{ label?, dir?, command, args?, port? }] }` o un array
45
+ * directo. `dir` es relativo a la raiz y debe quedar dentro de ella. Cada chip
46
+ * sigue pasando por la whitelist (`processes.allow`) al arrancar.
47
+ */
48
+ export function loadProjectProcs(root) {
49
+ const file = path.join(root, PROCS_FILE);
50
+ if (!existsSync(file)) return [];
51
+ const raw = readJson(file);
52
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.processes) ? raw.processes : [];
53
+ const chips = [];
54
+ for (const entry of list) {
55
+ if (!entry || typeof entry !== 'object') continue;
56
+ const command = String(entry.command ?? '').trim();
57
+ if (!command) continue;
58
+ const rel = String(entry.dir ?? '').trim().replace(/^[/\\]+/, '');
59
+ const cwd = path.resolve(root, rel || '.');
60
+ if (!isInside(root, cwd) || !existsSync(cwd)) continue;
61
+ const port = entry.port ? Number(entry.port) : undefined;
62
+ chips.push({
63
+ label: String(entry.label ?? command).trim() || command,
64
+ command,
65
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
66
+ cwd,
67
+ port: Number.isInteger(port) && port > 0 ? port : undefined,
68
+ source: 'project',
69
+ });
70
+ }
71
+ return chips;
72
+ }
73
+
33
74
  /** Chip para servir `dir` como sitio estático con el servidor sin dependencias. */
34
75
  export function staticServerChip(dir, { port = STATIC_PORT, label } = {}) {
35
76
  return {
@@ -59,9 +100,9 @@ export function packageManager(dir) {
59
100
  return 'npm';
60
101
  }
61
102
 
62
- /** Devuelve chips `{ label, command, args, cwd }` para arrancar el proyecto. */
103
+ /** Devuelve chips `{ label, command, args, cwd, source }` para arrancar el proyecto. */
63
104
  export function detectProcs(dir) {
64
- const chips = [];
105
+ const auto = [];
65
106
  const pkg = readJson(path.join(dir, 'package.json'));
66
107
  if (pkg?.scripts && typeof pkg.scripts === 'object') {
67
108
  const pm = packageManager(dir);
@@ -70,29 +111,39 @@ export function detectProcs(dir) {
70
111
  const first = Object.keys(pkg.scripts)[0];
71
112
  if (first) names.push(first);
72
113
  }
73
- for (const name of names) chips.push({ label: `${name} (${pm})`, command: pm, args: ['run', name], cwd: dir });
114
+ for (const name of names) auto.push({ label: `${name} (${pm})`, command: pm, args: ['run', name], cwd: dir });
74
115
  }
75
116
 
76
117
  if (existsSync(path.join(dir, 'artisan'))) {
77
- chips.push({ label: 'artisan serve', command: 'php', args: ['artisan', 'serve'], cwd: dir });
118
+ auto.push({ label: 'artisan serve', command: 'php', args: ['artisan', 'serve'], cwd: dir });
78
119
  } else if (readJson(path.join(dir, 'composer.json'))?.scripts?.dev) {
79
- chips.push({ label: 'composer dev', command: 'composer', args: ['run', 'dev'], cwd: dir });
120
+ auto.push({ label: 'composer dev', command: 'composer', args: ['run', 'dev'], cwd: dir });
80
121
  }
81
122
 
82
123
  for (const rel of ['public/index.php', 'backend/public/index.php', 'index.php']) {
83
124
  if (!existsSync(path.join(dir, rel))) continue;
84
125
  const docroot = path.dirname(rel) === '.' ? '.' : path.dirname(rel);
85
- chips.push({ label: `php -S (${rel})`, command: 'php', args: ['-S', '127.0.0.1:8000', '-t', docroot], cwd: dir });
126
+ auto.push({ label: `php -S (${rel})`, command: 'php', args: ['-S', '127.0.0.1:8000', '-t', docroot], cwd: dir });
86
127
  break;
87
128
  }
88
129
 
89
- if (!chips.length) {
130
+ if (!auto.length) {
90
131
  const staticRoot = findStaticRoot(dir);
91
132
  if (staticRoot) {
92
133
  const target = staticRoot === '.' ? dir : path.join(dir, staticRoot);
93
- chips.push(staticServerChip(target));
134
+ auto.push(staticServerChip(target));
94
135
  }
95
136
  }
96
137
 
138
+ const chips = [];
139
+ const seen = new Set();
140
+ const add = (chip) => {
141
+ const key = `${chip.command}|${(chip.args ?? []).join(' ')}|${chip.cwd ?? ''}`;
142
+ if (seen.has(key)) return;
143
+ seen.add(key);
144
+ chips.push(chip);
145
+ };
146
+ for (const chip of loadProjectProcs(dir)) add(chip);
147
+ for (const chip of auto) add({ ...chip, source: 'auto' });
97
148
  return chips;
98
149
  }
@@ -71,16 +71,37 @@ async function pidsOnUnix(port) {
71
71
  return [];
72
72
  }
73
73
 
74
+ function validPort(port) {
75
+ const value = Number(port);
76
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
77
+ throw new Error('Puerto inválido (usá 1-65535).');
78
+ }
79
+ return value;
80
+ }
81
+
82
+ /** PIDs escuchando en `port` (Windows `netstat` o Unix lsof/fuser/ss). */
83
+ export async function pidsOnPort(port) {
84
+ const value = validPort(port);
85
+ return process.platform === 'win32' ? pidsOnWindows(value) : pidsOnUnix(value);
86
+ }
87
+
88
+ /**
89
+ * Estado de un puerto.
90
+ * @returns {Promise<{port:number,listening:boolean,pids:number[]}>}
91
+ */
92
+ export async function portInfo(port) {
93
+ const value = validPort(port);
94
+ const pids = await pidsOnPort(value);
95
+ return { port: value, listening: pids.length > 0, pids };
96
+ }
97
+
74
98
  /**
75
99
  * Kill every process listening on `port`.
76
100
  * @returns {Promise<{port:number,pids:number[],killed:number[],errors:string[]}>}
77
101
  */
78
102
  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);
103
+ const value = validPort(port);
104
+ const pids = await pidsOnPort(value);
84
105
  const killed = [];
85
106
  const errors = [];
86
107
  for (const pid of pids) {
@@ -5,8 +5,8 @@ import net from 'node:net';
5
5
  import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
6
6
  import { tmpdir } from 'node:os';
7
7
  import path from 'node:path';
8
- import { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from '../src/procs/detect.js';
9
- import { killPort } from '../src/procs/kill.js';
8
+ import { detectProcs, loadProjectProcs, packageManager, DEFAULT_PROCESS_ALLOW, PROCS_FILE } from '../src/procs/detect.js';
9
+ import { killPort, portInfo } from '../src/procs/kill.js';
10
10
 
11
11
  async function waitForPort(port, attempts = 50) {
12
12
  for (let i = 0; i < attempts; i++) {
@@ -99,3 +99,71 @@ test('detectProcs does not add the static chip when there is a dev script', asyn
99
99
  await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
100
100
  assert.ok(!detectProcs(dir).some((c) => c.args.some((a) => String(a).endsWith('static-server.mjs'))));
101
101
  });
102
+
103
+ test('loadProjectProcs reads .tamnora/procs.json and confines dir to the root', async () => {
104
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
105
+ await mkdir(path.join(dir, '.tamnora'), { recursive: true });
106
+ await mkdir(path.join(dir, 'backend'));
107
+ await writeFile(
108
+ path.join(dir, PROCS_FILE),
109
+ JSON.stringify({
110
+ processes: [
111
+ { label: 'backend', dir: 'backend', command: 'node', args: ['index.js'], port: 3000 },
112
+ { label: 'afuera', dir: '..', command: 'node', args: ['evil.js'] },
113
+ { label: 'sin dir', command: 'npm', args: ['run', 'dev'] },
114
+ ],
115
+ })
116
+ );
117
+
118
+ const chips = loadProjectProcs(dir);
119
+ assert.equal(chips.length, 2, 'descarta el dir que se escapa');
120
+ assert.equal(chips[0].cwd, path.join(dir, 'backend'));
121
+ assert.equal(chips[0].port, 3000);
122
+ assert.equal(chips[0].source, 'project');
123
+ assert.equal(chips[1].cwd, dir);
124
+ assert.ok(chips.every((c) => c.source === 'project'));
125
+ });
126
+
127
+ test('detectProcs puts project chips first and dedupes against auto-detection', async () => {
128
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
129
+ await mkdir(path.join(dir, '.tamnora'), { recursive: true });
130
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
131
+ await writeFile(
132
+ path.join(dir, PROCS_FILE),
133
+ JSON.stringify({
134
+ processes: [
135
+ { label: 'backend', command: 'node', args: ['server.js'], port: 3000 },
136
+ { label: 'dev repetido', command: 'npm', args: ['run', 'dev'] },
137
+ ],
138
+ })
139
+ );
140
+
141
+ const chips = detectProcs(dir);
142
+ assert.equal(chips[0].source, 'project');
143
+ assert.equal(chips[0].command, 'node');
144
+ const devChips = chips.filter((c) => c.command === 'npm' && c.args.join(' ') === 'run dev');
145
+ assert.equal(devChips.length, 1, 'el chip manual gana al autodetectado');
146
+ });
147
+
148
+ test('portInfo reports whether a port is listening', async (t) => {
149
+ const free = await portInfo(47599);
150
+ assert.equal(free.listening, false);
151
+ assert.deepEqual(free.pids, []);
152
+ await assert.rejects(() => portInfo(0), /Puerto inválido/);
153
+
154
+ const port = 48000 + Math.floor(Math.random() * 1000);
155
+ const script = `const http=require('node:http');http.createServer((q,r)=>r.end('ok')).listen(${port},'127.0.0.1');setInterval(()=>{},1000);`;
156
+ const child = spawn(process.execPath, ['-e', script], { stdio: 'ignore', windowsHide: true });
157
+ t.after(() => {
158
+ try {
159
+ child.kill('SIGKILL');
160
+ } catch {
161
+ // already gone
162
+ }
163
+ });
164
+ await waitForPort(port);
165
+ const info = await portInfo(port);
166
+ assert.equal(info.port, port);
167
+ assert.equal(info.listening, true);
168
+ assert.ok(info.pids.length >= 1);
169
+ });
@@ -824,7 +824,13 @@ const chatForm = $('#chat-form');
824
824
  const chatInput = $('#chat-input');
825
825
  const submitBtn = chatForm.querySelector('button[type="submit"]');
826
826
  const stopBtn = $('#stop-btn');
827
+ const interruptBtn = $('#interrupt-btn');
827
828
  const controllers = new Map();
829
+ // `whenIdle` espera a que el turno de una sesion termine (lo usa "interrumpir").
830
+ const idleWaiters = new Map();
831
+ // Sesiones donde el turno actual se esta cortando a proposito: `drainQueue` no
832
+ // debe arrancar un mensaje al final de ese turno (lo arranca el interruptor).
833
+ const interrupting = new Set();
828
834
  const sessionsEl = $('#sessions');
829
835
 
830
836
  let activePane = blankPane;
@@ -1874,6 +1880,7 @@ function activateSession(id) {
1874
1880
  showPane(id);
1875
1881
  if (record.cwd) setCwd(record.cwd);
1876
1882
  if (stopBtn) stopBtn.hidden = !record.working;
1883
+ syncInterruptBtn();
1877
1884
  auditStatus(record);
1878
1885
  updateStatus();
1879
1886
  renderTokens();
@@ -1891,6 +1898,39 @@ function updateSubmitLabel(working) {
1891
1898
  submitBtn.title = working ? 'El agente está trabajando: se envía al terminar' : 'enviar';
1892
1899
  }
1893
1900
 
1901
+ /** El boton de interrumpir solo tiene sentido con un turno en curso y texto que mandar. */
1902
+ function syncInterruptBtn() {
1903
+ if (!interruptBtn) return;
1904
+ const record = state.panes.get(state.sessionId);
1905
+ const working = Boolean(record?.working);
1906
+ const hasInput = Boolean(chatInput.value.trim()) || state.attachments.length > 0;
1907
+ interruptBtn.hidden = !working;
1908
+ interruptBtn.disabled = !working || !hasInput;
1909
+ }
1910
+
1911
+ /** Promesa que resuelve cuando el turno de la sesion deja de estar activo. */
1912
+ function whenIdle(sessionKey, timeoutMs = 8000) {
1913
+ const record = state.panes.get(sessionKey);
1914
+ if (!record?.working) return Promise.resolve();
1915
+ return new Promise((resolve) => {
1916
+ let timer = null;
1917
+ const settle = () => {
1918
+ if (timer) clearTimeout(timer);
1919
+ const list = idleWaiters.get(sessionKey);
1920
+ if (list) {
1921
+ const index = list.indexOf(settle);
1922
+ if (index >= 0) list.splice(index, 1);
1923
+ if (!list.length) idleWaiters.delete(sessionKey);
1924
+ }
1925
+ resolve();
1926
+ };
1927
+ timer = setTimeout(settle, timeoutMs);
1928
+ const list = idleWaiters.get(sessionKey) ?? [];
1929
+ list.push(settle);
1930
+ idleWaiters.set(sessionKey, list);
1931
+ });
1932
+ }
1933
+
1894
1934
  function auditStatus(record) {
1895
1935
  submitBtn.disabled = false;
1896
1936
  updateSubmitLabel(record.working);
@@ -1941,10 +1981,18 @@ function setWorking(id, working) {
1941
1981
  // solo se recupera recargando.
1942
1982
  if (record) record.working = record.attached = working;
1943
1983
  renderTabs();
1984
+ if (!working) {
1985
+ const waiters = idleWaiters.get(id);
1986
+ if (waiters) {
1987
+ idleWaiters.delete(id);
1988
+ for (const settle of [...waiters]) settle();
1989
+ }
1990
+ }
1944
1991
  if (id === state.sessionId) {
1945
1992
  submitBtn.disabled = false;
1946
1993
  updateSubmitLabel(working);
1947
1994
  if (stopBtn) stopBtn.hidden = !working;
1995
+ syncInterruptBtn();
1948
1996
  $('#sb-rounds').textContent = working ? '' : 'listo';
1949
1997
  $('#sb-rounds').classList.toggle('running', working);
1950
1998
  renderFooterActivity(working ? 'thinking' : null);
@@ -2361,7 +2409,7 @@ function contextTokensForRequest() {
2361
2409
  return context ? Math.floor(context * 0.8) : undefined;
2362
2410
  }
2363
2411
 
2364
- async function startTurn(sessionKey, pane, input, images) {
2412
+ async function startTurn(sessionKey, pane, input, images, opts = {}) {
2365
2413
  const user = addMessage('user', input || `(${images.length} imagen${images.length > 1 ? 'es' : ''})`, pane);
2366
2414
  appendImages(user, images);
2367
2415
  const turn = createChatTurn(pane, sessionKey);
@@ -2386,6 +2434,7 @@ async function startTurn(sessionKey, pane, input, images) {
2386
2434
  mode: modeSelect.value || undefined,
2387
2435
  yes: yesInput.checked ? true : undefined,
2388
2436
  contextTokens: contextTokensForRequest(),
2437
+ interrupt: opts.interrupt ? true : undefined,
2389
2438
  },
2390
2439
  (event, data, id) => {
2391
2440
  if (id !== null) turn.cursor = Number(id) || turn.cursor;
@@ -2463,6 +2512,9 @@ function renderQueue(sessionKey = state.sessionId) {
2463
2512
  function drainQueue(sessionKey) {
2464
2513
  const record = state.panes.get(sessionKey);
2465
2514
  if (!record || record.working) return;
2515
+ // Mientras se interrumpe, el mensaje que manda el usuario lo arranca
2516
+ // `interruptAndSend`, no la cola.
2517
+ if (interrupting.has(sessionKey)) return;
2466
2518
  const list = queueFor(sessionKey);
2467
2519
  if (!list.length) return;
2468
2520
  const next = list.shift();
@@ -2530,11 +2582,13 @@ async function sendMessage() {
2530
2582
  }
2531
2583
 
2532
2584
  // Si el agente está trabajando, el mensaje queda en cola y se envía solo.
2585
+ // Para cortarlo y mandar el mensaje ya, está el botón/atajo de interrumpir.
2533
2586
  if (current?.working) {
2534
2587
  enqueueMessage(state.sessionId, input, images);
2535
2588
  chatInput.value = '';
2536
2589
  state.attachments = [];
2537
2590
  renderAttachments();
2591
+ syncInterruptBtn();
2538
2592
  return;
2539
2593
  }
2540
2594
 
@@ -2572,12 +2626,55 @@ if (stopBtn) {
2572
2626
  });
2573
2627
  }
2574
2628
 
2629
+ /**
2630
+ * Interrumpe el turno en curso y manda `input`/`images` ya mismo.
2631
+ *
2632
+ * Marcamos la sesion en `interrupting` para que el `finally` del turno viejo no
2633
+ * dispare la cola (lo arrancamos nosotros). Esperamos a que el turno anterior se
2634
+ * desenrolle antes de empezar el nuevo; el server, por su lado, cancela el run y
2635
+ * espera a que termine antes de aceptar el POST con `interrupt: true`.
2636
+ */
2637
+ async function interruptAndSend(sessionKey, input, images) {
2638
+ const record = state.panes.get(sessionKey);
2639
+ if (!record) return;
2640
+ interrupting.add(sessionKey);
2641
+ controllers.get(sessionKey)?.abort();
2642
+ await whenIdle(sessionKey);
2643
+ const promise = startTurn(sessionKey, record.el, input, images, { interrupt: true });
2644
+ // startTurn ya arranco (working=true y controller nuevo); soltamos el guardia
2645
+ // para que SU finally pueda drenar la cola.
2646
+ interrupting.delete(sessionKey);
2647
+ await promise;
2648
+ }
2649
+
2650
+ function sendInterrupt() {
2651
+ const record = state.panes.get(state.sessionId);
2652
+ if (!record?.working) return;
2653
+ const input = chatInput.value.trim();
2654
+ // Los comandos (`/…`, `!…`) se resuelven por su camino normal, no interrumpen.
2655
+ if (input.startsWith('/') || input.startsWith('!')) return sendMessage();
2656
+ const images = state.attachments.slice();
2657
+ if (!input && !images.length) return;
2658
+ chatInput.value = '';
2659
+ state.attachments = [];
2660
+ renderAttachments();
2661
+ syncInterruptBtn();
2662
+ interruptAndSend(state.sessionId, input, images);
2663
+ }
2664
+
2665
+ if (interruptBtn) interruptBtn.addEventListener('click', sendInterrupt);
2666
+
2575
2667
  chatForm.addEventListener('submit', (event) => {
2576
2668
  event.preventDefault();
2577
2669
  sendMessage();
2578
2670
  });
2579
2671
  chatInput.addEventListener('keydown', (event) => {
2580
2672
  if (handleSlashKey(event)) return;
2673
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
2674
+ event.preventDefault();
2675
+ sendInterrupt();
2676
+ return;
2677
+ }
2581
2678
  if (event.key === 'Enter' && !event.shiftKey) {
2582
2679
  event.preventDefault();
2583
2680
  sendMessage();
@@ -3041,7 +3138,8 @@ const COMMANDS = [
3041
3138
  ['/compact', 'resumir la conversación y liberar contexto'],
3042
3139
  ['/clear', 'borrar los mensajes de esta sesión'],
3043
3140
  ['/cwd', 'mostrar la carpeta de trabajo'],
3044
- ['/kill <puerto>', 'matar el proceso que escucha en un puerto'],
3141
+ ['/port <puerto>', 'ver si un puerto está activo (y qué PID lo usa)'],
3142
+ ['/portkill <puerto>', 'matar el proceso que escucha en un puerto'],
3045
3143
  ['/help', 'mostrar esta ayuda'],
3046
3144
  ];
3047
3145
 
@@ -3104,13 +3202,14 @@ async function runCommand(name, arg) {
3104
3202
  notify(`Carpeta de trabajo: \`${cwd}\``);
3105
3203
  return { handled: true };
3106
3204
  }
3205
+ case 'portkill':
3107
3206
  case 'kill': {
3108
3207
  const port = Number(arg);
3109
3208
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
3110
- notify('Uso: `/kill <puerto>`, por ejemplo `/kill 3000`.', 'notice err');
3209
+ notify('Uso: `/portkill <puerto>`, por ejemplo `/portkill 3000`.', 'notice err');
3111
3210
  return { handled: true };
3112
3211
  }
3113
- const out = await api('/api/procs/kill', { method: 'POST', body: { port } }).catch((error) => ({
3212
+ const out = await api('/api/ports/kill', { method: 'POST', body: { port } }).catch((error) => ({
3114
3213
  error: error.message,
3115
3214
  }));
3116
3215
  if (out.error) notify(`No se pudo matar el puerto ${port}: ${out.error}`, 'notice err');
@@ -3119,6 +3218,18 @@ async function runCommand(name, arg) {
3119
3218
  else notify(`Puerto ${port}: no se pudo matar (PID ${out.errors.join(', ')}).`, 'notice err');
3120
3219
  return { handled: true };
3121
3220
  }
3221
+ case 'port': {
3222
+ const port = Number(arg);
3223
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
3224
+ notify('Uso: `/port <puerto>`, por ejemplo `/port 3000`.', 'notice err');
3225
+ return { handled: true };
3226
+ }
3227
+ const out = await api(`/api/ports/${port}`).catch((error) => ({ error: error.message }));
3228
+ if (out.error) notify(`No se pudo consultar el puerto ${port}: ${out.error}`, 'notice err');
3229
+ else if (out.listening) notify(`Puerto ${port}: **activo** (PID ${out.pids.join(', ')}).`);
3230
+ else notify(`Puerto ${port}: nada escuchando.`);
3231
+ return { handled: true };
3232
+ }
3122
3233
  case 'clear':
3123
3234
  case 'limpiar': {
3124
3235
  if (!state.sessionId) return needSession();
@@ -3274,6 +3385,7 @@ function handleSlashKey(event) {
3274
3385
  function onChatInputChanged() {
3275
3386
  slashIndex = 0;
3276
3387
  updateSlashMenu();
3388
+ syncInterruptBtn();
3277
3389
  }
3278
3390
 
3279
3391
  chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
@@ -5508,8 +5620,19 @@ async function detectProcs() {
5508
5620
  procsCwdEl.textContent = cwd ? `cwd: ${cwd}` : 'sin carpeta';
5509
5621
  const data = await api(`/api/procs/detect?cwd=${encodeURIComponent(cwd)}`).catch(() => null);
5510
5622
  if (!data) return;
5511
- for (const chip of data.chips) procsChipsEl.appendChild(button(chip.label, () => startProc(chip), 'xchip'));
5623
+ for (const chip of data.chips) {
5624
+ const node = button(chip.label, () => startProc(chip), 'xchip');
5625
+ if (chip.source === 'project') node.title = 'definido en .tamnora/procs.json';
5626
+ if (chip.allowed === false) {
5627
+ node.disabled = true;
5628
+ node.title = `"${chip.command}" no está en processes.allow`;
5629
+ }
5630
+ procsChipsEl.appendChild(node);
5631
+ }
5512
5632
  if (!data.chips.length) procsChipsEl.appendChild(el('span', 'muted', 'no detecté comandos de dev'));
5633
+ else if (data.chips.some((c) => c.source === 'project')) {
5634
+ procsChipsEl.appendChild(el('span', 'muted', '· desde .tamnora/procs.json'));
5635
+ }
5513
5636
  await refreshProcs();
5514
5637
  }
5515
5638
 
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.16.1</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.17.0</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -209,6 +209,9 @@
209
209
  <button class="send-btn" type="submit" aria-label="enviar" title="enviar">
210
210
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 7-7 7 7" /><path d="M12 19V5" /></svg>
211
211
  </button>
212
+ <button class="add-btn interrupt-btn" type="button" id="interrupt-btn" title="Interrumpir al agente y enviar este mensaje (Ctrl+Enter)" aria-label="interrumpir y enviar" hidden>
213
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" /></svg>
214
+ </button>
212
215
  <button class="add-btn" type="button" id="stop-btn" title="Detener la respuesta" aria-label="detener" hidden>
213
216
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
214
217
  </button>
@@ -1018,6 +1018,10 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
1018
1018
  .send-btn:hover { filter: brightness(1.08); }
1019
1019
  .send-btn:disabled { opacity: 0.5; cursor: default; }
1020
1020
  .send-btn[data-working="1"] { background: var(--theirs); color: var(--accent); border-color: var(--accent); }
1021
+ /* Interrumpir: corta el turno en curso y manda el mensaje ya. */
1022
+ .interrupt-btn { color: var(--accent); border-color: var(--accent); }
1023
+ .interrupt-btn:hover { background: var(--accent); color: var(--primary-fg); }
1024
+ .interrupt-btn:disabled { opacity: 0.45; cursor: default; color: var(--muted); border-color: var(--border); }
1021
1025
  /* Dictado activo: verde titilando (cuenta regresiva y grabación). */
1022
1026
  .add-btn.recording { color: #2eb872; border-color: currentColor; animation: live-pulse 1s ease-in-out infinite; }
1023
1027
  .add-btn.busy { opacity: 0.55; }
@@ -1407,8 +1411,8 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1407
1411
  .explorer-head { display: flex; align-items: center; gap: 6px; padding: 9px 12px; border-bottom: 1px solid var(--border); min-width: 0; }
1408
1412
  .explorer-title { font: 700 14px var(--serif); letter-spacing: -0.02em; }
1409
1413
  .explorer-head .spacer { flex: 1; }
1410
- /* Los botones de accion (refrescar/cerrar) nunca se recortan: las pestanas se
1411
- angostan y hacen scroll horizontal en vez de empujarlos fuera del panel. */
1414
+ /* Los botones de accion (refrescar/cerrar) nunca se recortan: las pestanas
1415
+ bajan a una segunda fila (wrap) en vez de empujarlos fuera o desaparecer. */
1412
1416
  .explorer-head .btn.icon { flex: 0 0 auto; }
1413
1417
  .explorer-cwd { padding: 9px 12px 0; color: var(--muted); font: 500 11px var(--mono); word-break: break-all; }
1414
1418
  .explorer-cwd:empty { display: none; }
@@ -1430,8 +1434,7 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1430
1434
  .explorer-status { padding: 6px 12px; border-top: 1px solid var(--border); color: var(--muted); font: 500 11px var(--mono); min-height: 27px; }
1431
1435
  .explorer-status.err { color: var(--danger); }
1432
1436
 
1433
- .xtabs { display: flex; gap: 4px; min-width: 0; flex: 0 1 auto; overflow-x: auto; scrollbar-width: none; }
1434
- .xtabs::-webkit-scrollbar { display: none; }
1437
+ .xtabs { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; flex: 0 1 auto; }
1435
1438
  .xtab { flex: 0 0 auto; border: 0; background: none; color: var(--muted); cursor: pointer; font: 600 12px var(--sans); padding: 4px 7px; border-radius: 7px; }
1436
1439
  .xtab:hover { background: var(--theirs); color: var(--text); }
1437
1440
  .xtab.is-active { background: var(--theirs); color: var(--text); }
@@ -36,11 +36,17 @@ export function createProcManager({ allow } = {}) {
36
36
  );
37
37
  const procs = new Map();
38
38
 
39
+ function allows(bin) {
40
+ const value = String(bin ?? '').trim();
41
+ if (!value) return false;
42
+ const base = path.basename(value).replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
43
+ return allowed.has(base) || allowed.has(value.toLowerCase());
44
+ }
45
+
39
46
  function start({ cwd, command, args = [], port } = {}) {
40
47
  const bin = String(command ?? '').trim();
41
48
  if (!bin) throw new Error('Falta el comando.');
42
- const base = path.basename(bin).replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
43
- if (!allowed.has(base) && !allowed.has(bin.toLowerCase())) {
49
+ if (!allows(bin)) {
44
50
  throw new Error(`Comando no permitido: ${bin}. Agregalo a processes.allow en tamnora.json.`);
45
51
  }
46
52
  const child = spawn(bin, args.map(String), {
@@ -106,5 +112,5 @@ export function createProcManager({ allow } = {}) {
106
112
  procs.clear();
107
113
  }
108
114
 
109
- return { start, stop, list, logs, close, allowed: [...allowed] };
115
+ return { start, stop, list, logs, close, allows, allowed: [...allowed] };
110
116
  }
@@ -10,6 +10,15 @@
10
10
  export function createRunManager({ store }) {
11
11
  const runs = new Map();
12
12
  const listeners = new Set();
13
+ // Resolvers esperando a que el run de una sesion termine (`waitForIdle`).
14
+ const idleWaiters = new Map();
15
+
16
+ function settleIdle(sessionId) {
17
+ const waiters = idleWaiters.get(sessionId);
18
+ if (!waiters) return;
19
+ idleWaiters.delete(sessionId);
20
+ for (const resolve of waiters) resolve();
21
+ }
13
22
 
14
23
  function persist(run, sessionId, type, data, agentId) {
15
24
  const coalesce =
@@ -82,6 +91,7 @@ export function createRunManager({ store }) {
82
91
  run.subscribers.clear();
83
92
  runs.delete(sessionId);
84
93
  announce(sessionId, 'end', { durationMs: Date.now() - run.startedAt });
94
+ settleIdle(sessionId);
85
95
  }
86
96
 
87
97
  function cancel(sessionId) {
@@ -92,7 +102,24 @@ export function createRunManager({ store }) {
92
102
  const active = [...runs.values()];
93
103
  for (const run of active) run.controller.abort();
94
104
  runs.clear();
95
- for (const run of active) announce(run.sessionId, 'end', { durationMs: Date.now() - run.startedAt, reason: 'shutdown' });
105
+ for (const run of active) {
106
+ announce(run.sessionId, 'end', { durationMs: Date.now() - run.startedAt, reason: 'shutdown' });
107
+ settleIdle(run.sessionId);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Promesa que resuelve cuando el run de la sesion ya no esta activo. Sirve
113
+ * para "interrumpir": cancelar el run actual y esperar a que se desenrolle
114
+ * antes de arrancar el siguiente (si no, `start` devuelve null y se pierde).
115
+ */
116
+ function waitForIdle(sessionId) {
117
+ if (!runs.has(sessionId)) return Promise.resolve();
118
+ return new Promise((resolve) => {
119
+ const waiters = idleWaiters.get(sessionId) ?? [];
120
+ waiters.push(resolve);
121
+ idleWaiters.set(sessionId, waiters);
122
+ });
96
123
  }
97
124
 
98
125
  function isActive(sessionId) {
@@ -120,5 +147,5 @@ export function createRunManager({ store }) {
120
147
  return () => listeners.delete(fn);
121
148
  }
122
149
 
123
- return { emit, start, finish, cancel, cancelAll, isActive, signal, subscribe, subscribeAll, activeSessions };
150
+ return { emit, start, finish, cancel, cancelAll, isActive, waitForIdle, signal, subscribe, subscribeAll, activeSessions };
124
151
  }
@@ -155,7 +155,15 @@ export function registerChat(route, ctx) {
155
155
  }
156
156
 
157
157
  if (ctx.runs.isActive(sessionId)) {
158
- return sendError(res, 409, 'La sesión ya tiene una respuesta en curso.');
158
+ // "Interrumpir": si el cliente lo pide, cancelamos el run en curso y
159
+ // esperamos a que se desenrolle antes de arrancar el nuevo. Sin esto, un
160
+ // segundo mensaje del mismo cliente chocaria con el 409.
161
+ if (body.interrupt !== true) {
162
+ return sendError(res, 409, 'La sesión ya tiene una respuesta en curso.');
163
+ }
164
+ ctx.runs.cancel(sessionId);
165
+ ctx.approvals.cancelForSession(sessionId);
166
+ await ctx.runs.waitForIdle(sessionId);
159
167
  }
160
168
 
161
169
  const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
@@ -1,4 +1,4 @@
1
- import { detectProcs, killPort } from '../lib/core.js';
1
+ import { detectProcs, killPort, portInfo } from '../lib/core.js';
2
2
  import { readJson, sendJson, sendError } from '../lib/http.js';
3
3
  import { requireLocal, resolveDirOr400 } from '../lib/workspace.js';
4
4
 
@@ -8,9 +8,11 @@ export function registerProcs(route, ctx) {
8
8
  });
9
9
 
10
10
  route('GET', '/api/procs/detect', ({ res, query }) => {
11
+ if (!ctx.procsEnabled) return sendJson(res, 200, { cwd: ctx.cwd, chips: [] });
11
12
  const dir = resolveDirOr400(res, query.get('cwd'), ctx.cwd);
12
13
  if (!dir) return;
13
- sendJson(res, 200, { cwd: dir, chips: detectProcs(dir) });
14
+ const chips = detectProcs(dir).map((chip) => ({ ...chip, allowed: ctx.procs.allows(chip.command) }));
15
+ sendJson(res, 200, { cwd: dir, chips });
14
16
  });
15
17
 
16
18
  route('POST', '/api/procs/start', async ({ req, res }) => {
@@ -43,7 +45,7 @@ export function registerProcs(route, ctx) {
43
45
  sendJson(res, 200, { ok: true });
44
46
  });
45
47
 
46
- route('POST', '/api/procs/kill', async ({ req, res }) => {
48
+ async function killPortHandler({ req, res }) {
47
49
  if (!requireLocal(req, res, 'Matar procesos solo está disponible en conexiones locales.')) {
48
50
  return;
49
51
  }
@@ -53,5 +55,20 @@ export function registerProcs(route, ctx) {
53
55
  } catch (error) {
54
56
  sendError(res, 400, error.message);
55
57
  }
58
+ }
59
+
60
+ route('POST', '/api/procs/kill', killPortHandler);
61
+
62
+ route('POST', '/api/ports/kill', killPortHandler);
63
+
64
+ route('GET', '/api/ports/:port', async ({ req, res, params }) => {
65
+ if (!requireLocal(req, res, 'Consultar puertos solo está disponible en conexiones locales.')) {
66
+ return;
67
+ }
68
+ try {
69
+ sendJson(res, 200, await portInfo(params.port));
70
+ } catch (error) {
71
+ sendError(res, 400, error.message);
72
+ }
56
73
  });
57
74
  }
@@ -262,7 +262,73 @@ test('keeps a run alive after the client disconnects and can stop it', async ()
262
262
  }
263
263
  });
264
264
 
265
- /** Lee un SSE hasta que `until(data)` se cumpla, y devuelve lo acumulado. */
265
+ test('interrupt cancels the running turn and starts the new message right away', async () => {
266
+ const calls = { n: 0 };
267
+ let abortedFirst = false;
268
+ const provider = {
269
+ id: 'interrupt',
270
+ async streamChat(_config, { onEvent, signal }) {
271
+ calls.n += 1;
272
+ if (calls.n === 1) {
273
+ onEvent?.({ type: 'text', delta: 'trabajando', full: 'trabajando' });
274
+ await new Promise((resolve, reject) => {
275
+ const timer = setTimeout(resolve, 5000);
276
+ const onAbort = () => {
277
+ clearTimeout(timer);
278
+ abortedFirst = true;
279
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
280
+ };
281
+ if (signal?.aborted) return onAbort();
282
+ signal?.addEventListener('abort', onAbort, { once: true });
283
+ });
284
+ }
285
+ onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
286
+ return { content: 'listo', reasoning: '', toolCalls: [] };
287
+ },
288
+ };
289
+ const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
290
+ const json = { 'Content-Type': 'application/json' };
291
+ try {
292
+ const created = await (
293
+ await fetch(`${base}/api/sessions`, {
294
+ method: 'POST',
295
+ headers: json,
296
+ body: JSON.stringify({ model: 'm' }),
297
+ })
298
+ ).json();
299
+ const id = created.session.id;
300
+
301
+ const first = fetch(`${base}/api/chat`, {
302
+ method: 'POST',
303
+ headers: json,
304
+ body: JSON.stringify({ input: 'uno', model: 'm', sessionId: id }),
305
+ })
306
+ .then((res) => res.text())
307
+ .catch(() => null);
308
+ await new Promise((resolve) => setTimeout(resolve, 80));
309
+
310
+ const second = await (
311
+ await fetch(`${base}/api/chat`, {
312
+ method: 'POST',
313
+ headers: json,
314
+ body: JSON.stringify({ input: 'dos', model: 'm', sessionId: id, interrupt: true }),
315
+ })
316
+ ).text();
317
+
318
+ assert.match(second, /event: text/);
319
+ assert.match(second, /listo/);
320
+ assert.equal(abortedFirst, true);
321
+ await first;
322
+
323
+ await new Promise((resolve) => setTimeout(resolve, 60));
324
+ const after = await (await fetch(`${base}/api/sessions/${id}`)).json();
325
+ assert.equal(after.running, false);
326
+ } finally {
327
+ await instance.close();
328
+ }
329
+ });
330
+
331
+ /** Lee un SSE hasta que `until(data)` se cumpla, y devuelve lo acumulado. */
266
332
  async function readSse(url, options, until, timeoutMs = 5000) {
267
333
  const controller = new AbortController();
268
334
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -1561,20 +1627,38 @@ test('runs a console shell command and kills a port', async () => {
1561
1627
  });
1562
1628
  assert.equal(missing.status, 400);
1563
1629
 
1564
- const badPort = await fetch(`${base}/api/procs/kill`, {
1565
- method: 'POST',
1566
- headers: json,
1567
- body: JSON.stringify({ port: 0 }),
1568
- });
1569
- assert.equal(badPort.status, 400);
1570
-
1571
- const none = await fetch(`${base}/api/procs/kill`, {
1572
- method: 'POST',
1573
- headers: json,
1574
- body: JSON.stringify({ port: 47555 }),
1575
- });
1576
- assert.equal(none.status, 200);
1577
- assert.deepEqual((await none.json()).pids, []);
1630
+ const badPort = await fetch(`${base}/api/procs/kill`, {
1631
+ method: 'POST',
1632
+ headers: json,
1633
+ body: JSON.stringify({ port: 0 }),
1634
+ });
1635
+ assert.equal(badPort.status, 400);
1636
+
1637
+ const none = await fetch(`${base}/api/procs/kill`, {
1638
+ method: 'POST',
1639
+ headers: json,
1640
+ body: JSON.stringify({ port: 47555 }),
1641
+ });
1642
+ assert.equal(none.status, 200);
1643
+ assert.deepEqual((await none.json()).pids, []);
1644
+
1645
+ const status = await fetch(`${base}/api/ports/47555`);
1646
+ assert.equal(status.status, 200);
1647
+ const info = await status.json();
1648
+ assert.equal(info.port, 47555);
1649
+ assert.equal(info.listening, false);
1650
+ assert.deepEqual(info.pids, []);
1651
+
1652
+ const aliasKill = await fetch(`${base}/api/ports/kill`, {
1653
+ method: 'POST',
1654
+ headers: json,
1655
+ body: JSON.stringify({ port: 47555 }),
1656
+ });
1657
+ assert.equal(aliasKill.status, 200);
1658
+ assert.deepEqual((await aliasKill.json()).pids, []);
1659
+
1660
+ const badStatus = await fetch(`${base}/api/ports/0`);
1661
+ assert.equal(badStatus.status, 400);
1578
1662
  } finally {
1579
1663
  await instance.close();
1580
1664
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",