lampson 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/skills.syn CHANGED
@@ -1,179 +1,179 @@
1
- -- lib/skills.syn — skills: procedimientos en Markdown que el modelo carga bajo demanda
2
- --
3
- -- Una skill = carpeta con SKILL.md y frontmatter YAML mínimo:
4
- -- ---
5
- -- name: deploy
6
- -- description: Cómo desplegar este proyecto (una línea; es lo que ve el modelo en el índice)
7
- -- ---
8
- -- ...instrucciones...
9
- --
10
- -- Se buscan en (prioridad de abajo hacia arriba, el nombre repetido gana el más específico):
11
- -- .lampson/skills-global/<name>/SKILL.md -- ~/.agents/skills (junction que crea lampson.ps1/sh; `npx skills add -g`)
12
- -- .lampson/skills-claude/<name>/SKILL.md -- ~/.claude/skills (idem; las de Claude Code también sirven acá)
13
- -- skills/<name>/SKILL.md -- del harness (enseñan a usar lampson y Synsema)
14
- -- workspace/.claude/skills/<name>/SKILL.md -- del proyecto, instaladas para Claude Code
15
- -- workspace/.agents/skills/<name>/SKILL.md -- del proyecto, canónico de `npx skills add owner/repo` (skills.sh)
16
- -- workspace/skills/<name>/SKILL.md -- del proyecto (committeables)
17
- -- workspace/.lampson/skills/<name>/SKILL.md -- locales al proyecto (no committeadas)
18
- --
19
- -- Skills externas (2026-08-27): el estándar "Agent Skills" (agentskills.io) es el mismo formato que usamos
20
- -- (SKILL.md + frontmatter name/description), así que NO hay instalador propio: `npx skills add owner/repo
21
- -- --skill x` deja la skill en ./.agents/skills (o ~/.agents/skills con -g) y Lampson la ve en el próximo
22
- -- arranque. Las carpetas de HOME no se pueden declarar como capability con ruta dinámica → se MONTAN
23
- -- bajo .lampson/ (mismo truco que workspace/).
24
- --
25
- -- El system prompt lleva SOLO el índice (name + description); el contenido entra al contexto
26
- -- cuando el modelo llama a la tool `skill(name)`. Así el prompt no crece con cada skill.
27
-
28
- let ROOTS be [
29
- {"dir": ".lampson/skills-global", "source": "global"},
30
- {"dir": ".lampson/skills-claude", "source": "global"},
31
- {"dir": "skills", "source": "harness"},
32
- {"dir": "workspace/.claude/skills", "source": "project"},
33
- {"dir": "workspace/.agents/skills", "source": "project"},
34
- {"dir": "workspace/skills", "source": "project"},
35
- {"dir": "workspace/.lampson/skills", "source": "local"}
36
- ]
37
-
38
- task frontmatter(md)
39
- -- devuelve {name, description, body}; sin frontmatter → name vacío
40
- let out be {"name": "", "description": "", "body": md}
41
- when not starts_with(md, "---")
42
- give out
43
- let rest be slice(md, 3, length(md))
44
- let parts be split(rest, "\n---")
45
- when length(parts) < 2
46
- give out
47
- let head be parts[0]
48
- let body be join(slice(parts, 1, length(parts)), "\n---")
49
- each line in split(head, "\n")
50
- let l be trim(line)
51
- when starts_with(l, "name:")
52
- set out["name"] to trim(slice(l, 5, length(l)))
53
- otherwise when starts_with(l, "description:")
54
- set out["description"] to trim(slice(l, 12, length(l)))
55
- set out["body"] to trim(body)
56
- give out
57
-
58
- task scan_root(root, acc)
59
- let out be acc
60
- try
61
- each e in list_dir(root["dir"])
62
- when e["is_dir"]
63
- let path be root["dir"] + "/" + e["name"] + "/SKILL.md"
64
- try
65
- let fm be frontmatter(read_file(path))
66
- let name be when fm["name"] == "" then e["name"] otherwise fm["name"]
67
- set out[name] to {"name": name, "description": fm["description"], "path": path, "source": root["source"]}
68
- recover err
69
- set out to out
70
- recover err
71
- give out
72
- give out
73
-
74
- -- índice: map name → {name, description, path, source}
75
- export task index()
76
- require file.read("skills")
77
- require file.read("skills/*")
78
- require file.read("workspace")
79
- require file.read("workspace/*")
80
- require file.read(".lampson")
81
- require file.read(".lampson/*")
82
- let acc be {}
83
- each r in ROOTS
84
- set acc to scan_root(r, acc)
85
- give acc
86
-
87
- -- sección del system prompt
88
- export task prompt_section(idx)
89
- let names be sort_by(keys(idx), (n) => n)
90
- when length(names) == 0
91
- give ""
92
- let lines be ["", "# Skills (load one with the `skill` tool when relevant — read it BEFORE doing that kind of task)"]
93
- each n in names
94
- let s be idx[n]
95
- set lines to append(lines, "- " + n + " (" + s["source"] + "): " + s["description"])
96
- give join(lines, "\n")
97
-
98
- export task load(name)
99
- require file.read("skills")
100
- require file.read("skills/*")
101
- require file.read("workspace")
102
- require file.read("workspace/*")
103
- require file.read(".lampson")
104
- require file.read(".lampson/*")
105
- let idx be index()
106
- when not contains(idx, name)
107
- raise("unknown skill '" + name + "'. Available: " + join(sort_by(keys(idx), (n) => n), ", "))
108
- let s be idx[name]
109
- let fm be frontmatter(read_file(s["path"]))
110
- -- archivos hermanos de la skill (scripts, templates) para que el modelo sepa que existen
111
- let dir be slice(s["path"], 0, length(s["path"]) - length("/SKILL.md"))
112
- let extras be []
113
- each e in list_dir(dir)
114
- when e["name"] != "SKILL.md"
115
- set extras to append(extras, dir + "/" + e["name"] + (when e["is_dir"] then "/" otherwise ""))
116
- let head be "# skill: " + name + " (" + s["source"] + ", " + s["path"] + ")\n"
117
- when length(extras) > 0
118
- set head to head + "Files in this skill: " + join(extras, ", ") + "\n"
119
- give head + "\n" + fm["body"]
120
-
121
- -- ---------- instalación de skills externas (skills.sh / cualquier repo con SKILL.md) ----------
122
- -- No reinventamos el instalador: `npx skills add <owner/repo> --skill <name>` (vercel-labs/skills) escribe
123
- -- en ./.agents/skills (scope project) o ~/.agents/skills (-g, scope global — sirve para TODOS los proyectos,
124
- -- que es lo que uno quiere con una skill de Go, de Next, de diseño…). `-a codex -y` = no interactivo
125
- -- (codex ya usa la carpeta canónica, así que no crea symlinks extra; verificado 2026-08-27). Es una acción
126
- -- con efectos fuera del workspace → permission.syn la marca "ask" SIEMPRE (human in the loop), incluso en yolo.
127
- task shell_exe()
128
- require env("LAMPSON_*")
129
- require env("OS")
130
- let is_win be env("OS", "") == "Windows_NT"
131
- give env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
132
-
133
- task valid_slug(s)
134
- when s == nothing or s == ""
135
- give false
136
- give matches(s, "[a-zA-Z0-9._/@:-]{1,120}")
137
-
138
- -- la junction/symlink .lampson/skills-global → ~/.agents/skills la crea lampson.ps1/sh al arrancar, pero si
139
- -- la carpeta HOME no existía todavía (primera instalación global) hay que crear ambas ahora
140
- task ensure_global_mount()
141
- require exec
142
- require env("LAMPSON_*")
143
- require env("OS")
144
- let is_win be env("OS", "") == "Windows_NT"
145
- let win be "src=\"$USERPROFILE/.agents/skills\"; mkdir -p \"$src\"; [ -e .lampson/skills-global ] || cmd //c mklink //J \".lampson\\\\skills-global\" \"$(cygpath -w \"$src\")\" >/dev/null"
146
- let unix be "src=\"$HOME/.agents/skills\"; mkdir -p \"$src\"; [ -e .lampson/skills-global ] || ln -s \"$src\" .lampson/skills-global"
147
- run(shell_exe(), ["-c", when is_win then win otherwise unix], 30, {"cwd": "."})
148
-
149
- task tail_out(s)
150
- let t be strip_ansi(replace_text(s, "\r", ""))
151
- when length(t) > 3000
152
- give slice(t, length(t) - 3000, length(t))
153
- give t
154
-
155
- export task install(source, name, scope)
156
- require exec
157
- require time
158
- require env("LAMPSON_*")
159
- require env("OS")
160
- require file.read("skills")
161
- require file.read("skills/*")
162
- require file.read("workspace")
163
- require file.read("workspace/*")
164
- require file.read(".lampson")
165
- require file.read(".lampson/*")
166
- when not valid_slug(source)
167
- raise("invalid source '" + text(source) + "' (expected owner/repo or a github URL)")
168
- when not valid_slug(name)
169
- raise("invalid skill name '" + text(name) + "'")
170
- let global be scope != "project"
171
- let argv be ["-y", "skills", "add", source, "--skill", name, "-a", "codex", "-y"]
172
- when global
173
- set argv to append(argv, "-g")
174
- ensure_global_mount()
175
- let r be run(shell_exe(), ["-c", "CI=1 npx " + join(argv, " ") + " 2>&1"], 240, {"cwd": when global then "." otherwise "workspace"})
176
- let idx be index()
177
- when contains(idx, name)
178
- give "installed skill '" + name + "' (" + (when global then "global: ~/.agents/skills, available in every project" otherwise "project: ./.agents/skills") + "). It is in the skills index now — call skill(name=\"" + name + "\") to load it.\n" + tail_out(r["stdout"])
179
- give "npx skills finished (exit " + text(r["exit_code"]) + ") but '" + name + "' is not in the skills index. Output:\n" + tail_out(r["stdout"])
1
+ -- lib/skills.syn — skills: procedimientos en Markdown que el modelo carga bajo demanda
2
+ --
3
+ -- Una skill = carpeta con SKILL.md y frontmatter YAML mínimo:
4
+ -- ---
5
+ -- name: deploy
6
+ -- description: Cómo desplegar este proyecto (una línea; es lo que ve el modelo en el índice)
7
+ -- ---
8
+ -- ...instrucciones...
9
+ --
10
+ -- Se buscan en (prioridad de abajo hacia arriba, el nombre repetido gana el más específico):
11
+ -- .lampson/skills-global/<name>/SKILL.md -- ~/.agents/skills (junction que crea lampson.ps1/sh; `npx skills add -g`)
12
+ -- .lampson/skills-claude/<name>/SKILL.md -- ~/.claude/skills (idem; las de Claude Code también sirven acá)
13
+ -- skills/<name>/SKILL.md -- del harness (enseñan a usar lampson y Synsema)
14
+ -- workspace/.claude/skills/<name>/SKILL.md -- del proyecto, instaladas para Claude Code
15
+ -- workspace/.agents/skills/<name>/SKILL.md -- del proyecto, canónico de `npx skills add owner/repo` (skills.sh)
16
+ -- workspace/skills/<name>/SKILL.md -- del proyecto (committeables)
17
+ -- workspace/.lampson/skills/<name>/SKILL.md -- locales al proyecto (no committeadas)
18
+ --
19
+ -- Skills externas (2026-08-27): el estándar "Agent Skills" (agentskills.io) es el mismo formato que usamos
20
+ -- (SKILL.md + frontmatter name/description), así que NO hay instalador propio: `npx skills add owner/repo
21
+ -- --skill x` deja la skill en ./.agents/skills (o ~/.agents/skills con -g) y Lampson la ve en el próximo
22
+ -- arranque. Las carpetas de HOME no se pueden declarar como capability con ruta dinámica → se MONTAN
23
+ -- bajo .lampson/ (mismo truco que workspace/).
24
+ --
25
+ -- El system prompt lleva SOLO el índice (name + description); el contenido entra al contexto
26
+ -- cuando el modelo llama a la tool `skill(name)`. Así el prompt no crece con cada skill.
27
+
28
+ let ROOTS be [
29
+ {"dir": ".lampson/skills-global", "source": "global"},
30
+ {"dir": ".lampson/skills-claude", "source": "global"},
31
+ {"dir": "skills", "source": "harness"},
32
+ {"dir": "workspace/.claude/skills", "source": "project"},
33
+ {"dir": "workspace/.agents/skills", "source": "project"},
34
+ {"dir": "workspace/skills", "source": "project"},
35
+ {"dir": "workspace/.lampson/skills", "source": "local"}
36
+ ]
37
+
38
+ task frontmatter(md)
39
+ -- devuelve {name, description, body}; sin frontmatter → name vacío
40
+ let out be {"name": "", "description": "", "body": md}
41
+ when not starts_with(md, "---")
42
+ give out
43
+ let rest be slice(md, 3, length(md))
44
+ let parts be split(rest, "\n---")
45
+ when length(parts) < 2
46
+ give out
47
+ let head be parts[0]
48
+ let body be join(slice(parts, 1, length(parts)), "\n---")
49
+ each line in split(head, "\n")
50
+ let l be trim(line)
51
+ when starts_with(l, "name:")
52
+ set out["name"] to trim(slice(l, 5, length(l)))
53
+ otherwise when starts_with(l, "description:")
54
+ set out["description"] to trim(slice(l, 12, length(l)))
55
+ set out["body"] to trim(body)
56
+ give out
57
+
58
+ task scan_root(root, acc)
59
+ let out be acc
60
+ try
61
+ each e in list_dir(root["dir"])
62
+ when e["is_dir"]
63
+ let path be root["dir"] + "/" + e["name"] + "/SKILL.md"
64
+ try
65
+ let fm be frontmatter(read_file(path))
66
+ let name be when fm["name"] == "" then e["name"] otherwise fm["name"]
67
+ set out[name] to {"name": name, "description": fm["description"], "path": path, "source": root["source"]}
68
+ recover err
69
+ set out to out
70
+ recover err
71
+ give out
72
+ give out
73
+
74
+ -- índice: map name → {name, description, path, source}
75
+ export task index()
76
+ require file.read("skills")
77
+ require file.read("skills/*")
78
+ require file.read("workspace")
79
+ require file.read("workspace/*")
80
+ require file.read(".lampson")
81
+ require file.read(".lampson/*")
82
+ let acc be {}
83
+ each r in ROOTS
84
+ set acc to scan_root(r, acc)
85
+ give acc
86
+
87
+ -- sección del system prompt
88
+ export task prompt_section(idx)
89
+ let names be sort_by(keys(idx), (n) => n)
90
+ when length(names) == 0
91
+ give ""
92
+ let lines be ["", "# Skills (load one with the `skill` tool when relevant — read it BEFORE doing that kind of task)"]
93
+ each n in names
94
+ let s be idx[n]
95
+ set lines to append(lines, "- " + n + " (" + s["source"] + "): " + s["description"])
96
+ give join(lines, "\n")
97
+
98
+ export task load(name)
99
+ require file.read("skills")
100
+ require file.read("skills/*")
101
+ require file.read("workspace")
102
+ require file.read("workspace/*")
103
+ require file.read(".lampson")
104
+ require file.read(".lampson/*")
105
+ let idx be index()
106
+ when not contains(idx, name)
107
+ raise("unknown skill '" + name + "'. Available: " + join(sort_by(keys(idx), (n) => n), ", "))
108
+ let s be idx[name]
109
+ let fm be frontmatter(read_file(s["path"]))
110
+ -- archivos hermanos de la skill (scripts, templates) para que el modelo sepa que existen
111
+ let dir be slice(s["path"], 0, length(s["path"]) - length("/SKILL.md"))
112
+ let extras be []
113
+ each e in list_dir(dir)
114
+ when e["name"] != "SKILL.md"
115
+ set extras to append(extras, dir + "/" + e["name"] + (when e["is_dir"] then "/" otherwise ""))
116
+ let head be "# skill: " + name + " (" + s["source"] + ", " + s["path"] + ")\n"
117
+ when length(extras) > 0
118
+ set head to head + "Files in this skill: " + join(extras, ", ") + "\n"
119
+ give head + "\n" + fm["body"]
120
+
121
+ -- ---------- instalación de skills externas (skills.sh / cualquier repo con SKILL.md) ----------
122
+ -- No reinventamos el instalador: `npx skills add <owner/repo> --skill <name>` (vercel-labs/skills) escribe
123
+ -- en ./.agents/skills (scope project) o ~/.agents/skills (-g, scope global — sirve para TODOS los proyectos,
124
+ -- que es lo que uno quiere con una skill de Go, de Next, de diseño…). `-a codex -y` = no interactivo
125
+ -- (codex ya usa la carpeta canónica, así que no crea symlinks extra; verificado 2026-08-27). Es una acción
126
+ -- con efectos fuera del workspace → permission.syn la marca "ask" SIEMPRE (human in the loop), incluso en yolo.
127
+ task shell_exe()
128
+ require env("LAMPSON_*")
129
+ require env("OS")
130
+ let is_win be env("OS", "") == "Windows_NT"
131
+ give env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
132
+
133
+ task valid_slug(s)
134
+ when s == nothing or s == ""
135
+ give false
136
+ give matches(s, "[a-zA-Z0-9._/@:-]{1,120}")
137
+
138
+ -- la junction/symlink .lampson/skills-global → ~/.agents/skills la crea lampson.ps1/sh al arrancar, pero si
139
+ -- la carpeta HOME no existía todavía (primera instalación global) hay que crear ambas ahora
140
+ task ensure_global_mount()
141
+ require exec
142
+ require env("LAMPSON_*")
143
+ require env("OS")
144
+ let is_win be env("OS", "") == "Windows_NT"
145
+ let win be "src=\"$USERPROFILE/.agents/skills\"; mkdir -p \"$src\"; [ -e .lampson/skills-global ] || cmd //c mklink //J \".lampson\\\\skills-global\" \"$(cygpath -w \"$src\")\" >/dev/null"
146
+ let unix be "src=\"$HOME/.agents/skills\"; mkdir -p \"$src\"; [ -e .lampson/skills-global ] || ln -s \"$src\" .lampson/skills-global"
147
+ run(shell_exe(), ["-c", when is_win then win otherwise unix], 30, {"cwd": "."})
148
+
149
+ task tail_out(s)
150
+ let t be strip_ansi(replace_text(s, "\r", ""))
151
+ when length(t) > 3000
152
+ give slice(t, length(t) - 3000, length(t))
153
+ give t
154
+
155
+ export task install(source, name, scope)
156
+ require exec
157
+ require time
158
+ require env("LAMPSON_*")
159
+ require env("OS")
160
+ require file.read("skills")
161
+ require file.read("skills/*")
162
+ require file.read("workspace")
163
+ require file.read("workspace/*")
164
+ require file.read(".lampson")
165
+ require file.read(".lampson/*")
166
+ when not valid_slug(source)
167
+ raise("invalid source '" + text(source) + "' (expected owner/repo or a github URL)")
168
+ when not valid_slug(name)
169
+ raise("invalid skill name '" + text(name) + "'")
170
+ let global be scope != "project"
171
+ let argv be ["-y", "skills", "add", source, "--skill", name, "-a", "codex", "-y"]
172
+ when global
173
+ set argv to append(argv, "-g")
174
+ ensure_global_mount()
175
+ let r be run(shell_exe(), ["-c", "CI=1 npx " + join(argv, " ") + " 2>&1"], 240, {"cwd": when global then "." otherwise "workspace"})
176
+ let idx be index()
177
+ when contains(idx, name)
178
+ give "installed skill '" + name + "' (" + (when global then "global: ~/.agents/skills, available in every project" otherwise "project: ./.agents/skills") + "). It is in the skills index now — call skill(name=\"" + name + "\") to load it.\n" + tail_out(r["stdout"])
179
+ give "npx skills finished (exit " + text(r["exit_code"]) + ") but '" + name + "' is not in the skills index. Output:\n" + tail_out(r["stdout"])
@@ -1,105 +1,105 @@
1
- -- lib/tools/bash.syn — ejecutar un comando de shell con cwd = workspace/
2
- --
3
- -- MIGAS:
4
- -- * en Windows `bash` a secas resuelve al de WSL y cuelga; usar Git Bash explícito (default) o cmd.
5
- -- * NO usar `run()` para el comando del modelo: se cuelga para siempre si el comando deja un
6
- -- descendiente vivo (Windows hereda el pipe de stdout) y su timeout mata solo al hijo directo.
7
- -- Desde 2026-08-27 el comando corre con `proc_spawn` (v0.6.9): eventos por línea hasta el exit o el
8
- -- plazo, stdin cerrado (EOF: nada se queda esperando teclado), y `proc_close` mata el ÁRBOL
9
- -- (Job Object / process group) — un nieto con el pipe abierto no cuelga (1 s de gracia). Adiós
10
- -- bash_wrapper.sh y archivos temporales.
11
- -- * el proceso hijo NO hereda el scope de Synsema (un `cat ../x` en bash funciona). El aislamiento del
12
- -- shell lo dan permission.syn (patrones) + el operador (contenedor si hace falta).
13
- use "./common.syn" as c
14
- use "./proc.syn" as proc
15
-
16
- export task shell_config()
17
- require env("LAMPSON_*")
18
- require env("OS")
19
- let is_win be env("OS", "") == "Windows_NT"
20
- let shell be env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
21
- let is_cmd be c.ends_with(lower(shell), "cmd.exe") or lower(shell) == "cmd"
22
- give {"shell": shell, "flag": env("LAMPSON_SHELL_FLAG", when is_cmd then "/c" otherwise "-c"), "is_cmd": is_cmd}
23
-
24
- -- comandos que son servidores/watchers: rechazarlos ANTES de correrlos. El timeout mata el árbol, pero
25
- -- un servidor lanzado acá dejó un huérfano con el puerto 3000 tomado y el `process` real se fue al 3001
26
- -- (2026-08-27). La descripción ya lo decía; el modelo lo intentó igual "con | head" — hay que impedirlo.
27
- let SERVER_RE be ".*((npm|pnpm|yarn|bun)( run)? (dev|start|serve|watch|preview)|npx (next|vite|nodemon|serve|http-server)|next (dev|start)|vite( |$)|nodemon|synsema serve|uvicorn|gunicorn|flask run|manage\\.py runserver|python3? -m http\\.server|rails s(erver)?|php -S|ng serve|cargo (run|watch)|go run|dotnet (run|watch)|tail -f).*"
28
-
29
- export task looks_like_server(command)
30
- let cmd be trim(lower(replace_text(command, "\n", " ")))
31
- when matches(cmd, "timeout [0-9]+.*")
32
- give false
33
- give matches(cmd, SERVER_RE)
34
-
35
- -- corre argv con plazo real: junta stdout+stderr por líneas, corta al plazo y mata el árbol
36
- export task run_capture(exe, argv, t)
37
- require exec
38
- require time
39
- require file("workspace")
40
- require file("workspace/*")
41
- let p be proc_spawn(exe, argv, {"cwd": c.ROOT, "stderr": "merge", "on_full": "drop_oldest"})
42
- proc_close_stdin(p)
43
- let deadline be now() + t
44
- let lines be []
45
- let size be 0
46
- let cut be false
47
- let code be nothing
48
- let timed_out be false
49
- while code == nothing and not timed_out
50
- let left be deadline - now()
51
- when left <= 0
52
- set timed_out to true
53
- otherwise
54
- let ev be proc_recv(p, when left < 0.2 then 0.2 otherwise left)
55
- when ev == nothing
56
- set timed_out to now() >= deadline
57
- otherwise when ev["type"] == "exit"
58
- set code to ev["data"]["exit_code"]
59
- otherwise when size < 1000000
60
- set lines to append(lines, ev["data"])
61
- set size to size + length(ev["data"]) + 1
62
- otherwise
63
- set cut to true
64
- proc_close(p)
65
- let out be join(lines, "\n")
66
- when cut
67
- set out to out + "\n[output truncated at 1 MB]"
68
- when timed_out
69
- give "ERROR: timeout after " + text(t) + "s — the command and its child processes were killed. For servers/watchers use the process tool. Output so far:\n" + out
70
- when code != 0
71
- set out to out + "\n[exit code " + text(code) + "]"
72
- give out
73
-
74
- export task tool(command, timeout)
75
- require exec
76
- require time
77
- require env("LAMPSON_*")
78
- require env("OS")
79
- require file(".lampson")
80
- require file(".lampson/*")
81
- when looks_like_server(command)
82
- give "REFUSED: this looks like a server/watcher (`" + command + "`). bash kills its process tree at the timeout, and a dev server left half-killed keeps its port busy. Use the process tool instead: process(action=\"start\", name=\"dev\", command=\"...\"), then process(action=\"logs\") or just keep working — its new output is appended to every bash result. If it is really a one-shot command, wrap it with `timeout 30 ...` and explain why."
83
- let sc be shell_config()
84
- let t be when timeout == nothing then 120 otherwise floor(timeout)
85
- when t > 600
86
- set t to 600
87
- when t < 1
88
- set t to 1
89
- let out be run_capture(sc["shell"], [sc["flag"], command], t)
90
- when trim(out) == ""
91
- set out to "(no output)"
92
- -- lo que pasó mientras tanto en los procesos gestionados (servidores) — el agente "ve la consola"
93
- let rep be proc.report()
94
- when rep != ""
95
- set out to out + "\n\n" + rep
96
- give c.truncate(out, c.MAX_OUTPUT)
97
-
98
- export let SPEC be {
99
- "name": "bash",
100
- "description": "Run a shell command with the workspace root as working directory (no persistent shell state between calls: cwd and env reset). Returns stdout+stderr and [exit code N] when non-zero. Use it for git, tests, builds, package managers. Commands are killed (with their child processes) after the timeout (default 120s, max 600) — so NEVER run a server, watcher or REPL here: use the `process` tool (start/logs/stop) for anything long-running; its new log lines are appended to every bash result automatically. Avoid interactive commands. Prefer read/grep/find over cat/grep/find for files.",
101
- "parameters": {"type": "object", "properties": {
102
- "command": {"type": "string"},
103
- "timeout": {"type": "integer", "description": "Seconds (optional, default 120, max 600)"}
104
- }, "required": ["command"]}
105
- }
1
+ -- lib/tools/bash.syn — ejecutar un comando de shell con cwd = workspace/
2
+ --
3
+ -- MIGAS:
4
+ -- * en Windows `bash` a secas resuelve al de WSL y cuelga; usar Git Bash explícito (default) o cmd.
5
+ -- * NO usar `run()` para el comando del modelo: se cuelga para siempre si el comando deja un
6
+ -- descendiente vivo (Windows hereda el pipe de stdout) y su timeout mata solo al hijo directo.
7
+ -- Desde 2026-08-27 el comando corre con `proc_spawn` (v0.6.9): eventos por línea hasta el exit o el
8
+ -- plazo, stdin cerrado (EOF: nada se queda esperando teclado), y `proc_close` mata el ÁRBOL
9
+ -- (Job Object / process group) — un nieto con el pipe abierto no cuelga (1 s de gracia). Adiós
10
+ -- bash_wrapper.sh y archivos temporales.
11
+ -- * el proceso hijo NO hereda el scope de Synsema (un `cat ../x` en bash funciona). El aislamiento del
12
+ -- shell lo dan permission.syn (patrones) + el operador (contenedor si hace falta).
13
+ use "./common.syn" as c
14
+ use "./proc.syn" as proc
15
+
16
+ export task shell_config()
17
+ require env("LAMPSON_*")
18
+ require env("OS")
19
+ let is_win be env("OS", "") == "Windows_NT"
20
+ let shell be env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
21
+ let is_cmd be c.ends_with(lower(shell), "cmd.exe") or lower(shell) == "cmd"
22
+ give {"shell": shell, "flag": env("LAMPSON_SHELL_FLAG", when is_cmd then "/c" otherwise "-c"), "is_cmd": is_cmd}
23
+
24
+ -- comandos que son servidores/watchers: rechazarlos ANTES de correrlos. El timeout mata el árbol, pero
25
+ -- un servidor lanzado acá dejó un huérfano con el puerto 3000 tomado y el `process` real se fue al 3001
26
+ -- (2026-08-27). La descripción ya lo decía; el modelo lo intentó igual "con | head" — hay que impedirlo.
27
+ let SERVER_RE be ".*((npm|pnpm|yarn|bun)( run)? (dev|start|serve|watch|preview)|npx (next|vite|nodemon|serve|http-server)|next (dev|start)|vite( |$)|nodemon|synsema serve|uvicorn|gunicorn|flask run|manage\\.py runserver|python3? -m http\\.server|rails s(erver)?|php -S|ng serve|cargo (run|watch)|go run|dotnet (run|watch)|tail -f).*"
28
+
29
+ export task looks_like_server(command)
30
+ let cmd be trim(lower(replace_text(command, "\n", " ")))
31
+ when matches(cmd, "timeout [0-9]+.*")
32
+ give false
33
+ give matches(cmd, SERVER_RE)
34
+
35
+ -- corre argv con plazo real: junta stdout+stderr por líneas, corta al plazo y mata el árbol
36
+ export task run_capture(exe, argv, t)
37
+ require exec
38
+ require time
39
+ require file("workspace")
40
+ require file("workspace/*")
41
+ let p be proc_spawn(exe, argv, {"cwd": c.ROOT, "stderr": "merge", "on_full": "drop_oldest"})
42
+ proc_close_stdin(p)
43
+ let deadline be now() + t
44
+ let lines be []
45
+ let size be 0
46
+ let cut be false
47
+ let code be nothing
48
+ let timed_out be false
49
+ while code == nothing and not timed_out
50
+ let left be deadline - now()
51
+ when left <= 0
52
+ set timed_out to true
53
+ otherwise
54
+ let ev be proc_recv(p, when left < 0.2 then 0.2 otherwise left)
55
+ when ev == nothing
56
+ set timed_out to now() >= deadline
57
+ otherwise when ev["type"] == "exit"
58
+ set code to ev["data"]["exit_code"]
59
+ otherwise when size < 1000000
60
+ set lines to append(lines, ev["data"])
61
+ set size to size + length(ev["data"]) + 1
62
+ otherwise
63
+ set cut to true
64
+ proc_close(p)
65
+ let out be join(lines, "\n")
66
+ when cut
67
+ set out to out + "\n[output truncated at 1 MB]"
68
+ when timed_out
69
+ give "ERROR: timeout after " + text(t) + "s — the command and its child processes were killed. For servers/watchers use the process tool. Output so far:\n" + out
70
+ when code != 0
71
+ set out to out + "\n[exit code " + text(code) + "]"
72
+ give out
73
+
74
+ export task tool(command, timeout)
75
+ require exec
76
+ require time
77
+ require env("LAMPSON_*")
78
+ require env("OS")
79
+ require file(".lampson")
80
+ require file(".lampson/*")
81
+ when looks_like_server(command)
82
+ give "REFUSED: this looks like a server/watcher (`" + command + "`). bash kills its process tree at the timeout, and a dev server left half-killed keeps its port busy. Use the process tool instead: process(action=\"start\", name=\"dev\", command=\"...\"), then process(action=\"logs\") or just keep working — its new output is appended to every bash result. If it is really a one-shot command, wrap it with `timeout 30 ...` and explain why."
83
+ let sc be shell_config()
84
+ let t be when timeout == nothing then 120 otherwise floor(timeout)
85
+ when t > 600
86
+ set t to 600
87
+ when t < 1
88
+ set t to 1
89
+ let out be run_capture(sc["shell"], [sc["flag"], command], t)
90
+ when trim(out) == ""
91
+ set out to "(no output)"
92
+ -- lo que pasó mientras tanto en los procesos gestionados (servidores) — el agente "ve la consola"
93
+ let rep be proc.report()
94
+ when rep != ""
95
+ set out to out + "\n\n" + rep
96
+ give c.truncate(out, c.MAX_OUTPUT)
97
+
98
+ export let SPEC be {
99
+ "name": "bash",
100
+ "description": "Run a shell command with the workspace root as working directory (no persistent shell state between calls: cwd and env reset). Returns stdout+stderr and [exit code N] when non-zero. Use it for git, tests, builds, package managers. Commands are killed (with their child processes) after the timeout (default 120s, max 600) — so NEVER run a server, watcher or REPL here: use the `process` tool (start/logs/stop) for anything long-running; its new log lines are appended to every bash result automatically. Avoid interactive commands. Prefer read/grep/find over cat/grep/find for files.",
101
+ "parameters": {"type": "object", "properties": {
102
+ "command": {"type": "string"},
103
+ "timeout": {"type": "integer", "description": "Seconds (optional, default 120, max 600)"}
104
+ }, "required": ["command"]}
105
+ }