@danieltmn/tamnora 0.17.0 → 0.18.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 (58) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +37 -3
  3. package/SECURITY.md +8 -0
  4. package/harness/packages/cli/src/commands/mcp.js +31 -4
  5. package/harness/packages/cli/src/commands/repl.js +7 -2
  6. package/harness/packages/cli/src/commands/run.js +5 -2
  7. package/harness/packages/cli/src/commands/tui.js +9 -2
  8. package/harness/packages/cli/src/lib/runtime.js +23 -1
  9. package/harness/packages/cli/test/tunnel.test.js +13 -1
  10. package/harness/packages/core/src/agent/modes.js +3 -0
  11. package/harness/packages/core/src/config/index.js +3 -1
  12. package/harness/packages/core/src/index.js +15 -1
  13. package/harness/packages/core/src/mcp/catalog.js +79 -0
  14. package/harness/packages/core/src/mcp/index.js +14 -2
  15. package/harness/packages/core/src/mcp/registry.js +29 -1
  16. package/harness/packages/core/src/redact.js +25 -0
  17. package/harness/packages/core/src/skills/catalog.js +78 -0
  18. package/harness/packages/core/src/skills/remote.js +51 -0
  19. package/harness/packages/core/src/tools/executors.js +93 -2
  20. package/harness/packages/core/src/tools/index.js +27 -6
  21. package/harness/packages/core/src/tools/labels.js +2 -0
  22. package/harness/packages/core/src/tools/mcp.js +91 -0
  23. package/harness/packages/core/src/tools/permissions.js +53 -8
  24. package/harness/packages/core/src/tools/registry.js +14 -7
  25. package/harness/packages/core/src/tools/schema.js +86 -0
  26. package/harness/packages/core/src/tts/edge.js +28 -7
  27. package/harness/packages/core/src/tunnel.js +51 -5
  28. package/harness/packages/core/test/compact.test.js +2 -0
  29. package/harness/packages/core/test/labels.test.js +1 -0
  30. package/harness/packages/core/test/mcp-registry.test.js +37 -1
  31. package/harness/packages/core/test/mcp-routing.test.js +115 -0
  32. package/harness/packages/core/test/redact.test.js +22 -0
  33. package/harness/packages/core/test/skills.test.js +11 -0
  34. package/harness/packages/core/test/tools.test.js +97 -3
  35. package/harness/packages/server/public/app.js +501 -23
  36. package/harness/packages/server/public/index.html +41 -2
  37. package/harness/packages/server/public/styles.css +99 -3
  38. package/harness/packages/server/src/lib/agentTools.js +45 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +2 -0
  40. package/harness/packages/server/src/lib/prompt.js +4 -1
  41. package/harness/packages/server/src/lib/questions.js +72 -0
  42. package/harness/packages/server/src/lib/scheduler.js +12 -1
  43. package/harness/packages/server/src/lib/tasks.js +3 -1
  44. package/harness/packages/server/src/routes/chat.js +31 -2
  45. package/harness/packages/server/src/routes/mcp.js +54 -2
  46. package/harness/packages/server/src/routes/preview.js +49 -28
  47. package/harness/packages/server/src/routes/providers.js +12 -0
  48. package/harness/packages/server/src/routes/sessions.js +3 -1
  49. package/harness/packages/server/src/routes/skills.js +48 -1
  50. package/harness/packages/server/src/routes/state.js +8 -1
  51. package/harness/packages/server/src/server.js +37 -1
  52. package/harness/packages/server/test/preview.test.js +111 -0
  53. package/harness/packages/server/test/server.test.js +186 -19
  54. package/harness/packages/server/test/telegram-features.test.js +2 -0
  55. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  56. package/harness/packages/server/test/telegram.test.js +2 -0
  57. package/harness/tamnora.example.json +3 -1
  58. package/package.json +1 -1
@@ -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.17.0</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.18.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>
@@ -270,7 +270,12 @@
270
270
  <div class="xp-bar">
271
271
  <input id="preview-url" placeholder="http://127.0.0.1:5173" spellcheck="false" />
272
272
  <button id="preview-open" class="btn" type="button">abrir</button>
273
- <button id="preview-tunnel" class="btn" type="button" title="Abrir un túnel público al puerto (para el celular)">túnel</button>
273
+ <select id="preview-provider" title="cómo exponer el puerto para acceso remoto" aria-label="proveedor de acceso remoto">
274
+ <option value="tailscale">tailscale</option>
275
+ <option value="tunnelmole">público</option>
276
+ </select>
277
+ <button id="preview-tunnel" class="btn" type="button" title="Exponer el puerto para acceso remoto (tailscale privado o túnel público)">túnel</button>
278
+ <button id="preview-close" class="btn danger" type="button" hidden title="Cerrar el túnel/exposición remota">cerrar</button>
274
279
  </div>
