lampson 0.2.8 → 0.2.10

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.
Files changed (81) hide show
  1. package/.env.example +10 -1
  2. package/README.md +8 -3
  3. package/bin/lampson.js +1 -1
  4. package/chat.syn +111 -7
  5. package/cli.syn +143 -7
  6. package/edge.syn +26 -0
  7. package/hub.tpl.syn +127 -6
  8. package/lampson.ps1 +43 -2
  9. package/lampson.sh +40 -2
  10. package/lib/agents.syn +27 -6
  11. package/lib/approvals.syn +2 -2
  12. package/lib/auth.syn +274 -0
  13. package/lib/computer.syn +2000 -0
  14. package/lib/computer_judge.syn +410 -0
  15. package/lib/fs.syn +19 -3
  16. package/lib/git.syn +2 -2
  17. package/lib/judge.syn +78 -0
  18. package/lib/line.syn +2 -2
  19. package/lib/loop.syn +36 -4
  20. package/lib/lsp.syn +4 -4
  21. package/lib/mcp.syn +20 -4
  22. package/lib/md.syn +11 -11
  23. package/lib/origin.syn +67 -0
  24. package/lib/permission.syn +21 -2
  25. package/lib/prompt.syn +9 -1
  26. package/lib/provider.syn +70 -20
  27. package/lib/schedule.syn +12 -12
  28. package/lib/session.syn +5 -5
  29. package/lib/settings.syn +65 -1
  30. package/lib/skills.syn +22 -6
  31. package/lib/tools/bash.syn +1 -1
  32. package/lib/tools/common.syn +1 -1
  33. package/lib/tools/fetch.syn +98 -71
  34. package/lib/tools/grep.syn +1 -1
  35. package/lib/tools/memo.syn +3 -3
  36. package/lib/tools/proc.syn +38 -20
  37. package/lib/tools/qr.js +26 -0
  38. package/lib/tools/url.syn +5 -5
  39. package/lib/tools.syn +23 -2
  40. package/lib/trace.syn +2 -2
  41. package/lib/tree.syn +29 -13
  42. package/lib/update.syn +1 -1
  43. package/lib/workspaces.syn +202 -36
  44. package/package.json +3 -2
  45. package/public/apple-touch-icon.png +0 -0
  46. package/public/badge-96.png +0 -0
  47. package/public/css/code.css +48 -0
  48. package/public/css/hub.css +9 -0
  49. package/public/css/login.css +16 -0
  50. package/public/css/mobile.css +95 -0
  51. package/public/css/panel.css +30 -0
  52. package/public/css/tokens.css +1 -1
  53. package/public/hub.html +11 -1
  54. package/public/icon-192.png +0 -0
  55. package/public/icon-512.png +0 -0
  56. package/public/icon-maskable-512.png +0 -0
  57. package/public/icons-src/badge.svg +12 -0
  58. package/public/icons-src/icon-maskable.svg +15 -0
  59. package/public/icons-src/icon.svg +13 -0
  60. package/public/index.html +16 -3
  61. package/public/js/agents.js +1 -1
  62. package/public/js/chat.js +1 -1
  63. package/public/js/config.js +145 -2
  64. package/public/js/core.js +4 -2
  65. package/public/js/devices.js +80 -0
  66. package/public/js/editor.js +232 -0
  67. package/public/js/login.js +78 -0
  68. package/public/js/mobile.js +122 -0
  69. package/public/js/procs.js +1 -1
  70. package/public/js/pwa.js +5 -0
  71. package/public/js/terminal.js +1 -1
  72. package/public/js/tree.js +12 -4
  73. package/public/login.html +43 -0
  74. package/public/manifest.webmanifest +16 -0
  75. package/public/offline.html +24 -0
  76. package/public/sw.js +63 -0
  77. package/public/vendor/highlight.min.js +1343 -0
  78. package/public/vendor/qrcode.js +2297 -0
  79. package/skills/computer-use/SKILL.md +178 -0
  80. package/skills/lampson/SKILL.md +5 -0
  81. package/web.syn +67 -6
@@ -7,7 +7,7 @@
7
7
  -- web.syn chat.syn → copias (refrescadas al arrancar) .lampson/ → estado propio (sesiones, tareas…)
8
8
  -- .lampson/global → junction a <home>/.lampson (config.json con keys, mcp/lsp globales, skills-*)
9
9
  -- con eso `file("workspace/*")`, `use "./lib/x.syn"` y todo el código de siempre funcionan SIN cambios.
