lampson 0.1.2 → 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.
Files changed (49) hide show
  1. package/.env.example +9 -0
  2. package/README.md +60 -2
  3. package/chat.syn +264 -9
  4. package/lampson.ps1 +17 -3
  5. package/lampson.sh +17 -3
  6. package/lib/agents.syn +1 -1
  7. package/lib/approvals.syn +176 -0
  8. package/lib/diff.syn +37 -11
  9. package/lib/line.syn +13 -8
  10. package/lib/loop.syn +25 -11
  11. package/lib/md.syn +76 -18
  12. package/lib/permission.syn +18 -0
  13. package/lib/prompt.syn +1 -1
  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 +55 -0
  18. package/lib/tools.syn +94 -2
  19. package/lib/ui.syn +161 -0
  20. package/package.json +1 -1
  21. package/public/css/chat.css +56 -0
  22. package/public/css/layout.css +97 -0
  23. package/public/css/panel.css +125 -0
  24. package/public/css/sidebar.css +80 -0
  25. package/public/css/tokens.css +57 -0
  26. package/public/index.html +49 -1187
  27. package/public/js/agents.js +31 -0
  28. package/public/js/app.js +21 -0
  29. package/public/js/approvals.js +26 -0
  30. package/public/js/chat.js +92 -0
  31. package/public/js/config.js +98 -0
  32. package/public/js/core.js +88 -0
  33. package/public/js/events.js +31 -0
  34. package/public/js/lamps.js +112 -0
  35. package/public/js/lsp.js +81 -0
  36. package/public/js/mcp.js +74 -0
  37. package/public/js/memory.js +17 -0
  38. package/public/js/panel.js +101 -0
  39. package/public/js/procs.js +45 -0
  40. package/public/js/schedules.js +118 -0
  41. package/public/js/sessions.js +69 -0
  42. package/public/js/sidebar.js +33 -0
  43. package/public/js/terminal.js +49 -0
  44. package/public/js/theme.js +6 -0
  45. package/public/js/todo.js +10 -0
  46. package/public/js/tree.js +53 -0
  47. package/public/js/update.js +14 -0
  48. package/skills/lampson/SKILL.md +16 -0
  49. package/web.syn +106 -15
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
@@ -139,6 +187,10 @@ servers, flags), `↑↓` browse history or the menu, `Alt+Enter` inserts a newl
139
187
  last tool result in full, `Ctrl+U`/`Ctrl+W` clear the line/word, `Esc` closes the menu. Approvals are
140
188
  an arrow-key menu (`permitir`/`denegar`, or `p`/`d`).
141
189
 
190
+ While the model thinks or a slow tool runs (a long `bash`, a sub-agent), a status line shows what is
191
+ running, the elapsed time and a bar that fills as you wait (`⠋ pensando ▰▰▱▱▱▱▱▱▱▱ 12s`); it appears
192
+ after 0.4 s so instant tools do not flicker, and anything you type meanwhile lands in the prompt.
193
+
142
194
  The terminal renders the model's markdown (headings, lists, tables, code fences) and shows every tool
143
195
  result: `edit`/`write` print a line diff (`- red / + green`, line numbers, 2 lines of context); other
144
196
  tools are collapsed to 15 lines. `/out [n]` prints the n-th last result of the turn in full and
@@ -181,7 +233,13 @@ run Lampson in a container.
181
233
  lampson.ps1 / .sh launcher: mounts ./workspace, starts terminal or web
182
234
  chat.syn terminal REPL (colors, approvals via Synsema's native `approve`)
183
235
  web.syn HTTP server: POST /api/chat → SSE events; sessions, tree, file viewer, processes, ports
184
- 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)
185
243
  lib/
186
244
  provider.syn config from .env · chat(cfg, messages, catalog) · retry with backoff
187
245
  loop.syn run_turn(): LLM → tool calls → permissions → call_tool → results → repeat; doom-loop guard; compaction
@@ -299,7 +357,7 @@ Borrowed from the harness that does each part best (see `notes/*.md`):
299
357
  | Budget runs out silently | 80 %: a notice appended to the latest tool result (no new user message, cache stays warm); 95 %: last step without tools, summary required | hermes / opencode |
