Coding harness built specifically for AMD Strix Halo
  • Rust 92.5%
  • Shell 2.8%
  • PHP 2.3%
  • Python 2%
  • RenderScript 0.3%
  • Other 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Simon Harms 0398a64bbc
A narrow terminal keeps the modal's reason inside its frame
At 40 and 30 columns the approval prompt lost its reason line - the whole
question, command and reason were elided as one title, and the elision cut the
last line - and the two long session choices ran past the box's right border.
The question's first line is the title and the rest is body; box_render clips
a line wider than its content box instead of only padding it.

Seen at 40x24 and 30x24 in a pty. Tests: ui 81.
2026-11-09 10:21:31 -05:00
.commandcode/taste replace the harness, TUI and CLI with the new runtime 2026-10-01 19:37:33 -04:00
benchmarks Re-run the live terminal benchmark after diff painting 2026-11-09 09:54:43 -05:00
crates A narrow terminal keeps the modal's reason inside its frame 2026-11-09 10:21:31 -05:00
docs A narrow terminal keeps the modal's reason inside its frame 2026-11-09 10:21:31 -05:00
php8 docs: 48% of phpMyAdmin's src parses (was 30%); the turn fuse is the real budget 2026-10-26 09:37:04 -04:00
queues docs: 48% of phpMyAdmin's src parses (was 30%); the turn fuse is the real budget 2026-10-26 09:37:04 -04:00
scripts A tool result says how much output there was 2026-11-09 09:26:44 -05:00
tasks docs: 48% of phpMyAdmin's src parses (was 30%); the turn fuse is the real budget 2026-10-26 09:37:04 -04:00
.gitignore Make the status bar and palette fit the terminal 2026-11-09 08:44:18 -05:00
.next-wave.lock harness: next-wave.sh, so a wave does not wait for someone to notice 2026-10-20 16:23:42 -04:00
batch.sh Make main the Rust-only tree 2026-11-07 14:34:06 -05:00
Cargo.lock Use accelerated SHA-256 for file tracking and verification 2026-11-07 16:26:45 -05:00
Cargo.toml Cut warnings and sync the optimized malaikat core 2026-11-07 14:31:59 -05:00
CHANGELOG.md A narrow terminal keeps the modal's reason inside its frame 2026-11-09 10:21:31 -05:00
commit-session.sh harness: every reader of the capture root uses the durable path 2026-10-22 10:21:51 -04:00
CONVENTIONS.md Make main the Rust-only tree 2026-11-07 14:34:06 -05:00
gap-rank.py harness: every reader of the capture root uses the durable path 2026-10-22 10:21:51 -04:00
harness-health.py harness-health: an explained exit is not a fault 2026-10-22 23:03:09 -04:00
HARNESS.md Merge branch 'jnana/session-harness' of https://git.simonharms.com/thesimonharms/mcode 2026-10-19 09:26:26 -04:00
merge-to-main.sh Make main the Rust-only tree 2026-11-07 14:34:06 -05:00
next-wave.sh next-wave: captures land under ~/.local/share, not the 24h-pruned scratch dir 2026-10-22 10:20:37 -04:00
parse-coverage.sh tasks: the three non-union parse gaps phpMyAdmin's src shows 2026-10-23 12:44:01 -04:00
pic-session.sh Make main the Rust-only tree 2026-11-07 14:34:06 -05:00
preflight.sh tasks: the two branches have to merge, and the merge is a session job 2026-10-18 21:35:03 -04:00
README.md Make main the Rust-only tree 2026-11-07 14:34:06 -05:00
recover-capture.sh harness: every reader of the capture root uses the durable path 2026-10-22 10:21:51 -04:00
ROADMAP.md Make main the Rust-only tree 2026-11-07 14:34:06 -05:00
RUST-MIGRATION.md mcode log --grep, and a stale suite count 2026-11-09 10:19:15 -05:00
selftest.sh harness: a PHP 8 surface probe, the round-1 tasks, and a relative-launch fix 2026-10-23 08:01:19 -04:00
session-loop.sh harness: the default profile is ornith-122b-iq3 2026-10-22 10:31:07 -04:00
verdict.sh harness: every reader of the capture root uses the durable path 2026-10-22 10:21:51 -04:00