10
- -- * cada workspace corre `synsema serve web.syn --port 808N --bind 127.0.0.1` DESACOPLADO del hub (start /b,
10
+ -- * cada workspace corre `synsema serve web.syn --port 471NN --bind 127.0.0.1` DESACOPLADO del hub (start /b,
11
11
  -- process_group:false): sobrevive a reinicios del hub y a cerrar terminales. Solo loopback.
12
12
  -- * el hub (hub.syn, :8080) se GENERA desde hub.tpl.syn con una ruta proxy por workspace y corre con
13
13
  -- `synsema serve hub.syn` desacoplado: al registrar/quitar un workspace se reescribe y un helper lo reinicia.
@@ -18,6 +18,7 @@
18
18
  use "./tools/bash.syn" as t_bash
19
19
  use "./tools/proc.syn" as proc
20
20
  use "./settings.syn" as settings
21
+ use "./auth.syn" as auth
21
22
 
22
23
  export let ROOT be ".lampson/ws"
23
24
  export let REGISTRY be ".lampson/workspaces.json"
@@ -47,11 +48,11 @@ export task home()
47
48
  let r be sh("pwd -W 2>/dev/null || pwd -P", 10)
48
49
  let p be trim(text(r["stdout"]))
49
50
  when is_win()
50
- give replace_text(p, "/", "\\")
51
+ give replace(p, "/", "\\")
51
52
  give p
52
53
 
53
54
  export task norm(p)
54
- let s be lower(replace_text(trim(text(p)), "\\", "/"))
55
+ let s be lower(replace(trim(text(p)), "\\", "/"))
55
56
  when matches(s, "/[a-z]/.*")
56
57
  set s to slice(s, 1, 2) + ":" + slice(s, 2, length(s))
57
58
  while length(s) > 1 and slice(s, length(s) - 1, length(s)) == "/"
@@ -64,7 +65,7 @@ export task slug_of(path)
64
65
  let n be norm(path)
65
66
  let parts be where(split(n, "/"), (x) => x != "")
66
67
  let base be when length(parts) > 0 then parts[length(parts) - 1] otherwise "workspace"
67
- let runs be find_all(base, "[a-z0-9]+")
68
+ let runs be regex_find_all(base, "[a-z0-9]+")
68
69
  let clean be when length(runs) == 0 then "project" otherwise join(runs, "-")
69
70
  give clean + "-" + slice(decode(sha256(n), "hex"), 0, 8)
70
71
 
@@ -102,10 +103,7 @@ export task migrate_ports()
102
103
  each k in keys(doc["workspaces"])
103
104
  let w be doc["workspaces"][k]
104
105
  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"])
106
+ kill_ports([w["port"]])
109
107
  set w["port"] to doc["next_port"]
110
108
  set doc["next_port"] to doc["next_port"] + 1
111
109
  set doc["workspaces"][k] to w
@@ -167,14 +165,14 @@ export task dir_of(slug)
167
165
 
168
166
  task link(where_, target)
169
167
  when is_win()
170
- let r be run("cmd", ["/c", "mklink", "/J", replace_text(where_, "/", "\\"), replace_text(target, "/", "\\")], 15)
168
+ let r be run("cmd", ["/c", "mklink", "/J", replace(where_, "/", "\\"), replace(target, "/", "\\")], 15)
171
169
  give r["exit_code"] == 0
172
170
  let r be run("ln", ["-s", target, where_], 10)
173
171
  give r["exit_code"] == 0
174
172
 
175
173
  task unlink(where_)
176
174
  when is_win()
177
- run("cmd", ["/c", "rmdir", replace_text(where_, "/", "\\")], 10)
175
+ run("cmd", ["/c", "rmdir", replace(where_, "/", "\\")], 10)
178
176
  otherwise
179
177
  run("rm", ["-f", where_], 10)
180
178
  give true
@@ -187,7 +185,7 @@ task ensure_dir(p)
187
185
  when is_win()
188
186
  -- argumentos separados, como mklink: una línea entera con comillas dentro de `cmd /c` se rompe al citarla.
189
187
  -- mkdir de cmd crea los intermedios y falla (sin daño) si ya existe.
190
- run("cmd", ["/c", "mkdir", replace_text(p, "/", "\\")], 10)
188
+ run("cmd", ["/c", "mkdir", replace(p, "/", "\\")], 10)
191
189
  otherwise
192
190
  run("mkdir", ["-p", p], 10)
193
191
  give true
@@ -254,7 +252,7 @@ task migrate(w, d)
254
252
  recover err
255
253
  set doc to nothing
256
254
  when doc != nothing and contains(doc, "meta") and contains(doc["meta"], "project") and doc["meta"]["project"] == slug
257
- let id be replace_text(e["name"], ".json", "")
255
+ let id be replace(e["name"], ".json", "")
258
256
  sh("mkdir -p '" + d + "/.lampson/sessions' '" + d + "/.lampson/trace' && mv -f '.lampson/sessions/" + e["name"] + "' '" + d + "/.lampson/sessions/' && mv -f .lampson/trace/" + id + ".* '" + d + "/.lampson/trace/' 2>/dev/null; true", 20)
259
257
  set moved to moved + 1
260
258
  recover err
@@ -313,7 +311,7 @@ export task ensure(path)
313
311
  let port be doc["next_port"]
314
312
  set doc["next_port"] to port + 1
315
313
  save_reg(doc)
316
- let parts be where(split(replace_text(text(path), "\\", "/"), "/"), (x) => x != "")
314
+ let parts be where(split(replace(text(path), "\\", "/"), "/"), (x) => x != "")
317
315
  set w to {"slug": slug, "name": parts[length(parts) - 1], "path": text(path), "port": port, "policy": "auto", "created": now(), "last_used": now()}
318
316
  put(w)
319
317
  when regen_hub() and hub_alive()
@@ -350,8 +348,10 @@ export task remove(slug)
350
348
  task hub_routes(w)
351
349
  let base be "http://127.0.0.1:" + text(w["port"])
352
350
  let lines be []
351
+ -- el API pide auth en el hub: el proceso del workspace ve 127.0.0.1 y confía en todo lo que le llega.
352
+ -- Los links de aprobación no: llevan su propio token de un solo uso.
353
353
  each m in ["GET", "POST"]
354
- set lines to append(lines, " route \"" + m + " /w/" + w["slug"] + "/api/*path\"\n proxy to \"" + base + "\"")
354
+ set lines to append(lines, " route \"" + m + " /w/" + w["slug"] + "/api/*path\" requires auth\n proxy to \"" + base + "\"")
355
355
  set lines to append(lines, " route \"GET /w/" + w["slug"] + "/approve/*path\"\n proxy to \"" + base + "\"")
356
356
  give join(lines, "\n")
357
357
 
@@ -365,7 +365,10 @@ export task regen_hub()
365
365
  let blocks be []
366
366
  each w in all()
367
367
  set blocks to append(blocks, hub_routes(w))
368
- let body be replace_text(tpl, " -- @workspaces", when length(blocks) == 0 then " -- (sin workspaces registrados todavía)" otherwise join(blocks, "\n"))
368
+ let body be replace(tpl, " -- @workspaces", when length(blocks) == 0 then " -- (sin workspaces registrados todavía)" otherwise join(blocks, "\n"))
369
+ -- con dominio propio, de afuera se entra SOLO por el edge (:443, TLS): el hub deja de escuchar en la red
370
+ when domain_cfg() != nothing
371
+ set body to replace(body, " -- @bind", " bind \"127.0.0.1\" -- dominio activo (lampson --domain): de afuera se entra por el edge HTTPS")
369
372
  let cur be ""
370
373
  try
371
374
  set cur to read_file(HUB_FILE)
@@ -384,8 +387,8 @@ task spawn_detached(cwd, cmdline, envs)
384
387
  when is_win()
385
388
  -- WorkingDirectory absoluto: el relativo se resolvería contra el cwd del PowerShell (que ya es `cwd`)
386
389
  let h be home()
387
- let abs be when cwd == "." then h otherwise h + "\\" + replace_text(cwd, "/", "\\")
388
- let ps be "Start-Process -FilePath cmd -ArgumentList '/c', '" + replace_text(cmdline, "'", "''") + "' -WorkingDirectory '" + replace_text(abs, "'", "''") + "' -WindowStyle Hidden"
390
+ let abs be when cwd == "." then h otherwise h + "\\" + replace(cwd, "/", "\\")
391
+ let ps be "Start-Process -FilePath cmd -ArgumentList '/c', '" + replace(cmdline, "'", "''") + "' -WorkingDirectory '" + replace(abs, "'", "''") + "' -WindowStyle Hidden"
389
392
  let p be proc_spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], {"cwd": cwd, "env": envs, "process_group": false})
