@danieltmn/tamnora 0.13.1 → 0.15.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 (32) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/harness/packages/core/src/config/index.js +2 -0
  3. package/harness/packages/core/src/hooks/index.js +42 -0
  4. package/harness/packages/core/src/index.js +3 -0
  5. package/harness/packages/core/src/plugins/index.js +36 -0
  6. package/harness/packages/core/src/rules/index.js +9 -63
  7. package/harness/packages/core/src/schedule/cron.js +124 -0
  8. package/harness/packages/core/src/storage/sqlite.js +96 -1
  9. package/harness/packages/core/src/tools/glob.js +64 -2
  10. package/harness/packages/core/test/cron.test.js +46 -0
  11. package/harness/packages/core/test/hooks.test.js +29 -0
  12. package/harness/packages/core/test/plugins.test.js +25 -0
  13. package/harness/packages/server/public/app.js +193 -3
  14. package/harness/packages/server/public/index.html +76 -2
  15. package/harness/packages/server/public/landing/index.html +5 -3
  16. package/harness/packages/server/public/styles.css +17 -10
  17. package/harness/packages/server/src/lib/advisor.js +4 -0
  18. package/harness/packages/server/src/lib/agentRun.js +24 -1
  19. package/harness/packages/server/src/lib/channels/telegram.js +7 -1
  20. package/harness/packages/server/src/lib/checkpoints.js +106 -0
  21. package/harness/packages/server/src/lib/compact.js +4 -0
  22. package/harness/packages/server/src/lib/hooks.js +45 -0
  23. package/harness/packages/server/src/lib/plugins.js +76 -0
  24. package/harness/packages/server/src/lib/scheduler.js +190 -0
  25. package/harness/packages/server/src/routes/advisor.js +4 -2
  26. package/harness/packages/server/src/routes/chat.js +32 -7
  27. package/harness/packages/server/src/routes/schedules.js +124 -0
  28. package/harness/packages/server/src/routes/sessions.js +18 -2
  29. package/harness/packages/server/src/server.js +24 -0
  30. package/harness/packages/server/test/server.test.js +201 -0
  31. package/harness/tamnora.example.json +2 -0
  32. package/package.json +1 -1
@@ -52,8 +52,10 @@ export async function summarizeSession(ctx, session, instruction) {
52
52
  if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
53
53
  const resolved = providerFor(ctx, session);
54
54
  if (resolved.error) return resolved;
55
+ ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, messages: messages.length });
55
56
  const summary = await summarize(ctx, session, messages, instruction);
56
57
  if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
58
+ ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, summary });
57
59
  return { summary };
58
60
  }
59
61
 
@@ -70,8 +72,10 @@ export async function maybeAutoCompact(ctx, session, history, { contextTokens }
70
72
  const live = liveHistory(history, session.meta?.summaryCursor ?? null);
71
73
  const plan = planCompaction(live, { contextTokens, threshold, keepLast });
72
74
  if (!plan) return null;
75
+ ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, auto: true, messages: plan.toSummarize.length });
73
76
  const summary = await summarize(ctx, session, plan.toSummarize, COMPACT_INSTRUCTION, session.meta?.summary);
74
77
  if (!summary) return null;
75
78
  ctx.store.touchSession(session.id, { meta: { summary, summaryCursor: plan.cursor } });
79
+ ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, auto: true, summary });
76
80
  return { summary, cursor: plan.cursor, summarized: plan.toSummarize.length };
77
81
  }