300
358
  | Edits a file it never read, or one that changed | **Observation gate in code**: `read` records the file hash; `edit`/`write` on an existing file are rejected without it, or if the file changed since | deepseek |
301
359
  | Loses the plan | `todo` tool (whole-list replacement, one `in_progress` at a time, scoped to the session like the three references); re-injected only after context compaction, active items only | hermes, opencode |
302
- | Reads the whole project before touching anything | **Exploration cap** (ours): after 8 read-only calls in a row (read/ls/find/grep) without an edit/write/command the result carries a warning; after 16 they are refused until it acts (`LAMPSON_EXPLORE_CAP`) | — |
360
+ | Reads the whole project before touching anything | **Exploration cap** (ours): after 12 read-only calls in a row (read/ls/find/grep) without an edit/write/command the result carries a warning; after 24 they are refused until it acts (`LAMPSON_EXPLORE_CAP`) | — |
303
361
 
304
362
  ### Sub-agents
305
363
 
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_*")
@@ -46,10 +47,14 @@ use "./lib/trace.syn" as trace
46
47
  use "./lib/md.syn" as md
47
48
  use "./lib/diff.syn" as diff
48
49
  use "./lib/line.syn" as ed
50
+ use "./lib/ui.syn" as ui
49
51
  use "./lib/mcp.syn" as mcp
50
52
  use "./lib/lamps.syn" as lamps
51
53
  use "./lib/lsp.syn" as lsp
52
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
53
58
 
54
59
  -- ---------- UI de terminal ----------
55
60
  -- Colores ANSI (Windows Terminal, PowerShell 7, cualquier terminal moderna). LAMPSON_NO_COLOR=1 los apaga.
@@ -107,6 +112,15 @@ task summarize(name, out)
107
112
  set used to used + length(l) + 2
108
113
  let rest be length(lines) - length(shown)
109
114
  give text(length(lines)) + (when name == "ls" then " entradas: " otherwise " archivos: ") + join(shown, " ") + (when rest > 0 then dim(" +" + text(rest)) otherwise "")
115
+ when name == "delegate" and not VERBOSE
116
+ -- una línea por subagente: el encabezado [id · agente · estado · pasos · tokens · log]
117
+ let heads be []
118
+ each l in lines
119
+ when starts_with(trim(l), "[") and contains(l, " steps") or (starts_with(trim(l), "[") and contains(l, " · log:"))
120
+ set heads to append(heads, trim(l))
121
+ when length(heads) == 0
122
+ give first_line(out, 120)
123
+ give join(heads, "\n ")
110
124
  when name == "grep" and not VERBOSE
111
125
  let files be {}
112
126
  each l in lines
@@ -136,11 +150,21 @@ task diff_of(name, out)
136
150
  give diff.diff(d["old"], d["new"], 2, COLOR)
137
151
 
138
152
  task print_diff(path, d)
139
- let head be path + " · " + green("+" + text(d["added"])) + " " + red("−" + text(d["removed"]))
140
- print(" " + green("✓") + " " + head)
153
+ print_diff_with(" ", path, d)
154
+
155
+ -- lead = lo que va antes del ✓ (la llamada, si se fusionó en la misma línea)
156
+ task print_diff_with(lead, path, d)
157
+ let head be green("✓") + " " + c("34", path) + " " + green("+" + text(d["added"])) + " " + red("−" + text(d["removed"]))
158
+ print(lead + head)
141
159
  each l in d["lines"]
142
160
  print(" " + l)
143
161
 
162
+ -- tools "instantáneas": la llamada se imprime junto con su resultado, en una sola línea
163
+ task is_instant(name)
164
+ give name == "read" or name == "ls" or name == "find" or name == "grep" or name == "memory" or name == "edit" or name == "write" or name == "todo" or name == "lsp"
165
+
166
+ let pending_call be ""
167
+
144
168
  -- /out: resultado completo de una tool del turno
145
169
  task print_full(o)
146
170
  when o["diff"] != nothing
@@ -149,17 +173,110 @@ task print_full(o)
149
173
  each l in split(o["output"], "\n")
150
174
  print(" " + l)
151
175
 
