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
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
@@ -23,6 +26,7 @@ require serve(8080)
23
26
  require net
24
27
  require time
25
28
  require exec
29
+ require random
26
30
  require env("LAMPSON_*")
27
31
  require env("OS")
28
32
  require secret("LAMPSON_*")
@@ -36,6 +40,8 @@ require file("memory")
36
40
  require file("memory/*")
37
41
  require file(".lampson")
38
42
  require file(".lampson/*")
43
+ require file.read("public")
44
+ require file.read("public/*")
39
45
 
40
46
  use "./lib/provider.syn" as provider
41
47
  use "./lib/tools.syn" as tools
@@ -54,6 +60,10 @@ use "./lib/mcp.syn" as mcp
54
60
  use "./lib/lamps.syn" as lamps
55
61
  use "./lib/lsp.syn" as lsp
56
62
  use "./lib/tools/todo.syn" as todo
63
+ use "./lib/schedule.syn" as schedule
64
+ use "./lib/sched_run.syn" as sched_run
65
+ use "./lib/approvals.syn" as approvals
66
+ use "./lib/permission.syn" as permission
57
67
 
58
68
  let APPROVAL_TIMEOUT be 180
59
69
 
@@ -106,6 +116,15 @@ task boot(profile, mode, ask_fn, pname, model)
106
116
  task lampson_subagent(spec_json)
107
117
  give agents.run_child_json(spec_json)
108
118
 
119
+ -- TAREAS PROGRAMADAS (lib/schedule.syn): este proceso es el residente de lampson — con `lampson --daemon start`
120
+ -- (synsema daemon) queda en background y ejecuta lo programado aunque no haya ninguna UI abierta. Un tick cada
121
+ -- 30 s (cron_every de Synsema: intervalo puro; la hora de reloj la resuelve schedule.syn) corre lo vencido:
122
+ -- lámparas, comandos y corridas del agente con su sobre de permisos; una aprobación en una corrida desatendida
123
+ -- sale por lib/approvals.syn (panel «Aprobaciones» de la UI, webhook firmado, link de un solo uso).
124
+ task sched_tick()
125
+ give sched_run.tick()
126
+ cron_every(schedule.TICK_SECONDS, sched_tick)
127
+
109
128
  -- servers MCP: supervisores (agentes) lanzados al arrancar; los handlers leen su estado del blackboard
110
129
  mcp.start_all(8)
111
130
 
@@ -119,22 +138,39 @@ serve on 8080
119
138
  auth with check_client
120
139
  static "./public"
121
140
 
122
- 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())
123
153
  give {"sessions": session.list()}
124
154
 
125
- route "GET /api/sessions/:id" requires auth
155
+ -- buscador de sesiones (modal «ver todas»): ?q=texto busca en títulos y mensajes de este proyecto
156
+ route "GET /w/:slug/api/sessions/search" requires auth
157
+ let q be when contains(query, "q") then text(query.q) otherwise ""
158
+ let n be when contains(query, "limit") then floor(number(query.limit)) otherwise 200
159
+ give {"q": q, "sessions": session.search(q, n)}
160
+
161
+ route "GET /w/:slug/api/sessions/:id" requires auth
126
162
  when not session.exists(params.id)
127
163
  give not_found("session not found")
128
164
  let doc be session.load(params.id)
129
165
  let visible be where(doc["messages"], (m) => m["role"] != "system")
130
166
  give {"id": doc["id"], "updated": doc["updated"], "meta": doc["meta"], "messages": visible}
131
167
 
132
- route "GET /api/config" requires auth
168
+ route "GET /w/:slug/api/config" requires auth
133
169
  let cfg be provider.config()
134
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}
135
171
 
136
172
  -- proveedor/modelo por defecto y API keys → .lampson/config.json (local). La key nunca vuelve al cliente.
137
- route "POST /api/settings" requires auth
173
+ route "POST /w/:slug/api/settings" requires auth
138
174
  expect body {provider: text}
139
175
  let b be json of request
140
176
  let name be lower(text(b["provider"]))
@@ -147,12 +183,12 @@ serve on 8080
147
183
  let ncfg be provider.config_for(name, "")
148
184
  give {"ok": true, "providers": provider.providers(), "provider": name, "model": ncfg["model"], "vision": provider.supports_vision(ncfg), "configured": provider.configured()}
149
185
 
150
- route "POST /api/sessions/delete" requires auth
186
+ route "POST /w/:slug/api/sessions/delete" requires auth
151
187
  expect body {id: text}