mcode

A coding agent for local models, served by malaikat. It starts the model server for you, keeps a small local model on track, and checks its work.

cd your-repo
mcode

Bare mcode opens the TUI and starts the default profile (ornith-122b-iq3, a thinking model served with no reply cap) plus the Laya sidecar. Run one task and exit:

mcode -p "fix the failing test" --yes

Build

The implementation is Rust:

cargo build --release -p mcode

The binary is target/release/mcode. It embeds the malaikat library, so it starts and fronts the model server in its own process; no separate malaikat executable is needed. To use an external malaikat instead, set MCODE_MALAIKAT (or malaikat_bin in the config).

The Go implementation is preserved on the golang branch:

git checkout golang
go build -o bin/mcode ./cmd/mcode

Test

cargo test --offline --workspace --no-fail-fast -- --test-threads=1

The unit and integration tests use a fake model server, so they need no GPU. The Laya test that loads the installed weights is separate:

cargo test --offline --release -p malaikat-core --test laya -- --ignored

The Go tests are on the golang branch (go test ./...). The live Go test is behind a build tag:

go test -tags live -run TestLiveEndToEnd -timeout 6m ./internal/runtime/

Commands

Command Action
mcode the TUI in the current directory
mcode -p "..." [--yes] run one task, print the answer, exit
mcode approve not yet: approvals happen in the TUI
mcode model [name] list malaikat profiles
mcode goal [text|complete] show or set the durable goal
mcode log [--session X] [--type T] [--limit N] search the session log
mcode install [mclass|all] [--status] install the mclass companion
mcode version print the version

Flags: --cwd DIR, --model NAME, --mode lean|full|plan|minimal, --url URL, --yes.

A headless run exits 0 only for a task that verified as complete, 1 for one that did not, and 128 + signal for one its user stopped: SIGINT, SIGTERM and SIGHUP cancel the run's context, which every model call derives from, so the turn ends on [stopped] with a turn event in the session log instead of waiting out the step deadline (measured: the process is gone 106 ms after the signal). A second signal ends the process at once - a run that will not stop has no other way out - and it writes nothing on the way out.

Slash commands

Command Action
/plan [task] plan mode; /plan <task> plans it, /plan execute runs the plan
/mode lean|full|plan|minimal change the tool surface
/model [name] list or switch the malaikat profile
/goal <text> / /goal complete set or verify-and-close the goal
/verify run the acceptance checks now
/accept list proposed and active checks; /accept <text> accepts one
/accept reject <text> drop a proposal
/accept undo <text> un-accept a check
/compact compact the session now
/undo restore the files the last turn changed
/resume [id] resume a saved session
/yes / /ask allow every command this session, or ask again
/stop stop the current turn (Esc also stops)
/help, /quit
ctrl+k open the command palette
drag with the mouse select chat text; releasing copies it to the terminal's clipboard

