lampson 0.1.2 → 0.1.4

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 (49) hide show
  1. package/.env.example +9 -0
  2. package/README.md +60 -2
  3. package/chat.syn +264 -9
  4. package/lampson.ps1 +17 -3
  5. package/lampson.sh +17 -3
  6. package/lib/agents.syn +1 -1
  7. package/lib/approvals.syn +176 -0
  8. package/lib/diff.syn +37 -11
  9. package/lib/line.syn +13 -8
  10. package/lib/loop.syn +25 -11
  11. package/lib/md.syn +76 -18
  12. package/lib/permission.syn +18 -0
  13. package/lib/prompt.syn +1 -1
  14. package/lib/sched_run.syn +161 -0
  15. package/lib/schedule.syn +660 -0
  16. package/lib/session.syn +38 -1
  17. package/lib/settings.syn +55 -0
  18. package/lib/tools.syn +94 -2
  19. package/lib/ui.syn +161 -0
  20. package/package.json +1 -1
  21. package/public/css/chat.css +56 -0
  22. package/public/css/layout.css +97 -0
  23. package/public/css/panel.css +125 -0
  24. package/public/css/sidebar.css +80 -0
  25. package/public/css/tokens.css +57 -0
  26. package/public/index.html +49 -1187
  27. package/public/js/agents.js +31 -0
  28. package/public/js/app.js +21 -0
  29. package/public/js/approvals.js +26 -0
  30. package/public/js/chat.js +92 -0
  31. package/public/js/config.js +98 -0
  32. package/public/js/core.js +88 -0
  33. package/public/js/events.js +31 -0
  34. package/public/js/lamps.js +112 -0
  35. package/public/js/lsp.js +81 -0
  36. package/public/js/mcp.js +74 -0
  37. package/public/js/memory.js +17 -0
  38. package/public/js/panel.js +101 -0
  39. package/public/js/procs.js +45 -0
  40. package/public/js/schedules.js +118 -0
  41. package/public/js/sessions.js +69 -0
  42. package/public/js/sidebar.js +33 -0
  43. package/public/js/terminal.js +49 -0
  44. package/public/js/theme.js +6 -0
  45. package/public/js/todo.js +10 -0
  46. package/public/js/tree.js +53 -0
  47. package/public/js/update.js +14 -0
  48. package/skills/lampson/SKILL.md +16 -0
  49. package/web.syn +106 -15