152
188
  let b be json of request
153
189
  give {"result": session.delete(text(b["id"]))}
154
190
 
155
- route "GET /api/tree" requires auth
191
+ route "GET /w/:slug/api/tree" requires auth
156
192
  give tree.tree(6)
157
193
 
158
194
  -- Terminal real en el navegador: un shell dentro de un pseudo-terminal (pty) por conexión WebSocket.
@@ -160,8 +196,9 @@ serve on 8080
160
196
  -- Servidor → navegador: frames de texto con prefijo: "o" + salida cruda del pty (ANSI incluido, xterm.js
161
197
  -- la dibuja) | "c" + JSON de control ({type: hello|exit}).
162
198
  -- El shell vive lo que vive el socket: cerrar el panel lo mata (el runtime no deja huérfanos).
163
- route "GET /api/term" requires auth
199
+ route "GET /w/:slug/api/term" requires auth
164
200
  socket
201
+ state_set("lampson:last_used", now())
165
202
  let sh be term_shell()
166
203
  let p be proc_spawn(sh[0], sh[1], {"cwd": term_cwd(), "pty": true, "cols": 120, "rows": 32})
167
204
  ws_send(socket, "c" + json_encode({"type": "hello", "shell": sh[0], "pid": proc_stats(p)["pid"], "cwd": term_cwd()}))
@@ -188,25 +225,25 @@ serve on 8080
188
225
  ws_send(socket, "o" + as_text(ev["data"]))
189
226
  proc_close(p)
190
227
 
191
- route "GET /api/update" requires auth
228
+ route "GET /w/:slug/api/update" requires auth
192
229
  give update.check()
193
230
 
194
- route "POST /api/update" requires auth
231
+ route "POST /w/:slug/api/update" requires auth
195
232
  give {"result": update.apply()}
196
233
 
197
- route "GET /api/git" requires auth
234
+ route "GET /w/:slug/api/git" requires auth
198
235
  give git.status()
199
236
 
200
- route "GET /api/memory" requires auth
237
+ route "GET /w/:slug/api/memory" requires auth
201
238
  give {"dir": memo.dir(), "notes": memo.list()}
202
239
 
203
- route "GET /api/memory/note" requires auth
240
+ route "GET /w/:slug/api/memory/note" requires auth
204
241
  when not contains(query, "name")
205
242
  give fail(400, "name required")
206
243
  give {"name": query.name, "content": memo.note_read(query.name)}
207
244
 
208
245
  -- traza legible de una sesión (ver lib/trace.syn)
209
- route "GET /api/trace" requires auth
246
+ route "GET /w/:slug/api/trace" requires auth
210
247
  when not contains(query, "session")
211
248
  give fail(400, "session required")
212
249
  when not matches(query.session, "[A-Za-z0-9_-]{1,40}")
@@ -215,22 +252,22 @@ serve on 8080
215
252
  give {"session": query.session, "file": trace.file_of(query.session), "trace": trace.tail(query.session, n)}
216
253
 
217
254
  -- modelos que declara la API del proveedor (para no tipear nombres a ciegas: DeepSeek es case-sensitive)
218
- route "GET /api/models" requires auth
255
+ route "GET /w/:slug/api/models" requires auth
219
256
  let pname be when contains(query, "provider") then lower(text(query.provider)) otherwise ""
220
257
  let cfg be provider.config_for(pname, "")
221
258
  give provider.list_models(cfg)
222
259
 
223
- route "GET /api/todo" requires auth
260
+ route "GET /w/:slug/api/todo" requires auth
224
261
  -- ?session=<id> → la lista de esa sesión; sin query, la de la sesión en curso
225
262
  when contains(query, "session") and matches(query.session, "[A-Za-z0-9_-]{1,40}")
226
263
  give {"items": todo.load(query.session)}
227
264
  give {"items": todo.load(nothing)}
228
265
 
229
- route "GET /api/mcp" requires auth
266
+ route "GET /w/:slug/api/mcp" requires auth
230
267
  give {"servers": mcp.summary(), "global": mcp.GLOBAL_CONFIG, "project": mcp.PROJECT_CONFIG}
231
268
 
232
269
  -- conectar un server MCP desde la UI: se escribe en el mcp.json del scope y arranca su supervisor
233
- route "POST /api/mcp/add" requires auth
270
+ route "POST /w/:slug/api/mcp/add" requires auth
234
271
  expect body {name: text, command: text}
