@danieltmn/tamnora 0.24.2 → 0.25.1

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 (83) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/harness/packages/cli/src/commands/repl.js +18 -2
  3. package/harness/packages/cli/src/commands/run.js +5 -1
  4. package/harness/packages/cli/src/commands/tui.js +5 -1
  5. package/harness/packages/cli/src/lib/runtime.js +2 -0
  6. package/harness/packages/cli/src/tui/app.js +22 -2
  7. package/harness/packages/cli/src/tui/layout.js +1 -1
  8. package/harness/packages/core/src/agent/runAgent.js +5 -2
  9. package/harness/packages/core/src/agents/index.js +141 -0
  10. package/harness/packages/core/src/config/index.js +1 -0
  11. package/harness/packages/core/src/index.js +10 -0
  12. package/harness/packages/core/src/providers/index.js +3 -1
  13. package/harness/packages/core/src/providers/ollama.js +4 -1
  14. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  15. package/harness/packages/core/src/providers/openrouter.js +5 -1
  16. package/harness/packages/core/src/providers/presets.js +4 -3
  17. package/harness/packages/core/src/providers/reasoning.js +58 -0
  18. package/harness/packages/core/test/agents.test.js +111 -0
  19. package/harness/packages/core/test/reasoning.test.js +43 -0
  20. package/harness/packages/server/public/app.js +868 -834
  21. package/harness/packages/server/public/appearance.js +4 -0
  22. package/harness/packages/server/public/boot.js +49 -0
  23. package/harness/packages/server/public/chat.js +1 -0
  24. package/harness/packages/server/public/chrome.js +946 -852
  25. package/harness/packages/server/public/commands.js +448 -405
  26. package/harness/packages/server/public/core.js +229 -216
  27. package/harness/packages/server/public/favicon.ico +0 -0
  28. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  29. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  30. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  31. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  32. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  33. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  34. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  35. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  36. package/harness/packages/server/public/icons/logo.png +0 -0
  37. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/icons/tamnora.ico +0 -0
  40. package/harness/packages/server/public/icons (1).zip +0 -0
  41. package/harness/packages/server/public/index.html +161 -101
  42. package/harness/packages/server/public/landing/boot.js +16 -0
  43. package/harness/packages/server/public/landing/index.html +123 -59
  44. package/harness/packages/server/public/landing/landing.js +11 -9
  45. package/harness/packages/server/public/logo.png +0 -0
  46. package/harness/packages/server/public/markdown.js +1 -1
  47. package/harness/packages/server/public/sessions.js +1 -0
  48. package/harness/packages/server/public/settings.js +1322 -1080
  49. package/harness/packages/server/public/settingsNav.js +82 -0
  50. package/harness/packages/server/public/styles.css +2807 -2525
  51. package/harness/packages/server/public/system.js +2 -2
  52. package/harness/packages/server/src/lib/agents.js +74 -0
  53. package/harness/packages/server/src/lib/appearance.js +4 -0
  54. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  55. package/harness/packages/server/src/lib/prompt.js +11 -3
  56. package/harness/packages/server/src/lib/restart.js +20 -0
  57. package/harness/packages/server/src/lib/scheduler.js +4 -0
  58. package/harness/packages/server/src/lib/securityHeaders.js +53 -0
  59. package/harness/packages/server/src/lib/sessionView.js +1 -0
  60. package/harness/packages/server/src/lib/skills.js +13 -0
  61. package/harness/packages/server/src/lib/tasks.js +71 -23
  62. package/harness/packages/server/src/routes/agents.js +88 -0
  63. package/harness/packages/server/src/routes/chat.js +9 -1
  64. package/harness/packages/server/src/routes/providers.js +7 -0
  65. package/harness/packages/server/src/routes/reload.js +46 -0
  66. package/harness/packages/server/src/routes/sessions.js +3 -1
  67. package/harness/packages/server/src/server.js +13 -1
  68. package/harness/packages/server/test/accessibility.test.js +72 -0
  69. package/harness/packages/server/test/agents.test.js +194 -0
  70. package/harness/packages/server/test/appearance.test.js +4 -0
  71. package/harness/packages/server/test/consoleModules.test.js +133 -57
  72. package/harness/packages/server/test/consoleTabs.test.js +228 -0
  73. package/harness/packages/server/test/docsSync.test.js +73 -0
  74. package/harness/packages/server/test/landingLogin.test.js +89 -0
  75. package/harness/packages/server/test/markdownSecurity.test.js +80 -0
  76. package/harness/packages/server/test/reload.test.js +95 -0
  77. package/harness/packages/server/test/securityHeaders.test.js +109 -0
  78. package/harness/packages/server/test/settingsNav.test.js +90 -0
  79. package/harness/packages/server/test/themeCss.js +110 -0
  80. package/harness/packages/server/test/themes.test.js +72 -0
  81. package/harness/scripts/restart.mjs +101 -0
  82. package/harness/tamnora.example.json +1 -0
  83. package/package.json +1 -1
