Skip to content

v0.13.1 — dogfood fix train

Choose a tag to compare

@ahwurm ahwurm released this 08 Sep 15:09
· 8 commits to main since this release

[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

  • start offers to make a workspace when your project has none. The layer
    has always been there — almost nobody finds init --workspace before reading
    the docs, and the moment you want one is the moment you start the harness
    inside a project. So a start that finds no .localharness/ anywhere above it
    now asks once: create ./.localharness for 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-input is passed (start takes the flag now), when --config-dir,
    LOCALHARNESS_DIR or LOCALHARNESS_HOME named a directory, when a workspace
    was already found up-tree, and in $HOME or 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
    in declined_workspace_offers.yaml next 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_nudges above 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_content from the stream but nothing surfaced it, so a 3-minute
    think looked identical to a hang (--verbose is 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|off toggles
    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 requirement bash_exec has always had.

Changed

  • The per-reply output cap is the configured value, fitted to the window, and
    grows when a reply is cut off.
    start sent DEFAULT_MAX_TOKENS (4,096)
    whatever default_max_tokens or the agent's max_tokens said, and the reply
    reserve it was clamped into was the same flat number — so raising the value in
    config.yaml changed nothing (the live session carried default_max_tokens: 8192 and 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 resolved max_tokens (agent yaml → division → org default_max_tokens)
    is what the session and the /model swap 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_tokens when it reports usage) so a larger cap can never push
    prompt + max_tokens past 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_complete take a per-call max_tokens, and the llm_response Action
    carries output_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_tokens now means "a quarter of the window this
    session runs in, and never below 4,096 tokens" — the served window, or the
    smaller max_context_tokens you 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 /model swap, 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 the max_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_tokens in config.yaml reaches your agents at
    all.
    The chain read org.yaml — a legacy standalone file nothing in the
    harness has ever written, since init writes org: inside config.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 from config.yaml
    (both layers, workspace over global), and a division that sets no max_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 a config.yaml written by an older init carries
    default_max_tokens: 4096, which now pins the cap at 4,096 — delete that line
    (or set it to null, which is what init writes now) to get the derived one.
    default_temperature and default_model have 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 add leaves a .git file, 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 .git file 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] proj no 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 update on Windows stops reporting a failed upgrade that
    worked
    (#156). uv installs 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 and update printed "upgrade command failed" to someone whose
    package had just been upgraded. The upgrade is now handed to a detached process
    that finishes after update exits. macOS and Linux are untouched, and a genuine
    failure still fails.
  • autoresearch report prints an undo that works. The adopted-mutation inbox
    offered a git revert line for a commit that has not existed since adoption
    moved to the config overlay in 0.13.0. It now prints the localharness components set <path> <old value> that actually reverses the change, taken from
    the value the archive already stores.
  • The kill_file setting 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=True with 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. The llm_response Action now carries
    finish_reason and reasoning_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_exec had no coreutils from a PowerShell-started harness.
    Discovery preferred Git\usr\bin\bash.exe over the Git\bin\bash.exe wrapper.
    The inner binary inherits the harness's PATH as-is — no /usr/bin — so mkdir,
    ls, cp and friends were all command not found (observed live: three
    mkdir -p calls in one session, every one reported ✓; the only files that
    landed were the ones the write tool created parents for itself). The suite
    never caught it because pytest under git-bash already has /usr/bin on PATH.
    The wrapper, which sets PATH before exec'ing the inner bash, is now searched
    first, and the Store WindowsApps\bash.exe WSL alias is rejected alongside the
    System32 stub. A regression test strips Git entries from PATH before running
    command -v mkdir.
  • A bash_exec command that could not RUN is a tool failure, and its exit code
    is visible either way.
    Every non-zero exit used to be success=True with the
    code tucked in metadata — and metadata never reaches the model, which reads
    output alone on success — so the terminal showed ✓ and a mkdir that had
    said command not found read 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 because GetExitCodeProcess returns 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 — grep with no match, test -f on a
    missing file and diff on 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 an exit code N line, so the code the model could not
    see before is in the text it actually reads. No || true needed.
  • bash_exec no 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 /c flag, cmd starts interactive on the inherited
    terminal stdin and sits there; the inner 60s timeout's proc.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_search hides operational memory unless asked for it. A
    gate/resolved_error row 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/ and learned/ keys are filtered out unless the
    query names them (e.g. "gate", "lesson"); the clustering pass already excluded
    the same namespaces.