Synsemadocsv0.6.xENES

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.

agentic.syn
-- 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 again

Rutas 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)

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)
BuiltinDevuelve
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:

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

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.

BuiltinDevuelve
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.

BuiltinDevuelve
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):

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.

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.

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.

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
            …

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):

VariableDefaultQué
SYNSEMA_SERVE_WORKERScores (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_WAITING256hilos 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_GRACE10segundos de drain ante SIGINT/SIGTERM; 0 = inmediato
SYNSEMA_SSE_KEEPALIVE15segundos sin frames antes de un comentario SSE : keepalive; 0 apaga
SYNSEMA_WS_SERVER_PING30intervalo 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_MESSAGE16MBtamaño máximo de un mensaje entrante en rutas socket (techo 64MB)
SYNSEMA_WS_MAX_CONNS4096handles WebSocket vivos por intérprete (ws_connect + entrantes)
SYNSEMA_PROC_MAX64hijos proc_spawn vivos por intérprete (techo 1024)
SYNSEMA_WATCH_MAX64handles watch vivos por intérprete (techo 1024; cada uno es un hilo scanner)
SYNSEMA_RUN_PROGRAM_MAX_DEPTH4profundidad máxima de anidamiento de run_program dentro de run_program (v0.6.14+)
SYNSEMA_STATE_DIR<dir del programa>/.synsema/statedó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_BUDGETilimitadotecho 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á§

Mirá Servidor HTTP, Cliente WebSocket, Multi-agente y Deploy.