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.
@@ -0,0 +1,220 @@
1
+ -- lib/tools/url.syn — URLs para la tool fetch y para permission.syn (puro, sin capacidades)
2
+ --
3
+ -- parse(url) → {ok, error?, url, scheme, host, port, path, query, origin, dir}
4
+ -- host_class(host) → "public" | "private" | "blocked"
5
+ -- sensitive(url) → motivo (texto) si la URL lleva una credencial, o nothing
6
+ -- resolve(base, href) → URL absoluta de un href (relativo o no), o nothing si no es navegable (#, javascript:)
7
+ --
8
+ -- Política de hosts (tomada de hermes url_safety.py, sin resolver DNS: el runtime no expone un resolver):
9
+ -- * "blocked": endpoints de metadata de la nube (169.254.169.254 y familia, metadata.google.internal).
10
+ -- Nunca son un destino legítimo: se deniegan SIEMPRE, incluso en yolo.
11
+ -- * "private": loopback, redes privadas (10/8, 172.16/12, 192.168/16, CGNAT 100.64/10), link-local,
12
+ -- localhost y sufijos internos. Legítimo para mirar el propio dev server → PIDE aprobación (yolo permite,
13
+ -- strict deniega). Un host numérico ambiguo (entero, hex, octal) cae aquí también.
14
+ -- * "public": el resto. Límite conocido: sin DNS no se detecta un nombre público que resuelve a una IP
15
+ -- privada (DNS rebinding); hermes lo cubre con un resolver, aquí queda documentado.
16
+
17
+ let SENSITIVE_PARAMS be ["access_token", "api_key", "apikey", "auth_token", "authorization", "awsaccesskeyid", "client_secret", "credential", "credentials", "jwt", "password", "passwd", "secret", "session_id", "signature", "token", "private_key", "x_amz_security_token", "x_amz_signature", "x-amz-security-token", "x-amz-signature"]
18
+ let SECRET_RE be "(?i)(\\bsk-(ant-|proj-)?[a-z0-9_-]{24,}|\\bghp_[a-z0-9]{20,}|\\bgithub_pat_[a-z0-9_]{20,}|\\bgho_[a-z0-9]{20,}|\\bglpat-[a-z0-9_-]{16,}|\\bxox[abprs]-[a-z0-9-]{10,}|\\bakia[0-9a-z]{16}\\b|\\baiza[0-9a-z_-]{30,}|-----begin [a-z ]*private key|\\beyj[a-z0-9_-]{20,}\\.eyj[a-z0-9_-]{20,})"
19
+
20
+ task fail(msg)
21
+ give {"ok": false, "error": msg}
22
+
23
+ export task parse(raw)
24
+ when raw == nothing
25
+ give fail("missing URL")
26
+ let u be trim(text(raw))
27
+ -- "https:// host" — el modelo a veces mete un espacio tras el esquema (hermes normalize_url_for_request)
28
+ set u to replace_re(u, "^([A-Za-z][A-Za-z0-9+.-]*://)\\s+", "\\1")
29
+ when u == ""
30
+ give fail("missing URL")
31
+ when length(u) > 2048
32
+ give fail("URL longer than 2048 chars")
33
+ when contains(u, " ") or contains(u, "\n") or contains(u, "\t")
34
+ give fail("URL contains whitespace: " + u)
35
+ let sp be split(u, "://")
36
+ when length(sp) < 2
37
+ give fail("not an absolute http(s) URL: " + u)
38
+ let scheme be lower(sp[0])
39
+ when scheme != "http" and scheme != "https"
40
+ give fail("unsupported scheme '" + scheme + "' (only http and https)")
41
+ let rest be join(slice(sp, 1, length(sp)), "://")
42
+ -- fragmento y query
43
+ let frag_sp be split(rest, "#")
44
+ set rest to frag_sp[0]
45
+ let q_sp be split(rest, "?")
46
+ let query be when length(q_sp) > 1 then join(slice(q_sp, 1, length(q_sp)), "?") otherwise ""
47
+ set rest to q_sp[0]
48
+ -- autoridad y path
49
+ let slash_sp be split(rest, "/")
50
+ let authority be slash_sp[0]
51
+ let path be when length(slash_sp) > 1 then "/" + join(slice(slash_sp, 1, length(slash_sp)), "/") otherwise "/"
52
+ when contains(authority, "@")
53
+ give fail("credentials in the URL are not allowed")
54
+ when authority == ""
55
+ give fail("missing host: " + u)
56
+ let host be lower(authority)
57
+ let port be ""
58
+ when starts_with(host, "[")
59
+ -- IPv6 literal: [::1]:8080
60
+ let close be split(host, "]")
61
+ set host to slice(close[0], 1, length(close[0]))
62
+ when length(close) > 1 and starts_with(close[1], ":")
63
+ set port to slice(close[1], 1, length(close[1]))
64
+ otherwise
65
+ let hp be split(host, ":")
66
+ when length(hp) > 2
67
+ give fail("malformed host: " + authority)
68
+ set host to hp[0]
69
+ when length(hp) == 2
70
+ set port to hp[1]
71
+ when port != "" and not matches(port, "[0-9]{1,5}")
72
+ give fail("malformed port: " + port)
73
+ -- "example.com." (FQDN con punto final) ≡ "example.com"
74
+ while ends_with(host, ".")
75
+ set host to slice(host, 0, length(host) - 1)
76
+ when host == ""
77
+ give fail("missing host: " + u)
78
+ let origin be scheme + "://" + host + (when port != "" then ":" + port otherwise "")
79
+ let dir_parts be split(path, "/")
80
+ let dir be join(slice(dir_parts, 0, length(dir_parts) - 1), "/") + "/"
81
+ give {"ok": true, "url": origin + path + (when query != "" then "?" + query otherwise ""), "scheme": scheme, "host": host, "port": port, "path": path, "query": query, "origin": origin, "dir": dir}
82
+
83
+ -- octetos de una IPv4 en notación decimal con puntos, o nothing
84
+ task ipv4_octets(host)
85
+ when not matches(host, "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}")
86
+ give nothing
87
+ let out be []
88
+ each p in split(host, ".")
89
+ let n be floor(number(p))
90
+ when n > 255
91
+ give nothing
92
+ set out to append(out, n)
93
+ give out
94
+
95
+ task ipv4_class(o)
96
+ let a be o[0]
97
+ let b be o[1]
98
+ when a == 169 and b == 254
99
+ give "blocked"
100
+ when a == 100 and b == 100 and o[2] == 100 and o[3] == 200
101
+ give "blocked"
102
+ when a == 127 or a == 10 or a == 0
103
+ give "private"
104
+ when a == 192 and b == 168
105
+ give "private"
106
+ when a == 172 and b >= 16 and b <= 31
107
+ give "private"
108
+ when a == 100 and b >= 64 and b <= 127
109
+ give "private"
110
+ when a >= 224
111
+ give "private"
112
+ give "public"
113
+
114
+ export task host_class(host)
115
+ let h be lower(text(host))
116
+ when h == "metadata.google.internal" or h == "metadata.goog" or h == "fd00:ec2::254"
117
+ give "blocked"
118
+ let o be ipv4_octets(h)
119
+ when o != nothing
120
+ give ipv4_class(o)
121
+ -- IPv6: loopback, unspecified, ULA fc00::/7, link-local fe80::/10, IPv4 mapeadas ::ffff:a.b.c.d
122
+ when contains(h, ":")
123
+ when h == "::1" or h == "::"
124
+ give "private"
125
+ when starts_with(h, "::ffff:")
126
+ let inner be ipv4_octets(slice(h, 7, length(h)))
127
+ give when inner != nothing then ipv4_class(inner) otherwise "private"
128
+ when starts_with(h, "fc") or starts_with(h, "fd") or starts_with(h, "fe8") or starts_with(h, "fe9") or starts_with(h, "fea") or starts_with(h, "feb")
129
+ give "private"
130
+ give "public"
131
+ when h == "localhost" or ends_with(h, ".localhost") or ends_with(h, ".internal") or ends_with(h, ".local") or ends_with(h, ".home.arpa") or ends_with(h, ".lan")
132
+ give "private"
133
+ -- formas numéricas ambiguas (2130706433, 0x7f000001, 0177.0.0.1): clásicos de bypass → tratar como privado
134
+ when matches(h, "[0-9]+") or matches(h, "0x[0-9a-f]+") or matches(h, "[0-9x.]+")
135
+ give "private"
136
+ give "public"
137
+
138
+ -- %XX → carácter (para inspeccionar una URL con secretos codificados)
139
+ task percent_decode(s)
140
+ let out be s
141
+ each m in find_all(s, "%[0-9A-Fa-f]{2}")
142
+ let ch be nothing
143
+ try
144
+ set ch to json_decode("\"\\u00" + slice(m, 1, 3) + "\"")
145
+ recover e
146
+ set ch to nothing
147
+ when ch != nothing
148
+ set out to replace_text(out, m, ch)
149
+ give out
150
+
151
+ export task sensitive(raw)
152
+ when raw == nothing
153
+ give nothing
154
+ let u be text(raw)
155
+ let decoded be percent_decode(u)
156
+ when length(find_all(u, SECRET_RE)) > 0 or length(find_all(decoded, SECRET_RE)) > 0
157
+ give "an API key or token"
158
+ let p be parse(u)
159
+ when not p["ok"]
160
+ give nothing
161
+ when p["query"] == ""
162
+ give nothing
163
+ each pair in split(p["query"], "&")
164
+ let kv be split(pair, "=")
165
+ let k be lower(trim(percent_decode(kv[0])))
166
+ let v be when length(kv) > 1 then join(slice(kv, 1, length(kv)), "=") otherwise ""
167
+ when v != "" and contains(SENSITIVE_PARAMS, k)
168
+ give "a credential-like query parameter (" + kv[0] + ")"
169
+ give nothing
170
+
171
+ -- "/a/b/../c/./d" → "/a/c/d"
172
+ task normalize_path(path)
173
+ let out be []
174
+ each seg in split(path, "/")
175
+ when seg == ".."
176
+ when length(out) > 0
177
+ set out to slice(out, 0, length(out) - 1)
178
+ otherwise when seg != "." and seg != ""
179
+ set out to append(out, seg)
180
+ give "/" + join(out, "/") + (when ends_with(path, "/") and length(out) > 0 then "/" otherwise "")
181
+
182
+ export task resolve(base, href)
183
+ when href == nothing
184
+ give nothing
185
+ let h be trim(text(href))
186
+ let lh be lower(h)
187
+ when h == "" or starts_with(h, "#") or starts_with(lh, "javascript:") or starts_with(lh, "data:") or starts_with(lh, "mailto:") or starts_with(lh, "tel:")
188
+ give nothing
189
+ when matches(h, "[A-Za-z][A-Za-z0-9+.-]*:.*")
190
+ give h
191
+ let b be parse(base)
192
+ when not b["ok"]
193
+ give h
194
+ when starts_with(h, "//")
195
+ give b["scheme"] + ":" + h
196
+ -- separar query/fragmento del path relativo para normalizar solo el path
197
+ let tail be ""
198
+ let core be h
199
+ let qi be split(h, "?")
200
+ let fi be split(h, "#")
201
+ when length(qi) > 1
202
+ set core to qi[0]
203
+ set tail to "?" + join(slice(qi, 1, length(qi)), "?")
204
+ otherwise when length(fi) > 1
205
+ set core to fi[0]
206
+ set tail to "#" + join(slice(fi, 1, length(fi)), "#")
207
+ when core == ""
208
+ give b["origin"] + b["path"] + tail
209
+ when starts_with(core, "/")
210
+ give b["origin"] + normalize_path(core) + tail
211
+ give b["origin"] + normalize_path(b["dir"] + core) + tail
212
+
213
+ -- nombre de archivo estable para la caché/spill de una URL: fetch-<host>-<digest>
214
+ export task slug(host)
215
+ let s be replace_re(lower(text(host)), "[^a-z0-9._-]", "-")
216
+ when length(s) > 60
217
+ set s to slice(s, 0, 60)
218
+ when s == ""
219
+ give "page"
220
+ give s
package/lib/tools.syn CHANGED
@@ -19,13 +19,14 @@ use "./tools/ls.syn" as t_ls
19
19
  use "./tools/find.syn" as t_find
20
20
  use "./tools/grep.syn" as t_grep
21
21
  use "./tools/bash.syn" as t_bash
22
+ use "./tools/fetch.syn" as t_fetch
22
23
  use "./tools/skill.syn" as t_skill
23
24
  use "./tools/process.syn" as t_process
24
25
  use "./tools/memo.syn" as t_memo
25
26
  use "./tools/todo.syn" as t_todo
26
27
  use "./skills.syn" as skills
27
28
  use "./mcp.syn" as mcp
28
- use "./lamps.syn" as lamps
29
+ use "./plugins.syn" as plugins
29
30
  use "./lsp.syn" as lsp
30
31
  use "./schedule.syn" as schedule
31
32
 
@@ -86,40 +87,40 @@ let MCP_SPEC be {
86
87
  }, "required": ["action"]}
