lampson 0.1.4 → 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.
package/public/js/tree.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // tree.js — árbol de archivos (panel derecho) con estado git, y el visor de archivos
2
2
  let gitStatus = { changes: {} };
3
3
  async function loadGit() {
4
- try { gitStatus = await (await fetch('/api/git')).json(); } catch (e) { gitStatus = { repo: false, changes: {} }; }
4
+ try { gitStatus = await (await fetch(BASE + '/api/git')).json(); } catch (e) { gitStatus = { repo: false, changes: {} }; }
5
5
  const g = $('#git'); if (!gitStatus.repo) { g.style.display = 'none'; return; }
6
6
  const k = gitStatus.counts || {}; const nuevos = (k.added||0)+(k.untracked||0);
7
7
  const parts = []; // solo contadores distintos de cero — lo demás es ruido
@@ -20,7 +20,7 @@ function gitClass(path, isDir) {
20
20
  async function loadTree() {
21
21
  await loadGit();
22
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; }
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
24
  const render = (entries, parent, depth) => {
25
25
  for (const e of entries) {
26
26
  const n = document.createElement('div'); n.className = 'node';
@@ -41,7 +41,7 @@ async function loadTree() {
41
41
  }
42
42
  async function openFile(path, row) {
43
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();
44
+ const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d = await r.json();
45
45
  if (!r.ok) { add('denied', esc(d.error || 'error')); return; }
46
46
  $('#vpath').textContent = d.path; $('#vmeta').textContent = d.lines + ' líneas';
47
47
  $('#vbody').className = ''; $('#vbody').innerHTML = String(d.content).split('\n').map((l, i) => `<span class="ln">${i + 1}</span>${esc(l)}`).join('\n');
@@ -1,13 +1,13 @@
1
1
  // update.js — actualizaciones de Lampson (una vez por carga; el fetch a origin puede tardar)
2
2
  async function checkUpdate() {
3
- let u; try { u = await (await fetch('/api/update')).json(); } catch (e) { return; }
3
+ let u; try { u = await (await fetch(BASE + '/api/update')).json(); } catch (e) { return; }
4
4
  const b = $('#update'); if (!u.available) { b.style.display = 'none'; return; }
5
5
  b.textContent = `⬆ actualizar · ${u.behind} commit${u.behind === 1 ? '' : 's'}`;
6
6
  b.title = `${u.current} → ${u.latest}\n${(u.notes || []).map(n => '· ' + n).join('\n')}\n\nen terminal: ${u.command}`;
7
7
  b.style.display = '';
8
8
  b.onclick = async () => {
9
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 }; }
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
11
  add('meta', '⬆ ' + esc(r.result || ''));
12
12
  b.disabled = false; b.textContent = '⬆ reiniciá el servidor'; b.onclick = null;
13
13
  };
@@ -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(); }
@@ -78,6 +78,11 @@ the terminal button and run `npm run dev`), then tell me the URL".
78
78
  a vision model instead of guessing what the image shows.
79
79
  - Sessions can be deleted (`/delete <id>`, or ✕ in the web sidebar).
80
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
+
81
86
  ## Network exposure
82
87
  - The web server listens on all interfaces but every `/api/*` route (and the terminal socket) only
83
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`;
package/web.syn CHANGED
@@ -1,9 +1,12 @@
1
1
  -- web.syn — Lampson por URL (chat web con SSE)
2
2
  --
3
- -- synsema serve web.syn http://127.0.0.1:8080
3
+ -- Un proceso POR WORKSPACE, lanzado por el hub / cli.syn con cwd = .lampson/ws/<slug> (ver lib/workspaces.syn):
4
+ -- synsema serve web.syn --port 808N --bind 127.0.0.1 --env-file <home>/.env
5
+ -- Todas las rutas van bajo /w/:slug/… porque el hub (hub.syn, :8080) proxyea sin quitar el prefijo. El :slug se
6
+ -- ignora (cada proceso es un workspace); LAMPSON_WS trae el propio. Standalone sigue sirviendo en :8080.
4
7
  --
5
- -- API:
6
- -- GET / UI (public/index.html)
8
+ -- API (bajo /w/<slug>):
9
+ -- GET /api/health {alive, last_used, schedules_on} (hub, terminal)
7
10
  -- GET /api/sessions lista de sesiones
8
11
  -- GET /api/sessions/:id historial de una sesión
9
12
  -- POST /api/chat {session?, message, agent?} → SSE con eventos del loop y "done" al final
@@ -37,6 +40,8 @@ require file("memory")
37
40
  require file("memory/*")
38
41
  require file(".lampson")
39
42
  require file(".lampson/*")
43
+ require file.read("public")
44
+ require file.read("public/*")
40
45
 
41
46
  use "./lib/provider.syn" as provider
42
47
  use "./lib/tools.syn" as tools
@@ -133,28 +138,39 @@ serve on 8080
133
138
  auth with check_client
134
139
  static "./public"
135
140
 
136
- route "GET /api/sessions" requires auth
141
+ -- standalone (sin hub: docker, `synsema serve web.syn`): la UI vive igual bajo /w/<slug> (BASE en js/core.js)
142
+ route "GET /"
143
+ give redirect("/w/" + (when env("LAMPSON_WS", "") == "" then "local" otherwise env("LAMPSON_WS", "")))
144
+ route "GET /w/:slug"
145
+ give html(read_file("public/index.html"))
146
+
147
+ -- salud para el hub/supervisor y la terminal (último uso = chat, terminal, eventos o abrir la UI)
148
+ route "GET /w/:slug/api/health"
149
+ give {"alive": true, "slug": env("LAMPSON_WS", ""), "last_used": state_get("lampson:last_used", nothing), "schedules_on": length(where(schedule.all(), (t) => t["enabled"] == true)), "version": env("LAMPSON_VERSION", "")}
150
+
151
+ route "GET /w/:slug/api/sessions" requires auth
152
+ state_set("lampson:last_used", now())
137
153
  give {"sessions": session.list()}
138
154
 
139
155
  -- 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
156
+ route "GET /w/:slug/api/sessions/search" requires auth
141
157
  let q be when contains(query, "q") then text(query.q) otherwise ""
142
158
  let n be when contains(query, "limit") then floor(number(query.limit)) otherwise 200
143
159
  give {"q": q, "sessions": session.search(q, n)}
144
160
 
145
- route "GET /api/sessions/:id" requires auth
161
+ route "GET /w/:slug/api/sessions/:id" requires auth
146
162
  when not session.exists(params.id)
147
163
  give not_found("session not found")
148
164
  let doc be session.load(params.id)
149
165
  let visible be where(doc["messages"], (m) => m["role"] != "system")
150
166
  give {"id": doc["id"], "updated": doc["updated"], "meta": doc["meta"], "messages": visible}
151
167
 
152
- route "GET /api/config" requires auth
168
+ route "GET /w/:slug/api/config" requires auth
153
169
  let cfg be provider.config()
154
170
  give {"provider": cfg["provider"], "model": cfg["model"], "wire": cfg["wire"], "vision": provider.supports_vision(cfg), "providers": provider.providers(), "configured": provider.configured(), "permission": lower(env("LAMPSON_PERMISSION", "ask")), "agents": agents.names(), "workspace": env("LAMPSON_WORKSPACE", "./workspace"), "profiles": agents.PROFILES}
155
171
 
156
172
  -- proveedor/modelo por defecto y API keys → .lampson/config.json (local). La key nunca vuelve al cliente.
157
- route "POST /api/settings" requires auth
173
+ route "POST /w/:slug/api/settings" requires auth
158
174
  expect body {provider: text}
159
175
  let b be json of request
160
176
  let name be lower(text(b["provider"]))
@@ -167,12 +183,12 @@ serve on 8080
167
183
  let ncfg be provider.config_for(name, "")
168
184
  give {"ok": true, "providers": provider.providers(), "provider": name, "model": ncfg["model"], "vision": provider.supports_vision(ncfg), "configured": provider.configured()}
169
185
 
170
- route "POST /api/sessions/delete" requires auth
186
+ route "POST /w/:slug/api/sessions/delete" requires auth
171
187
  expect body {id: text}
172
188
  let b be json of request
173
189
  give {"result": session.delete(text(b["id"]))}
174
190
 
175
- route "GET /api/tree" requires auth
191
+ route "GET /w/:slug/api/tree" requires auth
176
192
  give tree.tree(6)
177
193
 
178
194
  -- Terminal real en el navegador: un shell dentro de un pseudo-terminal (pty) por conexión WebSocket.
@@ -180,8 +196,9 @@ serve on 8080
180
196
  -- Servidor → navegador: frames de texto con prefijo: "o" + salida cruda del pty (ANSI incluido, xterm.js
181
197
  -- la dibuja) | "c" + JSON de control ({type: hello|exit}).
182
198
  -- El shell vive lo que vive el socket: cerrar el panel lo mata (el runtime no deja huérfanos).
183
- route "GET /api/term" requires auth
199
+ route "GET /w/:slug/api/term" requires auth
184
200
  socket
201
+ state_set("lampson:last_used", now())
185
202
  let sh be term_shell()
186
203
  let p be proc_spawn(sh[0], sh[1], {"cwd": term_cwd(), "pty": true, "cols": 120, "rows": 32})
187
204
  ws_send(socket, "c" + json_encode({"type": "hello", "shell": sh[0], "pid": proc_stats(p)["pid"], "cwd": term_cwd()}))
@@ -208,25 +225,25 @@ serve on 8080
208
225
  ws_send(socket, "o" + as_text(ev["data"]))
209
226
  proc_close(p)
210
227
 
211
- route "GET /api/update" requires auth
228
+ route "GET /w/:slug/api/update" requires auth
212
229
  give update.check()
213
230
 
214
- route "POST /api/update" requires auth
231
+ route "POST /w/:slug/api/update" requires auth
215
232
  give {"result": update.apply()}
216
233
 
217
- route "GET /api/git" requires auth
234
+ route "GET /w/:slug/api/git" requires auth
218
235
  give git.status()
219
236
 
220
- route "GET /api/memory" requires auth
237
+ route "GET /w/:slug/api/memory" requires auth
221
238
  give {"dir": memo.dir(), "notes": memo.list()}
222
239
 
223
- route "GET /api/memory/note" requires auth
240
+ route "GET /w/:slug/api/memory/note" requires auth
224
241
  when not contains(query, "name")
225
242
  give fail(400, "name required")
226
243
  give {"name": query.name, "content": memo.note_read(query.name)}
227
244
 
228
245
  -- traza legible de una sesión (ver lib/trace.syn)
229
- route "GET /api/trace" requires auth
246
+ route "GET /w/:slug/api/trace" requires auth
230
247
  when not contains(query, "session")
231
248
  give fail(400, "session required")
232
249
  when not matches(query.session, "[A-Za-z0-9_-]{1,40}")
@@ -235,22 +252,22 @@ serve on 8080
235
252
  give {"session": query.session, "file": trace.file_of(query.session), "trace": trace.tail(query.session, n)}
236
253
 
237
254
  -- modelos que declara la API del proveedor (para no tipear nombres a ciegas: DeepSeek es case-sensitive)
238
- route "GET /api/models" requires auth
255
+ route "GET /w/:slug/api/models" requires auth
239
256
  let pname be when contains(query, "provider") then lower(text(query.provider)) otherwise ""
240
257
  let cfg be provider.config_for(pname, "")
241
258
  give provider.list_models(cfg)
242
259
 
243
- route "GET /api/todo" requires auth
260
+ route "GET /w/:slug/api/todo" requires auth
244
261
  -- ?session=<id> → la lista de esa sesión; sin query, la de la sesión en curso
245
262
  when contains(query, "session") and matches(query.session, "[A-Za-z0-9_-]{1,40}")
246
263
  give {"items": todo.load(query.session)}
247
264
  give {"items": todo.load(nothing)}
248
265
 
249
- route "GET /api/mcp" requires auth
266
+ route "GET /w/:slug/api/mcp" requires auth
250
267
  give {"servers": mcp.summary(), "global": mcp.GLOBAL_CONFIG, "project": mcp.PROJECT_CONFIG}
251
268
 
252
269
  -- conectar un server MCP desde la UI: se escribe en el mcp.json del scope y arranca su supervisor
253
- route "POST /api/mcp/add" requires auth
270
+ route "POST /w/:slug/api/mcp/add" requires auth
254
271
  expect body {name: text, command: text}
255
272
  let b be json of request
256
273
  let scope be when contains(b, "scope") then lower(text(b["scope"])) otherwise "global"
@@ -260,7 +277,7 @@ serve on 8080
260
277
  recover err
261
278
  give fail(400, text(err))
262
279
 
263
- route "POST /api/mcp/remove" requires auth
280
+ route "POST /w/:slug/api/mcp/remove" requires auth
264
281
  expect body {name: text}
265
282
  let b be json of request
266
283
  try
@@ -269,10 +286,10 @@ serve on 8080
269
286
  give fail(400, text(err))
270
287
 
271
288
  -- language servers (navegación semántica): listar, agregar (preset o comando propio), quitar
272
- route "GET /api/lsp" requires auth
289
+ route "GET /w/:slug/api/lsp" requires auth
273
290
  give {"servers": lsp.summary(), "presets": lsp.PRESETS, "global": lsp.GLOBAL_CONFIG, "project": lsp.PROJECT_CONFIG}
274
291
 
275
- route "POST /api/lsp/add" requires auth
292
+ route "POST /w/:slug/api/lsp/add" requires auth
276
293
  expect body {name: text}
277
294
  let b be json of request
278
295
  let scope be when contains(b, "scope") then lower(text(b["scope"])) otherwise "global"
@@ -283,7 +300,7 @@ serve on 8080
283
300
  recover err
284
301
  give fail(400, text(err))
285
302
 
286
- route "POST /api/lsp/remove" requires auth
303
+ route "POST /w/:slug/api/lsp/remove" requires auth
287
304
  expect body {name: text}
288
305
  let b be json of request
289
306
  try
@@ -292,11 +309,11 @@ serve on 8080
292
309
  give fail(400, text(err))
293
310
 
294
311
  -- lámparas (plugins de tools): listar y encender/apagar desde la barra superior de la UI
295
- route "GET /api/lamps" requires auth
312
+ route "GET /w/:slug/api/lamps" requires auth
296
313
  give {"lamps": lamps.summary(), "global": lamps.GLOBAL_DIR, "project": lamps.PROJECT_DIR}
297
314
 
298
315
  -- el usuario corre una tool de una lámpara encendida él mismo (sin pasar por el modelo)
299
- route "POST /api/lamps/run" requires auth
316
+ route "POST /w/:slug/api/lamps/run" requires auth
300
317
  expect body {tool: text}
301
318
  let b be json of request
302
319
  let args be when contains(b, "args") then b["args"] otherwise {}
@@ -305,7 +322,7 @@ serve on 8080
305
322
  recover err
306
323
  give fail(400, text(err))
307
324
 
308
- route "POST /api/lamps/remove" requires auth
325
+ route "POST /w/:slug/api/lamps/remove" requires auth
309
326
  expect body {name: text}
310
327
  let b be json of request
311
328
  try
@@ -313,7 +330,7 @@ serve on 8080
313
330
  recover err
314
331
  give fail(400, text(err))
315
332
 
316
- route "POST /api/lamps/toggle" requires auth
333
+ route "POST /w/:slug/api/lamps/toggle" requires auth
317
334
  expect body {name: text}
318
335
  let b be json of request
319
336
  let on be contains(b, "enabled") and b["enabled"] == true
@@ -322,15 +339,16 @@ serve on 8080
322
339
  recover err
323
340
  give fail(400, text(err))
324
341
 
325
- route "GET /api/agents" requires auth
342
+ route "GET /w/:slug/api/agents" requires auth
326
343
  agents.prune(10, 1800)
327
344
  give {"agents": agents.list_children()}
328
345
 
329
346
  -- Eventos en vivo para la UI (SSE): lo que publican los supervisores de procesos (proc.<name>: líneas y
330
347
  -- cambios de estado) y los subagentes (subagent.started|done|<id>). Un bus por programa; cada cliente
331
348
  -- tiene su cola acotada (drop_oldest). Heartbeat cada 25 s para que el proxy/navegador no cierre.
332
- route "GET /api/events" requires auth
349
+ route "GET /w/:slug/api/events" requires auth
333
350
  stream
351
+ state_set("lampson:last_used", now())
334
352
  let sub be bus_subscribe(["proc.*", "subagent.*", "mcp.*", "approval.*", "schedule.*"], {"max_queue": 512})
335
353
  let open be true
336
354
  while open
@@ -344,7 +362,7 @@ serve on 8080
344
362
  set open to false
345
363
  bus_unsubscribe(sub)
346
364
 
347
- route "GET /api/agents/log" requires auth
365
+ route "GET /w/:slug/api/agents/log" requires auth
348
366
  when not contains(query, "id")
349
367
  give fail(400, "id required")
350
368
  when not matches(query.id, "[a-z]+-[0-9]+")
@@ -359,7 +377,7 @@ serve on 8080
359
377
  recover err
360
378
  give fail(404, "no such sub-agent")
361
379
 
362
- route "POST /api/agents/stop" requires auth
380
+ route "POST /w/:slug/api/agents/stop" requires auth
363
381
  expect body {id: text}
364
382
  let b be json of request
365
383
  when not matches(b["id"], "[a-z]+-[0-9]+")
@@ -368,38 +386,38 @@ serve on 8080
368
386
  bus_publish("subagent." + b["id"], {"kind": "stop", "line": "stop requested from the UI"})
369
387
  give {"result": "stop requested for " + b["id"]}
370
388
 
371
- route "GET /api/proc" requires auth
389
+ route "GET /w/:slug/api/proc" requires auth
372
390
  give {"processes": proc.list()}
373
391
 
374
- route "GET /api/proc/log" requires auth
392
+ route "GET /w/:slug/api/proc/log" requires auth
375
393
  when not contains(query, "name")
376
394
  give fail(400, "name required")
377
395
  let n be when contains(query, "tail") then number(query.tail) otherwise 200
378
396
  give {"name": query.name, "running": proc.alive(query.name), "log": proc.logs(query.name, n)}
379
397
 
380
- route "GET /api/ports" requires auth
398
+ route "GET /w/:slug/api/ports" requires auth
381
399
  give {"ports": proc.listeners()}
382
400
 
383
- route "POST /api/ports/kill" requires auth
401
+ route "POST /w/:slug/api/ports/kill" requires auth
384
402
  expect body {pid: number}
385
403
  let b be json of request
386
404
  give {"result": proc.kill_pid(b["pid"])}
387
405
 
388
- route "POST /api/proc/stop" requires auth
406
+ route "POST /w/:slug/api/proc/stop" requires auth
389
407
  expect body {name: text}
390
408
  let b be json of request
391
409
  give {"result": proc.halt(b["name"])}
392
410
 
393
- route "GET /api/file" requires auth
411
+ route "GET /w/:slug/api/file" requires auth
394
412
  when not contains(query, "path")
395
413
  give fail(400, "path required")
396
414
  give tree.file_content(query.path)
397
415
 
398
416
  -- configuración general (rueda de la UI): zona horaria, URL pública, webhook… → .lampson/config.json
399
- route "GET /api/settings/values" requires auth
417
+ route "GET /w/:slug/api/settings/values" requires auth
400
418
  give {"values": settings.values(), "tz_detected": schedule.tz_offset()}
401
419
 
402
- route "POST /api/settings/values" requires auth
420
+ route "POST /w/:slug/api/settings/values" requires auth
403
421
  let b be json of request
404
422
  try
405
423
  each k in keys(b)
@@ -411,17 +429,17 @@ serve on 8080
411
429
  give fail(400, text(err))
412
430
 
413
431
  -- aprobaciones (lib/approvals.syn): las del chat y las de tareas programadas comparten la cola
414
- route "POST /api/approve" requires auth
432
+ route "POST /w/:slug/api/approve" requires auth
415
433
  expect body {id: text, decision: bool}
416
434
  let b be json of request
417
435
  give {"ok": approvals.answer(text(b["id"]), b["decision"])}
418
436
 
419
- route "GET /api/approvals" requires auth
437
+ route "GET /w/:slug/api/approvals" requires auth
420
438
  give {"approvals": approvals.list(), "public_url": approvals.public_url(), "webhook": approvals.webhook_url() != ""}
421
439
 
422
440
  -- 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
441
  -- 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"
442
+ route "GET /w/:slug/approve/:id/:token"
425
443
  let d be when contains(query, "d") then lower(text(query.d)) otherwise ""
426
444
  when d != "yes" and d != "no"
427
445
  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>"))
@@ -431,12 +449,12 @@ serve on 8080
431
449
  give html(approvals.html_page(when d == "yes" then "✓ Permitido" otherwise "✗ Denegado", "Lampson sigue con la tarea. Podés cerrar esta pestaña."))
432
450
 
433
451
  -- tareas programadas (lib/schedule.syn): listar, crear, quitar, encender/apagar, correr ahora, log
434
- route "GET /api/schedules" requires auth
452
+ route "GET /w/:slug/api/schedules" requires auth
435
453
  let age be schedule.daemon_age()
436
454
  -- este proceso tiene su propio tick (cron_every): las tareas corren mientras la web esté abierta
437
455
  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
456
 
439
- route "POST /api/schedules/add" requires auth
457
+ route "POST /w/:slug/api/schedules/add" requires auth
440
458
  let b be json of request
441
459
  try
442
460
  let t be schedule.add(b)
@@ -444,7 +462,7 @@ serve on 8080
444
462
  recover err
445
463
  give fail(400, text(err))
446
464
 
447
- route "POST /api/schedules/remove" requires auth
465
+ route "POST /w/:slug/api/schedules/remove" requires auth
448
466
  expect body {id: text}
449
467
  let b be json of request
450
468
  try
@@ -452,7 +470,7 @@ serve on 8080
452
470
  recover err
453
471
  give fail(400, text(err))
454
472
 
455
- route "POST /api/schedules/toggle" requires auth
473
+ route "POST /w/:slug/api/schedules/toggle" requires auth
456
474
  expect body {id: text}
457
475
  let b be json of request
458
476
  let on be contains(b, "enabled") and b["enabled"] == true
@@ -463,7 +481,7 @@ serve on 8080
463
481
 
464
482
  -- correr ahora: la marca run_now la levanta el próximo tick (así el request no se queda minutos abierto
465
483
  -- y la corrida vive en el hilo del cron, no en el del request)
466
- route "POST /api/schedules/run" requires auth
484
+ route "POST /w/:slug/api/schedules/run" requires auth
467
485
  expect body {id: text}
468
486
  let b be json of request
469
487
  try
@@ -472,13 +490,13 @@ serve on 8080
472
490
  recover err
473
491
  give fail(400, text(err))
474
492
 
475
- route "GET /api/schedules/log" requires auth
493
+ route "GET /w/:slug/api/schedules/log" requires auth
476
494
  when not contains(query, "id")
477
495
  give fail(400, "id required")
478
496
  let n be when contains(query, "tail") then floor(number(query.tail)) otherwise 300
479
497
  give {"id": query.id, "log": schedule.log_tail(query.id, n), "task": schedule.get(query.id)}
480
498
 
481
- route "POST /api/chat" requires auth
499
+ route "POST /w/:slug/api/chat" requires auth
482
500
  expect body {message: text}
483
501
  let b be json of request
484
502
  let message be b["message"]
@@ -489,6 +507,7 @@ serve on 8080
489
507
  set mode to "ask"
490
508
  let pname be when contains(b, "provider") then lower(text(b["provider"])) otherwise ""
491
509
  let pmodel be when contains(b, "model") then text(b["model"]) otherwise ""
510
+ state_set("lampson:last_used", now())
492
511
  stream
493
512
  task emit(kind, data, tag)
494
513
  trace.event(sid, kind, data, tag)