@@ -0,0 +1,49 @@
1
+ // terminal.js — terminal real: xterm.js ↔ WebSocket /api/term ↔ pty en el servidor
2
+ // Frames binarios = bytes del pty; texto JSON = control (hello / exit). Cerrar el panel cierra el socket y mata el shell.
3
+ let term = null, termWs = null, termFit = null;
4
+ function termTheme() {
5
+ const s = getComputedStyle(document.documentElement); const v = n => s.getPropertyValue(n).trim();
6
+ return { background: v('--paper'), foreground: v('--ink'), cursor: v('--accent'), cursorAccent: v('--paper'), selectionBackground: v('--sel'),
7
+ black: v('--paper-3'), brightBlack: v('--ink-3'), red: v('--rubric'), brightRed: v('--rubric'), green: v('--str'), brightGreen: v('--str'),
8
+ yellow: v('--amber'), brightYellow: v('--amber'), blue: v('--term-blue'), brightBlue: v('--accent'), magenta: v('--rubric'), brightMagenta: v('--rubric'),
9
+ cyan: v('--accent'), brightCyan: v('--accent'), white: v('--ink-2'), brightWhite: v('--ink') };
10
+ }
11
+ function openTerm() {
12
+ if (term) { showPane('term'); termFit.fit(); term.focus(); return; }
13
+ showPane('term'); procOpen = null; clearInterval(procTimer);
14
+ term = new Terminal({ cursorBlink: true, fontFamily: getComputedStyle(document.documentElement).getPropertyValue('--mono'), fontSize: 13, lineHeight: 1.25, theme: termTheme(), scrollback: 5000, allowProposedApi: true });
15
+ termFit = new FitAddon.FitAddon(); term.loadAddon(termFit); term.open($('#xterm')); termFit.fit();
16
+ // URLs clickeables (npm run dev imprime http://localhost:3000): link provider mínimo con la API nativa
17
+ // de xterm v5 — el addon web-links no está vendorizado y no hace falta para http/https
18
+ const TERM_URL_RE = /https?:\/\/[^\s"'`<>()\[\]{}]*[^\s"'`<>()\[\]{}.,;:!?]/g;
19
+ term.registerLinkProvider({
20
+ provideLinks(y, cb) {
21
+ const line = term.buffer.active.getLine(y - 1);
22
+ if (!line) return cb(undefined);
23
+ const text = line.translateToString(true);
24
+ const links = []; let m; TERM_URL_RE.lastIndex = 0;
25
+ while ((m = TERM_URL_RE.exec(text))) {
26
+ links.push({ range: { start: { x: m.index + 1, y }, end: { x: m.index + m[0].length, y } }, text: m[0], activate: (_e, uri) => window.open(uri, '_blank') });
27
+ }
28
+ cb(links.length ? links : undefined);
29
+ }
30
+ });
31
+ $('#tmeta').textContent = 'conectando…';
32
+ termWs = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/api/term'); termWs.binaryType = 'arraybuffer';
33
+ const send = (o) => { if (termWs && termWs.readyState === 1) termWs.send(JSON.stringify(o)); };
34
+ termWs.onopen = () => { $('#termpane').classList.add('live'); send({ type: 'resize', cols: term.cols, rows: term.rows }); term.focus(); };
35
+ termWs.onmessage = (e) => { // "o" + salida del pty | "c" + JSON de control
36
+ const s = String(e.data);
37
+ if (s[0] === 'o') { term.write(s.slice(1)); return; }
38
+ let m; try { m = JSON.parse(s.slice(1)); } catch { return; }
39
+ if (m.type === 'hello') $('#tmeta').textContent = `${m.shell} · pid ${m.pid} · ${m.cwd || 'workspace'}`;
40
+ if (m.type === 'exit') { term.write(`\r\n\x1b[2m[shell terminado · código ${m.code}]\x1b[0m\r\n`); $('#termpane').classList.remove('live'); }
41
+ };
42
+ termWs.onclose = () => { $('#termpane').classList.remove('live'); if ($('#tmeta').textContent === 'conectando…') $('#tmeta').textContent = 'sin conexión'; };
43
+ term.onData(d => send({ type: 'in', data: d }));
44
+ term.onResize(({ cols, rows }) => send({ type: 'resize', cols, rows }));
45
+ new ResizeObserver(() => { if ($('#termpane').style.display !== 'none') termFit.fit(); }).observe($('#xterm'));
46
+ }
47
+ function closeTerm() { if (termWs) { try { termWs.close(); } catch (e) {} } if (term) term.dispose(); term = null; termWs = null; termFit = null; $('#xterm').innerHTML = ''; $('#termpane').classList.remove('live'); showPane('log'); }
48
+ $('#term').onclick = openTerm;
49
+ $('#tclose').onclick = closeTerm;
@@ -0,0 +1,6 @@
1
+ // theme.js — tema día / noche (el valor inicial ya lo puso el script del <head>)
2
+ const SUN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.7v2.1M12 19.2v2.1M2.7 12h2.1M19.2 12h2.1M5.3 5.3l1.5 1.5M17.2 17.2l1.5 1.5M18.7 5.3l-1.5 1.5M6.8 17.2l-1.5 1.5"/></svg>';
3
+ const MOON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M20.2 14.6A8.6 8.6 0 0 1 9.4 3.8a8.6 8.6 0 1 0 10.8 10.8z"/></svg>';
4
+ function paintTheme() { const light = document.documentElement.getAttribute('data-theme') === 'light'; $('#theme').innerHTML = light ? MOON : SUN; $('#theme').title = light ? 'Cambiar a tema oscuro' : 'Cambiar a tema claro'; }
5
+ $('#theme').onclick = () => { const r = document.documentElement; r.setAttribute('data-theme', r.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); try { localStorage.setItem('lampson.theme', r.getAttribute('data-theme')); } catch (e) {} paintTheme(); if (typeof term !== 'undefined' && term) term.options.theme = termTheme(); };
6
+ paintTheme();
@@ -0,0 +1,10 @@
1
+ // todo.js — lista de tareas del agente en la sesión actual (tool todo)
2
+ async function loadTodo() {
3
+ let r; try { r = await (await fetch('/api/todo' + (session ? '?session=' + encodeURIComponent(session) : ''))).json(); } catch (e) { return; }
4
+ const box = $('#todoBox'); box.innerHTML = '';
5
+ const items = r.items || []; const open = items.filter(i => i.status === 'pending' || i.status === 'in_progress').length;
6
+ $('#todoCount').textContent = items.length ? (open ? `${open} · ${items.length}` : `${items.length}`) : ''; autoSec('todo', open > 0);
7
+ if (!items.length) { box.innerHTML = '<div class="p none">ninguna</div>'; return; }
8
+ const mark = { completed: '☑', in_progress: '▶', cancelled: '✕', pending: '☐' };
9
+ for (const it of items) { const d = document.createElement('div'); d.className = 'p' + (it.status === 'in_progress' ? ' run' : ''); d.style.cursor = 'default'; d.innerHTML = `<span class="nm" style="opacity:${it.status === 'completed' || it.status === 'cancelled' ? .5 : 1}">${mark[it.status] || '☐'} ${esc(it.content)}</span>`; box.appendChild(d); }
10
+ }
@@ -0,0 +1,53 @@
1
+ // tree.js — árbol de archivos (panel derecho) con estado git, y el visor de archivos
2
+ let gitStatus = { changes: {} };
3
+ async function loadGit() {
4
+ try { gitStatus = await (await fetch('/api/git')).json(); } catch (e) { gitStatus = { repo: false, changes: {} }; }
5
+ const g = $('#git'); if (!gitStatus.repo) { g.style.display = 'none'; return; }
6
+ const k = gitStatus.counts || {}; const nuevos = (k.added||0)+(k.untracked||0);
7
+ const parts = []; // solo contadores distintos de cero — lo demás es ruido
8
+ if (k.modified) parts.push(`<b class="w">~${k.modified}</b>`);
9
+ if (nuevos) parts.push(`<b class="g">+${nuevos}</b>`);
10
+ if (k.deleted) parts.push(`<b class="r">−${k.deleted}</b>`);
11
+ g.innerHTML = `⎇ <span class="br">${esc(gitStatus.branch)}</span>` + (parts.length ? parts.join('') : '<b class="c">✓</b>');
12
+ g.title = parts.length ? `${k.modified||0} modificados · ${nuevos} nuevos · ${k.deleted||0} borrados` : 'working tree limpio';
13
+ g.style.display = 'flex';
14
+ }
15
+ function gitClass(path, isDir) {
16
+ const ch = gitStatus.changes || {};
17
+ if (!isDir) { const c = ch[path]; return c === '??' ? 'gU' : c === 'A' ? 'gA' : c === 'D' ? 'gD' : c ? 'gM' : ''; }
18
+ const p = path + '/'; for (const k in ch) if (k.startsWith(p)) return 'gM'; return '';
19
+ }
20
+ async function loadTree() {
21
+ await loadGit();
22
+ const box = $('#tree'); box.innerHTML = '';
23
+ let t; try { t = await (await fetch('/api/tree')).json(); } catch (e) { box.innerHTML = '<div class="row none">sin árbol</div>'; return; }
24
+ const render = (entries, parent, depth) => {
25
+ for (const e of entries) {
26
+ const n = document.createElement('div'); n.className = 'node';
27
+ const gc = gitClass(e.path, e.is_dir); const code = (gitStatus.changes || {})[e.path];
28
+ const r = document.createElement('div'); r.className = 'row' + (e.is_dir ? ' dir' : '') + (gc ? ' ' + gc : ''); r.title = e.path + (code ? ' · git ' + code : '');
29
+ r.innerHTML = `<span class="tw">${e.is_dir ? '▸' : ''}</span><span>${esc(e.name)}${e.is_dir ? '/' : ''}</span>` + (code && !e.is_dir ? `<span class="gs">${code === '??' ? 'N' : code}</span>` : '');
30
+ n.appendChild(r);
31
+ if (e.is_dir) {
32
+ const k = document.createElement('div'); k.className = 'kids hidden'; render(e.children || [], k, depth + 1); n.appendChild(k);
33
+ r.querySelector('.tw').textContent = '▸';
34
+ r.onclick = () => { k.classList.toggle('hidden'); r.querySelector('.tw').textContent = k.classList.contains('hidden') ? '▸' : '▾'; };
35
+ } else { r.onclick = () => openFile(e.path, r); }
36
+ parent.appendChild(n);
37
+ }
38
+ };
39
+ render(t.entries || [], box, 0);
40
+ if (t.truncated) { const d = document.createElement('div'); d.className = 'row none'; d.textContent = '… árbol truncado'; box.appendChild(d); }
41
+ }
42
+ async function openFile(path, row) {
43
+ document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active')); if (row) row.classList.add('active');
44
+ const r = await fetch('/api/file?path=' + encodeURIComponent(path)); const d = await r.json();
45
+ if (!r.ok) { add('denied', esc(d.error || 'error')); return; }
46
+ $('#vpath').textContent = d.path; $('#vmeta').textContent = d.lines + ' líneas';
47
+ $('#vbody').className = ''; $('#vbody').innerHTML = String(d.content).split('\n').map((l, i) => `<span class="ln">${i + 1}</span>${esc(l)}`).join('\n');
48
+ showPane('viewer'); $('#stage').scrollTop = 0;
49
+ }
50
+ // el árbol se refresca solo cuando una tool pudo crear/borrar archivos, con un pequeño debounce; ↻ lo fuerza a mano
51
+ function treeChanged() { debounce('tree', loadTree, 400); }
52
+ $('#tree-reload').onclick = e => { e.stopPropagation(); const b = e.currentTarget; b.classList.remove('spin'); void b.offsetWidth; b.classList.add('spin'); loadTree(); };
53
+ $('#vclose').onclick = () => { showPane('log'); procOpen = null; clearInterval(procTimer); $('#vbody').className = ''; document.querySelectorAll('.row.active, .m.active, .p.active').forEach(x => x.classList.remove('active')); memOpen = null; };
@@ -0,0 +1,14 @@
1
+ // update.js — actualizaciones de Lampson (una vez por carga; el fetch a origin puede tardar)
2
+ async function checkUpdate() {
3
+ let u; try { u = await (await fetch('/api/update')).json(); } catch (e) { return; }
4
+ const b = $('#update'); if (!u.available) { b.style.display = 'none'; return; }
5
+ b.textContent = `⬆ actualizar · ${u.behind} commit${u.behind === 1 ? '' : 's'}`;
6
+ b.title = `${u.current} → ${u.latest}\n${(u.notes || []).map(n => '· ' + n).join('\n')}\n\nen terminal: ${u.command}`;
7
+ b.style.display = '';
8
+ b.onclick = async () => {
9
+ b.disabled = true; b.textContent = 'actualizando…';
10
+ let r; try { r = await (await fetch('/api/update', { method: 'POST' })).json(); } catch (e) { r = { result: 'no se pudo actualizar: ' + e.message }; }
11
+ add('meta', '⬆ ' + esc(r.result || ''));
12
+ b.disabled = false; b.textContent = '⬆ reiniciá el servidor'; b.onclick = null;
13
+ };
14
+ }
@@ -40,6 +40,22 @@ description: How this harness works — tools, workspace mount, permissions, age
40
40
  brief — a child does not see this conversation, cannot ask the user and cannot delegate. Reports are
41
41
  self-reports: verify before claiming success. Live logs: `.lampson/agents/<id>.log`.
42
42
 
43
+ ## Scheduled tasks (`schedule` tool)
44
+ - When the user says "every day at 9", "each 6 hours", "on Mondays", "periodically", "send me", propose
45
+ `schedule(action=add, name, at, kind, …)`: `at` = `every 6h` | `daily 09:00` | `mon,wed 08:30` |
46
+ `weekdays 09:00`; one-time: `today 15:14` | `tomorrow 09:00` | `once 2026-08-29 15:14` | `in 2h` (it turns itself off after running) — the USER'S LOCAL time: write the hour as they say it, never convert to UTC (the tool result shows the next run with its offset). Tasks belong to the current workspace. `kind=lamp` (a lamp that is ON: lamp + tool + args), `kind=bash` (one
47
+ command that finishes on its own), `kind=prompt` (an unattended agent run: write a self-contained prompt —
48
+ what to do, how to verify, what to report — and pick `agent` build/review/plan/explore).
49
+ - `permission` is the envelope of a `prompt` run with nobody watching: `strict` (dangerous → denied),
50
+ `ask` (default: the user gets an approval request in the web UI and, if configured, a link on their
51
+ phone; denied if unanswered within `approval_timeout`), `yolo`. Prefer `strict` or `review`/`plan` profiles
52
+ for reports; `ask`/`build` only when the task must change things.
53
+ - `add` ALWAYS asks the user (it authorizes future runs). `notify` = a webhook URL that receives the result
54
+ as JSON — the way to "search X and send it to me" without an MCP.
55
+ - Tasks are executed by Lampson's resident process. If `schedule(action=list)` says no scheduler is running,
56
+ tell the user: `lampson --daemon start` (or keep `lampson --web` open). A `prompt` run's report lands in a NEW session named
57
+ `⏰ <name>` (never in the current chat — say so); `action=log` shows the runs.
58
+
43
59
  ## When you need the USER to run something
