v0.13.1 — dogfood fix train
[0.13.1] — 2026-09-04
A fix train from running 0.13.0 on real work, on Linux and on Windows. A long
task that promised a next step and then stopped, a startup that quietly deleted
its own warnings, a picker that crashed on an agent's name, a Windows upgrade
that reported failure after succeeding, and a drafting session whose every
composing step spent the whole output cap on hidden reasoning, came back empty,
and closed as a success — plus the workspace layer offering itself to projects
that do not have one yet.
Added
startoffers to make a workspace when your project has none. The layer
has always been there — almost nobody findsinit --workspacebefore reading
the docs, and the moment you want one is the moment you start the harness
inside a project. So astartthat finds no.localharness/anywhere above it
now asks once: create./.localharnessfor this project? Default no. Yes
scaffolds it and the session continues with that layer already active, so there
is no "now run it again". This is the only prompt in the harness that writes to
disk, so it stays silent and creates nothing when there is no terminal, when
--no-inputis passed (starttakes the flag now), when--config-dir,
LOCALHARNESS_DIRorLOCALHARNESS_HOMEnamed a directory, when a workspace
was already found up-tree, and in$HOMEor the global config directory —
none of which are a project. A closed stdin answers no.- A declined offer is remembered, once per directory, ever. The answer goes
indeclined_workspace_offers.yamlnext to your global config — never in the
project — and only an answered prompt records anything, so a session that could
not ask has not spent your decision. Nothing else reads that file:init --workspace, or simply making the directory yourself, still gives you a
workspace whatever you once answered. If the file is unreadable you get the
question again rather than a silently skipped one. - A repeated stuck-nudge now says something new. Where you have raised
agent.baton_gate.max_nudgesabove its default of 1, the second and later
nudges quote the model's own announcing words back to it and name the two ways
out — do it with a tool call, or say plainly that you are done or blocked —
instead of repeating the first nudge verbatim. At the default bound nothing
changes: the single nudge is the same message as before. - Terminal colors from the architecture diagrams. The entity hues on
localharness.dev's architecture plates — memory cyan, tool purple, provider
amber — are now the terminal's, at startup and inside every turn, so a tool
call in the scrollback is the same color as the tool box in the diagram. Names
take the color; message bodies stay neutral, and success/error keep green and
red, because a verdict is not a type. One knock-on you will see: the agent has
taken the accent green, so your own input is now the site's ink rather than
green. - Live reasoning stream:
start --show-reasoning,terminal.show_reasoning,
/reasoning. Thinking models were dead air: the client already assembled
reasoning_contentfrom the stream but nothing surfaced it, so a 3-minute
think looked identical to a hang (--verboseis per-component startup detail,
not this). Reasoning deltas now flow from the stream consumer to the terminal
channel, which prints them as dim⋯lines — line-buffered, with a long
unbroken paragraph streamed in pieces and the tail flushed when the reply
lands. Off by default; the sink is always wired so/reasoning on|offtoggles
it mid-session. Needs the server's reasoning parser (vLLM--reasoning-parser,
llama.cpp--reasoning-format, Ollama think); without one the thinking is
inline<think>text the harness strips. - A Platform support section in the README (Linux / Windows), and
Git for Windows named as the Windows requirementbash_exechas always had.
Changed
- The per-reply output cap is the configured value, fitted to the window, and
grows when a reply is cut off.startsentDEFAULT_MAX_TOKENS(4,096)
whateverdefault_max_tokensor the agent'smax_tokenssaid, and the reply
reserve it was clamped into was the same flat number — so raising the value in
config.yamlchanged nothing (the live session carrieddefault_max_tokens: 8192and still asked for 4,096; five empty replies of ~170 s each, no draft,
and the cruncher's extracts cut mid-sentence for the same reason). Now the
agent's resolvedmax_tokens(agent yaml → division → orgdefault_max_tokens)
is what the session and the/modelswap refit send; the shared reply
reserve grows to hold it (bounded at half the window; the small-window curve is
unchanged); every request is fitted to the window's real headroom
(window − prompt − window/64, prompt taken from the server's own
prompt_tokenswhen it reports usage) so a larger cap can never push
prompt + max_tokenspast the served window; and a reply that ends with
finish_reason="length"doubles the cap for the retry within that headroom —
for an empty reply, for a truncated tool call, and for a truncated final
answer, which used to ship with its tail missing and is now re-prompted once.
The raised cap is kept for the agent's life.LLMClient.complete/
stream_completetake a per-callmax_tokens, and thellm_responseAction
carriesoutput_cap, so the ledger says what each request asked for. - …and when you have not set one, that cap auto-derives from the served context
window instead of being a bigger number picked by us. An unset
max_tokens/default_max_tokensnow means "a quarter of the window this
session runs in, and never below 4,096 tokens" — the served window, or the
smallermax_context_tokensyou pinned: 32,768 on the 131,072
the reference setup serves, 8,192 on a 32K one, and unchanged on anything
small, where the 4,096 floor still meets the same reserve curve as before
(an 8,192-token window still asks for 1,024). A quarter is not a taste number —
the reply reserve grows to hold the cap but is bounded at half the window, so a
quarter is the largest fraction that always fits inside that bound with history
keeping the other three quarters. It is computed where the served window is
first known (start's window probe, and again on a/modelswap, so moving to
a roomier model widens the reply instead of carrying the old window's number),
and it only sets the STARTING cap — the per-request fit and the grow-on-cutoff
above still apply. It is bounded above by the same 128,000 themax_tokens
fields validate against, so a derived default can never land somewhere you
could not have typed by hand. A number you write in config is used exactly as
written, including 4,096. One consequence worth knowing on slow hardware: the
default worst-case reply is now 8x longer than the arithmetic in
docs/reference-architectures/gaps.md§1 assumed, against a request timeout
that is still a constant — that section says what is and is not known about it. - An org-level
default_max_tokensinconfig.yamlreaches your agents at
all. The chain readorg.yaml— a legacy standalone file nothing in the
harness has ever written, sinceinitwritesorg:insideconfig.yaml—
so the org rung of "agent → division → org" was dead on every real install and
the value in the file you have was inert. It is now read fromconfig.yaml
(both layers, workspace over global), and a division that sets nomax_tokens
of its own no longer shadows it with a schema default. Two consequences worth
knowing on upgrade: an org cap you had set and assumed was working starts
working, and aconfig.yamlwritten by an olderinitcarries
default_max_tokens: 4096, which now pins the cap at 4,096 — delete that line
(or set it tonull, which is whatinitwrites now) to get the derived one.
default_temperatureanddefault_modelhave the same dead rung and are
deliberately left alone here; switching on a temperature that has been inert in
someone's config is a change that needs its own decision. - A linked git worktree counts as inside the project it was cut from.
git worktree addleaves a.gitfile, not a directory, and the repository walk
stopped there — so the main checkout's.localharness/one level up read as
config from outside your tree, and the harness asked the one-time trust
question about your own repository. The file names its parent repository, and a
workspace at or below that parent is now inside. A submodule's.gitfile has
the same shape and reads the same way. SECURITY.md documents the rule.
Fixed
- A long task that ends on "Let me check the config…" gets nudged instead of
accepted. The gate that catches a reply announcing work it never did was
missing the plainest form of it. "Now let me confirm…" was caught; a bare "Let
me confirm…" — the same announcement in different grammar — was not, so the
promise was delivered as the answer and the turn ended. Both forms now share
one list of action verbs. Separately, and worse, the detector split the reply
into sentences on every., so a closing sentence containing a filename was cut
at the dot: for "…check the content.json format expectations" it judged the
fragment "json format expectations" and found nothing to catch. Any filename,
version or decimal in a final sentence did that. A sentence now ends at
punctuation followed by a space or the end of the text. Closing courtesies like
"let me know if…" are still accepted, and a subagent that ends this way is
reported to its parent as having produced no result rather than passed off as a
finding. start's startup summary no longer deletes its own warnings (#157). The
warnings were appended inside square brackets and printed through Rich, which
read the whole group as one formatting tag and removed it. Every degraded
startup — memory fallen back to in-memory, a hook that failed to load — printed
a clean-looking summary and told you nothing.- An agent named
[old] projno longer breaks the agent picker (#158). Agent
names were rendered as markup in the roster table, so a bracketed name lost the
bracketed part, and a name that looked like a closing tag crashed the picker
outright. Names are now literal text. localharness updateon Windows stops reporting a failed upgrade that
worked (#156).uvinstalls the new package and then copies the launcher over
localharness.exe— which on Windows is the running program, and is locked, so
the copy failed andupdateprinted "upgrade command failed" to someone whose
package had just been upgraded. The upgrade is now handed to a detached process
that finishes afterupdateexits. macOS and Linux are untouched, and a genuine
failure still fails.autoresearch reportprints an undo that works. The adopted-mutation inbox
offered agit revertline for a commit that has not existed since adoption
moved to the config overlay in 0.13.0. It now prints thelocalharness components set <path> <old value>that actually reverses the change, taken from
the value the archive already stores.- The
kill_filesetting no longer claims the harness deletes the file. It
never has: the kill switch is checked by existence and left in place, so it has
to be removed by hand or the next session stops at its first step too. - An empty completion no longer ends the turn as a success. Observed live
(qwen3.8-27b): two replies ~3 minutes apart with no text and no tool call — the
whole output budget spent on hidden reasoning — and the turn completed
success=Truewith a STALE narration line ("Now let me pull the voice anchor
exemplars…") as its summary, because the #91 fallback resolves the last in-turn
assistant text. The loop now re-prompts once with a message that names the cause
(not the "only a confirmation" nudge), and a second empty reply ends the turn as
a failure whose summary says so. Thellm_responseAction now carries
finish_reasonandreasoning_chars, and history.jsonl records the real
finish_reason instead of a hard-coded"stop", so the ledger can say why a
reply was empty. - Windows
bash_exechad no coreutils from a PowerShell-started harness.
Discovery preferredGit\usr\bin\bash.exeover theGit\bin\bash.exewrapper.
The inner binary inherits the harness's PATH as-is — no/usr/bin— somkdir,
ls,cpand friends were allcommand not found(observed live: three
mkdir -pcalls in one session, every one reported ✓; the only files that
landed were the ones thewritetool created parents for itself). The suite
never caught it because pytest under git-bash already has/usr/binon PATH.
The wrapper, which sets PATH before exec'ing the inner bash, is now searched
first, and the StoreWindowsApps\bash.exeWSL alias is rejected alongside the
System32 stub. A regression test strips Git entries from PATH before running
command -v mkdir. - A
bash_execcommand that could not RUN is a tool failure, and its exit code
is visible either way. Every non-zero exit used to besuccess=Truewith the
code tucked in metadata — and metadata never reaches the model, which reads
outputalone on success — so the terminal showed ✓ and amkdirthat had
saidcommand not foundread as done. Exit 127 (not found), 126 (not
executable) and abnormal termination now fail — usually there is nothing to
rephrase, the thing asked for is missing, unrunnable or dead — and because the
loop forwards.error(not.output) on failure the command's own output
travels inside the error message (exit code 127: …mkdir: command not found).
Abnormal termination means both of its spellings: a signal on POSIX (a negative
code) and an NTSTATUS crash on Windows, which arrives as a large positive
number becauseGetExitCodeProcessreturns a DWORD — a rule written for
signals alone would have called the same segfault a failure on Linux and a
success on Windows. 126 and 127 are the shell's convention, not a reserved
range: a program is free to exit 127 meaning something of its own, and it will
be reported as a failure it did not have. That is the deliberate side to err
on, since 127 from the shell is the git-bash bug this exists for. Every other
non-zero exit stays an ordinary result —grepwith no match,test -fon a
missing file anddiffon differing files are answers in the language commands
are written in, and calling them tool failures fought the idiom — but the
result now opens with anexit code Nline, so the code the model could not
see before is in the text it actually reads. No|| trueneeded. bash_execno longer inherits the harness's stdin, and a timeout kills the
whole process tree. Observed live (Windows):cmd /c "…"under git-bash —
MSYS path-converts the/cflag, cmd starts interactive on the inherited
terminal stdin and sits there; the inner 60s timeout'sproc.kill()reached
bash alone, the orphaned cmd.exe kept the stdout pipe open,communicate()
blocked, and the base-class outer timeout fired at 65s instead. stdin is now
DEVNULL; on timeout the tree is killed (a Windows job object —taskkill /T
does not reach git-bash's forked children, verified; the command's own process
group on POSIX) and the post-kill wait is bounded.memory_searchhides operational memory unless asked for it. A
gate/resolved_errorrow whose value quoted a file path was the top hit for
four unrelated queries in one 50-call session, ahead of the handful of real
facts.gate/,predgate/andlearned/keys are filtered out unless the
query names them (e.g. "gate", "lesson"); the clustering pass already excluded
the same namespaces.