@danieltmn/tamnora 0.16.0 → 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 +13 -0
- package/README.md +21 -1
- package/harness/packages/core/src/index.js +2 -2
- package/harness/packages/core/src/procs/detect.js +59 -8
- package/harness/packages/core/src/procs/kill.js +26 -5
- package/harness/packages/core/test/procs.test.js +70 -2
- package/harness/packages/server/public/app.js +129 -6
- package/harness/packages/server/public/index.html +4 -1
- package/harness/packages/server/public/styles.css +25 -4
- package/harness/packages/server/src/lib/procs.js +9 -3
- package/harness/packages/server/src/lib/runManager.js +29 -2
- package/harness/packages/server/src/routes/chat.js +9 -1
- package/harness/packages/server/src/routes/procs.js +20 -3
- package/harness/packages/server/test/server.test.js +99 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ 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
|
+
|
|
15
|
+
## [0.16.1] - 2026-10-10
|
|
16
|
+
|
|
17
|
+
### Cambios
|
|
18
|
+
|
|
19
|
+
- fix(consola): boton continuar con estilo propio en vez de chip
|
|
20
|
+
|
|
8
21
|
## [0.16.0] - 2026-10-10
|
|
9
22
|
|
|
10
23
|
### 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`, `/
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 =
|
|
80
|
-
|
|
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);
|
|
@@ -2092,7 +2140,7 @@ function renderCompletion(message, data) {
|
|
|
2092
2140
|
|
|
2093
2141
|
/** Retoma un turno cortado por límite/bucle sin tener que escribir "continúa". */
|
|
2094
2142
|
function addContinueButton(turn, data) {
|
|
2095
|
-
const btn = el('button', '
|
|
2143
|
+
const btn = el('button', 'continue-btn', data.wrappedUp ? '↻ Continuar el trabajo' : '↻ Continuar');
|
|
2096
2144
|
btn.type = 'button';
|
|
2097
2145
|
btn.addEventListener('click', () => {
|
|
2098
2146
|
if (state.panes.get(turn.sessionKey)?.working) return;
|
|
@@ -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
|
-
['/
|
|
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: `/
|
|
3209
|
+
notify('Uso: `/portkill <puerto>`, por ejemplo `/portkill 3000`.', 'notice err');
|
|
3111
3210
|
return { handled: true };
|
|
3112
3211
|
}
|
|
3113
|
-
const out = await api('/api/
|
|
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)
|
|
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.
|
|
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>
|
|
@@ -872,6 +872,24 @@ body.resizing * { cursor: col-resize !important; }
|
|
|
872
872
|
.chip.err { border-left-color: var(--danger); color: var(--danger); }
|
|
873
873
|
.chip.ok { border-left-color: var(--ok); color: var(--ok); }
|
|
874
874
|
.chip.warn { border-left-color: var(--warn); color: var(--warn); }
|
|
875
|
+
|
|
876
|
+
.continue-btn {
|
|
877
|
+
align-self: flex-start;
|
|
878
|
+
display: inline-flex;
|
|
879
|
+
align-items: center;
|
|
880
|
+
gap: 6px;
|
|
881
|
+
background: var(--accent-soft);
|
|
882
|
+
color: var(--accent);
|
|
883
|
+
border: 1px solid var(--accent);
|
|
884
|
+
border-radius: 10px;
|
|
885
|
+
padding: 6px 14px;
|
|
886
|
+
cursor: pointer;
|
|
887
|
+
font: 600 12.5px var(--sans);
|
|
888
|
+
white-space: nowrap;
|
|
889
|
+
transition: 0.15s ease;
|
|
890
|
+
}
|
|
891
|
+
.continue-btn:hover { background: var(--accent); color: var(--primary-fg); }
|
|
892
|
+
.continue-btn:active { transform: translateY(1px); }
|
|
875
893
|
details.reasoning { margin-top: 6px; }
|
|
876
894
|
details.reasoning > summary { color: var(--muted); cursor: pointer; font-size: 12.5px; }
|
|
877
895
|
details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top: 4px; border-left: 1px solid var(--border); padding-left: 10px; }
|
|
@@ -1000,6 +1018,10 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
|
|
|
1000
1018
|
.send-btn:hover { filter: brightness(1.08); }
|
|
1001
1019
|
.send-btn:disabled { opacity: 0.5; cursor: default; }
|
|
1002
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); }
|
|
1003
1025
|
/* Dictado activo: verde titilando (cuenta regresiva y grabación). */
|
|
1004
1026
|
.add-btn.recording { color: #2eb872; border-color: currentColor; animation: live-pulse 1s ease-in-out infinite; }
|
|
1005
1027
|
.add-btn.busy { opacity: 0.55; }
|
|
@@ -1389,8 +1411,8 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
1389
1411
|
.explorer-head { display: flex; align-items: center; gap: 6px; padding: 9px 12px; border-bottom: 1px solid var(--border); min-width: 0; }
|
|
1390
1412
|
.explorer-title { font: 700 14px var(--serif); letter-spacing: -0.02em; }
|
|
1391
1413
|
.explorer-head .spacer { flex: 1; }
|
|
1392
|
-
/* Los botones de accion (refrescar/cerrar) nunca se recortan: las pestanas
|
|
1393
|
-
|
|
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. */
|
|
1394
1416
|
.explorer-head .btn.icon { flex: 0 0 auto; }
|
|
1395
1417
|
.explorer-cwd { padding: 9px 12px 0; color: var(--muted); font: 500 11px var(--mono); word-break: break-all; }
|
|
1396
1418
|
.explorer-cwd:empty { display: none; }
|
|
@@ -1412,8 +1434,7 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
1412
1434
|
.explorer-status { padding: 6px 12px; border-top: 1px solid var(--border); color: var(--muted); font: 500 11px var(--mono); min-height: 27px; }
|
|
1413
1435
|
.explorer-status.err { color: var(--danger); }
|
|
1414
1436
|
|
|
1415
|
-
.xtabs { display: flex; gap: 4px; min-width: 0; flex: 0 1 auto;
|
|
1416
|
-
.xtabs::-webkit-scrollbar { display: none; }
|
|
1437
|
+
.xtabs { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; flex: 0 1 auto; }
|
|
1417
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; }
|
|
1418
1439
|
.xtab:hover { background: var(--theirs); color: var(--text); }
|
|
1419
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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