lampson 0.1.3 → 0.2.0

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 (55) hide show
  1. package/.env.example +9 -0
  2. package/README.md +87 -4
  3. package/bin/lampson.js +1 -1
  4. package/chat.syn +133 -0
  5. package/cli.syn +68 -0
  6. package/hub.tpl.syn +127 -0
  7. package/lampson.ps1 +77 -51
  8. package/lampson.sh +68 -22
  9. package/lib/agents.syn +1 -1
  10. package/lib/approvals.syn +179 -0
  11. package/lib/lsp.syn +10 -1
  12. package/lib/mcp.syn +10 -1
  13. package/lib/permission.syn +18 -0
  14. package/lib/sched_run.syn +161 -0
  15. package/lib/schedule.syn +660 -0
  16. package/lib/session.syn +38 -1
  17. package/lib/settings.syn +66 -3
  18. package/lib/skills.syn +2 -0
  19. package/lib/tools.syn +94 -2
  20. package/lib/workspaces.syn +555 -0
  21. package/package.json +3 -1
  22. package/public/css/chat.css +56 -0
  23. package/public/css/hub.css +12 -0
  24. package/public/css/layout.css +97 -0
  25. package/public/css/panel.css +125 -0
  26. package/public/css/sidebar.css +80 -0
  27. package/public/css/tokens.css +57 -0
  28. package/public/hub.html +36 -0
  29. package/public/index.html +51 -1197
  30. package/public/js/agents.js +31 -0
  31. package/public/js/app.js +21 -0
  32. package/public/js/approvals.js +26 -0
  33. package/public/js/chat.js +92 -0
  34. package/public/js/config.js +100 -0
  35. package/public/js/core.js +91 -0
  36. package/public/js/events.js +31 -0
  37. package/public/js/hub.js +40 -0
  38. package/public/js/lamps.js +112 -0
  39. package/public/js/lsp.js +81 -0
  40. package/public/js/mcp.js +74 -0
  41. package/public/js/memory.js +17 -0
  42. package/public/js/panel.js +101 -0
  43. package/public/js/procs.js +45 -0
  44. package/public/js/schedules.js +118 -0
  45. package/public/js/sessions.js +69 -0
  46. package/public/js/sidebar.js +33 -0
  47. package/public/js/terminal.js +49 -0
  48. package/public/js/theme.js +6 -0
  49. package/public/js/todo.js +10 -0
  50. package/public/js/tree.js +53 -0
  51. package/public/js/update.js +14 -0
  52. package/public/js/workspaces.js +83 -0
  53. package/skills/lampson/SKILL.md +21 -0
  54. package/skills/synsema/SKILL.md +4 -1
  55. package/web.syn +165 -55
package/lampson.ps1 CHANGED
@@ -1,28 +1,29 @@
1
- # lampson.ps1 — launcher Windows. Monta el proyecto como ./workspace (junction) y arranca.
1
+ # lampson.ps1 — launcher Windows. Cada proyecto es un WORKSPACE con su propio proceso; un hub en :8080 los sirve a todos.
2
2
  #
3
- # cd C:\mi\proyecto ; lampson # el directorio ACTUAL es el workspace (REPL)
4
- # cd C:\mi\proyecto ; lampson --web # servidor web en http://127.0.0.1:8080 (también -Web)
5
- # lampson --workspace C:\otro\proyecto # elegir la ubicación explícitamente (también -Workspace)
6
- # lampson --agent plan # perfil inicial: build | plan | review | explore
7
- # lampson --yolo | --strict | --ask # permisos para comandos peligrosos (--dangerously-skip-permissions = --yolo)
8
- # lampson --update # actualizar Lampson (git pull) y salir
9
- # lampson --help
3
+ # cd C:\mi\proyecto ; lampson # terminal sobre este workspace (lo registra y lo arranca si hace falta)
4
+ # cd C:\mi\proyecto ; lampson --web # abre http://127.0.0.1:8080/w/<slug>/ en el navegador
5
+ # lampson --workspace C:\otro\proyecto # elegir la carpeta explícitamente
6
+ # lampson --hub start|stop|status|logs # el servicio residente (workspaces + tareas programadas)
7
+ # lampson --install | --uninstall # arrancar el hub al iniciar sesión (Tarea Programada)
8
+ # lampson --agent plan · --yolo|--strict|--ask · --update · --help
10
9
  #
11
- # Por qué una junction: el scope file("./*") de Synsema v0.6.7 equivale a "*" (disco entero); el scope
12
- # file("workspace/*") confina. Montar el proyecto bajo un nombre literal es lo que hace real el
13
- # least-privilege de las tools (mismo modelo mental que `docker -v proyecto:/workspace`).
10
+ # Cómo funciona (ver SPEC-WORKSPACES.md): .lampson\ws\<slug>\ es el cwd del proceso del workspace, con una junction
11
+ # `workspace` al proyecto y junctions a lib/public/skills/lamps/memory de esta instalación. El registro y los procesos
12
+ # los maneja lib/workspaces.syn vía cli.syn; este script solo resuelve la carpeta, llama a cli.syn y abre lo pedido.
14
13
  $ErrorActionPreference = "Stop"
15
14
  $here = Split-Path -Parent $MyInvocation.MyCommand.Path
16
15
  $caller = (Get-Location).Path
17
- $mount = Join-Path $here "workspace"
18
16
 
