@danieltmn/tamnora 0.13.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.14.0] - 2026-10-10
9
+
10
+ ### Agregado
11
+
12
+ - **Fase 3 del roadmap del server (automatización y seguridad)**:
13
+ - **Tareas programadas**: `every N m|h`, `hourly`, `daily HH:MM` y un subconjunto
14
+ de cron. El scheduler corre un tick cada 30 s y dispara tareas `agent` (turno
15
+ headless que reusa el historial y avisa por los canales) o `shell` (con la
16
+ allowlist de procesos). CRUD en `/api/schedules` y pestaña **Tareas**.
17
+ - **Deshacer (checkpoints/rewind)**: se guarda la versión previa de los archivos
18
+ que `write`/`edit` tocan y `POST /api/sessions/:id/rewind` restaura los
19
+ archivos y borra el turno del historial. Acción **deshacer cambios** en el menú
20
+ de cada mensaje del asistente.
21
+
22
+ ### Cambios
23
+
24
+ - Consola: el botón de tema muestra el icono del tema al que se va a cambiar
25
+ (en claro, luna; en oscuro, sol). Igual en la landing.
26
+ - Consola: el fondo del chat pasa a un color sólido; la imagen queda solo en la
27
+ landing.
28
+ - Consola: los botones de los paneles (p. ej. cerrar/refrescar) ya no se recortan
29
+ al angostar: las pestañas se comprimen y hacen scroll.
30
+
31
+ ## [0.13.1] - 2026-10-10
32
+
33
+ ### Arreglado
34
+
35
+ - El test de permisos ya no es flaky: verifica que el `askId` resuelto no
36
+ reaparezca (antes asumía que no quedaba ninguno pendiente, lo que fallaba por
37
+ carrera cuando el agente ya pedía el siguiente permiso).
38
+
8
39
  ## [0.13.0] - 2026-10-10
9
40
 
10
41
  ### Agregado
@@ -123,6 +123,7 @@ export {
123
123
  buildStaticRulesBlock,
124
124
  buildPathRulesBlock,
125
125
  } from './rules/index.js';