44
60
  The user can run any command themselves from the chat by prefixing it with `!` — e.g. `!npm run dev`,
45
61
  `!cat .env`, `!git push`. Their command runs without the permission policy and its output is added to
package/web.syn CHANGED
@@ -23,6 +23,7 @@ require serve(8080)
23
23
  require net
24
24
  require time
25
25
  require exec
26
+ require random
26
27
  require env("LAMPSON_*")
27
28
  require env("OS")
28
29
  require secret("LAMPSON_*")
@@ -54,6 +55,10 @@ use "./lib/mcp.syn" as mcp
54
55
  use "./lib/lamps.syn" as lamps
55
56
  use "./lib/lsp.syn" as lsp
56
57
  use "./lib/tools/todo.syn" as todo
58
+ use "./lib/schedule.syn" as schedule
59
+ use "./lib/sched_run.syn" as sched_run
60
+ use "./lib/approvals.syn" as approvals
61
+ use "./lib/permission.syn" as permission
57
62
 
58
63
  let APPROVAL_TIMEOUT be 180
59
64
 
@@ -106,6 +111,15 @@ task boot(profile, mode, ask_fn, pname, model)
106
111
  task lampson_subagent(spec_json)
107
112
  give agents.run_child_json(spec_json)
108
113
 
114
+ -- TAREAS PROGRAMADAS (lib/schedule.syn): este proceso es el residente de lampson — con `lampson --daemon start`
115
+ -- (synsema daemon) queda en background y ejecuta lo programado aunque no haya ninguna UI abierta. Un tick cada
116
+ -- 30 s (cron_every de Synsema: intervalo puro; la hora de reloj la resuelve schedule.syn) corre lo vencido:
117
+ -- lámparas, comandos y corridas del agente con su sobre de permisos; una aprobación en una corrida desatendida
118
+ -- sale por lib/approvals.syn (panel «Aprobaciones» de la UI, webhook firmado, link de un solo uso).
119
+ task sched_tick()
120
+ give sched_run.tick()
121
+ cron_every(schedule.TICK_SECONDS, sched_tick)
122
+
109
123
  -- servers MCP: supervisores (agentes) lanzados al arrancar; los handlers leen su estado del blackboard