235
272
  let b be json of request
236
273
  let scope be when contains(b, "scope") then lower(text(b["scope"])) otherwise "global"
@@ -240,7 +277,7 @@ serve on 8080
240
277
  recover err
241
278
  give fail(400, text(err))
242
279
 
243
- route "POST /api/mcp/remove" requires auth
280
+ route "POST /w/:slug/api/mcp/remove" requires auth
244
281
  expect body {name: text}
245
282
  let b be json of request
246
283
  try
@@ -249,10 +286,10 @@ serve on 8080
249
286
  give fail(400, text(err))
250
287
 
251
288
  -- language servers (navegación semántica): listar, agregar (preset o comando propio), quitar
252
- route "GET /api/lsp" requires auth
289
+ route "GET /w/:slug/api/lsp" requires auth
253
290
  give {"servers": lsp.summary(), "presets": lsp.PRESETS, "global": lsp.GLOBAL_CONFIG, "project": lsp.PROJECT_CONFIG}
254
291
 
255
- route "POST /api/lsp/add" requires auth
292
+ route "POST /w/:slug/api/lsp/add" requires auth
256
293
  expect body {name: text}
257
294
  let b be json of request
258
295
  let scope be when contains(b, "scope") then lower(text(b["scope"])) otherwise "global"
@@ -263,7 +300,7 @@ serve on 8080
263
300
  recover err
264
301
  give fail(400, text(err))
265
302
 
266
- route "POST /api/lsp/remove" requires auth
303
+ route "POST /w/:slug/api/lsp/remove" requires auth
267
304
  expect body {name: text}
268
305
  let b be json of request
269
306
  try
@@ -272,11 +309,11 @@ serve on 8080
272
309
  give fail(400, text(err))
273
310
 
274
311
  -- lámparas (plugins de tools): listar y encender/apagar desde la barra superior de la UI
275
- route "GET /api/lamps" requires auth
312
+ route "GET /w/:slug/api/lamps" requires auth
276
313
  give {"lamps": lamps.summary(), "global": lamps.GLOBAL_DIR, "project": lamps.PROJECT_DIR}
277
314
 
278
315
  -- el usuario corre una tool de una lámpara encendida él mismo (sin pasar por el modelo)
279
- route "POST /api/lamps/run" requires auth
316
+ route "POST /w/:slug/api/lamps/run" requires auth
280
317
  expect body {tool: text}
281
318
  let b be json of request
282
319
  let args be when contains(b, "args") then b["args"] otherwise {}
@@ -285,7 +322,7 @@ serve on 8080
285
322
  recover err
286
323
  give fail(400, text(err))
287
324
 
288
- route "POST /api/lamps/remove" requires auth
325
+ route "POST /w/:slug/api/lamps/remove" requires auth
289
326
  expect body {name: text}
290
327
  let b be json of request
291
328
  try
@@ -293,7 +330,7 @@ serve on 8080
293
330
  recover err
294
331
  give fail(400, text(err))
295
332
 
296
- route "POST /api/lamps/toggle" requires auth
333
+ route "POST /w/:slug/api/lamps/toggle" requires auth
297
334
  expect body {name: text}
298
335
  let b be json of request
299
336
  let on be contains(b, "enabled") and b["enabled"] == true
@@ -302,16 +339,17 @@ serve on 8080
302
339
  recover err
303
340
  give fail(400, text(err))
304
341
 
305
- route "GET /api/agents" requires auth
342
+ route "GET /w/:slug/api/agents" requires auth
306
343
  agents.prune(10, 1800)
307
344
  give {"agents": agents.list_children()}
308
345
 
309
346
  -- Eventos en vivo para la UI (SSE): lo que publican los supervisores de procesos (proc.<name>: líneas y
310
347
  -- cambios de estado) y los subagentes (subagent.started|done|<id>). Un bus por programa; cada cliente
311
348
  -- tiene su cola acotada (drop_oldest). Heartbeat cada 25 s para que el proxy/navegador no cierre.
312
- route "GET /api/events" requires auth
349
+ route "GET /w/:slug/api/events" requires auth
313
350
  stream
314
- let sub be bus_subscribe(["proc.*", "subagent.*", "mcp.*"], {"max_queue": 512})
351
+ state_set("lampson:last_used", now())
352
+ let sub be bus_subscribe(["proc.*", "subagent.*", "mcp.*", "approval.*", "schedule.*"], {"max_queue": 512})
315
353
  let open be true
316
354
  while open
