Audience & purpose: contributors doing pre-release or post-change QA — a companion to development.md, the developer manual. Not for end users; if you're looking for how to use Crew rather than verify a change to it, see user-guide.md.
Every automated suite (bun run check) runs without a model call and without a human watching a
screen. Some things can only be verified by actually running omp, calling a tool with a real
model, and looking at what comes back — this document is the complete, current list of those
checks: what to run, in what order, exactly what you should see, and what it means if you don't.
Run these after any change that touches the daemon lifecycle, the IPC layer, an orchestration RPC
method, an OMP tool, or the monitor — the automated suites can tell you a function returns the
right value; only these checks can tell you the whole system, wired together, still behaves the
way architecture.md says it does.
- Adding a check: append a new numbered section (or a lettered sub-step under an existing one) once a change needs a check no existing section covers — what to run, in what order, exactly what you should see, and what it means if you don't, matching the existing sections' shape.
- Revisiting: when the behavior a check verifies changes, rewrite that check in place — command, expected output, and all — rather than leaving the stale version next to a new one; a check that describes behavior the system no longer has is worse than no check.
- Retiring: when a check no longer applies (the feature it verified was removed or superseded),
remove it from this document and record the retirement in
engineering-lessons.mdor an ADR, the same rulefuture-features.mduses for its own entries.
This document is the complete, current list of these checks — pruning it when a check goes stale is separate work from writing this rule down, and has not happened yet.
- Prerequisites
- Environment variables and configuration
- Owning what you test
- 1. The daemon through OMP (no model call, no extension CLI needed)
- 2. The embedded monitor (
/crewslash command, no model call) - 3. The orchestration tools (needs a real model call)
- 4. Worker adapters
- 5. Cross-agent workspace isolation (requires a real adapter)
- 6. Reading a finished run's output (
run/result— needs a real model call) - 7. Run completion semantics, live (ADR-0027 — needs a real model call)
- 8. The dashboard (no model call)
- Reading the widget line
- If something doesn't match
Same as development.md: Rust stable, Bun 1.3.14+,
omp ≥ 17.0.7 on your PATH. Build both sides first:
bun run setup # installs JS deps + builds the crewd runtime
bun run build # bundles the OMP extension to dist/index.js, loaded belowSeveral environment variables control Crew's behavior. Set these once per shell session:
# Override the state directory location (must be absolute)
export CREW_STATE_DIR=/path/to/state
# Vendor CLIs (claude, codex, copilot, the local omp model server) are ordinary installed
# dependencies, and this variable forbids the observation-only invocations -- live conformance
# suites, the availability probe, #[ignore]d live tests. Only the exact value "1" disables; "0",
# empty, and absent all permit.
#
# You do not normally need to set it. `.cargo/config.toml`'s [env] block sets it for every
# CARGO-LAUNCHED process, so `cargo test` is already gated and a forgotten prefix can no longer
# spawn a vendor binary. Setting it in your shell is only needed for something cargo did not
# start. To run live conformance deliberately, turn it off by VALUE, because [env] means it is
# never absent under cargo:
# CREW_DISABLE_VENDOR_CLI=0 CREW_LIVE_CWD=<trusted-dir> cargo test --test conformance
#
# It gates the conformance harness and test suite ONLY. A running daemon's run/submit spawns the
# real vendor CLI regardless of this variable, so any live run through omp/crewd in the sections
# below is a real, possibly billed, vendor launch:
export CREW_DISABLE_VENDOR_CLI=1
# Path override for the crewd binary (bypasses packaged binary discovery)
export OMP_CREW_BINARY="$PWD/target/debug/crewd"State directory resolution (in precedence order) — applied identically whether the extension
resolves --state-dir before spawning crewd, or you omit --state-dir on a bare crewd
invocation (StateRoot::resolve, crates/runtime/src/security/mod.rs):
CREW_STATE_DIR(must be absolute)$XDG_STATE_HOME/omp/crewwhenXDG_STATE_HOMEis set (must be absolute) -- or its legacy$XDG_STATE_HOME/omp/batmansibling, if only that one exists$HOME/${PI_CONFIG_DIR:-.omp}/crew-- or its legacy$HOME/${PI_CONFIG_DIR:-.omp}/batmansibling, if only that one exists
Configuration file locations (in precedence order, lowest to highest). The CLI itself has no
auto-discovery and no CREW_CONFIG-style environment variable — each layer is loaded only from a
path passed explicitly via repeatable --config <path>; the extension resolves and passes these
for you:
- User config —
~/.omp/crew.json - Project config —
<repo>/.omp/crew.json
Configuration files are strict JSON (crew.json, see architecture.md § Configuration and Policy) with unknown-key rejection at every
depth, failing closed with the exact JSON path that named the unknown key. Example:
// ~/.omp/crew.json
{
"limits": { "maxConcurrentWorkers": 4 },
"retention": { "period": "30d", "maxRuns": 20 },
"display": { "backend": "auto" },
"security": {
"patterns": [
"AKIA[0-9A-Za-z]{16}",
"sk-[a-zA-Z0-9]{32}"
]
}
}Security notes:
security.patternsis additive across layers (concatenated, never replaced), so a lower layer's redaction patterns can never be silently dropped by a higher one. An org pattern that fails to compile as a regex refuses the daemon's startup rather than degrading to built-in rules only.- Vendor CLIs are ordinary installed dependencies.
CREW_DISABLE_VENDOR_CLI=1forbids observation-only vendor invocation, and.cargo/config.toml's[env]block now sets it for every cargo-launched process, socargo testis gated without anyone remembering; CI sets it explicitly as well. It does not gate a running daemon —run/submitspawns the real vendor CLI regardless, so an unattended run throughomp/crewdcan still make a billed model call.
Two things get rebuilt independently — the crewd binary and the extension bundle
(packages/extension/dist/index.js) — and it's easy to run a check below while actually still
exercising the old build of either one. Before trusting any result in this document, make sure
you're exercising the build you just made.
The extension side has two speeds. For fast iteration that keeps skills, use omp --extension ./packages/extension + bun run build — you get live source feedback immediately without losing delegation-guard rules or any other functionality. Do NOT point --extension directly at packages/extension/src/index.ts (even though it does run TypeScript directly); that silently drops all skills, rules, and other package-provided tools because a FILE path establishes no package root. The tradeoff (live edit cycle, but no provider ecosystem) is rarely worth the loss.
The daemon side does not restart itself. crewd runs detached, and OMP connects-or-spawns
(ADR-0008, §1 below) — if a daemon started from your previous build of crewd is still alive, a
fresh omp session just reconnects to it over its Unix socket. Rebuilding crewd and re-running a
check without killing the old daemon first tests nothing new. After any Rust change:
crewd stop --repo "$PWD" # or: pkill -f "crewd serve"before your next omp invocation. --state-dir can be omitted here — the bare CLI resolves the
same state root the extension does (see
cli-reference.md's state-directory note)
— but only pass it explicitly when your current shell's environment might not match the one the
extension used to spawn the daemon (a different $CREW_STATE_DIR/$XDG_STATE_HOME, or none set
in one shell and set in the other).
Confirm identity, don't assume it. §1's expected output includes Binary source: override —
that's the extension reporting it resolved crewd via OMP_CREW_BINARY, not a downloaded release
(platform.ts's resolveCrewd). If you ever see Binary source: package when you meant to test a
local build, OMP_CREW_BINARY isn't set, or isn't pointing where you think.
Cheapest first check, no daemon needed. crew_doctor / /crew doctor (or
cargo run -p crew-runtime -- doctor) verifies config parsing, the state directory, and rollout
gates without spawning anything — run it before any section below when you just want to know "did
I break something obvious," without paying for a daemon spawn or a model call.
Match the section to what you touched — you don't need to run every section every time:
| You touched | Run |
|---|---|
| IPC/lifecycle/journal/daemon startup | §1 |
| Event model or monitor rendering | §2 |
| An orchestration RPC method or tool | §3 |
| Adapter code (claude/codex/copilot/omp-rpc) | §4b first (free); §4c only if the change could affect real vendor-CLI behavior |
| Workspace lease/apply/isolation | §5 |
run/result / redaction / read-back |
§6 |
| Run completion, turn-end settlement, follow-ups, the subagent guard | §7 |
Dashboard or /crew health surface |
§8 |
| Monitor resilience to daemon death | §2a |
The daemon is tested through the OMP extension, which handles the full lifecycle: connecting to an existing daemon or spawning a new one (the connect-or-spawn design, ADR-0008). This is the lowest layer you can actually exercise end-to-end.
export OMP_CREW_BINARY="$PWD/target/debug/crewd"
EXT="$PWD/packages/extension"
omp --extension "$EXT" --print "/crew health"Expect:
Crew runtime: running
Protocol: 1.0 (healthy: true)
Project: 18f82a46-....
Active runs: 0
Schema version: 1
Uptime: 0s
Binary source: override
Dashboard: http://127.0.0.1:<port>/?token=<token>
The Dashboard: line appears whenever the dashboard is enabled, and deliberately prints the full
URL including its access token — one-click discoverability was chosen over hiding the token
(see the comment above formatStatus in packages/extension/src/status.ts and
RuntimeStatus.dashboardUrl's own doc). Don't "fix" it; do treat transcripts containing it as
carrying a bearer credential. §8 tests the dashboard itself.
Run it again — same command, same repo:
omp --extension "$EXT" --print "/crew health"Expect the same Project id, with a higher Uptime. That's the connect-or-spawn design
(ADR-0008) reconnecting to the daemon it just started, not spawning a second one. If you rebuilt
crewd between these two invocations and still see the same Project id, that's the gotcha in
"Owning what you test" above — you reconnected to the daemon from your
old build, not the new one.
What this verifies: The extension's ensureRuntime() can derive the per-repo Unix socket
path (SHA-256 of the canonical VCS root, <stateDir>/repos/<repoId>/runtime.sock), spawn
crewd serve detached, connect with bounded exponential backoff, negotiate protocol v1.0, and
serve JSON-RPC requests. The daemon's single-instance flock locking, journal-before-shutdown, and
AdapterRegistry wiring into ServerConfig.run_driver all happen inside lifecycle::serve().
What you can verify here: The CLI's serve/status/stop/schema subcommands are fully
implemented and can be used directly. You can test the daemon's file-locking behavior (exit code
73 on double-serve), detached logging to runtime.log, and idle-timeout exit through the CLI
itself, not just through the extension.
The crewd CLI now provides direct access to all daemon operations:
# Start a daemon in the foreground
crewd serve --repo /path/to/repo --state-dir /path/to/state --foreground
# Query a running daemon's status
crewd status --repo /path/to/repo --state-dir /path/to/state
# Stop a running daemon
crewd stop --repo /path/to/repo --state-dir /path/to/state
# Display runtime events (replay + live)
crewd monitor --repo /path/to/repo --state-dir /path/to/state
# Export events to JSONL
crewd audit export --repo /path/to/repo --state-dir /path/to/state --output events.jsonl
# Print the JSON Schema
crewd schema
# Print the version
crewd versionAll commands accept --state-dir (defaults to .crew if it exists) and --repo (required).
The serve command additionally accepts --idle-seconds (optional, makes the daemon exit after
N seconds with no connections and no active runs) and --foreground (logs to stderr instead of
runtime.log).
The OMP extension registers an embedded monitor driven by a pure model.ts reducer over
EventEnvelopes and a render.ts formatter. It's accessed via the /crew slash command —
or via the crewd monitor CLI subcommand.
# Through OMP extension (interactive)
omp --extension "$EXT" # no --print: stays open, interactiveType /crew. Expect the widget to appear above the editor, showing run rows. An empty state
renders "No Crew runs yet."
What this verifies: The monitor controller subscribes to the daemon's event stream via
CrewClient.subscribe(fromSequence, cb), persists lastSequence via a custom
pi.appendEntry('crew-monitor', {sequence}) session entry for replay-on-restart, and updates
the widget on every event. The controller handles session_start (connect, then show the widget
only if the journal has runs — a run-free session stays hidden until the first run event, and a
dead daemon stays silent) and session_shutdown (unsubscribe).
With an interactive session open and at least one run row visible (the queued run from §3a
works), kill the daemon out from under it: pkill -f "crewd serve". Then take any crew action in
the still-open session (/crew health is enough). Expect the daemon to be respawned by
connect-or-spawn and the widget's subscription to heal and keep updating without restarting
omp. Through 0.6.0 the subscription died with the daemon and stayed dead until a full omp
restart; a widget that freezes while a fresh crewd is demonstrably up is that regression.
crewd monitor --repo /path/to/repo --state-dir /path/to/stateThis connects as a display principal, replays every event from sequence 0, renders one line per
contributing envelope, then follows new events live until interrupted (SIGINT/SIGTERM). A
transient disconnect reconnects and replays from the highest sequence already rendered plus one,
so no visible line is duplicated.
Options:
--run-id <id>— Render only the run matching this id (full, un-truncated form)
What this verifies: The CLI's monitor command connects to the runtime, replays events, and
renders them as plain-text lines until interrupted. This is the same logic as the embedded
monitor but exposed as a CLI subcommand for direct testing.
The 11 orchestration tools (crew_profile, crew_worker, crew_task, crew_run, crew_workspace, crew_artifact, crew_child, crew_violation, crew_message, crew_approval, crew_reconcile — see user-guide.md for what each does) are regular OMP tools the model chooses to call — this
genuinely needs a model, and each step below takes something like ten seconds to a couple of
minutes. Work in a scratch repository, never this one:
mkdir -p /tmp/crew-smoke && cd /tmp/crew-smoke && git init -q && git commit -q --allow-empty -m initomp --extension "$EXT" --print \
'Use crew_task to upsert a task. Then use
crew_worker to create a worker with fingerprint "sha256:smoke" and adapter "fake". Then use
crew_run to submit a run for that task against that worker with prompt "smoke test".
Report the taskId and workerId plainly.'Expect the model to report a taskId and workerId, and to say run/submit failed with
adapter_unavailable — and, importantly, that it can't report a runId from that call. That
last part is correct, not a bug in the model: run/submit's error response is
ServiceError { code, message }, with no data field at all, so the caller genuinely has no way
to learn the run's id from that one call alone.
The run was still committed as queued underneath. Look it up with a second call, using the
taskId from the response above:
omp --extension "$EXT" --print \
'Use crew_run with op "list" and taskId "<taskId from above>" to find the run that was just
submitted. Report the runId and state plainly.'Expect state: queued. The run is preserved even though nothing could start it — run/submit
never pretends a run started that it can't back, and it never drops the run just because no
adapter exists to run it (ADR-0013).
Open an interactive session and leave it running. This is a different invocation from the
--print calls above, and it matters that it stays open for the rest of this step:
omp --extension "$EXT" # no --print: stays open, interactiveType /crew. Expect one line, replayed from the daemon's journal the instant this session
started — it never touched the task/worker/run above, this is a brand-new session:
<runId-prefix> · queued · run queued
Now, without closing that session, open a second terminal and run the message-send call there — a separate, short-lived process that connects to the same daemon and exits on its own:
omp --extension "$EXT" --print \
'Use crew_message to send a "question" on runId "<runId from 3a>" from workerId
"<workerId from 3a>", taskId "<taskId from 3a>", payload "should I proceed?".'Go back to the first terminal — the one you never touched during that second call — and look at it again. Expect it to have updated on its own, with zero input from you:
<runId-prefix> · queued · messageRecorded recorded
That's the live-broadcast path: the first session was already subscribed to the daemon's event
stream, and the message-send (from a different process) got pushed to it over the socket it
already had open — no reconnect, no re-typed /crew, no polling.
Only the trailing "latest activity" field changes here; the run's own state stays queued
throughout, because this scenario never starts an adapter. A real crew_run against a
configured worker profile walks queued -> starting -> working; when the worker's turn ends,
the run moves to waitingUser and is handed back to the leader — a run is a conversation the
leader closes (ADR-0027). It terminalizes through crew_run op finish (or cancel), and a
vendor process that dies with no observable outcome is settled lost by the backstop — never
succeeded (crates/runtime/src/adapter/run_lifecycle.rs; §7 exercises this live).
Close the first session entirely (Ctrl+C or /exit) and start a third, completely fresh
one:
omp --extension "$EXT"Type /crew again. Expect the same final line, replayed cold by a session that has never
seen any of this before:
<runId-prefix> · queued · messageRecorded recorded
Nothing is lost, nothing duplicates. This is a genuinely different property from 3b's live-broadcast test — 3b required the watching session to stay open the whole time; this one requires it to be fully torn down and restarted. Both must hold; neither one proves the other.
Approval creation (ApprovalService::request) is only ever invoked by an adapter reporting it
needs human sign-off, and there is no approval/request RPC method — so there is no way to
trigger it from a live omp session. Exercise that half of the flow with:
cargo test -p crew-runtime --test approvalwhich drives ApprovalService directly, the same way this walkthrough can't.
crew_profile registered with an adapter that has no model configured yet never accepts a
model as a decision, whatever the caller passed: in an interactive session, crew opens the
model-selection dialog itself and asks the human, offering the current models for that adapter
(omp's own catalogue, or the vendor's own model family table when omp has no catalogue entry for
that provider — see below for which one you'll see). A leader-supplied model only preselects a
row in that list if it resolves to one of the options shown; the human's own pick is what actually
gets registered and persisted to the repository's .omp/crew.json. Later registrations for that
adapter reuse the stored answer silently, no dialog.
Without an interactive UI attached, or if the dialog times out with no answer, or if there is
nothing to offer (no catalogue entry and no vendor family table for that adapter), registration is
refused with a typed model-not-configured error — nothing is registered or persisted.
ompRpc and any caller-defined adapter have no catalogue and no dialog either way: an explicit
model is used exactly as given (this is unchanged from before the dialog existed), and omitting
it is what triggers the same model-not-configured error for them.
Once a model IS configured for an adapter, an explicit model on a later call is never a
second decision — it is resolved against omp's own model catalogue (omp models ls --json, read
fresh every call, never refreshed over the network) plus a small per-adapter table of
vendor-defined aliases (packages/extension/src/models.ts), and the result is only ever compared
to the stored answer. There are five outcomes; each is deterministic and free to exercise (write
.omp/crew.json yourself first, then just crew_profile calls — no model call, no dialog):
You call crew_profile with... |
Resolves to | What you see |
|---|---|---|
An id already exact in the catalogue, e.g. claude-opus-5 |
Itself | Registered silently — no note, nothing persisted differently from before model resolution was added (ADR-0032) |
A vendor alias, e.g. haiku for claude |
claude-haiku-4-5 |
model: claude-haiku-4-5 (resolved "haiku" via claude's own alias table) |
A shorthand matching exactly one catalogue id, e.g. sol for codex |
gpt-5.6-sol |
model: gpt-5.6-sol (resolved "sol" -- the only openai-codex model matching it) |
| A shorthand matching several catalogue ids | Nothing — refused | "<input>" matches N models for adapter <adapter>: <id>, <id>, ... -- name one of them exactly. (a typed model-ambiguous error; nothing is registered or persisted) |
| A name omp's catalogue does not know (typo, or a genuinely new/unlisted model) | Used as given, not persisted | model: <name> (not in omp's catalogue for <provider>; passing through UNVERIFIED -- the vendor will reject it if it is wrong) |
The same resolution also decides which dialog row a leader's suggestion preselects on
first use: a suggestion that resolves exact, alias, or match preselects that row; one that
resolves ambiguous or unverified (or doesn't resolve at all when the catalogue is down and it
isn't an alias) preselects nothing, and the dialog still opens with every real option on offer —
there is no way to preselect, let alone register, a name nothing could confirm.
The alias table itself is small and vendor-sourced (claude: fable/opus/sonnet/haiku, read
from the installed binary's own latest_per_family config, not its --help text; codex: the
single documented gpt-5.6 → gpt-5.6-sol; copilot: none, since it defines no aliases crew can
verify) — see the doc comment on VENDOR_ALIASES in models.ts before assuming it needs a new
entry for some other shorthand. A test in models.test.ts reads this table's claude half back
out of the installed claude binary's own baked catalogue and fails (or skips, visibly, if the
binary isn't present) when they've drifted apart.
When omp's catalogue has nothing for an adapter's provider (e.g. no anthropic credentials in
omp, so claude has none, even though the claude binary itself works fine), the dialog falls
back to that same alias table's values instead of refusing outright — currentModels in
models.ts names which source answered, and the dialog says so in its title.
Persistence follows verification, not success. An exact, alias, or match resolution is
confirmed and gets written to .omp/crew.json on first use, same as before model resolution was
added — and so is a dialog pick, which is a member of a verified list by construction. An
unverified name (only reachable once a model is already configured, comparing an explicit
override to it) runs but is deliberately not re-recorded.
The existing conflict check still applies, but now compares canonical ids, not raw spelling — a stored
claude-opus-5 and an explicit opus are recognized as the same model and accepted as a no-op,
not refused. A request that resolves to a genuinely different model than the one stored is still
refused with the same typed error as before:
model already configured as <stored> for adapter <adapter> -- crew_profile never overwrites a
stored model; edit the repository's .omp/crew.json directly to change it (/crew config path
locates it).
configuredModel in that message is always the raw text from .omp/crew.json (the file the
correction path points at), never the resolved canonical id — even when the stored value is itself
a shorthand. A conflict is refused, never silently overwritten and never silently dropped
(packages/extension/src/tools/profiles.ts, decideModel/resolveModelName in models.ts; see
models.test.ts for worked examples of each outcome above).
Use crewd stop to gracefully shut down the daemon:
crewd stop --repo /tmp/crew-smoke --state-dir /tmp/crew-stateOr, if the daemon is not responding:
# Find the daemon process:
pgrep -fl crewd
# Kill it if still running:
pkill -f "crewd serve"
# Remove the scratch repo:
rm -rf /tmp/crew-smokeSteps 1-3 never spawn a real Claude/Codex/Copilot/OMP-RPC process. This section covers the four supervised adapters, their conformance suites, and the worker coordination MCP surface.
The crewd conformance and crewd adapters CLI subcommands (see
cli-reference.md) run the same fixture/live suites as
the cargo test commands below and write a JSON report; crewd adapters --json is the quick
one-shot check that every adapter's fixture suite still passes. Use the CLI when you want a report
file or to check outside a Rust dev environment; use cargo test (below) when you want the
integration test harness's own assertions and #[ignore]/live gating.
Four vendor CLIs, plus everything from the top-level Prerequisites above:
claude --version # prints a version and confirms the CLI is authenticated -- do not pin the
# number here; it drifted from 2.1.251 to 2.1.263 in one week during wave 2
codex --version # verified baseline: codex-cli 0.145.0 (exact match required for the
# schema-compatibility check — see 4b)
copilot --version # verified baseline: GitHub Copilot CLI 1.0.73 (1.0.75 verified to work)
omp --version # verified baseline: omp/17.0.7 (17.1.1 verified to work)None of these baselines are a hard requirement — the conformance test suites measure what the installed CLI actually supports rather than trusting the version string; a newer patch version that still passes every fixture scenario is fine. Codex is the one exception: its adapter checks the installed binary's own generated JSON-RPC schema against a committed compatibility manifest, so an incompatible schema change (not just a version bump) fails that one check specifically, independent of everything else.
OMP_CREW_BINARY (the same override from the top-level Prerequisites) is how you point a real
omp session at your dev build rather than a packaged release — set it once per shell:
export OMP_CREW_BINARY="$PWD/target/debug/crewd"Build the daemon:
cargo build -p crew-runtimeFixture mode runs the conformance suites against committed JSONL fixtures under
fixtures/adapters/<name>/ — zero model calls, zero vendor CLI invocations. Run via cargo test:
# All four adapters, fixture mode (crewd CLI, black-box):
cargo test -p crew-runtime --test conformance
# claude-tui's own committed fixture (`fixtures/adapters/claude-tui/`):
cargo test -p crew-runtime --test claude_tui_fixtureFixture mode is TUI-sourced now (crew-v2 gap-closure; see ADR-0026) — the headless control
plane this section used to also exercise via a per-adapter test file
(claude_adapter/codex_adapter/copilot_adapter/omp_rpc_adapter) is retired; those files are
deleted along with it. Each vendor's own scenario probes live under adapter::tui::*_conformance
(exercised via conformance and tui_adapter above), not a standalone per-adapter test binary.
Expected shape (one array element per adapter for the full test; a single-element array otherwise):
[
{
"adapter": "claude-tui",
"mode": "fixture",
"version": "2.1.220",
"declaredCapabilities": { "protocol": "structured", "resume": "session", ... },
"effectiveCapabilities": { "protocol": "structured", "resume": "session", ... },
"scenarios": [
{ "name": "probe", "passed": true, "detail": "claude --version reported ...; authReady=true" },
{ "name": "read_only_start_and_progress", "passed": true, "detail": "..." },
...
],
"passed": true
}
]"Pass" for one adapter means top-level "passed": true — every entry in scenarios has its own
"passed": true. effectiveCapabilities only ever narrows declaredCapabilities, never widens
it: a scenario failure downgrades exactly the capability it disproves (e.g. a failed approval
scenario forces approvals to "none") and leaves everything else untouched. If "passed": false
anywhere, read that scenario's own detail first — it names concretely what failed, not just that
something did.
Every adapter's fixture report should show "passed": true throughout, with these documented,
intentional exceptions — genuine gaps or environment dependencies, reported honestly rather than
papered over with a fabricated pass:
| Adapter | Scenario(s) | Why |
|---|---|---|
codex-tui |
follow_up, cancellation_scope, session_resume, runtime_restart |
The installed codex-cli does not write a thread's rollout file to disk until a turn actually runs — resuming/following up/cancelling a turn on a never-turned thread needs a real (billed) turn, which fixture mode must never make. Live mode (4f.1) proves what it can for real when its gate is set. |
copilot-tui |
session_resume, runtime_restart |
The installed CLI (1.0.75) does not persist a never-prompted session across a process boundary — proving full persistence needs a real turn. |
copilot-tui |
unexpected_child_observation |
ACP protocol v1 has no session/update variant this adapter maps to a nested-worker observation — a genuine, currently-unimplemented gap. |
The headless control plane this section used to test directly against a per-adapter test file
(a real, billed call reached via cargo test --test claude_live -- --ignored, etc.) is retired
(crew-v2 gap-closure; see ADR-0026) — those test files are deleted along with it. See 4f.1
below, the TUI live conformance harness (crewd conformance --live --mode tui), which is now
the only live path against a real vendor CLI.
AdapterRegistry (the RunDriver implementation this section's conformance suites feed into) is
wired into the running daemon: lifecycle::serve()'s ServerConfig sets run_driver to an
AdapterRegistry instance (cargo test -p crew-runtime --test adapter_registry exercises it
directly).
However, whether the registry starts an adapter is still gated by PolicyEvaluator: the
limits.maxConcurrentWorkers concurrency ceiling (from crew.json), and a nested-worker check
that denies unexpected child workers pre-authorization. The wider org-governance surface this
evaluator used to also enforce -- model/adapter allowlists, a required-capability list, cost
ceilings, and the native_discovery_reviewed rollout gate -- is retired (crew-v2 gap-closure;
see
future-features.md):
that surface was config-sourced from the YAML org layer removed in that work, which was never
actually reachable in production.
Practically: submitting a run through a live omp session with a real adapter's vendor CLI
installed will attempt to start the adapter as long as the concurrency ceiling isn't reached
and the worker isn't an unexpected nested one. A denial names the dimension that refused (for
example concurrency ceiling 8 reached; 8 active runs), and an absent vendor CLI still reports
adapter_unavailable — a separate, availability-level answer.
To exercise the registry's own start/reject/authorize/construct logic directly:
cargo test -p crew-runtime --test adapter_registryThe supervised path (a real adapter's vendor process calling crew_task/crew_send
through its injected MCP config) is reachable from a live omp session once the vendor CLI is
installed and the merged org policy permits the adapter and model. Exercising it costs real
model calls, so the deterministic check below drives the same MCP server directly instead.
The MCP server side is now a real CLI subcommand (crewd coordination-mcp --state-dir <path> --repo <path> --run-id <id>, wired in 2026-08-02 — previously the argv every adapter's
MCP config already built pointed at a subcommand that didn't exist, so coordination-mcp
failed immediately with clap's unrecognized-subcommand error) and the scope-token-authenticated
in-process/subprocess plumbing behind it are fully built and independently tested against a
real compiled crewd binary, driven as a genuine MCP client would:
cargo test -p crew-runtime --test coordination_mcpThat suite spawns the real crewd coordination-mcp --state-dir ... --repo ... --run-id ...
subprocess, drives it over real stdio exactly as a supervised vendor CLI's own MCP client would,
and verifies crew_task/crew_peers/crew_send/crew_request_child/
crew_publish_artifact/crew_report_blocked/crew_ask_policy all land in a real
CoordinationBroker behind a real Server — including the scope/authorization negative cases
(missing, expired, wrong-run, post-vendor-exit, or unrelated-process credentials all fail; a
verified descendant of the same live vendor process may reconnect).
All four adapters (claude, codex, copilot, omp-rpc) default to TUI mode: each worker runs as the real vendor CLI spawned on a PTY inside a pane owned by a display backend (herdr / tmux / terminal). A viewer (or the harness) can type into that pane. Every burst of keystrokes written to a pane is journaled as a RuntimeEvent::OutOfBandInput { backend, pane_ref } (Rust type notation; see below for wire format) — the keystrokes themselves are never recorded, only that input happened and on which pane — and the run's needsReconciliation flag is set. This is the redaction-boundary guarantee for interactive control: a human steering a live run leaves an auditable trace without leaking typed content.
Manual check (observing the journal needs no model call; only starting the run does):
export OMP_CREW_BINARY="$PWD/target/debug/crewd"
EXT="$PWD/packages/extension"
# Terminal A: start a daemon + a TUI run against one of the four adapters,
# then leave it open. See user-guide.md for the exact run tool.
omp --extension "$EXT"
# Terminal B: tail the journal for the OutOfBandInput event
crewd monitor --repo "$PWD" --state-dir "$HOME/.omp/crew" | grep -i OutOfBandInputAttach to the run's pane via the active display backend (e.g. tmux attach -t <pane-ref> for tmux, or the herdr/terminal viewer), type a few characters, and confirm:
- Terminal B shows one
outOfBandInputevent per pane-write burst, carrying onlybackend+paneRef— no keystroke text. - The run's
needsReconciliationflips true (visible via/crewafter acrew_reconcile, orcrewd audit export --repo "$PWD" --state-dir "$HOME/.omp/crew" --output /tmp/audit.jsonland grep for the flag).
Attach-socket liveness (CREWATTACH1): when testing /crew reopen or pane persistence, the
attach socket must send CREWATTACH1\n as its very first bytes; a probe that doesn't see the
marker within 250ms marks the pane stale and returns -32602 instead of attaching to a
fork-inherited dead socket. See
cli-reference.md § Attach Socket Liveness.
crewd conformance --live --mode tui walks the scenario set against the real interactive vendor CLIs on a PTY (tui is the only accepted --mode value and its default — the headless control plane this also used to reach is retired, --mode headless is a typed rejection now, see ADR-0026). --adapter takes all or one of claude, codex, copilot, ompRpc; --output <path> writes the JSON report.
# billed model calls for claude/codex/copilot; omp-rpc reaches a model only when a turn runs
CREW_DISABLE_VENDOR_CLI=0 CREW_LIVE_CWD=/tmp/crew-smoke-proj \
./target/debug/crewd conformance --live --mode tui --adapter all --output /tmp/live.jsonObserved per-vendor outcomes (this release):
probe— pass: real vendor CLI reachable, declared capabilities intact, adapter spawns it on a PTY.cancellation_scope— pass:cancel(CancelScope::Worker)terminates the vendor process and aProcessExitedis journaled.read_only_start_and_progress/follow_up— pass for claude and omp-rpc; codex passed both earlier the same day and is currently blocked by an explicit vendor credit wall (usage_limit_exceededrecorded in its rollout); copilot's turns are refused with an explicit monthly-quota error after submit+discovery were proven. The original failures were never a "vendor limitation": the adapter typed the prompt and its Enter as one atomic write at a fixed moment after spawn. Whether the vendor's TUI processes that Enter depends on where its render loop is when the bytes land -- too early (stdin not yet wired) or mid-layout and the CR is swallowed; machine load shifts that timeline, which is why identical bytes submitted on some runs and not others. The fix is incrates/runtime/src/adapter/tui/adapter.rs: two-phase delivery (text once stdin is wired, Enter only afterENTER_IDLE_MINoutput silence), plus a 150ms text-to-Enter gap on queue-style sends (an atomictext\ris swallowed whole by codex). If a scenario fails, check the vendor's own session store first:~/.claude/projects/,~/.codex/sessions/,~/.copilot/session-state/(note: current copilot versions write<session-id>/events.jsonlinside per-session directories, not flat files),~/.omp/agent/sessions/<raw-cwd-slug>/(omp slugs the cwd as given; it does not resolve/tmpto/private/tmpthe way claude does). A rollout/session file containing your prompt but no assistant reply means billing, not the adapter.session_resume— skipped: a single-process resume is not a daemon restart; genuine restart recovery is proven by the separate serve→stop→serve end-to-end smoke, not this report.
When a requested display backend or placement is not available, the runtime attempts to downgrade to the hidden backend instead of failing the run. This downgrade is journaled as a paneDowngraded event (note camelCase in the wire format) carrying:
requestedBackendandrequestedPlacement: what the run asked foractualBackend: the backend that was used instead (alwayshiddenon downgrade)reason: a redacted error message explaining why creation failed
To observe this behavior, attempt to attach a run with a backend/placement combination the current environment doesn't support (e.g., request Workspace placement on tmux, or run in an environment with no active display server). The run should proceed with hidden backend, and the journal should contain one paneDowngraded event.
Verify via crewd audit export --repo "$PWD" --state-dir "$HOME/.omp/crew" --output /tmp/audit.jsonl and grep for paneDowngraded:
grep paneDowngraded /tmp/audit.jsonlWhat this verifies: display backend selection is resilient — when a pane cannot be created at the requested backend, the runtime logs the failure and falls back gracefully instead of failing the run.
This section verifies that two parallel runs execute in separate git worktrees, each with its own
isolated workspace. It exercises run/submit with workspaceMode: "isolated", the two-phase lease
acquisition (allocating → materialize → activate), and the crew_peer_workspace coordination
tool for cross-workspace review.
Everything from §4a above, with an org policy that permits the adapters and models you intend to run, and the daemon built and ready:
export OMP_CREW_BINARY="$PWD/target/debug/crewd"
mkdir -p /tmp/crew-cross-agent && cd /tmp/crew-cross-agent && git init -q && git commit -q --allow-empty -m initUse crew_profile to register two profiles (one per adapter), then crew_worker to create
workers with those profileIds:
omp --extension "$EXT" --print \
'Use crew_profile to register a Claude profile with adapter "claude", model "<your model>",
source "manual-test", and startupOptions {"claude":{}}. Then register a second profile for
"codex" with startupOptions {"codex":{}}. Report both profileIds plainly.'Then create two workers, each with one profileId:
omp --extension "$EXT" --print \
'Use crew_worker to create two workers, one with profileId "<profileId1>" and one with
profileId "<profileId2>". Report both workerIds plainly.'Create a task, then submit two runs with workspaceMode: "isolated":
omp --extension "$EXT" --print \
'Use crew_task to upsert a task.
Then use crew_run to submit two runs: one for workerId "<workerId1>" and one for
workerId "<workerId2>", both with the same taskId, workspaceMode "isolated", and prompt
"Create a file hello.txt containing your adapter name". Report both runIds and workspacePaths
plainly.'Expected: Both run/submit calls return Ok with distinct runIds and distinct
workspacePaths, each of the form /tmp/crew-workspace-<projectId>/<runId>. Confirm on disk:
git -C /tmp/crew-cross-agent worktree listBoth workspace paths should appear as detached worktrees.
omp --extension "$EXT" --print \
'Use crew_run with op "get" for runId "<runId1>" and runId "<runId2>". Report each run
state and workspacePath plainly.'Each response must carry workspacePath and workspaceMode: "gitWorktree".
Verify the worker coordination surface can resolve a peer's workspace. From the worker MCP side,
crew_peer_workspace { peerRunId: "<other runId>" } returns the peer's path,
isolationKind, and state. A call with a runId belonging to a different task fails with
"peerRunId is not a run on this task".
Release both workspaces via crew_workspace with op: "release", then verify the worktrees
are gone:
git -C /tmp/crew-cross-agent worktree listThe worktrees should no longer appear. Clean up the scratch directory:
rm -rf /tmp/crew-cross-agentVerifies Gap 2 of the multiagent-cooperation design: the model can read a worker's final answer and chain it into a second run. Work in a scratch repository:
mkdir -p /tmp/crew-result-smoke && cd /tmp/crew-result-smoke && git init -q && git commit -q --allow-empty -m initResolved (ADR-0027, 2026-08 fix wave): through 0.6.0, completion was keyed solely on process exit — and since the adapter deliberately keeps the vendor CLI alive after its final answer, a live run never finished on its own and this section required a manual
cancel. Completion is now settled by the worker's turn end: the run moves towaitingUserwith the pane still open,op: "result"is readable right there (a settled turn qualifies, terminal not required), and the leader closes the run withop: "finish". A live run still sitting inworkingafter its answer has visibly arrived is a regression of the turn-end guard, not the old limitation.
omp --extension "$EXT" --print \
'Use crew_profile to register a Claude profile (adapter "claude", a model of your choice,
startupOptions {"claude":{}}, source "manual-test"), crew_worker to create a worker from
that profileId, crew_task to upsert a task, and crew_run to submit a run with prompt
"Reply with exactly the word pomegranate and nothing else". Poll crew_run op "get" until
the state is waitingUser or terminal, then call crew_run op "result", report resultText and
usage plainly, and close the run with op "finish".'Expect: resultText containing exactly pomegranate; usage.inputTokens and
usage.outputTokens both > 0; state waitingUser at read time, succeeded after the finish.
Calling op: "result" while the run is
still working is refused with run <id> is not finished (state: working) — that refusal is
correct behavior, not a bug: a partial answer is never returned.
omp --extension "$EXT" --print \
'Call crew_run op "result" for runId "<runId from 6a>". Then submit a second run on the
same worker and task whose prompt embeds that resultText: "The previous worker said:
<resultText>. Reply with the fruit it named, uppercased." Poll it to waitingUser, read its
result, finish it, and report the result plainly.'Expect the second run's resultText to contain POMEGRANATE.
What this verifies: the full read-back path — journaled adapterMessageFinal → redaction
boundary → run/result fold → Ajv-validated result → the model composing the next prompt from
it. This is the chaining primitive every multi-worker synthesis flow builds on. Clean up as in
§3's "Clean up".
Through 0.6.0, run completion was keyed on process exit — and since TUI adapters deliberately keep
the vendor CLI alive after its final answer, a live run could never finish on its own (the first
live test failed exactly here). ADR-0027 replaced that with turn-end settlement: a worker's
finished turn hands the run back to the leader as waitingUser, and the run is a conversation the
leader closes. Fixture mode (§4b) proves the parsing; only these checks prove the live behavior.
Work in a scratch repository as in §3/§6.
Register a claude profile/worker/task and submit a short run (as in §6a). Expect, in order:
- The pane attaches in your host (tmux → split, herdr → pane, plain terminal → a tab of that same
terminal app) —
pane attached: <backend>in/crew. - The run walks
queued -> starting -> working. - When the answer lands, the run moves to
waitingUserwith the vendor CLI still running — nothing exits, and nothing needs to. crew_runopfinish(optional outcomesucceeded|failed, defaultsucceeded— the leader states the outcome, it is never inferred from the vendor's turn markers) terminalizes it.- The full submit prompt is in the journal, redacted (
runPromptEvent, ADR-0028) — check/crew run <runId>or an audit export, never expect raw text.
A vendor process that dies with no observable outcome is settled lost by the backstop. Two
routes to succeeded are legitimate and neither involves the turn: a clean zero exit
(terminal_state_for, ADR-0023) and an explicit leader finish. A journaled turn end is not
one of them — the check is that succeeded never appears without either a clean exit or a
finish. A run that settles succeeded off the back of a turn end is the bug ADR-0027 exists to
prevent, whether or not the turn end itself was journaled correctly.
On the waitingUser run from 7a (before finishing it), send a crew_message with kind
followUp to its worker. Expect the payload to be delivered into the live pane as a follow-up
turn (RunDriver::send_follow_up; delivery is two-phase — text once stdin is wired, Enter only
after output silence), the run to return to working, and a re-settle to waitingUser when that
turn ends. Every message kind delivers down this path; the one exception is steer, which needs
the adapter's interrupt-then-compose capability and is refused with capability_unsupported
on adapters without it — a typed refusal there is correct behavior, not a finding
(crates/runtime/src/adapter/registry.rs).
When the run resumes (either from an explicit follow-up message or from a genuine user-authored
turn in the vendor's transcript), a runResumed event is journaled with a cause field
distinguishing the two paths (note camelCase in wire format):
cause: "followUpMessage"— the leader sent an explicit follow-up viacrew_messagecause: "realUserTurn"— the vendor's transcript contained a genuine new user-authored entry (type"user", not a sidechain), not bookkeeping or metadata
Verify the event appears in the audit export:
crewd audit export --repo "$PWD" --state-dir "$HOME/.omp/crew" --output /tmp/audit.jsonl && grep runResumed /tmp/audit.jsonlExpect one runResumed entry with the appropriate cause. This event documents what triggered
resumption, so consumers (dashboards, tools, audit trails) can distinguish between user-directed
follow-ups and resumptions triggered by new transcript content.
Submit a run whose prompt forces subagents, e.g. "Use your Task tool to dispatch two parallel subagents, each summarizing a different file; then combine their answers yourself." Expect:
- The run stays
workingthrough every subagent's turn end — a session-file entry withisSidechainis a subagent's turn and must never settle the parent run (crates/runtime/src/adapter/tui/claude.rs; unit-proved bya_sidechain_entry_never_ends_the_parent_runs_turn, but only a live claude session dispatching real subagents proves the guard against real session files). adapterNestedWorkerObservedappears in the run's activity.- Exactly one settlement, on the parent's own turn end. A
waitingUsermid-dispatch is the guard failing, and a finding.
Submit a run whose prompt is well over 8 KB with the instruction at the very end (e.g. "reply
with the final word of this prompt"). Expect the answer to prove the tail arrived, and the journal
to carry the full redacted text. Through 0.6.0 a multi-KB prompt was silently truncated — one
atomic PTY write; delivery is now two-phase with an idle-gated Enter
(crates/runtime/src/adapter/tui/adapter.rs).
Timeout facts, when they occur, are decided with crew_run op timeoutAck
(decision extend | nudge | abort); forcing one live requires waiting out the timeout sweep,
so it's not part of the standard pass.
/crew health prints the dashboard's full tokenized URL when the dashboard is enabled (§1).
Checks:
-
Open the printed URL: the page loads, and the token disappears from the address bar — a valid
?token=onGET /is exchanged for thecrew_dashboardcookie via a 303 redirect so the secret leaves the URL, browser history, and anyReferer(crates/runtime/src/dashboard/mod.rs). The exchange is the design, not a leak — and a reload now renders via the cookie, so a working page after reload is correct, not a gate failure. -
The gate itself is tested without the cookie — a private window, or:
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:<port>/' # expect 401 curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:<port>/?token=bad' # expect 401
Expect
401both times, and nothing else: access control runs before routing, so an unauthorized request cannot reach a handler at all — not even a 404, which would confirm which paths exist. -
With runs present (§3a's queued run needs no model call), runs appear with their states; after any §7 run, usage and cost figures populate from
adapterUsageReportedevents. The journaled prompt is not shown on the dashboard today (that column is future work) — read it via/crew run <runId>or an audit export instead. -
Known adapters show their real vendor mark.
claude,codex,copilot, andomp/ompRpceach render an inline vendor logo in the run/worker table instead of the plain BRAND.md colour cell; an adapter with no supplied mark (or a run whose worker row is missing, so its adapter can't be proven) falls back to the neutral colour cell exactly as before this feature — that fallback is correct, not a regression (crates/runtime/src/dashboard/page.rs::markOf,LOGOS). -
Reload or reconnect now shows history immediately. Open the dashboard (or kill and restart your connection) after runs already exist, with nothing new happening: every already-committed run/event appears right away, replayed from the journal — before this fix, a viewer connecting after the fact saw an empty feed until the next live mutation. A brief network blip that reconnects the
EventSourcemust not duplicate any row already shown (the client tracks rendered sequences and skips a replay/live duplicate of the same event). -
A stale dashboard token after a daemon restart says so, and stops retrying. Restart the daemon (
crewd stop --repo "$PWD"then let/crew healthrespawn it) without reloading the already-open dashboard page: the live indicator changes to "dashboard link expired" (title text names/crew healthas the fix), not the generic "daemon not running" — the old message was actively wrong here, since the daemon is running, just behind a new per-run token this page's cookie no longer matches. The page probes/api/stateon the nextEventSourceerror specifically to tell a rejected token (401, unrecoverable — retries stop) apart from a genuinely unreachable daemon (no response after 3 failures, retries continue, "daemon not running"). Reload the page to pick up a fresh token from a new/crew health.
The /crew widget is a rounded border (drawn by
packages/extension/src/monitor/render.ts::assembleBox) with an icon header
(renderWidgetHeader) spliced directly into the top border line. Each row inside the box is
prefixed with a per-state Nerd Font icon (render.ts::stateIcon) before the state word, and
colored per-state (render.ts::stateColor). Underneath the icon, the joined structure of each row
(rendered by renderRowLine) is unchanged:
<first 8 chars of runId> · <icon> <state> · [adapter/model] · [flags] · [pending approvals] · [workspace mode] · <latest activity>
— joined by ·, with any part that's undefined simply omitted. In this walkthrough there's no
real adapter, so you'll only ever see the run id, state (always queued here), and
latestActivity, which is set per event kind (packages/extension/src/monitor/model.ts):
| Event | latestActivity |
|---|---|
RunEvent |
"run " + state (e.g. "run queued", "run starting", "run working") |
MessageEvent |
"${kind} ${deliveryState}" (e.g. "messageRecorded recorded") |
ApprovalEvent |
"approval requested: <action>" or "approval decided" |
ChildEvent |
"child worker requested", "child worker accepted", or "child worker request denied" |
AdapterProtocolHealthEvent |
"protocol healthy" or "protocol unhealthy: <detail>" — the vendor's own error subtype/stop reason when one was journaled |
PolicyViolationRecorded |
"policy violation: <code>" |
PolicyViolationDecided |
"violation decided: <resolution>" |
AdapterUsageEvent |
"usage <inputTokens> in / <outputTokens> out", plus " ($<costUsd>)" when cost was reported |
AdapterArtifactEvent |
"artifact <artifactKind> <artifactId>" |
DisplayEvent |
"pane attached: <backend> (<paneRef>)" or "pane detached: <backend>" |
WorkspaceEvent |
"workspace <kind>" |
(RunFlagsEvent updates the row's flags but sets no latestActivity of its own.)
The widget caps at MAX_WIDGET_ROWS rows (now 7) — not 10 — because the host's ctx.ui.setWidget
truncates array-content widgets at 10 total lines, and the border chrome (2 lines, plus a
possible overflow line) has to fit inside that same 10-line budget alongside the rows. When
truncated, the box appends "… N more; use /crew run <runId> for full details." as its last
row, before the bottom border. The /crew run <runId> detail block is a labeled multi-line
dump: Run/Task/Worker/State/Harness-model/Flags/Pending approvals/Workspace mode/Latest
activity/First seen/Last event.
See code-walkthrough.md's §4 debugging playbook first —
most manual-test surprises (METHOD_NOT_FOUND, an empty /crew, connect timeouts) are covered
there with the exact cause. If a step in this document produces something not described here or
there, that's either a real regression or a gap in this document — both are worth fixing; open an
issue or extend this file, the same way the run/submit error-shape gap above was found by
running the walkthrough for real and getting confused by it.