lampson 0.2.6 → 0.2.8

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.
@@ -25,6 +25,14 @@ description: How this harness works — tools, workspace mount, permissions, age
25
25
  more context. `replace_all=true` for intentional multi-replace.
26
26
  - `read` returns up to 2000 lines; use `offset`/`limit` for big files. Outputs > 30k chars are truncated.
27
27
  - `find` is a simple glob (`*.ts`, `test_*`, `*config*`), `grep` is regex by default (`regex=false` for literals).
28
+ - `fetch(url)` returns a web page as Markdown (about a tenth of the characters of its HTML: scripts, styles,
29
+ nav and footers stripped, links absolute, code blocks and tables kept; a site that serves Markdown is
30
+ taken as is). Follows up to 5 redirects. Long pages come back head+tail and the full text is saved to
31
+ `.lampson/spill/fetch-<host>-<id>.md` — page it with `read offset/limit` or `grep`, never re-fetch; the
32
+ same URL within 20 minutes is served from that file. `format=text` for prose without links, `format=html`
33
+ to see the raw markup. localhost / private hosts ask the user (good for checking your own dev server);
34
+ cloud metadata endpoints and URLs carrying tokens are refused. Binary content (images, PDF) is reported,
35
+ not returned — use `bash` with `curl -o` for downloads.
28
36
  - `lsp(op=symbols, path)` is the cheapest way to understand a big file: every function/class/variable
29
37
  with its line range — then `read` only the range you need. `definition` / `references` / `hover` /
30
38
  `implementation` take 1-based `line` + `character` ON the identifier. If no server is configured for
@@ -43,7 +51,7 @@ description: How this harness works — tools, workspace mount, permissions, age
43
51
  ## Scheduled tasks (`schedule` tool)
44
52
  - When the user says "every day at 9", "each 6 hours", "on Mondays", "periodically", "send me", propose
45
53
  `schedule(action=add, name, at, kind, …)`: `at` = `every 6h` | `daily 09:00` | `mon,wed 08:30` |
46
- `weekdays 09:00`; one-time: `today 15:14` | `tomorrow 09:00` | `once 2026-08-29 15:14` | `in 2h` (it turns itself off after running) — the USER'S LOCAL time: write the hour as they say it, never convert to UTC (the tool result shows the next run with its offset). Tasks belong to the current workspace. `kind=lamp` (a lamp that is ON: lamp + tool + args), `kind=bash` (one
54
+ `weekdays 09:00`; one-time: `today 15:14` | `tomorrow 09:00` | `once 2026-08-29 15:14` | `in 2h` (it turns itself off after running) — the USER'S LOCAL time: write the hour as they say it, never convert to UTC (the tool result shows the next run with its offset). Tasks belong to the current workspace. `kind=plugin` (a plugin that is ON: plugin + tool + args), `kind=bash` (one
47
55
  command that finishes on its own), `kind=prompt` (an unattended agent run: write a self-contained prompt —
48
56
  what to do, how to verify, what to report — and pick `agent` build/review/plan/explore).
49
57
  - `permission` is the envelope of a `prompt` run with nobody watching: `strict` (dangerous → denied),
@@ -95,6 +103,10 @@ the terminal button and run `npm run dev`), then tell me the URL".
95
103
  ## Project memory (persistent notes)
96
104
  - `memory(write, name, content)` saves a Markdown note about THIS project in Lampson's `memory/<project>/`
97
105
  folder (outside the repo). The system prompt lists your notes; `memory(read, name)` loads one.
106
+ - That folder is OUTSIDE the workspace and outside the reach of read/ls/grep/bash (they are confined to the
107
+ project): the `memory` tool is the only door. Never look for it with `ls`, never write notes with bash, and
108
+ never store notes inside the repo (no `.lampson/notes/`). If the tool says the folder is unreachable, tell
109
+ the user to run `lampson` again in the project (it repairs the link) — do not improvise.
98
110
  - Save what you would otherwise rediscover: how to run/test, env quirks, decisions, where things live,
99
111
  root causes of bugs. Update notes instead of contradicting them. The user can read and edit them.
100
112
 
@@ -124,15 +136,18 @@ the terminal button and run `npm run dev`), then tell me the URL".
124
136
  (`workspace/.agents/skills`, `workspace/.claude/skills`, and the global `~/.agents/skills` / `~/.claude/skills`
125
137
  mounted as `.lampson/skills-global` / `.lampson/skills-claude`). Frontmatter `name:` + `description:`.
126
138
  `skill(action=install, source=owner/repo, name=x, scope=global|project)` installs one (always asks the user).
