@danieltmn/tamnora 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/SECURITY.md +6 -0
  3. package/harness/packages/cli/src/commands/repl.js +2 -0
  4. package/harness/packages/cli/src/commands/run.js +2 -0
  5. package/harness/packages/cli/src/commands/tui.js +2 -0
  6. package/harness/packages/cli/src/lib/runtime.js +4 -0
  7. package/harness/packages/cli/src/tui/app.js +4 -0
  8. package/harness/packages/core/src/agent/runAgent.js +131 -47
  9. package/harness/packages/core/src/config/index.js +3 -1
  10. package/harness/packages/core/src/config/secrets.js +36 -0
  11. package/harness/packages/core/src/eval/runTask.js +2 -11
  12. package/harness/packages/core/src/index.js +13 -1
  13. package/harness/packages/core/src/mcp/common.js +15 -11
  14. package/harness/packages/core/src/mcp/index.js +1 -1
  15. package/harness/packages/core/src/mcp/registry.js +1 -0
  16. package/harness/packages/core/src/mcp/stdio.js +41 -2
  17. package/harness/packages/core/src/redact.js +104 -0
  18. package/harness/packages/core/test/agent.test.js +93 -0
  19. package/harness/packages/core/test/mcp.test.js +67 -0
  20. package/harness/packages/core/test/redact.test.js +45 -0
  21. package/harness/packages/server/public/app.js +217 -18
  22. package/harness/packages/server/public/index.html +89 -26
  23. package/harness/packages/server/public/styles.css +61 -1
  24. package/harness/packages/server/src/lib/agentTools.js +9 -3
  25. package/harness/packages/server/src/lib/channels/telegram.js +11 -5
  26. package/harness/packages/server/src/routes/chat.js +8 -1
  27. package/harness/packages/server/src/routes/mcp.js +29 -5
  28. package/harness/packages/server/src/routes/providers.js +16 -15
  29. package/harness/packages/server/src/server.js +13 -0
  30. package/harness/packages/server/test/server.test.js +76 -1
  31. package/harness/tamnora.example.json +17 -1
  32. package/package.json +1 -1
