lampson 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/chat.syn CHANGED
@@ -329,9 +329,7 @@ let COMMANDS be [
329
329
  ["/ask", "", "permisos: PREGUNTAR antes de un comando peligroso (rm -rf, git push --force, sudo…)"],
330
330
  ["/yolo", "", "permisos: PERMITIR comandos peligrosos sin preguntar (los destructivos del sistema siguen bloqueados)"],
331
331
  ["/strict", "", "permisos: DENEGAR comandos peligrosos"],
332
- ["/model", "<nombre>", "cambiar el modelo para esta sesión (mismo proveedor y key)"],
333
- ["/provider", "[nombre] [modelo]", "cambiar de proveedor (deepseek, anthropic, openai, minimax…); sin args lista cuáles tienen key"],
334
- ["/setup", "", "elegir proveedor, modelo y pegar la API key (queda en lampson/.lampson/config.json, local)"],
332
+ ["/model", "[proveedor] [modelo]", "elegir proveedor y modelo con flechas (la lista de modelos la da la API); pide la key solo si falta; queda guardado"],
335
333
  ["/paste", "", "adjuntar la imagen del portapapeles al próximo mensaje (copiá una captura y escribí /paste)"],
336
334
  ["/image", "<ruta>", "adjuntar una imagen (png/jpg/gif/webp) al próximo mensaje; /image sin ruta muestra o quita las pendientes"],
337
335
  ["/delete", "<id>", "borrar una sesión guardada, con confirmación (ver /sessions)"],
@@ -381,7 +379,7 @@ task complete_args(cmd, head, last)
381
379
  give workspace_paths(last)
382
380
  when cmd == "/resume" or cmd == "/delete"
383
381
  give apply((s) => s["id"], session.list())
384
- when cmd == "/provider"
382
+ when cmd == "/provider" or cmd == "/model" or cmd == "/setup"
385
383
  when first
386
384
  give apply((p) => p["name"], provider.providers())
387
385
  give []
@@ -420,9 +418,8 @@ task complete_args(cmd, head, last)
420
418
  give []
421
419
  give apply((s) => s["id"], schedule.summary())
422
420
  when cmd == "/agent"
423
- give ["build", "plan", "review", "explore", "worker"]
424
- when cmd == "/model"
425
- give []
421
+ -- solo los perfiles elegibles por el usuario: `worker` es interno de delegate (agents.names lo filtra)
422
+ give agents.names()
426
423
  give []
427
424
 
428
425
  -- rutas del workspace que empiezan con el prefijo escrito (directorios con "/" final para seguir completando)
@@ -599,51 +596,140 @@ task skills_list()
599
596
  -- ---------- onboarding ----------
600
597
  -- Primera vez (ningún proveedor con key) o /setup: elegir proveedor, modelo y pegar la key. Va a
601
598
  -- lampson/.lampson/config.json (local, gitignored) y vale para terminal y web. Sin TTY no pregunta.
602
- task setup_wizard()
603
- print("")
604
- let list be provider.providers()
605
- -- menú ↑↓/Enter (o inicial / número) cuando hay TTY; sin TTY, la lista numerada + read_line de siempre.
606
- -- Las etiquetas van en texto plano: las pinta el menú (ver lib/line.syn).
607
- let labels be []
608
- each p in list
609
- set labels to append(labels, pad(p["name"], 12) + pad(p["model"], 26) + (when p["name"] == "ollama" then "(local, sin key)" otherwise (when p["has_key"] then "● key guardada" otherwise "")))
599
+ -- ---------- /model · /provider · /setup: UN solo flujo ----------
600
+ -- Proveedor → modelo (lista viva de la API del proveedor) → key solo si falta. Todo con menús ↑↓ cuando hay
601
+ -- TTY; sin TTY, listas numeradas + read_line. Siempre se GUARDA en lampson/.lampson/config.json (igual que
602
+ -- la web): ya no hay "solo esta sesión". Con args (`/model glm glm-5.3-flash`) aplica directo lo que vino y
603
+ -- pregunta solo lo que falte. cur_name/cur_model = lo activo, para marcarlo y ponerlo primero.
604
+ let MENU_MAX be 18
605
+
606
+ -- menú o lista numerada según haya TTY. Devuelve el índice elegido, o nothing si el usuario canceló.
607
+ task pick_from(question, labels)
610
608
  flush()
611
- let pick be ed.choose(" " + cyan("Configuremos el modelo.") + " Elegí un proveedor:", labels, COLOR)
612
- let name be ""
609
+ let pick be ed.choose(" " + question, labels, COLOR)
613
610
  when pick == nothing
614
- print(" (sin cambios)")
611
+ give nothing
612
+ when pick != -1
613
+ give pick
614
+ print(" " + question + " (número)")
615
+ each e in enumerate(labels)
616
+ print(" " + pad(text(e["index"] + 1) + ".", 4) + e["item"])
617
+ let choice be read_line(" > ")
618
+ when choice == nothing or trim(choice) == ""
619
+ give nothing
620
+ let c be trim(choice)
621
+ when matches(c, "^[0-9]+$")
622
+ let i be floor(number(c)) - 1
623
+ when i >= 0 and i < length(labels)
624
+ give i
625
+ give nothing
626
+ -- también vale escribir el nombre (primera palabra de la etiqueta)
627
+ each e in enumerate(labels)
628
+ when lower(split(trim(e["item"]), " ")[0]) == lower(c)
629
+ give e["index"]
630
+ give nothing
631
+
632
+ task ask_provider(cur_name)
633
+ let list be provider.providers()
634
+ -- el actual primero, después el resto en orden
635
+ let ordered be where(list, (p) => p["name"] == cur_name) + where(list, (p) => p["name"] != cur_name)
636
+ let labels be []
637
+ each p in ordered
638
+ let flag be when p["name"] == "ollama" then "local, sin key" otherwise (when p["has_key"] then "● key" otherwise "")
639
+ set labels to append(labels, pad(p["name"], 12) + pad(p["model"], 26) + pad(flag, 16) + (when p["name"] == cur_name then "◀ actual" otherwise ""))
640
+ let i be pick_from(cyan("Proveedor:"), labels)
641
+ when i == nothing
642
+ give ""
643
+ give ordered[i]["name"]
644
+
645
+ -- modelos del proveedor según su API; si son muchos, un filtro antes del menú; siempre "otro…" para tipear
646
+ task ask_model(name, cur_name, cur_model)
647
+ let pcfg be provider.config_for(name, "")
648
+ let default be when name == cur_name and cur_model != "" then cur_model otherwise pcfg["model"]
649
+ let lm be provider.list_models(pcfg)
650
+ when lm["error"] != nothing
651
+ print(" " + dim("no pude listar los modelos de " + name + " (" + lm["error"] + ")"))
652
+ let typed be read_line(" modelo [" + default + "]: ")
653
+ give when typed == nothing or trim(typed) == "" then default otherwise trim(typed)
654
+ let ids be lm["models"]
655
+ when length(ids) > MENU_MAX
656
+ print(" " + dim(text(length(ids)) + " modelos en " + name + " · escribí parte del nombre para filtrar (Enter = los primeros " + text(MENU_MAX) + ")"))
657
+ let f be read_line(" filtro: ")
658
+ let ff be when f == nothing then "" otherwise lower(trim(f))
659
+ when ff != ""
660
+ set ids to where(ids, (m) => contains(lower(m), ff))
661
+ when length(ids) == 0
662
+ print(" " + dim("ningún modelo contiene «" + ff + "»"))
663
+ set ids to lm["models"]
664
+ when length(ids) > MENU_MAX
665
+ set ids to slice(ids, 0, MENU_MAX)
666
+ -- el default primero (si la API lo lista), después el resto; al final la opción de escribir
667
+ let ordered be where(ids, (m) => m == default) + where(ids, (m) => m != default)
668
+ let labels be apply((m) => m + (when m == default then " ◀ actual" otherwise ""), ordered)
669
+ set labels to append(labels, "otro… (escribir)")
670
+ let i be pick_from(cyan("Modelo de " + name + ":") + dim(" (según su API)"), labels)
671
+ when i == nothing
672
+ give ""
673
+ when i == length(labels) - 1
674
+ let typed be read_line(" modelo [" + default + "]: ")
675
+ give when typed == nothing or trim(typed) == "" then default otherwise trim(typed)
676
+ give ordered[i]
677
+
678
+ -- pide la key si el proveedor no tiene una guardada. Devuelve false si el usuario canceló.
679
+ task ensure_key(name)
680
+ when name == "ollama" or provider.has_key(name)
681
+ give true
682
+ print(" " + dim("la key se guarda en lampson/.lampson/config.json (solo en esta máquina)"))
683
+ let key be read_line(" API key de " + name + ": ")
684
+ when key == nothing
615
685
  give false
616
- otherwise when pick == -1
617
- print(" " + cyan("Configuremos el modelo.") + " Elegí un proveedor (número o nombre):")
618
- each e in enumerate(list)
619
- let p be e["item"]
620
- print(" " + pad(text(e["index"] + 1) + ".", 4) + pad(p["name"], 12) + dim(p["model"]) + (when p["name"] == "ollama" then dim(" (local, sin key)") otherwise (when p["has_key"] then green(" ● key guardada") otherwise "")))
621
- let choice be read_line(" proveedor: ")
622
- when choice == nothing or trim(choice) == ""
686
+ when trim(key) == ""
687
+ print(" " + yellow("sin key: podés agregarla después con /model"))
688
+ give true
689
+ settings.set_key(name, trim(key))
690
+ give true
691
+
692
+ -- pre_name / pre_model: lo que vino por args ("" = preguntar). Devuelve true si guardó algo.
693
+ task setup_wizard(pre_name, pre_model, cur_name, cur_model)
694
+ print("")
695
+ let name be lower(trim(pre_name))
696
+ when name == ""
697
+ set name to ask_provider(cur_name)
698
+ when name == ""
623
699
  print(" (sin cambios)")
624
700
  give false
625
- set name to lower(trim(choice))
626
- when matches(name, "[0-9]+")
627
- let i be floor(number(name)) - 1
628
- when i >= 0 and i < length(list)
629
- set name to list[i]["name"]
630
- when not contains(provider.PRESETS, name)
631
- print(" " + red("no conozco el proveedor " + name))
701
+ when not contains(provider.PRESETS, name)
702
+ print(" " + red("no conozco el proveedor " + name) + " · disponibles: " + join(sort_by(keys(provider.PRESETS), (x) => x), ", "))
703
+ give false
704
+ -- la key va antes que el modelo: sin key la API no lista los modelos
705
+ when not ensure_key(name)
706
+ print(" (sin cambios)")
707
+ give false
708
+ let model be trim(pre_model)
709
+ when model == ""
710
+ set model to ask_model(name, cur_name, cur_model)
711
+ when model == ""
712
+ print(" (sin cambios)")
632
713
  give false
633
- otherwise
634
- set name to list[pick]["name"]
635
- let preset be provider.PRESETS[name]
636
- let model be read_line(" modelo [" + preset["model"] + "]: ")
637
- set model to when model == nothing then "" otherwise trim(model)
638
- when name != "ollama"
639
- print(" " + dim("la key se guarda en lampson/.lampson/config.json (solo en esta máquina); Enter para dejar la actual"))
640
- let key be read_line(" API key de " + name + ": ")
641
- when key != nothing and trim(key) != ""
642
- settings.set_key(name, trim(key))
714
+ -- un "modelo" que empieza con / es un comando tipeado por error (o un stdin que se acabó): no se guarda
715
+ when starts_with(model, "/")
716
+ print(" (sin cambios)")
717
+ give false
718
+ set model to provider.normalize_model(name, model)
643
719
  settings.set_default(name, model)
644
- print(" " + green("listo") + " · " + name + " · " + (when model == "" then preset["model"] otherwise model) + dim(" (cambialo cuando quieras con /setup o /provider)"))
720
+ print(" " + green(" " + name + " · " + model + " · guardado") + dim(" (/model para cambiarlo; /model <proveedor> [modelo] va directo)"))
645
721
  give true
646
722
 
723
+ -- `/model x y` · `/provider x y` · `/setup`: args → (proveedor, modelo). Un solo arg que no es proveedor =
724
+ -- modelo del proveedor actual.
725
+ task setup_from_args(rest, cur_name, cur_model)
726
+ let args be where(split(trim(rest), " "), (x) => x != "")
727
+ when length(args) == 0
728
+ give setup_wizard("", "", cur_name, cur_model)
729
+ when length(args) == 1 and not contains(provider.PRESETS, lower(args[0]))
730
+ give setup_wizard(cur_name, args[0], cur_name, cur_model)
731
+ give setup_wizard(args[0], when length(args) > 1 then args[1] otherwise "", cur_name, cur_model)
732
+
647
733
  -- ---------- imágenes en el REPL ----------
648
734
  -- Una terminal no puede pegar una imagen, así que se lee del portapapeles (/paste) o de un archivo
649
735
  -- (/image ruta) con un script del sistema que devuelve "media_type|w|h|base64". Quedan pendientes
@@ -685,10 +771,14 @@ let cfg be provider.config()
685
771
  when not provider.configured() and cfg["provider"] != "ollama"
686
772
  print("")
687
773
  print(" Bienvenido a Lampson. Todavía no hay ninguna API key configurada.")
688
- when setup_wizard()
774
+ when setup_wizard("", "", cfg["provider"], cfg["model"])
689
775
  set cfg to provider.config()
690
776
  let env_info be agents.env_info(cfg)
691
777
  let profile be lower(env("LAMPSON_AGENT", "build"))
778
+ -- `--agent worker` (o cualquier nombre desconocido): worker es interno de delegate, no un perfil de sesión
779
+ when not contains(agents.names(), profile)
780
+ print("agente '" + profile + "' no disponible · disponibles: " + join(agents.names(), ", ") + " · uso build")
781
+ set profile to "build"
692
782
  task system_for(p)
693
783
  give {"role": "system", "content": prompt.build(env_info, agents.addendum_for(p))}
694
784
  task opts_for(p, mode)
@@ -932,34 +1022,10 @@ while running
932
1022
  let lines be when n == "" then 40 otherwise floor(number(n))
933
1023
  print(dim(" " + trace.file_of(sid)))
934
1024
  print(trace.tail(sid, lines))
935
- otherwise when starts_with(input, "/model")
936
- let want be trim(slice(input, 6, length(input)))
937
- when want == ""
938
- print("modelo actual: " + cfg["model"] + " · uso: /model <nombre>")
939
- let lm be provider.list_models(cfg)
940
- when lm["error"] != nothing
941
- print(" " + dim("no pude listar los modelos de " + cfg["provider"] + ": " + lm["error"]))
942
- otherwise
943
- print(" disponibles en " + cfg["provider"] + ": " + join(lm["models"], ", "))
944
- otherwise
945
- set cfg["model"] to provider.normalize_model(cfg["provider"], want)
946
- print("modelo=" + want + " (solo esta sesión; para fijarlo: LAMPSON_MODEL en lampson\\.env)")
947
- otherwise when starts_with(input, "/provider")
948
- let args be where(split(trim(slice(input, 9, length(input))), " "), (x) => x != "")
949
- when length(args) == 0
950
- print(" proveedor actual: " + cfg["provider"] + " · modelo " + cfg["model"])
951
- each p in provider.providers()
952
- print(" " + (when p["has_key"] then green("● ") otherwise dim("○ ")) + pad(p["name"], 12) + dim(p["model"] + (when p["has_key"] then "" otherwise " (sin key: LAMPSON_API_KEY_" + upper(p["name"]) + " en lampson\\.env)")))
953
- print(" uso: /provider <nombre> [modelo]")
954
- otherwise when not contains(provider.PRESETS, lower(args[0]))
955
- print(" no conozco el proveedor " + args[0] + " · disponibles: " + join(sort_by(keys(provider.PRESETS), (x) => x), ", "))
956
- otherwise
957
- set cfg to provider.config_for(args[0], when length(args) > 1 then args[1] otherwise "")
958
- when not cfg["has_key"]
959
- print(" " + yellow("ojo: no hay key para " + cfg["provider"] + " — agregá LAMPSON_API_KEY_" + upper(cfg["provider"]) + "=… en lampson\\.env (se usará LAMPSON_API_KEY, que probablemente falle)"))
960
- print(" proveedor=" + cfg["provider"] + " · modelo=" + cfg["model"] + " · " + cfg["wire"] + " → " + cfg["base_url"] + dim(" (solo esta sesión; para fijarlo: LAMPSON_PROVIDER en .env)"))
961
- otherwise when input == "/setup"
962
- when setup_wizard()
1025
+ otherwise when starts_with(input, "/model") or starts_with(input, "/provider") or starts_with(input, "/setup")
1026
+ -- un solo flujo (ver setup_wizard): proveedor → modelo (lista de la API) → key si falta; se guarda en config.json
1027
+ let rest be trim(replace_re(input, "^/(model|provider|setup)", ""))
1028
+ when setup_from_args(rest, cfg["provider"], cfg["model"])
963
1029
  set cfg to provider.config()
964
1030
  otherwise when input == "/paste"
965
1031
  attach_image("clip", "")
@@ -1271,7 +1337,7 @@ while running
1271
1337
  let want be trim(slice(input, 6, length(input)))
1272
1338
  when want == ""
1273
1339
  print("agente=" + profile + " · disponibles: " + join(agents.names(), ", "))
1274
- otherwise when contains(agents.PROFILES, want)
1340
+ otherwise when contains(agents.names(), want)
1275
1341
  set profile to want
1276
1342
  set system_msg to system_for(profile)
1277
1343
  set opts to opts_for(profile, mode)
package/hub.tpl.syn CHANGED
@@ -57,13 +57,21 @@ serve on 8080
57
57
  -- la UI de un workspace: el mismo index.html; el frontend deduce /w/<slug> de la URL (BASE en js/core.js).
58
58
  -- Sin barra final: el runtime normaliza "/w/x/" → "/w/x" (301), así que la ruta canónica es esta
59
59
  route "GET /w/:slug"
60
+ -- entrar a un workspace apagado lo enciende (y levanta la pausa manual): si alguien abre la URL es para usarlo
61
+ let w be ws.get(params.slug)
62
+ when w != nothing and ws.health(w) == nothing
63
+ ws.start_ws(w)
64
+ ws.wait_ready(w, 8)
65
+ ws.summary()
60
66
  give html(read_file("public/index.html"))
61
67
 
62
68
  route "GET /api/hub" requires auth
63
69
  give {"ok": true, "port": ws.HUB_PORT, "idle_hours": ws.idle_hours(), "home": ws.home()}
64
70
 
65
71
  route "GET /api/workspaces" requires auth
66
- give {"workspaces": ws.summary(), "idle_hours": ws.idle_hours()}
72
+ -- la caché del blackboard (tick + acciones) salvo ?fresh=1: la UI lo pide tras encender/apagar
73
+ let fresh be contains(query, "fresh")
74
+ give {"workspaces": when fresh then ws.summary() otherwise ws.summary_cached(20), "idle_hours": ws.idle_hours()}
67
75
 
68
76
  -- registrar una carpeta como workspace (y arrancarla)
69
77
  route "POST /api/workspaces" requires auth
@@ -73,6 +81,7 @@ serve on 8080
73
81
  let w be ws.ensure(text(b["path"]))
74
82
  ws.start_ws(w)
75
83
  ws.wait_ready(w, 8)
84
+ ws.summary()
76
85
  give {"ok": true, "workspace": w, "url": "/w/" + w["slug"] + "/"}
77
86
  recover err
78
87
  give fail(400, text(err))
@@ -84,7 +93,9 @@ serve on 8080
84
93
  when w == nothing
85
94
  give not_found("no such workspace")
86
95
  ws.start_ws(w)
87
- give {"ok": ws.wait_ready(w, 8)}
96
+ let ok be ws.wait_ready(w, 8)
97
+ ws.summary()
98
+ give {"ok": ok}
88
99
 
89
100
  route "POST /api/workspaces/stop" requires auth
90
101
  expect body {slug: text}
@@ -92,13 +103,15 @@ serve on 8080
92
103
  let w be ws.get(text(b["slug"]))
93
104
  when w == nothing
94
105
  give not_found("no such workspace")
95
- give {"ok": ws.stop_ws(w)}
106
+ give {"ok": ws.pause_ws(w)}
96
107
 
97
108
  route "POST /api/workspaces/remove" requires auth
98
109
  expect body {slug: text}
99
110
  let b be json of request
100
111
  try
101
- give {"ok": true, "result": ws.remove(text(b["slug"]))}
112
+ let res be ws.remove(text(b["slug"]))
113
+ ws.summary()
114
+ give {"ok": true, "result": res}
102
115
  recover err
103
116
  give fail(400, text(err))
104
117
 
@@ -113,6 +126,7 @@ serve on 8080
113
126
  give fail(400, "policy must be auto | always | off")
114
127
  set w["policy"] to text(b["policy"])
115
128
  ws.put(w)
129
+ ws.summary()
116
130
  give {"ok": true}
117
131
 
118
132
  -- diálogo nativo del SO (PC); nothing → la UI usa el explorador propio
package/lib/provider.syn CHANGED
@@ -36,7 +36,9 @@ export let PRESETS be {
36
36
  "openrouter": {"wire": "openai", "base_url": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-chat"},
37
37
  "ollama": {"wire": "openai", "base_url": "http://127.0.0.1:11434/v1", "model": "qwen2.5-coder"},
38
38
  "anthropic": {"wire": "anthropic", "base_url": "https://api.anthropic.com/v1", "model": "claude-sonnet-4-6"},
39
- "minimax": {"wire": "anthropic", "base_url": "https://api.minimax.io/anthropic/v1", "model": "MiniMax-M3"}
39
+ "minimax": {"wire": "anthropic", "base_url": "https://api.minimax.io/anthropic/v1", "model": "MiniMax-M3"},
40
+ -- Z.ai (Zhipu): API compatible con OpenAI. Coding Plan: LAMPSON_BASE_URL=https://api.z.ai/api/coding/paas/v4
41
+ "glm": {"wire": "openai", "base_url": "https://api.z.ai/api/paas/v4", "model": "glm-5.3"}
40
42
  }
41
43
 
42
44
  -- Keys: una por proveedor en .env como LAMPSON_API_KEY_<PROVIDER> (LAMPSON_API_KEY_DEEPSEEK, _ANTHROPIC…);
@@ -95,7 +97,7 @@ export task providers()
95
97
  -- Nombres de modelo: DeepSeek, OpenAI, Anthropic, Groq, Kimi, Grok y OpenRouter publican ids en minúsculas
96
98
  -- y la API rechaza otra capitalización ("deepseek-v4-pro" sí, "DeepSeek-V4-Pro" → 400 invalid_request_error,
97
99
  -- visto 2026-08-27 con un nombre tipeado en /setup). MiniMax ("MiniMax-M3") y Ollama (tags) se dejan tal cual.
98
- let LOWERCASE_MODELS be ["deepseek", "openai", "anthropic", "groq", "kimi", "grok", "openrouter"]
100
+ let LOWERCASE_MODELS be ["deepseek", "openai", "anthropic", "groq", "kimi", "grok", "openrouter", "glm"]
99
101
 
100
102
  export task normalize_model(name, model)
101
103
  let m be trim(text(model))
@@ -255,6 +257,9 @@ export task supports_vision(cfg)
255
257
  give contains(m, "vision") or starts_with(m, "grok-4")
256
258
  when p == "kimi"
257
259
  give contains(m, "vision") or contains(m, "vl")
260
+ -- GLM: los modelos con visión llevan sufijo v (glm-4.5v, glm-4.6v)
261
+ when p == "glm"
262
+ give contains(m, "vision") or ends_with(m, "v") or contains(m, "v-")
258
263
  give false
259
264
 
260
265
  -- Modelo sin visión: la imagen se reemplaza por una nota que el modelo puede explicarle al usuario
@@ -380,6 +380,10 @@ export task start_ws(w)
380
380
  require file.read("web.syn")
381
381
  require file.read("chat.syn")
382
382
  require file.read(".env")
383
+ -- encender levanta la pausa manual
384
+ when contains(w, "paused") and w["paused"] == true
385
+ set w["paused"] to false
386
+ put(w)
383
387
  let d be prepare(w)
384
388
  let h be home()
385
389
  let sep be when is_win() then "\\" otherwise "/"
@@ -396,13 +400,21 @@ export task health(w)
396
400
  require net("127.0.0.1")
397
401
  require time
398
402
  try
399
- let r be http_get("http://127.0.0.1:" + text(w["port"]) + "/w/" + w["slug"] + "/api/health", {"timeout": 2})
403
+ let r be http("GET", "http://127.0.0.1:" + text(w["port"]) + "/w/" + w["slug"] + "/api/health", {}, {}, "", 1.5)
400
404
  when r["ok"]
401
405
  give json_decode(body of r)
402
406
  give nothing
403
407
  recover err
404
408
  give nothing
405
409
 
410
+ -- salud de TODOS a la vez. En Windows con el firewall en modo stealth, conectar a un puerto cerrado de 127.0.0.1
411
+ -- no se rechaza al instante: el SYN queda sin respuesta ~2 s. Secuencial era 2 s × workspaces (6 s con tres
412
+ -- apagados); en paralelo es 2 s en total, y el resultado se comparte en el blackboard para que la lista sea inmediata.
413
+ export task health_all(list)
414
+ require net("127.0.0.1")
415
+ require time
416
+ give parallel_map(health, list, 8)
417
+
406
418
  -- esperar hasta que responda (arranque)
407
419
  export task wait_ready(w, secs)
408
420
  require net("127.0.0.1")
@@ -414,6 +426,25 @@ export task wait_ready(w, secs)
414
426
  sleep(0.4)
415
427
  give false
416
428
 
429
+ -- «■ apagar»: marca la pausa manual en el registro y para el proceso; el supervisor no lo vuelve a levantar
430
+ export task pause_ws(w)
431
+ require exec
432
+ require time
433
+ require env("LAMPSON_*")
434
+ require env("OS")
435
+ require net("127.0.0.1")
436
+ require file(".lampson")
437
+ require file(".lampson/*")
438
+ set w["paused"] to true
439
+ put(w)
440
+ stop_ws(w)
441
+ -- esperar a que deje de responder (matar tarda unos segundos) y dejar la caché al día
442
+ let deadline be now() + 6
443
+ while now() < deadline and health(w) != nothing
444
+ sleep(0.4)
445
+ summary()
446
+ give true
447
+
417
448
  export task stop_ws(w)
418
449
  require exec
419
450
  require time
@@ -490,6 +521,11 @@ export task should_live(w, hc)
490
521
  require file.read(".lampson/*")
491
522
  require env("LAMPSON_*")
492
523
  require time
524
+ -- apagado a mano («■ apagar» en el hub): gana sobre la política y sobre las tareas programadas hasta que
525
+ -- alguien lo encienda (botón ▶, o `lampson` en esa carpeta). Antes el tick lo revivía a los 15 s y el botón
526
+ -- parecía no hacer nada.
527
+ when contains(w, "paused") and w["paused"] == true
528
+ give false
493
529
  let pol be when contains(w, "policy") then w["policy"] otherwise "auto"
494
530
  when pol == "always"
495
531
  give true
@@ -528,8 +564,11 @@ export task tick()
528
564
  restart_hub_later()
529
565
  let started be []
530
566
  let stopped be []
531
- each w in all()
532
- let hc be health(w)
567
+ let list be all()
568
+ let hcs be health_all(list)
569
+ each e in enumerate(list)
570
+ let w be e["item"]
571
+ let hc be hcs[e["index"]]
533
572
  let want be should_live(w, hc)
534
573
  when want and hc == nothing
535
574
  start_ws(w)
@@ -537,6 +576,7 @@ export task tick()
537
576
  otherwise when not want and hc != nothing
538
577
  stop_ws(w)
539
578
  set stopped to append(stopped, w["slug"])
579
+ summary()
540
580
  give {"started": started, "stopped": stopped}
541
581
 
542
582
  -- resumen para la UI del hub
@@ -549,11 +589,35 @@ export task summary()
549
589
  require file(".lampson")
550
590
  require file(".lampson/*")
551
591
  let out be []
552
- each w in all()
553
- let hc be health(w)
554
- set out to append(out, {"slug": w["slug"], "name": w["name"], "path": w["path"], "port": w["port"], "policy": when contains(w, "policy") then w["policy"] otherwise "auto", "created": w["created"], "last_used": when hc != nothing and contains(hc, "last_used") and hc["last_used"] != nothing then hc["last_used"] otherwise w["last_used"], "alive": hc != nothing, "schedules_on": when hc != nothing and contains(hc, "schedules_on") then hc["schedules_on"] otherwise schedules_on_disk(w), "url": "/w/" + w["slug"]})
592
+ let list be all()
593
+ let hcs be health_all(list)
594
+ each e in enumerate(list)
595
+ let w be e["item"]
596
+ let hc be hcs[e["index"]]
597
+ set out to append(out, {"slug": w["slug"], "name": w["name"], "path": w["path"], "port": w["port"], "policy": when contains(w, "policy") then w["policy"] otherwise "auto", "created": w["created"], "last_used": when hc != nothing and contains(hc, "last_used") and hc["last_used"] != nothing then hc["last_used"] otherwise w["last_used"], "alive": hc != nothing, "paused": contains(w, "paused") and w["paused"] == true, "schedules_on": when hc != nothing and contains(hc, "schedules_on") then hc["schedules_on"] otherwise schedules_on_disk(w), "url": "/w/" + w["slug"]})
598
+ share out as "hub:summary"
599
+ share now() as "hub:summary_at"
555
600
  give out
556
601
 
602
+ -- la lista que ve la UI: la última calculada (tick cada 15 s, y cada start/stop la refresca) si tiene menos de
603
+ -- max_age segundos; si no, se calcula ahora. GET /api/workspaces?fresh=1 fuerza el cálculo.
604
+ export task summary_cached(max_age)
605
+ require exec
606
+ require time
607
+ require env("LAMPSON_*")
608
+ require env("OS")
609
+ require net("127.0.0.1")
610
+ require file(".lampson")
611
+ require file(".lampson/*")
612
+ try
613
+ observe "hub:summary_at" as at
614
+ observe "hub:summary" as cached
615
+ when at != nothing and cached != nothing and now() - at < max_age
616
+ give cached
617
+ recover err
618
+ give summary()
619
+ give summary()
620
+
557
621
  -- ---------- explorador de carpetas ----------
558
622
  -- diálogo nativo del SO (PC con escritorio). Devuelve la ruta elegida, "" si canceló, nothing si no hay diálogo.
559
623
  export task pick_folder()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lampson",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "A coding agent written in Synsema: terminal + web, tools confined to the mounted project, lamps (your own tool plugins), LSP, MCP, sub-agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,3 +54,10 @@
54
54
  .empty .try { cursor:pointer; color:var(--accent); }
55
55
  .empty .try:hover { text-decoration:underline; text-underline-offset:.18em; }
56
56
  @media (max-width:1400px) { .empty .grid { grid-template-columns:repeat(2, minmax(0,1fr)); } }
57
+
58
+ /* workspace apagado (events.js): un aviso que se vea, no una línea más del log */
59
+ .msg.wsoff{border:1px solid var(--rubric);border-left-width:4px;background:var(--paper-2);padding:12px 16px;border-radius:var(--r);font:400 14.5px/1.55 var(--serif);color:var(--ink);opacity:1}
60
+ .msg.wsoff a{font-family:var(--mono);font-size:13px;margin-left:.2em;color:var(--accent);text-decoration:none}
61
+ .msg.wsoff a:hover{text-decoration:underline}
62
+ .msg.wsoff .wsst{color:var(--ink-3);font-family:var(--mono);font-size:12px;margin-left:.6em}
63
+ .msg.wson{border-left:4px solid var(--ok, #8fc28f);padding-left:12px}
@@ -6,6 +6,10 @@ body.hub #stage { display:flex; align-items:center; padding:20px 40px; }
6
6
  .card.ws { display:block; text-decoration:none; color:inherit; cursor:pointer; transition:border-color .12s; }
7
7
  .card.ws:hover { border-color:var(--ink-3); }
8
8
  .card.ws.on { border-color:var(--accent); }
9
+ /* estado a un vistazo: verde vivo · rojo apagado · ámbar mientras arranca */
10
+ .card.ws.on h3 { color:var(--ok); }
11
+ .card.ws.off h3 { color:var(--rubric); }
12
+ .card.ws.busy h3 { color:var(--amber); }
9
13
  .card.ws.new { border-style:dashed; }
10
14
  .card.ws .nm { font:600 16px/1.3 var(--serif); color:var(--ink); margin:0 0 4px; }
11
15
  .card.ws .pth { font:400 11px/1.4 var(--mono); color:var(--ink-3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin:0 0 6px; }
@@ -58,6 +58,10 @@
58
58
  .panel .li:hover { background:var(--paper-2); }
59
59
  .panel .li.sel { background:var(--paper-2); box-shadow:inset 2px 0 0 var(--accent); }
60
60
  .panel .li .dot { color:var(--ink-3); margin-top:1px; }
61
+ .panel .li .dot.on { color:var(--ok); }
62
+ .panel .li .dot.off { color:var(--rubric); }
63
+ .panel .st.on { color:var(--ok); font-weight:600; }
64
+ .panel .st.off { color:var(--rubric); font-weight:600; }
61
65
  .panel .li.on .dot { color:var(--accent); }
62
66
  .panel .li.bad .dot { color:var(--rubric); }
63
67
  .panel .li.new .nm { color:var(--accent); font-weight:400; }
@@ -19,7 +19,7 @@
19
19
  --ink:#e9e8df; --ink-2:#9b9c90; --ink-3:#848678;
20
20
  --rule:#2b2c27; --rule-2:#3c3d36;
21
21
  --accent:#8fb4dc; --accent-bg:#1b2836;
22
- --rubric:#d98670; --str:#a8bd84; --amber:#d9b26f;
22
+ --rubric:#d98670; --str:#a8bd84; --amber:#d9b26f; --ok:#8fc28f;
23
23
  --sel:#33465b; --term-blue:#4f86d1;
24
24
  --serif:"Plex Serif",Georgia,"Times New Roman",serif;
25
25
  --mono:"Plex Mono",ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;
@@ -30,7 +30,7 @@
30
30
  --ink:#1a1b18; --ink-2:#56584f; --ink-3:#6a6c61;
31
31
  --rule:#e4e3da; --rule-2:#cfcec2;
32
32
  --accent:#21578c; --accent-bg:#e9eef4;
33
- --rubric:#99372a; --str:#4a6b3f; --amber:#8a6a1f;
33
+ --rubric:#99372a; --str:#4a6b3f; --amber:#8a6a1f; --ok:#3d7a3d;
34
34
  --sel:#cfe0f0; --term-blue:#21578c;
35
35
  }
36
36
 
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="10" fill="#131412"/>
3
+ <circle cx="32" cy="27" r="11" fill="#d9c26a"/>
4
+ <g stroke="#d9c26a" stroke-width="3" stroke-linecap="round">
5
+ <line x1="32" y1="6" x2="32" y2="10"/>
6
+ <line x1="14" y1="13" x2="17" y2="16"/>
7
+ <line x1="50" y1="13" x2="47" y2="16"/>
8
+ <line x1="9" y1="27" x2="13" y2="27"/>
9
+ <line x1="55" y1="27" x2="51" y2="27"/>
10
+ </g>
11
+ <rect x="26" y="40" width="12" height="7" rx="1.5" fill="#e9e8df"/>
12
+ <rect x="27.5" y="49" width="9" height="3" rx="1.5" fill="#9b9c90"/>
13
+ </svg>
package/public/hub.html CHANGED
@@ -2,6 +2,7 @@
2
2
  <html lang="es">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
5
6
  <title>Lampson · workspaces</title>
6
7
  <meta name="viewport" content="width=device-width, initial-scale=1">
7
8
  <link rel="stylesheet" href="/css/tokens.css">
package/public/index.html CHANGED
@@ -2,6 +2,7 @@
2
2
  <html lang="es">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
5
6
  <title>Lampson</title>
6
7
  <meta name="viewport" content="width=device-width, initial-scale=1">
7
8
  <!-- estilos: tokens (fuentes, paleta, base) → layout (grid, cabecera, paneles, chat, composer) → sidebar → chat → panel (el modal) -->