@danieltmn/tamnora 0.7.1 → 0.9.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 +95 -0
- package/README.md +9 -0
- package/SECURITY.md +11 -6
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/run.js +1 -1
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/commands/tui.js +1 -1
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +3 -0
- package/harness/packages/core/src/git/index.js +177 -0
- package/harness/packages/core/src/index.js +16 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/procs/kill.js +95 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/procs.test.js +50 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4715 -3669
- package/harness/packages/server/public/index.html +94 -5
- package/harness/packages/server/public/landing/index.html +5 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +196 -23
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +51 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +35 -0
- package/harness/packages/server/src/routes/procs.js +25 -15
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +19 -0
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +25 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +617 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- package/package.json +1 -1
|
@@ -4,12 +4,19 @@ import {
|
|
|
4
4
|
createTools,
|
|
5
5
|
resolveMode,
|
|
6
6
|
MAX_TOOL_ROUNDS,
|
|
7
|
-
summarizeToolCall,
|
|
8
|
-
previewToolResult,
|
|
9
7
|
} from '../lib/core.js';
|
|
10
8
|
import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
|
|
11
9
|
import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
|
|
12
10
|
import { createSubagentTool } from '../lib/tasks.js';
|
|
11
|
+
import {
|
|
12
|
+
combineToolset,
|
|
13
|
+
composeAgentMessages,
|
|
14
|
+
buildUserMessage,
|
|
15
|
+
resolveProviderFor,
|
|
16
|
+
toolCallPayload,
|
|
17
|
+
toolResultPayload,
|
|
18
|
+
} from '../lib/agentRun.js';
|
|
19
|
+
import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/announcer.js';
|
|
13
20
|
|
|
14
21
|
/**
|
|
15
22
|
* Emit streaming partials at most once per `ms` window, keeping only the
|
|
@@ -57,6 +64,13 @@ export function registerChat(route, ctx) {
|
|
|
57
64
|
sendJson(res, 200, { ok: true, allow, scope });
|
|
58
65
|
});
|
|
59
66
|
|
|
67
|
+
// Lo pendiente, para que un cliente sin stream abierto (el celular mirando
|
|
68
|
+
// otra sesion) sepa igual que hay un permiso esperando.
|
|
69
|
+
route('GET', '/api/permissions', ({ res, query }) => {
|
|
70
|
+
const sessionId = query.get('sessionId') ?? undefined;
|
|
71
|
+
sendJson(res, 200, { pending: ctx.approvals.list({ sessionId }) });
|
|
72
|
+
});
|
|
73
|
+
|
|
60
74
|
route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
|
|
61
75
|
if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
|
|
62
76
|
ctx.runs.cancel(params.id);
|
|
@@ -146,7 +160,14 @@ export function registerChat(route, ctx) {
|
|
|
146
160
|
const autoApprove = body.yes ?? ctx.yes;
|
|
147
161
|
const asked = new Set();
|
|
148
162
|
|
|
149
|
-
ctx.runs.emit(sessionId, 'start', {
|
|
163
|
+
ctx.runs.emit(sessionId, 'start', {
|
|
164
|
+
sessionId,
|
|
165
|
+
provider: providerId,
|
|
166
|
+
model,
|
|
167
|
+
mode: mode.id,
|
|
168
|
+
cwd: sessionCwd,
|
|
169
|
+
say: fraseInicio(),
|
|
170
|
+
});
|
|
150
171
|
ctx.runs.emit(sessionId, 'user', { content: input });
|
|
151
172
|
|
|
152
173
|
let mcp = null;
|
|
@@ -165,7 +186,7 @@ export function registerChat(route, ctx) {
|
|
|
165
186
|
asked.add(askId);
|
|
166
187
|
askMeta.set(askId, { sessionId });
|
|
167
188
|
ctx.runs.emit(sessionId, 'permission', { askId, sessionId, name, args });
|
|
168
|
-
const allow = await ctx.approvals.ask(askId);
|
|
189
|
+
const allow = await ctx.approvals.ask(askId, { sessionId, name, args });
|
|
169
190
|
asked.delete(askId);
|
|
170
191
|
askMeta.delete(askId);
|
|
171
192
|
ctx.runs.emit(sessionId, 'permission_resolved', { askId, allow });
|
|
@@ -194,45 +215,24 @@ export function registerChat(route, ctx) {
|
|
|
194
215
|
exclude: admin.definitions.map((d) => d.function.name),
|
|
195
216
|
emit: emitAgent,
|
|
196
217
|
});
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
...tasks.definitions,
|
|
200
|
-
];
|
|
201
|
-
const execute = (name, args, context) => {
|
|
202
|
-
if (tasks.handles(name)) return tasks.execute(name, args);
|
|
203
|
-
if (exposeAdmin && admin.handles(name)) {
|
|
204
|
-
return admin.execute(name, args, { approve: autoApprove ? async () => true : onAsk });
|
|
205
|
-
}
|
|
206
|
-
return local.execute(name, args, context);
|
|
207
|
-
};
|
|
218
|
+
const approve = autoApprove ? async () => true : (name, args) => onAsk(name, args);
|
|
219
|
+
const { definitions, execute } = combineToolset({ local, admin, tasks, exposeAdmin, approve });
|
|
208
220
|
|
|
209
|
-
const { provider, config } = ctx
|
|
210
|
-
...ctx.providerSettings(providerId),
|
|
211
|
-
apiKey: ctx.apiKeyFor(providerId),
|
|
221
|
+
const { provider, config } = resolveProviderFor(ctx, providerId, {
|
|
212
222
|
baseUrl: body.baseUrl || ctx.providerSettings(providerId).baseUrl,
|
|
213
223
|
});
|
|
214
224
|
|
|
215
|
-
const userMessage = images
|
|
216
|
-
? {
|
|
217
|
-
role: 'user',
|
|
218
|
-
content: [
|
|
219
|
-
{ type: 'text', text: input },
|
|
220
|
-
...images.map((url) => ({ type: 'image_url', image_url: { url } })),
|
|
221
|
-
],
|
|
222
|
-
}
|
|
223
|
-
: { role: 'user', content: input };
|
|
225
|
+
const userMessage = buildUserMessage(input, images);
|
|
224
226
|
|
|
225
227
|
const sessionRecord = ctx.store.getSession(sessionId);
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
|
|
232
|
-
...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
|
|
233
|
-
...history,
|
|
228
|
+
const messages = await composeAgentMessages({
|
|
229
|
+
ctx,
|
|
230
|
+
cwd: sessionCwd,
|
|
231
|
+
mode,
|
|
232
|
+
history,
|
|
234
233
|
userMessage,
|
|
235
|
-
|
|
234
|
+
sessionRecord,
|
|
235
|
+
});
|
|
236
236
|
// Persistimos el mensaje del usuario ya: asi sobrevive a un corte y la
|
|
237
237
|
// reconexion puede reconstruir el turno desde el historial.
|
|
238
238
|
ctx.store.addMessage(sessionId, userMessage);
|
|
@@ -240,6 +240,7 @@ export function registerChat(route, ctx) {
|
|
|
240
240
|
|
|
241
241
|
(async () => {
|
|
242
242
|
const startedAt = Date.now();
|
|
243
|
+
const filtroTools = createFiltroTools();
|
|
243
244
|
const partials = createPartialThrottle(300, (name, payload) => ctx.runs.emit(sessionId, name, payload));
|
|
244
245
|
try {
|
|
245
246
|
const result = await runAgent({
|
|
@@ -264,6 +265,9 @@ export function registerChat(route, ctx) {
|
|
|
264
265
|
} else if (event.type === 'retry') {
|
|
265
266
|
partials.flush();
|
|
266
267
|
ctx.runs.emit(sessionId, 'retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
|
|
268
|
+
} else if (event.type === 'usage') {
|
|
269
|
+
partials.flush();
|
|
270
|
+
ctx.runs.emit(sessionId, 'usage', { tokens: event.usage });
|
|
267
271
|
} else if (event.type === 'trim') {
|
|
268
272
|
partials.flush();
|
|
269
273
|
ctx.runs.emit(sessionId, 'trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
|
|
@@ -272,17 +276,15 @@ export function registerChat(route, ctx) {
|
|
|
272
276
|
ctx.runs.emit(sessionId, 'tools', { toolCalls: event.toolCalls });
|
|
273
277
|
} else if (event.type === 'tool_call') {
|
|
274
278
|
partials.flush();
|
|
275
|
-
|
|
279
|
+
const payload = toolCallPayload(event);
|
|
280
|
+
ctx.runs.emit(sessionId, 'tool_call', { ...payload, say: filtroTools(event.name, payload.summary) });
|
|
281
|
+
} else if (event.type === 'tool_result') {
|
|
282
|
+
partials.flush();
|
|
283
|
+
ctx.runs.emit(sessionId, 'tool_result', {
|
|
276
284
|
id: event.id,
|
|
277
285
|
name: event.name,
|
|
278
|
-
|
|
279
|
-
summary: summarizeToolCall(event.name, event.args),
|
|
286
|
+
...toolResultPayload(event),
|
|
280
287
|
});
|
|
281
|
-
} else if (event.type === 'tool_result') {
|
|
282
|
-
partials.flush();
|
|
283
|
-
const { preview, truncated } = previewToolResult(event.name, event.result);
|
|
284
|
-
const failed = /^Error|^Permiso denegado/i.test(String(event.result));
|
|
285
|
-
ctx.runs.emit(sessionId, 'tool_result', { id: event.id, name: event.name, preview, truncated, failed });
|
|
286
288
|
} else if (event.type === 'repeat') {
|
|
287
289
|
partials.flush();
|
|
288
290
|
ctx.runs.emit(sessionId, 'repeat', { name: event.name, count: event.count });
|
|
@@ -301,7 +303,8 @@ export function registerChat(route, ctx) {
|
|
|
301
303
|
ctx.store.touchSession(sessionId, { provider: providerId, model, title });
|
|
302
304
|
const toolCalls = newMessages.filter((message) => message.role === 'tool').length;
|
|
303
305
|
const durationMs = Date.now() - startedAt;
|
|
304
|
-
if (result.stopReason === 'aborted')
|
|
306
|
+
if (result.stopReason === 'aborted')
|
|
307
|
+
ctx.runs.emit(sessionId, 'aborted', { durationMs, toolCalls, say: fraseFin({ stopReason: 'aborted' }) });
|
|
305
308
|
else
|
|
306
309
|
ctx.runs.emit(sessionId, 'done', {
|
|
307
310
|
sessionId,
|
|
@@ -313,11 +316,12 @@ export function registerChat(route, ctx) {
|
|
|
313
316
|
tokens: result.usage ?? null,
|
|
314
317
|
toolCalls,
|
|
315
318
|
durationMs,
|
|
319
|
+
say: fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }),
|
|
316
320
|
});
|
|
317
321
|
} catch (error) {
|
|
318
322
|
partials.flush();
|
|
319
|
-
if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', {});
|
|
320
|
-
else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error) });
|
|
323
|
+
if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', { say: fraseFin({ stopReason: 'aborted' }) });
|
|
324
|
+
else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error), say: fraseError() });
|
|
321
325
|
} finally {
|
|
322
326
|
unsubscribe();
|
|
323
327
|
sse.close();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { openSse } from '../lib/http.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canal global de eventos: un SSE por pestaña de la consola.
|
|
5
|
+
*
|
|
6
|
+
* Sirve para que varias ventanas (o la PC y el celular a la vez) se enteren de
|
|
7
|
+
* que un run arranco en una sesion, sin tener cada una su propio stream por
|
|
8
|
+
* sesion abierto.
|
|
9
|
+
*
|
|
10
|
+
* Solo van eventos de ciclo de vida y **los permisos**. El detalle del turno
|
|
11
|
+
* sigue yendo por `/api/sessions/:id/stream`: filtrar es obligatorio, porque
|
|
12
|
+
* `subscribeAll` ve cada parcial de texto de todos los runs y reenviarlos seria
|
|
13
|
+
* duplicar el stream por sesion.
|
|
14
|
+
*
|
|
15
|
+
* Los permisos van aparte justamente porque son lo unico que, si solo viviera
|
|
16
|
+
* en el stream, un cliente no suscrito a esa sesion no tendria forma de saber
|
|
17
|
+
* que esta esperando una decision suya.
|
|
18
|
+
*
|
|
19
|
+
* Al conectar se manda `snapshot` con las sesiones que ya estan corriendo y con
|
|
20
|
+
* los permisos pendientes: es lo que hace el canal auto-sanador. Si el stream se
|
|
21
|
+
* corta y vuelve, el snapshot le dice al cliente que se quedo a medio camino.
|
|
22
|
+
*/
|
|
23
|
+
const GLOBAL_EVENTS = new Set(['run_start', 'run_end', 'permission', 'permission_resolved']);
|
|
24
|
+
|
|
25
|
+
export function registerEvents(route, ctx) {
|
|
26
|
+
route('GET', '/api/events', ({ req, res }) => {
|
|
27
|
+
const sse = openSse(req, res);
|
|
28
|
+
const unsubscribe = ctx.runs.subscribeAll((sessionId, event) => {
|
|
29
|
+
if (!GLOBAL_EVENTS.has(event.type)) return;
|
|
30
|
+
sse.event(event.type, event.data);
|
|
31
|
+
});
|
|
32
|
+
sse.event('snapshot', { active: ctx.runs.activeSessions(), permissions: ctx.approvals.list() });
|
|
33
|
+
req.on('close', unsubscribe);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -3,28 +3,20 @@ import fsp from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
5
5
|
import { staticServerChip } from '../lib/core.js';
|
|
6
|
-
import {
|
|
6
|
+
import { isInside, localOk, requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
7
7
|
|
|
8
8
|
const MAX_PREVIEW = 200 * 1024;
|
|
9
9
|
const IGNORED = new Set(['node_modules', '.git', '.tamnora', '.playwright-mcp', 'dist', '.cache']);
|
|
10
10
|
|
|
11
|
-
function isInside(root, target) {
|
|
12
|
-
const rel = path.relative(root, target);
|
|
13
|
-
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
14
|
-
}
|
|
15
|
-
|
|
16
11
|
/** Resolve `?root` + `?path` into a confined filesystem target, or send an error. */
|
|
17
|
-
function resolveTarget(req, res, query, fallback) {
|
|
18
|
-
if (!isLoopback(req.socket.localAddress)) {
|
|
19
|
-
sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
12
|
+
function resolveTarget(req, res, query, fallback, { allowAnyRoot }) {
|
|
22
13
|
let root;
|
|
23
|
-
|
|
24
|
-
root =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
if (allowAnyRoot) {
|
|
15
|
+
root = resolveDirOr400(res, query.get('root'), fallback);
|
|
16
|
+
if (!root) return null;
|
|
17
|
+
} else {
|
|
18
|
+
// Sin conexion local de verdad: root fijo al workspace, se ignora ?root.
|
|
19
|
+
root = fallback;
|
|
28
20
|
}
|
|
29
21
|
const rel = String(query.get('path') ?? '').replace(/^[/\\]+/, '');
|
|
30
22
|
const target = path.resolve(root, rel);
|
|
@@ -39,7 +31,11 @@ const toPosix = (value) => value.split(path.sep).join('/');
|
|
|
39
31
|
|
|
40
32
|
export function registerFiles(route, ctx) {
|
|
41
33
|
route('GET', '/api/files', ({ req, res, query }) => {
|
|
42
|
-
const
|
|
34
|
+
const local = localOk(req);
|
|
35
|
+
if (!local && !ctx.authEnabled) {
|
|
36
|
+
return sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
|
|
37
|
+
}
|
|
38
|
+
const resolved = resolveTarget(req, res, query, ctx.cwd, { allowAnyRoot: local });
|
|
43
39
|
if (!resolved) return;
|
|
44
40
|
|
|
45
41
|
let stat;
|
|
@@ -87,7 +83,11 @@ export function registerFiles(route, ctx) {
|
|
|
87
83
|
});
|
|
88
84
|
|
|
89
85
|
route('GET', '/api/files/content', async ({ req, res, query }) => {
|
|
90
|
-
const
|
|
86
|
+
const local = localOk(req);
|
|
87
|
+
if (!local && !ctx.authEnabled) {
|
|
88
|
+
return sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
|
|
89
|
+
}
|
|
90
|
+
const resolved = resolveTarget(req, res, query, ctx.cwd, { allowAnyRoot: local });
|
|
91
91
|
if (!resolved) return;
|
|
92
92
|
|
|
93
93
|
let stat;
|
|
@@ -117,19 +117,15 @@ export function registerFiles(route, ctx) {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
route('POST', '/api/files/preview', async ({ req, res }) => {
|
|
120
|
-
if (!
|
|
121
|
-
return
|
|
120
|
+
if (!requireLocal(req, res, 'La vista previa solo está disponible en conexiones locales.')) {
|
|
121
|
+
return;
|
|
122
122
|
}
|
|
123
123
|
if (!ctx.procsEnabled) {
|
|
124
124
|
return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
|
|
125
125
|
}
|
|
126
126
|
const body = await readJson(req);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
root = resolveExistingDir(body.root || ctx.cwd);
|
|
130
|
-
} catch (error) {
|
|
131
|
-
return sendError(res, 400, error.message);
|
|
132
|
-
}
|
|
127
|
+
const root = resolveDirOr400(res, body.root, ctx.cwd);
|
|
128
|
+
if (!root) return;
|
|
133
129
|
const rel = String(body.path ?? '').replace(/^[/\\]+/, '');
|
|
134
130
|
const target = path.resolve(root, rel);
|
|
135
131
|
if (!isInside(root, target)) return sendError(res, 400, 'La ruta queda fuera del workspace.');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { mkdir } from 'node:fs/promises';
|
|
3
3
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
4
|
-
import {
|
|
4
|
+
import { requireLocal, rootDirs, listSubdirs, resolveDirOr400 } from '../lib/workspace.js';
|
|
5
5
|
|
|
6
6
|
const INVALID_NAME = /[\\/:*?"<>|]/;
|
|
7
7
|
|
|
@@ -12,8 +12,8 @@ function validFolderName(name) {
|
|
|
12
12
|
|
|
13
13
|
export function registerFolders(route) {
|
|
14
14
|
route('GET', '/api/folders', ({ req, res, query }) => {
|
|
15
|
-
if (!
|
|
16
|
-
return
|
|
15
|
+
if (!requireLocal(req, res, 'El navegador de carpetas solo está disponible en conexiones locales.')) {
|
|
16
|
+
return;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const raw = String(query.get('path') ?? '').trim();
|
|
@@ -26,12 +26,8 @@ export function registerFolders(route) {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
dir = resolveExistingDir(raw);
|
|
32
|
-
} catch (error) {
|
|
33
|
-
return sendError(res, 400, error.message);
|
|
34
|
-
}
|
|
29
|
+
const dir = resolveDirOr400(res, raw);
|
|
30
|
+
if (!dir) return;
|
|
35
31
|
|
|
36
32
|
let dirs;
|
|
37
33
|
try {
|
|
@@ -49,8 +45,8 @@ export function registerFolders(route) {
|
|
|
49
45
|
});
|
|
50
46
|
|
|
51
47
|
route('POST', '/api/folders', async ({ req, res }) => {
|
|
52
|
-
if (!
|
|
53
|
-
return
|
|
48
|
+
if (!requireLocal(req, res, 'Crear carpetas solo está disponible en conexiones locales.')) {
|
|
49
|
+
return;
|
|
54
50
|
}
|
|
55
51
|
const body = await readJson(req);
|
|
56
52
|
const name = String(body.name ?? '').trim();
|
|
@@ -61,12 +57,8 @@ export function registerFolders(route) {
|
|
|
61
57
|
if (!String(body.parent ?? '').trim()) {
|
|
62
58
|
return sendError(res, 400, 'Elegí la carpeta contenedora primero.');
|
|
63
59
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
parent = resolveExistingDir(body.parent);
|
|
67
|
-
} catch (error) {
|
|
68
|
-
return sendError(res, 400, error.message);
|
|
69
|
-
}
|
|
60
|
+
const parent = resolveDirOr400(res, body.parent);
|
|
61
|
+
if (!parent) return;
|
|
70
62
|
|
|
71
63
|
const target = path.join(parent, name);
|
|
72
64
|
try {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { gitStatus, gitDiff } from '../lib/core.js';
|
|
3
|
+
import { sendJson, sendError } from '../lib/http.js';
|
|
4
|
+
import { isInside, localOk, requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
5
|
+
|
|
6
|
+
export function registerGit(route, ctx) {
|
|
7
|
+
const resolveRoot = (req, res, raw) => {
|
|
8
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'El estado de git solo está disponible en conexiones locales.')) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
// Con auth (sesion ya validada) se permite por el tunel, pero el cwd
|
|
12
|
+
// pedido queda confinado al workspace salvo conexion local de verdad.
|
|
13
|
+
const root = resolveDirOr400(res, localOk(req) ? raw : null, ctx.cwd);
|
|
14
|
+
return root;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
route('GET', '/api/git/status', async ({ req, res, query }) => {
|
|
18
|
+
const root = resolveRoot(req, res, query.get('cwd'));
|
|
19
|
+
if (!root) return;
|
|
20
|
+
const status = await gitStatus(root);
|
|
21
|
+
sendJson(res, 200, { root, ...status });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
route('GET', '/api/git/diff', async ({ req, res, query }) => {
|
|
25
|
+
const root = resolveRoot(req, res, query.get('cwd'));
|
|
26
|
+
if (!root) return;
|
|
27
|
+
const rel = String(query.get('path') ?? '').replace(/^[/\\]+/, '');
|
|
28
|
+
if (rel && !isInside(root, path.resolve(root, rel))) {
|
|
29
|
+
return sendError(res, 400, 'La ruta queda fuera del workspace.');
|
|
30
|
+
}
|
|
31
|
+
const result = await gitDiff(root, { path: rel, staged: query.get('staged') === '1' });
|
|
32
|
+
if (!result.ok) return sendError(res, 400, result.error ?? 'No se pudo obtener el diff.');
|
|
33
|
+
sendJson(res, 200, { root, path: rel || null, ...result });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { detectProcs } from '../lib/core.js';
|
|
1
|
+
import { detectProcs, killPort } from '../lib/core.js';
|
|
2
2
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
-
import {
|
|
3
|
+
import { requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
4
4
|
|
|
5
5
|
export function registerProcs(route, ctx) {
|
|
6
6
|
route('GET', '/api/procs', ({ res }) => {
|
|
@@ -8,24 +8,19 @@ export function registerProcs(route, ctx) {
|
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
route('GET', '/api/procs/detect', ({ res, query }) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
dir = resolveExistingDir(query.get('cwd') || ctx.cwd);
|
|
14
|
-
} catch (error) {
|
|
15
|
-
return sendError(res, 400, error.message);
|
|
16
|
-
}
|
|
11
|
+
const dir = resolveDirOr400(res, query.get('cwd'), ctx.cwd);
|
|
12
|
+
if (!dir) return;
|
|
17
13
|
sendJson(res, 200, { cwd: dir, chips: detectProcs(dir) });
|
|
18
14
|
});
|
|
19
15
|
|
|
20
16
|
route('POST', '/api/procs/start', async ({ req, res }) => {
|
|
17
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'Iniciar procesos solo está disponible en conexiones locales.')) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
21
20
|
if (!ctx.procsEnabled) return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
|
|
22
21
|
const body = await readJson(req);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
dir = resolveExistingDir(body.cwd || ctx.cwd);
|
|
26
|
-
} catch (error) {
|
|
27
|
-
return sendError(res, 400, error.message);
|
|
28
|
-
}
|
|
22
|
+
const dir = resolveDirOr400(res, body.cwd, ctx.cwd);
|
|
23
|
+
if (!dir) return;
|
|
29
24
|
try {
|
|
30
25
|
const proc = ctx.procs.start({ cwd: dir, command: body.command, args: body.args, port: body.port });
|
|
31
26
|
sendJson(res, 201, { proc });
|
|
@@ -40,8 +35,23 @@ export function registerProcs(route, ctx) {
|
|
|
40
35
|
sendJson(res, 200, data);
|
|
41
36
|
});
|
|
42
37
|
|
|
43
|
-
route('POST', '/api/procs/:id/stop', ({ res, params }) => {
|
|
38
|
+
route('POST', '/api/procs/:id/stop', ({ req, res, params }) => {
|
|
39
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'Detener procesos solo está disponible en conexiones locales.')) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
44
42
|
if (!ctx.procs.stop(params.id)) return sendError(res, 404, 'Proceso no encontrado.');
|
|
45
43
|
sendJson(res, 200, { ok: true });
|
|
46
44
|
});
|
|
45
|
+
|
|
46
|
+
route('POST', '/api/procs/kill', async ({ req, res }) => {
|
|
47
|
+
if (!requireLocal(req, res, 'Matar procesos solo está disponible en conexiones locales.')) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const body = await readJson(req);
|
|
51
|
+
try {
|
|
52
|
+
sendJson(res, 200, await killPort(body.port));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
sendError(res, 400, error.message);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
47
57
|
}
|
|
@@ -1,27 +1,16 @@
|
|
|
1
1
|
import { runAgent } from '../lib/core.js';
|
|
2
2
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
3
|
import { workspaceForRequest } from '../lib/workspace.js';
|
|
4
|
-
|
|
5
|
-
function view(session) {
|
|
6
|
-
return {
|
|
7
|
-
id: session.id,
|
|
8
|
-
title: session.title,
|
|
9
|
-
provider: session.provider,
|
|
10
|
-
model: session.model,
|
|
11
|
-
cwd: session.cwd,
|
|
12
|
-
goal: session.meta?.goal ?? null,
|
|
13
|
-
hasSummary: Boolean(session.meta?.summary),
|
|
14
|
-
createdAt: session.created_at,
|
|
15
|
-
updatedAt: session.updated_at,
|
|
16
|
-
};
|
|
17
|
-
}
|
|
4
|
+
import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
18
5
|
|
|
19
6
|
const COMPACT_INSTRUCTION =
|
|
20
7
|
'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
|
|
21
8
|
|
|
22
9
|
export function registerSessions(route, ctx) {
|
|
23
10
|
route('GET', '/api/sessions', ({ res }) => {
|
|
24
|
-
|
|
11
|
+
const awaiting = awaitingBySession(ctx.approvals.list());
|
|
12
|
+
const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
|
|
13
|
+
sendJson(res, 200, { sessions });
|
|
25
14
|
});
|
|
26
15
|
|
|
27
16
|
route('GET', '/api/search', ({ res, query }) => {
|
|
@@ -38,7 +27,7 @@ export function registerSessions(route, ctx) {
|
|
|
38
27
|
return text.replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
39
28
|
};
|
|
40
29
|
sendJson(res, 200, {
|
|
41
|
-
sessions: ctx.store.searchSessions(q, limit).map(
|
|
30
|
+
sessions: ctx.store.searchSessions(q, limit).map(sessionView),
|
|
42
31
|
messages: ctx.store.searchMessages(q, limit).map((m) => ({
|
|
43
32
|
id: m.id,
|
|
44
33
|
sessionId: m.sessionId,
|
|
@@ -66,17 +55,21 @@ export function registerSessions(route, ctx) {
|
|
|
66
55
|
cwd,
|
|
67
56
|
meta,
|
|
68
57
|
});
|
|
69
|
-
sendJson(res, 201, { session:
|
|
58
|
+
sendJson(res, 201, { session: sessionView(ctx.store.getSession(id)) });
|
|
70
59
|
});
|
|
71
60
|
|
|
72
61
|
route('GET', '/api/sessions/:id', ({ res, params }) => {
|
|
73
62
|
const session = ctx.store.getSession(params.id);
|
|
74
63
|
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
75
64
|
sendJson(res, 200, {
|
|
76
|
-
session:
|
|
65
|
+
session: sessionView(session),
|
|
77
66
|
tokens: ctx.store.sessionTokens(params.id),
|
|
78
67
|
messages: ctx.store.getMessages(params.id),
|
|
79
68
|
running: ctx.runs?.isActive(params.id) ?? false,
|
|
69
|
+
// Un permiso pendiente NO se reconstruye desde los mensajes (no son
|
|
70
|
+
// mensajes), asi que va aparte: sin esto, recargar la pagina dejaba el run
|
|
71
|
+
// esperando una decision que no se podia tomar.
|
|
72
|
+
permissions: ctx.approvals.list({ sessionId: params.id }),
|
|
80
73
|
});
|
|
81
74
|
});
|
|
82
75
|
|
|
@@ -104,7 +97,7 @@ export function registerSessions(route, ctx) {
|
|
|
104
97
|
if ('title' in body) patch.title = String(body.title || '').slice(0, 80) || session.title;
|
|
105
98
|
if ('goal' in body) patch.meta = { goal: body.goal ? String(body.goal) : undefined };
|
|
106
99
|
ctx.store.touchSession(params.id, patch);
|
|
107
|
-
sendJson(res, 200, { session:
|
|
100
|
+
sendJson(res, 200, { session: sessionView(ctx.store.getSession(params.id)) });
|
|
108
101
|
});
|
|
109
102
|
|
|
110
103
|
route('DELETE', '/api/sessions/:id/messages', ({ res, params }) => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createTools } from '../lib/core.js';
|
|
2
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
import { requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
4
|
+
|
|
5
|
+
export function registerShell(route, ctx) {
|
|
6
|
+
route('POST', '/api/shell', async ({ req, res }) => {
|
|
7
|
+
if (!requireLocal(req, res, 'El shell solo está disponible en conexiones locales.')) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const body = await readJson(req);
|
|
11
|
+
const command = String(body.command ?? '').trim();
|
|
12
|
+
if (!command) return sendError(res, 400, 'Falta "command".');
|
|
13
|
+
const root = resolveDirOr400(res, body.cwd, ctx.cwd);
|
|
14
|
+
if (!root) return;
|
|
15
|
+
const tools = createTools({ root, enabled: ['bash'], yes: true });
|
|
16
|
+
const output = await tools.execute('bash', { command });
|
|
17
|
+
sendJson(res, 200, { command, cwd: root, output: String(output ?? '') });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { sendJson } from '../lib/http.js';
|
|
2
|
+
import { sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Snapshot de todo lo que el cliente necesita para reconciliar, en un solo
|
|
6
|
+
* request.
|
|
7
|
+
*
|
|
8
|
+
* Este endpoint es la **verdad**: los streams SSE son un atajo de latencia que
|
|
9
|
+
* se cae en cualquier enlace movil (y que iOS corta cuando manda la pestana a
|
|
10
|
+
* background). Por eso no lleva mensajes ni tool results — solo liveness, que es
|
|
11
|
+
* chico y se puede pedir seguido. El detalle de una sesion sigue bajo demanda
|
|
12
|
+
* en `/api/sessions/:id`.
|
|
13
|
+
*/
|
|
14
|
+
export function registerState(route, ctx) {
|
|
15
|
+
route('GET', '/api/state', ({ res }) => {
|
|
16
|
+
const permissions = ctx.approvals.list();
|
|
17
|
+
const awaiting = awaitingBySession(permissions);
|
|
18
|
+
const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
|
|
19
|
+
sendJson(res, 200, {
|
|
20
|
+
sessions,
|
|
21
|
+
permissions: permissions.map(({ askId, sessionId, name, args, requestedAt }) => ({
|
|
22
|
+
askId,
|
|
23
|
+
sessionId,
|
|
24
|
+
name,
|
|
25
|
+
args,
|
|
26
|
+
requestedAt,
|
|
27
|
+
})),
|
|
28
|
+
// Para que el cliente pueda detectar que el server va en otro reloj.
|
|
29
|
+
now: Date.now(),
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|