317
355
  let ev be bus_recv(sub, 25)
@@ -324,7 +362,7 @@ serve on 8080
324
362
  set open to false
325
363
  bus_unsubscribe(sub)
326
364
 
327
- route "GET /api/agents/log" requires auth
365
+ route "GET /w/:slug/api/agents/log" requires auth
328
366
  when not contains(query, "id")
329
367
  give fail(400, "id required")
330
368
  when not matches(query.id, "[a-z]+-[0-9]+")
@@ -339,7 +377,7 @@ serve on 8080
339
377
  recover err
340
378
  give fail(404, "no such sub-agent")
341
379
 
342
- route "POST /api/agents/stop" requires auth
380
+ route "POST /w/:slug/api/agents/stop" requires auth
343
381
  expect body {id: text}
344
382
  let b be json of request
345
383
  when not matches(b["id"], "[a-z]+-[0-9]+")
@@ -348,40 +386,117 @@ serve on 8080
348
386
  bus_publish("subagent." + b["id"], {"kind": "stop", "line": "stop requested from the UI"})
349
387
  give {"result": "stop requested for " + b["id"]}
350
388
 
351
- route "GET /api/proc" requires auth
389
+ route "GET /w/:slug/api/proc" requires auth
352
390
  give {"processes": proc.list()}
353
391
 
354
- route "GET /api/proc/log" requires auth
392
+ route "GET /w/:slug/api/proc/log" requires auth
355
393
  when not contains(query, "name")
356
394
  give fail(400, "name required")
357
395
  let n be when contains(query, "tail") then number(query.tail) otherwise 200
358
396
  give {"name": query.name, "running": proc.alive(query.name), "log": proc.logs(query.name, n)}
359
397
 
360
- route "GET /api/ports" requires auth
398
+ route "GET /w/:slug/api/ports" requires auth
361
399
  give {"ports": proc.listeners()}
362
400
 
363
- route "POST /api/ports/kill" requires auth
401
+ route "POST /w/:slug/api/ports/kill" requires auth
364
402
  expect body {pid: number}
365
403
  let b be json of request
366
404
  give {"result": proc.kill_pid(b["pid"])}
367
405
 
368
- route "POST /api/proc/stop" requires auth
406
+ route "POST /w/:slug/api/proc/stop" requires auth
369
407
  expect body {name: text}
370
408
  let b be json of request
371
409
  give {"result": proc.halt(b["name"])}
372
410
 
373
- route "GET /api/file" requires auth
411
+ route "GET /w/:slug/api/file" requires auth
374
412
  when not contains(query, "path")
375
413
  give fail(400, "path required")
376
414
  give tree.file_content(query.path)
377
415
 
378
- route "POST /api/approve" requires auth
416
+ -- configuración general (rueda de la UI): zona horaria, URL pública, webhook… → .lampson/config.json
417
+ route "GET /w/:slug/api/settings/values" requires auth
418
+ give {"values": settings.values(), "tz_detected": schedule.tz_offset()}
419
+
420
+ route "POST /w/:slug/api/settings/values" requires auth
421
+ let b be json of request
422
+ try
423
+ each k in keys(b)
424
+ settings.set_value(k, b[k])
425
+ -- la zona horaria cacheada en el blackboard se recalcula
426
+ share nothing as "lampson:tz"
427
+ give {"ok": true, "values": settings.values(), "tz_detected": schedule.tz_offset()}
428
+ recover err
429
+ give fail(400, text(err))
430
+
431
+ -- aprobaciones (lib/approvals.syn): las del chat y las de tareas programadas comparten la cola
432
+ route "POST /w/:slug/api/approve" requires auth
379
433
  expect body {id: text, decision: bool}
380
434
  let b be json of request