275
280
  <div class="preview-tools">
276
281
  <select id="preview-device" title="tamaño de pantalla" aria-label="tamaño de pantalla">
@@ -564,12 +569,36 @@
564
569
  <p class="muted verify-line">
565
570
  Se combinan con <code>mcp[]</code> de <code>tamnora.json</code>; los guardados acá viven en la base.
566
571
  </p>
572
+ <div class="import-bar">
573
+ <label class="field grow"><span>carga de herramientas MCP</span>
574
+ <select id="mcp-mode">
575
+ <option value="auto">auto (diferir si hay muchas)</option>
576
+ <option value="lazy">diferido (índice + mcp_call)</option>
577
+ <option value="eager">completas (todas en cada turno)</option>
578
+ </select>
579
+ </label>
580
+ <span id="mcp-count" class="muted"></span>
581
+ </div>
582
+ <div class="import-bar">
583
+ <input id="mcp-pinned" placeholder="tools/servidores fijos (coma), ej. mcp__postgres__query" spellcheck="false" />
584
+ <button id="mcp-pinned-save" class="btn" type="button">fijar</button>
585
+ </div>
567
586
  <div class="import-bar">
568
587
  <input id="mcp-import" placeholder="nombre del registro oficial, URL o JSON (bloque mcpServers)" spellcheck="false" />
569
588
  <button id="mcp-import-btn" class="btn" type="button">importar</button>
570
589
  </div>
590
+ <div class="import-bar">
591
+ <input id="mcp-search" placeholder="buscar en el registro oficial (ej. github, postgres)" spellcheck="false" />
592
+ <button id="mcp-search-btn" class="btn" type="button">buscar</button>
593
+ </div>
571
594
  <div id="mcp-import-status" class="summary"></div>
595
+ <div id="mcp-search-status" class="summary"></div>
596
+ <ul id="mcp-search-results" class="providers"></ul>
572
597
  <ul id="mcp-list" class="providers"></ul>
598
+ <details class="catalog">
599
+ <summary>sugeridos</summary>
600
+ <ul id="mcp-catalog" class="providers"></ul>
601
+ </details>
573
602
  </div>
574
603
 
575
604
  <div class="panel ajustes-panel" data-panel="skills">
@@ -584,8 +613,18 @@
584
613
  <input id="skill-import" placeholder="https://skills.sh/owner/repo/skill" spellcheck="false" />
585
614
  <button id="skill-import-btn" class="btn" type="button">importar</button>
586
615
  </div>
616
+ <div class="import-bar">
617
+ <input id="skill-search" placeholder="buscar skills en GitHub (ej. testing, react)" spellcheck="false" />
618
+ <button id="skill-search-btn" class="btn" type="button">buscar</button>
619
+ </div>
587
620
  <div id="skill-import-status" class="summary"></div>
621
+ <div id="skill-search-status" class="summary"></div>
622
+ <ul id="skill-search-results" class="providers"></ul>
588
623
  <ul id="skill-list" class="providers"></ul>
624
+ <details class="catalog">
625
+ <summary>sugeridas</summary>
626
+ <ul id="skill-catalog" class="providers"></ul>
627
+ </details>
589
628
  </div>
590
629
 
591
630
  <div class="panel ajustes-panel" data-panel="memoria">
@@ -15,6 +15,12 @@
15
15
  --accent-soft: rgba(217, 112, 74, 0.18);
16
16
  --ok: #8fae7f;
17
17
  --warn: #d9a441;
18
+ --info: #6f9fd8;
19
+ --danger-soft: rgba(224, 112, 92, 0.16);
20
+ --ok-soft: rgba(143, 174, 127, 0.16);
21
+ --warn-soft: rgba(217, 164, 65, 0.16);
22
+ --info-soft: rgba(111, 159, 216, 0.16);
23
+ --muted-soft: rgba(156, 151, 137, 0.14);
18
24
  --solid: #12130b;
19
25
  --primary-fg: #fff7ef;
20
26
  --sans: Manrope, system-ui, "Segoe UI", Arial, sans-serif;