110
124
  mcp.start_all(8)
111
125
 
@@ -122,6 +136,12 @@ serve on 8080
122
136
  route "GET /api/sessions" requires auth
123
137
  give {"sessions": session.list()}
124
138
 
139
+ -- buscador de sesiones (modal «ver todas»): ?q=texto busca en títulos y mensajes de este proyecto
140
+ route "GET /api/sessions/search" requires auth
141
+ let q be when contains(query, "q") then text(query.q) otherwise ""
142
+ let n be when contains(query, "limit") then floor(number(query.limit)) otherwise 200
143
+ give {"q": q, "sessions": session.search(q, n)}
144
+
125
145
  route "GET /api/sessions/:id" requires auth
126
146
  when not session.exists(params.id)
127
147
  give not_found("session not found")
@@ -311,7 +331,7 @@ serve on 8080
311
331
  -- tiene su cola acotada (drop_oldest). Heartbeat cada 25 s para que el proxy/navegador no cierre.
312
332
  route "GET /api/events" requires auth
313
333
  stream
314
- let sub be bus_subscribe(["proc.*", "subagent.*", "mcp.*"], {"max_queue": 512})
334
+ let sub be bus_subscribe(["proc.*", "subagent.*", "mcp.*", "approval.*", "schedule.*"], {"max_queue": 512})
315
335
  let open be true
