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 +13 -5
- package/chat.syn +64 -23
- package/lib/line.syn +17 -6
- package/lib/term.syn +162 -0
- package/lib/workspaces.syn +37 -1
- package/package.json +1 -1
- package/public/css/layout.css +36 -6
- package/public/css/sidebar.css +4 -0
- package/public/index.html +2 -2
- package/public/js/app.js +1 -1
- package/public/js/procs.js +6 -3
- package/public/js/schedules.js +1 -1
- package/public/js/sessions.js +4 -4
- package/public/js/terminal.js +153 -18
- package/public/js/theme.js +1 -1
- package/public/js/workspaces.js +7 -6
- package/skills/synsema/SKILL.md +8 -1
- package/web.syn +73 -48
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
|
|
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
|
-
- **
|
|
49
|
-
— `>_ terminal` in the header
|
|
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
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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.
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
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"]
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
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" +
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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"})
|
package/lib/workspaces.syn
CHANGED
|
@@ -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
|
-
|
|
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
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/sidebar.css
CHANGED
|
@@ -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="
|
|
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">>_ 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="
|
|
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">—</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
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)
|
|
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);
|
package/public/js/procs.js
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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);
|
package/public/js/schedules.js
CHANGED
|
@@ -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();
|
|
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;
|
package/public/js/sessions.js
CHANGED
|
@@ -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 = () =>
|
|
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();
|
|
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) =>
|
|
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
|
|
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();
|
package/public/js/terminal.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
// terminal.js —
|
|
2
|
-
// Frames
|
|
3
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
const send = (o) => { if (
|
|
34
|
-
|
|
35
|
-
|
|
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')
|
|
40
|
-
if (m.type === 'exit') {
|
|
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(() =>
|
|
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 ? '❐' : '□';
|
|
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
|
-
|
|
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.
|
package/public/js/theme.js
CHANGED
|
@@ -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
|
|
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();
|
package/public/js/workspaces.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(); }
|
package/skills/synsema/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
-
--
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
--
|
|
195
|
-
--
|
|
196
|
-
--
|
|
197
|
-
--
|
|
198
|
-
--
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
217
|
-
when
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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()
|