lampson 0.1.3 → 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.
package/.env.example CHANGED
@@ -27,3 +27,12 @@ LAMPSON_API_KEY=sk-...
27
27
 
28
28
  # Reanudar una sesión en chat.syn
29
29
  # LAMPSON_SESSION=20260826-233000
30
+
31
+ # Tareas programadas y aprobaciones a distancia (proceso residente: lampson --daemon start)
32
+ # LAMPSON_TZ=-03:00 # zona horaria para "daily 09:00" (default: la del sistema, `date +%z`)
33
+ # LAMPSON_PUBLIC_URL=https://lampson.midominio.com # URL por la que se llega a este lampson desde afuera (túnel, VPS,
34
+ # # edge Synsema con TLS): con esto cada aprobación pendiente trae links de un solo uso
35
+ # # (GET /approve/<id>/<token>?d=yes|no) para decidir desde el móvil
36
+ # LAMPSON_WEBHOOK_URL=https://hooks.example.com/lampson # POST JSON por cada aprobación pendiente (id, mensaje, links);
37
+ # # reenvialo a Telegram/Slack/mail con n8n, un bot o un .syn de 6 líneas
38
+ # LAMPSON_WEBHOOK_SECRET=… # firma HMAC-SHA256 del body (X-Lampson-Signature: sha256=<hex>)
package/README.md CHANGED
@@ -29,6 +29,14 @@ language itself — and every step is visible, in the terminal or in a web UI.
29
29
  under a capability ceiling, or any executable (js, py, sh…). Global in `lampson/lamps/<name>/`, per
30
30
  project in `.lampson/lamps/<name>/` (the agent can write those). **Off by default**: you turn them on
31
31
  from the top bar of the web UI or `/lamps on <name>`; their tools join the catalog as `lamp_<lamp>_<tool>`.
32
+ - **Scheduled tasks**: "every 6h", "daily 09:00", "mon,wed 08:30" — a lamp tool, a fixed shell command, or a
33
+ full unattended agent run from a prompt with a permission envelope fixed when you create it (`strict` /
34
+ `ask` / `yolo`). They run inside Lampson's resident process (`lampson --daemon start`, or the open web UI),
35
+ the run's session shows up as `⏰ name`, and a webhook can receive each result (for "search and send me" tasks).
36
+ - **Approvals from anywhere**: an unattended run that hits something dangerous waits for you — in the web UI
37
+ («Aprobaciones»), and, with `LAMPSON_PUBLIC_URL` + `LAMPSON_WEBHOOK_URL`, through a signed webhook carrying
38
+ one-time **decision links** you can open from your phone (Telegram, mail, Slack…). Never auto-approved:
39
+ no answer in time = denied.
32
40
  - **Project memory**: the agent keeps its own notes per project (`memory/<project>/*.md`, outside
33
41
  the repo) — how to run it, gotchas, decisions — and rereads them in the next session. You can read
34
42
  and edit them (web panel, `/memory`).
@@ -116,6 +124,46 @@ does the same with persistent sessions and memory.
116
124
  > Status: developed and tested on Windows 11; the Docker image (Ubuntu 24.04) is built by CI. The Linux/macOS
117
125
  > installer is written but not yet exercised on a real machine — issues welcome.
118
126
 
127
+ ## Scheduled tasks and the resident process
128
+
129
+ Lampson can run things with nobody at the keyboard. Scheduled tasks run inside **any** open Lampson — the web UI
130
+ or the terminal REPL (a background thread ticks every 30 s). To run them with nothing open:
131
+
132
+ ```
133
+ lampson --daemon start # web UI + scheduler + approvals, in the background (synsema daemon)
134
+ lampson --daemon status # also: stop · logs · restart
135
+ ```
136
+
137
+ Timezone, public URL and webhook are set from the ⚙ button of the web header (tabs: General · Aprobaciones a
138
+ distancia · Proveedor; stored in `lampson/.lampson/config.json`, `.env` wins) or from `.env`.
139
+
140
+ Create a task from the chat («todos los días a las 9 corré los tests y avisame» — the agent calls the
141
+ `schedule` tool and asks you to approve the task, showing exactly what will run and with which permissions),
142
+ from the web sidebar («Programadas» → +), or from the terminal (`/schedule add <json>`). Three kinds:
143
+
144
+ | kind | what runs | authorization |
145
+ |---|---|---|
146
+ | `lamp` | a tool of a lamp that is ON | turning the lamp on |
147
+ | `bash` | one fixed command (must finish on its own) | approved once, at creation |
148
+ | `prompt` | a full agent turn with your instructions and a profile (`build` / `review` / `plan` / `explore`) | the permission envelope: `strict` (dangerous → denied), `ask` (dangerous → approval request, denied if unanswered within `approval_timeout`, 2 h by default), `yolo` |
149
+
150
+ Schedules: `every 6h` · `every 30m` · `daily 09:00` · `mon,wed 08:30` · `weekdays 09:00` (local time; `LAMPSON_TZ`
151
+ overrides). Synsema's `cron_every` is a pure interval, so a 30 s tick in `web.syn` translates it to wall-clock time;
152
+ a run missed while the daemon was down executes when it comes back and is marked as late. State lives in
153
+ `.lampson/schedules.json`, logs in `.lampson/schedules/<id>.log`; a `prompt` run also leaves a normal session (`⏰ name`)
154
+ with its trace. `notify` = a webhook URL that receives each result as JSON.
155
+
156
+ **Approving from your phone.** Set `LAMPSON_PUBLIC_URL` (how this Lampson is reached from outside — a tunnel, a VPS,
157
+ or a Synsema edge with TLS in front) and `LAMPSON_WEBHOOK_URL` (+ `LAMPSON_WEBHOOK_SECRET`, HMAC-SHA256 in
158
+ `X-Lampson-Signature`). Every pending approval — from a scheduled run or from the chat — POSTs `{id, message, why,
159
+ expires_at, respond_link_yes, respond_link_no}`; forward the links wherever you read (n8n, a bot, a 6-line `.syn`).
160
+ `GET /approve/<id>/<token>?d=yes|no` is public on purpose: the 32-byte one-time token is the authorization, and it
161
+ dies with the deadline. `GET /api/approvals` (loopback) lists what is pending, never the tokens.
162
+
163
+ For a real server use systemd (`synsema serve web.syn`, `Restart=always`) instead of `synsema daemon`, which has no
164
+ boot start / crash restart. One box, several projects: one `web.syn` per project on its own port, behind a Synsema
165
+ edge that terminates TLS and routes by host (see the Synsema deploy docs).
166
+
119
167
  ## Use
