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/web.syn ADDED
@@ -0,0 +1,468 @@
1
+ -- web.syn — Lampson por URL (chat web con SSE)
2
+ --
3
+ -- synsema serve web.syn → http://127.0.0.1:8080
4
+ --
5
+ -- API:
6
+ -- GET / UI (public/index.html)
7
+ -- GET /api/sessions lista de sesiones
8
+ -- GET /api/sessions/:id historial de una sesión
9
+ -- POST /api/chat {session?, message, agent?} → SSE con eventos del loop y "done" al final
10
+ --
11
+ -- Permisos bajo web: el modo (ask|yolo|strict) viene en cada POST /api/chat (selector de la UI; default
12
+ -- LAMPSON_PERMISSION). En "ask", una tool peligrosa emite el evento SSE `approval_request` y el loop
13
+ -- espera (polling de state_*) hasta que la UI responda por POST /api/approve o venza el timeout → deniega.
14
+ -- Nunca se auto-aprueba en silencio.
15
+ --
16
+ -- MIGAS serve: secret() y config() se resuelven DENTRO del handler (globals redactados por request);
17
+ -- las tasks deben definirse ANTES de `serve on`; `send` solo parsea dentro de `stream`, así que
18
+ -- el emisor de eventos se define dentro del bloque stream.
19
+
20
+ intent: "Web chat for a coding agent: tools from an allow-list, least-privilege per tool, SSE progress"
21
+
22
+ require serve(8080)
23
+ require net
24
+ require time
25
+ require exec
26
+ require env("LAMPSON_*")
27
+ require env("OS")
28
+ require secret("LAMPSON_*")
29
+ require file("workspace")
30
+ require file("workspace/*")
31
+ require file.read("skills")
32
+ require file.read("skills/*")
33
+ require file.read("lamps")
34
+ require file.read("lamps/*")
35
+ require file("memory")
36
+ require file("memory/*")
37
+ require file(".lampson")
38
+ require file(".lampson/*")
39
+
40
+ use "./lib/provider.syn" as provider
41
+ use "./lib/tools.syn" as tools
42
+ use "./lib/prompt.syn" as prompt
43
+ use "./lib/loop.syn" as loop
44
+ use "./lib/session.syn" as session
45
+ use "./lib/agents.syn" as agents
46
+ use "./lib/tree.syn" as tree
47
+ use "./lib/git.syn" as git
48
+ use "./lib/tools/proc.syn" as proc
49
+ use "./lib/tools/memo.syn" as memo
50
+ use "./lib/update.syn" as update
51
+ use "./lib/settings.syn" as settings
52
+ use "./lib/trace.syn" as trace
53
+ use "./lib/mcp.syn" as mcp
54
+ use "./lib/lamps.syn" as lamps
55
+ use "./lib/lsp.syn" as lsp
56
+ use "./lib/tools/todo.syn" as todo
57
+
58
+ let APPROVAL_TIMEOUT be 180
59
+
60
+ -- Quién puede usar la API (chat con tools, terminal, matar procesos): `serve` escucha en 0.0.0.0, así que
61
+ -- sin esto cualquiera en la LAN tendría una shell en tu proyecto. Política: SOLO loopback. Opt-in para
62
+ -- clientes remotos: LAMPSON_WEB_TOKEN en .env + header `Authorization: Bearer <token>` (la UI web no lo
63
+ -- manda; es para scripts). `auth with` corre ANTES del upgrade WebSocket, así que cubre /api/term.
64
+ task check_client(token, request)
65
+ let ip be text(ip of request)
66
+ when ip == "127.0.0.1" or ip == "::1" or ip == "::ffff:127.0.0.1"
67
+ give {"local": true}
68
+ let want be env("LAMPSON_WEB_TOKEN", "")
69
+ when want != "" and token == want
70
+ give {"local": false}
71
+ give nothing
72
+
73
+ -- salida del pty como texto (la doc de 0.6.8 ya lo dice así; is_bytes por si cambia)
74
+ task as_text(x)
75
+ when is_bytes(x)
76
+ give decode(x, "utf8_lossy")
77
+ give x
78
+
79
+ -- shell para el terminal web: [cmd, args]. Windows: pwsh si está, si no PowerShell 5; unix: bash de login.
80
+ -- En pwsh, los directorios de `ls` salen sin fondo azul (el default de $PSStyle se lee mal en un tema oscuro).
81
+ task term_shell()
82
+ when env("OS", "") == "Windows_NT"
83
+ try
84
+ run("pwsh", ["-NoLogo", "-Command", "exit"], 10)
85
+ give ["pwsh", ["-NoLogo", "-NoExit", "-Command", "$PSStyle.FileInfo.Directory = $PSStyle.Foreground.BrightBlue"]]
86
+ recover err
87
+ give ["powershell", ["-NoLogo"]]
88
+ give ["bash", ["-l"]]
89
+
90
+ -- cwd del terminal: la ruta REAL del proyecto (LAMPSON_WORKSPACE), no la junction ./workspace —
91
+ -- así el prompt muestra dónde estás de verdad y coincide con el header.
92
+ task term_cwd()
93
+ give env("LAMPSON_WORKSPACE", "workspace")
94
+
95
+ task boot(profile, mode, ask_fn, pname, model)
96
+ let cfg be provider.config_for(pname, model)
97
+ let p be when contains(agents.PROFILES, profile) then profile otherwise "build"
98
+ let opts be loop.default_opts(agents.registry_for(p), agents.catalog_for(p), ask_fn)
99
+ set opts to loop.with_steps(opts, agents.steps_for(p))
100
+ set opts to loop.with_inbox(opts, agents.parent_inbox, "")
101
+ set opts to loop.with_mode(opts, mode)
102
+ give {"cfg": cfg, "system": {"role": "system", "content": prompt.build(agents.env_info(cfg), agents.addendum_for(p))}, "opts": opts, "profile": p}
103
+
104
+ -- Subagente en background: `agent Child` (lib/agents.syn) solo puede llamar tasks TOP-LEVEL de este
105
+ -- programa; esta es su puerta de entrada. Bajo serve el agente sobrevive al request que lo lanzó.
106
+ task lampson_subagent(spec_json)
107
+ give agents.run_child_json(spec_json)
108
+
109
+ -- servers MCP: supervisores (agentes) lanzados al arrancar; los handlers leen su estado del blackboard
110
+ mcp.start_all(8)
111
+
112
+ -- marca de corrida (blackboard): session.save la estampa en meta.run; reanudar una sesión guardada por
113
+ -- OTRA corrida dispara la nota de procesos muertos (proc.resume_note) para que el modelo no los crea vivos
114
+ share {"id": text(now())} as "lampson:run"
115
+ share {"kind": "web"} as "lampson:ui"
116
+
117
+ serve on 8080
118
+ max_streams 50
119
+ auth with check_client
120
+ static "./public"
121
+
122
+ route "GET /api/sessions" requires auth
123
+ give {"sessions": session.list()}
124
+
125
+ route "GET /api/sessions/:id" requires auth
126
+ when not session.exists(params.id)
127
+ give not_found("session not found")
128
+ let doc be session.load(params.id)
129
+ let visible be where(doc["messages"], (m) => m["role"] != "system")
130
+ give {"id": doc["id"], "updated": doc["updated"], "meta": doc["meta"], "messages": visible}
131
+
132
+ route "GET /api/config" requires auth
133
+ let cfg be provider.config()
134
+ give {"provider": cfg["provider"], "model": cfg["model"], "wire": cfg["wire"], "vision": provider.supports_vision(cfg), "providers": provider.providers(), "configured": provider.configured(), "permission": lower(env("LAMPSON_PERMISSION", "ask")), "agents": agents.names(), "workspace": env("LAMPSON_WORKSPACE", "./workspace"), "profiles": agents.PROFILES}
135
+
136
+ -- proveedor/modelo por defecto y API keys → .lampson/config.json (local). La key nunca vuelve al cliente.
137
+ route "POST /api/settings" requires auth
138
+ expect body {provider: text}
139
+ let b be json of request
140
+ let name be lower(text(b["provider"]))
141
+ when not contains(provider.PRESETS, name)
142
+ give fail(400, "unknown provider " + name)
143
+ when contains(b, "key")
144
+ settings.set_key(name, text(b["key"]))
145
+ let model be when contains(b, "model") then trim(text(b["model"])) otherwise ""
146
+ settings.set_default(name, model)
147
+ let ncfg be provider.config_for(name, "")
148
+ give {"ok": true, "providers": provider.providers(), "provider": name, "model": ncfg["model"], "vision": provider.supports_vision(ncfg), "configured": provider.configured()}
149
+
150
+ route "POST /api/sessions/delete" requires auth
151
+ expect body {id: text}
152
+ let b be json of request
153
+ give {"result": session.delete(text(b["id"]))}
154
+
155
+ route "GET /api/tree" requires auth
156
+ give tree.tree(6)
157
+
158
+ -- Terminal real en el navegador: un shell dentro de un pseudo-terminal (pty) por conexión WebSocket.
159
+ -- Navegador → servidor: texto JSON {type: "in", data: teclas} | {type: "resize", cols, rows}.
160
+ -- Servidor → navegador: frames de texto con prefijo: "o" + salida cruda del pty (ANSI incluido, xterm.js
161
+ -- la dibuja) | "c" + JSON de control ({type: hello|exit}).
162
+ -- El shell vive lo que vive el socket: cerrar el panel lo mata (el runtime no deja huérfanos).
163
+ route "GET /api/term" requires auth
164
+ socket
165
+ let sh be term_shell()
166
+ let p be proc_spawn(sh[0], sh[1], {"cwd": term_cwd(), "pty": true, "cols": 120, "rows": 32})
167
+ ws_send(socket, "c" + json_encode({"type": "hello", "shell": sh[0], "pid": proc_stats(p)["pid"], "cwd": term_cwd()}))
168
+ let open be true
169
+ while open
170
+ let ev be select({"ui": socket, "sh": p}, 600)
171
+ when ev == nothing
172
+ set open to proc_status(p) == "running"
173
+ otherwise when ev["name"] == "ui"
174
+ when ev["type"] == "close"
175
+ set open to false
176
+ otherwise when ev["type"] == "binary"
177
+ proc_send(p, ev["data"])
178
+ otherwise
179
+ let m be json_decode(ev["data"])
180
+ when m["type"] == "in"
181
+ proc_send(p, m["data"])
182
+ otherwise when m["type"] == "resize"
183
+ proc_resize(p, floor(m["cols"]), floor(m["rows"]))
184
+ otherwise when ev["type"] == "exit"
185
+ ws_send(socket, "c" + json_encode({"type": "exit", "code": ev["data"]["exit_code"]}))
186
+ set open to false
187
+ otherwise
188
+ ws_send(socket, "o" + as_text(ev["data"]))
189
+ proc_close(p)
190
+
191
+ route "GET /api/update" requires auth
192
+ give update.check()
193
+
194
+ route "POST /api/update" requires auth
195
+ give {"result": update.apply()}
196
+
197
+ route "GET /api/git" requires auth
198
+ give git.status()
199
+
200
+ route "GET /api/memory" requires auth
201
+ give {"dir": memo.dir(), "notes": memo.list()}
202
+
203
+ route "GET /api/memory/note" requires auth
204
+ when not contains(query, "name")
205
+ give fail(400, "name required")
206
+ give {"name": query.name, "content": memo.note_read(query.name)}
207
+
208
+ -- traza legible de una sesión (ver lib/trace.syn)
209
+ route "GET /api/trace" requires auth
210
+ when not contains(query, "session")
211
+ give fail(400, "session required")
212
+ when not matches(query.session, "[A-Za-z0-9_-]{1,40}")
213
+ give fail(400, "bad session id")
214
+ let n be when contains(query, "tail") then floor(number(query.tail)) otherwise 400
215
+ give {"session": query.session, "file": trace.file_of(query.session), "trace": trace.tail(query.session, n)}
216
+
217
+ -- modelos que declara la API del proveedor (para no tipear nombres a ciegas: DeepSeek es case-sensitive)
218
+ route "GET /api/models" requires auth
219
+ let pname be when contains(query, "provider") then lower(text(query.provider)) otherwise ""
220
+ let cfg be provider.config_for(pname, "")
221
+ give provider.list_models(cfg)
222
+
223
+ route "GET /api/todo" requires auth
224
+ -- ?session=<id> → la lista de esa sesión; sin query, la de la sesión en curso
225
+ when contains(query, "session") and matches(query.session, "[A-Za-z0-9_-]{1,40}")
226
+ give {"items": todo.load(query.session)}
227
+ give {"items": todo.load(nothing)}
228
+
229
+ route "GET /api/mcp" requires auth
230
+ give {"servers": mcp.summary(), "global": mcp.GLOBAL_CONFIG, "project": mcp.PROJECT_CONFIG}
231
+
232
+ -- conectar un server MCP desde la UI: se escribe en el mcp.json del scope y arranca su supervisor
233
+ route "POST /api/mcp/add" requires auth
234
+ expect body {name: text, command: text}
235
+ let b be json of request
236
+ let scope be when contains(b, "scope") then lower(text(b["scope"])) otherwise "global"
237
+ let envm be when contains(b, "env") then b["env"] otherwise {}
238
+ try
239
+ give {"ok": true, "result": mcp.add_server(text(b["name"]), text(b["command"]), envm, scope), "servers": mcp.summary()}
240
+ recover err
241
+ give fail(400, text(err))
242
+
243
+ route "POST /api/mcp/remove" requires auth
244
+ expect body {name: text}
245
+ let b be json of request
246
+ try
247
+ give {"ok": true, "result": mcp.remove_server(text(b["name"])), "servers": mcp.summary()}
248
+ recover err
249
+ give fail(400, text(err))
250
+
251
+ -- language servers (navegación semántica): listar, agregar (preset o comando propio), quitar
252
+ route "GET /api/lsp" requires auth
253
+ give {"servers": lsp.summary(), "presets": lsp.PRESETS, "global": lsp.GLOBAL_CONFIG, "project": lsp.PROJECT_CONFIG}
254
+
255
+ route "POST /api/lsp/add" requires auth
256
+ expect body {name: text}
257
+ let b be json of request
258
+ let scope be when contains(b, "scope") then lower(text(b["scope"])) otherwise "global"
259
+ let cmd be when contains(b, "command") then text(b["command"]) otherwise nothing
260
+ let langs be when contains(b, "languages") then b["languages"] otherwise nothing
261
+ try
262
+ give {"ok": true, "result": lsp.add_server(text(b["name"]), cmd, langs, scope), "servers": lsp.summary()}
263
+ recover err
264
+ give fail(400, text(err))
265
+
266
+ route "POST /api/lsp/remove" requires auth
267
+ expect body {name: text}
268
+ let b be json of request
269
+ try
270
+ give {"ok": true, "result": lsp.remove_server(text(b["name"])), "servers": lsp.summary()}
271
+ recover err
272
+ give fail(400, text(err))
273
+
274
+ -- lámparas (plugins de tools): listar y encender/apagar desde la barra superior de la UI
275
+ route "GET /api/lamps" requires auth
276
+ give {"lamps": lamps.summary(), "global": lamps.GLOBAL_DIR, "project": lamps.PROJECT_DIR}
277
+
278
+ -- el usuario corre una tool de una lámpara encendida él mismo (sin pasar por el modelo)
279
+ route "POST /api/lamps/run" requires auth
280
+ expect body {tool: text}
281
+ let b be json of request
282
+ let args be when contains(b, "args") then b["args"] otherwise {}
283
+ try
284
+ give {"ok": true, "output": lamps.call(text(b["tool"]), args)}
285
+ recover err
286
+ give fail(400, text(err))
287
+
288
+ route "POST /api/lamps/remove" requires auth
289
+ expect body {name: text}
290
+ let b be json of request
291
+ try
292
+ give {"ok": true, "result": lamps.remove(text(b["name"])), "lamps": lamps.summary()}
293
+ recover err
294
+ give fail(400, text(err))
295
+
296
+ route "POST /api/lamps/toggle" requires auth
297
+ expect body {name: text}
298
+ let b be json of request
299
+ let on be contains(b, "enabled") and b["enabled"] == true
300
+ try
301
+ give {"ok": true, "result": lamps.set_enabled(text(b["name"]), on), "lamps": lamps.summary()}
302
+ recover err
303
+ give fail(400, text(err))
304
+
305
+ route "GET /api/agents" requires auth
306
+ agents.prune(10, 1800)
307
+ give {"agents": agents.list_children()}
308
+
309
+ -- Eventos en vivo para la UI (SSE): lo que publican los supervisores de procesos (proc.<name>: líneas y
310
+ -- cambios de estado) y los subagentes (subagent.started|done|<id>). Un bus por programa; cada cliente
311
+ -- tiene su cola acotada (drop_oldest). Heartbeat cada 25 s para que el proxy/navegador no cierre.
312
+ route "GET /api/events" requires auth
313
+ stream
314
+ let sub be bus_subscribe(["proc.*", "subagent.*", "mcp.*"], {"max_queue": 512})
315
+ let open be true
316
+ while open
317
+ let ev be bus_recv(sub, 25)
318
+ try
319
+ when ev == nothing
320
+ send {"topic": "ping"} as "event"
321
+ otherwise
322
+ send {"topic": ev["topic"], "data": ev["data"]} as "event"
323
+ recover err
324
+ set open to false
325
+ bus_unsubscribe(sub)
326
+
327
+ route "GET /api/agents/log" requires auth
328
+ when not contains(query, "id")
329
+ give fail(400, "id required")
330
+ when not matches(query.id, "[a-z]+-[0-9]+")
331
+ give fail(400, "bad id")
332
+ try
333
+ let meta be nothing
334
+ try
335
+ set meta to json_decode(read_file(agents.DIR + "/" + query.id + ".json"))
336
+ recover e2
337
+ set meta to nothing
338
+ give {"id": query.id, "log": read_file(agents.DIR + "/" + query.id + ".log"), "agent": meta}
339
+ recover err
340
+ give fail(404, "no such sub-agent")
341
+
342
+ route "POST /api/agents/stop" requires auth
343
+ expect body {id: text}
344
+ let b be json of request
345
+ when not matches(b["id"], "[a-z]+-[0-9]+")
346
+ give fail(400, "bad id")
347
+ write_file(agents.DIR + "/" + b["id"] + ".stop", "stop")
348
+ bus_publish("subagent." + b["id"], {"kind": "stop", "line": "stop requested from the UI"})
349
+ give {"result": "stop requested for " + b["id"]}
350
+
351
+ route "GET /api/proc" requires auth
352
+ give {"processes": proc.list()}
353
+
354
+ route "GET /api/proc/log" requires auth
355
+ when not contains(query, "name")
356
+ give fail(400, "name required")
357
+ let n be when contains(query, "tail") then number(query.tail) otherwise 200
358
+ give {"name": query.name, "running": proc.alive(query.name), "log": proc.logs(query.name, n)}
359
+
360
+ route "GET /api/ports" requires auth
361
+ give {"ports": proc.listeners()}
362
+
363
+ route "POST /api/ports/kill" requires auth
364
+ expect body {pid: number}
365
+ let b be json of request
366
+ give {"result": proc.kill_pid(b["pid"])}
367
+
368
+ route "POST /api/proc/stop" requires auth
369
+ expect body {name: text}
370
+ let b be json of request
371
+ give {"result": proc.halt(b["name"])}
372
+
373
+ route "GET /api/file" requires auth
374
+ when not contains(query, "path")
375
+ give fail(400, "path required")
376
+ give tree.file_content(query.path)
377
+
378
+ route "POST /api/approve" requires auth
379
+ expect body {id: text, decision: bool}
380
+ let b be json of request
381
+ state_set("approval:" + b["id"], when b["decision"] then "yes" otherwise "no")
382
+ give {"ok": true}
383
+
384
+ route "POST /api/chat" requires auth
385
+ expect body {message: text}
386
+ let b be json of request
387
+ let message be b["message"]
388
+ let sid be when contains(b, "session") then text(b["session"]) otherwise ""
389
+ let want be when contains(b, "agent") then lower(text(b["agent"])) otherwise "build"
390
+ let mode be when contains(b, "permission") then lower(text(b["permission"])) otherwise lower(env("LAMPSON_PERMISSION", "ask"))
391
+ when mode != "ask" and mode != "yolo" and mode != "strict"
392
+ set mode to "ask"
393
+ let pname be when contains(b, "provider") then lower(text(b["provider"])) otherwise ""
394
+ let pmodel be when contains(b, "model") then text(b["model"]) otherwise ""
395
+ stream
396
+ task emit(kind, data, tag)
397
+ trace.event(sid, kind, data, tag)
398
+ send {"kind": kind, "data": data, "tag": tag} as "event"
399
+ -- aprobación humana desde la UI: pregunta por SSE y espera la respuesta (o el timeout)
400
+ task ask_web(name, args, why)
401
+ let id be text(floor(now() * 1000)) + "-" + name
402
+ send {"kind": "approval_request", "data": {"id": id, "name": name, "args": args, "why": why, "timeout": APPROVAL_TIMEOUT}} as "event"
403
+ let waited be 0
404
+ let answer be nothing
405
+ while answer == nothing and waited < APPROVAL_TIMEOUT
406
+ set answer to state_get("approval:" + id, nothing)
407
+ when answer == nothing
408
+ sleep(0.5)
409
+ set waited to waited + 0.5
410
+ state_delete("approval:" + id)
411
+ let ok be answer == "yes"
412
+ send {"kind": "approval_result", "data": {"id": id, "approved": ok, "timeout": answer == nothing}} as "event"
413
+ give ok
414
+ let ctx be boot(want, mode, ask_web, pname, pmodel)
415
+ let messages be [ctx["system"]]
416
+ when sid != ""
417
+ when session.exists(sid)
418
+ let prev_doc be session.load(sid)
419
+ let prev be prev_doc["messages"]
420
+ set messages to [ctx["system"]] + slice(prev, 1, length(prev))
421
+ -- sesión guardada por otra corrida de lampson: sus procesos ya no viven — decírselo
422
+ -- al modelo con el estado real (una sola vez: el próximo save re-estampa meta.run)
423
+ when session.from_other_run(prev_doc)
424
+ let note be proc.resume_note(sid)
425
+ when note != ""
426
+ set messages to append(messages, {"role": "user", "content": note})
427
+ trace.event(sid, "inbox", note, "")
428
+ send {"kind": "inbox", "data": note, "tag": ""} as "event"
429
+ otherwise
430
+ set sid to ""
431
+ when sid == ""
432
+ set sid to session.new_id()
433
+ -- sesión actual (blackboard): la tool todo guarda su lista por sesión (lib/tools/todo.syn)
434
+ share {"id": sid} as "lampson:session"
435
+ send {"kind": "session", "data": sid} as "event"
436
+ send {"kind": "mode", "data": {"agent": ctx["profile"], "permission": mode, "provider": ctx["cfg"]["provider"], "model": ctx["cfg"]["model"], "has_key": ctx["cfg"]["has_key"]}} as "event"
437
+ when starts_with(trim(message), "!")
438
+ -- comando del USUARIO: sin política de permisos, salida al contexto; el modelo no interviene
439
+ let cmd be trim(slice(trim(message), 1, length(trim(message))))
440
+ let bash_tool be agents.registry_for("build")["bash"]
441
+ send {"kind": "tool_call", "data": {"id": "user", "name": "bash", "args": {"command": cmd}}} as "event"
442
+ let out be call_tool(bash_tool, {"command": cmd, "timeout": 600})
443
+ send {"kind": "tool_result", "data": {"call": {"id": "user", "name": "bash", "args": {"command": cmd}}, "output": out}} as "event"
444
+ set messages to append(messages, {"role": "user", "content": "[The user ran this command in the shell themselves]\n$ " + cmd + "\n" + tools.truncate(out, 6000)})
445
+ session.save(sid, messages, {"title": session.title_of(messages)})
446
+ send {"kind": "done", "data": {"text": "", "steps": 0, "usage": {"input": 0, "output": 0}, "stopped": "user_command", "session": sid}} as "event"
447
+ otherwise
448
+ -- imágenes pegadas en la UI: [{media_type, data(base64)}] — el cliente ya las redujo; tope 5 MB por mensaje
449
+ let images be []
450
+ when contains(b, "images")
451
+ let total be 0
452
+ each im in b["images"]
453
+ when contains(im, "data") and contains(im, "media_type")
454
+ set total to total + length(im["data"])
455
+ when total <= 5000000
456
+ let entry be {"media_type": text(im["media_type"]), "data": text(im["data"])}
457
+ when contains(im, "w") and contains(im, "h")
458
+ set entry to {"media_type": text(im["media_type"]), "data": text(im["data"]), "w": floor(number(im["w"])), "h": floor(number(im["h"]))}
459
+ set images to append(images, entry)
460
+ let umsg be {"role": "user", "content": message}
461
+ when length(images) > 0
462
+ set umsg to {"role": "user", "content": message, "images": images}
463
+ set messages to append(messages, umsg)
464
+ trace.user(sid, message, ctx["profile"], mode, ctx["cfg"]["model"])
465
+ let result be loop.run_turn(ctx["cfg"], messages, ctx["opts"], emit)
466
+ trace.turn_end(sid, result)
467
+ session.save(sid, result["messages"], {"title": session.title_of(result["messages"])})
468
+ send {"kind": "done", "data": {"text": result["text"], "steps": result["steps"], "usage": result["usage"], "stopped": result["stopped"], "session": sid}} as "event"