@danieltmn/tamnora 0.16.1 → 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.
- package/CHANGELOG.md +45 -0
- package/README.md +58 -4
- package/SECURITY.md +8 -0
- package/harness/packages/cli/src/commands/mcp.js +31 -4
- package/harness/packages/cli/src/commands/repl.js +7 -2
- package/harness/packages/cli/src/commands/run.js +5 -2
- package/harness/packages/cli/src/commands/tui.js +9 -2
- package/harness/packages/cli/src/lib/runtime.js +23 -1
- package/harness/packages/cli/test/tunnel.test.js +13 -1
- package/harness/packages/core/src/agent/modes.js +3 -0
- package/harness/packages/core/src/config/index.js +3 -1
- package/harness/packages/core/src/index.js +17 -3
- package/harness/packages/core/src/mcp/catalog.js +79 -0
- package/harness/packages/core/src/mcp/index.js +14 -2
- package/harness/packages/core/src/mcp/registry.js +29 -1
- package/harness/packages/core/src/procs/detect.js +59 -8
- package/harness/packages/core/src/procs/kill.js +26 -5
- package/harness/packages/core/src/redact.js +25 -0
- package/harness/packages/core/src/skills/catalog.js +78 -0
- package/harness/packages/core/src/skills/remote.js +51 -0
- package/harness/packages/core/src/tools/executors.js +93 -2
- package/harness/packages/core/src/tools/index.js +27 -6
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/mcp.js +91 -0
- package/harness/packages/core/src/tools/permissions.js +53 -8
- package/harness/packages/core/src/tools/registry.js +14 -7
- package/harness/packages/core/src/tools/schema.js +86 -0
- package/harness/packages/core/src/tts/edge.js +28 -7
- package/harness/packages/core/src/tunnel.js +51 -5
- package/harness/packages/core/test/compact.test.js +2 -0
- package/harness/packages/core/test/labels.test.js +1 -0
- package/harness/packages/core/test/mcp-registry.test.js +37 -1
- package/harness/packages/core/test/mcp-routing.test.js +115 -0
- package/harness/packages/core/test/procs.test.js +70 -2
- package/harness/packages/core/test/redact.test.js +22 -0
- package/harness/packages/core/test/skills.test.js +11 -0
- package/harness/packages/core/test/tools.test.js +97 -3
- package/harness/packages/server/public/app.js +629 -28
- package/harness/packages/server/public/index.html +44 -2
- package/harness/packages/server/public/styles.css +106 -7
- package/harness/packages/server/src/lib/agentTools.js +45 -3
- package/harness/packages/server/src/lib/channels/telegram.js +2 -0
- package/harness/packages/server/src/lib/procs.js +9 -3
- package/harness/packages/server/src/lib/prompt.js +4 -1
- package/harness/packages/server/src/lib/questions.js +72 -0
- package/harness/packages/server/src/lib/runManager.js +29 -2
- package/harness/packages/server/src/lib/scheduler.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +3 -1
- package/harness/packages/server/src/routes/chat.js +40 -3
- package/harness/packages/server/src/routes/mcp.js +54 -2
- package/harness/packages/server/src/routes/preview.js +49 -28
- package/harness/packages/server/src/routes/procs.js +20 -3
- package/harness/packages/server/src/routes/providers.js +12 -0
- package/harness/packages/server/src/routes/sessions.js +3 -1
- package/harness/packages/server/src/routes/skills.js +48 -1
- package/harness/packages/server/src/routes/state.js +8 -1
- package/harness/packages/server/src/server.js +37 -1
- package/harness/packages/server/test/preview.test.js +111 -0
- package/harness/packages/server/test/server.test.js +285 -34
- package/harness/packages/server/test/telegram-features.test.js +2 -0
- package/harness/packages/server/test/telegram-voz.test.js +2 -0
- package/harness/packages/server/test/telegram.test.js +2 -0
- package/harness/tamnora.example.json +3 -1
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
|
|
@@ -155,7 +171,16 @@ export function registerChat(route, ctx) {
|
|
|
155
171
|
}
|
|
156
172
|
|
|
157
173
|
if (ctx.runs.isActive(sessionId)) {
|
|
158
|
-
|
|
174
|
+
// "Interrumpir": si el cliente lo pide, cancelamos el run en curso y
|
|
175
|
+
// esperamos a que se desenrolle antes de arrancar el nuevo. Sin esto, un
|
|
176
|
+
// segundo mensaje del mismo cliente chocaria con el 409.
|
|
177
|
+
if (body.interrupt !== true) {
|
|
178
|
+
return sendError(res, 409, 'La sesión ya tiene una respuesta en curso.');
|
|
179
|
+
}
|
|
180
|
+
ctx.runs.cancel(sessionId);
|
|
181
|
+
ctx.approvals.cancelForSession(sessionId);
|
|
182
|
+
ctx.questions.cancelForSession(sessionId);
|
|
183
|
+
await ctx.runs.waitForIdle(sessionId);
|
|
159
184
|
}
|
|
160
185
|
|
|
161
186
|
const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
|
|
@@ -200,13 +225,23 @@ export function registerChat(route, ctx) {
|
|
|
200
225
|
ctx.runs.emit(sessionId, 'permission_resolved', { askId, allow });
|
|
201
226
|
return allow;
|
|
202
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
|
+
};
|
|
203
235
|
const local = createTools({
|
|
204
236
|
root: sessionCwd,
|
|
205
237
|
enabled,
|
|
206
238
|
permissions: ctx.config.permissions,
|
|
207
239
|
yes: autoApprove,
|
|
208
240
|
onAsk,
|
|
241
|
+
onQuestion,
|
|
209
242
|
mcp,
|
|
243
|
+
mcpMode: ctx.effectiveMcpMode(),
|
|
244
|
+
mcpPinned: ctx.effectiveMcpPinned(),
|
|
210
245
|
signal: ctx.runs.signal(sessionId),
|
|
211
246
|
});
|
|
212
247
|
// En modo plan (solo lectura) no exponemos las tools de administración.
|
|
@@ -220,6 +255,8 @@ export function registerChat(route, ctx) {
|
|
|
220
255
|
model,
|
|
221
256
|
permissions: ctx.config.permissions,
|
|
222
257
|
mcp,
|
|
258
|
+
mcpMode: ctx.effectiveMcpMode(),
|
|
259
|
+
mcpPinned: ctx.effectiveMcpPinned(),
|
|
223
260
|
signal: ctx.runs.signal(sessionId),
|
|
224
261
|
exclude: [...admin.definitions.map((d) => d.function.name), ...(ctx.plugins?.list?.().map((t) => t.name) ?? [])],
|
|
225
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, {
|
|
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.
|
|
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 });
|
|
@@ -1,58 +1,79 @@
|
|
|
1
1
|
import { startTunnel } from '../lib/core.js';
|
|
2
2
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
import { requireLocal, localOk } from '../lib/workspace.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* Preview de un dev server para acceso remoto
|
|
6
|
-
*
|
|
7
|
-
* tunel
|
|
6
|
+
* Preview de un dev server para acceso remoto. Con `tailscale` publica el puerto
|
|
7
|
+
* en el tailnet (`tailscale serve --https=8443`, privado); con `tunnelmole` abre
|
|
8
|
+
* un tunel publico. El estado vive en memoria y se expone en `GET /api/preview`
|
|
9
|
+
* para que el cliente lo recupere tras una recarga. Devuelve `{ close }` para
|
|
10
|
+
* liberar el tunel al apagar.
|
|
8
11
|
*/
|
|
9
12
|
export function registerPreview(route, ctx) {
|
|
10
13
|
let tunnel = null;
|
|
11
14
|
|
|
15
|
+
const view = (t) => (t ? { url: t.url, port: t.port, provider: t.provider } : null);
|
|
16
|
+
|
|
17
|
+
const stopCurrent = () => {
|
|
18
|
+
try {
|
|
19
|
+
tunnel?.stop?.();
|
|
20
|
+
} catch {
|
|
21
|
+
// ignore
|
|
22
|
+
}
|
|
23
|
+
tunnel = null;
|
|
24
|
+
};
|
|
25
|
+
|
|
12
26
|
route('GET', '/api/preview', ({ res }) => {
|
|
13
|
-
sendJson(res, 200, { tunnel: tunnel
|
|
27
|
+
sendJson(res, 200, { tunnel: view(tunnel) });
|
|
14
28
|
});
|
|
15
29
|
|
|
16
30
|
route('POST', '/api/preview/tunnel', async ({ req, res }) => {
|
|
31
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'Abrir túneles solo está disponible en conexiones locales.')) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
17
34
|
const body = await readJson(req);
|
|
18
35
|
const port = Number(body.port);
|
|
19
36
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return sendError(res, 400, 'Puerto inválido.');
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
// ignore
|
|
25
|
-
}
|
|
26
|
-
tunnel = null;
|
|
37
|
+
// Remoto autenticado: solo puertos de procesos que el server ya corre.
|
|
38
|
+
if (ctx.authEnabled && !localOk(req)) {
|
|
39
|
+
const known = ctx.procs?.list?.().some((proc) => proc.running && proc.port === port);
|
|
40
|
+
if (!known) return sendError(res, 403, 'Solo podés exponer puertos de procesos activos.');
|
|
27
41
|
}
|
|
28
|
-
|
|
42
|
+
|
|
43
|
+
const preview = ctx.config.preview || {};
|
|
44
|
+
const provider = String(body.provider || preview.provider || 'tailscale').toLowerCase();
|
|
45
|
+
const httpsPort = Number(preview.tailscalePort) || 8443;
|
|
46
|
+
|
|
47
|
+
stopCurrent();
|
|
48
|
+
let started;
|
|
29
49
|
try {
|
|
30
|
-
|
|
31
|
-
tunnel = { url: started.url, port, stop: started.stop };
|
|
32
|
-
sendJson(res, 200, { tunnel: { url: tunnel.url, port } });
|
|
50
|
+
started = await startTunnel(port, provider, { preview: true, httpsPort });
|
|
33
51
|
} catch (error) {
|
|
34
|
-
|
|
52
|
+
if (provider !== 'tunnelmole' && preview.fallback !== false) {
|
|
53
|
+
try {
|
|
54
|
+
started = await startTunnel(port, 'tunnelmole');
|
|
55
|
+
} catch (fallbackError) {
|
|
56
|
+
return sendError(res, 502, `No se pudo abrir el túnel: ${fallbackError.message}`);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
return sendError(res, 502, `No se pudo abrir el túnel: ${error.message}`);
|
|
60
|
+
}
|
|
35
61
|
}
|
|
62
|
+
tunnel = { url: started.url, port, provider: started.provider, stop: started.stop };
|
|
63
|
+
sendJson(res, 200, { tunnel: view(tunnel) });
|
|
36
64
|
});
|
|
37
65
|
|
|
38
|
-
route('DELETE', '/api/preview/tunnel', ({ res }) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} catch {
|
|
42
|
-
// ignore
|
|
66
|
+
route('DELETE', '/api/preview/tunnel', ({ req, res }) => {
|
|
67
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'Cerrar túneles solo está disponible en conexiones locales.')) {
|
|
68
|
+
return;
|
|
43
69
|
}
|
|
44
|
-
|
|
70
|
+
stopCurrent();
|
|
45
71
|
sendJson(res, 200, { ok: true });
|
|
46
72
|
});
|
|
47
73
|
|
|
48
74
|
return {
|
|
49
75
|
close() {
|
|
50
|
-
|
|
51
|
-
tunnel?.stop?.();
|
|
52
|
-
} catch {
|
|
53
|
-
// ignore
|
|
54
|
-
}
|
|
55
|
-
tunnel = null;
|
|
76
|
+
stopCurrent();
|
|
56
77
|
},
|
|
57
78
|
};
|
|
58
79
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { detectProcs, killPort } from '../lib/core.js';
|
|
1
|
+
import { detectProcs, killPort, portInfo } from '../lib/core.js';
|
|
2
2
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
3
|
import { requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
4
4
|
|
|
@@ -8,9 +8,11 @@ export function registerProcs(route, ctx) {
|
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
route('GET', '/api/procs/detect', ({ res, query }) => {
|
|
11
|
+
if (!ctx.procsEnabled) return sendJson(res, 200, { cwd: ctx.cwd, chips: [] });
|
|
11
12
|
const dir = resolveDirOr400(res, query.get('cwd'), ctx.cwd);
|
|
12
13
|
if (!dir) return;
|
|
13
|
-
|
|
14
|
+
const chips = detectProcs(dir).map((chip) => ({ ...chip, allowed: ctx.procs.allows(chip.command) }));
|
|
15
|
+
sendJson(res, 200, { cwd: dir, chips });
|
|
14
16
|
});
|
|
15
17
|
|
|
16
18
|
route('POST', '/api/procs/start', async ({ req, res }) => {
|
|
@@ -43,7 +45,7 @@ export function registerProcs(route, ctx) {
|
|
|
43
45
|
sendJson(res, 200, { ok: true });
|
|
44
46
|
});
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
async function killPortHandler({ req, res }) {
|
|
47
49
|
if (!requireLocal(req, res, 'Matar procesos solo está disponible en conexiones locales.')) {
|
|
48
50
|
return;
|
|
49
51
|
}
|
|
@@ -53,5 +55,20 @@ export function registerProcs(route, ctx) {
|
|
|
53
55
|
} catch (error) {
|
|
54
56
|
sendError(res, 400, error.message);
|
|
55
57
|
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
route('POST', '/api/procs/kill', killPortHandler);
|
|
61
|
+
|
|
62
|
+
route('POST', '/api/ports/kill', killPortHandler);
|
|
63
|
+
|
|
64
|
+
route('GET', '/api/ports/:port', async ({ req, res, params }) => {
|
|
65
|
+
if (!requireLocal(req, res, 'Consultar puertos solo está disponible en conexiones locales.')) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
sendJson(res, 200, await portInfo(params.port));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
sendError(res, 400, error.message);
|
|
72
|
+
}
|
|
56
73
|
});
|
|
57
74
|
}
|
|
@@ -78,6 +78,8 @@ export function registerProviders(route, ctx) {
|
|
|
78
78
|
maxOutputTokens: ctx.effectiveMaxOutputTokens(),
|
|
79
79
|
defaultCwd: ctx.store.getSetting('defaultCwd') || '',
|
|
80
80
|
strictScope: ctx.effectiveStrictScope(),
|
|
81
|
+
mcpMode: ctx.effectiveMcpMode(),
|
|
82
|
+
mcpPinned: ctx.effectiveMcpPinned(),
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
route('GET', '/api/settings', ({ res }) => {
|
|
@@ -91,6 +93,16 @@ export function registerProviders(route, ctx) {
|
|
|
91
93
|
if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
|
|
92
94
|
if (body.defaultCwd !== undefined) ctx.store.setSetting('defaultCwd', String(body.defaultCwd ?? '').trim() || null);
|
|
93
95
|
if (body.strictScope !== undefined) ctx.store.setSetting('strictScope', Boolean(body.strictScope));
|
|
96
|
+
if (body.mcpMode !== undefined) {
|
|
97
|
+
const mode = String(body.mcpMode);
|
|
98
|
+
ctx.store.setSetting('mcpMode', ['auto', 'eager', 'lazy'].includes(mode) ? mode : 'auto');
|
|
99
|
+
}
|
|
100
|
+
if (body.mcpPinned !== undefined) {
|
|
101
|
+
const list = Array.isArray(body.mcpPinned)
|
|
102
|
+
? body.mcpPinned.map((v) => String(v).trim()).filter(Boolean)
|
|
103
|
+
: String(body.mcpPinned).split(/[\s,]+/).map((v) => v.trim()).filter(Boolean);
|
|
104
|
+
ctx.store.setSetting('mcpPinned', list);
|
|
105
|
+
}
|
|
94
106
|
for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs', 'maxOutputTokens']) {
|
|
95
107
|
if (body[key] === undefined) continue;
|
|
96
108
|
const value = Number(body[key]);
|
|
@@ -6,7 +6,7 @@ import { rewindSession, checkpointTurns } from '../lib/checkpoints.js';
|
|
|
6
6
|
|
|
7
7
|
export function registerSessions(route, ctx) {
|
|
8
8
|
route('GET', '/api/sessions', ({ res }) => {
|
|
9
|
-
const awaiting = awaitingBySession(ctx.approvals.list());
|
|
9
|
+
const awaiting = awaitingBySession([...ctx.approvals.list(), ...ctx.questions.list()]);
|
|
10
10
|
const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
|
|
11
11
|
sendJson(res, 200, { sessions });
|
|
12
12
|
});
|
|
@@ -68,6 +68,8 @@ export function registerSessions(route, ctx) {
|
|
|
68
68
|
// mensajes), asi que va aparte: sin esto, recargar la pagina dejaba el run
|
|
69
69
|
// esperando una decision que no se podia tomar.
|
|
70
70
|
permissions: ctx.approvals.list({ sessionId: params.id }),
|
|
71
|
+
// Idem para los formularios interactivos (`ask_user`).
|
|
72
|
+
questions: ctx.questions.list({ sessionId: params.id }),
|
|
71
73
|
});
|
|
72
74
|
});
|
|
73
75
|
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
homeDir,
|
|
4
|
+
parseSkillRef,
|
|
5
|
+
fetchSkillFromGithub,
|
|
6
|
+
searchSkillsGithub,
|
|
7
|
+
writeSkillFiles,
|
|
8
|
+
SKILLS_CATALOG,
|
|
9
|
+
skillCatalogEntry,
|
|
10
|
+
envValue,
|
|
11
|
+
} from '../lib/core.js';
|
|
3
12
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
4
13
|
import {
|
|
5
14
|
SKILL_ID_PATTERN,
|
|
15
|
+
allSkills,
|
|
6
16
|
skillsView,
|
|
7
17
|
findSkill,
|
|
8
18
|
writeSkill,
|
|
@@ -46,6 +56,43 @@ export function registerSkills(route, ctx) {
|
|
|
46
56
|
sendJson(res, 200, { skills: skillsView(ctx) });
|
|
47
57
|
});
|
|
48
58
|
|
|
59
|
+
route('GET', '/api/skills/catalog', ({ res }) => {
|
|
60
|
+
sendJson(res, 200, {
|
|
61
|
+
catalog: SKILLS_CATALOG.map((entry) => ({
|
|
62
|
+
id: entry.id,
|
|
63
|
+
name: entry.name,
|
|
64
|
+
description: entry.description ?? '',
|
|
65
|
+
when: entry.when ?? '',
|
|
66
|
+
installed: Boolean(findSkill(ctx, entry.id)),
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
route('GET', '/api/skills/search', async ({ res, query }) => {
|
|
72
|
+
const q = String(query.get('q') ?? '').trim();
|
|
73
|
+
if (!q) return sendJson(res, 200, { results: [] });
|
|
74
|
+
let results;
|
|
75
|
+
try {
|
|
76
|
+
results = await searchSkillsGithub(q, { token: envValue(['GITHUB_TOKEN', 'GH_TOKEN']) });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return sendError(res, 502, `No pude buscar en GitHub: ${error.message}`);
|
|
79
|
+
}
|
|
80
|
+
const installed = new Set(allSkills(ctx.cwd).map((s) => s.id));
|
|
81
|
+
sendJson(res, 200, { results: results.map((r) => ({ ...r, installed: installed.has(r.slug || r.repo) })) });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** Instala una skill del catalogo (queda deshabilitada hasta que la prendan). */
|
|
85
|
+
route('POST', '/api/skills', async ({ req, res }) => {
|
|
86
|
+
const body = await readJson(req);
|
|
87
|
+
const entry = skillCatalogEntry(String(body.id ?? ''));
|
|
88
|
+
if (!entry) return sendError(res, 404, 'Esa skill no está en el catálogo.');
|
|
89
|
+
const id = sanitizeId(entry.id);
|
|
90
|
+
if (!SKILL_ID_PATTERN.test(id)) return sendError(res, 500, 'Id de skill inválido.');
|
|
91
|
+
await writeSkill(id, { name: entry.name, description: entry.description, when: entry.when, body: entry.body });
|
|
92
|
+
setSkillEnabled(ctx.store, id, false);
|
|
93
|
+
sendJson(res, 200, { installed: id, skills: skillsView(ctx) });
|
|
94
|
+
});
|
|
95
|
+
|
|
49
96
|
route('GET', '/api/skills/:id', ({ res, params }) => {
|
|
50
97
|
const skill = findSkill(ctx, params.id);
|
|
51
98
|
if (!skill) return sendError(res, 404, 'Skill no encontrada.');
|
|
@@ -14,7 +14,8 @@ import { sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
|
14
14
|
export function registerState(route, ctx) {
|
|
15
15
|
route('GET', '/api/state', ({ res }) => {
|
|
16
16
|
const permissions = ctx.approvals.list();
|
|
17
|
-
const
|
|
17
|
+
const questions = ctx.questions.list();
|
|
18
|
+
const awaiting = awaitingBySession([...permissions, ...questions]);
|
|
18
19
|
const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
|
|
19
20
|
sendJson(res, 200, {
|
|
20
21
|
sessions,
|
|
@@ -25,6 +26,12 @@ export function registerState(route, ctx) {
|
|
|
25
26
|
args,
|
|
26
27
|
requestedAt,
|
|
27
28
|
})),
|
|
29
|
+
questions: questions.map(({ askId, sessionId, title, requestedAt }) => ({
|
|
30
|
+
askId,
|
|
31
|
+
sessionId,
|
|
32
|
+
title,
|
|
33
|
+
requestedAt,
|
|
34
|
+
})),
|
|
28
35
|
// Para que el cliente pueda detectar que el server va en otro reloj.
|
|
29
36
|
now: Date.now(),
|
|
30
37
|
});
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
resolveApiKey,
|
|
13
13
|
composeProviderSettings,
|
|
14
14
|
buildMcpRegistry,
|
|
15
|
+
buildMcpIndex,
|
|
16
|
+
useLazyMcp,
|
|
17
|
+
MCP_AUTO_THRESHOLD,
|
|
15
18
|
readPackageVersion,
|
|
16
19
|
scopeDirective,
|
|
17
20
|
} from './lib/core.js';
|
|
@@ -20,6 +23,7 @@ import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
|
|
|
20
23
|
import { profilePrompt, resolveProfile } from './lib/profile.js';
|
|
21
24
|
import { skillsBlock } from './lib/skills.js';
|
|
22
25
|
import { createApprovalBroker } from './lib/approvals.js';
|
|
26
|
+
import { createQuestionBroker } from './lib/questions.js';
|
|
23
27
|
import { createPresence } from './lib/presence.js';
|
|
24
28
|
import { createRunManager } from './lib/runManager.js';
|
|
25
29
|
import { createProcManager } from './lib/procs.js';
|
|
@@ -91,6 +95,7 @@ export async function createTamnoraServer({
|
|
|
91
95
|
const authEnabled = Boolean(app.user && app.user.password);
|
|
92
96
|
const store = new Store(dbPath ?? databasePath(cwd));
|
|
93
97
|
const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
|
|
98
|
+
const questions = createQuestionBroker({ timeoutMs: config.questionTimeoutMs });
|
|
94
99
|
const presence = createPresence();
|
|
95
100
|
const runs = createRunManager({ store });
|
|
96
101
|
const procs = createProcManager({ allow: config.processes?.allow });
|
|
@@ -122,11 +127,17 @@ export async function createTamnoraServer({
|
|
|
122
127
|
};
|
|
123
128
|
|
|
124
129
|
let mcpPromise = null;
|
|
130
|
+
let mcpMeta = null;
|
|
125
131
|
const getMcp = async () => {
|
|
126
132
|
if (!loadMcp) return null;
|
|
127
133
|
const servers = mergedMcpServers().filter((s) => s.enabled !== false);
|
|
128
134
|
if (!servers.length) return null;
|
|
129
|
-
if (!mcpPromise)
|
|
135
|
+
if (!mcpPromise) {
|
|
136
|
+
mcpPromise = buildMcpRegistry(servers).then((registry) => {
|
|
137
|
+
mcpMeta = { total: registry.definitions.length, index: buildMcpIndex(registry.tools ?? []) };
|
|
138
|
+
return registry;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
130
141
|
return mcpPromise;
|
|
131
142
|
};
|
|
132
143
|
|
|
@@ -138,6 +149,7 @@ export async function createTamnoraServer({
|
|
|
138
149
|
// ignore MCP shutdown errors
|
|
139
150
|
}
|
|
140
151
|
mcpPromise = null;
|
|
152
|
+
mcpMeta = null;
|
|
141
153
|
};
|
|
142
154
|
|
|
143
155
|
const customProviderDefs = () =>
|
|
@@ -168,6 +180,24 @@ export async function createTamnoraServer({
|
|
|
168
180
|
const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
|
|
169
181
|
const effectiveMaxOutputTokens = () => settingNumber('maxOutputTokens', config.maxOutputTokens);
|
|
170
182
|
const effectiveStrictScope = () => store.getSetting('strictScope') === true;
|
|
183
|
+
const MCP_MODES = new Set(['auto', 'eager', 'lazy']);
|
|
184
|
+
const effectiveMcpMode = () => {
|
|
185
|
+
const value = String(store.getSetting('mcpMode') ?? config.mcpMode ?? 'auto');
|
|
186
|
+
return MCP_MODES.has(value) ? value : 'auto';
|
|
187
|
+
};
|
|
188
|
+
const effectiveMcpPinned = () => {
|
|
189
|
+
const stored = store.getSetting('mcpPinned');
|
|
190
|
+
if (Array.isArray(stored)) return stored.map(String);
|
|
191
|
+
return Array.isArray(config.mcpPinned) ? config.mcpPinned.map(String) : [];
|
|
192
|
+
};
|
|
193
|
+
// Indice de tools MCP (para carga diferida con `mcp_call`): solo si el modo
|
|
194
|
+
// es diferido y el registro ya se conecto. Se rearma con cada `reloadMcp`.
|
|
195
|
+
const mcpText = () => {
|
|
196
|
+
if (!mcpMeta?.index) return '';
|
|
197
|
+
const total = mcpMeta.total;
|
|
198
|
+
if (!useLazyMcp(effectiveMcpMode(), total, MCP_AUTO_THRESHOLD)) return '';
|
|
199
|
+
return mcpMeta.index;
|
|
200
|
+
};
|
|
171
201
|
|
|
172
202
|
const ctx = {
|
|
173
203
|
cwd,
|
|
@@ -176,6 +206,7 @@ export async function createTamnoraServer({
|
|
|
176
206
|
authEnabled,
|
|
177
207
|
store,
|
|
178
208
|
approvals,
|
|
209
|
+
questions,
|
|
179
210
|
presence,
|
|
180
211
|
runs,
|
|
181
212
|
isAutoApprove,
|
|
@@ -189,6 +220,9 @@ export async function createTamnoraServer({
|
|
|
189
220
|
effectiveMaxDurationMs,
|
|
190
221
|
effectiveMaxOutputTokens,
|
|
191
222
|
effectiveStrictScope,
|
|
223
|
+
effectiveMcpMode,
|
|
224
|
+
effectiveMcpPinned,
|
|
225
|
+
mcpText,
|
|
192
226
|
providerSettings,
|
|
193
227
|
customProviderDefs,
|
|
194
228
|
apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
|
|
@@ -212,6 +246,7 @@ export async function createTamnoraServer({
|
|
|
212
246
|
files: config.instructions,
|
|
213
247
|
profileText: () => profilePrompt(resolveProfile(ctx.app)),
|
|
214
248
|
skillsText: () => skillsBlock(ctx),
|
|
249
|
+
mcpText: () => ctx.mcpText(),
|
|
215
250
|
scopeText: () => scopeDirective(ctx.effectiveStrictScope()),
|
|
216
251
|
ruleFiles: config.rules ?? [],
|
|
217
252
|
memory: config.memory?.enabled !== false,
|
|
@@ -377,6 +412,7 @@ export async function createTamnoraServer({
|
|
|
377
412
|
|
|
378
413
|
const close = async () => {
|
|
379
414
|
approvals.cancelAll();
|
|
415
|
+
questions.cancelAll();
|
|
380
416
|
runs.cancelAll();
|
|
381
417
|
ctx.scheduler?.stop();
|
|
382
418
|
channels.stop();
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile, unlink } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
7
|
+
import { setHome, bridgeStatePath } from '../../core/src/index.js';
|
|
8
|
+
|
|
9
|
+
async function startServer() {
|
|
10
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-preview-'));
|
|
11
|
+
setHome(cwd);
|
|
12
|
+
// provider "none": la ruta no lanza un tunel real en los tests.
|
|
13
|
+
await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ preview: { provider: 'none' } }));
|
|
14
|
+
const instance = await createTamnoraServer({
|
|
15
|
+
cwd,
|
|
16
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
17
|
+
loadMcp: false,
|
|
18
|
+
telegram: false,
|
|
19
|
+
});
|
|
20
|
+
const info = await instance.listen({ port: 0 });
|
|
21
|
+
return { instance, base: info.url, cwd };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Deja un bridge.json apuntando a un tunel publico vivo (este proceso). */
|
|
25
|
+
async function fakePublicTunnel() {
|
|
26
|
+
await writeFile(
|
|
27
|
+
bridgeStatePath(),
|
|
28
|
+
`${JSON.stringify({ pid: process.pid, url: 'https://fake.tunnelmole.net', provider: 'tunnelmole', port: 4517 })}\n`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function clearTunnel() {
|
|
33
|
+
try {
|
|
34
|
+
await unlink(bridgeStatePath());
|
|
35
|
+
} catch {
|
|
36
|
+
/* nada */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('GET /api/preview arranca sin tunel', async () => {
|
|
41
|
+
const { instance, base } = await startServer();
|
|
42
|
+
try {
|
|
43
|
+
const data = await (await fetch(`${base}/api/preview`)).json();
|
|
44
|
+
assert.equal(data.tunnel, null);
|
|
45
|
+
} finally {
|
|
46
|
+
await clearTunnel();
|
|
47
|
+
await instance.close();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('POST/GET/DELETE /api/preview/tunnel mantiene el estado', async () => {
|
|
52
|
+
const { instance, base } = await startServer();
|
|
53
|
+
try {
|
|
54
|
+
const post = await fetch(`${base}/api/preview/tunnel`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ port: 8080 }),
|
|
58
|
+
});
|
|
59
|
+
assert.equal(post.status, 200);
|
|
60
|
+
const created = await post.json();
|
|
61
|
+
assert.equal(created.tunnel.port, 8080);
|
|
62
|
+
assert.equal(created.tunnel.provider, 'none');
|
|
63
|
+
|
|
64
|
+
const got = await (await fetch(`${base}/api/preview`)).json();
|
|
65
|
+
assert.equal(got.tunnel.port, 8080);
|
|
66
|
+
|
|
67
|
+
const del = await fetch(`${base}/api/preview/tunnel`, { method: 'DELETE' });
|
|
68
|
+
assert.equal(del.status, 200);
|
|
69
|
+
const after = await (await fetch(`${base}/api/preview`)).json();
|
|
70
|
+
assert.equal(after.tunnel, null);
|
|
71
|
+
} finally {
|
|
72
|
+
await clearTunnel();
|
|
73
|
+
await instance.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('POST /api/preview/tunnel rechaza un puerto invalido', async () => {
|
|
78
|
+
const { instance, base } = await startServer();
|
|
79
|
+
try {
|
|
80
|
+
const post = await fetch(`${base}/api/preview/tunnel`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({ port: 0 }),
|
|
84
|
+
});
|
|
85
|
+
assert.equal(post.status, 400);
|
|
86
|
+
} finally {
|
|
87
|
+
await clearTunnel();
|
|
88
|
+
await instance.close();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('con un tunel publico activo el preview queda cerrado para no locales', async () => {
|
|
93
|
+
const { instance, base } = await startServer();
|
|
94
|
+
try {
|
|
95
|
+
await fakePublicTunnel();
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
97
|
+
|
|
98
|
+
const post = await fetch(`${base}/api/preview/tunnel`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ port: 8080 }),
|
|
102
|
+
});
|
|
103
|
+
assert.equal(post.status, 403);
|
|
104
|
+
|
|
105
|
+
const del = await fetch(`${base}/api/preview/tunnel`, { method: 'DELETE' });
|
|
106
|
+
assert.equal(del.status, 403);
|
|
107
|
+
} finally {
|
|
108
|
+
await clearTunnel();
|
|
109
|
+
await instance.close();
|
|
110
|
+
}
|
|
111
|
+
});
|