87
88
  }
88
89
 
89
- -- la task de la tool lamp (lamps.syn vive un nivel arriba de tools/, como mcp). enable/disable piden humano
90
+ -- la task de la tool plugin (plugins.syn vive un nivel arriba de tools/, como mcp). enable/disable piden humano
90
91
  -- SIEMPRE (permission.syn): encender = autorizar código a correr con las capacidades de su manifiesto.
91
- task lamp_tool(action, name, manifest, files)
92
+ task plugin_tool(action, name, manifest, files)
92
93
  require exec
93
94
  require time
94
95
  require env("LAMPSON_*")
95
96
  require file(".lampson")
96
97
  require file(".lampson/*")
97
- require file.read("lamps")
98
- require file.read("lamps/*")
98
+ require file.read("plugins")
99
+ require file.read("plugins/*")
99
100
  require file("workspace")
100
101
  require file("workspace/*")
101
102
  when action == "create"
102
- give lamps.create(name, manifest, files)
103
+ give plugins.create(name, manifest, files)
103
104
  when action == "enable"
104
- give lamps.set_enabled(name, true)
105
+ give plugins.set_enabled(name, true)
105
106
  when action == "disable"
106
- give lamps.set_enabled(name, false)
107
- let sm be lamps.summary()
107
+ give plugins.set_enabled(name, false)
108
+ let sm be plugins.summary()
108
109
  when length(sm) == 0
