lampson 0.2.3 → 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/lib/term.syn ADDED
@@ -0,0 +1,162 @@
1
+ -- lib/term.syn — terminales del navegador que SOBREVIVEN a un F5.
2
+ --
3
+ -- DISEÑO (mismo patrón que lib/tools/proc.syn): un proc_spawn hecho dentro de un handler muere cuando
4
+ -- termina el request — y un socket es un request. Por eso el pty vive dentro de un AGENTE supervisor
5
+ -- (lo único con ciclo de vida propio bajo `serve`) y el socket es sólo un PUENTE por el bus:
6
+ --
7
+ -- navegador → socket → bus "term.ctl.<id>" → agente → proc_send / proc_resize / kill / replay
8
+ -- agente → bus "term.out.<id>" → socket → navegador ("o" salida · "exit" código · "replay")
9
+ --
10
+ -- Cerrar la pestaña del navegador ya no mata la shell: el socket se va, el agente sigue. Al volver, la UI
11
+ -- pide GET /api/terms, ve las que están vivas y se reengancha con ?id=<id>; el agente le reenvía los
12
+ -- últimos chunks del pty (replay en memoria) para que la pantalla no aparezca en blanco.
13
+ --
14
+ -- Recolección: cada socket enganchado manda un ping; sin NINGÚN socket por IDLE_CLOSE segundos, el
15
+ -- supervisor cierra la shell. Y todo muere con el proceso lampson, como siempre (sin huérfanos).
16
+ --
17
+ -- OJO (miga del runtime): un agente NO ve las constantes ni las tasks del módulo, sólo sus parámetros y
18
+ -- las builtins — por eso KEEP_CHUNKS / IDLE_CLOSE viajan en el `spawn`.
19
+
20
+ export let MAX_TERMS be 4
21
+ let KEEP_CHUNKS be 240 -- replay: últimos N chunks del pty (bastante más que una pantalla)
22
+ let IDLE_CLOSE be 1800 -- 30 min sin ningún socket enganchado → se recoge la shell
23
+ let IDS_KEY be "term:ids"
24
+
25
+ -- shell del terminal web: [cmd, args]. Windows: pwsh si está, si no PowerShell 5; unix: bash de login.
26
+ -- En pwsh, los directorios de `ls` salen sin fondo azul (el default de $PSStyle se lee mal en oscuro).
27
+ export task shell()
28
+ require exec
29
+ require env("OS")
30
+ when env("OS", "") == "Windows_NT"
31
+ try
32
+ run("pwsh", ["-NoLogo", "-Command", "exit"], 10)
33
+ give ["pwsh", ["-NoLogo", "-NoExit", "-Command", "$PSStyle.FileInfo.Directory = $PSStyle.Foreground.BrightBlue"]]
34
+ recover err
35
+ give ["powershell", ["-NoLogo"]]
36
+ give ["bash", ["-l"]]
37
+
38
+ -- cwd del terminal: la ruta REAL del proyecto (LAMPSON_WORKSPACE), no la junction ./workspace —
39
+ -- así el prompt muestra dónde estás de verdad y coincide con el header.
40
+ export task cwd()
41
+ require env("LAMPSON_*")
42
+ give env("LAMPSON_WORKSPACE", "workspace")
43
+
44
+ -- ---------- supervisor: un agente por terminal ----------
45
+ agent TermSup
46
+ require exec
47
+ require time
48
+ require env("LAMPSON_*")
49
+ require env("OS")
50
+ require file("workspace")
51
+ require file("workspace/*")
52
+ let key be "term:" + id
53
+ let p be nothing
54
+ try
55
+ set p to proc_spawn(sh, args, {"cwd": dir, "pty": true, "cols": cols, "rows": rows})
56
+ recover err
57
+ share {"status": "exited", "pid": 0, "shell": sh, "cwd": dir, "started": nothing, "error": text(err)} as key
58
+ when p != nothing
59
+ share {"status": "running", "pid": proc_stats(p)["pid"], "shell": sh, "cwd": dir, "started": now(), "error": ""} as key
60
+ let sub be bus_subscribe("term.ctl." + id)
61
+ let chunks be []
62
+ let seen be now()
63
+ let open be true
64
+ while open
65
+ let ev be select({"p": p, "c": sub}, 20)
66
+ when ev == nothing
67
+ when proc_status(p) != "running"
68
+ set open to false
69
+ otherwise when now() - seen > idle
70
+ proc_close(p)
71
+ set open to false
72
+ otherwise when ev["name"] == "c"
73
+ let m be ev["data"]
74
+ set seen to now()
75
+ when m["k"] == "in"
76
+ proc_send(p, m["d"])
77
+ otherwise when m["k"] == "resize"
78
+ proc_resize(p, floor(m["cols"]), floor(m["rows"]))
79
+ otherwise when m["k"] == "replay"
80
+ -- sólo para el socket que lo pidió (`to`): con dos pestañas abiertas, la otra no repite
81
+ bus_publish("term.out." + id, {"k": "replay", "to": m["to"], "d": join(chunks, "")})
82
+ otherwise when m["k"] == "kill"
83
+ proc_close(p)
84
+ set open to false
85
+ otherwise when ev["type"] == "exit"
86
+ bus_publish("term.out." + id, {"k": "exit", "to": "", "code": ev["data"]["exit_code"]})
87
+ set open to false
88
+ otherwise
89
+ let d be when is_bytes(ev["data"]) then decode(ev["data"], "utf8_lossy") otherwise ev["data"]
90
+ set chunks to append(chunks, d)
91
+ when length(chunks) > keep
92
+ set chunks to slice(chunks, length(chunks) - keep, length(chunks))
93
+ bus_publish("term.out." + id, {"k": "o", "to": "", "d": d})
94
+ proc_close(p)
95
+ bus_unsubscribe(sub)
96
+ share {"status": "exited", "pid": 0, "shell": sh, "cwd": dir, "started": nothing, "error": ""} as key
97
+
98
+ -- ---------- estado (blackboard) ----------
99
+
100
+ export task state(id)
101
+ observe "term:" + id as st
102
+ give st
103
+
104
+ export task alive(id)
105
+ let st be state(id)
106
+ when st == nothing
107
+ give false
108
+ give st["status"] == "running"
109
+
110
+ task ids()
111
+ observe IDS_KEY as v
112
+ when v == nothing
113
+ give []
114
+ give v
115
+
116
+ -- las terminales vivas, para que la UI reconstruya sus pestañas después de un F5
117
+ export task list()
118
+ let out be []
119
+ each id in ids()
120
+ let st be state(id)
121
+ when st != nothing and st["status"] == "running"
122
+ set out to append(out, {"id": id, "pid": st["pid"], "shell": st["shell"], "cwd": st["cwd"], "started": st["started"]})
123
+ give out
124
+
125
+ export task count()
126
+ give length(list())
127
+
128
+ task new_id()
129
+ require time
130
+ require random
131
+ give "t" + text(floor(now() * 1000)) + "-" + text(floor(random() * 10000))
132
+
133
+ -- arranca una terminal nueva y devuelve su id (nothing si ya se llegó al máximo)
134
+ export task start(cols, rows)
135
+ require exec
136
+ require time
137
+ require random
138
+ require env("LAMPSON_*")
139
+ require env("OS")
140
+ require file("workspace")
141
+ require file("workspace/*")
142
+ when count() >= MAX_TERMS
143
+ give nothing
144
+ let sh be shell()
145
+ let dir be cwd()
146
+ let id be new_id()
147
+ -- "starting" ANTES del spawn: la clave puede tener el estado viejo de un id anterior y list() miente
148
+ share {"status": "starting", "pid": 0, "shell": sh[0], "cwd": dir, "started": now(), "error": ""} as "term:" + id
149
+ share append(ids(), id) as IDS_KEY
150
+ spawn TermSup with id = id, sh = sh[0], args = sh[1], dir = dir, cols = cols, rows = rows, keep = KEEP_CHUNKS, idle = IDLE_CLOSE
151
+ let waited be 0
152
+ while waited < 30 and state(id)["status"] == "starting"
153
+ sleep(0.1)
154
+ set waited to waited + 1
155
+ give id
156
+
157
+ -- entrada del navegador hacia la shell (el socket es sólo el cartero)
158
+ export task ctl(id, msg)
159
+ give bus_publish("term.ctl." + id, msg)
160
+
161
+ export task kill(id)
162
+ give bus_publish("term.ctl." + id, {"k": "kill"})
@@ -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.3",
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; }
@@ -14,6 +14,9 @@ header { grid-area:top; border-bottom:1px solid var(--rule); background:var(--pa
14
14
  .hbtn:hover { color:var(--ink); border-color:var(--ink-3); }
15
15
  .hbtn.upd { color:var(--amber); border-color:var(--amber); }
16
16
  .hbtn.upd:hover { color:var(--ink); border-color:var(--ink); }
17
+ /* terminal minimizada: la shell sigue viva y el botón la trae de vuelta */
18
+ .hbtn.live { color:var(--str); border-color:var(--str); }
19
+ .hbtn.live:hover { color:var(--ink); border-color:var(--ink); }
17
20
  .theme-toggle { display:flex; align-items:center; justify-content:center; background:none; border:0; color:var(--ink-3); cursor:pointer; padding:6px; border-radius:var(--r); line-height:0; }
18
21
  .theme-toggle:hover { color:var(--ink); background:var(--paper-2); }
19
22
  header select { font:400 11.5px/1 var(--mono); color:var(--ink-2); background:var(--paper); border:1px solid var(--rule); border-radius:var(--r); padding:5px 6px; cursor:pointer; }
@@ -54,14 +57,41 @@ main { grid-area:main; display:grid; grid-template-rows:1fr auto; min-height:0;
54
57
  #viewer pre { margin:0; padding:14px 18px; font:400 12.5px/1.62 var(--mono); tab-size:4; }
55
58
  #viewer pre .ln { display:inline-block; width:4ch; margin-right:16px; color:var(--ink-3); text-align:right; user-select:none; }
56
59
  #viewer pre.log { color:var(--ink-2); white-space:pre-wrap; }
57
- /* terminal real (xterm.js sobre un pty del servidor) — ocupa la zona central como un archivo abierto */
58
- #termpane { display:none; height:100%; flex-direction:column; }
59
- #termpane .vh { position:static; }
60
+ /* terminal real (xterm.js sobre un pty del servidor) — ocupa la zona central como un archivo abierto.
61
+ Varias terminales conviven como pestañas: una instancia de xterm por .xt, sólo la activa a la vista. */
62
+ #termpane { display:none; height:100%; flex-direction:column; overflow:hidden; }
63
+ #termpane .vh { position:static; flex:none; }
60
64
  #termpane .vh .st { width:7px; height:7px; border-radius:50%; background:var(--ink-3); }
61
65
  #termpane.live .vh .st { background:var(--str); }
62
- #xterm { flex:1; min-height:0; padding:10px 14px; background:var(--paper); }
63
- #xterm .xterm { height:100%; }
64
- #xterm .xterm-viewport { background:transparent !important; }
66
+ /* cabecera del panel en UNA línea: pestañas y botones no se encogen y la meta se recorta */
67
+ #termpane .vh b, #termpane .vh .ttabs, #termpane .vh .hbtn, #termpane .vh .wbtn { flex:none; white-space:nowrap; }
68
+ #termpane .vh #tmeta { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
69
+ /* pestañas: en cuál estás se ve de lejos (la activa lleva el acento del tema, clara y oscura) */
70
+ .vh .ttabs { display:flex; align-items:center; gap:5px; }
71
+ .vh .ttab { cursor:pointer; min-width:20px; text-align:center; padding:2px 7px; border-radius:2px; border:1px solid var(--rule-2); color:var(--ink-3); background:transparent; }
72
+ .vh .ttab:hover { color:var(--ink); border-color:var(--ink-3); }
73
+ .vh .ttab.on { color:var(--accent); border-color:var(--accent); background:var(--accent-bg); font-weight:600; }
74
+ .vh .ttab.dead { text-decoration:line-through; opacity:.75; }
75
+ /* «+ nueva terminal»: mismo botón que los del encabezado, en el acento para que se vea */
76
+ .vh .hbtn.sm { padding:4px 9px; font-size:9.5px; letter-spacing:.08em; }
77
+ .vh .hbtn.new { color:var(--accent); border-color:var(--accent); }
78
+ .vh .hbtn.new:hover { background:var(--accent-bg); color:var(--accent); border-color:var(--accent); }
79
+ .vh .hbtn.new:disabled { color:var(--ink-3); border-color:var(--rule-2); background:transparent; }
80
+ /* minimizar · pantalla completa · cerrar, a la derecha como en cualquier ventana */
81
+ .vh .wbtn { cursor:pointer; width:22px; height:20px; display:inline-flex; align-items:center; justify-content:center; border-radius:2px; color:var(--ink-3); font-size:12px; line-height:1; }
82
+ .vh .wbtn:hover { color:var(--ink); background:var(--paper); }
83
+ .vh .wbtn.cl:hover { color:var(--rubric); }
84
+ /* El aire alrededor de cada terminal va como MARGEN, nunca como padding: el FitAddon mide el alto/ancho
85
+ del contenedor con getComputedStyle (que con box-sizing:border-box incluye su padding) y sólo
86
+ descuenta el padding del .xterm, que es 0. Con padding calculaba una fila de más, el .xterm-screen
87
+ desbordaba el hueco, el #stage sacaba su barra de scroll, eso robaba 7px de ancho, el
88
+ ResizeObserver volvía a ajustar y cada rebote hacía clear()+resize() del terminal y un resize del
89
+ pty: el parpadeo. Con margen el fit mide exactamente el hueco; overflow:hidden es el cinturón. */
90
+ #termpane.max { position:fixed; inset:0; z-index:60; height:auto; background:var(--paper); }
91
+ #xterms { flex:1; min-height:0; display:flex; }
92
+ #xterms .xt { flex:1; min-height:0; margin:10px 14px; background:var(--paper); overflow:hidden; }
93
+ #xterms .xt .xterm { height:100%; }
94
+ #xterms .xt .xterm-viewport { background:transparent !important; }
65
95
 
66
96
  /* ---- composer ---- */
67
97
  form { padding:12px 40px 12px; border-top:1px solid var(--rule); background:var(--paper); box-sizing:border-box; width:100%; max-width:100%; min-width:0; }
@@ -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) -->
@@ -21,7 +22,7 @@
21
22
  <span class="brand">lampson<span class="proj" id="proj">…</span></span>
22
23
  <span class="spacer"></span>
23
24
  <button class="hbtn upd" id="update" style="display:none"></button>
24
- <button class="hbtn" id="term" title="abrir un terminal real (shell en el workspace)">&gt;_ terminal</button>
25
+ <button class="hbtn" id="term" title="terminal real (shell en el workspace): lo abre, lo trae de vuelta si está minimizado y lo esconde si está a la vista — verde = shell abierta">&gt;_ terminal</button>
25
26
  <button class="hbtn" id="new" title="empezar una conversación nueva">+ sesión</button>
26
27
  <button class="theme-toggle" id="cfgBtn" type="button" title="configuración: zona horaria, aprobaciones a distancia, proveedor"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button>
27
28
  <button class="theme-toggle" id="theme" type="button" title="cambiar tema"></button>
@@ -92,7 +93,7 @@
92
93
  <div id="stage">
93
94
  <div id="viewer"><div class="vh"><b id="vpath"></b><span id="vmeta"></span><span class="spacer"></span><span class="x" id="vclose">✕ cerrar</span></div><pre id="vbody"></pre></div>
94
95
  <div id="log"></div>
95
- <div id="termpane"><div class="vh"><span class="st"></span><b>terminal</b><span id="tmeta"></span><span class="spacer"></span><span class="x" id="tclose">✕ cerrar</span></div><div id="xterm"></div></div>
96
+ <div id="termpane"><div class="vh"><span class="st"></span><b>terminal</b><span class="ttabs" id="ttabs"></span><button class="hbtn sm new" id="tnew" title="abrir otra terminal: una shell nueva en el workspace">+ nueva terminal</button><span id="tmeta"></span><span class="spacer"></span><span class="wbtn" id="tmin" title="minimizar: esconde el panel y la shell sigue viva">&#8212;</span><span class="wbtn" id="tmax" title="pantalla completa">&#9633;</span><span class="wbtn cl" id="tclose" title="cerrar esta terminal y matar su shell">&#10005;</span></div><div id="xterms"></div></div>
96
97
  </div>
97
98
  <div>
98
99
  <form id="f">
@@ -2,10 +2,32 @@
2
2
  // Los supervisores de procesos publican cada línea y cambio de estado; los subagentes, inicio/fin/pasos; las
3
3
  // tareas programadas y aprobaciones, sus cambios. La UI reacciona a eso (con un pequeño debounce) en vez de
4
4
  // recargar por timer; los timers quedan como respaldo lento por si el stream se cae (reconexión con backoff).
5
- let events = null, evBackoff = 1000;
5
+ let events = null, evBackoff = 1000, evOffline = false, evEverOpen = false;
6
+ // El hub responde aunque el workspace esté apagado: por ahí se pregunta "¿sigue vivo?" sin llenar la consola de 502.
7
+ const EV_HUB = (location.port && location.port !== '8080') ? location.protocol + '//' + location.hostname + ':8080' : '';
8
+ async function wsAlive() {
9
+ if (!WS_SLUG) return true;
10
+ try { const r = await fetch(EV_HUB + '/api/workspaces'); const d = await r.json(); const w = (d.workspaces || []).find(x => x.slug === WS_SLUG); return w ? { alive: w.alive, paused: w.paused } : { alive: true }; } catch (e) { return { alive: true }; }
11
+ }
12
+ function goOffline(state) {
13
+ if (evOffline) return;
14
+ evOffline = true;
15
+ // apagado a mano (desde el hub o desde este mismo panel): volver al inicio; el banner queda para caídas
16
+ if (state.paused) { location.href = EV_HUB + '/'; return; }
17
+ document.title = '○ ' + document.title.replace(/^○ /, '');
18
+ const why = state.paused ? 'Lo apagaste desde el hub.' : 'El proceso no responde.';
19
+ const m = add('meta wsoff', `○ <b>Este workspace está apagado.</b> ${why} <a href="#" class="wson">▶ encender</a> · <a href="${EV_HUB}/">ir al hub</a> <span class="wsst"></span>`);
20
+ m.querySelector('.wson').onclick = async (e) => { e.preventDefault(); m.querySelector('.wsst').textContent = 'encendiendo…'; try { await fetch(EV_HUB + '/api/workspaces/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug: WS_SLUG }) }); } catch (err) {} };
21
+ const poll = async () => {
22
+ const s = await wsAlive();
23
+ if (s.alive) { evOffline = false; document.title = document.title.replace(/^○ /, ''); add('meta wson', '● workspace encendido · reconectado'); evBackoff = 1000; connectEvents(); if (typeof loadProcs === 'function') loadProcs(); if (typeof loadSessions === 'function') loadSessions(); }
24
+ else setTimeout(poll, 3000);
25
+ };
26
+ setTimeout(poll, 3000);
27
+ }
6
28
  function connectEvents() {
7
29
  try { events = new EventSource(BASE + '/api/events'); } catch (e) { events = null; return; }
8
- events.onopen = () => { evBackoff = 1000; };
30
+ events.onopen = () => { evBackoff = 1000; evEverOpen = true; };
9
31
  events.addEventListener('event', e => {
10
32
  let ev; try { ev = JSON.parse(e.data); } catch { return; }
11
33
  const t = ev.topic || '';
@@ -26,6 +48,12 @@ function connectEvents() {
26
48
  if (id && agentOpen === id && agentPaint) debounce('agentview', agentPaint, 250);
27
49
  }
28
50
  });
29
- events.onerror = () => { events.close(); events = null; setTimeout(connectEvents, evBackoff); evBackoff = Math.min(evBackoff * 2, 30000); };
51
+ events.onerror = async () => {
52
+ events.close(); events = null;
53
+ // ¿se cayó el stream o se apagó el workspace? Si el hub dice que no está vivo, no insistimos contra el proxy.
54
+ const s = await wsAlive();
55
+ if (!s.alive) { goOffline(s); return; }
56
+ setTimeout(connectEvents, evBackoff); evBackoff = Math.min(evBackoff * 2, 30000);
57
+ };
30
58
  }
31
59
  connectEvents();
package/public/js/hub.js CHANGED
@@ -24,8 +24,16 @@ async function paintGrid() {
24
24
  const grid = $('#wsGrid'); let r; try { r = await (await fetch(BASE + '/api/workspaces')).json(); } catch (e) { grid.innerHTML = '<div class="card"><p>el hub no responde</p></div>'; return; }
25
25
  grid.innerHTML = '';
26
26
  for (const w of (r.workspaces || [])) {
27
- const c = document.createElement('a'); c.className = 'card ws' + (w.alive ? ' on' : ''); c.href = w.url;
27
+ const c = document.createElement('a'); c.className = 'card ws' + (w.alive ? ' on' : ' off'); c.href = w.url;
28
28
  c.innerHTML = `<h3>${w.alive ? '● vivo' : '○ apagado'}${w.schedules_on ? ' · ⏰ ' + w.schedules_on : ''}</h3><p class="nm">${esc(w.name)}</p><p class="pth">${esc(w.path)}</p><p class="meta">último uso ${esc(fmtWhen(w.last_used))}</p>`;
29
+ // entrar = usarlo: si está apagado se enciende acá mismo (con aviso en la tarjeta) y después se abre. La ruta
30
+ // /w/<slug> del hub también lo enciende sola, así que un link directo funciona igual; esto solo da feedback.
31
+ if (!w.alive) c.onclick = async (e) => {
32
+ e.preventDefault(); if (c.dataset.busy) return; c.dataset.busy = '1';
33
+ const h = c.querySelector('h3'); h.textContent = '⟳ encendiendo…'; c.classList.add('busy');
34
+ try { const r = await api('/api/workspaces/start', { slug: w.slug }); if (!r.ok || !r.data.ok) { h.textContent = '○ no arrancó · mirá .lampson/ws/' + w.slug + '/.lampson/web.log'; c.classList.remove('busy'); delete c.dataset.busy; return; } } catch (err) {}
35
+ location.href = w.url;
36
+ };
29
37
  grid.appendChild(c);
30
38
  }
31
39
  const add = document.createElement('a'); add.className = 'card ws new'; add.href = '#'; add.innerHTML = '<h3>+</h3><p class="nm">nuevo workspace</p><p class="pth">elegí una carpeta de tu disco</p>';
@@ -71,6 +71,8 @@ const Panel = (() => {
71
71
  if (!cur || cur.layout !== 'browse') return;
72
72
  const spec = cur, q = query(), my = ++seq;
73
73
  const keep = pendingKey != null ? pendingKey : (rows[sel] && spec.browse.key ? spec.browse.key(rows[sel]) : null); pendingKey = null;
74
+ // mientras carga, decirlo (la lista de workspaces puede tardar si el hub tiene que sondear procesos)
75
+ if (!rows.length) { const it = box().querySelector('.bitems'); if (it) it.innerHTML = '<div class="bempty">cargando…</div>'; }
74
76
  let list; try { list = await spec.browse.load(q); } catch (e) { list = []; }
75
77
  if (cur !== spec || my !== seq) return;
76
78
  rows = list || [];