#moonbitlang/workflow/shim

    Build a process adapter from an agent CLI to the workflow child contract: one request line on stdin, one result file out. This shared library parses the parent's request, runs the CLI, watches for cancellation, and writes the result file atomically. It supports native and wasm.

    The dialect-specific work lives in the executables shim/claude and shim/codex. Use those directly to run existing engines; import moonbitlang/workflow/shim in moon.pkg when implementing another adapter.

    #Two different stdin streams

    sequenceDiagram participant P as Parent runner participant S as Your shim participant C as Agent CLI P->>S: One request line Note over P,S: Keep stdin open<br/>as the cancellation channel S->>C: Prompt in argv Note over S,C: CLI stdin is already closed C-->>S: CLI-specific stdout lines alt Normal completion S-->>P: Result file: completed, usage, steps else Parent cancels P->>S: Close stdin S->>C: SIGINT, then kill after grace S-->>P: Result file: interrupted end

    The CLI must not inherit the parent's cancellation pipe as its input. run_cli gives it an already-closed stdin and delivers its prompt through the argv supplied by the dialect. CLI stderr and environment are inherited. The shim's own stdout carries nothing: the runner drains it unread.

    #A shim is serve plus a dialect

    serve(default_command~, run~) is a whole shim's main. It parses argv into Options, reads the request line from stdin into a Request, calls run with both, and writes the ShimResult that run returns to the --result-file path:

    ///|
    async fn main {
    @shim.serve(default_command="my-agent", run=(options, request) => {
    let exit = @shim.run_cli(
    command=options.command,
    args=["--prompt", request.prompt],
    observe=_ => @shim.Continue,
    )
    match exit {
    Exited(0) => @shim.ShimResult(NoReport, steps=0)
    Exited(code) => @shim.ShimResult(Failed("exited \{code}"))
    Stopped => @shim.ShimResult(MaxStepsExhausted)
    Cancelled => @shim.ShimResult(Interrupted("the parent closed stdin"))
    Failed(reason) => @shim.ShimResult(Failed(reason))
    }
    })
    }

    Every ending the shim controls writes a result. An error run raises becomes a failed result, and so does a request that cannot be parsed but names its request_id. When there is nothing to answer — argv without a result path, stdin closed before a request, or a malformed request whose id is unknown — serve raises instead: let the error escape main, which prints it on stderr and exits nonzero, and the runner reports that the child left no result. The exit status is otherwise 0: the result file, not the status, says how the run ended.

    The file is written once, to a uniquely named sibling created exclusively, then renamed over the path, so a reader sees either no file or a whole one.

    #Requests and options

    serve accepts the version 1 request document of contract §2 and hands run a Request:

    {"version":1,"request_id":"probe-1","kind":"explore","limits":{"max_steps":8},"input":{"query":"Find the entry point","hints":"Look in src/"}}

    version must be 1; request_id (echoed in the result) and kind are required strings; input is required; limits.max_steps (an integer of at least 1) and an object-valued schema (null means absent) are optional. A present malformed ceiling or schema is refused, never read as absent.

    The input must yield a nonblank prompt: a string, {query, hints?}, or {prompt}. For the request above, request.prompt is "Find the entry point\n\nHints: Look in src/", request.max_steps is Some(8), and request.input keeps the original object. An arbitrary object such as {task: ...} is not automatically translated, even when kind is worker.

    Both shipped dialects take these options:

    OptionParsed value
    --result-file PATHRequired. Where the result file goes; a launch passes {result_file}.
    --command EXECLI executable; defaults to the dialect's command name.
    --model NAMEModel name forwarded by the dialect.
    --cwd DIRChild working directory.
    --writableRequest a write-capable run; default false.
    --schema FILEDefault schema file when the request supplies no schema.
    -- ARGS...Remaining arguments passed through unchanged, in options.extra.

    Unknown options are refused, and so is argv without --result-file; the problem goes to stderr and the shim exits nonzero. --help prints plain-text help before any request is read.

    Request(...) and Options(...) build values directly, for dialect tests; they do not validate.

    #Report the result

    A dialect returns a ShimResult(status, usage?, steps?):

    StatusResult statusMeaning
    Completed(report)completedThe CLI finished; report is the result's output.
    NoReportno_reportThe CLI exited cleanly without an answer.
    MaxStepsExhaustedmax_steps_exhaustedThe step ceiling stopped the run.
    Interrupted(reason)interruptedThe parent closed stdin.
    Failed(reason)failedThe run could not finish.

    usage is a Usage with the prompt, completion, cache-hit, and cache-miss token counts (total_tokens is derived) and an optional cost_usd when the CLI states its own price. Omit it when the CLI reported nothing: the result then carries no usage, and the parent records the spend as unaccounted rather than free. Usage::add sums two readings. steps is the dialect's own step count.

    ///|
    test "a completed result document" {
    let usage = @shim.Usage(
    prompt_tokens=10,
    completion_tokens=4,
    cache_hit_tokens=6,
    cache_miss_tokens=4,
    )
    assert_eq(
    @shim.ShimResult(Completed({ "answer": "ok" }), usage~, steps=1).to_json(
    request_id="probe-1",
    ),
    {
    "version": 1,
    "request_id": "probe-1",
    "status": "completed",
    "output": { "answer": "ok" },
    "usage": {
    "prompt_tokens": 10,
    "completion_tokens": 4,
    "total_tokens": 14,
    "prompt_cache_hit_tokens": 6,
    "prompt_cache_miss_tokens": 4,
    },
    "steps": 1,
    },
    )
    }

    integral(Double) rejects fractional or unrepresentable Int counters when a dialect reads its CLI's numbers.

    #Run and classify the CLI

    run_cli(command~, args~, observe~, cwd?) calls the async observe callback for each stdout line. The callback updates the dialect's state and returns Continue or Stop.

    CliExitAdapter action
    Exited(code)Inspect dialect state for a report or failure; a nonzero exit without a report is Failed.
    StoppedThe observer requested a stop, usually a step ceiling: MaxStepsExhausted.
    CancelledParent stdin reached EOF: Interrupted.
    Failed(reason)Launch or stream handling failed: Failed.

    Stop, and the parent's cancel, signal the CLI with SIGINT by default and continue observing stdout for up to stop_grace_ms=3_000, allowing late usage to arrive. The CLI is then terminated if necessary. Async cancellation of run_cli itself re-raises after cleanup instead of becoming CliExit.Cancelled. A normal stdout close has a bounded 2-second exit wait. until_cancelled replaces the stdin-EOF cancel signal, for a test whose stdin is not a parent's pipe; stop_signal can override SIGINT.

    A dialect's usual run is: resolve the schema, build argv, run and observe, then return the status its state implies with the usage and steps it counted. Report wrapping, step definitions, tool policy, and schema application belong to the dialect.

    #Development

    serve.mbt is the shim's main loop; request.mbt parses the request (and options.mbt the shim's argv); result.mbt builds and writes the result file; shim.mbt runs the CLI. shim_test.mbt checks the result document and process teardown; shim_wbtest.mbt checks the parsers and drives whole runs through a scripted sh dialect into a result file. Keep recorded CLI output fixtures and their dialect classifier changes together.

    moon test shim --target native moon test shim --target wasm

    CliExit

    pub(all) enum CliExit {
    Exited(Int)
    Stopped
    Cancelled
    Failed(String)
    } derive(Eq)

    How a CLI run ended, from the shim's side. Cancelled is the parent's stdin EOF: the run is interrupted, and the parent is already winding down. Stopped is the observer's own decision (a step ceiling). Failed is a run that never got going — an unspawnable command, a broken pipe.

    Options

    pub struct Options {
    result_file : String
    command : String
    model : String?
    cwd : String?
    writable : Bool
    schema : String?
    extra : Array[String]
    }

    The shim's own argv, shared by both dialects: engine settings that are deployment configuration, never part of the request. Everything after -- is passed to the CLI verbatim — the escape hatch for a flag the shim does not know yet.

    --result-file where to write the result (required; the runner passes its {result_file} placeholder here) --command the CLI binary (default: the dialect's name on PATH) --model forwarded as the CLI's model flag --cwd the CLI's working directory --writable write-capable run (default: read-only unless kind = worker) --schema JSON Schema the final answer must match -- <args...> passed through to the CLI unchanged

    A dialect READS one; parse is how a shim gets it, off argv. A caller fabricating one (a dialect's own tests) goes through Options(...), so a later setting costs no dialect anything.

    Options::Options

    fn Options::Options(command~ : String, result_file~ : String, model? : String, cwd? : String, writable? : Bool, schema? : String, extra? : Array[String]) -> Options

    Options from their parts, for a caller that FABRICATES them instead of parsing argv. Only the binary and the result path are required — every other setting is a flag the operator did not pass, and writable defaults to the read-only run that argv defaults to.

    Request

    pub struct Request {
    request_id : String
    kind : String
    max_steps : Int?
    input : Json
    prompt : String
    schema : Json?
    }

    One request as the runner sent it: the request document (docs/child-contract.md §2). prompt is derived from input the way Workflow::agent builds it: a query plus optional hints.

    A shim READS one; serve gets it off the wire. A caller fabricating one (a dialect's own tests) goes through Request(...), so a later request field costs no dialect anything.

    Request::Request

    fn Request::Request(request_id~ : String, kind~ : String, input~ : Json, prompt~ : String, max_steps? : Int, schema? : Json) -> Request

    A request from its parts, for a caller that FABRICATES one instead of reading it off the wire. The step ceiling and the schema are optional, as on the wire.

    ShimResult

    type ShimResult derive(Eq,
    Debug
    )

    Everything a shim reports about one run: how it ended, what it cost when the CLI said, and how many steps the dialect counted.

    ShimResult::ShimResult

    fn ShimResult::ShimResult(status : Status, usage? : Usage, steps? : Int) -> ShimResult

    A result from its parts. Omit usage when the CLI reported none, and steps when the run never got to count any.

    ShimResult::to_json

    fn ShimResult::to_json(self : ShimResult, request_id~ : String) -> Json

    The result document answering request request_id, as serve writes it: usage and steps only when known, total_tokens derived.

    Status

    pub(all) enum Status {
    Completed(Json)
    NoReport
    MaxStepsExhausted
    Interrupted(String)
    Failed(String)
    } derive(Eq,
    Debug
    )

    How a shim run ended: the result-file statuses a shim can reach (docs/child-contract.md §3). The engine-side statuses a CLI shim cannot observe (context_yield, aborted) have no variant.

    Usage

    pub struct Usage {
    prompt_tokens : Int
    completion_tokens : Int
    cache_hit_tokens : Int
    cache_miss_tokens : Int
    cost_usd : Double?
    } derive(Eq,
    Debug
    )

    What a run cost, in the contract's counters, plus the CLI's own price when it states one. A dialect that learned nothing reports no Usage at all — an unknown cost is never a zero one.

    Usage::Usage

    fn Usage::Usage(prompt_tokens~ : Int, completion_tokens~ : Int, cache_hit_tokens~ : Int, cache_miss_tokens~ : Int, cost_usd? : Double) -> Usage

    A usage reading from its parts; cost_usd only when the CLI stated it.

    Usage::add

    fn Usage::add(self : Usage, other : Usage) -> Usage

    Two readings together. The price of the sum is known only when both prices are: a sum that left one out would under-report it.

    Verdict

    pub(all) enum Verdict {
    Continue
    Stop
    } derive(Eq)

    THE SHIM SIDE OF THE CHILD CONTRACT — what every process shim around a foreign agent CLI needs (one request line in, one result file out): serve parses the shim's argv and the one request line the runner writes, and writes the one result file; run_cli runs the CLI while the parent's stdin-EOF cancel signal stays live. shim/claude and shim/codex are the two dialects; this package knows no CLI.

    What the observer decides after each CLI stdout line.

    integral

    fn integral(value : Double) -> Int?

    JSON has one number type; an Int counter arrives as a Number. Reject a fractional value rather than truncating it.

    run_cli

    async fn run_cli(command~ : String, args~ : Array[String], cwd? : String, until_cancelled? : async () -> Unit, stop_grace_ms? : Int, stop_signal? :
    Signal
    , observe~ : async (String) -> Verdict) -> CliExit

    Run the CLI: spawn it with its stdin already at EOF (the prompt rides argv; OUR stdin is the parent's cancel channel and is never the CLI's), hand every stdout line to observe, and tear the CLI down when the observer says Stop or the parent closes our stdin. stderr is inherited, so the CLI's diagnostics reach the parent's stderr untouched.

    A Stop, and the parent's cancel, are GRACEFUL: the CLI gets stop_signal (SIGINT, its own Ctrl-C path) and stop_grace_ms to flush, and every line it prints meanwhile still reaches observe — a CLI that reports usage only at the end of a turn can still get its cost recorded on a capped or cancelled run. Then it is killed. Every error path — an observer that raises, a broken pipe, the caller's cancellation — closes the pipes and terminates the CLI before returning or re-raising; the task group would reap it anyway, but not before the caller had already been told.

    until_cancelled returns when the parent cancels the run; by default that is our stdin reaching EOF, the contract's cancel signal. A test, whose stdin is not a parent's pipe, passes its own.

    serve

    async fn serve(default_command~ : String, run~ : async (Options, Request) -> ShimResult) -> Unit

    A whole shim, as main: parse argv into Options, read the request line from stdin, hand both to run, and write the ShimResult it returns to the --result-file path (the child contract). stdin stays open afterwards as the parent's cancel channel, which run_cli watches.

    Every ending the shim controls writes a result: an error run raises is a failed result, and so is a malformed request that names its request_id. With no result path to write to, no request line, or a malformed request whose id is unknown, serve raises instead — let it escape main, which prints it and exits nonzero.