120
168
 
121
169
  ```bash
@@ -185,7 +233,13 @@ run Lampson in a container.
185
233
  lampson.ps1 / .sh launcher: mounts ./workspace, starts terminal or web
186
234
  chat.syn terminal REPL (colors, approvals via Synsema's native `approve`)
187
235
  web.syn HTTP server: POST /api/chat → SSE events; sessions, tree, file viewer, processes, ports
188
- public/index.html web UI (no build step, no dependencies)
236
+ public/ web UI (no build step, no dependencies; classic scripts served by `static`)
237
+ index.html markup only: header, the two side panels, the chat; loads css/ and js/ in order
238
+ css/ tokens (fonts, palette, base) · layout (grid, header, panels, chat, composer) · sidebar · chat · panel
239
+ js/core.js shared state + helpers ($, esc, md, add, api, showPane/showText, inlineConfirm, debounce, empty)
240
+ js/panel.js THE modal component: one shell, three layouts (browse = search + list + detail, tabs, form)
241
+ js/<view>.js one file per thing on screen: sessions, chat, tree, terminal, procs, agents, memory, todo,
242
+ mcp, lsp, lamps, schedules, approvals, config (+ provider), update, events (SSE), app (boot)
189
243
  lib/
190
244
  provider.syn config from .env · chat(cfg, messages, catalog) · retry with backoff
191
245
  loop.syn run_turn(): LLM → tool calls → permissions → call_tool → results → repeat; doom-loop guard; compaction
package/chat.syn CHANGED
@@ -14,6 +14,7 @@ require net
14
14
  require time
15
15
  require stdin
16
16
  require exec
17
+ require random
17
18
  require env("LAMPSON_*")
18
19
  require env("OS")
19
20
  require secret("LAMPSON_*")
@@ -51,6 +52,9 @@ use "./lib/mcp.syn" as mcp
51
52
  use "./lib/lamps.syn" as lamps
52
53
  use "./lib/lsp.syn" as lsp
53
54
  use "./lib/tools/todo.syn" as todo
55
+ use "./lib/schedule.syn" as schedule
56
+ use "./lib/sched_run.syn" as sched_run
57
+ use "./lib/approvals.syn" as approvals
54
58
 
55
59
  -- ---------- UI de terminal ----------
56
60
  -- Colores ANSI (Windows Terminal, PowerShell 7, cualquier terminal moderna). LAMPSON_NO_COLOR=1 los apaga.
@@ -344,6 +348,8 @@ let COMMANDS be [
344
348
  ["/mcp", "[add <nombre> <comando…> [--project] | remove <nombre>]", "servers MCP: listar, conectar o quitar (global: lampson/.lampson/mcp.json · proyecto: .lampson/mcp.json)"],
345
349
  ["/lamps", "[on <nombre> | off <nombre> | run <lámpara> <tool> [json] | remove <nombre>]", "lámparas (plugins de tools): listar, encender o apagar (global: lampson/lamps/ · proyecto: .lampson/lamps/)"],
346
350
  ["/lsp", "[add <typescript|python|rust|go|css|html> [--project] | add <nombre> <comando…> --ext .x=lang | remove <nombre>]", "language servers (navegación semántica: symbols/definition/references/hover); arrancan en la primera consulta"],
351
+ ["/schedule", "[add <json> | run <id> | on <id> | off <id> | remove <id> | log <id>]", "tareas programadas (cada 6h, todos los días a las 9…): lámpara, comando o corrida del agente; corren mientras lampson esté abierto (o con lampson --daemon start, sin nada abierto)"],
352
+ ["/approve", "<id> yes|no", "responder una aprobación pendiente de una tarea programada que corre en background"],
347
353
  ["/out", "[n]", "resultado completo de la última tool del turno (n = contar hacia atrás: /out 2 es la anteúltima)"],
348
354
  ["/verbose", "", "alternar: mostrar SIEMPRE el output completo de cada tool (queda guardado en config.json)"],
349
355
  ["/trace", "[n]", "traza legible de esta sesión (pasos, tools, tiempos, tokens, errores): .lampson/trace/<sesión>.log"],
@@ -403,6 +409,16 @@ task complete_args(cmd, head, last)
403
409
  when trim(head) == "add"
404
410
  give sort_by(keys(lsp.PRESETS), (x) => x)
405
411
  give ["--project", "--ext"]
412
+ when cmd == "/approve"
413
+ when first
414
+ give apply((a) => a["id"], approvals.pending())
415
+ give ["yes", "no"]
416
+ when cmd == "/schedule"
417
+ when first
418
+ give ["add", "run", "on", "off", "remove", "log"]
419
+ when starts_with(trim(head), "add")
420
+ give []
421
+ give apply((s) => s["id"], schedule.summary())
406
422
  when cmd == "/agent"
407
423
  give ["build", "plan", "review", "explore", "worker"]
408
424
  when cmd == "/model"
@@ -672,6 +688,47 @@ task opts_for(p, mode)
672
688
  task lampson_subagent(spec_json)
673
689
  give agents.run_child_json(spec_json)
674
690
 
691
+ -- Tareas programadas TAMBIÉN en la terminal: `cron_every` no corre mientras el hilo principal está en el teclado,
692
+ -- pero un `agent` sí (hilo real, como el Spinner). Cada 30 s llama al tick (heartbeat + corridas vencidas); una
693
+ -- corrida `prompt` transcurre al lado del chat sin tocar su sesión (blackboard lampson:sched:session) y si pide
694
+ -- permiso lo dice por consola: /approve <id> yes|no (o link/webhook si están configurados).
695
+ task lampson_sched_tick()
696
+ give sched_run.tick()
697
+
698
+ agent Sched
699
+ require net
700
+ require time
701
+ require exec
702
+ require random
703
+ require env("LAMPSON_*")
704
+ require env("OS")
705
+ require secret("LAMPSON_*")
706
+ require file("workspace")
707
+ require file("workspace/*")
708
+ require file.read("skills")
709
+ require file.read("skills/*")
710
+ require file.read("lamps")
711
+ require file.read("lamps/*")
712
+ require file("memory")
713
+ require file("memory/*")
714
+ require file(".lampson")
715
+ require file(".lampson/*")
716
+ let alive be true
717
+ let waited be 25
718
+ while alive
719
+ observe "lampson:spinner:stop" as st
720
+ when st == true
721
+ set alive to false
722
+ otherwise when waited >= 30
723
+ set waited to 0
724
+ try
725
+ lampson_sched_tick()
726
+ recover err
727
+ print("[lampson] scheduler: " + text(err))
728
+ otherwise
729
+ sleep(1)
730
+ set waited to waited + 1
731
+
675
732
  -- !comando del usuario en un pseudo-terminal: la salida se ve EN VIVO y, si el proceso se queda esperando
676
733
  -- (prompt y/N, contraseña, REPL, `npm init`…), lo que escribas se le manda como teclas + Enter.
677
734
  -- Enter vacío = seguir esperando · ^C = cortar. Devuelve la salida (sin ANSI) para el contexto del agente.
@@ -765,6 +822,7 @@ banner(env_info["cwd"], cfg, profile, opts["permission_mode"], sid)
765
822
  share nothing as "lampson:busy"
766
823
  share false as "lampson:spinner:stop"
767
824
  spawn Spinner with esc = ESC, color = COLOR
825
+ spawn Sched
768
826
  flush()
769
827
 
770
828
  let total_usage be {"input": 0, "output": 0}
@@ -1005,6 +1063,71 @@ while running
1005
1063
  each s in ss
1006
1064
  print(" " + (when s["status"] == "ready" then green("● ") otherwise (when s["status"] == "error" or s["status"] == "exited" then red("○ ") otherwise dim("○ "))) + pad(s["name"], 12) + dim(pad(s["scope"], 8) + pad(s["status"], 8) + join(s["extensions"], " ") + " $ " + s["command"]) + (when s["error"] != nothing then red(" " + text(s["error"])) otherwise ""))
1007
1065
  print(" " + dim("idle = arranca en la primera consulta del agente"))
1066
+ otherwise when input == "/schedule" or starts_with(input, "/schedule ")
1067
+ -- tareas programadas (lib/schedule.syn). Crear: JSON, o pedírselo al agente ("todos los días a las 9 corré…").
1068
+ -- Las corre el daemon (web.syn con `lampson --daemon start`); `/schedule run` las corre acá mismo, con
1069
+ -- el menú de aprobación de la terminal si la tarea es `ask`.
1070
+ let srest be trim(slice(input, 9, length(input)))
1071
+ let stoks be where(split(srest, " "), (x) => x != "")
1072
+ let sact be when length(stoks) > 0 then stoks[0] otherwise ""
1073
+ let sid_arg be when length(stoks) > 1 then stoks[1] otherwise ""
1074
+ when sact == "add"
1075
+ let sjson be trim(slice(srest, 3, length(srest)))
1076
+ when sjson == ""
1077
+ print(" uso: /schedule add {\"name\": \"…\", \"when\": \"daily 09:00\", \"action\": {\"type\": \"bash\", \"command\": \"npm test\"}, \"permission\": \"ask\"}")
1078
+ print(" " + dim("when: every 6h · daily 09:00 · mon,wed 08:30 · weekdays 09:00 action.type: lamp {lamp, tool, args} · bash {command} · prompt {prompt, agent}"))
1079
+ print(" " + dim("o pedíselo al agente en lenguaje natural: «todos los días a las 9 revisá los tests y avisame»"))
1080
+ otherwise
1081
+ try
1082
+ let st be schedule.add(json_decode(sjson))
1083
+ print(" " + green("⏰ programada «" + st["name"] + "» (" + st["id"] + "): " + schedule.describe_plan(st["plan"]) + " · próxima " + schedule.fmt_local(st["next_run"])))
1084
+ recover err
1085
+ print(" " + red(text(err)))
1086
+ otherwise when sact == "run" and sid_arg != ""
1087
+ let st be schedule.get(sid_arg)
1088
+ when st == nothing
1089
+ print(" " + red("no existe la tarea " + sid_arg))
1090
+ otherwise
1091
+ print(" " + yellow("⏰ corriendo «" + st["name"] + "» · " + schedule.describe_action(st["action"])))
1092
+ flush()
1093
+ let sr be sched_run.run(st, ask_user, on_event)
1094
+ print(" " + (when sr["last_status"] == "ok" then green("✓ ") otherwise red("✗ ")) + sr["last_status"] + " · " + first_line(sr["last_summary"], 160))
1095
+ print(" " + dim("log: " + schedule.LOG_DIR + "/" + st["id"] + ".log"))
1096
+ otherwise when (sact == "on" or sact == "off") and sid_arg != ""
1097
+ try
1098
+ print(" " + schedule.set_enabled(sid_arg, sact == "on"))
1099
+ recover err
1100
+ print(" " + red(text(err)))
1101
+ otherwise when sact == "remove" and sid_arg != ""
1102
+ try
1103
+ print(" " + schedule.remove(sid_arg))
1104
+ recover err
1105
+ print(" " + red(text(err)))
1106
+ otherwise when sact == "log" and sid_arg != ""
1107
+ let tl be schedule.log_tail(sid_arg, 60)
1108
+ print(when tl == "" then " sin corridas todavía" otherwise tl)
1109
+ otherwise
1110
+ let ss be schedule.summary()
1111
+ let age be schedule.daemon_age()
1112
+ when length(ss) == 0
1113
+ print(" sin tareas programadas. Pedísela al agente («cada 6 horas corré los tests y avisame») o: " + cyan("/schedule add <json>"))
1114
+ each s in ss
1115
+ print(" " + (when s["running"] then yellow("● ") otherwise (when s["enabled"] then green("● ") otherwise dim("○ "))) + pad(s["id"], 22) + dim(pad(s["plan"], 30)) + s["action_text"])
1116
+ print(" " + dim("permisos " + s["permission"] + " · próxima " + s["next_local"] + (when s["last_run"] != nothing then " · última " + s["last_status"] + ": " + first_line(s["last_summary"], 80) otherwise "")))
1117
+ let pend be approvals.pending()
1118
+ each ap in pend
1119
+ print(" " + yellow("⚠ aprobación pendiente " + ap["id"] + ": " + first_line(ap["message"], 100)) + dim(" → /approve " + ap["id"] + " yes|no"))
1120
+ print(" " + dim("corren solas mientras lampson esté abierto (tick cada " + text(schedule.TICK_SECONDS) + " s) · con todo cerrado: lampson --daemon start · run/on/off/remove/log <id>"))
1121
+ otherwise when starts_with(input, "/approve ")
1122
+ let atoks be where(split(trim(slice(input, 9, length(input))), " "), (x) => x != "")
1123
+ when length(atoks) < 2 or not contains(["yes", "no", "si", "sí", "y", "n"], lower(atoks[1]))
1124
+ print(" uso: /approve <id> yes|no (pendientes: /schedule)")
1125
+ otherwise
1126
+ let yes be contains(["yes", "si", "sí", "y"], lower(atoks[1]))
1127
+ when approvals.answer(atoks[0], yes)
1128
+ print(" " + (when yes then green("✓ permitido ") otherwise red("✗ denegado ")) + atoks[0])
1129
+ otherwise
1130
+ print(" " + red("no hay una aprobación pendiente con id " + atoks[0]))
1008
1131
  otherwise when input == "/lamps" or starts_with(input, "/lamps ")
1009
1132
  let lrest be trim(slice(input, 6, length(input)))
1010
1133
  when starts_with(lrest, "run ")
package/lampson.ps1 CHANGED
@@ -5,6 +5,7 @@
5
5
  # lampson --workspace C:\otro\proyecto # elegir la ubicación explícitamente (también -Workspace)
6
6
  # lampson --agent plan # perfil inicial: build | plan | review | explore
7
7
  # lampson --yolo | --strict | --ask # permisos para comandos peligrosos (--dangerously-skip-permissions = --yolo)
8
+ # lampson --daemon start|stop|status|logs|restart # proceso residente (web + tareas programadas + aprobaciones)
8
9
  # lampson --update # actualizar Lampson (git pull) y salir
9
10
  # lampson --help
10
11
  #
@@ -17,12 +18,13 @@ $caller = (Get-Location).Path
17
18
  $mount = Join-Path $here "workspace"
18
19
 
19
20
  # --- args: acepta --flag y -Flag, sin distinguir mayúsculas ---
20
- $Workspace = ""; $Web = $false; $Agent = ""; $Perm = ""
21
+ $Workspace = ""; $Web = $false; $Agent = ""; $Perm = ""; $Daemon = ""
21
22
  $i = 0
22
23
  while ($i -lt $args.Count) {
23
24
  $a = [string]$args[$i]
24
25
  switch -Regex ($a.ToLower()) {
25
26
  '^--?(web|w)$' { $Web = $true }
27
+ '^--?(daemon|d)$' { $i++; $Daemon = if ($i -lt $args.Count) { ([string]$args[$i]).ToLower() } else { "status" } }
26
28
  '^--?(workspace|ws)$' { $i++; $Workspace = [string]$args[$i] }
27
29
  '^--?(agent|a)$' { $i++; $Agent = [string]$args[$i] }
28
30
  '^--?(yolo|y|dangerously-skip-permissions)$' { $Perm = "yolo" }
@@ -30,7 +32,7 @@ while ($i -lt $args.Count) {
30
32
  '^--?(ask)$' { $Perm = "ask" }
31
33
  '^--?(permission|p)$' { $i++; $Perm = ([string]$args[$i]).ToLower() }
32
34
  '^--?(update|u)$' { Write-Host "actualizando Lampson en $here"; git -C $here pull --ff-only origin main; Write-Host ("lampson " + (git -C $here rev-parse --short HEAD)); exit $LASTEXITCODE }
33
- '^--?(help|h|\?)$' { Get-Content $PSCommandPath | Select-Object -Skip 1 -First 9 | ForEach-Object { $_.TrimStart('#',' ') }; exit 0 }
35
+ '^--?(help|h|\?)$' { Get-Content $PSCommandPath | Select-Object -Skip 1 -First 10 | ForEach-Object { $_.TrimStart('#',' ') }; exit 0 }
34
36
  default { if ($Workspace -eq "" -and -not $a.StartsWith("-")) { $Workspace = $a } else { Write-Error "argumento desconocido: $a (probá lampson --help)"; exit 1 } }
35
37
  }
36
38
  $i++
@@ -49,6 +51,12 @@ if ($Workspace -ne "") {
49
51
  if (Test-Path -LiteralPath $mount) {
50
52
  $item = Get-Item -LiteralPath $mount -Force
51
53
  if ($item.LinkType -ne "Junction") { Write-Error "./workspace existe y no es una junction; movelo antes de montar otro proyecto"; exit 1 }
54
+ # un daemon/web corriendo sobre OTRO proyecto (latido reciente del scheduler) quedaría apuntando a este: aviso
55
+ $prev = $item.Target; if ($prev -is [array]) { $prev = $prev[0] }
56
+ $hb = Join-Path $here ".lampson\schedules.heartbeat"
57
+ if ($prev -and $prev.TrimEnd('\').ToLower() -ne $target.TrimEnd('\').ToLower() -and (Test-Path -LiteralPath $hb) -and ((Get-Date) - (Get-Item -LiteralPath $hb).LastWriteTime).TotalSeconds -lt 90) {
58
+ Write-Host "AVISO: hay un lampson (web/daemon) corriendo sobre $prev; al montar $target sus tareas programadas quedan en pausa. Paralo o reinicialo desde su carpeta: lampson --daemon restart" -ForegroundColor Yellow
59
+ }
52
60
  $item.Delete()
53
61
  }
54
62
  New-Item -ItemType Junction -Path $mount -Target $target | Out-Null
@@ -76,7 +84,13 @@ try {
76
84
  $env:LAMPSON_WORKSPACE = $target
77
85
  if ($Agent -ne "") { $env:LAMPSON_AGENT = $Agent }
78
86
  if ($Perm -ne "") { $env:LAMPSON_PERMISSION = $Perm }
79
- if ($Web) {
87
+ if ($Daemon -ne "") {
88
+ # proceso residente: web.syn en background (synsema daemon, sin config del sistema; para producción,
89
+ # systemd/NSSM con `synsema serve web.syn`). Ejecuta las tareas programadas y atiende aprobaciones por link.
90
+ if ($Daemon -notmatch '^(start|stop|status|logs|restart)$') { Write-Error "uso: lampson --daemon start|stop|status|logs|restart"; exit 1 }
91
+ if ($Daemon -eq "start" -or $Daemon -eq "restart") { Write-Host "Lampson daemon · workspace: $target · http://127.0.0.1:8080" }
92
+ synsema daemon $Daemon web.syn
93
+ } elseif ($Web) {
80
94
  Write-Host "Lampson web · workspace: $target"
81
95
  Write-Host "abrí http://127.0.0.1:8080 (Ctrl+C para parar)"
82
96
  synsema serve web.syn
package/lampson.sh CHANGED
@@ -3,20 +3,22 @@
3
3
  # cd /mi/proyecto && lampson # REPL (con lampson/ en el PATH)
4
4
  # cd /mi/proyecto && lampson --web # servidor web en http://127.0.0.1:8080
5
5
  # lampson --workspace /otro/proyecto [--web] [--agent plan]
6
+ # lampson --daemon start|stop|status|logs|restart # proceso residente (web + tareas programadas + aprobaciones)
6
7
  set -euo pipefail
7
8
  here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
9
  caller="$(pwd)"
9
- ws=""; web=0
10
+ ws=""; web=0; daemon=""
10
11
  while [ $# -gt 0 ]; do
11
12
  case "$1" in
12
13
  --web) web=1 ;;
14
+ --daemon) shift; daemon="${1:-status}" ;;
13
15
  --workspace) shift; ws="$1" ;;
14
16
  --agent) shift; export LAMPSON_AGENT="$1" ;;
15
17
  --yolo|--dangerously-skip-permissions) export LAMPSON_PERMISSION=yolo ;;
16
18
  --strict) export LAMPSON_PERMISSION=strict ;;
17
19
  --ask) export LAMPSON_PERMISSION=ask ;;
18
20
  --update) echo "actualizando Lampson en $here"; git -C "$here" pull --ff-only origin main; echo "lampson $(git -C "$here" rev-parse --short HEAD)"; exit $? ;;
19
- *) echo "uso: lampson [--web] [--workspace RUTA] [--agent PERFIL] [--yolo|--strict|--ask] [--update]" >&2; exit 1 ;;
21
+ *) echo "uso: lampson [--web] [--daemon start|stop|status|logs|restart] [--workspace RUTA] [--agent PERFIL] [--yolo|--strict|--ask] [--update]" >&2; exit 1 ;;
20
22
  esac
21
23
  shift
22
24
  done
@@ -25,6 +27,13 @@ if [ -n "$ws" ]; then
25
27
  [ -d "$ws" ] || { echo "el workspace no existe o no es un directorio: $ws" >&2; exit 1; }
26
28
  ws="$(cd "$ws" && pwd)"
27
29
  if [ -e "$here/workspace" ] && [ ! -L "$here/workspace" ]; then echo "./workspace existe y no es un symlink" >&2; exit 1; fi
30
+ # un daemon/web corriendo sobre OTRO proyecto (latido reciente del scheduler) quedaría apuntando a este: aviso
31
+ if [ -L "$here/workspace" ] && [ -f "$here/.lampson/schedules.heartbeat" ]; then
32
+ prev="$(readlink -f "$here/workspace")"
33
+ if [ "$prev" != "$ws" ] && [ $(( $(date +%s) - $(stat -c %Y "$here/.lampson/schedules.heartbeat") )) -lt 90 ]; then
34
+ echo "AVISO: hay un lampson (web/daemon) corriendo sobre $prev; al montar $ws sus tareas programadas quedan en pausa. Paralo o reinicialo desde su carpeta: lampson --daemon restart" >&2
35
+ fi
36
+ fi
28
37
  rm -f "$here/workspace"; ln -s "$ws" "$here/workspace"
29
38
  elif [ ! -e "$here/workspace" ]; then
30
39
  echo "no hay workspace: corré lampson desde el directorio del proyecto, o --workspace /ruta" >&2; exit 1
@@ -39,4 +48,9 @@ for pair in "skills-global:$HOME/.agents/skills" "skills-claude:$HOME/.claude/sk
39
48
  done
40
49
  echo "workspace -> $LAMPSON_WORKSPACE"
41
50
  cd "$here"
42
- if [ "$web" = 1 ]; then echo "web: http://127.0.0.1:8080"; exec synsema serve web.syn; else exec synsema run chat.syn; fi
51
+ if [ -n "$daemon" ]; then
52
+ # proceso residente en background (synsema daemon: sin arranque al boot ni reinicio; para producción, systemd
53
+ # con `synsema serve web.syn`). Ejecuta las tareas programadas y atiende aprobaciones por link.
54
+ case "$daemon" in start|stop|status|logs|restart) ;; *) echo "uso: lampson --daemon start|stop|status|logs|restart" >&2; exit 1 ;; esac
55
+ exec synsema daemon "$daemon" web.syn
56
+ elif [ "$web" = 1 ]; then echo "web: http://127.0.0.1:8080"; exec synsema serve web.syn; else exec synsema run chat.syn; fi
package/lib/agents.syn CHANGED
@@ -40,7 +40,7 @@ let PARALLEL be 4
40
40
 
41
41
  export let PROFILES be {
42
42
  "build": {
43
- "tools": ["read", "write", "edit", "ls", "find", "grep", "lsp", "bash", "process", "memory", "todo", "skill", "mcp", "lamp", "delegate"],
43
+ "tools": ["read", "write", "edit", "ls", "find", "grep", "lsp", "bash", "process", "memory", "todo", "skill", "mcp", "lamp", "schedule", "delegate"],
44
44
  "steps": 40,
45
45
  "addendum": "You are in BUILD mode: you may read, edit, create files and run commands. Delegate exploration of large codebases to the `explore` agent and independent code review to the `review` agent when it saves context; several independent questions can go in one delegate call (they run in parallel)."
46
46
  },
@@ -0,0 +1,176 @@
1
+ -- lib/approvals.syn — aprobaciones humanas fuera de la terminal: cola compartida + webhook + links de decisión
2
+ --
3
+ -- Un solo mecanismo para las puertas por las que un humano aprueba algo que el agente quiere hacer:
4
+ -- 1. la UI web (POST /api/approve desde el chat, o desde el panel «Aprobaciones» para las tareas programadas);
5
+ -- 2. la terminal: /approve <id> yes|no (una tarea programada corriendo en background mientras chateás);
6
+ -- 3. un LINK firmado que llega por el canal que sea (Telegram, mail, Slack…): GET /approve/<id>/<token>?d=yes|no
7
+ -- — de un solo uso; el token es un secreto por aprobación (32 bytes CSPRNG), igual que el `approve` nativo
8
+ -- de Synsema bajo serve (doc 62-human), solo que acá también sirve para las tareas del cron y para la UI;
9
+ -- 4. la consola del proceso (el link se imprime ahí también).
10
+ -- Además, si LAMPSON_WEBHOOK_URL está configurado (o config.json {"webhook_url"}), cada aprobación pendiente dispara
11
+ -- un POST JSON con {id, message, why, expires_at, respond_link_yes, respond_link_no} firmado con HMAC-SHA256
12
+ -- (X-Lampson-Signature: sha256=<hex>, secreto LAMPSON_WEBHOOK_SECRET) — el mismo patrón que Stripe/GitHub, así
13
+ -- cualquier receptor (n8n, un bot, otro .syn) reenvía los links. Los links necesitan LAMPSON_PUBLIC_URL (la URL
14
+ -- por la que se llega a este lampson desde afuera: un túnel, el VPS, un edge Synsema con TLS).
15
+ -- Fire-and-forget (10 s, un intento): un canal caído nunca bloquea; la UI y la consola quedan como respaldo.
16
+ --
17
+ -- Estado: BLACKBOARD (share/observe), que existe bajo `serve` y bajo `run` y lo ven handlers, agentes y ticks
18
+ -- (`state_*` solo existe bajo serve — verificado 2026-08-29). Claves: "approval:meta:<id>" (visible, sin token),
19
+ -- "approval:token:<id>", "approval:answer:<id>" (yes|no) y el índice "approval:index" (lista de ids).
20
+ --
21
+ -- Garantía: NUNCA se auto-aprueba. Sin respuesta al vencer el plazo → denegado (fail-closed), como Synsema.
22
+
23
+ use "./settings.syn" as settings
24
+
25
+ task read_cfg(key, envname)
26
+ let v be env(envname, "")
27
+ when v != ""
28
+ give v
29
+ let doc be settings.load()
30
+ when contains(doc, key)
31
+ give text(doc[key])
32
+ give ""
33
+
34
+ export task webhook_url()
35
+ require env("LAMPSON_*")
36
+ require file.read(".lampson")
37
+ require file.read(".lampson/*")
38
+ give read_cfg("webhook_url", "LAMPSON_WEBHOOK_URL")
39
+
40
+ export task public_url()
41
+ require env("LAMPSON_*")
42
+ require file.read(".lampson")
43
+ require file.read(".lampson/*")
44
+ let u be read_cfg("public_url", "LAMPSON_PUBLIC_URL")
45
+ while length(u) > 0 and slice(u, length(u) - 1, length(u)) == "/"
46
+ set u to slice(u, 0, length(u) - 1)
47
+ give u
48
+
49
+ task secret_text()
50
+ give read_cfg("webhook_secret", "LAMPSON_WEBHOOK_SECRET")
51
+
52
+ task index()
53
+ observe "approval:index" as ix
54
+ give when ix == nothing then [] otherwise ix
55
+
56
+ task meta_of(id)
57
+ observe "approval:meta:" + id as m
58
+ give m
59
+
60
+ task set_meta(id, m)
61
+ share m as "approval:meta:" + id
62
+
63
+ -- abre una aprobación: id único, token de un solo uso, metadatos visibles (sin token) para la UI.
64
+ -- source = "chat" | "schedule:<id>"; message = lo que se quiere hacer; why = por qué pide permiso.
65
+ export task open(source, message, why, timeout_s)
66
+ require time
67
+ require random
68
+ require net
69
+ require env("LAMPSON_*")
70
+ require file.read(".lampson")
71
+ require file.read(".lampson/*")
72
+ let id be "a" + slice(text(floor(now() * 1000)), 4, 13) + "-" + slice(token(16), 0, 6)
73
+ let tok be token(32)
74
+ let meta be {"id": id, "source": source, "message": message, "why": why, "created": now(), "expires_at": floor(now() + timeout_s), "status": "pending"}
75
+ set_meta(id, meta)
76
+ share tok as "approval:token:" + id
77
+ share nothing as "approval:answer:" + id
78
+ share append(index(), id) as "approval:index"
79
+ bus_publish("approval.request", meta)
80
+ let links be links_for(id, tok)
81
+ print("[lampson] aprobación pendiente " + id + " — " + message + " (vence en " + text(floor(timeout_s)) + " s)" + (when links["yes"] != "" then "\n sí: " + links["yes"] + "\n no: " + links["no"] otherwise "\n respondé desde la UI web (Aprobaciones), con /approve " + id + " yes|no en la terminal, o configurá la URL pública para recibir links"))
82
+ notify(meta, links)
83
+ give id
84
+
85
+ task links_for(id, tok)
86
+ let base be public_url()
87
+ when base == ""
88
+ give {"yes": "", "no": "", "path": "/approve/" + id + "/" + tok}
89
+ give {"yes": base + "/approve/" + id + "/" + tok + "?d=yes", "no": base + "/approve/" + id + "/" + tok + "?d=no", "path": "/approve/" + id + "/" + tok}
90
+
91
+ -- webhook firmado (fire-and-forget)
92
+ task notify(meta, links)
93
+ let url be webhook_url()
94
+ when url == ""
95
+ give false
96
+ let payload be {"id": meta["id"], "type": "approve", "source": meta["source"], "message": meta["message"], "why": meta["why"], "expires_at": meta["expires_at"], "respond_path": links["path"], "respond_link_yes": links["yes"], "respond_link_no": links["no"]}
97
+ let body be json_encode(payload)
98
+ let headers be {"Content-Type": "application/json", "User-Agent": "lampson"}
99
+ let sec be secret_text()
100
+ when sec != ""
101
+ set headers["X-Lampson-Signature"] to "sha256=" + hmac_sha256(body, sec)
102
+ try
103
+ let r be http_post(url, body, headers)
104
+ when not r["ok"]
105
+ print("[lampson] webhook " + url + " respondió " + text(r["status"]))
106
+ give r["ok"]
107
+ recover err
108
+ print("[lampson] webhook falló: " + text(err))
109
+ give false
110
+
111
+ -- espera (polling) hasta que alguien responda o venza el plazo. Devuelve true/false; sin respuesta = false.
112
+ export task wait(id, timeout_s)
113
+ require time
114
+ let deadline be now() + timeout_s
115
+ let answer be nothing
116
+ while answer == nothing and now() < deadline
117
+ observe "approval:answer:" + id as a
118
+ set answer to a
119
+ when answer == nothing
120
+ sleep(0.5)
121
+ let meta be meta_of(id)
122
+ let ok be answer == "yes"
123
+ when meta != nothing
124
+ set meta["status"] to when answer == nothing then "expired" otherwise (when ok then "approved" otherwise "denied")
125
+ set meta["answered"] to now()
126
+ set_meta(id, meta)
127
+ bus_publish("approval.result", meta)
128
+ share nothing as "approval:token:" + id
129
+ give ok
130
+
131
+ -- responder desde la UI (loopback, ya autenticado) o desde la terminal: sin token
132
+ export task answer(id, decision)
133
+ let meta be meta_of(id)
134
+ when meta == nothing
135
+ give false
136
+ when meta["status"] != "pending"
137
+ give false
138
+ let v be when decision then "yes" otherwise "no"
139
+ share v as "approval:answer:" + id
140
+ give true
141
+
142
+ -- responder por link: el token debe coincidir (comparación en tiempo constante) y ser de un solo uso
143
+ export task answer_with_token(id, tok, decision)
144
+ observe "approval:token:" + id as want
145
+ when want == nothing
146
+ give {"ok": false, "why": "expired"}
147
+ when not verify_hmac(id, hmac_sha256(id, tok), want)
148
+ give {"ok": false, "why": "bad token"}
149
+ share nothing as "approval:token:" + id
150
+ let v be when decision then "yes" otherwise "no"
151
+ share v as "approval:answer:" + id
152
+ give {"ok": true, "why": ""}
153
+
154
+ -- pendientes y recientes para la UI (nunca incluye tokens); las resueltas hace más de 1 h se olvidan
155
+ export task list()
156
+ require time
157
+ let out be []
158
+ let keep be []
159
+ each id in index()
160
+ let m be meta_of(id)
161
+ when m != nothing
162
+ when m["status"] != "pending" and contains(m, "answered") and now() - m["answered"] > 3600
163
+ share nothing as "approval:meta:" + id
164
+ otherwise
165
+ set out to append(out, m)
166
+ set keep to append(keep, id)
167
+ when length(keep) != length(index())
168
+ share keep as "approval:index"
169
+ give sort_by(out, (m) => 0 - m["created"])
170
+
171
+ export task pending()
172
+ give where(list(), (m) => m["status"] == "pending")
173
+
174
+ -- página mínima que ve quien abre el link
175
+ export task html_page(title, body)
176
+ give "<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>" + title + "</title><body style='font:16px/1.5 system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#f6f4ef;color:#1d1c1a'><div style='max-width:520px;padding:32px;border:1px solid #d9d4c7;border-radius:6px;background:#fffdf8'><p style='margin:0 0 6px;font:11px/1 ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase;color:#a33'>lampson</p><h1 style='font-size:22px;margin:0 0 12px'>" + title + "</h1><p style='margin:0'>" + body + "</p></div></body>"
@@ -114,6 +114,17 @@ export task evaluate(name, args, mode)
114
114
  give {"decision": "deny", "reason": "strict mode (" + act + " MCP server)"}
115
115
  give {"decision": "ask", "reason": act + "s MCP server '" + (when contains(args, "name") then text(args["name"]) otherwise "?") + "'" + (when act == "add" then " → runs: " + (when contains(args, "command") then text(args["command"]) otherwise "?") otherwise "")}
116
116
  give {"decision": "allow", "reason": "read-only tool"}
117
+ when name == "schedule"
118
+ -- programar = autorizar corridas futuras SIN nadie mirando (un comando, una lámpara, o el agente entero
119
+ -- con un sobre de permisos): humano siempre, incluso en yolo. list/log son lectura.
120
+ let sact be when contains(args, "action") then text(args["action"]) otherwise "list"
121
+ when sact == "list" or sact == "log"
122
+ give {"decision": "allow", "reason": "read-only tool"}
123
+ when mode == "strict"
124
+ give {"decision": "deny", "reason": "strict mode (schedule " + sact + ")"}
125
+ when sact == "add"
126
+ give {"decision": "ask", "reason": "schedules an unattended " + (when contains(args, "kind") then text(args["kind"]) otherwise "?") + " task" + (when contains(args, "permission") then " with permission=" + text(args["permission"]) otherwise " with permission=ask")}
127
+ give {"decision": "ask", "reason": "schedule " + sact + " " + (when contains(args, "id") then text(args["id"]) otherwise "?")}
117
128
  when name == "skill" and is_install(args)
118
129
  -- instala instrucciones/scripts de terceros FUERA del workspace (~/.agents/skills): humano siempre, incluso en yolo
119
130
  when mode == "strict"
@@ -145,6 +156,13 @@ export task describe_call(name, args)
145
156
  when act == "remove"
146
157
  give "mcp remove " + (when contains(args, "name") then text(args["name"]) otherwise "?")
147
158
  give "mcp list"
159
+ when name == "schedule"
160
+ let sact be arg(args, "action", "list")
161
+ when sact == "add"
162
+ let what be arg(args, "kind", "?")
163
+ let body be when what == "lamp" then "lamp " + arg(args, "lamp", "?") + "." + arg(args, "tool", "?") + (when contains(args, "args") then " " + one_line(json_encode(args["args"]), 80) otherwise "") otherwise (when what == "bash" then "$ " + one_line(arg(args, "command", "?"), 120) otherwise "agent " + arg(args, "agent", "build") + ": " + one_line(arg(args, "prompt", "?"), 160))
164
+ give "schedule add «" + arg(args, "name", "?") + "» · " + arg(args, "at", "?") + " · " + body + " · permission " + arg(args, "permission", "ask") + (when contains(args, "notify") then " · notify " + text(args["notify"]) otherwise "")
165
+ give "schedule " + sact + (when contains(args, "id") then " " + text(args["id"]) otherwise "")
148
166
  when name == "skill" and is_install(args)
149
167
  give "skill install " + text(args["source"]) + " --skill " + text(args["name"]) + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
150
168
  when name == "bash"