109
- give "no lamps found. A lamp is a folder with a lamp.json manifest: " + lamps.GLOBAL_DIR + "/<name>/ (global) or " + lamps.PROJECT_DIR + "/<name>/ (this project; you can create it with write). See the lampson skill for the manifest format."
110
+ give "no plugins found. A plugin is a folder with a plugin.json manifest: " + plugins.GLOBAL_DIR + "/<name>/ (global) or " + plugins.PROJECT_DIR + "/<name>/ (this project; you can create it with write). See the lampson skill for the manifest format."
110
111
  let lines be []
111
112
  each l in sm
112
- set lines to append(lines, l["name"] + " (" + l["scope"] + ", " + l["kind"] + ", " + (when l["enabled"] then "ON" otherwise "off") + "): " + (when l["error"] != nothing then "BROKEN — " + text(l["error"]) otherwise text(length(l["tools"])) + " tools [" + join(l["tools"], ", ") + "]" + (when l["description"] != "" then " — " + l["description"] otherwise "")))
113
+ set lines to append(lines, l["name"] + " (" + l["scope"] + ", " + l["kind"] + ", " + (when l["enabled"] then "ON" otherwise "off") + "): " + (when l["error"] != nothing then "BROKEN — " + text(l["error"]) otherwise text(length(l["tools"])) + " tools [" + join(l["tools"], ", ") + "]" + (when l["description"] != "" then " — " + l["description"] otherwise "")) + (when l["legacy"] then " [" + plugins.LEGACY_HINT + "]" otherwise ""))
113
114
  give join(lines, "\n")
114
115
 