What makes it work on a local model

  • A frozen prefix. The first system message does not change inside a session, so llama.cpp keeps its KV cache. Volatile state (goal, plan, todos, checks) rides on the newest request.
  • A lean tool surface. map, read, write, edit, bash, grep, glob, find, todo, criteria, explore, and skill or ask_user when the session has them. map comes first because an unscoped task is mostly a search problem: one call lists the files with their classes, functions and methods instead of four steps of glob, read, grep and read. mode plan is read-only (map, read, grep, glob, find, todo); mode minimal is read, edit, bash. A mode that offers fewer tools than the prompt names states the surface it really has in its system message, built from the registry the session was given, so the model reads its surface instead of learning it from a refusal.
  • A read-heavy search costs one summary, not a transcript. explore sends one question to a read-only subagent that works in its own history and replies with a short summary: the files it read never enter this conversation, which every later step would prefill again. It cannot write — the executor refuses any call outside its set — and explore is not in its own set, so the delegation stays one level deep. Its steps are capped by MCODE_EXPLORE_STEPS (default 8), and MCODE_EXPLORE=0 takes the tool away.
  • A valid history, always. One message type for the whole program, and the history is validated before every request. Tool calls are committed with their results, so the model always sees what it did.
  • The project's own checks. go test ./..., npm test, make test, and the rest are detected at start and run after any turn that changed files. A failure goes back to the model as the next turn. A check a task names in its own words — `sh check.sh` is the acceptance test and must report 0 failed — is adopted from that sentence and runs like a detected one. A check the model suggests is a proposal: you accept it with /accept before it runs.
  • Progress guards. The loop stops a model that only talks, repeats the same action, re-runs a step that already failed in the identical way, writes an essay, or fills the context.
  • A deletion is named, not silent. write refuses to halve a file; edit refuses to drop more than a fifth of one (at least five lines) unless the call sets allow_delete:true, and the refusal lists the lines it would lose.
  • A course correction, not a dead end. When a guard fires, the turn gets one more chance (guard_retries): the retry names the reason and asks for a different approach, with a smaller step and time budget than the turn it corrects. Verification failures have their own retry on top of that.
  • The end of the budget is announced. The step and time budget belongs to the task, not to one attempt, and a task that is nearly out of it is told so: [harness] Your budget for this task is nearly spent: about 5 model calls and 4m12s of wall clock left. Spend what is left on the smallest useful piece, run the acceptance check if you can, then write your closing report: what works (with the check's result), what does not, and what is left undone. It arrives with five model calls or five minutes left, whichever comes first. Measured on the shipped code, three of the five CRUD arms (104 model calls in 32m19s, and 197 steps twice) ended at the task's budget is spent with no closing report at all, because the harness's only note came at the moment of the stop.
  • A prompt budget. context_budget (or MCODE_CONTEXT_BUDGET) caps the prompt before the model's window does, because prefill is the wall on a local GPU: a measured 65k-token prompt cost nine minutes of prefill per step on a Strix Halo box, and compaction otherwise waits for window minus the output cap (~180k on a 196k profile). Set it to keep steps fast. MCODE_CONTEXT_BUDGET=32768 is a good starting point for a 30B-class MoE on an iGPU.
  • The prompt is not invisible. A step whose prompt passes prompt_notice_tokens (MCODE_PROMPT_NOTICE_TOKENS, default 8192) prints [prompt 65000 tokens, prefill 434 s, 18 tok/s decode, over the 32768-token budget], and the same line goes to the session log, so a nine-minute prefill is announced instead of looking like a hang.
  • A budget measured, not guessed. prefill_budget_seconds (MCODE_PREFILL_BUDGET_SECONDS, default 60, 0 disables) is how long one step may spend in prefill. A step over it caps the prompt at what the measured rate can prefill inside the limit — a measured 65000-token/434 s step prints [prefill 434 s for 65000 tokens (147 tok/s); compacting at 8192 tokens from now on] and compacts sooner — and the measurement is kept per profile, so the next session starts with [prefill: 147 tok/s measured on glm47-flash; 60 s of prefill is about 8192 tokens (context_budget)] instead of a static number.
  • A server that is dying is not a server that is working. The stall watch fires when nothing arrives, so a step that has collapsed to 0.2 tok/s — alive, but getting nowhere — was invisible. The decode rate over a window is now reported while the step runs (the server is generating at about 3.5 tok/s over the last 2 s (28 characters); the step is alive but going nowhere - lower the prompt budget or stop the run), and a call shorter than the window is caught by the server's own timings (the step's decode rate collapsed: 24 tokens at 0.2 tok/s). MCODE_SLOW_TPS (default 2, 0 disables) and MCODE_SLOW_WINDOW (default 30s) set the rate and the window; a healthy step prints nothing. The window is decode only — it starts at the call's first byte — so a step paying a long prefill is not reported as a collapse (measured before this: a step at 42 s of prefill and 33 tok/s of decode was reported as 0.01 tok/s). A report that stays true is a step that will not finish, so after MCODE_SLOW_GRACE (default 60s; 0 reports without ending anything) the step ends the way a stall does: the call is aborted, the server restarted and the step retried, because the restart is what brings the rate back. The grace runs from the window that is under the rate now, so a step that comes back above it is not cut.
  • A long prefill is not a dead server. The stall watch measures silence, and silence is what a server evaluating a long prompt looks like too: at 96k tokens that is two to three minutes with nothing on the wire, and the answer was to restart the server — which empties the prompt cache and pays for the whole prompt again (233 s of prefill at a 91,514-token prompt). Measured on session 4 against pic: two stalls in one 35-minute session, with 3.5-minute stretches of no server activity at all. The watch now asks the server before restarting, on GET /slots, twice a gap apart: a slot that reports more of the prompt processed than at the first sample is prefilling, the step is left alone and the wait is printed (the server is still prefilling: 41234 of 96000 prompt tokens processed, 8100 more in 5s) and written to the session log as a prefill event. A slot that has stopped, an idle server, one started with --no-slots and one that does not answer at all all fall through to the restart the watch already did. MCODE_PREFILL_PROBE_MS (default 5000; 0 turns the question off) sets the gap, and each sample is bounded (2 s), because the question is asked in front of the bound that ends the step: a /slots that does not answer must cost the watch a bounded wait and no more.
  • One call, one deadline. MCODE_STEP_TIMEOUT (default 15m, 0 disables) is the absolute bound on one model call. The stall watch needs silence and the turn's time budget is only checked between calls, so a request that never returned used to cost the whole task budget — measured: 25 minutes frozen inside one request, 34 minutes on the run. A call past the deadline is treated as a stalled server: the server is restarted and the step retried, and if it keeps hanging the run ends with the reason named.
  • A server that is loading is waited for. A chat call answered 503 Loading model used to end the run in fifty milliseconds with the raw body on stderr and exit 1 - measured at 0.058 s through the real binary against a scripted server on the MCODE_URL path, which is what a second run against a server that is still loading sees. The request never reaches the model, so nothing is lost by resending it: the call waits, with one line naming the status, the server message and the bound and another every 30 s while it waits, and the answer arrives when the model is up. MCODE_BUSY_WAIT (default 5m; a bare number is seconds; 0 turns the wait off) bounds it, and so does the step deadline. A server that never serves ends the run with waited 5m0s for the model server: llm: server returned 503: ... rather than restarting a server that has taken nothing.
  • Laya. The System-1 sidecar reads intent, command risk, and stalls. When it is not running, the harness continues without it.