176
+ -- ---------- indicador de espera ----------
177
+ -- El intérprete principal se bloquea en la llamada al modelo y en cada tool; el que dibuja es un agente
178
+ -- (hilo real) que mira el blackboard: `lampson:busy` = {label, kind, since} mientras hay algo en curso.
179
+ -- Arranca a dibujar recién a los 0,4 s (las tools instantáneas no parpadean) y suelta la terminal
180
+ -- (`lampson:spinner:holding` = false) antes de que el principal vuelva a imprimir.
181
+ agent Spinner
182
+ require time
183
+ let frames be ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
184
+ let i be 0
185
+ let drawn be false
186
+ let run be true
187
+ -- MIGA: un agente NO puede abrir la terminal (term_open → nothing) pero sí print+flush. Cada print de un
188
+ -- agente sale con el prefijo "[Spinner_0] " en la línea de abajo: cada frame limpia esa línea (\r ESC[K),
189
+ -- sube una (ESC[1A) y redibuja la suya. Al terminar deja el cursor en la línea del spinner, vacía.
190
+ let clear_here be "\r" + esc + "[K"
191
+ let up be esc + "[1A"
192
+ while run
193
+ observe "lampson:spinner:stop" as st
194
+ observe "lampson:busy" as b
195
+ when st == true
196
+ set run to false
197
+ otherwise when b == nothing
198
+ when drawn
199
+ print(clear_here + up + clear_here + up)
200
+ flush()
201
+ set drawn to false
202
+ share false as "lampson:spinner:holding"
203
+ sleep(0.05)
204
+ otherwise
205
+ let el be now() - b["since"]
206
+ when el >= 0.4
207
+ -- barra de "costo hundido": se llena rápido al principio y cada vez más despacio
208
+ let fill be floor(10 * (1 - exp(0 - el / 40)))
209
+ let bar be ""
210
+ let k be 0
211
+ while k < 10
212
+ set bar to bar + (when k < fill then "▰" otherwise "▱")
213
+ set k to k + 1
214
+ let hint be when el > 90 then " esto está tardando (Ctrl+C corta)" otherwise (when el > 30 then " ya casi" otherwise (when el > 8 then " sigue trabajando" otherwise ""))
215
+ let secs be text(floor(el)) + "s"
216
+ let label be b["label"]
217
+ when length(label) > 60
218
+ set label to slice(label, 0, 59) + "…"
219
+ let frame be frames[i % 10]
220
+ let body be when color then esc + "[36m" + frame + esc + "[0m " + label + " " + esc + "[2m" + bar + " " + secs + hint + esc + "[0m" otherwise frame + " " + label + " " + bar + " " + secs + hint
221
+ when drawn
222
+ print(clear_here + up + clear_here + " " + body)
223
+ otherwise
224
+ share true as "lampson:spinner:holding"
225
+ print(clear_here + " " + body)
226
+ set drawn to true
227
+ flush()
228
+ set i to i + 1
229
+ sleep(0.08)
230
+ when drawn
231
+ print(clear_here + up + clear_here + up)
232
+ flush()
233
+ share false as "lampson:spinner:holding"
234
+
235
+ task busy_on(label, kind)
236
+ share {"label": label, "kind": kind, "since": now()} as "lampson:busy"
237
+
238
+ -- apagar y esperar a que el agente suelte la terminal (máx. 1 s) antes de volver a imprimir
239
+ task busy_off()
240
+ share nothing as "lampson:busy"
241
+ let waited be 0
242
+ let holding be true
243
+ while holding and waited < 100
244
+ observe "lampson:spinner:holding" as hd
245
+ set holding to hd == true
246
+ when holding
247
+ sleep(0.01)
248
+ set waited to waited + 1
249
+
152
250
  -- tag = "" para el agente principal; los subagentes en background NO pasan por acá (escriben su log)
153
251
  task on_event(kind, data, tag)
154
252
  trace.event(sid, kind, data, tag)
155
- when kind == "assistant"
253
+ when kind == "busy"
254
+ busy_on(data["label"], data["kind"])
255
+ otherwise when kind == "idle"
256
+ busy_off()
257
+ otherwise when kind == "assistant"
258
+ -- ● marca el turno del asistente (primera línea con contenido)
259
+ let lines be split(md.render(data, COLOR), "\n")
260
+ let marked be false
261
+ let out_lines be []
262
+ each l in lines
263
+ when not marked and trim(l) != "" and starts_with(l, " ")
264
+ set out_lines to append(out_lines, c("36;1", "● ") + slice(l, 2, length(l)))
265
+ set marked to true
266
+ otherwise
267
+ set out_lines to append(out_lines, l)
156
268
  print("")
157
- print(md.render(data, COLOR))
269
+ print(join(out_lines, "\n"))
158
270
  print("")
159
271
  otherwise when kind == "inbox"
160
272
  print(" " + cyan("✉ " + first_line(data, 140)))
161
273
  otherwise when kind == "tool_call"
162
- print(" " + yellow("▸ " + permission.describe_call(data["name"], data["args"])))
274
+ let desc be permission.describe_call(data["name"], data["args"])
275
+ when is_instant(data["name"])
276
+ set pending_call to desc
277
+ otherwise
278
+ set pending_call to ""
279
+ print(" " + yellow("▸ " + desc))
163
280
  otherwise when kind == "tool_result"
164
281
  let out be data["output"]
165
282
  let name be data["call"]["name"]
@@ -167,11 +284,17 @@ task on_event(kind, data, tag)
167
284
  let d be diff_of(name, out)
168
285
  let path be when d != nothing then text(data["call"]["args"]["path"]) otherwise ""
169
286
  set turn_outputs to append(turn_outputs, {"name": name, "args": data["call"]["args"], "output": out, "diff": d, "path": path})
287
+ let lead be when pending_call != "" then " " + yellow("▸ " + pending_call) + " " otherwise " "
288
+ set pending_call to ""
170
289
  when d != nothing
171
- print_diff(path, d)
290
+ print_diff_with(lead, path, d)
172
291
  otherwise
173
292
  let mark be when bad then red("✗") otherwise green("✓")
174
- print(" " + mark + " " + (when bad then red(summarize(name, out)) otherwise dim(summarize(name, out))))
293
+ let s be summarize(name, out)
294
+ -- una sola línea → cortar al ancho; multilínea (bash) se deja
295
+ when not contains(s, "\n")
296
+ set s to ui.cut(s, ui.cols() - ui.width(lead) - 4)
297
+ print(lead + mark + " " + (when bad then red(s) otherwise dim(s)))
175
298
  otherwise when kind == "tool_denied"
176
299
  set data to data
177
300
  otherwise when kind == "error"
@@ -180,11 +303,13 @@ task on_event(kind, data, tag)
180
303
  print(" " + yellow("el proveedor rechazó el nombre del modelo (" + cfg["model"] + "): /model sin argumentos lista los válidos, /model <nombre> lo cambia"))
181
304
  otherwise when kind == "compact"
182
305
  print(" " + dim("⧗ compactando contexto (~" + text(data["before"]) + " tokens)"))
306
+ busy_on("compactando contexto", "llm")
183
307
  flush()
184
308
 
185
309
  -- Human in the loop con el `approve` nativo de Synsema: prompt [approve] … (y/n) en TTY; sin TTY deniega
186
310
  -- (fail-closed, un agente no puede auto-aprobarse); `within` acota la espera.
187
311
  task ask_user(name, args, why)
312
+ busy_off()
188
313
  print("")
189
314
  print(" " + yellow("⚠ requiere tu aprobación · " + why))
190
315
  print(" " + permission.describe_call(name, args))
@@ -223,6 +348,8 @@ let COMMANDS be [
223
348
  ["/mcp", "[add <nombre> <comando…> [--project] | remove <nombre>]", "servers MCP: listar, conectar o quitar (global: lampson/.lampson/mcp.json · proyecto: .lampson/mcp.json)"],
224
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/)"],
225
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"],
226
353
  ["/out", "[n]", "resultado completo de la última tool del turno (n = contar hacia atrás: /out 2 es la anteúltima)"],
227
354
  ["/verbose", "", "alternar: mostrar SIEMPRE el output completo de cada tool (queda guardado en config.json)"],
228
355
  ["/trace", "[n]", "traza legible de esta sesión (pasos, tools, tiempos, tokens, errores): .lampson/trace/<sesión>.log"],
@@ -282,6 +409,16 @@ task complete_args(cmd, head, last)
282
409
  when trim(head) == "add"
283
410
  give sort_by(keys(lsp.PRESETS), (x) => x)
284
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())
285
422
  when cmd == "/agent"
286
423
  give ["build", "plan", "review", "explore", "worker"]
287
424
  when cmd == "/model"
@@ -551,6 +688,47 @@ task opts_for(p, mode)
551
688
  task lampson_subagent(spec_json)
552
689
  give agents.run_child_json(spec_json)
553
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
+
554
732
  -- !comando del usuario en un pseudo-terminal: la salida se ve EN VIVO y, si el proceso se queda esperando
555
733
  -- (prompt y/N, contraseña, REPL, `npm init`…), lo que escribas se le manda como teclas + Enter.
556
734
  -- Enter vacío = seguir esperando · ^C = cortar. Devuelve la salida (sin ANSI) para el contexto del agente.
@@ -641,6 +819,10 @@ when sid == ""
641
819
  share {"id": sid} as "lampson:session"
642
820
 
643
821
  banner(env_info["cwd"], cfg, profile, opts["permission_mode"], sid)
822
+ share nothing as "lampson:busy"
823
+ share false as "lampson:spinner:stop"
824
+ spawn Spinner with esc = ESC, color = COLOR
825
+ spawn Sched
644
826
  flush()
645
827
 
646
828
  let total_usage be {"input": 0, "output": 0}
@@ -881,6 +1063,71 @@ while running
881
1063
  each s in ss
882
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 ""))
883
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]))
884
1131
  otherwise when input == "/lamps" or starts_with(input, "/lamps ")