@@ -46,6 +52,12 @@ html[data-theme='paper'] {
46
52
  --accent-soft: rgba(189, 84, 47, 0.1);
47
53
  --ok: #66865d;
48
54
  --warn: #8a5a00;
55
+ --info: #3f6ea6;
56
+ --danger-soft: rgba(147, 64, 34, 0.1);
57
+ --ok-soft: rgba(102, 134, 93, 0.14);
58
+ --warn-soft: rgba(138, 90, 0, 0.12);
59
+ --info-soft: rgba(63, 110, 166, 0.12);
60
+ --muted-soft: rgba(104, 104, 93, 0.12);
49
61
  --solid: #f7f2e6;
50
62
  --primary-fg: #fbf6ec;
51
63
  --page-bg: #f5efe2;
@@ -820,6 +832,60 @@ body.resizing * { cursor: col-resize !important; }
820
832
  .perm-card.collapsed .perm-args,
821
833
  .perm-card.collapsed .perm-actions { display: none; }
822
834
 
835
+ /* question card (ask_user) ------------------------------------------------- */
836
+ .question-card {
837
+ border: 1px solid var(--accent);
838
+ border-radius: 8px;
839
+ padding: 10px 12px;
840
+ background: var(--accent-soft);
841
+ }
842
+ .question-title { font-weight: 700; margin-bottom: 8px; }
843
+ .question-form { display: flex; flex-direction: column; gap: 9px; }
844
+ .question-field { display: flex; flex-direction: column; gap: 4px; }
845
+ .question-label { font-size: 12.5px; color: var(--muted); }
846
+ .question-input {
847
+ font: inherit;
848
+ color: var(--text);
849
+ background: var(--theirs);
850
+ border: 1px solid var(--border);
851
+ border-radius: 6px;
852
+ padding: 6px 8px;
853
+ }
854
+ .question-input:focus { outline: none; border-color: var(--accent); }
855
+ .question-actions { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
856
+ .question-actions .spacer { flex: 1; }
857
+ .question-card.answered { border-color: var(--ok); background: var(--ok-soft); }
858
+ .question-card.answered .question-title { color: var(--ok); }
859
+
860
+ /* choice card (offer_choices) ---------------------------------------------- */
861
+ .choice-card {
862
+ border: 1px solid var(--border);
863
+ border-radius: 8px;
864
+ padding: 10px 12px;
865
+ background: var(--theirs);
866
+ margin-top: 6px;
867
+ }
868
+ .choice-question { margin-bottom: 8px; }
869
+ .choice-options { display: flex; flex-wrap: wrap; gap: 6px; }
870
+ .choice-btn { border-radius: 999px; padding: 5px 12px; }
871
+
872
+ /* plan card (show_plan) ---------------------------------------------------- */
873
+ .plan-card {
874
+ border: 1px solid var(--border);
875
+ border-radius: 8px;
876
+ padding: 10px 12px;
877
+ background: var(--theirs);
878
+ margin-top: 6px;
879
+ }
880
+ .plan-title { font-weight: 700; margin-bottom: 6px; }
881
+ .plan-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
882
+ .plan-step { display: flex; align-items: baseline; gap: 8px; line-height: 1.4; }
883
+ .plan-mark { flex: none; width: 14px; font-family: var(--mono); color: var(--muted); }
884
+ .plan-step.done .plan-mark { color: var(--ok); }
885
+ .plan-step.done .plan-text { color: var(--muted); text-decoration: line-through; }
886
+ .plan-step.active .plan-mark { color: var(--accent); }
887
+ .plan-step.active .plan-text { color: var(--text); font-weight: 600; }
888
+
823
889
  /* activity indicators ------------------------------------------------------ */
824
890
  .activity { display: flex; align-items: center; gap: 9px; margin-top: 10px; color: var(--muted); font-size: 12.5px; }
825
891
  .activity .loader { color: var(--accent); }
@@ -1439,7 +1505,7 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1439
1505
  .xtab:hover { background: var(--theirs); color: var(--text); }
1440
1506
  .xtab.is-active { background: var(--theirs); color: var(--text); }
1441
1507
  .xp-pane { display: flex; flex-direction: column; flex: 1; min-height: 0; }
1442
- .xp-bar { display: flex; gap: 6px; align-items: center; padding: 9px 12px; }
1508
+ .xp-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 9px 12px; }
1443
1509
  .xp-bar input { flex: 1; min-width: 0; }
1444
1510
  .xp-bar .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1445
1511
  .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 8px; }
@@ -1498,6 +1564,16 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1498
1564
  .import-bar { display: flex; gap: 6px; margin: 4px 0 10px; }
1499
1565
  .import-bar input { flex: 1; min-width: 0; }
1500
1566
 
