lampson 0.2.6 → 0.2.7

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.
@@ -0,0 +1,408 @@
1
+ -- lib/plugins.syn — plugins: tools que el usuario (o el agente) escribe y el humano ENCIENDE
2
+ --
3
+ -- Qué es un plugin: una carpeta con un manifiesto `plugin.json` y el código que implementa sus tools.
4
+ -- plugins/<nombre>/ GLOBAL (lampson se instala una vez → vale para todos los proyectos)
5
+ -- workspace/.lampson/plugins/<nombre>/ del PROYECTO (el agente puede crearlos con write: es su workspace)
6
+ -- plugin.json = {"name", "description", "kind": "syn" | "exec",
7
+ -- "entry": "plugin.syn" (kind=syn: un programa Synsema)
8
+ -- "command": "python plugin.py" (kind=exec: cualquier ejecutable — js, py, sh…)
9
+ -- "caps": "file.read=workspace/*" (kind=syn, opcional: techo EXTRA sobre stdout,time,env=PLUGIN_*)
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=PLUGIN_*[,caps] <entry>`: el techo lo fija el
15
+ -- manifiesto que el humano aprobó al encenderlo; un `require` de más en el código del
16
+ -- plugin 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: PLUGIN_TOOL, PLUGIN_ARGS (JSON), PLUGIN_DIR (carpeta del plugin,
19
+ -- relativa a la raíz de lampson = cwd del hijo si kind=syn; si kind=exec el cwd es el workspace),
20
+ -- PLUGIN_WORKSPACE (ruta real del proyecto). El plugin imprime el resultado (texto o JSON) por stdout.
21
+ --
22
+ -- Encendido: `.lampson/plugins.json` {"enabled": {"<nombre>": true}}. APAGADO por defecto: descubrir una
23
+ -- carpeta nueva no lo activa; lo enciende el humano (UI: barra superior; terminal: /plugins on <nombre>) o el
24
+ -- agente con la tool `plugin` (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 `plugin_<plugin>_<tool>`; loop.execute las despacha acá
29
+ -- (registry las marca con "plugin", como "mcp"). permission.syn: ask por defecto, yolo permite, strict deniega;
30
+ -- las readonly también en plan/review/explore.
31
+ --
32
+ -- Compat (2026-09-01: antes se llamaban "lámparas"/lamps). Se siguen descubriendo las carpetas viejas
33
+ -- `.lampson/lamps/<nombre>/` (marcadas `legacy: true` en summary; la UI y /plugins lo avisan), un `plugin.json`
34
+ -- ausente cae a `lamp.json`, el estado cae a `.lampson/lamps.json` si `.lampson/plugins.json` no existe (se
35
+ -- escribe siempre el nuevo), y el hijo recibe también LAMP_TOOL/LAMP_ARGS/LAMP_DIR/LAMP_WORKSPACE (el techo
36
+ -- base incluye env=LAMP_*). Es un puente, no un formato: renombrá la carpeta y el manifiesto.
37
+
38
+ use "./tools/common.syn" as c
39
+
40
+ export let GLOBAL_DIR be "plugins"
41
+ export let PROJECT_DIR be "workspace/.lampson/plugins"
42
+ export let LEGACY_PROJECT_DIR be "workspace/.lampson/lamps"
43
+ export let STATE_FILE be ".lampson/plugins.json"
44
+ let LEGACY_STATE_FILE be ".lampson/lamps.json"
45
+ export let PREFIX be "plugin_"
46
+ export let LEGACY_HINT be "old .lampson/lamps/ folder — rename it to .lampson/plugins/ (and lamp.json to plugin.json)"
47
+ let DEFAULT_TIMEOUT be 60
48
+ let BASE_CAPS be "stdout,time,env=PLUGIN_*,env=LAMP_*"
49
+
50
+ -- nombre de plugin: SIN "_" (el separador de plugin_<plugin>_<tool>); las tools sí pueden llevarlo
51
+ task valid_name(name)
52
+ when name == nothing or name == ""
53
+ give false
54
+ give matches(text(name), "[a-zA-Z0-9-]{1,32}")
55
+
56
+ task valid_tool_name(name)
57
+ when name == nothing or name == ""
58
+ give false
59
+ give matches(text(name), "[a-zA-Z0-9_-]{1,40}")
60
+
61
+ -- lee y valida un manifiesto; devuelve {ok, plugin} o {ok: false, error}. `legacy` = viene de una carpeta vieja
62
+ -- (o de un lamp.json): se lista igual, con el aviso.
63
+ task read_manifest(dir, name, scope, legacy)
64
+ let path be dir + "/" + name + "/plugin.json"
65
+ let is_legacy be legacy
66
+ when not file_exists(path) and file_exists(dir + "/" + name + "/lamp.json")
67
+ set path to dir + "/" + name + "/lamp.json"
68
+ set is_legacy to true
69
+ let doc be nothing
70
+ try
71
+ set doc to json_decode(read_file(path))
72
+ recover err
73
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "no plugin.json: " + text(err)}
74
+ let kind be when contains(doc, "kind") then lower(text(doc["kind"])) otherwise "syn"
75
+ when kind != "syn" and kind != "exec"
76
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "kind must be syn or exec"}
77
+ when kind == "syn" and not contains(doc, "entry")
78
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "kind=syn needs \"entry\" (the .syn file)"}
79
+ when kind == "exec" and not contains(doc, "command")
80
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "kind=exec needs \"command\""}
81
+ when not contains(doc, "tools") or length(doc["tools"]) == 0
82
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "no tools declared"}
83
+ let tools be []
84
+ each t in doc["tools"]
85
+ when not contains(t, "name") or not valid_tool_name(t["name"])
86
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "a tool has no valid name"}
87
+ 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})
88
+ give {"ok": true, "name": name, "scope": scope, "legacy": is_legacy, "plugin": {"name": name, "scope": scope, "dir": dir + "/" + name, "kind": kind, "legacy": is_legacy, "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}}
89
+
90
+ task scan(dir, scope, legacy)
91
+ let out be []
92
+ try
93
+ each e in list_dir(dir)
94
+ when e["is_dir"] and valid_name(e["name"])
95
+ set out to append(out, read_manifest(dir, e["name"], scope, legacy))
96
+ recover err
97
+ give out
98
+ give out
99
+
100
+ -- estado de encendido: el archivo nuevo; si no existe todavía, el viejo (solo lectura — se escribe siempre el nuevo)
101
+ task load_state()
102
+ let f be when file_exists(STATE_FILE) then STATE_FILE otherwise LEGACY_STATE_FILE
103
+ try
104
+ let doc be json_decode(read_file(f))
105
+ give when contains(doc, "enabled") then doc["enabled"] otherwise {}
106
+ recover err
107
+ give {}
108
+
109
+ -- todos los plugins descubiertos (proyecto pisa a global por nombre; la carpeta nueva pisa a la vieja), con
110
+ -- enabled, legacy y error. LAMPSON_PLUGINS_DIR = una carpeta extra (tests).
111
+ export task all()
112
+ require file(".lampson")
113
+ require file(".lampson/*")
114
+ require file.read("plugins")
115
+ require file.read("plugins/*")
116
+ require file("workspace")
117
+ require file("workspace/*")
118
+ require env("LAMPSON_*")
119
+ let by_name be {}
120
+ each r in scan(GLOBAL_DIR, "global", false)
121
+ set by_name[r["name"]] to r
122
+ each r in scan(LEGACY_PROJECT_DIR, "project", true)
123
+ set by_name[r["name"]] to r
124
+ each r in scan(PROJECT_DIR, "project", false)
125
+ set by_name[r["name"]] to r
126
+ when env("LAMPSON_PLUGINS_DIR", "") != ""
127
+ each r in scan(env("LAMPSON_PLUGINS_DIR", ""), "extra", false)
128
+ set by_name[r["name"]] to r
129
+ let st be load_state()
130
+ let out be []
131
+ each n in sort_by(keys(by_name), (x) => x)
132
+ let r be by_name[n]
133
+ let on be contains(st, n) and st[n] == true
134
+ when r["ok"]
135
+ let l be r["plugin"]
136
+ set l["enabled"] to on
137
+ set l["error"] to nothing
138
+ set out to append(out, l)
139
+ otherwise
140
+ set out to append(out, {"name": n, "scope": r["scope"], "dir": "", "kind": "?", "legacy": r["legacy"], "description": "", "entry": "", "command": "", "caps": "", "timeout": 0, "tools": [], "enabled": false, "error": r["error"]})
141
+ give out
142
+
143
+ export task enabled()
144
+ require file(".lampson")
145
+ require file(".lampson/*")
146
+ require file.read("plugins")
147
+ require file.read("plugins/*")
148
+ require file("workspace")
149
+ require file("workspace/*")
150
+ require env("LAMPSON_*")
151
+ give where(all(), (l) => l["enabled"] and l["error"] == nothing)
152
+
153
+ -- encender/apagar (el humano, o el agente tras aprobación). Encender un plugin roto es error.
154
+ export task set_enabled(name, on)
155
+ require file(".lampson")
156
+ require file(".lampson/*")
157
+ require file.read("plugins")
158
+ require file.read("plugins/*")
159
+ require file("workspace")
160
+ require file("workspace/*")
161
+ require env("LAMPSON_*")
162
+ when not valid_name(name)
163
+ raise("invalid plugin name")
164
+ let found be where(all(), (l) => l["name"] == name)
165
+ when length(found) == 0
166
+ raise("no plugin named '" + name + "' (global: " + GLOBAL_DIR + "/<name>/plugin.json · project: " + PROJECT_DIR + "/<name>/plugin.json)")
167
+ when on and found[0]["error"] != nothing
168
+ raise("plugin '" + name + "' is broken: " + text(found[0]["error"]))
169
+ let st be load_state()
170
+ set st[name] to on == true
171
+ write_file(STATE_FILE, json_encode({"enabled": st}))
172
+ let l be found[0]
173
+ let legacy_note be when l["legacy"] then " · NOTE: " + LEGACY_HINT otherwise ""
174
+ when on
175
+ give "plugin '" + 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 " + PREFIX + name + "_<tool>" + legacy_note
176
+ give "plugin '" + name + "' OFF" + legacy_note
177
+
178
+ task ceiling_of(l)
179
+ give when l["caps"] == "" then BASE_CAPS otherwise BASE_CAPS + "," + l["caps"]
180
+
181
+ -- ---------- catálogo ----------
182
+ export task tool_name(plugin, tool)
183
+ give PREFIX + plugin + "_" + tool
184
+
185
+ task split_name(full)
186
+ let segs be split(slice(full, length(PREFIX), length(full)), "_")
187
+ when length(segs) < 2
188
+ raise("bad plugin tool name '" + full + "'")
189
+ give {"plugin": segs[0], "tool": join(slice(segs, 1, length(segs)), "_")}
190
+
191
+ export task catalog(readonly_only)
192
+ require file(".lampson")
193
+ require file(".lampson/*")
194
+ require file.read("plugins")
195
+ require file.read("plugins/*")
196
+ require file("workspace")
197
+ require file("workspace/*")
198
+ require env("LAMPSON_*")
199
+ let out be []
200
+ each l in enabled()
201
+ each t in l["tools"]
202
+ when not readonly_only or t["readonly"]
203
+ set out to append(out, {"name": tool_name(l["name"], t["name"]), "description": "[plugin " + l["name"] + "] " + t["description"], "parameters": t["parameters"]})
204
+ give out
205
+
206
+ export task names(readonly_only)
207
+ require file(".lampson")
208
+ require file(".lampson/*")
209
+ require file.read("plugins")
210
+ require file.read("plugins/*")
211
+ require file("workspace")
212
+ require file("workspace/*")
213
+ require env("LAMPSON_*")
214
+ give apply(catalog(readonly_only), (s) => s["name"])
215
+
216
+ -- ---------- llamada: un proceso hijo por invocación ----------
217
+ task run_child(exe, argv, cwd, envs, t)
218
+ let p be proc_spawn(exe, argv, {"cwd": cwd, "env": envs, "stderr": "merge", "on_full": "drop_oldest"})
219
+ proc_close_stdin(p)
220
+ let deadline be now() + t
221
+ let lines be []
222
+ let code be nothing
223
+ let timed_out be false
224
+ while code == nothing and not timed_out
225
+ let left be deadline - now()
226
+ when left <= 0
227
+ set timed_out to true
228
+ otherwise
229
+ let ev be proc_recv(p, when left < 0.2 then 0.2 otherwise left)
230
+ when ev == nothing
231
+ set timed_out to now() >= deadline
232
+ otherwise when ev["type"] == "exit"
233
+ set code to ev["data"]["exit_code"]
234
+ otherwise
235
+ set lines to append(lines, ev["data"])
236
+ proc_close(p)
237
+ let out be join(lines, "\n")
238
+ when timed_out
239
+ give "ERROR: plugin timed out after " + text(t) + "s (killed). Output so far:\n" + out
240
+ when code != 0
241
+ give "ERROR: plugin exited with code " + text(code) + "\n" + out
242
+ give when trim(out) == "" then "(no output)" otherwise out
243
+
244
+ -- separa "python plugin.py --x" en exe + args (comillas simples/dobles respetadas)
245
+ export task split_command(line)
246
+ let toks be []
247
+ let cur be ""
248
+ let q be ""
249
+ let has be false
250
+ let i be 0
251
+ while i < length(line)
252
+ let ch be slice(line, i, i + 1)
253
+ set i to i + 1
254
+ when q != ""
255
+ when ch == q
256
+ set q to ""
257
+ otherwise
258
+ set cur to cur + ch
259
+ otherwise when ch == "\"" or ch == "'"
260
+ set q to ch
261
+ set has to true
262
+ otherwise when ch == " " or ch == "\t"
263
+ when cur != "" or has
264
+ set toks to append(toks, cur)
265
+ set cur to ""
266
+ set has to false
267
+ otherwise
268
+ set cur to cur + ch
269
+ when cur != "" or has
270
+ set toks to append(toks, cur)
271
+ give toks
272
+
273
+ export task call(full_name, args)
274
+ require exec
275
+ require time
276
+ require file(".lampson")
277
+ require file(".lampson/*")
278
+ require file.read("plugins")
279
+ require file.read("plugins/*")
280
+ require file("workspace")
281
+ require file("workspace/*")
282
+ require env("LAMPSON_*")
283
+ let parts be split_name(full_name)
284
+ let found be where(enabled(), (l) => l["name"] == parts["plugin"])
285
+ when length(found) == 0
286
+ give "ERROR: plugin '" + parts["plugin"] + "' is not enabled (the user turns plugins on)"
287
+ let l be found[0]
288
+ let tl be where(l["tools"], (t) => t["name"] == parts["tool"])
289
+ when length(tl) == 0
290
+ give "ERROR: plugin '" + l["name"] + "' has no tool '" + parts["tool"] + "'"
291
+ -- PLUGIN_DIR relativa a la raíz de lampson (cwd del hijo para kind=syn); PLUGIN_WORKSPACE = la ruta real del
292
+ -- proyecto (lampson.ps1 la exporta; sin ella, la junction). LAMP_* = los nombres viejos, por compat.
293
+ let tool_args be json_encode(when args == nothing then {} otherwise args)
294
+ let ws be env("LAMPSON_WORKSPACE", c.ROOT)
295
+ let envs be {"PLUGIN_TOOL": parts["tool"], "PLUGIN_ARGS": tool_args, "PLUGIN_DIR": l["dir"], "PLUGIN_WORKSPACE": ws, "LAMP_TOOL": parts["tool"], "LAMP_ARGS": tool_args, "LAMP_DIR": l["dir"], "LAMP_WORKSPACE": ws}
296
+ when l["kind"] == "syn"
297
+ -- techo del manifiesto: el código del plugin no puede pedir más de lo que el humano aprobó
298
+ give c.truncate(run_child(env("LAMPSON_SYNSEMA", "synsema"), ["run", "--cap-set", ceiling_of(l), l["dir"] + "/" + l["entry"]], ".", envs, l["timeout"]), c.MAX_OUTPUT)
299
+ let argv be split_command(l["command"])
300
+ when length(argv) == 0
301
+ give "ERROR: plugin '" + l["name"] + "' has an empty command"
302
+ -- el comando corre desde la RAÍZ DEL WORKSPACE (para que "python plugin.py" vea el proyecto), así que un
303
+ -- token que nombra un archivo de la carpeta del plugin se resuelve solo (2026-08-28: "python lamp.py"
304
+ -- salía con code 2 porque lamp.py no estaba en el cwd)
305
+ 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"]
306
+ let resolved be []
307
+ each tok in argv
308
+ when not contains(tok, "/") and not contains(tok, "\\") and file_exists(l["dir"] + "/" + tok)
309
+ set resolved to append(resolved, rel_dir + "/" + tok)
310
+ otherwise
311
+ set resolved to append(resolved, tok)
312
+ give c.truncate(run_child(resolved[0], slice(resolved, 1, length(resolved)), c.ROOT, envs, l["timeout"]), c.MAX_OUTPUT)
313
+
314
+ -- crear un plugin DEL PROYECTO (workspace/.lampson/plugins/<name>/): escribe plugin.json + el código, revalida el
315
+ -- manifiesto y, si es syn, corre `synsema check` sobre el entry. Como cordis_define en dsh: define y valida,
316
+ -- NO ejecuta ni enciende — encender sigue siendo del humano (set_enabled tras ask).
317
+ -- `files` = {"plugin.syn": "…", "helper.py": "…"}: nombres simples (sin / ni ..) dentro de la carpeta.
318
+ export task create(name, manifest, files)
319
+ require exec
320
+ require time
321
+ require file(".lampson")
322
+ require file(".lampson/*")
323
+ require file.read("plugins")
324
+ require file.read("plugins/*")
325
+ require file("workspace")
326
+ require file("workspace/*")
327
+ require env("LAMPSON_*")
328
+ when not valid_name(name)
329
+ raise("invalid plugin name '" + text(name) + "': letters, digits and - only (no _), max 32")
330
+ when manifest == nothing or not contains(manifest, "tools")
331
+ raise("manifest needs at least \"tools\" (and \"entry\" for kind=syn or \"command\" for kind=exec)")
332
+ let dir be PROJECT_DIR + "/" + name
333
+ let m be manifest
334
+ set m["name"] to name
335
+ when not contains(m, "kind")
336
+ set m["kind"] to "syn"
337
+ write_file(dir + "/plugin.json", json_encode(m))
338
+ let written be ["plugin.json"]
339
+ when files != nothing
340
+ each fname in keys(files)
341
+ when not matches(fname, "[A-Za-z0-9_.-]{1,64}") or starts_with(fname, ".")
342
+ raise("bad file name '" + fname + "' (simple names inside the plugin folder only)")
343
+ write_file(dir + "/" + fname, text(files[fname]))
344
+ set written to append(written, fname)
345
+ let found be where(all(), (l) => l["name"] == name)
346
+ when length(found) == 0
347
+ raise("plugin written but not discovered (?)")
348
+ let l be found[0]
349
+ when l["error"] != nothing
350
+ give "plugin '" + name + "' written to " + dir + " but its manifest is INVALID: " + text(l["error"]) + " — fix it with edit and call plugin(action=list) to re-check"
351
+ let note be ""
352
+ when l["kind"] == "syn"
353
+ let chk be run_child(env("LAMPSON_SYNSEMA", "synsema"), ["check", l["dir"] + "/" + l["entry"]], ".", {}, 30)
354
+ when starts_with(chk, "ERROR")
355
+ give "plugin '" + 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."
356
+ set note to " · synsema check OK · ceiling when on: " + ceiling_of(l)
357
+ when l["kind"] == "exec"
358
+ set note to " · exec (no capability ceiling: the user will see that when approving)"
359
+ give "plugin '" + 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 plugin folder named in the command is resolved automatically" otherwise "") + ". It is OFF until the user turns it on: offer to do it (plugin action=enable asks them), or point them to the plugin switch of their UI. Its tools will be " + PREFIX + name + "_<tool>."
360
+
361
+ -- eliminar un plugin DEL PROYECTO: borra su carpeta (no hay delete_file en el runtime → rm -rf por el shell
362
+ -- de bash.syn) y lo saca del estado. Los globales (plugins/) se borran a mano: son de la instalación, no del repo.
363
+ export task remove(name)
364
+ require exec
365
+ require time
366
+ require env("LAMPSON_*")
367
+ require env("OS")
368
+ require file(".lampson")
369
+ require file(".lampson/*")
370
+ require file.read("plugins")
371
+ require file.read("plugins/*")
372
+ require file("workspace")
373
+ require file("workspace/*")
374
+ when not valid_name(name)
375
+ raise("invalid plugin name")
376
+ let found be where(all(), (l) => l["name"] == name)
377
+ when length(found) == 0
378
+ raise("no plugin named '" + name + "'")
379
+ let l be found[0]
380
+ when l["scope"] != "project"
381
+ raise("'" + name + "' is a " + l["scope"] + " plugin (" + GLOBAL_DIR + "/" + name + "): delete that folder by hand")
382
+ let dir be when l["dir"] != "" then l["dir"] otherwise PROJECT_DIR + "/" + name
383
+ let is_win be env("OS", "") == "Windows_NT"
384
+ let sh be env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
385
+ let r be run(sh, ["-c", "rm -rf '" + dir + "'"], 30, {"cwd": "."})
386
+ when file_exists(dir + "/plugin.json") or file_exists(dir + "/lamp.json")
387
+ raise("could not delete " + dir + ": " + text(r["stderr"]))
388
+ let st be load_state()
389
+ let clean be {}
390
+ each k in keys(st)
391
+ when k != name
392
+ set clean[k] to st[k]
393
+ write_file(STATE_FILE, json_encode({"enabled": clean}))
394
+ give "plugin '" + name + "' deleted (" + dir + ")"
395
+
396
+ -- resumen para UI/terminal/tool
397
+ export task summary()
398
+ require file(".lampson")
399
+ require file(".lampson/*")
400
+ require file.read("plugins")
401
+ require file.read("plugins/*")
402
+ require file("workspace")
403
+ require file("workspace/*")
404
+ require env("LAMPSON_*")
405
+ let out be []
406
+ each l in all()
407
+ set out to append(out, {"name": l["name"], "scope": l["scope"], "kind": l["kind"], "legacy": l["legacy"], "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"]})
408
+ give out
package/lib/prompt.syn CHANGED
@@ -56,7 +56,7 @@ Operate like a careful senior engineer: precise, honest, and economical with wor
56
56
  - bash: shell commands from the workspace root. State does not persist between calls (cd resets) — chain with &&. Check the [exit code: N] marker on every result (it is the last line) and investigate failures before moving on. Never pipe a build or test through head/tail: the output is already truncated for you (the full text is saved to a file whose path you get) and the pipe hides the real exit code. Never run a server or watcher here.
57
57
  - process: start/logs/stop long-running commands (dev servers, watchers). Their new log lines arrive automatically with every later tool result — do not sleep or poll. Stop what you started when the task is done.
58
58
  - delegate: sub-agents with a fresh context (explore, plan, review, worker); several tasks in one call run in parallel, background=true returns at once and the report arrives later as a message. Give a highly detailed, self-contained brief, say whether it should write code or only research, how to verify, and exactly what to return. Its report is a self-report — verify what matters before telling the user it is done. Its output is not visible to the user: summarise it.
59
- - memory: durable facts across sessions (user preferences, environment details, tool quirks, conventions). Check it before investigating. Write declarative facts, not instructions to yourself; do not store task progress or anything stale in a week.
59
+ - memory: durable facts across sessions (user preferences, environment details, tool quirks, conventions). Check it before investigating. Write declarative facts, not instructions to yourself; do not store task progress or anything stale in a week. The notes live outside the project: only this tool reaches them (never ls/bash, never files inside the repo).
60
60
  - skill: before starting a kind of task, scan the skill list below; if one matches or is even partially relevant, load it and follow it. Err on the side of loading.
61
61
  - todo: your task list (whole-list replacement, one in_progress at a time; see Method step 1).
62
62
  - MCP tools are named mcp_<server>_<tool>; calling one may ask the user for approval. The mcp tool lists, connects (action=add, with the user's approval) and disconnects servers; a new server's tools enter your catalog on the next turn.
@@ -70,6 +70,6 @@ Concise and direct; lead with the change or the answer, not a preamble. Prefer t
70
70
  - OS: {env_info["os"]} Shell used by bash tool: {env_info["shell"]}
71
71
  - Date: {env_info["date"]}
72
72
  - Model: {env_info["model"]} via {env_info["provider"]}
73
- - The user talks to you from the {env_info["ui"]} UI. When something needs the user's hand (turning a lamp on, adding an LSP server, connecting an MCP), tell them the way that exists in THEIR UI: web → the «lámparas» switch / the LSP and MCP sections of the sidebar; terminal → /lamps on <name>, /lsp add <preset>, /mcp add. Never tell a web user to type a slash command — or simply offer to do it yourself (the tool asks them for approval).`
73
+ - The user talks to you from the {env_info["ui"]} UI. When something needs the user's hand (turning a plugin on, adding an LSP server, connecting an MCP), tell them the way that exists in THEIR UI: web → the «plugins» switch / the LSP and MCP sections of the sidebar; terminal → /plugins on <name>, /lsp add <preset>, /mcp add. Never tell a web user to type a slash command — or simply offer to do it yourself (the tool asks them for approval).`
74
74
  let mode be when addendum == nothing then "" otherwise (when addendum == "" then "" otherwise "\n\n# Mode\n" + addendum)
75
75
  give core + mode + skills.prompt_section(skills.index()) + memo.prompt_section() + read_context_files()
package/lib/sched_run.syn CHANGED
@@ -1,4 +1,4 @@
1
- -- lib/sched_run.syn — ejecutar UNA tarea programada (lamp | bash | prompt) y dejar registro
1
+ -- lib/sched_run.syn — ejecutar UNA tarea programada (plugin | bash | prompt) y dejar registro
2
2
  --
3
3
  -- Separado de lib/schedule.syn porque las corridas `prompt` necesitan agents/loop/provider, y schedule.syn es
4
4
  -- importado por tools.syn (que agents.syn importa): acá no hay ciclo. Lo usan web.syn (tick del cron y
@@ -23,7 +23,7 @@ use "./session.syn" as session
23
23
  use "./trace.syn" as trace
24
24
  use "./permission.syn" as permission
25
25
  use "./tools.syn" as tools
26
- use "./lamps.syn" as lamps
26
+ use "./plugins.syn" as plugins
27
27
 
28
28
  let PROMPT_NOTE be "\n\n# Scheduled run\n- This is an UNATTENDED scheduled run (no user is watching). Do the task, verify, and finish with a short report: what you found or changed, with paths. Do not ask questions - if something is missing, say so in the report.\n- A permission request may take hours to be answered (the user gets a link). Prefer actions that need no approval; if one is denied, do not retry it - report it.\n- ONLY the workspace is accessible: absolute paths, `..`, home folders, other projects and skill folders outside it do NOT exist for your tools (a `File not found` there is final - never retry). Work with what is inside the workspace and what you already know."
29
29
 
@@ -67,8 +67,8 @@ export task run(t, ask_fn, on_event)
67
67
  require file("workspace/*")
68
68
  require file.read("skills")
69
69
  require file.read("skills/*")
70
- require file.read("lamps")
71
- require file.read("lamps/*")
70
+ require file.read("plugins")
71
+ require file.read("plugins/*")
72
72
  require file("memory")
73
73
  require file("memory/*")
74
74
  require file(".lampson")
@@ -94,11 +94,11 @@ export task run(t, ask_fn, on_event)
94
94
  let names be where(agents.profile(p)["tools"], (n) => n != "delegate" and n != "mcp" and n != "schedule")
95
95
  let reg be tools.registry_subset(names)
96
96
  let cat be tools.catalog_subset(names)
97
- -- lámparas encendidas también (las de solo lectura si el perfil no edita)
97
+ -- plugins encendidos también (las de solo lectura si el perfil no edita)
98
98
  let ro be not (p == "build")
99
- each ln in lamps.names(ro)
100
- set reg[ln] to "lamp"
101
- set cat to cat + lamps.catalog(ro)
99
+ each ln in plugins.names(ro)
100
+ set reg[ln] to "plugin"
101
+ set cat to cat + plugins.catalog(ro)
102
102
  let asker be ask_fn
103
103
  when asker == nothing and t["permission"] == "ask"
104
104
  set asker to ask_remote
@@ -135,8 +135,8 @@ export task tick()
135
135
  require file("workspace/*")
136
136
  require file.read("skills")
137
137
  require file.read("skills/*")
138
- require file.read("lamps")
139
- require file.read("lamps/*")
138
+ require file.read("plugins")
139
+ require file.read("plugins/*")
140
140
  require file("memory")
141
141
  require file("memory/*")
142
142
  require file(".lampson")
package/lib/schedule.syn CHANGED
@@ -1,7 +1,7 @@
1
1
  -- lib/schedule.syn — tareas programadas: "cada 6 h", "todos los días a las 9", "lunes 8:30"
2
2
  --
3
3
  -- Qué se programa (action.type):
4
- -- lamp → una tool de una lámpara ENCENDIDA: {lamp, tool, args}. Encenderla ya fue la autorización.
4
+ -- plugin → una tool de un plugin ENCENDIDO: {plugin, tool, args}. Encenderlo ya fue la autorización.
5
5
  -- bash → un comando fijo desde el workspace: {command, timeout}. Aprobado UNA vez, al crear la tarea.
6
6
  -- prompt → una corrida completa del agente con un texto: {prompt, agent}. Sin humano al lado: corre con el
7
7
  -- sobre de permisos que se fijó al crearla (permission: strict | ask | yolo). En `ask`, cuando el
@@ -23,7 +23,7 @@
23
23
  -- Este módulo no importa agents/loop (ciclo con tools.syn): las corridas `prompt` las hace lib/sched_run.syn.
24
24
 
25
25
  use "./tools/bash.syn" as t_bash
26
- use "./lamps.syn" as lamps
26
+ use "./plugins.syn" as plugins
27
27
  use "./permission.syn" as permission
28
28
  use "./tools/common.syn" as c
29
29
  use "./tools/memo.syn" as memo
@@ -380,15 +380,19 @@ task slug(name)
380
380
  -- valida y normaliza la acción → {ok, action, error}
381
381
  task check_action(action)
382
382
  when action == nothing or not contains(action, "type")
383
- give {"ok": false, "error": "action needs a type: lamp | bash | prompt"}
383
+ give {"ok": false, "error": "action needs a type: plugin | bash | prompt"}
384
384
  let kind be lower(text(action["type"]))
385
385
  when kind == "lamp"
386
- when not contains(action, "lamp") or not contains(action, "tool")
387
- give {"ok": false, "error": "action lamp needs lamp and tool"}
388
- let full be lamps.tool_name(text(action["lamp"]), text(action["tool"]))
389
- when not contains(lamps.names(false), full)
390
- give {"ok": false, "error": "lamp tool " + full + " is not available (the lamp must exist and be ON)"}
391
- give {"ok": true, "action": {"type": "lamp", "lamp": text(action["lamp"]), "tool": text(action["tool"]), "args": when contains(action, "args") then action["args"] otherwise {}}}
386
+ -- compat: "lamp" era el nombre de "plugin" hasta 2026-09 (tareas guardadas, JSON escrito a mano)
387
+ set kind to "plugin"
388
+ when kind == "plugin"
389
+ let pname be plugin_of(action)
390
+ when pname == "" or not contains(action, "tool")
391
+ give {"ok": false, "error": "action plugin needs plugin and tool"}
392
+ let full be plugins.tool_name(pname, text(action["tool"]))
393
+ when not contains(plugins.names(false), full)
394
+ give {"ok": false, "error": "plugin tool " + full + " is not available (the plugin must exist and be ON)"}
395
+ give {"ok": true, "action": {"type": "plugin", "plugin": pname, "tool": text(action["tool"]), "args": when contains(action, "args") then action["args"] otherwise {}}}
392
396
  when kind == "bash"
393
397
  when not contains(action, "command") or trim(text(action["command"])) == ""
394
398
  give {"ok": false, "error": "action bash needs a command"}
@@ -405,7 +409,7 @@ task check_action(action)
405
409
  when not contains(["build", "plan", "review", "explore"], agent)
406
410
  give {"ok": false, "error": "agent must be build | plan | review | explore"}
407
411
  give {"ok": true, "action": {"type": "prompt", "prompt": text(action["prompt"]), "agent": agent}}
408
- give {"ok": false, "error": "unknown action type '" + kind + "' (lamp | bash | prompt)"}
412
+ give {"ok": false, "error": "unknown action type '" + kind + "' (plugin | bash | prompt)"}
409
413
 
410
414
  -- crear: spec = {name, when, action, permission?, approval_timeout?, notify?} → la tarea (raise si es inválida)
411
415
  export task add(spec)
@@ -415,8 +419,8 @@ export task add(spec)
415
419
  require env("OS")
416
420
  require file(".lampson")
417
421
  require file(".lampson/*")
418
- require file.read("lamps")
419
- require file.read("lamps/*")
422
+ require file.read("plugins")
423
+ require file.read("plugins/*")
420
424
  require file("workspace")
421
425
  require file("workspace/*")
422
426
  when spec == nothing or not contains(spec, "when")
@@ -450,9 +454,20 @@ export task add(spec)
450
454
  put(t)
451
455
  give t
452
456
 
457
+ -- el nombre del plugin de una acción; acepta la clave vieja "lamp" (tareas guardadas antes de 2026-09)
458
+ task plugin_of(action)
459
+ when contains(action, "plugin")
460
+ give text(action["plugin"])
461
+ when contains(action, "lamp")
462
+ give text(action["lamp"])
463
+ give ""
464
+
465
+ task is_plugin_action(a)
466
+ give a["type"] == "plugin" or a["type"] == "lamp"
467
+
453
468
  task default_name(action)
454
- when action["type"] == "lamp"
455
- give action["lamp"] + " " + action["tool"]
469
+ when is_plugin_action(action)
470
+ give plugin_of(action) + " " + action["tool"]
456
471
  when action["type"] == "bash"
457
472
  let cmd be action["command"]
458
473
  give when length(cmd) > 40 then slice(cmd, 0, 40) + "…" otherwise cmd
@@ -533,7 +548,7 @@ export task heartbeat()
533
548
  write_file(HEARTBEAT, text(now()))
534
549
  give true
535
550
 
536
- -- ---------- corridas: lamp y bash (prompt en sched_run.syn) ----------
551
+ -- ---------- corridas: plugin y bash (prompt en sched_run.syn) ----------
537
552
  export task run_simple(t)
538
553
  require exec
539
554
  require time
@@ -541,13 +556,13 @@ export task run_simple(t)
541
556
  require env("OS")
542
557
  require file(".lampson")
543
558
  require file(".lampson/*")
544
- require file.read("lamps")
545
- require file.read("lamps/*")
559
+ require file.read("plugins")
560
+ require file.read("plugins/*")
546
561
  require file("workspace")
547
562
  require file("workspace/*")
548
563
  let a be t["action"]
549
- when a["type"] == "lamp"
550
- give lamps.call(lamps.tool_name(a["lamp"], a["tool"]), a["args"])
564
+ when is_plugin_action(a)
565
+ give plugins.call(plugins.tool_name(plugin_of(a), a["tool"]), a["args"])
551
566
  when a["type"] == "bash"
552
567
  let v be permission.evaluate("bash", {"command": a["command"]}, "yolo")
553
568
  when v["decision"] == "deny"
@@ -640,8 +655,8 @@ export task log_tail(id, n)
640
655
 
641
656
  -- descripción de la acción (una línea) para previews y listados
642
657
  export task describe_action(a)
643
- when a["type"] == "lamp"
644
- give "lamp " + a["lamp"] + "." + a["tool"] + (when length(keys(a["args"])) > 0 then " " + one_line(json_encode(a["args"]), 80) otherwise "")
658
+ when is_plugin_action(a)
659
+ give "plugin " + plugin_of(a) + "." + a["tool"] + (when length(keys(a["args"])) > 0 then " " + one_line(json_encode(a["args"]), 80) otherwise "")
645
660
  when a["type"] == "bash"
646
661
  give "$ " + one_line(a["command"], 120)
647
662
  give "agent " + a["agent"] + ": " + one_line(a["prompt"], 140)