- Rust 92.5%
- Shell 2.8%
- PHP 2.3%
- Python 2%
- RenderScript 0.3%
- Other 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .commandcode/taste | ||
| benchmarks | ||
| crates | ||
| docs | ||
| php8 | ||
| queues | ||
| scripts | ||
| tasks | ||
| .gitignore | ||
| .next-wave.lock | ||
| batch.sh | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| commit-session.sh | ||
| CONVENTIONS.md | ||
| gap-rank.py | ||
| harness-health.py | ||
| HARNESS.md | ||
| merge-to-main.sh | ||
| next-wave.sh | ||
| parse-coverage.sh | ||
| pic-session.sh | ||
| preflight.sh | ||
| README.md | ||
| recover-capture.sh | ||
| ROADMAP.md | ||
| RUST-MIGRATION.md | ||
| selftest.sh | ||
| session-loop.sh | ||
| verdict.sh | ||
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, andskillorask_userwhen the session has them.mapcomes 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 planis read-only (map,read,grep,glob,find,todo);mode minimalisread,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.
exploresends 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 — andexploreis not in its own set, so the delegation stays one level deep. Its steps are capped byMCODE_EXPLORE_STEPS(default 8), andMCODE_EXPLORE=0takes 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/acceptbefore 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.
writerefuses to halve a file;editrefuses to drop more than a fifth of one (at least five lines) unless the call setsallow_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 atthe task's budget is spentwith no closing report at all, because the harness's only note came at the moment of the stop. - A prompt budget.
context_budget(orMCODE_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=32768is 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,0disables) 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,0disables) andMCODE_SLOW_WINDOW(default30s) 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 afterMCODE_SLOW_GRACE(default60s;0reports 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 sof 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, onGET /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 aprefillevent. A slot that has stopped, an idle server, one started with--no-slotsand one that does not answer at all all fall through to the restart the watch already did.MCODE_PREFILL_PROBE_MS(default5000;0turns 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/slotsthat does not answer must cost the watch a bounded wait and no more. - One call, one deadline.
MCODE_STEP_TIMEOUT(default15m,0disables) 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 modelused to end the run in fifty milliseconds with the raw body on stderr and exit 1 - measured at0.058 sthrough the real binary against a scripted server on theMCODE_URLpath, 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(default5m; a bare number is seconds;0turns the wait off) bounds it, and so does the step deadline. A server that never serves ends the run withwaited 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 updatedoes not upgrade; build from source.planmode saves the plan text, and/plan executeruns it, but the plan is not yet shown as a first-class panel.