@@ -0,0 +1,104 @@
1
+ // Redaccion de secretos para no filtrarlos a la conversacion (y de ahi al
2
+ // historial, a la UI o a un canal de mensajeria). Es defensa en profundidad:
3
+ // la barrera principal es no inyectar secretos en el entorno de los MCP.
4
+
5
+ import { getEnv } from './env.js';
6
+
7
+ /** Un nombre de variable "sensible" (token, password, key, credencial...). */
8
+ const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTH|COOKIE|SESSION)/i;
9
+
10
+ /** Patrones de tokens conocidos, utiles aunque no vengan de una variable. */
11
+ const SECRET_PATTERNS = [
12
+ /npm_[A-Za-z0-9]{16,}/g,
13
+ /sk-[A-Za-z0-9_-]{16,}/g,
14
+ /ghp_[A-Za-z0-9]{20,}/g,
15
+ /github_pat_[A-Za-z0-9_]{20,}/g,
16
+ /AKIA[0-9A-Z]{16}/g,
17
+ /AIza[0-9A-Za-z_-]{30,}/g,
18
+ /xox[baprs]-[A-Za-z0-9-]{10,}/g,
19
+ /ya29\.[A-Za-z0-9_-]{20,}/g,
20
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
21
+ ];
22
+
23
+ export const REDACTED = '[REDACTADO]';
24
+
25
+ /** ¿El nombre de la variable parece contener un secreto? */
26
+ export function isSecretName(name) {
27
+ return SECRET_NAME.test(String(name ?? ''));
28
+ }
29
+
30
+ /**
31
+ * Valores de entorno que hay que redactar: los de variables con nombre
32
+ * sensible y al menos 8 caracteres (evita tapar valores triviales).
33
+ * @param {Record<string,string>} [env]
34
+ * @returns {string[]} ordenados de mayor a menor (los mas largos primero)
35
+ */
36
+ export function secretValues(env = getEnv()) {
37
+ const values = new Set();
38
+ for (const [name, value] of Object.entries(env ?? {})) {
39
+ if (!isSecretName(name)) continue;
40
+ const text = String(value ?? '');
41
+ if (text.length >= 8) values.add(text);
42
+ }
43
+ return [...values].sort((a, b) => b.length - a.length);
44
+ }
45
+
46
+ /**
47
+ * Redacta secretos conocidos (valores de env sensibles + patrones de tokens).
48
+ * @param {string} text
49
+ * @param {{env?:Record<string,string>, extra?:string[]}} [opts]
50
+ * @returns {string}
51
+ */
52
+ export function redactSecrets(text, { env, extra = [] } = {}) {
53
+ let output = String(text ?? '');
54
+ if (!output) return output;
55
+
56
+ const values = [...secretValues(env), ...extra].filter((v) => v && v.length >= 8);
57
+ for (const value of values) {
58
+ if (output.includes(value)) output = output.split(value).join(REDACTED);
59
+ }
60
+ for (const pattern of SECRET_PATTERNS) {
61
+ output = output.replace(pattern, REDACTED);
62
+ }
63
+ return output;
64
+ }
65
+
66
+ /**
67
+ * Redacta recursivamente strings y valores bajo claves sensibles en un objeto.
68
+ * @param {unknown} value
69
+ * @param {{env?:Record<string,string>, extra?:string[]}} [opts]
70
+ */
71
+ export function redactDeep(value, opts = {}) {
72
+ if (typeof value === 'string') return redactSecrets(value, opts);
73
+ if (Array.isArray(value)) return value.map((item) => redactDeep(item, opts));
74
+ if (value && typeof value === 'object') {
75
+ const result = {};
76
+ for (const [key, item] of Object.entries(value)) {
77
+ result[key] = isSecretName(key) ? REDACTED : redactDeep(item, opts);
78
+ }
79
+ return result;
80
+ }
81
+ return value;
82
+ }
83
+
84
+ /**
85
+ * Enmascara un valor para mostrarlo al cliente (nunca el secreto completo).
86
+ * `env:VAR` se conserva para que se vea la indireccion.
87
+ */
88
+ export function maskSecret(value) {
89
+ const text = String(value ?? '');
90
+ if (!text) return '';
91
+ if (text.startsWith('env:')) return text;
92
+ if (text.length <= 4) return '••••';
93
+ return `••••${text.slice(-4)}`;
94
+ }
95
+
96
+ /** Enmascara un mapa de env para devolverlo por la API. */
97
+ export function maskEnv(env) {
98
+ if (!env || typeof env !== 'object') return env ?? null;
99
+ const result = {};
100
+ for (const [key, value] of Object.entries(env)) {
101
+ result[key] = isSecretName(key) ? maskSecret(value) : String(value ?? '');
102
+ }
103
+ return result;
104
+ }
@@ -235,3 +235,96 @@ test('agent aggregates usage and stamps tokens on assistant messages', async ()
235
235
  assert.equal(assistants[1].tokens_in, 20);
236
236
  assert.equal(assistants[1].tokens_out, 5);
237
237
  });
