openseek_protocol

    Typed engine event stream: the openseek run/serve stdout wire contract.

    Download zip
    Author
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    2 hours ago
    Downloads
    14

    Dependencies

    #OpenSeek Protocol

    bobzhang/openseek_protocol owns both directions of the serve protocol: the stdout event stream the engine reports (Event), and the stdin command stream it is told (Command). Between them they are the whole wire contract with the TUI, the desktop host, the desktop frontend, and any script driving run or serve.

    It is a leaf module with no openseek dependencies, split so the decoder is portable:

    PackageContentsTargetsDeps
    bobzhang/openseek_protocolEvent, Usage, Command, SteerKind, to_json, parsejs, wasm, wasm-gc, nativecore/json
    bobzhang/openseek_protocol/emitemit (to_json + stdout writer)nativeasync, above

    Only the writer does I/O, and only a native process can write fd 1 asynchronously. Keeping it in its own package means a client that reads the stream does not have to be a native binary — desktop/frontend compiles to js, and its decoder can now be the same match the engine's encoder is checked against.

    Being a module rather than a package is what lets a different module consume it: desktop/moon.work can list "../protocol" as a member and bind the working tree (a moon.work member wins over the registry, so there is no stale mooncakes snapshot).

    The stream is a protocol, not a log. emit writes each event's line straight to stdout through its own sink — no logger in between — so a line is the event itself:

    {"event":"assistant_delta","content":"Hel"}

    There is no envelope at all — no timestamp, no severity, no source — and the CLI links no logger, so nothing but events can reach this stream.

    #Why it exists

    The contract used to live as anonymous JSON literals at ~55 @xlog.info() <? {…} call sites, with a hand-written decoder per client. Nothing tied the two directions together, and they had drifted:

    • tool_result was emitted with brief from one site and without it from two.
    • mcp_connect_failed was emitted with error from one site and without it from another.
    • compaction_failed was reported at warn from one site and error from two.

    Event closes that by construction: one variant per event, owning its payload, with to_json the only author of the shape and parse its inverse. There is no severity for a call site to pick — the line is the event and nothing else; a client that wants to rank events does so from the variant it decoded. Every reader — the TUI, the desktop host, the desktop frontend — matches on the same enum, so adding a variant is a compile error at each one: ignoring an event is a decision someone wrote down, not a _ => None nobody noticed.

    What that caught, once the readers were made exhaustive:

    • reasoning_delta had not been emitted since 2026-06-21 (a85d5682), yet the TUI and the desktop both still decoded it under tests that passed on fabricated lines. The agent now emits it unconditionally; interactive clients render it live, while other consumers may ignore the high-volume stream.
    • runtime_update had not been emitted since 2026-07-05 (4b1ec831), yet the desktop host still decoded it, likewise under a passing test.
    • The desktop host synthesized compaction_failed with reason while the engine writes error, under a comment claiming "the same wire shape the engine emits". One decoder happened to accept both spellings, so nothing noticed.

    #API

    // Report an event. The line is `to_json`, nothing more.
    @emit.emit(AssistantDelta(content="Hel"))
    @emit.emit(AgentAborted(reason="interrupted"))

    // Or build the line without logging it — what the desktop host forwards for a
    // compaction whose engine died before reporting one itself.
    let line = CompactionFailed(error="engine exited").to_json()

    // Read one back. `None` means "not an event this engine emits" — an unknown
    // name or a malformed payload — so a client stays tolerant of a newer engine.
    match @protocol.parse(line) {
    Some(AssistantDelta(content~)) => render(content)
    Some(_) | None => ()
    }

    emit writes each line without a source: events are not log entries, so no line points back at reporting code. A process that wants a log keeps one separately; the engine CLI has none.

    #When a field may be absent

    The rule and the fields it covers live in parse's doc comment (events) and Command::parse's (commands) — beside the code that enforces them, and nowhere else. This file used to restate the rule and the list, and within a month it was wrong on both: it still said "exactly one field qualifies" after the count went to three, and stated an "iff" after a second case was found. A rule copied is a rule that drifts, which is the failure this package exists to prevent — so the copy is gone rather than corrected.

    What is worth knowing here: a field is defaulted only for a reason the git history can settle, never because no reader happens to use it. Run git log -S for the field against its event's introducing commit before adding another; the doc comment says what to look for.

    #The command direction

    Command is the same shape for the opposite direction: one type, one encoder (to_json/to_jsonl), one decoder (Command::parse), and every controller encodes through it — cmd/tui and the desktop host both, where each used to model the commands itself. They drifted exactly as the events had: the TUI sent steer with a kind and the desktop sent it without one, working only because the engine's decoder happened to default it.

    // A controller writes a line.
    let line = (Prompt(text="do it") : @protocol.Command).to_jsonl()

    // The engine reads one back. `Err` is a line it cannot read — and only that:
    // whether a readable command is *acceptable* is the engine's to say, which is
    // why `serve`, not `parse`, refuses a blank goal.
    match @protocol.Command::parse(line) {
    Ok(Prompt(text~)) => start_turn(text)
    Ok(_) => ()
    Err(message) => report(message)
    }

    One command runs the other way round. approval_requested is the only event that is a question: a tool has blocked and the turn does not advance until an ApprovalDecision carrying that request's id comes back, at which point the engine emits approval_resolved to retire the prompt. Every other event reports something that already happened, and a controller that only reads is a valid controller for all of them — but not for this one, which is why an engine asks nobody unless it was started with --approval ask.

    Command::parse returns Result, not Option, unlike parse for events. An unreadable event is a line to ignore; an unreadable command is a request that will never be answered, and silence is the one reply a controller cannot act on. Its Err strings reach the controller as command_error, so they are wire contract too.

    #Invariants

    • parse is emit's inverse for every variant. emit/emit_test.mbt pins this per sample — it needs both halves, so it lives with the writer; it is the property the package exists to provide.
    • Optional string fields are written as the value or null, never omitted. A field's own ToJson would encode Some(v) as the one-element array [v], which every decoder's string lookup rejects — silently turning a present field into an absent one. or_null is why, and the round-trip test is what caught it.
    • Usage is owned here, not borrowed from the provider. It is structurally identical to @deepseek.Usage — same fields, same order, same JSON — and deliberately a separate type. The wire format must not be whatever a vendor's response struct happens to be, and this module cannot depend on the engine's provider layer without a cycle. agent's wire_usage is the single place the two meet.

    #Known gaps

    • The stream has no clock. timestamp and source were the log envelope's; events are not log entries, so a line does not say when it was written or where it was emitted. A client that needs ordering uses stream order (the engine writes events in order), and one that needs an authoritative time uses the durable session record.

    Command

    pub(all) enum Command {
    Prompt(text~ : String, submission_id~ : String?)
    Steer(kind~ : SteerKind, text~ : String)
    Compact
    Cancel
    GoalSet(text~ : String, auto~ : Bool)
    GoalClear
    ApprovalDecision(id~ : String, allow~ : Bool)
    } derive(Eq,
    Debug
    )

    One command a controller writes to openseek serve's stdin.

    The other half of the serve protocol: Event is what the engine reports, this is what it is told. Both directions used to be anonymous JSON at each end — the events for ~60 call sites and three decoders, these for one decoder and two encoders that had already drifted apart:

    • the TUI sent steer with a kind; the desktop sent it without one, and worked only because parse falls back to Prompt. Neither end wrote that rule down.
    • the desktop had no goal at all, so the same protocol meant different things to its two clients, and nothing said whether that was a decision.

    One type, one encoder, one decoder — the same shape the event direction now has.

    Command::equal

    fn Command::equal(Command, Command) -> Bool

    Command::not_equal

    fn Command::not_equal(x : Command, y : Command) -> Bool

    Command::parse

    fn Command::parse(line : Json) -> Result[Command, String]

    Read one stdin line back into a Command.

    Err carries the text the engine reports to its controller as a command_error event, so these strings are themselves wire contract, not diagnostics — they are what a controller reads to learn what it got wrong.

    It reports a line this protocol cannot read, and nothing else. Whether a readable command is acceptable is the engine's to say: a blank goal is well-formed and refused, and serve refuses it. Were that check here, GoalSet(text=" ") would be a value this type holds and to_json writes but parse rejects — breaking the round-trip below for a value the type itself offers.

    Unlike parse for events, this returns a Result: an unreadable event is a line to ignore, but an unreadable command is a request that will never be answered, and silence is the one reply a controller cannot act on.

    When a field may be absent

    The same rule parse states for events, and both of its cases apply here — one field each, verified against git log -S for the field versus its command's introducing commit:

    1. Added after its command existed. steer's kind arrived in 9b2f6c43; steer itself in 17562cc1. An engine from between them is sent a kind it ignores, and a controller older than the field sends none — so absent means Prompt, which is what every controller meant before there was a choice. The desktop was still sending exactly that. prompt's submission_id is the same case: it arrived long after prompt itself, an engine older than the field ignores the key, and a controller that does not tag its submissions sends none — absent means untagged, which is what every prompt was before there was a tag.
    2. Optional since the command shipped. goal's auto arrived with goal (c8cc6ad7), so case (1) does not cover it — but the engine has read auto as absent-means-false from the first line it ever decoded. It is a flag with a default, not a value that can go missing.

    The distinction matters because only (1) is about talking to an old binary. A field that is merely unread — text on a command nobody inspects — is still required: its absence means the line is not what it claims.

    Command::to_json

    fn Command::to_json(self : Command) -> Json

    The command as the line a controller writes.

    Command::to_jsonl

    fn Command::to_jsonl(self : Command) -> String

    The command as a JSONL line, newline included.

    Command::to_repr

    Event

    pub(all) enum Event {
    AgentSetupFailed(error~ : String)
    AgentStep(step~ : Int)
    AgentAborted(reason~ : String)
    AgentFinished(answer~ : String)
    MaxStepsExhausted
    TurnFailed(error~ : String)
    AssistantDelta(content~ : String)
    ReasoningDelta(content~ : String)
    AssistantMessage(content~ : String)
    ReasoningMessage(content~ : String)
    Usage(usage~ : Usage)
    ToolResult(tool_call_id~ : String, tool_name~ : String, is_error~ : Bool, content~ : String, brief~ : String?)
    ToolCallDecodeError(tool_call_id~ : String, tool_name~ : String, error~ : String)
    ApprovalRequested(id~ : String, tool_name~ : String, detail~ : String, body~ : String?)
    ApprovalResolved(id~ : String, outcome~ : String)
    SteerApplied(kind~ : String, content~ : String)
    SteerDropped(content~ : String)
    BackgroundNotice(content~ : String)
    GoalUpdated(goal~ : String?)
    GoalBlocked(reason~ : String)
    GoalUnblocked
    GoalCheck(content~ : String)
    GoalReminder(content~ : String)
    GoalContinue(remaining~ : Int)
    GoalBudgetExhausted(turns~ : Int)
    PlanReminder(content~ : String)
    CompactionStarted(from_sequence~ : Int, to_sequence~ : Int)
    CompactionFinished(from_sequence~ : Int, to_sequence~ : Int, summary~ : String)
    CompactionFailed(error~ : String)
    AutoCompactionStarted(from_sequence~ : Int, to_sequence~ : Int)
    AutoCompactionFinished(from_sequence~ : Int, to_sequence~ : Int, summary~ : String)
    AutoCompactionFailed(error~ : String)
    ContextYield(to_sequence~ : Int, answer~ : String)
    SubrunStarted(id~ : String, kind~ : String, label~ : String)
    SubrunFinished(id~ : String, status~ : String, steps~ : Int, prompt_tokens~ : Int, completion_tokens~ : Int)
    SessionStarted(session~ : String, session_root~ : String, workspace_root~ : String?)
    SessionError(error~ : String)
    WorkspaceCreated(dir~ : String)
    CommandError(error~ : String)
    FleetStarted(runs~ : Int, task~ : String)
    McpConfigIgnored(reason~ : String)
    McpConfigUnreadable(path~ : String, error~ : String)
    McpConfigInvalid(path~ : String, error~ : String)
    McpToolsRegistered(servers~ : Int, tools~ : Int, names~ : Array[String])
    McpToolDuplicate(server~ : String, tool~ : String)
    McpToolRenamed(from~ : String, to~ : String)
    McpToolsCapped(server~ : String, kept~ : Int)
    McpServerSkippedOverCap(server~ : String)
    McpConnectFailed(server~ : String, error~ : String?)
    McpListToolsFailed(server~ : String, error~ : String)
    McpListToolsTimeout(server~ : String)
    McpNoTools(server~ : String)
    } derive(Eq,
    Debug
    )

    One event on the engine's stdout JSONL stream — the wire contract between openseek run/openseek serve and every client that reads them (the TUI, the desktop host, and any script consuming run's stdout).

    The stream is a protocol, not a log. emit (in openseek_protocol/emit) writes each event's line — exactly its flat to_json fields — straight to stdout through its own writer, never through a logger — the CLI links none — so a client reading stdout sees only events.

    There is no severity on the line: a client that wants one derives it from the variant it decoded. emit is the only writer; parse is its inverse.

    Event::equal

    fn Event::equal(Event, Event) -> Bool

    Event::not_equal

    fn Event::not_equal(x : Event, y : Event) -> Bool

    Event::to_json

    fn Event::to_json(self : Event) -> Json

    The event as one line's worth of JSON: its "event" name plus its payload, flat.

    This is the wire shape, and the only place it is written. emit writes it as the whole line; the desktop host forwards it for a compaction its engine died before reporting. Two writers of the same event must not spell it two ways — they did once, and nothing noticed until a decoder was asked to be exhaustive.

    Flat, not nested under a payload key, because the writer emits each event's own fields as one line; parse reads them from the top level.

    Event::to_repr

    SteerKind

    pub(all) enum SteerKind {
    Prompt
    Command
    } derive(Eq,
    Debug
    )

    How a mid-turn steer reaches the model.

    The wire spells these "prompt" and "command". Notice is deliberately absent: the engine synthesizes notices for itself (a background job finishing), and reports them back as steer_applied with kind: "notice", but no controller may send one — decode has always rejected an unknown kind, and a notice is not a thing a client has to say.

    SteerKind::equal

    fn SteerKind::equal(SteerKind, SteerKind) -> Bool

    SteerKind::not_equal

    fn SteerKind::not_equal(x : SteerKind, y : SteerKind) -> Bool

    Usage

    pub(all) struct Usage {
    prompt_tokens : Int
    completion_tokens : Int
    total_tokens : Int
    prompt_cache_hit_tokens : Int
    prompt_cache_miss_tokens : Int
    } derive(Eq, ToJson,
    Debug
    )

    Token counts for one model response, as the usage event carries them.

    Structurally identical to @deepseek.Usage — same field names, same order, so the same JSON — but owned here on purpose. This type IS the wire format for the event; leaving it as the vendor's response struct meant renaming a DeepSeek field silently changed the contract every client parses, and it would tie this module to the engine's provider layer. The engine converts at the one site that reports usage.

    ToJson but deliberately not FromJson: the derive reads a JSON number into an Int by truncating, so @json.from_json would accept a prompt_tokens of 1.5 as 1 while parse rejects that same line. A public second decoder that disagrees with the canonical one is the exact failure this module exists to prevent, and a fabricated token count is worse than a dropped line — the counters feed the context-ceiling guard. parse decodes them field by field through int, which rejects a fractional value.

    Usage::equal

    fn Usage::equal(Usage, Usage) -> Bool

    Usage::not_equal

    fn Usage::not_equal(x : Usage, y : Usage) -> Bool

    Usage::to_json

    fn Usage::to_json(Usage) -> Json

    Usage::to_repr

    parse

    fn parse(line : Json) -> Event?

    Read one JSONL line from the engine's stdout stream back into an Event.

    The inverse of emit: parse(json) == Some(event) for every line emit writes, which emit/emit_test.mbt pins per variant — it needs both halves, so it lives with the writer. Fields are read from the top level: emit writes each event's fields flat, so there is no nesting and no envelope at all (events do not go through @xlog).

    None means "not an event this engine emits" — an unknown event name, a missing event key, or a payload whose required fields are absent or mistyped. Clients stay tolerant of a newer engine by treating None as "ignore this line" rather than as a failure.

    When a field may be absent

    A field is defaulted only when one of two things is true of it:

    1. It was added after its event already existed, so engines older than the field still emit the event without it — and the TUI and desktop launch whichever openseek is on PATH, making a new reader against an old engine a real pairing. Every event field defaulted here is this case.
    2. The engine has treated it as optional since the event shipped — a flag whose absence has always meant something definite, rather than a value gone missing. No event field is this case; Command's auto is, which is why that rule is stated separately in command.mbt rather than by pointing here.

    Everything else is required. "No reader uses it" is not a reason to default a field: tool_call_id, usage's cache counters and mcp_tools_registered's names are all unread by every current client, yet every engine that ever emitted those events emitted those fields, so their absence means the line is not what it claims to be. Guessing there would hide a real wire break behind a fabricated value.

    Three fields qualify today, all under (1), each verified against git log -S for the field versus its event's introducing commit — the check to run before adding a fourth, and the one that catches a field wrongly made required, as workspace_root was:

    • steer_applied's kind — added 9b2f6c43 (2026-06-25) to an event from 56140618.
    • session_started's workspace_root — added d5d0b208 (2026-06-13, with --dir) to an event from d11e04c2 (2026-06-12).
    • tool_result's brief — added 4eacdc95 to an event from 1f761102.

    split_child_session_id

    fn split_child_session_id(id : String) -> (String, Int)?

    The parent session id and sub-run ordinal behind a child session id, or None for an ordinary session.

    A sub-run of a DURABLE parent persists its own transcript under <parent session id>-sr-<N>, where N is the ordinal of the sr-N that the SubrunStarted/SubrunFinished brackets carry. The derivation lives here, next to those events, because every reader of the stream also reads the store: the desktop sidebar folds children under their parent, the viewer nests their transcripts, and both would otherwise reinvent this parse.

    Nesting is textual and the INNERMOST suffix wins: a-sr-1-sr-2 is child 2 of a-sr-1, not child 1 of a. The ordinal is bounded to nine digits so a pathological session name cannot overflow it — beyond that the id is simply not a child id, which is the fail-closed reading (it renders as an ordinary session rather than folding under a parent it may not have).