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/.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
@@ -4,7 +4,10 @@
4
4
  reads, searches, edits and runs commands through tools that are confined to that project by the
5
5
  language itself — and every step is visible, in the terminal or in a web UI.
6
6
 
7
- - **Terminal or web**: `lampson` (REPL) or `lampson --web` (http://127.0.0.1:8080).
7
+ - **Workspaces, always on**: every project folder is a *workspace* with its own process (sessions, scheduled tasks,
8
+ MCP, lamps, dev servers), all behind one hub at http://127.0.0.1:8080 — open several at once and switch without
9
+ killing the others. `lampson` (terminal) and `lampson --web` (browser) open the workspace of the current folder;
10
+ `lampson --install` keeps the hub alive across logins.
8
11
  - **Any OpenAI- or Anthropic-compatible API** over raw HTTP: DeepSeek, Kimi, Groq, Grok, OpenRouter,
9
12
  Ollama, Anthropic, MiniMax… one `.env` line to switch.
10
13
  - **Least-privilege by construction**: each tool declares its capabilities; the runtime enforces
@@ -29,6 +32,14 @@ language itself — and every step is visible, in the terminal or in a web UI.
29
32
  under a capability ceiling, or any executable (js, py, sh…). Global in `lampson/lamps/<name>/`, per
30
33
  project in `.lampson/lamps/<name>/` (the agent can write those). **Off by default**: you turn them on
31
34
  from the top bar of the web UI or `/lamps on <name>`; their tools join the catalog as `lamp_<lamp>_<tool>`.
35
+ - **Scheduled tasks**: "every 6h", "daily 09:00", "mon,wed 08:30" — a lamp tool, a fixed shell command, or a
36
+ full unattended agent run from a prompt with a permission envelope fixed when you create it (`strict` /
37
+ `ask` / `yolo`). They run inside Lampson's resident process (`lampson --daemon start`, or the open web UI),
38
+ the run's session shows up as `⏰ name`, and a webhook can receive each result (for "search and send me" tasks).
39
+ - **Approvals from anywhere**: an unattended run that hits something dangerous waits for you — in the web UI
40
+ («Aprobaciones»), and, with `LAMPSON_PUBLIC_URL` + `LAMPSON_WEBHOOK_URL`, through a signed webhook carrying
41
+ one-time **decision links** you can open from your phone (Telegram, mail, Slack…). Never auto-approved:
42
+ no answer in time = denied.
32
43
  - **Project memory**: the agent keeps its own notes per project (`memory/<project>/*.md`, outside
33
44
  the repo) — how to run it, gotchas, decisions — and rereads them in the next session. You can read
34
45
  and edit them (web panel, `/memory`).
@@ -116,6 +127,68 @@ does the same with persistent sessions and memory.
116
127
  > Status: developed and tested on Windows 11; the Docker image (Ubuntu 24.04) is built by CI. The Linux/macOS
117
128
  > installer is written but not yet exercised on a real machine — issues welcome.
118
129
 
130
+ ## Workspaces and the hub (how it runs)
131
+
132
+ ```
133
+ lampson # in a project folder: registers it as a workspace (first time), makes sure the hub and
134
+ # the workspace's process are up, opens the terminal on it
135
+ lampson --web # same, then opens http://127.0.0.1:8080/w/<slug> in the browser
136
+ http://127.0.0.1:8080 # the workspaces screen: open one, create one (native folder dialog; a server-side
137
+ # browser on a VPS), turn them on/off, life policy
138
+ lampson --hub start|stop|status|logs|restart
139
+ lampson --install # Windows: Scheduled Task at logon · Linux: systemd --user unit (Restart=always)
140
+ ```
141
+
142
+ Why processes: in Synsema a tool's file capability is a literal path relative to the process cwd, so a workspace is
143
+ a directory `~/lampson/.lampson/ws/<slug>/` with a junction `workspace` → your project (plus junctions to the
144
+ install's `lib/`, `public/`, `skills/`, `lamps/`, `memory/`), and its own `.lampson/` state. Its process
145
+ (`synsema serve web.syn --port 808N --bind 127.0.0.1`) only ever sees that folder. The hub (`hub.syn`, generated
146
+ from `hub.tpl.syn`, one proxy route per workspace) is the only listener you use: it serves the UI and forwards
147
+ `/w/<slug>/api/…` — SSE and the WebSocket terminal included (Synsema ≥ 0.6.12). Workspace processes are detached
148
+ from the hub, so a hub restart never kills them. The hub is also the supervisor: every 15 s it starts what should
149
+ be alive and stops what is idle (⚙ → hours of inactivity, `0` = never; a workspace with enabled scheduled tasks or
150
+ policy «siempre vivo» stays up).
151
+
152
+ On a VPS put the hub behind your domain (`synsema serve hub.syn --port 443 --domain … --tls-auto …` or your
153
+ edge); workspace ports never leave loopback. `LAMPSON_PUBLIC_URL` = `https://host` and approval links carry
154
+ `/w/<slug>` automatically.
155
+
156
+ ## Scheduled tasks and the resident process
157
+
158
+ Lampson can run things with nobody at the keyboard. Scheduled tasks run inside the workspace's process (the hub
159
+ keeps it alive while it has enabled tasks) — or inside the terminal REPL when no process is up (a background thread
160
+ ticks every 30 s). `lampson --install` makes the hub survive reboots.
161
+
162
+ Timezone, public URL and webhook are set from the ⚙ button of the web header (tabs: General · Aprobaciones a
163
+ distancia · Proveedor; stored in `lampson/.lampson/config.json`, `.env` wins) or from `.env`.
164
+
165
+ Create a task from the chat («todos los días a las 9 corré los tests y avisame» — the agent calls the
166
+ `schedule` tool and asks you to approve the task, showing exactly what will run and with which permissions),
167
+ from the web sidebar («Programadas» → +), or from the terminal (`/schedule add <json>`). Three kinds:
168
+
169
+ | kind | what runs | authorization |
170
+ |---|---|---|
171
+ | `lamp` | a tool of a lamp that is ON | turning the lamp on |
172
+ | `bash` | one fixed command (must finish on its own) | approved once, at creation |
173
+ | `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` |
174
+
175
+ Schedules: `every 6h` · `every 30m` · `daily 09:00` · `mon,wed 08:30` · `weekdays 09:00` (local time; `LAMPSON_TZ`
176
+ overrides). Synsema's `cron_every` is a pure interval, so a 30 s tick in `web.syn` translates it to wall-clock time;
177
+ a run missed while the daemon was down executes when it comes back and is marked as late. State lives in
178
+ `.lampson/schedules.json`, logs in `.lampson/schedules/<id>.log`; a `prompt` run also leaves a normal session (`⏰ name`)
179
+ with its trace. `notify` = a webhook URL that receives each result as JSON.
180
+
181
+ **Approving from your phone.** Set `LAMPSON_PUBLIC_URL` (how this Lampson is reached from outside — a tunnel, a VPS,
182
+ or a Synsema edge with TLS in front) and `LAMPSON_WEBHOOK_URL` (+ `LAMPSON_WEBHOOK_SECRET`, HMAC-SHA256 in
183
+ `X-Lampson-Signature`). Every pending approval — from a scheduled run or from the chat — POSTs `{id, message, why,
184
+ expires_at, respond_link_yes, respond_link_no}`; forward the links wherever you read (n8n, a bot, a 6-line `.syn`).
185
+ `GET /approve/<id>/<token>?d=yes|no` is public on purpose: the 32-byte one-time token is the authorization, and it
186
+ dies with the deadline. `GET /api/approvals` (loopback) lists what is pending, never the tokens.
187
+
188
+ For a real server use systemd (`synsema serve web.syn`, `Restart=always`) instead of `synsema daemon`, which has no
189
+ boot start / crash restart. One box, several projects: one `web.syn` per project on its own port, behind a Synsema
190
+ edge that terminates TLS and routes by host (see the Synsema deploy docs).
191
+
119
192
  ## Use
120
193
 
121
194
  ```bash
@@ -182,10 +255,20 @@ run Lampson in a container.
182
255
  ## Architecture
183
256
 
184
257
  ```
185
- lampson.ps1 / .sh launcher: mounts ./workspace, starts terminal or web
258
+ lampson.ps1 / .sh launcher: resolves the workspace (cli.syn), ensures hub + process, opens terminal or browser
259
+ cli.syn what launchers ask Synsema: ensure/list/hub-start/hub-stop/hub-restart (last line = JSON)
260
+ hub.tpl.syn template of the hub (:8080): workspaces screen + API, supervisor tick, one proxy route per workspace
261
+ lib/workspaces.syn registry (.lampson/workspaces.json), ws/<slug> dirs + junctions, detached processes, health,
262
+ life policy, folder picker (native dialog / server-side browser)
186
263
  chat.syn terminal REPL (colors, approvals via Synsema's native `approve`)
187
- 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)
264
+ web.syn one process per workspace: /w/:slug/api/… → SSE chat, sessions, tree, processes, schedules…
265
+ public/ web UI (no build step, no dependencies; classic scripts served by `static`)
266
+ index.html markup only: header, the two side panels, the chat; loads css/ and js/ in order
267
+ css/ tokens (fonts, palette, base) · layout (grid, header, panels, chat, composer) · sidebar · chat · panel
268
+ js/core.js shared state + helpers ($, esc, md, add, api, showPane/showText, inlineConfirm, debounce, empty)
269
+ js/panel.js THE modal component: one shell, three layouts (browse = search + list + detail, tabs, form)
270
+ js/<view>.js one file per thing on screen: sessions, chat, tree, terminal, procs, agents, memory, todo,
271
+ mcp, lsp, lamps, schedules, approvals, config (+ provider), update, events (SSE), app (boot)
189
272
  lib/
190
273
  provider.syn config from .env · chat(cfg, messages, catalog) · retry with backoff
191
274
  loop.syn run_turn(): LLM → tool calls → permissions → call_tool → results → repeat; doom-loop guard; compaction
package/bin/lampson.js CHANGED
@@ -22,7 +22,7 @@ const home = process.env.LAMPSON_HOME || path.join(os.homedir(), 'lampson');
22
22
  const marker = path.join(home, '.npm-installed');
23
23
 
24
24
  // what gets copied into the home: the runtime, never state. Keep in sync with "files" in package.json.
25
- const CODE = ['lib', 'public', 'skills', 'chat.syn', 'web.syn', 'lampson.ps1', 'lampson.sh', 'lampson.cmd', '.env.example', 'README.md', 'LICENSE'];
25
+ const CODE = ['lib', 'public', 'skills', 'chat.syn', 'web.syn', 'hub.tpl.syn', 'cli.syn', 'lampson.ps1', 'lampson.sh', 'lampson.cmd', '.env.example', 'README.md', 'LICENSE'];
26
26
 
27
27
  // copia un archivo; en unix, los scripts de shell y los .syn salen con LF aunque el paquete se haya armado
28
28
  // en Windows (un CRLF en lampson.sh rompe bash: "set: pipefail\r: invalid option")
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,57 @@ 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
+ -- bajo el hub el proceso del workspace (LAMPSON_PORT) ya hace tick: no duplicar corridas
726
+ let port be env("LAMPSON_PORT", "")
727
+ let other be false
728
+ when port != ""
729
+ try
730
+ let hr be http_get("http://127.0.0.1:" + port + "/w/" + env("LAMPSON_WS", "x") + "/api/health", {"timeout": 2})
731
+ set other to hr["ok"]
732
+ recover e2
733
+ set other to false
734
+ when not other
735
+ lampson_sched_tick()
736
+ recover err
737
+ print("[lampson] scheduler: " + text(err))
738
+ otherwise
739
+ sleep(1)
740
+ set waited to waited + 1
741
+
675
742
  -- !comando del usuario en un pseudo-terminal: la salida se ve EN VIVO y, si el proceso se queda esperando
676
743
  -- (prompt y/N, contraseña, REPL, `npm init`…), lo que escribas se le manda como teclas + Enter.
677
744
  -- Enter vacío = seguir esperando · ^C = cortar. Devuelve la salida (sin ANSI) para el contexto del agente.
@@ -765,6 +832,7 @@ banner(env_info["cwd"], cfg, profile, opts["permission_mode"], sid)
765
832
  share nothing as "lampson:busy"
766
833
  share false as "lampson:spinner:stop"
767
834
  spawn Spinner with esc = ESC, color = COLOR
835
+ spawn Sched
768
836
  flush()
769
837
 
770
838
  let total_usage be {"input": 0, "output": 0}
@@ -1005,6 +1073,71 @@ while running
1005
1073
  each s in ss
1006
1074
  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
1075
  print(" " + dim("idle = arranca en la primera consulta del agente"))
1076
+ otherwise when input == "/schedule" or starts_with(input, "/schedule ")
1077
+ -- tareas programadas (lib/schedule.syn). Crear: JSON, o pedírselo al agente ("todos los días a las 9 corré…").
1078
+ -- Las corre el daemon (web.syn con `lampson --daemon start`); `/schedule run` las corre acá mismo, con
1079
+ -- el menú de aprobación de la terminal si la tarea es `ask`.
1080
+ let srest be trim(slice(input, 9, length(input)))
1081
+ let stoks be where(split(srest, " "), (x) => x != "")
1082
+ let sact be when length(stoks) > 0 then stoks[0] otherwise ""
1083
+ let sid_arg be when length(stoks) > 1 then stoks[1] otherwise ""
1084
+ when sact == "add"
1085
+ let sjson be trim(slice(srest, 3, length(srest)))
1086
+ when sjson == ""
1087
+ print(" uso: /schedule add {\"name\": \"…\", \"when\": \"daily 09:00\", \"action\": {\"type\": \"bash\", \"command\": \"npm test\"}, \"permission\": \"ask\"}")
1088
+ 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}"))
1089
+ print(" " + dim("o pedíselo al agente en lenguaje natural: «todos los días a las 9 revisá los tests y avisame»"))
1090
+ otherwise
1091
+ try
1092
+ let st be schedule.add(json_decode(sjson))
1093
+ print(" " + green("⏰ programada «" + st["name"] + "» (" + st["id"] + "): " + schedule.describe_plan(st["plan"]) + " · próxima " + schedule.fmt_local(st["next_run"])))
1094
+ recover err
1095
+ print(" " + red(text(err)))
1096
+ otherwise when sact == "run" and sid_arg != ""
1097
+ let st be schedule.get(sid_arg)
1098
+ when st == nothing
1099
+ print(" " + red("no existe la tarea " + sid_arg))
1100
+ otherwise
1101
+ print(" " + yellow("⏰ corriendo «" + st["name"] + "» · " + schedule.describe_action(st["action"])))
1102
+ flush()
1103
+ let sr be sched_run.run(st, ask_user, on_event)
1104
+ print(" " + (when sr["last_status"] == "ok" then green("✓ ") otherwise red("✗ ")) + sr["last_status"] + " · " + first_line(sr["last_summary"], 160))
1105
+ print(" " + dim("log: " + schedule.LOG_DIR + "/" + st["id"] + ".log"))
1106
+ otherwise when (sact == "on" or sact == "off") and sid_arg != ""
1107
+ try
1108
+ print(" " + schedule.set_enabled(sid_arg, sact == "on"))
1109
+ recover err
1110
+ print(" " + red(text(err)))
1111
+ otherwise when sact == "remove" and sid_arg != ""
1112
+ try
1113
+ print(" " + schedule.remove(sid_arg))
1114
+ recover err
1115
+ print(" " + red(text(err)))
1116
+ otherwise when sact == "log" and sid_arg != ""
1117
+ let tl be schedule.log_tail(sid_arg, 60)
1118
+ print(when tl == "" then " sin corridas todavía" otherwise tl)
1119
+ otherwise
1120
+ let ss be schedule.summary()
1121
+ let age be schedule.daemon_age()
1122
+ when length(ss) == 0
1123
+ print(" sin tareas programadas. Pedísela al agente («cada 6 horas corré los tests y avisame») o: " + cyan("/schedule add <json>"))
1124
+ each s in ss
1125
+ 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"])
1126
+ 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 "")))
1127
+ let pend be approvals.pending()
1128
+ each ap in pend
1129
+ print(" " + yellow("⚠ aprobación pendiente " + ap["id"] + ": " + first_line(ap["message"], 100)) + dim(" → /approve " + ap["id"] + " yes|no"))
1130
+ 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>"))
1131
+ otherwise when starts_with(input, "/approve ")
1132
+ let atoks be where(split(trim(slice(input, 9, length(input))), " "), (x) => x != "")
1133
+ when length(atoks) < 2 or not contains(["yes", "no", "si", "sí", "y", "n"], lower(atoks[1]))
1134
+ print(" uso: /approve <id> yes|no (pendientes: /schedule)")
1135
+ otherwise
1136
+ let yes be contains(["yes", "si", "sí", "y"], lower(atoks[1]))
1137
+ when approvals.answer(atoks[0], yes)
1138
+ print(" " + (when yes then green("✓ permitido ") otherwise red("✗ denegado ")) + atoks[0])
1139
+ otherwise
1140
+ print(" " + red("no hay una aprobación pendiente con id " + atoks[0]))
1008
1141
  otherwise when input == "/lamps" or starts_with(input, "/lamps ")
