@danieltmn/tamnora 0.1.0 → 0.2.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 +47 -0
- package/harness/packages/cli/src/cli.js +2 -0
- package/harness/packages/cli/src/commands/eval.js +3 -0
- package/harness/packages/cli/src/commands/repl.js +2 -0
- package/harness/packages/cli/src/commands/run.js +2 -1
- package/harness/packages/cli/src/commands/tui.js +18 -11
- package/harness/packages/cli/src/lib/runtime.js +3 -1
- package/harness/packages/cli/src/lib/tunnel.js +2 -113
- package/harness/packages/cli/src/lib/util.js +2 -0
- package/harness/packages/cli/src/tui/app.js +45 -4
- package/harness/packages/cli/src/tui/keys.js +3 -0
- package/harness/packages/cli/test/tui.test.js +5 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/config/index.js +3 -0
- package/harness/packages/core/src/console.js +18 -0
- package/harness/packages/core/src/eval/judge.js +3 -3
- package/harness/packages/core/src/eval/runner.js +3 -0
- package/harness/packages/core/src/index.js +4 -0
- package/harness/packages/core/src/procs/detect.js +69 -0
- package/harness/packages/core/src/providers/ollama.js +3 -3
- package/harness/packages/core/src/providers/openaiCompatible.js +3 -3
- package/harness/packages/core/src/providers/openrouter.js +2 -2
- package/harness/packages/core/src/providers/resilience.js +1 -1
- package/harness/packages/core/src/providers/utils.js +43 -0
- package/harness/packages/core/src/skills/index.js +72 -0
- package/harness/packages/core/src/storage/sqlite.js +21 -0
- package/harness/packages/core/src/tunnel.js +113 -0
- package/harness/packages/core/test/config.test.js +15 -0
- package/harness/packages/core/test/procs.test.js +34 -0
- package/harness/packages/core/test/providers.test.js +20 -0
- package/harness/packages/core/test/resilience.test.js +1 -0
- package/harness/packages/core/test/skills.test.js +49 -0
- package/harness/packages/server/public/app.js +440 -6
- package/harness/packages/server/public/index.html +124 -6
- package/harness/packages/server/public/styles.css +43 -1
- package/harness/packages/server/src/cli.js +2 -0
- package/harness/packages/server/src/lib/agentTools.js +159 -0
- package/harness/packages/server/src/lib/procs.js +125 -0
- package/harness/packages/server/src/lib/prompt.js +6 -3
- package/harness/packages/server/src/lib/skills.js +69 -0
- package/harness/packages/server/src/lib/telegram.js +267 -0
- package/harness/packages/server/src/routes/chat.js +9 -1
- package/harness/packages/server/src/routes/mcp.js +71 -8
- package/harness/packages/server/src/routes/preview.js +58 -0
- package/harness/packages/server/src/routes/procs.js +47 -0
- package/harness/packages/server/src/routes/sessions.js +10 -0
- package/harness/packages/server/src/routes/skills.js +61 -0
- package/harness/packages/server/src/server.js +79 -6
- package/harness/packages/server/test/server.test.js +227 -0
- package/harness/tamnora.example.json +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { runAgent, createTools, resolveMode } from './core.js';
|
|
2
|
+
import { resolveExistingDir } from './workspace.js';
|
|
3
|
+
|
|
4
|
+
const API = 'https://api.telegram.org';
|
|
5
|
+
|
|
6
|
+
const HELP = [
|
|
7
|
+
'Tamnora Harness — bot',
|
|
8
|
+
'',
|
|
9
|
+
'/new — nueva sesión',
|
|
10
|
+
'/status — proveedor, modelo, modo y sesión',
|
|
11
|
+
'/model <id> — cambia el modelo',
|
|
12
|
+
'/provider <id> — cambia el proveedor',
|
|
13
|
+
'/mode plan|build — cambia el modo',
|
|
14
|
+
'/stop — cancela la respuesta en curso',
|
|
15
|
+
'/help — esta ayuda',
|
|
16
|
+
'',
|
|
17
|
+
'Cualquier otro texto se envía al agente.',
|
|
18
|
+
].join('\n');
|
|
19
|
+
|
|
20
|
+
async function tg(token, method, payload) {
|
|
21
|
+
const res = await fetch(`${API}/bot${token}/${method}`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify(payload ?? {}),
|
|
25
|
+
});
|
|
26
|
+
let data = null;
|
|
27
|
+
try {
|
|
28
|
+
data = await res.json();
|
|
29
|
+
} catch {
|
|
30
|
+
data = null;
|
|
31
|
+
}
|
|
32
|
+
if (!data?.ok) {
|
|
33
|
+
const error = new Error(data?.description || `HTTP ${res.status}`);
|
|
34
|
+
error.status = res.status;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return data.result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function splitMessage(text, max = 3900) {
|
|
41
|
+
const value = String(text ?? '').trim() || '(sin texto)';
|
|
42
|
+
const parts = [];
|
|
43
|
+
for (let i = 0; i < value.length; i += max) parts.push(value.slice(i, i + max));
|
|
44
|
+
return parts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Bot de Telegram por long polling (no necesita URL publica). Los chats
|
|
51
|
+
* autorizados conversan con el mismo engine del harness; cada chat mapea a una
|
|
52
|
+
* sesion. Devuelve `{ stop }` o `null` si no hay token.
|
|
53
|
+
*/
|
|
54
|
+
export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () => {} } = {}) {
|
|
55
|
+
if (!token) return null;
|
|
56
|
+
const allowed = new Set(allowedChatIds.map(String));
|
|
57
|
+
const controllers = new Map();
|
|
58
|
+
let running = true;
|
|
59
|
+
let offset = 0;
|
|
60
|
+
|
|
61
|
+
const authorized = (chatId) => allowed.size > 0 && allowed.has(String(chatId));
|
|
62
|
+
const sessionKey = (chatId) => `telegram:chat:${chatId}`;
|
|
63
|
+
|
|
64
|
+
async function send(chatId, text) {
|
|
65
|
+
let first = null;
|
|
66
|
+
for (const chunk of splitMessage(text)) {
|
|
67
|
+
const result = await tg(token, 'sendMessage', { chat_id: chatId, text: chunk, disable_web_page_preview: true });
|
|
68
|
+
if (!first) first = result;
|
|
69
|
+
}
|
|
70
|
+
return first;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function edit(chatId, messageId, text) {
|
|
74
|
+
const parts = splitMessage(text);
|
|
75
|
+
await tg(token, 'editMessageText', { chat_id: chatId, message_id: messageId, text: parts[0], disable_web_page_preview: true }).catch(() => null);
|
|
76
|
+
for (let i = 1; i < parts.length; i++) {
|
|
77
|
+
await tg(token, 'sendMessage', { chat_id: chatId, text: parts[i], disable_web_page_preview: true }).catch(() => null);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ensureSession(chatId) {
|
|
82
|
+
const key = sessionKey(chatId);
|
|
83
|
+
let sessionId = ctx.store.getSetting(key);
|
|
84
|
+
if (!sessionId || !ctx.store.getSession(sessionId)) {
|
|
85
|
+
sessionId = ctx.store.createSession({
|
|
86
|
+
title: `Telegram ${chatId}`,
|
|
87
|
+
provider: ctx.effectiveProviderId(),
|
|
88
|
+
model: ctx.effectiveModel(),
|
|
89
|
+
cwd: ctx.cwd,
|
|
90
|
+
meta: { telegramChatId: String(chatId) },
|
|
91
|
+
});
|
|
92
|
+
ctx.store.setSetting(key, sessionId);
|
|
93
|
+
}
|
|
94
|
+
return sessionId;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function run(chatId, input) {
|
|
98
|
+
const sessionId = ensureSession(chatId);
|
|
99
|
+
const session = ctx.store.getSession(sessionId);
|
|
100
|
+
const providerId = session.provider || ctx.effectiveProviderId();
|
|
101
|
+
const model = session.model || ctx.effectiveModel();
|
|
102
|
+
if (!model) {
|
|
103
|
+
await send(chatId, 'Falta el modelo. Usá /model <id> o configurá uno en Ajustes.');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const mode = resolveMode(ctx.effectiveMode());
|
|
107
|
+
const cwd = resolveExistingDir(session.cwd || ctx.cwd);
|
|
108
|
+
const history = ctx.store.getMessages(sessionId);
|
|
109
|
+
|
|
110
|
+
let mcp = null;
|
|
111
|
+
try {
|
|
112
|
+
mcp = await ctx.getMcp();
|
|
113
|
+
} catch {
|
|
114
|
+
mcp = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const local = createTools({
|
|
118
|
+
root: cwd,
|
|
119
|
+
enabled: mode.tools ?? undefined,
|
|
120
|
+
permissions: ctx.config.permissions,
|
|
121
|
+
yes: false,
|
|
122
|
+
mcp,
|
|
123
|
+
});
|
|
124
|
+
const exposeAdmin = !mode.tools;
|
|
125
|
+
const admin = ctx.agentTools;
|
|
126
|
+
const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
|
|
127
|
+
const execute = (name, args, context) =>
|
|
128
|
+
exposeAdmin && admin.handles(name)
|
|
129
|
+
? admin.execute(name, args, { approve: async () => false })
|
|
130
|
+
: local.execute(name, args, context);
|
|
131
|
+
const { provider, config } = ctx.resolveProvider({
|
|
132
|
+
...ctx.providerSettings(providerId),
|
|
133
|
+
apiKey: ctx.apiKeyFor(providerId),
|
|
134
|
+
});
|
|
135
|
+
const basePrompt = await ctx.systemPromptFor(cwd);
|
|
136
|
+
const messages = [
|
|
137
|
+
{ role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
|
|
138
|
+
...history,
|
|
139
|
+
{ role: 'user', content: input },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
controllers.set(String(chatId), controller);
|
|
144
|
+
const message = await send(chatId, '…');
|
|
145
|
+
let full = '';
|
|
146
|
+
let lastEdit = 0;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const result = await runAgent({
|
|
150
|
+
provider,
|
|
151
|
+
config,
|
|
152
|
+
model,
|
|
153
|
+
messages,
|
|
154
|
+
tools: definitions,
|
|
155
|
+
execute,
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
maxRounds: ctx.config.maxRounds,
|
|
158
|
+
onEvent(event) {
|
|
159
|
+
if (event.type !== 'text') return;
|
|
160
|
+
full = event.full;
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
if (now - lastEdit > 1200) {
|
|
163
|
+
lastEdit = now;
|
|
164
|
+
edit(chatId, message.message_id, full || '…');
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
await edit(chatId, message.message_id, result.content?.trim() || full || '(sin texto)');
|
|
169
|
+
const newMessages = result.messages.slice(1 + history.length);
|
|
170
|
+
ctx.store.addMessages(sessionId, newMessages);
|
|
171
|
+
ctx.store.touchSession(sessionId, { provider: providerId, model });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
await edit(chatId, message.message_id, error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`);
|
|
174
|
+
} finally {
|
|
175
|
+
controllers.delete(String(chatId));
|
|
176
|
+
mcp?.close?.();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function handleCommand(chatId, text) {
|
|
181
|
+
const [raw, ...rest] = text.slice(1).split(/\s+/);
|
|
182
|
+
const cmd = raw.toLowerCase();
|
|
183
|
+
const arg = rest.join(' ').trim();
|
|
184
|
+
const sessionId = ctx.store.getSetting(sessionKey(chatId));
|
|
185
|
+
if (cmd === 'start' || cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
|
|
186
|
+
if (cmd === 'new' || cmd === 'nueva') {
|
|
187
|
+
ctx.store.setSetting(sessionKey(chatId), null);
|
|
188
|
+
return send(chatId, 'Nueva sesión. Mandá tu mensaje.');
|
|
189
|
+
}
|
|
190
|
+
if (cmd === 'status') {
|
|
191
|
+
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
192
|
+
return send(
|
|
193
|
+
chatId,
|
|
194
|
+
`proveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (cmd === 'model') {
|
|
198
|
+
if (!arg) return send(chatId, `modelo actual: ${ctx.effectiveModel() || '—'}`);
|
|
199
|
+
ctx.store.setSetting('activeModel', arg);
|
|
200
|
+
if (sessionId) ctx.store.touchSession(sessionId, { model: arg });
|
|
201
|
+
return send(chatId, `modelo: ${arg}`);
|
|
202
|
+
}
|
|
203
|
+
if (cmd === 'provider') {
|
|
204
|
+
if (!arg) return send(chatId, `proveedor actual: ${ctx.effectiveProviderId()}`);
|
|
205
|
+
ctx.store.setSetting('activeProvider', arg);
|
|
206
|
+
if (sessionId) ctx.store.touchSession(sessionId, { provider: arg });
|
|
207
|
+
return send(chatId, `proveedor: ${arg}`);
|
|
208
|
+
}
|
|
209
|
+
if (cmd === 'mode' || cmd === 'modo') {
|
|
210
|
+
if (!arg) return send(chatId, `modo actual: ${ctx.effectiveMode()}`);
|
|
211
|
+
const id = arg.toLowerCase() === 'build' ? 'agent' : arg.toLowerCase();
|
|
212
|
+
ctx.store.setSetting('activeMode', resolveMode(id).id);
|
|
213
|
+
return send(chatId, `modo: ${resolveMode(id).label}`);
|
|
214
|
+
}
|
|
215
|
+
if (cmd === 'stop' || cmd === 'parar') {
|
|
216
|
+
controllers.get(String(chatId))?.abort();
|
|
217
|
+
return send(chatId, 'Cancelando…');
|
|
218
|
+
}
|
|
219
|
+
return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function handleMessage(message) {
|
|
223
|
+
const chatId = String(message.chat.id);
|
|
224
|
+
if (!authorized(chatId)) {
|
|
225
|
+
await send(chatId, `No autorizado. Tu chat id es ${chatId}; agregalo a telegram.allowedChatIds para habilitarlo.`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const text = String(message.text ?? '').trim();
|
|
229
|
+
if (!text) return;
|
|
230
|
+
if (text.startsWith('/')) return handleCommand(chatId, text);
|
|
231
|
+
return run(chatId, text);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function loop() {
|
|
235
|
+
while (running) {
|
|
236
|
+
let updates = [];
|
|
237
|
+
try {
|
|
238
|
+
updates = await tg(token, 'getUpdates', { offset, timeout: 25, allowed_updates: ['message'] });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error.status === 401 || error.status === 404) {
|
|
241
|
+
log(`telegram: token inválido (${error.message}); se detiene el bot.`);
|
|
242
|
+
running = false;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
await sleep(3000);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
for (const update of updates) {
|
|
249
|
+
offset = update.update_id + 1;
|
|
250
|
+
if (update.message?.text) {
|
|
251
|
+
handleMessage(update.message).catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Arranca en segundo plano; no bloquea el server.
|
|
258
|
+
loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
stop() {
|
|
262
|
+
running = false;
|
|
263
|
+
for (const controller of controllers.values()) controller.abort();
|
|
264
|
+
controllers.clear();
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -123,7 +123,7 @@ export function registerChat(route, ctx) {
|
|
|
123
123
|
sse.event('permission_resolved', { askId, allow });
|
|
124
124
|
return allow;
|
|
125
125
|
};
|
|
126
|
-
const
|
|
126
|
+
const local = createTools({
|
|
127
127
|
root: sessionCwd,
|
|
128
128
|
enabled,
|
|
129
129
|
permissions: ctx.config.permissions,
|
|
@@ -131,6 +131,14 @@ export function registerChat(route, ctx) {
|
|
|
131
131
|
onAsk,
|
|
132
132
|
mcp,
|
|
133
133
|
});
|
|
134
|
+
// En modo plan (solo lectura) no exponemos las tools de administración.
|
|
135
|
+
const exposeAdmin = !mode.tools;
|
|
136
|
+
const admin = ctx.agentTools;
|
|
137
|
+
const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
|
|
138
|
+
const execute = (name, args, context) =>
|
|
139
|
+
exposeAdmin && admin.handles(name)
|
|
140
|
+
? admin.execute(name, args, { approve: autoApprove ? async () => true : onAsk })
|
|
141
|
+
: local.execute(name, args, context);
|
|
134
142
|
|
|
135
143
|
const { provider, config } = ctx.resolveProvider({
|
|
136
144
|
...ctx.providerSettings(providerId),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { buildMcpRegistry, mcpTransport } from '../lib/core.js';
|
|
2
|
-
import { sendJson } from '../lib/http.js';
|
|
2
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
|
|
4
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
|
|
3
5
|
|
|
4
6
|
function safeTransport(server) {
|
|
5
7
|
try {
|
|
@@ -9,17 +11,49 @@ function safeTransport(server) {
|
|
|
9
11
|
}
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
function listServers(ctx) {
|
|
15
|
+
return ctx.mergedMcpServers().map((server) => ({
|
|
16
|
+
id: server.id,
|
|
17
|
+
name: server.name ?? server.id,
|
|
18
|
+
transport: safeTransport(server),
|
|
19
|
+
enabled: server.enabled !== false,
|
|
20
|
+
source: server.source ?? 'config',
|
|
21
|
+
command: server.command ?? null,
|
|
22
|
+
args: Array.isArray(server.args) ? server.args : [],
|
|
23
|
+
url: server.url ?? null,
|
|
24
|
+
env: server.env ?? null,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseServer(body, id) {
|
|
29
|
+
const transport = body.transport === 'http' ? 'http' : 'stdio';
|
|
30
|
+
const command = String(body.command ?? '').trim();
|
|
31
|
+
const url = String(body.url ?? '').trim();
|
|
32
|
+
if (transport === 'stdio' && !command) throw new Error('Un servidor stdio necesita "command".');
|
|
33
|
+
if (transport === 'http' && !url) throw new Error('Un servidor http necesita "url".');
|
|
34
|
+
const args = Array.isArray(body.args)
|
|
35
|
+
? body.args.map((a) => String(a))
|
|
36
|
+
: String(body.args ?? '')
|
|
37
|
+
.split(/\s+/)
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
name: String(body.name ?? id).trim() || id,
|
|
42
|
+
transport,
|
|
43
|
+
command: transport === 'stdio' ? command : null,
|
|
44
|
+
url: transport === 'http' ? url : null,
|
|
45
|
+
enabled: body.enabled !== false,
|
|
46
|
+
config: { args, env: body.env ?? null },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
12
50
|
export function registerMcp(route, ctx) {
|
|
13
51
|
route('GET', '/api/mcp', async ({ res, query }) => {
|
|
14
|
-
const servers = (ctx
|
|
15
|
-
id: server.id,
|
|
16
|
-
name: server.name,
|
|
17
|
-
transport: safeTransport(server),
|
|
18
|
-
enabled: server.enabled !== false,
|
|
19
|
-
}));
|
|
52
|
+
const servers = listServers(ctx);
|
|
20
53
|
|
|
21
54
|
if (query.get('connect')) {
|
|
22
|
-
const
|
|
55
|
+
const connectable = ctx.mergedMcpServers().filter((s) => s.enabled !== false);
|
|
56
|
+
const registry = await buildMcpRegistry(connectable);
|
|
23
57
|
try {
|
|
24
58
|
for (const server of servers) {
|
|
25
59
|
const session = registry.sessions.find((s) => s.server.id === server.id);
|
|
@@ -47,4 +81,33 @@ export function registerMcp(route, ctx) {
|
|
|
47
81
|
|
|
48
82
|
sendJson(res, 200, { servers });
|
|
49
83
|
});
|
|
84
|
+
|
|
85
|
+
route('PUT', '/api/mcp/servers/:id', async ({ req, res, params }) => {
|
|
86
|
+
const id = String(params.id);
|
|
87
|
+
if (!ID_PATTERN.test(id)) return sendError(res, 400, 'Id de servidor inválido.');
|
|
88
|
+
const body = await readJson(req);
|
|
89
|
+
let record;
|
|
90
|
+
try {
|
|
91
|
+
record = parseServer(body, id);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return sendError(res, 400, error.message);
|
|
94
|
+
}
|
|
95
|
+
ctx.store.saveMcpServer(record);
|
|
96
|
+
await ctx.reloadMcp();
|
|
97
|
+
sendJson(res, 200, { servers: listServers(ctx) });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
route('DELETE', '/api/mcp/servers/:id', async ({ res, params }) => {
|
|
101
|
+
const id = String(params.id);
|
|
102
|
+
const stored = ctx.store.listMcpServers().some((s) => s.id === id);
|
|
103
|
+
if (!stored) return sendError(res, 404, 'Ese servidor está definido en tamnora.json y no se borra desde acá.');
|
|
104
|
+
ctx.store.deleteMcpServer(id);
|
|
105
|
+
await ctx.reloadMcp();
|
|
106
|
+
sendJson(res, 200, { servers: listServers(ctx) });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
route('POST', '/api/mcp/reload', async ({ res }) => {
|
|
110
|
+
await ctx.reloadMcp();
|
|
111
|
+
sendJson(res, 200, { ok: true, servers: listServers(ctx) });
|
|
112
|
+
});
|
|
50
113
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { startTunnel } from '../lib/core.js';
|
|
2
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Preview de un dev server para acceso remoto: abre un tunel aparte al puerto
|
|
6
|
+
* del proceso (asi el celular puede embeber la app aunque este detras del
|
|
7
|
+
* tunel del harness). Devuelve `{ close }` para liberar el tunel al apagar.
|
|
8
|
+
*/
|
|
9
|
+
export function registerPreview(route, ctx) {
|
|
10
|
+
let tunnel = null;
|
|
11
|
+
|
|
12
|
+
route('GET', '/api/preview', ({ res }) => {
|
|
13
|
+
sendJson(res, 200, { tunnel: tunnel ? { url: tunnel.url, port: tunnel.port } : null });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
route('POST', '/api/preview/tunnel', async ({ req, res }) => {
|
|
17
|
+
const body = await readJson(req);
|
|
18
|
+
const port = Number(body.port);
|
|
19
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return sendError(res, 400, 'Puerto inválido.');
|
|
20
|
+
if (tunnel) {
|
|
21
|
+
try {
|
|
22
|
+
tunnel.stop?.();
|
|
23
|
+
} catch {
|
|
24
|
+
// ignore
|
|
25
|
+
}
|
|
26
|
+
tunnel = null;
|
|
27
|
+
}
|
|
28
|
+
const provider = ctx.config.preview?.provider || 'tunnelmole';
|
|
29
|
+
try {
|
|
30
|
+
const started = await startTunnel(port, provider);
|
|
31
|
+
tunnel = { url: started.url, port, stop: started.stop };
|
|
32
|
+
sendJson(res, 200, { tunnel: { url: tunnel.url, port } });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
sendError(res, 502, `No se pudo abrir el túnel: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
route('DELETE', '/api/preview/tunnel', ({ res }) => {
|
|
39
|
+
try {
|
|
40
|
+
tunnel?.stop?.();
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
tunnel = null;
|
|
45
|
+
sendJson(res, 200, { ok: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
close() {
|
|
50
|
+
try {
|
|
51
|
+
tunnel?.stop?.();
|
|
52
|
+
} catch {
|
|
53
|
+
// ignore
|
|
54
|
+
}
|
|
55
|
+
tunnel = null;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { detectProcs } from '../lib/core.js';
|
|
2
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
import { resolveExistingDir } from '../lib/workspace.js';
|
|
4
|
+
|
|
5
|
+
export function registerProcs(route, ctx) {
|
|
6
|
+
route('GET', '/api/procs', ({ res }) => {
|
|
7
|
+
sendJson(res, 200, { enabled: ctx.procsEnabled, allow: ctx.procs.allowed, procs: ctx.procs.list() });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
route('GET', '/api/procs/detect', ({ res, query }) => {
|
|
11
|
+
let dir;
|
|
12
|
+
try {
|
|
13
|
+
dir = resolveExistingDir(query.get('cwd') || ctx.cwd);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return sendError(res, 400, error.message);
|
|
16
|
+
}
|
|
17
|
+
sendJson(res, 200, { cwd: dir, chips: detectProcs(dir) });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
route('POST', '/api/procs/start', async ({ req, res }) => {
|
|
21
|
+
if (!ctx.procsEnabled) return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
|
|
22
|
+
const body = await readJson(req);
|
|
23
|
+
let dir;
|
|
24
|
+
try {
|
|
25
|
+
dir = resolveExistingDir(body.cwd || ctx.cwd);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return sendError(res, 400, error.message);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const proc = ctx.procs.start({ cwd: dir, command: body.command, args: body.args, port: body.port });
|
|
31
|
+
sendJson(res, 201, { proc });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
sendError(res, 400, error.message);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
route('GET', '/api/procs/:id/logs', ({ res, params, query }) => {
|
|
38
|
+
const data = ctx.procs.logs(params.id, Number(query.get('since')) || 0);
|
|
39
|
+
if (!data) return sendError(res, 404, 'Proceso no encontrado.');
|
|
40
|
+
sendJson(res, 200, data);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
route('POST', '/api/procs/:id/stop', ({ res, params }) => {
|
|
44
|
+
if (!ctx.procs.stop(params.id)) return sendError(res, 404, 'Proceso no encontrado.');
|
|
45
|
+
sendJson(res, 200, { ok: true });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -154,4 +154,14 @@ export function registerSessions(route, ctx) {
|
|
|
154
154
|
ctx.store.deleteSession(params.id);
|
|
155
155
|
sendJson(res, 200, { ok: true });
|
|
156
156
|
});
|
|
157
|
+
|
|
158
|
+
route('GET', '/api/projects', ({ res }) => {
|
|
159
|
+
sendJson(res, 200, { projects: ctx.store.listProjects() });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
route('DELETE', '/api/projects', ({ res, query }) => {
|
|
163
|
+
if (!query.has('cwd')) return sendError(res, 400, 'Falta "cwd".');
|
|
164
|
+
const removed = ctx.store.deleteSessionsByCwd(query.get('cwd'));
|
|
165
|
+
sendJson(res, 200, { ok: true, ...removed });
|
|
166
|
+
});
|
|
157
167
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
2
|
+
import {
|
|
3
|
+
SKILL_ID_PATTERN,
|
|
4
|
+
skillsView,
|
|
5
|
+
findSkill,
|
|
6
|
+
writeSkill,
|
|
7
|
+
removeSkill,
|
|
8
|
+
setSkillEnabled,
|
|
9
|
+
} from '../lib/skills.js';
|
|
10
|
+
|
|
11
|
+
export function registerSkills(route, ctx) {
|
|
12
|
+
route('GET', '/api/skills', ({ res }) => {
|
|
13
|
+
sendJson(res, 200, { skills: skillsView(ctx) });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
route('GET', '/api/skills/:id', ({ res, params }) => {
|
|
17
|
+
const skill = findSkill(ctx, params.id);
|
|
18
|
+
if (!skill) return sendError(res, 404, 'Skill no encontrada.');
|
|
19
|
+
sendJson(res, 200, {
|
|
20
|
+
skill: {
|
|
21
|
+
id: skill.id,
|
|
22
|
+
name: skill.name || skill.id,
|
|
23
|
+
description: skill.description || '',
|
|
24
|
+
when: skill.when || '',
|
|
25
|
+
body: skill.body,
|
|
26
|
+
removable: Boolean(skill.home),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
route('PUT', '/api/skills/:id', async ({ req, res, params }) => {
|
|
32
|
+
const id = String(params.id);
|
|
33
|
+
if (!SKILL_ID_PATTERN.test(id)) return sendError(res, 400, 'Id de skill inválido (a-z, 0-9, - y _).');
|
|
34
|
+
const body = await readJson(req);
|
|
35
|
+
if (!String(body.body ?? '').trim()) return sendError(res, 400, 'La skill necesita un cuerpo.');
|
|
36
|
+
await writeSkill(id, {
|
|
37
|
+
name: body.name,
|
|
38
|
+
description: body.description,
|
|
39
|
+
when: body.when,
|
|
40
|
+
body: body.body,
|
|
41
|
+
});
|
|
42
|
+
sendJson(res, 200, { skills: skillsView(ctx) });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
route('DELETE', '/api/skills/:id', async ({ res, params }) => {
|
|
46
|
+
const skill = findSkill(ctx, params.id);
|
|
47
|
+
if (!skill) return sendError(res, 404, 'Skill no encontrada.');
|
|
48
|
+
if (!skill.home) return sendError(res, 400, 'Esa skill vive en el workspace y no se borra desde acá.');
|
|
49
|
+
await removeSkill(skill.id);
|
|
50
|
+
setSkillEnabled(ctx.store, skill.id, true);
|
|
51
|
+
sendJson(res, 200, { skills: skillsView(ctx) });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
route('POST', '/api/skills/:id/toggle', async ({ res, params }) => {
|
|
55
|
+
const skill = findSkill(ctx, params.id);
|
|
56
|
+
if (!skill) return sendError(res, 404, 'Skill no encontrada.');
|
|
57
|
+
const enabled = !skillsView(ctx).find((s) => s.id === skill.id)?.enabled;
|
|
58
|
+
setSkillEnabled(ctx.store, skill.id, enabled);
|
|
59
|
+
sendJson(res, 200, { id: skill.id, enabled, skills: skillsView(ctx) });
|
|
60
|
+
});
|
|
61
|
+
}
|