lampson 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,8 +12,8 @@ language itself — and every step is visible, in the terminal or in a web UI.
12
12
  Ollama, Anthropic, MiniMax… one `.env` line to switch.
13
13
  - **Least-privilege by construction**: each tool declares its capabilities; the runtime enforces
14
14
  them. File tools can only touch the mounted workspace (absolute paths, `..`, sibling dirs → denied).
15
- - **Permissions you control**: `ask` (approve dangerous commands, in the terminal or with a button in
16
- the web UI), `yolo`, `strict`. Destructive system commands are always blocked.
15
+ - **Permissions you control**: `ask` (approve dangerous commands, with a ↑↓ menu in the terminal or a
16
+ button in the web UI), `yolo`, `strict`. Destructive system commands are always blocked.
17
17
  - **Agents**: `build` (edits), `plan` (read-only), `review` (runs tests, never edits), `explore`,
18
18
  `worker` (scoped implementation). **Sub-agents**: `delegate` runs several of them *in parallel*
19
19
  (real threads) with their own context and a restricted toolset, or in the *background* — the report
@@ -45,8 +45,14 @@ language itself — and every step is visible, in the terminal or in a web UI.
45
45
  and edit them (web panel, `/memory`).
46
46
  - **`!command`**: run something yourself from the chat inside a real pseudo-terminal (prompts, passwords
47
47
  and REPLs work); the output lands in the agent's context.
48
- - **Terminal in the browser**: the web UI opens a real shell (pty, cwd = your project) in the center pane
49
- — `>_ terminal` in the header. Synsema 0.6.8+.
48
+ - **Terminals in the browser**: the web UI opens a real shell (pty, cwd = your project) in the center pane
49
+ — `>_ terminal` in the header, `+ nueva terminal` for another one (up to 4 tabs, one shell each), plus the
50
+ usual window controls: minimize (the pane hides, every shell keeps running, the header button turns green),
51
+ full screen, close (kills the active shell). **A page reload doesn't kill them**: each shell lives in a
52
+ supervisor agent (`lib/term.syn`), not in the socket, so the page comes back, asks `GET /api/terms` and
53
+ re-attaches by id with a replay of the last output — same pid, same session, same `cd`. With no socket
54
+ attached for 30 min the shell is collected, and everything dies with lampson. A shell that exits keeps its
55
+ output on screen — read the last logs, then `+` for a fresh one. Synsema 0.6.8+.
50
56
  - **Local only**: the web API (chat with tools, terminal, process control) answers loopback clients
51
57
  only; anything else gets 401. To let a script in from elsewhere, set `LAMPSON_WEB_TOKEN` in `.env`
52
58
  and send `Authorization: Bearer <token>`.
@@ -260,8 +266,10 @@ cli.syn what launchers ask Synsema: ensure/list/hub-start/hub-stop/
260
266
  hub.tpl.syn template of the hub (:8080): workspaces screen + API, supervisor tick, one proxy route per workspace
261
267
  lib/workspaces.syn registry (.lampson/workspaces.json), ws/<slug> dirs + junctions, detached processes, health,
262
268
  life policy, folder picker (native dialog / server-side browser)
263
- chat.syn terminal REPL (colors, approvals via Synsema's native `approve`)
269
+ chat.syn terminal REPL (colors, ↑↓ menus for approvals / setup / confirmations, `approve` without a TTY)
264
270
  web.syn one process per workspace: /w/:slug/api/… → SSE chat, sessions, tree, processes, schedules…
271
+ lib/term.syn browser terminals: one supervisor agent per pty (survives the socket → survives F5),
272
+ bus bridge term.ctl.<id> / term.out.<id>, replay buffer, idle collection
265
273
  public/ web UI (no build step, no dependencies; classic scripts served by `static`)
266
274
  index.html markup only: header, the two side panels, the chat; loads css/ and js/ in order
267
275
  css/ tokens (fonts, palette, base) · layout (grid, header, panels, chat, composer) · sidebar · chat · panel
package/chat.syn CHANGED
@@ -334,7 +334,7 @@ let COMMANDS be [
334
334
  ["/setup", "", "elegir proveedor, modelo y pegar la API key (queda en lampson/.lampson/config.json, local)"],
335
335
  ["/paste", "", "adjuntar la imagen del portapapeles al próximo mensaje (copiá una captura y escribí /paste)"],
336
336
  ["/image", "<ruta>", "adjuntar una imagen (png/jpg/gif/webp) al próximo mensaje; /image sin ruta muestra o quita las pendientes"],
337
- ["/delete", "<id>", "borrar una sesión guardada (ver /sessions)"],
337
+ ["/delete", "<id>", "borrar una sesión guardada, con confirmación (ver /sessions)"],
338
338
  ["/config", "", "ver la configuración vigente (proveedor, modelo, límites, workspace)"],
339
339
  ["/files", "", "árbol completo del workspace"],
340
340
  ["/procs", "", "procesos que el agente dejó corriendo (servidores) y su estado"],
@@ -349,7 +349,7 @@ let COMMANDS be [
349
349
  ["/lamps", "[on <nombre> | off <nombre> | run <lámpara> <tool> [json] | remove <nombre>]", "lámparas (plugins de tools): listar, encender o apagar (global: lampson/lamps/ · proyecto: .lampson/lamps/)"],
350
350
  ["/lsp", "[add <typescript|python|rust|go|css|html> [--project] | add <nombre> <comando…> --ext .x=lang | remove <nombre>]", "language servers (navegación semántica: symbols/definition/references/hover); arrancan en la primera consulta"],
351
351
  ["/schedule", "[add <json> | run <id> | on <id> | off <id> | remove <id> | log <id>]", "tareas programadas (cada 6h, todos los días a las 9…): lámpara, comando o corrida del agente; corren mientras lampson esté abierto (o con lampson --daemon start, sin nada abierto)"],
352
- ["/approve", "<id> yes|no", "responder una aprobación pendiente de una tarea programada que corre en background"],
352
+ ["/approve", "<id> [yes|no]", "responder una aprobación pendiente de una tarea programada que corre en background (sin yes|no la pregunta con el menú ↑↓)"],
353
353
  ["/out", "[n]", "resultado completo de la última tool del turno (n = contar hacia atrás: /out 2 es la anteúltima)"],
354
354
  ["/verbose", "", "alternar: mostrar SIEMPRE el output completo de cada tool (queda guardado en config.json)"],
355
355
  ["/trace", "[n]", "traza legible de esta sesión (pasos, tools, tiempos, tokens, errores): .lampson/trace/<sesión>.log"],
@@ -601,23 +601,37 @@ task skills_list()
601
601
  -- lampson/.lampson/config.json (local, gitignored) y vale para terminal y web. Sin TTY no pregunta.
602
602
  task setup_wizard()
603
603
  print("")
604
- print(" " + cyan("Configuremos el modelo.") + " Elegí un proveedor (número o nombre):")
605
604
  let list be provider.providers()
606
- each e in enumerate(list)
607
- let p be e["item"]
608
- 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 "")))
609
- let choice be read_line(" proveedor: ")
610
- when choice == nothing or trim(choice) == ""
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 "")))
610
+ flush()
611
+ let pick be ed.choose(" " + cyan("Configuremos el modelo.") + " Elegí un proveedor:", labels, COLOR)
612
+ let name be ""
613
+ when pick == nothing
611
614
  print(" (sin cambios)")
612
615
  give false
613
- let name be lower(trim(choice))
614
- when matches(name, "[0-9]+")
615
- let i be floor(number(name)) - 1
616
- when i >= 0 and i < length(list)
617
- set name to list[i]["name"]
618
- when not contains(provider.PRESETS, name)
619
- print(" " + red("no conozco el proveedor " + name))
620
- 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) == ""
623
+ print(" (sin cambios)")
624
+ 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))
632
+ give false
633
+ otherwise
634
+ set name to list[pick]["name"]
621
635
  let preset be provider.PRESETS[name]