127
- - **Lamps** = tool plugins you can create for this project without touching the harness:
128
- `lamp(action=create, name, manifest, files={"lamp.syn": ""})` writes `workspace/.lampson/lamps/<name>/`,
139
+ - **Plugins** = tools you can create for this project without touching the harness (they were called
140
+ "lamps" until 0.2.6 — the user may still say "lámpara"; a `.lampson/lamps/` folder still works):
141
+ `plugin(action=create, name, manifest, files={"plugin.syn": "…"})` writes `workspace/.lampson/plugins/<name>/`,
129
142
  validates the manifest and runs `synsema check` on a syn entry (it does not run or enable anything —
130
- like dsh's cordis_define). Then `lamp(action=enable, name)` — the user must approve (a lamp is off until
143
+ like dsh's cordis_define). Then `plugin(action=enable, name)` — the user must approve (a plugin is off until
131
144
  a human turns it on). Build one when a task needs a reusable project-specific tool. Manifest:
132
- `{"name", "description", "kind": "syn"|"exec", "entry": "lamp.syn" (syn) | "command": "python lamp.py" (exec),
133
- "caps": "file.read=workspace/*" (syn, optional extra ceiling over stdout,time,env=LAMP_*), "timeout": 60,
134
- "tools": [{"name", "description", "parameters": {JSON Schema}, "readonly": bool}]}`. Lamp names: letters,
135
- digits, `-` (no `_`). Each call runs the lamp as ONE child process: `synsema run --cap-set <ceiling> entry`
136
- for `syn`, the command for `exec`. Inside, read `LAMP_TOOL` and `LAMP_ARGS` (JSON) from env
137
- (`require env("LAMP_*")` in a .syn) and print the result to stdout. A syn lamp cannot use more than its
138
- manifest's `caps`; ask only for what the tool needs. Once on, its tools are `lamp_<name>_<tool>`.
145
+ `{"name", "description", "kind": "syn"|"exec", "entry": "plugin.syn" (syn) | "command": "python plugin.py" (exec),
146
+ "caps": "file.read=workspace/*" (syn, optional extra ceiling over stdout,time,env=PLUGIN_*), "timeout": 60,
147
+ "tools": [{"name", "description", "parameters": {JSON Schema}, "readonly": bool}]}`. Plugin names: letters,
148
+ digits, `-` (no `_`). Each call runs the plugin as ONE child process: `synsema run --cap-set <ceiling> entry`
149
+ for `syn`, the command for `exec`. Inside, read `PLUGIN_TOOL` and `PLUGIN_ARGS` (JSON) from env
150
+ (`require env("PLUGIN_*")` in a .syn) and print the result to stdout. A syn plugin cannot use more than its
151
+ manifest's `caps`; ask only for what the tool needs. Once on, its tools are `plugin_<name>_<tool>`.
152
+ Not the same thing as a **lamp** from lamps.sh (a portable, ceiling-enforced capability unit for any MCP
153
+ agent): if the user wants one of those, it is `lamp add <ref>` + `lamp mcp` as an MCP server, not a plugin.
package/web.syn CHANGED
@@ -34,8 +34,8 @@ require file("workspace")
34
34
  require file("workspace/*")
35
35
  require file.read("skills")
36
36
  require file.read("skills/*")
37
- require file.read("lamps")
38
- require file.read("lamps/*")
37
+ require file.read("plugins")
38
+ require file.read("plugins/*")
39
39
  require file("memory")
40
40
  require file("memory/*")
41
41
  require file(".lampson")
@@ -50,6 +50,7 @@ use "./lib/loop.syn" as loop
50
50
  use "./lib/session.syn" as session
51
51
  use "./lib/agents.syn" as agents
52
52
  use "./lib/tree.syn" as tree
53
+ use "./lib/fs.syn" as fs
53
54
  use "./lib/git.syn" as git
54
55
  use "./lib/tools/proc.syn" as proc
55
56
  use "./lib/tools/memo.syn" as memo
@@ -57,7 +58,7 @@ use "./lib/update.syn" as update
57
58
  use "./lib/settings.syn" as settings
58
59
  use "./lib/trace.syn" as trace
59
60
  use "./lib/mcp.syn" as mcp
60
- use "./lib/lamps.syn" as lamps
61
+ use "./lib/plugins.syn" as plugins
61
62
  use "./lib/lsp.syn" as lsp
62
63
  use "./lib/tools/todo.syn" as todo
63
64
  use "./lib/schedule.syn" as schedule
@@ -105,7 +106,7 @@ task lampson_subagent(spec_json)
105
106
  -- TAREAS PROGRAMADAS (lib/schedule.syn): este proceso es el residente de lampson — con `lampson --daemon start`
106
107
  -- (synsema daemon) queda en background y ejecuta lo programado aunque no haya ninguna UI abierta. Un tick cada
107
108
  -- 30 s (cron_every de Synsema: intervalo puro; la hora de reloj la resuelve schedule.syn) corre lo vencido:
108
- -- lámparas, comandos y corridas del agente con su sobre de permisos; una aprobación en una corrida desatendida
109
+ -- plugins, comandos y corridas del agente con su sobre de permisos; una aprobación en una corrida desatendida
109
110
  -- sale por lib/approvals.syn (panel «Aprobaciones» de la UI, webhook firmado, link de un solo uso).
110
111
  task sched_tick()
111
112
  give sched_run.tick()
