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/.env.example +1 -1
- package/README.md +58 -437
- package/chat.syn +177 -70
- package/hub.tpl.syn +18 -4
- package/lib/line.syn +17 -6
- package/lib/provider.syn +7 -2
- package/lib/term.syn +162 -0
- package/lib/workspaces.syn +70 -6
- package/package.json +1 -1
- package/public/css/chat.css +7 -0
- package/public/css/hub.css +4 -0
- package/public/css/layout.css +36 -6
- package/public/css/panel.css +4 -0
- package/public/css/tokens.css +2 -2
- package/public/favicon.svg +13 -0
- package/public/hub.html +1 -0
- package/public/index.html +3 -2
- package/public/js/events.js +31 -3
- package/public/js/hub.js +9 -1
- package/public/js/panel.js +2 -0
- package/public/js/terminal.js +153 -18
- package/public/js/theme.js +1 -1
- package/public/js/workspaces.js +11 -7
- package/skills/synsema/SKILL.md +8 -1
- package/web.syn +54 -46
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"})
|
package/lib/workspaces.syn
CHANGED
|
@@ -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
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
package/public/css/chat.css
CHANGED
|
@@ -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}
|
package/public/css/hub.css
CHANGED
|
@@ -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; }
|
package/public/css/layout.css
CHANGED
|
@@ -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
|
-
|
|
59
|
-
#termpane
|
|
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
|
-
|
|
63
|
-
#
|
|
64
|
-
#
|
|
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; }
|
package/public/css/panel.css
CHANGED
|
@@ -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; }
|
package/public/css/tokens.css
CHANGED
|
@@ -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="
|
|
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">>_ 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="
|
|
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">—</span><span class="wbtn" id="tmax" title="pantalla completa">□</span><span class="wbtn cl" id="tclose" title="cerrar esta terminal y matar su shell">✕</span></div><div id="xterms"></div></div>
|
|
96
97
|
</div>
|
|
97
98
|
<div>
|
|
98
99
|
<form id="f">
|
package/public/js/events.js
CHANGED
|
@@ -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 = () => {
|
|
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>';
|
package/public/js/panel.js
CHANGED
|
@@ -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 || [];
|