390
393
  proc_wait(p, 15)
391
394
  proc_close(p)
@@ -414,6 +417,10 @@ export task start_ws(w)
414
417
  let sep be when is_win() then "\\" otherwise "/"
415
418
  let envf be h + sep + ".env"
416
419
  let envs be {"LAMPSON_WS": w["slug"], "LAMPSON_WORKSPACE": w["path"], "LAMPSON_PORT": text(w["port"]), "LAMPSON_HOME": h}
420
+ -- el juez de computer use (⚙ → Computer use): el runtime lo lee del entorno al arrancar — por eso aplica al reiniciar
421
+ let je be settings.judge_env()
422
+ each k in keys(je)
423
+ set envs[k] to je[k]
417
424
  let exe be env("LAMPSON_SYNSEMA", "synsema")
418
425
  -- el .env de la instalación (cwd = home; la capability es relativa, la ruta que se pasa al hijo es absoluta)
419
426
  let cmd be exe + " serve web.syn --port " + text(w["port"]) + " --bind 127.0.0.1" + (when file_exists(".env") then " --env-file \"" + envf + "\"" otherwise "") + " > .lampson/web.log 2>&1"
@@ -470,16 +477,30 @@ export task pause_ws(w)
470
477
  summary()
471
478
  give true
472
479
 
473
- export task stop_ws(w)
480
+ -- matar lo que escucha en estos puertos: UNA consulta de puertos (proc.port_owners) para todos → cuántos mató
481
+ export task kill_ports(ports)
474
482
  require exec
475
- require time
476
483
  require env("LAMPSON_*")
477
484
  require env("OS")
478
- require net("127.0.0.1")
479
- let victims be where(proc.listeners(), (l) => l["port"] == w["port"])
480
- each l in victims
481
- proc.kill_pid(l["pid"])
482
- give length(victims) > 0
485
+ let n be 0
486
+ let done be {}
487
+ each l in proc.port_owners()
488
+ when contains(ports, l["port"]) and not contains(done, text(l["pid"]))
489
+ set done[text(l["pid"])] to true
490
+ proc.kill_pid(l["pid"])
491
+ set n to n + 1
492
+ give n
493
+
494
+ export task stop_ws(w)
495
+ give kill_ports([w["port"]]) > 0
496
+
497
+ -- Lampson entero (lampson --hub stop, ⚙ → Apagar o reiniciar): workspaces y hub en una sola pasada, y el edge
498
+ export task stop_all()
499
+ let ports be [HUB_PORT]
500
+ each w in all()
501
+ set ports to append(ports, w["port"])
502
+ stop_edge()
503
+ give kill_ports(ports)
483
504
 
484
505
  -- ---------- hub ----------
485
506
  export task hub_alive()
@@ -508,15 +529,34 @@ export task start_hub()
508
529
  spawn_detached(".", exe + " serve " + HUB_FILE + " > .lampson/hub.log 2>&1", {"LAMPSON_HOME": home()})
509
530
  give true
510
531
 
511
- -- hub.syn cambió (workspace nuevo/quitado): un helper DESACOPLADO (cli.syn hub-restart) para el hub y lo vuelve a
512
- -- arrancar — puede llamarse desde el propio hub (que va a morir en el medio) o desde cli.syn
532
+ -- un comando de cli.syn en un helper DESACOPLADO: para lo que el propio hub pide y en el medio lo mata a él
533
+ -- (reiniciarse, apagar Lampson entero). Log en .lampson/<cmd>.log
534
+ export task cli_later(cmd)
535
+ require exec
536
+ require time
537
+ require env("LAMPSON_*")
538
+ require env("OS")
539
+ let exe be env("LAMPSON_SYNSEMA", "synsema")
540
+ spawn_detached(".", exe + " run cli.syn > .lampson/" + cmd + ".log 2>&1", {"LAMPSON_HOME": home(), "LAMPSON_CMD": cmd, "LAMPSON_DETACHED": "1"})
541
+ give true
542
+
543
+ -- hub.syn cambió (workspace nuevo/quitado): cli.syn hub-restart para el hub y lo vuelve a arrancar (los workspaces
544
+ -- siguen) — puede llamarse desde el propio hub o desde cli.syn
513
545
  export task restart_hub_later()
546
+ give cli_later("hub-restart")
547
+
548
+ -- reiniciar UN workspace desde adentro (su propio proceso va a morir en el medio): un helper desacoplado
549
+ -- (cli.syn ws-restart) lo para y lo vuelve a arrancar con el entorno nuevo — el juez de computer use se lee al
550
+ -- arrancar el proceso (settings.judge_env), así que cambiarlo en ⚙ necesita esto (2026-09-28)
551
+ export task restart_ws_later(slug)
514
552
  require exec
515
553
  require time
516
554
  require env("LAMPSON_*")
517
555
  require env("OS")
518
556
  let exe be env("LAMPSON_SYNSEMA", "synsema")
519
- spawn_detached(".", exe + " run cli.syn > .lampson/hub-restart.log 2>&1", {"LAMPSON_HOME": home(), "LAMPSON_CMD": "hub-restart"})
557
+ -- cwd = la instalación: en Windows spawn_detached resuelve "." contra home(); en unix hay que dársela absoluta
558
+ let cwd be when is_win() then "." otherwise home()
559
+ spawn_detached(cwd, exe + " run cli.syn > .lampson/ws-restart.log 2>&1", {"LAMPSON_HOME": home(), "LAMPSON_CMD": "ws-restart", "LAMPSON_WS": slug})
520
560
  give true