@@ -0,0 +1,46 @@
1
+ // Recargas operativas desde la consola: reiniciar el server y (re)abrir el
2
+ // puente remoto (tailscale). Son privilegiadas, igual que update/shell/git:
3
+ // solo conexion local. En ambos casos la respuesta vuelve rapido y la accion
4
+ // corre aparte (script detached / `tailscale serve --bg`).
5
+ import { sendJson, sendError } from '../lib/http.js';
6
+ import { requireLocal } from '../lib/workspace.js';
7
+ import { spawnRestart } from '../lib/restart.js';
8
+ import { startTailscaleServe, stopTailscaleServe } from '../lib/core.js';
9
+
10
+ export function registerReload(route, ctx) {
11
+ route('POST', '/api/server/restart', ({ req, res }) => {
12
+ if (!requireLocal(req, res, 'Reiniciar el servidor solo esta permitido en la conexion local.')) return;
13
+
14
+ const spawnRestarter = ctx.spawnRestarter ?? spawnRestart;
15
+ const started = spawnRestarter({
16
+ pid: process.pid,
17
+ argv: process.argv.slice(1),
18
+ });
19
+ if (!started.ok) return sendError(res, 500, started.error);
20
+
21
+ sendJson(res, 202, { ok: true, restarting: true });
22
+ // Soltar el puerto antes de que el server nuevo intente entrar.
23
+ setTimeout(() => {
24
+ Promise.resolve(ctx.shutdown?.()).catch(() => process.exit(0));
25
+ }, 800);
26
+ });
27
+
28
+ route('POST', '/api/bridge/reload', async ({ req, res }) => {
29
+ if (!requireLocal(req, res, 'Abrir el puente solo esta permitido en la conexion local.')) return;
30
+
31
+ const port = ctx.serverInfo?.port;
32
+ if (!port) return sendError(res, 500, 'No se en que puerto corre el server.');
33
+
34
+ // Reinicio puntual: apagar el handler de la consola (443) y volver a
35
+ // publicarlo. Un `serve reset` borraria tambien el preview en 8443.
36
+ const stop = ctx.tunnelStop ?? stopTailscaleServe;
37
+ const start = ctx.tunnelStart ?? startTailscaleServe;
38
+ try {
39
+ await stop().catch(() => {});
40
+ const tun = await start(port);
41
+ sendJson(res, 200, { ok: true, url: tun.url, provider: tun.provider ?? 'tailscale' });
42
+ } catch (error) {
43
+ sendError(res, 502, String(error?.message ?? error));
44
+ }
45
+ });
46
+ }
@@ -1,5 +1,5 @@
1
1
  import { readJson, sendJson, sendError } from '../lib/http.js';
2
- import { resolveMode } from '../lib/core.js';
2
+ import { resolveMode, normalizeEffort } from '../lib/core.js';
3
3
  import { workspaceForRequest } from '../lib/workspace.js';
4
4
  import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
5
5
  import { summarizeSession, compactSession, SUMMARY_INSTRUCTION } from '../lib/compact.js';