381
- state_set("approval:" + b["id"], when b["decision"] then "yes" otherwise "no")
382
- give {"ok": true}
435
+ give {"ok": approvals.answer(text(b["id"]), b["decision"])}
436
+
437
+ route "GET /w/:slug/api/approvals" requires auth
438
+ give {"approvals": approvals.list(), "public_url": approvals.public_url(), "webhook": approvals.webhook_url() != ""}
439
+
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
441
+ -- autorización, como en el `approve` nativo de Synsema bajo serve). GET porque un link en un chat es un GET.
442
+ route "GET /w/:slug/approve/:id/:token"
443
+ let d be when contains(query, "d") then lower(text(query.d)) otherwise ""
444
+ when d != "yes" and d != "no"
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>"))
446
+ let r be approvals.answer_with_token(text(params.id), text(params.token), d == "yes")
447
+ when not r["ok"]
448
+ 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"] + ")."))
449
+ give html(approvals.html_page(when d == "yes" then "✓ Permitido" otherwise "✗ Denegado", "Lampson sigue con la tarea. Podés cerrar esta pestaña."))
450
+
451
+ -- tareas programadas (lib/schedule.syn): listar, crear, quitar, encender/apagar, correr ahora, log
452
+ route "GET /w/:slug/api/schedules" requires auth
453
+ let age be schedule.daemon_age()
454
+ -- este proceso tiene su propio tick (cron_every): las tareas corren mientras la web esté abierta
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()}
456
+
457
+ route "POST /w/:slug/api/schedules/add" requires auth
458
+ let b be json of request
459
+ try
460
+ let t be schedule.add(b)
461
+ give {"ok": true, "task": t, "result": "programada «" + t["name"] + "»: " + schedule.describe_plan(t["plan"]) + " · próxima " + schedule.fmt_local(t["next_run"])}
462
+ recover err
463
+ give fail(400, text(err))
464
+
465
+ route "POST /w/:slug/api/schedules/remove" requires auth
466
+ expect body {id: text}
467
+ let b be json of request
468
+ try
469
+ give {"ok": true, "result": schedule.remove(text(b["id"]))}
470
+ recover err
471
+ give fail(400, text(err))
472
+
473
+ route "POST /w/:slug/api/schedules/toggle" requires auth
474
+ expect body {id: text}
475
+ let b be json of request
476
+ let on be contains(b, "enabled") and b["enabled"] == true
477
+ try
478
+ give {"ok": true, "result": schedule.set_enabled(text(b["id"]), on)}
479
+ recover err
480
+ give fail(400, text(err))
481
+
482
+ -- correr ahora: la marca run_now la levanta el próximo tick (así el request no se queda minutos abierto
483
+ -- y la corrida vive en el hilo del cron, no en el del request)
484
+ route "POST /w/:slug/api/schedules/run" requires auth
485
+ expect body {id: text}
486
+ let b be json of request
487
+ try
488
+ let t be schedule.request_run(text(b["id"]))
489
+ give {"ok": true, "result": "«" + t["name"] + "» corre en el próximo tick (≤ " + text(schedule.TICK_SECONDS) + " s)"}
490
+ recover err
491
+ give fail(400, text(err))
492
+
493
+ route "GET /w/:slug/api/schedules/log" requires auth
494
+ when not contains(query, "id")
495
+ give fail(400, "id required")
496
+ let n be when contains(query, "tail") then floor(number(query.tail)) otherwise 300
497
+ give {"id": query.id, "log": schedule.log_tail(query.id, n), "task": schedule.get(query.id)}
383
498
 
384
- route "POST /api/chat" requires auth
499
+ route "POST /w/:slug/api/chat" requires auth
385
500
  expect body {message: text}
386
501
  let b be json of request
387
502
  let message be b["message"]
@@ -392,24 +507,19 @@ serve on 8080
392
507
  set mode to "ask"
393
508
  let pname be when contains(b, "provider") then lower(text(b["provider"])) otherwise ""
394
509
  let pmodel be when contains(b, "model") then text(b["model"]) otherwise ""
510
+ state_set("lampson:last_used", now())
395
511
  stream
396
512
  task emit(kind, data, tag)
397
513
  trace.event(sid, kind, data, tag)
398
514
  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)
515
+ -- aprobación humana: la cola de lib/approvals.syn (id + token) la UI responde por SSE/POST /api/approve,
516
+ -- y si hay webhook/LAMPSON_PUBLIC_URL también llega un link al canal del usuario (aprobar desde el móvil)
400
517
  task ask_web(name, args, why)
401
- let id be text(floor(now() * 1000)) + "-" + name
518
+ let id be approvals.open("chat", permission.describe_call(name, args), why, APPROVAL_TIMEOUT)
402
519
  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"
520
+ let t0 be now()
521
+ let ok be approvals.wait(id, APPROVAL_TIMEOUT)
522
+ send {"kind": "approval_result", "data": {"id": id, "approved": ok, "timeout": (not ok) and now() - t0 >= APPROVAL_TIMEOUT - 1}} as "event"
413
523
  give ok
414
524
  let ctx be boot(want, mode, ask_web, pname, pmodel)
415
525
  let messages be [ctx["system"]]