Servidor HTTP
Apps agénticas
Una app que orquesta agentes frente a una UI en vivo necesita cinco cosas que un servidor REST común no tiene: un canal bidireccional al navegador, un proceso hijo que podés mirar mientras corre, una forma de que un evento llegue a todos los clientes abiertos, una sola espera sobre todo eso, y un servidor que vence, cancela y se apaga sin dejar nada atrás. Desde el engine v0.6.7 las cinco están en el lenguaje, con las mismas reglas que todo lo demás: deny-by-default, memoria acotada, errores que se dicen.
No es sólo para agentes. Las mismas cinco primitivas son lo que necesitan un chat, un minijuego multijugador, un editor colaborativo, un dashboard en vivo, un bot de build o una terminal en el navegador: un socket por cliente, un bus por sala o tema (bus_subscribe("room." + id)), un proceso que mirás, un select por conexión y un servidor que limpia lo suyo. "Agéntica" es donde se encontró el hueco; la página aplica a cualquier programa con estado vivo compartido entre clientes.
-- Doc example: the event bus (`bus_*`) and the unified `select` — the plumbing of an
-- agentic app. N subscribers receive the SAME event (fan-out — unlike signal/wait_for,
-- which one receiver consumes), queues are bounded, topics accept globs, and ONE wait
-- covers any mix of handles. Pure: no capability (same trust level as share/signal).
-- Incoming sockets (`route … socket`) and live processes (`proc_*`) need a running
-- server / an `exec` grant, so the page prose covers those with the shapes that run.
-- The watch test writes into ./_doctest_watch and deletes it at the end.
intent: "doc example: event bus + select"
require file("_doctest_watch") -- watch() reads the tree it watches
require file("_doctest_watch/*")
require file.write("_doctest_watch")
require file.write("_doctest_watch/*")
require stdin -- term_open()
let wide be bus_subscribe("agent.*")
let exact be bus_subscribe(["agent.done", "ui.*"])
let other be bus_subscribe("other")
test "publish fans out to every matching subscriber and says how many got it"
assert_eq(bus_publish("agent.done", {"step": 1}), 2)
let ev be bus_recv(wide, 1)
assert_eq(ev["type"], "event")
assert_eq(ev["topic"], "agent.done")
assert_eq(ev["data"]["step"], 1)
assert_eq(bus_recv(exact, 1)["topic"], "agent.done")
assert_eq(bus_recv(other, 0.1), nothing) -- no match → nothing at the timeout
test "select waits on any handles at once and tags the winner"
bus_publish("ui.click", "go")
let ev be select({"wide": wide, "exact": exact}, 1)
assert_eq(ev["name"], "exact")
assert_eq(ev["source"], "bus")
assert_eq(ev["topic"], "ui.click")
assert_eq(ev["data"], "go")
assert_eq(select([wide, other], 0.1), nothing) -- nothing ready → nothing
task publish_glob()
give bus_publish("agent.*", 1) -- globs belong to bus_subscribe
task publish_task()
give bus_publish("agent.done", publish_glob) -- a task is not data
test "a published topic is literal and the payload must be data (loud, not degraded)"
assert_error(publish_glob)
assert_error(publish_task)
test "queues are bounded: drop_oldest keeps the newest — the publisher never blocks"
let small be bus_subscribe("q", {"max_queue": 2})
bus_publish("q", 1)
bus_publish("q", 2)
bus_publish("q", 3)
assert_eq(bus_recv(small, 1)["data"], 2)
assert_eq(bus_recv(small, 1)["data"], 3)
bus_unsubscribe(small)
test "bus_topics lists live subscriptions by pattern"
assert(some(bus_topics(), (t) => t["topic"] == "agent.*"))
test "on_full \"error\": the next recv raises and the subscription is gone"
let strict be bus_subscribe("strict", {"max_queue": 1, "on_full": "error"})
bus_publish("strict", 1)
bus_publish("strict", 2) -- the queue overflows
assert_error(() => bus_recv(strict, 0.1))
assert_error(() => bus_recv(strict, 0.1)) -- unknown or closed subscription
test "max_queue_bytes bounds the queue by size (drop_oldest by default)"
let tiny be bus_subscribe("bytes", {"max_queue_bytes": 10})
bus_publish("bytes", "aaaaaaaa")
bus_publish("bytes", "bbbbbbbb") -- both do not fit: the oldest goes
assert_eq(bus_recv(tiny, 0.1)["data"], "bbbbbbbb")
assert_eq(bus_recv(tiny, 0.1), nothing)
bus_unsubscribe(tiny)
assert(not some(bus_topics(), (t) => t["topic"] == "bytes"))
test "select takes handles, never nothing — build the map with the ones that exist"
assert_error(() => select({"files": wide, "build": nothing}, 0.1))
let targets be {"files": wide}
assert_eq(select(targets, 0.1), nothing)
-- The message of an error, or nothing when the call did not fail.
task try_message(f)
try
f()
recover e
give e
give nothing
test "run and proc_spawn with cwd: a relative command is resolved against that folder (v0.6.46+)"
-- the grant must name the path inside cwd; checked before anything starts
assert(contains(try_message(() => run("./tool.bat", [], 5, {"cwd": "sub"})), "exec(\"./sub/tool.bat\")"))
assert(contains(try_message(() => proc_spawn("./tool.bat", [], {"cwd": "sub"})), "exec(\"./sub/tool.bat\")"))
test "watch: create/delete events, ignore, stats, close"
write_file("_doctest_watch/seed.txt", "0")
let w be watch("_doctest_watch", {"interval": 0.05, "ignore": ["*.tmp"], "recursive": false, "max_entries": 100, "max_queue": 16})
write_file("_doctest_watch/a.txt", "1")
write_file("_doctest_watch/b.tmp", "1") -- ignored by name
let ev be watch_recv(w, 2)
assert_eq(ev["type"], "create")
assert_eq(ev["path"], "_doctest_watch/a.txt")
assert_eq(ev["is_dir"], false)
assert_eq(ev["source"], "watch")
assert_eq(watch_recv(w, 0.3), nothing) -- nothing for b.tmp
delete_file("_doctest_watch/a.txt")
assert_eq(watch_recv(w, 2)["type"], "delete")
let st be watch_stats(w)
assert_eq(st["recursive"], false)
assert_eq(st["interval"], 0.05)
assert_eq(sort(keys(st)), ["dropped", "entries", "interval", "path", "queued", "recursive", "scans"])
watch_close(w)
watch_close(w) -- idempotent
-- v0.6.46+: a recursive watch (the default) reads the whole tree, so it needs "<folder>/**";
-- this file grants the folder and its first level only
let e be try_message(() => watch("_doctest_watch"))
assert(contains(e, "file_read(\"_doctest_watch/**\")"))
delete_dir("_doctest_watch", {"recursive": true})
test "term_open() is nothing without a terminal (pipes, CI, serve, synsema test)"
assert_eq(term_open(), nothing) -- fall back to read_line
test "a pipe carries bytes both ways; close is sticky and half close still lets the other side answer (v0.6.42+)"
let p be pipe()
pipe_send(p["a"], "hola")
let m be pipe_recv(p["b"], 1)
assert_eq(m["type"], "data")
assert_eq(decode(m["data"]), "hola")
pipe_close(p["a"], "write") -- a stops sending
assert_eq(pipe_recv(p["b"], 1)["type"], "close")
assert_eq(pipe_recv(p["b"], 1)["type"], "close") -- sticky: it says close again at once
pipe_send(p["b"], "respuesta") -- b can still answer
let ev be select({"x": p["a"]}, 1)
assert_eq(ev["source"], "pipe")
assert_eq(ev["name"], "x")
assert_eq(decode(ev["data"]), "respuesta")
test "pipe_write takes what fits right now and never waits; pipe_stats shows the queues (v0.6.45+)"
let p be pipe({"max_buffer": 4})
assert_eq(pipe_write(p["a"], "abcdef"), 4) -- took 4 bytes: credit those, keep "ef"
assert_eq(pipe_write(p["a"], "ef"), 0) -- full right now: 0, no timeout error
assert_eq(pipe_stats(p["a"]), {"queued_in": 0, "queued_out": 4, "status": "open"})
assert_eq(pipe_stats(p["b"])["queued_in"], 4)
assert_eq(decode(pipe_recv(p["b"], 1)["data"]), "abcd")
assert_eq(pipe_write(p["a"], "ef"), 2) -- room againRutas WebSocket — socket§
Una ruta con un bloque socket acepta un WebSocket entrante. Adentro, socket es el handle de esta conexión — el mismo tipo de handle que devuelve ws_connect, así que toda la familia ws_* funciona sin cambios:
require serve(8080)
serve on 8080
route "GET /ws" -- the handshake is a GET (RFC 6455)
socket
ws_send(socket, {"hello": "agent"})
while true
let ev be ws_recv(socket, 30) -- {type: "text"|"binary"|"close", data}
when ev == nothing -- 30 s idle
stop
when ev["type"] == "close"
stop
otherwise
ws_send(socket, {"echo": ev["data"]})
route "GET /private" requires auth -- auth runs BEFORE the upgrade: 401, no upgrade
socket
ws_send(socket, "hello " + request.user.name)
request,params,query,headersyuserestán ligados como en cualquier ruta.ws_stats(socket)["role"]es"server".- El cierre es honesto. Termina el bloque o
stop→Close 1000. Error no atrapado →Close 1011+ el mensaje (≤ 123 bytes) y una línea[socket] … handler faileden el log. El server cancelando el handler (timeoutde la ruta, shutdown) →Close 1001(going away) + el motivo. Un cliente que desaparece sin handshake de cierre llega como{type: "close", data: "connection reset without closing handshake"}en el próximows_recv/select. - Un request HTTP común a una ruta socket →
426 Upgrade Required(body JSON, headerUpgrade: websocket). Los clientes HTTP/2 reciben el mismo 426; los navegadores siempre abren WebSockets por HTTP/1.1. - El keepalive es del server: responde pings y manda los suyos cada
SYNSEMA_WS_SERVER_PINGsegundos (default 30) mientras el handler espera; sin pong en dos intervalos → unclosecon motivokeepalive timeout. Un mensaje mayor aSYNSEMA_WS_MAX_MESSAGE(16MB, techo 64MB) es un error atrapable. El backpressure es real en las dos direcciones (canales acotados; la ventana TCP frena al lado lento). - Presupuesto: un socket ocupa un slot de
max_streams(503+Retry-Afteral agotar) y cuenta contraSYNSEMA_WS_MAX_CONNS. Sin capability nueva — la ruta ya está dentro deserve. - Reglas: sólo
GET,socketystreamen la misma ruta es error de parseo. Una rutasockettambién puede vivir en un grupoexport routesy montarse (v0.6.30: la ruta montada responde426a HTTP plano y hace el upgrade igual que una directa). Fuera de una rutasocketes un nombre común. - Un handle jamás cruza requests. Para empujar a N sockets abiertos desde un cron, un agente u otro request, cada handler de socket se suscribe al bus y reenvía — abajo. A propósito no hay una tabla global de sockets.
Procesos vivos — proc_*§
run es one-shot: captura todo y devuelve al final. Un agente que corre cargo test, npm run build o un worker largo necesita ver la salida a medida que sale, mandar stdin y matarlo — un proceso como handle con eventos. Mismo gate que run (require exec(cmd), scope = el comando tal como se escribe), args como lista, nunca un shell. Pipes por defecto (una herramienta que detecta "no es una tty" se comporta como en CI); "pty": true le da una terminal real — ver Pseudo-terminal abajo.
require exec("cargo")
let p be proc_spawn("cargo", ["test"], {"cwd": "./repo"})
while true
let ev be proc_recv(p, 60) -- {type: "stdout"|"stderr"|"exit", data} or nothing
when ev == nothing
proc_kill(p) -- silent for 60 s: TERM ("KILL" to insist)
otherwise when ev["type"] == "exit"
print("exit " + text(ev["data"]["exit_code"]))
stop
otherwise
print(ev["type"] + ": " + ev["data"]) -- one event per line, no trailing newline
proc_close(p)
| Builtin | Devuelve |
|---|---|
proc_spawn(cmd, args?, opts?) | handle |
proc_recv(h, timeout?) | próximo evento, o nothing al vencer |
proc_select(lista | mapa, timeout?) | primer evento listo entre procesos (select acepta cualquier handle) |
proc_send(h, texto | bytes) | true; error si stdin está cerrado. Es una escritura bloqueante de pipe: un hijo que nunca lee stdin te bloquea |
proc_close_stdin(h) | true — EOF al hijo. Sólo pipes: un pty no tiene un stdin aparte — mandá la tecla de EOF (bytes([4]) Ctrl-D, bytes([26, 13]) Ctrl-Z + Enter) |
proc_resize(h, cols, rows) | true — sólo pty (SIGWINCH / ResizePseudoConsole); error en un proceso por pipes |
proc_status(h) | "running" · "exited" · "killed" · "closed" |
proc_kill(h, señal?) | "TERM" (default; SIGTERM en Unix) o "KILL"; en Windows ambas terminan. Alcanza al árbol de procesos entero (v0.6.9+, ver Ciclo de vida) |
proc_wait(h, timeout?) | {exit_code, signal} o nothing |
proc_stats(h) | {pid, cmd, status, exit_code, pty, tree, queued, queued_bytes, dropped, uptime} — tree: el kill alcanza a los nietos |
proc_close(h) | libera el handle; mata si sigue vivo (TERM, KILL a los 2 s, luego wait) — el árbol entero. Sin huérfanos. Idempotente |
exit llega una vez, después de drenar los dos pipes, como {exit_code, signal} (exit_code -1 si murió por señal; signal es nothing en una salida normal y siempre en Windows). opts: cwd, env (hereda + sobreescribe; un valor secret es error — reveal() explícito), env_clean (engine v0.6.45+, también en run: true arranca al hijo sin entorno del padre, solo env — más SystemRoot en Windows, sin la cual no arrancan el runtime de C ni los sockets; el comando en sí se sigue buscando en el PATH del padre, así que pasá PATH en env si el hijo arranca otros programas. Sin eso el hijo hereda el entorno menos los secretos de Synsema — y desde v0.6.45 eso vale también para pty: true), line_mode (true; false = chunks crudos de texto ≤ 64 KiB — data siempre es texto, UTF-8 lossy, nunca partido dentro de un carácter), stderr ("separate" | "merge"), pty (+ cols, rows, term — abajo), process_group (true: grupo de procesos / Job Object propio para que el kill alcance al árbol; false desprende a propósito un daemon que debe sobrevivir a proc_close — entonces sólo muere el hijo directo), y la cola acotada max_queue (4096) / max_queue_bytes (64 MiB) con on_full = "block" (default: el lector deja de drenar, el hijo se frena en su write — backpressure real, sin pérdida) | "drop_oldest" | "error" (el próximo recv falla, el proceso se mata).
cwd y un comando relativo (motor v0.6.46+, breaking). Con cwd, un comando con separador de ruta (./tool, bin/tool) se resuelve contra esa carpeta, como haría cd sub && ./tool, y esa es la ruta que tiene que nombrar el permiso: run("./tool.bat", [], 60, {"cwd": "sub"}) y proc_spawn("./tool.bat", [], {"cwd": "sub"}) piden exec("./sub/tool.bat") y corren sub/tool.bat. Antes de v0.6.46 pedían exec("./tool.bat") y qué archivo corría dependía de la plataforma (el de sub en Unix, el de al lado del programa en Windows), así que el permiso no decía qué archivo habilitaba. Un comando sin separador (git, tool.bat) se sigue buscando en el PATH — no en cwd — y una ruta absoluta no cambia. Para migrar: escribí el permiso como lo nombra el error — Capability not granted: exec("./sub/tool.bat") … add require exec("./sub/tool.bat")` — con el ./ (exec("sub/tool.bat")` no lo cubre).
Ciclo de vida. Cuando termina el intérprete que lo lanzó — el request bajo serve, el programa, el agente — todo proceso vivo se mata. El árbol entero, no sólo el hijo (v0.6.9+): el hijo nace en su propio grupo de procesos en Unix y en un Job Object con kill-on-close en Windows, así proc_kill/proc_close sobre sh -c "npm run dev" se llevan también al node de abajo — y en Windows hasta un crash del intérprete cierra el job y el árbol con él. proc_stats(h)["tree"] te dice que está en efecto. Un handler nunca deja un fantasma; el trabajo que debe sobrevivir a un request va en cron_after o en un agente. SYNSEMA_PROC_MAX (64, techo 1024) acota los procesos vivos por intérprete. Un nieto que retiene el pipe tras el exit del hijo no te cuelga: 1 s de gracia y se entrega el exit.
Pseudo-terminal — pty: true§
Engine v0.6.8+. Muchos programas preguntan "¿estoy hablando con una terminal?" y, sobre un pipe, saltan la pregunta, asumen "no", se cuelgan o se niegan: prompts y/N que leen /dev/tty, contraseñas de ssh/sudo/gpg, menús con flechas, docker run -it, REPLs, barras de progreso y toda TUI (vim, htop, un CLI agéntico). "pty": true corre al hijo dentro de un pseudo-terminal real — openpty en Linux/macOS, ConPTY en Windows ≥ 10 1809. Misma API, mismos eventos, mismo gate exec(cmd): un pty no otorga ningún poder del SO que un pipe no dé; cambia cómo se comporta el hijo, no qué puede hacer. sandbox lo deniega como a cualquier exec.
require exec("npm")
let p be proc_spawn("npm", ["install"], {"pty": true, "cols": 120, "rows": 40})
let screen be ""
while true
let ev be proc_recv(p, 60)
when ev == nothing
proc_kill(p)
otherwise when ev["type"] == "exit"
stop
otherwise
set screen to screen + ev["data"] -- raw text chunks, ANSI included
when contains(strip_ansi(screen), "[y/N]") -- read it like a human would
proc_send(p, "y\r") -- keystrokes: Enter is \r on a tty
set screen to ""
proc_close(p)
Qué cambia en modo pty — consecuencias de "es una terminal", no API nueva:
- Un solo stream. Una tty no separa stderr: todo llega como
stdout. - Texto crudo con secuencias de escape.
line_modepasa afalsepor defecto —datasigue siendo texto, nobytes: chunks UTF-8, nunca partidos dentro de un carácter, bytes inválidos → U+FFFD. Mandáselos a xterm.js como frames de texto tal cual, ostrip_ansi(texto)para ver lo que ve un humano (colores, movimientos de cursor y títulos OSC fuera; los redibujos con\rconservan el último cuadro). El runtime nunca interpreta VT. - El eco está activo. Lo que mandás con
proc_sendvuelve por la salida. Un secret revelado que tipeás vuelve en claro — siguen valiendo las reglas de siempre (secreten args/env/send → error). - Teclas, no líneas. Enter es
"\r", Ctrl-C esbytes([3]), Ctrl-Dbytes([4]), una flecha es ESC +[A. No hayproc_close_stdin(el error dice qué tecla mandar). - Tamaño.
cols/rows(default 80×24),proc_resize(h, cols, rows)después.termfijaTERM(defaultxterm-256color;env.TERMgana). - El kill alcanza al árbol (como con pipes desde v0.6.9). En Unix el hijo es líder de sesión y se señala al grupo de procesos entero; en Windows se termina el Job Object y se cierra la pseudo-consola — ningún nieto sobrevive colgado de la tty.
- El handshake de Windows está resuelto. ConPTY emite
ESC[6nal arrancar y no entrega nada hasta que alguien contesta; el runtime contesta ese primero y lo quita del stream (un xterm.js del otro lado no lo ve, así no contesta dos veces). LosESC[6nposteriores son de la app y pasan intactos. - Windows: el nombre corre como está escrito (motor v0.6.46+). Con
pty: true, un comando con ruta que no existe tal cual es un error:proc_spawn: cannot start "…\sub/notes": no such file (with pty, the exact name is run; no PATHEXT extension is added). Antes, la capa de terminal probaba las extensiones dePATHEXT, así que un permiso paranotes.txtpodía corrernotes.bat, y una ruta absoluta sin extensión (C:/x/node) corríanode.exe. Nombrá el archivo con su extensión (./notes.bat). Un comando sin separador (notes) se sigue buscando en elPATHconPATHEXT. exit_codees-1si murió por señal; en un ptysignales el número cuando se conoce (15, 9, 1, 2), si nonothing.
Una terminal web es una ruta socket y un pty en un mismo select: bytes del navegador → proc_send, eventos del proceso → socket_send, un mensaje {resize} → proc_resize; xterm.js dibuja. Quién recibe ese socket es una decisión de auth de la app — el runtime no agrega ninguna capability porque no hay nada nuevo que gatear.
Probá primero el flag no interactivo (--yes, CI=1, DEBIAN_FRONTEND=noninteractive): más barato y determinista. El pty es para cuando no existe.
Bus de eventos — bus_*§
signal/wait_for es punto a punto (un receptor consume). Una UI en vivo necesita fan-out: N handlers — uno por cliente SSE o socket — reciben el mismo evento que publicó un agente, un cron u otro request, sin polling. Eso es el bus. Un bus por programa, visible desde todos lados: top-level, workers de parallel_map, ticks de cron, agentes spawneados y cada handler de cada worker del server. Sólo in-process (Redis con la misma API es el roadmap). Sin capability — mismo nivel de confianza que share/signal.
bus_publish("agent.progress", {"step": 3, "of": 10}) -- → number of subscribers reached
let sub be bus_subscribe("agent.*") -- a topic or a list; globs * and ?
let ev be bus_recv(sub, 25) -- {type: "event", topic, data, timestamp} or nothing
bus_unsubscribe(sub)
bus_topics() -- [{topic, subscribers}] by pattern
- Los payloads son datos (texto/número/bool/lista/mapa/bytes); una task o un
secretes error, nunca una degradación silenciosa. Los topics publicados son literales — un glob enbus_publishes error (los globs son debus_subscribe). - Acotado por suscriptor en cantidad y bytes:
bus_subscribe("t", {"max_queue": 1024, "max_queue_bytes": 16777216, "on_full": "drop_oldest"})."drop_oldest"es el default (un suscriptor lento nunca frena al publicador);"error"hace fallar el próximo recv y retira la suscripción. - Una suscripción vive lo que vive su intérprete: al terminar el handler/agente se retira — nadie llena una cola que nadie lee.
La ruta SSE que todo dashboard quiere — alimentada por el bus, con el heartbeat a cargo del server:
route "GET /events"
stream
let sub be bus_subscribe("agent.*")
while true
let ev be bus_recv(sub, 25)
when ev != nothing
send ev["data"] as ev["topic"]
File-watch — watch§
Cambios en disco como handle con eventos (engine v0.6.9+), en el mismo hub que procesos, sockets y bus — así un solo select cubre "cambió un archivo" y "terminó el build". Gate: file.read — mirar un árbol es leerlo: require file.read("<carpeta>/**") para un watch recursivo (el default), la carpeta sola con {"recursive": false}.
require file("src/**") -- src and everything below it: a recursive watch reads the whole tree
require exec("cargo")
let files be watch("src", {"interval": 0.2, "ignore": ["*.tmp"]})
let build be nothing
while true
let targets be {"files": files}
when build != nothing -- select takes handles only, never nothing
set targets["build"] to build
let ev be select(targets, 60)
when ev != nothing -- nothing = 60 s of quiet: wait again
when ev["source"] == "watch" -- {type, path, is_dir}
when build != nothing
proc_close(build) -- kills the previous build, whole tree
set build to proc_spawn("cargo", ["build"])
otherwise when ev["type"] == "exit"
print("build exit " + text(ev["data"]["exit_code"]))
No hay continue: el resto del cuerpo va bajo when ev != nothing. Y select rechaza un target nothing (expected a connection handle …, got nothing), así que el mapa se arma con los handles que existen; un build terminado puede quedarse sin molestar — después de su exit no tiene nada más que decir.
| Builtin | Devuelve |
|---|---|
watch(path, opts?) | handle — también lo acepta select, etiquetado source: "watch" |
watch_recv(h, timeout?) | el próximo evento, o nothing al timeout |
watch_stats(h) | {path, recursive, interval, entries, scans, queued, dropped} |
watch_close(h) | libera el handle y apaga el scanner. Idempotente |
Los eventos son {type: "create" | "modify" | "delete", path, is_dir}. path usa / y queda relativa si la raíz era relativa. Un rename es un delete más un create. Los directorios sólo emiten create/delete (su mtime cambia con cada hijo — ruido). No se emite nada por lo que ya existía al arrancar el watch. opts: recursive (true), interval en segundos (0.5, piso 0.02), ignore (nombres de entrada, glob * permitido; default [".git", "node_modules", "target"] — pasá [] para incluirlos), max_entries (100 000: por encima watch() falla, y un árbol que lo supera después hace que el próximo recv falle y retira el handle), max_queue (4096; el overflow descarta lo más viejo y lo cuenta en dropped).
Un watch recursivo necesita file.read("<carpeta>/") (motor v0.6.46+, breaking)*, como grep y static. watch("src") con require file.read("src") (o "src/") ya no arranca: Capability not granted: file_read("src/") … add require file.read("src/")` (con sólo "src/" el mensaje nombra file_read("src"), porque src/ no cubre la carpeta misma). Antes el permiso se chequeaba sólo en la raíz, así que el watch informaba nombres de archivos bajo src/sub/ que el programa no podía leer. Dá "src/", o pasá {"recursive": false}, que sigue necesitando sólo la carpeta (como list_dir; watch_stats dice entonces recursive: false). Una raíz que es un archivo** al arrancar el watch se mira como recursive: false (si una carpeta la reemplaza, sólo se ve su primer nivel). Una raíz que no existe es un error, como antes: watch: cannot watch "nope": no such file or directory`.
Cómo funciona, honestamente. Es polling con snapshot — mtime + tamaño por entrada, comparados cada interval — no inotify, FSEvents ni ReadDirectoryChangesW. Eso compra semántica idéntica en Linux, macOS y Windows, sin límites de watches del kernel y sin dependencia nueva; cuesta un recorrido del directorio por tick (de ahí ignore y max_entries) y una latencia igual a interval. Un cambio hecho y deshecho dentro de un mismo intervalo no se ve: recibís el estado, no la historia. SYNSEMA_WATCH_MAX (64, techo 1024) acota los watches vivos por intérprete; como los procesos, cada watch muere con el intérprete que lo abrió.
La terminal — term§
La terminal propia del programa como handle con eventos (engine v0.6.11+), en el mismo hub que procesos, sockets, bus y watches — así un solo select cubre "el humano tocó una tecla" y "el subagente publicó un resultado". Gate: require stdin. Es lo que necesita un chat CLI, una paleta de comandos o un editor de línea con historial: read_line te da una línea cocinada por vez; term_open te da cada tecla en el momento en que se pulsa.
require stdin
require stdout
let commands be ["/help", "/history", "/approve", "/model", "/quit"]
let term be term_open()
when term == nothing -- pipe / CI / serve / test: no terminal
let line be read_line("> ") -- same program, plain input
otherwise
let buf be ""
let sub be bus_subscribe("agent.*")
while true
let matches be where(commands, (c) => starts_with(c, buf))
term_write(term, "\r\u001b[2K> " + buf + " " + join(matches, " ")) -- ESC[2K: clear the line
let ev be select({"keys": term, "agent": sub}, 60)
when ev != nothing -- nothing = 60 s idle: redraw and wait
when ev["source"] == "bus"
term_write(term, "\r\n[agent] " + json_encode(ev["data"]) + "\r\n")
otherwise when ev["type"] == "eof"
stop
otherwise when ev["type"] == "paste"
set buf to buf + ev["text"]
otherwise when ev["type"] == "key" -- resize/focus events carry no "key"
when ev["key"] == "char" and not ev["ctrl"]
set buf to buf + ev["text"]
otherwise when ev["key"] == "backspace"
set buf to slice(buf, 0, length(buf) - 1)
otherwise when ev["key"] == "tab" and length(matches) == 1
set buf to matches[0]
otherwise when ev["key"] == "enter" and ev["alt"]
set buf to buf + "\n" -- Alt+Enter = new line
otherwise when ev["key"] == "enter"
stop
term_close(term)
Cada tecla es un evento, así que el menú de / filtra mientras se tipea; el loop sigue vivo mientras un subagente habla por el bus. Tres detalles que el loop resuelve: el carácter de escape se escribe \u001b (desde v0.6.29 \uXXXX es un escape; \x1b NO lo es e imprimiría cuatro caracteres); leer una clave que el mapa no tiene es un error (Map has no key 'key'), así que ev["key"] se lee solo en eventos type: "key"; y un stop dentro de when anidados igual sale del while.
| Builtin | Devuelve |
|---|---|
term_open(opts?) | handle — también lo acepta select, etiquetado source: "term"; nothing cuando stdin o stdout no son una TTY, bajo serve, synsema test/conform y en WebAssembly |
term_recv(h, timeout?) | el próximo evento, o nothing al vencer (default 30 s — pasá uno explícito cuando esperás a un humano) |
term_size(h) | {cols, rows} |
term_write(h, text) | escribe a stdout ya, sin pasar por el buffer de print; se permiten escapes ANSI (cursor, borrar línea, colores) |
term_stats(h) | {kitty, paste, ansi, keys, queued, dropped} |
term_close(h) | restaura la terminal y apaga el lector. Idempotente — y el runtime lo hace igual al soltar el handle (fin del programa, error de runtime, stop, pánico) |
Eventos (todos etiquetados source: "term", handle, name):
{type: "key", key, text, ctrl, alt, shift}—keyes un nombre:"char"(ytextes el carácter, con Shift ya aplicado:"A"),"enter","tab","backtab","backspace","delete","insert","escape","up"/"down"/"left"/"right","home"/"end","pageup"/"pagedown","f1"…"f12". Tab y Enter nunca llegan como"\t"/"\r". Conctrl: true,textes la letra minúscula (Ctrl+O→{key: "char", text: "o", ctrl: true}). Sólo pulsaciones (sin eventos de release/repeat).{type: "paste", text}— un pegado (bracketed paste) como un solo evento, saltos de línea incluidos (terminales Unix; en Windows un pegado llega como ráfaga de eventoskey—term_stats(h)["paste"]dice cuál aplica).{type: "resize", cols, rows},{type: "focus", gained}(cuando la terminal los emite).{type: "eof"}— stdin se cerró; se entrega una vez y el handle desaparece (comoread_line→nothing, no es un error).
opts: paste (true, bracketed paste), kitty (true: pide el kitty keyboard protocol cuando la terminal lo soporta — Windows Terminal, kitty, WezTerm, foot, Ghostty — que es lo que hace distinguible Shift+Enter; sin él Shift+Enter es un Enter común, así que hacé de Alt+Enter tu atajo multilínea y de Shift+Enter un bonus), ctrl_c ("exit": Ctrl+C restaura la terminal y termina el proceso con código 130, como haría SIGINT — el raw mode si no se lo tragaría; "key": llega como {key: "char", text: "c", ctrl: true} y el programa decide), max_queue (16384, descarta los más viejos).
Lo que se sostiene con una terminal abierta: print/log siguen funcionando (el runtime escribe \r\n por vos, sin escalera); ask de texto libre, approve y confirm funcionan — el handler de consola suspende el raw mode mientras el humano contesta y lo reanuda; una terminal por proceso — un agente spawneado no puede abrirla mientras la tiene el programa principal. Restaurar la terminal es tarea del runtime, nunca del script: lo que activaste vos (cursor oculto, colores) lo deshacés vos.
Una sola espera — select§
let ev be select({"sock": socket, "child": child, "feed": feed, "cancel": sub}, 60)
targets es una lista de handles o un mapa nombre → handle — cualquier mezcla de ws_connect, socket, proc_spawn, bus_subscribe, watch, term_open, tcp_connect y extremos de pipe (v0.6.42+). Devuelve el primer evento listo, etiquetado con source ("ws" | "proc" | "bus" | "watch" | "term" | "tcp" | "pipe"), handle y name (forma mapa), más los campos propios del evento (type/data en sockets, procesos, TCP y pipes; topic/data/timestamp en el bus). nothing al vencer o cuando todos los targets desaparecieron. Equitativo (round-robin), duerme en el poller del kernel mientras no hay nada, falla rápido con un error atrapable cuando un target muere por error de protocolo/cola, y despierta al instante ante una cancelación. ws_select también acepta cualquier handle (conserva su etiqueta conn); proc_select es la misma espera restringida a procesos.
Todo junto — una conexión, un hijo, una suscripción, un loop:
require serve(8080)
require exec("sh")
serve on 8080
route "GET /console"
socket
let sub be bus_subscribe("agent.*")
let child be proc_spawn("sh", ["-c", "for i in 1 2 3; do echo step $i; sleep 1; done"])
while true
let ev be select({"ui": socket, "child": child, "bus": sub}, 60)
when ev == nothing
ws_send(socket, "idle")
otherwise when ev["name"] == "ui"
when ev["type"] == "close"
stop
otherwise
ws_send(socket, "you said " + ev["data"])
otherwise when ev["name"] == "child"
when ev["type"] == "exit"
ws_send(socket, "exit " + text(ev["data"]["exit_code"]))
otherwise
ws_send(socket, ev["type"] + ": " + ev["data"])
otherwise
ws_send(socket, "[" + ev["topic"] + "] " + json_encode(ev["data"]))
La versión completa, con un feed SSE /events y un POST /announce que publica, es examples/agent_console.syn en el repo del engine.
TCP crudo y pipes — un túnel en Synsema (motor v0.6.42+)§
Dos canales de bytes se suman a las cinco piezas de arriba. tcp_connect(host, port, opts?) abre una conexión TCP saliente, con el permiso net("host:puerto") del puerto exacto (un permiso para :8080 no alcanza a :8081); sobre ella andan tcp_send, tcp_recv, tcp_close y tcp_stats. pipe(opts?) devuelve dos extremos de bytes conectados {a, b} (pipe_send, pipe_recv, pipe_close). Los dos tienen tope en cada dirección y los dos van en select. tcp_recv/pipe_recv dan {type: "data", data} (bytes — decode para leerlos como texto), {type: "close"} cuando el otro lado deja de mandar, o nothing al vencer. close se repite, como un canal cerrado: cada lectura siguiente lo vuelve a dar al instante, y la conexión sigue abierta para tu respuesta hasta que la cerrás; pipe_close(end, "write") es un medio cierre.
- Un par que se va sin cerrar (un reset: su proceso murió, se cayó la red;
os error 10054en
Windows) llega como {type: "close", reason: "connection reset by peer"} (engine v0.6.45+) — en tcp_recv/pipe_recv y en select, sin cortar la espera de las otras conexiones. Mandar por esa conexión después es un error que lo dice: … connection N: the other side is gone (connection reset by peer). Una violación de protocolo sigue siendo un error.
tcp_write(conn, data)/pipe_write(end, data)(engine v0.6.45+) toman lo que entra ahora
y dan cuántos bytes tomaron — nunca esperan; 0 = lleno en este momento. tcp_send/pipe_send siguen siendo todo-o-timeout. Un loop que atiende muchos streams le acredita a cada uno lo que de verdad se tomó y guarda el resto para la vuelta siguiente, en vez de parsear el texto de un error de timeout. Con pipe({"max_buffer": 4}), pipe_write(p["a"], "abcdef") da 4 y la escritura siguiente 0.
pipe_stats(end)→{queued_in, queued_out, status}(engine v0.6.45+), comotcp_stats:
después de esa escritura, pipe_stats(p["a"]) es {queued_in: 0, queued_out: 4, status: "open"} y su par muestra queued_in: 4.
require net("127.0.0.1:8080")
let c be tcp_connect("127.0.0.1", 8080, {"timeout": 5})
tcp_send(c, "GET / HTTP/1.0\r\nHost: x\r\n\r\n")
let reply be ""
let live be true
while live
let m be tcp_recv(c, 5)
when m == nothing or m["type"] == "close"
set live to false
otherwise
set reply to reply + decode(m["data"])
tcp_close(c)
print(reply)
Un extremo de pipe es un número, y se le puede pasar a otro intérprete — de una ruta a un agente, de una request a una ruta socket; el primero que lo usa se queda con él. Eso, más proxy to un extremo de pipe, es un túnel entero: el server habla HTTP por un extremo (con SSE y upgrades de WebSocket) y quien tenga el otro lleva los bytes a donde sea. Acá un agente los copia a un servicio local:
require serve(8080)
require net("127.0.0.1:3000")
agent Bridge
require net("127.0.0.1:3000")
let svc be tcp_connect("127.0.0.1", 3000)
let live be true
while live
let m be select({"pipe": peer, "svc": svc}, 20)
when m == nothing
set live to false
otherwise when m["type"] == "close"
set live to false
otherwise when m["name"] == "pipe"
tcp_send(svc, m["data"])
otherwise
pipe_send(peer, m["data"])
tcp_close(svc)
pipe_close(peer)
serve on 8080
route "GET /*path"
let p be pipe() -- one pipe per request
spawn Bridge with peer = p["b"] -- the agent takes one end…
proxy to p["a"] -- …the server speaks HTTP over the other
Un producto de túneles — cuentas, una página por usuario, cuotas — es un programa en Synsema sobre estas primitivas; el motor no sabe nada de túneles. Un extremo que nadie toma se cierra a los 60 s sin actividad en su par, y un programa puede tener a lo sumo 4096 extremos esperando a que alguien los tome.
Timeouts y cancelación§
Por default un handler no tiene límite de tiempo. Declaralo en el bloque serve (default de todas las rutas) y/o por ruta:
serve on 8080
timeout 30 -- seconds, default for every route
route "POST /think"
timeout 300 -- route override: top of the body, at most once
give reason "…" given request.json
route "GET /events"
timeout none -- this route opts out of the block default
stream
…
- Rutas sized: al vencer, el cliente recibe
504 {"error": "gateway timeout: the handler exceeded 30s", "status": 504}conConnection: close, y el handler queda cancelado — se corta en su próximo statement o espera (log:handler cancelled: request timed out after 30s). El tiempo en cola esperando un worker cuenta. stream/socket: el timeout es la vida máxima de la conexión — el SSE termina conevent: error{"error": "cancelled: request timed out after 30s"}; un socket recibeClose 1001con ese motivo.- La cancelación es cooperativa y no se cura. El intérprete la chequea antes de cada statement y dentro de cada espera (
sleep,ws_recv,select,proc_*,bus_recv,wait_for,run), levantandocancelled: <motivo>. Untry/recoverpuede observarla, pero el próximo statement vuelve a fallar — limpiá y salí. Los hijos derun/proc_spawnse matan. timeoutes una cláusula. En cualquier otro lugar (dentro de unwhen, una task, el top-level) es un error de runtime, no un no-op silencioso. Dentro de un grupoexport routesfunciona igual que en una ruta directa (v0.6.19+): una ruta montada contimeout 1responde504al segundo.- La desconexión del cliente en una ruta sized no se detecta (hyper no la expone sin body pendiente); streams y sockets sí la detectan.
Shutdown ordenado§
Ante SIGINT (Ctrl-C) — y SIGTERM en Unix, lo que mandan docker stop y systemd:
1. El listener se cierra: las conexiones nuevas se rechazan; un request sobre una conexión keep-alive abierta recibe 503 {"error": "server shutting down"} + Retry-After: 2. 2. Log: [serve] shutting down: draining N in-flight request(s), grace 10s (SYNSEMA_SHUTDOWN_GRACE). El cron deja de programar, los agentes vivos se cancelan (agent_stop, motivo server shutting down), streams y sockets se cancelan de inmediato (SSE event: error cancelled: server shutting down; sockets Close 1001). 3. Los requests sized en vuelo tienen hasta SYNSEMA_SHUTDOWN_GRACE segundos (default 10; 0 = inmediato); lo que queda se cancela; [serve] stopped; código de salida 0 (fue pedido). Un segundo Ctrl-C durante el drain sale ya con 130. 4. Con varios bloques serve on el proceso sale cuando drenó el último.
synsema run no cambia (no tiene listener). Bajo systemd poné TimeoutStopSec por encima de la gracia.
Desde el programa (engine v0.6.18+): shutdown(reason?) pide exactamente este drain — desde una ruta, un bloque socket, un job de cron o un agente. El log dice [serve] shutdown requested by the program: <motivo>, después corren los pasos de arriba y el proceso sale con 0. Idempotente (una segunda llamada no hace nada); error bajo synsema run (un programa run termina con su top level — stop sale de un loop o de una task) y antes de que algo escuche; sin capability (apagarse no es un recurso del host); un secret como motivo se rechaza (el motivo se loguea). Es como una app de escritorio se apaga cuando se cierra su última ventana — Tu app en el escritorio.
Agentes bajo serve§
Un agente spawneado desde un handler o un tick de cron recibe el mismo cableado que un tick de cron — state_*, la base compartida, la cola de approvals, cron, el bus, la memoria declarada — no una isla con builtins frescos. Corre bajo el techo del host de synsema serve --sandbox o --cap-set "<lista>" (un require adentro nunca excede lo que fijó el operador, exactamente como bajo run; --sandbox acá es stdout,time + serve), y un shutdown ordenado lo detiene.
agents() -- [{id, name, state, error, started_at, finished_at}]
agent_stop(id, reason?) -- true if it was alive; the agent ends in state "stopped"
id es la instancia (Researcher_0), name el agente declarado; estados idle · starting · working · waiting · done · error · stopped. agent_stop es cancelación cooperativa: el agente levanta cancelled: <motivo> antes de su próximo statement y cualquier espera despierta al instante — un agente en while true ya no es inmortal. Los dos funcionan bajo run y serve, sin capability (introspección del propio proceso). Ojo: give agents() pagina como todo give <lista> — leé items, o give {"agents": agents()}.
Knobs del server (entorno del proceso, no .env)§
synsema init los lista, comentados, en .env.example — con la advertencia de que los lee el entorno del proceso (export, Environment= de systemd, -e de Docker):
| Variable | Default | Qué |
|---|---|---|
SYNSEMA_SERVE_WORKERS | cores (mín. 2) | pool de intérpretes para handlers sized (streams/sockets tienen hilo propio); desde v0.6.42 limita el trabajo de CPU, no las requests abiertas |
SYNSEMA_SERVE_MAX_WAITING | 256 | hilos extra mientras los handlers esperan (sleep, HTTP, LLM, bases de datos, select…), así una ruta que espera no frena al resto; se van apenas se vacía la cola; 0 = ninguno (v0.6.42+) |
SYNSEMA_SHUTDOWN_GRACE | 10 | segundos de drain ante SIGINT/SIGTERM; 0 = inmediato |
SYNSEMA_SSE_KEEPALIVE | 15 | segundos sin frames antes de un comentario SSE : keepalive; 0 apaga |
SYNSEMA_WS_SERVER_PING | 30 | intervalo del ping del server en rutas socket; sin pong en 2 intervalos → close; 0 apaga |
SYNSEMA_WS_SUBPROTOCOLS | — | subprotocolos que el server puede acordar (coma-separados); sin la variable no acuerda ninguno |
SYNSEMA_WS_MAX_MESSAGE | 16MB | tamaño máximo de un mensaje entrante en rutas socket (techo 64MB) |
SYNSEMA_WS_MAX_CONNS | 4096 | handles WebSocket vivos por intérprete (ws_connect + entrantes) |
SYNSEMA_PROC_MAX | 64 | hijos proc_spawn vivos por intérprete (techo 1024) |
SYNSEMA_WATCH_MAX | 64 | handles watch vivos por intérprete (techo 1024; cada uno es un hilo scanner) |
SYNSEMA_RUN_PROGRAM_MAX_DEPTH | 4 | profundidad máxima de anidamiento de run_program dentro de run_program (v0.6.14+) |
SYNSEMA_STATE_DIR | <dir del programa>/.synsema/state | dónde memory("NAME") guarda <NAME>.db — apuntalo a un volumen montado y dejá el código en solo lectura (la .db se crea ahí; nada bajo el dir del programa) |
SYNSEMA_LLM_BUDGET_PER_IDENTITY | (ninguno) | id=N,id2=M — techos de tokens LLM por identidad bajo serve (la identidad que devuelve la task de auth); contadores del proceso; al llegar, las ops de esa identidad degradan a [llm budget exceeded for identity …], las demás siguen (v0.6.20+) |
SYNSEMA_HEALTH_PATH | (ninguno) | endpoint de salud opt-in del host: GET <path> → 200 {ok, uptime_s, in_flight, engine}, sin auth ni rate limit, ausente del descubrimiento; una ruta declarada en ese path gana (aviso al arrancar); lo mismo que serve --health (v0.6.20+) |
SYNSEMA_AUDIT | (ninguno) | json / <path> / fd:N / unix:<path> — el stream de audit de capacidades sin el flag, también dentro de un binario de synsema build; el flag --audit gana (v0.6.20+) |
SYNSEMA_LLM_BUDGET | ilimitado | techo de tokens LLM por proceso — al llegar, las ops LLM degradan al marker [llm budget exceeded: …], nunca un error; a diferencia de las filas de arriba, también se lee del .env — ver Primitivas LLM |
Correr Synsema que generaste — run_program (v0.6.14+)§
Una app agéntica que escribe Synsema (una tool que emite el LLM, el plugin de un usuario, un loop que se auto-repara) lo corre con run_program(source, opts) — un proceso hijo del mismo binario bajo un techo ∩ el del padre, con su propio env/cwd/timeout y su audit devuelto como un valor (require sandbox_run). Sin exec, sin synsema en el PATH, sin parsear stderr; el hijo nunca puede exceder al padre. Es la forma segura de "correr el código que generó el modelo" — modelo completo en Sandbox, firma en Builtins.
Desde el engine v0.6.45: env no puede poner SYNSEMA_ ni las variables que eligen rutas del host (HOME, TMP, XDG_, …); las rutas del techo viajan absolutas, ancladas en el padre (un cwd mueve al hijo, no a su techo); filename es una etiqueta, nunca una carpeta; y el hijo guarda su estado donde el padre guarda el suyo. Un hijo "native" con memory=<nombre> comparte la memoria del padre, también desde un tick de cron o un handler de serve — detalles en Sandbox.
Lo que a propósito no está§
- Un emulador de terminal o un framework de TUI.
pty: truele da al hijo una terminal real; dibujarla (parsear VT, un modelo de pantalla) es tarea del consumidor — xterm.js en un navegador,strip_ansipara un agente.term_opente da a vos teclas y tamaño, nada más: un editor de línea, una paleta de/o un menú son unas decenas de líneas de Synsema sobreselect(el ejemplo de arriba), no una biblioteca de widgets en el runtime. - Un notificador nativo del filesystem.
watchhace polling sobre un snapshot a propósito: una sola semántica en todos los SO, sin límites del kernel, sin crate*-sys. Si algún día importa latencia sub-segundo sobre un árbol enorme, sería unopts.backend, no una API nueva. - Un bus entre procesos. Redis pub/sub va a reusar
bus_*con unopts.backend. - Una tabla global de sockets. Empujar a "todos los sockets" desde cualquier lado sería una puerta de escape del aislamiento por request; el camino es el bus.
Mirá Servidor HTTP, Cliente WebSocket, Multi-agente y Deploy.