238
+
239
+ test('agent sigue solo hasta end_turn sin tope de rondas', async () => {
240
+ let round = 0;
241
+ const provider = {
242
+ async streamChat(_config, { tools }) {
243
+ if (!tools || tools.length === 0) return { content: 'listo', reasoning: '', toolCalls: [] };
244
+ round += 1;
245
+ if (round <= 10) {
246
+ return {
247
+ content: '',
248
+ reasoning: '',
249
+ toolCalls: [{ id: `c${round}`, function: { name: 'step', arguments: JSON.stringify({ n: round }) } }],
250
+ };
251
+ }
252
+ return { content: 'terminado', reasoning: '', toolCalls: [] };
253
+ },
254
+ };
255
+
256
+ const result = await runAgent({
257
+ provider,
258
+ config: {},
259
+ model: 'm',
260
+ messages: [{ role: 'user', content: 'x' }],
261
+ tools: [{ type: 'function', function: { name: 'step', parameters: {} } }],
262
+ execute: async () => 'ok',
263
+ });
264
+
265
+ assert.equal(result.stopReason, 'end_turn');
266
+ assert.equal(result.rounds, 11);
267
+ assert.equal(result.content, 'terminado');
268
+ });
269
+
270
+ test('agent corta por no progreso y cierra con resumen sin tools', async () => {
271
+ const provider = {
272
+ async streamChat(_config, { tools }) {
273
+ if (!tools || tools.length === 0) return { content: 'resumen final', reasoning: '', toolCalls: [] };
274
+ return {
275
+ content: '',
276
+ reasoning: '',
277
+ toolCalls: [{ id: 'x', function: { name: 'noop', arguments: '{}' } }],
278
+ };
279
+ },
280
+ };
281
+ let executions = 0;
282
+ const result = await runAgent({
283
+ provider,
284
+ config: {},
285
+ model: 'm',
286
+ messages: [{ role: 'user', content: 'x' }],
287
+ tools: [{ type: 'function', function: { name: 'noop', parameters: {} } }],
288
+ execute: async () => {
289
+ executions += 1;
290
+ return 'ok';
291
+ },
292
+ repeatLimit: 2,
293
+ });
294
+
295
+ assert.equal(result.stopReason, 'no_progress');
296
+ assert.equal(result.wrappedUp, true);
297
+ assert.equal(result.content, 'resumen final');
298
+ assert.equal(executions, 2);
299
+ });
300
+
301
+ test('agent respeta el presupuesto de tokens y cierra con resumen', async () => {
302
+ let round = 0;
303
+ const provider = {
304
+ async streamChat(_config, { tools }) {
305
+ if (!tools || tools.length === 0) {
306
+ return { content: 'cierre', reasoning: '', toolCalls: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };
307
+ }
308
+ round += 1;
309
+ return {
310
+ content: '',
311
+ reasoning: '',
312
+ toolCalls: [{ id: `c${round}`, function: { name: 'step', arguments: JSON.stringify({ n: round }) } }],
313
+ usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },
314
+ };
315
+ },
316
+ };
317
+
318
+ const result = await runAgent({
319
+ provider,
320
+ config: {},
321
+ model: 'm',
322
+ messages: [{ role: 'user', content: 'x' }],
323
+ tools: [{ type: 'function', function: { name: 'step', parameters: {} } }],
324
+ execute: async () => 'ok',
325
+ maxTokens: 50,
326
+ });
327
+
328
+ assert.equal(result.stopReason, 'budget');
329
+ assert.equal(result.content, 'cierre');
330
+ });
@@ -6,7 +6,9 @@ import { tmpdir } from 'node:os';
6
6
  import path from 'node:path';
7
7
  import {
8
8
  buildMcpRegistry,
9
+ childEnv,
9
10
  connectMcpStdioServer,
11
+ mcpResultToText,
10
12
  mcpTransport,
11
13
  } from '../src/index.js';
12
14
 
@@ -156,3 +158,68 @@ test('buildMcpRegistry connects over HTTP (JSON responses)', async () => {
156
158
  server.close();
157
159
  }
158
160
  });