622
636
  let model be read_line(" modelo [" + preset["model"] + "]: ")
623
637
  set model to when model == nothing then "" otherwise trim(model)
@@ -955,9 +969,16 @@ while running
955
969
  when length(pending_images) == 0
956
970
  print(" sin imágenes pendientes · uso: /image <ruta> o /paste")
957
971
  otherwise
958
- print(" " + text(length(pending_images)) + " imagen(es) pendiente(s) — se mandan con tu próximo mensaje. ¿Quitarlas? (s/N)")
959
- let a be read_line(" ")
960
- when a != nothing and lower(trim(a)) == "s"
972
+ print(" " + text(length(pending_images)) + " imagen(es) pendiente(s) — se mandan con tu próximo mensaje.")
973
+ flush()
974
+ let ipick be ed.choose(" ¿qué hago con ellas?", ["dejarlas", "quitarlas"], COLOR)
975
+ when ipick == -1
976
+ print(" ¿Quitarlas? (s/N)")
977
+ let a be read_line(" ")
978
+ when a != nothing and lower(trim(a)) == "s"
979
+ set pending_images to []
980
+ print(" quitadas")
981
+ otherwise when ipick == 1
961
982
  set pending_images to []
962
983
  print(" quitadas")
963
984
  otherwise
@@ -969,7 +990,12 @@ while running
969
990
  otherwise when id == sid
970
991
  print(" esa es la sesión actual; hacé /new primero")
971
992
  otherwise
972
- print(" " + session.delete(id))
993
+ flush()
994
+ let dpick be ed.choose(" ¿borrar la sesión " + id + "?", ["cancelar", "borrar"], COLOR)
995
+ when dpick == 1 or dpick == -1 -- sin TTY el comando ya es explícito: borra
996
+ print(" " + session.delete(id))
997
+ otherwise
998
+ print(" (no se borró)")
973
999
  otherwise when input == "/new"
974
1000
  set sid to session.new_id()
975
1001
  set messages to [system_msg]
@@ -1126,14 +1152,29 @@ while running
1126
1152
  print(" " + dim("permisos " + s["permission"] + " · próxima " + s["next_local"] + (when s["last_run"] != nothing then " · última " + s["last_status"] + ": " + first_line(s["last_summary"], 80) otherwise "")))
1127
1153
  let pend be approvals.pending()
1128
1154
  each ap in pend
1129
- print(" " + yellow("⚠ aprobación pendiente " + ap["id"] + ": " + first_line(ap["message"], 100)) + dim(" → /approve " + ap["id"] + " yes|no"))
1155
+ print(" " + yellow("⚠ aprobación pendiente " + ap["id"] + ": " + first_line(ap["message"], 100)) + dim(" → /approve " + ap["id"]))
1130
1156
  print(" " + dim("corren solas mientras lampson esté abierto (tick cada " + text(schedule.TICK_SECONDS) + " s) · con todo cerrado: lampson --daemon start · run/on/off/remove/log <id>"))
1131
1157
  otherwise when starts_with(input, "/approve ")
1132
1158
  let atoks be where(split(trim(slice(input, 9, length(input))), " "), (x) => x != "")
1133
- when length(atoks) < 2 or not contains(["yes", "no", "si", "sí", "y", "n"], lower(atoks[1]))
1159
+ let yes be false
1160
+ let answered be false
1161
+ when length(atoks) == 1
1162
+ -- «/approve <id>» a secas: menú ↑↓ (sin TTY, el uso de siempre)
1163
+ flush()
1164
+ let apick be ed.choose(" aprobación " + atoks[0], ["permitir", "denegar"], COLOR)
1165
+ when apick == -1
1166
+ print(" uso: /approve <id> yes|no (pendientes: /schedule)")
1167
+ otherwise when apick == nothing
1168
+ print(" (sin responder)")
1169
+ otherwise
1170
+ set yes to apick == 0
1171
+ set answered to true
1172
+ otherwise when length(atoks) < 2 or not contains(["yes", "no", "si", "sí", "y", "n"], lower(atoks[1]))
1134
1173
  print(" uso: /approve <id> yes|no (pendientes: /schedule)")
1135
1174
  otherwise
1136
- let yes be contains(["yes", "si", "sí", "y"], lower(atoks[1]))
1175
+ set yes to contains(["yes", "si", "sí", "y"], lower(atoks[1]))
1176
+ set answered to true
1177
+ when answered
1137
1178
  when approvals.answer(atoks[0], yes)
1138
1179
  print(" " + (when yes then green("✓ permitido ") otherwise red("✗ denegado ")) + atoks[0])
1139
1180
  otherwise
package/lib/line.syn CHANGED
@@ -320,7 +320,10 @@ export task read(prompt, color, ctx)
320
320
  term_close(h)
321
321
  give result
322
322
 
323
- -- menú vertical de opciones (aprobaciones): ↑↓ + Enter, o la letra inicial / número; Esc = nothing
323
+ -- menú vertical de opciones (aprobaciones, /setup, confirmaciones): ↑↓ + Enter, o la letra inicial /
324
+ -- número; Esc = nothing. La activa lleva ❯ y color; debajo, la línea de ayuda. Las opciones llegan en
325
+ -- TEXTO PLANO (sin ANSI): el menú es el que pinta, y un reset de color adentro le cortaría la línea.
326
+ let CHOOSE_HELP be "Enter confirma · Esc cancela"
324
327
  export task choose(question, options, color)
325
328
  let h be term_open({"ctrl_c": "exit"})
326
329
  when h == nothing
@@ -329,6 +332,11 @@ export task choose(question, options, color)
329
332
  let done be false
330
333
  let result be nothing
331
334
  let drawn be 0