1567
+ details.catalog { margin: 10px 0; }
1568
+ details.catalog > summary {
1569
+ cursor: pointer;
1570
+ font: 600 11px var(--mono);
1571
+ letter-spacing: 0.06em;
1572
+ text-transform: uppercase;
1573
+ color: var(--muted);
1574
+ }
1575
+ details.catalog[open] > summary { margin-bottom: 6px; }
1576
+
1501
1577
  .file-actions { display: flex; align-items: center; gap: 10px; }
1502
1578
  .file-modal { width: min(880px, calc(100vw - 40px)); }
1503
1579
  .file-preview {
@@ -1950,9 +2026,29 @@ dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: bl
1950
2026
  .advisor-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
1951
2027
  .advisor-glyph { color: var(--accent); font-size: 13px; line-height: 1; }
1952
2028
  .advisor-title { font: 600 10px var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
2029
+ .advisor-badge {
2030
+ font: 600 10px var(--mono);
2031
+ letter-spacing: 0.04em;
2032
+ text-transform: uppercase;
2033
+ padding: 1px 7px;
2034
+ border-radius: 999px;
2035
+ color: var(--accent);
2036
+ background: var(--accent-soft);
2037
+ border: 1px solid currentColor;
2038
+ }
1953
2039
  .advisor-text { color: var(--text); line-height: 1.5; }
1954
- .advisor-code { border-left-color: var(--warn); }
1955
- .advisor-code .advisor-glyph { color: var(--warn); }
2040
+ .advisor-code { border-left-color: var(--danger); background: var(--danger-soft); }
2041
+ .advisor-code .advisor-glyph,
2042
+ .advisor-code .advisor-badge { color: var(--danger); }
2043
+ .advisor-explain { border-left-color: var(--info); background: var(--info-soft); }
2044
+ .advisor-explain .advisor-glyph,
2045
+ .advisor-explain .advisor-badge { color: var(--info); }
2046
+ .advisor-advice { border-left-color: var(--ok); background: var(--ok-soft); }
2047
+ .advisor-advice .advisor-glyph,
2048
+ .advisor-advice .advisor-badge { color: var(--ok); }
2049
+ .advisor-summary { border-left-color: var(--muted); background: var(--muted-soft); }
2050
+ .advisor-summary .advisor-glyph,
2051
+ .advisor-summary .advisor-badge { color: var(--muted); }
1956
2052
 
1957
2053
  .brand .ver.ver-action { cursor: pointer; }
1958
2054
  .brand .ver.ver-action:hover { color: var(--accent); }
@@ -7,11 +7,15 @@ import {
7
7
  parseSkillMarkdown,
8
8
  parseSkillRef,
9
9
  fetchSkillFromGithub,
10
+ searchSkillsGithub,
10
11
  writeSkillFiles,
11
12
  getMcpRegistryServer,
12
13
  registryServerToConfig,
14
+ registryServerToCandidate,
15
+ searchMcpRegistry,
13
16
  normalizeMcpConfig,
14
17
  homeDir,
18
+ envValue,
15
19
  memorySlug,
16
20
  listMemoryTopics,
17
21
  readMemoryIndex,
@@ -37,6 +41,12 @@ function definition(name, description, parameters, required = []) {
37
41
 
38
42
  const DEFINITIONS = [
39
43
  definition('list_mcp', 'Lista los servidores MCP configurados (de tamnora.json y de la base).', {}),
44
+ definition(
45
+ 'search_mcp',
46
+ 'Busca servidores en el registro oficial de MCP y devuelve candidatos (id, nombre, descripción y env requerido) sin instalarlos. Para registrar uno, usá install_mcp con el nombre del registro.',
47
+ { query: { type: 'string', description: 'Texto a buscar, ej. "github" o "postgres"' } },
48
+ ['query']
49
+ ),
40
50
  definition(
41
51
  'install_mcp',
42
52
  'Registra un servidor MCP. stdio: pasá command (y args). http: pasá url. Para webs con JavaScript/Cloudflare (que fetch_url no lee) instalá un MCP de navegador, ej. command "npx" args ["-y","@playwright/mcp@latest"].',
@@ -57,6 +67,12 @@ const DEFINITIONS = [
57
67
  ),
58
68
  definition('remove_mcp', 'Elimina un servidor MCP guardado desde la consola.', { id: { type: 'string' } }, ['id']),
59
69
  definition('list_skills', 'Lista las skills instaladas y si están habilitadas.', {}),
70
+ definition(
71
+ 'search_skills',
72
+ 'Busca skills en GitHub y devuelve refs instalables (owner/repo/slug). Para instalarlas, usá install_skill con "ref".',
73
+ { query: { type: 'string', description: 'Texto a buscar, ej. "testing" o "react"' } },
74
+ ['query']
75
+ ),
60
76
  definition(
61
77
  'skill',
62
78
  'Carga el contenido completo de una skill por su id. Usala cuando una skill del índice aplique a la tarea.',
@@ -113,7 +129,7 @@ function decisionFor(ctx, name, args) {
113
129
  const value = perms[name];
114
130
  if (value === 'allow' || value === 'ask' || value === 'deny') return value;
115
131
  if (name === 'memory') return String(args?.action ?? 'read').toLowerCase() === 'write' ? 'ask' : 'allow';
116
- return name.startsWith('list_') || name === 'skill' ? 'allow' : 'ask';
132
+ return name.startsWith('list_') || name.startsWith('search_') || name === 'skill' ? 'allow' : 'ask';
117
133
  }
118
134
 
119
135
  /**
@@ -144,6 +160,19 @@ export function createAgentTools(ctx) {
144
160
  source: s.source ?? 'config',
145
161
  })), null, 2);
146
162
 
163
+ case 'search_mcp': {
164
+ const query = String(args.query ?? '').trim();
165
+ if (!query) return 'Pasá "query" para buscar en el registro MCP.';
166
+ let servers;
167
+ try {
168
+ servers = await searchMcpRegistry(query, { limit: 10 });
169
+ } catch (error) {
170
+ return `No se pudo consultar el registro MCP: ${error.message}`;
171
+ }
172
+ if (!servers.length) return `Sin resultados para "${query}".`;
173
+ return JSON.stringify(servers.map(registryServerToCandidate), null, 2);
174
+ }
175
+
147
176
  case 'install_mcp': {
148
177
  if (args.registry || args.name) {
149
178
  let server;
@@ -223,6 +252,19 @@ export function createAgentTools(ctx) {
223
252
  case 'list_skills':
224
253
  return JSON.stringify(skillsView(ctx).map(({ id, name, enabled, description }) => ({ id, name, enabled, description })), null, 2);
225
254
 
255
+ case 'search_skills': {
256
+ const query = String(args.query ?? '').trim();
257
+ if (!query) return 'Pasá "query" para buscar skills.';
258
+ let results;
259
+ try {
260
+ results = await searchSkillsGithub(query, { token: envValue(['GITHUB_TOKEN', 'GH_TOKEN']) });
261
+ } catch (error) {
262
+ return `No se pudo buscar en GitHub: ${error.message}`;
263
+ }
264
+ if (!results.length) return `Sin resultados para "${query}".`;
265
+ return JSON.stringify(results, null, 2);
266
+ }
267
+
226
268
  case 'skill': {
227
269
  const id = String(args.name ?? args.id ?? '').trim();
228
270
  const skill = findSkill(ctx, id);
@@ -308,7 +350,7 @@ export function createAgentTools(ctx) {
308
350
  }
309
351
  }
310
352
 
311
- // Tools de admin que no modifican nada (lístar/leer): disponibles en todo modo.
312
- const readOnly = new Set(['list_mcp', 'list_skills', 'skill']);
353
+ // Tools de admin que no modifican nada (lístar/leer/buscar): disponibles en todo modo.
354
+ const readOnly = new Set(['list_mcp', 'search_mcp', 'list_skills', 'search_skills', 'skill']);
313
355
  return { definitions: DEFINITIONS, handles, execute, readOnly };
314
356
  }
@@ -474,6 +474,8 @@ export function startTelegramChannel(
474
474
  yes: false,
475
475
  onAsk,
476
476
  mcp,
477
+ mcpMode: ctx.effectiveMcpMode(),
478
+ mcpPinned: ctx.effectiveMcpPinned(),
477
479
  });
478
480
  const exposeAdmin = !mode.tools;
479
481
  const admin = ctx.agentTools;
@@ -33,11 +33,12 @@ function workspacePreamble(cwd) {
33
33
  * @param {string[]} opts.files - instruction file names (e.g. ['AGENTS.md'])
34
34
  * @param {() => string} [opts.profileText] - user profile block, re-read on every call
35
35
  * @param {() => string} [opts.skillsText] - skills block, re-read on every call
36
+ * @param {() => string} [opts.mcpText] - MCP tool index block, re-read on every call
36
37
  * @param {() => string} [opts.scopeText] - strict-scope block, re-read on every call
37
38
  * @param {string[]} [opts.ruleFiles] - glob patterns of project rule files
38
39
  * @param {boolean} [opts.memory] - inject the project memory block
39
40
  */
40
- export function createPromptBuilder({ cwd, files, profileText, skillsText, scopeText, ruleFiles = [], memory = false }) {
41
+ export function createPromptBuilder({ cwd, files, profileText, skillsText, mcpText, scopeText, ruleFiles = [], memory = false }) {
41
42
  const cache = new Map();
42
43
 
43
44
  async function buildFor(workspace) {
@@ -60,6 +61,8 @@ export function createPromptBuilder({ cwd, files, profileText, skillsText, scope
60
61
  if (profile) parts.push(profile);
61
62
  const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
62
63
  if (extra) parts.push(extra);
64
+ const mcp = typeof mcpText === 'function' ? String(mcpText() ?? '').trim() : '';
65
+ if (mcp) parts.push(mcp);
63
66
  if (memory) {
64
67
  const mem = memoryPrompt(key);
65
68
  if (mem) parts.push(mem);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Broker para formularios interactivos (`ask_user`). El chat registra un pedido
3
+ * pendiente (`ask`) y lo espera mientras el SSE emite un evento `question`; la
4
+ * consola responde con `POST /api/questions/:askId`, que llama a `answer`. Los
5
+ * pedidos que nadie contesta expiran como cancelados.
6
+ *
7
+ * Igual que `approvals`, guarda el `meta` (sessionId, title, questions) para que
8
+ * un cliente **sin** stream abierto sepa que hay un formulario esperando.
9
+ *
10
+ * @param {object} [opts]
11
+ * @param {number} [opts.timeoutMs] - how long to wait before cancelling
12
+ */
13
+ export function createQuestionBroker({ timeoutMs = 900000 } = {}) {
14
+ const pending = new Map();
15
+
16
+ function settle(id, answers, ok) {
17
+ const entry = pending.get(id);
18
+ if (!entry) return false;
19
+ clearTimeout(entry.timer);
20
+ pending.delete(id);
21
+ entry.resolve({ ok, answers: answers ?? null });
22
+ return true;
23
+ }
24
+
25
+ return {
26
+ /** Register a pending form; resolves to `{ ok, answers }`. */
27
+ ask(id, meta = {}) {
28
+ return new Promise((resolve) => {
29
+ const timer = setTimeout(() => settle(id, null, false), timeoutMs);
30
+ timer.unref?.();
31
+ pending.set(id, { resolve, timer, requestedAt: Date.now(), ...meta });
32
+ });
33
+ },
34
+
35
+ /** Answer a pending form. Returns false if it expired or is unknown. */
36
+ answer(id, answers) {
37
+ return settle(id, answers, true);
38
+ },
39
+
40
+ /** Cancel every pending form of a session (used when the run stops). */
41
+ cancelForSession(sessionId) {
42
+ for (const [id, entry] of pending) {
43
+ if (entry.sessionId === sessionId) settle(id, null, false);
44
+ }
45
+ },
46
+
47
+ /** Cancel everything still pending (used on shutdown). */
48
+ cancelAll() {
49
+ for (const id of [...pending.keys()]) settle(id, null, false);
50
+ },
51
+
52
+ /** Lo que sigue esperando, para que cualquier cliente pueda mostrarlo. */
53
+ list({ sessionId } = {}) {
54
+ const items = [];
55
+ for (const [askId, entry] of pending) {
56
+ if (sessionId !== undefined && entry.sessionId !== sessionId) continue;
57
+ items.push({
58
+ askId,
59
+ sessionId: entry.sessionId ?? null,
60
+ title: entry.title ?? '',
61
+ questions: entry.questions ?? [],
62
+ requestedAt: entry.requestedAt,
63
+ });
64
+ }
65
+ return items.sort((a, b) => a.requestedAt - b.requestedAt);
66
+ },
67
+
68
+ get pending() {
69
+ return pending.size;
70
+ },
71
+ };
72
+ }
@@ -53,7 +53,16 @@ async function runAgentSchedule(ctx, schedule) {
53
53
  ctx.store.addMessage(sessionId, userMessage);
54
54
  const prefixLength = messages.length;
55
55
 
56
- const local = createTools({ root: cwd, enabled: mode.tools ?? undefined, permissions: ctx.config.permissions, yes: true, mcp, signal });
56
+ const local = createTools({
57
+ root: cwd,
58
+ enabled: mode.tools ?? undefined,
59
+ permissions: ctx.config.permissions,
60
+ yes: true,
61
+ mcp,
62
+ mcpMode: ctx.effectiveMcpMode(),
63
+ mcpPinned: ctx.effectiveMcpPinned(),
64
+ signal,
65
+ });
57
66
  const exposeAdmin = !mode.tools;
58
67
  const admin = ctx.agentTools;
59
68
  const emit = (type, data, agentId = null) => ctx.runs.emit(sessionId, type, data, { agentId });
@@ -64,6 +73,8 @@ async function runAgentSchedule(ctx, schedule) {
64
73
  model,
65
74
  permissions: ctx.config.permissions,
66
75
  mcp,
76
+ mcpMode: ctx.effectiveMcpMode(),
77
+ mcpPinned: ctx.effectiveMcpPinned(),
67
78
  signal,
68
79
  exclude: admin.definitions.map((d) => d.function.name),
69
80
  emit,
@@ -32,6 +32,8 @@ export function createSubagentTool(ctx, options) {
32
32
  model,
33
33
  permissions = {},
34
34
  mcp = null,
35
+ mcpMode = 'auto',
36
+ mcpPinned = [],
35
37
  signal,
36
38
  exclude = [],
37
39
  emit,
@@ -67,7 +69,7 @@ export function createSubagentTool(ctx, options) {
67
69
  Array.isArray(args.tools) && args.tools.length
68
70
  ? args.tools.filter((tool) => !exclude.includes(tool))
69
71
  : mode.tools ?? undefined;
70
- const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp, signal });
72
+ const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp, mcpMode, mcpPinned, signal });
71
73
  const basePrompt = await ctx.systemPromptFor(cwd);
72
74
  const messages = [
73
75
  { role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
@@ -76,12 +76,28 @@ export function registerChat(route, ctx) {
76
76
  sendJson(res, 200, { pending: ctx.approvals.list({ sessionId }) });
77
77
  });
78
78
 
79
+ // Respuesta de un formulario interactivo (`ask_user`).
80
+ route('POST', '/api/questions/:askId', async ({ req, res, params }) => {
81
+ const body = await readJson(req);
82
+ const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
83
+ if (!ctx.questions.answer(params.askId, answers)) {
84
+ return sendError(res, 404, 'Formulario expirado o inexistente.');
85
+ }
86
+ sendJson(res, 200, { ok: true });
87
+ });
88
+
89
+ route('GET', '/api/questions', ({ res, query }) => {
90
+ const sessionId = query.get('sessionId') ?? undefined;
91
+ sendJson(res, 200, { pending: ctx.questions.list({ sessionId }) });
92
+ });
93
+
79
94
  route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
80
95
  if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
81
96
  ctx.runs.cancel(params.id);
82
- // Un run esperando una aprobacion quedaria colgado: la denegamos para que
83
- // el bucle del agente pueda desenrollarse y termine.
97
+ // Un run esperando una aprobacion o un formulario quedaria colgado: los
98
+ // resolvemos para que el bucle del agente pueda desenrollarse y termine.
84
99
  ctx.approvals.cancelForSession(params.id);
100
+ ctx.questions.cancelForSession(params.id);
85
101
  sendJson(res, 200, { ok: true, stopped: true });
86
102
  });
87
103
 
@@ -163,6 +179,7 @@ export function registerChat(route, ctx) {
163
179
  }
164
180
  ctx.runs.cancel(sessionId);
165
181
  ctx.approvals.cancelForSession(sessionId);
182
+ ctx.questions.cancelForSession(sessionId);
166
183
  await ctx.runs.waitForIdle(sessionId);
167
184
  }
168
185
 
@@ -208,13 +225,23 @@ export function registerChat(route, ctx) {
208
225
  ctx.runs.emit(sessionId, 'permission_resolved', { askId, allow });
209
226
  return allow;
210
227
  };
228
+ const onQuestion = async ({ title, questions }) => {
229
+ const askId = randomUUID();
230
+ ctx.runs.emit(sessionId, 'question', { askId, sessionId, title, questions });
231
+ const result = await ctx.questions.ask(askId, { sessionId, title, questions });
232
+ ctx.runs.emit(sessionId, 'question_answered', { askId, answers: result.answers, cancelled: !result.ok });
233
+ return result;
234
+ };
211
235
  const local = createTools({
212
236
  root: sessionCwd,
213
237
  enabled,
214
238
  permissions: ctx.config.permissions,
215
239
  yes: autoApprove,
216
240
  onAsk,
241
+ onQuestion,
217
242
  mcp,
243
+ mcpMode: ctx.effectiveMcpMode(),
244
+ mcpPinned: ctx.effectiveMcpPinned(),
218
245
  signal: ctx.runs.signal(sessionId),
219
246
  });
220
247
  // En modo plan (solo lectura) no exponemos las tools de administración.
@@ -228,6 +255,8 @@ export function registerChat(route, ctx) {
228
255
  model,
229
256
  permissions: ctx.config.permissions,
230
257
  mcp,
258
+ mcpMode: ctx.effectiveMcpMode(),
259
+ mcpPinned: ctx.effectiveMcpPinned(),
231
260
  signal: ctx.runs.signal(sessionId),
232
261
  exclude: [...admin.definitions.map((d) => d.function.name), ...(ctx.plugins?.list?.().map((t) => t.name) ?? [])],
233
262
  emit: emitAgent,
@@ -3,8 +3,14 @@ import {
3
3
  mcpTransport,
4
4
  getMcpRegistryServer,
5
5
  registryServerToConfig,
6
+ registryServerToCandidate,
7
+ searchMcpRegistry,
6
8
  normalizeMcpConfig,
9
+ MCP_CATALOG,
10
+ mcpCatalogEntry,
7
11
  maskEnv,
12
+ useLazyMcp,
13
+ MCP_AUTO_THRESHOLD,
8
14
  } from '../lib/core.js';
9
15
  import { readJson, sendJson, sendError } from '../lib/http.js';
10
16
 
@@ -79,11 +85,15 @@ function parseServer(body, id, existing = null) {
79
85
  export function registerMcp(route, ctx) {
80
86
  route('GET', '/api/mcp', async ({ res, query }) => {
81
87
  const servers = listServers(ctx);
88
+ const mode = ctx.effectiveMcpMode();
89
+ const pinned = ctx.effectiveMcpPinned();
90
+ let toolCount = null;
82
91
 
83
92
  if (query.get('connect')) {
84
93
  const connectable = ctx.mergedMcpServers().filter((s) => s.enabled !== false);
85
94
  const registry = await buildMcpRegistry(connectable);
86
95
  try {
96
+ toolCount = registry.definitions.length;
87
97
  for (const server of servers) {
88
98
  const session = registry.sessions.find((s) => s.server.id === server.id);
89
99
  const failure = registry.errors.find((e) => e.server.id === server.id);
@@ -108,14 +118,56 @@ export function registerMcp(route, ctx) {
108
118
  }
109
119
  }
110
120
 
111
- sendJson(res, 200, { servers });
121
+ sendJson(res, 200, {
122
+ servers,
123
+ mode,
124
+ pinned,
125
+ threshold: MCP_AUTO_THRESHOLD,
126
+ toolCount,
127
+ lazy: toolCount === null ? null : useLazyMcp(mode, toolCount, MCP_AUTO_THRESHOLD),
128
+ });
129
+ });
130
+
131
+ route('GET', '/api/mcp/catalog', ({ res }) => {
132
+ const installed = new Set(ctx.mergedMcpServers().map((s) => s.id));
133
+ sendJson(res, 200, {
134
+ catalog: MCP_CATALOG.map((entry) => ({
135
+ id: entry.id,
136
+ name: entry.name,
137
+ description: entry.description ?? '',
138
+ transport: entry.type === 'http' ? 'http' : 'stdio',
139
+ command: entry.command ?? null,
140
+ args: entry.args ?? [],
141
+ requiredEnv: entry.requiredEnv ?? [],
142
+ installed: installed.has(entry.id),
143
+ })),
144
+ });
145
+ });
146
+
147
+ route('GET', '/api/mcp/search', async ({ res, query }) => {
148
+ const q = String(query.get('q') ?? '').trim();
149
+ if (!q) return sendJson(res, 200, { results: [] });
150
+ let servers;
151
+ try {
152
+ servers = await searchMcpRegistry(q, { limit: 15 });
153
+ } catch (error) {
154
+ return sendError(res, 502, `No pude consultar el registro MCP: ${error.message}`);
155
+ }
156
+ const installed = new Set(ctx.mergedMcpServers().map((s) => s.id));
157
+ sendJson(res, 200, {
158
+ results: servers.map(registryServerToCandidate).map((c) => ({ ...c, installed: installed.has(c.id) })),
159
+ });
112
160
  });
113
161
 
114
162
  route('POST', '/api/mcp/import', async ({ req, res }) => {
115
163
  const body = await readJson(req);
116
164
  let record;
117
165
  try {
118
- if (body.json !== undefined || body.config !== undefined) {
166
+ if (body.catalog) {
167
+ const entry = mcpCatalogEntry(String(body.catalog));
168
+ if (!entry) return sendError(res, 404, 'Ese servidor no está en el catálogo.');
169
+ record = entry;
170
+ } else if (body.json !== undefined || body.config !== undefined) {
119
171
  record = normalizeMcpConfig(body.json ?? body.config, body.id);
120
172
  } else if (body.name) {
121
173
  const server = await getMcpRegistryServer(String(body.name), { strict: true });