316
336
  while open
317
337
  let ev be bus_recv(sub, 25)
@@ -375,11 +395,88 @@ serve on 8080
375
395
  give fail(400, "path required")
376
396
  give tree.file_content(query.path)
377
397
 
398
+ -- configuración general (rueda de la UI): zona horaria, URL pública, webhook… → .lampson/config.json
399
+ route "GET /api/settings/values" requires auth
400
+ give {"values": settings.values(), "tz_detected": schedule.tz_offset()}
401
+
402
+ route "POST /api/settings/values" requires auth
403
+ let b be json of request
404
+ try
405
+ each k in keys(b)
406
+ settings.set_value(k, b[k])
407
+ -- la zona horaria cacheada en el blackboard se recalcula
408
+ share nothing as "lampson:tz"
409
+ give {"ok": true, "values": settings.values(), "tz_detected": schedule.tz_offset()}
410
+ recover err
411
+ give fail(400, text(err))
412
+
413
+ -- aprobaciones (lib/approvals.syn): las del chat y las de tareas programadas comparten la cola
378
414
  route "POST /api/approve" requires auth
379
415
  expect body {id: text, decision: bool}
380
416
  let b be json of request
381
- state_set("approval:" + b["id"], when b["decision"] then "yes" otherwise "no")
382
- give {"ok": true}
417
+ give {"ok": approvals.answer(text(b["id"]), b["decision"])}
418
+
419
+ route "GET /api/approvals" requires auth
420
+ give {"approvals": approvals.list(), "public_url": approvals.public_url(), "webhook": approvals.webhook_url() != ""}
421
+
422
+ -- link de decisión (PÚBLICO a propósito: lo abre el humano desde el teléfono; el token de un solo uso es la
423
+ -- autorización, como en el `approve` nativo de Synsema bajo serve). GET porque un link en un chat es un GET.
424
+ route "GET /approve/:id/:token"
425
+ let d be when contains(query, "d") then lower(text(query.d)) otherwise ""
426
+ when d != "yes" and d != "no"
427
+ give html(approvals.html_page("¿Permitir?", "<a href='?d=yes' style='display:inline-block;padding:10px 18px;border-radius:4px;background:#1d1c1a;color:#fff;text-decoration:none;margin-right:10px'>Sí, permitir</a> <a href='?d=no' style='display:inline-block;padding:10px 18px;border-radius:4px;border:1px solid #a33;color:#a33;text-decoration:none'>No, denegar</a>"))
428
+ let r be approvals.answer_with_token(text(params.id), text(params.token), d == "yes")
429
+ when not r["ok"]
430
+ give html(approvals.html_page("Este link ya no sirve", "La aprobación venció, ya fue respondida o el link no es válido (" + r["why"] + ")."))
431
+ give html(approvals.html_page(when d == "yes" then "✓ Permitido" otherwise "✗ Denegado", "Lampson sigue con la tarea. Podés cerrar esta pestaña."))
432
+
433
+ -- tareas programadas (lib/schedule.syn): listar, crear, quitar, encender/apagar, correr ahora, log
434
+ route "GET /api/schedules" requires auth
435
+ let age be schedule.daemon_age()
436
+ -- este proceso tiene su propio tick (cron_every): las tareas corren mientras la web esté abierta
437
+ give {"tasks": schedule.summary(), "tick": schedule.TICK_SECONDS, "alive": true, "heartbeat_age": age, "mount_ok": schedule.mount_ok(), "workspace": env("LAMPSON_WORKSPACE", ""), "tz": schedule.tz_offset(), "lamps": lamps.summary()}
438
+
439
+ route "POST /api/schedules/add" requires auth
440
+ let b be json of request
441
+ try
442
+ let t be schedule.add(b)
443
+ give {"ok": true, "task": t, "result": "programada «" + t["name"] + "»: " + schedule.describe_plan(t["plan"]) + " · próxima " + schedule.fmt_local(t["next_run"])}
444
+ recover err
445
+ give fail(400, text(err))
446
+
447
+ route "POST /api/schedules/remove" requires auth
448
+ expect body {id: text}
449
+ let b be json of request
450
+ try
451
+ give {"ok": true, "result": schedule.remove(text(b["id"]))}
452
+ recover err
453
+ give fail(400, text(err))
454
+
455
+ route "POST /api/schedules/toggle" requires auth
456
+ expect body {id: text}
457
+ let b be json of request
458
+ let on be contains(b, "enabled") and b["enabled"] == true
459
+ try
460
+ give {"ok": true, "result": schedule.set_enabled(text(b["id"]), on)}
461
+ recover err
462
+ give fail(400, text(err))
463
+
464
+ -- correr ahora: la marca run_now la levanta el próximo tick (así el request no se queda minutos abierto
465
+ -- y la corrida vive en el hilo del cron, no en el del request)
466
+ route "POST /api/schedules/run" requires auth
467
+ expect body {id: text}
468
+ let b be json of request
469
+ try
470
+ let t be schedule.request_run(text(b["id"]))
471
+ give {"ok": true, "result": "«" + t["name"] + "» corre en el próximo tick (≤ " + text(schedule.TICK_SECONDS) + " s)"}
472
+ recover err
473
+ give fail(400, text(err))
474
+
475
+ route "GET /api/schedules/log" requires auth
476
+ when not contains(query, "id")
477
+ give fail(400, "id required")
478
+ let n be when contains(query, "tail") then floor(number(query.tail)) otherwise 300
479
+ give {"id": query.id, "log": schedule.log_tail(query.id, n), "task": schedule.get(query.id)}
383
480
 
