Runtime Adapters
Runtime adapters keep Gitmoot workflow logic independent from Codex, Claude Code, Kimi Code, omp, shell commands, and future runtimes. Gitmoot snapshots the agent template and rendered job prompt before handing work to an adapter.
Current Runtimes
- Codex starts and resumes sessions through the Codex CLI noninteractive commands. Prefer explicit session ids for long-running agents.
- Claude Code uses Claude CLI print/resume style commands when available. Restart the daemon or runtime session after changing token environment.
- Kimi Code starts a session with
kimi -p '<prompt>' --output-format stream-jsonand resumes or delivers follow-up work withkimi -S <session-id> -p '<prompt>' --output-format stream-json, parsing the session id from the stream-json output. Select it withgitmoot agent start <name> --runtime kimi. Authenticate once withkimi login, then restart the Gitmoot daemon so it inherits the session. Very large prompts (≥100 KiB): the Kimi CLI takes the prompt only as a single-pargument, and any single process argument above the kernel'sMAX_ARG_STRLEN(~128 KiB) fails to launch withfork/exec: argument list too long. When a rendered prompt reaches the 100 KiB safety threshold, Gitmoot stages it to a file in a dedicated temporary directory, grants that directory to the Kimi session with--add-dir(so Kimi's workspace-scoped file-read tool can open it), and passes a short instruction telling the agent to read that file as its full task, keeping the launch under the limit. Normal-size prompts are passed verbatim as before, unchanged. (The Claude and Codex adapters pass the prompt as an argv argument too, but their CLIs can also read it from stdin, so they have a native escape hatch.) Auth failures are not classified from output (#1857): a failed kimi job reports the CLI's own text and exit cause verbatim, with no gitmoot-added "authentication required" label, and a genuine authorization rejection is unlabelled too. Kimi relays its tools' output through the same streams it reports itself on, so the previous substring test labelled a read-only sandbox denial as an auth failure when the GitHub CLI printedTo log in, run: gh auth logininside it. Kimi's stream-json envelope carries no error or auth field, so there is nothing of kimi's own to classify from. - omp is the oh-my-pi CLI (v17.2.4), a multi-provider routing harness
rather than one vendor's CLI. Select it with
gitmoot agent start <name> --runtime omp. It advertisesreview,implement, andask— notproduce— and every job runs a fresh, stateless session. The full contract is below. - Shell invokes a configured shell command and is mainly for smoke tests, demos, and adapter contract checks.
The omp Runtime
omp (oh-my-pi) resolves its own provider per run from its profile auth storage
or from provider environment keys, so "which model answered" is a property of
the omp profile, not of the Gitmoot runtime name. Everything below is the v1
adapter contract.
Command shape
Every delivery — and agent start — builds one argument vector:
omp -p --mode=json --approval-mode=<policy> --no-session \
[--add-dir <path>]… [--model <M>] [--thinking <level>] [--max-time <s>] \
[--plan-yolo [--plan-yolo-into <M>]] \
[@<staged>/prompt.md] -- '<single prompt token>'
Each fixed element is load-bearing:
-p --mode=jsonselects print mode with the NDJSON envelope the adapter parses. Without it omp prints prose and every job fails result extraction.--no-sessionkeeps the run in memory: no per-worktree session files accumulate, and there is nothing to accidentally resume.--add-dirgrants the agent's writable paths first, then its readable paths (the same ordering Kimi uses). These are cooperative visibility hints for omp's own file layer, not an enforcement boundary — read-only paths are not made read-only by this flag.--thinkingcarries the resolved Gitmoot effort value (see below).--max-timeis emitted only when the job's context carries a deadline, at 90% of the time remaining. omp treats it as a hard deadline and still flushes a normal NDJSON envelope, whereas Gitmoot's own context deadline kills the process group mid-stream: the partial NDJSON is still captured as the job's raw output, but the terminalagent_endnever arrives, so the run fails as truncated instead of reporting what it did. The--max-timetrip is the quieter of the two, because the envelope it leaves behind is complete — see Truncation for how it is caught anyway.- The prompt is exactly one argv token after
--. Multiple positionals would become multiple separately billed turns, a prompt starting with-would be read as an unknown flag (exit 2), and one starting with@would be read as a file attachment.--disables all of that for the value after it. --plan-yoloappears if and only if the job asked for plan mode, and--plan-yolo-into <M>only alongside it. See Plan mode below.
The adapter resolves omp on PATH before spawning anything, so a daemon
that cannot see the binary reports a PATH problem instead of a parse error.
--thinking accepts a closed allow-list: off, minimal, low, medium,
high, xhigh, max, auto. A resolved effort outside that set is dropped
(no flag, omp's own default) rather than forwarded — a typo must not silently
downgrade a seat, and inventing a level a future omp rejects at parse time would
exit 2 with no output at all. Codex is the only other runtime that consumes the
resolved effort value; Claude and Kimi ignore it entirely.
Sessions: fresh every job, never resumed
v1 is stateless by construction, and that is a correctness requirement rather
than a simplification. omp's --resume runs switchToResumedProject, which
calls setProjectDir() with the resumed session's cwd and overwrites the
parsed --cwd. A resumed job would therefore edit the previous worktree while
the job's own worktree stayed clean — a green job with an empty diff, which
surfaces as an empty pull request that reads as success. The adapter never
passes --resume, --continue, or --fork, and Deliver ignores the stored
runtime reference entirely.
A registered omp agent's RuntimeRef may be a session UUID (what agent start
returns from the stream header), fresh:<suffix>, or — at the adapter's own
Validate — empty. last is rejected on purpose: it is the resume grammar of
the other runtimes, and accepting it would advertise a capability v1 does not
have.
Exit code 0 is not success
Under --mode=json omp's process.exit(1) for a model error lives inside an
if (mode === "text") branch, so a failed turn exits 0. Success is
parse-derived from the NDJSON stream, and every rule fails loudly rather than
returning an empty success:
- an assistant
stopReasonoferrororabortedfails the run even on exit 0 when it is still unrecovered at end of stream (see Retries and terminal state — omp retries transient failures on the same stream, so the first errored turn is not the run's verdict); - no
agent_endevent anywhere fails the run (the CLI died mid-stream, or the envelope drifted past recognition); - a final
agent_endtaggedisTerminal: falsefails the run (the stream stopped between scheduled continuations); - the final assistant message must be the answer: empty text there fails the
run — an empty review must never read as "nothing to flag" — and so does a
final
stopReasonoftoolUseorlength(see Truncation); - forward compatibility covers the rows the parser does not read, and only
those. Unknown event types stay skipped whether or not their payload decodes,
so new omp event kinds keep working, and so does a
message_endwhose role puts it outside the assistant filter (omp emits one for every user, developer and custom message of a turn, and their content is legally a bare string). But a row the parser was going to read and then could not fails the run, naming the shape: a load-bearing event (session,message_end,auto_retry_start,auto_retry_end,agent_end) whose payload does not decode; amessage_endthat decodes carrying no role at all (role is the discriminator that decides whether the parser reads the row, so a zero-valued one is a guess, not a reading); and a syntactically malformed line that still identifies itself as an assistantmessage_end(a"type"ofmessage_endand a"role"ofassistant). Each of those shapes would otherwise delete a failed final turn from the stream and hand an earlier turn's sentence back as the run's answer. Garbage that identifies nothing — a stray log line, a half-written tail carrying only one of those two markers — is still skipped.
Stdout is preserved as the job's raw output on every failure path, and the summary is the final assistant text, never the NDJSON envelope.
Truncation is not an answer
A run can be cut off while every rule above still passes, and the adapter
creates the conditions for it itself: --max-time rides on every
daemon-dispatched job, and omp enforces that deadline inside its tool loop. It
stops scheduling tools, pairs each pending tool call with a synthetic aborted
tool result, and closes the run with a terminal agent_end. Nothing on that
wire carries an error stopReason. The provider's output cap does the same thing
via stopReason: "length".
So the parser reads the final assistant message rather than the last one that
happened to carry text. A truncated run fails, and the error says TRUNCATED:
- a final message with no text — it was holding a tool call it never got to answer;
- a final
stopReasonoftoolUse(the loop was still calling tools) orlength(the provider cut the message mid-sentence), even when that message carries text.
The toolUse rule is deliberately wider than omp's own reading, and it is the
one place the adapter overrules the runtime: omp settles a toolUse stop as
complete as soon as the message carries a tool call or any non-whitespace text,
so a final toolUse message with real text is a run omp considers finished and
Gitmoot fails anyway. The tradeoff is chosen, not overlooked — a false red costs
one retry of a job, a false green costs a wrong merge verdict, and the two cannot
be told apart on the wire, because a work note ("Let me read the file first.") and
a finished review's verdict are the same bytes to the parser.
The rule this replaces would have answered such a job with whatever the run said before the cut — that work note reported as a review's verdict. A partial answer is a failure, never a success, and nothing is thrown away by that: the whole stream, including the text the rule discarded, is kept as the job's raw output.
Retries and terminal state
omp absorbs transient provider failures itself (retries are enabled by default, up to 10 of them, covering 5xx/overload/429/stream-stall), and the failed attempt and the successful one land on the same stream. So the first errored assistant turn is not the run's verdict, and latching it would fail a completed job and discard its answer. The rules the parser applies:
- A recovered retry is a success. What clears a pending failure is narrow: a later assistant message carrying real content — a non-whitespace text part or a tool call.
- A content-less turn can never clear a failure. omp's own classifier treats a stop with no text and no tool call as a failure shape, so letting one count as recovery would report a failed run as a success carrying a stale mid-run sentence as the job's answer.
auto_retry_endwithsuccess: falseis omp giving up, so it sets a failure in its own right; when nothing earlier in the saga carried a cause, itsfinalErrorbecomes the cause (that is the entire failure signal for a saga whose every message carried a non-error stop reason). That failure is latched: a content-carrying message arriving afterwards does not clear it, because a verdict the runtime issued about itself is not something a later message gets to overturn.- A retry saga still open at end of stream — an
auto_retry_startwith noauto_retry_endafter it — fails closed. The state machine ended mid-retry; "a retry was pending" is never "the error was being handled".
Token usage
Usage is per assistant message, not per run: the adapter sums usage.input and
usage.output across assistant message_end events. (Note the field names —
omp spells them input/output, not the input_tokens/output_tokens every
other adapter here uses.) The agent_end.telemetry rollup exists only when omp
runs under an OTEL telemetry config; it is preferred over the sum only when
every agent_end carried one and it is at least the per-message sum on both
axes, because the sum is a lower bound on what the run was billed. Usage from a
failed run is reported on every path — the exit-0 one (the dominant failure
here, since a failed omp turn exits 0) and the non-zero exit alike, including a
process group killed on Gitmoot's own deadline. It is whatever the stream proved
before the run died, so read it as a floor: a process killed mid-stream may
have been billed for a message whose message_end never reached stdout.
Autonomy policies
--policy | ordinary omp argument | Effect |
|---|---|---|
read-only | --approval-mode=always-ask | restricts omp to read tools; declared applied |
workspace-write | --approval-mode=write | permits edit tools but refuses shell and out-of-worktree writes; declared applied |
danger-full-access | --approval-mode=yolo | declared applied |
auto (default) | --approval-mode=yolo | declared widened |
The flag is always present for determinism: omitting it would inherit
whatever approval mode the host config carries, which is not deterministic
across machines. Its VALUE follows the stored autonomy policy
(#1721). Until that mapping
landed, all four policies passed yolo, so an agent stored read-only ran with
unrestricted tools on every non-seat dispatch.
:::caution Corrected
An earlier version of this page claimed that always-ask "would brick the runtime
rather than restrict it", because omp's read/grep/ls tools declare no permission
tier and every headless call would throw. That was wrong. Measured against
omp 17.2.4 headless with stdin closed and no host pre-approvals: read, grep
and read-on-a-directory all succeed (isError:false); bash and write return
isError:true with "requires approval but no interactive UI available" and the
write does not land; the process still exits 0 with a full agent_end envelope
the adapter parses. So always-ask restricts omp to read-only tools and
terminates cleanly — which is what a read-only policy wants. omp 17.2.4 has no
ls tool at all. That measurement is what the mapping is built on: read-only
now passes always-ask.
:::
For ordinary, non-seat deliveries, read-only stays enforced Gitmoot-side and
by omp's always-ask mode. The implement capability is refused at
agent start and agent subscribe when the agent carries auto/empty or
read-only, and an implement job for such an agent is refused again at
dispatch — both at CLI enqueue and in the daemon.
A kernel-enforced ReadOnlySeat is the exception. Gitmoot wraps every such omp
delivery in its Landlock sandbox and passes --approval-mode=yolo, declared
widened, so headless shell and test tools can run. Landlock, rather than omp's
interactive approval UI, is then the write boundary. Ordinary non-seat
read-only deliveries retain always-ask; omp still does not advertise
produce, so it never reaches the separate produce-stage wrapper.
Plan mode
omp ships a --plan-yolo mode and the adapter passes it — but only when the job
asks for it. A job payload carrying plan: true runs
--plan-yolo; adding plan_into: "<model>" also emits
--plan-yolo-into <model>, pinning the model the execution phase runs on (omp's
declared default target is the smol role). plan_into must be one non-flag
model selector without internal whitespace or control characters. An unpaired
or malformed target is rejected before dispatch.
Plan mode is workflow shape — plan first, then auto-execute — and it is a
separate field from the autonomy policy so the two are not conflated into one
overloaded knob. Separate is not independent: plan mode ends in an
implementation phase, and upstream omp adds the write tool to the active set
while plan mode is armed rather than removing it. A plan request is therefore a
write request, and the dispatch gate requires the same write-granting policy
(workspace-write or danger-full-access) that an implement job requires. A
plan-mode job on an auto/empty or read-only seat is refused — without that
check, plan mode was a write bypass, because the implement-job gate keys on job
type and never sees an ask job carrying a plan.
On omp/17.2.4, omp --version && omp --help declares that --plan-yolo starts
in read-only plan mode, auto-approves the model's plan on its first resolve call,
then executes it, and that --plan-yolo-into selects the execution model. That is
omp's versioned CLI declaration; the tool-set behavior above is what its source
actually does. the approval mode is unchanged by plan mode: a plan run and an ordinary run
of the same policy carry the same --approval-mode value.
The execution phase is a model switch. Left unset, omp resolves
--plan-yolo-into to its cheap @smol role, so the job's model would plan and a
different model would write the code. Gitmoot therefore resolves the target
itself: an explicit plan_into wins, otherwise the job's effective model carries
through. The payload's plan_mode records the resolved target —
plan-into:<model>, or plan-into:<runtime-default> in the one case where no
model is resolvable — so the evidence always says which model implemented, and
never a bare plan that cannot.
Only the omp runtime implements plan mode. A plan request routed to any other runtime fails loudly at dispatch instead of quietly running as an ordinary implementation. Runtime preflight evaluates the two plan flags only for a plan request, so an older omp CLI that lacks those optional flags can still run ordinary non-plan jobs.
Note that this is omp's plan-first discipline inside one run. Gitmoot's own plan step is separate and, per the owner ruling of 2026-08-14, also auto-executes: a plan proceeds into its implementation and stops only when it needs a confirmation or an answer from its parent seat or the owner.
Capabilities
review, implement, ask. produce is deliberately excluded: the produce
path runs the runtime under Gitmoot's Landlock wrapper, and omp ships as a Bun
binary whose sandbox interaction is unprobed. Advertising the capability before
it is proven would turn an unknown into a silent stage failure.
Runtime-family review signal
OMP remains the runtime wrapper, but it is not treated as one model family. After an OMP CLI process succeeds and its structured stream proves a complete run, the adapter records the provider reported with the final assistant message in the append-only job-event ledger.
The merge gate compares upstream families, not wrappers. OMP routes through
openai or openai-codex are the native Codex family, anthropic is the
native Claude family, and kimi-code is the native Kimi family. A provider
without a native adapter stays namespaced, such as omp:devin. Different
models or agent names using one provider remain one family.
Family diversity is advisory. A same-family comparison emits
merge_gate_family_advisory; missing successful provider evidence or a
role-only in-session implementation with no recorded family emits
merge_gate_family_unresolved. Neither event disqualifies an otherwise
substantive approval from a reviewer whose identity is independent of every
implementer.
For a review fan-out, every coordinating parent is an announcement rather than a
verdict, including nested fan-outs. The gate applies identity and family checks
to each approving leaf and records approval evidence on that leaf.
Transcripts
omp's raw NDJSON is retained verbatim by [transcripts] retention, and the
transcript translator is a deliberate passthrough for now: every row is
emitted undecoded and carries the transcript kind raw, so a JSONL export shows
"kind":"raw" on every omp line and omp's own event discriminator — the NDJSON
type field — survives only inside each row's raw JSON text. Do not write a
consumer that expects per-event kinds here. A decoding translator that lifts
tool calls and usage out of the stream is a named follow-up. The passthrough
exists because the alternative is not a graceful degradation — with no
translator case, job watch --transcript and
job transcript <id> would error even though the bytes are on disk, and
job transcript --all would abort the whole batch mid-stream, breaking the
export of every other runtime's jobs.
Credentials and environment
omp is a routing harness, so an authentication failure never means one fixed credential; the adapter's auth message names the profile auth storage, the provider environment keys, and the binary's install path, because a daemon that cannot see the binary and a daemon that cannot see a credential are the two failure modes operators actually hit.
Read-only OMP review and ask seats are stricter regardless of env_curation.
They require OMP_AUTH_BROKER_URL as an HTTPS or loopback HTTP origin, an
owner-only token file named by OMP_AUTH_BROKER_TOKEN_FILE, and a
provider-qualified effective model such as kimi-code/k3. An upstream
OMP_AUTH_BROKER_TOKEN in the daemon environment
is refused because the runtime requires seat-readable /proc. Gitmoot writes
the random job-local token and loopback URL only into the seat's minimal
config.yml; no broker secret enters the child environment. The proxy filters
credentials and usage to that provider, forwards only credential refreshes, and
acknowledges disable/block/accounting writes locally rather than mutating the
shared broker. The seat receives no upstream token, profile path, or ambient
provider key. Its daemon-staged OMP binary uses job-private PI_CONFIG_DIR and
PI_CODING_AGENT_DIR roots; the proxy and state are removed after delivery.
Missing or unsafe broker configuration refuses before runtime staging; an
unqualified effective model refuses before OMP launches.
Child-environment curation is off by default, and with it off omp inherits
the daemon's whole environment exactly like every other runtime. When
[credentials] env_curation = true is enabled, omp's curated allowlist is
routing plumbing only — OMP_PROFILE, PI_PROFILE, PI_CODING_AGENT_DIR,
PI_SMOL_MODEL, PI_SLOW_MODEL, PI_PLAN_MODEL, OMP_AUTH_BROKER_URL,
OMP_AUTH_BROKER_TOKEN. No raw provider key is listed; under curation a
provider key reaches omp only through omp's own profile auth storage or an
explicit env_passthrough entry.
Two caveats worth knowing before enabling curation:
- The
OMP_AUTH_BROKER_URL/OMP_AUTH_BROKER_TOKENpair really is a credential-acquisition path (omp discovers a broker-backed auth store from it), and the pair is indivisible: omp throws when the URL is set and no token is available, so dropping only the token converts an inherited URL into a hard failure. Drop both — and pass them through explicitly — if you want omp strictly fail-closed. --profileselects omp's auth/state store; it does not isolate process environment. A passed-through key is visible to every omp profile this daemon runs.
Heartbeats
Heartbeat runtime overrides are derived from the adapter registry, so omp
became a valid --runtime for heartbeats the moment it was registered — no
config change required. Operationally that means enabled heartbeats can dispatch
to omp, and therefore to whatever credential its profile resolves, as soon as
this ships.
Oversize prompts
Any single process argument above the kernel's MAX_ARG_STRLEN (~128 KiB) fails
to launch, so a prompt at or above the 100 KiB safety threshold is not passed on
argv at all. Unlike Kimi, omp has a native file channel: the adapter
stages the prompt to prompt.md in a dedicated temporary directory (never the
job worktree, so an implement job's git add cannot pick it up) and passes
@<abs-path> before --. omp's own CLI reads that file and inlines it into the
same turn, so no workspace grant and no agent file-read is involved. The
trailing prompt token becomes a short pointer telling the model the attached
file is the task. Above omp's own 5 MiB attachment ceiling the job is refused
instead: omp would substitute a (skipped: too large) placeholder and answer a
task it never received, which the adapter would otherwise return as a success.
Implement Jobs and the Commit Contract
Gitmoot owns the commit for implement jobs: it commits and delivers the
worktree's changes after the job finishes. Every rendered implement prompt
carries one deterministic sentence telling the worker not to run git commit
or git push. Ask and review prompts are unchanged.
For Codex, a workspace-write job whose checkout is a linked git worktree
gets one extra sandbox grant: the worktree's resolved git directory
(<main-repo>/.git/worktrees/<name>) is passed to the Codex CLI with
--add-dir, so routine git operations that write metadata (an index refresh
from git status, or git add) work inside the sandbox. The grant is
additive; it does not replace any writable_roots configured in the
operator's ~/.codex/config.toml. Read-only and danger-full-access sandboxes
are unchanged, and a primary (non-worktree) checkout gets no extra grant.
Metadata Registry
Each built-in runtime carries declarative metadata — advertised capabilities,
default model and effort values, an advisory list of known-valid models, and a
descriptor of where token usage is read from — seeded from compiled defaults
that reproduce Gitmoot's historical behavior. All of it is surfaced by
gitmoot runtime list (add --json for machine output). Two fields are
behavioral: default_model and default_effort. Every other field is
inspection-only.
Operators can override a built-in runtime's recorded metadata without
recompiling via a [runtimes.<name>] section in config.toml:
[runtimes.codex]
default_model = "gpt-5.5-codex"
default_effort = "high"
models = ["gpt-5.5-codex", "gpt-5.4-codex"]
capabilities = ["review", "implement", "ask"]
default_model is the fallback when neither the job nor agent pins --model:
job/agent model, then default_model, then the runtime CLI's own default.
default_effort follows the same precedence after job/agent --effort. Codex
receives the resolved value as -c model_reasoning_effort=<value>; omp receives
it as --thinking <level> when it matches omp's closed allow-list
(off|minimal|low|medium|high|xhigh|max|auto) and drops it otherwise; Claude and
Kimi do not expose a reasoning-effort surface, so it is a no-op for those
adapters. With both defaults unset, no model or effort is forced.
Every other field is inspection-only: models is advisory (Gitmoot never
rejects a --model based on it); capabilities gates nothing at dispatch; and
adapter behavior (auth, sandbox, session resume, stream parsing) always stays
in Go. With no [runtimes.*] section behavior is byte-identical. The section can
only tweak a built-in runtime; adding a new first-class runtime is a code
change, and an unknown runtime name is a config error.
Agent Session Values
RuntimeRef is runtime-specific:
- Codex accepts a session UUID, thread name, or
last. - Claude accepts a UUID or
last. - Kimi accepts a session id of the form
session_<uuid>or an empty value. - Kimi CLI (legacy) accepts a session UUID or an empty value.
- omp accepts a session UUID or
fresh:<suffix>, neverlast— it never resumes, and the reference is not used at delivery. - Shell uses the configured command.
Prefer explicit runtime session ids over last for durable agents. Use
gitmoot agent doctor <name> after subscribing or starting an agent.
Runtime Safety
Adapters should pass the rendered Gitmoot prompt through without rewriting
workflow semantics. Gitmoot parses the returned gitmoot_result object after
delivery and keeps raw output for diagnostics.
Use the plugin docs for runtime discovery setup: Codex And Claude Plugins. Use troubleshooting when session validation or resume fails: Troubleshooting.
The full adapter authoring reference lives in
docs/adapters.md.