@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +9 -0
  3. package/SECURITY.md +11 -6
  4. package/harness/packages/cli/src/cli.js +1 -1
  5. package/harness/packages/cli/src/commands/bridge.js +180 -119
  6. package/harness/packages/cli/src/commands/run.js +1 -1
  7. package/harness/packages/cli/src/commands/server.js +26 -50
  8. package/harness/packages/cli/src/commands/tui.js +1 -1
  9. package/harness/packages/cli/src/lib/daemon.js +92 -40
  10. package/harness/packages/cli/test/release.test.js +9 -0
  11. package/harness/packages/core/src/agent/modes.js +1 -1
  12. package/harness/packages/core/src/agent/runAgent.js +3 -0
  13. package/harness/packages/core/src/git/index.js +177 -0
  14. package/harness/packages/core/src/index.js +16 -1
  15. package/harness/packages/core/src/open.js +43 -0
  16. package/harness/packages/core/src/pid.js +35 -0
  17. package/harness/packages/core/src/procs/kill.js +95 -0
  18. package/harness/packages/core/src/tools/executors.js +16 -0
  19. package/harness/packages/core/src/tools/labels.js +2 -0
  20. package/harness/packages/core/src/tools/permissions.js +1 -0
  21. package/harness/packages/core/src/tools/schema.js +17 -0
  22. package/harness/packages/core/src/tts/edge.js +232 -0
  23. package/harness/packages/core/src/tunnel.js +180 -19
  24. package/harness/packages/core/test/open.test.js +19 -0
  25. package/harness/packages/core/test/procs.test.js +50 -0
  26. package/harness/packages/core/test/tools.test.js +12 -0
  27. package/harness/packages/core/test/tts.test.js +41 -0
  28. package/harness/packages/server/public/app.js +4715 -3669
  29. package/harness/packages/server/public/index.html +94 -5
  30. package/harness/packages/server/public/landing/index.html +5 -5
  31. package/harness/packages/server/public/reconcile.js +121 -0
  32. package/harness/packages/server/public/relayClient.js +170 -0
  33. package/harness/packages/server/public/runStream.js +207 -0
  34. package/harness/packages/server/public/styles.css +196 -23
  35. package/harness/packages/server/src/auth.js +29 -5
  36. package/harness/packages/server/src/lib/agentRun.js +77 -0
  37. package/harness/packages/server/src/lib/announcer.js +92 -0
  38. package/harness/packages/server/src/lib/approvals.js +25 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  40. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  41. package/harness/packages/server/src/lib/procs.js +3 -18
  42. package/harness/packages/server/src/lib/runManager.js +24 -2
  43. package/harness/packages/server/src/lib/sessionView.js +43 -0
  44. package/harness/packages/server/src/lib/tasks.js +4 -9
  45. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  46. package/harness/packages/server/src/lib/workspace.js +37 -1
  47. package/harness/packages/server/src/routes/chat.js +51 -47
  48. package/harness/packages/server/src/routes/events.js +35 -0
  49. package/harness/packages/server/src/routes/files.js +22 -26
  50. package/harness/packages/server/src/routes/folders.js +9 -17
  51. package/harness/packages/server/src/routes/git.js +35 -0
  52. package/harness/packages/server/src/routes/procs.js +25 -15
  53. package/harness/packages/server/src/routes/sessions.js +12 -19
  54. package/harness/packages/server/src/routes/shell.js +19 -0
  55. package/harness/packages/server/src/routes/state.js +32 -0
  56. package/harness/packages/server/src/routes/tts.js +82 -0
  57. package/harness/packages/server/src/server.js +25 -1
  58. package/harness/packages/server/test/announcer.test.js +51 -0
  59. package/harness/packages/server/test/reconcile.test.js +146 -0
  60. package/harness/packages/server/test/relay.test.js +219 -0
  61. package/harness/packages/server/test/runStream.test.js +245 -0
  62. package/harness/packages/server/test/security.test.js +252 -0
  63. package/harness/packages/server/test/server.test.js +617 -1
  64. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  65. package/harness/packages/server/test/tts.test.js +133 -0
  66. 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', { sessionId, provider: providerId, model, mode: mode.id, cwd: sessionCwd });
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 definitions = [
198
- ...(exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions),
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.resolveProvider({
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.length
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 goal = sessionRecord?.meta?.goal;
227
- const summary = sessionRecord?.meta?.summary;
228
- const basePrompt = await ctx.systemPromptFor(sessionCwd);
229
- const messages = [
230
- { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
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
- ctx.runs.emit(sessionId, 'tool_call', {
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
- args: event.args,
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') ctx.runs.emit(sessionId, 'aborted', { durationMs, toolCalls });
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 { isLoopback, resolveExistingDir } from '../lib/workspace.js';
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
- try {
24
- root = resolveExistingDir(query.get('root') || fallback);
25
- } catch (error) {
26
- sendError(res, 400, error.message);
27
- return null;
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 resolved = resolveTarget(req, res, query, ctx.cwd);
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 resolved = resolveTarget(req, res, query, ctx.cwd);
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 (!isLoopback(req.socket.localAddress)) {
121
- return sendError(res, 403, 'La vista previa solo está disponible en conexiones locales.');
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
- let root;
128
- try {
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 { isLoopback, rootDirs, listSubdirs, resolveExistingDir } from '../lib/workspace.js';
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 (!isLoopback(req.socket.localAddress)) {
16
- return sendError(res, 403, 'El navegador de carpetas solo está disponible en conexiones locales.');
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
- let dir;
30
- try {
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 (!isLoopback(req.socket.localAddress)) {
53
- return sendError(res, 403, 'Crear carpetas solo está disponible en conexiones locales.');
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
- let parent;
65
- try {
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 { resolveExistingDir } from '../lib/workspace.js';
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
- let dir;
12
- try {
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
- let dir;
24
- try {
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
- sendJson(res, 200, { sessions: ctx.store.listSessions(50).map(view) });
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(view),
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: view(ctx.store.getSession(id)) });
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: view(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: view(ctx.store.getSession(params.id)) });
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
+ }