@@ -0,0 +1,45 @@
1
+ // Runner de hooks: carga los modulos (`core/src/hooks`) y despacha eventos a
2
+ // sus handlers de forma aislada (un hook que tira no rompe el run) y con los
3
+ // datos redactados. Es opt-in: sin archivos de hooks, no se ejecuta codigo.
4
+
5
+ import { loadHooks, redactDeep } from './core.js';
6
+
7
+ /**
8
+ * Crea el runner. `dispatch(event)` llama a los handlers suscritos a
9
+ * `event.type` (y a los wildcard `*`); no devuelve valor (observadores).
10
+ */
11
+ export async function createHookRunner(ctx, patterns = []) {
12
+ let hooks = [];
13
+ try {
14
+ hooks = await loadHooks(ctx.cwd, patterns);
15
+ } catch {
16
+ hooks = [];
17
+ }
18
+ const handlers = new Map();
19
+ for (const hook of hooks) {
20
+ for (const [event, fn] of Object.entries(hook.events)) {
21
+ if (typeof fn !== 'function') continue;
22
+ const list = handlers.get(event) ?? [];
23
+ list.push(fn);
24
+ handlers.set(event, list);
25
+ }
26
+ }
27
+
28
+ function dispatch(event) {
29
+ const list = [...(handlers.get(event.type) ?? []), ...(handlers.get('*') ?? [])];
30
+ if (!list.length) return;
31
+ const safe = redactDeep(event);
32
+ for (const fn of list) {
33
+ Promise.resolve()
34
+ .then(() => fn(safe, ctx))
35
+ .catch(() => {
36
+ // un hook caido no debe frenar el run
37
+ });
38
+ }
39
+ }
40
+
41
+ const view = () =>
42
+ hooks.map((hook) => ({ id: hook.id, path: hook.path, events: Object.keys(hook.events).filter((e) => e !== '*') }));
43
+
44
+ return { dispatch, count: hooks.length, list: view };
45
+ }
@@ -0,0 +1,76 @@
1
+ // Plugins: agregan herramientas propias al agente (`.tamnora/plugins/*.mjs` o
2
+ // las rutas de `config.plugins`). Corren codigo del usuario, asi que pasan por
3
+ // permisos (default `ask`).
4
+
5
+ import { loadPlugins } from './core.js';
6
+
7
+ function normalizeName(value) {
8
+ return String(value ?? '')
9
+ .replace(/[^a-z0-9_-]+/gi, '_')
10
+ .replace(/^_+|_+$/g, '')
11
+ .slice(0, 64);
12
+ }
13
+
14
+ /** Crea el toolset de plugins. `execute(name, args, { approve })`. */
15
+ export async function createPlugins(ctx, patterns = []) {
16
+ let plugins = [];
17
+ try {
18
+ plugins = await loadPlugins(ctx.cwd, patterns);
19
+ } catch {
20
+ plugins = [];
21
+ }
22
+
23
+ const tools = [];
24
+ const used = new Set();
25
+ for (const plugin of plugins) {
26
+ for (const tool of plugin.tools) {
27
+ const name = `plugin__${normalizeName(plugin.id)}__${normalizeName(tool.name)}`;
28
+ if (!name || used.has(name)) continue;
29
+ used.add(name);
30
+ tools.push({
31
+ name,
32
+ source: tool.name,
33
+ plugin: plugin.id,
34
+ description: String(tool.description || tool.name),
35
+ parameters: tool.parameters && typeof tool.parameters === 'object' ? tool.parameters : {},
36
+ required: Array.isArray(tool.required) ? tool.required : [],
37
+ handler: tool.handler,
38
+ });
39
+ }
40
+ }
41
+
42
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
43
+ const definitions = tools.map((tool) => ({
44
+ type: 'function',
45
+ function: {
46
+ name: tool.name,
47
+ description: tool.description,
48
+ parameters: { type: 'object', properties: tool.parameters, required: tool.required },
49
+ },
50
+ }));
51
+
52
+ async function execute(name, args = {}, { approve } = {}) {
53
+ const tool = byName.get(name);
54
+ if (!tool) return `Herramienta desconocida: ${name}`;
55
+ const decision = (ctx.config.permissions ?? {})[tool.name] ?? 'ask';
56
+ if (decision === 'deny') return `Permiso denegado para "${name}".`;
57
+ if (decision === 'ask') {
58
+ const ok = approve ? await approve(name, args) : false;
59
+ if (!ok) return `Permiso denegado para "${name}".`;
60
+ }
61
+ try {
62
+ const out = await tool.handler(args, ctx);
63
+ return typeof out === 'string' ? out : JSON.stringify(out ?? null, null, 2);
64
+ } catch (error) {
65
+ return `Error en ${name}: ${error?.message ?? error}`;
66
+ }
67
+ }
68
+
69
+ return {
70
+ definitions,
71
+ handles: (name) => byName.has(name),
72
+ execute,
73
+ count: tools.length,
74
+ list: () => tools.map(({ name, source, plugin, description }) => ({ name, source, plugin, description })),
75
+ };
76
+ }
@@ -0,0 +1,190 @@
1
+ // Tareas programadas: un tick por minuto revisa `schedules` y dispara las que
2
+ // vencieron. `shell` usa la allowlist de procesos; `agent` corre un turno
3
+ // headless en una sesion (reusa runManager, asi que los canales avisan solos).
4
+
5
+ import { runAgent, createTools, resolveMode, parseScheduleSpec, nextRun, MAX_TOOL_ROUNDS } from './core.js';
6
+ import {
7
+ combineToolset,
8
+ composeAgentMessages,
9
+ buildUserMessage,
10
+ resolveProviderFor,
11
+ toolCallPayload,
12
+ toolResultPayload,
13
+ } from './agentRun.js';
14
+ import { createSubagentTool } from './tasks.js';
15
+ import { createRulesInjector } from './rules.js';
16
+
17
+ function scheduleSessionId(ctx, schedule) {
18
+ const payload = schedule.payload ?? {};
19
+ if (payload.sessionId) return payload.sessionId;
20
+ return ctx.store.getSetting(`scheduleSession:${schedule.id}`) ?? null;
21
+ }
22
+
23
+ async function runAgentSchedule(ctx, schedule) {
24
+ const payload = schedule.payload ?? {};
25
+ const cwd = schedule.cwd || ctx.cwd;
26
+ const providerId = payload.provider || ctx.effectiveProviderId();
27
+ const model = payload.model || ctx.effectiveModel();
28
+ if (!model) throw new Error('Falta el modelo para la tarea.');
29
+ const mode = resolveMode(payload.mode ?? 'agent');
30
+ const prompt = String(payload.prompt ?? '').trim() || `Ejecutá la tarea programada "${schedule.name}".`;
31
+
32
+ let sessionId = scheduleSessionId(ctx, schedule);
33
+ if (!sessionId || !ctx.store.getSession(sessionId)) {
34
+ sessionId = ctx.store.createSession({ title: `Tarea: ${schedule.name}`, provider: providerId, model, cwd });
35
+ ctx.store.setSetting(`scheduleSession:${schedule.id}`, sessionId);
36
+ }
37
+
38
+ const run = ctx.runs.start(sessionId);
39
+ if (!run) throw new Error('La sesión de la tarea ya está corriendo.');
40
+
41
+ let mcp = null;
42
+ try {
43
+ mcp = await ctx.getMcp();
44
+ } catch {
45
+ mcp = null;
46
+ }
47
+
48
+ const signal = ctx.runs.signal(sessionId);
49
+ const sessionRecord = ctx.store.getSession(sessionId);
50
+ const history = ctx.store.getMessages(sessionId);
51
+ const userMessage = buildUserMessage(prompt);
52
+ const messages = await composeAgentMessages({ ctx, cwd, mode, history, userMessage, sessionRecord });
53
+ ctx.store.addMessage(sessionId, userMessage);
54
+ const prefixLength = messages.length;
55
+
56
+ const local = createTools({ root: cwd, enabled: mode.tools ?? undefined, permissions: ctx.config.permissions, yes: true, mcp, signal });
57
+ const exposeAdmin = !mode.tools;
58
+ const admin = ctx.agentTools;
59
+ const emit = (type, data, agentId = null) => ctx.runs.emit(sessionId, type, data, { agentId });
60
+ const tasks = createSubagentTool(ctx, {
61
+ sessionId,
62
+ cwd,
63
+ providerId,
64
+ model,
65
+ permissions: ctx.config.permissions,
66
+ mcp,
67
+ signal,
68
+ exclude: admin.definitions.map((d) => d.function.name),
69
+ emit,
70
+ });
71
+ const { definitions, execute } = combineToolset({
72
+ local,
73
+ admin,
74
+ tasks,
75
+ plugins: exposeAdmin ? ctx.plugins : null,
76
+ exposeAdmin,
77
+ approve: async () => true,
78
+ });
79
+ const { provider, config } = resolveProviderFor(ctx, providerId);
80
+
81
+ try {
82
+ const result = await runAgent({
83
+ provider,
84
+ config,
85
+ model,
86
+ messages,
87
+ tools: definitions,
88
+ execute,
89
+ toolContext: { cwd, sessionId },
90
+ onLifecycle: createRulesInjector({ cwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
91
+ signal,
92
+ maxRounds: ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
93
+ maxTokens: ctx.effectiveMaxTokens?.() || 0,
94
+ maxDurationMs: ctx.effectiveMaxDurationMs?.() || 0,
95
+ maxOutputTokens: ctx.effectiveMaxOutputTokens?.() || 0,
96
+ onEvent(event) {
97
+ if (event.type === 'text') emit('text', { full: event.full });
98
+ else if (event.type === 'reasoning') emit('reasoning', { full: event.full });
99
+ else if (event.type === 'tool_call') emit('tool_call', toolCallPayload(event));
100
+ else if (event.type === 'tool_result') emit('tool_result', { id: event.id, name: event.name, ...toolResultPayload(event) });
101
+ else if (event.type === 'usage') emit('usage', { tokens: event.usage });
102
+ },
103
+ });
104
+ ctx.store.addMessages(sessionId, result.messages.slice(prefixLength));
105
+ ctx.runs.emit(sessionId, 'done', {
106
+ sessionId,
107
+ content: result.content,
108
+ rounds: result.rounds,
109
+ stopReason: result.stopReason,
110
+ wrappedUp: result.wrappedUp === true,
111
+ tokens: result.usage ?? null,
112
+ schedule: schedule.id,
113
+ });
114
+ return { ok: true, sessionId };
115
+ } finally {
116
+ ctx.runs.finish(sessionId);
117
+ }
118
+ }
119
+
120
+ function runShellSchedule(ctx, schedule) {
121
+ const payload = schedule.payload ?? {};
122
+ if (!ctx.procsEnabled) throw new Error('Los procesos están deshabilitados.');
123
+ const cwd = schedule.cwd || ctx.cwd;
124
+ const proc = ctx.procs.start({ cwd, command: payload.command, args: payload.args ?? [], port: payload.port });
125
+ return { ok: true, procId: proc.id };
126
+ }
127
+
128
+ async function executeSchedule(ctx, schedule) {
129
+ if (schedule.type === 'shell') return runShellSchedule(ctx, schedule);
130
+ return runAgentSchedule(ctx, schedule);
131
+ }
132
+
133
+ /**
134
+ * Crea el scheduler. `tick()` revisa y dispara lo vencido; `start()` lo corre
135
+ * cada `intervalMs` (default 30 s). `runNow(id)` dispara una tarea a mano.
136
+ */
137
+ export function createScheduler(ctx, { intervalMs = 30000, log = () => {} } = {}) {
138
+ let timer = null;
139
+ let ticking = false;
140
+
141
+ function plan(schedule, from = Date.now()) {
142
+ const parsed = parseScheduleSpec(schedule.spec);
143
+ return parsed ? nextRun(parsed, from) : null;
144
+ }
145
+
146
+ async function fire(schedule) {
147
+ const next = plan(schedule);
148
+ ctx.store.markScheduleRun(schedule.id, { status: 'running', nextRun: next });
149
+ try {
150
+ const result = await executeSchedule(ctx, schedule);
151
+ ctx.store.markScheduleRun(schedule.id, { status: 'ok', nextRun: next });
152
+ log(`tarea "${schedule.id}" ok`);
153
+ return result;
154
+ } catch (error) {
155
+ const message = String(error?.message ?? error);
156
+ ctx.store.markScheduleRun(schedule.id, { status: `error: ${message}`.slice(0, 140), nextRun: next });
157
+ log(`tarea "${schedule.id}" falló: ${message}`);
158
+ throw error;
159
+ }
160
+ }
161
+
162
+ async function tick() {
163
+ if (ticking) return;
164
+ ticking = true;
165
+ try {
166
+ for (const schedule of ctx.store.dueSchedules()) {
167
+ try {
168
+ await fire(schedule);
169
+ } catch {
170
+ // seguir con las siguientes
171
+ }
172
+ }
173
+ } finally {
174
+ ticking = false;
175
+ }
176
+ }
177
+
178
+ function start() {
179
+ if (timer) return;
180
+ timer = setInterval(() => tick().catch(() => {}), intervalMs);
181
+ timer.unref?.();
182
+ }
183
+
184
+ function stop() {
185
+ if (timer) clearInterval(timer);
186
+ timer = null;
187
+ }
188
+
189
+ return { start, stop, tick, fire, runNow: (id) => fire(ctx.store.getSchedule(id)) };
190
+ }
@@ -20,6 +20,8 @@ export function registerAdvisor(route, ctx) {
20
20
  if ('explain' in body) next.explain = body.explain !== false;
21
21
  if ('advice' in body) next.advice = body.advice !== false;
22
22
  if ('notes' in body) next.notes = String(body.notes ?? '').slice(0, 2000);
23
+ if ('provider' in body) next.provider = String(body.provider ?? '').trim().slice(0, 60);
24
+ if ('model' in body) next.model = String(body.model ?? '').trim().slice(0, 120);
23
25
  if ('maxWords' in body) {
24
26
  const value = Number(body.maxWords);
25
27
  next.maxWords = Number.isFinite(value)
@@ -34,8 +36,8 @@ export function registerAdvisor(route, ctx) {
34
36
 
35
37
  route('POST', '/api/advisor/test', async ({ res }) => {
36
38
  const options = resolveAdvisor(ctx.app);
37
- const providerId = ctx.effectiveProviderId();
38
- const model = ctx.effectiveModel();
39
+ const providerId = options.provider || ctx.effectiveProviderId();
40
+ const model = options.model || ctx.effectiveModel();
39
41
  if (!model) return sendError(res, 400, 'Elegí un proveedor y un modelo primero.');
40
42
  const { provider, config } = ctx.resolveProvider({
41
43
  ...ctx.providerSettings(providerId),
@@ -10,6 +10,7 @@ import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
10
10
  import { createSubagentTool } from '../lib/tasks.js';
11
11
  import {
12
12
  combineToolset,
13
+ combineLifecycle,
13
14
  composeAgentMessages,
14
15
  buildUserMessage,
15
16
  resolveProviderFor,
@@ -20,6 +21,7 @@ import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/ann
20
21
  import { resolveAdvisor, advisorShouldRun, consultAdvisor } from '../lib/advisor.js';
21
22
  import { maybeAutoCompact } from '../lib/compact.js';
22
23
  import { createRulesInjector } from '../lib/rules.js';
24
+ import { createCheckpointer } from '../lib/checkpoints.js';
23
25
 
24
26
  /**
25
27
  * Emit streaming partials at most once per `ms` window, keeping only the
@@ -219,11 +221,18 @@ export function registerChat(route, ctx) {
219
221
  permissions: ctx.config.permissions,
220
222
  mcp,
221
223
  signal: ctx.runs.signal(sessionId),
222
- exclude: admin.definitions.map((d) => d.function.name),
224
+ exclude: [...admin.definitions.map((d) => d.function.name), ...(ctx.plugins?.list?.().map((t) => t.name) ?? [])],
223
225
  emit: emitAgent,
224
226
  });
225
227
  const approve = autoApprove ? async () => true : (name, args) => onAsk(name, args);
226
- const { definitions, execute } = combineToolset({ local, admin, tasks, exposeAdmin, approve });
228
+ const { definitions, execute } = combineToolset({
229
+ local,
230
+ admin,
231
+ tasks,
232
+ plugins: exposeAdmin ? ctx.plugins : null,
233
+ exposeAdmin,
234
+ approve,
235
+ });
227
236
 
228
237
  const { provider, config } = resolveProviderFor(ctx, providerId, {
229
238
  baseUrl: body.baseUrl || ctx.providerSettings(providerId).baseUrl,
@@ -254,8 +263,13 @@ export function registerChat(route, ctx) {
254
263
  });
255
264
  // Persistimos el mensaje del usuario ya: asi sobrevive a un corte y la
256
265
  // reconexion puede reconstruir el turno desde el historial.
257
- ctx.store.addMessage(sessionId, userMessage);
266
+ const userMessageId = ctx.store.addMessage(sessionId, userMessage);
258
267
  const prefixLength = messages.length;
268
+ const onLifecycle = combineLifecycle([
269
+ createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }),
270
+ createCheckpointer(ctx, { sessionId, cwd: sessionCwd, turnId: userMessageId }),
271
+ ctx.hooks ? (event) => ctx.hooks.dispatch(event) : null,
272
+ ]);
259
273
 
260
274
  (async () => {
261
275
  const startedAt = Date.now();
@@ -270,7 +284,7 @@ export function registerChat(route, ctx) {
270
284
  tools: definitions,
271
285
  execute,
272
286
  toolContext: { cwd: sessionCwd, sessionId },
273
- onLifecycle: createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
287
+ onLifecycle: onLifecycle ?? undefined,
274
288
  signal: ctx.runs.signal(sessionId),
275
289
  maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
276
290
  maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
@@ -350,10 +364,20 @@ export function registerChat(route, ctx) {
350
364
  advisorOptions.enabled &&
351
365
  advisorShouldRun({ toolCalls, answer: result.content })
352
366
  ) {
367
+ // El asesor puede tener proveedor/modelo propios; si no, usa los activos.
368
+ let advisorProvider = provider;
369
+ let advisorConfig = config;
370
+ let advisorModel = model;
371
+ if (advisorOptions.provider || advisorOptions.model) {
372
+ const resolved = resolveProviderFor(ctx, advisorOptions.provider || providerId);
373
+ advisorProvider = resolved.provider;
374
+ advisorConfig = resolved.config;
375
+ advisorModel = advisorOptions.model || model;
376
+ }
353
377
  const advice = await consultAdvisor({
354
- provider,
355
- config,
356
- model,
378
+ provider: advisorProvider,
379
+ config: advisorConfig,
380
+ model: advisorModel,
357
381
  signal: ctx.runs.signal(sessionId),
358
382
  userInput: input,
359
383
  answer: result.content,
@@ -368,6 +392,7 @@ export function registerChat(route, ctx) {
368
392
  mode: advice.kind,
369
393
  });
370
394
  ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
395
+ ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.text, kind: advice.kind });
371
396
  }
372
397
  }
373
398
  } catch (error) {
@@ -0,0 +1,124 @@
1
+ // Tareas programadas: CRUD + "correr ahora". El tick vive en lib/scheduler.js.
2
+
3
+ import { randomUUID } from 'node:crypto';
4
+ import { parseScheduleSpec, nextRunFor } from '../lib/core.js';
5
+ import { readJson, sendJson, sendError } from '../lib/http.js';
6
+
7
+ const ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
8
+
9
+ function sanitizeId(value) {
10
+ return String(value ?? '')
11
+ .trim()
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9_-]+/g, '-')
14
+ .replace(/^-+|-+$/g, '')
15
+ .slice(0, 40);
16
+ }
17
+
18
+ function buildPayload(body, type) {
19
+ if (type === 'shell') {
20
+ const args = Array.isArray(body.args)
21
+ ? body.args.map(String)
22
+ : String(body.args ?? '').trim()
23
+ ? String(body.args).match(/(?:[^\s"]+|"[^"]*")+/g).map((s) => s.replace(/^"|"$/g, ''))
24
+ : [];
25
+ return { command: String(body.command ?? '').trim(), args, port: body.port ?? null };
26
+ }
27
+ return {
28
+ prompt: String(body.prompt ?? '').trim(),
29
+ mode: body.mode === 'plan' ? 'plan' : 'agent',
30
+ model: body.model || undefined,
31
+ provider: body.provider || undefined,
32
+ sessionId: body.sessionId || undefined,
33
+ };
34
+ }
35
+
36
+ function validate(type, payload) {
37
+ if (type === 'shell' && !payload.command) return 'Una tarea shell necesita "command".';
38
+ if (type === 'agent' && !payload.prompt) return 'Una tarea agent necesita "prompt".';
39
+ return null;
40
+ }
41
+
42
+ export function registerSchedules(route, ctx) {
43
+ route('GET', '/api/schedules', ({ res }) => {
44
+ sendJson(res, 200, { schedules: ctx.store.listSchedules() });
45
+ });
46
+
47
+ route('POST', '/api/schedules', async ({ req, res }) => {
48
+ const body = await readJson(req);
49
+ const spec = String(body.spec ?? '').trim();
50
+ if (!parseScheduleSpec(spec)) {
51
+ return sendError(res, 400, 'Especificación inválida. Ej.: "every 30 m", "daily 09:00", "0 6 * * 1".');
52
+ }
53
+ const id = sanitizeId(body.id || body.name) || randomUUID().slice(0, 8);
54
+ if (!ID_PATTERN.test(id)) return sendError(res, 400, 'Id de tarea inválido.');
55
+ if (ctx.store.getSchedule(id)) return sendError(res, 409, `Ya existe una tarea con id "${id}".`);
56
+ const type = body.type === 'shell' ? 'shell' : 'agent';
57
+ const payload = buildPayload(body, type);
58
+ const problem = validate(type, payload);
59
+ if (problem) return sendError(res, 400, problem);
60
+ const schedule = ctx.store.saveSchedule({
61
+ id,
62
+ name: String(body.name ?? id).trim() || id,
63
+ type,
64
+ spec,
65
+ cwd: body.cwd || ctx.cwd,
66
+ enabled: body.enabled !== false,
67
+ payload,
68
+ nextRun: nextRunFor(spec),
69
+ });
70
+ sendJson(res, 201, { schedule });
71
+ });
72
+
73
+ route('PUT', '/api/schedules/:id', async ({ req, res, params }) => {
74
+ const current = ctx.store.getSchedule(params.id);
75
+ if (!current) return sendError(res, 404, 'Tarea no encontrada.');
76
+ const body = await readJson(req);
77
+ const spec = body.spec === undefined ? current.spec : String(body.spec).trim();
78
+ if (!parseScheduleSpec(spec)) return sendError(res, 400, 'Especificación inválida.');
79
+ const type = body.type === 'shell' || body.type === 'agent' ? body.type : current.type;
80
+ const merged = { ...current.payload, ...buildPayload(body, type) };
81
+ const problem = validate(type, merged);
82
+ if (problem) return sendError(res, 400, problem);
83
+ const schedule = ctx.store.saveSchedule({
84
+ id: current.id,
85
+ name: body.name ?? current.name,
86
+ type,
87
+ spec,
88
+ cwd: body.cwd ?? current.cwd,
89
+ enabled: body.enabled === undefined ? current.enabled : body.enabled !== false,
90
+ payload: merged,
91
+ nextRun: nextRunFor(spec),
92
+ });
93
+ sendJson(res, 200, { schedule });
94
+ });
95
+
96
+ route('POST', '/api/schedules/:id/toggle', ({ res, params }) => {
97
+ const current = ctx.store.getSchedule(params.id);
98
+ if (!current) return sendError(res, 404, 'Tarea no encontrada.');
99
+ const enabled = !current.enabled;
100
+ ctx.store.setScheduleEnabled(current.id, enabled);
101
+ const nextRun = enabled ? nextRunFor(current.spec) : null;
102
+ ctx.store.markScheduleRun(current.id, { status: current.lastStatus, nextRun });
103
+ sendJson(res, 200, { id: current.id, enabled, schedule: ctx.store.getSchedule(current.id) });
104
+ });
105
+
106
+ route('DELETE', '/api/schedules/:id', ({ res, params }) => {
107
+ const current = ctx.store.getSchedule(params.id);
108
+ if (!current) return sendError(res, 404, 'Tarea no encontrada.');
109
+ ctx.store.deleteSchedule(params.id);
110
+ sendJson(res, 200, { schedules: ctx.store.listSchedules() });
111
+ });
112
+
113
+ route('POST', '/api/schedules/:id/run', async ({ res, params }) => {
114
+ const current = ctx.store.getSchedule(params.id);
115
+ if (!current) return sendError(res, 404, 'Tarea no encontrada.');
116
+ if (!ctx.scheduler) return sendError(res, 503, 'El planificador no está activo.');
117
+ try {
118
+ const result = await ctx.scheduler.runNow(params.id);
119
+ sendJson(res, 200, { ok: true, result, schedule: ctx.store.getSchedule(params.id) });
120
+ } catch (error) {
121
+ sendError(res, 500, `La tarea falló: ${error?.message ?? error}`);
122
+ }
123
+ });
124
+ }
@@ -2,6 +2,7 @@ import { readJson, sendJson, sendError } from '../lib/http.js';
2
2
  import { workspaceForRequest } from '../lib/workspace.js';
3
3
  import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
4
4
  import { summarizeSession, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from '../lib/compact.js';
5
+ import { rewindSession, checkpointTurns } from '../lib/checkpoints.js';
5
6
 
6
7
  export function registerSessions(route, ctx) {
7
8
  route('GET', '/api/sessions', ({ res }) => {
@@ -117,8 +118,23 @@ export function registerSessions(route, ctx) {
117
118
  sendJson(res, 200, { ok: true, removed });
118
119
  });
119
120
 
120
- route('POST', '/api/sessions/:id/compact', async ({ res, params }) => {
121
- const session = ctx.store.getSession(params.id);
121
+ route('GET', '/api/sessions/:id/checkpoints', async ({ res, params }) => {
122
+ if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
123
+ sendJson(res, 200, { turns: await checkpointTurns(params.id) });
124
+ });
125
+
126
+ route('POST', '/api/sessions/:id/rewind', async ({ req, res, params }) => {
127
+ if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
128
+ if (ctx.runs?.isActive(params.id)) return sendError(res, 409, 'Hay un run en curso en esta sesión.');
129
+ const body = await readJson(req);
130
+ const turn = String(body.turn || '');
131
+ if (!turn) return sendError(res, 400, 'Falta "turn" (id del mensaje del turno).');
132
+ const result = await rewindSession(ctx, params.id, turn);
133
+ ctx.store.touchSession(params.id, { meta: { summary: undefined, summaryCursor: undefined } });
134
+ sendJson(res, 200, { ok: true, ...result });
135
+ });
136
+
137
+ route('POST', '/api/sessions/:id/compact', async ({ res, params }) => { const session = ctx.store.getSession(params.id);
122
138
  if (!session) return sendError(res, 404, 'Sesión no encontrada.');
123
139
  const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
124
140
  if (out.error) return sendError(res, out.status, out.error);
@@ -51,6 +51,10 @@ import { registerEvents } from './routes/events.js';
51
51
  import { registerState } from './routes/state.js';
52
52
  import { registerPresence } from './routes/presence.js';
53
53
  import { registerEval } from './routes/eval.js';
54
+ import { registerSchedules } from './routes/schedules.js';
55
+ import { createScheduler } from './lib/scheduler.js';
56
+ import { createHookRunner } from './lib/hooks.js';
57
+ import { createPlugins } from './lib/plugins.js';
54
58
 
55
59
  const VERSION = readPackageVersion();
56
60
 
@@ -215,6 +219,12 @@ export async function createTamnoraServer({
215
219
  ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
216
220
  ctx.agentTools = createAgentTools(ctx);
217
221
 
222
+ // Hooks locales (opt-in: solo si hay archivos). Observan el ciclo de vida.
223
+ ctx.hooks = await createHookRunner(ctx, config.hooks ?? []);
224
+
225
+ // Plugins locales: tools propias del usuario (`.tamnora/plugins/*.mjs`).
226
+ ctx.plugins = await createPlugins(ctx, config.plugins ?? []);
227
+
218
228
  // Canales de mensajeria (Telegram via long polling) si hay token.
219
229
  const channels = createChannelsManager(ctx, {
220
230
  enabled: telegram,
@@ -223,6 +233,9 @@ export async function createTamnoraServer({
223
233
  channels.load();
224
234
  ctx.channels = channels;
225
235
 
236
+ // Planificador de tareas (tick cada 30 s). Arranca cuando el server escucha.
237
+ ctx.scheduler = createScheduler(ctx, { log: (message) => process.stderr.write(`${message}\n`) });
238
+
226
239
  const routes = [];
227
240
  const route = (method, pattern, handler) =>
228
241
  routes.push({ method, segments: pattern.split('/').filter(Boolean), handler });
@@ -263,6 +276,15 @@ export async function createTamnoraServer({
263
276
  registerState(route, ctx);
264
277
  registerPresence(route, ctx);
265
278
  registerEval(route, ctx);
279
+ registerSchedules(route, ctx);
280
+
281
+ route('GET', '/api/hooks', ({ res }) => {
282
+ sendJson(res, 200, { hooks: ctx.hooks?.list?.() ?? [] });
283
+ });
284
+
285
+ route('GET', '/api/plugins', ({ res }) => {
286
+ sendJson(res, 200, { plugins: ctx.plugins?.list?.() ?? [] });
287
+ });
266
288
 
267
289
  function matchRoute(method, pathname) {
268
290
  const parts = pathname.split('/').filter(Boolean);
@@ -347,6 +369,7 @@ export async function createTamnoraServer({
347
369
  url: `http://${address.address === '::' ? '127.0.0.1' : address.address}:${address.port}`,
348
370
  };
349
371
  ctx.serverInfo = info;
372
+ ctx.scheduler?.start();
350
373
  resolve(info);
351
374
  }
352
375
  );
@@ -355,6 +378,7 @@ export async function createTamnoraServer({
355
378
  const close = async () => {
356
379
  approvals.cancelAll();
357
380
  runs.cancelAll();
381
+ ctx.scheduler?.stop();
358
382
  channels.stop();
359
383
  preview.close();
360
384
  procs.close();