@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
@@ -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');
@@ -3730,6 +3754,8 @@ const asesorExplainEl = $('#asesor-explain');
3730
3754
  const asesorAdviceEl = $('#asesor-advice');
3731
3755
  const asesorWordsEl = $('#asesor-words');
3732
3756
  const asesorNotesEl = $('#asesor-notes');
3757
+ const asesorProviderEl = $('#asesor-provider');
3758
+ const asesorModelEl = $('#asesor-model');
3733
3759
  const trProviderEl = $('#tr-provider');
3734
3760
  const trModelEl = $('#tr-model');
3735
3761
 
@@ -4075,6 +4101,157 @@ async function saveMemoryIndex() {
4075
4101
  refreshMemory();
4076
4102
  }
4077
4103
 
4104
+ /* --- ajustes: Tareas programadas ----------------------------------------- */
4105
+
4106
+ function fmtWhen(ms) {
4107
+ try {
4108
+ return new Date(ms).toLocaleString();
4109
+ } catch {
4110
+ return '';
4111
+ }
4112
+ }
4113
+
4114
+ function scheduleTypeFields() {
4115
+ const type = $('#sc-type').value;
4116
+ const agent = type === 'agent';
4117
+ $('#sc-agent-prompt-row').hidden = !agent;
4118
+ $('#sc-agent-mode-row').hidden = !agent;
4119
+ $('#sc-shell-cmd-row').hidden = agent;
4120
+ $('#sc-shell-args-row').hidden = agent;
4121
+ }
4122
+
4123
+ async function refreshSchedules() {
4124
+ if (!scheduleListEl) return;
4125
+ const data = await api('/api/schedules').catch(() => null);
4126
+ if (!data) return;
4127
+ clear(scheduleListEl);
4128
+ if (!data.schedules.length) {
4129
+ scheduleListEl.appendChild(el('li', 'muted', 'sin tareas todavía'));
4130
+ return;
4131
+ }
4132
+ for (const s of data.schedules) {
4133
+ const li = el('li', s.enabled ? 'active' : '');
4134
+ li.appendChild(el('span', `pdot${s.enabled ? ' on' : ''}`));
4135
+ const main = el('div', 'grow');
4136
+ const meta = [s.type, s.spec];
4137
+ if (s.nextRun) meta.push(`próxima ${fmtWhen(s.nextRun)}`);
4138
+ if (s.lastStatus) meta.push(s.lastStatus);
4139
+ main.append(el('div', '', s.name || s.id), el('div', 'pmeta', meta.join(' · ')));
4140
+ li.appendChild(main);
4141
+ const actions = el('div', 'pactions');
4142
+ actions.appendChild(button('correr', (event) => { event.stopPropagation(); runSchedule(s.id); }));
4143
+ actions.appendChild(button(s.enabled ? 'pausar' : 'activar', (event) => { event.stopPropagation(); toggleSchedule(s.id); }));
4144
+ actions.appendChild(button('editar', (event) => { event.stopPropagation(); openScheduleForm(s.id); }));
4145
+ actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeSchedule(s.id); }, 'btn danger'));
4146
+ li.appendChild(actions);
4147
+ li.addEventListener('click', () => openScheduleForm(s.id));
4148
+ scheduleListEl.appendChild(li);
4149
+ }
4150
+ }
4151
+
4152
+ async function openScheduleForm(id = null) {
4153
+ $('#schedule-title').textContent = id ? `editar ${id}` : 'nueva tarea';
4154
+ $('#sc-id').value = id ?? '';
4155
+ $('#sc-id').readOnly = Boolean(id);
4156
+ $('#sc-name').value = '';
4157
+ $('#sc-type').value = 'agent';
4158
+ $('#sc-spec').value = '';
4159
+ $('#sc-prompt').value = '';
4160
+ $('#sc-mode').value = 'agent';
4161
+ $('#sc-command').value = '';
4162
+ $('#sc-args').value = '';
4163
+ $('#sc-cwd').value = '';
4164
+ $('#sc-enabled').checked = true;
4165
+ setStatus('#sc-status', '');
4166
+ if (id) {
4167
+ const data = await api('/api/schedules').catch(() => null);
4168
+ const s = data?.schedules?.find((item) => item.id === id);
4169
+ if (s) {
4170
+ $('#sc-name').value = s.name ?? '';
4171
+ $('#sc-type').value = s.type ?? 'agent';
4172
+ $('#sc-spec').value = s.spec ?? '';
4173
+ $('#sc-cwd').value = s.cwd ?? '';
4174
+ $('#sc-enabled').checked = s.enabled !== false;
4175
+ const payload = s.payload ?? {};
4176
+ if (s.type === 'shell') {
4177
+ $('#sc-command').value = payload.command ?? '';
4178
+ $('#sc-args').value = Array.isArray(payload.args) ? payload.args.join(' ') : payload.args ?? '';
4179
+ } else {
4180
+ $('#sc-prompt').value = payload.prompt ?? '';
4181
+ $('#sc-mode').value = payload.mode ?? 'agent';
4182
+ }
4183
+ }
4184
+ }
4185
+ scheduleTypeFields();
4186
+ scheduleModal.showModal();
4187
+ if (!id) $('#sc-id').focus();
4188
+ }
4189
+
4190
+ function closeScheduleForm() {
4191
+ if (scheduleModal.open) scheduleModal.close();
4192
+ }
4193
+
4194
+ async function submitSchedule(event) {
4195
+ event.preventDefault();
4196
+ const id = $('#sc-id').value.trim();
4197
+ if (!id) return setStatus('#sc-status', 'Falta el id.', 'err');
4198
+ const type = $('#sc-type').value;
4199
+ const body = {
4200
+ id,
4201
+ name: $('#sc-name').value.trim() || id,
4202
+ type,
4203
+ spec: $('#sc-spec').value.trim(),
4204
+ cwd: $('#sc-cwd').value.trim() || undefined,
4205
+ enabled: $('#sc-enabled').checked,
4206
+ };
4207
+ if (type === 'shell') {
4208
+ body.command = $('#sc-command').value.trim();
4209
+ body.args = $('#sc-args').value.trim();
4210
+ } else {
4211
+ body.prompt = $('#sc-prompt').value.trim();
4212
+ body.mode = $('#sc-mode').value;
4213
+ }
4214
+ try {
4215
+ const exists = $('#sc-id').readOnly;
4216
+ await api(exists ? `/api/schedules/${encodeURIComponent(id)}` : '/api/schedules', {
4217
+ method: exists ? 'PUT' : 'POST',
4218
+ body,
4219
+ });
4220
+ } catch (error) {
4221
+ return setStatus('#sc-status', error.message, 'err');
4222
+ }
4223
+ closeScheduleForm();
4224
+ refreshSchedules();
4225
+ }
4226
+
4227
+ async function toggleSchedule(id) {
4228
+ await toggleEntity(`/api/schedules/${encodeURIComponent(id)}/toggle`, {
4229
+ method: 'POST',
4230
+ errorLabel: 'cambiar la tarea',
4231
+ refresh: refreshSchedules,
4232
+ });
4233
+ }
4234
+
4235
+ async function removeSchedule(id) {
4236
+ await deleteEntity(`/api/schedules/${encodeURIComponent(id)}`, {
4237
+ confirmText: `¿Eliminar la tarea "${id}"?`,
4238
+ refresh: refreshSchedules,
4239
+ });
4240
+ }
4241
+
4242
+ async function runSchedule(id) {
4243
+ setStatus('#schedule-status', `corriendo ${id}…`);
4244
+ try {
4245
+ await api(`/api/schedules/${encodeURIComponent(id)}/run`, { method: 'POST' });
4246
+ } catch (error) {
4247
+ setStatus('#schedule-status', error.message, 'err');
4248
+ refreshSchedules();
4249
+ return;
4250
+ }
4251
+ setStatus('#schedule-status', `${id}: listo`, 'ok');
4252
+ refreshSchedules();
4253
+ }
4254
+
4078
4255
  async function refreshChannels() {
4079
4256
  if (!tgStatusEl) return;
4080
4257
  const data = await api('/api/channels').catch(() => null);
@@ -4559,6 +4736,8 @@ function aplicarAsesor(data) {
4559
4736
  if (asesorAdviceEl) asesorAdviceEl.checked = data.advice !== false;
4560
4737
  if (asesorWordsEl) asesorWordsEl.value = data.maxWords ?? 40;
4561
4738
  if (asesorNotesEl) asesorNotesEl.value = data.notes ?? '';
4739
+ if (asesorProviderEl) asesorProviderEl.value = data.provider ?? '';
4740
+ if (asesorModelEl) asesorModelEl.value = data.model ?? '';
4562
4741
  const estado = $('#asesor-status');
4563
4742
  if (estado) {
4564
4743
  estado.className = `summary${data.enabled ? ' ok' : ''}`;
@@ -4580,6 +4759,8 @@ function asesorBody() {
4580
4759
  advice: asesorAdviceEl?.checked !== false,
4581
4760
  maxWords: Number(asesorWordsEl?.value) || 40,
4582
4761
  notes: asesorNotesEl?.value ?? '',
4762
+ provider: asesorProviderEl?.value ?? '',
4763
+ model: asesorModelEl?.value ?? '',
4583
4764
  };
4584
4765
  }
4585
4766
 
@@ -4760,6 +4941,15 @@ $('#asesor-test')?.addEventListener('click', testAsesor);
4760
4941
  memoryModal?.addEventListener('click', (event) => {
4761
4942
  if (event.target === memoryModal) closeMemoryForm();
4762
4943
  });
4944
+
4945
+ $('#sc-type')?.addEventListener('change', scheduleTypeFields);
4946
+ $('#schedule-new')?.addEventListener('click', () => openScheduleForm());
4947
+ $('#schedule-close')?.addEventListener('click', closeScheduleForm);
4948
+ $('#sc-cancel')?.addEventListener('click', closeScheduleForm);
4949
+ scheduleForm?.addEventListener('submit', submitSchedule);
4950
+ scheduleModal?.addEventListener('click', (event) => {
4951
+ if (event.target === scheduleModal) closeScheduleForm();
4952
+ });
4763
4953
  }
4764
4954
 
4765
4955
  /* --- 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.1</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.15.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>
@@ -723,8 +737,16 @@
723
737
  <p class="muted verify-line">
724
738
  Un asesor que observa lo que el agente hace, lo interpreta y solo habla cuando tiene
725
739
  algo que aportar: te alerta de un problema en el código, te explica un proceso o te da
726
- un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos.
740
+ un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos, o unos propios.
727
741
  </p>
742
+ <div class="row">
743
+ <label class="field"><span>proveedor (opcional)</span>
744
+ <input id="asesor-provider" spellcheck="false" placeholder="por defecto: el activo" />
745
+ </label>
746
+ <label class="field grow"><span>modelo (opcional)</span>
747
+ <input id="asesor-model" spellcheck="false" placeholder="por defecto: el activo" />
748
+ </label>
749
+ </div>
728
750
  <label class="check" title="tener un asesor que interprete y comente el turno">
729
751
  <input id="asesor-enabled" type="checkbox" /> Habilitar el asesor
730
752
  </label>
@@ -1001,6 +1023,58 @@
1001
1023
  </form>
1002
1024
  </dialog>
1003
1025
 
1026
+ <dialog id="schedule-modal" class="modal">
1027
+ <form id="schedule-form" class="modal-card">
1028
+ <div class="row between">
1029
+ <h3 id="schedule-title">nueva tarea</h3>
1030
+ <button type="button" id="schedule-close" class="btn icon" title="cerrar" aria-label="cerrar">
1031
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1032
+ <path d="M18 6 6 18M6 6l12 12" />
1033
+ </svg>
1034
+ </button>
1035
+ </div>
1036
+ <div class="row">
1037
+ <label class="field"><span>id</span><input id="sc-id" spellcheck="false" placeholder="resumen-diario" /></label>
1038
+ <label class="field grow"><span>nombre</span><input id="sc-name" spellcheck="false" placeholder="Resumen diario" /></label>
1039
+ </div>
1040
+ <div class="row">
1041
+ <label class="field"><span>tipo</span>
1042
+ <select id="sc-type">
1043
+ <option value="agent">agent</option>
1044
+ <option value="shell">shell</option>
1045
+ </select>
1046
+ </label>
1047
+ <label class="field grow"><span>cuándo</span>
1048
+ <input id="sc-spec" spellcheck="false" placeholder="every 30 m · daily 09:00 · 0 6 * * 1" />
1049
+ </label>
1050
+ </div>
1051
+ <label class="field" id="sc-agent-prompt-row"><span>prompt (agent)</span>
1052
+ <textarea id="sc-prompt" rows="4" spellcheck="false" placeholder="Qué tiene que hacer el agente…"></textarea>
1053
+ </label>
1054
+ <label class="field" id="sc-agent-mode-row"><span>modo (agent)</span>
1055
+ <select id="sc-mode">
1056
+ <option value="agent">agent</option>
1057
+ <option value="plan">plan</option>
1058
+ </select>
1059
+ </label>
1060
+ <label class="field" id="sc-shell-cmd-row"><span>comando (shell)</span>
1061
+ <input id="sc-command" spellcheck="false" placeholder="node · npm · pnpm" />
1062
+ </label>
1063
+ <label class="field" id="sc-shell-args-row"><span>argumentos (shell)</span>
1064
+ <input id="sc-args" spellcheck="false" placeholder="run build" />
1065
+ </label>
1066
+ <label class="field"><span>carpeta (opcional)</span>
1067
+ <input id="sc-cwd" spellcheck="false" placeholder="por defecto: la carpeta del server" />
1068
+ </label>
1069
+ <label class="check"><input id="sc-enabled" type="checkbox" checked /> activa</label>
1070
+ <div class="row between settings-actions">
1071
+ <button type="button" id="sc-cancel" class="btn">cancelar</button>
1072
+ <button type="submit" class="btn primary">guardar</button>
1073
+ </div>
1074
+ <div id="sc-status" class="summary"></div>
1075
+ </form>
1076
+ </dialog>
1077
+
1004
1078
  <dialog id="palette-modal" class="modal palette-modal">
1005
1079
  <div class="palette">
1006
1080
  <div class="palette-row">
@@ -183,9 +183,11 @@
183
183
  }
184
184
  .nav-icon:hover { color: var(--accent); }
185
185
  .nav-icon svg { width: 17px; height: 17px; display: block; }
186
- #theme-toggle .ico-moon { display: none; }
187
- html[data-theme='dark'] #theme-toggle .ico-sun { display: none; }
188
- html[data-theme='dark'] #theme-toggle .ico-moon { display: block; }
186
+ /* El icono muestra el tema al que se va a cambiar: claro -> luna, oscuro -> sol. */
187
+ #theme-toggle .ico-sun { display: none; }
188
+ #theme-toggle .ico-moon { display: block; }
189
+ html[data-theme='dark'] #theme-toggle .ico-sun { display: block; }
190
+ html[data-theme='dark'] #theme-toggle .ico-moon { display: none; }
189
191
 
190
192
  .hero {
191
193
  width: min(1180px, calc(100% - 48px));
@@ -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%;
@@ -15,6 +15,8 @@ export const DEFAULT_ADVISOR = {
15
15
  advice: true,
16
16
  notes: '',
17
17
  maxWords: 40,
18
+ provider: '',
19
+ model: '',
18
20
  };
19
21
 
20
22
  const KINDS = new Set(['code', 'explain', 'advice']);
@@ -34,6 +36,8 @@ export function resolveAdvisor(app) {
34
36
  maxWords: Number.isFinite(maxWords)
35
37
  ? Math.min(Math.max(Math.round(maxWords), 10), 120)
36
38
  : DEFAULT_ADVISOR.maxWords,
39
+ provider: typeof saved.provider === 'string' ? saved.provider.trim().slice(0, 60) : '',
40
+ model: typeof saved.model === 'string' ? saved.model.trim().slice(0, 120) : '',
37
41
  };
38
42
  }
39
43
 
@@ -20,21 +20,44 @@ export function toolCallPayload(event) {
20
20
  * Combina tools locales + admin + subagentes en un toolset unificado.
21
21
  * `approve` es el callback de aprobacion de las tools de administraación.
22
22
  */
23
- export function combineToolset({ local, admin = null, tasks = null, exposeAdmin = false, approve = null }) {
23
+ export function combineToolset({ local, admin = null, tasks = null, plugins = null, exposeAdmin = false, approve = null }) {
24
24
  const adminAllowed = (name) => Boolean(admin?.handles(name)) && (exposeAdmin || admin.readOnly?.has(name));
25
25
  const definitions = [
26
26
  ...local.definitions,
27
27
  ...(admin ? admin.definitions.filter((d) => adminAllowed(d.function.name)) : []),
28
28
  ...(tasks ? tasks.definitions : []),
29
+ ...(plugins ? plugins.definitions : []),
29
30
  ];
30
31
  const execute = (name, args, context = {}) => {
31
32
  if (tasks?.handles(name)) return tasks.execute(name, args);
32
33
  if (adminAllowed(name)) return admin.execute(name, args, { approve, ...context });
34
+ if (plugins?.handles(name)) return plugins.execute(name, args, { approve, ...context });
33
35
  return local.execute(name, args, context);
34
36
  };
35
37
  return { definitions, execute };
36
38
  }
37
39
 
40
+ /**
41
+ * Combina varios handlers de `onLifecycle` en uno: los corre a todos y concatena
42
+ * los `inject` que devuelvan (reglas por path, más tarde hooks).
43
+ */
44
+ export function combineLifecycle(handlers = []) {
45
+ const active = handlers.filter(Boolean);
46
+ if (!active.length) return null;
47
+ return async function onLifecycle(event) {
48
+ const injects = [];
49
+ for (const handler of active) {
50
+ const out = await Promise.resolve()
51
+ .then(() => handler(event))
52
+ .catch(() => undefined);
53
+ if (out && typeof out === 'object' && typeof out.inject === 'string' && out.inject.trim()) {
54
+ injects.push(out.inject);
55
+ }
56
+ }
57
+ return injects.length ? { inject: injects.join('\n\n') } : undefined;
58
+ };
59
+ }
60
+
38
61
  /** Resuelve proveedor + config con la API key almacenada. */
39
62
  export function resolveProviderFor(ctx, providerId, extra = {}) {
40
63
  return ctx.resolveProvider({
@@ -477,7 +477,13 @@ export function startTelegramChannel(
477
477
  });
478
478
  const exposeAdmin = !mode.tools;
479
479
  const admin = ctx.agentTools;
480
- const { definitions, execute } = combineToolset({ local, admin, exposeAdmin, approve: onAsk });
480
+ const { definitions, execute } = combineToolset({
481
+ local,
482
+ admin,
483
+ plugins: exposeAdmin ? ctx.plugins : null,
484
+ exposeAdmin,
485
+ approve: onAsk,
486
+ });
481
487
  const { provider, config } = resolveProviderFor(ctx, providerId);
482
488
  const userMessage = buildUserMessage(input, images);
483
489
  const messages = await composeAgentMessages({ ctx, cwd, mode, history, userMessage });
@@ -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
+ }