lampson 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.env.example +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +382 -0
  4. package/bin/lampson.js +81 -0
  5. package/chat.syn +799 -0
  6. package/lamps/example-hello/lamp.json +16 -0
  7. package/lamps/example-hello/lamp.syn +19 -0
  8. package/lampson.cmd +4 -0
  9. package/lampson.ps1 +88 -0
  10. package/lampson.sh +42 -0
  11. package/lib/agents.syn +471 -0
  12. package/lib/git.syn +58 -0
  13. package/lib/lamps.syn +386 -0
  14. package/lib/loop.syn +455 -0
  15. package/lib/lsp.syn +503 -0
  16. package/lib/mcp.syn +403 -0
  17. package/lib/permission.syn +154 -0
  18. package/lib/prompt.syn +75 -0
  19. package/lib/provider.syn +522 -0
  20. package/lib/session.syn +111 -0
  21. package/lib/settings.syn +70 -0
  22. package/lib/skills.syn +179 -0
  23. package/lib/tools/bash.syn +105 -0
  24. package/lib/tools/common.sh +49 -0
  25. package/lib/tools/common.syn +91 -0
  26. package/lib/tools/edit.syn +32 -0
  27. package/lib/tools/find.syn +35 -0
  28. package/lib/tools/grep.syn +31 -0
  29. package/lib/tools/img.ps1 +36 -0
  30. package/lib/tools/img.sh +22 -0
  31. package/lib/tools/ls.syn +18 -0
  32. package/lib/tools/memo.syn +148 -0
  33. package/lib/tools/proc.sh +42 -0
  34. package/lib/tools/proc.syn +314 -0
  35. package/lib/tools/process.syn +46 -0
  36. package/lib/tools/read.syn +25 -0
  37. package/lib/tools/skill.syn +14 -0
  38. package/lib/tools/todo.syn +97 -0
  39. package/lib/tools/write.syn +22 -0
  40. package/lib/tools.syn +198 -0
  41. package/lib/trace.syn +116 -0
  42. package/lib/tree.syn +59 -0
  43. package/lib/update.syn +57 -0
  44. package/package.json +40 -0
  45. package/public/fonts/plex-mono-400-latin-ext.woff2 +0 -0
  46. package/public/fonts/plex-mono-400-latin.woff2 +0 -0
  47. package/public/fonts/plex-mono-600-latin-ext.woff2 +0 -0
  48. package/public/fonts/plex-mono-600-latin.woff2 +0 -0
  49. package/public/fonts/plex-serif-400-latin-ext.woff2 +0 -0
  50. package/public/fonts/plex-serif-400-latin.woff2 +0 -0
  51. package/public/fonts/plex-serif-400i-latin-ext.woff2 +0 -0
  52. package/public/fonts/plex-serif-400i-latin.woff2 +0 -0
  53. package/public/fonts/plex-serif-600-latin-ext.woff2 +0 -0
  54. package/public/fonts/plex-serif-600-latin.woff2 +0 -0
  55. package/public/index.html +1268 -0
  56. package/public/vendor/xterm-addon-fit.js +2 -0
  57. package/public/vendor/xterm.css +218 -0
  58. package/public/vendor/xterm.js +2 -0
  59. package/skills/debugging/SKILL.md +33 -0
  60. package/skills/lampson/SKILL.md +117 -0
  61. package/skills/synsema/SKILL.md +75 -0
  62. package/web.syn +468 -0