885
1132
  let lrest be trim(slice(input, 6, length(input)))
886
1133
  when starts_with(lrest, "run ")
@@ -1005,23 +1252,31 @@ while running
1005
1252
  -- catálogo fresco por turno: si el turno anterior conectó/quitó un server MCP, sus tools
1006
1253
  -- entran/salen acá (con los mismos servers el catálogo es idéntico → el prompt cache no se corta)
1007
1254
  set opts to opts_for(profile, mode)
1255
+ let t0 be now()
1008
1256
  let result be loop.run_turn(cfg, messages, opts, on_event)
1009
1257
  trace.turn_end(sid, result)
1010
1258
  set messages to result["messages"]
1011
1259
  set total_usage to {"input": total_usage["input"] + result["usage"]["input"], "output": total_usage["output"] + result["usage"]["output"]}
1012
- let summary be text(result["steps"]) + (when result["steps"] == 1 then " paso" otherwise " pasos") + " · " + fmt_tokens(result["usage"]["input"] + result["usage"]["output"]) + " tokens"
1260
+ let summary be text(result["steps"]) + (when result["steps"] == 1 then " paso" otherwise " pasos") + " · " + fmt_tokens(result["usage"]["input"] + result["usage"]["output"]) + " tokens · " + ui.fmt_duration(now() - t0)
1013
1261
  when result["stopped"] == "max_steps"