19
17
  # --- args: acepta --flag y -Flag, sin distinguir mayúsculas ---
20
- $Workspace = ""; $Web = $false; $Agent = ""; $Perm = ""
18
+ $Workspace = ""; $Web = $false; $Agent = ""; $Perm = ""; $Hub = ""; $Install = ""
21
19
  $i = 0
22
20
  while ($i -lt $args.Count) {
23
21
  $a = [string]$args[$i]
24
22
  switch -Regex ($a.ToLower()) {
25
23
  '^--?(web|w)$' { $Web = $true }
24
+ '^--?(hub|daemon|d)$' { $i++; $Hub = if ($i -lt $args.Count) { ([string]$args[$i]).ToLower() } else { "status" } }
25
+ '^--?install$' { $Install = "install" }
26
+ '^--?uninstall$' { $Install = "uninstall" }
26
27
  '^--?(workspace|ws)$' { $i++; $Workspace = [string]$args[$i] }
27
28
  '^--?(agent|a)$' { $i++; $Agent = [string]$args[$i] }
28
29
  '^--?(yolo|y|dangerously-skip-permissions)$' { $Perm = "yolo" }
@@ -30,59 +31,84 @@ while ($i -lt $args.Count) {
30
31
  '^--?(ask)$' { $Perm = "ask" }
31
32
  '^--?(permission|p)$' { $i++; $Perm = ([string]$args[$i]).ToLower() }
32
33
  '^--?(update|u)$' { Write-Host "actualizando Lampson en $here"; git -C $here pull --ff-only origin main; Write-Host ("lampson " + (git -C $here rev-parse --short HEAD)); exit $LASTEXITCODE }
33
- '^--?(help|h|\?)$' { Get-Content $PSCommandPath | Select-Object -Skip 1 -First 9 | ForEach-Object { $_.TrimStart('#',' ') }; exit 0 }
34
+ '^--?(help|h|\?)$' { Get-Content $PSCommandPath | Select-Object -Skip 1 -First 8 | ForEach-Object { $_.TrimStart('#',' ') }; exit 0 }
34
35
  default { if ($Workspace -eq "" -and -not $a.StartsWith("-")) { $Workspace = $a } else { Write-Error "argumento desconocido: $a (probá lampson --help)"; exit 1 } }
35
36
  }
36
37
  $i++
37
38
  }
38
39
 
39
- # --- resolver el proyecto: explícito > directorio actual (si no es el propio lampson) > montado previo ---
40
- if ($Workspace -eq "" -and $caller -ne $here) { $Workspace = $caller }
41
- if ($Workspace -ne "") {
42
- if (-not (Test-Path -LiteralPath $Workspace -PathType Container)) { Write-Error "el workspace no existe o no es un directorio: $Workspace"; exit 1 }
43
- $target = (Resolve-Path -LiteralPath $Workspace).Path
44
- $home_ = [Environment]::GetFolderPath("UserProfile")
45
- if ($target.TrimEnd('\') -eq $home_.TrimEnd('\') -or $target -match '^[A-Za-z]:\\?$') {
46
- Write-Error "'$target' es tu carpeta personal / la raíz del disco, no un proyecto. Entrá al repo (cd) y volvé a correr lampson, o usá --workspace C:\ruta\al\proyecto"; exit 1
47
- }
48
- if ($target -eq $here) { Write-Error "no montes el propio directorio de lampson como workspace desde fuera; usá --workspace"; exit 1 }
49
- if (Test-Path -LiteralPath $mount) {
50
- $item = Get-Item -LiteralPath $mount -Force
51
- if ($item.LinkType -ne "Junction") { Write-Error "./workspace existe y no es una junction; movelo antes de montar otro proyecto"; exit 1 }
52
- $item.Delete()
53
- }
54
- New-Item -ItemType Junction -Path $mount -Target $target | Out-Null
55
- } elseif (-not (Test-Path -LiteralPath $mount)) {
56
- Write-Error "no hay workspace: corré lampson desde el directorio del proyecto, o lampson --workspace C:\ruta"; exit 1
57
- }
58
- $target = (Get-Item -LiteralPath $mount -Force).Target
59
- if ($target -is [array]) { $target = $target[0] }
60
-
61
- # --- skills externas globales (npx skills add -g → ~/.agents/skills; las de Claude Code → ~/.claude/skills) ---
62
- # Se montan como junction bajo .lampson/ porque una capability no puede apuntar a una ruta dinámica (HOME).
40
+ # skills externas globales (npx skills add -g ~/.agents/skills; Claude Code ~/.claude/skills), montadas bajo .lampson\
41
+ # (una capability no puede apuntar a HOME); los workspaces las ven por su junction .lampson\global
63
42
  $skillMounts = @{ "skills-global" = (Join-Path $HOME ".agents\skills"); "skills-claude" = (Join-Path $HOME ".claude\skills") }
64
43
  New-Item -ItemType Directory -Force (Join-Path $here ".lampson") | Out-Null
65
44
  foreach ($k in $skillMounts.Keys) {
66
45
  $link = Join-Path $here ".lampson\$k"; $src = $skillMounts[$k]
67
- if (Test-Path -LiteralPath $link) { $li = Get-Item -LiteralPath $link -Force; if ($li.LinkType -eq "Junction") { $li.Delete() } }
68
- if (Test-Path -LiteralPath $src -PathType Container) { New-Item -ItemType Junction -Path $link -Target $src | Out-Null }
46
+ if (-not (Test-Path -LiteralPath $link) -and (Test-Path -LiteralPath $src -PathType Container)) { New-Item -ItemType Junction -Path $link -Target $src | Out-Null }
47
+ }
48
+
49
+ function Invoke-Cli([string]$cmd, [string]$ws) {
50
+ # cli.syn imprime la respuesta como la ÚLTIMA línea (JSON)
51
+ $env:LAMPSON_CMD = $cmd; if ($ws -ne "") { $env:LAMPSON_WORKSPACE = $ws }
52
+ $out = & synsema run cli.syn 2>&1
53
+ if ($LASTEXITCODE -ne 0) { $out | ForEach-Object { Write-Host $_ }; throw "cli.syn falló ($cmd)" }
54
+ $lines = @($out | ForEach-Object { [string]$_ })
55
+ $lines | Select-Object -SkipLast 1 | ForEach-Object { Write-Host $_ }
56
+ return ($lines[-1] | ConvertFrom-Json)
69
57
  }
70
58
 
71
- # --- arrancar desde el directorio de lampson (ahí viven .env, lib/, skills/, .lampson/) ---
72
- # Push/Pop: si PowerShell ejecuta este .ps1 en la shell del usuario (pasa cuando lampson.ps1 y lampson.cmd
73
- # comparten nombre en el PATH), el cwd del usuario debe quedar como estaba al salir.
74
59
  Push-Location $here
75
60
  try {
76
- $env:LAMPSON_WORKSPACE = $target
77
- if ($Agent -ne "") { $env:LAMPSON_AGENT = $Agent }
78
- if ($Perm -ne "") { $env:LAMPSON_PERMISSION = $Perm }
61
+ $env:LAMPSON_HOME = $here
62
+ # --- hub / servicio ---
63
+ if ($Hub -ne "") {
64
+ switch ($Hub) {
65
+ "start" { $r = Invoke-Cli "hub-start" ""; Write-Host "hub: http://127.0.0.1:8080" }
66
+ "stop" { $r = Invoke-Cli "hub-stop" ""; Write-Host "hub y workspaces parados" }
67
+ "status" { $r = Invoke-Cli "hub-status" ""; Write-Host ("hub " + $(if ($r.alive) { "● vivo · http://127.0.0.1:8080" } else { "○ parado" })); foreach ($w in $r.workspaces) { Write-Host (" " + $(if ($w.alive) { "●" } else { "○" }) + " " + $w.name + " " + $w.path + " " + $w.policy) } }
68
+ "logs" { if (Test-Path ".lampson\hub.log") { Get-Content ".lampson\hub.log" -Tail 40 } else { Write-Host "sin log todavía" } }
69
+ "restart" { $null = Invoke-Cli "hub-stop" ""; Start-Sleep 1; $null = Invoke-Cli "hub-start" ""; Write-Host "hub reiniciado" }
70
+ default { Write-Error "uso: lampson --hub start|stop|status|logs|restart"; exit 1 }
71
+ }
72
+ exit 0
73
+ }
74
+ # --- arranque al iniciar sesión: Tarea Programada que corre el hub (synsema daemon, --watch) ---
75
+ if ($Install -eq "install") {
76
+ $cmd = "cmd /c cd /d `"$here`" && set LAMPSON_HOME=$here && synsema serve hub.syn > .lampson\hub.log 2>&1"
77
+ schtasks /Create /F /TN "Lampson hub" /SC ONLOGON /TR $cmd | Out-Null
78
+ Write-Host "instalado: el hub de Lampson arranca al iniciar sesión (Tarea Programada «Lampson hub»). Arrancándolo ahora…"
79
+ $null = Invoke-Cli "hub-start" ""
80
+ exit 0
81
+ }
82
+ if ($Install -eq "uninstall") { schtasks /Delete /F /TN "Lampson hub" | Out-Null; Write-Host "quitado el arranque al iniciar sesión (el hub sigue corriendo hasta lampson --hub stop)"; exit 0 }
83
+
84
+ # --- resolver el proyecto: explícito > directorio actual (si no es el propio lampson) ---
85
+ if ($Workspace -eq "" -and $caller -ne $here) { $Workspace = $caller }
86
+ if ($Workspace -eq "") { Write-Error "corré lampson desde la carpeta del proyecto, o lampson --workspace C:\ruta (lampson --hub status lista los workspaces)"; exit 1 }
87
+ if (-not (Test-Path -LiteralPath $Workspace -PathType Container)) { Write-Error "el workspace no existe o no es un directorio: $Workspace"; exit 1 }
88
+ $target = (Resolve-Path -LiteralPath $Workspace).Path
89
+ $home_ = [Environment]::GetFolderPath("UserProfile")
90
+ if ($target.TrimEnd('\') -eq $home_.TrimEnd('\') -or $target -match '^[A-Za-z]:\\?$') {
91
+ Write-Error "'$target' es tu carpeta personal / la raíz del disco, no un proyecto. Entrá al repo (cd) y volvé a correr lampson, o usá --workspace C:\ruta\al\proyecto"; exit 1
92
+ }
93
+ if ($target -eq $here) { Write-Error "no montes el propio directorio de lampson como workspace"; exit 1 }
94
+
95
+ # registrar/preparar el workspace y asegurar hub + proceso
96
+ $r = Invoke-Cli "ensure" $target
97
+ $wsDir = Join-Path $here ($r.dir -replace '/', '\')
79
98
  if ($Web) {
80
- Write-Host "Lampson web · workspace: $target"
81
- Write-Host "abrí http://127.0.0.1:8080 (Ctrl+C para parar)"
82
- synsema serve web.syn
83
- } else {
84
- synsema run chat.syn
99
+ Write-Host ("Lampson web · " + $r.url + " (workspace " + $r.slug + $(if ($r.alive) { " ● vivo" } else { " ○ arrancando" }) + ")")
100
+ Start-Process $r.url
101
+ exit 0
85
102
  }
103
+ # terminal: chat.syn dentro del directorio del workspace (misma junction `workspace` que el proceso web)
104
+ Push-Location $wsDir
105
+ try {
106
+ $env:LAMPSON_WORKSPACE = $target; $env:LAMPSON_WS = $r.slug; $env:LAMPSON_PORT = [string]$r.port
107
+ if ($Agent -ne "") { $env:LAMPSON_AGENT = $Agent }
108
+ if ($Perm -ne "") { $env:LAMPSON_PERMISSION = $Perm }
109
+ $envFile = Join-Path $here ".env"
110
+ if (Test-Path -LiteralPath $envFile) { synsema run --env-file $envFile chat.syn } else { synsema run chat.syn }
111
+ } finally { Pop-Location }
86
112
  } finally {
87
113
  Pop-Location
88
114
  }
package/lampson.sh CHANGED
@@ -1,42 +1,88 @@
1
1
  #!/usr/bin/env bash
2
- # lampson.sh — launcher unix. El directorio ACTUAL es el workspace (o --workspace /ruta).
3
- # cd /mi/proyecto && lampson # REPL (con lampson/ en el PATH)
4
- # cd /mi/proyecto && lampson --web # servidor web en http://127.0.0.1:8080
5
- # lampson --workspace /otro/proyecto [--web] [--agent plan]
2
+ # lampson.sh — launcher unix. Cada proyecto es un WORKSPACE con su propio proceso; un hub en :8080 los sirve a todos.
3
+ # cd /mi/proyecto && lampson # terminal sobre este workspace
4
+ # cd /mi/proyecto && lampson --web # abre http://127.0.0.1:8080/w/<slug>/
5
+ # lampson --hub start|stop|status|logs|restart · --install (systemd --user) · --uninstall
6
+ # lampson --workspace /ruta [--agent plan] [--yolo|--strict|--ask] · --update
6
7
  set -euo pipefail
7
8
  here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
9
  caller="$(pwd)"
9
- ws=""; web=0
10
+ ws=""; web=0; hub=""; install=""
10
11
  while [ $# -gt 0 ]; do
11
12
  case "$1" in
12
13
  --web) web=1 ;;
14
+ --hub|--daemon) shift; hub="${1:-status}" ;;
15
+ --install) install=install ;;
16
+ --uninstall) install=uninstall ;;
13
17
  --workspace) shift; ws="$1" ;;
14
18
  --agent) shift; export LAMPSON_AGENT="$1" ;;
15
19
  --yolo|--dangerously-skip-permissions) export LAMPSON_PERMISSION=yolo ;;
16
20
  --strict) export LAMPSON_PERMISSION=strict ;;
17
21
  --ask) export LAMPSON_PERMISSION=ask ;;
18
22
  --update) echo "actualizando Lampson en $here"; git -C "$here" pull --ff-only origin main; echo "lampson $(git -C "$here" rev-parse --short HEAD)"; exit $? ;;
19
- *) echo "uso: lampson [--web] [--workspace RUTA] [--agent PERFIL] [--yolo|--strict|--ask] [--update]" >&2; exit 1 ;;
23
+ -*) echo "uso: lampson [--web] [--hub start|stop|status|logs|restart] [--install|--uninstall] [--workspace RUTA] [--agent PERFIL] [--yolo|--strict|--ask] [--update]" >&2; exit 1 ;;
24
+ *) ws="$1" ;;
20
25
  esac
21
26
  shift
22
27
  done
23
- [ -z "$ws" ] && [ "$caller" != "$here" ] && ws="$caller"
24
- if [ -n "$ws" ]; then
25
- [ -d "$ws" ] || { echo "el workspace no existe o no es un directorio: $ws" >&2; exit 1; }
26
- ws="$(cd "$ws" && pwd)"
27
- if [ -e "$here/workspace" ] && [ ! -L "$here/workspace" ]; then echo "./workspace existe y no es un symlink" >&2; exit 1; fi
28
- rm -f "$here/workspace"; ln -s "$ws" "$here/workspace"
29
- elif [ ! -e "$here/workspace" ]; then
30
- echo "no hay workspace: corré lampson desde el directorio del proyecto, o --workspace /ruta" >&2; exit 1
31
- fi
32
- export LAMPSON_WORKSPACE="$(readlink -f "$here/workspace")"
33
- # skills externas globales (npx skills add -g → ~/.agents/skills; Claude Code → ~/.claude/skills), montadas bajo .lampson/
28
+ export LAMPSON_HOME="$here"
29
+ # skills externas globales, montadas bajo .lampson/ (los workspaces las ven por .lampson/global)
34
30
  mkdir -p "$here/.lampson"
35
31
  for pair in "skills-global:$HOME/.agents/skills" "skills-claude:$HOME/.claude/skills"; do
36
32
  link="$here/.lampson/${pair%%:*}"; src="${pair#*:}"
37
- [ -L "$link" ] && rm -f "$link"
38
- [ -d "$src" ] && ln -s "$src" "$link"
33
+ [ -e "$link" ] || { [ -d "$src" ] && ln -s "$src" "$link"; } || true
39
34
  done
40
- echo "workspace -> $LAMPSON_WORKSPACE"
41
- cd "$here"
42
- if [ "$web" = 1 ]; then echo "web: http://127.0.0.1:8080"; exec synsema serve web.syn; else exec synsema run chat.syn; fi
35
+ cli() { # última línea = JSON
36
+ local out; out="$(cd "$here" && LAMPSON_CMD="$1" LAMPSON_WORKSPACE="${2:-}" synsema run cli.syn)" || { echo "$out" >&2; exit 1; }
37
+ echo "$out" | sed '$d' >&2 || true
38
+ echo "$out" | tail -n 1
39
+ }
40
+ json() { node -e "const j=JSON.parse(process.argv[1]);const v=j[process.argv[2]];console.log(typeof v==='object'?JSON.stringify(v):v)" "$1" "$2" 2>/dev/null || python3 -c "import json,sys;print(json.loads(sys.argv[1])[sys.argv[2]])" "$1" "$2"; }
41
+ if [ -n "$hub" ]; then
42
+ case "$hub" in
43
+ start) cli hub-start >/dev/null; echo "hub: http://127.0.0.1:8080" ;;
44
+ stop) cli hub-stop >/dev/null; echo "hub y workspaces parados" ;;
45
+ status) r="$(cli hub-status)"; echo "hub $( [ "$(json "$r" alive)" = "true" ] && echo '● vivo · http://127.0.0.1:8080' || echo '○ parado')"; (cd "$here" && LAMPSON_CMD=list synsema run cli.syn | sed '$d') ;;
46
+ logs) [ -f "$here/.lampson/hub.log" ] && tail -n 40 "$here/.lampson/hub.log" || echo "sin log todavía" ;;
47
+ restart) cli hub-stop >/dev/null; sleep 1; cli hub-start >/dev/null; echo "hub reiniciado" ;;
48
+ *) echo "uso: lampson --hub start|stop|status|logs|restart" >&2; exit 1 ;;
49
+ esac
50
+ exit 0
51
+ fi
52
+ if [ "$install" = install ]; then
53
+ unit="$HOME/.config/systemd/user/lampson-hub.service"; mkdir -p "$(dirname "$unit")"
54
+ cat > "$unit" <<EOF
55
+ [Unit]
56
+ Description=Lampson hub (workspaces, tareas programadas, proxy)
57
+ After=network.target
58
+ [Service]
59
+ WorkingDirectory=$here
60
+ Environment=LAMPSON_HOME=$here
61
+ ExecStart=$(command -v synsema) serve hub.syn
62
+ Restart=always
63
+ RestartSec=3
64
+ TimeoutStopSec=20
65
+ [Install]
66
+ WantedBy=default.target
67
+ EOF
68
+ (cd "$here" && LAMPSON_CMD=hub-stop synsema run cli.syn >/dev/null 2>&1 || true)
69
+ systemctl --user daemon-reload && systemctl --user enable --now lampson-hub.service && echo "instalado: systemd --user lampson-hub.service (arranca con tu sesión; 'loginctl enable-linger $USER' para que viva sin sesión abierta)"
70
+ exit 0
71
+ fi
72
+ if [ "$install" = uninstall ]; then systemctl --user disable --now lampson-hub.service || true; rm -f "$HOME/.config/systemd/user/lampson-hub.service"; systemctl --user daemon-reload; echo "quitado"; exit 0; fi
73
+ # --- proyecto ---
74
+ [ -z "$ws" ] && [ "$caller" != "$here" ] && ws="$caller"
75
+ [ -n "$ws" ] || { echo "corré lampson desde la carpeta del proyecto, o --workspace /ruta (lampson --hub status lista los workspaces)" >&2; exit 1; }
76
+ [ -d "$ws" ] || { echo "el workspace no existe o no es un directorio: $ws" >&2; exit 1; }
77
+ ws="$(cd "$ws" && pwd -P)"
78
+ [ "$ws" = "$HOME" ] || [ "$ws" = "/" ] && { echo "'$ws' no es un proyecto" >&2; exit 1; }
79
+ r="$(cli ensure "$ws")"
80
+ slug="$(json "$r" slug)"; port="$(json "$r" port)"; url="$(json "$r" url)"; dir="$(json "$r" dir)"
81
+ if [ "$web" = 1 ]; then
82
+ echo "Lampson web · $url (workspace $slug)"
83
+ (xdg-open "$url" >/dev/null 2>&1 || open "$url" >/dev/null 2>&1 || true)
84
+ exit 0
85
+ fi
86
+ cd "$here/$dir"
87
+ export LAMPSON_WORKSPACE="$ws" LAMPSON_WS="$slug" LAMPSON_PORT="$port"
88
+ if [ -f "$here/.env" ]; then exec synsema run --env-file "$here/.env" chat.syn; else exec synsema run chat.syn; fi
package/lib/agents.syn CHANGED
@@ -40,7 +40,7 @@ let PARALLEL be 4
40
40
 
41
41
  export let PROFILES be {
42
42
  "build": {
43
- "tools": ["read", "write", "edit", "ls", "find", "grep", "lsp", "bash", "process", "memory", "todo", "skill", "mcp", "lamp", "delegate"],
43
+ "tools": ["read", "write", "edit", "ls", "find", "grep", "lsp", "bash", "process", "memory", "todo", "skill", "mcp", "lamp", "schedule", "delegate"],
44
44
  "steps": 40,
45
45
  "addendum": "You are in BUILD mode: you may read, edit, create files and run commands. Delegate exploration of large codebases to the `explore` agent and independent code review to the `review` agent when it saves context; several independent questions can go in one delegate call (they run in parallel)."
46
46
  },
@@ -0,0 +1,179 @@
1
+ -- lib/approvals.syn — aprobaciones humanas fuera de la terminal: cola compartida + webhook + links de decisión
2
+ --
3
+ -- Un solo mecanismo para las puertas por las que un humano aprueba algo que el agente quiere hacer:
4
+ -- 1. la UI web (POST /api/approve desde el chat, o desde el panel «Aprobaciones» para las tareas programadas);
5
+ -- 2. la terminal: /approve <id> yes|no (una tarea programada corriendo en background mientras chateás);
6
+ -- 3. un LINK firmado que llega por el canal que sea (Telegram, mail, Slack…): GET /approve/<id>/<token>?d=yes|no
7
+ -- — de un solo uso; el token es un secreto por aprobación (32 bytes CSPRNG), igual que el `approve` nativo
8
+ -- de Synsema bajo serve (doc 62-human), solo que acá también sirve para las tareas del cron y para la UI;
9
+ -- 4. la consola del proceso (el link se imprime ahí también).
10
+ -- Además, si LAMPSON_WEBHOOK_URL está configurado (o config.json {"webhook_url"}), cada aprobación pendiente dispara
11
+ -- un POST JSON con {id, message, why, expires_at, respond_link_yes, respond_link_no} firmado con HMAC-SHA256
12
+ -- (X-Lampson-Signature: sha256=<hex>, secreto LAMPSON_WEBHOOK_SECRET) — el mismo patrón que Stripe/GitHub, así
13
+ -- cualquier receptor (n8n, un bot, otro .syn) reenvía los links. Los links necesitan LAMPSON_PUBLIC_URL (la URL
14
+ -- por la que se llega a este lampson desde afuera: un túnel, el VPS, un edge Synsema con TLS).
15
+ -- Fire-and-forget (10 s, un intento): un canal caído nunca bloquea; la UI y la consola quedan como respaldo.
16
+ --
17
+ -- Estado: BLACKBOARD (share/observe), que existe bajo `serve` y bajo `run` y lo ven handlers, agentes y ticks
18
+ -- (`state_*` solo existe bajo serve — verificado 2026-08-29). Claves: "approval:meta:<id>" (visible, sin token),
19
+ -- "approval:token:<id>", "approval:answer:<id>" (yes|no) y el índice "approval:index" (lista de ids).
20
+ --
21
+ -- Garantía: NUNCA se auto-aprueba. Sin respuesta al vencer el plazo → denegado (fail-closed), como Synsema.
22
+
23
+ use "./settings.syn" as settings
24
+
25
+ task read_cfg(key, envname)
26
+ let v be env(envname, "")
27
+ when v != ""
28
+ give v
29
+ let doc be settings.load()
30
+ when contains(doc, key)
31
+ give text(doc[key])
32
+ give ""
33
+
34
+ export task webhook_url()
35
+ require env("LAMPSON_*")
36
+ require file.read(".lampson")
37
+ require file.read(".lampson/*")
38
+ give read_cfg("webhook_url", "LAMPSON_WEBHOOK_URL")
39
+
40
+ export task public_url()
41
+ require env("LAMPSON_*")
42
+ require file.read(".lampson")
43
+ require file.read(".lampson/*")
44
+ let u be read_cfg("public_url", "LAMPSON_PUBLIC_URL")
45
+ while length(u) > 0 and slice(u, length(u) - 1, length(u)) == "/"
46
+ set u to slice(u, 0, length(u) - 1)
47
+ give u
48
+
49
+ task secret_text()
50
+ give read_cfg("webhook_secret", "LAMPSON_WEBHOOK_SECRET")
51
+
52
+ task index()
53
+ observe "approval:index" as ix
54
+ give when ix == nothing then [] otherwise ix
55
+
56
+ task meta_of(id)
57
+ observe "approval:meta:" + id as m
58
+ give m
59
+
60
+ task set_meta(id, m)
61
+ share m as "approval:meta:" + id
62
+
63
+ -- abre una aprobación: id único, token de un solo uso, metadatos visibles (sin token) para la UI.
64
+ -- source = "chat" | "schedule:<id>"; message = lo que se quiere hacer; why = por qué pide permiso.
65
+ export task open(source, message, why, timeout_s)
66
+ require time
67
+ require random
68
+ require net
69
+ require env("LAMPSON_*")
70
+ require file.read(".lampson")
71
+ require file.read(".lampson/*")
72
+ let id be "a" + slice(text(floor(now() * 1000)), 4, 13) + "-" + slice(token(16), 0, 6)
73
+ let tok be token(32)
74
+ let meta be {"id": id, "source": source, "message": message, "why": why, "created": now(), "expires_at": floor(now() + timeout_s), "status": "pending"}
75
+ set_meta(id, meta)
76
+ share tok as "approval:token:" + id
77
+ share nothing as "approval:answer:" + id
78
+ share append(index(), id) as "approval:index"
79
+ bus_publish("approval.request", meta)
80
+ let links be links_for(id, tok)
81
+ print("[lampson] aprobación pendiente " + id + " — " + message + " (vence en " + text(floor(timeout_s)) + " s)" + (when links["yes"] != "" then "\n sí: " + links["yes"] + "\n no: " + links["no"] otherwise "\n respondé desde la UI web (Aprobaciones), con /approve " + id + " yes|no en la terminal, o configurá la URL pública para recibir links"))
82
+ notify(meta, links)
83
+ give id
84
+
85
+ -- bajo el hub cada workspace vive en /w/<slug> (LAMPSON_WS); el link público lleva ese prefijo
86
+ task links_for(id, tok)
87
+ let slug be env("LAMPSON_WS", "")
88
+ let prefix be when slug == "" then "" otherwise "/w/" + slug
89
+ let base be public_url()
90
+ when base == ""
91
+ give {"yes": "", "no": "", "path": prefix + "/approve/" + id + "/" + tok}
92
+ give {"yes": base + prefix + "/approve/" + id + "/" + tok + "?d=yes", "no": base + prefix + "/approve/" + id + "/" + tok + "?d=no", "path": prefix + "/approve/" + id + "/" + tok}
93
+
94
+ -- webhook firmado (fire-and-forget)
95
+ task notify(meta, links)
96
+ let url be webhook_url()
97
+ when url == ""
98
+ give false
99
+ let payload be {"id": meta["id"], "type": "approve", "source": meta["source"], "message": meta["message"], "why": meta["why"], "expires_at": meta["expires_at"], "respond_path": links["path"], "respond_link_yes": links["yes"], "respond_link_no": links["no"]}
100
+ let body be json_encode(payload)
101
+ let headers be {"Content-Type": "application/json", "User-Agent": "lampson"}
102
+ let sec be secret_text()
103
+ when sec != ""
104
+ set headers["X-Lampson-Signature"] to "sha256=" + hmac_sha256(body, sec)
105
+ try
106
+ let r be http_post(url, body, headers)
107
+ when not r["ok"]
108
+ print("[lampson] webhook " + url + " respondió " + text(r["status"]))
109
+ give r["ok"]
110
+ recover err
111
+ print("[lampson] webhook falló: " + text(err))
112
+ give false
113
+
114
+ -- espera (polling) hasta que alguien responda o venza el plazo. Devuelve true/false; sin respuesta = false.
115
+ export task wait(id, timeout_s)
116
+ require time
117
+ let deadline be now() + timeout_s
118
+ let answer be nothing
119
+ while answer == nothing and now() < deadline
120
+ observe "approval:answer:" + id as a
121
+ set answer to a
122
+ when answer == nothing
123
+ sleep(0.5)
124
+ let meta be meta_of(id)
125
+ let ok be answer == "yes"
126
+ when meta != nothing
127
+ set meta["status"] to when answer == nothing then "expired" otherwise (when ok then "approved" otherwise "denied")
128
+ set meta["answered"] to now()
129
+ set_meta(id, meta)
130
+ bus_publish("approval.result", meta)
131
+ share nothing as "approval:token:" + id
132
+ give ok
133
+
134
+ -- responder desde la UI (loopback, ya autenticado) o desde la terminal: sin token
135
+ export task answer(id, decision)
136
+ let meta be meta_of(id)
137
+ when meta == nothing
138
+ give false
139
+ when meta["status"] != "pending"
140
+ give false
141
+ let v be when decision then "yes" otherwise "no"
142
+ share v as "approval:answer:" + id
143
+ give true
144
+
145
+ -- responder por link: el token debe coincidir (comparación en tiempo constante) y ser de un solo uso
146
+ export task answer_with_token(id, tok, decision)
147
+ observe "approval:token:" + id as want
148
+ when want == nothing
149
+ give {"ok": false, "why": "expired"}
150
+ when not verify_hmac(id, hmac_sha256(id, tok), want)
151
+ give {"ok": false, "why": "bad token"}
152
+ share nothing as "approval:token:" + id
153
+ let v be when decision then "yes" otherwise "no"
154
+ share v as "approval:answer:" + id
155
+ give {"ok": true, "why": ""}
156
+
157
+ -- pendientes y recientes para la UI (nunca incluye tokens); las resueltas hace más de 1 h se olvidan
158
+ export task list()
159
+ require time
160
+ let out be []
161
+ let keep be []
162
+ each id in index()
163
+ let m be meta_of(id)
164
+ when m != nothing
165
+ when m["status"] != "pending" and contains(m, "answered") and now() - m["answered"] > 3600
166
+ share nothing as "approval:meta:" + id
167
+ otherwise
168
+ set out to append(out, m)
169
+ set keep to append(keep, id)
170
+ when length(keep) != length(index())
171
+ share keep as "approval:index"
172
+ give sort_by(out, (m) => 0 - m["created"])
173
+
174
+ export task pending()
175
+ give where(list(), (m) => m["status"] == "pending")
176
+
177
+ -- página mínima que ve quien abre el link
178
+ export task html_page(title, body)
179
+ give "<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>" + title + "</title><body style='font:16px/1.5 system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#f6f4ef;color:#1d1c1a'><div style='max-width:520px;padding:32px;border:1px solid #d9d4c7;border-radius:6px;background:#fffdf8'><p style='margin:0 0 6px;font:11px/1 ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase;color:#a33'>lampson</p><h1 style='font-size:22px;margin:0 0 12px'>" + title + "</h1><p style='margin:0'>" + body + "</p></div></body>"
package/lib/lsp.syn CHANGED
@@ -22,7 +22,16 @@
22
22
 
23
23
  use "./tools/common.syn" as c
24
24
 
25
- export let GLOBAL_CONFIG be ".lampson/lsp.json"
25
+ -- global = compartido por todos los workspaces: .lampson/global/ (junction al .lampson de la instalación) cuando
26
+ -- el proceso corre bajo el hub; .lampson/ en una instalación directa o en los tests
27
+ task global_config()
28
+ try
29
+ when file_exists(".lampson/global")
30
+ give ".lampson/global/lsp.json"
31
+ recover err
32
+ give ".lampson/lsp.json"
33
+ give ".lampson/lsp.json"
34
+ export let GLOBAL_CONFIG be global_config()
26
35
  export let PROJECT_CONFIG be "workspace/.lampson/lsp.json"
27
36
  let CALL_TIMEOUT be 60
28
37
  let START_WAIT be 25
package/lib/mcp.syn CHANGED
@@ -23,7 +23,16 @@
23
23
 
24
24
  use "./tools/common.syn" as c
25
25
 
26
- export let GLOBAL_CONFIG be ".lampson/mcp.json"
26
+ -- global = compartido por todos los workspaces: .lampson/global/ (junction al .lampson de la instalación) cuando
27
+ -- el proceso corre bajo el hub; .lampson/ en una instalación directa o en los tests
28
+ task global_config()
29
+ try
30
+ when file_exists(".lampson/global")
31
+ give ".lampson/global/mcp.json"
32
+ recover err
33
+ give ".lampson/mcp.json"
34
+ give ".lampson/mcp.json"
35
+ export let GLOBAL_CONFIG be global_config()
27
36
  export let PROJECT_CONFIG be "workspace/.lampson/mcp.json"
28
37
  let CALL_TIMEOUT be 120
29
38
 
@@ -114,6 +114,17 @@ export task evaluate(name, args, mode)
114
114
  give {"decision": "deny", "reason": "strict mode (" + act + " MCP server)"}
115
115
  give {"decision": "ask", "reason": act + "s MCP server '" + (when contains(args, "name") then text(args["name"]) otherwise "?") + "'" + (when act == "add" then " → runs: " + (when contains(args, "command") then text(args["command"]) otherwise "?") otherwise "")}
116
116
  give {"decision": "allow", "reason": "read-only tool"}
117
+ when name == "schedule"
118
+ -- programar = autorizar corridas futuras SIN nadie mirando (un comando, una lámpara, o el agente entero
119
+ -- con un sobre de permisos): humano siempre, incluso en yolo. list/log son lectura.
120
+ let sact be when contains(args, "action") then text(args["action"]) otherwise "list"
121
+ when sact == "list" or sact == "log"
122
+ give {"decision": "allow", "reason": "read-only tool"}
123
+ when mode == "strict"
124
+ give {"decision": "deny", "reason": "strict mode (schedule " + sact + ")"}
125
+ when sact == "add"
126
+ give {"decision": "ask", "reason": "schedules an unattended " + (when contains(args, "kind") then text(args["kind"]) otherwise "?") + " task" + (when contains(args, "permission") then " with permission=" + text(args["permission"]) otherwise " with permission=ask")}
127
+ give {"decision": "ask", "reason": "schedule " + sact + " " + (when contains(args, "id") then text(args["id"]) otherwise "?")}
117
128
  when name == "skill" and is_install(args)
118
129
  -- instala instrucciones/scripts de terceros FUERA del workspace (~/.agents/skills): humano siempre, incluso en yolo
119
130
  when mode == "strict"
@@ -145,6 +156,13 @@ export task describe_call(name, args)
145
156
  when act == "remove"
146
157
  give "mcp remove " + (when contains(args, "name") then text(args["name"]) otherwise "?")
147
158
  give "mcp list"
159
+ when name == "schedule"
160
+ let sact be arg(args, "action", "list")
161
+ when sact == "add"
162
+ let what be arg(args, "kind", "?")
163
+ let body be when what == "lamp" then "lamp " + arg(args, "lamp", "?") + "." + arg(args, "tool", "?") + (when contains(args, "args") then " " + one_line(json_encode(args["args"]), 80) otherwise "") otherwise (when what == "bash" then "$ " + one_line(arg(args, "command", "?"), 120) otherwise "agent " + arg(args, "agent", "build") + ": " + one_line(arg(args, "prompt", "?"), 160))
164
+ give "schedule add «" + arg(args, "name", "?") + "» · " + arg(args, "at", "?") + " · " + body + " · permission " + arg(args, "permission", "ask") + (when contains(args, "notify") then " · notify " + text(args["notify"]) otherwise "")
165
+ give "schedule " + sact + (when contains(args, "id") then " " + text(args["id"]) otherwise "")
148
166
  when name == "skill" and is_install(args)
149
167
  give "skill install " + text(args["source"]) + " --skill " + text(args["name"]) + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
150
168
  when name == "bash"