@@ -49,6 +49,7 @@ export function registerSessions(route, ctx) {
49
49
  const meta = {};
50
50
  if (body.goal) meta.goal = String(body.goal);
51
51
  if (body.mode) meta.mode = resolveMode(String(body.mode)).id;
52
+ if ('effort' in body) meta.effort = normalizeEffort(body.effort);
52
53
  const id = ctx.store.createSession({
53
54
  title: String(body.title || 'Nueva sesión').slice(0, 80),
54
55
  provider: body.provider || ctx.effectiveProviderId(),
@@ -105,6 +106,7 @@ export function registerSessions(route, ctx) {
105
106
  const meta = {};
106
107
  if ('goal' in body) meta.goal = body.goal ? String(body.goal) : undefined;
107
108
  if ('mode' in body) meta.mode = resolveMode(String(body.mode)).id;
109
+ if ('effort' in body) meta.effort = normalizeEffort(body.effort);
108
110
  if (Object.keys(meta).length) patch.meta = meta;
109
111
  ctx.store.touchSession(params.id, patch);
110
112
  sendJson(res, 200, { session: sessionView(ctx.store.getSession(params.id)) });
@@ -17,12 +17,15 @@ import {
17
17
  MCP_AUTO_THRESHOLD,
18
18
  readPackageVersion,
19
19
  scopeDirective,
20
+ normalizeEffort,
20
21
  } from './lib/core.js';
21
22
  import { sendJson, sendError } from './lib/http.js';
22
23
  import { requireTrustedHost, requireSameOrigin } from './lib/workspace.js';
24
+ import { applySecurityHeaders, cspMode } from './lib/securityHeaders.js';
23
25
  import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
24
26
  import { profilePrompt, resolveProfile } from './lib/profile.js';
25
27
  import { skillsBlock } from './lib/skills.js';
28
+ import { agentsIndexPrompt } from './lib/agents.js';
26
29
  import { createApprovalBroker } from './lib/approvals.js';
27
30
  import { createQuestionBroker } from './lib/questions.js';
28
31
  import { createPresence } from './lib/presence.js';
@@ -41,12 +44,14 @@ import { registerModels } from './routes/models.js';
41
44
  import { registerProviders } from './routes/providers.js';
42
45
  import { registerMcp } from './routes/mcp.js';
43
46
  import { registerSkills } from './routes/skills.js';
47
+ import { registerAgents } from './routes/agents.js';
44
48
  import { registerMemory } from './routes/memory.js';
45
49
  import { registerChannels } from './routes/channels.js';
46
50
  import { registerProfile } from './routes/profile.js';
47
51
  import { registerTts } from './routes/tts.js';
48
52
  import { registerTranscribe } from './routes/transcribe.js';
49
53
  import { registerUpdate } from './routes/update.js';
54
+ import { registerReload } from './routes/reload.js';
50
55
  import { registerAdvisor } from './routes/advisor.js';
51
56
  import { registerProcs } from './routes/procs.js';
52
57
  import { registerShell } from './routes/shell.js';
@@ -181,6 +186,7 @@ export async function createTamnoraServer({
181
186
  const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
182
187
  const effectiveMaxOutputTokens = () => settingNumber('maxOutputTokens', config.maxOutputTokens);
183
188
  const effectiveStrictScope = () => store.getSetting('strictScope') === true;
189
+ const effectiveEffort = () => normalizeEffort(store.getSetting('activeEffort') ?? config.effort);
184
190
  const effectivePromptCaching = () => {
185
191
  const stored = store.getSetting('promptCaching');
186
192
  if (stored === null || stored === undefined) return config.promptCaching === true;
@@ -226,6 +232,7 @@ export async function createTamnoraServer({
226
232
  effectiveMaxDurationMs,
227
233
  effectiveMaxOutputTokens,
228
234
  effectiveStrictScope,
235
+ effectiveEffort,
229
236
  effectivePromptCaching,
230
237
  effectiveMcpMode,
231
238
  effectiveMcpPinned,
@@ -267,6 +274,7 @@ export async function createTamnoraServer({
267
274
  files: config.instructions,
268
275
  profileText: () => profilePrompt(resolveProfile(ctx.app)),
269
276
  skillsText: () => skillsBlock(ctx),
277
+ agentsText: () => agentsIndexPrompt(ctx),
270
278
  mcpText: () => ctx.mcpText(),
271
279
  scopeText: () => scopeDirective(ctx.effectiveStrictScope()),
272
280
  ruleFiles: config.rules ?? [],
@@ -324,6 +332,7 @@ export async function createTamnoraServer({
324
332
  registerProviders(route, ctx);
325
333
  registerMcp(route, ctx);
326
334
  registerSkills(route, ctx);
335
+ registerAgents(route, ctx);
327
336
  registerMemory(route, ctx);
328
337
  registerChannels(route, ctx);
329
338
  registerProfile(route, ctx);
@@ -331,6 +340,7 @@ export async function createTamnoraServer({
331
340
  registerTranscribe(route, ctx);
332
341
  registerAdvisor(route, ctx);
333
342
  registerUpdate(route, ctx);
343
+ registerReload(route, ctx);
334
344
  registerProcs(route, ctx);
335
345
  registerShell(route, ctx);
336
346
  const preview = registerPreview(route, ctx);
@@ -376,8 +386,10 @@ export async function createTamnoraServer({
376
386
  const sockets = new Set();
377
387
  const server = http.createServer(async (req, res) => {
378
388
  const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
389
+ const isApi = url.pathname.startsWith('/api/');
390
+ applySecurityHeaders(res, { api: isApi, csp: cspMode(config) });
379
391
 
380
- if (url.pathname.startsWith('/api/')) {
392
+ if (isApi) {
381
393
  const matched = matchRoute(req.method, url.pathname);
382
394
  if (!matched) return sendError(res, 404, `Ruta no encontrada: ${req.method} ${url.pathname}`);
383
395
  // Guardas de request SIEMPRE activas (con o sin auth): DNS-rebinding y
@@ -0,0 +1,72 @@
1
+ // Accesibilidad de la consola que se verifica sobre el texto de los archivos.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+ import { readStyles } from './themeCss.js';
6
+
7
+ const PUBLIC = new URL('../public/', import.meta.url);
8
+ const css = readStyles();
9
+
10
+ /** Contenido del bloque `@media (...)` que empieza en `inicio`. */
11
+ function bloqueMedia(inicio) {
12
+ const abre = css.indexOf('{', inicio);
13
+ let prof = 0;
14
+ for (let i = abre; i < css.length; i += 1) {
15
+ if (css[i] === '{') prof += 1;
16
+ else if (css[i] === '}') {
17
+ prof -= 1;
18
+ if (prof === 0) return css.slice(abre + 1, i);
19
+ }
20
+ }
21
+ throw new Error('bloque sin cerrar');
22
+ }
23
+
24
+ test('prefers-reduced-motion apaga transiciones y los pulsos decorativos', () => {
25
+ const inicio = css.indexOf('@media (prefers-reduced-motion: reduce)');
26
+ assert.ok(inicio >= 0, 'falta el bloque de movimiento reducido');
27
+ const bloque = bloqueMedia(inicio);
28
+ assert.match(bloque, /transition-duration:\s*0\.01ms/);
29
+ assert.match(bloque, /scroll-behavior:\s*auto/);
30
+ assert.match(bloque, /animation:\s*none/);
31
+ });
32
+
33
+ test('toda animacion infinita que no es un indicador de progreso se apaga con movimiento reducido', () => {
34
+ // Quedan fuera los loaders y el spinner: indican que el agente esta trabajando.
35
+ const PROGRESO = new Set(['loader-thinking', 'loader-working', 'spin']);
36
+ const bloque = bloqueMedia(css.indexOf('@media (prefers-reduced-motion: reduce)'));
37
+
38
+ const reglas = [...css.matchAll(/([^{}]+)\{([^{}]*animation:\s*([a-z-]+)[^;}]*infinite[^;}]*;?[^{}]*)\}/g)];
39
+ assert.ok(reglas.length >= 5, 'no se leyeron las animaciones');
40
+ for (const [, selectores, , nombre] of reglas) {
41
+ if (PROGRESO.has(nombre)) continue;
42
+ for (const selector of selectores.split(',').map((s) => s.trim()).filter(Boolean)) {
43
+ const limpio = selector.split('\n').pop().trim();
44
+ assert.ok(bloque.includes(limpio), `"${limpio}" anima (${nombre}) y no se apaga con movimiento reducido`);
45
+ }
46
+ }
47
+ });
48
+
49
+ test('el scroll animado de JS respeta el movimiento reducido', () => {
50
+ for (const archivo of readdirSync(PUBLIC).filter((f) => f.endsWith('.js'))) {
51
+ const src = readFileSync(new URL(archivo, PUBLIC), 'utf8');
52
+ assert.ok(!/behavior:\s*['"]smooth['"]/.test(src), `${archivo} fuerza scroll animado: usar scrollBehavior()`);
53
+ }
54
+ });
55
+
56
+ test('en pantallas tactiles los campos van a 16px (iOS hace zoom con menos)', () => {
57
+ const inicio = css.indexOf('@media (pointer: coarse), (max-width: 480px)');
58
+ assert.ok(inicio >= 0, 'falta el bloque tactil');
59
+ const bloque = bloqueMedia(inicio);
60
+ assert.match(bloque, /select,[\s\S]*font-size:\s*16px/);
61
+ assert.match(bloque, /min-height:\s*40px/);
62
+ });
63
+
64
+ test('el foco de teclado es visible en botones, enlaces y elementos enfocables', () => {
65
+ assert.match(css, /button:focus-visible, a:focus-visible, summary:focus-visible, \[tabindex\]:focus-visible \{\s*outline: 2px solid var\(--accent\)/);
66
+ });
67
+
68
+ test('las regiones que cambian solas se anuncian a lectores de pantalla', () => {
69
+ const html = readFileSync(new URL('index.html', PUBLIC), 'utf8');
70
+ assert.ok(/id="toasts"[^>]*aria-live="polite"/.test(html), 'los avisos (toasts) deberian ser aria-live');
71
+ assert.ok(/id="sb-activity"[^>]*(role="status"|aria-live)/.test(html), 'la actividad del agente deberia ser un status');
72
+ });
@@ -0,0 +1,194 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { startServer } from './helpers.js';
7
+
8
+ const json = { 'Content-Type': 'application/json' };
9
+
10
+ test('manages subagents from the console (CRUD + toggle)', async () => {
11
+ const { instance, base } = await startServer();
12
+ try {
13
+ let data = await (await fetch(`${base}/api/agents`)).json();
14
+ assert.deepEqual(data.agents, []);
15
+
16
+ let res = await fetch(`${base}/api/agents/revisor`, {
17
+ method: 'PUT',
18
+ headers: json,
19
+ body: JSON.stringify({
20
+ name: 'Revisor',
21
+ description: 'Busca bugs',
22
+ provider: 'openai',
23
+ model: 'gpt',
24
+ mode: 'plan',
25
+ effort: 'high',
26
+ maxRounds: 4,
27
+ tools: ['read', 'grep'],
28
+ skills: ['code-review'],
29
+ body: '# Rol\nMirá el diff.',
30
+ }),
31
+ });
32
+ assert.equal(res.status, 200);
33
+ data = await res.json();
34
+ const agent = data.agents.find((item) => item.id === 'revisor');
35
+ assert.equal(agent.name, 'Revisor');
36
+ assert.equal(agent.effort, 'high');
37
+ assert.equal(agent.mode, 'plan');
38
+ assert.equal(agent.maxRounds, 4);
39
+ assert.deepEqual(agent.tools, ['read', 'grep']);
40
+ assert.deepEqual(agent.skills, ['code-review']);
41
+ assert.equal(agent.enabled, true);
42
+
43
+ const detail = await (await fetch(`${base}/api/agents/revisor`)).json();
44
+ assert.match(detail.agent.body, /Mirá el diff/);
45
+
46
+ res = await fetch(`${base}/api/agents/revisor/toggle`, { method: 'POST' });
47
+ data = await res.json();
48
+ assert.equal(data.enabled, false);
49
+ data = await (await fetch(`${base}/api/agents`)).json();
50
+ assert.equal(data.agents[0].enabled, false);
51
+
52
+ res = await fetch(`${base}/api/agents/revisor/toggle`, { method: 'POST' });
53
+ assert.equal((await res.json()).enabled, true);
54
+
55
+ res = await fetch(`${base}/api/agents/revisor`, { method: 'DELETE' });
56
+ assert.equal(res.status, 200);
57
+ data = await res.json();
58
+ assert.deepEqual(data.agents, []);
59
+ } finally {
60
+ await instance.close();
61
+ }
62
+ });
63
+
64
+ test('rejects invalid subagent payloads', async () => {
65
+ const { instance, base } = await startServer();
66
+ try {
67
+ let res = await fetch(`${base}/api/agents/${encodeURIComponent('../escape')}`, {
68
+ method: 'PUT',
69
+ headers: json,
70
+ body: JSON.stringify({ body: 'x' }),
71
+ });
72
+ assert.equal(res.status, 400);
73
+
74
+ res = await fetch(`${base}/api/agents/vacio`, {
75
+ method: 'PUT',
76
+ headers: json,
77
+ body: JSON.stringify({ mode: 'plan' }),
78
+ });
79
+ assert.equal(res.status, 400);
80
+ assert.match((await res.json()).error, /cuerpo o una descripción/);
81
+ } finally {
82
+ await instance.close();
83
+ }
84
+ });
85
+
86
+ test('effort persists globally and per session', async () => {
87
+ const { instance, base } = await startServer();
88
+ try {
89
+ let data = await (await fetch(`${base}/api/settings`)).json();
90
+ assert.equal(data.settings.effort, '');
91
+
92
+ let res = await fetch(`${base}/api/settings`, { method: 'PUT', headers: json, body: JSON.stringify({ effort: 'max' }) });
93
+ data = await res.json();
94
+ assert.equal(data.settings.effort, 'max');
95
+
96
+ res = await fetch(`${base}/api/settings`, { method: 'PUT', headers: json, body: JSON.stringify({ effort: 'auto' }) });
97
+ assert.equal((await res.json()).settings.effort, '');
98
+
99
+ res = await fetch(`${base}/api/sessions`, {
100
+ method: 'POST',
101
+ headers: json,
102
+ body: JSON.stringify({ title: 'con esfuerzo', model: 'm', effort: 'low' }),
103
+ });
104
+ const created = await res.json();
105
+ assert.equal(created.session.effort, 'low');
106
+
107
+ res = await fetch(`${base}/api/sessions/${created.session.id}`, {
108
+ method: 'PUT',
109
+ headers: json,
110
+ body: JSON.stringify({ effort: 'high' }),
111
+ });
112
+ assert.equal((await res.json()).session.effort, 'high');
113
+ } finally {
114
+ await instance.close();
115
+ }
116
+ });
117
+
118
+ test('a named subagent runs with its own model, effort and instructions', async () => {
119
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-subagent-'));
120
+ const calls = { parent: 0 };
121
+ const sub = {};
122
+ const parent = {
123
+ id: 'parent',
124
+ async streamChat() {
125
+ calls.parent += 1;
126
+ if (calls.parent === 1) {
127
+ return {
128
+ content: '',
129
+ reasoning: '',
130
+ toolCalls: [
131
+ {
132
+ id: 't1',
133
+ type: 'function',
134
+ function: {
135
+ name: 'task',
136
+ arguments: JSON.stringify({ agent: 'revisor', description: 'revisión', prompt: 'revisá el cambio' }),
137
+ },
138
+ },
139
+ ],
140
+ };
141
+ }
142
+ return { content: 'listo', reasoning: '', toolCalls: [] };
143
+ },
144
+ };
145
+ const revisor = {
146
+ id: 'revisor',
147
+ async streamChat(_config, { model, messages, reasoningEffort }) {
148
+ Object.assign(sub, { model, reasoningEffort, system: messages?.[0]?.content ?? '' });
149
+ return { content: 'resumen del revisor', reasoning: '', toolCalls: [] };
150
+ },
151
+ };
152
+ const providers = { parent, revisor };
153
+ const { instance, base } = await startServer({
154
+ cwd,
155
+ resolveProvider: ({ providerId }) => ({ provider: providers[providerId] ?? parent, config: {} }),
156
+ });
157
+ try {
158
+ await fetch(`${base}/api/agents/revisor`, {
159
+ method: 'PUT',
160
+ headers: json,
161
+ body: JSON.stringify({
162
+ name: 'Revisor',
163
+ description: 'Busca bugs',
164
+ provider: 'revisor',
165
+ model: 'revisor-model',
166
+ mode: 'plan',
167
+ effort: 'high',
168
+ body: 'MARCADOR_REVISOR',
169
+ }),
170
+ });
171
+
172
+ const created = await (
173
+ await fetch(`${base}/api/sessions`, {
174
+ method: 'POST',
175
+ headers: json,
176
+ body: JSON.stringify({ title: 'delegar', model: 'parent-model', provider: 'parent' }),
177
+ })
178
+ ).json();
179
+ await (
180
+ await fetch(`${base}/api/chat`, {
181
+ method: 'POST',
182
+ headers: json,
183
+ body: JSON.stringify({ sessionId: created.session.id, input: 'delegá al revisor', model: 'parent-model', provider: 'parent' }),
184
+ })
185
+ ).text();
186
+
187
+ assert.equal(sub.model, 'revisor-model');
188
+ assert.equal(sub.reasoningEffort, 'high');
189
+ assert.match(sub.system, /MARCADOR_REVISOR/);
190
+ assert.equal(calls.parent >= 2, true);
191
+ } finally {
192
+ await instance.close();
193
+ }
194
+ });
@@ -79,10 +79,14 @@ test('la validacion del server coincide con el registro del cliente', () => {
79
79
  'solarized-dark',
80
80
  'bosque',
81
81
  'vino',
82
+ 'arena',
83
+ 'hielo',
82
84
  'vercel',
83
85
  'dark-plus',
84
86
  'github',
85
87
  'github-dark',
88
+ 'mocha',
89
+ 'ebano',
86
90
  ].sort()
87
91
  );
88
92
  assert.equal(resolveThemeId('solarized-dark'), 'solarized-dark');
@@ -53,34 +53,147 @@ function importsDe(archivo) {
53
53
  const exportsPorModulo = new Map(modulos.map((m) => [m, exportsDe(m)]));
54
54
  const importsPorModulo = new Map(modulos.map((m) => [m, importsDe(m)]));
55
55
 
56
+ /**
57
+ * Copia del source con cadenas, comentarios y literales regex **vaciados**
58
+ * (mismos largos y saltos de linea). Contar llaves/parentesis o buscar llamadas
59
+ * sobre esta copia no se confunde con un `}` de un string, un `(` de un
60
+ * comentario ni con los grupos de un regex. Los templates se vacian enteros
61
+ * salvo sus interpolaciones (`${...}`, que es codigo real): un barrido naive
62
+ * cierra en el backtick del template anidado (`${x.map((v) => `${v}`)}`) y
63
+ * desbalancea todo lo que viene despues.
64
+ */
65
+ function sinCadenas(src) {
66
+ const out = [...src];
67
+ const vaciar = (i) => {
68
+ if (out[i] !== '\n') out[i] = ' ';
69
+ return i + 1;
70
+ };
71
+ // Un `/` abre regex salvo que venga de un valor (division): `a / b` vs
72
+ // `return /re/`, `if (/re/)`, `x = /re/`.
73
+ const TRAS_REGEX = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'delete', 'void', 'new', 'do', 'else', 'yield', 'await', 'case']);
74
+ const PUNCT_REGEX = new Set(['(', '[', '{', '=', ':', ',', ';', '!', '?', '&', '|', '+', '-', '*', '%', '^', '~', '<', '>', '']);
75
+ let ultimaPalabra = '';
76
+ let ultimoChar = '';
77
+
78
+ function plantilla(i) {
79
+ while (i < src.length) {
80
+ const c = src[i];
81
+ if (c === '\\') i = vaciar(vaciar(i));
82
+ else if (c === '`') return vaciar(i);
83
+ else if (c === '$' && src[i + 1] === '{') {
84
+ i = vaciar(vaciar(i));
85
+ i = codigo(i, true);
86
+ if (i < src.length) i = vaciar(i);
87
+ } else i = vaciar(i);
88
+ }
89
+ return i;
90
+ }
91
+
92
+ function literarRegex(i) {
93
+ let enClase = false;
94
+ i = vaciar(i);
95
+ while (i < src.length && src[i] !== '\n') {
96
+ const c = src[i];
97
+ if (c === '\\') {
98
+ i = vaciar(vaciar(i));
99
+ continue;
100
+ }
101
+ if (c === '[') enClase = true;
102
+ else if (c === ']') enClase = false;
103
+ else if (c === '/' && !enClase) return vaciar(i) + saltarFlags(i + 1);
104
+ i = vaciar(i);
105
+ }
106
+ return i + saltarFlags(i);
107
+ }
108
+
109
+ function saltarFlags(i) {
110
+ let j = i;
111
+ while (j < src.length && /[a-z]/i.test(src[j])) j = vaciar(j);
112
+ return j - i;
113
+ }
114
+
115
+ function codigo(i, cierraInterp) {
116
+ let prof = 0;
117
+ while (i < src.length) {
118
+ const c = src[i];
119
+ const dos = src[i + 1];
120
+ if (c === '/' && dos === '/') {
121
+ while (i < src.length && src[i] !== '\n') i = vaciar(i);
122
+ continue;
123
+ }
124
+ if (c === '/' && dos === '*') {
125
+ i = vaciar(vaciar(i));
126
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i = vaciar(i);
127
+ if (i < src.length) i = vaciar(vaciar(i));
128
+ continue;
129
+ }
130
+ if (c === '"' || c === "'") {
131
+ i = vaciar(i);
132
+ while (i < src.length && src[i] !== c) i = src[i] === '\\' ? vaciar(vaciar(i)) : vaciar(i);
133
+ if (i < src.length) i = vaciar(i);
134
+ ultimaPalabra = '';
135
+ ultimoChar = ')';
136
+ continue;
137
+ }
138
+ if (c === '`') {
139
+ i = plantilla(vaciar(i));
140
+ ultimaPalabra = '';
141
+ ultimoChar = ')';
142
+ continue;
143
+ }
144
+ const trasPalabra = ultimaPalabra && ultimoChar === ultimaPalabra[ultimaPalabra.length - 1];
145
+ const abreRegex = trasPalabra ? TRAS_REGEX.has(ultimaPalabra) : PUNCT_REGEX.has(ultimoChar);
146
+ if (c === '/' && abreRegex) {
147
+ i = literarRegex(i);
148
+ ultimaPalabra = '';
149
+ ultimoChar = ')';
150
+ continue;
151
+ }
152
+ if (cierraInterp) {
153
+ if (c === '{') prof += 1;
154
+ else if (c === '}') {
155
+ if (prof === 0) return i; // el `}` de cierre lo vacia el llamador
156
+ prof -= 1;
157
+ }
158
+ }
159
+ if (/[A-Za-z0-9_$]/.test(c)) {
160
+ ultimaPalabra += c;
161
+ ultimoChar = c;
162
+ } else if (!/\s/.test(c)) {
163
+ ultimaPalabra = '';
164
+ ultimoChar = c;
165
+ }
166
+ i += 1;
167
+ }
168
+ return i;
169
+ }
170
+
171
+ codigo(0, false);
172
+ return out.join('');
173
+ }
174
+
56
175
  /**
57
176
  * Statements de nivel superior de un modulo, agrupando por balance de llaves y
58
177
  * parentesis. Asi `const x = f();` sale como un statement, y un
59
- * `addEventListener(...)` con un callback multilinea tambien.
178
+ * `addEventListener(...)` con un callback multilinea tambien. El texto se
179
+ * devuelve crudo (para los mensajes de error) pero el balance se cuenta sobre
180
+ * `sinCadenas`, que preserva largos y offsets.
60
181
  */
61
182
  function statementsTopLevel(src) {
183
+ const limpio = sinCadenas(src);
62
184
  const stmts = [];
63
185
  let actual = '';
64
186
  let inicio = 0;
65
187
  let llaves = 0;
66
188
  let corchetes = 0;
67
189
  let parentesis = 0;
68
- let enCadena = '';
69
190
  let pos = 0;
70
- for (const linea of src.split(/\r?\n/)) {
191
+ for (const linea of src.matchAll(/[^\n]*(?:\r?\n|$)/g)) {
192
+ if (!linea[0]) continue; // match vacio al final
193
+ const texto = linea[0].replace(/\r?\n$/, '');
71
194
  if (!actual) inicio = pos;
72
- actual += `${actual ? '\n' : ''}${linea}`;
73
- for (let i = 0; i < linea.length; i += 1) {
74
- const c = linea[i];
75
- const previo = linea[i - 1];
76
- if (enCadena) {
77
- if (c === enCadena && previo !== '\\') enCadena = '';
78
- continue;
79
- }
80
- if (c === '"' || c === "'" || c === '`') {
81
- enCadena = c;
82
- continue;
83
- }
195
+ actual += `${actual ? '\n' : ''}${texto}`;
196
+ for (const c of limpio.slice(pos, pos + texto.length)) {
84
197
  if (c === '{') llaves += 1;
85
198
  else if (c === '}') llaves -= 1;
86
199
  else if (c === '[') corchetes += 1;
@@ -88,7 +201,7 @@ function statementsTopLevel(src) {
88
201
  else if (c === '(') parentesis += 1;
89
202
  else if (c === ')') parentesis -= 1;
90
203
  }
91
- pos += linea.length + 1;
204
+ pos += linea[0].length;
92
205
  if (llaves <= 0 && corchetes <= 0 && parentesis <= 0) {
93
206
  if (actual.trim()) stmts.push({ texto: actual, inicio });
94
207
  actual = '';
@@ -106,54 +219,28 @@ function statementsTopLevel(src) {
106
219
  * callbacks (`=> {...}`, `=> expr`, `function () {...}`) y literales de datos
107
220
  * (`= [...]`, `= {...}`), que a lo sumo guardan referencias. Lo que queda es lo
108
221
  * que se evalua al cargar el modulo.
109
- *
110
- * Recorre con estado de cadenas: un `=>` dentro de un template no es un arrow.
111
222
  */
112
223
  function sinDiferidos(stmt) {
113
- const s = stmt.replace(/\r?\n/g, ' ');
224
+ const s = sinCadenas(stmt).replace(/\r?\n/g, ' ');
114
225
  let out = '';
115
226
  let i = 0;
116
- let cadena = '';
117
227
  const cortarBloque = (desde) => {
118
228
  // Corta hasta el cierre balanceado de `{`/`[` abierto en `desde - 1`.
119
229
  let prof = 1;
120
230
  let j = desde;
121
- let cad = '';
122
- for (; j < s.length; j += 1) {
231
+ while (j < s.length) {
123
232
  const c = s[j];
124
- const previo = s[j - 1];
125
- if (cad) {
126
- if (c === cad && previo !== '\\') cad = '';
127
- continue;
128
- }
129
- if (c === '"' || c === "'" || c === '`') {
130
- cad = c;
131
- continue;
132
- }
133
233
  if (c === '{' || c === '[') prof += 1;
134
234
  else if (c === '}' || c === ']') {
135
235
  prof -= 1;
136
236
  if (prof === 0) return j + 1;
137
237
  }
238
+ j += 1;
138
239
  }
139
240
  return j;
140
241
  };
141
-
142
242
  while (i < s.length) {
143
243
  const c = s[i];
144
- const previo = s[i - 1];
145
- if (cadena) {
146
- out += c;
147
- if (c === cadena && previo !== '\\') cadena = '';
148
- i += 1;
149
- continue;
150
- }
151
- if (c === '"' || c === "'" || c === '`') {
152
- cadena = c;
153
- out += c;
154
- i += 1;
155
- continue;
156
- }
157
244
  const resto = s.slice(i);
158
245
  if (c === '=' && s[i + 1] === '>') {
159
246
  const despues = s.slice(i + 2).match(/^\s*([[{])/);
@@ -186,18 +273,7 @@ function sinDiferidos(stmt) {
186
273
  /** Profundidad de llaves antes del caracter `hasta` (para saber si es nivel 0). */
187
274
  function profundidadEn(src, hasta) {
188
275
  let prof = 0;
189
- let cadena = '';
190
- for (let i = 0; i < hasta; i += 1) {
191
- const c = src[i];
192
- const previo = src[i - 1];
193
- if (cadena) {
194
- if (c === cadena && previo !== '\\') cadena = '';
195
- continue;
196
- }
197
- if (c === '"' || c === "'" || c === '`') {
198
- cadena = c;
199
- continue;
200
- }
276
+ for (const c of sinCadenas(src).slice(0, hasta)) {
201
277
  if (c === '{') prof += 1;
202
278
  else if (c === '}') prof -= 1;
203
279
  }