@@ -193,7 +194,10 @@ serve on 8080
193
194
  give {"result": session.delete(text(b["id"]))}
194
195
 
195
196
  route "GET /w/:slug/api/tree" requires auth
196
- give tree.tree(6)
197
+ let t be tree.tree(6)
198
+ -- ruta real del proyecto (lampson.ps1/.sh exportan LAMPSON_WORKSPACE): «copiar ruta absoluta» en el explorador
199
+ set t["root"] to env("LAMPSON_WORKSPACE", "")
200
+ give t
197
201
 
198
202
  -- Terminales del navegador. El pty NO vive en este handler: vive en un agente supervisor
199
203
  -- (lib/term.syn), y este socket es un puente por el bus. Así la shell sobrevive a un F5 —
@@ -261,7 +265,8 @@ serve on 8080
261
265
  give git.status()
262
266
 
263
267
  route "GET /w/:slug/api/memory" requires auth
264
- give {"dir": memo.dir(), "notes": memo.list()}
268
+ -- ok=false: el link memory/ del workspace está roto (la UI lo dice; el agente ya lo sabe por el prompt)
269
+ give {"dir": memo.dir(), "ok": memo.available(), "notes": memo.list()}
265
270
 
266
271
  route "GET /w/:slug/api/memory/note" requires auth
267
272
  when not contains(query, "name")
@@ -334,34 +339,34 @@ serve on 8080
334
339
  recover err
335
340
  give fail(400, text(err))
336
341
 
337
- -- lámparas (plugins de tools): listar y encender/apagar desde la barra superior de la UI
338
- route "GET /w/:slug/api/lamps" requires auth
339
- give {"lamps": lamps.summary(), "global": lamps.GLOBAL_DIR, "project": lamps.PROJECT_DIR}
342
+ -- plugins (tools propias): listar y encender/apagar desde la barra superior de la UI
343
+ route "GET /w/:slug/api/plugins" requires auth
344
+ give {"plugins": plugins.summary(), "global": plugins.GLOBAL_DIR, "project": plugins.PROJECT_DIR}
340
345
 
341
- -- el usuario corre una tool de una lámpara encendida él mismo (sin pasar por el modelo)
342
- route "POST /w/:slug/api/lamps/run" requires auth
346
+ -- el usuario corre una tool de un plugin encendido él mismo (sin pasar por el modelo)
347
+ route "POST /w/:slug/api/plugins/run" requires auth
343
348
  expect body {tool: text}
344
349
  let b be json of request
345
350
  let args be when contains(b, "args") then b["args"] otherwise {}
346
351
  try
347
- give {"ok": true, "output": lamps.call(text(b["tool"]), args)}
352
+ give {"ok": true, "output": plugins.call(text(b["tool"]), args)}
348
353
  recover err
349
354
  give fail(400, text(err))
350
355
 
351
- route "POST /w/:slug/api/lamps/remove" requires auth
356
+ route "POST /w/:slug/api/plugins/remove" requires auth
352
357
  expect body {name: text}
353
358
  let b be json of request
354
359
  try
355
- give {"ok": true, "result": lamps.remove(text(b["name"])), "lamps": lamps.summary()}
360
+ give {"ok": true, "result": plugins.remove(text(b["name"])), "plugins": plugins.summary()}
356
361
  recover err
357
362
  give fail(400, text(err))
358
363
 
359
- route "POST /w/:slug/api/lamps/toggle" requires auth
364
+ route "POST /w/:slug/api/plugins/toggle" requires auth
360
365
  expect body {name: text}
361
366
  let b be json of request
362
367
  let on be contains(b, "enabled") and b["enabled"] == true
363
368
  try
364
- give {"ok": true, "result": lamps.set_enabled(text(b["name"]), on), "lamps": lamps.summary()}
369
+ give {"ok": true, "result": plugins.set_enabled(text(b["name"]), on), "plugins": plugins.summary()}
365
370
  recover err
366
371
  give fail(400, text(err))
367
372
 
@@ -439,6 +444,16 @@ serve on 8080
439
444
  give fail(400, "path required")
440
445
  give tree.file_content(query.path)
441
446
 
447
+ -- explorador: nuevo archivo/carpeta, renombrar, mover, copiar/duplicar, borrar (lib/fs.syn; solo dentro
448
+ -- del workspace, nunca pisa un destino). {op, path, name?, to?, content?} → {ok, result: {path, is_dir}}
449
+ route "POST /w/:slug/api/fs" requires auth
450
+ expect body {op: text, path: text}
451
+ let b be json of request
452
+ try
453
+ give {"ok": true, "result": fs.apply(b)}
454
+ recover err
455
+ give fail(400, text(err))
456
+
442
457
  -- configuración general (rueda de la UI): zona horaria, URL pública, webhook… → .lampson/config.json
443
458
  route "GET /w/:slug/api/settings/values" requires auth
444
459
  give {"values": settings.values(), "tz_detected": schedule.tz_offset()}