1014
1262
  set summary to summary + " · " + red("límite de " + text(opts["max_steps"]) + " pasos por turno")
1015
1263
  print(dim(" ─── " + summary))
1016
1264
  print(" " + yellow("El agente paró por el límite de pasos, no porque terminó. Escribí «seguí» para que continúe donde quedó, o subí LAMPSON_MAX_STEPS."))
1017
1265
  otherwise when result["stopped"] != "done"
1018
- set summary to summary + " · " + red("detenido: " + result["stopped"])
1266
+ -- el detalle del error ya salió en la línea ‼; acá solo la causa corta
1267
+ let why be result["stopped"]
1268
+ when starts_with(why, "error: network")
1269
+ set why to "error de red (ver ‼ arriba)"
1270
+ otherwise when starts_with(why, "error:")
1271
+ set why to first_line(why, 60)
1272
+ set summary to summary + " · " + red("detenido: " + why)
1019
1273
  print(dim(" ─── " + summary))
1020
1274
  otherwise
1021
1275
  print(dim(" ─── " + summary))
1022
1276
  session.save(sid, messages, {"title": session.title_of(messages)})
1023
1277
  flush()
1024
1278
  -- los procesos gestionados (servidores) y los subagentes en background mueren con lampson
1279
+ share true as "lampson:spinner:stop"
1025
1280
  agents.stop_all()
1026
1281
  mcp.stop_all()
1027
1282
  lsp.stop_all()
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
  },