lampson 0.1.4 → 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.
package/README.md CHANGED
@@ -4,7 +4,10 @@
4
4
  reads, searches, edits and runs commands through tools that are confined to that project by the
5
5
  language itself — and every step is visible, in the terminal or in a web UI.
6
6
 
7
- - **Terminal or web**: `lampson` (REPL) or `lampson --web` (http://127.0.0.1:8080).
7
+ - **Workspaces, always on**: every project folder is a *workspace* with its own process (sessions, scheduled tasks,
8
+ MCP, lamps, dev servers), all behind one hub at http://127.0.0.1:8080 — open several at once and switch without
9
+ killing the others. `lampson` (terminal) and `lampson --web` (browser) open the workspace of the current folder;
10
+ `lampson --install` keeps the hub alive across logins.
8
11
  - **Any OpenAI- or Anthropic-compatible API** over raw HTTP: DeepSeek, Kimi, Groq, Grok, OpenRouter,
9
12
  Ollama, Anthropic, MiniMax… one `.env` line to switch.
10
13
  - **Least-privilege by construction**: each tool declares its capabilities; the runtime enforces
@@ -124,16 +127,38 @@ does the same with persistent sessions and memory.
124
127
  > Status: developed and tested on Windows 11; the Docker image (Ubuntu 24.04) is built by CI. The Linux/macOS
125
128
  > installer is written but not yet exercised on a real machine — issues welcome.
126
129
 
127
- ## Scheduled tasks and the resident process
128
-
129
- Lampson can run things with nobody at the keyboard. Scheduled tasks run inside **any** open Lampson — the web UI
130
- or the terminal REPL (a background thread ticks every 30 s). To run them with nothing open:
130
+ ## Workspaces and the hub (how it runs)
131
131
 
132
132
  ```
133
- lampson --daemon start # web UI + scheduler + approvals, in the background (synsema daemon)
134
- lampson --daemon status # also: stop · logs · restart
133
+ lampson # in a project folder: registers it as a workspace (first time), makes sure the hub and
134
+ # the workspace's process are up, opens the terminal on it
135
+ lampson --web # same, then opens http://127.0.0.1:8080/w/<slug> in the browser
136
+ http://127.0.0.1:8080 # the workspaces screen: open one, create one (native folder dialog; a server-side
137
+ # browser on a VPS), turn them on/off, life policy
138
+ lampson --hub start|stop|status|logs|restart
139
+ lampson --install # Windows: Scheduled Task at logon · Linux: systemd --user unit (Restart=always)
135
140
  ```
136
141
 
142
+ Why processes: in Synsema a tool's file capability is a literal path relative to the process cwd, so a workspace is
143
+ a directory `~/lampson/.lampson/ws/<slug>/` with a junction `workspace` → your project (plus junctions to the
144
+ install's `lib/`, `public/`, `skills/`, `lamps/`, `memory/`), and its own `.lampson/` state. Its process
145
+ (`synsema serve web.syn --port 808N --bind 127.0.0.1`) only ever sees that folder. The hub (`hub.syn`, generated
146
+ from `hub.tpl.syn`, one proxy route per workspace) is the only listener you use: it serves the UI and forwards
147
+ `/w/<slug>/api/…` — SSE and the WebSocket terminal included (Synsema ≥ 0.6.12). Workspace processes are detached
148
+ from the hub, so a hub restart never kills them. The hub is also the supervisor: every 15 s it starts what should
149
+ be alive and stops what is idle (⚙ → hours of inactivity, `0` = never; a workspace with enabled scheduled tasks or
150
+ policy «siempre vivo» stays up).
151
+
152
+ On a VPS put the hub behind your domain (`synsema serve hub.syn --port 443 --domain … --tls-auto …` or your
153
+ edge); workspace ports never leave loopback. `LAMPSON_PUBLIC_URL` = `https://host` and approval links carry
154
+ `/w/<slug>` automatically.
155
+
156
+ ## Scheduled tasks and the resident process
157
+
158
+ Lampson can run things with nobody at the keyboard. Scheduled tasks run inside the workspace's process (the hub
159
+ keeps it alive while it has enabled tasks) — or inside the terminal REPL when no process is up (a background thread
160
+ ticks every 30 s). `lampson --install` makes the hub survive reboots.
161
+
137
162
  Timezone, public URL and webhook are set from the ⚙ button of the web header (tabs: General · Aprobaciones a
138
163
  distancia · Proveedor; stored in `lampson/.lampson/config.json`, `.env` wins) or from `.env`.
139
164
 
@@ -230,9 +255,13 @@ run Lampson in a container.
230
255
  ## Architecture
231
256
 
232
257
  ```
233
- lampson.ps1 / .sh launcher: mounts ./workspace, starts terminal or web
258
+ lampson.ps1 / .sh launcher: resolves the workspace (cli.syn), ensures hub + process, opens terminal or browser
259
+ cli.syn what launchers ask Synsema: ensure/list/hub-start/hub-stop/hub-restart (last line = JSON)
260
+ hub.tpl.syn template of the hub (:8080): workspaces screen + API, supervisor tick, one proxy route per workspace
261
+ lib/workspaces.syn registry (.lampson/workspaces.json), ws/<slug> dirs + junctions, detached processes, health,
262
+ life policy, folder picker (native dialog / server-side browser)
234
263
  chat.syn terminal REPL (colors, approvals via Synsema's native `approve`)
235
- web.syn HTTP server: POST /api/chat → SSE events; sessions, tree, file viewer, processes, ports
264
+ web.syn one process per workspace: /w/:slug/api/… → SSE chat, sessions, tree, processes, schedules…
236
265
  public/ web UI (no build step, no dependencies; classic scripts served by `static`)
237
266
  index.html markup only: header, the two side panels, the chat; loads css/ and js/ in order
238
267
  css/ tokens (fonts, palette, base) · layout (grid, header, panels, chat, composer) · sidebar · chat · panel
package/bin/lampson.js CHANGED
@@ -22,7 +22,7 @@ const home = process.env.LAMPSON_HOME || path.join(os.homedir(), 'lampson');
22
22
  const marker = path.join(home, '.npm-installed');
23
23
 
24
24
  // what gets copied into the home: the runtime, never state. Keep in sync with "files" in package.json.
25
- const CODE = ['lib', 'public', 'skills', 'chat.syn', 'web.syn', 'lampson.ps1', 'lampson.sh', 'lampson.cmd', '.env.example', 'README.md', 'LICENSE'];
25
+ const CODE = ['lib', 'public', 'skills', 'chat.syn', 'web.syn', 'hub.tpl.syn', 'cli.syn', 'lampson.ps1', 'lampson.sh', 'lampson.cmd', '.env.example', 'README.md', 'LICENSE'];
26
26
 
27
27
  // copia un archivo; en unix, los scripts de shell y los .syn salen con LF aunque el paquete se haya armado
28
28
  // en Windows (un CRLF en lampson.sh rompe bash: "set: pipefail\r: invalid option")
package/chat.syn CHANGED
@@ -722,7 +722,17 @@ agent Sched
722
722
  otherwise when waited >= 30
723
723
  set waited to 0
724
724
  try
725
- lampson_sched_tick()
725
+ -- bajo el hub el proceso del workspace (LAMPSON_PORT) ya hace tick: no duplicar corridas
726
+ let port be env("LAMPSON_PORT", "")
727
+ let other be false
728
+ when port != ""
729
+ try
730
+ let hr be http_get("http://127.0.0.1:" + port + "/w/" + env("LAMPSON_WS", "x") + "/api/health", {"timeout": 2})
731
+ set other to hr["ok"]
732
+ recover e2
733
+ set other to false
734
+ when not other
735
+ lampson_sched_tick()
726
736
  recover err
727
737
  print("[lampson] scheduler: " + text(err))
728
738
  otherwise
package/cli.syn ADDED
@@ -0,0 +1,68 @@
1
+ -- cli.syn — lo que los launchers (lampson.ps1 / lampson.sh) le piden a Synsema. Corre con cwd = instalación.
2
+ --
3
+ -- LAMPSON_CMD=ensure LAMPSON_WORKSPACE=<ruta> synsema run cli.syn
4
+ -- registra/prepara el workspace, asegura el hub y su proceso → imprime UNA línea JSON {slug, port, dir, url, alive}
5
+ -- LAMPSON_CMD=hub-status → {alive}
6
+ -- LAMPSON_CMD=hub-start | hub-stop
7
+ -- LAMPSON_CMD=list
8
+ -- La salida útil va en la ÚLTIMA línea (JSON) para que el launcher la parsee.
9
+
10
+ require exec
11
+ require time
12
+ require net("127.0.0.1")
13
+ require env("LAMPSON_*")
14
+ require env("OS")
15
+ require env("HOME")
16
+ require env("USERPROFILE")
17
+ require file(".lampson")
18
+ require file(".lampson/*")
19
+ require file.read("web.syn")
20
+ require file.read(".env")
21
+ require file.read("chat.syn")
22
+ require file.read("hub.tpl.syn")
23
+ require file("hub.syn")
24
+
25
+ use "./lib/workspaces.syn" as ws
26
+
27
+ let cmd be env("LAMPSON_CMD", "ensure")
28
+
29
+ when cmd == "ensure"
30
+ let path be env("LAMPSON_WORKSPACE", "")
31
+ when path == ""
32
+ raise("LAMPSON_WORKSPACE required")
33
+ let w be ws.ensure(path)
34
+ when not ws.hub_alive()
35
+ ws.start_hub()
36
+ let hc be ws.health(w)
37
+ when hc == nothing
38
+ ws.start_ws(w)
39
+ ws.wait_ready(w, 10)
40
+ set hc to ws.health(w)
41
+ ws.touch(w["slug"])
42
+ print(json_encode({"slug": w["slug"], "port": w["port"], "dir": ws.dir_of(w["slug"]), "url": "http://127.0.0.1:" + text(ws.HUB_PORT) + "/w/" + w["slug"], "alive": hc != nothing, "hub": ws.hub_alive()}))
43
+ otherwise when cmd == "hub-status"
44
+ print(json_encode({"alive": ws.hub_alive(), "workspaces": ws.summary()}))
45
+ otherwise when cmd == "hub-start"
46
+ when not ws.hub_alive()
47
+ ws.start_hub()
48
+ print(json_encode({"alive": true}))
49
+ otherwise when cmd == "hub-stop"
50
+ each w in ws.all()
51
+ ws.stop_ws(w)
52
+ ws.stop_hub()
53
+ print(json_encode({"alive": false}))
54
+ otherwise when cmd == "hub-restart"
55
+ -- lanzado desacoplado por workspaces.restart_hub_later: espera a que el hub termine lo que estaba haciendo,
56
+ -- lo para y lo vuelve a arrancar con el hub.syn regenerado (bajo systemd, Restart=always ya lo levanta solo)
57
+ sleep(1.5)
58
+ ws.stop_hub()
59
+ sleep(2)
60
+ when not ws.hub_alive()
61
+ ws.start_hub()
62
+ print(json_encode({"restarted": true}))
63
+ otherwise when cmd == "list"
64
+ each w in ws.summary()
65
+ print((when w["alive"] then "● " otherwise "○ ") + w["slug"] + " " + w["path"] + " :" + text(w["port"]) + " " + w["policy"])
66
+ print(json_encode({"count": length(ws.all())}))
67
+ otherwise
68
+ raise("unknown LAMPSON_CMD " + cmd)
package/hub.tpl.syn ADDED
@@ -0,0 +1,127 @@
1
+ -- hub.tpl.syn — PLANTILLA del hub. lib/workspaces.syn genera hub.syn (gitignored) reemplazando `-- @workspaces` por una
2
+ -- ruta proxy por workspace; el hub corre con `synsema daemon start hub.syn --watch` y se reinicia solo al cambiar.
3
+ --
4
+ -- El hub es lo único que escucha hacia afuera (:8080; en un VPS, con --domain/--tls-auto):
5
+ -- GET / pantalla de workspaces (public/hub.html)
6
+ -- GET /w/<slug> la UI de ese workspace (public/index.html; los assets /css /js los sirve el hub)
7
+ -- * /w/<slug>/api/… proxy al proceso del workspace (127.0.0.1:808N) — SSE y WebSocket pasan (Synsema ≥ 0.6.12)
8
+ -- GET /w/<slug>/approve/… links de aprobación de ese workspace
9
+ -- /api/workspaces… registro, crear (diálogo nativo o explorador), start/stop/quitar, política
10
+ -- Supervisor: cada 15 s arranca los workspaces que deben vivir y apaga los inactivos (política en ⚙ → idle_hours).
11
+ -- Solo loopback puede usar la API del hub (misma regla que web.syn); LAMPSON_WEB_TOKEN para scripts remotos.
12
+
13
+ intent: "Lampson hub: workspaces registry, supervisor and reverse proxy to one process per workspace"
14
+
15
+ require serve(8080)
16
+ require net("127.0.0.1")
17
+ require time
18
+ require exec
19
+ require env("LAMPSON_*")
20
+ require env("OS")
21
+ require env("HOME")
22
+ require env("USERPROFILE")
23
+ require file(".lampson")
24
+ require file(".lampson/*")
25
+ require file.read("web.syn")
26
+ require file.read(".env")
27
+ require file.read("chat.syn")
28
+ require file.read("hub.tpl.syn")
29
+ require file("hub.syn")
30
+ require file.read("public")
31
+ require file.read("public/*")
32
+
33
+ use "./lib/workspaces.syn" as ws
34
+ use "./lib/settings.syn" as settings
35
+
36
+ task check_client(token, request)
37
+ let ip be text(ip of request)
38
+ when ip == "127.0.0.1" or ip == "::1" or ip == "::ffff:127.0.0.1"
39
+ give {"local": true}
40
+ let want be env("LAMPSON_WEB_TOKEN", "")
41
+ when want != "" and token == want
42
+ give {"local": false}
43
+ give nothing
44
+
45
+ task hub_tick()
46
+ give ws.tick()
47
+ cron_every(15, hub_tick)
48
+
49
+ serve on 8080
50
+ max_streams 200
51
+ auth with check_client
52
+ static "./public"
53
+
54
+ route "GET /"
55
+ give html(read_file("public/hub.html"))
56
+
57
+ -- la UI de un workspace: el mismo index.html; el frontend deduce /w/<slug> de la URL (BASE en js/core.js).
58
+ -- Sin barra final: el runtime normaliza "/w/x/" → "/w/x" (301), así que la ruta canónica es esta
59
+ route "GET /w/:slug"
60
+ give html(read_file("public/index.html"))
61
+
62
+ route "GET /api/hub" requires auth
63
+ give {"ok": true, "port": ws.HUB_PORT, "idle_hours": ws.idle_hours(), "home": ws.home()}
64
+
65
+ route "GET /api/workspaces" requires auth
66
+ give {"workspaces": ws.summary(), "idle_hours": ws.idle_hours()}
67
+
68
+ -- registrar una carpeta como workspace (y arrancarla)
69
+ route "POST /api/workspaces" requires auth
70
+ expect body {path: text}
71
+ let b be json of request
72
+ try
73
+ let w be ws.ensure(text(b["path"]))
74
+ ws.start_ws(w)
75
+ ws.wait_ready(w, 8)
76
+ give {"ok": true, "workspace": w, "url": "/w/" + w["slug"] + "/"}
77
+ recover err
78
+ give fail(400, text(err))
79
+
80
+ route "POST /api/workspaces/start" requires auth
81
+ expect body {slug: text}
82
+ let b be json of request
83
+ let w be ws.get(text(b["slug"]))
84
+ when w == nothing
85
+ give not_found("no such workspace")
86
+ ws.start_ws(w)
87
+ give {"ok": ws.wait_ready(w, 8)}
88
+
89
+ route "POST /api/workspaces/stop" requires auth
90
+ expect body {slug: text}
91
+ let b be json of request
92
+ let w be ws.get(text(b["slug"]))
93
+ when w == nothing
94
+ give not_found("no such workspace")
95
+ give {"ok": ws.stop_ws(w)}
96
+
97
+ route "POST /api/workspaces/remove" requires auth
98
+ expect body {slug: text}
99
+ let b be json of request
100
+ try
101
+ give {"ok": true, "result": ws.remove(text(b["slug"]))}
102
+ recover err
103
+ give fail(400, text(err))
104
+
105
+ -- política por workspace: auto | always | off
106
+ route "POST /api/workspaces/policy" requires auth
107
+ expect body {slug: text, policy: text}
108
+ let b be json of request
109
+ let w be ws.get(text(b["slug"]))
110
+ when w == nothing
111
+ give not_found("no such workspace")
112
+ when not contains(["auto", "always", "off"], text(b["policy"]))
113
+ give fail(400, "policy must be auto | always | off")
114
+ set w["policy"] to text(b["policy"])
115
+ ws.put(w)
116
+ give {"ok": true}
117
+
118
+ -- diálogo nativo del SO (PC); nothing → la UI usa el explorador propio
119
+ route "POST /api/workspaces/pick" requires auth
120
+ let p be ws.pick_folder()
121
+ give {"path": p, "native": p != nothing}
122
+
123
+ route "GET /api/workspaces/browse" requires auth
124
+ give ws.browse(when contains(query, "path") then text(query.path) otherwise "")
125
+
126
+ -- proxies generados (una por workspace registrado):
127
+ -- @workspaces
package/lampson.ps1 CHANGED
@@ -1,30 +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 --daemon start|stop|status|logs|restart # proceso residente (web + tareas programadas + aprobaciones)
9
- # lampson --update # actualizar Lampson (git pull) y salir
10
- # 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
11
9
  #
12
- # Por qué una junction: el scope file("./*") de Synsema v0.6.7 equivale a "*" (disco entero); el scope
13
- # file("workspace/*") confina. Montar el proyecto bajo un nombre literal es lo que hace real el
14
- # 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.
15
13
  $ErrorActionPreference = "Stop"
16
14
  $here = Split-Path -Parent $MyInvocation.MyCommand.Path
17
15
  $caller = (Get-Location).Path
18
- $mount = Join-Path $here "workspace"
19
16
 
20
17
  # --- args: acepta --flag y -Flag, sin distinguir mayúsculas ---
21
- $Workspace = ""; $Web = $false; $Agent = ""; $Perm = ""; $Daemon = ""
18
+ $Workspace = ""; $Web = $false; $Agent = ""; $Perm = ""; $Hub = ""; $Install = ""
22
19
  $i = 0
23
20
  while ($i -lt $args.Count) {
24
21
  $a = [string]$args[$i]
25
22
  switch -Regex ($a.ToLower()) {
26
23
  '^--?(web|w)$' { $Web = $true }
27
- '^--?(daemon|d)$' { $i++; $Daemon = if ($i -lt $args.Count) { ([string]$args[$i]).ToLower() } else { "status" } }
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" }
28
27
  '^--?(workspace|ws)$' { $i++; $Workspace = [string]$args[$i] }
29
28
  '^--?(agent|a)$' { $i++; $Agent = [string]$args[$i] }
30
29
  '^--?(yolo|y|dangerously-skip-permissions)$' { $Perm = "yolo" }
@@ -32,71 +31,84 @@ while ($i -lt $args.Count) {
32
31
  '^--?(ask)$' { $Perm = "ask" }
33
32
  '^--?(permission|p)$' { $i++; $Perm = ([string]$args[$i]).ToLower() }
34
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 }
35
- '^--?(help|h|\?)$' { Get-Content $PSCommandPath | Select-Object -Skip 1 -First 10 | ForEach-Object { $_.TrimStart('#',' ') }; exit 0 }
34
+ '^--?(help|h|\?)$' { Get-Content $PSCommandPath | Select-Object -Skip 1 -First 8 | ForEach-Object { $_.TrimStart('#',' ') }; exit 0 }
36
35
  default { if ($Workspace -eq "" -and -not $a.StartsWith("-")) { $Workspace = $a } else { Write-Error "argumento desconocido: $a (probá lampson --help)"; exit 1 } }
37
36
  }
38
37
  $i++
39
38
  }
40
39
 
41
- # --- resolver el proyecto: explícito > directorio actual (si no es el propio lampson) > montado previo ---
42
- if ($Workspace -eq "" -and $caller -ne $here) { $Workspace = $caller }
43
- if ($Workspace -ne "") {
44
- if (-not (Test-Path -LiteralPath $Workspace -PathType Container)) { Write-Error "el workspace no existe o no es un directorio: $Workspace"; exit 1 }
45
- $target = (Resolve-Path -LiteralPath $Workspace).Path
46
- $home_ = [Environment]::GetFolderPath("UserProfile")
47
- if ($target.TrimEnd('\') -eq $home_.TrimEnd('\') -or $target -match '^[A-Za-z]:\\?$') {
48
- 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
49
- }
50
- if ($target -eq $here) { Write-Error "no montes el propio directorio de lampson como workspace desde fuera; usá --workspace"; exit 1 }
51
- if (Test-Path -LiteralPath $mount) {
52
- $item = Get-Item -LiteralPath $mount -Force
53
- if ($item.LinkType -ne "Junction") { Write-Error "./workspace existe y no es una junction; movelo antes de montar otro proyecto"; exit 1 }
54
- # un daemon/web corriendo sobre OTRO proyecto (latido reciente del scheduler) quedaría apuntando a este: aviso
55
- $prev = $item.Target; if ($prev -is [array]) { $prev = $prev[0] }
56
- $hb = Join-Path $here ".lampson\schedules.heartbeat"
57
- if ($prev -and $prev.TrimEnd('\').ToLower() -ne $target.TrimEnd('\').ToLower() -and (Test-Path -LiteralPath $hb) -and ((Get-Date) - (Get-Item -LiteralPath $hb).LastWriteTime).TotalSeconds -lt 90) {
58
- Write-Host "AVISO: hay un lampson (web/daemon) corriendo sobre $prev; al montar $target sus tareas programadas quedan en pausa. Paralo o reinicialo desde su carpeta: lampson --daemon restart" -ForegroundColor Yellow
59
- }
60
- $item.Delete()
61
- }
62
- New-Item -ItemType Junction -Path $mount -Target $target | Out-Null
63
- } elseif (-not (Test-Path -LiteralPath $mount)) {
64
- Write-Error "no hay workspace: corré lampson desde el directorio del proyecto, o lampson --workspace C:\ruta"; exit 1
65
- }
66
- $target = (Get-Item -LiteralPath $mount -Force).Target
67
- if ($target -is [array]) { $target = $target[0] }
68
-
69
- # --- skills externas globales (npx skills add -g → ~/.agents/skills; las de Claude Code → ~/.claude/skills) ---
70
- # 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
71
42
  $skillMounts = @{ "skills-global" = (Join-Path $HOME ".agents\skills"); "skills-claude" = (Join-Path $HOME ".claude\skills") }
72
43
  New-Item -ItemType Directory -Force (Join-Path $here ".lampson") | Out-Null
73
44
  foreach ($k in $skillMounts.Keys) {
74
45
  $link = Join-Path $here ".lampson\$k"; $src = $skillMounts[$k]
75
- if (Test-Path -LiteralPath $link) { $li = Get-Item -LiteralPath $link -Force; if ($li.LinkType -eq "Junction") { $li.Delete() } }
76
- 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)
77
57
  }
78
58
 
79
- # --- arrancar desde el directorio de lampson (ahí viven .env, lib/, skills/, .lampson/) ---
80
- # Push/Pop: si PowerShell ejecuta este .ps1 en la shell del usuario (pasa cuando lampson.ps1 y lampson.cmd
81
- # comparten nombre en el PATH), el cwd del usuario debe quedar como estaba al salir.
82
59
  Push-Location $here
83
60
  try {
84
- $env:LAMPSON_WORKSPACE = $target
85
- if ($Agent -ne "") { $env:LAMPSON_AGENT = $Agent }
86
- if ($Perm -ne "") { $env:LAMPSON_PERMISSION = $Perm }
87
- if ($Daemon -ne "") {
88
- # proceso residente: web.syn en background (synsema daemon, sin config del sistema; para producción,
89
- # systemd/NSSM con `synsema serve web.syn`). Ejecuta las tareas programadas y atiende aprobaciones por link.
90
- if ($Daemon -notmatch '^(start|stop|status|logs|restart)$') { Write-Error "uso: lampson --daemon start|stop|status|logs|restart"; exit 1 }
91
- if ($Daemon -eq "start" -or $Daemon -eq "restart") { Write-Host "Lampson daemon · workspace: $target · http://127.0.0.1:8080" }
92
- synsema daemon $Daemon web.syn
93
- } elseif ($Web) {
94
- Write-Host "Lampson web · workspace: $target"
95
- Write-Host "abrí http://127.0.0.1:8080 (Ctrl+C para parar)"
96
- synsema serve web.syn
97
- } else {
98
- synsema run chat.syn
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 '/', '\')
98
+ if ($Web) {
99
+ Write-Host ("Lampson web · " + $r.url + " (workspace " + $r.slug + $(if ($r.alive) { " ● vivo" } else { " ○ arrancando" }) + ")")
100
+ Start-Process $r.url
101
+ exit 0
99
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 }
100
112
  } finally {
101
113
  Pop-Location
102
114
  }
package/lampson.sh CHANGED
@@ -1,56 +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]
6
- # lampson --daemon start|stop|status|logs|restart # proceso residente (web + tareas programadas + aprobaciones)
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
7
7
  set -euo pipefail
8
8
  here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
9
  caller="$(pwd)"
10
- ws=""; web=0; daemon=""
10
+ ws=""; web=0; hub=""; install=""
11
11
  while [ $# -gt 0 ]; do
12
12
  case "$1" in
13
13
  --web) web=1 ;;
14
- --daemon) shift; daemon="${1:-status}" ;;
14
+ --hub|--daemon) shift; hub="${1:-status}" ;;
15
+ --install) install=install ;;
16
+ --uninstall) install=uninstall ;;
15
17
  --workspace) shift; ws="$1" ;;
16
18
  --agent) shift; export LAMPSON_AGENT="$1" ;;
17
19
  --yolo|--dangerously-skip-permissions) export LAMPSON_PERMISSION=yolo ;;
18
20
  --strict) export LAMPSON_PERMISSION=strict ;;
19
21
  --ask) export LAMPSON_PERMISSION=ask ;;
20
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 $? ;;
21
- *) echo "uso: lampson [--web] [--daemon start|stop|status|logs|restart] [--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" ;;
22
25
  esac
23
26
  shift
24
27
  done
25
- [ -z "$ws" ] && [ "$caller" != "$here" ] && ws="$caller"
26
- if [ -n "$ws" ]; then
27
- [ -d "$ws" ] || { echo "el workspace no existe o no es un directorio: $ws" >&2; exit 1; }
28
- ws="$(cd "$ws" && pwd)"
29
- if [ -e "$here/workspace" ] && [ ! -L "$here/workspace" ]; then echo "./workspace existe y no es un symlink" >&2; exit 1; fi
30
- # un daemon/web corriendo sobre OTRO proyecto (latido reciente del scheduler) quedaría apuntando a este: aviso
31
- if [ -L "$here/workspace" ] && [ -f "$here/.lampson/schedules.heartbeat" ]; then
32
- prev="$(readlink -f "$here/workspace")"
33
- if [ "$prev" != "$ws" ] && [ $(( $(date +%s) - $(stat -c %Y "$here/.lampson/schedules.heartbeat") )) -lt 90 ]; then
34
- echo "AVISO: hay un lampson (web/daemon) corriendo sobre $prev; al montar $ws sus tareas programadas quedan en pausa. Paralo o reinicialo desde su carpeta: lampson --daemon restart" >&2
35
- fi
36
- fi
37
- rm -f "$here/workspace"; ln -s "$ws" "$here/workspace"
38
- elif [ ! -e "$here/workspace" ]; then
39
- echo "no hay workspace: corré lampson desde el directorio del proyecto, o --workspace /ruta" >&2; exit 1
40
- fi
41
- export LAMPSON_WORKSPACE="$(readlink -f "$here/workspace")"
42
- # 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)
43
30
  mkdir -p "$here/.lampson"
44
31
  for pair in "skills-global:$HOME/.agents/skills" "skills-claude:$HOME/.claude/skills"; do
45
32
  link="$here/.lampson/${pair%%:*}"; src="${pair#*:}"
46
- [ -L "$link" ] && rm -f "$link"
47
- [ -d "$src" ] && ln -s "$src" "$link"
33
+ [ -e "$link" ] || { [ -d "$src" ] && ln -s "$src" "$link"; } || true
48
34
  done
49
- echo "workspace -> $LAMPSON_WORKSPACE"
50
- cd "$here"
51
- if [ -n "$daemon" ]; then
52
- # proceso residente en background (synsema daemon: sin arranque al boot ni reinicio; para producción, systemd
53
- # con `synsema serve web.syn`). Ejecuta las tareas programadas y atiende aprobaciones por link.
54
- case "$daemon" in start|stop|status|logs|restart) ;; *) echo "uso: lampson --daemon start|stop|status|logs|restart" >&2; exit 1 ;; esac
55
- exec synsema daemon "$daemon" web.syn
56
- elif [ "$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