Preference model

mclass classifies coding preferences. It is a native MiniLM encoder plus two heads: it reads the user text and writes a typed label. A durable preference in a user turn is saved during the turn, but only above a confidence floor (0.7, MCODE_PREFS_CONFIDENCE): the classifier reads a long imperative task as durable at about 0.63, and storing those would inject a task into every later session. A question is never saved. mcode install mclass downloads the model files (about 90 MB) from Hugging Face into ~/.mcode/opt/mclass/artifacts. No Python, no uv. Check the state with mcode install --status. Without the model, a built-in Bayes classifier runs.

Set MCODE_MCLASS=0 to force the Bayes classifier, or MCODE_MCLASS_ARTIFACTS to a directory to use different model files.

Verification model

mverify judges fuzzy acceptance checks. It is a small TF-IDF plus logistic regression classifier that asks: does this output satisfy this prompt? The model is embedded in the binary, so it always runs. No install, no subprocess, no Python.

On a verify pass the harness judges the output against the prompt once, then judges each fuzzy check the model wrote. A pass needs the verifier to say pass and a confidence at or above verify.confidence_threshold (default 0.7). /goal complete hands failures back to the model and retries, up to verify.max_retries (default 3). The automatic pass after an edit runs the deterministic command checks only, and while a turn is still running the project's own build and test commands are re-run every few edits: a failure there goes into the state block the model is shown, so it hears about the breakage while it can still fix it. Task-sourced checks wait for the end of the turn - a corpus oracle that takes a minute is not worth running on suspicion.