161
+
162
+ const ENV_SERVER = `
163
+ import readline from 'node:readline';
164
+ readline.createInterface({ input: process.stdin }).on('line', (line) => {
165
+ if (!line.trim()) return;
166
+ const m = JSON.parse(line);
167
+ let r = null;
168
+ if (m.method === 'initialize') r = { jsonrpc: '2.0', id: m.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} } } };
169
+ else if (m.method === 'tools/list') r = { jsonrpc: '2.0', id: m.id, result: { tools: [{ name: 'env', inputSchema: { type: 'object' } }] } };
170
+ else if (m.method === 'tools/call') r = { jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: String(process.env.TAMNORA_TEST_VALUE ?? '') }] } };
171
+ if (r) process.stdout.write(JSON.stringify(r) + '\\n');
172
+ });
173
+ `;
174
+
175
+ async function makeEnvServer() {
176
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-mcp-env-'));
177
+ const file = path.join(dir, 'env-server.mjs');
178
+ await writeFile(file, ENV_SERVER);
179
+ return { id: 'env', command: process.execPath, args: [file] };
180
+ }
181
+
182
+ test('childEnv no hereda process.env por defecto y resuelve env:VAR', () => {
183
+ process.env.TAMNORA_TEST_VALUE = 'visible123';
184
+ try {
185
+ assert.equal(childEnv({}).TAMNORA_TEST_VALUE, undefined);
186
+ const declared = childEnv({ env: { VAL: 'env:TAMNORA_TEST_VALUE' } });
187
+ assert.equal(declared.VAL, 'visible123');
188
+ assert.equal(childEnv({ inheritEnv: true }).TAMNORA_TEST_VALUE, 'visible123');
189
+ } finally {
190
+ delete process.env.TAMNORA_TEST_VALUE;
191
+ }
192
+ });
193
+
194
+ test('un MCP stdio no ve el entorno del padre salvo inheritEnv o env declarado', async () => {
195
+ process.env.TAMNORA_TEST_VALUE = 'visible123';
196
+ try {
197
+ const hidden = await connectMcpStdioServer(await makeEnvServer());
198
+ assert.equal(await hidden.callTool('env', {}), '');
199
+ hidden.close();
200
+
201
+ const declared = await connectMcpStdioServer({
202
+ ...(await makeEnvServer()),
203
+ env: { TAMNORA_TEST_VALUE: 'env:TAMNORA_TEST_VALUE' },
204
+ });
205
+ assert.equal(await declared.callTool('env', {}), 'visible123');
206
+ declared.close();
207
+
208
+ const inherited = await connectMcpStdioServer({ ...(await makeEnvServer()), inheritEnv: true });
209
+ assert.equal(await inherited.callTool('env', {}), 'visible123');
210
+ inherited.close();
211
+ } finally {
212
+ delete process.env.TAMNORA_TEST_VALUE;
213
+ }
214
+ });
215
+
216
+ test('mcpResultToText redacta secretos del entorno', () => {
217
+ process.env.TAMNORA_TEST_SECRET = 'ultrasecreto123';
218
+ try {
219
+ const text = mcpResultToText({ content: [{ type: 'text', text: 'valor=ultrasecreto123 fin' }] });
220
+ assert.match(text, /REDACTADO/);
221
+ assert.doesNotMatch(text, /ultrasecreto123/);
222
+ } finally {
223
+ delete process.env.TAMNORA_TEST_SECRET;
224
+ }
225
+ });
@@ -0,0 +1,45 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ REDACTED,
5
+ isSecretName,
6
+ maskEnv,
7
+ maskSecret,
8
+ redactDeep,
9
+ redactSecrets,
10
+ secretValues,
11
+ } from '../src/redact.js';
12
+
13
+ test('isSecretName detecta nombres sensibles', () => {
14
+ assert.equal(isSecretName('NPM_TOKEN'), true);
15
+ assert.equal(isSecretName('MYSQL_PASSWORD'), true);
16
+ assert.equal(isSecretName('OPENAI_API_KEY'), true);
17
+ assert.equal(isSecretName('PATH'), false);
18
+ assert.equal(isSecretName('USERNAME'), false);
19
+ });
20
+
21
+ test('secretValues toma valores de nombres sensibles (>= 8 chars)', () => {
22
+ const values = secretValues({ NPM_TOKEN: 'npm_abcdefgh', PATH: '/usr/bin', SHORT: 'x' });
23
+ assert.deepEqual(values, ['npm_abcdefgh']);
24
+ });
25
+
26
+ test('redactSecrets tapa valores de env y patrones conocidos', () => {
27
+ const env = { NPM_TOKEN: 'npm_secretvalue123456' };
28
+ assert.equal(redactSecrets('token= npm_secretvalue123456', { env }), `token= ${REDACTED}`);
29
+ assert.equal(redactSecrets('ghp_abcdefghijklmnopqrst'), REDACTED);
30
+ assert.equal(redactSecrets('hola mundo'), 'hola mundo');
31
+ });
32
+
33
+ test('redactDeep tapa claves sensibles dentro de objetos', () => {
34
+ const result = redactDeep({ user: 'ana', password: 'hunter2', nested: { apiKey: 'abc123' } });
35
+ assert.deepEqual(result, { user: 'ana', password: REDACTED, nested: { apiKey: REDACTED } });
36
+ });
37
+
38
+ test('maskSecret y maskEnv enmascaran sin exponer el valor', () => {
39
+ assert.equal(maskSecret('supersecret'), '••••cret');
40
+ assert.equal(maskSecret('env:NPM_TOKEN'), 'env:NPM_TOKEN');
41
+ assert.deepEqual(maskEnv({ MYSQL_HOST: 'db.local', MYSQL_PASSWORD: 'supersecret' }), {
42
+ MYSQL_HOST: 'db.local',
43
+ MYSQL_PASSWORD: '••••cret',
44
+ });
45
+ });
@@ -178,7 +178,7 @@ function activateTab(name) {
178
178
  hideMoreMenu();
179
179
  if (name === 'runs') refreshRuns();
180
180
  if (name === 'providers') refreshProviders();
181
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); }
181
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); }
182
182
  }
183
183
 