1009
1142
  let lrest be trim(slice(input, 6, length(input)))
1010
1143
  when starts_with(lrest, "run ")
package/cli.syn ADDED
@@ -0,0 +1,68 @@
1
+ -- cli.syn — lo que los launchers (lampson.ps1 / lampson.sh) le piden a Synsema. Corre con cwd = instalación.
2
+ --
3
+ -- LAMPSON_CMD=ensure LAMPSON_WORKSPACE=<ruta> synsema run cli.syn
4
+ -- registra/prepara el workspace, asegura el hub y su proceso → imprime UNA línea JSON {slug, port, dir, url, alive}
5
+ -- LAMPSON_CMD=hub-status → {alive}
6
+ -- LAMPSON_CMD=hub-start | hub-stop
7
+ -- LAMPSON_CMD=list
8
+ -- La salida útil va en la ÚLTIMA línea (JSON) para que el launcher la parsee.
9
+
10
+ require exec
11
+ require time
12
+ require net("127.0.0.1")
13
+ require env("LAMPSON_*")
14
+ require env("OS")
15
+ require env("HOME")
16
+ require env("USERPROFILE")
17
+ require file(".lampson")
18
+ require file(".lampson/*")
19
+ require file.read("web.syn")
20
+ require file.read(".env")
21
+ require file.read("chat.syn")
22
+ require file.read("hub.tpl.syn")
23
+ require file("hub.syn")
24
+
25
+ use "./lib/workspaces.syn" as ws
26
+
27
+ let cmd be env("LAMPSON_CMD", "ensure")
28
+
29
+ when cmd == "ensure"
30
+ let path be env("LAMPSON_WORKSPACE", "")
31
+ when path == ""
32
+ raise("LAMPSON_WORKSPACE required")
33
+ let w be ws.ensure(path)
34
+ when not ws.hub_alive()
35
+ ws.start_hub()
36
+ let hc be ws.health(w)
37
+ when hc == nothing
38
+ ws.start_ws(w)
39
+ ws.wait_ready(w, 10)
40
+ set hc to ws.health(w)
41
+ ws.touch(w["slug"])
42
+ print(json_encode({"slug": w["slug"], "port": w["port"], "dir": ws.dir_of(w["slug"]), "url": "http://127.0.0.1:" + text(ws.HUB_PORT) + "/w/" + w["slug"], "alive": hc != nothing, "hub": ws.hub_alive()}))
43
+ otherwise when cmd == "hub-status"
44
+ print(json_encode({"alive": ws.hub_alive(), "workspaces": ws.summary()}))
45
+ otherwise when cmd == "hub-start"
46
+ when not ws.hub_alive()
47
+ ws.start_hub()
48
+ print(json_encode({"alive": true}))
49
+ otherwise when cmd == "hub-stop"
50
+ each w in ws.all()
51
+ ws.stop_ws(w)
52
+ ws.stop_hub()
53
+ print(json_encode({"alive": false}))
54
+ otherwise when cmd == "hub-restart"
55
+ -- lanzado desacoplado por workspaces.restart_hub_later: espera a que el hub termine lo que estaba haciendo,
56
+ -- lo para y lo vuelve a arrancar con el hub.syn regenerado (bajo systemd, Restart=always ya lo levanta solo)
57
+ sleep(1.5)
58
+ ws.stop_hub()
59
+ sleep(2)
60
+ when not ws.hub_alive()
61
+ ws.start_hub()
62
+ print(json_encode({"restarted": true}))
63
+ otherwise when cmd == "list"
64
+ each w in ws.summary()
65
+ print((when w["alive"] then "● " otherwise "○ ") + w["slug"] + " " + w["path"] + " :" + text(w["port"]) + " " + w["policy"])
66
+ print(json_encode({"count": length(ws.all())}))
67
+ otherwise
68
+ raise("unknown LAMPSON_CMD " + cmd)
package/hub.tpl.syn ADDED
@@ -0,0 +1,127 @@
1
+ -- hub.tpl.syn — PLANTILLA del hub. lib/workspaces.syn genera hub.syn (gitignored) reemplazando `-- @workspaces` por una
2
+ -- ruta proxy por workspace; el hub corre con `synsema daemon start hub.syn --watch` y se reinicia solo al cambiar.
3
+ --
4
+ -- El hub es lo único que escucha hacia afuera (:8080; en un VPS, con --domain/--tls-auto):
5
+ -- GET / pantalla de workspaces (public/hub.html)
6
+ -- GET /w/<slug> la UI de ese workspace (public/index.html; los assets /css /js los sirve el hub)
7
+ -- * /w/<slug>/api/… proxy al proceso del workspace (127.0.0.1:808N) — SSE y WebSocket pasan (Synsema ≥ 0.6.12)
8
+ -- GET /w/<slug>/approve/… links de aprobación de ese workspace
9
+ -- /api/workspaces… registro, crear (diálogo nativo o explorador), start/stop/quitar, política
10
+ -- Supervisor: cada 15 s arranca los workspaces que deben vivir y apaga los inactivos (política en ⚙ → idle_hours).
11
+ -- Solo loopback puede usar la API del hub (misma regla que web.syn); LAMPSON_WEB_TOKEN para scripts remotos.
12
+
13
+ intent: "Lampson hub: workspaces registry, supervisor and reverse proxy to one process per workspace"
14
+
15
+ require serve(8080)
16
+ require net("127.0.0.1")
17
+ require time
18
+ require exec
19
+ require env("LAMPSON_*")
20
+ require env("OS")
21
+ require env("HOME")
22
+ require env("USERPROFILE")
23
+ require file(".lampson")
24
+ require file(".lampson/*")
25
+ require file.read("web.syn")
26
+ require file.read(".env")
27
+ require file.read("chat.syn")
28
+ require file.read("hub.tpl.syn")
29
+ require file("hub.syn")
30
+ require file.read("public")
31
+ require file.read("public/*")
32
+
33
+ use "./lib/workspaces.syn" as ws
34
+ use "./lib/settings.syn" as settings
35
+
36
+ task check_client(token, request)
37
+ let ip be text(ip of request)
38
+ when ip == "127.0.0.1" or ip == "::1" or ip == "::ffff:127.0.0.1"
39
+ give {"local": true}
40
+ let want be env("LAMPSON_WEB_TOKEN", "")
41
+ when want != "" and token == want
42
+ give {"local": false}
43
+ give nothing
44
+
45
+ task hub_tick()
46
+ give ws.tick()
47
+ cron_every(15, hub_tick)
48
+
49
+ serve on 8080
50
+ max_streams 200
51
+ auth with check_client
52
+ static "./public"
53
+
54
+ route "GET /"
55
+ give html(read_file("public/hub.html"))
56
+
57
+ -- la UI de un workspace: el mismo index.html; el frontend deduce /w/<slug> de la URL (BASE en js/core.js).
58
+ -- Sin barra final: el runtime normaliza "/w/x/" → "/w/x" (301), así que la ruta canónica es esta
59
+ route "GET /w/:slug"
60
+ give html(read_file("public/index.html"))
61
+
62
+ route "GET /api/hub" requires auth
63
+ give {"ok": true, "port": ws.HUB_PORT, "idle_hours": ws.idle_hours(), "home": ws.home()}
64
+
65
+ route "GET /api/workspaces" requires auth
66
+ give {"workspaces": ws.summary(), "idle_hours": ws.idle_hours()}
67
+
68
+ -- registrar una carpeta como workspace (y arrancarla)
69
+ route "POST /api/workspaces" requires auth
70
+ expect body {path: text}
71
+ let b be json of request
72
+ try
73
+ let w be ws.ensure(text(b["path"]))
74
+ ws.start_ws(w)
75
+ ws.wait_ready(w, 8)
76
+ give {"ok": true, "workspace": w, "url": "/w/" + w["slug"] + "/"}
77
+ recover err
78
+ give fail(400, text(err))
79
+
80
+ route "POST /api/workspaces/start" requires auth
81
+ expect body {slug: text}
82
+ let b be json of request
83
+ let w be ws.get(text(b["slug"]))
84
+ when w == nothing
85
+ give not_found("no such workspace")
86
+ ws.start_ws(w)
87
+ give {"ok": ws.wait_ready(w, 8)}
88
+
89
+ route "POST /api/workspaces/stop" requires auth
90
+ expect body {slug: text}
91
+ let b be json of request
92
+ let w be ws.get(text(b["slug"]))
93
+ when w == nothing
94
+ give not_found("no such workspace")
95
+ give {"ok": ws.stop_ws(w)}
96
+
97
+ route "POST /api/workspaces/remove" requires auth
98
+ expect body {slug: text}
99
+ let b be json of request
100
+ try
101
+ give {"ok": true, "result": ws.remove(text(b["slug"]))}
102
+ recover err
103
+ give fail(400, text(err))
104
+
105
+ -- política por workspace: auto | always | off
106
+ route "POST /api/workspaces/policy" requires auth
107
+ expect body {slug: text, policy: text}
108
+ let b be json of request
109
+ let w be ws.get(text(b["slug"]))
110
+ when w == nothing
111
+ give not_found("no such workspace")
112
+ when not contains(["auto", "always", "off"], text(b["policy"]))
113
+ give fail(400, "policy must be auto | always | off")
114
+ set w["policy"] to text(b["policy"])
115
+ ws.put(w)
116
+ give {"ok": true}
117
+
118
+ -- diálogo nativo del SO (PC); nothing → la UI usa el explorador propio
119
+ route "POST /api/workspaces/pick" requires auth
120
+ let p be ws.pick_folder()
121
+ give {"path": p, "native": p != nothing}
122
+
123
+ route "GET /api/workspaces/browse" requires auth
124
+ give ws.browse(when contains(query, "path") then text(query.path) otherwise "")
125
+
126
+ -- proxies generados (una por workspace registrado):
127
+ -- @workspaces