lampson 0.1.3 → 0.2.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 (55) hide show
  1. package/.env.example +9 -0
  2. package/README.md +87 -4
  3. package/bin/lampson.js +1 -1
  4. package/chat.syn +133 -0
  5. package/cli.syn +68 -0
  6. package/hub.tpl.syn +127 -0
  7. package/lampson.ps1 +77 -51
  8. package/lampson.sh +68 -22
  9. package/lib/agents.syn +1 -1
  10. package/lib/approvals.syn +179 -0
  11. package/lib/lsp.syn +10 -1
  12. package/lib/mcp.syn +10 -1
  13. package/lib/permission.syn +18 -0
  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 +66 -3
  18. package/lib/skills.syn +2 -0
  19. package/lib/tools.syn +94 -2
  20. package/lib/workspaces.syn +555 -0
  21. package/package.json +3 -1
  22. package/public/css/chat.css +56 -0
  23. package/public/css/hub.css +12 -0
  24. package/public/css/layout.css +97 -0
  25. package/public/css/panel.css +125 -0
  26. package/public/css/sidebar.css +80 -0
  27. package/public/css/tokens.css +57 -0
  28. package/public/hub.html +36 -0
  29. package/public/index.html +51 -1197
  30. package/public/js/agents.js +31 -0
  31. package/public/js/app.js +21 -0
  32. package/public/js/approvals.js +26 -0
  33. package/public/js/chat.js +92 -0
  34. package/public/js/config.js +100 -0
  35. package/public/js/core.js +91 -0
  36. package/public/js/events.js +31 -0
  37. package/public/js/hub.js +40 -0
  38. package/public/js/lamps.js +112 -0
  39. package/public/js/lsp.js +81 -0
  40. package/public/js/mcp.js +74 -0
  41. package/public/js/memory.js +17 -0
  42. package/public/js/panel.js +101 -0
  43. package/public/js/procs.js +45 -0
  44. package/public/js/schedules.js +118 -0
  45. package/public/js/sessions.js +69 -0
  46. package/public/js/sidebar.js +33 -0
  47. package/public/js/terminal.js +49 -0
  48. package/public/js/theme.js +6 -0
  49. package/public/js/todo.js +10 -0
  50. package/public/js/tree.js +53 -0
  51. package/public/js/update.js +14 -0
  52. package/public/js/workspaces.js +83 -0
  53. package/skills/lampson/SKILL.md +21 -0
  54. package/skills/synsema/SKILL.md +4 -1
  55. package/web.syn +165 -55