184
184
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -203,27 +203,61 @@ document.addEventListener('keydown', (event) => {
203
203
  /* --- settings modal (modo / proveedor / modelo / auto) -------------------- */
204
204
 
205
205
  const settingsModal = $('#settings-modal');
206
+ const agentControls = $('#agent-settings-controls');
207
+ const agentControlsSlot = $('#agent-controls-slot');
208
+ const settingsControlsSlot = $('#settings-controls-slot');
209
+
210
+ /** Los controles modo/proveedor/modelo/auto se comparten entre el tab Agente
211
+ * y el modal: se mueven de un slot al otro para no duplicar IDs ni estado. */
212
+ function moveAgentControls(target) {
213
+ if (agentControls && target) target.appendChild(agentControls);
214
+ }
215
+
216
+ function openSettingsModal() {
217
+ if (!settingsModal) return;
218
+ moveAgentControls(settingsControlsSlot);
219
+ syncDefaultCwd();
220
+ settingsModal.showModal();
221
+ }
222
+
223
+ function closeSettingsModal() {
224
+ moveAgentControls(agentControlsSlot);
225
+ settingsModal?.close();
226
+ }
227
+
206
228
  if (settingsModal) {
207
- $('#settings-toggle')?.addEventListener('click', () => {
208
- syncDefaultCwd();
209
- settingsModal.showModal();
210
- });
211
- $('#settings-close')?.addEventListener('click', () => settingsModal.close());
212
- $('#settings-done')?.addEventListener('click', () => settingsModal.close());
229
+ $('#settings-toggle')?.addEventListener('click', openSettingsModal);
230
+ $('#settings-close')?.addEventListener('click', closeSettingsModal);
231
+ $('#settings-done')?.addEventListener('click', closeSettingsModal);
213
232
  $('#settings-providers')?.addEventListener('click', () => {
214
- settingsModal.close();
233
+ closeSettingsModal();
215
234
  activateTab('providers');
216
235
  });
217
- $('#default-cwd-pick')?.addEventListener('click', () => {
218
- settingsModal.close();
219
- openFolderPicker('default');
220
- });
236
+ $('#agent-settings-open')?.addEventListener('click', openSettingsModal);
237
+ $('#agent-providers')?.addEventListener('click', () => activateTab('providers'));
238
+ $('#default-cwd-pick')?.addEventListener('click', () => openFolderPicker('default'));
221
239
  $('#default-cwd-clear')?.addEventListener('click', () => {
222
240
  writeDefaultCwd('');
223
241
  syncDefaultCwd();
224
242
  });
225
243
  settingsModal.addEventListener('click', (event) => {
226
- if (event.target === settingsModal) settingsModal.close();
244
+ if (event.target === settingsModal) closeSettingsModal();
245
+ });
246
+ settingsModal.addEventListener('close', () => moveAgentControls(agentControlsSlot));
247
+ }
248
+
249
+ // El footer (modo · proveedor · modelo · estado) abre el modal de ajustes.
250
+ const statusbarEl = $('#statusbar');
251
+ if (statusbarEl) {
252
+ statusbarEl.addEventListener('click', (event) => {
253
+ if (event.target.closest('#sb-context')) return;
254
+ openSettingsModal();
255
+ });
256
+ statusbarEl.addEventListener('keydown', (event) => {
257
+ if (event.key === 'Enter' || event.key === ' ') {
258
+ event.preventDefault();
259
+ openSettingsModal();
260
+ }
227
261
  });
228
262
  }
229
263
 
@@ -235,7 +269,7 @@ const PALETTE_COMMANDS = [
235
269
  { id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
236
270
  { id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
237
271
  { id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
238
- { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => { syncDefaultCwd(); settingsModal?.showModal(); } },
272
+ { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => openSettingsModal() },
239
273
  { id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('providers') },
240
274
  { id: 'ajustes', label: 'MCP y Skills (ajustes)', run: () => activateTab('ajustes') },
241
275
  { id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
@@ -1545,11 +1579,25 @@ function renderCompletion(message, data) {
1545
1579
  const duration = formatDuration(data.durationMs);
1546
1580
  if (duration) parts.push(duration);
1547
1581
  if (data.tokens?.total_tokens) parts.push(`${Number(data.tokens.total_tokens).toLocaleString('es')} tokens`);
1548
- const maxed = data.stopReason === 'max_rounds';
1549
- message.tools.appendChild(el('div', maxed ? 'chip warn' : 'chip ok', `${maxed ? '⚠ máximo de rondas' : '✓ terminado'} · ${parts.join(' · ')}`));
1582
+ const cut = ['max_rounds', 'no_progress', 'budget'].includes(data.stopReason);
1583
+ const label = cut ? (data.wrappedUp ? '⚠ corte de seguridad · resumen' : '⚠ corte de seguridad') : '✓ terminado';
1584
+ message.tools.appendChild(el('div', cut ? 'chip warn' : 'chip ok', `${label} · ${parts.join(' · ')}`));
1550
1585
  scrollMessages(paneOf(message));
1551
1586
  }
1552
1587
 
1588
+ /** Retoma un turno cortado por límite/bucle sin tener que escribir "continúa". */
1589
+ function addContinueButton(turn, data) {
1590
+ const btn = el('button', 'chip', data.wrappedUp ? '↻ Continuar el trabajo' : '↻ Continuar');
1591
+ btn.type = 'button';
1592
+ btn.addEventListener('click', () => {
1593
+ if (state.panes.get(turn.sessionKey)?.working) return;
1594
+ chatInput.value = 'Continuá donde quedaste.';
1595
+ sendMessage();
1596
+ });
1597
+ turn.assistant().tools.appendChild(btn);
1598
+ scrollMessages(paneOf(turn.assistant()));
1599
+ }
1600
+
1553
1601
  function handleChatEvent(event, data, turn) {
1554
1602
  if (handleAgentEvent(event, data, turn)) return;
1555
1603
  if (event === 'agent_start') {
@@ -1615,6 +1663,8 @@ function handleChatEvent(event, data, turn) {
1615
1663
  if (!turn.sawText) setText(turn.assistant(), `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
1616
1664
  } else if (event === 'trim') {
1617
1665
  turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1666
+ } else if (event === 'repeat') {
1667
+ turn.assistant().tools.appendChild(el('div', 'chip warn', `↻ ${data.name} repetida sin cambios`));
1618
1668
  } else if (event === 'warning') {
1619
1669
  turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1620
1670
  } else if (event === 'error') {
@@ -1625,12 +1675,16 @@ function handleChatEvent(event, data, turn) {
1625
1675
  const finalText = (turn.active?.textEl?.textContent ?? '').trim();
1626
1676
  const tools = Number(data.toolCalls) || 0;
1627
1677
  if (!finalText) {
1628
- if (data.stopReason === 'max_rounds') {
1678
+ if (data.stopReason === 'no_progress') {
1679
+ setText(turn.assistant(), '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.');
1680
+ } else if (data.stopReason === 'budget') {
1681
+ setText(turn.assistant(), '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.');
1682
+ } else if (data.stopReason === 'max_rounds') {
1629
1683
  setText(
1630
1684
  turn.assistant(),
1631
1685
  `⚠ El agente alcanzó el máximo de rondas (${data.rounds ?? '?'}) sin dejar una respuesta final.${
1632
1686
  tools ? ` Hizo ${tools} llamadas a herramientas.` : ''
1633
- } Pedile que continúe o subí \`maxRounds\`.`
1687
+ }`
1634
1688
  );
1635
1689
  } else if (tools) {
1636
1690
  setText(turn.assistant(), '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.');
@@ -1639,6 +1693,7 @@ function handleChatEvent(event, data, turn) {
1639
1693
  }
1640
1694
  }
1641
1695
  renderCompletion(turn.active, data);
1696
+ if (data.continuable) addContinueButton(turn, data);
1642
1697
  updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
1643
1698
  addTokens(data.tokens);
1644
1699
  } else if (event === 'aborted') {
@@ -1679,6 +1734,7 @@ async function attachRun(sessionKey, pane) {
1679
1734
  async function sendMessage() {
1680
1735
  const current = state.panes.get(state.sessionId);
1681
1736
  if (current?.working) return;
1737
+ hideSlashMenu();
1682
1738
  let input = chatInput.value.trim();
1683
1739
  const images = state.attachments.slice();
1684
1740
  if (input.startsWith('/') && !images.length) {
@@ -1778,11 +1834,13 @@ chatForm.addEventListener('submit', (event) => {
1778
1834
  sendMessage();
1779
1835
  });
1780
1836
  chatInput.addEventListener('keydown', (event) => {
1837
+ if (handleSlashKey(event)) return;
1781
1838
  if (event.key === 'Enter' && !event.shiftKey) {
1782
1839
  event.preventDefault();
1783
1840
  sendMessage();
1784
1841
  }
1785
1842
  });
1843
+ chatInput.addEventListener('input', onChatInputChanged);
1786
1844
 
1787
1845
  /* --- image attachments ---------------------------------------------------- */
1788
1846
 
@@ -1956,6 +2014,99 @@ async function runCommand(name, arg) {
1956
2014
  }
1957
2015
  }
1958
2016
 
2017
+ /* --- menú de comandos "/" ------------------------------------------------- */
2018
+
2019
+ const slashMenuEl = $('#slash-menu');
2020
+ let slashMatches = [];
2021
+ let slashIndex = 0;
2022
+
2023
+ function slashCommandEntries() {
2024
+ return COMMANDS.map(([cmd, desc]) => {
2025
+ const name = cmd.split(' ')[0];
2026
+ const takesArgs = cmd.includes(' ');
2027
+ return { name, desc, insert: takesArgs ? `${name} ` : name, takesArgs };
2028
+ });
2029
+ }
2030
+
2031
+ function hideSlashMenu() {
2032
+ if (!slashMenuEl) return;
2033
+ slashMenuEl.hidden = true;
2034
+ slashMenuEl.replaceChildren();
2035
+ slashMatches = [];
2036
+ slashIndex = 0;
2037
+ }
2038
+
2039
+ function renderSlashMenu() {
2040
+ if (!slashMenuEl) return;
2041
+ slashMenuEl.replaceChildren();
2042
+ slashMatches.forEach((entry, index) => {
2043
+ const item = el('button', `slash-item${index === slashIndex ? ' active' : ''}`);
2044
+ item.type = 'button';
2045
+ item.append(el('span', 'slash-cmd', entry.name), el('span', 'slash-desc', entry.desc));
2046
+ // mousedown (no click) para no perder el foco del textarea antes de elegir.
2047
+ item.addEventListener('mousedown', (event) => {
2048
+ event.preventDefault();
2049
+ chooseSlash(index);
2050
+ });
2051
+ slashMenuEl.appendChild(item);
2052
+ });
2053
+ slashMenuEl.hidden = false;
2054
+ }
2055
+
2056
+ function updateSlashMenu() {
2057
+ if (!slashMenuEl) return;
2058
+ const value = chatInput.value;
2059
+ if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
2060
+ const query = value.slice(1).toLowerCase();
2061
+ slashMatches = slashCommandEntries().filter((entry) => entry.name.slice(1).toLowerCase().startsWith(query));
2062
+ if (!slashMatches.length) return hideSlashMenu();
2063
+ if (slashIndex >= slashMatches.length) slashIndex = 0;
2064
+ renderSlashMenu();
2065
+ }
2066
+
2067
+ function chooseSlash(index) {
2068
+ const entry = slashMatches[index];
2069
+ if (!entry) return;
2070
+ chatInput.value = entry.insert;
2071
+ chatInput.focus();
2072
+ hideSlashMenu();
2073
+ if (!entry.takesArgs) sendMessage();
2074
+ }
2075
+
2076
+ function handleSlashKey(event) {
2077
+ if (!slashMenuEl || slashMenuEl.hidden || !slashMatches.length) return false;
2078
+ if (event.key === 'ArrowDown') {
2079
+ event.preventDefault();
2080
+ slashIndex = (slashIndex + 1) % slashMatches.length;
2081
+ renderSlashMenu();
2082
+ return true;
2083
+ }
2084
+ if (event.key === 'ArrowUp') {
2085
+ event.preventDefault();
2086
+ slashIndex = (slashIndex - 1 + slashMatches.length) % slashMatches.length;
2087
+ renderSlashMenu();
2088
+ return true;
2089
+ }
2090
+ if (event.key === 'Escape') {
2091
+ event.preventDefault();
2092
+ hideSlashMenu();
2093
+ return true;
2094
+ }
2095
+ if (event.key === 'Enter' || event.key === 'Tab') {
2096
+ event.preventDefault();
2097
+ chooseSlash(slashIndex);
2098
+ return true;
2099
+ }
2100
+ return false;
2101
+ }
2102
+
2103
+ function onChatInputChanged() {
2104
+ slashIndex = 0;
2105
+ updateSlashMenu();
2106
+ }
2107
+
2108
+ chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
2109
+
1959
2110
  /* --- folder picker -------------------------------------------------------- */
1960
2111
 
1961
2112
  const folderModal = $('#folder-modal');
@@ -2402,6 +2553,50 @@ const tgTranscribeEl = $('#tg-transcribe');
2402
2553
  const tgTranscribeProviderEl = $('#tg-transcribe-provider');
2403
2554
  const tgStatusEl = $('#tg-status');
2404
2555
 
2556
+ /* --- ajustes: pestañas internas + límites del agente --------------------- */
2557
+
2558
+ const ajustesTabs = document.querySelectorAll('.ajustes-tab');
2559
+
2560
+ function activateAjustesPanel(name) {
2561
+ ajustesTabs.forEach((tab) => tab.classList.toggle('active', tab.dataset.panel === name));
2562
+ document
2563
+ .querySelectorAll('.ajustes-panel')
2564
+ .forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === name));
2565
+ if (name === 'agente') moveAgentControls(agentControlsSlot);
2566
+ }
2567
+
2568
+ ajustesTabs.forEach((tab) =>
2569
+ tab.addEventListener('click', () => activateAjustesPanel(tab.dataset.panel))
2570
+ );
2571
+
2572
+ const limitsRoundsEl = $('#limits-rounds');
2573
+ const limitsTokensEl = $('#limits-tokens');
2574
+ const limitsDurationEl = $('#limits-duration');
2575
+
2576
+ async function refreshLimits() {
2577
+ const data = await api('/api/settings').catch(() => null);
2578
+ const settings = data?.settings;
2579
+ if (!settings) return;
2580
+ if (limitsRoundsEl) limitsRoundsEl.value = settings.maxRounds ?? 0;
2581
+ if (limitsTokensEl) limitsTokensEl.value = settings.maxTokens ?? 0;
2582
+ if (limitsDurationEl) limitsDurationEl.value = Math.round((settings.maxDurationMs ?? 0) / 1000);
2583
+ }
2584
+
2585
+ $('#limits-save')?.addEventListener('click', async () => {
2586
+ const body = {
2587
+ maxRounds: Math.max(0, Number(limitsRoundsEl?.value) || 0),
2588
+ maxTokens: Math.max(0, Number(limitsTokensEl?.value) || 0),
2589
+ maxDurationMs: Math.max(0, Math.round((Number(limitsDurationEl?.value) || 0) * 1000)),
2590
+ };
2591
+ try {
2592
+ await api('/api/settings', { method: 'PUT', body });
2593
+ refreshLimits();
2594
+ setStatus('#limits-msg', 'guardado', 'ok');
2595
+ } catch (error) {
2596
+ setStatus('#limits-msg', error.message, 'err');
2597
+ }
2598
+ });
2599
+
2405
2600
  function setStatus(node, text, cls = '') {
2406
2601
  const elNode = $(node);
2407
2602
  if (!elNode) return;
@@ -2443,6 +2638,8 @@ function syncMcpFormFields() {
2443
2638
  const http = $('#mf-transport').value === 'http';
2444
2639
  $('#mf-command-field').hidden = http;
2445
2640
  $('#mf-args-field').hidden = http;
2641
+ $('#mf-env-field').hidden = http;
2642
+ $('#mf-inherit-field').hidden = http;
2446
2643
  $('#mf-url-field').hidden = !http;
2447
2644
  }
2448
2645
 
@@ -2458,6 +2655,7 @@ function openMcpForm(server = null) {
2458
2655
  $('#mf-env').value = Object.entries(server?.env ?? {})
2459
2656
  .map(([key, value]) => `${key}=${value ?? ''}`)
2460
2657
  .join('\n');
2658
+ $('#mf-inherit-env').checked = server?.inheritEnv === true;
2461
2659
  $('#mf-enabled').checked = server ? server.enabled !== false : true;
2462
2660
  syncMcpFormFields();
2463
2661
  setStatus('#mf-status', '');
@@ -2517,6 +2715,7 @@ async function submitMcp(event) {
2517
2715
  args: $('#mf-args').value.trim(),
2518
2716
  url: $('#mf-url').value.trim(),
2519
2717
  env: parseEnv($('#mf-env').value),
2718
+ inheritEnv: $('#mf-inherit-env').checked,
2520
2719
  enabled: $('#mf-enabled').checked,
2521
2720
  };
2522
2721
  try {