@@ -478,7 +493,7 @@ serve on 8080
478
493
  route "GET /w/:slug/api/schedules" requires auth
479
494
  let age be schedule.daemon_age()
480
495
  -- este proceso tiene su propio tick (cron_every): las tareas corren mientras la web esté abierta
481
- give {"tasks": schedule.summary(), "tick": schedule.TICK_SECONDS, "alive": true, "heartbeat_age": age, "mount_ok": schedule.mount_ok(), "workspace": env("LAMPSON_WORKSPACE", ""), "tz": schedule.tz_offset(), "lamps": lamps.summary()}
496
+ give {"tasks": schedule.summary(), "tick": schedule.TICK_SECONDS, "alive": true, "heartbeat_age": age, "mount_ok": schedule.mount_ok(), "workspace": env("LAMPSON_WORKSPACE", ""), "tz": schedule.tz_offset(), "plugins": plugins.summary()}
482
497
 
483
498
  route "POST /w/:slug/api/schedules/add" requires auth
484
499
  let b be json of request
@@ -1,19 +0,0 @@
1
- -- lamps/example-hello/lamp.syn — la lámpara de ejemplo
2
- --
3
- -- Lampson la corre así por cada llamada: synsema run --cap-set stdout,time,env=LAMP_* lamp.syn
4
- -- El techo (--cap-set) sale del manifiesto (lamp.json → "caps") que el humano aprobó al encenderla;
5
- -- pedir más acá (p. ej. `require net`) falla con "above the host ceiling".
6
- -- Entrada por env: LAMP_TOOL (qué tool), LAMP_ARGS (sus args en JSON), LAMP_DIR, LAMP_WORKSPACE.
7
- -- Salida: lo que imprimas por stdout vuelve al modelo como resultado de la tool.
8
- intent: "example lamp for lampson: greet"
9
-
10
- require env("LAMP_*")
11
-
12
- let tool be env("LAMP_TOOL", "")
13
- let args be json_decode(env("LAMP_ARGS", "{}"))
14
-
15
- when tool == "greet"
16
- let who be when contains(args, "who") then text(args["who"]) otherwise "world"
17
- print("hello, " + who + "! (from the example-hello lamp, running under a capability ceiling)")
18
- otherwise
19
- print("ERROR: unknown tool '" + tool + "'")
package/lib/lamps.syn DELETED
@@ -1,386 +0,0 @@
1
- -- lib/lamps.syn — "lámparas": plugins de tools que el usuario (o el agente) escribe y el humano ENCIENDE
2
- --
3
- -- Qué es una lámpara: una carpeta con un manifiesto `lamp.json` y el código que implementa sus tools.
4
- -- lamps/<nombre>/ GLOBAL (lampson se instala una vez → vale para todos los proyectos)
5
- -- workspace/.lampson/lamps/<nombre>/ del PROYECTO (el agente puede crearlas con write: es su workspace)
6
- -- lamp.json = {"name", "description", "kind": "syn" | "exec",
7
- -- "entry": "lamp.syn" (kind=syn: un programa Synsema)
8
- -- "command": "python lamp.py" (kind=exec: cualquier ejecutable — js, py, sh…)
9
- -- "caps": "file.read=workspace/*" (kind=syn, opcional: techo EXTRA sobre stdout,time,env=LAMP_*)
10
- -- "timeout": 60,
11
- -- "tools": [{"name", "description", "parameters": {JSON Schema}, "readonly": false}]}
12
- --
13
- -- Cómo corre (patrón "safe runner" de la doc 22-sandbox): cada llamada es UN proceso hijo que termina.
14
- -- kind=syn → `synsema run --cap-set stdout,time,env=LAMP_*[,caps] <entry>`: el techo lo fija el
15
- -- manifiesto que el humano aprobó al encenderla; un `require` de más en el código de la
16
- -- lámpara falla con "above the host ceiling" — no hay forma de escalar desde adentro.
17
- -- kind=exec → el comando tal cual (sin techo de lenguaje: por eso encender es SIEMPRE humano).
18
- -- La tool y sus args viajan por env: LAMP_TOOL, LAMP_ARGS (JSON), LAMP_DIR (carpeta de la lámpara,
19
- -- relativa a la raíz de lampson = cwd del hijo si kind=syn; si kind=exec el cwd es el workspace),
20
- -- LAMP_WORKSPACE (ruta real del proyecto). La lámpara imprime el resultado (texto o JSON) por stdout.
21
- --
22
- -- Encendido: `.lampson/lamps.json` {"enabled": {"<nombre>": true}}. APAGADA por defecto: descubrir una
23
- -- carpeta nueva no la activa; la enciende el humano (UI: barra superior; terminal: /lamps on <nombre>) o el
24
- -- agente con la tool `lamp` (action=enable → ask SIEMPRE, como skill install / mcp add). Crear = write;
25
- -- encender = permiso. Sin `use` dinámico (Synsema no lo tiene, a propósito): nada de esto entra al proceso
26
- -- de lampson, solo corre como hijo.
27
- --
28
- -- Las tools encendidas entran al catálogo como `lamp_<lámpara>_<tool>`; loop.execute las despacha acá
29
- -- (registry las marca con "lamp", como "mcp"). permission.syn: ask por defecto, yolo permite, strict deniega;
30
- -- las readonly también en plan/review/explore.
31
-
32
- use "./tools/common.syn" as c
33
-
34
- export let GLOBAL_DIR be "lamps"
35
- export let PROJECT_DIR be "workspace/.lampson/lamps"
36
- export let STATE_FILE be ".lampson/lamps.json"
37
- let DEFAULT_TIMEOUT be 60
38
- let BASE_CAPS be "stdout,time,env=LAMP_*"
39
-
40
- -- nombre de lámpara: SIN "_" (el separador de lamp_<lámpara>_<tool>); las tools sí pueden llevarlo
41
- task valid_name(name)
42
- when name == nothing or name == ""
43
- give false
44
- give matches(text(name), "[a-zA-Z0-9-]{1,32}")
45
-
46
- task valid_tool_name(name)
47
- when name == nothing or name == ""
48
- give false
49
- give matches(text(name), "[a-zA-Z0-9_-]{1,40}")
50
-
51
- -- lee y valida un manifiesto; devuelve {ok, lamp} o {ok: false, error}
52
- task read_manifest(dir, name, scope)
53
- let path be dir + "/" + name + "/lamp.json"
54
- let doc be nothing
55
- try
56
- set doc to json_decode(read_file(path))
57
- recover err
58
- give {"ok": false, "name": name, "scope": scope, "error": "no lamp.json: " + text(err)}
59
- let kind be when contains(doc, "kind") then lower(text(doc["kind"])) otherwise "syn"
60
- when kind != "syn" and kind != "exec"
61
- give {"ok": false, "name": name, "scope": scope, "error": "kind must be syn or exec"}
62
- when kind == "syn" and not contains(doc, "entry")
63
- give {"ok": false, "name": name, "scope": scope, "error": "kind=syn needs \"entry\" (the .syn file)"}
64
- when kind == "exec" and not contains(doc, "command")
65
- give {"ok": false, "name": name, "scope": scope, "error": "kind=exec needs \"command\""}
66
- when not contains(doc, "tools") or length(doc["tools"]) == 0
67
- give {"ok": false, "name": name, "scope": scope, "error": "no tools declared"}
68
- let tools be []
69
- each t in doc["tools"]
70
- when not contains(t, "name") or not valid_tool_name(t["name"])
71
- give {"ok": false, "name": name, "scope": scope, "error": "a tool has no valid name"}
72
- set tools to append(tools, {"name": text(t["name"]), "description": when contains(t, "description") then text(t["description"]) otherwise "", "parameters": when contains(t, "parameters") then t["parameters"] otherwise {"type": "object", "properties": {}}, "readonly": contains(t, "readonly") and t["readonly"] == true})
73
- give {"ok": true, "name": name, "scope": scope, "lamp": {"name": name, "scope": scope, "dir": dir + "/" + name, "kind": kind, "description": when contains(doc, "description") then text(doc["description"]) otherwise "", "entry": when contains(doc, "entry") then text(doc["entry"]) otherwise "", "command": when contains(doc, "command") then text(doc["command"]) otherwise "", "caps": when contains(doc, "caps") then text(doc["caps"]) otherwise "", "timeout": when contains(doc, "timeout") then floor(number(doc["timeout"])) otherwise DEFAULT_TIMEOUT, "tools": tools}}
74
-
75
- task scan(dir, scope)
76
- let out be []
77
- try
78
- each e in list_dir(dir)
79
- when e["is_dir"] and valid_name(e["name"])
80
- set out to append(out, read_manifest(dir, e["name"], scope))
81
- recover err
82
- give out
83
- give out
84
-
85
- task load_state()
86
- try
87
- let doc be json_decode(read_file(STATE_FILE))
88
- give when contains(doc, "enabled") then doc["enabled"] otherwise {}
89
- recover err
90
- give {}
91
-
92
- -- todas las lámparas descubiertas (proyecto pisa a global por nombre), con enabled y error.
93
- -- LAMPSON_LAMPS_DIR = una carpeta extra (tests).
94
- export task all()
95
- require file(".lampson")
96
- require file(".lampson/*")
97
- require file.read("lamps")
98
- require file.read("lamps/*")
99
- require file("workspace")
100
- require file("workspace/*")
101
- require env("LAMPSON_*")
102
- let by_name be {}
103
- each r in scan(GLOBAL_DIR, "global")
104
- set by_name[r["name"]] to r
105
- each r in scan(PROJECT_DIR, "project")
106
- set by_name[r["name"]] to r
107
- when env("LAMPSON_LAMPS_DIR", "") != ""
108
- each r in scan(env("LAMPSON_LAMPS_DIR", ""), "extra")
109
- set by_name[r["name"]] to r
110
- let st be load_state()
111
- let out be []
112
- each n in sort_by(keys(by_name), (x) => x)
113
- let r be by_name[n]
114
- let on be contains(st, n) and st[n] == true
115
- when r["ok"]
116
- let l be r["lamp"]
117
- set l["enabled"] to on
118
- set l["error"] to nothing
119
- set out to append(out, l)
120
- otherwise
121
- set out to append(out, {"name": n, "scope": r["scope"], "dir": "", "kind": "?", "description": "", "entry": "", "command": "", "caps": "", "timeout": 0, "tools": [], "enabled": false, "error": r["error"]})
122
- give out
123
-
124
- export task enabled()
125
- require file(".lampson")
126
- require file(".lampson/*")
127
- require file.read("lamps")
128
- require file.read("lamps/*")
129
- require file("workspace")
130
- require file("workspace/*")
131
- require env("LAMPSON_*")
132
- give where(all(), (l) => l["enabled"] and l["error"] == nothing)
133
-
134
- -- encender/apagar (el humano, o el agente tras aprobación). Encender una lámpara rota es error.
135
- export task set_enabled(name, on)
136
- require file(".lampson")
137
- require file(".lampson/*")
138
- require file.read("lamps")
139
- require file.read("lamps/*")
140
- require file("workspace")
141
- require file("workspace/*")
142
- require env("LAMPSON_*")
143
- when not valid_name(name)
144
- raise("invalid lamp name")
145
- let found be where(all(), (l) => l["name"] == name)
146
- when length(found) == 0
147
- raise("no lamp named '" + name + "' (global: " + GLOBAL_DIR + "/<name>/lamp.json · project: " + PROJECT_DIR + "/<name>/lamp.json)")
148
- when on and found[0]["error"] != nothing
149
- raise("lamp '" + name + "' is broken: " + text(found[0]["error"]))
150
- let st be load_state()
151
- set st[name] to on == true
152
- write_file(STATE_FILE, json_encode({"enabled": st}))
153
- let l be found[0]
154
- when on
155
- give "lamp '" + name + "' ON (" + l["kind"] + ", " + text(length(l["tools"])) + " tools" + (when l["kind"] == "syn" then ", ceiling: " + ceiling_of(l) otherwise ", NO capability ceiling: exec") + ") — its tools are in the catalog from the next turn as lamp_" + name + "_<tool>"
156
- give "lamp '" + name + "' OFF"
157
-
158
- task ceiling_of(l)
159
- give when l["caps"] == "" then BASE_CAPS otherwise BASE_CAPS + "," + l["caps"]
160
-
161
- -- ---------- catálogo ----------
162
- export task tool_name(lamp, tool)
163
- give "lamp_" + lamp + "_" + tool
164
-
165
- task split_name(full)
166
- let segs be split(slice(full, 5, length(full)), "_")
167
- when length(segs) < 2
168
- raise("bad lamp tool name '" + full + "'")
169
- give {"lamp": segs[0], "tool": join(slice(segs, 1, length(segs)), "_")}
170
-
171
- export task catalog(readonly_only)
172
- require file(".lampson")
173
- require file(".lampson/*")
174
- require file.read("lamps")
175
- require file.read("lamps/*")
176
- require file("workspace")
177
- require file("workspace/*")
178
- require env("LAMPSON_*")
179
- let out be []
180
- each l in enabled()
181
- each t in l["tools"]
182
- when not readonly_only or t["readonly"]
183
- set out to append(out, {"name": tool_name(l["name"], t["name"]), "description": "[lamp " + l["name"] + "] " + t["description"], "parameters": t["parameters"]})
184
- give out
185
-
186
- export task names(readonly_only)
187
- require file(".lampson")
188
- require file(".lampson/*")
189
- require file.read("lamps")
190
- require file.read("lamps/*")
191
- require file("workspace")
192
- require file("workspace/*")
193
- require env("LAMPSON_*")
194
- give apply(catalog(readonly_only), (s) => s["name"])
195
-
196
- -- ---------- llamada: un proceso hijo por invocación ----------
197
- task run_lamp(exe, argv, cwd, envs, t)
198
- let p be proc_spawn(exe, argv, {"cwd": cwd, "env": envs, "stderr": "merge", "on_full": "drop_oldest"})
199
- proc_close_stdin(p)
200
- let deadline be now() + t
201
- let lines be []
202
- let code be nothing
203
- let timed_out be false
204
- while code == nothing and not timed_out
205
- let left be deadline - now()
206
- when left <= 0
207
- set timed_out to true
208
- otherwise
209
- let ev be proc_recv(p, when left < 0.2 then 0.2 otherwise left)
210
- when ev == nothing
211
- set timed_out to now() >= deadline
212
- otherwise when ev["type"] == "exit"
213
- set code to ev["data"]["exit_code"]
214
- otherwise
215
- set lines to append(lines, ev["data"])
216
- proc_close(p)
217
- let out be join(lines, "\n")
218
- when timed_out
219
- give "ERROR: lamp timed out after " + text(t) + "s (killed). Output so far:\n" + out
220
- when code != 0
221
- give "ERROR: lamp exited with code " + text(code) + "\n" + out
222
- give when trim(out) == "" then "(no output)" otherwise out
223
-
224
- -- separa "python lamp.py --x" en exe + args (comillas simples/dobles respetadas)
225
- export task split_command(line)
226
- let toks be []
227
- let cur be ""
228
- let q be ""
229
- let has be false
230
- let i be 0
231
- while i < length(line)
232
- let ch be slice(line, i, i + 1)
233
- set i to i + 1
234
- when q != ""
235
- when ch == q
236
- set q to ""
237
- otherwise
238
- set cur to cur + ch
239
- otherwise when ch == "\"" or ch == "'"
240
- set q to ch
241
- set has to true
242
- otherwise when ch == " " or ch == "\t"
243
- when cur != "" or has
244
- set toks to append(toks, cur)
245
- set cur to ""
246
- set has to false
247
- otherwise
248
- set cur to cur + ch
249
- when cur != "" or has
250
- set toks to append(toks, cur)
251
- give toks
252
-
253
- export task call(full_name, args)
254
- require exec
255
- require time
256
- require file(".lampson")
257
- require file(".lampson/*")
258
- require file.read("lamps")
259
- require file.read("lamps/*")
260
- require file("workspace")
261
- require file("workspace/*")
262
- require env("LAMPSON_*")
263
- let parts be split_name(full_name)
264
- let found be where(enabled(), (l) => l["name"] == parts["lamp"])
265
- when length(found) == 0
266
- give "ERROR: lamp '" + parts["lamp"] + "' is not enabled (the user turns lamps on)"
267
- let l be found[0]
268
- let tl be where(l["tools"], (t) => t["name"] == parts["tool"])
269
- when length(tl) == 0
270
- give "ERROR: lamp '" + l["name"] + "' has no tool '" + parts["tool"] + "'"
271
- -- LAMP_DIR relativa a la raíz de lampson (cwd del hijo para kind=syn); LAMP_WORKSPACE = la ruta real del
272
- -- proyecto (lampson.ps1 la exporta; sin ella, la junction)
273
- let envs be {"LAMP_TOOL": parts["tool"], "LAMP_ARGS": json_encode(when args == nothing then {} otherwise args), "LAMP_DIR": l["dir"], "LAMP_WORKSPACE": env("LAMPSON_WORKSPACE", c.ROOT)}
274
- when l["kind"] == "syn"
275
- -- techo del manifiesto: el código de la lámpara no puede pedir más de lo que el humano aprobó
276
- give c.truncate(run_lamp(env("LAMPSON_SYNSEMA", "synsema"), ["run", "--cap-set", ceiling_of(l), l["dir"] + "/" + l["entry"]], ".", envs, l["timeout"]), c.MAX_OUTPUT)
277
- let argv be split_command(l["command"])
278
- when length(argv) == 0
279
- give "ERROR: lamp '" + l["name"] + "' has an empty command"
280
- -- el comando corre desde la RAÍZ DEL WORKSPACE (para que "python lamp.py" vea el proyecto), así que un
281
- -- token que nombra un archivo de la carpeta de la lámpara se resuelve solo (2026-08-28: "python lamp.py"
282
- -- salía con code 2 porque lamp.py no estaba en el cwd)
283
- let rel_dir be when starts_with(l["dir"], c.ROOT + "/") then slice(l["dir"], length(c.ROOT) + 1, length(l["dir"])) otherwise "../" + l["dir"]
284
- let resolved be []
285
- each tok in argv
286
- when not contains(tok, "/") and not contains(tok, "\\") and file_exists(l["dir"] + "/" + tok)
287
- set resolved to append(resolved, rel_dir + "/" + tok)
288
- otherwise
289
- set resolved to append(resolved, tok)
290
- give c.truncate(run_lamp(resolved[0], slice(resolved, 1, length(resolved)), c.ROOT, envs, l["timeout"]), c.MAX_OUTPUT)
291
-
292
- -- crear una lámpara DEL PROYECTO (workspace/.lampson/lamps/<name>/): escribe lamp.json + el código, revalida el
293
- -- manifiesto y, si es syn, corre `synsema check` sobre el entry. Como cordis_define en dsh: define y valida,
294
- -- NO ejecuta ni enciende — encender sigue siendo del humano (set_enabled tras ask).
295
- -- `files` = {"lamp.syn": "…", "helper.py": "…"}: nombres simples (sin / ni ..) dentro de la carpeta.
296
- export task create(name, manifest, files)
297
- require exec
298
- require time
299
- require file(".lampson")
300
- require file(".lampson/*")
301
- require file.read("lamps")
302
- require file.read("lamps/*")
303
- require file("workspace")
304
- require file("workspace/*")
305
- require env("LAMPSON_*")
306
- when not valid_name(name)
307
- raise("invalid lamp name '" + text(name) + "': letters, digits and - only (no _), max 32")
308
- when manifest == nothing or not contains(manifest, "tools")
309
- raise("manifest needs at least \"tools\" (and \"entry\" for kind=syn or \"command\" for kind=exec)")
310
- let dir be PROJECT_DIR + "/" + name
311
- let m be manifest
312
- set m["name"] to name
313
- when not contains(m, "kind")
314
- set m["kind"] to "syn"
315
- write_file(dir + "/lamp.json", json_encode(m))
316
- let written be ["lamp.json"]
317
- when files != nothing
318
- each fname in keys(files)
319
- when not matches(fname, "[A-Za-z0-9_.-]{1,64}") or starts_with(fname, ".")
320
- raise("bad file name '" + fname + "' (simple names inside the lamp folder only)")
321
- write_file(dir + "/" + fname, text(files[fname]))
322
- set written to append(written, fname)
323
- let found be where(all(), (l) => l["name"] == name)
324
- when length(found) == 0
325
- raise("lamp written but not discovered (?)")
326
- let l be found[0]
327
- when l["error"] != nothing
328
- give "lamp '" + name + "' written to " + dir + " but its manifest is INVALID: " + text(l["error"]) + " — fix it with edit and call lamp(action=list) to re-check"
329
- let note be ""
330
- when l["kind"] == "syn"
331
- let chk be run_lamp(env("LAMPSON_SYNSEMA", "synsema"), ["check", l["dir"] + "/" + l["entry"]], ".", {}, 30)
332
- when starts_with(chk, "ERROR")
333
- give "lamp '" + name + "' written to " + dir + " but `synsema check " + l["entry"] + "` FAILED:\n" + chk + "\nFix the code with edit; it stays off until it checks and the user turns it on."
334
- set note to " · synsema check OK · ceiling when on: " + ceiling_of(l)
335
- when l["kind"] == "exec"
336
- set note to " · exec (no capability ceiling: the user will see that when approving)"
337
- give "lamp '" + name + "' created (project, " + l["kind"] + ", " + text(length(l["tools"])) + " tools: " + join(apply(l["tools"], (t) => t["name"]), ", ") + ") in " + dir + " [" + join(written, ", ") + "]" + note + (when l["kind"] == "exec" then ". The command runs from the workspace root; a file of the lamp folder named in the command is resolved automatically" otherwise "") + ". It is OFF until the user turns it on: offer to do it (lamp action=enable asks them), or point them to the lamp switch of their UI. Its tools will be lamp_" + name + "_<tool>."
338
-
339
- -- eliminar una lámpara DEL PROYECTO: borra su carpeta (no hay delete_file en el runtime → rm -rf por el shell
340
- -- de bash.syn) y la saca del estado. Las globales (lamps/) se borran a mano: son de la instalación, no del repo.
341
- export task remove(name)
342
- require exec
343
- require time
344
- require env("LAMPSON_*")
345
- require env("OS")
346
- require file(".lampson")
347
- require file(".lampson/*")
348
- require file.read("lamps")
349
- require file.read("lamps/*")
350
- require file("workspace")
351
- require file("workspace/*")
352
- when not valid_name(name)
353
- raise("invalid lamp name")
354
- let found be where(all(), (l) => l["name"] == name)
355
- when length(found) == 0
356
- raise("no lamp named '" + name + "'")
357
- let l be found[0]
358
- when l["scope"] != "project"
359
- raise("'" + name + "' is a " + l["scope"] + " lamp (" + GLOBAL_DIR + "/" + name + "): delete that folder by hand")
360
- let dir be PROJECT_DIR + "/" + name
361
- let is_win be env("OS", "") == "Windows_NT"
362
- let sh be env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
363
- let r be run(sh, ["-c", "rm -rf '" + dir + "'"], 30, {"cwd": "."})
364
- when file_exists(dir + "/lamp.json")
365
- raise("could not delete " + dir + ": " + text(r["stderr"]))
366
- let st be load_state()
367
- let clean be {}
368
- each k in keys(st)
369
- when k != name
370
- set clean[k] to st[k]
371
- write_file(STATE_FILE, json_encode({"enabled": clean}))
372
- give "lamp '" + name + "' deleted (" + dir + ")"
373
-
374
- -- resumen para UI/terminal/tool
375
- export task summary()
376
- require file(".lampson")
377
- require file(".lampson/*")
378
- require file.read("lamps")
379
- require file.read("lamps/*")
380
- require file("workspace")
381
- require file("workspace/*")
382
- require env("LAMPSON_*")
383
- let out be []
384
- each l in all()
385
- set out to append(out, {"name": l["name"], "scope": l["scope"], "kind": l["kind"], "description": l["description"], "enabled": l["enabled"], "error": l["error"], "caps": when l["kind"] == "syn" and l["error"] == nothing then ceiling_of(l) otherwise "", "command": when l["kind"] == "exec" then l["command"] otherwise "", "tools": apply(l["tools"], (t) => t["name"]), "tool_specs": l["tools"], "dir": l["dir"]})
386
- give out