package/chat.syn ADDED
@@ -0,0 +1,799 @@
1
+ -- chat.syn — Lampson por terminal (REPL)
2
+ --
3
+ -- synsema run chat.syn -- sesión nueva
4
+ -- LAMPSON_SESSION=<id> synsema run chat.syn -- reanudar
5
+ --
6
+ -- Comandos: /help /new /sessions /resume <id> /tokens /yolo /ask /strict /agent <build|plan|review|explore> /exit
7
+ --
8
+ -- El ENTRY otorga el superset de capacidades; cada tool declara las suyas y call_tool intersecta.
9
+ -- El proyecto objetivo se monta como ./workspace (ver lampson.ps1 / lampson.sh y lib/tools/common.syn).
10
+
11
+ intent: "Coding agent REPL: the model picks tools from an allow-list, each runs least-privilege in the workspace"
12
+
13
+ require net
14
+ require time
15
+ require stdin
16
+ require exec
17
+ require env("LAMPSON_*")
18
+ require env("OS")
19
+ require secret("LAMPSON_*")
20
+ require file("workspace")
21
+ require file("workspace/*")
22
+ require file.read("skills")
23
+ require file.read("skills/*")
24
+ require file.read("lamps")
25
+ require file.read("lamps/*")
26
+ require file("memory")
27
+ require file("memory/*")
28
+ require file(".lampson")
29
+ require file(".lampson/*")
30
+
31
+ use "./lib/provider.syn" as provider
32
+ use "./lib/tools.syn" as tools
33
+ use "./lib/prompt.syn" as prompt
34
+ use "./lib/loop.syn" as loop
35
+ use "./lib/session.syn" as session
36
+ use "./lib/permission.syn" as permission
37
+ use "./lib/agents.syn" as agents
38
+ use "./lib/tree.syn" as tree
39
+ use "./lib/skills.syn" as skills
40
+ use "./lib/git.syn" as git
41
+ use "./lib/tools/proc.syn" as proc
42
+ use "./lib/tools/memo.syn" as memo
43
+ use "./lib/update.syn" as update
44
+ use "./lib/settings.syn" as settings
45
+ use "./lib/trace.syn" as trace
46
+ use "./lib/mcp.syn" as mcp
47
+ use "./lib/lamps.syn" as lamps
48
+ use "./lib/lsp.syn" as lsp
49
+ use "./lib/tools/todo.syn" as todo
50
+
51
+ -- ---------- UI de terminal ----------
52
+ -- Colores ANSI (Windows Terminal, PowerShell 7, cualquier terminal moderna). LAMPSON_NO_COLOR=1 los apaga.
53
+
54
+ let ESC be decode(bytes("1b", "hex"))
55
+ let COLOR be env("LAMPSON_NO_COLOR", "") == ""
56
+
57
+ task c(code, s)
58
+ when not COLOR
59
+ give s
60
+ give ESC + "[" + code + "m" + s + ESC + "[0m"
61
+
62
+ task cyan(s)
63
+ give c("36;1", s)
64
+ task dim(s)
65
+ give c("2", s)
66
+ task yellow(s)
67
+ give c("33", s)
68
+ task green(s)
69
+ give c("32", s)
70
+ task red(s)
71
+ give c("31", s)
72
+
73
+ task first_line(s, max)
74
+ let f be split(s, "\n")[0]
75
+ when length(f) > max
76
+ give slice(f, 0, max) + "…"
77
+ give f
78
+
79
+ task fmt_tokens(n)
80
+ when n >= 1000
81
+ give text(floor(n / 100) / 10) + "k"
82
+ give text(n)
83
+
84
+ -- resumen de UNA línea del resultado de una tool, según la tool
85
+ task summarize(name, out)
86
+ let lines be split(out, "\n")
87
+ when starts_with(out, "ERROR") or starts_with(out, "DENIED")
88
+ give first_line(out, 140)
89
+ when name == "read"
90
+ give text(length(lines)) + " líneas"
91
+ when name == "ls" or name == "find" or name == "grep"
92
+ give text(length(lines)) + " resultados · " + first_line(out, 90)
93
+ when name == "bash"
94
+ -- hasta 5 líneas del output, el resto colapsado
95
+ let shown be []
96
+ each e in enumerate(lines)
97
+ when e["index"] < 5
98
+ when trim(e["item"]) != ""
99
+ set shown to append(shown, slice(e["item"], 0, 140))
100
+ let extra be when length(lines) > 5 then dim(" (+" + text(length(lines) - 5) + " líneas)") otherwise ""
101
+ give join(shown, "\n ") + extra
102
+ give first_line(out, 120)
103
+
104
+ -- tag = "" para el agente principal; los subagentes en background NO pasan por acá (escriben su log)
105
+ task on_event(kind, data, tag)
106
+ trace.event(sid, kind, data, tag)
107
+ when kind == "assistant"
108
+ print("")
109
+ print(data)
110
+ print("")
111
+ otherwise when kind == "inbox"
112
+ print(" " + cyan("✉ " + first_line(data, 140)))
113
+ otherwise when kind == "tool_call"
114
+ print(" " + yellow("⚙ " + permission.describe_call(data["name"], data["args"])))
115
+ otherwise when kind == "tool_result"
116
+ let out be data["output"]
117
+ let bad be starts_with(out, "ERROR") or starts_with(out, "DENIED")
118
+ let mark be when bad then red("✗") otherwise green("✓")
119
+ print(" " + mark + " " + (when bad then red(summarize(data["call"]["name"], out)) otherwise dim(summarize(data["call"]["name"], out))))
120
+ otherwise when kind == "tool_denied"
121
+ set data to data
122
+ otherwise when kind == "error"
123
+ print(" " + red("‼ " + text(data)))
124
+ when contains(lower(text(data)), "model")
125
+ print(" " + yellow("el proveedor rechazó el nombre del modelo (" + cfg["model"] + "): /model sin argumentos lista los válidos, /model <nombre> lo cambia"))
126
+ otherwise when kind == "compact"
127
+ print(" " + dim("⧗ compactando contexto (~" + text(data["before"]) + " tokens)"))
128
+ flush()
129
+
130
+ -- Human in the loop con el `approve` nativo de Synsema: prompt [approve] … (y/n) en TTY; sin TTY deniega
131
+ -- (fail-closed, un agente no puede auto-aprobarse); `within` acota la espera.
132
+ task ask_user(name, args, why)
133
+ print("")
134
+ print(" " + yellow("⚠ requiere tu aprobación · " + why))
135
+ print(" " + permission.describe_call(name, args))
136
+ flush()
137
+ let ok be approve " ¿permitir?" within 10m
138
+ give ok
139
+
140
+ let LINE be " ────────────────────────────────────────────────────────────────────────"
141
+
142
+ -- catálogo de comandos del REPL: [comando, argumentos, descripción]. Es la fuente de /help, de `/` y de las sugerencias.
143
+ let COMMANDS be [
144
+ ["/agent", "<build|plan|review|explore>", "cambiar de agente (qué tools ve): build edita · plan solo lee y propone · review revisa y corre tests · explore busca"],
145
+ ["/ask", "", "permisos: PREGUNTAR antes de un comando peligroso (rm -rf, git push --force, sudo…)"],
146
+ ["/yolo", "", "permisos: PERMITIR comandos peligrosos sin preguntar (los destructivos del sistema siguen bloqueados)"],
147
+ ["/strict", "", "permisos: DENEGAR comandos peligrosos"],
148
+ ["/model", "<nombre>", "cambiar el modelo para esta sesión (mismo proveedor y key)"],
149
+ ["/provider", "[nombre] [modelo]", "cambiar de proveedor (deepseek, anthropic, openai, minimax…); sin args lista cuáles tienen key"],
150
+ ["/setup", "", "elegir proveedor, modelo y pegar la API key (queda en lampson/.lampson/config.json, local)"],
151
+ ["/paste", "", "adjuntar la imagen del portapapeles al próximo mensaje (copiá una captura y escribí /paste)"],
152
+ ["/image", "<ruta>", "adjuntar una imagen (png/jpg/gif/webp) al próximo mensaje; /image sin ruta muestra o quita las pendientes"],
153
+ ["/delete", "<id>", "borrar una sesión guardada (ver /sessions)"],
154
+ ["/config", "", "ver la configuración vigente (proveedor, modelo, límites, workspace)"],
155
+ ["/files", "", "árbol completo del workspace"],
156
+ ["/procs", "", "procesos que el agente dejó corriendo (servidores) y su estado"],
157
+ ["/logs", "<nombre> [n]", "últimas n líneas del log de un proceso gestionado"],
158
+ ["/stop", "<nombre>", "parar un proceso gestionado"],
159
+ ["/kill", "<pid>", "matar un proceso NO gestionado (p.ej. un servidor huérfano que ves en /procs)"],
160
+ ["/memory", "[nombre]", "notas que el agente guardó sobre ESTE proyecto (memory/<proyecto>/*.md); con nombre, la muestra"],
161
+ ["/skills", "", "skills disponibles (instrucciones que el agente puede cargar)"],
162
+ ["/agents", "", "subagentes lanzados con delegate: estado, pasos y log de cada uno"],
163
+ ["/todo", "", "lista de tareas del agente en esta sesión (la mantiene con la tool todo)"],
164
+ ["/mcp", "[add <nombre> <comando…> [--project] | remove <nombre>]", "servers MCP: listar, conectar o quitar (global: lampson/.lampson/mcp.json · proyecto: .lampson/mcp.json)"],
165
+ ["/lamps", "[on <nombre> | off <nombre> | run <lámpara> <tool> [json] | remove <nombre>]", "lámparas (plugins de tools): listar, encender o apagar (global: lampson/lamps/ · proyecto: .lampson/lamps/)"],
166
+ ["/lsp", "[add <typescript|python|rust|go|css|html> [--project] | add <nombre> <comando…> --ext .x=lang | remove <nombre>]", "language servers (navegación semántica: symbols/definition/references/hover); arrancan en la primera consulta"],
167
+ ["/trace", "[n]", "traza legible de esta sesión (pasos, tools, tiempos, tokens, errores): .lampson/trace/<sesión>.log"],
168
+ ["/tokens", "", "tamaño del contexto y tokens gastados en esta sesión"],
169
+ ["/new", "", "empezar una sesión nueva (historial vacío)"],
170
+ ["/sessions", "", "listar sesiones guardadas"],
171
+ ["/resume", "<id>", "reanudar una sesión guardada"],
172
+ ["/flags", "", "flags de arranque: lampson --web --agent --yolo --workspace …"],
173
+ ["/update", "", "actualizar Lampson a la última versión (git pull); también: lampson --update"],
174
+ ["!<comando>", "", "correr un comando VOS en un terminal real (prompts y REPLs funcionan; ^C corta); la salida queda en el contexto del agente"],
175
+ ["/help", "", "esta ayuda"],
176
+ ["/exit", "", "salir (la sesión queda guardada)"]
177
+ ]
178
+
179
+ task pad(s, n)
180
+ let out be s
181
+ while length(out) < n
182
+ set out to out + " "
183
+ give out
184
+
185
+ task help()
186
+ print("")
187
+ print(" Escribí lo que querés hacer en el proyecto. El agente lee, busca, edita y corre comandos con tools")
188
+ print(" acotadas al workspace; cada paso queda a la vista. Los comandos empiezan con /:")
189
+ print("")
190
+ each c in COMMANDS
191
+ print(" " + pad(c[0] + " " + c[1], 38) + c[2])
192
+ print("")
193
+
194
+ task flags()
195
+ print("")
196
+ print(" Flags de arranque (desde cualquier carpeta; el directorio actual es el workspace):")
197
+ print("")
198
+ print(" lampson REPL por terminal sobre el proyecto actual")
199
+ print(" lampson --web interfaz web en http://127.0.0.1:8080")
200
+ print(" lampson --workspace C:\\ruta elegir el proyecto sin hacer cd")
201
+ print(" lampson --agent plan perfil inicial: build | plan | review | explore")
202
+ print(" lampson --yolo | --strict | --ask permisos (--dangerously-skip-permissions = --yolo)")
203
+ print(" lampson --help ayuda del launcher")
204
+ print("")
205
+ print(" Configuración persistente (opcional): lampson\\.env → LAMPSON_PROVIDER, LAMPSON_API_KEY, LAMPSON_MODEL…")
206
+ print("")
207
+
208
+ task show_config(cfg, ws, profile, mode, opts)
209
+ print("")
210
+ print(" proveedor " + cfg["provider"] + " (" + cfg["wire"] + " → " + cfg["base_url"] + ")")
211
+ print(" modelo " + cfg["model"] + " max_tokens " + text(cfg["max_tokens"]) + " timeout " + text(floor(cfg["timeout"])) + "s")
212
+ print(" agente " + profile + " tools: " + join(agents.profile(profile)["tools"], ", "))
213
+ print(" permisos " + mode)
214
+ print(" límites " + text(opts["max_steps"]) + " pasos/turno · " + text(floor(opts["budget_tokens"])) + " tokens/turno · compacta a " + text(floor(opts["compact_at_tokens"])))
215
+ print(" workspace " + ws)
216
+ print("")
217
+
218
+ -- sugerencias para un comando desconocido: por prefijo
219
+ task suggest(input)
220
+ let word be split(input, " ")[0]
221
+ let hits be []
222
+ each c in COMMANDS
223
+ when starts_with(c[0], word)
224
+ set hits to append(hits, c[0])
225
+ when length(hits) == 0
226
+ give "comando desconocido: " + word + " · escribí / para ver la lista"
227
+ give "¿quisiste decir " + join(hits, " · ") + "?"
228
+
229
+ task banner(ws, cfg, profile, mode, sid)
230
+ print("")
231
+ print(" ██ █████ ███ ███ ██████ ███████ ██████ ███ ██")
232
+ print(" ██ ██ ██ ████████ ██ ██ ██ ██ ██ ████ ██")
233
+ print(" ██ ███████ ██ ██ ██ ██████ ███████ ██ ██ ██ ██ ██")
234
+ print(" ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████")
235
+ print(" ██████ ██ ██ ██ ██ ███████ ██████ ██ ███")
236
+ print(" harness de agente en Synsema")
237
+ print(LINE)
238
+ print(" workspace " + ws)
239
+ print(" agente " + profile + " permisos " + mode + " modelo " + cfg["model"])
240
+ print(" sesión " + sid + " " + git.summary())
241
+ let upd be update.line()
242
+ when upd != ""
243
+ print(" " + yellow("⬆ " + upd) + dim(" (o /update acá)"))
244
+ print(LINE)
245
+ print(tree.render(0, 22))
246
+ print(LINE)
247
+ print(" Escribí un pedido, " + cyan("/") + " para los comandos, " + cyan("!") + "comando para correr algo vos.")
248
+
249
+ task skills_list()
250
+ let idx be skills.index()
251
+ let out be []
252
+ each n in sort_by(keys(idx), (x) => x)
253
+ set out to append(out, " " + n + " (" + idx[n]["source"] + "): " + idx[n]["description"])
254
+ when length(out) == 0
255
+ give " (sin skills)"
256
+ give join(out, "\n")
257
+
258
+ -- ---------- onboarding ----------
259
+ -- Primera vez (ningún proveedor con key) o /setup: elegir proveedor, modelo y pegar la key. Va a
260
+ -- lampson/.lampson/config.json (local, gitignored) y vale para terminal y web. Sin TTY no pregunta.
261
+ task setup_wizard()
262
+ print("")
263
+ print(" " + cyan("Configuremos el modelo.") + " Elegí un proveedor (número o nombre):")
264
+ let list be provider.providers()
265
+ each e in enumerate(list)
266
+ let p be e["item"]
267
+ print(" " + pad(text(e["index"] + 1) + ".", 4) + pad(p["name"], 12) + dim(p["model"]) + (when p["name"] == "ollama" then dim(" (local, sin key)") otherwise (when p["has_key"] then green(" ● key guardada") otherwise "")))
268
+ let choice be read_line(" proveedor: ")
269
+ when choice == nothing or trim(choice) == ""
270
+ print(" (sin cambios)")
271
+ give false
272
+ let name be lower(trim(choice))
273
+ when matches(name, "[0-9]+")
274
+ let i be floor(number(name)) - 1
275
+ when i >= 0 and i < length(list)
276
+ set name to list[i]["name"]
277
+ when not contains(provider.PRESETS, name)
278
+ print(" " + red("no conozco el proveedor " + name))
279
+ give false
280
+ let preset be provider.PRESETS[name]
281
+ let model be read_line(" modelo [" + preset["model"] + "]: ")
282
+ set model to when model == nothing then "" otherwise trim(model)
283
+ when name != "ollama"
284
+ print(" " + dim("la key se guarda en lampson/.lampson/config.json (solo en esta máquina); Enter para dejar la actual"))
285
+ let key be read_line(" API key de " + name + ": ")
286
+ when key != nothing and trim(key) != ""
287
+ settings.set_key(name, trim(key))
288
+ settings.set_default(name, model)
289
+ print(" " + green("listo") + " · " + name + " · " + (when model == "" then preset["model"] otherwise model) + dim(" (cambialo cuando quieras con /setup o /provider)"))
290
+ give true
291
+
292
+ -- ---------- imágenes en el REPL ----------
293
+ -- Una terminal no puede pegar una imagen, así que se lee del portapapeles (/paste) o de un archivo
294
+ -- (/image ruta) con un script del sistema que devuelve "media_type|w|h|base64". Quedan pendientes
295
+ -- hasta el próximo mensaje al agente (mismo formato canónico que la web: m["images"]).
296
+ let pending_images be []
297
+
298
+ task grab_image(mode, path)
299
+ let r be nothing
300
+ when env("OS", "") == "Windows_NT"
301
+ set r to run("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "lib/tools/img.ps1", mode, path], 60)
302
+ otherwise
303
+ set r to run("bash", ["lib/tools/img.sh", mode, path], 60)
304
+ let line be trim(replace_text(r["stdout"], "\r", ""))
305
+ when line == "" or starts_with(line, "ERROR|")
306
+ give {"error": when line == "" then trim(r["stderr"]) otherwise slice(line, 6, length(line))}
307
+ let parts be split(line, "|")
308
+ when length(parts) < 4
309
+ give {"error": "salida inesperada del lector de imágenes"}
310
+ give {"media_type": parts[0], "w": floor(number(parts[1])), "h": floor(number(parts[2])), "data": parts[3]}
311
+
312
+ task attach_image(mode, path)
313
+ when length(pending_images) >= 4
314
+ print(" " + yellow("máximo 4 imágenes por mensaje (/image para quitarlas)"))
315
+ give false
316
+ let im be grab_image(mode, path)
317
+ when contains(im, "error")
318
+ print(" " + red("✗ " + im["error"]))
319
+ give false
320
+ set pending_images to append(pending_images, im)
321
+ let vis be provider.supports_vision(cfg)
322
+ print(" " + green("📎 imagen " + text(length(pending_images)) + " adjunta") + dim(" · " + text(im["w"]) + "x" + text(im["h"]) + " · " + text(floor(length(im["data"]) * 3 / 4 / 1024)) + " KB · se manda con tu próximo mensaje"))
323
+ when not vis
324
+ print(" " + yellow("ojo: " + cfg["provider"] + " · " + cfg["model"] + " no acepta imágenes; el modelo recibirá solo una nota. Cambiá con /provider a uno con visión (anthropic, gpt-4o…)"))
325
+ give true
326
+
327
+ -- ---------- boot ----------
328
+
329
+ let cfg be provider.config()
330
+ when not provider.configured() and cfg["provider"] != "ollama"
331
+ print("")
332
+ print(" Bienvenido a Lampson. Todavía no hay ninguna API key configurada.")
333
+ when setup_wizard()
334
+ set cfg to provider.config()
335
+ let env_info be agents.env_info(cfg)
336
+ let profile be lower(env("LAMPSON_AGENT", "build"))
337
+ task system_for(p)
338
+ give {"role": "system", "content": prompt.build(env_info, agents.addendum_for(p))}
339
+ task opts_for(p, mode)
340
+ let o be loop.default_opts(agents.registry_for(p), agents.catalog_for(p), ask_user)
341
+ set o to loop.with_steps(o, agents.steps_for(p))
342
+ set o to loop.with_inbox(o, agents.parent_inbox, "")
343
+ give loop.with_mode(o, mode)
344
+
345
+ -- Subagente en background: `agent Child` (lib/agents.syn) corre en un hilo con intérprete propio y solo
346
+ -- puede llamar tasks TOP-LEVEL de este programa — esta es su única puerta de entrada al harness.
347
+ task lampson_subagent(spec_json)
348
+ give agents.run_child_json(spec_json)
349
+
350
+ -- !comando del usuario en un pseudo-terminal: la salida se ve EN VIVO y, si el proceso se queda esperando
351
+ -- (prompt y/N, contraseña, REPL, `npm init`…), lo que escribas se le manda como teclas + Enter.
352
+ -- Enter vacío = seguir esperando · ^C = cortar. Devuelve la salida (sin ANSI) para el contexto del agente.
353
+ task shell_argv(cmd)
354
+ when env("OS", "") == "Windows_NT"
355
+ give ["powershell", ["-NoLogo", "-NoProfile", "-Command", cmd]]
356
+ give ["bash", ["-lc", cmd]]
357
+
358
+ task run_interactive(cmd)
359
+ let sh be shell_argv(cmd)
360
+ let p be proc_spawn(sh[0], sh[1], {"cwd": env("LAMPSON_WORKSPACE", "workspace"), "pty": true, "cols": 120, "rows": 40})
361
+ let captured be []
362
+ let partial be ""
363
+ let alive be true
364
+ while alive
365
+ let ev be proc_recv(p, 5)
366
+ when ev == nothing
367
+ when proc_status(p) != "running"
368
+ set alive to false
369
+ otherwise
370
+ -- silencio: si quedó una línea a medias es casi seguro un prompt; dejar escribir
371
+ let hint be when trim(partial) == "" then "(sin salida hace 5 s · Enter: seguir esperando · texto: mandárselo · ^C: cortar) " otherwise partial + " "
372
+ let line be read_line(dim(hint))
373
+ set partial to ""
374
+ when line == nothing or trim(line) == "^C"
375
+ proc_kill(p)
376
+ set captured to append(captured, "[cortado por el usuario]")
377
+ otherwise when trim(line) != ""
378
+ proc_send(p, line + "\r")
379
+ otherwise when ev["type"] == "exit"
380
+ when partial != ""
381
+ print(" " + partial)
382
+ set captured to append(captured, partial)
383
+ set captured to append(captured, "[exit " + text(ev["data"]["exit_code"]) + "]")
384
+ set alive to false
385
+ otherwise
386
+ let raw be ev["data"]
387
+ when is_bytes(raw)
388
+ set raw to decode(raw, "utf8_lossy")
389
+ let chunk be strip_ansi(raw)
390
+ let lines be split(partial + chunk, "\n")
391
+ let n be length(lines)
392
+ each e in enumerate(lines)
393
+ when e["index"] < n - 1
394
+ print(" " + e["item"])
395
+ set captured to append(captured, e["item"])
396
+ set partial to lines[n - 1]
397
+ flush()
398
+ proc_close(p)
399
+ give join(captured, "\n")
400
+
401
+ let system_msg be system_for(profile)
402
+ -- servers MCP (globales en .lampson/mcp.json, del proyecto en workspace/.lampson/mcp.json): arrancan antes
403
+ -- de armar el catálogo de tools; hasta 8 s de espera a que estén listos
404
+ let mcp_servers be mcp.start_all(8)
405
+ each ms in mcp.summary()
406
+ print(" " + (when ms["status"] == "ready" then green("● mcp " + ms["name"]) otherwise red("○ mcp " + ms["name"] + " " + ms["status"])) + dim(" (" + ms["scope"] + ") " + text(length(ms["tools"])) + " tools" + (when ms["error"] != nothing then " · " + text(ms["error"]) otherwise "")))
407
+ -- language servers (lib/lsp.syn): solo se anuncian; arrancan lazy en la primera consulta
408
+ each s in lsp.summary()
409
+ print(" " + dim("○ lsp " + s["name"] + " (" + s["scope"] + ") " + join(s["extensions"], " ") + " · arranca en la primera consulta"))
410
+ -- lámparas (plugins de tools, lib/lamps.syn): solo se anuncian; apagadas no entran al catálogo
411
+ each l in lamps.summary()
412
+ print(" " + (when l["enabled"] then green("● lamp " + l["name"]) otherwise dim("○ lamp " + l["name"] + " off")) + dim(" (" + l["scope"] + ", " + l["kind"] + ") " + text(length(l["tools"])) + " tools" + (when l["error"] != nothing then " · " + text(l["error"]) otherwise "")))
413
+ let opts be opts_for(profile, lower(env("LAMPSON_PERMISSION", "ask")))
414
+
415
+ -- marca de corrida (blackboard): session.save la estampa en meta.run; reanudar una sesión guardada por
416
+ -- OTRA corrida dispara la nota de procesos muertos (proc.resume_note) para que el modelo no los crea vivos
417
+ share {"id": text(now())} as "lampson:run"
418
+ share {"kind": "terminal"} as "lampson:ui"
419
+
420
+ let sid be env("LAMPSON_SESSION", "")
421
+ let messages be [system_msg]
422
+ when sid != ""
423
+ when session.exists(sid)
424
+ let doc be session.load(sid)
425
+ set messages to doc["messages"]
426
+ print("reanudando sesión " + sid + " (" + text(length(messages)) + " mensajes)")
427
+ when session.from_other_run(doc)
428
+ let note be proc.resume_note(sid)
429
+ when note != ""
430
+ set messages to append(messages, {"role": "user", "content": note})
431
+ print(" " + cyan("✉ " + first_line(note, 140)))
432
+ otherwise
433
+ print("sesión " + sid + " no existe; creando una nueva")
434
+ set sid to ""
435
+ when sid == ""
436
+ set sid to session.new_id()
437
+ -- sesión actual (blackboard): la tool todo guarda su lista por sesión y no recibe contexto (lib/tools/todo.syn)
438
+ share {"id": sid} as "lampson:session"
439
+
440
+ banner(env_info["cwd"], cfg, profile, opts["permission_mode"], sid)
441
+ flush()
442
+
443
+ let total_usage be {"input": 0, "output": 0}
444
+ let mode be opts["permission_mode"]
445
+ let running be true
446
+ -- turnos automáticos por avisos de subagentes en background (padre idle): tope 3 seguidos, se
447
+ -- reinicia con input humano real (anti bucle auto-excitado, deepseek-harness)
448
+ let followups be 0
449
+ while running
450
+ let inbox be when followups < 3 then agents.parent_inbox(0, "") otherwise nothing
451
+ let line be nothing
452
+ when inbox != nothing
453
+ set followups to followups + 1
454
+ each m in inbox["messages"]
455
+ print("")
456
+ print(" " + cyan("✉ " + first_line(m, 140)))
457
+ set messages to append(messages, {"role": "user", "content": m})
458
+ trace.user(sid, "(turno automático por aviso de subagente)", profile, mode, cfg["model"])
459
+ set opts to opts_for(profile, mode)
460
+ let result be loop.run_turn(cfg, messages, opts, on_event)
461
+ trace.turn_end(sid, result)
462
+ set messages to result["messages"]
463
+ set total_usage to {"input": total_usage["input"] + result["usage"]["input"], "output": total_usage["output"] + result["usage"]["output"]}
464
+ print(dim(" ─── " + text(result["steps"]) + " pasos · " + fmt_tokens(result["usage"]["input"] + result["usage"]["output"]) + " tokens · turno automático por subagente"))
465
+ session.save(sid, messages, {"title": session.title_of(messages)})
466
+ flush()
467
+ otherwise
468
+ set line to read_line("\n" + (when length(pending_images) > 0 then dim("📎" + text(length(pending_images)) + " ") otherwise "") + cyan("❯ "))
469
+ when inbox != nothing
470
+ set running to running
471
+ otherwise when line == nothing
472
+ set running to false
473
+ otherwise
474
+ set followups to 0
475
+ let input be trim(line)
476
+ when input == ""
477
+ set running to running
478
+ otherwise when input == "/exit" or input == "/quit"
479
+ set running to false
480
+ otherwise when input == "/help" or input == "/"
481
+ help()
482
+ otherwise when input == "/flags"
483
+ flags()
484
+ otherwise when input == "/update"
485
+ let u be update.check()
486
+ when not u["available"]
487
+ print(" ya estás en la última versión (" + u["current"] + ")")
488
+ otherwise
489
+ each n in u["notes"]
490
+ print(" " + dim("· " + n))
491
+ print(" " + update.apply())
492
+ otherwise when input == "/config"
493
+ show_config(cfg, env_info["cwd"], profile, mode, opts)
494
+ otherwise when starts_with(input, "/trace")
495
+ let n be trim(slice(input, 6, length(input)))
496
+ let lines be when n == "" then 40 otherwise floor(number(n))
497
+ print(dim(" " + trace.file_of(sid)))
498
+ print(trace.tail(sid, lines))
499
+ otherwise when starts_with(input, "/model")
500
+ let want be trim(slice(input, 6, length(input)))
501
+ when want == ""
502
+ print("modelo actual: " + cfg["model"] + " · uso: /model <nombre>")
503
+ let lm be provider.list_models(cfg)
504
+ when lm["error"] != nothing
505
+ print(" " + dim("no pude listar los modelos de " + cfg["provider"] + ": " + lm["error"]))
506
+ otherwise
507
+ print(" disponibles en " + cfg["provider"] + ": " + join(lm["models"], ", "))
508
+ otherwise
509
+ set cfg["model"] to provider.normalize_model(cfg["provider"], want)
510
+ print("modelo=" + want + " (solo esta sesión; para fijarlo: LAMPSON_MODEL en lampson\\.env)")
511
+ otherwise when starts_with(input, "/provider")
512
+ let args be where(split(trim(slice(input, 9, length(input))), " "), (x) => x != "")
513
+ when length(args) == 0
514
+ print(" proveedor actual: " + cfg["provider"] + " · modelo " + cfg["model"])
515
+ each p in provider.providers()
516
+ print(" " + (when p["has_key"] then green("● ") otherwise dim("○ ")) + pad(p["name"], 12) + dim(p["model"] + (when p["has_key"] then "" otherwise " (sin key: LAMPSON_API_KEY_" + upper(p["name"]) + " en lampson\\.env)")))
517
+ print(" uso: /provider <nombre> [modelo]")
518
+ otherwise when not contains(provider.PRESETS, lower(args[0]))
519
+ print(" no conozco el proveedor " + args[0] + " · disponibles: " + join(sort_by(keys(provider.PRESETS), (x) => x), ", "))
520
+ otherwise
521
+ set cfg to provider.config_for(args[0], when length(args) > 1 then args[1] otherwise "")
522
+ when not cfg["has_key"]
523
+ print(" " + yellow("ojo: no hay key para " + cfg["provider"] + " — agregá LAMPSON_API_KEY_" + upper(cfg["provider"]) + "=… en lampson\\.env (se usará LAMPSON_API_KEY, que probablemente falle)"))
524
+ print(" proveedor=" + cfg["provider"] + " · modelo=" + cfg["model"] + " · " + cfg["wire"] + " → " + cfg["base_url"] + dim(" (solo esta sesión; para fijarlo: LAMPSON_PROVIDER en .env)"))
525
+ otherwise when input == "/setup"
526
+ when setup_wizard()
527
+ set cfg to provider.config()
528
+ otherwise when input == "/paste"
529
+ attach_image("clip", "")
530
+ otherwise when starts_with(input, "/image")
531
+ let p be trim(slice(input, 6, length(input)))
532
+ when p == ""
533
+ when length(pending_images) == 0
534
+ print(" sin imágenes pendientes · uso: /image <ruta> o /paste")
535
+ otherwise
536
+ print(" " + text(length(pending_images)) + " imagen(es) pendiente(s) — se mandan con tu próximo mensaje. ¿Quitarlas? (s/N)")
537
+ let a be read_line(" ")
538
+ when a != nothing and lower(trim(a)) == "s"
539
+ set pending_images to []
540
+ print(" quitadas")
541
+ otherwise
542
+ attach_image("file", p)
543
+ otherwise when starts_with(input, "/delete")
544
+ let id be trim(slice(input, 7, length(input)))
545
+ when id == ""
546
+ print(" uso: /delete <id> (ver /sessions)")
547
+ otherwise when id == sid
548
+ print(" esa es la sesión actual; hacé /new primero")
549
+ otherwise
550
+ print(" " + session.delete(id))
551
+ otherwise when input == "/new"
552
+ set sid to session.new_id()
553
+ set messages to [system_msg]
554
+ share {"id": sid} as "lampson:session"
555
+ print("nueva sesión " + sid)
556
+ otherwise when input == "/sessions"
557
+ each s in session.list()
558
+ print(" " + s["id"] + " " + s["title"])
559
+ otherwise when starts_with(input, "/resume ")
560
+ let id be trim(slice(input, 8, length(input)))
561
+ when session.exists(id)
562
+ set sid to id
563
+ let rdoc be session.load(id)
564
+ set messages to rdoc["messages"]
565
+ share {"id": sid} as "lampson:session"
566
+ print("reanudada " + id + " (" + text(length(messages)) + " mensajes)")
567
+ when session.from_other_run(rdoc)
568
+ let note be proc.resume_note(sid)
569
+ when note != ""
570
+ set messages to append(messages, {"role": "user", "content": note})
571
+ print(" " + cyan("✉ " + first_line(note, 140)))
572
+ otherwise
573
+ print("no existe " + id)
574
+ otherwise when input == "/files"
575
+ print(tree.render(2, 200))
576
+ otherwise when input == "/procs"
577
+ let items be proc.list()
578
+ when length(items) == 0
579
+ print(" (ningún proceso gestionado)")
580
+ each p in items
581
+ print(" " + (when p["running"] then green("● running") otherwise red("○ exited ")) + " " + p["name"] + " $ " + p["command"])
582
+ let ports be proc.listeners()
583
+ when length(ports) > 0
584
+ print("")
585
+ print(" " + dim("puertos en escucha en esta máquina (de cualquier programa, no solo de lampson; /kill <pid> para matar uno):"))
586
+ each l in ports
587
+ print(" " + yellow(":" + text(l["port"])) + " pid " + text(l["pid"]) + " " + l["name"] + " " + dim(first_line(l["command"], 110)))
588
+ otherwise when starts_with(input, "/logs")
589
+ let args be split(trim(slice(input, 5, length(input))), " ")
590
+ when args[0] == ""
591
+ print(" uso: /logs <nombre> [líneas]")
592
+ otherwise
593
+ let n be when length(args) > 1 then number(args[1]) otherwise 60
594
+ print(proc.logs(args[0], n))
595
+ otherwise when starts_with(input, "/kill")
596
+ let pid be trim(slice(input, 5, length(input)))
597
+ print(" " + (when pid == "" then "uso: /kill <pid> (ver /procs)" otherwise proc.kill_pid(number(pid))))
598
+ otherwise when starts_with(input, "/stop")
599
+ let nm be trim(slice(input, 5, length(input)))
600
+ print(" " + (when nm == "" then "uso: /stop <nombre>" otherwise proc.halt(nm)))
601
+ otherwise when starts_with(input, "/memory")
602
+ let nm be trim(slice(input, 7, length(input)))
603
+ when nm == ""
604
+ let items be memo.list()
605
+ print(" " + dim("memory/" + memo.slug() + "/"))
606
+ when length(items) == 0
607
+ print(" (sin notas todavía — el agente las crea con la tool memory)")
608
+ each it in items
609
+ print(" " + yellow(it["name"]) + " " + it["title"])
610
+ otherwise
611
+ print(memo.note_read(nm))
612
+ otherwise when input == "/todo"
613
+ let items be todo.load(sid)
614
+ print(when length(items) == 0 then " sin tareas (el agente las crea con la tool todo)" otherwise " " + replace_text(todo.format(items), "\n", "\n "))
615
+ otherwise when input == "/lsp" or starts_with(input, "/lsp ")
616
+ let srest be trim(slice(input, 4, length(input)))
617
+ when starts_with(srest, "add ")
618
+ -- /lsp add typescript [--project] · /lsp add mylang cmd args… --ext .x=lang [--ext .y=lang2]
619
+ let sscope be when contains(srest, "--project") then "project" otherwise "global"
620
+ let sline be trim(replace_text(srest, "--project", ""))
621
+ let stoks be where(split(slice(sline, 4, length(sline)), " "), (x) => x != "")
622
+ when length(stoks) == 0
623
+ print(" uso: /lsp add <" + join(keys(lsp.PRESETS), "|") + "> [--project] · /lsp add <nombre> <comando…> --ext .ext=languageId")
624
+ otherwise
625
+ let langs be {}
626
+ let cmdtoks be []
627
+ let ti be 1
628
+ while ti < length(stoks)
629
+ when stoks[ti] == "--ext" and ti + 1 < length(stoks)
630
+ let kv be split(stoks[ti + 1], "=")
631
+ when length(kv) == 2
632
+ set langs[kv[0]] to kv[1]
633
+ set ti to ti + 2
634
+ otherwise
635
+ set cmdtoks to append(cmdtoks, stoks[ti])
636
+ set ti to ti + 1
637
+ try
638
+ print(" " + lsp.add_server(stoks[0], when length(cmdtoks) == 0 then nothing otherwise join(cmdtoks, " "), when length(keys(langs)) == 0 then nothing otherwise langs, sscope))
639
+ recover err
640
+ print(" " + red(text(err)))
641
+ otherwise when starts_with(srest, "remove ")
642
+ try
643
+ print(" " + lsp.remove_server(trim(slice(srest, 7, length(srest)))))
644
+ recover err
645
+ print(" " + red(text(err)))
646
+ otherwise
647
+ let ss be lsp.summary()
648
+ when length(ss) == 0
649
+ print(" sin language servers configurados. Agregá uno: " + cyan("/lsp add typescript") + dim(" (presets: " + join(keys(lsp.PRESETS), ", ") + ")"))
650
+ print(" " + dim("el agente los usa con la tool lsp: symbols (estructura de un archivo sin leerlo), definition, references, hover"))
651
+ each s in ss
652
+ print(" " + (when s["status"] == "ready" then green("● ") otherwise (when s["status"] == "error" or s["status"] == "exited" then red("○ ") otherwise dim("○ "))) + pad(s["name"], 12) + dim(pad(s["scope"], 8) + pad(s["status"], 8) + join(s["extensions"], " ") + " $ " + s["command"]) + (when s["error"] != nothing then red(" " + text(s["error"])) otherwise ""))
653
+ print(" " + dim("idle = arranca en la primera consulta del agente"))
654
+ otherwise when input == "/lamps" or starts_with(input, "/lamps ")
655
+ let lrest be trim(slice(input, 6, length(input)))
656
+ when starts_with(lrest, "run ")
657
+ -- /lamps run <lámpara> <tool> [json de args] → la corre el usuario, sin el modelo
658
+ let rt be where(split(trim(slice(lrest, 4, length(lrest))), " "), (x) => x != "")
659
+ when length(rt) < 2
660
+ print(" uso: /lamps run <lámpara> <tool> [{\"arg\": \"valor\"}]")
661
+ otherwise
662
+ let rargs be {}
663
+ when length(rt) > 2
664
+ try
665
+ set rargs to json_decode(join(slice(rt, 2, length(rt)), " "))
666
+ recover err
667
+ print(" " + red("args: JSON inválido"))
668
+ try
669
+ print(lamps.call(lamps.tool_name(rt[0], rt[1]), rargs))
670
+ recover err
671
+ print(" " + red(text(err)))
672
+ otherwise when starts_with(lrest, "remove ")
673
+ try
674
+ print(" " + lamps.remove(trim(slice(lrest, 7, length(lrest)))))
675
+ recover err
676
+ print(" " + red(text(err)))
677
+ set opts to opts_for(profile, mode)
678
+ otherwise when starts_with(lrest, "on ") or starts_with(lrest, "off ")
679
+ let lon be starts_with(lrest, "on ")
680
+ try
681
+ print(" " + lamps.set_enabled(trim(slice(lrest, when lon then 3 otherwise 4, length(lrest))), lon))
682
+ recover err
683
+ print(" " + red(text(err)))
684
+ set opts to opts_for(profile, mode)
685
+ otherwise
686
+ let ls be lamps.summary()
687
+ when length(ls) == 0
688
+ print(" sin lámparas. Una lámpara es una carpeta con lamp.json: " + dim("lampson\\lamps\\<nombre>\\ (global) · .lampson\\lamps\\<nombre>\\ del repo (proyecto)"))
689
+ print(" " + dim("el agente también puede crearlas (write en .lampson/lamps/) y pedirte encenderlas"))
690
+ each l in ls
691
+ print(" " + (when l["enabled"] then green("● ") otherwise dim("○ ")) + pad(l["name"], 14) + dim(pad(l["scope"], 8) + pad(l["kind"], 5)) + (when l["error"] != nothing then red("rota: " + text(l["error"])) otherwise dim(text(length(l["tools"])) + " tools [" + join(l["tools"], ", ") + "]" + (when l["kind"] == "syn" then " techo: " + l["caps"] otherwise " $ " + l["command"]))))
692
+ print(" " + dim("encender/apagar: /lamps on <nombre> · /lamps off <nombre> · correr una vos: /lamps run <lámpara> <tool> [json]"))
693
+ otherwise when input == "/mcp" or starts_with(input, "/mcp ")
694
+ let mrest be trim(slice(input, 4, length(input)))
695
+ when starts_with(mrest, "add ")
696
+ -- /mcp add <nombre> <comando…> [--project] (env/keys: editá el mcp.json a mano, no van por terminal)
697
+ let mscope be when contains(mrest, "--project") then "project" otherwise "global"
698
+ let mline be trim(replace_text(mrest, "--project", ""))
699
+ let mtoks be where(split(slice(mline, 4, length(mline)), " "), (x) => x != "")
700
+ when length(mtoks) < 2
701
+ print(" uso: /mcp add <nombre> <comando…> [--project] p.ej.: /mcp add github npx -y @modelcontextprotocol/server-github")
702
+ otherwise
703
+ try
704
+ print(" " + mcp.add_server(mtoks[0], join(slice(mtoks, 1, length(mtoks)), " "), {}, mscope))
705
+ recover err
706
+ print(" " + red(text(err)))
707
+ set opts to opts_for(profile, mode)
708
+ otherwise when starts_with(mrest, "remove ")
709
+ try
710
+ print(" " + mcp.remove_server(trim(slice(mrest, 7, length(mrest)))))
711
+ recover err
712
+ print(" " + red(text(err)))
713
+ set opts to opts_for(profile, mode)
714
+ otherwise
715
+ let sm be mcp.summary()
716
+ when length(sm) == 0
717
+ print(" sin servers MCP conectados (no hay ninguno configurado).")
718
+ print(" conectá uno: " + cyan("/mcp add <nombre> <comando…> [--project]") + dim(" · o editá lampson\\.lampson\\mcp.json (global) / .lampson\\mcp.json del repo (proyecto)"))
719
+ print(" " + dim("el agente también puede: pedile \"conectá el MCP de github\" y te va a pedir aprobación"))
720
+ each ms in sm
721
+ print(" " + (when ms["status"] == "ready" then green("● ") otherwise red("○ ")) + pad(ms["name"], 14) + dim(pad(ms["scope"], 8) + ms["status"] + " $ " + ms["command"]) + (when ms["error"] != nothing then red(" " + text(ms["error"])) otherwise ""))
722
+ when length(ms["tools"]) > 0
723
+ print(" " + dim(join(ms["tools"], ", ")))
724
+ -- servers que se pusieron listos después del arranque: recomponer el catálogo
725
+ set opts to opts_for(profile, mode)
726
+ otherwise when input == "/agents"
727
+ agents.prune(10, 1800)
728
+ let kids be agents.list_children()
729
+ when length(kids) == 0
730
+ print(" sin subagentes todavía (el agente los lanza con la tool delegate)")
731
+ each c in kids
732
+ print(" " + (when c["status"] == "running" then green("● running") otherwise dim("○ " + c["status"] + " ")) + " " + c["id"] + " " + text(c["steps"]) + " pasos " + dim(first_line(c["brief"], 80)) + dim(" log: " + agents.DIR + "/" + c["id"] + ".log"))
733
+ otherwise when input == "/skills"
734
+ print(skills_list())
735
+ print(dim(" instalar una de skills.sh: pedile al agente «instalá la skill X de owner/repo» (global, pide aprobación) · o !npx skills add owner/repo --skill X -g"))
736
+ otherwise when input == "/tokens"
737
+ print("contexto≈" + text(loop.estimate_tokens(messages)) + " tokens · gastado in=" + text(total_usage["input"]) + " out=" + text(total_usage["output"]))
738
+ otherwise when input == "/yolo" or input == "/ask" or input == "/strict"
739
+ set mode to slice(input, 1, length(input))
740
+ set opts to opts_for(profile, mode)
741
+ print("permisos=" + opts["permission_mode"])
742
+ otherwise when starts_with(input, "/agent")
743
+ let want be trim(slice(input, 6, length(input)))
744
+ when want == ""
745
+ print("agente=" + profile + " · disponibles: " + join(agents.names(), ", "))
746
+ otherwise when contains(agents.PROFILES, want)
747
+ set profile to want
748
+ set system_msg to system_for(profile)
749
+ set opts to opts_for(profile, mode)
750
+ -- el system prompt cambia con el perfil; el historial se conserva (cambiar de agente no borra la conversación)
751
+ set messages to [system_msg] + slice(messages, 1, length(messages))
752
+ print("agente=" + profile + " · tools: " + join(agents.profile(profile)["tools"], ", "))
753
+ otherwise
754
+ print("no existe el agente " + want + " · disponibles: " + join(agents.names(), ", "))
755
+ otherwise when starts_with(input, "/")
756
+ print(" " + suggest(input))
757
+ otherwise when starts_with(input, "!")
758
+ -- comando del USUARIO: corre sin política de permisos (lo escribiste vos), salida al contexto
759
+ let cmd be trim(slice(input, 1, length(input)))
760
+ when cmd == ""
761
+ print(" uso: !<comando> p.ej. !git status · !npm test · !cat .env")
762
+ otherwise
763
+ print(" " + yellow("$ " + cmd))
764
+ flush()
765
+ let out be run_interactive(cmd)
766
+ set messages to append(messages, {"role": "user", "content": "[The user ran this command in the shell themselves]\n$ " + cmd + "\n" + tools.truncate(out, 6000)})
767
+ session.save(sid, messages, {"title": session.title_of(messages)})
768
+ otherwise
769
+ when length(pending_images) > 0
770
+ set messages to append(messages, {"role": "user", "content": input, "images": pending_images})
771
+ set pending_images to []
772
+ otherwise
773
+ set messages to append(messages, {"role": "user", "content": input})
774
+ trace.user(sid, input, profile, mode, cfg["model"])
775
+ -- catálogo fresco por turno: si el turno anterior conectó/quitó un server MCP, sus tools
776
+ -- entran/salen acá (con los mismos servers el catálogo es idéntico → el prompt cache no se corta)
777
+ set opts to opts_for(profile, mode)
778
+ let result be loop.run_turn(cfg, messages, opts, on_event)
779
+ trace.turn_end(sid, result)
780
+ set messages to result["messages"]
781
+ set total_usage to {"input": total_usage["input"] + result["usage"]["input"], "output": total_usage["output"] + result["usage"]["output"]}
782
+ let summary be text(result["steps"]) + (when result["steps"] == 1 then " paso" otherwise " pasos") + " · " + fmt_tokens(result["usage"]["input"] + result["usage"]["output"]) + " tokens"
783
+ when result["stopped"] == "max_steps"
784
+ set summary to summary + " · " + red("límite de " + text(opts["max_steps"]) + " pasos por turno")
785
+ print(dim(" ─── " + summary))
786
+ print(" " + yellow("El agente paró por el límite de pasos, no porque terminó. Escribí «seguí» para que continúe donde quedó, o subí LAMPSON_MAX_STEPS."))
787
+ otherwise when result["stopped"] != "done"
788
+ set summary to summary + " · " + red("detenido: " + result["stopped"])
789
+ print(dim(" ─── " + summary))
790
+ otherwise
791
+ print(dim(" ─── " + summary))
792
+ session.save(sid, messages, {"title": session.title_of(messages)})
793
+ flush()
794
+ -- los procesos gestionados (servidores) y los subagentes en background mueren con lampson
795
+ agents.stop_all()
796
+ mcp.stop_all()
797
+ lsp.stop_all()
798
+ proc.halt_all()
799
+ print("bye · sesión " + sid)