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
@@ -0,0 +1,660 @@
1
+ -- lib/schedule.syn — tareas programadas: "cada 6 h", "todos los días a las 9", "lunes 8:30"
2
+ --
3
+ -- Qué se programa (action.type):
4
+ -- lamp → una tool de una lámpara ENCENDIDA: {lamp, tool, args}. Encenderla ya fue la autorización.
5
+ -- bash → un comando fijo desde el workspace: {command, timeout}. Aprobado UNA vez, al crear la tarea.
6
+ -- prompt → una corrida completa del agente con un texto: {prompt, agent}. Sin humano al lado: corre con el
7
+ -- sobre de permisos que se fijó al crearla (permission: strict | ask | yolo). En `ask`, cuando el
8
+ -- agente quiere algo peligroso la aprobación sale por la cola de lib/approvals.syn (UI web, webhook,
9
+ -- link de un solo uso) y espera hasta approval_timeout; sin respuesta = denegado.
10
+ --
11
+ -- Horarios (campo `when`, texto libre y corto):
12
+ -- every 6h · every 30m · every 2d · cada 6h → intervalo (desde el fin de la corrida anterior)
13
+ -- daily 09:00 · diario 09:00 · 09:00 → todos los días a esa hora LOCAL
14
+ -- mon 08:30 · lun,mie,vie 08:30 · weekdays 09:00 · weekend 10:00 → esos días a esa hora local
15
+ -- Synsema trae `cron_every` (intervalo puro, sin reloj — doc 34-cron): la traducción a hora de reloj vive acá:
16
+ -- un tick cada 30 s compara `now()` con `next_run` de cada tarea. Persistido en .lampson/schedules.json: una
17
+ -- corrida perdida (daemon apagado) se ejecuta al volver y queda marcada como atrasada en el historial.
18
+ --
19
+ -- Quién ejecuta: el proceso residente (`lampson --daemon start` = `synsema daemon start web.syn`, o el web
20
+ -- abierto) — chat.syn en terminal NO tiene tick (el hilo principal está bloqueado en el teclado): desde ahí se
21
+ -- crean, listan y corren a mano (/schedule). Historial: .lampson/schedules/<id>.log + últimas corridas en el json.
22
+ --
23
+ -- Este módulo no importa agents/loop (ciclo con tools.syn): las corridas `prompt` las hace lib/sched_run.syn.
24
+
25
+ use "./tools/bash.syn" as t_bash
26
+ use "./lamps.syn" as lamps
27
+ use "./permission.syn" as permission
28
+ use "./tools/common.syn" as c
29
+ use "./tools/memo.syn" as memo
30
+ use "./settings.syn" as settings
31
+
32
+ export let FILE be ".lampson/schedules.json"
33
+ export let LOG_DIR be ".lampson/schedules"
34
+ export let HEARTBEAT be ".lampson/schedules.heartbeat"
35
+ export let TICK_SECONDS be 30
36
+ let KEEP_HISTORY be 20
37
+ let DAY be 86400
38
+ let DAYS_EN be ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
39
+ let DAYS_ES be ["lun", "mar", "mie", "jue", "vie", "sab", "dom"]
40
+ let DAY_NAMES be ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
41
+
42
+ -- ---------- zona horaria ----------
43
+ -- format_time/parse_time trabajan en UTC; el usuario piensa en hora local. LAMPSON_TZ ("-03:00" | "+0200")
44
+ -- gana; si no, se le pregunta al shell (`date +%z`, Git Bash en Windows) una vez por corrida (blackboard).
45
+ export task tz_offset()
46
+ require exec
47
+ require time
48
+ require env("LAMPSON_*")
49
+ require env("OS")
50
+ require file.read(".lampson")
51
+ require file.read(".lampson/*")
52
+ observe "lampson:tz" as cached
53
+ when cached != nothing
54
+ give cached
55
+ let raw be env("LAMPSON_TZ", "")
56
+ when raw == ""
57
+ let doc be settings.load()
58
+ when contains(doc, "tz")
59
+ set raw to text(doc["tz"])
60
+ when raw == ""
61
+ try
62
+ let sc be t_bash.shell_config()
63
+ let r be run(sc["shell"], [sc["flag"], "date +%z"], 10)
64
+ set raw to trim(text(r["stdout"]))
65
+ recover err
66
+ set raw to "+0000"
67
+ let off be parse_offset(raw)
68
+ share off as "lampson:tz"
69
+ give off
70
+
71
+ -- "+0200" | "-03:00" | "-3" → segundos
72
+ export task parse_offset(raw)
73
+ let s be replace_text(trim(raw), ":", "")
74
+ when s == ""
75
+ give 0
76
+ let sign be when starts_with(s, "-") then -1 otherwise 1
77
+ when starts_with(s, "-") or starts_with(s, "+")
78
+ set s to slice(s, 1, length(s))
79
+ when not matches(s, "[0-9]{1,4}")
80
+ give 0
81
+ when length(s) <= 2
82
+ give sign * floor(number(s)) * 3600
83
+ while length(s) < 4
84
+ set s to "0" + s
85
+ give sign * (floor(number(slice(s, 0, 2))) * 3600 + floor(number(slice(s, 2, 4))) * 60)
86
+
87
+ task fmt_offset(off)
88
+ let a be when off < 0 then 0 - off otherwise off
89
+ let h be floor(a / 3600)
90
+ let m be floor((a - h * 3600) / 60)
91
+ give (when off < 0 then "-" otherwise "+") + pad2(h) + ":" + pad2(m)
92
+
93
+ task pad2(n)
94
+ let t be text(floor(n))
95
+ when length(t) < 2
96
+ give "0" + t
97
+ give t
98
+
99
+ -- ---------- parseo del horario ----------
100
+ task day_index(tok)
101
+ let t be lower(tok)
102
+ each e in enumerate(DAYS_EN)
103
+ when starts_with(t, e["item"])
104
+ give e["index"] + 1
105
+ each e in enumerate(DAYS_ES)
106
+ when starts_with(t, e["item"])
107
+ give e["index"] + 1
108
+ when starts_with(t, "mié")
109
+ give 3
110
+ when starts_with(t, "sáb")
111
+ give 6
112
+ give 0
113
+
114
+ task parse_hhmm(tok)
115
+ when not matches(tok, "[0-9]{1,2}(:[0-9]{2})?")
116
+ give nothing
117
+ let parts be split(tok, ":")
118
+ let h be floor(number(parts[0]))
119
+ let m be when length(parts) > 1 then floor(number(parts[1])) otherwise 0
120
+ when h > 23 or m > 59
121
+ give nothing
122
+ give {"hh": h, "mm": m}
123
+
124
+ -- "6h" | "30m" | "2d" | "90s" → segundos
125
+ task parse_span(tok)
126
+ let t be lower(tok)
127
+ when not matches(t, "[0-9]+(\.[0-9]+)?(s|m|h|d|min|h?ora?s?|d[ií]as?|seg(undos)?|minutos?)?")
128
+ give nothing
129
+ let num be number(capture(t, "[0-9]+(?:\.[0-9]+)?"))
130
+ let unit be replace_re(t, "^[0-9.]+", "")
131
+ when unit == "s" or starts_with(unit, "seg")
132
+ give floor(num)
133
+ when unit == "m" or starts_with(unit, "min")
134
+ give floor(num * 60)
135
+ when unit == "" or unit == "h" or starts_with(unit, "hora")
136
+ give floor(num * 3600)
137
+ give floor(num * DAY)
138
+
139
+ -- → {ok, plan: {kind: every|daily|weekly, seconds?, hh?, mm?, days?}, error}
140
+ export task parse_when(spec)
141
+ let s be lower(trim(text(spec)))
142
+ set s to replace_text(replace_text(replace_text(s, " a las ", " "), " at ", " "), "todos los días", "daily")
143
+ set s to replace_text(replace_text(s, "todos los dias", "daily"), "every day", "daily")
144
+ let toks be where(split(replace_text(s, ",", " "), " "), (x) => x != "")
145
+ when length(toks) == 0
146
+ give {"ok": false, "error": "empty schedule"}
147
+ let head be toks[0]
148
+ -- una sola vez: once 2026-08-29 15:14 · today 15:14 · hoy 15:14 · tomorrow 09:00 · mañana 09:00 · in 2h · en 30m
149
+ when head == "in" or head == "en"
150
+ when length(toks) < 2
151
+ give {"ok": false, "error": "in needs a span: in 2h, in 30m"}
152
+ let secs be parse_span(when length(toks) >= 3 and matches(toks[1], "[0-9.]+") then toks[1] + toks[2] otherwise toks[1])
153
+ when secs == nothing or secs < 60
154
+ give {"ok": false, "error": "bad delay '" + join(slice(toks, 1, length(toks)), " ") + "' (min 60 s; e.g. in 2h, in 30m)"}
155
+ give {"ok": true, "plan": {"kind": "once", "delay": secs, "date": "", "rel": "", "hh": 0, "mm": 0}}
156
+ when head == "today" or head == "hoy" or head == "tomorrow" or head == "mañana" or head == "manana"
157
+ let hm be when length(toks) > 1 then parse_hhmm(toks[1]) otherwise nothing
158
+ when hm == nothing
159
+ give {"ok": false, "error": head + " needs a time: " + head + " 15:14"}
160
+ give {"ok": true, "plan": {"kind": "once", "delay": 0, "date": "", "rel": when head == "today" or head == "hoy" then "today" otherwise "tomorrow", "hh": hm["hh"], "mm": hm["mm"]}}
161
+ when head == "once"
162
+ let rest be slice(toks, 1, length(toks))
163
+ when length(rest) < 2 or not matches(rest[0], "[0-9]{4}-[0-9]{2}-[0-9]{2}")
164
+ give {"ok": false, "error": "once needs a date and time: once 2026-08-29 15:14"}
165
+ let hm be parse_hhmm(rest[1])
166
+ when hm == nothing
167
+ give {"ok": false, "error": "bad time '" + rest[1] + "' (HH:MM, 24 h)"}
168
+ give {"ok": true, "plan": {"kind": "once", "delay": 0, "date": rest[0], "rel": "", "hh": hm["hh"], "mm": hm["mm"]}}
169
+ when matches(head, "[0-9]{4}-[0-9]{2}-[0-9]{2}") and length(toks) >= 2
170
+ let hm be parse_hhmm(toks[1])
171
+ when hm == nothing
172
+ give {"ok": false, "error": "bad time '" + toks[1] + "' (HH:MM, 24 h)"}
173
+ give {"ok": true, "plan": {"kind": "once", "delay": 0, "date": head, "rel": "", "hh": hm["hh"], "mm": hm["mm"]}}
174
+ when head == "every" or head == "cada"
175
+ when length(toks) < 2
176
+ give {"ok": false, "error": "every needs a span: every 6h, every 30m, every 2d"}
177
+ let secs be parse_span(when length(toks) >= 3 and matches(toks[1], "[0-9.]+") then toks[1] + toks[2] otherwise toks[1])
178
+ when secs == nothing or secs < 60
179
+ give {"ok": false, "error": "bad interval '" + join(slice(toks, 1, length(toks)), " ") + "' (min 60 s; e.g. every 6h, every 30m, every 2d)"}
180
+ give {"ok": true, "plan": {"kind": "every", "seconds": secs}}
181
+ when head == "daily" or head == "diario" or head == "diaria"
182
+ let hm be when length(toks) > 1 then parse_hhmm(toks[1]) otherwise {"hh": 9, "mm": 0}
183
+ when hm == nothing
184
+ give {"ok": false, "error": "bad time '" + toks[1] + "' (HH:MM, 24 h)"}
185
+ give {"ok": true, "plan": {"kind": "daily", "hh": hm["hh"], "mm": hm["mm"]}}
186
+ let solo be parse_hhmm(head)
187
+ when solo != nothing and length(toks) == 1
188
+ give {"ok": true, "plan": {"kind": "daily", "hh": solo["hh"], "mm": solo["mm"]}}
189
+ -- días de la semana + hora
190
+ let days be []
191
+ let time_tok be nothing
192
+ let i be 0
193
+ each tok in toks
194
+ when tok == "weekly" or tok == "semanal" or tok == "on" or tok == "los"
195
+ set days to days
196
+ otherwise when tok == "weekdays" or tok == "laborables"
197
+ set days to [1, 2, 3, 4, 5]
198
+ otherwise when tok == "weekend" or tok == "finde"
199
+ set days to [6, 7]
200
+ otherwise when day_index(tok) > 0
201
+ set days to append(days, day_index(tok))
202
+ otherwise when parse_hhmm(tok) != nothing
203
+ set time_tok to parse_hhmm(tok)
204
+ otherwise
205
+ give {"ok": false, "error": "cannot understand '" + tok + "' — use: every 6h | daily 09:00 | mon,wed 08:30 | weekdays 09:00"}
206
+ when length(days) == 0
207
+ give {"ok": false, "error": "no day found in '" + spec + "' (mon…sun / lun…dom, weekdays, weekend)"}
208
+ when time_tok == nothing
209
+ set time_tok to {"hh": 9, "mm": 0}
210
+ give {"ok": true, "plan": {"kind": "weekly", "days": sort_by(days, (d) => d), "hh": time_tok["hh"], "mm": time_tok["mm"]}}
211
+
212
+ export task describe_plan(plan)
213
+ when plan["kind"] == "once"
214
+ when plan["delay"] > 0
215
+ give "una vez, en " + (when plan["delay"] >= 3600 then text(floor(plan["delay"] / 3600)) + " h" otherwise text(floor(plan["delay"] / 60)) + " min")
216
+ let hhmm0 be pad2(plan["hh"]) + ":" + pad2(plan["mm"])
217
+ when plan["rel"] != ""
218
+ give "una vez, " + (when plan["rel"] == "today" then "hoy" otherwise "mañana") + " a las " + hhmm0
219
+ give "una vez, el " + plan["date"] + " a las " + hhmm0
220
+ when plan["kind"] == "every"
221
+ let s be plan["seconds"]
222
+ when s >= DAY and s - floor(s / DAY) * DAY == 0
223
+ give "cada " + text(floor(s / DAY)) + (when s == DAY then " día" otherwise " días")
224
+ when s >= 3600 and s - floor(s / 3600) * 3600 == 0
225
+ give "cada " + text(floor(s / 3600)) + " h"
226
+ give "cada " + text(floor(s / 60)) + " min"
227
+ let hhmm be pad2(plan["hh"]) + ":" + pad2(plan["mm"])
228
+ when plan["kind"] == "daily"
229
+ give "todos los días a las " + hhmm
230
+ let names be apply(plan["days"], (d) => DAY_NAMES[d - 1])
231
+ give join(names, ", ") + " a las " + hhmm
232
+
233
+ -- ---------- próxima corrida ----------
234
+ -- (parse_time exige fecha Y hora: solo "%Y-%m-%d" da "not enough for unique date and time")
235
+ task local_day_start(local_ts)
236
+ give parse_time(format_time(local_ts, "%Y-%m-%d") + " 00:00", "%Y-%m-%d %H:%M")
237
+
238
+ -- próxima corrida (timestamp UTC) después de `from`, con `last` = fin de la corrida anterior (o nothing)
239
+ export task next_run(plan, from, last, off)
240
+ require time
241
+ when plan["kind"] == "every"
242
+ give (when last == nothing then from otherwise last) + plan["seconds"]
243
+ -- una sola vez: después de correr no hay próxima (record la apaga)
244
+ when plan["kind"] == "once" and last != nothing
245
+ give nothing
246
+ let local be from + off
247
+ let day0 be local_day_start(local)
248
+ let at be plan["hh"] * 3600 + plan["mm"] * 60
249
+ when plan["kind"] == "once"
250
+ when plan["delay"] > 0
251
+ give from + plan["delay"]
252
+ when plan["date"] != ""
253
+ give parse_time(plan["date"] + " " + pad2(plan["hh"]) + ":" + pad2(plan["mm"]), "%Y-%m-%d %H:%M") - off
254
+ give day0 + (when plan["rel"] == "tomorrow" then DAY otherwise 0) + at - off
255
+ when plan["kind"] == "daily"
256
+ let cand be day0 + at
257
+ when cand <= local
258
+ set cand to cand + DAY
259
+ give cand - off
260
+ let i be 0
261
+ while i < 8
262
+ let d be day0 + i * DAY
263
+ let wd be floor(number(format_time(d + 3600, "%u")))
264
+ when contains(plan["days"], wd) and d + at > local
265
+ give d + at - off
266
+ set i to i + 1
267
+ give from + DAY * 7
268
+
269
+ export task fmt_local(ts)
270
+ require exec
271
+ require time
272
+ require env("LAMPSON_*")
273
+ require env("OS")
274
+ when ts == nothing
275
+ give "—"
276
+ let off be tz_offset()
277
+ give format_time(ts + off, "%Y-%m-%d %H:%M") + " (" + fmt_offset(off) + ")"
278
+
279
+ -- ---------- persistencia ----------
280
+ task load_doc()
281
+ try
282
+ let doc be json_decode(read_file(FILE))
283
+ when not contains(doc, "tasks")
284
+ set doc["tasks"] to []
285
+ give doc
286
+ recover err
287
+ give {"tasks": [], "seq": 0}
288
+
289
+ task save_doc(doc)
290
+ write_file(FILE, json_encode(doc))
291
+
292
+ -- las tareas son DEL WORKSPACE (meta project = slug de la ruta, como las sesiones): en otro proyecto no se ven ni
293
+ -- corren. Una tarea vieja sin marca se adopta en el proyecto que la lee primero.
294
+ export task all()
295
+ require env("LAMPSON_*")
296
+ require file(".lampson")
297
+ require file(".lampson/*")
298
+ let mine be memo.slug()
299
+ let doc be load_doc()
300
+ let out be []
301
+ let fixed be false
302
+ let keep be []
303
+ each t in doc["tasks"]
304
+ when not contains(t, "project")
305
+ set t["project"] to mine
306
+ set fixed to true
307
+ set keep to append(keep, t)
308
+ when t["project"] == mine
309
+ set out to append(out, t)
310
+ when fixed
311
+ set doc["tasks"] to keep
312
+ save_doc(doc)
313
+ give out
314
+
315
+ -- ¿el workspace montado sigue siendo el de esta corrida? Solo hay UNA junction ./workspace: si el usuario abre
316
+ -- lampson en otra carpeta mientras el daemon corre, la junction apunta a otro proyecto y las tareas de este
317
+ -- workspace NO deben correr ahí (tick lo chequea y avisa). Se compara el destino real (readlink) con LAMPSON_WORKSPACE.
318
+ export task mount_ok()
319
+ require exec
320
+ require time
321
+ require env("LAMPSON_*")
322
+ require env("OS")
323
+ let want be norm_path(env("LAMPSON_WORKSPACE", ""))
324
+ when want == ""
325
+ give true
326
+ try
327
+ let sc be t_bash.shell_config()
328
+ -- Git Bash: `readlink -f` devuelve rutas POSIX y mapea %TEMP% a /tmp → `pwd -W` da la forma Windows real
329
+ let r be run(sc["shell"], [sc["flag"], "cd " + c.ROOT + " && (pwd -W 2>/dev/null || pwd -P)"], 10)
330
+ let got be norm_path(trim(text(r["stdout"])))
331
+ when got == ""
332
+ give true
333
+ give got == want
334
+ recover err
335
+ give true
336
+
337
+ task norm_path(p)
338
+ let s be lower(replace_text(trim(p), "\\", "/"))
339
+ -- Git Bash: /c/Users/x → c:/users/x
340
+ when matches(s, "/[a-z]/.*")
341
+ set s to slice(s, 1, 2) + ":" + slice(s, 2, length(s))
342
+ while length(s) > 0 and slice(s, length(s) - 1, length(s)) == "/"
343
+ set s to slice(s, 0, length(s) - 1)
344
+ give s
345
+
346
+ export task get(id)
347
+ require env("LAMPSON_*")
348
+ require file(".lampson")
349
+ require file(".lampson/*")
350
+ each t in all()
351
+ when t["id"] == id
352
+ give t
353
+ give nothing
354
+
355
+ task put(t)
356
+ let doc be load_doc()
357
+ let out be []
358
+ let found be false
359
+ each x in doc["tasks"]
360
+ when x["id"] == t["id"]
361
+ set out to append(out, t)
362
+ set found to true
363
+ otherwise
364
+ set out to append(out, x)
365
+ when not found
366
+ set out to append(out, t)
367
+ set doc["tasks"] to out
368
+ save_doc(doc)
369
+ give t
370
+
371
+ task slug(name)
372
+ let s be lower(replace_re(text(name), "[^A-Za-z0-9]+", "-"))
373
+ set s to replace_re(replace_re(s, "^-+", ""), "-+$", "")
374
+ when s == ""
375
+ give "task"
376
+ when length(s) > 24
377
+ give slice(s, 0, 24)
378
+ give s
379
+
380
+ -- valida y normaliza la acción → {ok, action, error}
381
+ task check_action(action)
382
+ when action == nothing or not contains(action, "type")
383
+ give {"ok": false, "error": "action needs a type: lamp | bash | prompt"}
384
+ let kind be lower(text(action["type"]))
385
+ when kind == "lamp"
386
+ when not contains(action, "lamp") or not contains(action, "tool")
387
+ give {"ok": false, "error": "action lamp needs lamp and tool"}
388
+ let full be lamps.tool_name(text(action["lamp"]), text(action["tool"]))
389
+ when not contains(lamps.names(false), full)
390
+ give {"ok": false, "error": "lamp tool " + full + " is not available (the lamp must exist and be ON)"}
391
+ give {"ok": true, "action": {"type": "lamp", "lamp": text(action["lamp"]), "tool": text(action["tool"]), "args": when contains(action, "args") then action["args"] otherwise {}}}
392
+ when kind == "bash"
393
+ when not contains(action, "command") or trim(text(action["command"])) == ""
394
+ give {"ok": false, "error": "action bash needs a command"}
395
+ let v be permission.evaluate("bash", {"command": text(action["command"])}, "yolo")
396
+ when v["decision"] == "deny"
397
+ give {"ok": false, "error": "command refused: " + v["reason"]}
398
+ when t_bash.looks_like_server(text(action["command"]))
399
+ give {"ok": false, "error": "that looks like a server/watcher; a scheduled command must finish on its own"}
400
+ give {"ok": true, "action": {"type": "bash", "command": text(action["command"]), "timeout": when contains(action, "timeout") then floor(number(action["timeout"])) otherwise 300}}
401
+ when kind == "prompt"
402
+ when not contains(action, "prompt") or trim(text(action["prompt"])) == ""
403
+ give {"ok": false, "error": "action prompt needs the prompt text"}
404
+ let agent be when contains(action, "agent") then lower(text(action["agent"])) otherwise "build"
405
+ when not contains(["build", "plan", "review", "explore"], agent)
406
+ give {"ok": false, "error": "agent must be build | plan | review | explore"}
407
+ give {"ok": true, "action": {"type": "prompt", "prompt": text(action["prompt"]), "agent": agent}}
408
+ give {"ok": false, "error": "unknown action type '" + kind + "' (lamp | bash | prompt)"}
409
+
410
+ -- crear: spec = {name, when, action, permission?, approval_timeout?, notify?} → la tarea (raise si es inválida)
411
+ export task add(spec)
412
+ require exec
413
+ require time
414
+ require env("LAMPSON_*")
415
+ require env("OS")
416
+ require file(".lampson")
417
+ require file(".lampson/*")
418
+ require file.read("lamps")
419
+ require file.read("lamps/*")
420
+ require file("workspace")
421
+ require file("workspace/*")
422
+ when spec == nothing or not contains(spec, "when")
423
+ raise("a schedule needs `when` (every 6h | daily 09:00 | mon,wed 08:30)")
424
+ let pw be parse_when(spec["when"])
425
+ when not pw["ok"]
426
+ raise(pw["error"])
427
+ let ca be check_action(when contains(spec, "action") then spec["action"] otherwise nothing)
428
+ when not ca["ok"]
429
+ raise(ca["error"])
430
+ let name be when contains(spec, "name") and trim(text(spec["name"])) != "" then trim(text(spec["name"])) otherwise default_name(ca["action"])
431
+ let perm be when contains(spec, "permission") then lower(text(spec["permission"])) otherwise "ask"
432
+ when not contains(["strict", "ask", "yolo"], perm)
433
+ raise("permission must be strict | ask | yolo")
434
+ let doc be load_doc()
435
+ let seq be (when contains(doc, "seq") then floor(number(doc["seq"])) otherwise 0) + 1
436
+ set doc["seq"] to seq
437
+ save_doc(doc)
438
+ let id be slug(name) + "-" + text(seq)
439
+ let first be next_run(pw["plan"], now(), nothing, tz_offset())
440
+ when pw["plan"]["kind"] == "once" and first < now()
441
+ raise("that moment has already passed (" + fmt_local(first) + "); one-time tasks need a future time")
442
+ let t be {
443
+ "id": id, "name": name, "project": memo.slug(), "when": trim(text(spec["when"])), "plan": pw["plan"], "action": ca["action"],
444
+ "permission": perm,
445
+ "approval_timeout": when contains(spec, "approval_timeout") then floor(number(spec["approval_timeout"])) otherwise 7200,
446
+ "notify": when contains(spec, "notify") then trim(text(spec["notify"])) otherwise "",
447
+ "enabled": true, "created": now(), "last_run": nothing, "last_status": "", "last_summary": "", "running": false, "run_now": false,
448
+ "next_run": first, "history": []
449
+ }
450
+ put(t)
451
+ give t
452
+
453
+ task default_name(action)
454
+ when action["type"] == "lamp"
455
+ give action["lamp"] + " " + action["tool"]
456
+ when action["type"] == "bash"
457
+ let cmd be action["command"]
458
+ give when length(cmd) > 40 then slice(cmd, 0, 40) + "…" otherwise cmd
459
+ let p be replace_text(action["prompt"], "\n", " ")
460
+ give when length(p) > 40 then slice(p, 0, 40) + "…" otherwise p
461
+
462
+ export task remove(id)
463
+ require env("LAMPSON_*")
464
+ require file(".lampson")
465
+ require file(".lampson/*")
466
+ when get(id) == nothing
467
+ raise("no scheduled task '" + text(id) + "' in this workspace")
468
+ let doc be load_doc()
469
+ let before be length(doc["tasks"])
470
+ set doc["tasks"] to where(doc["tasks"], (t) => t["id"] != id)
471
+ when length(doc["tasks"]) == before
472
+ raise("no scheduled task '" + text(id) + "'")
473
+ save_doc(doc)
474
+ give "scheduled task " + id + " removed"
475
+
476
+ export task set_enabled(id, on)
477
+ require exec
478
+ require time
479
+ require env("LAMPSON_*")
480
+ require env("OS")
481
+ require file(".lampson")
482
+ require file(".lampson/*")
483
+ let t be get(id)
484
+ when t == nothing
485
+ raise("no scheduled task '" + text(id) + "'")
486
+ set t["enabled"] to on == true
487
+ when on
488
+ set t["next_run"] to next_run(t["plan"], now(), nothing, tz_offset())
489
+ when t["plan"]["kind"] == "once" and t["next_run"] < now()
490
+ raise("'" + id + "' was a one-time task for " + describe_plan(t["plan"]) + " and that moment has passed; create a new one")
491
+ put(t)
492
+ give "scheduled task " + id + (when on then " ON · next: " + fmt_local(t["next_run"]) otherwise " OFF")
493
+
494
+ -- sesión de la corrida en curso (para verla en vivo desde la UI)
495
+ export task set_session(id, sid)
496
+ require env("LAMPSON_*")
497
+ require file(".lampson")
498
+ require file(".lampson/*")
499
+ let t be get(id)
500
+ when t == nothing
501
+ give false
502
+ set t["session"] to sid
503
+ put(t)
504
+ give true
505
+
506
+ -- pedir que corra en el próximo tick del daemon
507
+ export task request_run(id)
508
+ require env("LAMPSON_*")
509
+ require file(".lampson")
510
+ require file(".lampson/*")
511
+ let t be get(id)
512
+ when t == nothing
513
+ raise("no scheduled task '" + text(id) + "'")
514
+ set t["run_now"] to true
515
+ put(t)
516
+ give t
517
+
518
+ -- ¿hay un daemon (web.syn) haciendo tick? → segundos desde el último latido, o nothing
519
+ export task daemon_age()
520
+ require time
521
+ require file(".lampson")
522
+ require file(".lampson/*")
523
+ try
524
+ let ts be number(trim(read_file(HEARTBEAT)))
525
+ give now() - ts
526
+ recover err
527
+ give nothing
528
+
529
+ export task heartbeat()
530
+ require time
531
+ require file(".lampson")
532
+ require file(".lampson/*")
533
+ write_file(HEARTBEAT, text(now()))
534
+ give true
535
+
536
+ -- ---------- corridas: lamp y bash (prompt en sched_run.syn) ----------
537
+ export task run_simple(t)
538
+ require exec
539
+ require time
540
+ require env("LAMPSON_*")
541
+ require env("OS")
542
+ require file(".lampson")
543
+ require file(".lampson/*")
544
+ require file.read("lamps")
545
+ require file.read("lamps/*")
546
+ require file("workspace")
547
+ require file("workspace/*")
548
+ let a be t["action"]
549
+ when a["type"] == "lamp"
550
+ give lamps.call(lamps.tool_name(a["lamp"], a["tool"]), a["args"])
551
+ when a["type"] == "bash"
552
+ let v be permission.evaluate("bash", {"command": a["command"]}, "yolo")
553
+ when v["decision"] == "deny"
554
+ give "DENIED by policy (" + v["reason"] + ")"
555
+ give t_bash.tool(a["command"], a["timeout"])
556
+ give "ERROR: prompt tasks run through sched_run"
557
+
558
+ -- marca de inicio (para que la UI muestre «corriendo» y dos ticks no la repitan)
559
+ export task mark_started(t)
560
+ require file(".lampson")
561
+ require file(".lampson/*")
562
+ require time
563
+ set t["running"] to true
564
+ set t["run_now"] to false
565
+ put(t)
566
+ append_file(LOG_DIR + "/" + t["id"] + ".log", "\n══ " + format_time(now()) + " · " + t["name"] + " · " + t["action"]["type"] + "\n")
567
+ bus_publish("schedule.started", {"id": t["id"], "name": t["name"]})
568
+ give t
569
+
570
+ -- cierre de una corrida: historial (json + log), próxima corrida, webhook de resultado
571
+ export task record(t, started, status, output, extra)
572
+ require net
573
+ require time
574
+ require exec
575
+ require env("LAMPSON_*")
576
+ require env("OS")
577
+ require file(".lampson")
578
+ require file(".lampson/*")
579
+ let fresh be get(t["id"])
580
+ when fresh == nothing
581
+ set fresh to t
582
+ let fin be now()
583
+ let out be text(output)
584
+ let summary be one_line(out, 200)
585
+ let late be fresh["next_run"] != nothing and started - fresh["next_run"] > TICK_SECONDS * 4
586
+ let entry be {"started": started, "finished": fin, "status": status, "summary": summary, "late": late}
587
+ each k in keys(extra)
588
+ set entry[k] to extra[k]
589
+ let hist be append(fresh["history"], entry)
590
+ when length(hist) > KEEP_HISTORY
591
+ set hist to slice(hist, length(hist) - KEEP_HISTORY, length(hist))
592
+ set fresh["history"] to hist
593
+ set fresh["running"] to false
594
+ set fresh["last_run"] to fin
595
+ set fresh["last_status"] to status
596
+ set fresh["last_summary"] to summary
597
+ set fresh["next_run"] to next_run(fresh["plan"], fin, fin, tz_offset())
598
+ when fresh["plan"]["kind"] == "once"
599
+ set fresh["enabled"] to false
600
+ put(fresh)
601
+ append_file(LOG_DIR + "/" + t["id"] + ".log", out + "\n── " + status + " · " + text(floor(fin - started)) + " s" + (when late then " · ATRASADA (el daemon no estaba corriendo a la hora prevista)" otherwise "") + "\n")
602
+ bus_publish("schedule.done", {"id": t["id"], "name": t["name"], "status": status})
603
+ when fresh["notify"] != ""
604
+ let payload be {"type": "schedule.done", "id": fresh["id"], "name": fresh["name"], "status": status, "started": started, "finished": fin, "summary": summary, "output": c.truncate(out, 20000)}
605
+ each k in keys(extra)
606
+ set payload[k] to extra[k]
607
+ try
608
+ let r be http_post(fresh["notify"], json_encode(payload), {"Content-Type": "application/json", "User-Agent": "lampson"})
609
+ when not r["ok"]
610
+ append_file(LOG_DIR + "/" + t["id"] + ".log", "── notify " + fresh["notify"] + " → " + text(r["status"]) + "\n")
611
+ recover err
612
+ append_file(LOG_DIR + "/" + t["id"] + ".log", "── notify failed: " + text(err) + "\n")
613
+ give fresh
614
+
615
+ task one_line(s, max)
616
+ let t be trim(replace_text(replace_text(text(s), "\r", ""), "\n", " ⏎ "))
617
+ when length(t) > max
618
+ give slice(t, 0, max) + "…"
619
+ give t
620
+
621
+ -- tareas que deben correr ahora
622
+ export task due(ts)
623
+ require env("LAMPSON_*")
624
+ require file(".lampson")
625
+ require file(".lampson/*")
626
+ give where(all(), (t) => t["enabled"] and not t["running"] and (t["run_now"] or (t["next_run"] != nothing and t["next_run"] <= ts)))
627
+
628
+ export task log_tail(id, n)
629
+ require file(".lampson")
630
+ require file(".lampson/*")
631
+ when not matches(text(id), "[a-z0-9-]{1,40}")
632
+ give ""
633
+ try
634
+ let lines be split(replace_text(read_file(LOG_DIR + "/" + id + ".log"), "\r", ""), "\n")
635
+ when length(lines) > n
636
+ set lines to slice(lines, length(lines) - n, length(lines))
637
+ give join(lines, "\n")
638
+ recover err
639
+ give ""
640
+
641
+ -- descripción de la acción (una línea) para previews y listados
642
+ export task describe_action(a)
643
+ when a["type"] == "lamp"
644
+ give "lamp " + a["lamp"] + "." + a["tool"] + (when length(keys(a["args"])) > 0 then " " + one_line(json_encode(a["args"]), 80) otherwise "")
645
+ when a["type"] == "bash"
646
+ give "$ " + one_line(a["command"], 120)
647
+ give "agent " + a["agent"] + ": " + one_line(a["prompt"], 140)
648
+
649
+ -- resumen para UI/terminal/tool
650
+ export task summary()
651
+ require exec
652
+ require time
653
+ require env("LAMPSON_*")
654
+ require env("OS")
655
+ require file(".lampson")
656
+ require file(".lampson/*")
657
+ let out be []
658
+ each t in all()
659
+ set out to append(out, {"id": t["id"], "name": t["name"], "when": t["when"], "plan": describe_plan(t["plan"]), "action": t["action"], "action_text": describe_action(t["action"]), "permission": t["permission"], "approval_timeout": t["approval_timeout"], "notify": t["notify"], "enabled": t["enabled"], "running": t["running"], "run_now": t["run_now"], "session": when contains(t, "session") then t["session"] otherwise nothing, "last_run": t["last_run"], "last_status": t["last_status"], "last_summary": t["last_summary"], "next_run": t["next_run"], "next_local": fmt_local(when t["enabled"] then t["next_run"] otherwise nothing), "last_local": fmt_local(t["last_run"]), "history": t["history"]})
660
+ give out