335
+ -- Reservar el alto del bloque ANTES de dibujarlo: al pie de la pantalla esto scrollea una sola vez y
336
+ -- deja el menú entero a la vista. Sin esto, el ESC[nA del redibujo apunta a una línea que el scroll
337
+ -- movió y el menú aparece duplicado más abajo en cada tecla (visto con los 9 proveedores de /setup).
338
+ let need be length(options) + 2
339
+ term_write(h, rep("\n", need) + ESC + "[" + text(need) + "A")
332
340
  while not done
333
341
  let out be ""
334
342
  when drawn > 0
@@ -336,10 +344,11 @@ export task choose(question, options, color)
336
344
  set out to out + "\r" + ESC + "[J" + question
337
345
  let i be 0
338
346
  each o in options
339
- set out to out + "\r\n" + " " + (when i == sel then sgr(color, "7", " " + o + " ") otherwise " " + sgr(color, "2", o))
347
+ set out to out + "\r\n" + (when i == sel then " " + sgr(color, "1;36", " " + o) otherwise " " + sgr(color, "2", o))
340
348
  set i to i + 1
349
+ set out to out + "\r\n\r\n " + sgr(color, "2", CHOOSE_HELP)
341
350
  term_write(h, out)
342
- set drawn to length(options)
351
+ set drawn to length(options) + 2 -- una línea por opción + la vacía + la de ayuda
343
352
  let ev be term_recv(h, 600)
344
353
  when ev == nothing
345
354
  set result to nothing
@@ -361,9 +370,11 @@ export task choose(question, options, color)
361
370
  let t be lower(ev["text"])
362
371
  let j be 0
363
372
  each o in options
364
- when t == text(j + 1) or t == lower(slice(o, 0, 1))
365
- set result to j
366
- set done to true
373
+ -- la PRIMERA que coincide gana: con 9 proveedores hay iniciales repetidas (o, g…)
374
+ when not done
375
+ when t == text(j + 1) or t == lower(slice(o, 0, 1))
376
+ set result to j
377
+ set done to true
367
378
  set j to j + 1
368
379
  term_write(h, "\r\n")
369
380
  term_close(h)
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"})
@@ -24,7 +24,8 @@ export let REGISTRY be ".lampson/workspaces.json"
24
24
  export let HUB_PORT be 8080
25
25
  export let HUB_FILE be "hub.syn"
26
26
  export let HUB_TEMPLATE be "hub.tpl.syn"
27
- let FIRST_PORT be 8081
27
+ -- rango alto para no chocar con lo que usan las apps (3000, 5173, 8000, 8080-8090…): solo el hub queda en 8080
28
+ let FIRST_PORT be 47101
28
29
  let LINKS be ["lib", "public", "skills", "lamps", "memory"]
29
30
  let COPIES be ["web.syn", "chat.syn"]
30
31
 
@@ -82,6 +83,38 @@ task load_reg()
82
83
  task save_reg(doc)
83
84
  write_file(REGISTRY, json_encode(doc))
84
85
 
86
+ -- registros hechos con puertos 808N (antes de 0.2.3): reasignar al rango alto; los procesos viejos se paran en
87
+ -- ensure/tick (por puerto) y el supervisor los levanta en el nuevo
88
+ export task migrate_ports()
89
+ require exec
90
+ require time
91
+ require env("LAMPSON_*")
92
+ require env("OS")
93
+ require file(".lampson")
94
+ require file(".lampson/*")
95
+ require file.read("hub.tpl.syn")
96
+ require file("hub.syn")
97
+ let doc be load_reg()
98
+ let changed be false
99
+ when doc["next_port"] < FIRST_PORT
100
+ set doc["next_port"] to FIRST_PORT
101
+ set changed to true
102
+ each k in keys(doc["workspaces"])
103
+ let w be doc["workspaces"][k]
104
+ when w["port"] < FIRST_PORT
105
+ let old be w["port"]
106
+ each l in proc.listeners()
107
+ when l["port"] == old
108
+ proc.kill_pid(l["pid"])
109
+ set w["port"] to doc["next_port"]
110
+ set doc["next_port"] to doc["next_port"] + 1
111
+ set doc["workspaces"][k] to w
112
+ set changed to true
113
+ when changed
114
+ save_reg(doc)
115
+ regen_hub()
116
+ give changed
117
+
85
118
  export task all()
86
119
  require file(".lampson")
87
120
  require file(".lampson/*")
@@ -241,6 +274,7 @@ export task ensure(path)
241
274
  require file.read("chat.syn")
242
275
  require file.read("hub.tpl.syn")
243
276
  require file("hub.syn")
277
+ migrate_ports()
244
278
  let existing be by_path(path)
245
279
  let w be existing
246
280
  when w == nothing
@@ -490,6 +524,8 @@ export task tick()
490
524
  require file(".lampson/*")
491
525
  require file.read("web.syn")
492
526
  require file.read("chat.syn")
527
+ when migrate_ports() and hub_alive()
528
+ restart_hub_later()
493
529
  let started be []
494
530
  let stopped be []
495
531
  each w in all()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lampson",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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": {
@@ -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; }
@@ -78,3 +78,7 @@
78
78
  .del.ask .yes { color:var(--rubric); cursor:pointer; font-weight:600; }
79
79
  .del.ask .no { color:var(--ink-2); cursor:pointer; }
80
80
  .del.ask .yes:hover, .del.ask .no:hover { text-decoration:underline; text-underline-offset:.18em; }
81
+
82
+ /* puertos ocupados por lampson: visibles pero sin link ni ✕ */
83
+ .p .port.own { color:var(--ink-3); font-weight:400; }
84
+ .p.own .cm { color:var(--ink-3); }
package/public/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  <span class="brand">lampson<span class="proj" id="proj">…</span></span>
22
22
  <span class="spacer"></span>
23
23
  <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>
24
+ <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
25
  <button class="hbtn" id="new" title="empezar una conversación nueva">+ sesión</button>
26
26
  <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
27
  <button class="theme-toggle" id="theme" type="button" title="cambiar tema"></button>
@@ -92,7 +92,7 @@
92
92
  <div id="stage">
93
93
  <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
94
  <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>
95
+ <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
96
  </div>
97
97
  <div>
98
98
  <form id="f">
package/public/js/app.js CHANGED
@@ -14,7 +14,7 @@ async function loadCfg() {
14
14
  sel.onchange = () => { localStorage.setItem('lampson.agent', sel.value); hint(); }; hint();
15
15
  if (!log.children.length) empty();
16
16
  }
17
- loadCfg().then(() => { loadTree(); loadSessions(); loadProcs(); loadMemory(); loadAgents(); loadMcp(); loadLsp(); loadLamps(); loadTodo(); loadSched(); loadApprovals(); checkUpdate(); const pq = new URLSearchParams(location.search).get('proc'); if (pq) openProc(pq); else if (session) open(session); });
17
+ loadCfg().then(() => { loadTree(); loadSessions(); loadProcs(); loadMemory(); loadAgents(); loadMcp(); loadLsp(); loadLamps(); loadTodo(); loadSched(); loadApprovals(); checkUpdate(); const pq = new URLSearchParams(location.search).get('proc'); if (pq) openProc(pq); else if (session) openSession(session); });
18
18
  // respaldos lentos por si el stream de eventos se cae
19
19
  setInterval(loadProcs, events ? 60000 : 10000);
20
20
  setInterval(() => { loadSched(); if (!events) loadApprovals(); }, events ? 60000 : 15000);
@@ -3,13 +3,16 @@ let procOpen = null, procTimer = null;
3
3
  async function loadPorts() {
4
4
  let r; try { r = await (await fetch(BASE + '/api/ports')).json(); } catch (e) { return; }
5
5
  const box = $('#ports'); box.innerHTML = '';
6
- // los procesos de lampson (hub :8080 y los workspaces :808N) no son «servidores del proyecto»
7
- const list = (r.ports || []).filter(p => !(p.name === 'synsema.exe' || p.name === 'synsema') || !/\b(web|hub)\.syn\b/.test(p.command || '') && p.port !== 8080);
6
+ // los procesos de lampson (hub :8080, un workspace por puerto alto) OCUPAN el puerto: se ven, identificados,
7
+ // sin link (la UI se usa por el hub) y sin (un workspace se apaga desde su Panel; matarlo lo relanza el supervisor)
8
+ let wsByPort = {}; try { const ws = (await (await fetch('/api/workspaces')).json()).workspaces || []; for (const w of ws) wsByPort[w.port] = w; } catch (e) {}
9
+ const list = (r.ports || []).map(p => { const own = /\b(web|hub)\.syn\b/.test(p.command || '') || (p.port === 8080 && /synsema/.test(p.name || '')); return Object.assign({}, p, { lampson: own, wsName: own && wsByPort[p.port] ? wsByPort[p.port].name : (own && p.port === 8080 ? 'hub' : '') }); });
8
10
  $('#portCount').textContent = list.length || ''; autoSec('ports', list.length > 0);
9
11
  if (!list.length) { box.innerHTML = '<div class="p none">ninguno</div>'; return; }
10
12
  for (const p of list) {
11
- const d = document.createElement('div'); d.className = 'p'; d.style.cursor = 'default'; d.title = `pid ${p.pid}\n${p.command || '(línea de comando no disponible)'}`;
13
+ const d = document.createElement('div'); d.className = 'p' + (p.lampson ? ' own' : ''); d.style.cursor = 'default'; d.title = `pid ${p.pid}\n${p.command || '(línea de comando no disponible)'}`;
12
14
  const what = p.command ? p.command.replace(/^"?[A-Za-z]:\\[^"]*\\([^"\\]+)"?/, '$1') : p.name;
15
+ if (p.lampson) { d.innerHTML = `<span class="port own" title="puerto ocupado por lampson">:${p.port}</span><span class="cm">lampson · ${esc(p.wsName ? (p.port === 8080 ? 'hub' : 'workspace ' + p.wsName) : 'proceso')}</span>`; box.appendChild(d); continue; }
13
16
  d.innerHTML = `<a class="port" href="http://127.0.0.1:${p.port}" target="_blank" rel="noopener" title="abrir http://127.0.0.1:${p.port} en otra pestaña">:${p.port}</a><span class="cm">${esc(what)}</span><button title="matar este proceso (pid ${p.pid}; no lo gestiona lampson)">✕</button>`;
14
17
  const kb = d.querySelector('button'); kb.onclick = () => { const holder = document.createElement('span'); holder.className = 'del'; kb.replaceWith(holder); inlineConfirm(holder, `¿matar pid ${p.pid}?`, async () => { await api(BASE + '/api/ports/kill', { pid: p.pid }); loadPorts(); }); setTimeout(() => { if (!holder.classList.contains('ask')) loadPorts(); }, 6100); };
15
18
  box.appendChild(d);
@@ -68,7 +68,7 @@ function openSched(selectId) {
68
68
  if (t === SCHED_NEW) { schedFormWire(box, err); return; }
69
69
  box.querySelector('[data-run]').onclick = async () => { const r = await api(BASE + '/api/schedules/run', { id: t.id }); add('meta', '⏰ ' + esc(r.data.result || r.data.error || '')); setTimeout(loadSched, 1500); setTimeout(() => Panel.is('schedules') && Panel.detail(), 2500); };
70
70
  box.querySelector('[data-toggle]').onclick = async () => { const r = await api(BASE + '/api/schedules/toggle', { id: t.id, enabled: !t.enabled }); if (!r.ok) { err(r.data.error || 'error'); return; } loadSched(); };
71
- box.querySelectorAll('[data-session]').forEach(a => a.onclick = (e) => { e.preventDefault(); Panel.close(); open(a.dataset.session); });
71
+ box.querySelectorAll('[data-session]').forEach(a => a.onclick = (e) => { e.preventDefault(); Panel.close(); openSession(a.dataset.session); });
72
72
  const del = box.querySelector('.dfoot .del');
73
73
  del.onclick = () => inlineConfirm(del, `¿quitar ${t.name}?`, async () => { await api(BASE + '/api/schedules/remove', { id: t.id }); loadSched(); });
74
74
  const live = box.querySelector('[data-live]'); if (live) live.scrollTop = live.scrollHeight;
@@ -9,7 +9,7 @@ async function loadSessions() {
9
9
  if (!r.sessions.length) { box.innerHTML = '<div class="s none">todavía ninguna</div>'; return; }
10
10
  const shown = r.sessions.slice(0, 4);
11
11
  for (const s of shown) {
12
- const d = document.createElement('div'); d.className = 's' + (s.id === session ? ' active' : ''); d.innerHTML = '<span class="id">' + esc(s.id.slice(-6)) + '</span><span class="title">' + esc(s.title || '') + '</span><span class="del tr" title="traza: qué hizo el agente en esta sesión (pasos, tools, tiempos, tokens)">≡</span><span class="del" title="borrar esta sesión">✕</span>'; d.title = s.id; d.onclick = () => open(s.id);
12
+ const d = document.createElement('div'); d.className = 's' + (s.id === session ? ' active' : ''); d.innerHTML = '<span class="id">' + esc(s.id.slice(-6)) + '</span><span class="title">' + esc(s.title || '') + '</span><span class="del tr" title="traza: qué hizo el agente en esta sesión (pasos, tools, tiempos, tokens)">≡</span><span class="del" title="borrar esta sesión">✕</span>'; d.title = s.id; d.onclick = () => openSession(s.id);
13
13
  d.querySelector('.tr').onclick = (ev) => { ev.stopPropagation(); openTrace(s.id); };
14
14
  d.querySelector('.del:not(.tr)').onclick = (ev) => { ev.stopPropagation(); inlineConfirm(d.querySelector('.del:not(.tr)'), '¿borrar?', () => deleteSession(s.id)); };
15
15
  box.appendChild(d);
@@ -35,18 +35,18 @@ function openSessionsPanel() {
35
35
  + `<div class="dfoot"><span>.lampson/sessions/${esc(s.id)}.json</span><span class="del">borrar</span></div>`;
36
36
  },
37
37
  wire: (s, box) => {
38
- box.querySelector('[data-act="open"]').onclick = () => { Panel.close(); open(s.id); };
38
+ box.querySelector('[data-act="open"]').onclick = () => { Panel.close(); openSession(s.id); };
39
39
  box.querySelector('[data-act="trace"]').onclick = () => { Panel.close(); openTrace(s.id); };
40
40
  const del = box.querySelector('.dfoot .del');
41
41
  del.onclick = () => inlineConfirm(del, `¿borrar ${s.id}?`, async () => { await deleteSession(s.id); Panel.refresh(); });
42
42
  },
43
- onPick: (s) => open(s.id),
43
+ onPick: (s) => openSession(s.id),
44
44
  count: (rows, q) => q ? `${rows.length} coincidencia${rows.length === 1 ? '' : 's'}` : `${rows.length} sesión${rows.length === 1 ? '' : 'es'}`,
45
45
  emptyHtml: 'todavía no hay sesiones'
46
46
  }
47
47
  });
48
48
  }
49
- async function open(id) {
49
+ async function openSession(id) {
50
50
  session = id; localStorage.setItem('lampson.session', id); log.innerHTML = ''; log.classList.remove('hero'); showPane('log'); loadTodo();
51
51
  const r = await fetch(BASE + '/api/sessions/' + id); if (!r.ok) { session = ''; empty(); return loadSessions(); }
52
52
  const d = await r.json();
@@ -1,6 +1,20 @@
1
- // terminal.js — terminal real: xterm.js ↔ WebSocket /api/term ↔ pty en el servidor
2
- // Frames binarios = bytes del pty; texto JSON = control (hello / exit). Cerrar el panel cierra el socket y mata el shell.
3
- let term = null, termWs = null, termFit = null;
1
+ // terminal.js — terminales reales: xterm.js ↔ WebSocket /api/term ↔ un pty por terminal en el servidor.
2
+ // Frames de texto: "o" + salida del pty · "c" + JSON de control (hello / exit). Conviven varias como
3
+ // pestañas (cada una con su shell); minimizar esconde el panel con todas vivas y el botón del encabezado
4
+ // lo trae de vuelta; ✕ mata la shell de la activa (la última cierra el panel).
5
+ // La shell NO vive en el socket sino en un agente del servidor (lib/term.syn): recargar la página no la
6
+ // mata — al cargar pedimos /api/terms y nos reenganchamos por id, con replay de lo último que imprimió.
7
+ const MAX_TERMS = 4;
8
+ let terms = []; // [{id, el, term, ws, fit, ro, box, meta, live}]
9
+ let termAt = -1; // índice de la activa en terms
10
+ // qué estaba abierto antes del F5 (el panel y la pestaña activa; las shells las sabe el servidor)
11
+ function saveTermUi() {
12
+ try {
13
+ localStorage.setItem('lampson.term.open', termShown() ? '1' : '0');
14
+ localStorage.setItem('lampson.term.at', String(termAt));
15
+ localStorage.setItem('lampson.term.max', $('#termpane').classList.contains('max') ? '1' : '0');
16
+ } catch (e) {}
17
+ }
4
18
  function termTheme() {
5
19
  const s = getComputedStyle(document.documentElement); const v = n => s.getPropertyValue(n).trim();
6
20
  return { background: v('--paper'), foreground: v('--ink'), cursor: v('--accent'), cursorAccent: v('--paper'), selectionBackground: v('--sel'),
@@ -8,11 +22,56 @@ function termTheme() {
8
22
  yellow: v('--amber'), brightYellow: v('--amber'), blue: v('--term-blue'), brightBlue: v('--accent'), magenta: v('--rubric'), brightMagenta: v('--rubric'),
9
23
  cyan: v('--accent'), brightCyan: v('--accent'), white: v('--ink-2'), brightWhite: v('--ink') };
10
24
  }
11
- function openTerm() {
12
- if (term) { showPane('term'); termFit.fit(); term.focus(); return; }
13
- showPane('term'); procOpen = null; clearInterval(procTimer);
14
- term = new Terminal({ cursorBlink: true, fontFamily: getComputedStyle(document.documentElement).getPropertyValue('--mono'), fontSize: 13, lineHeight: 1.25, theme: termTheme(), scrollback: 5000, allowProposedApi: true });
15
- termFit = new FitAddon.FitAddon(); term.loadAddon(termFit); term.open($('#xterm')); termFit.fit();
25
+ // ojo: el display inicial lo pone el CSS, no el style inline — mirando sólo el inline, «está abierto»
26
+ // daba true antes del primer showPane() y la restauración se creía abierta
27
+ const termShown = () => getComputedStyle($('#termpane')).display !== 'none';
28
+ // Un solo lugar donde se ajusta el tamaño. fit() hace clear() + resize() del terminal y dispara un
29
+ // resize del pty (el shell repinta entero), así que sólo se llama cuando la caja cambió DE VERDAD:
30
+ // sin esa guarda, el ResizeObserver se realimenta con su propio ajuste y el terminal parpadea.
31
+ function fitTerm(t) {
32
+ if (!t || !t.fit || !termShown()) return;
33
+ const box = t.el.clientWidth + 'x' + t.el.clientHeight;
34
+ if (!t.el.clientWidth || !t.el.clientHeight || box === t.box) return;
35
+ t.box = box;
36
+ requestAnimationFrame(() => { if (t.term && t.fit) try { t.fit.fit(); } catch (e) {} }); // fuera del callback del observer
37
+ }
38
+ // pestañas: una por terminal (siempre visibles, también con una sola: así se ve que hay numeración)
39
+ function paintTabs() {
40
+ const tabs = $('#ttabs'); tabs.innerHTML = '';
41
+ terms.forEach((t, i) => {
42
+ const b = document.createElement('span');
43
+ b.className = 'ttab' + (i === termAt ? ' on' : '') + (t.live ? '' : ' dead');
44
+ b.textContent = String(i + 1);
45
+ b.title = 'terminal ' + (i + 1) + (t.meta ? ' · ' + t.meta : '') + (t.live ? '' : ' (shell terminada)');
46
+ b.onclick = () => activateTerm(i); tabs.appendChild(b);
47
+ });
48
+ const add = $('#tnew'); add.disabled = terms.length >= MAX_TERMS;
49
+ add.title = add.disabled ? 'máximo ' + MAX_TERMS + ' terminales abiertas' : 'abrir otra terminal: una shell nueva en el workspace';
50
+ const t = terms[termAt];
51
+ $('#tmeta').textContent = t ? t.meta : '';
52
+ $('#termpane').classList.toggle('live', !!(t && t.live));
53
+ $('#term').classList.toggle('live', terms.some(x => x.live)); // el botón del encabezado: hay shell viva
54
+ }
55
+ function activateTerm(i) {
56
+ termAt = i;
57
+ terms.forEach((t, k) => { t.el.style.display = k === i ? '' : 'none'; });
58
+ paintTabs(); saveTermUi();
59
+ const t = terms[i];
60
+ if (!t) return;
61
+ t.box = ''; fitTerm(t); t.term.refresh(0, t.term.rows - 1); t.term.focus();
62
+ }
63
+ // id = reengancharse a una shell que ya existe (tras un F5); show = false para reconstruir sin abrir el panel
64
+ function newTerm(id, show) {
65
+ if (terms.length >= MAX_TERMS) return;
66
+ if (show !== false) { showPane('term'); procOpen = null; clearInterval(procTimer); }
67
+ const el = document.createElement('div'); el.className = 'xt'; $('#xterms').appendChild(el);
68
+ const term = new Terminal({ cursorBlink: true, fontFamily: getComputedStyle(document.documentElement).getPropertyValue('--mono'), fontSize: 13, lineHeight: 1.25, theme: termTheme(), scrollback: 5000, allowProposedApi: true });
69
+ const fit = new FitAddon.FitAddon(); term.loadAddon(fit);
70
+ const t = { id: id || '', el, term, fit, ws: null, ro: null, box: '', meta: 'conectando…', live: false };
71
+ terms.push(t);
72
+ if (show !== false) termAt = terms.length - 1;
73
+ terms.forEach((x, k) => { x.el.style.display = k === termAt ? '' : 'none'; });
74
+ term.open(el); fitTerm(t);
16
75
  // URLs clickeables (npm run dev imprime http://localhost:3000): link provider mínimo con la API nativa
17
76
  // de xterm v5 — el addon web-links no está vendorizado y no hace falta para http/https
18
77
  const TERM_URL_RE = /https?:\/\/[^\s"'`<>()\[\]{}]*[^\s"'`<>()\[\]{}.,;:!?]/g;
@@ -28,22 +87,98 @@ function openTerm() {
28
87
  cb(links.length ? links : undefined);
29
88
  }
30
89
  });
31
- $('#tmeta').textContent = 'conectando…';
32
- termWs = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + BASE + '/api/term'); termWs.binaryType = 'arraybuffer';
33
- const send = (o) => { if (termWs && termWs.readyState === 1) termWs.send(JSON.stringify(o)); };
34
- termWs.onopen = () => { $('#termpane').classList.add('live'); send({ type: 'resize', cols: term.cols, rows: term.rows }); term.focus(); };
35
- termWs.onmessage = (e) => { // "o" + salida del pty | "c" + JSON de control
90
+ const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + BASE + '/api/term' + (id ? '?id=' + encodeURIComponent(id) : ''));
91
+ ws.binaryType = 'arraybuffer'; t.ws = ws;
92
+ const send = (o) => { if (ws.readyState === 1) ws.send(JSON.stringify(o)); };
93
+ t.send = send;
94
+ ws.onopen = () => { t.live = true; paintTabs(); send({ type: 'resize', cols: term.cols, rows: term.rows }); if (termAt === terms.indexOf(t)) term.focus(); };
95
+ ws.onmessage = (e) => { // "o" + salida del pty | "c" + JSON de control
36
96
  const s = String(e.data);
37
97
  if (s[0] === 'o') { term.write(s.slice(1)); return; }
38
98
  let m; try { m = JSON.parse(s.slice(1)); } catch { return; }
39
- if (m.type === 'hello') $('#tmeta').textContent = `${m.shell} · pid ${m.pid} · ${m.cwd || 'workspace'}`;
40
- if (m.type === 'exit') { term.write(`\r\n\x1b[2m[shell terminado · código ${m.code}]\x1b[0m\r\n`); $('#termpane').classList.remove('live'); }
99
+ if (m.type === 'hello') { t.id = m.id || t.id; t.meta = `${m.shell} · pid ${m.pid} · ${m.cwd || 'workspace'}`; paintTabs(); }
100
+ if (m.type === 'exit') {
101
+ t.live = false; paintTabs();
102
+ if (m.error === 'too_many') { term.write(`\r\n\x1b[2m[ya hay ${MAX_TERMS} terminales abiertas — cerrá una para abrir otra]\x1b[0m\r\n`); return; }
103
+ term.write(`\r\n\x1b[2m[shell terminado · código ${m.code}]\x1b[0m\r\n`);
104
+ }
105
+ };
106
+ // el socket se corta pero la shell sigue viva en el servidor: se recupera recargando (o al volver a entrar)
107
+ ws.onclose = () => {
108
+ t.live = false;
109
+ if (t.meta === 'conectando…') t.meta = 'sin conexión';
110
+ else if (!t.killed) term.write('\r\n\x1b[2m[conexión perdida · la shell sigue viva: recargá la página para reengancharte]\x1b[0m\r\n');
111
+ paintTabs();
41
112
  };
42
- termWs.onclose = () => { $('#termpane').classList.remove('live'); if ($('#tmeta').textContent === 'conectando…') $('#tmeta').textContent = 'sin conexión'; };
43
113
  term.onData(d => send({ type: 'in', data: d }));
44
114
  term.onResize(({ cols, rows }) => send({ type: 'resize', cols, rows }));
45
- new ResizeObserver(() => { if ($('#termpane').style.display !== 'none') termFit.fit(); }).observe($('#xterm'));
115
+ t.ro = new ResizeObserver(() => fitTerm(t)); t.ro.observe(el);
116
+ paintTabs(); saveTermUi();
117
+ }
118
+ // el botón del encabezado: abre la primera, trae de vuelta el panel minimizado, o lo esconde si está a la vista.
119
+ // Espera a saber qué shells sobrevivieron (termsReady): sin eso, un clic apurado abriría una shell de más.
120
+ function openTerm() {
121
+ termsReady.then(() => {
122
+ if (!terms.length) { newTerm(); return; }
123
+ if (termShown()) { minTerm(); return; }
124
+ showPane('term'); procOpen = null; clearInterval(procTimer);
125
+ activateTerm(termAt < 0 || termAt >= terms.length ? terms.length - 1 : termAt);
126
+ saveTermUi();
127
+ });
128
+ }
129
+ // minimizar: el panel se esconde, las shells siguen vivas (el botón del encabezado queda verde y lo devuelve)
130
+ function minTerm() { maxTerm(false); showPane('log'); saveTermUi(); }
131
+ // pantalla completa: el panel tapa la ventana entera; se vuelve con el mismo botón (❐).
132
+ function maxTerm(on) {
133
+ const pane = $('#termpane');
134
+ const max = on === undefined ? !pane.classList.contains('max') : on;
135
+ pane.classList.toggle('max', max);
136
+ $('#tmax').innerHTML = max ? '&#10064;' : '&#9633;';
137
+ $('#tmax').title = max ? 'volver al tamaño normal' : 'pantalla completa';
138
+ saveTermUi();
139
+ const t = terms[termAt];
140
+ if (t) { t.box = ''; fitTerm(t); t.term.focus(); }
141
+ }
142
+ // repintar las terminales abiertas al cambiar de tema (lo llama theme.js; ojo: «term» a secas es el BOTÓN
143
+ // del encabezado — los id del HTML son globales — así que el repintado tiene que pasar por acá)
144
+ function termsRetheme() { const th = termTheme(); terms.forEach(t => { t.term.options.theme = th; }); }
145
+ // cerrar: sólo la terminal activa. Hay que PEDIR la muerte de la shell ({type:"kill"}): irse del socket
146
+ // ya no la mata (ese es el precio, y la gracia, de que sobreviva a un F5). La última cierra el panel.
147
+ function closeTerm() {
148
+ const t = terms[termAt];
149
+ if (!t) { showPane('log'); return; }
150
+ t.killed = true;
151
+ if (t.ro) t.ro.disconnect();
152
+ if (t.ws) try { t.send({ type: 'kill' }); t.ws.close(); } catch (e) {}
153
+ t.term.dispose(); t.el.remove();
154
+ terms.splice(termAt, 1);
155
+ if (!terms.length) { termAt = -1; paintTabs(); maxTerm(false); showPane('log'); saveTermUi(); return; }
156
+ activateTerm(Math.min(termAt, terms.length - 1));
157
+ }
158
+ // al cargar: las shells que sobrevivieron al F5 vuelven como pestañas (el panel sólo si estaba abierto)
159
+ async function restoreTerms() {
160
+ let data; try { data = await (await fetch(BASE + '/api/terms')).json(); } catch (e) { return; }
161
+ const live = (data && data.terminals) || [];
162
+ if (!live.length) return;
163
+ // leer ANTES de crear nada: cada newTerm guarda el estado y pisaría lo que dejó la sesión anterior
164
+ let open = false, at = 0, max = false;
165
+ try {
166
+ open = localStorage.getItem('lampson.term.open') === '1';
167
+ at = +(localStorage.getItem('lampson.term.at') || 0);
168
+ max = localStorage.getItem('lampson.term.max') === '1';
169
+ } catch (e) {}
170
+ const yaAbrio = terms.length > 0; // alguien abrió una mientras preguntábamos: no le movemos la vista
171
+ for (const info of live) newTerm(info.id, false);
172
+ if (yaAbrio) { paintTabs(); return; }
173
+ termAt = Math.max(0, Math.min(at, terms.length - 1));
174
+ if (open) { showPane('term'); procOpen = null; clearInterval(procTimer); if (max) maxTerm(true); activateTerm(termAt); }
175
+ else { terms.forEach((t, k) => { t.el.style.display = k === termAt ? '' : 'none'; }); paintTabs(); }
46
176
  }
47
- function closeTerm() { if (termWs) { try { termWs.close(); } catch (e) {} } if (term) term.dispose(); term = null; termWs = null; termFit = null; $('#xterm').innerHTML = ''; $('#termpane').classList.remove('live'); showPane('log'); }
177
+ const termsReady = restoreTerms(); // se lanza al cargar; openTerm la espera
48
178
  $('#term').onclick = openTerm;
179
+ $('#tnew').onclick = newTerm;
180
+ $('#tmin').onclick = minTerm;
181
+ $('#tmax').onclick = () => maxTerm();
49
182
  $('#tclose').onclick = closeTerm;
183
+ // Sin atajo para salir de pantalla completa: Esc es del shell (vim, menús) y robársela rompería el terminal.
184
+ // El botón ❐ queda a la vista en la cabecera, que es lo único que se dibuja fuera del área del terminal.
@@ -2,5 +2,5 @@
2
2
  const SUN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.7v2.1M12 19.2v2.1M2.7 12h2.1M19.2 12h2.1M5.3 5.3l1.5 1.5M17.2 17.2l1.5 1.5M18.7 5.3l-1.5 1.5M6.8 17.2l-1.5 1.5"/></svg>';
3
3
  const MOON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M20.2 14.6A8.6 8.6 0 0 1 9.4 3.8a8.6 8.6 0 1 0 10.8 10.8z"/></svg>';
4
4
  function paintTheme() { const light = document.documentElement.getAttribute('data-theme') === 'light'; $('#theme').innerHTML = light ? MOON : SUN; $('#theme').title = light ? 'Cambiar a tema oscuro' : 'Cambiar a tema claro'; }
5
- $('#theme').onclick = () => { const r = document.documentElement; r.setAttribute('data-theme', r.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); try { localStorage.setItem('lampson.theme', r.getAttribute('data-theme')); } catch (e) {} paintTheme(); if (typeof term !== 'undefined' && term) term.options.theme = termTheme(); };
5
+ $('#theme').onclick = () => { const r = document.documentElement; r.setAttribute('data-theme', r.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); try { localStorage.setItem('lampson.theme', r.getAttribute('data-theme')); } catch (e) {} paintTheme(); if (typeof termsRetheme === 'function') termsRetheme(); };
6
6
  paintTheme();
@@ -1,6 +1,9 @@
1
1
  // workspaces.js — el selector de workspace de la cabecera (dentro de un workspace) y el Panel de workspaces.
2
2
  // La API de workspaces es del HUB (raíz, sin BASE): /api/workspaces… Sin hub (standalone) el selector se oculta.
3
3
  let wsList = [], wsIdle = 4;
4
+ // abierto por el puerto de un proceso (:808N) las URLs de otros workspaces tienen que ir al hub (:8080), no a este proceso
5
+ const HUB_BASE = (location.port && location.port !== '8080') ? location.protocol + '//' + location.hostname + ':8080' : '';
6
+ function wsUrl(w) { return HUB_BASE + w.url; }
4
7
  async function fetchWorkspaces() {
5
8
  try { const r = await (await fetch('/api/workspaces')).json(); wsList = r.workspaces || []; wsIdle = r.idle_hours; return true; } catch (e) { wsList = []; return false; }
6
9
  }
@@ -31,12 +34,12 @@ function openWorkspaces(selectSlug) {
31
34
  wire: (w, box) => {
32
35
  const err = (m) => { const e = box.querySelector('.derr'); if (e) e.textContent = m || ''; };
33
36
  if (w === WS_NEW) { wsNewWire(box, err); return; }
34
- box.querySelector('[data-open]').onclick = () => { if (w.slug !== WS_SLUG) location.href = w.url; else Panel.close(); };
37
+ box.querySelector('[data-open]').onclick = () => { if (w.slug !== WS_SLUG) location.href = wsUrl(w); else Panel.close(); };
35
38
  const st = box.querySelector('[data-start]'); if (st) st.onclick = async () => { err('arrancando…'); const r = await api('/api/workspaces/start', { slug: w.slug }); err(r.data.ok ? '' : 'no respondió a tiempo'); Panel.refresh(); };
36
39
  const sp = box.querySelector('[data-stop]'); if (sp) sp.onclick = async () => { await api('/api/workspaces/stop', { slug: w.slug }); setTimeout(() => Panel.refresh(), 800); };
37
40
  box.querySelector('[name="policy"]').onchange = async (ev) => { const r = await api('/api/workspaces/policy', { slug: w.slug, policy: ev.target.value }); if (!r.ok) err(r.data.error || 'error'); };
38
41
  const del = box.querySelector('.dfoot .del');
39
- del.onclick = () => inlineConfirm(del, `¿quitar ${w.name} del registro?`, async () => { const r = await api('/api/workspaces/remove', { slug: w.slug }); if (!r.ok) { err(r.data.error || 'error'); return; } if (w.slug === WS_SLUG) location.href = '/'; else Panel.refresh(); });
42
+ del.onclick = () => inlineConfirm(del, `¿quitar ${w.name} del registro?`, async () => { const r = await api('/api/workspaces/remove', { slug: w.slug }); if (!r.ok) { err(r.data.error || 'error'); return; } if (w.slug === WS_SLUG) location.href = HUB_BASE + '/'; else Panel.refresh(); });
40
43
  }
41
44
  }
42
45
  });
@@ -87,11 +90,9 @@ function wsNewWire(box, err0) {
87
90
  const r = await api('/api/workspaces', { path: chosen });
88
91
  if (!r.ok) { err(r.data.error || ('error ' + r.status)); return; }
89
92
  // el hub se regenera con la ruta nueva y se reinicia: esperar a que vuelva y navegar
90
- const url = r.data.url; let tries = 0;
93
+ const url = HUB_BASE + r.data.url; let tries = 0;
91
94
  const poll = async () => { tries++; try { const h = await fetch('/api/hub'); if (h.ok && tries > 2) { location.href = url; return; } } catch (e) {} if (tries < 40) setTimeout(poll, 500); else err('el hub no volvió: abrí ' + url + ' a mano'); };
92
95
  setTimeout(poll, 1200);
93
96
  };
94
97
  }
95
- // standalone (abierto por el puerto del workspace, no por el hub): la pill lleva al hub
96
- const HUB_URL = (location.port && location.port !== '8080') ? location.protocol + '//' + location.hostname + ':8080/' : '';
97
- if ($('#wsPill')) { if (HUB_URL) { const pill = $('#wsPill'); pill.style.display = ''; pill.textContent = 'workspaces ↗'; pill.title = 'esta pestaña es el proceso del workspace; los workspaces se gestionan en el hub'; pill.onclick = () => window.open(HUB_URL, '_blank'); } else { $('#wsPill').onclick = () => openWorkspaces(); paintWorkspacePill(); } }
98
+ if ($('#wsPill')) { $('#wsPill').onclick = () => openWorkspaces(); paintWorkspacePill(); }
@@ -3,7 +3,7 @@ name: synsema
3
3
  description: Writing, checking, running and testing Synsema (.syn) code — syntax reflexes, capabilities, live processes / pseudo-terminals, and the runtime traps that cost hours. Load before touching any .syn file.
4
4
  ---
5
5
 
6
- # Synsema quick reference (v0.6.12)
6
+ # Synsema quick reference (v0.6.13)
7
7
 
8
8
  > Curated 10 KB summary for the agent (the full reference is ~450 KB and lives in the user's editor
9
9
  > skill). Kept in sync by hand with each `synsema update`; if `synsema --version` is newer than the
@@ -14,6 +14,13 @@ description: Writing, checking, running and testing Synsema (.syn) code — synt
14
14
  `synsema test file.syn` (runs `test "..."` blocks) · `synsema serve file.syn` (HTTP server) ·
15
15
  `synsema update` (self-update; then refresh the AI skill with the command it prints).
16
16
  - Errors carry `file:line` and a suggestion. Read them; they are usually right.
17
+ - **Read the repo without opening files (v0.6.13+)**: `synsema code outline` (project map: intent,
18
+ symbols, imports per file), `routes [path]` (the table each `serve` publishes: method, path, auth,
19
+ stream/socket/proxy, response kind, capabilities), `refs <name>` (every use, through module aliases),
20
+ `symbol <name>`, `caps` (declared vs. effective vs. `missing`, with the `require` to add), `check`,
21
+ `search <text>`, `deps`. Add `--json` for scripts. Same eight tools over MCP: `synsema code --mcp`
22
+ (server `synsema-code`, static — it never talks to a running server). `outline` before opening a
23
+ `.syn`, `refs` before renaming, `check` after every edit.
17
24
 
18
25
  ## Syntax reflexes (Python → Synsema)
19
26
  - `let x be 5` / `set x to 6` (no `=`) · `-- comment` · `when / otherwise when / otherwise` (no colons)
package/web.syn CHANGED
@@ -64,6 +64,7 @@ use "./lib/schedule.syn" as schedule
64
64
  use "./lib/sched_run.syn" as sched_run
65
65
  use "./lib/approvals.syn" as approvals
66
66
  use "./lib/permission.syn" as permission
67
+ use "./lib/term.syn" as term
67
68
 
68
69
  let APPROVAL_TIMEOUT be 180
69
70
 
@@ -86,22 +87,6 @@ task as_text(x)
86
87
  give decode(x, "utf8_lossy")
87
88
  give x
88
89
 
89
- -- shell para el terminal web: [cmd, args]. Windows: pwsh si está, si no PowerShell 5; unix: bash de login.
90
- -- En pwsh, los directorios de `ls` salen sin fondo azul (el default de $PSStyle se lee mal en un tema oscuro).
91
- task term_shell()
92
- when env("OS", "") == "Windows_NT"
93
- try
94
- run("pwsh", ["-NoLogo", "-Command", "exit"], 10)
95
- give ["pwsh", ["-NoLogo", "-NoExit", "-Command", "$PSStyle.FileInfo.Directory = $PSStyle.Foreground.BrightBlue"]]
96
- recover err
97
- give ["powershell", ["-NoLogo"]]
98
- give ["bash", ["-l"]]
99
-
100
- -- cwd del terminal: la ruta REAL del proyecto (LAMPSON_WORKSPACE), no la junction ./workspace —
101
- -- así el prompt muestra dónde estás de verdad y coincide con el header.
102
- task term_cwd()
103
- give env("LAMPSON_WORKSPACE", "workspace")
104
-
105
90
  task boot(profile, mode, ask_fn, pname, model)
106
91
  let cfg be provider.config_for(pname, model)
107
92
  let p be when contains(agents.PROFILES, profile) then profile otherwise "build"
@@ -138,11 +123,29 @@ serve on 8080
138
123
  auth with check_client
139
124
  static "./public"
140
125
 
141
- -- standalone (sin hub: docker, `synsema serve web.syn`): la UI vive igual bajo /w/<slug> (BASE en js/core.js)
142
- route "GET /"
143
- give redirect("/w/" + (when env("LAMPSON_WS", "") == "" then "local" otherwise env("LAMPSON_WS", "")))
126
+ -- la UI vive bajo /w/<slug> (BASE en js/core.js); si alguien entra por el puerto del proceso, es lo mismo
127
+ -- Acceso DIRECTO por el puerto del proceso (sin X-Forwarded-Host, que solo agrega el hub al proxyear): la UI se
128
+ -- usa siempre por el hub redirigir a http://127.0.0.1:8080/w/<slug>. Standalone sin hub (docker) no hay
129
+ -- LAMPSON_WS y se sirve acá mismo.
144
130
  route "GET /w/:slug"
131
+ let h be headers of request
132
+ when env("LAMPSON_WS", "") != "" and not contains(h, "x-forwarded-host")
133
+ give redirect("http://127.0.0.1:8080/w/" + params.slug)
145
134
  give html(read_file("public/index.html"))
135
+ route "GET /"
136
+ when env("LAMPSON_WS", "") == ""
137
+ give redirect("/w/local")
138
+ give redirect("http://127.0.0.1:8080/w/" + env("LAMPSON_WS", ""))
139
+ route "GET /api/hub"
140
+ proxy to "http://127.0.0.1:8080"
141
+ route "GET /api/workspaces"
142
+ proxy to "http://127.0.0.1:8080"
143
+ route "GET /api/workspaces/*path"
144
+ proxy to "http://127.0.0.1:8080"
145
+ route "POST /api/workspaces"
146
+ proxy to "http://127.0.0.1:8080"
147
+ route "POST /api/workspaces/*path"
148
+ proxy to "http://127.0.0.1:8080"
146
149
 
147
150
  -- salud para el hub/supervisor y la terminal (último uso = chat, terminal, eventos o abrir la UI)
148
151
  route "GET /w/:slug/api/health"
@@ -191,39 +194,61 @@ serve on 8080
191
194
  route "GET /w/:slug/api/tree" requires auth
192
195
  give tree.tree(6)
193
196
 
194
- -- Terminal real en el navegador: un shell dentro de un pseudo-terminal (pty) por conexión WebSocket.
195
- -- Navegador servidor: texto JSON {type: "in", data: teclas} | {type: "resize", cols, rows}.
196
- -- Servidor navegador: frames de texto con prefijo: "o" + salida cruda del pty (ANSI incluido, xterm.js
197
- -- la dibuja) | "c" + JSON de control ({type: hello|exit}).
198
- -- El shell vive lo que vive el socket: cerrar el panel lo mata (el runtime no deja huérfanos).
197
+ -- Terminales del navegador. El pty NO vive en este handler: vive en un agente supervisor
198
+ -- (lib/term.syn), y este socket es un puente por el bus. Así la shell sobrevive a un F5 —
199
+ -- la pestaña se reengancha con ?id=<id> y recibe el replay de lo último que imprimió.
200
+ -- Navegador servidor: {type: "in", data} | {type: "resize", cols, rows} | {type: "kill"}.
201
+ -- Servidor navegador: "o" + salida cruda del pty | "c" + JSON de control (hello | exit).
202
+ -- Sin ningún socket enganchado por 30 min, el supervisor recoge la shell; todo muere con lampson.
199
203
  route "GET /w/:slug/api/term" requires auth
200
204
  socket
201
205
  state_set("lampson:last_used", now())
202
- let sh be term_shell()
203
- let p be proc_spawn(sh[0], sh[1], {"cwd": term_cwd(), "pty": true, "cols": 120, "rows": 32})
204
- ws_send(socket, "c" + json_encode({"type": "hello", "shell": sh[0], "pid": proc_stats(p)["pid"], "cwd": term_cwd()}))
205
- let open be true
206
- while open
207
- let ev be select({"ui": socket, "sh": p}, 600)
208
- when ev == nothing
209
- set open to proc_status(p) == "running"
210
- otherwise when ev["name"] == "ui"
211
- when ev["type"] == "close"
212
- set open to false
213
- otherwise when ev["type"] == "binary"
214
- proc_send(p, ev["data"])
206
+ let id be when contains(query, "id") then text(query.id) otherwise ""
207
+ when id == "" or not term.alive(id)
208
+ set id to term.start(120, 32) -- nothing = ya hay MAX_TERMS abiertas
209
+ when id == nothing
210
+ ws_send(socket, "c" + json_encode({"type": "exit", "code": -1, "error": "too_many"}))
211
+ otherwise
212
+ let st be term.state(id)
213
+ ws_send(socket, "c" + json_encode({"type": "hello", "id": id, "shell": st["shell"], "pid": st["pid"], "cwd": st["cwd"]}))
214
+ let sub be bus_subscribe("term.out." + id)
215
+ -- el replay vuelve marcado con este id de socket: otra pestaña abierta no lo repite
216
+ let me be text(floor(now() * 1000)) + "-" + text(floor(random() * 100000))
217
+ term.ctl(id, {"k": "replay", "to": me})
218
+ while true
219
+ let ev be select({"ui": socket, "sh": sub}, 20)
220
+ when ev == nothing
221
+ when not term.alive(id)
222
+ stop
223
+ term.ctl(id, {"k": "ping"}) -- «sigo acá»: sin pings se recoge la shell
224
+ otherwise when ev["name"] == "ui"
225
+ when ev["type"] == "close"
226
+ stop
227
+ otherwise when ev["type"] == "binary"
228
+ term.ctl(id, {"k": "in", "d": as_text(ev["data"])})
229
+ otherwise
230
+ let m be json_decode(ev["data"])
231
+ when m["type"] == "in"
232
+ term.ctl(id, {"k": "in", "d": m["data"]})
233
+ otherwise when m["type"] == "resize"
234
+ term.ctl(id, {"k": "resize", "cols": m["cols"], "rows": m["rows"]})
235
+ otherwise when m["type"] == "kill"
236
+ term.kill(id) -- cerrar la pestaña SÍ mata la shell, a pedido
215
237
  otherwise
216
- let m be json_decode(ev["data"])
217
- when m["type"] == "in"
218
- proc_send(p, m["data"])
219
- otherwise when m["type"] == "resize"
220
- proc_resize(p, floor(m["cols"]), floor(m["rows"]))
221
- otherwise when ev["type"] == "exit"
222
- ws_send(socket, "c" + json_encode({"type": "exit", "code": ev["data"]["exit_code"]}))
223
- set open to false
224
- otherwise
225
- ws_send(socket, "o" + as_text(ev["data"]))
226
- proc_close(p)
238
+ let d be ev["data"]
239
+ when d["k"] == "exit"
240
+ ws_send(socket, "c" + json_encode({"type": "exit", "code": d["code"]}))
241
+ stop
242
+ otherwise when d["k"] == "replay"
243
+ when d["to"] == me and d["d"] != ""
244
+ ws_send(socket, "o" + d["d"])
245
+ otherwise
246
+ ws_send(socket, "o" + d["d"])
247
+ bus_unsubscribe(sub)
248
+
249
+ -- las terminales vivas: la UI las repinta como pestañas al cargar (después de un F5 siguen ahí)
250
+ route "GET /w/:slug/api/terms" requires auth
251
+ give {"terminals": term.list(), "max": term.MAX_TERMS}
227
252
 
228
253
  route "GET /w/:slug/api/update" requires auth
229
254
  give update.check()