@@ -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 + BASE + '/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(BASE + '/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(BASE + '/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(BASE + '/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(BASE + '/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(BASE + '/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(BASE + '/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
+ }
@@ -0,0 +1,83 @@
1
+ // workspaces.js — el selector de workspace de la cabecera (dentro de un workspace) y el Panel de workspaces.
2
+ // La API de workspaces es del HUB (raíz, sin BASE): /api/workspaces… Sin hub (standalone) el selector se oculta.
3
+ let wsList = [], wsIdle = 4;
4
+ async function fetchWorkspaces() {
5
+ try { const r = await (await fetch('/api/workspaces')).json(); wsList = r.workspaces || []; wsIdle = r.idle_hours; return true; } catch (e) { wsList = []; return false; }
6
+ }
7
+ async function paintWorkspacePill() {
8
+ const pill = $('#wsPill'); if (!pill) return;
9
+ if (!BASE) { pill.style.display = 'none'; return; }
10
+ const ok = await fetchWorkspaces();
11
+ const me = wsList.find(w => w.slug === WS_SLUG);
12
+ pill.style.display = ok ? '' : 'none';
13
+ pill.textContent = (me ? me.name : WS_SLUG) + ' ▾'; pill.title = (me ? me.path + '\n' : '') + 'cambiar de workspace · los demás siguen vivos';
14
+ }
15
+ const WS_NEW = { slug: '__new' };
16
+ function openWorkspaces(selectSlug) {
17
+ Panel.open({
18
+ id: 'workspaces', eyebrow: 'workspaces', title: 'Workspaces', sub: 'cada uno corre en su propio proceso', layout: 'browse', select: selectSlug || WS_SLUG || null,
19
+ browse: {
20
+ placeholder: 'buscar workspace…', listWidth: '300px', key: w => w.slug,
21
+ load: async (q) => { await fetchWorkspaces(); const list = wsList.filter(w => !q || (w.name + ' ' + w.path).toLowerCase().includes(q.toLowerCase())); return q ? list : [...list, WS_NEW]; },
22
+ render: (w) => w === WS_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">nuevo workspace…</div><div class="meta">elegí una carpeta</div></div>` : `<span class="dot">${w.alive ? '●' : '○'}</span><div><div class="nm">${esc(w.name)}${w.slug === WS_SLUG ? ' <span class="meta">(este)</span>' : ''}</div><div class="meta">${esc(w.path)}</div></div>`,
23
+ count: (rows) => { const n = rows.filter(r => r !== WS_NEW).length, a = rows.filter(r => r !== WS_NEW && r.alive).length; return `${n} workspace${n === 1 ? '' : 's'} · ${a} vivo${a === 1 ? '' : 's'}`; },
24
+ emptyHtml: 'nada coincide', emptyDetail: 'Todavía no hay workspaces. Creá uno con «nuevo workspace…» o abrí <code>lampson</code> en la carpeta de un proyecto.',
25
+ detail: (w) => w === WS_NEW ? wsNewForm() : `<div class="dhead"><span class="nm">${esc(w.name)}</span><span class="meta">${w.alive ? '● vivo' : '○ apagado'} · ${esc(w.policy)}</span></div>
26
+ <div class="dcap">${esc(w.path)}</div>
27
+ <div class="ddesc">${w.schedules_on ? `${w.schedules_on} tarea${w.schedules_on === 1 ? '' : 's'} programada${w.schedules_on === 1 ? '' : 's'} encendida${w.schedules_on === 1 ? '' : 's'} (lo mantienen vivo). ` : ''}Último uso: ${esc(fmtWhen(w.last_used))}.</div>
28
+ <div class="dacts"><button class="primary" data-open>${w.slug === WS_SLUG ? 'Ya estás acá' : 'Abrir'}</button>${w.alive ? '<button data-stop>■ apagar</button>' : '<button data-start>▶ encender</button>'}</div>
29
+ <div class="dform" style="margin-top:14px"><label>vida <select name="policy"><option value="auto" ${w.policy === 'auto' ? 'selected' : ''}>auto — vivo mientras se use (${wsIdle === 0 ? 'nunca se apaga' : 'se apaga tras ' + wsIdle + ' h sin uso'}) o tenga tareas</option><option value="always" ${w.policy === 'always' ? 'selected' : ''}>siempre vivo</option><option value="off" ${w.policy === 'off' ? 'selected' : ''}>apagado — solo cuando lo abrís</option></select></label><span class="ds">las horas de inactividad se cambian en ⚙ → General</span></div>
30
+ <div class="dfoot"><span>/w/${esc(w.slug)}/ · :${w.port} (solo loopback)</span><span class="del">quitar del registro</span></div><div class="derr"></div>`,
31
+ wire: (w, box) => {
32
+ const err = (m) => { const e = box.querySelector('.derr'); if (e) e.textContent = m || ''; };
33
+ if (w === WS_NEW) { wsNewWire(box, err); return; }
34
+ box.querySelector('[data-open]').onclick = () => { if (w.slug !== WS_SLUG) location.href = w.url; else Panel.close(); };
35
+ const st = box.querySelector('[data-start]'); if (st) st.onclick = async () => { err('arrancando…'); const r = await api('/api/workspaces/start', { slug: w.slug }); err(r.data.ok ? '' : 'no respondió a tiempo'); Panel.refresh(); };
36
+ const sp = box.querySelector('[data-stop]'); if (sp) sp.onclick = async () => { await api('/api/workspaces/stop', { slug: w.slug }); setTimeout(() => Panel.refresh(), 800); };
37
+ box.querySelector('[name="policy"]').onchange = async (ev) => { const r = await api('/api/workspaces/policy', { slug: w.slug, policy: ev.target.value }); if (!r.ok) err(r.data.error || 'error'); };
38
+ const del = box.querySelector('.dfoot .del');
39
+ del.onclick = () => inlineConfirm(del, `¿quitar ${w.name} del registro?`, async () => { const r = await api('/api/workspaces/remove', { slug: w.slug }); if (!r.ok) { err(r.data.error || 'error'); return; } if (w.slug === WS_SLUG) location.href = '/'; else Panel.refresh(); });
40
+ }
41
+ }
42
+ });
43
+ }
44
+ function wsNewForm() {
45
+ return `<div class="dhead"><span class="nm serif">Nuevo workspace</span></div><div class="dform">
46
+ <p class="lead">Un workspace es una carpeta de tu disco: el agente solo puede tocar lo que hay adentro. Corre en su propio proceso, con sus sesiones, tareas programadas, MCP y lámparas.</p>
47
+ <div class="dacts" style="margin-top:0"><button class="primary" data-pick>Elegir carpeta…</button><span class="ds" data-pickhint>abre el diálogo de tu sistema</span></div>
48
+ <label style="margin-top:14px">ruta <input name="path" spellcheck="false" autocomplete="off" placeholder="C:\\proyectos\\mi-app · /home/yo/proyectos/mi-app"></label>
49
+ <div data-browser style="display:none"><div class="dcap" data-here></div><div class="bitems" data-dirs style="max-height:32vh;border:1px solid var(--rule);border-radius:var(--r);padding:4px"></div></div>
50
+ <div class="pfoot"><button class="primary" data-go>Crear y abrir</button><button data-browse>explorar en el servidor</button><span class="derr"></span></div></div>`;
51
+ }
52
+ function wsNewWire(box, err) {
53
+ const f = n => box.querySelector(`[name="${n}"]`);
54
+ const browser = box.querySelector('[data-browser]');
55
+ const showDir = async (p) => {
56
+ const r = await (await fetch('/api/workspaces/browse?path=' + encodeURIComponent(p || ''))).json();
57
+ browser.style.display = '';
58
+ box.querySelector('[data-here]').textContent = r.path + (r.error ? ' — ' + r.error : '');
59
+ f('path').value = r.path || '';
60
+ const list = box.querySelector('[data-dirs]'); list.innerHTML = '';
61
+ const up = document.createElement('div'); up.className = 'li nodot'; up.innerHTML = '<div><div class="nm">..</div></div>'; up.onclick = () => showDir(r.parent); list.appendChild(up);
62
+ for (const d of (r.dirs || [])) { const it = document.createElement('div'); it.className = 'li nodot'; it.innerHTML = `<div><div class="nm" style="font-weight:400">${esc(d)}/</div></div>`; it.onclick = () => showDir(r.path.replace(/[\\/]$/, '') + (r.path.includes('\\') ? '\\' : '/') + d); list.appendChild(it); }
63
+ };
64
+ box.querySelector('[data-browse]').onclick = () => showDir(f('path').value.trim());
65
+ box.querySelector('[data-pick]').onclick = async () => {
66
+ err('esperando el diálogo… (mirá la barra de tareas)');
67
+ const r = await api('/api/workspaces/pick', {});
68
+ if (!r.data.native) { err(''); box.querySelector('[data-pickhint]').textContent = 'no hay diálogo en este equipo (VPS): usá el explorador'; showDir(''); return; }
69
+ err(''); if (r.data.path) f('path').value = r.data.path;
70
+ };
71
+ box.querySelector('[data-go]').onclick = async () => {
72
+ const path = f('path').value.trim(); if (!path) { err('elegí una carpeta'); return; }
73
+ err('creando y arrancando… (el hub se reinicia un instante)');
74
+ const r = await api('/api/workspaces', { path });
75
+ if (!r.ok) { err(r.data.error || ('error ' + r.status)); return; }
76
+ // el hub se regenera con la ruta nueva y se reinicia (--watch): esperar a que vuelva y navegar
77
+ const url = r.data.url; let tries = 0;
78
+ const poll = async () => { tries++; try { const h = await fetch(BASE + '/api/hub'); if (h.ok && tries > 2) { location.href = url; return; } } catch (e) {} if (tries < 40) setTimeout(poll, 500); else err('el hub no volvió: abrí ' + url + ' a mano'); };
79
+ setTimeout(poll, 1200);
80
+ };
81
+ f('path').focus();
82
+ }
83
+ if ($('#wsPill')) { $('#wsPill').onclick = () => openWorkspaces(); paintWorkspacePill(); }
@@ -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
@@ -62,6 +78,11 @@ the terminal button and run `npm run dev`), then tell me the URL".
62
78
  a vision model instead of guessing what the image shows.
63
79
  - Sessions can be deleted (`/delete <id>`, or ✕ in the web sidebar).
64
80
 
81
+ ## Workspaces
82
+ - Each project folder is a workspace with its own Lampson process; the user switches between them from the
83
+ header pill or http://127.0.0.1:8080 (the hub). You only ever see this workspace. If the user asks to work on
84
+ another project, tell them to open it as a workspace (hub screen, or `lampson` in that folder) — you cannot.
85
+
65
86
  ## Network exposure
66
87
  - The web server listens on all interfaces but every `/api/*` route (and the terminal socket) only
67
88
  accepts loopback clients; others get 401 unless they present `LAMPSON_WEB_TOKEN`. If the user asks
@@ -3,7 +3,7 @@ name: synsema
3
3
  description: Writing, checking, running and testing Synsema (.syn) code — syntax reflexes, capabilities, live processes / pseudo-terminals, and the runtime traps that cost hours. Load before touching any .syn file.
4
4
  ---
5
5
 
6
- # Synsema quick reference (v0.6.11)
6
+ # Synsema quick reference (v0.6.12)
7
7
 
8
8
  > Curated 10 KB summary for the agent (the full reference is ~450 KB and lives in the user's editor
9
9
  > skill). Kept in sync by hand with each `synsema update`; if `synsema --version` is newer than the
@@ -55,6 +55,9 @@ description: Writing, checking, running and testing Synsema (.syn) code — synt
55
55
  `proc_spawn` in a handler is gone when the handler returns. A process that must outlive requests lives
56
56
  inside an `agent` spawned from the handler (own lifecycle; blackboard `share/observe` + `bus_*` are shared
57
57
  with handlers). That is how lampson's `process` tool works (`lib/tools/proc.syn`).
58
+ - **Reverse proxy streams (v0.6.12+)**: `proxy to "http://127.0.0.1:N"` passes SSE in real time and tunnels
59
+ `Upgrade: websocket`; needs `require net("<upstream host>")`; `GET /*path` does not match `/`. **Cron expressions
60
+ (v0.6.12+)**: `cron_every("0 9 * * mon-fri", task, {"tz": "-03:00"})` — fixed offset, no DST, no persistence.
58
61
  - **Own terminal / raw keys (v0.6.11+)**: `let h be term_open({"ctrl_c": "exit"})` → `nothing` without a
59
62
  TTY / under `test`/`serve` (fall back to `read_line`); `term_recv(h, secs)` → `{type: "key", key, text,
60
63
  ctrl, alt, shift}` (`key` = `"char"|"enter"|"tab"|"backspace"|"up"|…`), `paste`, `resize`, `eof`;