@danieltmn/tamnora 0.13.1 → 0.14.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.
@@ -22,7 +22,7 @@
22
22
  --mono: "DM Mono", ui-monospace, "Cascadia Code", "JetBrains Mono", "SF Mono", Consolas, monospace;
23
23
  --font: var(--sans);
24
24
  --chat-font: 14.5px;
25
- --page-bg: linear-gradient(rgba(11, 12, 8, 0.96), rgba(11, 12, 8, 0.96)), url("/img/fondo-agentes.webp") center/cover fixed no-repeat;
25
+ --page-bg: #101107;
26
26
  color-scheme: dark;
27
27
  }
28
28
 
@@ -48,7 +48,7 @@ html[data-theme='paper'] {
48
48
  --warn: #8a5a00;
49
49
  --solid: #f7f2e6;
50
50
  --primary-fg: #fbf6ec;
51
- --page-bg: linear-gradient(90deg, rgba(248, 244, 234, 0.9), rgba(244, 238, 224, 0.86)), url("/img/fondo-agentes.webp") center/cover fixed no-repeat;
51
+ --page-bg: #f5efe2;
52
52
  }
53
53
  html[data-theme='paper'] .tok-comment { color: #7a7669; }
54
54
  html[data-theme='paper'] .tok-string { color: #7a4a2c; }
@@ -144,9 +144,11 @@ pre, code, kbd, samp { font-family: var(--mono); }
144
144
  #more-menu button.menu-primary { display: none; }
145
145
  }
146
146
 
147
- #theme-toggle .ico-moon { display: none; }
148
- html[data-theme='dark'] #theme-toggle .ico-sun { display: none; }
149
- html[data-theme='dark'] #theme-toggle .ico-moon { display: block; }
147
+ /* El icono muestra el tema al que se va a cambiar: claro -> luna, oscuro -> sol. */
148
+ #theme-toggle .ico-sun { display: none; }
149
+ #theme-toggle .ico-moon { display: block; }
150
+ html[data-theme='dark'] #theme-toggle .ico-sun { display: block; }
151
+ html[data-theme='dark'] #theme-toggle .ico-moon { display: none; }
150
152
  .menu-pop {
151
153
  position: absolute;
152
154
  top: calc(100% + 4px);
@@ -1384,9 +1386,12 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1384
1386
  background: var(--rail);
1385
1387
  backdrop-filter: blur(14px);
1386
1388
  }
1387
- .explorer-head { display: flex; align-items: center; gap: 6px; padding: 9px 12px; border-bottom: 1px solid var(--border); }
1389
+ .explorer-head { display: flex; align-items: center; gap: 6px; padding: 9px 12px; border-bottom: 1px solid var(--border); min-width: 0; }
1388
1390
  .explorer-title { font: 700 14px var(--serif); letter-spacing: -0.02em; }
1389
1391
  .explorer-head .spacer { flex: 1; }
1392
+ /* Los botones de accion (refrescar/cerrar) nunca se recortan: las pestanas se
1393
+ angostan y hacen scroll horizontal en vez de empujarlos fuera del panel. */
1394
+ .explorer-head .btn.icon { flex: 0 0 auto; }
1390
1395
  .explorer-cwd { padding: 9px 12px 0; color: var(--muted); font: 500 11px var(--mono); word-break: break-all; }
1391
1396
  .explorer-cwd:empty { display: none; }
1392
1397
  .explorer-filter { padding: 9px 12px; }
@@ -1407,8 +1412,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1407
1412
  .explorer-status { padding: 6px 12px; border-top: 1px solid var(--border); color: var(--muted); font: 500 11px var(--mono); min-height: 27px; }
1408
1413
  .explorer-status.err { color: var(--danger); }
1409
1414
 
1410
- .xtabs { display: flex; gap: 4px; }
1411
- .xtab { border: 0; background: none; color: var(--muted); cursor: pointer; font: 600 12px var(--sans); padding: 4px 7px; border-radius: 7px; }
1415
+ .xtabs { display: flex; gap: 4px; min-width: 0; flex: 0 1 auto; overflow-x: auto; scrollbar-width: none; }
1416
+ .xtabs::-webkit-scrollbar { display: none; }
1417
+ .xtab { flex: 0 0 auto; border: 0; background: none; color: var(--muted); cursor: pointer; font: 600 12px var(--sans); padding: 4px 7px; border-radius: 7px; }
1412
1418
  .xtab:hover { background: var(--theirs); color: var(--text); }
1413
1419
  .xtab.is-active { background: var(--theirs); color: var(--text); }
1414
1420
  .xp-pane { display: flex; flex-direction: column; flex: 1; min-height: 0; }
@@ -1566,8 +1572,9 @@ dialog.file-modal.preview-maximized {
1566
1572
  }
1567
1573
 
1568
1574
  /* panel heads + collapsed handles ----------------------------------------- */
1569
- .panel-head { display: flex; align-items: center; gap: 8px; padding: 2px 2px 6px; }
1570
- .panel-head-title { flex: 1; font: 700 13px var(--serif); letter-spacing: -0.02em; color: var(--muted); text-transform: lowercase; }
1575
+ .panel-head { display: flex; align-items: center; gap: 8px; padding: 2px 2px 6px; min-width: 0; }
1576
+ .panel-head-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 13px var(--serif); letter-spacing: -0.02em; color: var(--muted); text-transform: lowercase; }
1577
+ .panel-head .btn.icon { flex: 0 0 auto; }
1571
1578
  .panel-handle {
1572
1579
  position: absolute;
1573
1580
  top: 50%;
@@ -35,6 +35,27 @@ export function combineToolset({ local, admin = null, tasks = null, exposeAdmin
35
35
  return { definitions, execute };
36
36
  }
37
37
 
38
+ /**
39
+ * Combina varios handlers de `onLifecycle` en uno: los corre a todos y concatena
40
+ * los `inject` que devuelvan (reglas por path, más tarde hooks).
41
+ */
42
+ export function combineLifecycle(handlers = []) {
43
+ const active = handlers.filter(Boolean);
44
+ if (!active.length) return null;
45
+ return async function onLifecycle(event) {
46
+ const injects = [];
47
+ for (const handler of active) {
48
+ const out = await Promise.resolve()
49
+ .then(() => handler(event))
50
+ .catch(() => undefined);
51
+ if (out && typeof out === 'object' && typeof out.inject === 'string' && out.inject.trim()) {
52
+ injects.push(out.inject);
53
+ }
54
+ }
55
+ return injects.length ? { inject: injects.join('\n\n') } : undefined;
56
+ };
57
+ }
58
+
38
59
  /** Resuelve proveedor + config con la API key almacenada. */
39
60
  export function resolveProviderFor(ctx, providerId, extra = {}) {
40
61
  return ctx.resolveProvider({
@@ -0,0 +1,106 @@
1
+ // Checkpoints: pre-imagen de los archivos que `write`/`edit` van a tocar, para
2
+ // poder "deshacer" un turno. Se guardan en la casa (`checkpoints/`), no en el
3
+ // workspace, asi no ensucian el repo. Indexados por `sessionId` + turno (id del
4
+ // mensaje del usuario que abrio el run).
5
+
6
+ import path from 'node:path';
7
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
8
+ import { homeDir, resolveInRoot } from './core.js';
9
+
10
+ const MAX_FILE_BYTES = 512 * 1024;
11
+
12
+ function checkpointsDir() {
13
+ return path.join(homeDir(), 'checkpoints');
14
+ }
15
+
16
+ function manifestPath(sessionId) {
17
+ const safe = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '-');
18
+ return path.join(checkpointsDir(), `${safe}.json`);
19
+ }
20
+
21
+ async function readManifest(sessionId) {
22
+ try {
23
+ return JSON.parse(await readFile(manifestPath(sessionId), 'utf8'));
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ async function writeManifest(sessionId, manifest) {
30
+ await mkdir(checkpointsDir(), { recursive: true });
31
+ await writeFile(manifestPath(sessionId), JSON.stringify(manifest), 'utf8');
32
+ }
33
+
34
+ /**
35
+ * Handler de `onLifecycle` que, antes de un `write`/`edit`, guarda la version
36
+ * previa del archivo (o marca que no existia) para el turno `turnId`.
37
+ */
38
+ export function createCheckpointer(ctx, { sessionId, cwd, turnId }) {
39
+ if (!sessionId || !turnId) return null;
40
+ return async function onLifecycle(event) {
41
+ if (event?.type !== 'tool.before') return undefined;
42
+ if (event.name !== 'write' && event.name !== 'edit') return undefined;
43
+ const rel = String(event.args?.path ?? '').trim();
44
+ if (!rel) return undefined;
45
+ let abs;
46
+ try {
47
+ abs = resolveInRoot(cwd, rel);
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ let entry;
52
+ try {
53
+ const st = await stat(abs);
54
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) return undefined;
55
+ entry = { path: rel, existed: true, content: (await readFile(abs)).toString('base64') };
56
+ } catch {
57
+ entry = { path: rel, existed: false, content: null };
58
+ }
59
+ const manifest = await readManifest(sessionId);
60
+ const list = manifest[turnId] ?? (manifest[turnId] = []);
61
+ if (!list.some((item) => item.path === rel)) list.push(entry);
62
+ await writeManifest(sessionId, manifest);
63
+ return undefined;
64
+ };
65
+ }
66
+
67
+ /** Turnos (ids de mensaje) que tienen checkpoint guardado. */
68
+ export async function checkpointTurns(sessionId) {
69
+ return Object.keys(await readManifest(sessionId));
70
+ }
71
+
72
+ /** Restaura los archivos del turno y trunca el historial desde ese mensaje. */
73
+ export async function rewindSession(ctx, sessionId, turnId) {
74
+ const session = ctx.store.getSession(sessionId);
75
+ const cwd = session?.cwd || ctx.cwd;
76
+ const manifest = await readManifest(sessionId);
77
+ const entries = manifest[turnId] ?? [];
78
+ let restored = 0;
79
+ const files = [];
80
+ for (const entry of entries) {
81
+ let abs;
82
+ try {
83
+ abs = resolveInRoot(cwd, entry.path);
84
+ } catch {
85
+ continue;
86
+ }
87
+ try {
88
+ if (entry.existed) {
89
+ await mkdir(path.dirname(abs), { recursive: true });
90
+ await writeFile(abs, Buffer.from(entry.content ?? '', 'base64'));
91
+ } else {
92
+ await rm(abs, { force: true });
93
+ }
94
+ restored += 1;
95
+ files.push(entry.path);
96
+ } catch {
97
+ // un archivo que no se pudo restaurar no frena el resto
98
+ }
99
+ }
100
+ const removed = ctx.store.deleteMessagesFrom(sessionId, turnId);
101
+ if (manifest[turnId]) {
102
+ delete manifest[turnId];
103
+ await writeManifest(sessionId, manifest);
104
+ }
105
+ return { restored, removed, files, turnId };
106
+ }
@@ -0,0 +1,183 @@
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({ local, admin, tasks, exposeAdmin, approve: async () => true });
72
+ const { provider, config } = resolveProviderFor(ctx, providerId);
73
+
74
+ try {
75
+ const result = await runAgent({
76
+ provider,
77
+ config,
78
+ model,
79
+ messages,
80
+ tools: definitions,
81
+ execute,
82
+ toolContext: { cwd, sessionId },
83
+ onLifecycle: createRulesInjector({ cwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
84
+ signal,
85
+ maxRounds: ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
86
+ maxTokens: ctx.effectiveMaxTokens?.() || 0,
87
+ maxDurationMs: ctx.effectiveMaxDurationMs?.() || 0,
88
+ maxOutputTokens: ctx.effectiveMaxOutputTokens?.() || 0,
89
+ onEvent(event) {
90
+ if (event.type === 'text') emit('text', { full: event.full });
91
+ else if (event.type === 'reasoning') emit('reasoning', { full: event.full });
92
+ else if (event.type === 'tool_call') emit('tool_call', toolCallPayload(event));
93
+ else if (event.type === 'tool_result') emit('tool_result', { id: event.id, name: event.name, ...toolResultPayload(event) });
94
+ else if (event.type === 'usage') emit('usage', { tokens: event.usage });
95
+ },
96
+ });
97
+ ctx.store.addMessages(sessionId, result.messages.slice(prefixLength));
98
+ ctx.runs.emit(sessionId, 'done', {
99
+ sessionId,
100
+ content: result.content,
101
+ rounds: result.rounds,
102
+ stopReason: result.stopReason,
103
+ wrappedUp: result.wrappedUp === true,
104
+ tokens: result.usage ?? null,
105
+ schedule: schedule.id,
106
+ });
107
+ return { ok: true, sessionId };
108
+ } finally {
109
+ ctx.runs.finish(sessionId);
110
+ }
111
+ }
112
+
113
+ function runShellSchedule(ctx, schedule) {
114
+ const payload = schedule.payload ?? {};
115
+ if (!ctx.procsEnabled) throw new Error('Los procesos están deshabilitados.');
116
+ const cwd = schedule.cwd || ctx.cwd;
117
+ const proc = ctx.procs.start({ cwd, command: payload.command, args: payload.args ?? [], port: payload.port });
118
+ return { ok: true, procId: proc.id };
119
+ }
120
+
121
+ async function executeSchedule(ctx, schedule) {
122
+ if (schedule.type === 'shell') return runShellSchedule(ctx, schedule);
123
+ return runAgentSchedule(ctx, schedule);
124
+ }
125
+
126
+ /**
127
+ * Crea el scheduler. `tick()` revisa y dispara lo vencido; `start()` lo corre
128
+ * cada `intervalMs` (default 30 s). `runNow(id)` dispara una tarea a mano.
129
+ */
130
+ export function createScheduler(ctx, { intervalMs = 30000, log = () => {} } = {}) {
131
+ let timer = null;
132
+ let ticking = false;
133
+
134
+ function plan(schedule, from = Date.now()) {
135
+ const parsed = parseScheduleSpec(schedule.spec);
136
+ return parsed ? nextRun(parsed, from) : null;
137
+ }
138
+
139
+ async function fire(schedule) {
140
+ const next = plan(schedule);
141
+ ctx.store.markScheduleRun(schedule.id, { status: 'running', nextRun: next });
142
+ try {
143
+ const result = await executeSchedule(ctx, schedule);
144
+ ctx.store.markScheduleRun(schedule.id, { status: 'ok', nextRun: next });
145
+ log(`tarea "${schedule.id}" ok`);
146
+ return result;
147
+ } catch (error) {
148
+ const message = String(error?.message ?? error);
149
+ ctx.store.markScheduleRun(schedule.id, { status: `error: ${message}`.slice(0, 140), nextRun: next });
150
+ log(`tarea "${schedule.id}" falló: ${message}`);
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ async function tick() {
156
+ if (ticking) return;
157
+ ticking = true;
158
+ try {
159
+ for (const schedule of ctx.store.dueSchedules()) {
160
+ try {
161
+ await fire(schedule);
162
+ } catch {
163
+ // seguir con las siguientes
164
+ }
165
+ }
166
+ } finally {
167
+ ticking = false;
168
+ }
169
+ }
170
+
171
+ function start() {
172
+ if (timer) return;
173
+ timer = setInterval(() => tick().catch(() => {}), intervalMs);
174
+ timer.unref?.();
175
+ }
176
+
177
+ function stop() {
178
+ if (timer) clearInterval(timer);
179
+ timer = null;
180
+ }
181
+
182
+ return { start, stop, tick, fire, runNow: (id) => fire(ctx.store.getSchedule(id)) };
183
+ }
@@ -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
@@ -254,8 +256,12 @@ export function registerChat(route, ctx) {
254
256
  });
255
257
  // Persistimos el mensaje del usuario ya: asi sobrevive a un corte y la
256
258
  // reconexion puede reconstruir el turno desde el historial.
257
- ctx.store.addMessage(sessionId, userMessage);
259
+ const userMessageId = ctx.store.addMessage(sessionId, userMessage);
258
260
  const prefixLength = messages.length;
261
+ const onLifecycle = combineLifecycle([
262
+ createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }),
263
+ createCheckpointer(ctx, { sessionId, cwd: sessionCwd, turnId: userMessageId }),
264
+ ]);
259
265
 
260
266
  (async () => {
261
267
  const startedAt = Date.now();
@@ -270,7 +276,7 @@ export function registerChat(route, ctx) {
270
276
  tools: definitions,
271
277
  execute,
272
278
  toolContext: { cwd: sessionCwd, sessionId },
273
- onLifecycle: createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
279
+ onLifecycle: onLifecycle ?? undefined,
274
280
  signal: ctx.runs.signal(sessionId),
275
281
  maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
276
282
  maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
@@ -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,8 @@ 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';
54
56
 
55
57
  const VERSION = readPackageVersion();
56
58
 
@@ -223,6 +225,9 @@ export async function createTamnoraServer({
223
225
  channels.load();
224
226
  ctx.channels = channels;
225
227
 
228
+ // Planificador de tareas (tick cada 30 s). Arranca cuando el server escucha.
229
+ ctx.scheduler = createScheduler(ctx, { log: (message) => process.stderr.write(`${message}\n`) });
230
+
226
231
  const routes = [];
227
232
  const route = (method, pattern, handler) =>
228
233
  routes.push({ method, segments: pattern.split('/').filter(Boolean), handler });
@@ -263,6 +268,7 @@ export async function createTamnoraServer({
263
268
  registerState(route, ctx);
264
269
  registerPresence(route, ctx);
265
270
  registerEval(route, ctx);
271
+ registerSchedules(route, ctx);
266
272
 
267
273
  function matchRoute(method, pathname) {
268
274
  const parts = pathname.split('/').filter(Boolean);
@@ -347,6 +353,7 @@ export async function createTamnoraServer({
347
353
  url: `http://${address.address === '::' ? '127.0.0.1' : address.address}:${address.port}`,
348
354
  };
349
355
  ctx.serverInfo = info;
356
+ ctx.scheduler?.start();
350
357
  resolve(info);
351
358
  }
352
359
  );
@@ -355,6 +362,7 @@ export async function createTamnoraServer({
355
362
  const close = async () => {
356
363
  approvals.cancelAll();
357
364
  runs.cancelAll();
365
+ ctx.scheduler?.stop();
358
366
  channels.stop();
359
367
  preview.close();
360
368
  procs.close();