126
+ export { parseScheduleSpec, parseCronFields, nextRun, nextRunFor } from './schedule/cron.js';
126
127
  export {
127
128
  searchMcpRegistry,
128
129
  getMcpRegistryServer,
@@ -0,0 +1,124 @@
1
+ // Especificaciones de programación (puro, sin dependencias): `every N m|h`,
2
+ // `hourly`, `daily HH:MM`, `HH:MM` y un subconjunto de cron (5 campos:
3
+ // minuto hora dia-mes mes dia-semana) con `*`, listas, rangos y pasos.
4
+
5
+ const MINUTE = 60_000;
6
+
7
+ function parseField(text, min, max) {
8
+ const out = new Set();
9
+ for (const part of String(text).split(',')) {
10
+ const token = part.trim();
11
+ if (!token) return null;
12
+ const [rangePart, stepPart] = token.split('/');
13
+ const step = stepPart === undefined ? 1 : Number(stepPart);
14
+ if (!Number.isInteger(step) || step <= 0) return null;
15
+ let start;
16
+ let end;
17
+ if (rangePart === '*') {
18
+ start = min;
19
+ end = max;
20
+ } else {
21
+ const range = rangePart.split('-');
22
+ start = Number(range[0]);
23
+ end = range.length > 1 ? Number(range[1]) : start;
24
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
25
+ }
26
+ if (start < min || end > max || start > end) return null;
27
+ for (let v = start; v <= end; v += step) out.add(v);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** Parsea los 5 campos de cron. Devuelve null si es inválido. */
33
+ export function parseCronFields(parts) {
34
+ const [minute, hour, dom, month, dow] = parts;
35
+ const fields = {
36
+ minute: parseField(minute, 0, 59),
37
+ hour: parseField(hour, 0, 23),
38
+ dom: parseField(dom, 1, 31),
39
+ month: parseField(month, 1, 12),
40
+ dow: parseField(dow, 0, 6),
41
+ };
42
+ if (Object.values(fields).some((set) => !set)) return null;
43
+ fields.domStar = dom.trim() === '*';
44
+ fields.dowStar = dow.trim() === '*';
45
+ return fields;
46
+ }
47
+
48
+ /**
49
+ * Normaliza una especificación a `{ type, ... }` o null:
50
+ * - `{ type: 'every', minutes }`
51
+ * - `{ type: 'daily', hour, minute }`
52
+ * - `{ type: 'cron', fields }`
53
+ */
54
+ export function parseScheduleSpec(spec) {
55
+ const text = String(spec ?? '').trim().toLowerCase();
56
+ if (!text) return null;
57
+
58
+ const every = text.match(/^every\s+(\d+)\s*(m|min|mins|minute|minutes|h|hr|hour|hours)$/);
59
+ if (every) {
60
+ const n = Number(every[1]);
61
+ if (n <= 0) return null;
62
+ return { type: 'every', minutes: every[2].startsWith('h') ? n * 60 : n, spec: text };
63
+ }
64
+ if (/^(every\s+hour|hourly)$/.test(text)) return { type: 'every', minutes: 60, spec: text };
65
+
66
+ const time = text.match(/^(?:daily\s+)?(\d{1,2}):(\d{2})$/);
67
+ if (time) {
68
+ const hour = Number(time[1]);
69
+ const minute = Number(time[2]);
70
+ if (hour > 23 || minute > 59) return null;
71
+ return { type: 'daily', hour, minute, spec: text };
72
+ }
73
+
74
+ const parts = text.split(/\s+/);
75
+ if (parts.length === 5) {
76
+ const fields = parseCronFields(parts);
77
+ if (!fields) return null;
78
+ return { type: 'cron', fields, spec: text };
79
+ }
80
+ return null;
81
+ }
82
+
83
+ function cronMatches(fields, date) {
84
+ if (!fields.minute.has(date.getMinutes())) return false;
85
+ if (!fields.hour.has(date.getHours())) return false;
86
+ if (!fields.month.has(date.getMonth() + 1)) return false;
87
+ const domMatch = fields.dom.has(date.getDate());
88
+ const dowMatch = fields.dow.has(date.getDay());
89
+ if (fields.domStar && fields.dowStar) return true;
90
+ if (fields.domStar) return dowMatch;
91
+ if (fields.dowStar) return domMatch;
92
+ return domMatch || dowMatch;
93
+ }
94
+
95
+ /** Próximo instante (ms) en que corre, estrictamente después de `from`. */
96
+ export function nextRun(parsed, from = Date.now()) {
97
+ if (!parsed) return null;
98
+ if (parsed.type === 'every') {
99
+ const ms = parsed.minutes * MINUTE;
100
+ return Math.floor(from / ms) * ms + ms;
101
+ }
102
+ if (parsed.type === 'daily') {
103
+ const next = new Date(from);
104
+ next.setSeconds(0, 0);
105
+ next.setHours(parsed.hour, parsed.minute, 0, 0);
106
+ if (next.getTime() <= from) next.setDate(next.getDate() + 1);
107
+ return next.getTime();
108
+ }
109
+ const limit = from + 366 * 24 * 60 * MINUTE;
110
+ const cursor = new Date(from);
111
+ cursor.setSeconds(0, 0);
112
+ cursor.setMinutes(cursor.getMinutes() + 1);
113
+ while (cursor.getTime() <= limit) {
114
+ if (cronMatches(parsed.fields, cursor)) return cursor.getTime();
115
+ cursor.setMinutes(cursor.getMinutes() + 1);
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** `parse + nextRun` en un paso. */
121
+ export function nextRunFor(spec, from = Date.now()) {
122
+ const parsed = parseScheduleSpec(spec);
123
+ return parsed ? nextRun(parsed, from) : null;
124
+ }
@@ -115,6 +115,21 @@ CREATE TABLE IF NOT EXISTS settings (
115
115
  key TEXT PRIMARY KEY,
116
116
  value TEXT
117
117
  );
118
+
119
+ CREATE TABLE IF NOT EXISTS schedules (
120
+ id TEXT PRIMARY KEY,
121
+ name TEXT,
122
+ type TEXT,
123
+ spec TEXT,
124
+ payload TEXT,
125
+ cwd TEXT,
126
+ enabled INTEGER,
127
+ last_run INTEGER,
128
+ last_status TEXT,
129
+ next_run INTEGER,
130
+ created_at INTEGER,
131
+ updated_at INTEGER
132
+ );
118
133
  `;
119
134
 
120
135
  /** Open (creating if needed) a SQLite database with the harness schema. */
@@ -533,8 +548,71 @@ export class Store {
533
548
  this.db.prepare(`DELETE FROM providers WHERE id = ?`).run(id);
534
549
  }
535
550
 
536
- // --- Settings (key/value) ----------------------------------------------
551
+ // --- Schedules (tareas programadas) ------------------------------------
552
+
553
+ listSchedules() {
554
+ return this.db
555
+ .prepare(`SELECT * FROM schedules ORDER BY created_at ASC`)
556
+ .all()
557
+ .map(toSchedule);
558
+ }
559
+
560
+ getSchedule(id) {
561
+ const row = this.db.prepare(`SELECT * FROM schedules WHERE id = ?`).get(id);
562
+ return row ? toSchedule(row) : null;
563
+ }
537
564
 
565
+ saveSchedule(schedule) {
566
+ const ts = now();
567
+ const existing = this.getSchedule(schedule.id);
568
+ this.db
569
+ .prepare(
570
+ `INSERT INTO schedules (id, name, type, spec, payload, cwd, enabled, last_run, last_status, next_run, created_at, updated_at)
571
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
572
+ ON CONFLICT(id) DO UPDATE SET
573
+ name = excluded.name, type = excluded.type, spec = excluded.spec, payload = excluded.payload,
574
+ cwd = excluded.cwd, enabled = excluded.enabled, next_run = excluded.next_run, updated_at = excluded.updated_at`
575
+ )
576
+ .run(
577
+ schedule.id,
578
+ schedule.name ?? schedule.id,
579
+ schedule.type ?? 'agent',
580
+ schedule.spec ?? '',
581
+ json(schedule.payload ?? {}),
582
+ schedule.cwd ?? null,
583
+ schedule.enabled === false ? 0 : 1,
584
+ schedule.lastRun ?? existing?.lastRun ?? null,
585
+ schedule.lastStatus ?? existing?.lastStatus ?? null,
586
+ schedule.nextRun ?? null,
587
+ existing?.createdAt ?? ts,
588
+ ts
589
+ );
590
+ return this.getSchedule(schedule.id);
591
+ }
592
+
593
+ deleteSchedule(id) {
594
+ this.db.prepare(`DELETE FROM schedules WHERE id = ?`).run(id);
595
+ }
596
+
597
+ setScheduleEnabled(id, enabled) {
598
+ this.db.prepare(`UPDATE schedules SET enabled = ?, updated_at = ? WHERE id = ?`).run(enabled ? 1 : 0, now(), id);
599
+ }
600
+
601
+ markScheduleRun(id, { status, nextRun } = {}) {
602
+ this.db
603
+ .prepare(`UPDATE schedules SET last_run = ?, last_status = ?, next_run = ?, updated_at = ? WHERE id = ?`)
604
+ .run(now(), status ?? null, nextRun ?? null, now(), id);
605
+ }
606
+
607
+ /** Tareas habilitadas cuyo `next_run` ya venció. */
608
+ dueSchedules(at = Date.now()) {
609
+ return this.db
610
+ .prepare(`SELECT * FROM schedules WHERE enabled = 1 AND next_run IS NOT NULL AND next_run <= ? ORDER BY next_run ASC`)
611
+ .all(at)
612
+ .map(toSchedule);
613
+ }
614
+
615
+ // --- Settings (key/value) ----------------------------------------------
538
616
  getSetting(key, fallback = null) {
539
617
  const row = this.db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key);
540
618
  return row ? parseJson(row.value) : fallback;
@@ -574,6 +652,23 @@ function toAgent(row) {
574
652
  };
575
653
  }
576
654
 
655
+ function toSchedule(row) {
656
+ return {
657
+ id: row.id,
658
+ name: row.name ?? row.id,
659
+ type: row.type ?? 'agent',
660
+ spec: row.spec ?? '',
661
+ payload: parseJson(row.payload) ?? {},
662
+ cwd: row.cwd ?? null,
663
+ enabled: row.enabled === 0 ? false : true,
664
+ lastRun: row.last_run ?? null,
665
+ lastStatus: row.last_status ?? null,
666
+ nextRun: row.next_run ?? null,
667
+ createdAt: row.created_at,
668
+ updatedAt: row.updated_at,
669
+ };
670
+ }
671
+
577
672
  function toProvider(row) {
578
673
  return {
579
674
  id: row.id,
@@ -0,0 +1,46 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseScheduleSpec, nextRun, nextRunFor } from '../src/schedule/cron.js';
4
+
5
+ test('parseScheduleSpec accepts every / hourly / daily / time', () => {
6
+ assert.deepEqual(parseScheduleSpec('every 15 m'), { type: 'every', minutes: 15, spec: 'every 15 m' });
7
+ assert.equal(parseScheduleSpec('every 2 h').minutes, 120);
8
+ assert.equal(parseScheduleSpec('hourly').minutes, 60);
9
+ assert.deepEqual(parseScheduleSpec('daily 09:30'), { type: 'daily', hour: 9, minute: 30, spec: 'daily 09:30' });
10
+ assert.equal(parseScheduleSpec('08:05').type, 'daily');
11
+ assert.equal(parseScheduleSpec('bogus'), null);
12
+ assert.equal(parseScheduleSpec('every 0 m'), null);
13
+ });
14
+
15
+ test('nextRun for every aligns to the interval', () => {
16
+ const from = new Date('2026-01-01T10:07:00').getTime();
17
+ const next = new Date(nextRun(parseScheduleSpec('every 15 m'), from));
18
+ assert.equal(next.getHours(), 10);
19
+ assert.equal(next.getMinutes(), 15);
20
+ });
21
+
22
+ test('nextRun for daily picks today or tomorrow', () => {
23
+ const spec = parseScheduleSpec('daily 09:00');
24
+ const before = new Date('2026-01-01T08:00:00').getTime();
25
+ assert.equal(new Date(nextRun(spec, before)).getDate(), 1);
26
+ const after = new Date('2026-01-01T10:00:00').getTime();
27
+ assert.equal(new Date(nextRun(spec, after)).getDate(), 2);
28
+ });
29
+
30
+ test('nextRun for a cron subset', () => {
31
+ const from = new Date('2026-01-01T10:00:00').getTime();
32
+ const next = new Date(nextRun(parseScheduleSpec('30 14 * * *'), from));
33
+ assert.equal(next.getHours(), 14);
34
+ assert.equal(next.getMinutes(), 30);
35
+ });
36
+
37
+ test('nextRun for weekday cron skips to the right day', () => {
38
+ const from = new Date('2026-01-01T00:00:00').getTime(); // Thursday
39
+ const next = new Date(nextRun(parseScheduleSpec('0 6 * * 1'), from)); // Monday 06:00
40
+ assert.equal(next.getDay(), 1);
41
+ assert.equal(next.getHours(), 6);
42
+ });
43
+
44
+ test('nextRunFor returns null for invalid spec', () => {
45
+ assert.equal(nextRunFor('nope'), null);
46
+ });
@@ -72,6 +72,7 @@ const ICON_PATHS = {
72
72
  'folder-open': ['m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2'],
73
73
  file: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z', 'M14 2v4a2 2 0 0 0 2 2h4'],
74
74
  'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
75
+ undo: ['M3 7v6h6', 'M3 13a9 9 0 1 1 2.64 6.36'],
75
76
  trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
76
77
  'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
77
78
  'dots-horizontal': ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
@@ -195,7 +196,7 @@ function activateTab(name) {
195
196
  hideMoreMenu();
196
197
  if (name === 'runs') refreshRuns();
197
198
  if (name === 'providers') refreshProviders();
198
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshMemory(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
199
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshMemory(); refreshSchedules(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
199
200
  }
200
201
 
201
202
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -980,6 +981,7 @@ function addMessageActions(message) {
980
981
  };
981
982
  addItem('copiar', () => copyMessage(message), 'copy');
982
983
  if (message.role === 'assistant') addItem('regenerar', () => regenerate(message), 'refresh-cw');
984
+ if (message.role === 'assistant') addItem('deshacer cambios', () => undoTurn(message), 'undo');
983
985
  if (message.role === 'user') addItem('editar', () => editMessage(message), 'pencil');
984
986
  menu.appendChild(el('div', 'msg-menu-sep'));
985
987
  addItem('borrar desde acá', () => deleteFrom(message), 'trash', 'msg-act danger');
@@ -1042,8 +1044,27 @@ async function regenerate(message) {
1042
1044
  await sendMessage();
1043
1045
  }
1044
1046
 
1045
- function editMessage(message) {
1046
- const id = messageId(message);
1047
+ async function undoTurn(message) {
1048
+ const id = message?.wrap?.closest('.messages')?.dataset.session || state.sessionId;
1049
+ if (!id || state.panes.get(id)?.working) return;
1050
+ const detail = await api(`/api/sessions/${id}`).catch(() => null);
1051
+ if (!detail) return;
1052
+ const messages = detail.messages || [];
1053
+ const at = messages.findIndex((m) => m.id === messageId(message));
1054
+ let turn = null;
1055
+ for (let i = at >= 0 ? at : messages.length - 1; i >= 0; i--) {
1056
+ if (messages[i].role === 'user') {
1057
+ turn = messages[i];
1058
+ break;
1059
+ }
1060
+ }
1061
+ if (!turn) return;
1062
+ if (!window.confirm('¿Deshacer este turno? Se restauran los archivos a como estaban y se borra el turno del historial.')) return;
1063
+ await api(`/api/sessions/${id}/rewind`, { method: 'POST', body: { turn: turn.id } }).catch(() => {});
1064
+ await reloadPane(id);
1065
+ }
1066
+
1067
+ function editMessage(message) { const id = messageId(message);
1047
1068
  chatInput.value = message.textEl.textContent;
1048
1069
  chatInput.focus();
1049
1070
  state.pendingEdit = id ? { fromId: id } : null;
@@ -3705,6 +3726,9 @@ const memoryListEl = $('#memory-list');
3705
3726
  const memoryIndexEl = $('#memory-index');
3706
3727
  const memoryModal = $('#memory-modal');
3707
3728
  const memoryForm = $('#memory-form');
3729
+ const scheduleListEl = $('#schedule-list');
3730
+ const scheduleModal = $('#schedule-modal');
3731
+ const scheduleForm = $('#schedule-form');
3708
3732
  const tgTokenEl = $('#tg-token');
3709
3733
  const tgChatsEl = $('#tg-chats');
3710
3734
  const tgTranscribeEl = $('#tg-transcribe');
@@ -4075,6 +4099,157 @@ async function saveMemoryIndex() {
4075
4099
  refreshMemory();
4076
4100
  }
4077
4101
 
4102
+ /* --- ajustes: Tareas programadas ----------------------------------------- */
4103
+
4104
+ function fmtWhen(ms) {
4105
+ try {
4106
+ return new Date(ms).toLocaleString();
4107
+ } catch {
4108
+ return '';
4109
+ }
4110
+ }
4111
+
4112
+ function scheduleTypeFields() {
4113
+ const type = $('#sc-type').value;
4114
+ const agent = type === 'agent';
4115
+ $('#sc-agent-prompt-row').hidden = !agent;
4116
+ $('#sc-agent-mode-row').hidden = !agent;
4117
+ $('#sc-shell-cmd-row').hidden = agent;
4118
+ $('#sc-shell-args-row').hidden = agent;
4119
+ }
4120
+
4121
+ async function refreshSchedules() {
4122
+ if (!scheduleListEl) return;
4123
+ const data = await api('/api/schedules').catch(() => null);
4124
+ if (!data) return;
4125
+ clear(scheduleListEl);
4126
+ if (!data.schedules.length) {
4127
+ scheduleListEl.appendChild(el('li', 'muted', 'sin tareas todavía'));
4128
+ return;
4129
+ }
4130
+ for (const s of data.schedules) {
4131
+ const li = el('li', s.enabled ? 'active' : '');
4132
+ li.appendChild(el('span', `pdot${s.enabled ? ' on' : ''}`));
4133
+ const main = el('div', 'grow');
4134
+ const meta = [s.type, s.spec];
4135
+ if (s.nextRun) meta.push(`próxima ${fmtWhen(s.nextRun)}`);
4136
+ if (s.lastStatus) meta.push(s.lastStatus);
4137
+ main.append(el('div', '', s.name || s.id), el('div', 'pmeta', meta.join(' · ')));
4138
+ li.appendChild(main);
4139
+ const actions = el('div', 'pactions');
4140
+ actions.appendChild(button('correr', (event) => { event.stopPropagation(); runSchedule(s.id); }));
4141
+ actions.appendChild(button(s.enabled ? 'pausar' : 'activar', (event) => { event.stopPropagation(); toggleSchedule(s.id); }));
4142
+ actions.appendChild(button('editar', (event) => { event.stopPropagation(); openScheduleForm(s.id); }));
4143
+ actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeSchedule(s.id); }, 'btn danger'));
4144
+ li.appendChild(actions);
4145
+ li.addEventListener('click', () => openScheduleForm(s.id));
4146
+ scheduleListEl.appendChild(li);
4147
+ }
4148
+ }
4149
+
4150
+ async function openScheduleForm(id = null) {
4151
+ $('#schedule-title').textContent = id ? `editar ${id}` : 'nueva tarea';
4152
+ $('#sc-id').value = id ?? '';
4153
+ $('#sc-id').readOnly = Boolean(id);
4154
+ $('#sc-name').value = '';
4155
+ $('#sc-type').value = 'agent';
4156
+ $('#sc-spec').value = '';
4157
+ $('#sc-prompt').value = '';
4158
+ $('#sc-mode').value = 'agent';
4159
+ $('#sc-command').value = '';
4160
+ $('#sc-args').value = '';
4161
+ $('#sc-cwd').value = '';
4162
+ $('#sc-enabled').checked = true;
4163
+ setStatus('#sc-status', '');
4164
+ if (id) {
4165
+ const data = await api('/api/schedules').catch(() => null);
4166
+ const s = data?.schedules?.find((item) => item.id === id);
4167
+ if (s) {
4168
+ $('#sc-name').value = s.name ?? '';
4169
+ $('#sc-type').value = s.type ?? 'agent';
4170
+ $('#sc-spec').value = s.spec ?? '';
4171
+ $('#sc-cwd').value = s.cwd ?? '';
4172
+ $('#sc-enabled').checked = s.enabled !== false;
4173
+ const payload = s.payload ?? {};
4174
+ if (s.type === 'shell') {
4175
+ $('#sc-command').value = payload.command ?? '';
4176
+ $('#sc-args').value = Array.isArray(payload.args) ? payload.args.join(' ') : payload.args ?? '';
4177
+ } else {
4178
+ $('#sc-prompt').value = payload.prompt ?? '';
4179
+ $('#sc-mode').value = payload.mode ?? 'agent';
4180
+ }
4181
+ }
4182
+ }
4183
+ scheduleTypeFields();
4184
+ scheduleModal.showModal();
4185
+ if (!id) $('#sc-id').focus();
4186
+ }
4187
+
4188
+ function closeScheduleForm() {
4189
+ if (scheduleModal.open) scheduleModal.close();
4190
+ }
4191
+
4192
+ async function submitSchedule(event) {
4193
+ event.preventDefault();
4194
+ const id = $('#sc-id').value.trim();
4195
+ if (!id) return setStatus('#sc-status', 'Falta el id.', 'err');
4196
+ const type = $('#sc-type').value;
4197
+ const body = {
4198
+ id,
4199
+ name: $('#sc-name').value.trim() || id,
4200
+ type,
4201
+ spec: $('#sc-spec').value.trim(),
4202
+ cwd: $('#sc-cwd').value.trim() || undefined,
4203
+ enabled: $('#sc-enabled').checked,
4204
+ };
4205
+ if (type === 'shell') {
4206
+ body.command = $('#sc-command').value.trim();
4207
+ body.args = $('#sc-args').value.trim();
4208
+ } else {
4209
+ body.prompt = $('#sc-prompt').value.trim();
4210
+ body.mode = $('#sc-mode').value;
4211
+ }
4212
+ try {
4213
+ const exists = $('#sc-id').readOnly;
4214
+ await api(exists ? `/api/schedules/${encodeURIComponent(id)}` : '/api/schedules', {
4215
+ method: exists ? 'PUT' : 'POST',
4216
+ body,
4217
+ });
4218
+ } catch (error) {
4219
+ return setStatus('#sc-status', error.message, 'err');
4220
+ }
4221
+ closeScheduleForm();
4222
+ refreshSchedules();
4223
+ }
4224
+
4225
+ async function toggleSchedule(id) {
4226
+ await toggleEntity(`/api/schedules/${encodeURIComponent(id)}/toggle`, {
4227
+ method: 'POST',
4228
+ errorLabel: 'cambiar la tarea',
4229
+ refresh: refreshSchedules,
4230
+ });
4231
+ }
4232
+
4233
+ async function removeSchedule(id) {
4234
+ await deleteEntity(`/api/schedules/${encodeURIComponent(id)}`, {
4235
+ confirmText: `¿Eliminar la tarea "${id}"?`,
4236
+ refresh: refreshSchedules,
4237
+ });
4238
+ }
4239
+
4240
+ async function runSchedule(id) {
4241
+ setStatus('#schedule-status', `corriendo ${id}…`);
4242
+ try {
4243
+ await api(`/api/schedules/${encodeURIComponent(id)}/run`, { method: 'POST' });
4244
+ } catch (error) {
4245
+ setStatus('#schedule-status', error.message, 'err');
4246
+ refreshSchedules();
4247
+ return;
4248
+ }
4249
+ setStatus('#schedule-status', `${id}: listo`, 'ok');
4250
+ refreshSchedules();
4251
+ }
4252
+
4078
4253
  async function refreshChannels() {
4079
4254
  if (!tgStatusEl) return;
4080
4255
  const data = await api('/api/channels').catch(() => null);
@@ -4760,6 +4935,15 @@ $('#asesor-test')?.addEventListener('click', testAsesor);
4760
4935
  memoryModal?.addEventListener('click', (event) => {
4761
4936
  if (event.target === memoryModal) closeMemoryForm();
4762
4937
  });
4938
+
4939
+ $('#sc-type')?.addEventListener('change', scheduleTypeFields);
4940
+ $('#schedule-new')?.addEventListener('click', () => openScheduleForm());
4941
+ $('#schedule-close')?.addEventListener('click', closeScheduleForm);
4942
+ $('#sc-cancel')?.addEventListener('click', closeScheduleForm);
4943
+ scheduleForm?.addEventListener('submit', submitSchedule);
4944
+ scheduleModal?.addEventListener('click', (event) => {
4945
+ if (event.target === scheduleModal) closeScheduleForm();
4946
+ });
4763
4947
  }
4764
4948
 
4765
4949
  /* --- eval ---------------------------------------------------------------- */
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.13.0</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.14.0</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -462,6 +462,7 @@
462
462
  <button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
463
463
  <button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
464
464
  <button type="button" class="ajustes-tab" data-panel="memoria" role="tab">Memoria</button>
465
+ <button type="button" class="ajustes-tab" data-panel="tareas" role="tab">Tareas</button>
465
466
  <button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
466
467
  <button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
467
468
  <button type="button" class="ajustes-tab" data-panel="asesor" role="tab">Asesor</button>
@@ -603,6 +604,19 @@
603
604
  <ul id="memory-list" class="providers"></ul>
604
605
  </div>
605
606
 
607
+ <div class="panel ajustes-panel" data-panel="tareas">
608
+ <div class="row between">
609
+ <h2>Tareas</h2>
610
+ <button id="schedule-new" class="btn">+ tarea</button>
611
+ </div>
612
+ <p class="muted verify-line">
613
+ Trabajos recurrentes que corren solos mientras el server está vivo. <code>agent</code> corre un turno
614
+ (con las tools y permisos configurados); <code>shell</code> ejecuta un comando permitido.
615
+ </p>
616
+ <div id="schedule-status" class="summary"></div>
617
+ <ul id="schedule-list" class="providers"></ul>
618
+ </div>
619
+
606
620
  <div class="panel ajustes-panel" data-panel="telegram">
607
621
  <div class="row between">
608
622
  <h2>Telegram</h2>
@@ -1001,6 +1015,58 @@
1001
1015
  </form>
1002
1016
  </dialog>
1003
1017
 
1018
+ <dialog id="schedule-modal" class="modal">
1019
+ <form id="schedule-form" class="modal-card">
1020
+ <div class="row between">
1021
+ <h3 id="schedule-title">nueva tarea</h3>
1022
+ <button type="button" id="schedule-close" class="btn icon" title="cerrar" aria-label="cerrar">
1023
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1024
+ <path d="M18 6 6 18M6 6l12 12" />
1025
+ </svg>
1026
+ </button>
1027
+ </div>
1028
+ <div class="row">
1029
+ <label class="field"><span>id</span><input id="sc-id" spellcheck="false" placeholder="resumen-diario" /></label>
1030
+ <label class="field grow"><span>nombre</span><input id="sc-name" spellcheck="false" placeholder="Resumen diario" /></label>
1031
+ </div>
1032
+ <div class="row">
1033
+ <label class="field"><span>tipo</span>
1034
+ <select id="sc-type">
1035
+ <option value="agent">agent</option>
1036
+ <option value="shell">shell</option>
1037
+ </select>
1038
+ </label>
1039
+ <label class="field grow"><span>cuándo</span>
1040
+ <input id="sc-spec" spellcheck="false" placeholder="every 30 m · daily 09:00 · 0 6 * * 1" />
1041
+ </label>
1042
+ </div>
1043
+ <label class="field" id="sc-agent-prompt-row"><span>prompt (agent)</span>
1044
+ <textarea id="sc-prompt" rows="4" spellcheck="false" placeholder="Qué tiene que hacer el agente…"></textarea>
1045
+ </label>
1046
+ <label class="field" id="sc-agent-mode-row"><span>modo (agent)</span>
1047
+ <select id="sc-mode">
1048
+ <option value="agent">agent</option>
1049
+ <option value="plan">plan</option>
1050
+ </select>
1051
+ </label>
1052
+ <label class="field" id="sc-shell-cmd-row"><span>comando (shell)</span>
1053
+ <input id="sc-command" spellcheck="false" placeholder="node · npm · pnpm" />
1054
+ </label>
1055
+ <label class="field" id="sc-shell-args-row"><span>argumentos (shell)</span>
1056
+ <input id="sc-args" spellcheck="false" placeholder="run build" />
1057
+ </label>
1058
+ <label class="field"><span>carpeta (opcional)</span>
1059
+ <input id="sc-cwd" spellcheck="false" placeholder="por defecto: la carpeta del server" />
1060
+ </label>
1061
+ <label class="check"><input id="sc-enabled" type="checkbox" checked /> activa</label>
1062
+ <div class="row between settings-actions">
1063
+ <button type="button" id="sc-cancel" class="btn">cancelar</button>
1064
+ <button type="submit" class="btn primary">guardar</button>
1065
+ </div>
1066
+ <div id="sc-status" class="summary"></div>
1067
+ </form>
1068
+ </dialog>
1069
+
1004
1070
  <dialog id="palette-modal" class="modal palette-modal">
1005
1071
  <div class="palette">
1006
1072
  <div class="palette-row">