384
481
  route "POST /api/chat" requires auth
385
482
  expect body {message: text}
@@ -396,20 +493,14 @@ serve on 8080
396
493
  task emit(kind, data, tag)
397
494
  trace.event(sid, kind, data, tag)
398
495
  send {"kind": kind, "data": data, "tag": tag} as "event"
399
- -- aprobación humana desde la UI: pregunta por SSE y espera la respuesta (o el timeout)
496
+ -- aprobación humana: la cola de lib/approvals.syn (id + token) la UI responde por SSE/POST /api/approve,
497
+ -- y si hay webhook/LAMPSON_PUBLIC_URL también llega un link al canal del usuario (aprobar desde el móvil)
400
498
  task ask_web(name, args, why)
401
- let id be text(floor(now() * 1000)) + "-" + name
499
+ let id be approvals.open("chat", permission.describe_call(name, args), why, APPROVAL_TIMEOUT)
402
500
  send {"kind": "approval_request", "data": {"id": id, "name": name, "args": args, "why": why, "timeout": APPROVAL_TIMEOUT}} as "event"
403
- let waited be 0
404
- let answer be nothing
405
- while answer == nothing and waited < APPROVAL_TIMEOUT
406
- set answer to state_get("approval:" + id, nothing)
407
- when answer == nothing
408
- sleep(0.5)
409
- set waited to waited + 0.5
410
- state_delete("approval:" + id)
411
- let ok be answer == "yes"
412
- send {"kind": "approval_result", "data": {"id": id, "approved": ok, "timeout": answer == nothing}} as "event"
501
+ let t0 be now()
502
+ let ok be approvals.wait(id, APPROVAL_TIMEOUT)
503
+ send {"kind": "approval_result", "data": {"id": id, "approved": ok, "timeout": (not ok) and now() - t0 >= APPROVAL_TIMEOUT - 1}} as "event"
413
504
  give ok
414
505
  let ctx be boot(want, mode, ask_web, pname, pmodel)
415
506
  let messages be [ctx["system"]]