115
- let LAMP_SPEC be {
116
- "name": "lamp",
117
- "description": "Lamps are tool plugins you can build for this project: a folder with a lamp.json manifest plus code — a Synsema program (kind=syn) run under a capability ceiling, or any executable (kind=exec). Consider one when a task needs a reusable custom tool (a project-specific query, generator, checker) that plain bash would repeat clumsily. action=list (default): every lamp found, on/off, its tools. action=create: write a PROJECT lamp (name + manifest + files) — it validates the manifest and runs `synsema check` on a syn entry, but does NOT run or enable it. action=enable / disable: turn one on or off — ALWAYS asks the user (enabling authorizes its code to run with the manifest's capabilities). An enabled lamp's tools appear in your catalog as lamp_<lamp>_<tool> from the NEXT turn. Manifest: {\"description\", \"kind\": \"syn\"|\"exec\", \"entry\": \"lamp.syn\" (syn) | \"command\": \"python lamp.py\" (exec), \"caps\": \"file.read=workspace/*\" (syn, optional extra ceiling over stdout,time,env=LAMP_*), \"timeout\": 60, \"tools\": [{\"name\", \"description\", \"parameters\": JSON Schema, \"readonly\": bool}]}. Inside the code read LAMP_TOOL and LAMP_ARGS (JSON) from env and print the result to stdout (a .syn needs `require env(\"LAMP_*\")`). Lamp names: letters, digits, - (no _).",
116
+ let PLUGIN_SPEC be {
117
+ "name": "plugin",
118
+ "description": "Plugins are tools you can build for this project (they were called 'lamps' until 0.2.6; the user may still say lamp/lámpara): a folder with a plugin.json manifest plus code — a Synsema program (kind=syn) run under a capability ceiling, or any executable (kind=exec, any language, no ceiling). Consider one when a task needs a reusable custom tool (a project-specific query, generator, checker) that plain bash would repeat clumsily. action=list (default): every plugin found, on/off, its tools. action=create: write a PROJECT plugin (name + manifest + files) — it validates the manifest and runs `synsema check` on a syn entry, but does NOT run or enable it. action=enable / disable: turn one on or off — ALWAYS asks the user (enabling authorizes its code to run with the manifest's capabilities). An enabled plugin's tools appear in your catalog as plugin_<plugin>_<tool> from the NEXT turn. Manifest: {\"description\", \"kind\": \"syn\"|\"exec\", \"entry\": \"plugin.syn\" (syn) | \"command\": \"python plugin.py\" (exec), \"caps\": \"file.read=workspace/*\" (syn, optional extra ceiling over stdout,time,env=PLUGIN_*), \"timeout\": 60, \"tools\": [{\"name\", \"description\", \"parameters\": JSON Schema, \"readonly\": bool}]}. Inside the code read PLUGIN_TOOL and PLUGIN_ARGS (JSON) from env and print the result to stdout (a .syn needs `require env(\"PLUGIN_*\")`). Plugin names: letters, digits, - (no _). Not the same as a lamp from lamps.sh (a portable, ceiling-enforced capability unit for any MCP agent): those are installed with `lamp add` and reach you as MCP tools via `lamp mcp`.",
118
119
  "parameters": {"type": "object", "properties": {
119
120
  "action": {"type": "string", "enum": ["list", "create", "enable", "disable"], "description": "Default: list"},
120
- "name": {"type": "string", "description": "create/enable/disable: lamp name"},
121
- "manifest": {"type": "object", "description": "create only: the lamp.json content (name is filled in)"},
122
- "files": {"type": "object", "description": "create only: {\"lamp.syn\": \"<code>\", …} — files written into the lamp folder", "additionalProperties": {"type": "string"}}
121
+ "name": {"type": "string", "description": "create/enable/disable: plugin name"},
122
+ "manifest": {"type": "object", "description": "create only: the plugin.json content (name is filled in)"},
123
+ "files": {"type": "object", "description": "create only: {\"plugin.syn\": \"<code>\", …} — files written into the plugin folder", "additionalProperties": {"type": "string"}}
123
124
  }, "required": ["action"]}
124
125
  }
125
126
 
@@ -158,8 +159,8 @@ let LSP_SPEC be {
158
159
 
159
160
  -- la tool schedule (lib/schedule.syn): tareas programadas. add/remove/enable/run piden humano SIEMPRE (permission.syn):
160
161
  -- crear una tarea = autorizar de una vez todo lo que va a hacer sin nadie mirando. Las corridas las hace el daemon
161
- -- (web.syn); desde acá solo se corren en el acto las lamp/bash (una prompt anidaría un loop dentro del turno).
162
- task schedule_tool(action, id, name, at, kind, lamp, tool, args, command, prompt, agent, permission, approval_timeout, notify)
162
+ -- (web.syn); desde acá solo se corren en el acto las plugin/bash (una prompt anidaría un loop dentro del turno).
163
+ task schedule_tool(action, id, name, at, kind, plugin, tool, args, command, prompt, agent, permission, approval_timeout, notify)
163
164
  require exec
164
165
  require time
165
166
  require net
@@ -167,15 +168,15 @@ task schedule_tool(action, id, name, at, kind, lamp, tool, args, command, prompt
167
168
  require env("OS")
168
169
  require file(".lampson")
169
170
  require file(".lampson/*")
170
- require file.read("lamps")
171
- require file.read("lamps/*")
171
+ require file.read("plugins")
172
+ require file.read("plugins/*")
172
173
  require file("workspace")
173
174
  require file("workspace/*")
174
175
  let act be when action == nothing then "list" otherwise action
175
176
  when act == "add"
176
177
  let a be {"type": kind}
177
- when kind == "lamp"
178
- set a to {"type": "lamp", "lamp": lamp, "tool": tool, "args": when args == nothing then {} otherwise args}
178
+ when kind == "plugin"
179
+ set a to {"type": "plugin", "plugin": plugin, "tool": tool, "args": when args == nothing then {} otherwise args}
179
180
  otherwise when kind == "bash"
180
181
  set a to {"type": "bash", "command": command}
181
182
  otherwise when kind == "prompt"
@@ -227,16 +228,16 @@ task daemon_note()
227
228
 
228
229
  let SCHEDULE_SPEC be {
229
230
  "name": "schedule",
230
- "description": "Scheduled tasks: run something on a schedule with nobody watching — a lamp tool, a fixed shell command, or a full agent run from a prompt (kind=prompt: the agent works unattended with the chosen profile and writes a report; its session appears as ⏰ <name>). `at` formats — recurring: 'every 6h' | 'every 30m' | 'daily 09:00' | 'mon,wed 08:30' | 'weekdays 09:00'; ONE-TIME (runs once, then turns itself off): 'today 15:14' | 'tomorrow 09:00' | 'once 2026-08-29 15:14' | 'in 2h'. Times are the USER'S LOCAL time (the machine's timezone): write the hour exactly as the user says it — NEVER convert to UTC. Tasks belong to the current workspace. permission = what a prompt run may do without asking: strict (dangerous actions denied), ask (default: a dangerous action sends the user an approval link/notification and waits up to approval_timeout seconds, denied if unanswered), yolo (allowed). notify = optional webhook URL that receives the result as JSON (for 'search and send me' tasks). action=add ALWAYS asks the user (it authorizes future unattended runs); remove/enable/disable/run also ask; list and log do not. The tasks are executed by Lampson's resident process (`lampson --daemon start` or the open web UI) — say so if the list shows no scheduler running. Use it when the user says 'every day at', 'each N hours', 'on Mondays', 'periodically', 'remind me', 'send me'.",
231
+ "description": "Scheduled tasks: run something on a schedule with nobody watching — a plugin tool, a fixed shell command, or a full agent run from a prompt (kind=prompt: the agent works unattended with the chosen profile and writes a report; its session appears as ⏰ <name>). `at` formats — recurring: 'every 6h' | 'every 30m' | 'daily 09:00' | 'mon,wed 08:30' | 'weekdays 09:00'; ONE-TIME (runs once, then turns itself off): 'today 15:14' | 'tomorrow 09:00' | 'once 2026-08-29 15:14' | 'in 2h'. Times are the USER'S LOCAL time (the machine's timezone): write the hour exactly as the user says it — NEVER convert to UTC. Tasks belong to the current workspace. permission = what a prompt run may do without asking: strict (dangerous actions denied), ask (default: a dangerous action sends the user an approval link/notification and waits up to approval_timeout seconds, denied if unanswered), yolo (allowed). notify = optional webhook URL that receives the result as JSON (for 'search and send me' tasks). action=add ALWAYS asks the user (it authorizes future unattended runs); remove/enable/disable/run also ask; list and log do not. The tasks are executed by Lampson's resident process (`lampson --daemon start` or the open web UI) — say so if the list shows no scheduler running. Use it when the user says 'every day at', 'each N hours', 'on Mondays', 'periodically', 'remind me', 'send me'.",
231
232
  "parameters": {"type": "object", "properties": {
232
233
  "action": {"type": "string", "enum": ["list", "add", "remove", "enable", "disable", "run", "log"], "description": "Default: list"},
233
234
  "id": {"type": "string", "description": "remove/enable/disable/run/log: the task id (from list)"},
234
235
  "name": {"type": "string", "description": "add: short human name"},
235
236
  "at": {"type": "string", "description": "add: the schedule — 'every 6h' | 'daily 09:00' | 'mon,wed 08:30' | 'weekdays 09:00'"},
236
- "kind": {"type": "string", "enum": ["lamp", "bash", "prompt"], "description": "add: what runs"},
237
- "lamp": {"type": "string", "description": "add kind=lamp: lamp name (must be ON)"},
238
- "tool": {"type": "string", "description": "add kind=lamp: tool of that lamp"},
239
- "args": {"type": "object", "description": "add kind=lamp: arguments for the tool"},
237
+ "kind": {"type": "string", "enum": ["plugin", "bash", "prompt"], "description": "add: what runs"},
238
+ "plugin": {"type": "string", "description": "add kind=plugin: plugin name (must be ON)"},
239
+ "tool": {"type": "string", "description": "add kind=plugin: tool of that plugin"},
240
+ "args": {"type": "object", "description": "add kind=plugin: arguments for the tool"},
240
241
  "command": {"type": "string", "description": "add kind=bash: the shell command (must finish on its own; no servers)"},
241
242
  "prompt": {"type": "string", "description": "add kind=prompt: self-contained instructions for the unattended agent run (what to do, how to verify, what to report)"},
242
243
  "agent": {"type": "string", "enum": ["build", "plan", "review", "explore"], "description": "add kind=prompt: profile (default build)"},
@@ -256,17 +257,18 @@ export task registry()
256
257
  "find": t_find.tool,
257
258
  "grep": t_grep.tool,
258
259
  "bash": t_bash.tool,
260
+ "fetch": t_fetch.tool,
259
261
  "process": t_process.tool,
260
262
  "memory": t_memo.tool,
261
263
  "todo": t_todo.tool,
262
264
  "skill": skill_tool,
263
265
  "mcp": mcp_tool,
264
- "lamp": lamp_tool,
266
+ "plugin": plugin_tool,
265
267
  "lsp": lsp_tool,
266
268
  "schedule": schedule_tool
267
269
  }
268
270
 
269
- export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, LAMP_SPEC, SCHEDULE_SPEC]
271
+ export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_fetch.SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, PLUGIN_SPEC, SCHEDULE_SPEC]
270
272
 
271
273
  -- Subconjuntos (para perfiles de agente): registry/catálogo filtrados por nombre.
272
274
  export task registry_subset(names)
@@ -3,7 +3,7 @@
3
3
  -- Por qué así (ver ../SPEC-WORKSPACES.md): en Synsema las capabilities de archivo Y los `use "./lib/…"` se resuelven
4
4
  -- relativos al CWD del proceso, y `proxy to` es estático (se evalúa al arrancar y anexa el path entero). Entonces:
5
5
  -- * cada workspace tiene su propio directorio .lampson/ws/<slug>/ que ES el cwd de su proceso:
6
- -- workspace → junction al proyecto lib public skills lamps memory → junctions a la instalación
6
+ -- workspace → junction al proyecto lib public skills plugins memory → junctions a la instalación
7
7
  -- web.syn chat.syn → copias (refrescadas al arrancar) .lampson/ → estado propio (sesiones, tareas…)
8
8
  -- .lampson/global → junction a <home>/.lampson (config.json con keys, mcp/lsp globales, skills-*)
9
9
  -- con eso `file("workspace/*")`, `use "./lib/x.syn"` y todo el código de siempre funcionan SIN cambios.
@@ -26,7 +26,7 @@ export let HUB_FILE be "hub.syn"
26
26
  export let HUB_TEMPLATE be "hub.tpl.syn"
27
27
  -- rango alto para no chocar con lo que usan las apps (3000, 5173, 8000, 8080-8090…): solo el hub queda en 8080
28
28
  let FIRST_PORT be 47101
29
- let LINKS be ["lib", "public", "skills", "lamps", "memory"]
29
+ let LINKS be ["lib", "public", "skills", "plugins", "memory"]
30
30
  let COPIES be ["web.syn", "chat.syn"]
31
31
 
32
32
  task is_win()
@@ -182,6 +182,31 @@ task unlink(where_)
182
182
  task is_link_dir(p)
183
183
  give file_exists(p)
184
184
 
185
+ -- crea una carpeta de la instalación (ruta absoluta: fuera del alcance de write_file, así que por exec)
186
+ task ensure_dir(p)
187
+ when is_win()
188
+ -- argumentos separados, como mklink: una línea entera con comillas dentro de `cmd /c` se rompe al citarla.
189
+ -- mkdir de cmd crea los intermedios y falla (sin daño) si ya existe.
190
+ run("cmd", ["/c", "mkdir", replace_text(p, "/", "\\")], 10)
191
+ otherwise
192
+ run("mkdir", ["-p", p], 10)
193
+ give true
194
+
195
+ -- un link a la instalación (lib, public, skills, plugins, memory): se crea si falta y se REHACE si quedó colgante.
196
+ -- file_exists da false tanto si no hay nada como si el link apunta a un destino que no existe (npm no trae
197
+ -- memory/ ni plugins/; un `ln -s` a una carpeta inexistente nace roto y después memory(write) muere con
198
+ -- "No such file or directory" — visto en un VPS el 2026-09-02). Por eso: destino primero, link después, y si
199
+ -- sigue sin resolverse es un error del workspace, no un silencio.
200
+ export task ensure_link(where_, target)
201
+ when file_exists(where_)
202
+ give true
203
+ unlink(where_)
204
+ ensure_dir(target)
205
+ link(where_, target)
206
+ when not file_exists(where_)
207
+ raise("could not link " + where_ + " → " + target + " (the workspace needs it: memory, plugins and the code live there)")
208
+ give true
209
+
185
210
  -- crea/repara .lampson/ws/<slug>: junctions al proyecto y a la instalación, copias de los entries
186
211
  export task prepare(w)
187
212
  require exec
@@ -209,16 +234,14 @@ export task prepare(w)
209
234
  when not link(ws_link, w["path"])
210
235
  raise("could not link " + ws_link + " → " + w["path"])
211
236
  each n in LINKS
212
- when not file_exists(d + "/" + n)
213
- link(d + "/" + n, h + sep + n)
214
- when not file_exists(d + "/.lampson/global")
215
- link(d + "/.lampson/global", h + sep + ".lampson")
237
+ ensure_link(d + "/" + n, h + sep + n)
238
+ ensure_link(d + "/.lampson/global", h + sep + ".lampson")
216
239
  each f in COPIES
217
240
  write_file(d + "/" + f, read_file(f))
218
241
  give d
219
242
 
220
243
  -- estado guardado ANTES de los workspaces (en <home>/.lampson, separado por slug en cada registro): sesiones y trazas
221
- -- del proyecto, sus tareas programadas y el encendido de lámparas se mueven a <ws>/.lampson la primera vez
244
+ -- del proyecto, sus tareas programadas y el encendido de plugins se mueven a <ws>/.lampson la primera vez
222
245
  task migrate(w, d)
223
246
  let slug be w["slug"]
224
247
  let moved be 0
@@ -248,8 +271,10 @@ task migrate(w, d)
248
271
  recover err
249
272
  set moved to moved
250
273
  try
251
- when file_exists(".lampson/lamps.json")
252
- write_file(d + "/.lampson/lamps.json", read_file(".lampson/lamps.json"))
274
+ when file_exists(".lampson/plugins.json")
275
+ write_file(d + "/.lampson/plugins.json", read_file(".lampson/plugins.json"))
276
+ otherwise when file_exists(".lampson/lamps.json")
277
+ write_file(d + "/.lampson/plugins.json", read_file(".lampson/lamps.json"))
253
278
  recover err
254
279
  set moved to moved
255
280
  give moved
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lampson",
3
- "version": "0.2.6",
4
- "description": "A coding agent written in Synsema: terminal + web, tools confined to the mounted project, lamps (your own tool plugins), LSP, MCP, sub-agents.",
3
+ "version": "0.2.8",
4
+ "description": "A coding agent written in Synsema: terminal + web, tools confined to the mounted project, plugins (your own tools, any language), LSP, MCP, sub-agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -9,7 +9,15 @@
9
9
  },
10
10
  "homepage": "https://github.com/kitecosmic/lampson#readme",
11
11
  "bugs": "https://github.com/kitecosmic/lampson/issues",
12
- "keywords": ["agent", "coding-agent", "synsema", "llm", "cli", "lsp", "mcp"],
12
+ "keywords": [
13
+ "agent",
14
+ "coding-agent",
15
+ "synsema",
16
+ "llm",
17
+ "cli",
18
+ "lsp",
19
+ "mcp"
20
+ ],
13
21
  "bin": {
14
22
  "lampson": "bin/lampson.js"
15
23
  },
@@ -18,7 +26,7 @@
18
26
  "lib/",
19
27
  "public/",
20
28
  "skills/",
21
- "lamps/",
29
+ "plugins/",
22
30
  "chat.syn",
23
31
  "web.syn",
24
32
  "hub.tpl.syn",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "example-hello",
3
- "description": "Example lamp: a Synsema program run under a capability ceiling. Copy this folder to make your own.",
3
+ "description": "Example plugin: a Synsema program run under a capability ceiling. Copy this folder to make your own.",
4
4
  "kind": "syn",
5
- "entry": "lamp.syn",
5
+ "entry": "plugin.syn",
6
6
  "caps": "",
7
7
  "timeout": 20,
8
8
  "tools": [
@@ -0,0 +1,19 @@
1
+ -- plugins/example-hello/plugin.syn — el plugin de ejemplo
2
+ --
3
+ -- Lampson lo corre así por cada llamada: synsema run --cap-set stdout,time,env=PLUGIN_*,env=LAMP_* plugin.syn
4
+ -- El techo (--cap-set) sale del manifiesto (plugin.json → "caps") que el humano aprobó al encenderlo;
5
+ -- pedir más acá (p. ej. `require net`) falla con "above the host ceiling".
6
+ -- Entrada por env: PLUGIN_TOOL (qué tool), PLUGIN_ARGS (sus args en JSON), PLUGIN_DIR, PLUGIN_WORKSPACE.
7
+ -- Salida: lo que imprimas por stdout vuelve al modelo como resultado de la tool.
8
+ intent: "example plugin for lampson: greet"
9
+
10
+ require env("PLUGIN_*")
11
+
12
+ let tool be env("PLUGIN_TOOL", "")
13
+ let args be json_decode(env("PLUGIN_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 plugin, running under a capability ceiling)")
18
+ otherwise
19
+ print("ERROR: unknown tool '" + tool + "'")
@@ -1,6 +1,6 @@
1
1
  /* panel: EL componente modal de la app (js/panel.js). Un cascarón (overlay, cabecera, ✕/Esc/clic afuera) y
2
2
  tres layouts: browse (buscador + lista + detalle), tabs (pestañas con formularios) y form (un formulario).
3
- Cada vista (sesiones, lámparas, configuración, programar…) solo aporta su contenido; nada de esto se repite. */
3
+ Cada vista (sesiones, plugins, configuración, programar…) solo aporta su contenido; nada de esto se repite. */
4
4
  .modal { position:fixed; inset:0; background:rgba(0,0,0,.45); display:flex; align-items:center; justify-content:center; z-index:50; }
5
5
  .panel { background:var(--paper); border:1px solid var(--rule-2); border-radius:var(--r); box-shadow:0 10px 30px rgba(0,0,0,.35); display:flex; flex-direction:column; min-height:0; max-height:92vh; }
6
6
  .panel.lg { width:min(1100px, 94vw); height:min(760px, 90vh); padding:14px 18px 12px; }
@@ -94,11 +94,11 @@
94
94
  .panel .dfoot .del.ask .yes { color:var(--rubric); cursor:pointer; font-weight:600; }
95
95
  .panel .dfoot .del.ask .no { cursor:pointer; }
96
96
  .panel .none { color:var(--ink-3); font:400 13px/1.6 var(--serif); max-width:520px; }
97
- /* switch encendido/apagado (lámparas) */
97
+ /* switch encendido/apagado (plugins) */
98
98
  .panel label.sw { display:inline-flex; align-items:center; margin:0 0 0 auto; gap:6px; cursor:pointer; user-select:none; font:400 11.5px/1 var(--mono); text-transform:none; letter-spacing:0; color:var(--ink-3); }
99
99
  .panel label.sw input { flex:none; width:16px; height:16px; padding:0; margin:0; accent-color:var(--accent); }
100
100
  .panel label.sw.on { color:var(--accent); }
101
- /* formulario generado desde un JSON Schema (tools de una lámpara) */
101
+ /* formulario generado desde un JSON Schema (tools de un plugin) */
102
102
  .panel .tool { border:1px solid var(--rule); border-radius:var(--r); padding:10px 12px; margin-bottom:10px; }
103
103
  .panel .tool .th { display:grid; grid-template-columns:auto 1fr auto; gap:8px 10px; align-items:baseline; }
104
104
  .panel .tool .th code { font-weight:600; color:var(--ink); }
@@ -82,3 +82,24 @@
82
82
  /* puertos ocupados por lampson: visibles pero sin link ni ✕ */
83
83
  .p .port.own { color:var(--ink-3); font-weight:400; }
84
84
  .p.own .cm { color:var(--ink-3); }
85
+
86
+ /* explorador de archivos: selección, portapapeles, arrastre, renombrar en línea y menú contextual */
87
+ #tree:focus { outline:none; }
88
+ .row.sel { background:var(--paper-2); }
89
+ .row.cut { opacity:.55; }
90
+ .row.dragging { opacity:.4; }
91
+ .row.drop, #tree.drop { background:var(--accent-bg); border-left-color:var(--accent); }
92
+ .row.flash { background:var(--accent-bg); transition:background .7s; }
93
+ .row input.rn { flex:1; min-width:0; font:inherit; color:var(--ink); background:var(--paper-2); border:1px solid var(--accent); border-radius:var(--r); padding:0 4px; outline:none; }
94
+ .row.new { color:var(--ink); }
95
+ .ctx { position:fixed; z-index:70; min-width:220px; background:var(--paper); border:1px solid var(--rule-2); border-radius:var(--r); box-shadow:0 10px 30px rgba(0,0,0,.35); padding:4px 0; font:400 12.5px/1.5 var(--mono); user-select:none; }
96
+ .ctx .it { display:flex; align-items:center; gap:14px; padding:4px 12px; cursor:pointer; color:var(--ink-2); white-space:nowrap; }
97
+ .ctx .it:hover { background:var(--paper-2); color:var(--ink); }
98
+ .ctx .it .k { margin-left:auto; color:var(--ink-3); font-size:10.5px; }
99
+ .ctx .it.dis { opacity:.4; pointer-events:none; }
100
+ .ctx .it.danger:hover { color:var(--rubric); }
101
+ .ctx .it.ask { color:var(--ink-2); gap:6px; }
102
+ .ctx .it.ask .yes { color:var(--rubric); font-weight:600; cursor:pointer; }
103
+ .ctx .it.ask .no { cursor:pointer; }
104
+ .ctx .it.ask .yes:hover, .ctx .it.ask .no:hover { text-decoration:underline; text-underline-offset:.18em; }
105
+ .ctx .sep { height:1px; background:var(--rule); margin:4px 0; }
package/public/hub.html CHANGED
@@ -25,7 +25,7 @@
25
25
  <div class="empty">
26
26
  <p class="eyebrow">workspaces</p>
27
27
  <h1>¿En qué proyecto trabajamos?</h1>
28
- <p class="lead">Cada workspace es una carpeta de tu disco con su propio agente: sesiones, tareas programadas, lámparas, MCP. Corren en procesos separados, así podés tener varios abiertos a la vez. Los que tienen tareas encendidas siguen vivos aunque cierres todo.</p>
28
+ <p class="lead">Cada workspace es una carpeta de tu disco con su propio agente: sesiones, tareas programadas, plugins, MCP. Corren en procesos separados, así podés tener varios abiertos a la vez. Los que tienen tareas encendidas siguen vivos aunque cierres todo.</p>
29
29
  <div class="wsgrid" id="wsGrid"></div>
30
30
  <p class="lead" style="margin-top:22px">Desde una terminal: <code>cd mi-proyecto &amp;&amp; lampson</code> (terminal) o <code>lampson --web</code> (esta web, en ese workspace).</p>
31
31
  </div>
package/public/index.html CHANGED
@@ -33,7 +33,7 @@
33
33
  <select id="agent" title="perfil de agente: qué tools puede usar"></select>
34
34
  <select id="perm" title="qué pasa con un comando peligroso (rm -rf, git push --force, sudo…)"><option value="ask">permisos: preguntar</option><option value="yolo">permisos: permitir todo</option><option value="strict">permisos: denegar</option></select>
35
35
  <span class="pill on" id="wsPill" style="display:none" title="cambiar de workspace">workspace ▾</span>
36
- <span class="pill" id="lamps" title="lámparas: plugins de tools que vos encendés · clic para ver y activar">lámparas</span>
36
+ <span class="pill" id="plugins" title="plugins: tools propias que vos encendés · clic para ver y activar">plugins</span>
37
37
  <span class="pill" id="model" title="proveedor y modelo · clic para cambiarlos o cargar una API key">…</span>
38
38
  </div>
39
39
  </header>
@@ -77,7 +77,7 @@
77
77
  <div class="body" id="apprBox"></div>
78
78
  </section>
79
79
  <section class="sec" data-sec="sched">
80
- <h2 title="tareas programadas (cada 6 h, todos los días a las 9, lunes 8:30…): una lámpara, un comando o una corrida del agente. Corren mientras lampson esté abierto, o con lampson --daemon start"><span class="caret">▸</span>Programadas<span class="cnt" id="schedCount"></span><button class="h2act" id="schedAddBtn" title="programar una tarea">+</button></h2>
80
+ <h2 title="tareas programadas (cada 6 h, todos los días a las 9, lunes 8:30…): un plugin, un comando o una corrida del agente. Corren mientras lampson esté abierto, o con lampson --daemon start"><span class="caret">▸</span>Programadas<span class="cnt" id="schedCount"></span><button class="h2act" id="schedAddBtn" title="programar una tarea">+</button></h2>
81
81
  <div class="body" id="schedBox"></div>
82
82
  </section>
83
83
  </aside>
@@ -119,7 +119,7 @@
119
119
  <script src="/js/todo.js"></script>
120
120
  <script src="/js/mcp.js"></script>
121
121
  <script src="/js/lsp.js"></script>
122
- <script src="/js/lamps.js"></script>
122
+ <script src="/js/plugins.js"></script>
123
123
  <script src="/js/schedules.js"></script>
124
124
  <script src="/js/approvals.js"></script>
125
125
  <script src="/js/config.js"></script>
package/public/js/app.js CHANGED
@@ -14,7 +14,7 @@ async function loadCfg() {
14
14
  sel.onchange = () => { localStorage.setItem('lampson.agent', sel.value); hint(); }; hint();
15
15
  if (!log.children.length) empty();
16
16
  }
17
- loadCfg().then(() => { loadTree(); loadSessions(); loadProcs(); loadMemory(); loadAgents(); loadMcp(); loadLsp(); loadLamps(); loadTodo(); loadSched(); loadApprovals(); checkUpdate(); const pq = new URLSearchParams(location.search).get('proc'); if (pq) openProc(pq); else if (session) openSession(session); });
17
+ loadCfg().then(() => { loadTree(); loadSessions(); loadProcs(); loadMemory(); loadAgents(); loadMcp(); loadLsp(); loadPlugins(); loadTodo(); loadSched(); loadApprovals(); checkUpdate(); const pq = new URLSearchParams(location.search).get('proc'); if (pq) openProc(pq); else if (session) openSession(session); });
18
18
  // respaldos lentos por si el stream de eventos se cae
19
19
  setInterval(loadProcs, events ? 60000 : 10000);
20
20
  setInterval(() => { loadSched(); if (!events) loadApprovals(); }, events ? 60000 : 15000);