Set MCODE_MVERIFY to a path to use an external verifier instead.

The workspace's own checks run once, just before the first call that can change the workspace - not when it opens, so a session that only reads never pays for them: that run is the baseline a later failure is compared against, and a check that passed at the start and fails later is reported as a regression whatever the project's own threshold says (pic's corpus asks for 95%, so a session that broke one case out of 88 still read green). A run can also be told which paths are the oracle: MCODE_PROTECT=compat/cases/*.php,compat/run.sh makes write and edit refuse those files and name the pattern that protected them, which is what stops a model from editing the acceptance test it is being judged by.

Check kinds

A check can be a command, file_exists, file_matches, http, links, citations or fuzzy. The project's own checks are detected from the workspace and always run first: go build ./... and go test ./... for Go, the test/lint/build scripts for npm, cargo test, pytest, mvn, gradle, mix, rake, deno, make test, phpunit — and, for a plain static site with no build system, a links check that every relative href/src in *.html exists on disk and every remote one answers. That is what stands in for a test suite when the deliverable is a site: a page nobody wrote is invisible until somebody opens it.

Citations: an oracle for knowledge tasks

A resource that rests on outside knowledge has nothing local to test against, which is where a locally generated one invented six of its sources - authors, titles and years, every one of them shape-perfect and unfindable. The answer is to supply the ground truth in the workspace: a SOURCES.md listing what may be cited, one work per line.

  • - Gericke, J.F.C. & Roorda, T. (1847). Javaansch-Nederduitsch Woordenboek.
  • - Horne, E.C. (1974). Javanese-English Dictionary. Yale University Press.

Then every citation-shaped phrase in the artifacts (Title (Year), in prose or markup) has to match an entry by title and year, and a workspace with a SOURCES.md gets that check automatically. Title alone is not enough: a real book credited to an invented author and the wrong year looks fine to a title matcher, and that is the exact shape of the failure this exists for.

With MCODE_CITE_LOOKUP=1 a work the list does not cover is looked up in Crossref and OpenLibrary instead. If no index knows it, the check says so; if the index cannot be reached, it says that instead rather than calling a real work invented. The lookup is opt-in because a verdict that moves with someone else's uptime should be asked for.

A workspace with no checks at all is not a failed verification. It is reported as nothing to verify, and the model is not asked to fix anything.

Permissions

Every command is rated allow, ask, or deny. rm -rf /, curl | sh, a force-push to main, and writes to system paths are denied. Installs, inline interpreters, and network downloads ask. The prompt offers allow, allow for this session, allow all this session, and deny. --yes allows the ask class. A deny is never weakened.

The project's own detected checks are pre-approved, because you already agreed to run them by launching mcode in the project.

Files

mcode keeps state in .mcode/ in the workspace: sessions/ (the JSONL log and snapshots), criteria.json, history, and goals/. The log records every prompt, user turn, tool call and tool result, and what the harness itself told the model — the state block, the note that the budget is nearly spent, a guard nudge, the instruction that a project check has broken — so mcode log --type message reads a run back call by call, saying what the model was told as well as what it said. It also records how the run was configured, as config events: the workspace, the value in force for every setting the run could not read, the context window, the tools the mode offers, the checks it detected and the baseline they started from — so mcode log --type config answers what a stored run was told to use. How the run ended is a turn event of its own — the reason it stopped and the line it printed about it — so mcode log --type turn says whether a stored session ended on a reply, on its guard, on its budget or on a stop. It writes .mcode/.gitignore so a wildcard git add never picks it up. Global state lives in ~/.local/share/mcode and ~/.config/mcode.

Configuration

~/.config/mcode/config.json and .mcode/config.json merge over the defaults. Environment variables win over both. A number a knob cannot read keeps its default, wherever it was set, and the value in force is named at startup; a file that is not valid JSON is not applied at all and is named.

Variable Effect
MCODE_PROFILE malaikat profile
MCODE_MODEL model id sent to the server
MCODE_MODE tool surface
MCODE_URL attach to a running server
MCODE_MALAIKAT path to the malaikat binary
MCODE_MAX_TOKENS output cap for one reply
MCODE_MAX_STEPS step fuse for one turn
MCODE_LLAMA_ARGS extra malaikat serve arguments
MCODE_LAYA=0 turn the sidecar off; a URL pins it
MCODE_VERIFY=0 skip the acceptance pass
MCODE_VERIFY_AUTORUN=0 skip the automatic pass after an edit
MCODE_VERIFY_CONFIDENCE minimum confidence for a fuzzy check to pass
MCODE_PROTECT workspace-relative patterns this run must not change, comma separated (the oracle a task is judged by)
MCODE_MVERIFY path to the fuzzy verifier
MCODE_MCLASS=0 force the built-in Bayes preference classifier
MCODE_MCLASS_ARTIFACTS directory that holds the mclass model files
MCODE_PREFS_CONFIDENCE minimum durable confidence a preference needs to be stored (default 0.7)
MCODE_CONTEXT_BUDGET cap the prompt in tokens before the model's window fills (default: window − output cap)
MCODE_PROMPT_CACHE=0 drop the server-side prompt cache (it is what degrades after about ten requests)
MCODE_GUARD_RETRIES course corrections a turn gets after the model stops making progress (default 1)
MCODE_PROMPT_NOTICE_TOKENS prompt size at which a step reports its prefill cost (default 8192)
MCODE_EXPLORE_STEPS model calls one exploration subagent may spend (default 8)
MCODE_EXPLORE=0 take the explore subagent tool away
MCODE_STEP_TIMEOUT absolute deadline for one model call (default 15m; a bare number is seconds; 0 disables)
MCODE_BUSY_WAIT how long one call keeps resending to a server that answers 503 Loading model (default 5m; a bare number is seconds; 0 turns the wait off)
MCODE_SLOW_TPS decode rate below which a step is reported as too slow (default 2; 0 disables)
MCODE_SLOW_WINDOW window the decode rate is measured over (default 30s)
MCODE_SLOW_GRACE how long a step may stay under the slow rate before it is ended and the server restarted (default 60s; a bare number is seconds; 0 reports without ending)
MCODE_PREFILL_PROBE_MS gap between the two GET /slots samples that tell a long prefill from a dead server (default 5000; 0 asks nothing and ends the step on the stall bound alone)

MCODE_GUARD_RETRIES, MCODE_STEP_TIMEOUT, MCODE_BUSY_WAIT, MCODE_SLOW_TPS, MCODE_SLOW_WINDOW, MCODE_SLOW_GRACE, MCODE_PREFILL_BUDGET_SECONDS and MCODE_PREFILL_PROBE_MS keep their default when the value does not parse; only a literal 0 turns a bound off. A typo like MCODE_STEP_TIMEOUT=5m0 used to fail time.ParseDuration, then atoi, and land on that zero, which took the deadline away. So was the silence about it: a value the run could not read is now named once at startup, with the value in force - [MCODE_STEP_TIMEOUT="5m0" is not a duration (15m, 900s) or a number of seconds, so the run will use 15m0s] - and the same goes for the numeric knobs that need a positive value (MCODE_MAX_STEPS="0" is not a positive whole number, so the run will use 200). A value the run read is not named.

The same gates hold a value in a config file, which used to skip them entirely: guard_retries: -2 in .mcode/config.json keeps the default 1 and is named - [.mcode/config.json: guard_retries=-2 is negative, so the run will use 1] - so a file can no longer take away the one course correction this harness documents, in silence. A file that is not valid JSON is refused whole and named rather than half-applied: {"max_steps": 50, "max_tokens": "big"} moves nothing now, where it used to move max_steps and drop the bad key without a word.

Not finished

  • mcode update does not upgrade; build from source.
  • plan mode saves the plan text, and /plan execute runs it, but the plan is not yet shown as a first-class panel.