521
561
 
522
562
  export task stop_hub()
@@ -524,10 +564,135 @@ export task stop_hub()
524
564
  require time
525
565
  require env("LAMPSON_*")
526
566
  require env("OS")
527
- let victims be where(proc.listeners(), (l) => l["port"] == HUB_PORT)
528
- each l in victims
529
- proc.kill_pid(l["pid"])
530
- give length(victims) > 0
567
+ give kill_ports([HUB_PORT]) > 0
568
+
569
+ -- ---------- dominio propio (opcional): edge HTTPS en :443 ----------
570
+ -- `lampson --domain lampson.midominio.com --email yo@midominio.com` guarda .lampson/domain.json; desde ahí el
571
+ -- supervisor mantiene vivo edge.syn (TLS de Let's Encrypt) y el hub pasa a escuchar solo en 127.0.0.1.
572
+ -- `lampson --domain off` lo quita y todo vuelve a ser como antes. Sin dominio, nada de esto corre.
573
+ export let EDGE_FILE be "edge.syn"
574
+ let DOMAIN_FILE be ".lampson/domain.json"
575
+ -- tras un arranque fallido no reintentar enseguida: Let's Encrypt limita las validaciones fallidas por hora
576
+ let EDGE_RETRY_S be 600
577
+
578
+ task edge_port()
579
+ give int(env("LAMPSON_EDGE_PORT", "443"))
580
+
581
+ export task domain_cfg()
582
+ require file.read(".lampson")
583
+ require file.read(".lampson/*")
584
+ try
585
+ when file_exists(DOMAIN_FILE)
586
+ let d be json_decode(read_file(DOMAIN_FILE))
587
+ -- ojo: este módulo define su propia task `get(slug)`, que tapa el builtin get(mapa, clave, default)
588
+ when type_of(d) == "map" and contains(d, "domain") and text(d["domain"]) != ""
589
+ give d
590
+ recover err
591
+ give nothing
592
+ give nothing
593
+
594
+ export task valid_domain(d)
595
+ give matches(d, "[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+") and not matches(d, "[0-9.]+")
596
+
597
+ -- guarda el dominio; el email (contacto de Let's Encrypt) se pide la primera vez y después se recuerda
598
+ export task set_domain(domain, email)
599
+ require file(".lampson")
600
+ require file(".lampson/*")
601
+ require env("LAMPSON_*")
602
+ let d be lower(trim(text(domain)))
603
+ set d to regex_replace(d, "^https?://", "")
604
+ set d to regex_replace(d, "/.*$", "")
605
+ when not valid_domain(d)
606
+ raise "«" + text(domain) + "» no parece un dominio (ej.: lampson.midominio.com)"
607
+ let prev be domain_cfg()
608
+ let em be trim(text(email))
609
+ when em == "" and prev != nothing
610
+ set em to when contains(prev, "email") then text(prev["email"]) otherwise ""
611
+ when not contains(em, "@")
612
+ raise "falta el email para Let's Encrypt (avisos de vencimiento del certificado): --email vos@dominio.com"
613
+ write_file(DOMAIN_FILE, json_encode({"domain": d, "email": em, "since": now()}))
614
+ -- el QR de emparejamiento apunta al dominio
615
+ auth.set_public_url("https://" + d)
616
+ give {"domain": d, "email": em}
617
+
618
+ export task clear_domain()
619
+ require file(".lampson")
620
+ require file(".lampson/*")
621
+ require env("LAMPSON_*")
622
+ let prev be domain_cfg()
623
+ write_file(DOMAIN_FILE, "{}")
624
+ when prev != nothing and auth.public_url() == "https://" + prev["domain"]
625
+ auth.set_public_url("")
626
+ give prev != nothing
627
+
628
+ export task edge_alive()
629
+ require exec
630
+ require time
631
+ require env("OS")
632
+ give length(edge_pids()) > 0
633
+
634
+ export task start_edge()
635
+ require exec
636
+ require time
637
+ require env("LAMPSON_*")
638
+ require env("OS")
639
+ require file(".lampson")
640
+ require file(".lampson/*")
641
+ let cfg be domain_cfg()
642
+ when cfg == nothing
643
+ give false
644
+ write_file(".lampson/edge.last", text(now()))
645
+ let exe be env("LAMPSON_SYNSEMA", "synsema")
646
+ let cmd be exe + " serve " + EDGE_FILE + " --port " + text(edge_port()) + " --domain " + cfg["domain"] + " --tls-auto " + cfg["email"] + " > .lampson/edge.log 2>&1"
647
+ spawn_detached(".", cmd, {"LAMPSON_HOME": home()})
648
+ give true
649
+
650
+ -- el edge escucha en :443, un puerto que en un VPS puede ser de otro (nginx, caddy): se reconoce por su línea de
651
+ -- comando, no solo por el puerto
652
+ task edge_pids()
653
+ let out be []
654
+ each l in proc.port_owners()
655
+ when l["port"] == edge_port() and contains(proc.command_of(l["pid"]), EDGE_FILE)
656
+ set out to append(out, l["pid"])
657
+ give out
658
+
659
+ export task stop_edge()
660
+ require exec
661
+ require time
662
+ require env("OS")
663
+ let pids be edge_pids()
664
+ each pid in pids
665
+ proc.kill_pid(pid)
666
+ give length(pids) > 0
667
+
668
+ -- lo llama el tick: dominio configurado + edge caído → levantarlo (con espera tras un intento fallido)
669
+ export task keep_edge()
670
+ require exec
671
+ require time
672
+ require env("LAMPSON_*")
673
+ require env("OS")
674
+ require file(".lampson")
675
+ require file(".lampson/*")
676
+ when domain_cfg() == nothing or edge_alive()
677
+ give false
678
+ let last be 0
679
+ try
680
+ set last to number(read_file(".lampson/edge.last"))
681
+ recover err
682
+ set last to 0
683
+ when now() - last < EDGE_RETRY_S
684
+ give false
685
+ give start_edge()
686
+
687
+ -- últimas líneas del log del edge (para diagnosticar desde la CLI)
688
+ export task edge_log(n)
689
+ require file.read(".lampson")
690
+ require file.read(".lampson/*")
691
+ try
692
+ let lines be where(split(replace(read_file(".lampson/edge.log"), "\r", ""), "\n"), (l) => trim(l) != "")
693
+ give slice(lines, max(0, length(lines) - n), length(lines))
694
+ recover err
695
+ give []
531
696
 
532
697
  -- ---------- política y supervisor (tick del hub) ----------
533
698
  export task idle_hours()
@@ -587,6 +752,7 @@ export task tick()
587
752
  require file.read("chat.syn")
588
753
  when migrate_ports() and hub_alive()
589
754
  restart_hub_later()
755
+ keep_edge()
590
756
  let started be []
591
757
  let stopped be []
592
758
  let list be all()
@@ -669,14 +835,14 @@ export task browse(path)
669
835
  require env("LAMPSON_*")
670
836
  require env("OS")
671
837
  let p be when path == nothing or trim(text(path)) == "" then env("HOME", env("USERPROFILE", "/")) otherwise text(path)
672
- let r be sh("cd \"" + replace_text(p, "\"", "") + "\" 2>/dev/null && (pwd -W 2>/dev/null || pwd -P) && ls -1 -d -- */ 2>/dev/null", 10)
673
- let lines be where(split(replace_text(text(r["stdout"]), "\r", ""), "\n"), (x) => trim(x) != "")
838
+ let r be sh("cd \"" + replace(p, "\"", "") + "\" 2>/dev/null && (pwd -W 2>/dev/null || pwd -P) && ls -1 -d -- */ 2>/dev/null", 10)
839
+ let lines be where(split(replace(text(r["stdout"]), "\r", ""), "\n"), (x) => trim(x) != "")
674
840
  when length(lines) == 0
675
841
  give {"path": p, "dirs": [], "error": "no existe o no se puede leer"}
676
842
  let here be lines[0]
677
843
  when is_win()
678
- set here to replace_text(here, "/", "\\")
679
- let dirs be apply(slice(lines, 1, length(lines)), (d) => replace_text(d, "/", ""))
680
- let parts be where(split(replace_text(here, "\\", "/"), "/"), (x) => x != "")
844
+ set here to replace(here, "/", "\\")
845
+ let dirs be apply(slice(lines, 1, length(lines)), (d) => replace(d, "/", ""))
846
+ let parts be where(split(replace(here, "\\", "/"), "/"), (x) => x != "")
681
847
  let parent be when length(parts) > 1 then join(slice(parts, 0, length(parts) - 1), when is_win() then "\\" otherwise "/") otherwise (when is_win() then parts[0] + "\\" otherwise "/")
682
848
  give {"path": here, "parent": parent, "dirs": where(dirs, (d) => not starts_with(d, "."))}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lampson",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "A coding agent written in Synsema: terminal + web, tools confined to the mounted project, plugins (your own tools, any language), LSP, MCP, sub-agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "chat.syn",
31
31
  "web.syn",
32
32
  "hub.tpl.syn",
33
+ "edge.syn",
33
34
  "cli.syn",
34
35
  "lampson.ps1",
35
36
  "lampson.sh",
@@ -42,7 +43,7 @@
42
43
  "node": ">=18"
43
44
  },
44
45
  "dependencies": {
45
- "synsema": "^0.6.11"
46
+ "synsema": "^0.6.42"
46
47
  },
47
48
  "scripts": {
48
49
  "test": "pwsh -NoProfile -File tests/run.ps1"
Binary file
Binary file
@@ -0,0 +1,48 @@
1
+ /* code: el editor de archivos del visor y el tema de highlight.js (archivos abiertos y bloques ``` del chat) */
2
+
3
+ /* ---- editor: textarea transparente sobre el <pre> resaltado; el gutter aparte sigue el scroll (js/editor.js) ---- */
4
+ #viewer.edit { height:100%; display:flex; flex-direction:column; }
5
+ #viewer.edit .vh { flex:none; }
6
+ #editor { flex:1; min-height:0; position:relative; overflow:hidden; background:var(--paper); }
7
+ #editor[hidden] { display:none; }
8
+ /* la misma métrica que #viewer pre (layout.css): 12.5px/1.62 mono, padding 14px 18px, números en 4ch + 16px */
9
+ #egut { position:absolute; left:0; top:0; width:calc(18px + 4ch + 16px); padding:14px 0 14px 18px; font:400 12.5px/1.62 var(--mono); color:var(--ink-3); text-align:right; user-select:none; pointer-events:none; will-change:transform; }
10
+ #egut span { display:block; padding-right:16px; }
11
+ #viewer #ehl, #viewer #eta { position:absolute; inset:0; margin:0; padding:14px 18px 14px calc(18px + 4ch + 16px); font:400 12.5px/1.62 var(--mono); tab-size:4; white-space:pre; overflow-wrap:normal; word-break:normal; letter-spacing:normal; }
12
+ #viewer #ehl { overflow:hidden; pointer-events:none; color:var(--ink); }
13
+ #viewer #ehl code { display:block; font:inherit; white-space:pre; min-height:100%; min-width:max-content; }
14
+ /* min/max-height:none pisan el textarea genérico del composer (layout.css: max-height 220px) */
15
+ #viewer #eta { display:block; width:100%; height:100%; min-height:0; max-height:none; border:0; outline:0; resize:none; background:transparent; color:transparent; -webkit-text-fill-color:transparent; caret-color:var(--ink); overflow:auto; }
16
+ #viewer #eta::selection { background:var(--sel); color:transparent; }
17
+ #viewer #eta[readonly] { caret-color:transparent; cursor:default; }
18
+ /* cabecera del visor en modo editor: lenguaje · líneas · estado; el botón guardar solo si hay algo que guardar */
19
+ .vh .dirty { color:var(--amber); font-weight:600; }
20
+ .vh .note { color:var(--str); }
21
+ .vh .warn { color:var(--amber); }
22
+ .vh .err { color:var(--rubric); }
23
+ .vh a { color:var(--accent); text-decoration:none; border-bottom:1px dashed var(--rule-2); }
24
+ .vh a:hover { color:var(--ink); border-bottom-color:var(--ink-3); }
25
+ .vh #vsave { padding:4px 9px; font-size:9.5px; letter-spacing:.08em; }
26
+ .vh #vsave:disabled { opacity:.4; }
27
+ .vh .x.ask { color:var(--ink); }
28
+ .vh .x.ask .yes { color:var(--rubric); cursor:pointer; font-weight:600; }
29
+ .vh .x.ask .no { cursor:pointer; }
30
+
31
+ /* ---- highlight.js: la paleta se raciona como en el resto de la UI — azul lo estructural (keywords, tags), verde
32
+ strings, ámbar números/literales/builtins/atributos, rubric rojo lo que abre permisos en Synsema (require,
33
+ allow, deny…), gris los comentarios. Funciona en dark y light porque todo son tokens. ---- */
34
+ .hljs { color:var(--ink); background:transparent; }
35
+ .hljs-comment, .hljs-quote, .hljs-meta, .hljs-meta .hljs-keyword, .hljs-meta .hljs-string { color:var(--ink-3); font-style:italic; }
36
+ .hljs-keyword, .hljs-selector-tag, .hljs-tag, .hljs-name, .hljs-doctag, .hljs-section, .hljs-template-tag { color:var(--accent); }
37
+ .hljs-string, .hljs-regexp, .hljs-addition, .hljs-char.escape_, .hljs-symbol, .hljs-bullet, .hljs-link { color:var(--str); }
38
+ .hljs-number, .hljs-literal, .hljs-built_in, .hljs-type, .hljs-attr, .hljs-attribute, .hljs-selector-class, .hljs-selector-id, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-variable, .hljs-template-variable, .hljs-variable.language_, .hljs-property { color:var(--amber); }
39
+ .hljs-title, .hljs-title.function_, .hljs-title.class_, .hljs-title.class_.inherited__, .hljs-title.function_.invoke__ { color:var(--ink); font-weight:600; }
40
+ .hljs-deletion { color:var(--rubric); }
41
+ .hljs-keyword.security_ { color:var(--rubric); }
42
+ .hljs-subst { color:var(--ink); font-weight:400; }
43
+ .hljs-punctuation, .hljs-operator { color:var(--ink-2); }
44
+ .hljs-emphasis { font-style:italic; }
45
+ .hljs-strong { font-weight:600; }
46
+ /* xml/html: el nombre del tag azul, sus atributos ámbar, los valores verdes */
47
+ .hljs-tag .hljs-attr { color:var(--amber); }
48
+ .hljs-tag .hljs-string { color:var(--str); }
@@ -14,3 +14,12 @@ body.hub #stage { display:flex; align-items:center; padding:20px 40px; }
14
14
  .card.ws .nm { font:600 16px/1.3 var(--serif); color:var(--ink); margin:0 0 4px; }
15
15
  .card.ws .pth { font:400 11px/1.4 var(--mono); color:var(--ink-3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin:0 0 6px; }
16
16
  .card.ws .meta { font:400 11px/1.4 var(--mono); color:var(--ink-3); margin:0; }
17
+ /* dispositivos: QR de emparejamiento */
18
+ .pairqr { display:flex; gap:16px; align-items:flex-start; margin-top:16px; flex-wrap:wrap; }
19
+ .pairqr .qr { width:220px; height:220px; background:#fff; border-radius:var(--r); padding:6px; }
20
+ .pairqr .qr svg { width:100%; height:100%; display:block; }
21
+ .pairqr .qrmeta { display:flex; flex-direction:column; gap:8px; min-width:180px; flex:1; }
22
+ @media (max-width: 640px) {
23
+ body.hub #stage { padding:16px; align-items:flex-start; }
24
+ .wsgrid { grid-template-columns:1fr; }
25
+ }
@@ -0,0 +1,16 @@
1
+ /* login.css — /login y /pair: una tarjeta centrada, pensada primero para el celular */
2
+ body.auth { display:flex; align-items:center; justify-content:center; min-height:100vh; min-height:100dvh; padding:24px 16px calc(24px + env(safe-area-inset-bottom)); }
3
+ .authbox { width:100%; max-width:400px; }
4
+ .authbox .logo { display:block; margin:0 0 18px; border-radius:10px; }
5
+ .authbox .eyebrow { font:400 11px/1 var(--mono); letter-spacing:.08em; text-transform:uppercase; color:var(--ink-3); margin:0 0 8px; }
6
+ .authbox h1 { font:600 26px/1.2 var(--serif); margin:0 0 10px; }
7
+ .authbox .lead { color:var(--ink-2); margin:0 0 22px; }
8
+ .authbox .hint { font:400 12px/1.5 var(--mono); color:var(--ink-3); margin:18px 0 0; }
9
+ .authbox .field { display:block; font:400 11px/1 var(--mono); color:var(--ink-3); margin:0 0 16px; }
10
+ .authbox .field input { display:block; width:100%; margin-top:6px; padding:12px; font:400 16px var(--mono); color:var(--ink); background:var(--paper-2); border:1px solid var(--rule-2); border-radius:var(--r); }
11
+ .authbox .field input:focus { outline:none; border-color:var(--accent); }
12
+ .authbox button.primary { width:100%; padding:14px; font:600 15px var(--mono); color:var(--paper); background:var(--accent); border:0; border-radius:var(--r); cursor:pointer; }
13
+ .authbox button.primary:disabled { opacity:.55; cursor:default; }
14
+ .authbox .msg { font:400 13px/1.5 var(--mono); margin:16px 0 0; min-height:1.5em; color:var(--ink-2); }
15
+ .authbox .msg.err { color:var(--rubric); }
16
+ .authbox .msg.ok { color:var(--ok); }
@@ -0,0 +1,95 @@
1
+ /* mobile.css — la vista de celular (≤ 900 px), con js/mobile.js. Va ÚLTIMA en la cascada: solo pisa lo que cambia.
2
+ Barra inferior Chat · Sesiones · Archivos · Más; chips de agente/permisos/modelo sobre la caja de texto; paneles y
3
+ modales a pantalla completa; nada más ancho que la pantalla (el código y las tablas se deslizan dentro de su caja). */
4
+ @media (min-width:901px) { .mtabs, .mchips, .msheet, .mnew { display:none !important; } }
5
+
6
+ @media (max-width:900px) {
7
+ html, body { height:100%; overflow:hidden; }
8
+ body { height:100dvh; grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr) auto; grid-template-areas:"top" "main" "tabs"; }
9
+
10
+ /* header: una fila — dónde estás, ⚙ y tema; la segunda fila se reparte entre los chips y «Más» */
11
+ header { padding-top:env(safe-area-inset-top); }
12
+ header .hrow { height:48px; padding:0 8px 0 14px; gap:4px; }
13
+ header .hrow.sub { display:none; }
14
+ header #term, header #new { display:none; }
15
+ .brand { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; font-size:15px; }
16
+ header .spacer { display:none; }
17
+ .theme-toggle { padding:10px; }
18
+
19
+ /* paneles: uno por pestaña, a pantalla completa en el lugar del chat */
20
+ aside { display:none; }
21
+ body[data-mtab="side"] aside:not(.right), body[data-mtab="files"] aside.right { display:flex; grid-area:main; border:0; overflow:auto; }
22
+ body[data-mtab="side"] main, body[data-mtab="files"] main { display:none; }
23
+ aside .fold, aside .rail { display:none !important; }
24
+ aside.closed .sec { display:flex; }
25
+ aside .sec h2 { padding:15px 16px 12px; font-size:11.5px; }
26
+ aside.right h2, aside:not(.right) > .sec:first-of-type h2 { padding-right:16px; }
27
+ aside .sec .body { padding:0 12px 12px 16px; font-size:15px; }
28
+ aside .sec .body > * { min-height:34px; }
29
+ .mnew { flex:none; margin:12px 14px 4px; padding:12px; font:600 11.5px/1 var(--mono); text-transform:uppercase; letter-spacing:.09em; color:var(--accent); background:var(--accent-bg); border:1px solid var(--accent); border-radius:var(--r); cursor:pointer; }
30
+
31
+ /* chat: todo el ancho, nada más ancho que la pantalla */
32
+ #log { padding:14px 16px 18px; max-width:none; }
33
+ #log.hero { padding:18px 16px; align-items:flex-start; }
34
+ .user, .assistant, .step { overflow-wrap:anywhere; }
35
+ .assistant pre, .step pre { max-width:100%; }
36
+ .assistant table { display:block; max-width:100%; overflow-x:auto; }
37
+ .assistant img { max-width:100%; height:auto; }
38
+ .empty code { white-space:normal; }
39
+ .empty .grid { grid-template-columns:minmax(0,1fr); }
40
+ .approval .card { max-width:none; }
41
+ .approval .card button { min-height:40px; }
42
+
43
+ /* composer: chips arriba, texto de 16 px (iOS no hace zoom), botón grande para el dedo */
44
+ form { padding:8px 10px 10px; }
45
+ /* el texto y el botón en la misma fila (como en cualquier app de mensajes); las imágenes pegadas, arriba */
46
+ .composer { display:flex; flex-wrap:wrap; align-items:flex-end; gap:8px; padding:6px 6px 6px 12px; }
47
+ .composer .attach { flex-basis:100%; }
48
+ .composer textarea { flex:1; width:auto; min-width:0; min-height:0; font-size:16px; max-height:30vh; padding:7px 0; }
49
+ .crow { flex:none; margin:0; }
50
+ .crow .keys, .crow .tools { display:none; }
51
+ #send { width:44px; height:40px; font-size:17px; }
52
+ #stop { height:40px; }
53
+ /* chips: UNA fila que se desliza de costado (en 4 renglones le comían medio chat) */
54
+ .mchips { display:flex; flex-wrap:nowrap; gap:6px; margin:0 -10px 8px; padding:0 10px; overflow-x:auto; scrollbar-width:none; }
55
+ .mchips::-webkit-scrollbar { display:none; }
56
+ .mchips #wsPill { display:none !important; }
57
+ .mchips select, .mchips .pill { flex:none; height:30px; font:400 12px/1 var(--mono); color:var(--ink-2); background:var(--paper-2); border:1px solid var(--rule-2); border-radius:15px; padding:0 10px; }
58
+ .mchips select { padding-right:4px; }
59
+ .mchips .pill { display:inline-flex; align-items:center; max-width:62vw; }
60
+ .mchips .pill.on { color:var(--accent); }
61
+ .mchips .pill.nokey { color:var(--rubric); }
62
+
63
+ /* barra inferior */
64
+ .mtabs { grid-area:tabs; display:flex; background:var(--paper); border-top:1px solid var(--rule); padding-bottom:env(safe-area-inset-bottom); }
65
+ .mtabs button { flex:1; position:relative; display:flex; flex-direction:column; align-items:center; gap:3px; padding:9px 0 7px; background:none; border:0; color:var(--ink-3); font:400 10.5px/1 var(--mono); letter-spacing:.04em; cursor:pointer; -webkit-tap-highlight-color:transparent; }
66
+ .mtabs button.on { color:var(--accent); }
67
+ .mtabs .badge { position:absolute; top:4px; left:calc(50% + 8px); min-width:16px; height:16px; padding:0 4px; border-radius:8px; background:var(--rubric); color:#fff; font:600 10px/16px var(--mono); font-style:normal; }
68
+
69
+ /* «Más»: hoja desde abajo */
70
+ .msheet { position:fixed; inset:0; z-index:70; display:flex; flex-direction:column; justify-content:flex-end; }
71
+ .msheet .mback { position:absolute; inset:0; background:rgba(0,0,0,.45); }
72
+ .msheet .mcard { position:relative; background:var(--paper); border-top:1px solid var(--rule-2); border-radius:12px 12px 0 0; padding:10px 12px calc(12px + env(safe-area-inset-bottom)); display:flex; flex-direction:column; gap:4px; }
73
+ .msheet .mwhere { padding:8px 6px 10px; border-bottom:1px solid var(--rule); margin-bottom:4px; font:400 12px/1.5 var(--mono); color:var(--ink-3); overflow-wrap:anywhere; }
74
+ .msheet .mgit { color:var(--ink-2); margin-bottom:2px; }
75
+ .msheet button { text-align:left; padding:14px 8px; background:none; border:0; border-radius:var(--r); color:var(--ink); font:400 16px/1 var(--serif); text-transform:none; letter-spacing:0; cursor:pointer; }
76
+ .msheet button:active { background:var(--paper-2); }
77
+ .msheet .mcancel { text-align:center; color:var(--ink-3); font:400 12px/1 var(--mono); text-transform:uppercase; letter-spacing:.09em; margin-top:4px; border-top:1px solid var(--rule); border-radius:0; }
78
+
79
+ /* modales (configuración, sesiones, plugins…): pantalla completa; en «browse» la lista arriba y el detalle abajo */
80
+ .modal { align-items:stretch; justify-content:stretch; }
81
+ .panel, .panel.lg, .panel.md, .panel.sm { width:100vw; height:100dvh; max-height:none; border:0; border-radius:0; box-shadow:none; padding:calc(10px + env(safe-area-inset-top)) 14px calc(12px + env(safe-area-inset-bottom)); }
82
+ .panel .browse { grid-template-columns:minmax(0,1fr); grid-template-rows:minmax(0,38%) minmax(0,1fr); }
83
+ .panel .blist { border-right:0; border-bottom:1px solid var(--rule); padding:10px 0 8px; }
84
+ .panel .bdetail { padding:12px 2px 8px; }
85
+ .panel input, .panel select, .panel textarea { font-size:16px; }
86
+ .panel .pghost { width:36px; height:36px; flex:none; }
87
+ /* cabecera del modal: la ✕ siempre a la vista (la ruta de «sub» la empujaba fuera de la pantalla) */
88
+ .panel .phead { gap:8px; min-width:0; }
89
+ .panel .phead .sub { display:none; }
90
+ .panel .phead h3 { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
91
+
92
+ /* terminal y visor de archivos: el ancho de la pantalla */
93
+ #termpane .vh { overflow-x:auto; }
94
+ #viewer pre { font-size:12px; }
95
+ }
@@ -154,3 +154,33 @@
154
154
  .panel .wsbrowser .bitems { max-height:30vh; overflow-y:auto; padding:4px; }
155
155
  .panel .wsbrowser .bfoot { padding:8px 10px; border-top:1px solid var(--rule); display:flex; justify-content:flex-end; }
156
156
  .panel .wsbrowser .bfoot button { font:400 12px/1 var(--mono); padding:6px 12px; }
157
+ /* computer use (⚙ → Computer use): estado del driver externo e instrucciones de instalación */
158
+ .panel .cu-status { margin:4px 0 14px; padding:10px 12px; border:1px solid var(--rule); border-radius:var(--r); }
159
+ .panel .cu-line { font:500 12.5px/1.5 var(--mono); color:var(--ink); }
160
+ .panel .cu-line.ok { color:#3fb950; }
161
+ .panel .cu-line.warn { color:#d29922; }
162
+ .panel .cu-line.no { color:var(--ink-3); }
163
+ .panel .cu-status ul { margin:6px 0 0 16px; padding:0; }
164
+ .panel .cu-status p { margin:6px 0; }
165
+ .panel .cu-cmd { margin:8px 0; padding:8px 10px; border:1px dashed var(--rule); border-radius:var(--r); font:12px/1.5 var(--mono); color:var(--ink); white-space:pre-wrap; user-select:all; }
166
+ .panel .dform label .chk { display:flex; align-items:center; gap:8px; font:400 13px/1.4 var(--serif); text-transform:none; letter-spacing:0; color:var(--ink); }
167
+ .panel .dform label .chk input { width:16px; height:16px; margin:0; accent-color:var(--accent); }
168
+ /* interruptor visible (computer use): pill con perilla; el texto al lado dice el estado */
169
+ .panel label.toggle { position:relative; display:inline-flex; align-items:center; gap:10px; margin:0 0 0 auto; cursor:pointer; user-select:none; font:500 12.5px/1 var(--mono); text-transform:none; letter-spacing:0; color:var(--ink-3); }
170
+ .panel .dform .trow label.toggle { margin:0; font:500 13px/1.3 var(--serif); color:var(--ink); }
171
+ .panel label.toggle input { position:absolute; opacity:0; width:0; height:0; margin:0; }
172
+ .panel label.toggle .knob { position:relative; flex:none; width:40px; height:22px; border-radius:11px; background:var(--rule); border:1px solid var(--rule); box-sizing:border-box; transition:background .15s, border-color .15s; }
173
+ .panel label.toggle .knob::after { content:""; position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%; background:var(--ink); opacity:.75; transition:left .15s, background .15s, opacity .15s; }
174
+ .panel label.toggle input:checked + .knob { background:var(--accent); border-color:var(--accent); }
175
+ .panel label.toggle input:checked + .knob::after { left:20px; background:var(--paper); opacity:1; }
176
+ .panel label.toggle input:disabled + .knob { opacity:.45; cursor:not-allowed; }
177
+ .panel label.toggle.on { color:var(--accent); }
178
+ .panel .dform .trow label.toggle.on { color:var(--ink); }
179
+ .panel .dform .trow { display:flex; flex-direction:column; gap:6px; margin:4px 0 16px; }
180
+ /* ⚙ Apagar o reiniciar: botones que se confirman en el lugar (inlineConfirm) */
181
+ .panel .dform .trow .hbtn.pwr { align-self:flex-start; }
182
+ .panel .dform .trow .hbtn.pwr.danger { color:var(--rubric); border-color:var(--rubric); }
183
+ .panel .dform .trow .hbtn.pwr:disabled { opacity:.45; cursor:default; }
184
+ .panel .dform .trow .hbtn.pwr.ask { text-transform:none; letter-spacing:0; }
185
+ .panel .dform .trow .hbtn.pwr.ask .yes { color:var(--rubric); cursor:pointer; text-decoration:underline; }
186
+ .panel .dform .trow .hbtn.pwr.ask .no { cursor:pointer; }
@@ -1,5 +1,5 @@
1
1
  /* tokens: fuentes, paleta (dark/light), base y botones — lo que todo lo demás asume */
2
- /* Misma dirección que docs.synsema.com: tinta sobre papel, una superfamilia (IBM Plex) en dos
2
+ /* Misma dirección que synsema.dev: tinta sobre papel, una superfamilia (IBM Plex) en dos
3
3
  voces — la SERIF para lo que se lee (mensajes, hero), la MONO para estructura y maquinaria
4
4
  (header, sidebar, pasos, código, controles). El color se raciona: azul = link / dónde estás,
5
5
  rubric rojo = peligro y permisos (la única cosa que vale la pena ver desde lejos). */