cli-channels runs in the studio-cli-channels environment and is the only job
with id-token: write. Its registry tokens are environment-only secrets
(STUDIO_NPM_TOKEN, STUDIO_HOMEBREW_TAP_TOKEN), and its first step fails unless
the environment has a required reviewer. npm publish passes --provenance,
matching the 1.x release line. A contract test parses every workflow and pins
the permissions, the secret references and the id-token grant. npm/ changes now
trigger the CI job that runs npm/publish.test.mjs.
neterr.IsConnReset fell back to any net.Error, so a *net.DNSError read as a
dropped stream and users saw "disconnected before completion after retry
attempts" for a host that never resolved and was never retried. DNS errors are
now excluded from the reset judgement, and explainError gives them their own
typed branch that names the host and separates a nonexistent name from a
temporary resolver failure.
Refs #11391
The storage panel showed "Measuring..." forever when one root (a huge or
slow directory, common on Windows) never finished, because GET /storage
walked all five roots synchronously with no limit and the client had no
timeout.
- Survey walks each root under a 10s budget and reports a lower bound with
Truncated set when it runs out, even if a filesystem call never returns.
- GET /storage?layout=1 answers at once with unmeasured roots and
GET /storage?root=<id> measures one root, so a slow root no longer holds
back the others.
- The panel draws rows immediately, fills each root in as it arrives, marks
a truncated count as a lower bound and shows "timed out" for a root the
kernel never answers.
Port of #11386 (main-v2). The recovery block now ends with a sentence
stating that the user's new message takes precedence, so an unrelated
message no longer makes the model resume the interrupted task.
Refs #11381
The docked workbench hides its canvas while the file list is open, and the
browser page and its address bar are drawn in that canvas. Selecting a tab or
opening a new browser tab left the list open, so the browser never appeared.
Every selection now goes through one helper that also closes the list.
The authoring guard skips any task without a solution/, so all twenty
verification-stress graders have only ever been checked against the pristine
seed - never against an answer that should pass. Ten now carry one.
The reference-solution test holds graders to both halves of the authoring rule
but skips memorybench, which ships no solution/ - the half #11329 fell through.
`-policy` is the MemoryBench experiment arm: `memory-off` hides the memory
store so a run can be compared against itself, and the only switch that
separates the two arms of that counterfactual. It is defined at
`cmd/e2ebench/main.go:268` and used in the recipe further down this README, but
the flag table never listed it.
Placed with the other suite-mode experiment arms (`-anchor`, `-cache`) rather
than beside `-ablate`, so it does not collide with the open #11364 that edits
that row.
ablation.Modules() has seven modules; the sentence documenting --ablate stops at
upstream. #11360 edited the same sentence to drop full-fold but left this one.
The bar named a field that does not exist (`PostSufficientSearches`) and a mode
that cannot produce counts (`-mode=calibrate`). The gate is the run's
`stopping_class` from the `-mode=run-index` report: a task qualifies when it is
neither `PostSufficientRetrieval` nor `PostSufficientRunaway`, so `SnippetStop`,
`SnippetThenRead` and `DefensiveRead` all qualify, and a defensive read is
deliberately not counted as waste. Also records that calibrate is not a
screening tool, which is why its cue-visibility profile is the same for every
task.
CloudDesktop only set Content-Type when the request had a body, so a
bodyless POST such as /runtimes/{id}/close reached csrfGuard without a
JSON type and was refused with 415 request.bad_content_type. Deleting a
project or session closes its panel first, so the delete failed in the
web Studio. csrfGuard is unchanged.
The benchmarks README still described this harness as the pre-#8244
comprehension/TTL/prune A/B, and taught a subcommand and flags the code has not
had since that rewrite.
A full-screen repaint draws every settled block, so when a background
[cli].diff_formatter run landed and the re-render hook was keyless, the
repaint dropped every diff block's cached rows and re-consulted the memo
for each. Once the transcript held more diff keys than the memo, the repaint
always re-missed at least one block, spawned a run for it, and that run
landed and repainted again — an endless loop of formatter runs and full
repaints.
Carry the landed DiffKey on the hook and remember, per block, the keys its
last render consulted, so a landed run drops only the blocks that asked for
its key. Gate the drop on the memo still holding the key: re-rendering a
block whose key was evicted would re-run the formatter and evict another
live key, so an evicted block keeps the rows it already shows.
The memo is raised to 128 so a normal transcript does not reach the eviction
path, and keyed by a 128-bit hash of the diff text rather than the text
itself, so an entry retains only the formatted output (~33-41% less
retained heap). Also record the formatter's 1 MiB size cap as a fallback
reason in the CLI reference.
ablation.FullFold has had no reader since #8244 (397ed30ec) replaced the
multi-threshold path with the single checkpoint design: foldSource and
carryPriorDigests lost their Off(FullFold) checks together with their test, so
-arm=incremental was identical to the default, and the default already folds the
model-visible projection. The checkpoint design is the intended one, so retire
the dead axis rather than restore the old re-read.
- ablation: drop the FullFold module (constant, comment, module list entry)
- compaction bench: drop the -arm flag, the arms struct, foldArm, and the arm
label from the report title and the JSON report
- benchmarks/README.md: drop the "Fold arms" section, and correct the paragraph
that claimed a fold re-derives its digest from the canonical transcript
- docs/CLI.md: drop full-fold from the --ablate list
Verified: go build ./...; go vet; go test ./benchmarks/compaction/
./internal/contract/ablation/; go run ./tools/repolint clean; golangci-lint
v2.12.2 (the version CI pins) 0 issues. -mode=cost still reports the same eight
generations, -arm is now an unknown flag, and the JSON report no longer carries
an arm label.
Fixes#11358.
studio's config.go sits exactly at its file-size budget, so the diff-fences
feature's added [cli] fields tipped it over repolint's ratchet. Extract the
CLIConfig type into cli.go, alongside the existing render_cli.go, to keep the
budget.
A shell tool result whose whole output is a unified diff — bash running
`git diff`, or `git show` in its default format — is tagged and rendered as a
diff: the TUI draws it through the same rows a ```diff fence produces, ACP
fences it for its client, and the desktop card renders it with DiffView.
The tag is computed once, at the one sink every emitter shares, and is gated by
the opt-in [agent].embedded_diff_detection (default false). Detection is
whole-text and conservative (diff.IsUnifiedDiff): every line must belong to the
diff grammar — including a `git show` / `git log -p` commit header and its
indented message — and a lossy Bound is never tagged, so a diff followed by
prose, a build log, or a truncation note stays prose. The tag is re-derived when
a session is rebuilt from /history, so a reopened session renders the same diff
the live one did.
The desktop DiffView splits a multi-file diff into per-file sections by the same
rule the CLI uses (git's "diff --git", else the "--- "/"+++ " pair) and names
each with its own "path +A -B" header row, so a `git diff` over several files no
longer shows only its first file's name over rows that belong to all of them.
A single-file diff keeps the card headline as its only name, so the fact is not
said twice.
trimDiffPreamble returning "" for a section with no --- line left a ```diff
fence without a --- /+++ pair rendering as an empty block. splitDiffSections now
returns sections untrimmed; the fence path trims each and falls back to the plain
code rail when it carries no file header, while the shell-diff path still drops a
pure preamble. A marked shell diff keeps the card's "⎿" connector on its first
row, like every other tool card's body. /history reads the embedded-diff flag the
session was built with instead of reloading config per request, and the hunk
parser rejects a body that overshoots its declared count.
[cli].diff_formatter names an optional external argv line (run without a shell)
that formats a diff before the CLI/TUI emits it — both a fenced
```diff/```patch block and a writer tool's diff card. The whole diff goes to the
command's stdin and its stdout is re-emitted with non-SGR control sequences
stripped. On failure, timeout, empty output, or an oversized block the built-in
renderer is used. Like [cli].update_channel it is user/global only: a
project-local reasonix.toml cannot run a command on the user's machine.
The run is memoised per (block content, width), because a View re-renders many
times while a fence streams and again on every resize. It runs only on a closed
fence, so a fence still streaming spawns no subprocess per delta, and the command
is handed the terminal width via COLUMNS with its output clamped to it. A
WaitDelay bounds the wait for a formatter's pipes, so one that leaves a child
holding stdout cannot block the render past the timeout. The writer card drops
the "--- /+++ " pair before piping, so the formatter cannot re-emit a raw host
path the card header already names.
This commit also wires [cli].diff_fences, the opt-in toggle that turns the
fenced-diff renderer on.
A fenced ```diff or ```patch block in the answer stream is drawn through the
colourised diff renderer — add/remove backgrounds, +/- gutter, line numbers,
chroma — instead of the generic code rail. A multi-file git-style fence is split
into per-file sections, each with a bold(accent(path)) +A -B header. Copy mode
keeps the plain rail path so the copied text stays byte-identical to the fence
source.
A fence that already carries ANSI (a pasted colourised diff) is shown verbatim,
with non-SGR control sequences stripped so a hostile payload cannot drive the
terminal.
It is off by default: the renderer runs only when activeDiffFences is set, so a
fence a model writes stays on the plain code rail unless the user opts in.
`@scope/[email protected]` and `[email protected]` were rejected by both
looksLikePackage implementations (Go installer and registry publish
validation), so an npm MCP entry could not pin its version. Both now accept
an optional `@<exact semver>` suffix and still refuse ranges, dist-tags,
empty versions, a repeated `@`, and anything with whitespace, shell
metacharacters, path segments or scheme prefixes.
A leading `-` is now refused as well, so a bare source can no longer be read
by npx as an option. The default MCP name drops the version suffix.
Pinning the top-level package does not pin its transitive dependencies.
Add owner headers to twelve contract and design documents, split their
over-wide paragraphs into rules, route their ownership to the primary
named in CODEOWNERS, and treat the generated protocol index as a
machine-read file. Baseline: doc-owner 40 -> 27, doc-prose 2276 -> 2147.
Add idle_timeout_seconds, a per-provider override of the 300s stream idle
watchdog. One knob bounds both windows a model call can spend silent: the
transport's pre-header deadline (prefill) and the read loop's inter-event
watchdog (generation). Zero or unset keeps the default.
The bound holds where the value is read: IdleTimeoutFromExtra clamps to
[1s, 32767s], so a hand-edited value — which never passes UpsertProvider's
validation — cannot overflow time.Duration(v) * time.Second into a negative
window the transport reads as "no timeout". validateProvider still rejects an
out-of-range value on the edit path.
The stall error names idle_timeout_seconds, and the docs note that a silent
pre-header wait is retried once, so the effective hold can reach ~2x the value.
A Studio install on another drive than the update cache never took the
chunked update: the swap's pre-flight renamed a probe from the install
into the cache, and a rename cannot cross volumes. The swap backup now
lives inside the install (<install>\.reasonix-update\<version>), the one
place guaranteed to share its volume; staged files still come from the
cache and are copied in when the rename fails.
Chunks are fetched through update.Transport, so they take the IPv4
route from the second attempt as the full download does, and chunks a
round could not fetch get a second round instead of voiding the delta.
Why a delta was abandoned is now a typed code (update.delta.*) that the
step giving up attaches; it rides on Progress.DeltaSkipped through the
full download and its failure, is logged, and the version panel says it
is downloading the full package and why.
Full downloads drop a connection that delivers nothing for 30 seconds
and resume on a new one; only attempts that made no progress count
against the retry budget. The artifact is written to a partial file in
the update cache named by its digest, so a timed-out or restarted update
resumes instead of starting over; a partial that fails verification is
removed. A body the manifest's size rules out (an error page served as
200) fails that address and moves to the fallback without being resumed.
With stalls caught, the fetch deadline is two hours rather than thirty
minutes.
A file copied in from another volume is written under a temporary name
beside its destination and renamed into place, so a crash mid-copy never
leaves a half-written file at the install path. A backup directory the
install refuses to create is ErrTreeNotSwappable. The delta phase has
its own 40-minute budget (update.delta.timed_out) and gives up after 16
chunks fail in a row, so a mirror that has gone quiet leaves the full
package its time. The reason is also exposed to screen readers.
"pnpm install" contains "npm install" as a substring, so verify.sh's
negative grep matched the very answer the positive grep demanded, and the
task could never pass. Disambiguate the negative check so it only matches
"npm install" when it is not preceded by "p", instead of anchoring the
positive check to line start, which would have moved the grader out of
line with every sibling task's plain substring convention.
Also extends TestSolvableCorpusSeedsMustNotGradeClean to the memorybench
corpus (it was not covered by the existing e2e/verification-stress/train
authoring guard) and adds a targeted regression test pinning the four
cases worked through by hand in the report.
Credit to chuiwenwei for the root-cause analysis and both candidate fixes.
Fixes#11329
The Studio manifest named only GitHub release URLs for full artifacts, so
every update downloaded the installer over the route CN users reach
slowest, even though the release workflow already uploads the same files
to the R2 mirror.
When the release is mirrored, studio-manifest now writes the mirror
address as each asset's url/sig and keeps the GitHub address in new
optional fallback/fallback_sig fields. Old clients ignore the new fields
and use the mirror, which the redirect allow-list already accepts.
The updater tries each address in order with its own retry budget,
resuming what the first delivered from the second. Signature, digest and
size checks are unchanged and apply to whichever address served the
bytes.
An approved market package whose reviewed version carries no content digest
was a dead end: the detail page showed "not pinned" and no way to install it.
It now offers "Trust and install". The request carries trust, and the kernel
plans it through install_source exactly like any other market install; the
preview's own contentDigest becomes the pin, apply must echo it, and content
that changes after the person confirmed is refused. The outcome and the ledger
record the install as unreviewed, and the confirmation says the content may
differ from what was reviewed.
A pinned version is unchanged: trust never replaces the reviewer's digest, a
digest other than the pin is refused as a version change, and moved content
still fails with market.content_changed. Without trust an unpinned version
still answers market.unpinned.
The publisher's own unreviewed install and the trusted install now share one
preview-pinned path, and market.unpreviewed is answered on both routes. The
list filter and badge read "pinned" instead of "installable", since an
unpinned package is now installable too.
A command the host cannot classify gets a before/after workspace scan,
and a scan that sees a change proves a write. An inline plan check such
as `python -c "import fizzbuzz; ..."` imports the module and writes its
bytecode, so the check was proven a change and owed itself another run.
Each check now measures "after the latest change" from the latest proven
change not counting its own residue: a run of exactly that check whose
written paths a complete workspace walk named, each of them a file whose
first touch this turn was its creation, none of them a file the plan
names or any other write this turn touched. A check that deletes a file
on one run and writes it back on the next is not its creator. Anything else counts: a run that does more than the check,
a check that rewrites the source it just checked, a check that rewrites
any file that predates the turn whether or not anything named it (go
generate, a whole-tree formatter, a lockfile rewrite), a path list cut
at the observation limit or never walked, and every run of
a different check - so a rebuild by one check voids another check's pass
taken against the stale build, and a check that never ran is not settled
because the only write came from another check. The plan deliverable and
the change criterion leave out the residue of every plan check.
The walk now also runs after a call whose watched paths already proved
a change, and records which files it saw appear: a new top-level entry
proves a change on its own, and without the walk a rewrite deeper in the
tree next to it went unnamed. The receipt says when its paths came from a
complete walk (paths_complete), so residue is never read off a list that
only holds what the host happened to watch.
The executor handoff now says what counts for a verify line: that exact
command, run as written after the latest change. It used to invite the
executor to validate and adapt inferred commands, which is how most held
runs ended up running a reworded variant.
Move the ProviderEntry/ProviderModelOverride types, their accessors and
validation into provider_entry.go, and the provider table rendering into
render_providers.go. Trim the moved doc comments to the <=3-line budget and
reclaim the freed repolint budget.
No behavior change: same types, accessors, validation and rendered output.
The TUI laid rows out with grapheme widths while bubbletea's renderer counts
per rune until the terminal answers the mode 2027 query, and so do the
terminals that never answer it (Terminal.app, legacy conhost). A VS16 emoji
pulled the scrollbar left, a ZWJ row lost its scrollbar cell, table lines
moved, and on conhost or behind an approval card those rows overflowed and
every row below was drawn twice. conhost also drew emoji as fallback glyphs
and `◆ ●` as `?`, and Windows Terminal drew boxes for flags and keycaps.
1.x has the alignment defect as well.
- termrender measures through one method, Cells(), with per-rune Truncate
and Cut of its own (the library's cut whole clusters and came out wider
than asked). The TUI sets it per rune and switches it to grapheme clusters
on the mode report bubbletea acts on; before the program starts it asks the
terminal how far U+26A0 U+FE0F advances, so one drawing it two wide gets
the grapheme count without answering 2027.
- Per rune, the whole frame is drawn stand-ins that keep the per-rune count:
joiners, selectors, keycap marks and tags go, a skin tone becomes its two
blank cells, a regional indicator its letter, and a one-column emoji with
only a two-column glyph is kept only before a blank.
- On conhost, told from a pseudoconsole by its window class, each rune is
asked of the console's own font (glyph and advance) and measurement; one it
cannot draw as counted takes a drawn symbol of the same meaning, its
halfwidth form, a flag letter, the OS best-fit when it really maps, or
blank cells. Windows Terminal gets letters for flags and base characters
for keycaps and tag flags.
- Live rows full screen are drawn at the transcript's width, so a pending
tool row is not wrapped again when it lands there.
Selection copies the original text; stand-ins change only what is drawn.
A desktop session whose person never named a posture now opens the way the
terminal does since #11256: Auto only where the OS sandbox confines writes
and the folder is trusted, Ask everywhere else (always on Windows). A named
posture - set in config or switched on the composer - is kept, and a value
this build does not know still opens in Ask with its notice.
- control: mode and "defaulted" are one approval sub-state; a trust answer
re-derives a defaulted posture with one check-and-set under the approval
lock, so a concurrent switch stays named. New FolderTrust port (Posture,
DecideWorkspaceTrust). TrustableFolder resolves symlinks and refuses a
filesystem root, anything holding a home directory, and anything inside or
holding the Reasonix home or state directory.
- config: desktop.default_tool_approval_mode is unset by default and renders
as a comment. A startup step releases a written "auto" once, recorded in
desktop-posture-released.json beside the config rather than in
config_version, which the 1.x line's own upgrades own; ask, yolo and
unknown values stay. The studio host now runs the startup upgrades.
- serve: POST /workspace-trust, approvalDefault on /status; the hub carries
only a named posture to later panes; a rebuild re-derives a default.
- studio: the terminal's "trust this folder?" question beside the composer
where a sandbox would confine what trust allows, and a note with a
one-click switch to Auto where no sandbox is in effect.
1.x's `reasonix run` and `-p` leave stderr empty on a clean run. Studio wrote
three slog records there on every run: `INFO boot: assembly timing`,
`WARN prompt refiner provider construction failed` (whenever the optional
refiner side model cannot be built) and, with -c, `INFO controller: resume
cache state`. A script that treats stderr output as a failure, or captures it
into a report, broke on every invocation.
All three are diagnostics nobody has to act on, so they log at debug level.
`run --debug` lowers the process log level while flags are parsed, before
assembly writes its first record, for whoever wants them back. Warnings the
user has to act on keep their own channel.
/events numbers frames from where a subscriber attaches, and Subscribe
connected in the background. An action taken right after it returned could
emit its frames before the connection registered, and the stream never
carried them. TestUnknownSlashCommandNeverReachesTheModel lost its
unknown-command notice this way whenever the kernel answered faster than the
subscription attached (every time on a Linux runner with GOMAXPROCS at 1 or 2, and on the coverage job), and the TUI's
own Init has the same order: it subscribes and then submits --prompt.
Subscribe now returns once the first connection has a response in hand (the
server registers the subscriber before it writes a byte) or has failed.
Every user-config save (reasonix config compact-ratio, the Studio settings
page, the serve settings endpoints, model and approval persistence) went
through Config.SaveTo, which re-rendered the whole file from the struct.
Anything the struct does not decode was dropped - [checkpoints],
browser.chrome_path, providers[].display_name, agent.web_search_model,
desktop.layout_style / status_bar_style_initialized,
remote.hosts[].credential_mode - and values the renderer normalises were
rewritten, so a 1.x default_tool_approval_mode = "danger-full-access"
became "ask". Losing credential_mode = "remote" flips a remote host from
using the host's credentials to tunnelling the local key.
An existing user config is now edited in place: the file is indexed by
table path (array elements by their name), the rendered form of the file
as it loads is compared with the rendered form of the config being saved,
and only the keys that differ are rewritten, added or removed. A key the
save changed but the renderer never writes (a retired field a setter
cleared) is removed, and the keys this build declares retired are still
stripped. Unknown keys and tables, comments, order, line endings and
values this build reads differently stay byte-for-byte. The edited text is
accepted only if it loads to the same configuration the full render would;
otherwise the full render is written and the previous bytes are kept
beside it as config.toml.rewrite-<time>. A missing or empty file is still
written from the template.
A config written from nothing now carries config_version = 12. The 1.x
line rewrites the whole file on start when the marker is below its latest
upgrade (12), dropping [storage], [memory] and the other tables it does
not know. This build's own upgrades still gate on version 6 and an
upgraded older file is still marked 6, so 1.x keeps running its 7-12
migrations for content that never had them.
Arrays of tables are edited per element, keyed by name. The whole array
is written from the render instead - leaving every other byte alone -
when an element the save changes or drops is not a block of its own in
the text, when an element has no name or shares one, when the save
reorders the elements, or when the file holds no such array yet (so
adding the first provider keeps the built-in ones). A new element is
placed before the next kept one, so a rename keeps its neighbours whole.
Dropped elements are removed in one pass over one index, so an element's
sub-tables are never handed to the element above it and removing many
elements is linear.
Keys and sub-tables Config does not decode are never dropped silently.
Rewriting an array carries each element's own into the rendered element
of the same name, verbatim; turning an inline table into a table carries
its plain entries as key lines. When an entry has nowhere to go (two
elements share a name, an element the load merged away, a nested table
value) the result is still written in place and the prior bytes are kept
beside it as config.toml.rewrite-<time>, as the full-rewrite fallback
does. An element the user deleted takes its entries with it.
A value the user sets is written even where the render would not show a
difference: a single-line key is rewritten when its encoded value
changed, so choosing Ask replaces a 1.x danger-full-access that already
loads as ask. A key nobody touched keeps its bytes.
A save never writes a file that does not load. system_prompt is
rendered as an escaped multi-line string (a backslash or """ in a prompt
used to produce an unparseable file). When the render still does not
load, the in-place edit is used if the lines the decoder refuses are the
same before and after the edit and the result loads as the render
describes; otherwise the save fails and the file is left as it was.
Inserted text takes the file's line ending outside strings only.
An unrecognised default_tool_approval_mode still opens a desktop window
in Ask, now with an approval_mode_unrecognized notice the frontend
localizes, and the file keeps the value until the user picks a mode.
The fixture is the file 1.x v1.39.5 writes through its own SaveTo with
the affected keys set.
A one-word message let the edit and rewind controls widen the card, and the
bubble then sat against the card's left edge, leaving a gap on the right.
The controls row no longer sizes the card (contain: inline-size), the bubble
is pushed to the right edge, and the controls drop to icons with their
accessible names, hints and keyboard focus when their labels need more room
than the bubble gives. Whether they fit is measured, not a fixed breakpoint.
Closes#11255
In the reading measure the transcript was 760px wide but replies and user
turns were still capped at --doc (706px), a value left over from when the
flow was a wide container with a narrower prose measure inside it. The
steps card inside a reply and the user bubble therefore stopped 60px short
of the composer's right edge, while the ask card and notices reached it.
The column now has one source: --column (760px) in tokens.css, with --doc
derived from it. The flow is laid at --doc, the composer and its shelves
at --column, and the per-row caps are gone, so every card shares the
composer's edges. The full measure still only moves --doc: the transcript
widens and the composer keeps the reading width. The studio-only
--measure variable that duplicated --doc is removed.
cards.mjs asserts the shared edges at 1440 and 1000px in the reading
measure, and that the full measure leaves the composer unchanged; the
new checks fail against the previous stylesheet.
Closes#11267
In 1.x a ! command is the user's own: it runs, its output is shown, and
nothing enters the conversation. The TUI handed it to RunShell, whose
answerShell puts the output into the conversation and starts a turn, so every
command cost a request and the model often replied with a question.
RunShellWith takes a ShellRun; LocalOnly skips answerShell. It sits on a new
LocalShell sub-port that only SessionAPI carries. serve's in-process /submit
takes localShell, and the TUI's shell mode sends through a RunShell client
call that sets it and echoes the command without waiting for a turn to name
it. RunShell keeps answering, so Studio is unchanged.
RunShell and its helpers move from controller.go to shell.go, which the
file-size ceiling on controller.go required.
With no terminal, 1.x printed usage for a bare `reasonix` and exited 0;
studio exited 2, the usage-error status, although nothing was wrong with the
command line. Session options with no terminal to open a session in remain a
usage error. bareUsage moves out of cli.go into its own file.
`reasonix doctor` warned on every install that the built-in review and
security-review skills allow use_capability, "which is not in the current
registry". Doctor checked allowed-tools against a hard-coded name list and
passed no tools; assembly always registers the proxy. It now checks against
the compile-time built-ins and the proxy, read from their owners.
1.x failed a request to an unreachable model service in about 0.3s. Studio
counted every transport error except cancellation as transient, so a refused
connect walked the whole MaxRetries backoff and `reasonix run` took about
92 seconds to report it, stalling every automated check pointed at a stopped
endpoint.
A refused connection (ECONNREFUSED, or WSAECONNREFUSED on Windows), a name
that does not resolve, and a certificate that does not verify are now
permanent: no backoff changes them. They are recognised by error identity.
Resets, timeouts and retryable statuses keep their retries.
1.x's `reasonix run` exited 0 once the model finished. Studio exited 1 with
`error_during_execution` whenever its final-readiness check stayed unmet (for
example, no check ran after the last write), so every 1.x script treated a
finished run as a failure.
Following the migration's decision 3 (in line with how other agent CLIs
report this):
- An unmet final-readiness judgement is a verdict, not a failed run: subtype
success, is_error false, exit 0, with `readiness` {attempts, missing} in the
result and in events-jsonl's run_done.
- `--fail-on-unverified` turns that verdict into exit status 3, kept apart
from 1 (run error) and 2 (usage) and documented.
- `permission_denials` in the result stays the gate's own list; run_done
carries its count and `-p` names the refused calls on stderr.
- `completion` repeats the turn's completion_summary when there was one.
1.x's `run --output-format stream-json` stamped every line with sessionId,
turnId, seq and status, opened the turn with turn_status (queued) and
user_message, reported tool_started between a call's dispatch and its result,
and closed with turn_done; --events-jsonl carried the same lifecycle records
without content. Studio's synchronous controller path publishes no turn
lifecycle, so both streams lacked all four kinds and the envelope, and they
leaked host-internal kinds (workspace_lease and friends) that no 1.x consumer
knows.
The run's output sink now supplies the lifecycle for the turn it drives and
stamps the envelope; only the kinds 1.x emitted reach either stream.
tool_started comes from a new host-only Tool.Executed on ToolResult, set when
the call cleared every gate and ran, so a refused call reports none.
The stats recorder swallowed RecordTurnCompletion instead of passing it to the
sink it wraps, so the run sink never learned a turn had finished: a failed run
reported num_turns 0. It now forwards the signal once (a TurnDone event still
records without a second completion).
Contract tests compare a real run against v1.39.5 output kept under
testdata/v1-run: lifecycle order, envelope keys, kind vocabulary.
* fix(goal): align Studio schema with report validation
Problem: schema-valid goal reports can fail execution for missing reasons or
blank verification commands.
Root cause: status-dependent requirements exist only in prose and execution
checks, not in the provider-visible JSON Schema.
Fix: encode conditional reason requirements and nonblank verification entries,
matching the executor's Unicode whitespace handling without relaxing Goal
authority or delivery validation. Update bilingual guidance and schema fixtures.
Verification: schema/executor agreement regressions and golden guards pass,
along with go vet ./..., make lint, and make check. The static schema changes
once on upgrade; system prompt bytes and within-version stability are preserved.
* fix(goal): keep report schema typed and within prefix budgets
The conditional status schemas omitted explicit types, which failed the
built-in provider compatibility gate. The added constraints also exceeded
both schema token budgets by 78 tokens.
Declare string enum and object branch types and trim descriptions whose
semantics are already explained by the tool. Keep every argument constraint
and the original budget ceilings, and refresh the serialized golden files.
Verified update-goal schema/execution tests, enum/type and schema-budget
guards, provider golden guards, go vet ./..., make lint, and make check.
* test(goal): keep whitespace validation coverage without schema changes
Problem: the schema hardening introduced provider compatibility risk through
a top-level anyOf and a new pattern constraint, and reduced model guidance
to fit the prefix budget.
Root cause: matching a general-purpose JSON Schema validator to execution
does not establish acceptance by model providers.
Fix: restore the original schema, descriptions, documentation, and golden
fixtures. Retain focused executor tests for blank reasons and verification
entries, optional completion fields, field diagnostics, and no report on
rejection. Existing error messages already identify the invalid fields.
Verification: update-goal tests, go vet ./..., make lint, and make check pass.
The owning suites retain the baseline ripgrep failure addressed by #11244.
The Regenerate and More menus on a finished reply were absolutely
positioned above the trigger inside the card. The card paints under
content-visibility containment and sits in the transcript scroller, so a
row near the scroller's top lost the upper part of the menu, and a short
reply lost it even mid-screen.
Both menus now go through one ReplyMenu: the list is portaled to the body,
placed by measurement, opens above the trigger when it fits and below it
otherwise, and follows the trigger on scroll and resize. The row keeps a
focus stop where the list used to sit, so Tab order through the menu is
unchanged; Escape now also returns focus to the trigger.
The scroll/resize follow effect that Picker and RewindControl each
carried is now one useFollow hook in place.ts, beside the shared beside()
flip arithmetic, which lowers the unnamed-input ceiling by one.
Closes#11250
/help was not a command, so it went to the model. /compact drew one line,
"context compacted", even for a fold that wrote nothing, and /context
dropped the breakdown the kernel attached to its summary.
/help is now answered on screen in 1.x's layout (commands, then built-in,
custom, skills and MCP prompts, eight rows a dynamic group), built from the
completion catalogue the session answers, the TUI's own commands included.
A finished fold draws 1.x's card: messages folded and trigger, the digest,
and the estimated tokens before and after (i18n CompactionEstimatedTokens,
the 1.x key); a fold with no digest draws none, and a running one says it is
working. The kernel names /context's notice with a context_report code, and
the TUI shows its detail under it.
Problem: A hidden directory could make grep search HOME and attribute its
matches to the requested external directory under Linux bubblewrap.
Root cause: Searching dot relied on the wrapper preserving the subprocess
working directory, but bubblewrap can fall back when that directory is masked.
Fix: Resolve directory roots to absolute physical paths and use the same path
as both cwd and the ripgrep argument. Restore requested output prefixes and
external-root error displays while preserving wrapped error identity.
Verification: Real Debian bubblewrap reproduced the prior failure for both
tmpfs and session-temp mounts; both cases pass with the fix. Grep regressions,
builtin and boot package tests, go vet, make lint, and make check pass.
The prologue was one chained assignment,
`$OutputEncoding=[Console]::OutputEncoding=[System.Text.Encoding]::UTF8`.
When the Console assignment throws (no console attached, or
ConstrainedLanguage refusing .NET property writes) neither encoding was
set and every command printed a 7-8 line error first. Each assignment is
now its own try/catch and .NET properties are only touched in
FullLanguage. Both encodings are BOM-less UTF8Encoding instances, so text
piped to native programs no longer starts with EF BB BF.
On Windows PowerShell 5.1 only, the shell tool also sets
$PSDefaultParameterValues['Out-File:Encoding']='utf8', so '>' and Out-File
write UTF-8 instead of UTF-16LE. '*:Encoding' is deliberately not set: it
would make Get-Content misread ANSI (GBK) files. Hooks keep only the output
part of the prologue, since they run the user's own scripts.
PowerShell discovery adds the Store install's execution alias
(%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe). An alias is a zero-byte
reparse point that stats fine but may fail to start the packaged app, so an
alias candidate (from the list or from PATH) must launch once before it is
selected; regular executables are taken as found.
A Chinese terminal still read English in the rows the TUI or the kernel wrote
themselves: "remembered → …" after a # note (1.x says 已记住), the session
picker's Search / No matches / Type to filter, a declined approval, a
sub-agent's call count, and the progress-watch notices. The completion menu
showed its key hint only past eight rows, and in English; 1.x always shows
it, from CompHintSlash / CompHintFile.
These now read from i18n, reusing 1.x's keys where 1.x had them. Text 1.x
itself prints in English (N lines, (active), N turns) stays English. The
language detection moves from i18n.go to detect.go; i18n.go was at the
800-line ceiling and the new keys need the room.
The memory-off arm hid the memory tool but still seeded the store, and the
state root was a sibling of the task workdir under the temp root, so agents
in the off arm found and read the seeded facts with shell commands.
- memory-off no longer seeds memory: the treatment is that no memory exists
- state roots live under the user cache dir with a random name, and a root
inside the workdir's parent is refused
- -trajectories is made absolute before the child, whose cwd is the task
workdir, receives it
A bare /rewind went to the kernel, which rewound the conversation one turn at
once, and /clear deleted the transcript with no question and left it on
screen. Both are destructive; 1.x asks first. Esc on an idle composer did
nothing, where 1.x clears what is typed and a second Esc opens the rewind
picker.
The TUI now answers /rewind with 1.x's picker (turn, then code+conversation,
conversation, code, or a fold before/after the turn; a partly covered file
restore asks again), prefills the turn's prompt and redraws the conversation
after a conversation rewind. /clear opens 1.x's Clear/Cancel question, the
cursor on Cancel, and clears at once in YOLO; the screen restarts under the
banner. Esc clears the composer, and twice within 600ms on an empty one opens
the picker. The kernel's /new and /clear notices now come from i18n.
Every todo_write and complete_step call drew a row ("● todo_write ⎿ 1
lines", "● Step"), though the task list above the composer already shows
what they changed; 1.x draws neither. A shell row previewed the result the
model reads, and the host appends its obligation notes to that result, so
"host obligations changed: - settled: …" appeared as if the command had
printed it.
A settled todo_write or complete_step that succeeded is now kept off the
screen, as 1.x keeps todo_write; a failed one still shows. A shell row
previews what the command streamed while it ran and falls back to the result
only when nothing streamed (a replayed session).
Found in an adversarial review of the new default posture:
- shellsafe: arguments that make a read-only command run another program or
write are refused in every spelling the program accepts (clustered short
flags, abbreviated long options): git grep -O, rg --pre, sort
--compress-program, go -toolexec/-exec/-vettool, file -C, uniq's output
operand, date -s, hostname NAME, git reflog expire/delete, npm audit fix,
kubectl --kubeconfig, gofmt -cpuprofile. less, man and cargo check/doc
leave the read-only tables.
- The headless gate and the posture decision live in the files REASONIX.md
declares sensitive again (approval_bridge.go, approval_orchestration.go);
the MCP fast-path refusal is decided in package permission.
- A sub-agent under an attended Auto no longer gets dynamic shell its parent
would have to ask about.
- Read-only keeps fresh human decisions human; a refusal no rule produced
names the posture instead of a deny list.
- The trust prompt and docs state what the sandbox actually bounds (temp,
toolchain caches, network) and that trust covers reasonix run; a home
directory or filesystem root is never trusted wholesale; the run result
names the posture it settled on.
- The desktop names each controller session in ready; a controller that
uses the name numbers its commands upward, and the desktop refuses a
command naming another session or an old number, so a relay cannot
replay a recorded command into a new session or twice into one.
- ready carries a per-process instance id. A state-changing request
already sent is resent only to that same instance; a restarted desktop
has no cached answer, so the request fails explicitly instead.
- The replay cache outlives controller sessions (the grace expiry no
longer clears it) and is reset only on device identity or account
change; its key binds method, path and body as well as the id.
- The phone seals and sends commands in one queue so their numbers
arrive in order; socket listeners are registered by the connection
itself; errors reach the banner through reason().
A terminal session with no named mode opens in auto only where the OS
sandbox demonstrably confines shell writes (sandbox.IntegrityEnforced) and
the person trusted the folder; everywhere else it asks, and a headless run
refuses writes and lists them as permission_denials in its result.
read-only is a real posture again: every call that is not a read is refused,
allow rules included, and an authorized MCP server's writers too. Gate
refusals carry a code (permission.unattended, read_only, deny_rule,
declined) that reaches the tool result and the run result.
YOLO keeps skipping prompts only; its first interactive use is confirmed
once and recorded under the Reasonix home. Shift+Tab follows 1.x's order,
Ctrl+Y returns to the posture it left, and a terminal's switch no longer
becomes the desktop's default. git -C/--no-pager classify as the
subcommand they wrap.
The kernel's slash menu drops the row that would replace the line with
itself, so a finished /tree left only fuzzy neighbours in the menu and Enter
accepted the first of them: /tree ran as /security-review. A finished /hooks
or /mcp stopped at their argument menu and a second Enter put its first
argument in the composer.
Complete now names the dropped row as Typed, which Studio ignores. The TUI
lists it first, and Enter sends the line when it already reads what the
highlighted row would make it: the command's label, or the argument in place.
That is the rule 1.x's composer follows; a half-typed name still completes.
Problem: Studio's ripgrep engine misses slash-glob matches under search
roots with symlinked ancestors, including macOS temporary directories.
Root cause: the absolute search argument keeps the symlink spelling while
ripgrep anchors globs against its physical process directory.
Fix: search dot from the requested subprocess directory and restore the
requested output prefix before external-root token projection. Both search
passes share the behavior; single-file targets and sandbox guards remain.
Verification: existing and new slash-glob regressions fail before the fix
and pass after it. Owning builtin/boot suites, go vet ./..., make lint, and
make check pass. The new test covers both passes and output path privacy.
The phone page reconnects silently when the relay closes for idleness, a
legacy admission expiry or a dropped socket, and resends in-flight requests
under the same id. A read is always safe to resend; a state change is
resent only to a desktop that advertises replay, and failed otherwise, so
nothing runs twice. Relay close codes decide the next step: 4401 asks the
user to sign in again (sign out, then the existing sign-in page), 4403 and
4004 stop for good.
The desktop keeps controller sessions across its own relay reconnects,
prunes them against the relay's relay_hello list, sends the relay's
heartbeat frame once the relay names it, and caches answers to
state-changing desktop requests for ten minutes so a resend is answered
from the cache instead of running again.
Refs #11226
The terminal typed /status, /help, /quit and the other 1.x commands studio
has not ported yet, and the kernel sent each line to the model as prose. That
spent a request, and for /status it ran git and asked for an approval.
A submission can now ask the controller to refuse a slash command no registry
resolves: it gets an unknown-command notice (i18n SlashUnknown, the wording
1.x uses) with the unknown_command code and starts no turn. Paths, code
comments and custom commands resolve exactly as before. The TUI always asks;
Studio does not, so its behaviour is unchanged.
A 1.x command line that reached one of these exited 2 on studio:
- `reasonix chat` / `reasonix code` were unknown commands. They are the
interactive session in 1.x, so they now route to the terminal UI.
- `reasonix --preset X -p task` was an unknown command because --preset was
not a leading session flag. It is again, so it becomes a print-mode run.
- `run --takeover` was an unknown flag. It is accepted; with no contention the
run proceeds as in 1.x. Taking a session away from another process has no
counterpart here, so a held session is still refused, and the refusal now
says --takeover cannot do it and points at --copy.
- `sessions reindex|diagnose|cleanup` fell through to the machine `session`
command. Sessions are listed straight from their directories here, so
reindex has no catalog to rebuild and reports the count it reads; diagnose
and cleanup report and trash covered conflict-recovery branches through the
sessionstore guards Desktop already uses, keeping 1.x's JSON key names for
what is measured and its dry-run-until---apply contract.
A provider entry gains display_name, the key 1.x already writes, so a
config shared with 1.x keeps it on every save. Name stays the identity:
refs, default_model, saved sessions and preset upgrades still point at it.
POST /providers/display-name relabels every entry shown as one account,
all or none, and refuses an over-long label or a control character with
its own code. The provider list, the model list and /status carry the
label; the settings list renames in place (pencil or F2, empty restores
the derived name), and the model picker, wallet header and ACP model
descriptions show it.
Closes#11237
resolveSetupTargets takes every unrecognized argument as the config path, so
`reasonix setup --help` wrote a file named --help and reported "Wrote --help".
1.x prints the setup usage for -h/--help and exits 0, and refuses any other
dashed argument with usage on stderr and exit 2. The ACP registry advertises
`reasonix setup` as the login method, so its help has to answer.
Port that guard, in its own file, ahead of target resolution.
The in-app find already searched the rows rather than the document, so
it sees messages the transcript has not mounted. What stood between a
person and it:
- The chord matched e.key exactly. Caps Lock sends "F", an input method
answers "Process", a Cyrillic layout sends "а" - none of them opened
anything. The window now reads the letter case-insensitively and falls
back to the key's position when the layout reports no Latin letter.
It takes Cmd on macOS and Ctrl elsewhere, so Ctrl+F in a macOS text
field keeps its Emacs meaning, and AltGr is not read as the modifier.
- Nothing on screen offered it. The title bar gets a find button (the
only way in on a phone) and the palette gets the command, both showing
the chord.
- It took the chord from every text field. A field hands the window's
chords through only when it says so (data-window-keys): the composer
and the find box do, a settings filter or the browser's address bar
does not. An editor that answered the press itself (CodeMirror's own
find) still spends it.
Stepping also moves within a row now. Twenty matches in one tool output
counted as twenty but painted and landed as one; the current occurrence
is the nth drawn in its row, and landing scrolls it - through the
output's own clip if it scrolls - to where it is read instead of leaving
the row's top under the bar. A newer landing cancels one still settling,
which typing a query used to race. Arrow keys step like Enter.
The current match was accent at 45%, which in the Studio theme is a
light grey behind light text in dark mode; it is the accent/accent-fg
pair now, neutral in both themes.
The window's key listener moved out of App.tsx into windowkeys.ts to
keep App under the line ceiling.
A workspace path concatenated into the sidecar is not valid JSON on
Windows, where its backslashes read as escapes; the meta then fails to
parse, the session is never routed to the blocked directory, and the copy
the test expects to fail succeeds.
A shell command whose command line exceeds CreateProcessW's 32,767 UTF-16
unit ceiling never starts; the model was told "fork/exec powershell.exe:
The filename or extension is too long." and read that as a file-size
problem. On Windows the PowerShell tool passes the whole script as one
-Command argument, so writing a large block of text (e.g. a Chinese
document in a here-string) hits this every time.
The host now counts the command line exactly as os/exec builds it and,
when it will not fit, refuses the call before launch with a typed error
(errCommandLineTooLong) that states the size, the limit, and that nothing
ran, and points at write_file/edit_file. The execution is recorded as
not run / not started. The not-run bookkeeping moves into one helper so
ExecuteDetailed stays within its size budget.
Refs #11229
Opening a session 1.x wrote deleted its <id>.context.json. 1.x writes
context schema 4 from 1.36.0; LoadCompactionState accepted only 1-3, and
loadProjectionSidecar removed the file on any load error. The model-written
compaction summary was lost for good, 1.x itself resent the full history on
its next turn, and 2.x sent the full history too.
A load error now drops the projection in memory only. Unknown schemas carry
ErrContextSchemaUnsupported. Schema 4 is schema 3 plus
projection.pinned_context_hash, so it is read when that hash is empty; a
pinned-context checkpoint is authenticated against 1.x provenance this build
does not model and stays unsupported. A load never rewrites another release's
file (lineage keys are normalised in memory), and dropping the projection of
an open 1.x event log leaves the file, since this build never changes that
log. When a 1.x conversation moves to a 2.x session, the projection is
inherited there in schema 3 if it still covers the transcript, so the first
2.x turn sends 1.x's summary instead of the folded history.
The effect test replays a schema 2 session captured from a real 1.38.7 build.
Problem: Clicking the Dock icon leaves a Studio window hidden after closing to tray.
Root cause: macOS activates the existing process without emitting second-instance, and the shell had no activate listener.
Fix: Route activation through the shared window reveal path and ignore reveal requests during shutdown. Extend native smoke coverage across activation, second-instance, and tray entry points.
Verification: macOS native smoke, Electron unit tests, frontend build, host build, go vet, and repository lint passed.
A phone reaching Studio through the account relay has no EventSource; the
page swaps in RemoteEventSource, which polls /events/replay over the
encrypted channel. It fell short of the stream it stands in for in three
ways:
- It found /replay by rewriting a URL that ends in /events. Since the
silence watchdog (#11216) a replacement stream carries ?lastEventId=, the
rewrite missed, and every poll asked for the live /events route, which the
desktop refuses over the relay. An idle pane went silent for 45s and then
stopped receiving events for good.
- It learned its starting position by asking for lastEventId=0, so each
attach downloaded the whole replay log (up to 8MB for a long running
session) through the relay and threw it away. Asking past the watermark
returns no frames and the watermark alone.
- It never said the frames SSE writes on its own, so a quiet stream looked
dead to the watchdog and a truncated log looked complete. A quiet poll now
restates the watermark, and complete=false becomes a stream_gap.
Separately, a reload always focused the first open pane, because which pane
the window showed lived only in React state. The relay's "reconnect" button
reloads the page, so the phone landed on the top project's first session
instead of the one it was on. The tab now keeps that id in sessionStorage
and takes it back when the pane is still open.
importJsonlSessions reported only artifact failures through hadArtifactFailure,
so a session whose own copy failed was skipped silently and the one-time import
marker still went down. The next launch then saw the marker and never retried,
leaving that session unimported for good.
Both copy paths now report the failure, so the marker stays off and the next
launch retries, matching what a failed artifact copy already did.
await_user's `need` is shown to the user as a one-line host notice. With
nothing bounding it, a model put the whole question there, numbered options
included, so the user got a wall of text to answer by typing instead of an
ask card they could click. The description now routes a pick among named
choices to ask, and the tool refuses a need longer than 200 characters with
an error that says to put the substance in the reply or call ask. Length is
a property of the argument, not a reading of its wording.
Closes#11220
The hand-back notice printed the model's `need` as one plain paragraph, so
its bold, lists and line breaks reached the user as literal markup. The
kernel now tags the notice with the await_user code and carries the need
verbatim as its detail; the text form stays for the TUI and CLI. Studio
titles the card from the code and renders the detail through the same
sanitized Markdown renderer replies use.
Closes#11219
Workspace configuration only takes effect once the workspace is approved,
so the perseveration boot effect tests reached the real provider instead
of the registered stub.
The perseveration guard fixtures are byte-exact recordings; checking them
out with CRLF on Windows shifts every offset the test asserts.
A phone paired over the LAN that sleeps, or whose Wi-Fi drops the link,
can leave its EventSource looking open on a socket nothing arrives on.
EventSource only reconnects on an error it is told about, so the pane
kept showing a live connection while no event reached it, and on plain
HTTP/1.1 each such dead stream also keeps holding one of the browser's
six connections to the host, starving the requests a session switch
makes.
The kernel already restates its position on every transport each 15s
(stream_watermark). The client now treats three silent intervals as a
dead stream: it closes it and opens a new one that resumes from the last
frame it folded (lastEventId), so nothing numbered is lost. Timers stop
while a phone sleeps, so becoming visible and coming back online check at
once instead of waiting for the next tick.
A workspace's reasonix.toml and .reasonix/settings.json are read as
untrusted input.
- [sandbox]: bash may only move to enforce and network only to false;
forbid_read and denied_domains add to the user's; allow_write entries
and workspace_root apply only when they resolve inside the workspace.
${VAR} in sandbox and proxy paths expands from the process environment
only.
- [permissions] mode, allow and allow_dynamic_bash, [network] proxy
settings and [desktop] default_tool_approval_mode are read from the
user config only; deny and ask add to the user's. An "always allow"
given in a workspace is stored under the Reasonix home
(project-grants.json) rather than in the checkout.
- Programs a workspace declares (hooks, language servers, providers,
tools.search.rg_path, tools.shell.path, [browser], tools.system_one
endpoints and the local Laya runtime) run only once approved for that
folder. An approval records the declaration and a digest of the files
it names, is kept under the Reasonix home, and is checked again before
each start; a change refuses the start with ErrProjectProgramChanged.
`reasonix trust` lists and approves them, and saving hooks from the
hooks page approves the block written. Model settings that named only
a held-back provider fall back to the user's choice.
- Single program paths may not point into the workspace, a temporary
directory, an allow_write root or a toolchain cache.
- Sandbox write roots and host write directories keep the identity they
had when first granted; a root whose identity changed is dropped with
sandbox.write_root_redirected or sandbox.write_root_changed. The
host-writable directory list lives in internal/base/hostwrite.
- Every ignored value is reported as a load warning with a typed reason
(Config.IgnoredProjectSettings); held-back programs raise the
project_programs_awaiting_approval notice.
The workspace explorer leaves every dot entry out, so folders such as
.reasonix, .github or a .gitignore could not be browsed from Studio at
all. The explorer head now has a toggle that asks the kernel for them.
GET /workspace/files takes hidden=1 to include dot entries in both the
folder listing and the search. VCS stores (fileutil.IsVCSStoreDir) and
dependency trees stay out either way. The default is unchanged: dot
entries hold tool state and local configuration, so they appear only for
a reader who asked, and the choice is remembered per machine.
The explorer's head moves into its own component so WorkbenchPanel
stays under the file-size ceiling.
Closing the last tab folded the docked workbench even when the file
explorer was open beside it, so dismissing a browser tab took the file
list away with it. An open explorer is now a reason to keep the column,
the same as any tab still open; the column still folds with its last tab
when the explorer is closed.
The speed meter already opens with a sparkline of recent throughput, so
the gauge glyph before it said the same thing twice and cost width in a
rail that is tight next to the composer. The cache and context meters
carry no leading icon either; the speed meter now reads the same way.
Under 760px the workbench collapses to one column and the stylesheet hid
the explorer unconditionally, so on a phone paired over the LAN (or any
window that narrow) the files button toggled to pressed over an empty
body and the workspace tree could not be reached at all.
At that width the explorer and the canvas now take turns in the single
column through the existing data-files toggle, the same way a docked
workbench already does. Picking a file still puts the list away and
shows the file.
The guard detects a degenerate generation loop — a model echoing one short block
of text or reasoning byte-for-byte — by default, and reports it through the
progress-watch channel as a new `perseveration` cause. Nothing is cut and nothing
reaches the model unless the caller opts in: a byte-identical block is sometimes
legitimate output (printing a value 300 times, a run of identical table or log
rows, a page of `=` or `A`), so cutting it by default was the wrong trade. The
notice is said once per loop episode — a scan that trips again while the previous
scan also tripped stays quiet, and the next scan that does not trip re-arms it.
The user's `progress_watch.pause` setting is what ends a run on a detected loop.
Cutting the stream and nudging an unattended retry is opt-in with
`perseveration_retries`, under [progress_watch] or per provider on a [[providers]]
entry (the provider value overrides the global default). Unset or 0 reports only;
a positive value also cuts and nudges up to that many retries; a negative value
disables the guard entirely.
The cut path records best-effort usage, lets extensions rule on the terminal
before it is persisted, and never executes the aborted stream's tool calls. It
collapses the loop to the phrase and its last repetition in what is shown and
stored alike, so the transcript follows the trim instead of silently diverging
from the stored turn — no history rewrite is needed. It then shows the user a
`[retrying (N) avoiding perseveration]` interjection, appends a host-authored
nudge through the mid-turn steer path, and retries. Once the budget is spent the
run stops with a typed perseveration pause and emits the `perseveration_loop`
notice, so a parent cannot mistake the loop for a clean result. The retry budget
lives on the run, so the next user message starts fresh.
Adds a boot-level effect test asserting what reaches the provider request and the
frontend sink, and updates the guide.
The version panel's install ran download, install and restart as one act:
once the signature checked, the application ended with no prompt. It also
wrote the pin before downloading anything, for every target, so an update
that failed (or a Windows chunked swap that rolled back after restart, the
2.20.x case behind #11115) left "pinned to 2.21.0" on a 2.20.0 install,
with its only way out disabled while any other move was in flight.
- StartInstall now stops at a verified, staged release (phase "ready").
POST /update/restart commits it; it is refused with
update.restart_busy while a local turn or background job is running,
unless the request says the person saw that (force).
- The pin is written when the install is committed, only for a rollback;
a move forward releases any pin. A failed or dismissed install puts the
previous pin back. A pin naming a build other than the running one is
released at launch.
- A failed move carries a dotted code from the step that failed
(catalog, download, signature, disk, no package, installer). A forward
move records itself; the next launch, still on the old build, reports
update.not_applied with the swap helper's reason, which the helper now
writes instead of printing to a stderr nobody reads.
- The panel explains each code, links the full installer where installing
once by hand is the way out, and drops wording that promised automatic
updates Studio does not perform.
Add an opt-in vi command mode to the composer prompt ([ui].commandmode =
"vi"). At the prompt Esc enters command mode for navigation and editing
rather than interrupting a turn; command mode owns the keys h l j k 0 $ ^
i a x I A p D and the mode switch.
The composer lives in internal/frontend/tui, so the mode is implemented
there against its textarea: vimode.go carries the command-mode keys and
the mode switch, input.go routes keys through it, and ask.go and
prompts.go keep Esc from dismissing an open question card or deciding an
approval card. Command mode consumes any key carrying printable text, so
a CJK/IME character is eaten rather than inserted.
Because the interrupt key now depends on the composer mode, the
running-turn hint can no longer name a fixed key: with ui.commandmode off
Esc stops a running turn, while in vi mode only ^C interrupts, since Esc
is a mode switch whether or not a turn runs. The thinking
status line therefore names the key that actually interrupts — "(Esc
cancels)" or "(Ctrl+C cancels)" — across en/zh/zh-TW, and the footer names
the composer's input mode, NORMAL in command mode and INSERT otherwise.
The cancelling line drops its "Ctrl+C exits" hint in vi mode, where Ctrl+C
re-cancels instead of exiting, and the approval, ask and plan card hints —
plus the plan choice list — drop their Esc mention there, where Esc is
ignored.
At the prompt, ^C with a non-empty prompt clears it and leaves shell mode,
in vi mode and out. In vi mode it first saves the trimmed draft to the
cmdline history when there is one, as send() does, so an accidental
interrupt never loses it (Up recalls it); a whitespace-only prompt is
cleared without being saved. In vi mode Esc no longer decides an approval
or ask card, where it is pressed out of habit; a row key or Ctrl+C decides
instead, and Ctrl+C still cancels a running turn even while an ask answer
is being typed.
[ui].commandmode is user-global only: a project reasonix.toml cannot set
it, so a cloned repo never rebinds the user's keys.
The fresh-start fallback was inferred from `--continue` being set, so a
bare `-r` picker run with `--continue` also skipped --copy even though the
picker resumes its choice in place and has nothing to duplicate. Carry an
explicit `startedFresh` out of session resolution instead, so only a
`--continue` that found no session skips --copy; the picker and `--copy`
alone stay usage errors.
`--continue --copy` with no saved session failed with "--copy requires
--resume or --continue", even though --continue had been passed. --continue
already falls back to a fresh session when it finds nothing, so there is
nothing to duplicate; --copy is now skipped and the run proceeds.
`--copy` with neither --resume nor --continue is still a usage error. Fixed on
both the run path (cli.go) and the TUI (tui_command.go).
The run path's copy resolution moves into applyRunCopy, which keeps runAgent
within its complexity ratchet.
OpenAI documents that GPT-6 Sol and Luna take function calls on Chat
Completions only at reasoning_effort=none, and that GPT-6 Astra's tool
calling requires Responses. The curated OpenAI preset nevertheless shipped
kind "openai" (Chat Completions) with default gpt-6-sol, effort medium and a
ladder without none, so every default turn was Chat Completions + tools +
effort medium: the one combination the model table (gpt6Capability,
Responses-only) already says cannot call tools.
- The preset's entry moves to kind "responses". Its effort list and default
are unchanged; both are inside every GPT-6 model's Responses ladder.
- shippedPresets gains the Chat Completions shape as a generation, with a
Kind field on shippedPreset: an entry still identified by preset_id
"openai", our base URL, the shipped catalog, window 1,050,000 and default
gpt-6-sol is moved onto the Responses wire. Anything its user changed in
that shape is left alone, and a second pass changes nothing.
- A consistency test walks every curated preset: a model whose table row
names its wires must be served on one of them, and declared efforts and
default must sit inside that model's ladder. The old preset fails it for
all three GPT-6 models.
- Effect tests through boot.Build hold a fresh install and an install of the
old shape to a Responses body carrying tools and reasoning.effort=medium.
Docs: https://developers.openai.com/api/docs/guides/latest-model,
https://developers.openai.com/api/docs/models/gpt-6-sol,
https://developers.openai.com/api/docs/models/gpt-6-luna,
https://developers.openai.com/api/docs/guides/reasoningCloses#11199
The agent's browser panel put its page away and showed "the page is hidden
while something covers it" when nothing a person could see was over it, and
sometimes stayed that way after the cover left.
Why it misfired:
- The check took the topmost element at nine points as the answer. A layer at
opacity 0, or a transparent full-window layer that only catches clicks, is
the topmost element and paints nothing, yet it hid the whole page.
- A cover that leaves by a CSS transition, an animation or a hover changes no
attribute the panel watched, so the page stayed away until something
unrelated moved. The observer also only listened to five attribute names.
- A popover reaching into one corner hid the whole page and replaced it with
a sentence.
What changes:
- ui/occlusion.ts decides from hit testing plus computed style and geometry:
an element counts only if it paints at the probe point (effective opacity,
visibility, background, backdrop filter, a border band, its own text rects,
or a replaced element). The slot's own content, its ancestors, and layers
that paint nothing are skipped. The rectangle sent to the shell is the part
of the slot the window shows, cut to clipping ancestors. A layer reaching no
more than 8px into the edge, like a column gutter's grip, does not count.
- Recovery no longer depends on a mutation: transition and animation
end events reschedule the check, all attributes are observed, and a 400ms poll
brings the live page back whatever moved.
- A covered page is frozen instead of hidden: the shell takes a picture of the
page on screen with capturePage before putting it away, and the panel draws
that picture in the slot until the cover leaves. The sentence is gone.
The picture channel (browser:freeze) takes only the page already shown in
this window, answers only this window's own page, and the picture is drawn in
an <img> and sent nowhere else. A freeze overtaken by a show does not put the
page away. An older shell without the verb falls back to hiding the page.
Tests: browser-panel.interaction.test.tsx covers invisible, transparent and
hidden layers, the slot's own content, recovery with no DOM change, stale
pictures and view transitions. test/freeze_live.js checks the channel in a
real Electron window.
A relay that serves several vendors' models under one address and key could
only declare one supported_efforts list for all of them. Each model can now
carry its own, stored where per-model settings already live
(model_overrides.<model>.supported_efforts / default_effort) and applied by
the same model resolution every request and every level picker reads, so a
level the picker offers is the level the request carries.
Precedence: the model's own list, then the connection's supported_efforts,
then the model table for the endpoint, then the protocol's generic ladder. A
model with no declaration inherits; installed configs need no migration.
- config: ModelEffortDeclaration / SetModelEffortDeclaration (keeps every
other override field, drops an emptied override, ErrModelDefaultEffortNotListed
for a default outside the levels) and InheritedEffortCapability. ResolveModel's
three branches share one forModel step.
- EffectiveEffort: the stored level is one per connection, so after a model
switch it can fall outside the next model's levels. That model now runs at
its own default, the level its picker's "auto" names, instead of omitting
the field while the picker says auto means something else.
- An override that only sets max_output_tokens is no longer dropped as empty
on load.
- serve: /providers lists each chat model's own, inherited and protocol
declarations; /providers/edit takes modelEfforts as the whole answer for the
listed models, refusing with provider.model_default_effort_not_listed and
provider.model_effort_unlisted.
- Studio: the "Thinking and reasoning levels" fold gains "Levels per model":
inherit (showing what that means) or pick the model's own levels and default.
The effect test builds a mixed relay through boot.Build and checks the
request body for every offered level of both models, and that a level a
model does not take never reaches the wire.
Closes#11192
The check that refuses a click as browser.covered answered a different
question from the one it was asked, in four ways:
- DOM.getNodeForLocation takes document coordinates, and it was given
viewport ones. On a page scrolled by less than the target's distance
from the top of the viewport it hit-tested a point that far above the
target and reported whatever sat there (a banner, a nav bar) as
covering it; scrolled further, the lookup found nothing and the check
passed, so a click under a fixed header or a veil went through to the
wrong element.
- ignorePointerEventsNone: true makes the hit test count
pointer-events:none elements as hits, the opposite of what a pointer
does, so decorative overlays were reported as covering.
- Newer Chromium (Edge 154) resolves a hit on a ::before/::after to a
CSSPseudoElement, which has no parentNode, so a button with a ripple
layer was refused as covered by its own pseudo-element.
- A form control drawn by its label (a zero-size or visually hidden
checkbox, a radio painted over by a span in its label) was refused as
not_visible or covered, although a person clicks the label.
The hit point is now converted to document coordinates, pointer-events:none
is left to the browser, a pseudo-element hit counts as its element, a hit
inside one of the target's labels counts unless it passes through other
interactive content (a link in the label follows the link), and before
refusing, the element is scrolled to the middle of the viewport, which
clears fixed headers and footers. A real cover is still refused, now
naming what is on top by ref. The label list comes from the page, so it is
capped and checked to be labels on the host side.
Measured with a table of pages at 1x and 1.5x device scale on Chromium 151
and Edge 154: before, 6 false refusals (7 on Edge), one click silently
landing on a fixed header and one passing through a veil; after, none,
and the true covers are still refused.
Every curated preset so far points at a Chinese coding plan or a
multi-model relay, so a user arriving with an OpenRouter, OpenAI or
Gemini key has to write the provider by hand.
- openrouter: DeepSeek, GPT-6, Gemini, Kimi, GLM, Qwen and MiniMax ids
checked against /api/v1/models; credits balance via the existing
OpenRouter decoder; HTTP-Referer / X-OpenRouter-Title /
X-OpenRouter-Categories so the traffic is attributed to Reasonix in
OpenRouter's app rankings. Claude ids are left out: on the openai
dialect no cache_control is sent, so every prompt token would bill at
the full input rate (claude-dialect).
- openai: GPT-6 Sol/Astra/Luna on Chat Completions, which already sends
max_completion_tokens to api.openai.com; declares the documented
low..max effort ladder.
- gemini: the Developer API's OpenAI-compatible endpoint.
- volcengine-coding-plan: the Coding Plan endpoint with ark-code-latest,
which follows the model chosen in the Ark console.
The presets live in their own file; provider_presets.go only appends
them.
A model can now declare optional run modes beside its effort ladder, in the
same model capability table. Each declaration names its id, the frontend's
label/hint keys, how it maps onto the request, the wires (kinds) and the
vendor endpoint it was established on, and whether it costs noticeably more.
GPT-5.6 (gpt-5.6 alias, sol/terra/luna) and GPT-6 (sol/luna/astra) declare
"pro", sent as Responses reasoning.mode "pro" beside reasoning.effort, on the
Responses API at api.openai.com only (developers.openai.com/api/docs/guides/
reasoning: mode and effort are independent; pro does more model work and is
billed at the model's rates for more tokens).
The mode is session state, not config and not prompt text:
- control.Controller lists the modes the session's model declares and sets
one; an undeclared mode is refused with ErrModelModeUnsupported.
- The agent carries the chosen id on every main-loop provider.Request; a new
conversation starts with it off. Subagents, compaction and side models
never carry it.
- The Responses adapter sends reasoning.mode only for a mode its endpoint
declared (projected by boot into the provider config), so a relay, Chat
Completions or a model without the declaration never sees the field.
- A rebuild carries the mode onto a model that declares it and drops it
otherwise, so a model switch turns Pro off.
- serve: POST /model-mode, refused as model_mode.unsupported; /status lists
modes only for a model that declares any.
Studio shows a switch at the bottom of the effort menu only when the model
declares a mode, marked as costing more; the trigger reads "... · Pro mode"
while it is on. The row stays pinned to the bottom of a scrolling menu.
Documents how Reasonix discovers and runs Agent Skills: the project,
custom, global and built-in roots, run_skill and slash invocation,
references/ and scripts/ handling, the extra frontmatter keys, and the
/skills and [skills] controls. agentskills.io asks listed clients to link
a page describing their implementation; this is that page.
Persist always-allow rules in private per-workspace user state and load them into the effective policy. Default skill installs to user scope unless project scope is requested, so neither action creates reasonix.toml in the workspace.
The model table named GPT-5.6's none..max ladder, but the provider was only
ever told a user-declared supported_efforts list, so on api.openai.com the
request builder refused "none" and "xhigh" at build time and clamped "max" to
"high". RequestEffortLevels now projects the resolved ladder (declared list
first, then the model table for the endpoint in hand) into the provider
config, so every rung the composer offers reaches the wire as itself.
Ladders, each from the vendor's own documentation:
- gpt-5.6 (alias of gpt-5.6-sol), sol/terra/luna: none|low|medium|high|xhigh|max,
default medium, on Chat Completions and Responses.
- gpt-6-sol / gpt-6-luna: none..max, default medium; gpt-6-astra: low..max
(none answers 400). Responses only: on Chat Completions GPT-6 takes
function calls only at reasoning_effort=none, and every turn carries tools.
- Model Studio Qwen3.8 (qwen3.8-max, -max-0902, -flash, -2.4t-a95b, -27b):
low|medium|xhigh plus none (enable_thinking=false), default xhigh; high and
max fold to xhigh, minimal to low. Vendor hosts: dashscope(-intl).aliyuncs.com
and <workspace>.<region>.maas.aliyuncs.com.
A ladder's vendor is now a host set with suffixes (vendorEndpoint), and a
ladder names the provider kinds it was declared for. The model table moves to
effort_models.go. The composer names none ("No reasoning") and tells xhigh
("Extra deep") apart from max, which shared one label.
reasoning.mode "pro" is not added here: it is a Responses-only dimension
independent of effort and needs its own control.
approval.go went 10 lines over repolint's 800-line ceiling with the MCP
origin handling. The ask-notification text builder now sits beside its
existing test file; no behaviour change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The rewind menu's three scope labels and the bash coverage-gap reason are
Chinese literals held in a table and rendered as written:
<span className="lb">{s.label}</span>
Neither static guard could see them. The catalogue test only collects
Chinese inside a t() call, and the JSX test only reads text between tags or
in a rendered attribute. A table holding Chinese is correct exactly when its
render site calls t(), so the render site was the thing that was wrong. The
browser sweep that is meant to be the judge never opens this menu either:
tools/steps.mjs walks no rewind step.
Route both render sites through t() and add the four keys to the English
catalogue. A Chinese window is unchanged - zh still falls back to the key.
Verified: pnpm typecheck; pnpm test (169 files, 1035 tests).
Extract ProjectConfigPath(root) as the single place the project config
path is computed, and route every caller through it: load, credentials,
edit, retired-key migration, MCP migration and boot. It still resolves to
<root>/reasonix.toml, so behaviour is unchanged.
MigrateV2 rewrites a fact and then the index. When the index is unreadable,
reindexIn fails after the fact has already been rewritten, and the next run
skips facts that carry an id, so the entry stays out of the index for good.
The migration now reads the index once per directory before touching any fact,
the same guard RestoreArchived uses, so a failure happens before the first
write and a retry still sees the fact as unmigrated.
Cache note: no prefix bytes move; this only decides whether a migration starts.
Add a user-global [tools.shell] env table so every command the bash tool
runs inherits extra KEY=value variables (for example
`BASH_ENV = "~/.reasonix/bashsandbox.rc"`). The value is applied over the
inherited environment but under the host-owned session/egress overrides, so
a preset value never displaces TMPDIR or an egress token.
The variables ride the sandbox spec (sandbox.Spec.ShellEnv), so foreground,
background, and host-terminal runs all inherit them alike. It is
user-global: a cloned repo's reasonix.toml cannot inject variables, because
the shell env is held across the project merge next to the sandbox grants.
Presetting a variable that names a host endpoint (SSH_AUTH_SOCK, DOCKER_HOST,
CONTAINER_HOST, XDG_RUNTIME_DIR) does not unmask it: the denied authority set
resolves from both the host environment and the effective one, so a preset
that points a client elsewhere still leaves the host's own socket masked. The
egress loopback proxy ports are unioned the same way, so a preset proxy is
closed and overriding a variable cannot reopen the host's.
The interpreter itself is still resolved by [tools.shell] prefer/path. A key
that cannot appear in an environment entry is dropped rather than written as
a malformed one, and keys are sorted so identical config yields identical
environment bytes.
The recall diagnostic reported one Omitted count for two different decisions:
the per-turn hit cap and the character budget. The note the model reads already
names both, so a reader could not tell which one dropped a fact.
RecallResult now reports OmittedByLimit and OmittedByBudget apart, and the
/memory recall diagnostic prints both. The trajectory audit keeps its combined
Omitted value, so the persisted shape is unchanged, and the model-visible note
still carries the sum of the two.
From #11146's review: "Splitting omitted into limit vs budget would be a nice
separate diagnostic."
Address the two non-blocking nits from #11148's review: state the constraint
the test pins instead of narrating the implementation, and assert the error is
the pinned-budget refusal rather than any error.
The assertion matches on identity rather than on the message:
validatePinnedBudget wraps ErrPinnedBudget, and both the recovery test and the
save path's own test assert with errors.Is. The sentinel's text is the setting
the message already names, so the user-visible message stays byte-identical.
Between roughly 470px and 720px of conversation column (a wide browser
pane is the usual way there) every toolbar control was flex: none, so the
model/effort group ran past the toolbar's box: the send or stop button sat
on the effort picker and the effort label painted outside the composer.
Below 470px the send cluster was taken out of flow with a fixed 38px
reserve, which the wider stop button does not fit in.
The row now lays out in flow at every width: the send cluster keeps its
own slot at the end of the line, the toolbar wraps inside what is left,
and the model group has a minimum basis, grows to fill the line and lets
the model name shrink to an ellipsis before it moves to a line of its
own. The spacer and the per-width offsets it needed are gone.
perf/composer.mjs sweeps 340-760px idle and running with a long model
name and fails on any control outside the composer, any two controls
overlapping, or a model name narrower than 40px; it fails on the old
stylesheet at 340px (running) and 480-720px (both states).
Fixes#11159
A run that keeps calling tools without changing anything the host can
observe (#11153: dozens of successful `echo` calls with varying arguments,
about 1.1M input tokens) had no signal short of reading the transcript.
This adds one, without bringing back any round-count guard: nothing is
sent to the model, no call is refused, and the cache-stable prefix and
the per-turn execution-policy block are untouched.
Judgement (kernel, structure only):
- evidence.EffectWatch scores each round's receipts. An effect is a
successful mutation or named write, a task step moving (StepProof,
TodoStep, or the host's todo progress counter), a verification whose
outcome changed since it last ran, a first read (a read-only tool
answering a request not seen before in this run, or a shell command
naming an existing path not named before, resolved by stat), or a
delegated result coming back. A command merely being new is not.
- agent.settleProgressRound runs at each tool-round boundary; the count
lives in turnRuntime, so a user message zeroes it. A second,
model-independent backstop compares the input tokens spent since the
last round with an effect against context window x N; an effect
restarts both counts, so a long run that keeps doing observable work
is never flagged however much it spends. Without a ledger the round
judgement stays silent rather than guess.
Feature 2 (default on): a content-free progress_watch event carries the
reading; Studio shows a one-line strip above the composer with Stop
(the ordinary cancel) and "don't show again (this session)". It clears
itself when an effect lands or the turn ends. The TUI says it once per
stall.
Feature 3 (default off): [progress_watch] pause = true ends the run at
the round boundary through a resumable pause (RunPauseInfo Kind
no_progress, TurnDone outcome no_progress). No finalization nudge is
injected. Continue sends an ordinary message, which starts a new run
with the count at zero. The section is user-level only: load restores
the user's value over a project reasonix.toml. Settings > Session edits
it through GET/POST /progress-watch, which persists to the user file
and hands the new watch to the running executor without a rebuild.
Out-of-range values refuse with progress_watch.out_of_range.
Refs #11153
The market entry gains up/down vote buttons that show the signed-in
account's vote; pressing the lit one withdraws it. Signed out, the entry
offers sign-in (a jump to the account section); a publisher, an
unverified account and a paired device each say why they cannot vote.
List rows show approval and install count, and the listing defaults to
the registry's new recommended order.
The kernel owns the calls: market.Client gains MyVote/Vote, which send
the account token only to the fixed registry host with redirects
refused, and ReportInstall. Registry refusals keep their identity
(signed_out, email_unverified, own_package, rate_limited; an identity
outage stays market.unreachable) through market.* refusal codes. Vote routes sit
behind accountAuthAllowed like every other account route.
A successful market install reports once, carrying only the slug and a
random id kept only for the market (market/install-id under the Reasonix
home, unrelated to the usage-statistics id), and only when the host allows
it (release builds), both desktop statistics switches are on, and
neither DO_NOT_TRACK, REASONIX_TELEMETRY=off nor CI opts out.
The [desktop] telemetry and metrics switch descriptions now say that
per-package market install counts are reported (anonymous, no content).
A publisher's own unreviewed install is never reported.
The usage panel summed every turn in the currency its price table was
kept in, and ignored the display currency the rest of Studio reads
costs in. A DeepSeek entry holds the vendor's USD table, so a user who
pinned CNY, or whose CNY wallet selected it, saw "US$" on the panel
while the pane's cost meter said "¥".
Every quote already carries the vendor's own table in both currencies,
and the stats line records both valuations; nothing had to be converted,
only read. The usage catalog now keeps each row's valuations and tallies
one partition per view: a row the vendor priced in the display currency
moves into it, and any other row keeps its billed currency, so a relay's
USD price is still shown as USD rather than converted at an invented
rate. The schema version moves to 5, which rebuilds the projection from
the JSONL it is derived from.
The live projection path also dropped the cost of every row a running
process appended, marking the file indexed all the same, so today's
spend vanished from the panel until the next restart. It now carries
the same cost a reindex would.
A skill in the Studio composer was only the text "/name", and only a
leading one ran: the kernel reads a slash at the start of a line and
nowhere else, so "/review" typed mid-sentence reached the model as prose.
Picking a skill or subagent from the slash menu now places a chip, at the
start of the line or after any "/" that opens a word. A "/" past the start
asks the kernel's completion as if it opened the line and keeps only
skills and subagents, since a command is not something a sentence can
hold. The chip is the record of the pick; the same letters typed by hand
stay prose, so nothing reads the submitted wording to find a skill.
The chip is drawn by a mirror laid over the textarea at the textarea's own
computed metrics, so caret, selection, IME and undo stay native. Edits are
reconciled from the text the chips were last placed in, so every way into
the box (typing, drops, prompt refine, a refused send) carries them, and an
edit that reaches into a chip removes it whole. Skills take --net and
subagents --deleg.
On send the line goes out as typed (input), the text with the chips taken
out (submit), and the chips (invocations). /submit and POST /inbox/items
route a chip line through the inbox envelope, which already carries
invocations to prepareInvocationTurn, so a mid-line chip reaches the model
byte-identical to a leading "/name task". A kernel that predates this reads
only input and still runs a leading chip. TryEnqueueAndSteer queues a line
holding an invocation as a follow-up: a steer is read as guidance and would
drop the skill.
Closes#11145
RestoreArchived checked id and name collisions but skipped the rest of what a
save validates: a recovered fact could take a subject_key another active fact
now holds, or push pinned guidance over the configured budget. The fact model
states the subject rule unconditionally, and the save path refuses both.
Validate the restored fact with the same validator SaveWithOptions uses, before
any part of the restore is written.
studio still drops the read error when it rewrites the MEMORY.md index:
store.go reads the existing index for indexLinesExceptIn and flushIndexIn
with `existing, _ :=`, so an unreadable index reads as empty and the next
save rebuilds it from the managed lines alone, silently discarding
hand-written content while reporting success.
main-v2 already fixed this in 2eb878989 ("preserve index when reads fail"),
with 79acaf6e9 moving the readers into store_index.go to stay inside the
repolint budgets. This ports the same shape to studio so both lines behave
alike:
- readIndexIn keeps treating a missing index as empty but returns any other
read error; indexLinesExceptIn reports that error and whether the name was
present, which lets indexContainsIn go away.
- flushIndexIn and reindexIn propagate the read error instead of rebuilding
the index from an empty read.
- archiveLocked and archiveMemoryInDir read the index before archiving, so a
failed read happens before any side effect.
- SaveWithOptions reuses removeMemoryFromDir and archiveMemoryInDir, dropping
the duplicated remove/flush and archive/flush pairs and keeping the
function inside its size and complexity budgets.
AppendDoc discarded the read error, so an existing but unreadable doc-memory
file (REASONIX.md/AGENTS.md) was treated as empty and rebuilt from scratch,
silently destroying the user's standing instructions. Surface the read error
unless the file is simply missing.
Ported from 1.x (#8319), which fixed the same defect on main-v2; the 2.x
writer still had the original discard.
Entries may carry a discussion number instead of an issue number. Discussions
have no REST comment API, so the reply goes through addDiscussionComment and
skips a body the bot already posted there.
A publisher can now keep a package to themselves and install their own
packages before, or without, review.
- Publishing takes a visibility. "Only me" saves the package as private:
it skips review and never appears in the market. My packages marks it
private and offers "Submit for review", which moves it to pending.
- Every row in My packages can be installed. The kernel reads the package
from the registry's /v1/me/packages/:handle/:name as the signed-in
account, never from what the window sends, so another account's package
is refused (market.not_yours).
- A live version keeps its reviewer's pin. Any other version has none, so
the preview's own contentDigest becomes the pin: apply must echo it
(market.unpreviewed otherwise) and install_source refuses material that
changed since the preview. The confirmation says "Unreviewed, visible
only to you", and the ledger records the install as unreviewed.
- The plan, the risk grades, the multi-skill confirmation and the
themes-only check are the same ones every market install passes; the
confirmation view is shared with the market entry page.
Each market row is now two lines: name, kind, verified/installed badges and
the install count on the first; a one-line truncated summary with the
publisher and version, de-emphasised, on the second. Rows stay buttons, so
they open with the keyboard, and truncate rather than overflow at phone width.
The listing now says which packages can be installed from the market. The
kernel reads the registry's `pinned` per listed package (nil when an older
registry does not say, so nothing is claimed) and forwards `pinned=1` for the
new "Installable only" toggle. The registry applies that filter, so pages stay
whole; a registry that answers a filtered ask with an unfiltered page is
refused as market.filter_unsupported rather than trimmed client-side. The
install path is unchanged and still checks the approved version's digest.
The chunked update stages the new tree in the update cache and a helper
swaps it in after exit, renaming each installed file into a backup
directory beside the staged tree. Two installs can never complete that
first rename: an all-users install under Program Files, which grants its
users read and execute only, and an install on another volume than the
user's cache, where MoveFileEx without MOVEFILE_COPY_ALLOWED refuses.
For both, the helper rolled every file back and relaunched the old build
with nothing shown, so the update downloaded, "installed", and the
application came back on the version it started from. Before chunked
updates these installs took the full installer, which raises its own
consent and writes wherever it installed.
tryDelta now proves that move before fetching anything: it creates an
entry in the install root and renames it into the cache's delta
directory. If either step fails the full package is downloaded instead,
as for any other delta that cannot be used.
Closes#11115
In a conversation column of 460px or less, app.css stretches the send
cluster to width: 100%, and at 470px or less studio.css takes the same
element out of flow (position: absolute; right: 0; bottom: 0). The two
rules combined into an invisible full-width strip over the composer's
last line, so every tap on the model picker or the effort picker landed
on the send cluster. The pickers rendered but never opened on a phone,
over LAN and over the relay alike.
The absolute rule now declares width: auto, so the cluster is only as
wide as the send button. A stylesheet test resolves the cascade for
the cluster at phone widths and fails if it is out of flow and a full
row wide.
Closes#11116
An over-budget worktrees/ or modules/ group was bound read-only as a whole,
but the exact mounts for the gitdirs inside it were still emitted first, so
planting submodule gitdirs kept growing the bubblewrap argument list. Paths
beneath a read-only tree are now left to that tree.
The serve launch-token sandbox test placed its files under /tmp outside the
write root, which bubblewrap masks with a tmpfs: the control read failed and
the denied reads passed without exercising the forbid-read mounts. The files
now live inside the write root on every platform.
Add English wording for the serve.host_rejected and
auth.launch_token_required refusals.
Signed-in users can now submit skills, plugins, MCP servers and themes to
the community registry from Settings > Extensions > Discover, and follow
each submission's review state.
- internal/ext/market: Client.Publish (POST /v1/packages) and Client.Mine
(GET /v1/me/packages) send the account session as a Bearer token to the
same fixed https host the listing reads, with redirects refused, so the
token never reaches another host. The registry's refusal codes are
projected onto sentinels (ErrSignedOut, ErrEmailUnverified, ErrNotOwner,
ErrVersionExists, ErrRateLimited, ErrRejected).
- A submission is checked against the same source rule an approved
version must pass to install (sourceInstallable, shared with
installable): plugins and themes must name a GitHub tree pinned to a
full commit. Nothing is sent for review that could never be installed.
- theme is its own listing kind that installs through the plugin
installer (Installer("theme") == "plugin") under the same commit pin and
digest check. The plan must consist only of plugin packages that carry
themes and nothing else (no skills, commands, agents, hooks, MCP servers,
prompts or runtime); otherwise market.not_theme.
- serve: POST /market/publish and GET /market/mine, gated like every
account route (host grant plus a stored session), each failure a dotted
code (market.signed_out, market.unpublishable, market.rejected, ...).
- Frontend: Browse / My packages / Publish views inside the market group
while signed in, a sign-in prompt otherwise; a Theme category filter.
- A theme's apply re-plans and checks themes-only before writing, because
a planId binds the source and actions but not the listing kind. A
registry accounts_unavailable answer reads as market.unreachable, not
as signed out.
The host's own git reads the workspace repository's configuration and hooks,
so a confined command that can rewrite them can make the host run a program
of its choosing. The OS sandbox now denies writes to them while leaving the
rest of .git writable.
For the repository git itself discovers from each writable root, protected
paths are: config, config.worktree, commondir and hooks/ of the gitdir and
of the common dir; config, config.worktree and commondir of every existing
linked worktree entry (new entries stay creatable); config, config.worktree,
commondir and hooks/ of every submodule gitdir under modules/. A .git file is followed to the gitdir it names
exactly as git resolves it, and the pointer file and commondir are
protected, so a repository cannot aim the protection at a decoy while git
reads something else. The .git entry, the gitdir, the common dir, every
symlink on the way to them and each writable ancestor are pinned: they
cannot be removed, renamed away or replaced by a symlink, while their own
mode and times stay writable (touch and chmod on the workspace root work).
- macOS: deny rules after every write allowance. Existing worktree and
submodule gitdirs (up to 128 each, found without following symlinks) get
exact rules and pins; patterns anchored at the common dir cover the rest
and gitdirs created later, skipping refs/ and logs/ segments so a branch
or tag named config or hooks stays writable. The profile stops growing
past the limit.
- Linux: pinned directories become their own bind mounts and protected files
and hook trees are bound read-only; only real directories and regular
files are mount targets. Up to 512 gitdirs per group get exact mounts;
past that the whole group directory is bound read-only, so planted HEAD
files cannot overflow the argument list. Only existing paths can be
mounted and symlinks cannot be pinned.
- Windows has no Bash sandbox; the guide says so.
A protected file with another hard link refuses the confined launch with
sandbox.git_metadata_linked (ErrGitMetadataLinked), since no path rule sees
a write through the other name. Whenever a confined command's output names
a protected path, also on exit 0 (git reports some refused config writes
and exits 0), the bash result carries sandbox.git_metadata_protected.
Real-sandbox tests cover each protected path, pointer and symlink
retargeting, patterns for gitdirs created later, bounded rule size with
2000 submodule gitdirs, ordinary git work, and the git operations that now
fail or silently skip their config write.
Every git invocation the host makes goes through gitcmd, and each one must
leave the programs a repository's own configuration names unexecuted:
filter and merge drivers, textconv and external diff, fsmonitor, hooks,
signature verifiers, submodule-boundary programs and lazy-fetch transport.
- Fixed keys are overridden with -c: core.fsmonitor (empty, not "false",
which git before 2.35.2 reads as a hook name), core.hooksPath,
log.showSignature, merge.verifySignatures, diff.submodule, gc.auto and
maintenance.auto and safe.bareRepository=explicit; submodule.recurse=false
for checkout, switch, restore, reset, merge and read-tree (host tree
updates never update submodules), and elsewhere only where the
repository's own config sets it.
GIT_NO_LAZY_FETCH=1 stops a missing object becoming a fetch through the
repository's remote (git 2.44+); a historical patch that fails for that
reason returns gitcmd.ErrObjectNotLocal, decided by git's own
--missing=print listing.
- diff, log and show carry --no-ext-diff and --no-textconv; status and diff
carry --ignore-submodules=dirty. The subcommand is found past global
options, so a call passing -C inside args is hardened too.
- Filter and merge drivers defined at local or worktree scope are listed by
git itself before each invocation (includes followed) and overridden empty;
user and system scope stay live; a name -c cannot address fails closed
(ErrRepositoryDrivers).
- worktree add uses --no-checkout and populates through gitcmd inside the new
worktree, so a conditional include keyed on the new gitdir or branch is
seen by the driver listing there rather than run by the add's checkout.
The reset takes the worktree-mutation timeout, and a failed reset removes
both the worktree and the branch the add created.
- Launcher ls-remote runs Detached with gitcmd's environment (only PATH is
taken from the launcher's declared env), requires an https://, ssh:// or
git:// remote (case-insensitive; http:// and local paths are refused), and
passes the remote and ref past -- so a leading-dash value is an operand.
- frontend/cli runGit passes its directory through gitcmd so enumeration runs
in the target repository, not the process cwd.
- Repository identity is resolved once, when a session opens: boot runs
gitcmd.Open (rev-parse --absolute-git-dir --git-common-dir --show-toplevel)
before any command, the controller carries the resulting gitcmd.Repo, and a
rebuild of the same workspace passes it on. Every later host call runs
through the Repo with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set, so a
nested .git, a commondir file or a damaged HEAD written afterwards cannot
substitute another repository or its config; a damaged HEAD fails closed.
Threaded through gitstatus Status/Diff (serve /changes and diff),
worktree Inspect/Create/TakeSnapshot and the candidate/apply/remove paths
(Snapshot and Candidate carry their Repo; host-created worktrees are opened
right after worktree add), the isolation store, best_of_n, and cli review
(resolved once before the review agent runs; ObjectsMissing is a Repo
method).
- Open accepts only a git dir belonging to the work tree that holds the
directory: the work tree's own .git (sharing no other common dir) or a
linked worktree's admin dir under <common>/worktrees whose gitdir file
names that work tree's .git. A .git file or core.worktree pointing at
another checkout is ErrNotRepository. Submodule checkouts and
--separate-git-dir repositories opened directly are therefore treated as
not version-controlled.
- Repo.Verify applies Open's test to a stored identity; Snapshot and
Candidate identities are verified when used again.
- The snapshot's private index copy keeps the real index's mtime: with a
fresh mtime git trusted stat for entries written in the index's own
second, so a same-size edit made in that second was left out.
- Snapshot staging uses gitcmd Repo.StageAll: gitlinks present as directories
and untracked nested repositories are excluded with :(exclude,literal)
pathspecs, because add -A checks a submodule by running git inside it under
its own config. A gitlink keeps the commit the index records; an untracked
nested repository is left out (it previously failed the snapshot when it had
no commit).
Regression tests use real git with payload scripts: the gitcmd config-key
matrix (signatures, lazy fetch, submodule boundary), the worktree call sites
(conditional includes on Create and CreateCandidate), the launcher's non-URL
remote rejection, identity pinning after a nested .git, a commondir file and
a damaged HEAD (config origin and filter marker), Open refusing another
checkout's git dir (.git file, core.worktree, foreign admin dir), StageAll
with nested repositories named like pathspec magic or globs, StageAll and TakeSnapshot
past a gitlink with its own filter, the listing and snapshot of a workspace
that later gains its own .git, and a global submodule.recurse=true against
host checkout and reset. The list-then-run race is left to a follow-up that
denies sandboxed writes to .git config and hooks.
`reasonix serve` now generates a per-launch token unless `--auth` or
`[serve].auth_mode` chooses otherwise. `none` remains an explicit opt-out for
deployments fronted by their own authentication: reads stay open to loopback
and bound names only (421 `serve.host_rejected` otherwise), and every
state-changing request, approvals included, needs the launch token (bearer
header or the /auth/token cookie) and otherwise refuses with
`auth.launch_token_required`. A request the loopback gate, a paired-device
gate or the in-process transport already admitted counts as holding it, so
Studio and the TUI keep working unchanged. Token mode also accepts the bearer
header.
The token never reaches the terminal: serve writes it to a 0600 file under the
remote state directory and prints the path, or names the --token-file of a
managed launch. Runtime sandboxes and read tools deny that directory and every
`--token-file`, `--provider-broker-file` and `--provider-broker-token-file`.
[serve] is read from the user config only, so a project reasonix.toml cannot
choose the token, the auth mode, or trust in forwarded headers.
Studio can now browse, search and install from the community registry
(Settings > Extensions > Discover). The market is a source, not a trust root:
- internal/ext/market reads the registry read-only: https to the fixed
crash.reasonix.io host, 15s timeout, capped bodies, redirects refused,
non-active rows dropped. Registry text is untrusted data.
- Only the approved version is installable, and only when its row carries a
content digest. The digest travels to install_source as expectDigest.
- install_source gains a contentDigest on every plan (machine-independent:
SKILL.md bytes, plugin commit, MCP entry) and refuses a plan whose material
differs (ErrDigestMismatch) or cannot be pinned (ErrNotPinnable). Preview
and apply both pass the check, and apply writes what its own plan read.
- GitHub plugin sources pinned to a full commit (tree/<sha>) now clone and
check out that commit instead of failing on --branch <sha>.
- A remote source that expands into more than one skill is graded high, so
the permission layer asks a person even under a blanket allow, and the
market confirmation lists every skill and holds the install button until
the person ticks that they read them.
- Market installs require the planId of the plan that was shown, go to the
global scope, and are recorded in <home>/market/installed.json; "installed"
is read back only while the recorded targets still exist.
- serve: GET /market/packages, GET /market/packages/{handle}/{name},
POST /market/plan, POST /market/install, each failure a dotted code
(market.unpinned, market.content_changed, market.unreachable, ...).
Adds an opt-in cloud backup under Settings > Account, shown only while
signed in. The kernel collects the user-level setup by category, seals
it locally (gzip, then XChaCha20-Poly1305 under an Argon2id key derived
from a passphrase the user sets), and uploads only the envelope to the
accounts service; the service never sees plaintext.
Categories: settings (providers, default model, interface look),
extensions (user skills, plugin references by source and version, MCP
servers), memory (user-level instruction files and global facts),
automation (global hooks, status line) and secrets (stored provider
keys, off by default). Without secrets, literal env/header values are
stripped with the plugin exporter's StripCredentials and URL userinfo
is dropped; the account session token never travels. Approval mode,
permissions and sandbox are deliberately not restorable.
Restore is preview-then-apply. The preview decrypts, diffs each item
against this machine, shows everything an item would write (endpoints,
env, headers, cwd, instruction file bodies) and holds the snapshot under
a single-use plan id. Apply recomputes consent against the machine at
apply time and refuses, writing nothing, unless each of these is named
in `consented`: hooks, the status line and MCP servers (code that runs
here); a provider that is new or changes endpoint, and a default model
pointing at one (conversations and keys go there); a key that replaces
one already stored; an instruction file that imports other files.
A snapshot is rejected outright when a key is not read by a provider it
carries, when a provider reads REASONIX_* or a host-steering variable
(proxy, TLS, loader, PATH), or when a unit's payload names another item.
Plugins are never installed by restore; they come back as sources for
the ordinary plugin install flow and its plan approval. File items are
written only under their named root, through validated relative paths
that may not cross a symlink; hidden files are not exported. Config
writes go through config.EditConfigFile, hooks through hook.Save, keys
through config.SetCredential.
Serve: GET/POST /backups, DELETE /backups/{id}, POST /backups/{id}/preview,
POST /backups/apply, gated by the account grant and a signed-in token,
with dotted refusal codes the frontend words in both languages.
The hosted build has no kernel behind its origin, so opening
studio.reasonix.io directly probed /runtimes, got the SPA shell and
reported that the kernel did not answer. The deploy now declares where a
visitor picks a computer, and a visit without ?device goes there.
A plan whose steps name candidate files owes a change the host proved.
The shadow verdict and the receipt already said so; the gate now holds
an answer that changed nothing, and releases a turn that concluded
blocked, is waiting on the user, or carries a change a restart brought
in. The hold names all three ways out.
The executor-handoff nudge now runs before the gate ends the run: an
executor that has not acted yet gets its one in-turn nudge first, and
the gate holds only what the nudge could not recover.
publish downloads studio-windows-signed by name, but any job in the run can
upload an artifact. windows-sign-installer now outputs the SHA-256 of the
installer it signed and verified, and publish requires the downloaded
artifact to hold exactly the installer and the portable zip, hashing to
that output and to windows-verify-package's archive hash. A mismatch, a
missing hash or an extra file fails with a studio-signing.* title before
anything is signed or published.
The Microsoft-signed d3dcompiler_47.dll and dxil.dll must now also chain to
Microsoft Root Certificate Authority 2010, pinned by thumbprint; subject and
issuer names alone can be issued under any trusted root.
The plan-criteria gate and the frozen contract verdict measured "passed
after the latest change" from LatestSuccessfulMutationIndex, which counts
an unclassified command as a change. A plan check such as `python -c ...`
is exactly such a command, so it became the latest change and owed itself
a later run that could never come: every plan whose checks were inline
scripts ended with its criteria outstanding, graded pass or not.
Both now read LatestProvenMutationIndex, the baseline project checks and
owed review already use: the latest proven write, or the latest unproven
one only when nothing was proven.
The Windows release signed only the three executables Studio builds. The
installed tree also ships Electron's ffmpeg.dll, dxcompiler.dll,
vk_swiftshader.dll and vulkan-1.dll unsigned, and Smart App Control / WDAC
can refuse to load an unsigned low-reputation DLL, so the app may not start.
Every PE file the Windows bundle may hold is now declared in
scripts/windows-signing-lib.ps1: seven the release signs, and
d3dcompiler_47.dll and dxil.dll, which Electron ships signed by Microsoft
and which are kept that way and verified as Microsoft-signed. A bundle with
an undeclared or missing PE file (.exe, .dll, .node, or any MZ header) fails
until a person updates the list.
windows-sign-payload signs the declared files, verifies all nine, and
records a SHA-256 digest of the whole signed tree as a job output. The
signed tree is the payload artifact; windows-package packages it as it
came. A new job, windows-verify-package, with no secrets and no
environment, checks the payload against that digest, unpacks the zip and
the installer's app-64.7z, requires both trees to equal the payload file
for file (plus resources/elevate.exe, pinned by SHA-256), and outputs the
SHA-256 of both packages. windows-sign-installer only requires the packages
to hash to those values before signing, and afterwards only runs SignTool
verification, so no archive is parsed in a job holding the Certum
credentials and Certum never signs an installer whose contents did not
match.
Signatures are read from the file itself through SignTool and the embedded
signer certificate. Get-AuthenticodeSignature answers from the OS catalog
for d3dcompiler_47.dll and reports a catalog signature whatever the file
carries.
The contract pins the new scripts, and validate now refuses a fingerprint
file that runs a script under scripts/ which is not pinned.
Root cause: some Anthropic-compatible gateways, DeepSeek's among them, open
the web_search_tool_result block with an empty content array and send the
results in a later web_search_tool_result_delta. The stream decoder declared
the delta's results field but never handled the event, so the search produced
no provider-run call: no card, and the only visible source list was whatever
the model wrote into its answer. 1.x fixed the same gap in #9197.
Fix: the server-run search state (serverBlock) now remembers result blocks
that opened empty and completes them from the delta, once per block. The
block-start and argument handling moved onto serverBlock with it, which keeps
readStream within its size and complexity budget.
Test: TestStreamSurfacesWebSearchResultDelta fails on origin/studio
("want 1 provider-run call from the result delta, got 0") and passes after
the fix; go test ./internal/model/anthropic/ passes on macOS and Windows.
Fixes#8900
Settings > Appearance > open theme folder failed on Windows with
`exec: "explorer.exe": executable file not found in %PATH%`.
Root cause: revealFolder started a bare "explorer.exe", which exec
resolves through PATH. explorer.exe lives only in %SystemRoot%, and the
environment Studio is launched with does not always carry that directory
on PATH, so the lookup failed before anything started.
Fix: resolve explorer.exe from %SystemRoot% (falling back to %windir%),
the same way the 1.x desktop's revealPath already does, and keep the
bare name only when neither variable is set.
Test: TestFolderCommandFindsExplorerWithoutPath sets PATH to an empty
directory and asserts the command resolves to %SystemRoot%\explorer.exe.
On Windows it failed on the base with the reporter's exact error and
passes after the fix; the serve Folder/Theme tests pass.
Fixes#11079
ACP clients render their token/cost meter from the stable usage_update
session notification (Session Context Size and Cost). The ACP server
never sent it, so clients such as Paseo showed no usage at all even
though the controller already measures the context gauge and the
session status telemetry already totals cost.
After every prompt turn, and after session/load and session/resume,
the server now sends usage_update with used/size from the controller's
ContextSnapshot (the same measurement the compaction trigger uses) and,
when every priced event produced a quote, the cumulative session cost
with its ISO currency. A session with no context window yet sends
nothing, since used and size are required by the protocol.
Test: TestPromptTurnReportsUsageUpdate drives session/prompt through the
real controller and agent with a scripted provider; it fails on the base
("usage_update notifications = 0, want 1 per turn") and passes with the
change. TestUsageUpdateForCarriesCostOnlyWhenPriced pins the wire shape.
Root cause: the live transcript draws a question from ask_request and
hides the ask tool call, since both describe the same prompt. A reload
has no event stream, so fromHistory rebuilt only the tool call - which
the transcript then hid. An answered question vanished entirely after a
reload, session switch or restart, even though its arguments (questions,
options, descriptions) and its result (the recorded answer) are both in
the history.
Fix: when an ask call has a successful result, fromHistory rebuilds it
as a sealed ask card from the call's own arguments, shaped the way
agent.AskTool shapes them for the wire, and carries the result verbatim
as the recorded answer. The card shows every question and option and,
in place of the per-question picks it cannot know, what the kernel
recorded the user said. A call with no result or a failed result stays
a plain tool call. The recorded answer is also searchable.
Test: src/state/ask_history.test.ts and
src/ui/cards/AskCard.recorded.test.tsx fail on the base (no ask item
after fromHistory; the footer reads "Library:未答") and pass with the
fix. Full frontend-next vitest 988/988, tsc clean, make check clean.
TestStreamSurfacesEarlyConnResetAsInterrupt and
TestStreamDoesNotReplayAfterOutput forced a real TCP RST right after
writing the 200 head. Whether net/http observes that RST before or after
the head is up to the OS scheduler. When it lands first, the failure is a
header-phase connection error, which SendWithRetry retries by design, so
the server counts several requests and the reqs == 1 assertion fails
(#8327). A local probe that resets before the head shows the path: the
server saw 11 requests and the call spent 92s in backoff.
The fixture is now a scripted RoundTripper: a 200 SSE response whose body
yields the prelude and then fails with a net.OpError(ECONNRESET). The
phase is fixed, the request counter is atomic, and no socket is involved.
Header-phase retry keeps its own coverage in provider's retry_test.go
(TestTransientErr, TestSendWithRetryRecoversAndNotifies).
Test: both tests pass 500/500 on Windows and on macOS. Mutating ReadCut
and streamFailure so a reset is no longer a StreamInterruptedError makes
both fail, so the fixture still guards the contract.
A Windows launch from a shortcut has no console, so every way Studio
could fail to start -- boot() throwing, the host exiting before its
handshake, the host never starting -- ended in app.quit() with nothing
on screen and nothing on disk.
The shell now writes <userData>/logs/shell.log (start, lock refusal,
host start/exit/spawn failure, boot failure with stack) and tees the
host's stderr into host.log, each rotated at 1 MB x 3. The launch
credential and any token-, secret- or password-named value are
redacted before reaching either file. A launch that fails shows an
error box (zh/en by the system locale) with the reason, the host's
last stderr lines when it never handshook, and the log folder, then
quits. A second launch refused the instance lock stays silent and only
leaves a log line.
Models often wrap an address in backticks ("open `http://127.0.0.1:8787`").
Markdown links and bare URLs were clickable in the transcript, but a code
span had no link handling, so the address could only be copied out.
Root cause: GFM autolinking never looks inside inline code, and nothing
else turned a code span into a link.
Fix: a remark plugin rewrites an mdast inlineCode node into a link when its
whole value parses as one http(s) URL with a host. mdast keeps inline code
apart from fenced code, so listings are untouched; a span already inside a
link is left alone. The existing click routing (closest a[href]) opens it
like any other link, and the code inherits the link colour.
Test: Markdown.plugins.test.tsx gains two cases. "links a code span that
holds exactly one web address" fails on origin/studio (expected null to be
truthy) and passes after; the negative case (command with a URL, file://,
bare scheme, fenced block) stays unlinked. tsc and the full frontend-next
vitest suite (155 files, 985 tests) pass; make check is clean. Real-browser
screenshots of the perf build, before and after, light and dark, show the
span underlined in the link colour and the fenced block unchanged.
Problem: files the agent creates or deletes appear in the Studio explorer
only once the whole turn ends (or the window regains focus), so a long
turn shows a stale tree for its full duration.
Root cause: the pane re-read the working-tree change list only on turn
boundaries, and the explorer reloaded its listing only when that list,
the running flag, the query or the focus glance changed. Nothing between
turn start and turn end asked the disk again.
Fix: count the finished tool calls that are not read-only (the kernel's
own readOnly declaration on each call) and use that count as a trigger:
the pane re-reads /changes and the explorer re-lists its tree and the
open file whenever it moves. Read-only calls cost nothing extra.
Test: pane-tree-refresh.interaction.test.tsx asserts /changes is read
again after a write_file result and not after a read_file result;
WorkbenchPanel.test.tsx asserts the tree picks up a new file when the
count moves mid-turn. Both fail on origin/studio ("expected changes to be
called 1 times, but got 0 times"; draft.txt never listed) and pass with
the fix. tsc --noEmit and repolint -changed pass; the full frontend-next
vitest run passes 982 of 983, the one failure being a 5s timeout in
session-new.interaction.test.tsx under full-suite load that passes when
run alone, with and without this change.
Fixes#7338
An edit whose old_string drops (or adds) a blank line the file has between
two otherwise identical lines never matches: the fuzzy matcher frees only a
blank-line run's length, never its presence. The failure then read as
"not found (nearest line N)" plus "re-read the file, check for stale
context", so the model re-read the file and reproduced the same span the
same way, over and over (#6337).
The host can tell this case apart structurally: with blank lines dropped on
both sides, old_string names exactly one span, and that span's blank-line
layout differs from old_string's. oldStringNotFoundError now reports that
span and its cause. Matching is unchanged; nothing new is accepted.
Test: TestEditFileNotFoundNamesBlankLinePresenceDrift (edit_file and
multi_edit, CRLF file) fails on studio with the generic nearest-line
message and passes after; two negative cases hold the claim to one span
with a genuinely different layout.
Fixes#6337
desktop/ replaces reasonix with the parent module, so the root bumps raise
its graph too, and the Studio desktop and package jobs failed with
"updates to go.mod needed". go mod tidy in desktop/ moves only the
versions the root already requires. This also covers #11033.
Model requests on the OpenAI Chat Completions, Anthropic Messages and
Responses wires, and the /models listing, carried no User-Agent, so Go's
default Go-http-client/1.1 went out. Gateways and relays in front of an
endpoint could not attribute the traffic, and some risk controls aimed at
generic Go clients blocked it.
provider.ApplyClientIdentity now sets User-Agent: Reasonix/<version> on
those requests unless one is already present, so a configured custom
header and the OpenCode Go identity still win. Both hosts (cmd/reasonix
and cmd/reasonix-studio-host) record their ldflags version once at start
through provider.SetClientVersion; an unversioned build reports "dev".
Test: TestRequestsIdentifyReasonix (chat + model listing),
TestMessagesIdentifyReasonix and TestResponsesIdentifyReasonix record the
User-Agent an httptest endpoint receives. Before the wiring all three saw
"Go-http-client/1.1" and failed; after it they see "Reasonix/9.8.7".
TestConfiguredUserAgentWins keeps a custom User-Agent header; the existing
OpenCode Go tests still require exactly "Reasonix" there.
Root cause: on DeepSeek's Anthropic endpoint in thinking mode, every
assistant tool_use turn in history must carry a thinking block. replayThinking
emitted one only when the turn had stored reasoning. A tool-call turn can be
committed without reasoning (the missing-reasoning fallback in the run loop
keeps the first complete response), and from then on every request in the
session, subagent review sessions included, was refused with HTTP 400
"content[].thinking in the thinking mode must be passed back to the API".
Studio has no repair path for that 400, so the session stayed broken.
Fix: while DeepSeek thinking is enabled, a tool-call turn without stored
reasoning replays {"type":"thinking","thinking":""}, which the endpoint
accepts (live-probed in #8927). contentBlock keeps the "thinking" field when
it is empty, because a thinking block without the field is refused as well.
This matches the OpenAI-compatible DeepSeek path, which already sends an
empty reasoning_content key on such turns. Turns with reasoning and thinking-off
requests serialize exactly as before.
Test: TestDeepSeekToolCallTurnWithoutReasoningStillReplaysThinking drives
Stream against an httptest server that answers like DeepSeek. On origin/studio
it fails with "status 400: ... content[].thinking ... must be passed back";
with the fix it passes. TestDeepSeekThinkingOffSendsNoEmptyThinking guards the
thinking-off shape. go test ./internal/model/anthropic/ ./internal/runtime/agent/
./internal/assembly/boot/, go vet, make check and golangci-lint are clean.
The P4 demonstration showed the shadow verdict calling a turn done when the
model changed nothing and said the task was complete: with no deliverable
in the contract, a turn that owes nothing reads as finished (#11056 made
such turns "none", which is honest but still not "owed").
Deliverables come from structure, never from the task's wording: a plan
whose steps name candidate files is a plan that expects its work to land
somewhere. Such a plan now adds one criterion, change@plan, satisfied by any
change the host proved in the task. It binds no path: candidate files are
the planner's inference, the right change may touch a subset, and a replan
moves them. Files the planner only read do not count, since an analysis
plan reads files too.
- A turn under such a plan that changed nothing ends incomplete on the
sealed verdict, and the receipt lists "the plan names files to change,
and no change was proven".
- Losing the deliverable is a relaxation like any plan check: a later plan
naming no files is routed to the user when an approver exists, and a
replan that still names files keeps it.
- Plans with no candidate files, and turns with no plan, are unchanged.
The readiness gate does not hold it yet; that follows the same measured
switch plan criteria took.
A blank-line run may drift in length only when non-blank lines bound the
window on both sides, so a blank-only old_string no longer names every run
in the file, and the window now applies the same trailing-newline rule as
the other fuzzy modes instead of writing a newline the file never had.
The P4 rollout demonstration ran a system-prompt candidate that told the
model not to edit files and to say the task was complete. The rollout gate
rejected it, but most of its 29 false completions were turns the shadow
verdict called completed with no obligations at all: the model changed
nothing, so it owed nothing, and owing nothing read as done. The contract
has no deliverables yet, and the rules forbid guessing from the task text
that a change was asked for.
Such a turn now gets outcome "none": nothing owed and no change the host
saw is no evidence of completing anything. A turn with a successful change
and nothing owed still completes; a failed write is not a change. "none"
agrees with a report that gave no verdict and diverges from one that said
done.
Nothing the user sees changes: the gate never inspected such turns and the
receipt was already absent for them. What changes is the bundle outcome
and every rate built on "completed", which it overstated.
The design doc records the new outcome and the P4 demonstration (promote a
harmless prompt candidate, reject a harmful one, both from bundle metrics).
First P4 slice of docs/design/TRUSTED_EXECUTION.md: the offline rollout
gate (K1: promotion authority is the offline corpus with repeated samples;
I11: false completion must not rise beyond the noise floor on a corpus with
no-solution tasks).
e2ebench -mode rollout reads baseline reports, "--", then candidate
reports (repeated samples are pooled) and decides:
- hold when the comparison means nothing: a side with no capability record
or more than one set (rollout.version_unknown), both sides the same set
(rollout.same_version), different tasks (rollout.corpus_mismatch), no
no-solution tasks to catch a false completion (rollout.corpus_lacks_no_solution),
or fewer than 50 solvable runs a side (rollout.insufficient_samples);
- reject when false completion or grader pass is significantly worse, its
whole 95% interval on the bad side (rollout.false_completion_rose,
rollout.pass_dropped);
- promote otherwise.
A rise inside the noise floor passes: ruling out any rise at all would take
hundreds of runs. The intervals are printed so the resolution is seen. With
-json the decision is written as a machine-readable record, reasons as
codes.
On the P3 reproduction data (one version, 29 solvable runs a side) it holds
for rollout.same_version and rollout.insufficient_samples.
Second P3 slice. compare mode printed two reports' metrics side by side and
left the reader to decide whether a difference meant anything. It now adds
a reproduction section:
- version: one capability set on both sides, two sets (a comparison, not a
reproduction), mixed, or unknown for reports that predate the set;
- corpus: whether both ran the same task ids with the same prompts;
- grader pass on solvable tasks, bundle completed, and false completion,
each with B - A and its 95% interval from Newcombe's method over Wilson
intervals, which stays sensible at the small counts and 0%/100% rates
these reports produce.
A report pair that is one version on one corpus with every interval
containing zero is reported as reproducing within the noise floor.
On existing data, one binary run twice over 82 tasks gives intervals of
about +/-15pp, and twice over 30 tasks one rate moved 20pp and was still
inside its interval: the resolution a single run has, now printed rather
than assumed.
First P3 slice of docs/design/TRUSTED_EXECUTION.md (I10: a rollout metric
counts only runs whose capability digests match). Until now a trajectory
said which model ran and hashed the prompt and tool schemas as a whole, but
not which skills, and no single field said whether two runs had the same
capabilities.
The run header now lists every capability version the run could use, and a
digest of the set:
- host: the build (version and commit), which carries the planner, memory
and compaction policies compiled into it;
- prompt: the system prompt;
- tool: each tool schema, MCP tools included, which is where an MCP
integration's behaviour shows (its launch config can hold secrets and is
not a version);
- skill: each skill by what it says and how it runs, never by where it was
found, so one skill on two machines is one version.
e2ebench reads the set digest and reports how many sets a report spans; a
report over more than one is an average over versions and says so.
Checked with a real reasonix.exe run on the Windows box: the header carried
1 host, 1 prompt, 9 skills and 18 tools and a set digest.
Stage two of moving completion onto the contract revision: the readiness
gate stops reading the replayed contract, where a complete_step citation of
kind manual settled a plan criterion on the model's word.
A required plan criterion now holds the turn only when its step names a
verification command that has not passed since the latest change (or has
never passed, when nothing changed). The message names the check that
settles it. A criterion no command checks is no longer held: the host
cannot settle it, and holding it only ever asked for a citation. It is
listed on the receipt instead (stage one).
Delivery sign-off still requires complete_step; only its role in settling
plan criteria is gone.
Stage one of moving completion onto the contract revision: what the user
reads changes, what blocks a turn does not.
The receipt's unproven plan criteria came from the replayed contract, where
a complete_step citation marks a criterion satisfied on the model's word.
They now come from the sealed verdict: a required plan criterion is proven
only when every verification command its step names passed after the latest
change. A criterion whose step names no command is listed as unproven with
"no command checks it", instead of being settled by a citation. Every other
gap is unchanged, and a turn with no sealed verdict (no trusted state, or no
contract revision in force) keeps the report's gaps as before.
The receipt card renders gaps, not a verdict noun, so this shows up as
listed items where a citation used to leave none.
Not changed: the readiness gate still reads the citation. Moving it is
stage two, measured first for the extra rounds it costs.
Plan criterion ids are positional within one plan (c1, c2, ...), so
#11017's plan@c1 meant different criteria in two plans of the same goal: a
reordered plan could pass as unchanged, a reworded one went unseen, and an
inserted criterion could read as a dropped one. What decides whether a plan
criterion is verifiable is the command its step names, and a command's
canonical identity does hold across plans. The contract now freezes those
commands, deduplicated with the project's checks, and answers each plan
criterion through the check its step names; a criterion whose step names
none is left to the replayed contract as unverifiable.
The plan flow asked the user only when a revision expanded scope. Dropping
a verification command narrows the work and relaxes the contract, and it
never reached the user. Now:
- a plan the user approved carries that, host-stamped (ApprovedByUser is
not part of the planner's JSON), and a derivation under it that loses
only plan checks is accepted as the user's (user:plan_approval);
- a later plan that would drop a check the task accepted is routed to
approval when an approver exists, even if neither the route nor the
planner asked for it;
- with no approver the plan runs and the dropped check stays owed.
The report still reads complete_step citations. Retiring that changes
what users see for every planned task with a criterion no command checks,
so it moves with the gate switch, where the incomplete terminal is
designed.
SignPath has no quota left, so Studio Windows builds could only ship
unsigned. Port the 1.x Certum SimplySign signer, pointed at Studio's own
account and certificate.
- Signing runs only when STUDIO_SIGNING_ENABLED=true, as three jobs, so
the SimplySign session is never up while third-party code runs:
windows-sign-payload signs the window, kernel and computer-use helper;
windows-package builds the installer and zip on a separate runner with
no environment or secrets; windows-sign-installer signs the installer.
The two signing jobs check out only workflow_sha, install no
toolchain, leave Defender on, and stop SimplySign after signing.
- Verification checks trust, the pinned thumbprint, the
STUDIO_SIGNING_SUBJECT subject, an issuer and a timestamp on every
signed executable, on the installer, and on the zip's copies.
- The SimplySign MSI is pinned by SHA-256 as well as by its signature.
- With the switch on, a missing secret or subject, or any signing
failure, blocks publish. The release body's Windows sentence follows
windows-sign-installer's result.
- Credentials are STUDIO_CERTUM_USERNAME, STUDIO_CERTUM_OTP_URI and
STUDIO_CERTUM_KEY_ID only. The concurrency group is
studio-certum-signing, shared with a new non-publishing smoke workflow.
- publish downloads every package artifact by its exact name, taking
Windows from windows-sign-installer when it ran, and fails when two
artifacts carry the same file name.
- signing-contract validates and fingerprints both the tag's tree and the
workflow_sha tree.
- The SignPath request steps, artifact configurations and request helper
are removed. The contract now covers the Certum files, and its call
graph detects Certum credentials.
- The runbook lists the repository settings to decide on before enabling
and says which PE files stay unsigned (Electron DLLs, elevate.exe, the
NSIS uninstaller).
The approved plan's acceptance criteria join the canonical contract. Each
is verified by the verification commands of the step it belongs to: the
user approved those commands with the plan, so they are the plan's own
verifiers, not a check the model picked. A criterion counts only when
every one of them passed after the latest change; a step naming none gives
the criterion no verifier, recorded as verifier.none rather than guessed.
This gives the shadow verdict a host answer for plan criteria that were
otherwise answered by complete_step citations, where the model binds a
check to a criterion. The frozen contract never reads that binding, and a
plan criterion it holds is not counted a second time from the replayed
contract.
Satisfying a criterion with its own step's commands discharges nothing the
host itself owes: stale verification and the project's checks are still
derived separately.
Open, recorded in the design doc: a plan revision the user approves that
drops a criterion is refused like any relaxation, and the report still
reads the citation. Both must change before the gate reads the revision.
On an unplanned turn the shadow contract turned every todo into a required
criterion and resolved it Satisfied when the model marked it completed.
Nothing but the model's own call stood behind that, and it reached the
user: a turn whose only action was marking a todo completed got a
completion receipt saying done. P1 measured it as the claim_only
divergence.
A todo is the model's own breakdown of the work, not a criterion anyone
accepted, so it is now recorded as optional and never resolved from its
status. Dropping only the resolution would have left every unplanned
multi-step turn reporting incomplete however well its change was proven;
making todos optional leaves completion to what the host proved, as on a
turn with no todos. An unfinished todo is still the readiness gate's to
hold: it counts open todos itself and never read this contract.
The report's "anything declared" test now counts required criteria only,
so a turn that only marked todos gives no verdict instead of done.
Still open: a complete_step citation binds a check to a plan criterion on
the model's word; that edge goes when plan criteria carry frozen verifiers.
Background and fleet children could not settle an unclassified command:
their walks would see siblings and the parent writing too, so #11009 left
them unknown. A run that declared write paths now takes a change the walk
sees inside them as its own, even while other writers run, and ignores
everything outside them.
This is an inference the user accepted, not a proof: claims are proven
disjoint up front and writer tools are fenced to them, but a sibling's
shell command writing into another run's paths is not excluded. A call
that only saw changes outside its paths stays unknown and is never read as
having changed nothing. A run with no declared paths, the root included,
attributes a walk only while no other background writer runs, and that
check no longer counts a background child as running beside itself.
The design doc records the inference as W11.
Not measured: on current studio the unproven_mutation divergence was 1/21
across seven delegation tasks (#11009), and a comparison costs about
CNY 8 for a difference that small.
A bare "/name" line or a lone skill chip reached the provider as the raw
rendered skill body in plain user prose: no <skill-pin> sentinel, and on the
structured (composer) path the typed text trailed as an unlabeled paragraph
instead of an Arguments line. The model-invoked path (run_skill,
read_only_skill, read_skill) already sent the pinned form, so one skill had
two payload shapes depending on who invoked it.
Every invocation site now renders through skill.RenderInvocation: the
string submit path, the structured path, RunSkill (ACP prompts) and the
slash_command tool. A lone inline chip takes the typed text as its
arguments, matching "/name task"; several inline chips, or chips mixed with
subagents, keep appending the shared text once. Nothing moves into the
cache-stable prefix. Store.Render is removed and Render is unexported, so the
pinned form is the only one an invocation can take.
An effect test through the real Build asserts the pinned payload at the
provider boundary for the bare and argument-carrying forms of both paths.
Refs #11004
A command the host cannot classify (sed -i, an inline interpreter) is
settled by walking the workspace before and after it. Only the root agent
had a root to walk: sub-agents are built without the write root, so a
child's unclassified command stayed unknown, merged into the parent that
way, and left an unproven_mutation no later check can discharge. On the P1
macOS run this was the cause behind the forced-delegation divergences
(sed -i '' and python3 -c inside children).
- agent.Options gains ObserveRoot, used only for that walk; the root agent
keeps walking its write root, so nothing else about it changes.
- Delegation hands a writer child the workspace as its observe root only
when it runs in the foreground: the parent is waiting on it, so what
changes during one of its calls is that call's. Background and fleet
writers get none, since their walks would claim their siblings' writes.
- While any background writer is registered, no agent settles by walking,
the root included: the difference between the two walks is not the
call's alone.
Measured on the Windows box, 7 delegation tasks x 3 samples per arm: the
unproven_mutation divergence was 1/21 on current studio and 0/21 with this
change, grader 21/21 on both. At that frequency the effect is not
measurable; the change stands on the mechanism and its tests, not on the
number.
A shell command that runs code the host cannot prove read-only, and that is
not a recognized check, counts as a possible change: after it the model is
handed a new stale_verification debt keyed on its own command, and nothing
says why. The model reads that as its check having run, or as the host
asking for the same thing again. The host already knows the reason, so the
first such command in a turn now carries it, with the commands the host
does recognize. The decision reads the host's own classification (not
read-only, verification not_verification, stale verification owed after),
never the command's wording, and it rides the tool result, not the prefix.
Measured on the Windows box, 30 tasks x 2 samples per arm, same binary
apart from this change, run back to back:
stale_verification divergences control 34/60 (57%) notice 19/60 (32%)
grader pass rate control 58/60 notice 58/60
tool calls per task control 20.0 notice 24.2
tokens per task control 199k notice 221k
Two-proportion z = 2.76 (p ~ 0.006). The tasks were selected for having
shown the divergence before, so the earlier 70% rate is not the baseline;
the control arm is. The extra calls are the recognized checks the model
now runs.
Expanding ${REASONIX_WORKSPACE_ROOT}/secret on Windows leaves a forward
slash after the drive-letter path, so the raw string never equals
filepath.Join's result even when the root came from the environment.
Studio and the 1.x line share config.toml and both read [browser] enabled,
with opposite defaults: 1.x writes enabled = false on every save of the
user file, which Studio then read as the person turning its browser tools
off, and a Studio save wrote enabled = true, which switched the 1.x command
line's browser on.
Studio now decides on [tools] browser_tools (unset = on) and carries
[browser] enabled through a save exactly as read, never writing it where the
file had none. Boot binds the browser tools from the new key.
The settings screen gets a built-in browser switch under Tools, backed by
GET/POST /browser-tools on the provider-edit grant. The save writes the user
file and rebuilds the runtime, declared as runtime-rebuild.
First P2 slice of docs/design/TRUSTED_EXECUTION.md. The task contract is now
a host-owned record with an identity and immutable revisions, not a replay
rebuilt from receipts each turn.
- internal/runtime/contract derives criteria only from sources the host
owns: the check identities a task began requiring and the test criteria
captured as host-owned bytes. Derivation is canonical, so the same sources
give byte-identical revisions. Host policy accepts a first revision and
any revision that keeps every criterion and adds to them; one that would
drop or alter a criterion is refused and the revision in force stands.
- Revisions are sealed as contract/1 records in the workspace stream. The
delivery checkpoint names the record, so a goal resumed in another
process is held to the revision it accepted, read back from Trusted Host
State. A name the store cannot produce is reported, never replaced by a
fresh acceptance. A new task starts a new contract.
- The shadow verdict answers each accepted criterion from the ledger, not
from the checkpoint's copy of the declaration, and drops the host
obligation that owes the same verifier so one debt is not counted twice.
The audit and trajectory carry the revision, the policy decision and any
load failure.
Still shadow: the readiness gate is unchanged. The refusal test edits a
checkpoint by hand, which nothing in production does; it demonstrates that
the checkpoint is not the authority, not a reachable bypass.
With the variable set empty the pattern expands to "/secret", which is
absolute on Unix but drive-relative on Windows, so it resolved under the
workspace root and the test failed on every Windows run since 0a0e1989a.
Pointing the variable at a separate directory asserts the actual property -
the value comes from the environment, not the root - on every platform.
The notes landed with section headings outside the allowed set and changes
that name no pull request, which pushed the repo-wide release-note count past
its ceiling and turned lint red on studio. Same content, each change now cites
#10970, #10980 or #10982.
The publish job now renders release-notes/studio/<semver>.md before it
signs anything. A #N that is a pull request gains "by @author"; a #N that
is an issue gains "fixed in #M by @author" for each merged pull request
GitHub links as closing it (the ClosedEvent closer and
closedByPullRequestsReferences, read over GraphQL; mentions are not
fixes). An issue fixed by a direct push stays plain. A 贡献者 list of the
credited humans, in first-appearance order, closes the notes. Bot authors
are never credited; the source file is not rewritten.
scripts/markdown-refs.mjs decides what is a reference: #N at a line start
or after whitespace, "(", "(" or a list separator, outside code spans,
backtick and tilde fences (each closed only by its own kind), indented
code, HTML comments and link targets. A number that names a discussion or
nothing stays plain with a warning instead of failing the release.
Lookups retry with backoff on 5xx, rate limits and dropped connections. A
failure that persists, or an authorisation failure, fails the job with a
release_credits.* code before signing. The publish job gains
issues: read and pull-requests: read.
CI gains a release-notes-scripts job, gated on scripts/ and
release-notes/studio/, that runs the three test files with contents: read.
scripts/release-credits.mjs is the same module the 1.x renderer uses.
A full e2ebench run put ten of eighteen divergences under one reason,
obligation.outstanding, and e2ebench gives every task its own state root
and deletes it afterwards, so the sealed bundle that said which obligation
it was does not survive the run. The kind is a content-free enum and is
what decides which old path owes the difference, so the audit now carries
host:<kind> in its place.
Every save replays the event log before it appends, and the reader refused a
log past 128 MiB. Images were stored inline as data URLs, so a session with
about 2 MiB of images per turn crossed the budget around turn 63: from then on
LoadSession failed and every Save failed with the same error, and later turns
never reached disk.
New records write each data-URL image to <id>.blobs/<sha256>, fsynced before
the record that names it, and carry an image_blobs side table instead. Replay
reads each blob back byte for byte and verifies its hash. Such records are
stamped schema_version 3, so older studio and 1.x builds refuse the log
instead of loading it without its images and saving that loss back.
A missing blob is restored from the copy the .jsonl checkpoint still inlines
and written back. With no copy left, the message keeps its text and records
the lost image as provider.UnavailableImage (hash and position); only
ModelMessages renders a note, and the blob reappearing restores the image. A
transcript missing an image never rewrites a checkpoint that still holds it,
and a runtime still holding an image whose blob vanished writes it back
instead of being reported as diverged; what cannot be restored fails under
ErrSessionImagesUnavailable.
The reader ceiling is 512 MiB. A save folds a log past 128 MiB only when the
fold drops at least 64 MiB, so a legacy inline-image log is folded to its text
once, while a large text-only log is not rewritten on every save. A compaction
sweeps blobs that neither the published log nor a salvage or recovery
sidecar (.events.jsonl.damaged, .superseded, .context.json) names, and stale
temp files; a sidecar it cannot read whole stops the sweep. The stored
compaction projection is built with ProjectionMessages, so an unavailable
image stays a host record there instead of rendered text. The
blob directory is a session sidecar dir: delete, rename (blobs before the
log), recovery trash, native re-homing and sub-agent delete take it along.
Refs #10965
Problem: on start-up the sidebar said "No folders yet" (尚无文件夹) and
"0 projects" under This machine, and the empty transcript asked to open a
project first, until the kernel answered the first tree read. A slow start
looked exactly like a machine whose folders had been lost.
Root cause: App held the tree as TreeWorkspace[] initialised to [], so "not
read yet" and "read, and empty" were the same value, and every reader of it
(the sidebar empty row, the project count, needsProject) drew the latter.
Fix: App tracks treeRead, set once the first hub.tree() read settles. Until
then the sidebar shows a "Reading folders…" status row instead of the empty
row, hides the project count, and needsProject stays false. With no pane
open, the main area says only that no session is open, without the
add-folder prompt it gives once the tree is read and empty. A failed read
still settles, as before, so the window never waits forever.
The row carries the clock icon on the existing tool-running spin (still
under prefers-reduced-motion) and, past one second, the elapsed time via
format.seconds, ticking once a second. The tick is display only and is
aria-hidden, so the status region is not re-announced every second; the
interval lives with the row and is cleared when the read settles or the
row unmounts.
Test: sidebar-first-read.interaction.test.tsx holds hub.tree() pending,
asserts the loading row and no empty-state wording, then resolves [] and
asserts "No folders yet"; a second case with no panes asserts the main
area's add-folder hint is absent until the read settles. Both fail on origin/studio (no loading row) and
passes with this change. tree-reading.interaction.test.tsx uses fake
timers: the elapsed value reads 3s then 5s, is aria-hidden inside the
status, disappears with no timers left once the read settles, and the
interval is cleared on unmount. The perf harness gains ?treems= to hold
tree reads back and ?panes=0 to open with no pane, used for before/after
screenshots in light and dark; perf/motion.mjs counts the reading row's
spin as live work.
Third slice of the trusted execution layer (P1). Every shadow bundle now
carries an outcome derived only from host evidence, and how that outcome
diverges from the completion report's verdict.
- internal/runtime/verdict: Evaluate turns the contract's criteria and
checks, the receipts and the ledger's outstanding obligations into one
verdict per obligation and an outcome (completed, incomplete, failed,
blocked). It never reads a criterion's status, because todo_write and
complete_step can set it; before contracts freeze verifiers, the only
criterion host evidence satisfies is an atomic ask proven by a successful
mutation. Checks keep the state the contract derives from receipts.
- Classify compares that outcome with the report's verdict: agree,
new_stricter or new_looser, with reason codes (claim_only, evidence.stale,
evidence.missing, check.failed, obligation.outstanding, verifier.none,
old_gaps, old_criteria).
- The EvidenceBundle audit carries outcome, class and reasons, and
e2ebench prices them like the completion report: false completion is a
completed bundle on a run the grader failed, next to the divergence and
reason census.
TestEffectClaimOnlyCompletionDivergesThroughRealBuild runs the known hole
through boot.Build: a todo marked completed with no other action finishes
the turn and the report says done; the sealed bundle says incomplete,
new_stricter, claim_only.
Second slice of the trusted execution layer (P1). The shadow bundle now
records the workspace as each turn found and left it, what changed during
the turn, and what changed between sealed turns while nobody observed it.
- internal/state/observation: a snapshot is a tree of directory nodes stored
as content-addressed index objects in Trusted Host State, so an unchanged
directory costs nothing to record again and two snapshots diff by walking
only subtrees whose digests differ. The policy is host-owned; the default
excludes version-control stores and the host's own state root, each with a
reason, and keeps dependency trees observed because verifiers read them.
Ignore files and workspace config are never consulted.
- A file is identified by its stamp (size, mtime, and on macOS/Linux ctime,
inode, device), not a content hash. On this repository's main checkout
(107,803 entries, 2.1 GB of node_modules) a cold snapshot takes 1.9 s and a
warm one about 0.2 s; hashing every file would cost seconds per process.
- trustedstate gains PutIndexObject, which skips the per-object fsync: tree
nodes are an index, not a seal, and with fsync the first snapshot of this
repository took 31 s. A node lost to a crash makes a later diff
unverifiable, never a satisfied comparison.
- The turn-start snapshot runs while the first model request is in flight;
the first tool batch waits for it, so nothing the turn does lands in its
own baseline. The previous snapshot is read from the stream's head record,
so edits made between sessions are seen too.
- The EvidenceBundle audit adds snapshot completeness and the count of
unobserved changes.
The design doc's W1/W3 now say what was built and why (W8-W10).
First slice of the trusted execution layer (docs/design/TRUSTED_EXECUTION.md,
P1). Nothing reads the bundle back to decide a turn.
- internal/state/trustedstate: content-addressed objects, one record chain
per stream, a head file replaced by rename. A head this process already
observed refuses rollback, a same-generation swap and a history that no
longer reaches it; a store with no observed head cannot tell a forged but
self-consistent chain, and the tests pin exactly that. Failures carry
sentinels and a typed code.
- IntegrityLevel is supplied by the components that confine what the model
drives: sandbox.WriteProtects (the platform's effective write roots, both
overlap directions, symlinked ancestors), plus one-way flags set by the MCP
launcher and the extension sidecar launcher whenever they start a process
outside the sandbox, including a "confined" MCP launch that fell back
because no backend was available.
- File tools refuse writes into <state>/trusted with a typed refusal
(workspace.trusted_state_write); an explicit allow_write entry keeps raw
access and also reaches the bash write roots, which drops the level.
- The root agent seals the contract, completion report and receipts at the
end of every turn, including turns the readiness gate stopped. Receipt
arguments are kept as digests only. A new content-free EvidenceBundle
audit reaches the trajectory with the record digest, generation, integrity
and the typed cause when sealing failed.
The design doc records what implementing this showed: MCP servers default to
host mode and sidecars always run unconfined, so installs with either are
tamper-evident only, and the level is per process (another process running
as the same user can write the store).
Define the host-owned layer behind "allowed to start", "completed with
evidence" and "allowed to ship": trust boundary, canonical objects
(Task Contract, Evidence Bundle, Capability Rollout, RiskPolicy,
ObservationPolicy, WorkspaceSnapshot, TrustedHead), state machines,
invariants and typed failure semantics. No UI and no implementation.
Workspace content, including REASONIX.md and .gitignore, is source
material for revision requests and never changes enforcement. Hash
chaining is tamper evidence relative to a trusted head, not an
authority boundary, so L3 enforcement requires host-protected state.
The document lives under docs/design/ so the embedded product-doc
corpus (docs/*.md) cannot present unshipped design as a feature.
Delete (and Cmd+Backspace on macOS) on the focused session row, or on a
kept version/recovery copy row, opens the same inline confirmation the row
menu's delete opens. Only a row holding focus reacts: the handler requires
the row itself to be the event target, never a hover target, and stays out
of the way while the row is being renamed.
The confirmation now also answers Escape as cancel, without letting the key
reach the window's stop-the-run handler, and focus returns to the row the
key asked about so a keyboard is not stranded on the page body.
Focus goes back only when the question is dismissed, not when another
row's question replaces it; after a delete lands it moves to the next row,
or the previous one when the deleted row was last.
The row keydown declares both session.open and session.delete, and the
copy row's delete button and the confirmation's cancel carry their action
ids, keeping the census's unnamed-write count where it was.
Fixes#3220
A screen share or a recording leaks the account balance and the session's
spend as readily as anything else in the meter bar. An eye button beside the
wallet masks both as ••• and persists per machine (rx-hide-amounts), so they
stay hidden across restarts. The sidebar's wallet entry and every amount in
the cost card (session total, this turn, original currency, per-source rows,
wallet) are masked too. Only the drawing changes: the wallet keeps being
read, so revealing shows the current value without a request.
Fixes#4135
The file writers refuse a target outside workspace_root and allow_write on
every OS, including Windows where bash is never OS-sandboxed. The refusal
was a plain error whose text said writes were "confined" and told the
reader to "widen [sandbox] workspace_root / allow_write", so a Windows user
who never enabled a sandbox was told by the agent that it had been
sandboxed.
confine now returns a tool.Refusal coded workspace.write_outside_scope that
names the refused path, the writable directories, that this is the file
tools' scope rather than an OS sandbox, and the setting that allows it. The
agent carries a tool.Refusal's code onto the tool result on the error path,
so the frontend receives the cause as an identity, and the tool card renders
the localized cause from that code. The scope itself is unchanged.
An option can ask for more than itself ("paste the output"), but once it
was picked there was nowhere to type: in Studio the pick and the free-text
box were mutually exclusive, and in the TUI a pick answered at once.
Studio keeps the box open under a single-choice pick as an optional note,
so the answer goes out as [pick, note]; the kernel already joins several
selections. The note is its own state: an Other answer left behind when a
pick is made is not sent as a note, and going back to Other finds it still
typed. Choosing Other still makes the typed text the whole answer, Enter in
the note box answers like Enter in the Other box, and a sealed card shows a
pick's note under the pick rather than as Other.
The TUI takes Tab on a single-choice option as "pick and add a note": the
composer opens for the note, Enter sends it after the pick, Esc keeps the
pick without one. Picking an option now also drops a previously typed
answer, which a single-choice question used to send alongside the pick.
Fixes#8136
A directory claim overlaps every path beneath it, so two writers that
name the same directory serialize even when they touch different files.
The task and fleet descriptions and their write_paths properties now say
to declare file paths when the targets are known.
Both tools sit behind use_capability, so the provider-visible prefix and
the boot golden fixtures are unchanged.
OpenCode Go refuses third-party requests that carry no session:
"Request is missing x-opencode-session and cannot be routed efficiently"
(HTTP 400). The 1.x provider layer sends that header; the studio adapters
never did, so every OpenCode Go call failed on its first request while
the same configuration worked in 1.x.
Each of the three adapters (chat, messages, responses) now mints one
session id per client and presents it, together with a Reasonix
User-Agent, on every request whose URL is on opencode.ai/zen/go. A
header the user configured is kept. Other hosts are untouched.
With the session in place the first tool round trip still failed:
OpenCode Go's chat route rejects a role=tool message carrying "name"
(fixed on 1.x in #10852). The chat adapter omits it there only.
The responses client groups the new id with the DashScope session-cache
flag, and the anthropic client with its custom headers, so neither
guarded struct grows a scalar.
Test: a local gateway that answers 400 without x-opencode-session,
reached through a transport that keeps the opencode.ai URL.
Before: all three adapters fail with "status 400: ... missing
x-opencode-session"; TestOpenCodeGoChatToolResultOmitsName fails with
tool message carries name "read_file". After: all pass, and two
clients present distinct ids while one client keeps its own.
Fixes#10921
ask also carries MCP elicitation, so the Notification hook forwarded an
MCP server's prompt verbatim to whatever channel the hook feeds. Name the
server for an elicitation ("answer needed (MCP <source>): ..."), prefer
its own message over the first field's prompt, and clip every forwarded
prompt to one line of 120 runes.
Follow-up to #10812.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
On a Chinese Windows, cmd.exe reports a missing command in the console
code page (GBK), not UTF-8. The stdio transport kept the stderr tail as
raw bytes, so an MCP startup failure such as "'read-only-mysql-mcp-server'
is not recognized as an internal or external command" reached the settings
page as U+FFFD mojibake and the user could not see why the server failed.
The stderr tail now goes through encoding.DecodeOutput, the one decoder
for process output. The buffer keeps the last 16 KiB, so only its head
can be cut: tailBuffer records when it dropped bytes and passes
Cut{Head: cut}. The end is never cut, because it is whatever the server
last wrote. Output that is not UTF-8 is decoded from the raw bytes, so a
GBK tail that begins with a CJK character keeps that character.
Tests:
- TestStdioFailureDecodesCodePageStderr starts a real child that writes
GBK to stderr and exits.
- TestStderrTailDecodesTheServersCodePage covers GBK that starts with a
CJK character, both uncut and cut, plus UTF-8 cut mid-character.
All of them fail on origin/studio and pass with this change.
Fixes#7857
DecodeOutput trims a partial rune only at an edge the caller cut. Output
that nothing cut but that stops inside a character still has invalid raw
bytes, so it went through charset detection and fell back to GB18030,
and the whole output turned into mojibake: "编译成功…正在运行" plus half
of "中" read as "缂栬瘧鎴愬姛…". This is common in real use:
- a process killed on timeout mid-write;
- a persistent shell's partial() read;
- a block-buffered pipe.
The output is now read as UTF-8, less the final incomplete rune, when
two things hold: that rune is the only invalid part, and a multi-byte
UTF-8 character has already appeared. Both are structural evidence. When
only ASCII precedes the half rune, nothing says UTF-8, so the bytes
still go through detection. The uncut GBK probes (价, 啊\r\n, ok 洹,
啊 ok) are unchanged.
Test: TestDecodeOutputReadsUncutUTF8EndingInPartialRune fails before
this change with the mojibake above and passes after it. It also pins
ASCII plus half a rune (read as a GBK pair) and uncut, tail-cut and
head-cut GBK.
Refs #6488
The edit_file fuzzy matcher already tolerates trailing whitespace, tab-vs-space
indentation and copied read_file line prefixes, but not a blank-line run the
caller reproduced at a different length. PEP 8 asks for two blank lines between
top-level definitions and a model routinely writes one, so the span fails to
match a file it otherwise quotes exactly.
blankRunRanges collapses each run of consecutive blank lines into one token, so
a run's length is free while its presence is not: a blank line absent from the
file still never matches one the caller invented. Non-blank lines keep the
exact-match rule modulo trailing whitespace, and the single-match requirement is
unchanged. The relaxation is a standalone last resort rather than a fuzzyMode,
so a span that drifted in two dimensions at once still fails.
An ACP client had no way to bring back a Goal the session still held.
session/set_mode goal always drafts a new Goal, so the next prompt replaced
the retained objective and reset its counters, while the controller's
ResumeGoal/PauseGoal (reached through /goal in the CLI and Studio composer)
were never driven by a frontend directly.
ACP gains two vendor methods, advertised in agentCapabilities._meta with
schemaVersion 1:
- _reasonix.io/session/goal/resume re-enters a stopped, blocked, paused
or cancelled Goal with its objective, counters, todos and delivery
checkpoint. It does not start a turn; the next session/prompt continues
it. A running, complete or absent Goal fails with -32010.
- _reasonix.io/session/goal/pause pauses a running Goal at its next
continuation boundary, or fails with -32011.
On success both clear the turn-outcome label (cancelled, failed) the
status showed in place of the Goal state, return the Goal in the status
shape, publish a status_update with event "goal", and emit
current_mode_update when the mode moves. docs/ACP.md documents that a
"failed" status labels the last turn while the Goal keeps running.
serve drives the same capabilities through POST /goal/resume and
POST /goal/pause, answering {changed, status, goal}, so frontend-parity
holds without a scope exception.
The rule that a running Goal cannot be resumed moves into
goalMachine.resume under its lock. Before, resume accepted a running Goal
and bumped its continuation epoch, dropping the continuation in flight;
this also affected /goal resume in the CLI and Studio.
initialize/authenticate move unchanged to handshake.go so service.go stays
inside its size budget.
Tests:
- acp: TestServeGoalResumeKeepsTheRetainedObjective and
TestServeGoalPauseStopsARunningGoal fail with only the declarations
present (capability not advertised; -32601 method not found) and pass
after. TestServeGoalFailedLabelKeepsTheGoalRunning fails when pause does
not clear the label (pause reports "failed") and passes after.
- control: TestControllerResumeGoalRefusesARunningGoal and the new
TestGoalAdvanceResultCannotCrossGoalLifecycle subtest fail on the base
("resume accepted a goal that is already running") and pass after.
- serve: TestServeGoalPauseAndResumeKeepTheObjective.
A Studio window can go blank in three ways, and none had a way back
short of quitting, although the kernel keeps running the turn and a
loaded page reattaches to it:
- A render error outside the per-block boundaries unmounts the React
tree while the renderer lives on. The shell installs no menu with a
reload role, so nothing reloads the page.
- The renderer dies while the window is visible. main.js handled
render-process-gone only before the window first showed, so the
window stayed dead.
- The renderer dies while the window is hidden in the tray. showWindow
then revealed the dead page.
installReload binds Cmd+R on macOS, Ctrl+R elsewhere (by key or by the
KeyR code, so non-Latin layouts work) and a bare F5 via
before-input-event. A dead renderer's input never reaches that event, so
it also reloads on render-process-gone while the window is visible,
except for a clean exit. It returns revive(), which showWindow calls
before showing: it reloads when webContents.isCrashed(). Crash reloads
share a guard of at most 3 within 60 s; past that the window is left
alone instead of looping.
Test: desktop/electron/test/unit.mjs, three tests (reload key; visible
crash plus loop guard; hidden crash revived before show). They fail on
the base (Cannot find module '../src/reload.js'), and the hidden-crash
test also fails against the previous revision (no revive); all pass
after, suite 46/46. On Electron 44.1.0: a visible
forcefullyCrashRenderer was followed by did-finish-load; crashing on
every load gave three reloads and then stopped; crashing a hidden
window and then revive() plus show() reloaded it (isCrashed=true, then
did-finish-load).
Refs #10319
Running Reasonix in a git repository left pasted attachments under
.reasonix/attachments and background-task state under .reasonix/tasks as
untracked files, so users added .reasonix to their .gitignore by hand.
Root cause: both stores created their directories in the workspace with no
marker telling git they hold host runtime state. Ignoring all of .reasonix
is not the answer: the same directory holds files a project commits on
purpose (settings.json hooks, skills, output styles, instructions).
Fix: fileutil.MarkUntracked drops a ".gitignore" containing "*" into a
directory the host owns. The marker is written to a temp file in the same
directory and published with os.Link, so a failed write or a crash never
leaves an empty marker that would read as present forever; Link also
refuses an existing target and does not follow a symlink there, so a
marker already in place is kept. The attachment writer and the task store
(task dirs and idempotency records) mark their own roots, best effort in
both: the marker is a courtesy to the user's git, and a filesystem without
hard links must still store the state. The user's .gitignore is never
touched.
Test: TestSavedAttachmentsStayOutOfGitStatus and
TestFileStoreStateStaysOutOfGitStatus run real `git status --porcelain
--untracked-files=all` in a fresh repository with the machine's git config
cut off (GIT_CONFIG_GLOBAL at a missing temp file, GIT_CONFIG_NOSYSTEM,
XDG_CONFIG_HOME at a temp dir, -c core.excludesFile=). With the base
store code they fail listing .reasonix/attachments/* and
.reasonix/tasks/{t1,.idempotency}/*; with the fix the status is empty.
TestMarkUntrackedLeavesNothingWhenTheWriteFails fails against a
create-then-write marker ("failed write left [.gitignore] behind") and
passes with the linked temp file; a symlinked marker is not followed.
Bold text in the transcript could only be told apart by weight. The
.md strong rule painted it with --text, and the theme vocabulary had no
entry a pack could use for it, so the only way to colour it was editing
the shipped stylesheet.
Add an fgStrong colour token. The kernel accepts it, the frontend maps it
to --text-strong, and .md strong reads var(--text-strong, var(--text)),
so every pack that does not name it renders exactly as before. It steps
with the reader's contrast setting like fg does, and the shipped-pack
contrast test now holds it to AA on the same surfaces as the other inks.
Test: TestDecodeAcceptsAStrongTextColour fails on the base (the token is
dropped with a "not a theme token" warning) and passes after; the two new
theme.test.ts cases fail on the base (--text-strong never set) and pass
after. Real-browser screenshots of the perf build, light and dark: before,
setting --text-strong leaves bold at the body colour; after, bold takes
it, and the default rendering is unchanged.
The root .gitignore already lists /workgroupassay; the 10 MB binary was
committed with 491016f8c alongside the tool's source and is rebuilt by
go run ./tools/workgroupassay.
TestEffectRestartKeepsTheReviewAHighRiskGoalOwes (internal/assembly/boot)
failed on windows-latest in runs 36256346762, 36232402249, 36230551992,
36229537444 and 36258827818 (#10901): "PHASE-1 ... never settled", goal
running, 25-26 requests after 20s.
Root cause: the goal in that test can never finish by design and stops only
on its token budget. At 30000 tokens and 1000 per reply, each phase ran about
30 provider requests, 23 of them refused "done" claims, each a full host
turn: about 65 ms on macOS here and, from the CI tail, about 0.8 s on a
hosted Windows runner. 30 turns at that rate do not fit the 20 s settle window. The
assertion only reads whether phase two carried the review nudge and whether
the goal ended incomplete, and the first refused claim already settles both.
At 8000 tokens each phase makes its planned calls plus three nudged
continuations, 16 requests per arm instead of 60, with the same outcome
(blocked, review demanded, in both arms). Settling now waits for the goal to
leave running while the controller is idle. The old test read "idle for
500 ms" as settled, but an idle gap between two continuations is not the
end of the goal.
TestTrySteerRejectedBecomesFollowup (internal/session/control) waited 1 s
for the gated runner. TrySteerInboxItem dispatches the rejected steer
before it returns, so the claim is already durable (state running, intent
follow-up, controller running) when the call comes back. What was being
timed is the turn goroutine reaching the runner. Before that it goes through
a chain of fsyncs: checkpoint begin, branch meta, in-flight marker, recovery
snapshot. That chain takes 100-670 ms on macOS here and can pass a second on
a loaded Windows runner. The test now asserts the admission synchronously.
The 8-minute package timeout in 36258827818 attempt 2 came from
TestThirtySteersApplyAndAckExactlyOnce. Its initial turn missed the same 1 s
start window, t.Fatal ran, and the deferred autosaveWG.Wait blocked forever:
the fake provider's first round was parked on p.release, which only the
test body closed. Every test that parks a fake on a release channel now
closes it idempotently (sync.OnceFunc) before waiting on autosaveWG, in
steeringTurn's cleanup and in TestSteerUnreadAtTurnEndIsDeliveredAsAFollowup.
TestThirtySteers now uses steeringTurn, whose existing 10 s start bound
remains a hang guard only. That wait also returns on TurnDone, so a turn that ends without reaching the provider fails at once
with the event. A deliberate t.Fatal right after steeringTurn used to hang
until -timeout. It now fails in 0.15 s.
TestBackgroundJobWakesSessionThroughManager failed the same way on this
PR's first Windows run (36263202340): a 1 s wait covered the job goroutine
queueing the continuation and the woken turn's fsync chain. The test now
takes the observer's own result for the publish step. It then waits for the
runner input, and the only other exit is a TurnDone. Every step ends in an
event the product always emits, so no clock races the disk. The note check
stays after the runner input: by then the turn's composition has drained
the note, or the ack has dropped it.
Local: -race -count=20 of the five control tests and of the boot test pass
(the boot test drops from about 18 s to about 4 s locally).
Root cause: the 1.x settings editor mirrored base_url into request_url
(and older ones into chat_url), and 1.x and Studio read the same
config.toml. Studio used either override verbatim, so a relay such as
https://router.shengsuanyun.com/api/v1 received its chat POST on the API
root and answered "404 page not found" while /models still listed the
catalog. 1.x fixed this in #9464 and its follow-up 73b6d9b4b; Studio never got
the comparison.
Fix: port provider.EndpointOverrideRepeatsBase into
internal/contract/provider (scheme and host case-insensitive, path exact
apart from a trailing slash, an override with a query or fragment is
deliberate and kept). The openai provider applies it to request_url and
chat_url, anthropic to its root with and without /v1, responses to its
base. Chat URL resolution moves into chaturl.go to keep openai.go inside
its budget.
Test: TestStreamIgnoresEndpointOverridesThatRepeatBaseURL (openai,
request_url and chat_url) and TestStreamIgnoresRequestURLThatRepeatsBaseURL
(anthropic, responses) stream against an httptest server whose base is
/api/v1. On the base they POST to "/api/v1/", "/api/v1" or "/api"; with
the fix they POST to /api/v1/chat/completions, /api/v1/messages and
/api/v1/responses. TestEndpointOverrideRepeatsBase covers the comparison.
Fixes#9658
The config template documents [statusline].command as a command whose
first stdout line replaces the built-in status data row, but the
terminal UI never read the key: nothing passed it from config into
tui.Options, so a configured command was silently ignored.
runTUI now hands the TUI a runner built from the loaded config. When
the footer refreshes its between-turn meters (startup, turn end,
session switch) the TUI fetches the kernel status, feeds
{model, contextUsed, contextWindow, cwd} as JSON on stdin, and shows
the command's first line in place of the telemetry row. Empty output
or a failed run keeps the built-in row. The spawn goes through the
hook spawner with a 2s timeout and without searching the working
directory for a bare executable name.
Statusline is user-level configuration and is never taken from a
project file: LoadForRoot restores the user/global value after the
project merge, the project render scope omits [statusline], and the
project delta never writes it. The template and reasonix.example.toml
say so.
Test:
- tui: TestStatuslineCommandReplacesTheTelemetryRowAfterATurn and
TestEmptyStatuslineOutputKeepsTheBuiltInRow drive the model through a
turn_done update against an HTTP kernel stub.
- cli: TestStatuslineRunnerFeedsStdinAndKeepsTheFirstLine runs a real
shell command; TestStatuslineRunnerIgnoresAProjectCommand loads a
project-only command and expects no runner.
- config: TestProjectStatuslineIsIgnored, TestUserStatuslineWinsOverProject,
TestStatuslineNeverRendersIntoAProjectFile.
Before the scope change the config and project-command tests fail on
their assertions (project value resolved and rendered); after it all
pass.
Problem: adding a source could write over, or read, a credential that belongs
to another endpoint (#9178):
- a name that folds to another provider's slot ("deepseek" beside the
official DEEPSEEK_API_KEY entries) wrote its key over that slot;
- adding under a name that already exists upserted over the entry and
inherited its slot, so a keyless source at a relay resolved and sent the
old key there;
- a removed provider's credential stays stored, and a later source whose
name derives the same slot read it.
Root cause: POST /providers derived the slot from the name alone and
treated an add as an upsert.
Fix:
- config.FreeAPIKeyEnvFor keeps the name's own slot only while no config
entry holds it and nothing is stored there; otherwise it allocates a
private REASONIX_CONNECTION_<id>_KEY slot, the scheme 1.x uses.
- POST /providers refuses a name that already exists (409
provider.name_taken) unless the body says replace. Onboarding, which fills
in the entry a fresh install already names, sends replace; a replace at
that entry's own host keeps its slot, anywhere else it gets a free one.
- An unreadable config refuses the save (500 provider.config_unreadable)
instead of falling back to the name-derived slot.
- The same-host keyless "second door" still shares a slot on purpose.
Test: on origin/studio, TestANewSourceNeverWritesAnotherProvidersKeySlot,
TestAKeylessSourceElsewhereDoesNotBorrowAFoldedSlot,
TestAddingUnderAnExistingNameIsRefused (200, want 409) and
TestAKeylessSourceDoesNotInheritARemovedProvidersKey (relay-b resolved
"sk-a") fail; all pass after, with TestReplacingAnEntryKeepsItsSlotOnlyAtItsHost
and the existing key-reuse tests. Onboarding.test asserts replace is sent.
edit_file on a UTF-8 file carrying CJK text rewrote bytes it never touched:
continuation bytes of characters far from the edit came back as 84 31 A4 37.
Detect took a nil error from the GB18030 decoder as proof the file was
GB18030. The decoder never fails: it maps every invalid sequence to U+FFFD.
So any file that was not valid UTF-8 -- one stray byte is enough -- was read
as GB18030, and the read-modify-write tools (edit_file, multi_edit,
delete_range, write_file over an existing file), checkpoint restore and the
config migrations then re-encoded every unreadable sequence as the GB18030
form of U+FFFD.
Detect now names a charset only when decoding and re-encoding restores the
input byte for byte. GB18030 is tried first, then GBK, which restores what
GB18030 cannot such as CP936's single-byte euro 0x80, so such a file stays
editable in its own encoding; anything else is LossyUTF8, whose bytes pass
through untouched. The new Kind is appended because checkpoints persist it
by value.
GBK cannot hold every character, so Encode now returns an error instead of
falling back to UTF-8, which rewrote a whole CP936 file as UTF-8 when an edit
added a character such as an emoji. A legacy charset that cannot represent a
character answers an *UnencodableError naming it, its byte offset and the
charset, with ErrUnencodable as its identity; the file tools, checkpoint
restore and the config migration refuse the write and leave the file as it
was. Fixtures encode through encodingtest.MustEncode.
Readers that hold only part of a stream say so rather than inherit the
whole-file answer:
- DetectFragment runs the round trip on the prefix the decoder consumes when
not at EOF, so a character the cut split is dropped structurally. grep's
8 KiB peek and read_file's 256 KiB sample use it.
- DecodeOutput is the one entry point for bytes that are only displayed.
The producer says which ends its bound cut (Cut{Head, Tail}); UTF-8 is read
without a character split at a cut end, and an uncut end keeps its bytes,
since a GBK pair there is output rather than half a rune. What no charset
restores is still read as GB18030. Hook output (tail cut when truncated),
the shell tail (head cut once it drops bytes) and the shell's combined
output (uncut) call only it.
- DetectAndDecode decodes a legacy charset once; the edit tools and
DecodeToUTF8 use it instead of detecting and decoding separately.
Tests that fail before this change (on the base or an earlier revision of it)
and pass here:
- TestE2EEditKeepsBytesOfUTF8FileWithStrayByte: e4 b8 ad became
e4 b8 84 31 a4 37 outside the edit (fails on origin/studio).
- TestDetectUTF8WithStrayByteIsNotGB18030 (fails on origin/studio).
- TestWritesRefuseCharacterCP936CannotHold (edit_file, multi_edit,
write_file): nil error and the whole CP936 file rewritten as UTF-8.
- TestDecodeOutputTrimsCutUTF8Edges: UTF-8 cut at the front read as mojibake.
- TestDecodeOutputKeepsUncutCodePageEdges, TestDecodeHookOutputKeepsUncutCodePageEdges:
GBK pairs at an uncut edge (bc db, b0 a1 0d 0a, 6f 6b 20 e4 a1,
b0 a1 20 6f 6b) were dropped as partial UTF-8 runes.
- TestEditKeepsCP936FileWithEuroByte: an edit adding Chinese wrote UTF-8
into a CP936 file.
- TestGrepGB18030PeekWithoutNewline, TestGrepGB18030PeekCutMidCharacter:
no matches past a peek cut mid-character.
- TestReadFileGB18030SampleWithoutNewline: garbled text past the sample.
- TestDecodeHookOutputReadsTruncatedCodePageText,
TestDecodeHookOutputReadsCP936Euro, TestCodePageOutputCutMidCharacterIsReadable:
mojibake for cut or euro-carrying cmd.exe output.
- TestEncodeRefusesCharacterTheCharsetCannotHold, TestDetectGBKWithEuroByte,
TestDetectFragmentDropsTheSplitCharacter, TestDecodeOutputReadsCutCodePageText:
the API's contract.
A balance_url pointing at https://openrouter.ai/api/v1/credits left the
balance readout empty. The wallet decoder is chosen by the endpoint's
host, and openrouter.ai had no entry, so the body was read as DeepSeek's
shape; {"data":{"total_credits","total_usage"}} carries no balance_infos
and was rejected as unreadable.
Add an openrouter.ai decoder: remaining = total_credits - total_usage,
in USD, available while positive. A body missing either field stays
ErrUnreadable rather than rendering as a zero balance.
Test: TestOpenRouterCreditsDecode and TestOpenRouterSpentWalletIsUnavailable
fail on the base ("wallet response not in this endpoint's shape") and pass
with the decoder; go test ./internal/model/billing/ is green.
`reasonix run` rejected --yolo and --dangerously-skip-permissions with
"unknown flag", `reasonix -y run "task"` failed with "unknown command",
and `reasonix --permission-mode auto run "task"` opened the terminal UI
with "run task" as its prompt instead of running headless. Print mode
disagreed with itself: `--yolo run -p hi` ran the task "run hi" and
`-y -p hi` was an unknown command.
Root cause: run's flag set never registered the yolo spellings the
terminal UI takes, and normalizeCommand only looked at argv[0], so a
leading session flag sent everything to the terminal UI, -y was read as
the command name, and the leading -p scan ran past the verb.
Fix:
- run registers --yolo/--dangerously-skip-permissions as an alias for
--permission-mode bypassPermissions; each conflict (-y with --yolo,
either with --permission-mode) names only the flags involved.
- run's flags move into runFlags/newRunFlags so they can be consulted
before dispatch.
- The verb is located with the union of the terminal UI's flags and
run's on the resume-normalized argv, as runTUI reads it, so a value of
either is never taken for it. Leading flags move after `run` only when
the session flags (the terminal UI's plus -y and -p) and run's both
parse them to the same flags with the same values. Otherwise the line stays the terminal
UI's: `--resume run hi`, `-r run hi` and `--inline run hi` keep their
meaning, and a run-only leading flag (--metrics, --output-format) is
reported by the terminal UI.
- -y and -p may lead like the other session flags, and the leading -p
scan stops at the verb even when a run-only flag precedes it.
- The first word after the leading flags is the verb, as it is with no
flags; CLI.md documents that, and interspersed run flags.
Test:
- TestRunApprovalFlagsReachTheHeadlessGate runs Run() against a fake
provider whose first reply calls write_file. The file appears only
where the resolved mode lets an unattended write through (ask and
dontAsk stay unwritten). On origin/studio the five new spellings exit
2 with no file. A mutation that drops the leading flags while routing
fails it for the three leading-flag spellings.
- TestNormalizeCommandCarriesLeadingFlagsIntoRun fails on origin/studio
(10 cases) and on the previous revision of this change (9 cases,
including the three terminal-UI regressions). The revision before this
one fails 2 cases: `--model m --metrics x run -p hi` and the same with
--output-format ran the task "run hi". The revision before this one
fails 2 more: `--resume --permission-mode
--permission-mode=bypassPermissions run hi` and `--resume --resume=abc
run hi` were forwarded to run although the two sets read different
values. It passes now.
- TestRunTakesTheYoloSpellings covers the alias and the per-flag
conflict messages.
Gates: gofmt, go vet (also GOOS=windows), make check clean,
golangci-lint 0 issues (also GOOS=windows), go test
./internal/frontend/cli/.
Refs #8355
Root cause: the todo_write schema declared "level" as
{"type":"integer","enum":[0,1]}. Gemini's function-declaration validator
accepts enum only on string properties with string members, so any Gemini
model behind an OpenAI-compatible relay rejected the whole request with
400 INVALID_ARGUMENT (TYPE_STRING on enum[0]/enum[1]), whatever the prompt.
Fix: express the same range as "minimum":0,"maximum":1. Execute already
rejects any level outside 0..1, so accepted input is unchanged. The level
description loses its "Nesting level:" lead-in to stay inside the schema
token budget. Boot golden fixtures are regenerated for the new bytes.
Test: TestBuiltinSchemaEnumsAreStrings walks every registered built-in
schema and fails on an enum that is not on a string property or has a
non-string member. On origin/studio it fails with three findings on
todo_write's level; with the fix it passes, as do
go test ./internal/tools/builtin/ ./internal/assembly/boot/.
Fixes#9005
A Responses turn could end with a tool call the model issued and never
run it. The stream reader dispatched a function call only when its
function_call_arguments.done or output_item.done event arrived; a relay
that forwards the added and delta events but not the done events (or
sends deltas without the item id) left the call open, the terminal
response.completed ended the attempt, and the turn committed as a clean
answer with no calls. The run then finished ok after one round.
The terminal response's output array is the response's own list of what
it issued. On response.completed and response.incomplete, every
function_call item there that the stream did not already close (matched
by call id) is now completed from that item; one whose status is set and
is not "completed" is left alone, so a call truncated at the output
limit is still not dispatched, and one listed without a call id is not
dispatched either.
The output array decodes one item at a time. Decoded as a typed slice, a
single item with a non-string field (arguments sent as an object by a
non-conforming relay) would fail the whole terminal event, which is then
skipped and the turn ends with unexpected EOF where it used to finish.
A call with no name, such as an arguments.done event that named no
item, is no longer dispatched as an empty call; it stays open for a
later event to name.
Test: wire_gap_test.go drives the real HTTP stream reader. On
origin/studio the "done events never came" and "deltas without item id"
cases fail with calls = nil; both pass after the change, and the
no-duplicate and truncated-call guards pass on both. The
unreadable-item, empty-call-id and unnamed-arguments.done cases fail
before the per-item decode and the skips, and pass after.
go test ./internal/model/... ./internal/runtime/agent/ passes.
The full-screen transcript showed an answer's thinking only as a
"thought for Ns" marker, with no way to read it; a long shell output
could already be opened with a click, the thinking could not.
Full screen, the marker now reads ▸ while shut and ▾ while open, with a
(Ctrl+O) hint. A left click on it, or Ctrl+O for the newest one (for a
terminal whose mouse is handed back to it), opens and shuts the thinking
underneath. Each opened row carries its own faint style, since the
transcript is split into rows after rendering. A streamed answer's first
chunk keeps its row, and when the answer settles that row takes the
final thinking and elapsed time, so thinking that kept arriving after
the text started is not lost. Inline mode, where printed rows cannot be
redrawn, keeps the fixed ▎ marker.
Tests (internal/frontend/tui/screen_test.go):
- TestThinkingOpensAndShutsOnAClick: streamed and settled answers open
and shut on a click of the marker row (FAIL on origin/studio).
- TestOpenedThinkingIsFaintOnEveryRow: every opened row starts with the
faint SGR under an ANSI256 profile (FAIL with one style around the block).
- TestStreamedThinkingOpensAsItSettled: thinking arriving after the text
and the final "thought for 4s" show when opened (FAIL with the row
copied at the first chunk).
- TestCtrlOOpensTheLatestThinking: Ctrl+O opens and shuts it with the
mouse handed to the terminal (FAIL without the key).
Fixes#7934
Root cause: POST /providers/edit saved the entry and answered 204 without
rebuilding the runtime. The context window, token limits, headers and
reasoning protocol are bound into the agent at assembly, so the open
conversation kept its old window: the gauge and the fold point went on
counting against the number the form had just replaced, until something
unrelated (a model switch) rebuilt it. /context/window already rebuilds
in place; the edit form did not.
Fix: after the save, act only when the conversation runs on the edited
source and a field the agent binds at assembly actually changed (base URL,
models, vision, per-model overrides, window, max output, headers, extra
body, protocol, efforts). A key-only edit needs no rebuild because the key
is read per request, so it answers 204. Otherwise:
- running model still listed: rebuild in place. With work in progress (a
running turn, a pending question or background jobs) the save still
lands and the answer is provider.saved_while_running; any other rebuild
failure is runtime.rebuild_failed.
- the edit unticks the running model: a plain save, 204.
- the running model was already unlisted before the edit: nothing on the
entry can carry the change to that conversation, so the answer is
provider.saved_model_unlisted ("switch models to apply it").
On any of these "saved but not applied" answers the edit form refreshes
the providers list and stays open to show why.
Test:
- TestEditProviderReachesTheConversationRunningOnIt failed on
origin/studio ("window = 0, want the saved 1000000"); passes.
- TestEditProviderMidTurnSaysTheConversationKeepsItsSettings failed on
origin/studio ("mid-turn = 204, want 409"); passes.
- TestEditProviderDroppingTheRunningModelIsStillSaved and
TestEditProviderKeyOnlyMidTurnNeedsNoRebuild failed on an earlier
revision of this change (409 instead of 204); pass.
- TestEditProviderAfterDroppingTheRunningModelSaysItIsUnlisted failed on
the previous head ("editing the window after the drop = 204, want
409"); passes.
- EditConn test: a saved_while_running answer calls onSaved, not onDone.
The serve package, go vet (also GOOS=windows), golangci-lint, make check,
tsc, vitest, census:verify and perf/i18n.mjs pass.
A task, parallel_tasks or fleet call may name a child model without a
provider, e.g. "deepseek-v4-flash". The sub-agent resolver passed that
straight to Config.ResolveModel, which answers a bare name with the first
provider that lists it. With the official DeepSeek entry listed before a
relay that serves the same model, a child of a session running on the relay
was silently sent to the official API instead.
A bare name the parent's provider serves is now qualified with that
provider before resolution, in both the runtime resolver and the identity
the usage record reports. A provider name, a provider/model reference, or
a model only another provider lists resolve as before.
Test: TestSubagentBareModelStaysOnParentProvider fails on origin/studio
(resolved refs = ["first/shared-flash" ...], want "relay/shared-flash")
and passes after the fix; go test ./internal/assembly/boot/
./internal/runtime/delegation/ pass, make check clean.
A reopened Studio conversation showed the host's own instructions to the
model as if the user had typed them: the tool-round / task-budget
finalization request, the retry after a reasoning-only answer, the
executor-handoff retry, and the output-truncation fact.
Root cause: /history and the TUI restore decide "who said this" from
provider.Message.HostAuthored, which is written by the producer. Four
injection sites in the run loop appended role=user messages without it
(only the workflow-phase repair set it), so the transcript rebuilt them
as user bubbles and session previews could count them as user turns.
Fix: set HostAuthored at each site. Provider requests are unchanged:
the projection clears the field before sending.
Test: TestLoopComposedUserMessagesAreHostAuthored drives a real Run into
the task-budget landing, an empty-final retry and a truncated tool call.
Before the fix all three cases fail ("a line the loop composed is
recorded as the user's"); after it they pass. agent, serve, control,
sessionstore and boot packages pass; make check and golangci-lint clean.
Fixes#8486
A code block's copy button already swaps to a check mark after a copy,
but hovering it said "Copy response", and the config-trouble banner's
button for the config file path read "Copy response" as its visible text.
Root cause: CopyButton's idle label was the fixed string for a chat
reply; the caller's `label` only reached aria-label, so title and
visible text ignored it. ConfigTrouble passed no label at all.
Fix: the idle label is the caller's `label` when given, and the config
banner labels its button "Copy path" (en locale entry added).
Test: src/ui/CopyButton.test.tsx fails on origin/studio (code block
title "复制回复" vs "复制这段代码"; path button text "复制回复" vs
"复制路径") and passes after. Full vitest (928), tsc, census:verify
and make check pass.
Choosing the free-text option on an ask card left the caret on the
option button, so the answer could not be typed until the box was
clicked, and Enter in the box did nothing: every typed answer needed
the mouse twice.
Choosing the option now focuses its box, every time it is chosen, and
Enter there does what the primary button would. When Enter moves to the
next question, focus follows into that pane (its open box, else its
first option), because the pane it leaves becomes display:none. The key
goes through useIme, so the Enter that confirms an IME candidate is not
read as an answer. The box carries an aria-label.
Test (PromptCards.interaction):
- the caret lands in the box after choosing the option; a composing
Enter does not answer, a later Enter does (clock stubbed, not slept);
- Other -> a normal option -> Other again puts the caret back in the
box (FAIL before: focus stayed on the button);
- Enter on question 1 moves focus to question 2's first option (FAIL
before: focus stayed in the hidden pane's input).
Before/after screenshots were taken on the perf build, light and dark.
Fixes#7904
An ACP client that sent a picture got an answer from a model that never
saw it. session/prompt flattened its blocks to text and kept only text
blocks and resources with inline text: an embedded resource carrying a
base64 blob, which a client sends because embeddedContext is advertised,
was dropped without a word, and so was any image block. An image-only
prompt was refused as empty.
Image blocks and blob resources are now decoded and stored with the
control package's attachment savers under .reasonix/attachments/ in the
session's workspace, and the prompt carries the "@path" reference that
serve and the CLI already hand a turn. Undecodable data or bytes that do
not sniff as an image fail the prompt with invalid params instead of
vanishing. Steer uses the same path. initialize now advertises
promptCapabilities.image = true.
Test: prompt_attachments_test.go drives session/prompt through the real
Serve loop and a real controller. On origin/studio the image block and
blob resource cases fail with the turn input "what is in this picture?"
(no reference), the image-only case fails with "empty prompt", and the
undecodable case returns no error; all pass after the change, and
go test ./internal/frontend/acp/ passes.
On a Windows host without Git Bash the shell tool resolves to PowerShell,
a supported path. Five tests still assumed bash there:
- TestBackgroundBashWaitAndOutput, TestBashTerminalFallsBackWhenUnhandled,
TestBashTerminalSkippedWhenEnvFilteringEnabled and TestRunForegroundSuccess
ran `printf`, which PowerShell does not have. They test job, terminal and
runner plumbing, not POSIX syntax, so they now run `echo`, which both
interpreters accept with the same output.
- TestDefaultSpawnerUsesGitBashForExplicitShOnWindows needs a real Git Bash;
it now skips when no bash is installed. Discovery itself stays covered by
the injected-host tests in internal/safety/sandbox.
- TestGoldenBaselineNoExtensions records the bash tool's bash description.
A host that resolves PowerShell renders a different, intended description,
so the golden now skips there instead of reporting drift, and is never
regenerated from such a host.
Test: the touched tests pass on macOS (bash host) and the four packages pass
in full; go vet passes with GOOS=windows. The PowerShell-only failure could
not be reproduced here (no Windows host without Git Bash is available); the
reporter's reproduction in #8326 shows `printf` not recognized and the
golden diff on the bash tool description.
Problem: a long high-effort think on Studio ended with "response stopped:
hit the client reasoning safety limit" and no answer, even though the
provider was still inside the output budget it had been sent.
Root cause: the agent cancelled the provider stream once 128 KiB of hidden
reasoning had been buffered (about 32K estimated tokens) and returned
errReasoningByteLimitExceeded. The request already carries a provider
output budget (auto ~64K on DeepSeek high), so the byte guard fired inside
the budget and turned a legitimate think into a failed turn. The 1.x line
dropped the abort in #8779; Studio never received it.
Fix: the byte limit now bounds only what the host retains. Reasoning keeps
streaming live, the stored copy is the rune-safe prefix of the stream's
first ReasoningByteLimit bytes, and the default rises to 8 MiB so no
provider-bound reasoning is cut under current output ceilings. Interrupted
usage estimates count every streamed reasoning byte. The sentinel and the
client_reasoning_limit finish reason had no producer left and are removed.
Test: TestDefaultReasoningLimitLetsLongThinkingFinish and
TestReasoningByteLimitBoundsRetainedReasoningOnly fail on origin/studio
with "reasoning output exceeded client byte limit" and pass after the fix.
go test ./internal/runtime/agent ./internal/assembly/boot
./internal/tools/builtin, the race run of the reasoning tests, go vet,
golangci-lint and make check are clean.
Hooks could see a sub-agent finish (SubagentStop) but not begin, and
SubagentStop fired only when the child answered. A failed, cancelled or
self-refused task ended silently, so an external observer driven by
hooks (activity indicators, notifiers) had no event marking the start
of delegated work and no reliable end.
SubagentStart is a new non-blocking event fired just before a
foreground `task` call executes, with the call's arguments in toolArgs.
SubagentStop now fires from a defer on every exit after that start
(answer, failure, cancel, refusal) with `error` set when there was no
answer, under a context detached from the turn's cancellation so a
cancelled turn does not kill the hook. Both carry the call id as callId
(tool_use_id in the Claude-format payload) so consumers can pair them.
Only a foreground `task` fires them; read_only_task, parallel_tasks,
fleet, skill sub-agents and background tasks fire neither. hook.go,
the reasonix-guide skill and GUIDE (en/zh) state that scope and that
exit 2 cannot block.
Test: TestSubagentStartPairsWithSubagentStop runs answer, failure,
cancel and refusal cases plus a background call. Before (SubagentStop
on success only): fails, cancelled and refuses each fail with
"SubagentStart must be followed by exactly one SubagentStop, saw 0".
After: all pass, with matching call ids, err set on the three non-answer
cases, and a live context in SubagentStop. TestRunnerSubagentStartPayload
checks callId, toolArgs and error reach the hook's stdin.
A global [[plugins]] entry had no way to name the workspace it runs for:
${VAR} expansion read only the process environment and the project
.env. Servers that key on the project path in a header or argument
(IntelliJ's IJ_MCP_SERVER_PROJECT_PATH) therefore needed one entry per
project. Installed plugin packages already got ${CLAUDE_PROJECT_DIR};
user and .mcp.json entries did not.
ExpandedPluginForRoot resolves ${REASONIX_WORKSPACE_ROOT} and
${CLAUDE_PROJECT_DIR} to the workspace, and both places that turn an
entry into a spec call it with the root they already hold:
pluginspec.FromEntry (session boot, capdiag, mcp oauth) and the
controller's mcpIdentitySpec (live add, install, reconnect). The host
owns these values, so they outrank a process or .env variable of the
same name; a hook's child process inherits its parent session's
CLAUDE_PROJECT_DIR. Config path fields (sandbox roots, skill paths,
proxy) keep plain environment expansion. WorkspaceRootValue is the one
canonical form (absolute, cleaned, symlinks as opened), also used for
the plugin-package substitution.
Tests (the first three fail before the fix and pass after):
- TestAddMCPServerExpandsWorkspaceRootInLiveHeaders: live AddMCPServer
against an httptest MCP server. Before: live headers = ("",
"/inherited/from/a/parent/hook").
- TestLoadForRootExpandsWorkspaceRootInPluginEntries: two workspaces,
one global http entry and one .mcp.json entry, with a conflicting
CLAUDE_PROJECT_DIR in process env and .env. Before: header = "".
- TestPluginSpecsForRootExpandWorkspaceRoot covers the boot mapping, and
TestWorkspaceRootVarsStayOutOfConfigPathExpansion keeps sandbox paths
out of scope.
Typing "@proinf" found nothing for ProjectInfoNew.tsx: fileref.Search
only recorded a hit when the query was a contiguous substring of a
directory name, the basename or a path segment, so a file could only be
reached by spelling a run of its name exactly or walking down to it one
directory at a time.
Search now keeps a last tier for names that contain the query's letters
in order (case-insensitive), files by basename and directories by name.
It ranks after every contiguous hit and only fills the slots those
leave, so existing results and their order are unchanged. The kernel's
Complete and the external-folder ref search both go through Search, so
every frontend gets it.
Tests: TestSearchMatchesSubsequenceAfterSubstringHits (fileref) and
TestCompleteRefFindsFileBySubsequence (control.Complete, the boundary the
composer calls) fail on origin/studio (got only [src/proinfo.go]; items
= []) and pass with the change.
Under a fake-ip resolver (Clash / mihomo) every public host answers with a
routable A from 198.18.0.0/15 beside a unique-local AAAA from fd00::/8.
The direct dial path refused the whole host as soon as any resolved address
was on the SSRF blocklist, so web_fetch failed for every site with
"refusing to fetch internal address ... (resolves to fdfe:...)".
Root cause: the guard treated one blocked address as a verdict on the host,
although the dial is pinned to a resolved address rather than the name. A
blocked address in a mixed answer can simply be skipped; the connect still
only ever reaches an address that passed the check. A host whose addresses
are all blocked is refused exactly as before.
The direct path moves into dialVettedAddress, which tries the allowed
addresses in resolver order, and the lookup becomes a package seam so the
test can supply a resolver answer.
Test: TestWebFetchDialsAllowedAddressBesideBlockedOne resolves a name to
[fdfe:dcba:9876::152, 127.0.0.1] and fetches from a loopback server. On the
base it fails with "refusing to fetch internal address api-docs.example
(resolves to fdfe:dcba:9876::152)"; with the fix it passes.
TestWebFetchRefusesHostWithOnlyBlockedAddresses keeps the all-blocked
refusal. go test ./internal/tools/builtin/, go vet, make check and
golangci-lint are clean.
A local model running at ~25 tok/s can spend well over two minutes
prefilling a long context without sending a byte. The openai, anthropic
and responses providers each cut such a call after 120s of silence (the
SSE idle watchdog, and the transport's response-header timeout on the two
that set one; anthropic set none, so its header wait was unbounded), and
the turn failed with "stream stalled — no data for 2m0s" although the
model was still working. The 1.x line already moved these guards to 300s.
The window now lives once, as provider.StreamIdleTimeout (300s), and all
three providers read it for both the idle watchdog and the header wait.
A longer header window multiplies through SendWithRetry, which retries a
response-header timeout with the full MaxRetries budget: 11 x 300s plus
backoff is close to an hour before the user sees an error, and a
prefill that timed out once times out identically on every attempt. A
transport timeout (a net.Error reporting Timeout() while the caller's
context is still live) now gets its own budget of one retry, the way
maxAuthRetries bounds auth failures, so the worst case is about ten
minutes.
Test:
- TestNewWaitsFiveMinutesForASilentModel in openai, anthropic and
responses builds each client through New and reads its idle and
header windows. Before: "stream idle window = 2m0s, want at least 5m"
in all three, and "response header window = 0s" for anthropic.
- TestSendWithRetryGivesAHeaderTimeoutOneRetry drives a real
http.Transport against a server that never sends headers. Before:
"calls = 4, want the timed-out attempt plus exactly one retry" (retry
limit 3). After: two calls and an error that is a net.Error timeout.
Fixes#6990
A Windows machine whose OpenSSH DefaultShell is Git Bash could not be used
as a remote host: `uname -sm` answers "MSYS_NT-10.0-26200 x86_64", which is
neither Linux nor macOS, and the fallback `echo %OS% %PROCESSOR_ARCHITECTURE%`
is cmd syntax that bash (and PowerShell) print back literally, so the machine
was refused as unsupported.
Root cause: detectPlatform asked the Windows question in cmd's syntax, while
every command windowsShell sends afterwards already goes through
`powershell -EncodedCommand`, which any of the three login shells runs the
same way.
Fix: ask for OS and PROCESSOR_ARCHITECTURE through psCommand. The guide no
longer says remote hosts are Linux and macOS only.
Test: TestWindowsIsIdentifiedWhateverTheLoginShell drives detectPlatform
through cmd, PowerShell, Git Bash (MSYS_NT) and MINGW64 login shells. On
origin/studio the Git Bash case fails with
bootstrap: unsupported remote: `echo %OS%` answered "%OS% %PROCESSOR_ARCHITECTURE%"
and all four pass after the change.
The explorer classified entries by os.ReadDir's DirEntry.IsDir, which is
false for any symlink. A link to a folder inside the workspace was drawn
as a file, so it could not be expanded, and clicking it asked for a file
read that fails. Search had the same flaw: WalkDir does not follow links,
so a linked folder whose name matched came back as a file hit, and a link
to a file outside the tree came back as a hit the file endpoint's
os.OpenRoot then refuses.
A link entry is now resolved: one naming a folder inside the tree is
listed as a folder (expanding it already works, listFolder checks the
real path), one naming a file inside the tree as a file, and one that
dangles or leaves the tree is not offered, matching the refusal
listFolder already gives such a folder.
Test: TestALinkIsListedAsWhatItResolvesTo and
TestASearchListsOnlyLinksToFilesInTheTree fail on origin/studio
("directories = [alpha zeta]", "search = [alpha/a.txt far.txt near.txt
txt-folder zeta/z.txt]") and pass with the fix; the serve package
passes in full.
Refs #5914
The planner keeps a session of its own and only ever saw its own plans,
so the next planning turn did not know what the executor had found or
changed, and turns the policy routed straight to the executor never
reached it at all. Users ended up relaying the executor's results to the
planner by hand.
Every executor run now owes the planner its final reply (plus the user's
input when the planner never saw that turn), and the next planning input
carries the owed outcomes in an <executor-since-last-plan> block at the
turn tail, so the planner's cache-stable prefix is untouched. The debt
clears only once a plan comes back; a planning turn that fails and rolls
back keeps it owed. Switching sessions clears it with the planner
transcript.
The debt is bounded where it is held: task and reply are cut to their
render caps on append, only the newest four outcomes are kept, and a
count of the trimmed ones is rendered. A reply is taken only from the
run's own messages; when a compaction rewrites the session mid-run,
those begin at the run's own user message, identified by a stamp later
than every message before the run, and without it the outcome reads
"no reply captured" instead of an earlier turn's reply.
Test: TestPlannerSeesWhatTheExecutorDidSinceItLastPlanned and
TestPlannerKeepsExecutorOutcomeOwedWhenPlanningFails fail on
origin/studio (the second planning input carries no executor reply).
TestExecutorOutcomeNeverReportsAnEarlierReplyAfterCompaction (an
earlier reply reported twice) and TestOwedExecutorOutcomesStayBounded
(7 outcomes held, want 4) fail on the previous revision. All pass after,
with the existing coordinator suite.
complete_step rejected diff/files evidence whose paths were cited
workspace-relative ("frontend/src/core/backend/idb.ts") when the writer
had been given the absolute path: the ledger records a receipt's path as
the tool received it, and complete_step compared cited and recorded
paths for exact equality, in the per-turn ledger and in the cross-turn
session fallback alike. The step could not be signed off even though
the file had been written.
The agent now attaches its workspace root to each tool call's evidence
scope, and complete_step checks every cited path under the forms that
name the same workspace file: the citation itself, a relative citation
joined to the root, and an absolute citation made relative to it. A path
outside the workspace keeps only its own form, relative-vs-relative stays
exact, and case folds only on Windows through NormalizePath. The ledger
queries themselves are unchanged, so completion reports and blocked
conclusions keep exact matching.
Test: TestCompleteStepCreditsCitationsOfTheSameWorkspaceFile and
TestE2ERelativeCitationOfAbsoluteWriteSignsOff fail on origin/studio
("diff paths have no matching successful writer receipt") and pass
after. TestCompleteStepCreditsNoOtherFileForACitation (a same tail
outside the workspace, a base name, a relative nested write, a deeper
file's trailing components, a .bak, a ../ escape, a different case on
Linux) is rejected on origin/studio, credited under suffix matching,
and rejected after.
A workspace below a Box Drive mount on Windows failed every config load
with "resolve project config path ...: The system cannot find the path
specified", whether or not reasonix.toml existed.
Root cause: resolveConfigAccessPath canonicalizes the whole path with
filepath.EvalSymlinks before it confines a project config link to its
directory. Below a cloud-drive reparse point that canonicalization fails
while the files themselves open normally, and the failure was treated as
fatal even when reasonix.toml was a plain file or absent.
Fix: when canonicalization fails and the config entry itself is absent or
a non-link file, use the logical path. Such a file lies in its own
directory whatever its ancestors resolve to, so the confinement check has
nothing to decide. A link still has to resolve, so an unresolvable link is
refused as before.
Test: path_access_test.go stubs canonicalization to fail below a mount
directory. Without the fallback both LoadForRoot cases fail with the
reported "resolve project config path" error; with it they load (and the
project value is applied), and a reasonix.toml link under the same mount
is still refused.
Off macOS the Electron shell sets the application menu to null so no bar
renders inside the window. Electron binds F11 only through a menu role
(togglefullscreen), so with no menu the key did nothing and the window
had no way into full screen on Linux or Windows.
The shell now listens for a bare, non-repeated F11 keyDown on the main
window's contents and toggles setFullScreen. macOS is left alone: it
keeps full screen on the title bar and the window menu.
Test: desktop/electron/test/unit.mjs "F11 toggles full screen where no
application menu binds it" fails on origin/studio (the key is unbound)
and passes with this change; node --test test/unit.mjs is 43/43.
The environment block in the system prompt printed "- Shell: bash". That
value is the interpreter sandbox.ResolveShell picked for the bash tool; the
user's login shell ($SHELL) never feeds it (it is only read to recover
PATH). Unattributed, the line reads as the user's shell, so a fish or zsh
user got advice about ~/.bashrc.
The line now reads "- Shell used by the bash tool: <shell>", and the
rendered [tools.shell] config section says the setting picks the bash
tool's interpreter and is independent of the login shell. The prefix
changes once on upgrade and stays byte-stable within a session.
Test: TestBuildEnvironmentBlockAttributesShellToBashTool builds through
boot with SHELL=/usr/bin/fish and reads the system message sent to the
provider. On origin/studio it fails (block shows "- Shell: bash"); with
the fix it passes, together with the environment and config packages.
Fixes#6823
The config documents ui.show_turn_usage as the switch for the per-request
"TURN … tok · in … · cached …" receipt in the CLI/TUI transcript, and the
1.x TUI reads it. The studio TUI never did: runTUI loaded the config but
passed nothing about it to tui.Options, and every usage row was drawn, so
setting it to false changed nothing.
tui.Options gains HideTurnUsage, set from the loaded config. A hidden usage
row still folds into the transcript (a later frame of the same attempt
restates it in place and TurnOut keeps counting), but it is marked committed
without being printed and is skipped in the live area.
Test: TestHiddenTurnUsageNeverReachesTheScreen drives a turn through the
model and checks the screen while the answer streams and after it settles.
Before the fix, hide=true failed in both states (receipt shown); after it,
both pass, and hide=false still shows the receipt.
Root cause: the workbench read an open file once, keyed on its path only.
The explorer tree beside it is re-read when a turn settles, when the
window is looked at again and when the change set moves, but the file
view never was, so an edit by the agent or another application stayed
invisible until the tab was closed and reopened.
Fix: the file view is re-read on the same occasions as the tree, and the
content is replaced only when the kernel's revision for it moved. A draft
with unsaved edits is never overwritten, including one made while a
reload read is in flight, and a hidden panel reads nothing. Revisions
are opaque, so a read only counts if no save and no later read has been
issued since it started; otherwise a reload issued before a save could
land after it and revert the view to the pre-save content.
Test: WorkbenchPanel.test.tsx adds five cases.
- "shows an open file as it is on disk once a turn settles" and
"... once the window is looked at again" fail on the base (view still
shows "First") and pass after the fix.
- "keeps unsaved edits when the file changes on disk underneath them"
pins that a dirty draft issues no read.
- "keeps an edit made while a reload was reading the file" fails when the
in-flight dirty-draft guard is removed and passes with it.
- "drops a reload that was issued before a save and lands after it"
fails without the read/save ordering (view reverts to "# Second") and
passes with it.
Full frontend-next vitest (915 tests), tsc --noEmit and make check are
clean.
Refs #5693
A fenced code block is drawn with a "│ " rail in its gutter. With the
mouse captured, selectedText read the rows exactly as drawn, so copying
code from an answer pasted a rail at the start of every line. Native
mouse mode already draws the rail as blank cells; the in-app copy now
reads the transcript rendered that way. The rail and its blank form have
the same width, so rows and columns still line up with the selection
drawn on screen, and only rows through the selection's end are rendered.
Test: TestSelectionCopiesCodeWithoutTheRail selects a railed code block
in a settled and a live answer. Before: copied
" │ func a() {\n │ return\n │ }". After: no rail; the tui package,
go vet, golangci-lint and make check pass.
remark-gfm defaults to singleTilde: true, so "500~1000 or 2000~3000"
struck out everything from the first tilde to the second. A lone tilde is
the ordinary way to write a range, especially in Chinese replies. The
transcript renderer now passes singleTilde: false; ~~text~~ still strikes.
Test: Markdown.plugins.test.tsx renders two ranges and one ~~...~~ span.
Before the fix it found 2 <del> elements; after, exactly one, holding
only the doubled-tilde text. Full frontend-next vitest and tsc pass.
SendWithRetry retries transient transport errors, but transientErr refused
anything matching errors.Is(err, context.DeadlineExceeded). net/http's
"timeout awaiting response headers" error implements Is(DeadlineExceeded),
so a slow gateway that hit ResponseHeaderTimeout ended the request on the
first attempt and left an unattended run stopped.
Whether the caller gave up is now asked of the caller's context rather than
read off the error's identity; a transport timeout while the caller is still
waiting is retried with the usual backoff.
Test: TestSendWithRetryRetriesResponseHeaderTimeout drives a real
http.Transport with ResponseHeaderTimeout against an httptest server that
stalls the first request. Before: "p: request failed: ... net/http: timeout
awaiting response headers". After: recovers on the second attempt.
TestSendWithRetryStopsWhenCallerDeadlineExpires pins that the caller's own
deadline is still not retried.
Refs #6354
A model switch rebuilds the controller from a snapshot of its history and
closes the old one. /compact runs asynchronously under the rotation gate,
but RuntimeStatus reported only running/finishing as active, so every
busy guard built on it (serve switchModel, provider/workspace rebinds,
ACP config rebuilds) let the switch through mid-fold: the new controller
carried the pre-fold history while the fold landed on the discarded
controller, which then wrote the compacted transcript over the same file.
RuntimeStatus.Running now includes the rotation gate, which also covers
rewind, summarize and new/clear session, matching the 1.x line where
maintenance already counts as running.
Test: TestSwitchModelRefusesWhileManualCompactIsFolding holds a real
manual compaction open at its summary call and switches the model through
serve. Before: switchModel returned nil and replaced the controller.
After: it refuses with busy.switch_model and keeps the old controller.
The Windows smoke and full legs looked hung: output stopped before
internal/assembly/boot and the step ran into its job timeout without
-timeout ever firing. Nothing was hung. With the result cache on, go test
passes each test binary -test.testlogfile, and before and after the run
it computes the cache key from that log, calling filepath.EvalSymlinks on
every recorded path outside the module. boot's log holds about 1.9M
entries (84 MB); on a Windows runner that costs about five minutes per
pass, and a restored cache that already holds a boot log pays it twice.
Pass -count=1 to every root-module go test that runs on Windows, and hold
ci.yml to it with a test that reads the workflow.
A quoted paragraph was wrapped to the full terminal width and then
shifted right by the "▎ " rail (plus any list indent), so every full
row ran two or more columns past the edge. The terminal soft-wrapped
the overflow, leaving a stray rail on one row and the last characters
of the line on the next — most visible with CJK text, where every row
is filled to the edge.
renderBlockquote now lays the quote's body out at the width left after
its prefix and restores the renderer's width afterwards, so nested
quotes and quotes inside list items narrow once per level.
Test: TestBlockquoteRowsFitTheRenderWidth renders top-level, nested,
list-indented and Latin blockquotes at widths 20/41/80 and asserts no
row exceeds the width. It fails on the base (an 84-column row at width 80, 24 at width 20)
and passes with the fix.
A reader who audits what the agent wrote had no way to keep diffs open
while leaving reads and commands folded. The folding settings answered per
part of the transcript (work group, step) and never per what a step did:
"steps" opened everything or only failures, and the work group folded once
the turn ended, so an edit was always two clicks deep.
Folding gains a "changed" mode for the work group and for steps. A step
opens when it changed a file (it carries a diff or line counts, the same
fact the +N/-M badge already reads); a work group opens when one of its
calls did. A "Changes only" preset sets both. The settings preview gains an
edit step so the mode shows what it does.
Test: src/ui/fold-changes.test.tsx renders a real Transcript with a read-only
turn stretch and one holding bash + edit_file. On the base, "changed" is
not a stored mode and both groups stay folded (expected [false, true],
got [false, false]); with the fix only the edit's group and the edit's
step open. Full vitest (144 files, 912 tests) and tsc pass.
curl, wget, pip and most download tools redraw their progress meter by
returning to column zero with a carriage return. The tool card renders
shell output in a <pre>, and a browser draws a carriage return as a
space, so every frame the command printed landed side by side on one
line. The card showed the first frame (0%) and the rest ran off to the
right: a 64-frame curl download measured 35003px wide in Chromium, so a
running download looked stuck at its start.
Term now resolves each line the way a terminal would leave it: every
frame overwrites the start of the one before, a CRLF ending stays a line
ending, and a trailing return holds the last complete frame while the
next one is still arriving. Copy still takes the raw output.
Test: four cases in ToolCards.state.test.tsx (latest frame shown,
shorter frame keeps the uncovered tail, CRLF, trailing return) fail on
origin/studio and pass with the fix. In the perf build the same curl
card goes from 35003px to 710px wide and shows 100% instead of 0%.
Root cause: the full-screen transcript only bound plain PgUp/PgDn, so the
Shift+PgUp/PgDn a terminal uses for its own scrollback fell through to the
composer and did nothing. Shift+Insert, which Linux terminals that pass the
key through expect to paste, was unbound too (the textarea binds only Ctrl+V).
Ctrl+C quitting (twice, when idle) and Ctrl+Insert copying a selection were
already in place.
Shift+Up/Down stay with the composer, which uses them to select lines.
Test: TestShiftPageKeysScrollTheTranscript and
TestShiftInsertPastesClipboardText fail on origin/studio ("shift+pgup left
the view on the tail", "shift+insert did nothing") and pass with the fix;
go test ./internal/frontend/tui/ passes.
Refs #4912
Anything a task produced had to be found by hand under the workspace
path, because the explorer had no way to open Explorer or Finder at a
file or folder. Nothing like it existed. The only reveal in the app
opens the theme folder.
The explorer header gains a button that shows the workspace root. Every
file and folder row gains a context menu:
- "Reveal in File Explorer" on Windows
- "Reveal in Finder" on macOS
- "Show in file manager" elsewhere
The kernel confines the path and the shell does the showing.
- GET /workspace/locate?path=<workspace-relative> answers the absolute
path of an entry in that runtime's workspace. "" and "." name the
root. It refuses "..", absolute paths and links that resolve outside
the tree with workspace.path_outside_tree, and a missing entry with
workspace.file_missing.
- The route is gated on the local-desktop host grant. AllowEditorOpen
is renamed AllowLocalDesktop because the grant now covers both
routes. A networked serve and a paired device get
workspace.locate_no_window.
- The hub refuses the route for a remote pane before proxying, with the
same code. Only the hub knows the pane is remote, and a far kernel,
compromised or not, answers with paths that mean nothing here.
- The renderer sends only its pane base and the relative path, over the
new shell:reveal IPC. Electron main does the rest:
- it requires the base to be a pane (/rt/<id>);
- it asks the kernel with its own credential;
- it acts only on an absolute path the kernel answered, and on Windows
only on a drive-letter path, never a share or device path
(\\host\share, \\?\, \\.\, \??\);
- it only ever calls shell.showItemInFolder, the root included.
openPath is never used: a directory can be an application bundle,
or a link to one, and opening it would launch it outside the
sandbox.
- StudioHost now forwards the query string, which it used to drop
silently.
- The page hides the action in a browser tab (no bridge) and in a
remote pane.
Tests:
- workspace_locate_test.go:
- entries inside the tree, and "." as the root;
- escapes, including a symlink escape;
- a missing entry;
- no grant, and a paired device;
- the hub refusing a remote pane without reaching the far kernel.
- Electron unit tests drive reveal() through StudioHost against a real
HTTP server. They check that the root is only selected, even when it
is answered as a directory, that forged bases never reach the kernel,
and that relative answers and Windows share or device answers are
refused.
- WorkbenchPanel tests cover the header button, both row menus, the
keyboard path, the remote pane and a coded refusal.
Show a pending state while slash commands and skills load, and keep a dismissed pending request from reopening the menu when it resolves. Covers the delayed and Esc paths with interaction tests. Fixes#9311.
In Settings > Model services the protocol row (.vway) lays out the label,
the segmented control and the hint in one nowrap flex row. The label and
the control are flex: none, and the hint is the only item that can shrink
(flex: 0 1 auto, min-width: 0). The detail column is only ~474px wide even
in a 1400px window: label 47 + control 367 + gaps 20 + padding 24 leave
16px, so the hint was shrunk to 16px and set one word per line, 24 lines
down the pane (computed styles read in Chromium). The only rule that
wrapped the row was a max-width: 760px viewport query, which never fires
while the pane itself is narrow inside a wide window.
A switcher row now always wraps, and the hint takes flex: 1 1 100%, so it
sits on its own line under the switcher at every width - the way the
neighbouring rows put a description under their label rather than beside
their control. The viewport query that did this only on narrow windows is
removed.
Test: perf/overflow.mjs opens Settings > Model services at 1400px and 640px
and measures every switcher hint in the detail pane (width against the
room in its row, rendered line count).
Before (origin/studio): 1400px FAIL - width 16px of 450px, 24 lines;
640px ok. After: 1400px 450/450px, 2 lines; 640px 538/538px, 2 lines.
tsc --noEmit, vitest src/ui/Providers src/ui/AddProvider
src/ui/settings-catalog src/styles (36 passed), perf/contrast.mjs clean,
make check clean.
A running bash step in the activity group showed the same static clock as
the group header; the only sign the turn was alive was the composer's R.
Each step row also left a wide blank between the status mark and the
command.
Root causes:
- Nothing animated a running call's state mark; running differed from
done only by a tint.
- `.activity-body .hl .nm` was `flex: 0 0 96px`: a fixed name column, so
a short name like `bash` left ~80px of empty box before the command.
- `.flow .call[data-k="say"] .hl` (and its `.nm`/`.arg` rules) matched
every descendant `.hl`. An activity group is re-hosted inside the say
card, so a running row (a plain `.hl`, not a disclosure summary) picked
up the answer header's 29px left padding and 10px bottom margin and sat
26px right of the finished rows; args also took `--muted` instead of
the group's `--faint`.
Fix:
- A running call's clock turns (2.4s linear) while its state holds. One
fact moves in one place: the step's own mark while the group is open,
the group header's mark once it is folded. The header also says
"{n} 项运行中" in `--net`. Reduced motion stops the turn and keeps the
tint; hidden panes pause it.
- The name hugs its text up to 96px and never shrinks.
- The say header rules are scoped to the card's own header
(`> .c > .hl`), and plain step heads get the summary's 3px inset.
The motion guard counted any two infinite loops as the whole budget; it
now classifies each loop by what it reports (the run mark, or a running
call's mark at most once per running call) and fails any other loop.
Test: new perf/running.mjs drives a finished and a running bash call via
__feed in dark/light at 1440px and 640px, plus reduced motion. Against
origin/studio it fails 28 checks (no animation on the running mark, no
header signal, 83px between name and command, rows 26px apart); after
the fix all pass. motion, idle, cards, contrast, overflow and surface
guards pass; vitest 908/908; tsc clean; make check clean.
The explorer kept listing folders that had been deleted in Windows
Explorer: 12 entries against the 4 the OS showed.
Root cause: GET /workspace/files reads the disk on every request and keeps
no cache, but the panel only asked when it was opened, when the search
query changed, or when the git change set changed. That change set is
itself fetched only at turn boundaries, and untracked or ignored folders
(and every folder of a workspace that is not a repository) never move it.
So a change made in another application was never followed by a read, and
the tree's freshness rested on the agent being the one who wrote.
Fix: the listing is also read again when the window regains focus or its
page becomes visible (useGlance), and when a turn settles. No fs watcher:
the kernel listing is uncached and cheap, a watcher would need a new
dependency, a handle per expanded folder on Windows, and a new pushed
event type across the wire, all to refresh a panel nobody is looking at.
Test: WorkbenchPanel.test.tsx "drops a folder deleted outside the app once
the window is looked at again" deletes a folder from the backing store
without any port call and fires window focus. Fails on origin/studio (the
t620 row is still present), passes with the fix. A second case covers the
turn-settle read of a file git does not list.
The clean delivery receipt lays out the tick, the label and the evidence
(file count plus passing commands) in one flex row. Only the evidence was
meant to give way (min-width: 0 + ellipsis), but the label kept the default
flex-shrink: 1. Flex shrinking is weighted by basis, so a command many times
wider than the label took almost none of the loss and the label was shrunk to
its min-content width - a single glyph for CJK, which may break between any
two characters. "没有未经验证的部分" rendered one glyph per line at every window
width.
The tick and the label now take flex: none; the evidence is the only item
that shrinks and ellipsizes.
The row also sat off the answer's column: it was indented by the tool-call
symbol column (42px), which top-level transcript items no longer draw, and
ran the full flow width instead of --doc. It now hangs like the answer card
(6px inline hang, max-width --doc), so the tick lines up with the answer
text and the activity group, and the row ends where they end.
Test: perf/cards.mjs feeds a turn_done receipt with a long && chain through
the real App in Chromium and measures the label at 1440/640/420px.
Before: "回执标签不被长命令挤成竖排 — 9 行" FAIL at all three widths.
After: 1 行 at all three, and the command is still clipped in-row.
The same page checks the row against the answer's text column: before,
tick off by 48px and row 60px wider at 1440px; after, 0px/0px at all three.
tsc --noEmit, vitest src/ui/cards src/styles (67 passed), make check clean.
Refs #10851
multi_edit returned on the first step whose old_string was missing, not
unique, or empty, so a batch with three bad steps took three round trips
to surface them all even though nothing was ever written.
The sweep now records a failed step, leaves the buffer as it was, and
checks the remaining steps against it. Any failure still returns before
the write, so the file stays untouched. A lone failure keeps its
existing "edit N: ..." form; several are listed under
"K of N edits failed; <path> left untouched", with the note that steps
after a failed one ran without its replacement, so a later failure may
be a consequence of an earlier one.
Test: TestMultiEditReportsEveryFailedStep (5 edits, 3 bad: not found,
not unique, empty old_string) fails on origin/studio, which reports only
edit 2, and passes with this change; the file is unchanged in both.
go test ./internal/tools/builtin/ passes; make check and golangci-lint
on the package are clean.
Refs #10792
The checkout under review is untrusted input, yet review built its grep
from the merged config and read the review skill with the checkout as a
project root. A reasonix.toml setting [tools.search] engine = "rg" and
rg_path = "tools/rg" made review's grep execute a program the checkout
ships, and a .reasonix/skills/review/SKILL.md without read-only replaced
the built-in review skill, handing an attacker-written prompt bash.
Review now loads the user-global config alone (Roots.LoadUserConfigReadOnly)
for its tool registry: search engine, rg_path and sandbox. Its skill store
has no project root, so project-scope skill directories are never
discovered. Provider and model resolution are unchanged.
On studio only the executor and the controller fired the session's hooks.
Every other agent that calls tools ran with no hook runner, so a PreToolUse
deny on reading a secret did not cover its reads:
- delegated children: TaskTool.subagentOptions (task, read_only_task,
parallel_tasks, fleet, background tasks and model-invoked run_skill via
RunProfileSpec) and roleWiring.skillRunOptions (read_only_skill) set no
Hooks. This ports main-v2's 896faf36d (#10843), which never reached studio;
- the dual-model planner (roleWiring.planner);
- the guardian, reachable through gateApprover -> guardianSess.Review;
- `reasonix review`, which builds its subagent outside boot.
hook.Runner gains ForRole(role): a runner firing the same live hook set in
the same environment, whose session id is the parent's id at fire time
suffixed with ":"+role. A SetSessionID rotation (/new, clear, branch
switch) therefore reaches every child, and a hook keyed on session_id
neither merges the planners of different sessions nor confuses a child
with the executor. The hooks and the repeat-suppression state live in a
shared hookSet, so SaveHooks/Replace reaches every derived runner.
boot passes the runner to roleWiring: planner fires as <session>:planner,
guardian as <session>:guardian (guardian.NewSession takes the hooks), task
and skill children as <session>:subagent:<ref|call id> through
TaskToolOptions.HooksForRole and skillRunOptions.
`reasonix review` loads only the user's hooks: hook.LoadOptions gains
Scopes, and boot.NewUserHookRunner loads hook.UserScopes (global
settings.json and installed plugins). Project hooks from the reviewed
checkout never run, since a branch under review is untrusted input. Each
review run fires under a fresh id.
Review hooks start in the checkout root so they can inspect it, which on
Windows would let cmd.exe resolve a legacy hook's bare `python` or `node`
to an executable the checkout ships, since cmd.exe searches the current
directory first. The review runner's spawner (hook.WithoutCwdExeSearch)
sets NoDefaultCurrentDirectoryInExePath=1 in every hook process, which
cmd.exe honours; the cwd stays the checkout.
Tests (real PreToolUse scripts that log their payload and exit 2):
- TestBuildRunsPreToolUseInsideTaskSubagent (boot.Build; task,
read_only_task, run_skill, read_only_skill): the child's read is blocked
and fires under <parent>:subagent:...
- TestEffectPreToolUseCoversPlannerReads / ...GuardianReads (boot.Build):
blocked, under <parent>:planner / <parent>:guardian
- TestEffectPlannerHookSessionFollowsRotation: NewSession moves the
planner's hook session to the new parent id
- TestReviewCommandRunsOnlyUserHooks: a global deny blocks the review's
read, a project hook in the checkout never runs, two runs fire under
two distinct ids
- TestUserHookRunnerDisablesCwdExeSearch: through the spawn seam, the
review runner's hook process starts in the checkout with
NoDefaultCurrentDirectoryInExePath=1
- TestLoadUserScopesSkipProjectHooks, TestRunnerForRoleDerivesSessionIDAtFireTime
Refs #9255
The 1.x line keeps the CLI's install channels until 2.x is stable, so
the npm and Homebrew job now runs only with the repository variable
STUDIO_PUBLISHES_CLI=true. Studio releases still carry the CLI archives
its remote workspaces install from.
A pull request authored with GITHUB_TOKEN never starts other workflows, so
the weekly acknowledgments PR never reported the checks branch protection
requires and could only land by bypassing it. The step now uses the
dedicated ACKNOWLEDGMENTS_TOKEN secret when it is set, and falls back to
GITHUB_TOKEN otherwise.
The contributor table also listed github-actions[bot]; bot accounts are
now filtered before the top 20 are taken (30 are fetched to keep 20).
Checked by running the script against the live API: the previous version
writes one github-actions row, this one writes none.
Wails stopped being the desktop shell when the Electron shell landed, but
seven places on `studio` still describe it in the present tense. Six of them
were already fixed on `main-v2` by #10018; they only need the same edit
carried over here.
- `CHECKPOINTS`: the desktop rewind control calls `controller.Rewind` over the
desktop host protocol, not the Wails binding.
- `TASK_CONTRACT`: the constraint keeps the desktop host JSON contract intact.
- `RECOVERY`: `doctor` inspects install problems without loading the desktop
shell, and macOS keeps LaunchServices launching the desktop app bundle.
- `DESKTOP_CRASH_DIAGNOSTICS_RUNBOOK`: the budget is pre-shell work, and
report/metric persistence runs after shell startup.
- `SESSION_MEMORY_RETRIEVAL`: the example project file no longer points at
`desktop/frontend/wailsjs/`, which went away with the Wails shell. Studio
commits its generated files under `internal/ext/extension/protocol/`.
- `REASONIX.md`: the frontends behind `control.Controller` are the chat TUI,
HTTP/SSE serve and the Electron desktop.
- `issue-auto-label`: the classifier prompt no longer tells the model the
desktop app is Wails.
Root cause: Electron 44 picks its Wayland Ozone backend by itself in a
Wayland session, and on drivers whose EGL platform init fails there (#10369)
the GPU process exits and the app dies before a window appears; the same
machine works with --ozone-platform=x11. Studio's deb entry runs the
Electron binary directly, so no wrapper builds its argv, and the only
switch main.js sets is disable-backgrounding-occluded-windows. Chromium
selects Ozone before main.js runs, so appendSwitch cannot choose it.
Decision: default to X11 whenever it is available, the same rule as the
1.x line. On Linux, when the session is Wayland (XDG_SESSION_TYPE, else
WAYLAND_DISPLAY) and DISPLAY is set, main.js relaunches once with
--ozone-platform=x11 prepended and exits, before it claims the instance
lock so the relaunch finds nothing held. REASONIX_OZONE_PLATFORM=wayland
keeps native Wayland, x11 forces X11, auto is the default. A pure Wayland
session without DISPLAY and an X11 session are unchanged, and an explicit
--ozone-platform or --ozone-platform-hint argument wins, which is also what
stops the relaunched process from relaunching again. The update handover's
app.relaunch() reuses process.argv and keeps the flag.
Test: two cases in test/unit.mjs. One loads the real main.js with a stub
electron module under a Wayland+DISPLAY environment and asserts it
relaunches with the flag and exits before requestSingleInstanceLock; on
origin/studio it fails with actual [['lock']] where
[['relaunch', ['--ozone-platform=x11', ...]], ['exit', 0]] was expected.
The other walks the session table through relaunchForOzonePlatform. Both
pass with the fix (38/38). A probe on Electron 44.1.0 confirmed a
top-level return after app.relaunch({args}) + app.exit(0) before ready
starts a child that receives the new argv.
Root cause: on DeepSeek thinking mode, reasoningOnlyFinishHonoured
accepts a finish_reason=stop with empty content as the final answer.
When that stop comes right after a tool round, the tool results never
get a visible synthesis and the turn goes idle until the user sends
another message (#9758).
Decision: a narrow structural retry. When this turn ran tools, no
visible assistant text follows the last tool result in the transcript,
and the stop is reasoning-only, the existing empty-final retry runs
once; a second reasoning-only stop is honoured. A reasoning-only answer
to a turn that ran no tools is still honoured, so the extra thinking
round the honour rule prevents does not come back there. The judgement
reads structure only: usedAnyTool, empty content, message position.
Non-DeepSeek providers keep their existing bounded retry.
Tests: TestRunRetriesReasoningOnlyStopAfterToolRoundOnce and
TestRunAcceptsSecondReasoningOnlyStopAfterToolRound drive a scripted
DeepSeek-policy provider through Agent.Run. On the base both fail with
"provider calls = 2, want tool round + reasoning-only stop + one retry";
with the fix they pass. TestRunAcceptsReasoningOnlyFinalWhenModelStopped
(no tools) passes unchanged, as do internal/runtime/agent and its
direct dependents.
read_file told the model to reference an image as @path and, if it
cannot read images, to delegate with read_only_task. Only a reference
in the user's own message attaches a picture, so for an image the user
never attached the delegation reached a child with nothing to look at.
The refusal now says an image attached this turn can go to
read_only_task, and that any other one needs the user to send it.
In 2,217 local sessions, all six image refusals were attachments, which
the delegation does carry; the unattached case has not been seen, so no
new task parameter is added for it.
build-host.js built reasonix-studio-host on Linux, and linux-payload.js
built reasonix-studio-update-helper, with cgo at its default. On the
ubuntu-22.04 runner that links both against glibc 2.35: in the
studio-v2.20.0 .deb both name /lib64/ld-linux-x86-64.so.2 and the host
needs GLIBC_2.32/2.34, so Studio will not start its kernel on glibc 2.31
systems even though the host is already held CGO-free by studio.yml. On
Linux the only cgo in its closure is the stdlib's optional net and os/user
glue, which have pure-Go implementations.
Both builds now pass CGO_ENABLED=0, and the package job reads the ELF
program headers of the .deb's Go binaries (packaging/elf.js) and fails if
one names a program interpreter.
Test: test/unit.mjs adds a case for elf.js; on the base it fails with
"Cannot find module '../packaging/elf.js'", with the fix 36/36 pass.
`node packaging/elf.js` on the studio-v2.20.0 .deb's host and helper
exits 1 ("... is dynamically linked (interpreter
/lib64/ld-linux-x86-64.so.2)"), and exits 0 on both built with
CGO_ENABLED=0 GOOS=linux.
Refs #10372
Root cause: legacy conhost with a CJK TrueType font (the default on
Chinese Windows) sizes East Asian Ambiguous runes by the glyph, so it
draws a font-dependent subset two cells wide: · … “ ” ‘ ’ — – ● → ↑ ↓
◆ ° × and Greek/Cyrillic letters among them. The TUI and its renderer
count all of them as one cell, so every full-width row carrying k of
them is k cells too wide on screen: conhost wraps it, the alt-screen
frame grows past the window and scrolls, and later repaints land on
shifted rows, which is the stacked-frame garbage in the report.
Decision: measure instead of guess. When stdout is a console, a
never-activated screen buffer of that console (same font) measures
each non-ASCII rune the layout counts as one cell, once, via
WriteConsoleW and a cursor read-back. A rune the console draws wider is
swapped, after layout, for a one-cell stand-in both sides agree on:
Unicode's halfwidth variant when it has one (→ becomes U+FFEB), else
the OS US-ASCII best-fit (· becomes ., “ becomes "), else the OS
default character. Each candidate is itself measured before it is
used. The swap is rune for rune at equal counted width, so applying it
to the finished frame changes no row's layout; escape sequences and
OSC payloads pass through untouched. A pseudoconsole (Windows Terminal,
VS Code, SSH) measures these runes as one cell, so nothing is replaced
there; other platforms get a nil fit and are unchanged.
The same measurement also covers what the inline mode prints above the
frame (answers and the banner), since a wrapped print adds rows the
renderer does not know about. The banner is printed from a command
goroutine, so the per-rune cache is guarded by a mutex.
Test: TestFrameFitsConhostMeasuredWidths renders the real TUI View at
120 columns after a streamed answer, with the width table measured on
a Chinese Windows 11 conhost, and asserts no row draws wider than the
terminal. With the substitution disabled it fails:
row 3 draws 121 cells on conhost, terminal has 120
With the fix it passes (also under -race). In a real conhost window
the unfixed binary shows the status row twice with "au" / "to" split
across rows; the fixed binary keeps the 30-row frame intact.
Refs #10540
The rendered config template writes bash = "enforce" even when nobody
chose it, so on a Linux host without bubblewrap the settings read back
as enforce and every later save was refused as sandbox.unavailable,
including one that only added a writable directory. Only switching the
jail on is refused now; a file already holding enforce saves its other
fields, and bash stays refused at run time as before.
Reproduced on a bwrap-less Linux: the new test fails there without this
change and passes with it. It skips where a sandbox exists and on
Windows, which forces bash off.
Root cause: the transcript was written only on the 30s autosave tick or
at turn end. A process killed between admitting the user message and the
next save reopened with the in-flight marker but without the prompt, so
the turn vanished from both the history and the model's context.
Decision: the same bounded change as the 1.x line's option A (1). The
agent gains an AdmissionSaver seam called from LandAuthoredUserMessage,
the one place a turn's user message lands, and the controller saves the
transcript there: one extra save per turn, skipped without a session
path, and a failed save only logs. The 1.x partial-output checkpoint
does not apply here: studio has no event-log session store, and the
cache-stable prompt prefix is untouched.
Test: TestKilledTurnKeepsItsPrompt (internal/session/control) runs one
turn, starts a second, streams part of a reply and ends the child
process with os.Exit (no TurnDone, no final save), then reloads the
session and runs recovery. On origin/studio it fails with "prompt of the
killed turn lost after restart"; with the fix it passes.
The test saves the sandbox settings twice. On a Linux runner without
bubblewrap the second save asks for a sandbox the host cannot run and
is refused, which has nothing to do with the switch under test.
When native mouse is on (REASONIX_DISABLE_MOUSE, or the /mouse toggle), the
terminal owns the mouse: the in-app scrollbar can no longer be dragged, so the
transcript drops its right-hand column and reclaims that width for content, and
fenced code drops its "│" gutter for clean copy. Capture-on is unchanged.
When --continue finds no saved session, it reports the miss and starts a fresh
session instead of exiting 1. Both the run path and the TUI resume path fall
through, a --continue miss is no longer reported as "continue" in telemetry,
and the flag help and docs say so.
The ask tool blocks the run on the user exactly like an approval prompt,
but only approval prompts fired the Notification hook, so an external
channel wired to it heard about every pending approval while a blocking
question looked like the agent still working. In YOLO mode ask is the
only prompt that waits on the user, and it was the one that stayed silent.
Fire the hook once the AskRequest is emitted, mirroring the approval call
site: message "answer needed: <first prompt>" (en/zh/zh-TW),
notificationType "question_prompt".
Rebuild of #9048 on studio.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Looked at on Windows Terminal against 1.x with the same scripted turn:
- A streaming answer is printed a block at a time, and each block lost the
blank line before it, so headings, lists, code and tables ran together.
A later stretch now starts with that line.
- Tabs were drawn zero cells wide in the full-screen view, so code lost its
indentation. Rows expand them before wrapping, as 1.x does.
- A list resumed after a code block restarted at 1; it counts from the
number it was written with.
- oneLine cut by runes, so a line with CJK text ran past the row and wrapped
to column 0. It cuts by cells.
- The approval panel and the end-of-turn mark drew ⏸ and ⚠, which Windows
Terminal renders as two-cell emoji over one-cell slots. The panel is headed
by its title in the accent colour, and its key hint is translated.
- The end-of-turn card is 1.x's: what went unproven, in the reader's
language, instead of "incomplete · 2 gap(s)".
- The footer's compaction figure reads "距压缩 81%" (headroom left), not
"压缩 81%".
write_file replaces the whole file, so a model writing from what it read
earlier silently discards anything changed since: an edit the user made
in their editor while the turn ran, a formatter, another process. The
run now keeps what the agent last saw of each file, through read_file or
its own writes, and write_file refuses when the file holds something
else, telling the model to read it again. edit_file and multi_edit match
their old text against the current file and were already safe.
In 2,217 local sessions, the case never shows in the logs, because an
outside edit is not recorded there, so this is a safety net rather than
a measured gain. It costs nothing until a file has actually changed,
and is on by default. [tools].protect_changed_files and a switch in the
Studio sandbox settings turn it off.
An explicit --model whose provider has no key failed with
`provider "deepseek-flash" requires DEEPSEEK_API_KEY`. Provider keys
resolve only from the loaded config's credentials file
(storedCredentialValue on Roots.UserCredentialsPath), so the message
sent users to export the variable in their shell or system environment,
which Reasonix never reads. It also labelled the model ref as a
provider. Reported through `reasonix acp` from the VS Code extension in
#10458.
The error now names the model, the variable, the credentials file of
the config it was resolved against, and `reasonix setup`, and says
shell environment variables are not read.
Test: TestResolveModelForCLIMissingKeyNamesCredentialFile sets the key
only in the process environment. On the base it fails with
error "provider \"deepseek-flash\" requires REASONIX_CLI_TEST_SHELL_ONLY_KEY"
does not mention ".../.reasonix/.env"
and passes with the fix; go test ./internal/frontend/cli/ passes.
GitHub acts on "Fixes #N" only for pull requests merged into the default
branch, which is studio, so fixes merged into main-v2 left their issues
open. On a merge into main-v2 this reads the description's closing
keywords, notes the fix on each open issue and closes it.
Adds [skills].suppress_warnings (default false), which quiets the non-fatal skill warnings printed during startup discovery. doctor diagnostics are unchanged. Implements #6483.
The terminal UI ignored REASONIX_DISABLE_MOUSE, which the guide still
documents. It now reads it as 1.x does: 1 starts with the mouse handed
to the terminal, 0 captures it, and unset leaves capture off over SSH,
where the native selection reaches the user's clipboard.
Co-authored-by: BuGlessRB <[email protected]>
A tag is a mutable pointer: whoever controls the action's repository can
move it to new code and every workflow here runs it on the next event,
with this repository's token and on its self-hosted runners. A commit SHA
cannot be moved.
Each reference keeps its tag as a trailing comment, the form Dependabot's
github-actions ecosystem reads and updates. This lets the repository-level
"require full-length SHA" policy be enabled without failing studio runs.
With no recovery_model or guardian_model configured, the Goal evaluator
runs on the main model. A DeepSeek thinking model can stream its whole
answer as reasoning_content and finish with stop and an empty content
block. boundedllm.Call collected only ChunkText, so the evaluator got ""
and every goal paused with "empty goal evaluator response".
boundedllm.Config gains ReasoningAnswer. When a caller opts in and the
provider reports finish_reason=stop with no content, Call returns the
reasoning stream instead. A truncated stream still yields nothing, and
reasoning past MaxOutputBytes is refused only when it would become the
answer, so reasoning before real content is never charged. The goal
evaluator opts in because parseVerdict extracts the JSON object and fails
closed on anything else; free-text callers (advisor, prompt refine) keep
the content-only behaviour.
Test: TestEvaluateReadsVerdictFromReasoningOnlyStop fails on the base
with "empty goal evaluator response" and passes with the fix;
TestEvaluateIgnoresReasoningOfTruncatedStream and TestCallReasoningAnswer
cover the stop guard, opt-in and output budget.
Refs #9678
resolveSessionQuery reported a query that matched several sessions as a
plain sentence, so neither caller could recover which sessions matched:
`reasonix run --resume <q>` exited 1 with only a match count, and the
terminal UI refused to start the same way.
The ambiguity is now a typed ambiguousSessionQueryError carrying the
matched sessions. The headless start still exits 1 but lists each
candidate (last activity, the id --resume accepts, its title) followed by
a new AmbiguousResumeHint. The terminal UI, which already requires a TTY,
opens its session picker limited to those matches (tui.Options.PickAmong).
The narrowing belongs to that first picker alone: it is dropped whether
the first session fetch succeeds or fails, so a later /resume lists
everything.
The picker resumes the chosen session in place, so an ambiguous query
with --copy is refused up front with the same candidate listing rather
than opening a picker that would silently ignore --copy.
Tests: TestRunAmbiguousResumeListsCandidates drives runAgent with an
ambiguous --resume and fails on the base with only
`session query "session" is ambiguous (2 matches)` on stderr;
TestTUIAmbiguousResumeWithCopyIsRefused fails when the picker path is
taken with --copy set; TestPickerNarrowingEndsWithAFailedFirstFetch fails
when the narrowing is cleared only after a successful fetch;
TestResolveSessionQueryAmbiguityCarriesMatches and
TestPickerStartsOnTheGivenSessions cover the typed error and the
narrowed picker.
recall() kept no draft slot: up on a one-line composer overwrote the
text with the previous prompt, and down past the newest entry called
composer.Reset(). With any history present, a single down on a draft
that had never left for the history wiped it outright.
The draft is now set aside when a walk begins and restored when the
walk returns past the newest entry; down at the live position is left
to the textarea instead of resetting it.
Tests in internal/frontend/tui/recall_test.go: TestRecallReturnsTheDraftItLeft
and TestDownOnADraftKeepsIt fail on the base (the composer ends empty)
and pass with the fix.
Refs #10425
A CLI process that died from a panic on a background goroutine or a
fatal runtime error (out of memory, concurrent map writes) left no
evidence. runWithCrashCapture only recovers panics on the main
goroutine and nothing called debug.SetCrashOutput, so that output went
only to the console's stderr, which is gone once the terminal closes.
The CLI now mirrors runtime crash output into a per-PID file under the
Reasonix home. The next start turns any dump left by a dead process
into a pending crash report that keeps the runtime classification and
the sanitized goroutine stacks and redacts the panic value, and removes
the empty files of processes that exited without one. A panic already
reported through recover detaches the file first so it is not queued
twice.
Test: TestGoroutinePanicIsReportedOnNextStart re-runs the test binary
as a child that panics on a background goroutine, then asserts the next
start lists exactly one report with the panicking goroutine's stack and
no panic value. On the base it lists none.
Dispatch-only, so nothing a pull request writes runs on that machine.
The version is a prerelease one patch above the newest release, so the
build neither offers itself that release nor hides the next one.
A comment on a pull request needs pull-requests: write. A refusal that
is not a rate limit is final, so it fails the entry at once instead of
being retried for ten minutes.
A 1.x command line keeps working under 2.x: workspace-write is Auto,
danger-full-access is Yolo, and read-only, which 2.x has no mode for,
falls back to Ask. MIGRATING says where yolo's meaning differs.
Co-authored-by: BuGlessRB <[email protected]>
In 1.x a bare reasonix, or one given only session options, was the chat;
in 2.x it printed usage, so -c, -r and every muscle-memory command line
stopped at a help screen. In a terminal it now starts the terminal UI;
without one it still prints usage, since no one is there to chat with.
The terminal UI takes the rest of 1.x's session options: --effort,
--permission-mode (plan starts in plan mode, acceptEdits allows the write
tools), --yolo and --dangerously-skip-permissions, --add-dir,
--allowed-tools, --copy, --max-steps and the retired --profile. --yolo and
--dangerously-skip-permissions were listed as session options but nothing
read them. The usage text names the interactive form again.
The migration guide says 1.x 1.38.8 and later conversations now open in
2.x, where they are imported, how an import follows 1.x, and that the
machine session list shows them once imported. The roadmap marks W1b done,
which meets gate G2, and points W2 at its pull request.
1.x 1.38.8 and later keep every conversation in <project>/sessions-v4, a
directory per session holding a manifest and an events.frames log of
checksummed batches of zstd-compressed JSON, with large payloads and images
in a content pool beside it. 2.x did not list them, so a person moving from
npm 1.x found their recent history gone.
internal/state/sessionv4 reads that store and nothing else: it validates
each batch's framing and checksum, treats a batch without its end record as
a write in progress, resolves pool references by digest, and folds the
message events into the conversation 1.x shows, with origin "host" read as
HostAuthored and pooled images carried as data URLs. It takes no lock and
writes no byte under the root.
Session lists now include each v4 conversation that has no import yet, at
sessions/v4-<id>.jsonl, using 1.x's own listing cache when it still matches
the log. Opening that path imports the conversation there first, recording
its source; opening it again after 1.x continued it refreshes the copy only
while 2.x has not added to it. The name carries a prefix because 1.x takes
sessions-v4/<name> as the mirror of sessions/<name>.jsonl. Verified end to
end against stores written by 1.x 1.38.10 and main-v2 HEAD, on macOS and
Windows.
A reply batch needs one small job, and on GitHub-hosted runners it queued
behind every branch's CI matrix. The win-gpu runner takes only jobs that
name its label, so replies start at once and CI keeps its runners.
A batch of triage replies from a maintainer's own account runs into
GitHub's content-creation limit, which answers "submitted too quickly"
and stays closed for a while. This manual workflow takes a JSON list of
replies, posts them as github-actions[bot] under the repository's quota
at a steady pace, retries a refused comment, skips a reply already there
and closes the issues marked for it.
The migration guide said 1.x writes schema 2 from 1.38.2 on. It does so
only through 1.38.7; 1.38.8 and later keep conversations in sessions-v4,
which 2.x does not list. The guide now says where each 1.x version keeps a
conversation and which of them 2.x opens, drops the REASONIX_SESSION_LOG
step that 2.x reading schema 2 made unnecessary, and states that 2.x never
writes a 1.x session's files.
The roadmap records the direction the version announcement left open: the
gates that end 2.x pre-release in the same release that moves npm and
Homebrew, the planned weekly release train and update channels, the 1.x
feature and format freeze with maintenance ending 3 months after general
availability or at a backstop date, and splits the session work into the
done schema 2 half and the open sessions-v4 half.
1.x 1.38.2 through 1.38.7 save sessions in a schema 2 event log, an
append-only DAG of messages under named heads, and 2.x refused every one of
them with "uses schema 2; this build supports up to 1". 2.x now replays
that log read-only and materializes the head 1.x would open: parent chain,
patches, redactions and system override applied, and 1.x's origin "host"
read as HostAuthored so injected context stays out of the transcript and
the preview.
2.x never writes the log or anything beside it. A save that finds the
transcript unchanged stops with ErrSessionLogUnchanged, so opening a 1.x
conversation touches no file. Before a turn starts, and at any save that
has more than 1.x holds, the controller moves the conversation to a new
2.x session with the same title and says so; 1.x keeps the original byte
for byte. Verified against sessions written by a real 1.38.7 build.
Host-authored messages no longer count as a turn or become the preview,
and the event-log probe moves to its own file.
docs/MIGRATING.md was the 0.x to 1.0 guide and told a reader on the 2.x
default branch that main-v2 was the active line. It now covers moving from
1.x to 2.x: what the two lines share under one Reasonix home, which sessions
open where now that 1.x 1.38.2 writes a schema 2 log 2.x cannot read, the
REASONIX_SESSION_LOG=v1 switch that keeps a machine running both usable,
the commands that moved, and the steps to run 2.x beside 1.x.
docs/ROADMAP.md is the roadmap the version announcement (#10748) promised:
how 2.x ships, the terminal takeover that gates the release that moves npm
and Homebrew to 2.x, the remaining workstreams, and the decisions still
open. Both are owned by @esengine, and README links to them. The guide's
old prose debt leaves the repolint baseline.
1.x and 2.x share one session directory, and 1.x writes <id>.turns.jsonl
beside each transcript. 2.x classified it by its .jsonl ending and listed
it as a conversation of its own, so a machine that ran both showed a ghost
session for every 1.x conversation.
An Anthropic `error` event (overloaded_error and the like) that arrives
before anything was emitted left the provider as a plain
StreamPayloadError, so the Agent never replayed the attempt, while the
OpenAI provider already promotes the same pre-output payload error to
StreamInterrupt(upstream_error) (#10719). Apply that rule here: the read
loop's sink records whether a chunk reached the consumer, and only an
error with nothing emitted becomes an interruption. After output a
replay would show it twice, so it stays a payload error; the payload
identity survives the wrap for errors.As.
sendChunk moves into the sink so anthropic.go does not grow past its
size budget.
A disk catalog runs in WAL mode with no journal_size_limit, so after a
burst of writes the -wal file keeps its high-water size for as long as
the database is open (#10714). The limit is a per-connection pragma and
the pool opens up to MaxOpenConns connections, so it rides the DSN the
way busy_timeout and foreign_keys do rather than a single Exec that
would reach only one of them. 4 MiB leaves ordinary checkpoints alone.
Relays that re-serialize tool schemas emit `required: null` for an
object schema that omits "required", and the upstream validator rejects
the whole request with "null is not of type 'array'" (#10614).
context_budget is one such schema on this branch, and any MCP or plugin
tool may be another, so the fix sits in canonicalization rather than in
each tool: a root object schema gets an empty array when it has none,
including after an invalid non-array value is dropped.
This changes the tool block of the cache-stable prefix once (golden
ToolsHash/PrefixHash regenerated); the budgets rise by the bytes the
array costs: provider-visible 3700 -> 3708 tokens, built-in 6767 -> 6787.
Ctrl+V (Alt+V on Windows, whose terminals keep Ctrl+V for their own text
paste) reads the clipboard of the machine the terminal runs on. An image
goes to the kernel through /attachments and stands in the composer as
[image #N], sent as the @-reference it was stored under; without one the
clipboard's text comes in the way a terminal paste would, which is also
how a console without bracketed paste gets a paste folded.
internal/platform/clipimage reads the image through what each platform
ships: osascript, Windows PowerShell, wl-paste or xclip. A tool that cannot
say what the clipboard holds counts as offering no image rather than being
told apart by the words of its error, since either way the paste falls back
to text.
The inline renderer places a print by scrolling it in above a frame it
takes to sit at the bottom of the screen, and places each print from where
its last redraw left the frame. A restored conversation arrived as dozens
of prints with no redraw between them, on a screen the frame did not yet
fill, and landed interleaved with lines lost. A commit now goes out as one
print, split into pieces that fit above the frame with a redraw between
them, and a replay first fills the screen so the frame starts at the bottom.
The cancelled-turn, compaction, shell-busy, stream-reload and declined-
question lines now come from the i18n catalog.
Full screen, a shell call's output shows its ten-line preview with a
"more lines (Ctrl+B)" row, as in 1.x: Ctrl+B opens the newest one up to
200 lines and shuts it again, and a click on that row does the same. A
settled block keeps the row it draws, so opening it redraws only that
block. A drag held against the viewport's top or bottom row keeps
scrolling the selection until it leaves the edge or the transcript ends.
A bare -r opens the saved-session picker on the first frame, and /resume
opens it mid-chat, as in 1.x: typing filters, the open session is marked
and skipped by default, and choosing one binds the runtime to it through
/resume and reads it back. /resume and /mouse are offered in the slash
menu beside the kernel's commands. /sessions dates each entry, and moves
to its own file.
The TUI's event sink now carries the configured system notifications, as
the 1.x chat screen's did.
The transcript is drawn through its own viewport, as 1.x drew it: a
scrollbar in the last column, the wheel, PgUp/PgDn and Ctrl+Home/End,
dragging the thumb, and drag-to-select that copies on release. Settled rows
keep how to draw them, so a resize rewraps them. /mouse hands the mouse
back to the terminal for its own selection, and --inline writes the
conversation into the terminal's scrollback instead.
Users coming from 1.x meet the screen they know: the banner and tip, an
accent-framed composer with the ❯ prompt, the mode tag on its colored
ground, the footer's model/effort group and its cache, context, fold,
balance and cost row, a "本轮" usage line under each request, the ▎
thought marker, the ⎿ tool summaries, the To-dos panel, and approvals and
questions as selectable panels in place of the composer. Labels come from
the shared i18n catalog, so the screen speaks the configured language.
Shift+Tab steps ask, auto and plan; Ctrl+Y toggles YOLO.
The terminal UI reads as a product rather than a log: a welcome card names
the model and workspace, the composer sits between rules with a placeholder,
a running turn shows what it is doing and for how long, the status bar
carries a mode badge and a context gauge, approvals and questions are boxed
with their keys standing out, and the user's own lines are a band.
The theme is now read from config before the program starts; it was left at
the built-in default.
A message frame that arrives after the call which closed its answer now
settles that answer instead of appending a second copy, which printed every
answer before a call twice.
The page listed every model as a card to click for the primary model,
then drew the per-task roles as a second set of dropdown cards below it,
so choosing what runs where meant reading two layouts that said the same
kind of thing. The 1.x desktop app put this in one table, and #10761 asks
for its layout back.
Each use is now a row: what it is for, a select of the models that can do
it grouped by service, and the service that model goes through. The
default model's row also lists what that model declares: image input,
adjustable reasoning, context size. Roles that follow the default say so, and
decision says it has no source rather than offering chat models. The two
settings rows it replaces become one, 按用途选择模型; reasoning effort
and context upkeep stay below it.
The card list and the role picker are gone with their styles. The
services page now works out its default selection while rendering, so
it no longer flashes an empty detail before the selection lands.
Settings kept every model setting on one long page: the source list with
an editor that unfolded inside it, the primary-model list below that, and
roles and context after. Adding or changing a service meant finding the
right row, opening it, and scrolling past the rest. The 1.x desktop app
split this into 模型服务 and 模型偏好 with a list-and-detail layout, and
users asked for that back (#10761).
模型服务 is now its own section. Services are listed on the left, with a
search once there are six or more and 添加模型服务 at the foot; the
selected service's fields are open on the right without a second click,
and adding a service takes the right side while the list stays in
place. The page opens on the service in use, or on the one the composer
sent the user to declare effort levels for. At narrow widths the list
stacks above the detail.
The remaining section is renamed 模型偏好 and keeps the primary model,
reasoning effort, per-task models and context upkeep. The composer's
manage-models entry, its effort-declare link and the palette entry now
open 模型服务.
The composer's permission menu and Settings each kept their own table of
the four approval modes, and they drifted: the menu called ask 逐项确认
and auto 自动继续, Settings 询问 and 自动, the shelf 自动批准, and the two
lists ran in different orders. Users read the menu's 逐项确认 as a mode
Settings did not have.
approvals.ts is now the one table. Menu, Settings and shelf all say
不询问 / 询问 / 自动批准 / 全部放行, ordered from strictest to most
permissive, with the same descriptions.
In a narrow conversation pane the menu opened rightward from its trigger
and ran off the screen. There it now spans the toolbar row instead.
- Questions: an open ask is answered one question at a time. A digit
answers a single choice or toggles a multi choice, typed text joins the
picks as an answer no option offered, Esc declines with nothing
selected. The card left in the scrollback says what was answered.
- Completion: `/` at the start of a line and an `@` token open the
kernel's menu as they are typed, Tab asks anywhere. Offsets cross
/complete in UTF-16 both ways, so a line with CJK before the token
splices where the kernel meant; a directory keeps the menu open a level
down.
- The task list is re-read from /todos when the kernel says it moved and
drawn while it has work left; a finished list goes away.
Found running against a real model: a thought-only step and whitespace-only
answers printed an empty speaker header; option and menu rows lost their
indent to the row-shortening helper; the receipt read the verdict "done"
as unfinished.
A `!` command ran and was shown, and the model never saw it: the next turn
had no idea what the user had just looked at. RunShell now follows the
command with a turn whose input is the command, its exit code, any launch
error and its output, so the model responds to it the way it would to a
line typed to it.
The output is bounded at 24 KiB, cut from the middle with the cut said, so
a large listing neither fills the context nor reaches the model looking
complete. A command the user stopped is not followed by a turn, and neither
is one run where no model is attached. The ACP `!` prompt now ends after
that answer.
Expanding a folder in the explorer walked the whole workspace and kept
the entries whose parent matched, so one folder the process could not
read anywhere in the tree failed every expansion with a 500, and a large
tree made every click slow; the root listing, which skips into nothing,
kept working, which is why the list drew and its folders never opened.
The explorer drew that error in the file view's slot, which the docked
file list hides, so the click looked like it did nothing.
A folder listing now reads that folder alone, and its real path is held
inside the workspace so a link cannot list what is outside it. A search
still walks the tree and passes over a folder it cannot read. A missing
folder answers 404 workspace.file_missing, a linked-out one 400.
The explorer now shows its own failures in the file list. Reloading the
tree, which happens when the changed files change after a turn, reads
the folders that were open again, where it used to fold them all shut.
Checked on Windows against a workspace with a folder denied by ACL: the
folders beside it open, the denied one reports in the list, and an open
folder stays open through a reload.
The second layer of the terminal UI: the program a person operates, on
top of the client, stream and transcript.
- `reasonix tui [--model] [--preset] [--dir] [-c] [-r QUERY] [prompt]`
assembles the kernel the way `reasonix serve` does, adopts it into a hub
and drives it through Hub.InProcessClient: no port is opened. The kernel's
logging goes to ~/.reasonix/logs/tui.log while the UI owns the screen.
- Rendering keeps the terminal's scrollback: every settled row goes above
the program in the order it happened, and a streaming answer's finished
Markdown blocks go as soon as they are final (nothing inside an open
fence). What is still changing stays in a live region capped at half the
screen, each row cut short of the terminal width so none wraps into a row
the renderer does not know it drew.
- Input: Enter sends, or queues while a turn runs; Ctrl+S steers; Ctrl+J
(or Shift+Enter where the terminal reports it) is a newline; Esc
interrupts; `!` on an empty line is shell mode; Shift+Tab cycles the
approval mode; Ctrl+C twice or Ctrl+D on an empty line quits. Large pastes
fold into a token and go to the model whole. Approvals take only the
answers the host says it honours; plan approvals map to start, revise or
leave.
- The transcript seats a turn on the message it began on, the way Studio
does: a queued follow-up moves to where its turn started, steered input
waits for its steer event, and another client's turn is drawn once from
the kernel's text. Restore rebuilds from /history for -c/-r and after a
stream gap.
cli.go's argv normalisation moves into normalizeCommand so the dispatch
switch keeps one case per command within its complexity budget, and `tui`
joins the shell-completion registry.
The theme, colour profile, Markdown/math renderer, tool cards, diff view,
assistant block and clipboard writes move out of internal/frontend/cli into
internal/frontend/termrender, so a terminal frontend other than cli can draw
the same bytes without importing cli. cli now calls termrender for every
style, width, box and Markdown helper; view_helpers.go stays in cli.
termrender exports the surface a frontend draws with: Bold/Dim/Green/Red/
Yellow/Accent/Reverse, VisibleWidth/PadRight/Boxed, ConfigureTheme,
ConfigureThemeFromConfig(ForTTYOutput), ActiveTheme/ThemeName and the
ThemeStyle/ThemeFg/ThemeLipColor/ApplyTextareaTheme helpers over an exported
Palette/Color, RenderMarkdown and MarkdownRenderer, AssistantBlock, ToolCard/
ToolDot/ToolHead/ToolArg/ToolDisplayName, DiffBlock/DiffStat, CopyToClipboard
with ClipboardCopyMsg, ANSIConsoleReady and SetColorProfile.
Transcript helpers nothing calls (edgeScrollDir, selSpan, scrollbarThumb)
are dropped with their tests, and the clipboard message loses the seq and
status-hint fields no caller set. The metadata-command test now asserts that
version/help never reach the TTY theme setup, through a cli-side seam, since
the terminal probe it used to swap is termrender's.
The repolint baseline diff is the moved files' prior essay debt carried to
their new paths (mathnode.go, md.go, width_test.go; transcript.go's is paid
off), plus orphan budget for the eight exports whose consumer, the new terminal
UI, is not in the tree yet (ToolCard, DiffBlock, AssistantBlock,
RenderMarkdown, CopyToClipboard, ApplyTextareaTheme, ThemeName) or which only
a cli test drives (SetColorProfile). Wiring the UI reclaims that budget.
normalizeNullRedirects treated a backtick as an escape inside any quote,
so in echo 'a `' >nul the backtick swallowed the closing single quote,
the string never ended, and the real >nul after it was left alone; under
bash that writes a file named nul. Single-quoted text is literal in both
bash and PowerShell, and the backtick is PowerShell's escape only inside
double quotes, which is where the backslash case already applied.
The new cases also cover the escaped-quote branches, which had no test:
the sandbox package fell to 77.6% against its 78.1% coverage floor on
the lint job, three statements short.
fc3a44dbc moved jobs from internal/runtime to internal/tools, and the
pull-request race step still listed ./internal/runtime/jobs/..., so every
pull request's race job failed with setup failed on a directory that no
longer exists. Pushes run the full ./... sweep and never saw it. Every
other path the workflows and the Makefile name still exists.
* fix(pricing): bill Chinese public holidays as DeepSeek off-peak
Port of the main-v2 holiday fix (#10756) to the studio pricing package,
which moved from internal/billing to internal/contract/pricing and now
carries the peak schedule in PeakWindow. The holiday table rides the
same struct field pattern as WeekendOffPeak: PeakWindow.HolidayOffPeak
lists Beijing-local dates off the State Council 2026 notice, and
IsPeak flattens them like weekends. Make-up workdays (调休) stay
off-peak: they are calendar weekends, which is how DeepSeek rules them.
* chore(pricing): keep the holiday comments within the repo's comment rules
English only and within the essay budget; no behaviour change.
---------
Co-authored-by: nanami-0713 <[email protected]>
Co-authored-by: yhh <[email protected]>
The first layer of the new terminal UI, which drives a session through the
same HTTP/SSE contract Studio uses and imports nothing from the controller:
- Client: typed calls for the routes a chat needs; a refusal carries the
kernel's code (APIError), never only its sentence.
- Subscribe: /events with Studio's ordering — numbered frames delivered once
and in order, a jump filled from /events/replay before the frame that
revealed it, what the replay no longer holds reported as a gap, a
backwards count read as a restarted stream, a reconnect resumed with
Last-Event-ID.
- Transcript: the fold of Studio's session reducer — the settled message
repairs the streamed answer, one card per call with sub-agent calls under
their task, prompts settled here or by the kernel's receipt, pending input
moved to where the turn read it, and turn_done saying which of four ways
the turn ended.
An end-to-end test drives a real kernel behind an in-process hub through a
turn that streams, waits on an approval answered through the client, runs a
command and ends settled.
The editor is a frontend in the kernel's process, like the terminal UI
the in-process serve client exists for. A prompt that starts with `!` now
runs through RunShell instead of going to the model: it streams back as a
tool call and ends the prompt when the command finishes; cancelling the
prompt cancels the command.
Hub.InProcessClient reaches the hub's routes with no listener: a frontend
in the kernel's own process opens no port, so there is nothing on the
machine or the network to authenticate against. Responses stream through a
pipe as the handler flushes, so /events works through it.
A `!` line on /submit is the user's own shell command, which runs
unconfined. It stays refused off a socket (shell.unavailable_over_http);
through the in-process transport it runs. The transport marks the request
context itself, and no request read from a socket passes through it, so the
grant rests on which way the request came in rather than on anything it
carries.
Every ended job drew the same green pip and printed the kernel's raw status
word. Only done is green; failed is red, killed and interrupted are neutral,
and each is labelled in the reader's language.
The background-jobs deck listed jobs with no way to stop one: the controller
had CancelJob, but no port carried it and serve had no route. BackgroundJobs
joins SessionAPI, POST /jobs/{id}/cancel drives it and refuses a job that is no
longer running as job.not_running, and each running row gets a stop button.
On touch the deck could not be closed: it opened on :hover and :focus-within,
which a tap leaves set, so the chip's own toggle had no effect. Hover opening
is now limited to hover-capable pointers, and a press outside or Escape closes
it; the context card gets the same treatment.
The meter rail did not fit a phone: the chip ran past the edge, and tapping it
scrolled the overflow-hidden .app sideways, dragging the composer with it.
Narrow panes drop the sparkline, wallet, speed and (narrowest) cost readings,
the two popovers position against the rail instead of their chips, and .app
clips rather than hides so focus can no longer scroll it.
perf/composer.mjs counts only visible peers as overlapping the model button.
bypassed() matched only exact entries and a leading "*". The bypass list
Clash, v2rayN and xray write by default is `<local>;localhost;127.*;10.*;
...;192.168.*`, so every trailing-wildcard entry missed. It also lacked
WinINET's implicit rule that loopback goes direct unless `<-loopback>` is
listed.
In the default auto mode, on Windows with such a system proxy, requests to
local and LAN model endpoints went through the proxy: Ollama or LM Studio
on 127.0.0.1, vLLM on 192.168.x. That added a hop, failed outright when the
proxy routed to a remote exit, and made a cancelled stream keep the local
model generating. Cancel closed only the client-to-proxy hop, and the proxy
held its upstream connection open.
"*" is now a wildcard anywhere in an entry. Only "*" is special, and
brackets are trimmed from IPv6 entries. localhost, 127/8 and ::1 bypass
unless `<-loopback>` is present. TestBypassed gains the trailing-wildcard,
near-miss, mid-pattern and loopback cases; it fails on 8 of them without
the fix. The same change lands on main-v2.
The CLI had no install channel on the 2.x line: release-studio.yml builds the
CLI archives for Studio's remote installs, and npm's `reasonix` and the
Homebrew tap were published only by the retired 1.x release machinery. The
2.x line now takes both over, and 1.x no longer publishes the CLI.
A cli-channels job runs after the release is published:
- npm: the existing npm/build.mjs builds the per-platform packages and the
launcher and publishes them through publish.mjs's staged dist-tag flow. A
2.x version outranks every 1.x one, so `latest` moves to it; a candidate
goes to `next`.
- Homebrew: scripts/render-brew-cask.sh writes the cask from the digests in
the release's own SHA256SUMS — a missing digest fails the render rather
than shipping an empty one — and the job pushes it to
esengine/homebrew-reasonix. Candidates leave the tap alone, since brew has
no prerelease channel.
The Studio release carries the CLI archive too, listed first. Double-clicked
from Explorer, bare argv printed the usage and exited, and the console window
Windows opened for it closed with it, which read as a crash. When this
process is the only one attached to its console, it now names Studio's
installer and waits for Enter; from a shell nothing changes.
The usage examples dropped bare `reasonix`, `--continue` and `--resume`, which
only print the usage on this line.
LAYOUT.md: three list items split to stay under the prose width.
Every workspace holding a warm runtime drew a raised box, so four rows read as
four selections and people asked how to close them; the kernel already retires
idle panes at capacity. Only the workspace of the active session keeps its
emphasis, and a session held by a pane keeps its pip.
The project row's × removed the project from the list but sat next to + where
it read as "close". It is now a ··· menu (workspace.menu) whose one item leads
to the existing confirm.
The coverage job installs no bubblewrap, so an enforced bash sandbox refused
the background job and the test read no job at all. It measures how a job's
end is attributed, not where it runs.
Product:
- Tool cards wrap their head in details.tool-disclosure, so the hit highlight
and the failed-call peek never matched them; both selectors now reach it.
- Light --faint fell below AA on every raised ground and overrode the tiered
tokens; darkened by lightness only. Segments and the new-task shortcut sit on
--overlay and read --muted. Hard-coded crumb separator, delta and failed
state colours now use tokens; the approval tool tag uses --warn-ink, the
running step reads a darker --net, and the failure pill mixes its ink
towards --text so it clears AA in both schemes.
- The local workspace row's remove control is the same Cross icon the remote
rows already draw, not a text glyph.
Guards: retargeted to the current markup (model picker trigger, tool
disclosure head, context card MCP rows, wallpaper section, queue and plan
controls), dropped checks for the removed task board and focus mode, and the
wallpaper reach is measured with the dim overlay cleared. All 22 exit 0 and
selfcheck shows each can still go red.
Run — the synchronous turn `reasonix run` drives — never took the turn gate
the guarded paths check. A turn admitted through the gate, such as the
inbox delivering a background job's end, could therefore start on the same
executor while Run was mid-turn, and the two wrote the task state together:
the race TestEffectRestartNamesWhyABackgroundJobEnded tripped on CI.
Run now admits through runSynchronousTurn, as RunTurn does, and waits out a
turn already in flight rather than failing: after a resume the host may
dispatch a job's interruption before the caller's first Run. Its turn
completion is recorded once, by the admission, instead of twice. Cancel()
now reaches a headless turn as it does any other.
The restart test's scripted model started a new job on any turn it did not
recognise, which the interruption delivery now visibly is; it only
acknowledges such turns.
Run against studio's own stylesheet, the browser guards reached parts of the
window they had never measured.
- A collapsed column is inert, not only narrow. The rail was inert only in
focus mode and the workbench never, so Tab walked into controls nobody
could see and scrolled a column of zero width to show them. Both are inert
while collapsed, and the workbench stops painting once its collapse ends.
- Focus mode is gone. Its only way in was removed with the side column in
the studio redesign; the state, the Escape branch, the action id and the
layout notes that described it are removed with it.
- The queue's withdraw control lost its enlarged hit area when it moved from
the transcript into the queue panel: 19px tall. It gets the outward
::after back, 29px.
- Switching to the run analysis gave the transcript and the docked workbench
the same view-transition name, so the browser aborted the transition and
the rejection went uncaught. The docked workbench no longer takes the
name; it keeps it when it is the tab being switched to. A transition the
browser skips is not an error.
- The studio composer's own chrome is taller than the one the queue's
shared ceiling was set against, so 64 queued lines left the transcript at
36-39% of the pane. Studio lowers the ceiling (28vh, 19vh when short) and
the transcript stays above 40%.
The egress grant test sat between TestEverySubjectSensitiveToolActuallyAsksSomething
and its comment, which read as one six-line block. Windows has no /tmp and no
sandbox that reaches the proxy on a socket.
On Linux the list only warned: bubblewrap either kept the command's network
namespace (no network at all) or dropped it (all of it), and a separate
namespace cannot reach a proxy on the host's loopback.
The command now keeps its namespace, which has no route out. The egress
proxy also listens on a Unix socket under the user's cache, mounted
read-only into the sandbox, and the host binary runs inside the namespace
as the egress bridge: it listens on the namespace's 127.0.0.1:<proxy port>,
carries each connection to the socket, runs the command, and returns its
exit status as a shell would. HTTP_PROXY and friends need no change. The
bridge is an entry of whatever binary imports the sandbox package, chosen
by an environment variable it clears before the command starts, so no
helper has to be shipped or found.
The host's own loopback is out of reach from the namespace, so a local
proxy cannot be used to go around the list. A route with no socket keeps
the namespace shut rather than falling back to open.
The bridge's forwarding and exit status are tested on any Unix; the
namespace boundary and the end-to-end refusal and approval run where
bubblewrap is usable (Linux CI).
A host the egress list does not name was refused outright. Where the
frontend can ask, the proxy now holds the connection and asks through a
network_egress approval whose subject is the host: allow once (for that
command), allow for the session (that host only), or decline. A declined
host reaches the model as declined-by-user rather than blocked-by-allowlist.
- network_egress is declared subject-sensitive in permission, so a person
answers whatever the approval mode, an unattended session refuses, and a
session grant names the host it covers. Before that declaration a grant
for one host answered for every host after it.
- The controller's sandbox approver also answers ApproveEgress; bash finds
it on the tool context the escape approver already rides, so nothing
below the controller grew a new channel. Headless runs have none and keep
refusing.
- Each command asks once per host, one question at a time, and a token's
asker ends with the command. Denied hosts are never asked.
The guard asserted that the border glow runs while a turn runs; studio's
design removed that glow, and its running signal is the run mark's R being
traced (studio-r-run). The three directions stay: still at rest, moving
while running, still again after.
It also flagged the mark's straight stroke as drawn on nothing, because an
SVG shape's box leaves out its stroke and a straight line has no area. An
SVG shape is now judged by its drawing's box.
In the light theme the new-session button and the session filter segments
were separated from the sidebar by fill alone, a 1.8 step in OKLab
lightness, and the button had its border removed: neither read as a layer.
Both now mix 3% of the text colour into their fill, in light only.
Each session row reserved 34px of its title for the menu button and drew
the button at zero opacity until hover, so every title was cut short for a
control nobody could see. It now rests at 35% and comes up on hover, focus
and selection as before.
In a conversation narrower than 460px the ask card's actions stack, and they
stacked in DOM order: "reply without choosing" above the primary answer.
The approval card already leads with its main action there, and the cards
guard holds both; the primary button now takes the first row.
The refine button is disabled until there is a draft, yet its icon still
showed a pointer and it lifted on hover, reading as something to press.
A disabled mode button keeps its tooltip but takes the default cursor and
does not lift.
Network in the bash sandbox was one bit: open to everything, or shut. With
[sandbox] allowed_domains set, bash's external traffic goes through a
host-side proxy (internal/safety/egress) that admits only the listed hosts;
denied_domains outrank them.
- The proxy serves CONNECT and absolute-URI HTTP on loopback, decides from
the requested name, refuses names that resolve only to loopback,
link-local, metadata or this host's own addresses, and chains through the
upstream HTTP or SOCKS5 proxy the environment names.
- macOS: the Seatbelt profile shuts every destination but loopback, Unix
sockets and the proxy port, and closes the loopback ports of the proxies
the environment and the system proxy settings name. Loopback stays open
so tests and dev servers keep working. Bind, inbound and outbound are
separate grants, because (local ip "localhost:*") on network* matches the
local end of every outbound connection and admits all of them.
- Each confined launch carries a token as proxy userinfo; what the proxy
refused under it is appended to the bash result by the host, so a policy
refusal reaches the model as one instead of a bare connection error.
- The list only narrows open network: it applies where network is on and
the sandbox confines; a repo may set one only where the user has not, and
its denials only add to the user's.
- Linux and Windows warn that the list is not enforced there.
Known gaps, stated where they live: a host proxy on a loopback port nobody
declared stays reachable, name lookups still reach the system resolver,
the decision reads the requested name (domain fronting is not caught), and
MCP servers are not routed through the proxy.
internal/safety/egress is declared sensitive, with its coverage floor.
The window's error bar sat in the main column under the panes: every failed
request pushed the composer up by the bar's height, as a full-width strip at
the far end of the screen from most of what fails.
It now floats. The pane in front carries it on a zero-height anchor pinned to
the top edge of its composer card, at the right: it never moves the
conversation, rises with the card as the prompt grows, and stays clear of
the send button. With no pane on screen it takes the main area's corner. Its
fill is mixed into the surface, since it now sits over content.
perf/errbar.mjs holds the geometry at two widths: the bar appears and takes
clicks, sits above the card, leaves send clickable, and does not move the
composer. It reaches the bar the way a real failure does, through a hub
method the bench can now refuse. Against the old in-flow bar it fails on the
composer moving 45px and on the bar sitting below the card.
main.tsx has loaded studio.css since the studio interface landed, and the
perf harness never followed: every browser guard since has measured a page
without the studio layout, with the composer at the top of the window and
the sidebar unstyled. Run against the real layout, fifteen guards still
report failures; four of them (cards, idle, press, surface) were passing
only because the layout was missing, and contrast and pick stop failing.
host_authorities is user-global: loadForRoot holds the user's value and puts
it back after the project reasonix.toml merges. The held value was the slice
header, and decoding the project file writes into the backing array the
user's decode left behind, so the held copy read back the repo's list. A
cloned repo declaring host_authorities = ["docker"] got the container daemon
socket inside the bash sandbox — with an explicit user grant and with the
default one alike.
The held value is now a copy.
RewrittenTestCriteria read Go test files only, so a Python run that moved
its own expectation reached the completion summary, the best_of_n judge and
the integrity bench with a clean record.
pytest modules are now read by pytest's default collection contract
(test_*.py / *_test.py, module-level test* functions, test* methods of
Test* classes). Bodies come from Python's own line structure — the
indentation stack, bracket joining, whole strings — and compare as token
signatures, so reformatting, comments, docstrings and formatter trailing
commas do not read as rewrites while a changed expectation or parametrize
case does. Source that does not lex claims nothing.
Detection only: holding a criterion is still Go-only, now enforced at
captureRewrittenCriteria too, because no backend re-runs a captured Python
test and holding one would owe the turn a baseline run nothing can settle.
Opening the card refreshed the share status and, in the same commit, minted
a pairing code on the status it already held. When phone access had been
turned off elsewhere since, the mint reached the kernel first and was
refused as share.closed, and the window's error bar told a person who had
only opened the card to "turn phone access on first".
Opening now waits for the read and mints only when the door is open and no
live code is held. A failure inside the card is said inside the card, under
its header, and clears when the card closes, rather than landing in the
window's error bar at the far end of the screen.
Each candidate kernel already emitted a CompletionSummary at turn end —
verdict, checks passed/failed/suppressed, review, gaps, and existing tests
it rewrote or removed — but the candidate sink forwarded usage only, so the
judge compared attempts on their final message and diff alone.
candidateSink now keeps the last summary per attempt; the judge sees it as
a host record the attempt cannot write, and the parent's report carries it
per attempt. A failed check counts against an attempt, and one that
rewrote tests the task did not ask to change is suspect however green.
Discovery searched /Applications as a literal, so the tests that stand for a
machine with no editor could not build one: on a Mac with VS Code installed
TestDiscoverRefusesWithAnIdentityWhenNothingIsInstalled found it,
TestInstalledPathsAreSearchedBeforePath found it before the placed copy, and
serve's TestEditorOpenSeparatesMissingFromFailedToStart launched it against
a controller with no workspace and got a 500. CI runners have no editors, so
all three were green there and red on a developer's machine.
Discovery now reads the machine through a host value: the OS, the
environment and the system application folder. Discover passes the real
one; the editor tests pass an empty folder. The serve test no longer tries
to empty the machine at all: an editor the user configured that does not
exist is the missing class everywhere, and it reaches the same refusal.
The sidebar's version rows had no rendering test: the expander, the label
and opening the version's own path are now exercised through the real
Workspaces component.
The lease-alignment race posted to /fork, removed with explicit forking, so
half of it only collected 404s. /new rotates the active path through another
handler, which is what the race is for.
Editing an earlier message, regenerating a reply and rewinding the
conversation all truncate the transcript, and only the latest one could be
undone. What was said before the cut was gone.
A conversation rewind now saves what it is about to cut as a version of the
session: a whole session file, marked Superseded with the session as its
parent, left out of every session list so it never reads as a second
conversation. The Studio sidebar hangs versions under their conversation in
the same expander as recovery copies, labelled apart, and each opens as a
whole conversation. A version someone carries on becomes a conversation of
its own: the first save that adds a turn clears the mark and keeps the
parent. Undoing a rewind drops the version that now duplicates the restored
conversation, judged by content so it holds after a restart. Removing a
conversation removes its versions. Keeping a version is a record, not a
precondition: a rewind that cannot keep one still happens.
NewSessionPath moves to session_path.go to keep save.go inside its budget.
Closing the manager cancelled every running job the same way kill_shell
does, so a job read after a restart came back as killed. Nobody killed it:
the process running it ended, which only the host knows, and a bare "killed"
left the model to guess between that and a stop someone asked for.
A job cancelled because its manager is closing now ends Interrupted, the
status an abandoned record already repairs to, and one stopped by Kill stays
Killed. bash_output and wait render the cause after an interrupted status.
The tool descriptions are unchanged, so the cached prefix does not move.
The boot-level restart test starts a background job, restarts, and reads
it: a restart cannot keep the job alive, but the model is told why it ended.
Status and its cause move to status.go.
The store kept pending results in memory and removed every worktree when the
session closed. A restart between delegating an isolated task and applying
it lost the work, and the refusal that followed said no result was pending,
a claim the host could not know was true.
The worktree is now the result and the store only indexes it. A finished
run writes an entry mark beside its worktree naming the id, the workspace
and the snapshot; what it changed is derived from the worktree on every
read, so no stored tree can outlive the objects it names. A lookup the
memory misses is recovered from disk, closing a session no longer removes
anything, and a result ends only when applied or discarded. Results taken
from another workspace are never offered, since applying one would write
into that workspace from this one. Unsettled results older than fourteen
days are swept at boot, and only directories carrying the mark are touched.
The boot-level restart test delegates in one turn and applies in the next,
with and without a restart between them, and both arms now land the file.
A goal resumed after a restart re-demanded inspection, verification and
sign-off for a change that had all three before the process ended: their
receipts lived in the ledger, which does not cross the boundary. It was
fail-closed but not restart-equivalent, and every restart cost a re-run.
The delivery checkpoint now carries Proven: what the turn's last readiness
verdict had proven for the pending change, with a content fingerprint of
every path the scope changed. On resume, with no new write in this process,
the proof discharges what it covers, but only while every path still holds
the content it was earned on. A path edited or deleted while no process was
watching voids all of it, which is more than an uninterrupted run can see.
A change with a part the host cannot name carries no proof at all.
The verdict is recorded where the turn asks to stop, so the checkpoint reads
the same derivation the gate used rather than a second one. The Delivery
branch of the readiness check moves to appendDeliveryGaps to keep the
function inside its complexity budget. The restart harness no longer pins
any stricter step: both arms now owe exactly the review.
The repository now describes the two lines the version roadmap announced
(discussions/10748): 2.x on studio, 1.x on main-v2 in maintenance.
- README (en, zh-CN): a Versions section and a Studio 2.x release badge.
Install leads with Reasonix Studio 2.x from the studio-v* pre-releases,
with the 1.x CLI and desktop app beside it as the stable line. The
zh-CN build-from-source section gains the Studio build it lacked.
- Issue forms offer 2.x / 1.x / 0.x, and the labeler maps them to v3 /
v2 / v1, with the pasted version still overriding the dropdown. The
forms link the roadmap. The PR template and CONTRIBUTING say which
branch takes which changes.
Issue forms, schedules and Dependabot are read from the default branch
only, so these take effect when studio becomes it:
- CodeQL scans pushes and PRs on both lines, and the weekly scan checks
out each line and files results under its own ref, so 1.x keeps its
security scanning.
- v1-maintenance-schedule starts main-v2's nightly app-memory and
diagnostic-overhead monitors, which measure the 1.x desktop app, on
main-v2.
- Dependabot drops /desktop/frontend and /site, which do not exist on
studio, and covers /desktop/frontend-next and /desktop/electron. It
also names main-v2 as a target for patch updates only.
layer-order ranked model, tools, safety, ext, state and platform as one peer
pool that could import freely inside itself, so state -> tools or
model -> platform passed the gate and nothing held the directory graph
acyclic. The pool is split into the order the tree already follows
(safety/state, model, platform, ext/tools), and a package now imports only
its own directory or a strictly lower rank. The tree needed no change.
The structured review a high-risk change owes was derived from mutation
receipts in the evidence ledger, which has no persisted form. A goal
restarted between its turns resumed with an empty ledger, derived nothing,
and completed without the review. The restart-equivalence tests showed it
through the real boot and the controller's own resume.
The delivery checkpoint now carries OwedReview: the review kinds a change in
its scope still owes and the paths a review has to cover. Each turn's
checkpoint joins what the live ledger derives with what was carried in and is
still unsettled, so a restarted ledger deriving nothing is never read as a
discharge. The gate owes a carried review until a review of that kind covering
those paths lands; one kind does not settle the other. The live derivation is
unchanged and shares one helper with the checkpoint, so an uninterrupted run
is held exactly as before.
The restart harness now fails any step where a restart owes less than an
uninterrupted run. What a restart still re-demands — inspection, verification
and sign-off, whose receipts do not cross the boundary either — is fail-closed
and pinned as known, and the boot-level test asserts both arms end blocked.
The production form of the restart-equivalence harness: two real boots, the
session resumed from its file the way --continue does, and goal turns
submitted the way a window submits them, so they carry the goal's delivery
scope. The scripted model acts on milestones rather than turn counts, and
every reply spends budget, so a goal that cannot finish stops on its own
token budget as a real one does.
A high-risk change (a path the project declares sensitive) is made, checked
and signed off, with no review. Uninterrupted, the goal stays blocked and the
host keeps demanding the review. Restarted between its two phases, the
resumed goal is never asked for one and completes. Each arm run in its own
process gives the same result, so no package state carries between them.
The review obligation is derived from mutation receipts in the evidence
ledger, which has no persisted form: the goal sidecar carries only the
DeliveryCheckpoint. The case is pinned as a known divergence so the fix turns
it red.
Past IndexMaxChars the listing was cut mid-rune: with 91 skills only 35
lines survived whole, the other 56 were invisible to the model, and the cut
could leave half an identifier to call. The listing now degrades to names
only, cut whole-line with a count of what it left out, and points at
use_capability search for descriptions. A catalog under the cap renders the
same bytes as before.
No per-turn retrieval over the user's wording: ranking by prose would hide
skills inside a gate. The model asks through the existing capability search,
whose keywords now include the triggers a skill's author declared.
Stopping the process at a turn boundary and resuming must leave the host
deciding exactly what it would have decided had it kept running. The harness
plays a scenario twice, once in one agent and once through a restart after a
chosen step, and compares the readiness verdict of every step. The restarted
arm is built as a resume builds it: a fresh agent with an empty ledger, the
transcript, and the DeliveryCheckpoint after a JSON round trip, which is all
the goal sidecar carries; the controller's own restore of that checkpoint is
established by goal_resume_project_check_test.
A signed-off ordinary change agrees across a restart. A high-risk change does
not: the uninterrupted arm owes structured_review at every later step, while
the restarted arm owes re-verification and sign-off instead, and once those
are redone it finalizes with no review at all. The review obligation is
derived from mutation receipts in the ledger, and the ledger does not cross
the boundary. The case is pinned as a known divergence, so the fix turns it
red and takes the mark off.
With [agent] worktree_isolation on, task takes isolation "worktree". The
sub-agent then runs as an unattended kernel of its own, rooted at a fresh
worktree of the workspace as it is now, and nothing it writes reaches the
workspace. Its result lists the changed paths under an iso_ id, and the
parent settles them with apply_isolated or discard_isolated.
apply_isolated declares every path it writes, so the host reserves,
checkpoints and records them like any other write, and it applies path by
path: a path the workspace changed meanwhile refuses the whole apply by code
and keeps the result. A workspace git cannot snapshot is refused with
isolation.not_git; isolation is never faked with the shared workspace.
The child runs under the session's own approval posture, read when it starts.
It has no one to ask, so a session that asks gets a child that refuses what
needs approval, and nothing it does exceeds what the session would do
unattended. Arguments a separate kernel cannot honour (profile, tools,
write_paths, continuation, background) are refused rather than dropped. The
child offers neither isolation nor best_of_n, and the settlement tools are
hidden from sub-agents. Closing the session removes every worktree it holds.
best_of_n's attempt runner is shared as runUnattended, and task's name,
description and schema move to task_schema.go.
The host observes a write through Previewer, which describes one file. A
writer that changes several paths named nowhere in its arguments was left an
opaque writer: a coverage gap on the checkpoint, no preimages, and a receipt
with no paths, so neither rewind nor the evidence ledger could say what it did.
tool.WritePathDeclarer lets such a writer name every path before it runs. The
host reserves exactly those paths against background writers, captures each
one's preimage and after-fingerprint, and records them on the receipt. A code
rewind then restores a modified path and removes a created one.
Apply refuses unless the whole workspace still equals the snapshot, which
holds for best_of_n, where the parent waits, and never for a candidate that
ran beside a parent that kept editing. ApplyPaths judges each changed path
alone: the workspace's version is hashed through the same filters a snapshot
stages with, and compared against the snapshot's blob and the candidate's.
A path still at the snapshot is written, one already at the candidate's
version is left alone, and anything else is a conflict. One conflict writes
nothing, and the ConflictError names every path.
ChangePaths refuses a change outside the folder the snapshot was taken of,
and DiffStat gives per-path line counts for reporting a candidate.
tools/list, resources/list and prompts/list took the first page and ignored
nextCursor, so a server that paginates lost every tool past its first page
without any error. The three now read every page through one helper, bounded
at 64 pages; a cursor handed out twice is reported as a server loop rather
than served as a complete catalog.
The readiness gate read only the checks the current process declared. A Goal
restored into a process whose declaration had changed carried its captured
baseline in the delivery checkpoint, but nothing enforced it, so running the
replacement alone was enough to finalize. The Goal resume path was shown
reachable by goal_resume_project_check_test.
The gate now also owes every baseline check no current declaration names,
inside the delivery scope that captured it, anchored on the same write the
gate already uses. The trust-domain rule settles the authority question: the
workspace cannot supersede a captured criterion.
The full switch to ledger obligations is not taken: a resumed process starts
from an empty ledger, where the obligations owe nothing at all, so the switch
would open the resume path this closes. A restored checkpoint from another
scope does not bind an unscoped turn.
The 2.20.0 release spent 226s packaging Windows and 640s mirroring to R2.
Packaging compressed the same 245 MB tree twice, the 7-Zip archive and
then the NSIS installer. They now run at once; the zip reads its own copy of
win-unpacked because NSIS writes elevate.exe into the tree it packs, and a
zip read beside that would contain it or not by timing. A local cross-build
gives the same 128-file zip as the sequential step.
Defender real-time scanning is switched off for the Windows build, which
writes that tree several times over. continue-on-error keeps a runner image
that refuses the switch from failing the release.
The R2 mirror sent 18,755 delta chunks of about 10 KB with the AWS CLI's
default of 10 requests in flight, roughly 31 a second. Raising it to 64 leaves
the published objects unchanged.
The controller keeps the session in its finishing state while TurnDone fans
out, and Running() counts finishing, so the rig's done signal always arrives
before the session is free. A slow runner submitted the second line inside
that window and got busy.session_running, which the race leg reported as a
failure of the attribution it was testing.
Every MCP tool sat behind use_capability, so calling one cost a search and
an inspect before the call, and nothing let a user say "this server is used
every turn". A server now declares `load = "always"` (or `alwaysLoad: true`
in .mcp.json, a package manifest, or an installed mcpServers document), and
the user overrides it per server in [tools.mcp_load].
An always-loaded server's tools join the provider schema when the runtime is
assembled, and only when its schema is already known and the server is
authorized: a server discovered mid-session waits for the next assembly
whatever the discovery race, so one config and one cache give one schema,
and an unauthorized server stays behind use_capability where its approval is
asked for. The server is also connected in the background at assembly, so
the first direct call does not pay the handshake the loading saved.
Studio shows the mode as a tag beside the server and offers
deferred/always above its tool list. Switching writes the user config and
rebuilds the runtime in place, the same as the other settings boot reads,
because a new session reuses the runtime and would never see it.
The retired `tier` field was already stripped on load, which left the
eager/background partition, the slow-server demotion and the startup-latency
samples feeding it with nothing to act on; they are removed.
Any tool the classifier did not know was a proven mutation, MCP tools
included. So every non-read-only MCP call left a stale_verification
debt, even one that acted only on a remote system. The model then went
looking for a check to settle it: in a live run it tried to launch the
MCP server itself to "verify" a deploy.
A tool carrying MCP identity that does not name the paths it writes now
reports EffectsUnstated, read off its type through tool.MCPMetadata, the
same identity provenance reads. ToolCallMutationClassWith makes such a
call unknown rather than proven, which puts it on the path bash's
unclassifiable commands take: the workspace is scanned before and after.
Unchanged settles it as no mutation. A changed file is a proven mutation
with the observed paths, and owes its check as any change does.
TestEffectAnMCPCallOwesACheckOnlyWhenTheWorkspaceChanged drives both
through boot.Build: a call that writes nothing here owes nothing, and
one that writes deploy.log into the workspace owes the check. Without
the reclassification the first case owes stale_verification again.
Studio opens a pane with POST /runtimes, whose handler passed
r.Context() into boot.BuildRuntime. That context became the lifetime of
every lazily started MCP server (LazyToolset's sessionCtx) and the
controller's PluginCtx. net/http cancels it when the handler returns, so
in any pane after the first, a server whose schema was already cached
failed its first call with "MCP startup launch failed after 9ms ...
context canceled". The first pane was unaffected because serve builds
it on context.Background.
build now detaches once at its boundary with context.WithoutCancel,
keeping the context's values. Teardown still runs through the host's
own cancel causes.
TestEffectAnMCPServerStartsAfterTheBuildingRequestEnded builds twice:
once to cache the schema, then with a request context cancelled when
Build returns. Without the fix the call answers "context canceled".
MCP elicitation, form mode. It is declared in the legacy initialize
capabilities and in the 2026-07-28 _meta, and answered on all three
paths:
- legacy stdio: the request arrives unattached to any call and goes to
the most recent call in flight that has a person behind it;
- legacy Streamable HTTP: the elicitor comes from the call's context;
- modern MRTR: it is answered among the input requests.
The host renders requestedSchema: string, number, integer and boolean
fields, enum and titled oneOf, and multi-select arrays. Answers are
checked for required fields, range, length, email/uri/date formats, and
integers parsed exactly. A form that fails goes back with the reason,
prefilled with what was given, up to three times. Schema defaults
prefill the first form.
The form rides the ask prompt (tool.Elicitor on the tool context,
implemented by control.Controller) with an AskOrigin naming the server.
Refusing it answers the server with decline and leaves the turn
running, where an empty answer to the agent's own question still ends
it. The decision receipt records the field names and
accepted/declined, never the values. Studio draws it as a whole form
under the server's name, with a warning not to enter secrets. ACP has
only the permission prompt to draw it in, where a field titled like an
action reads as the agent asking leave, so ACP declines it.
Bounds against a hostile server:
- one open form per stdio connection; a second request is declined
through the bounded reply queue;
- a form ends with its call;
- at most 32 fields and 64 choices, with caps on text length;
- duplicate names or labels are refused;
- nobody to ask means decline.
URL mode is not declared.
Also: this window's own answer now overrides a decision receipt that
overtook it, which sealed the card as "handled in another window".
A call the host cannot classify pays a full workspace walk before it runs
and again after. On this repository (23.6k files) one walk took ~240ms
sequentially and takes ~50ms with sixteen directories read at once. A
20k-file Unity project goes from ~120ms to ~20ms.
The answer is unchanged. Links are not entered, VCS stores below the root
are skipped, an unreadable entry or a stop leaves the scan incomplete, and
more than limit files put it over the limit.
TestParallelScanAnswersAsTheSequentialWalk keeps the old WalkDir verbatim
and holds the two equal across limits on a tree with nested VCS stores and
a link out of the tree. Letting the walk enter a VCS store fails it.
StartTracked creates each child suspended, assigns it to a job object, and
then resumes it. Finding the one thread to resume took a system-wide
TH32CS_SNAPTHREAD snapshot. On a desktop with 8.7k threads that costs 54ms
per launch, about half of what a bash `pwd` costs end to end through
boot.Build (120ms before, 60ms after).
NtGetNextThread walks only the child's threads. Both checks still hold:
exactly one thread, and a previous suspend count of at most one. When
ntdll lacks the call, or the child's handle cannot be opened, the snapshot
answers as before.
App.tsx had reached 802 lines against the 800-line ceiling, and the lint
job failed on it. The fold-away hook is self-contained, so it moves to
its own module unchanged, with its comment in English. App.tsx is back
to 784.
From the security review of the MCP 2026-07-28 client:
- -32020, -32021 and -32022 sit in JSON-RPC's implementation-defined
range, so a legacy server may answer the pre-handshake probe with one
for its own reasons. Such a server failed to start. Only a -32022
carrying a non-empty list of revisions now marks an answer as modern;
every other reply gets initialize, which is what those servers had
before.
- One input_required round may ask for at most 16 inputs and echo at
most 1 MiB of requestState, so a server cannot make each retry
larger. Over that, the call fails with errMCPInputOverBounds.
- A modern connection no longer re-runs initialize when a session
expires. A modern server has no session to expire, and initialize
belongs to the other era.
confineRead resolved every walked file with filepath.EvalSymlinks, which on
Windows costs a FindFirstFile per path component. A glob over a 50k-file
Cocos assets tree spent 94% of its 22.9s there; it now takes 0.49s. Native
grep over a Unity Assets tree goes from 6.2s to 4.5s, the rest being file
reads.
WalkDir descends only into plain directories, so a plain file or directory
below the root resolves to the root's real path joined with its relative
path. A symlink, junction or other special entry still pays confineRead in
full. TestWalkConfineAgreesWithConfineRead holds the two answers equal over
a tree with a forbidden directory, sensitive names, a junction into the
forbidden directory, one out of the tree, and a file symlink to a forbidden
file; with the special-entry check removed it fails on that symlink.
Glob, native grep (and its ignorer) and code_index use it.
TestRedactLongConcurrentTranscriptAvoidsRegexpBacktracking ran 24
workers x 20 iterations over a 2,000-line transcript. Under the race
detector that sits at the ten-minute test limit on CI: one run passed at
581s, two timed out. Under -race it now runs 4 x 2, which still exposes
a data race in concurrent Redact; the ordinary run keeps the full load,
which is what checks backtracking. Locally the race run takes 5s.
The census found four proven-writing inputs outside the action vocabulary,
which held the Studio workflow red. The window's Escape registers through
listenAction as browser.open, session.stop or chrome.focus, whichever it
performs in the state it is registered in. The gutter's keyboard toggle
and grip carry chrome.rail on the rail edge and browser.open on the dock
edge, where opening the column opens a browser view. The theme file picker
is theme.import, the same intent as the button that opens it. chrome.focus
joins the registry.
A handler saves its tape entry in a defer, after the client has already read
the last byte of the response. srv.Close cut that off, so a replay started
right after stop could find no entry for the request just recorded; on Linux
and macOS TestTapeReplayNamesTheFirstRequestThatDiffers lost that race every
time. Stop now shuts down gracefully, bounded at ten seconds.
go mod tidy in desktop/ drops an indirect gopkg.in/yaml.v3 and 32 stale
go.sum lines nothing in the module imports any more; the desktop CI job
runs the same tidy and failed on the drift.
liveSession, waitLog, lineRef and their state are used only by
live_darwin_test.go and live_windows_test.go, so on Linux golangci-lint
reported all five as unused and failed the lint job. The file now
carries the same platforms as its callers.
"C:/Windows/x.png" is absolute only on Windows. On Linux and macOS it is
a relative name that filepath.IsLocal accepts, so the route answered
404 for a missing file, which is right, and the test expected 400. The
case now uses /etc/x.png everywhere and adds the drive-letter path on
Windows alone.
A server is asked with server/discover which era it speaks before anything
else; a modern one is never sent initialize, and every request carries its
protocol version, client info and capabilities in _meta. Over Streamable
HTTP the body is mirrored into MCP-Protocol-Version, Mcp-Method, Mcp-Name
and the Mcp-Param-* headers a tool's x-mcp-header annotations declare
(base64 sentinel for values a header cannot carry); a tool whose
annotations break the rules is left out. input_required results are
answered for roots/list and retried with the server's requestState;
elicitation and sampling surface as ErrMCPInputRequired. HTTP requests
are no longer chased by notifications/cancelled on a modern connection.
A modern server sharing no revision is refused with
ErrUnsupportedProtocolVersion; one still serving a legacy revision gets
the handshake. The SSE transport is legacy-only and skips the probe.
OAuth: the authorization response's iss is checked against the metadata
issuer (RFC 9207), required when the server advertises it, and dynamic
registration declares application_type native.
Steering text into a running turn had the same gap a new message did:
only the screen that sent it drew it, because the steer event was read
only to settle that screen's own pending row, and the device it came
from was lost.
A steer queued from the inbox now carries the item's Via into the
agent's steer entry (SteerItemFrom), onto the user message it lands as,
and onto the steer event, which also says when the host wrote the
guidance itself. A screen with no pending row of its own draws the
guidance once per item, never the host's; the sender's own row takes
the device, and /history returns it on steer rows.
A line queued on a phone while a turn was running lost its device: the
queued item runs long after the request that queued it, and only the
direct submit path carried one. InboxRequest and the persisted
PromptEnvelope now carry Via, an edit keeps the item's original one,
and the queued run binds it to the turn through the same turnTags the
direct path uses, so the turn is announced and landed as the phone's.
An answer to the model's question given on a device goes through
AnswerQuestionFrom, on the same Provenance port as submit and approve,
and names the device on the ask's receipt and nowhere else. A screen
that did not answer read 已在其他窗口处理 and nothing more; it now reads
由 设备 N 回答:<what the receipt recorded>, or 在电脑上回答 on a
phone, and keeps the old line where neither is known.
Steering text into a running turn is not attributed yet: it lands
inside that turn rather than opening one, and has no announcement to
carry a device on.
A line sent from a paired phone never reached the window: each client
minted its own user row, and turn_started carried only the message's
index, so the window showed an answer with no question above it, and
the phone showed the window's answers the same way. turn_started now
carries the line itself, read the way /history reads it, and a client
with no row of its own for that index draws one from it, once.
Who sent it is recorded where the message lands. A paired device's
request carries its pairing id and the number the window lists it
under; serve passes that to SubmitHTTPFrom, the submission carries it
through the turn's context beside its output format (turnTags, never a
controller slot), and the host's turn boundary stamps it on the user
message as provider.Via, which the projection strips before any
provider request. /history returns it, so a reload still says it.
Approvals made on a device go through ApproveFrom and name the device
on the decision receipt, and only there: the device changes who the
receipt names, never what the answer allows. Both calls sit on a
Provenance port that only SessionAPI carries, since an editor has no
paired devices to speak for; the window's own input still goes through
SubmitHTTPFormat and Approve.
On screen a line from another device reads 来自 设备 N, and on a phone
the window's lines read 来自 电脑; a screen's own lines carry nothing.
An approval settled elsewhere adds 由 设备 N 处理, or 在电脑上处理 on a
phone. The device bar tells the rest of the page which device it is.
It also stops re-asking /device on the window, which it did every ten
seconds after learning the window was not a device.
DecisionReceipt and Via move to provider/decision.go, keeping
provider.go under its size ceiling.
A finished reply showed as one run-on paragraph with its "##" and code
fences in plain sight, although its stored text and message frame kept
all 43 newlines. Rendering that text directly works, and so does
replaying it as a stream. What produced the screen is SayCard's error
boundary: once the markdown subtree threw, it stayed failed for good,
and its fallback was a div without pre-wrap, so every newline read as
a space.
The run that hit it was a dev build under constant HMR from two
sessions. The likeliest trigger is the lazily loaded renderer failing
to load once: React.lazy keeps that rejection for the life of the page.
That trigger is not proven.
Boundary now takes a retryKey. When it changes (the next chunk of a
streamed answer, or the finished one) the children get another try,
and componentDidCatch logs what threw, so the next occurrence names its
cause. LazyMarkdown swaps in a fresh lazy component when the import
fails, so a retry loads again. SayCard keys its boundary on the text,
and its fallback keeps whitespace, so even a real failure reads as
paragraphs.
When a release publishes a delta for this platform, the Windows install
now tries it before downloading the full installer. The client fetches
the index and checks its SHA-256 against the manifest and its minisign
signature against the release key, and refuses one for another version
or platform. It then plans against the install, fetches only the missing
chunks (8 at a time, each checked against its name, cached so an
interrupted attempt resumes), and assembles the new tree in the update
cache, checking every file. Until that point nothing in the install has
been touched, so any failure is logged and the full package does the
job as before.
The swap follows the macOS pattern: the host copies itself out of the
install and starts the copy with a plan file (--reasonix-tree-handoff),
detached, before the application ends. The helper waits for the shell's
and the host's processes to exit and hashes every staged file again. It
then moves each file in and keeps what it replaces in a backup
directory, retrying through the seconds a scanner still holds a file.
On any failure it puts every moved file back, so the install is the new
release or the old one, never a mixture. It updates DisplayVersion on
the uninstall entry whose InstallLocation is this install and relaunches
the application. Files the release does not list, such as the NSIS
uninstaller, are left in place.
ApplicationAt now states the shell's PID off macOS as well: the swap has
to wait for it, and before this nothing outside macOS read the field.
Run for real on Windows, replaying v2.18.1 -> v2.19.0 with a live stand-in
process: the helper waited for that process to exit, and all 120 release
files then matched v2.19.0 byte for byte, with the uninstaller kept.
The release now builds a chunked update from the signed Windows portable
zip, the same bytes the installer lays down. The output is a
zstd-packed Index (731 KB for v2.19.0, down from 1.6 MB) signed with
the release's minisign key, and the chunks it names. The delta stays out
of dist, which is what gets counted and published as the release's
artifacts.
The mirror step syncs chunks into one shared store,
studio/chunks/<sha256>.zst. Chunks are named by content and immutable,
so --size-only skips every chunk an earlier release already uploaded.
They go up before the tag's delta index, and both before the catalog,
so no client ever sees an index whose chunks are not there yet.
latest.json gains deltas: per platform, the index as an Asset (URL,
signature, size, SHA-256) on the mirror, plus the chunk store's base
URL. Older clients decode manifests leniently and ignore it. A release
without mirror credentials offers no delta, since nothing would be
uploaded for it.
studio-delta build now writes <delta-dir>/<platform>/index.json.zst, with
the chunks in <delta-dir>/chunks. simulate reads that layout, and on
v2.18.1 -> v2.19.0 still fetches 21.9 MB and verifies the staged tree.
A paired phone's page was the window's own, with nothing saying the
sessions on it live on another computer, and nothing on the window said
a phone was on it now rather than at some point.
On a device, a bar above the chrome reads 正在控制 <hostname>, names the
device by the number the window lists it under, and turns grey with
与 … 的连接已中断 when the machine stops answering. Its 断开 takes a
second press and unpairs the device from its own side through
POST /device/leave; the page reloads to the unpaired notice. GET /device
answers a paired device with its id, ordinal and the machine's name,
and the window or a networked serve's browser with 404
device.not_a_device, which is how the page knows not to draw the bar.
On the window, a device is online while it holds an event stream open,
which the gate counts by the stream's Accept header rather than by a
recent request, so a shut page reads as gone within one read. The
button carries the online count in place of its dot, a line under it
says 设备 N 已连接 or 已断开 when that changes, and the lists read 在线
or 最近 <time>. Sharing is watched whenever it is on, not only while
the card is open, so those stay current unasked.
Unpairing a device now cuts every stream it holds open. The credential
was checked when a stream opened and never again, so a phone the
window had disconnected went on receiving the conversation until it
reloaded; each stream now runs under a context Revoke and RevokeAll
cancel.
Every Studio update downloads the whole installer (115 MB on Windows),
even when a release changes a few megabytes. Measured on the real
v2.18.1 -> v2.19.0 Windows builds: the Electron binary is identical but
for its version resource, the kernel changes, and the frontend changes
by about half a megabyte.
internal/platform/delta describes a release as a signed-to-be Index: every
file's path, size and SHA-256, and the content-defined chunks it is made
of (normalized gear CDC, 4/16/64 KiB). Chunks are stored zstd-compressed
under the SHA-256 of their plain bytes, so any chunk two releases share
is stored and fetched once. An update cuts the running install with the
same chunker; content-defined boundaries move with an insertion instead
of shifting every chunk after it. It fetches only the missing chunks,
checking each against its name, and writes the new tree to a staging
directory, checking every file against the Index. Validate refuses
paths that leave the tree, backslashes and drive letters, two paths
that are one file on Windows, and chunks that do not add up to their
file.
cmd/studio-delta builds an Index and chunk store from a release zip, and
replays an update from an unpacked older release. On the real pair it
fetches 21.9 MB instead of 115 MB, reuses 344 of 387 MB, and verifies
the staged tree, in about five seconds.
Nothing publishes or consumes this yet. Wiring it into the release
workflow and the updater comes next.
Adds github.com/klauspost/compress for zstd.
The card reused the settings block, which explains itself at length, so
a popover under a 28px button ran to more than 560px with three
paragraphs in it. It now has its own arrangement in the language of the
chrome's context card: title and one-line subtitle with the switch in
the header, the code as the only large thing with its expiry and two
actions under it (copy link, new code), the network as one row, the
connected devices as compact rows, and the plain-HTTP caution as a
single line at the foot. Sections are divided by hairlines, sizes come
from the type scale. The settings block keeps the long form.
A device is named by its pairing order, 设备 1, 设备 2, with the user
agent kept as the row's title; read as a name it was a hundred
characters that all began with Mozilla/5.0. Disconnecting from the card
takes a second press on the same button, which reads 确认断开 for three
seconds, in place of the inline dialog the settings block uses.
The docked workbench sat flush against the window, split from the
conversation by one hairline. It now floats in its column: 8px of space
around it, a 12px border radius, a border and a soft shadow. The page it
shows sits inset inside the card with 8px corners, both the iframe in
the browser build and the native view's slot in the desktop build.
CSS cannot clip a native WebContentsView, so browserviews.js rounds the
page itself with setBorderRadius(8), the same radius .bview has.
A device is listed under its user agent, which runs to a hundred
characters. The name already ellipsed, but the two grids around the row
sized their implicit column to the widest content, so the column grew to
the full string, the card scrolled sideways, and the warning below
stretched with it. Both grids now bound their column to the card.
Under the address sits 「复制配对链接」, which puts the full link the QR
code draws, fragment and one-time code included, on the clipboard. The
bare address shown above it pairs nothing, which read as a broken
share when typed into another browser on the same machine. The link is
the same credential as the code, so it is copied on request and never
shown as text; it pairs once and lapses with the code.
The clipboard path is CopyButton's, exported as copyText, so the
fallback for hosts without navigator.clipboard is written once.
A fenced block was a bare box: a faint language tag in the corner, a copy
button only on hover, no line numbers, and a long file pushed the rest
of the reply off the screen.
It now has a header bar with the language, the line count, and Download
(saved under the language's suffix, .txt when there is none) and Copy
buttons. A gutter numbers every line, built from spans rather than a
list so that markdown's own list spacing cannot shift it against the
code. A settled block over 18 lines opens folded to 12, with a fade and
a "Show all N lines" toggle. The tail of a reply still arriving never
folds: Block gets a live flag, separate from tail, because a settled
message has a last block too.
The picker was disabled whenever the door was open, with nothing on
screen saying so, so it simply did not respond. Choosing another
network while sharing now moves the listener there and mints a code
for the new address; a line under the picker says that the phones
already paired will need to scan again, since the old address stops
answering.
Minting a code also set it apart from the status that knew about it:
the status from the open call arrived first, without an offer, and the
effect that drops a spent code dropped the fresh one. The code and a
status read after it are now set in the same tick.
The chrome's backdrop-filter makes it a stacking context, so the card's
z-index 60 only ranked it inside the bar, and the panes after the bar
took the hits the card paints over. A press on the switch landed on the
conversation pane, useDismiss read it as a press away, and the card
closed before the switch ever saw the click. The bar is lifted while a
card is open.
Found by clicking the card with a real pointer in Chrome against the
Studio host; the earlier check drove the switch through element.click(),
which skips hit-testing and so could not see this.
How long a reply thought was measured only by the window watching it,
so a reopened session, a reload or a second window showed "思考 53 字"
with no time at all.
The agent now measures it in the stream loop: from the first reasoning
text to the first answer output (text or a tool call), or to the last
reasoning text if no answer followed; a response with no reasoning has
none. The measure rides the settled message frame and is stored on the
assistant message (ThoughtMs, local UI metadata like WorkDurationMs), and
serve's history carries it. Studio prefers the kernel's figure over its
own clock, which stays only as a fallback for turns recorded before this.
While a reply is still thinking, the fold shows a running clock
("思考中 3.4 秒") instead of a bare ellipsis, and once the answer starts
it shows the final time rather than staying on "思考中".
To stay within budget, the frame emit and the per-chunk clock move out of
streamWithFrozen into thought_clock.go, and the temperature helpers move
from provider.go into temperature.go.
The chrome gets a QR icon beside the theme toggle. Pressing it opens a
card under it holding the same switch, network choice, pairing code and
paired phones as Settings > Remote > Phone access; with the door
already open, opening the card mints a fresh code at once, once per
opening, so a spent code is not replaced behind the phone that used it.
While the door is open the icon carries a dot and its title counts the
connected phones.
The button is drawn only where the kernel has a window to share:
/share answers 404 to a browser tab and to a paired phone, so neither
gets it.
The settings block and the card draw from one hook, useShare, and one
body, ShareBody; the card re-reads the state each time it opens, since
the settings block may have changed it meanwhile.
The title bar's drag rule claimed every non-control descendant of the
chrome, which would have made the card's text and code a window handle
in the shell; a [role="dialog"] now opts out whole.
Checked against the Studio host: the card opens under the icon, the
switch opens the LAN listener and shows the code, the icon takes its
dot, and the code decodes with jsQR to the listener's pairing link.
2b504d43f rendered data-action="ask.step" without declaring it, which
fails actions.test. It only moves between panes of one card, so it is a
view action proved by the interaction.
reasonix serve and reasonix web draw the share link as a QR code in the
terminal when stdout is one: dark modules on an explicit light
background, so it scans on a dark or a light terminal. In token mode
the code carries the token in the link's fragment, the way the printed
share link already does; in password mode it carries the login page.
--public-url names what a phone opens when that is not the bound
address, such as a TLS proxy's origin, and the share line uses it too.
Without it a wildcard bind is replaced by this machine's first private
address, and a loopback bind draws no code, since a phone cannot reach
it.
A token link rides only a link that is encrypted or stays off the
internet: https, or plain http to a loopback, private or tailnet
address. A public address over plain http gets no code and a line
saying to put TLS in front and pass --public-url https://. The
off-internet rule is serve.OffInternet, the same one the LAN share
applies. A supervised serve, which keeps its token out of the log,
draws nothing.
The password-flag handling moves out of runServeWithOptions into
applyServePassword, keeping that function inside its size budget.
With more than one question, the only footer action was Confirm, disabled
until every question had an answer, so after the first pick the next
question could only be reached through its tab.
A single-choice pick now moves to the next open question after a short
pause, so the mark is seen first. While another question is still open,
the primary button is Next (i/n); it is enabled once this question is
answered. Previous steps back. Confirm appears only on the last open
question. Multi-choice and free-text answers never advance by
themselves.
Under the scene fold the rail is a drawer over the conversation rather
than a column beside it: its grid track is zero, a veil dims what it
covers and closes it on a tap, and picking a session or opening the
settings puts it away. Opening it squeezed the conversation to a 90px
strip before.
The docked workbench takes the pane's whole width there, with the
conversation and composer set aside until the 「对话」 tab brings them
back; a 560px dock beside a 390px conversation left neither usable.
Both edge grips go under the fold, since the chrome's rail button and
the 「工作台」 tab are their entrances and the grips floated over the
approval card.
The settings sheet fills the screen; the studio layer's inset had
overridden the full-screen rule app.css already carried for narrow
windows.
The transcript no longer scrolls sideways: the wallpaper's reading
plate bled 64px past a column that had no room for it. Heights use dvh,
so a phone's browser bar no longer covers the composer. A coarse
pointer gets a composer of at least 16px, below which iOS zooms the
page on focus. Without hover, controls that only appeared on hover stay
visible. The icon-only branch pill drops its change count, which
overflowed onto the approval-mode button.
Approving best_of_n lets its attempts make their own tool calls without
asking again, but the card showed only the tool name. A tool can now
declare this with tool.ApprovalScoper. The controller stamps the scope
on every approval request at its single emit point, looking the tool up
in its registry, and the wire form carries the call's arguments only
alongside a scope, since they are what it covers.
Studio renders unattended_attempts in the reader's language: how many
attempts start, that their calls are handled as Auto review (deny rules
still apply, and a dontAsk session stays dontAsk), and the task they will
work on. A scope the window does not know still says that one exists.
The approval wire mapping moves to eventwire/approval.go, which keeps
wire.go and ToWire inside their budgets. The only change in the
sensitive approval_orchestration.go is the one line that fills Scope,
which is display metadata; no decision reads it.
Settings > Remote > Phone access opens a second listener on a private
address the person picks (Ethernet/Wi-Fi first, Tailscale next, tunnels
and virtual switches last, read from the interface's flags and hardware
address). The window shows a QR code carrying a one-time pairing code
in the URL fragment; the page trades it at POST /pair for an HttpOnly,
SameSite=Strict device cookie. A code pairs once and lapses after five
minutes; closing the share or quitting Studio unpairs every device, and
each device can be disconnected on its own.
A paired device holds what a networked serve client holds, and no more.
The window's grants (provider edits, account sign-in, workspace switch,
editor open, provider setup) are now read per request, so they stay shut
for device reach even though the device reaches the same handler. The
window's own routes (folder picker, SSH remotes, tray, notifications,
browser host, updates, asks, and the share controls themselves) answer
a device with 404 device.host_only, which the page already reads as a
kernel with no window.
The device gate refuses a Host other than the listener's own address
(rebinding), a state change from a foreign Origin, and every kernel
route without a known credential; the page's own files stay public so
an unpaired phone can load the page it pairs from.
The listener is plain HTTP, and the panel says so: fine on a home
network, not on a public one. Pairings live in memory only.
[agent] code_mode offers run_script. The model writes a Starlark
script. Inside it, call(name, **args) runs a tool and returns its output,
try_call returns ok/output/error, json encodes and decodes, and print
writes the result. Several dependent steps then cost one round trip, and
only what the script prints enters the conversation.
Every call inside a script goes through tool.Invoker, which the agent
binds to each tool call. The agent's invoker runs executeOne for it, so
a nested call passes the same parsing, policy, permission, hooks,
evidence and mutation observation as one the model made. It gets its own
tool card under the caller's id (s1.1, s1.2, ...), and its result
reaches only the script. A nested call's external origin labels the
script's result when the script declares none.
The interpreter reaches nothing else: no files, network or clock. Limits:
20M steps, 64 tool calls, 64 KiB of printed output and a 32 KiB script;
the interpreter is cancelled with the turn. run_script cannot run inside
a script, directly or through a proxy.
Adds go.starlark.net as a dependency.
The base rule makes the row full width, and the Studio skin adds 18px of
margin on each side, so the session tree was 36px wider than the rail. That
put a horizontal scrollbar under the tree, and opening a row's menu scrolled
it sideways.
The CLI keyed its session dir to the raw working directory, while boot
resolves the workspace to the nearest git root. Started from a
subdirectory, `reasonix serve` and chat wrote the transcript under the
subdirectory's project while the pane and the sidebar listed the
repository's, so the conversation never appeared where it was being held.
The session dir now comes from the same resolution boot uses, for the
build and for --resume/--continue lookups: the git root without --dir, and
the --dir directory itself with it. Resolution moves to session_dir.go.
Sessions already written under a subdirectory's project stay where they
are; they are not moved.
[agent] best_of_n offers a tool that runs one task 2-4 times in
parallel, each attempt an unattended kernel in its own detached git
worktree, then has a judge pick one attempt and writes only that one back.
The worktrees start from the workspace as it is: the snapshot is a
dangling commit written through a private copy of the index, so
uncommitted edits and untracked files are included, ignored files are
not, and the user's index and refs are untouched. The winner lands with
git restore --worktree plus deletions, unstaged, and only if the
workspace still holds the snapshot; otherwise ErrWorkspaceMoved keeps
the winner's worktree and reports where it is. Every other worktree is
removed.
Approving the one best_of_n call is what authorizes its attempts: they
run in auto mode, where deny and ask rules still hold, or in dontAsk
when the session is. An attempt's kernel never offers best_of_n, so
attempts cannot fan out again. Attempt transcripts live in a temp dir.
Their usage reaches the session as best-of, and the judge's as
best-of-judge. The judge is advisor_model when set, else the session's
model.
An attempt that stops at its own readiness gate (FinalReadinessError)
still has its diff judged, with the host's reason attached; any other
error takes it out. With one finished attempt no judge is paid; a judge
that names no finished attempt applies nothing.
Three reports, one cause and two gaps.
- Choosing another conversation hid the one that was running. openPane
took any pane without a session path to be the blank one and rebound it.
A new conversation's path is minted at the first submit, so a pane
running its first turn still looked blank to a pane list read before it.
A pane that is working, or has reported a path, is no longer blank.
- 「进行中 1」listed nothing. The tree's rows came only from transcripts
on disk, and a first turn in flight has not written one yet. The host
tree now also lists the conversations its panes hold that the listing
lacks, so the running one can be found and returned to. A session past
the per-folder cap is covered the same way. Such a row reads 新会话
rather than its file name, and its tab takes the numbered fallback.
- With one pane open there was no way to close a conversation: closing
lived only on the tab strip, which appears from two panes up. The row
menu now offers 关闭会话 for a conversation a pane holds, and refuses it
while a turn runs, like archiving does.
agent.advisor_model offers the working model an advise tool. A call
hands the conversation so far and one question to the advising model,
which answers in text with no tools; the answer is the tool result. The
cheap model keeps the loop and pays for the strong one only when it
decides a moment is worth it.
The agent binds its conversation to each tool call through
tool.TranscriptReader, a copy of the canonical transcript. The advisor
renders it as text, since tool calls cannot be replayed into a request
that declares none: the first user message is always kept, the rest
from the newest back within 120 KiB, local-only messages never. Usage
bills the new advisor source at the advising model's rate.
The tool is registered only when advisor_model is set and resolves, so
whether the schema carries it is prefix configuration decided at boot;
there is no fallback, since advice from the same model is none.
UseCapabilityTool, the MCP capability runtime, on-demand connects,
capability listing and search, and the argument-contract reading they
share move from internal/runtime/agent to internal/runtime/usecap. They
were already their own types rather than agent methods, and they need
nothing from the agent. usecap depends on the agent not at all; the
agent imports it.
- The two MCP checks both sides make are now tool.HasMCPDestructiveHint
and tool.IsMCPServerAuthorized. They live in contract/tool/mcp_server.go
beside the interfaces they read.
- The agent's call-contract gate and the proxy's hints share
usecap.ReadArgumentContract, with its Hint and Broken.
- FilterCapabilityListResult, FilterCapabilitySearchResult,
ParseMCPServerCapabilityID and WithContractHint are exported for the
child registry and the dispatch path.
The integration tests that drive a real agent through the proxy stay in
agent. The runtime's own tests move to usecap. Tests that check the
proxy's judgements against agent or delegation types run as usecap_test
through export_test.go. No test was added or removed.
Baseline: usecapability.go carries its debt unchanged under the new path,
and delegation/task.go and control/controller.go each gain one import
line (-allow-widen).
agent.screen_external_content asks the triage model, once per external
tool result, whether the text addresses the agent and tries to change
what it does. The verdict is advisory: a flagged result keeps its text
and gains a host notice under its provenance label, and the user gets a
suspected_injection notice naming the tool and origin. A screen that
fails or times out flags nothing.
Screens in one batch run in parallel on the triage model and bill
injection-screen. askClass's request loop becomes askTriage so both
questions share it. The prefix does not depend on the switch.
UserDecisionPolicy and LanguagePolicy move to policies.go beside the
other prompt policies, keeping config.go inside its budget.
Projection, compaction and token estimation were 121 methods on *Agent,
free to touch any of its state. They now form one subsystem inside the
agent package:
- windowState holds what the window owns for a conversation: the
installed projection, the compaction in flight, the pressure latches.
It used to sit as eight loose fields on sessionRuntime.
sessionRuntime.reset hands a new conversation to windowState.reset, and
the lifetime guard in sessionstate_test.go now holds both structs to
their reset and carry-over lists.
- The window's methods live on *contextWindow, which carries only its
state, the session, the configuration, the services and a windowHost.
- windowHost is everything the window asks of the loop: eight callbacks
(the todo and host-context tails, tool facts, the provider projection,
preparing a sampling request, the extension compaction intercepts,
failure annotation) and Session. It was derived from the compiler, not
written by hand, so it is the whole dependency.
- The loop reaches the window through a.window(). The agent's public
context API (ContextBudget, CacheState, LoadProjectionSidecar and the
rest) is unchanged and forwards to it.
The new repolint rule window-state holds the boundary. Only methods on
the window's own types may read sess.win, and the owners are declared in
tools/repolint/windowstate.go. REASONIX.md names the boundary.
Behaviour is unchanged. No test was added or removed. Tests reach the
window's internals through a.window() like the loop does.
A web page, a browser tab, the desktop or an MCP server can write text
that reaches the model as a tool result, and nothing told it apart from
the workspace's own output. The host now knows the origin by type: a tool
declares it through tool.ProvenanceDeclarer (web_fetch, browser_*,
computer_*), and any tool carrying MCP identity is labelled with its
server. The label is read after the call ran, so a page the call navigated
to is the one named, and is added only to the provider-bound message; the
trajectory and frontends keep the raw result.
ExternalContentPolicy in the prefix says what the label means. This moves
the cached prefix once (golden regenerated).
The client went ahead when an authorization server's metadata omitted
code_challenge_methods_supported. The spec makes refusing a MUST: without it
the client cannot tell whether the server verifies the challenge it is sent.
It now refuses with ErrOAuthPKCEUnadvertised before any browser opens, and
the error names the escape hatch.
Some real authorization servers leave the field out, so a server entry can
set oauth_allow_missing_pkce_metadata = true. It is honoured only from a
config the user keeps in their own home (user config, legacy user config,
~/.claude.json): a project's config, its .mcp.json and an installed package
are written by someone else, and none of them may relax the check for
whoever opens or installs them. MCPConfigSource.UserAuthorized() also covers
project sources, so it could not be the test. PKCE S256 is still sent when
the check is relaxed.
The renderer writes the flag back, or the next config save would drop it.
The per-server overrides moved from render.go to render_plugin.go to keep
render.go within its complexity and size budget.
Security review (internal/ext/plugin is sensitive): the default is stricter
than before; the only relaxation is per server, from user-owned sources,
pinned by a test per source; the authorization URL still carries an S256
challenge, pinned by the test. plugin, pluginspec, config and boot tests
pass on a clean worktree; golangci-lint reports 0 issues; the coverage gate
does not flag internal/ext/plugin.
The transcript was one bordered, shadowed card holding every turn, so the
frame grouped nothing; turns are now separated by their own rule. The text
column lines up with the composer below it, and the scrollbar sits at the
pane edge with its gutter reserved on both sides so the column stays centred.
Under a wallpaper the column still needs a surface to be read against. It is
a veil behind the text that fades out at the sides and the bottom rather than
a card with edges.
Which language a turn answers and reasons in now lives in
internal/runtime/langpref: the context preferences a host sets, the
transient blocks that tell the model, and inference from the user's own
text. It depended on nothing in agent, and control and delegation read
it directly rather than through the agent. The block-detection helper
the workspace block shares is exported as HasLeadingInjectedBlock.
This is the one cohesive, already-exported helper left in agent. The
rest of what depends on nothing there is private to the loop, and
exporting it to give it a package would only widen the surface.
Baseline: langpref.go carries its essay debt unchanged under the new
path, and agent.go and delegation/task.go each gain one import line
(-allow-widen).
The task tool, read-only tasks, fleets, parallel tasks, the sub-agent
store and records, profiles and the child's capability grant move from
internal/runtime/agent to internal/runtime/delegation. They are clients
of the agent: they build child agents and run them. The executor loop
now names none of them.
What the loop itself does for a child stays in agent. child_run.go holds
running an agent as a child and composing its answer and host receipts.
child_registry.go holds shaping the child's tool surface. Delegation uses
a small exported surface for these: AppendHostReceipts, AttachReviewReport,
CompleteSubtaskContract, ExpandToolPatterns, MaxToolOutputBytes,
NormalizeToolSchemas, SubagentImageNote, VisionRefFor, WithCallContext
and the host-receipt labels.
Tests followed the code. The ones that check the agent's own judgements
against the real delegation tools (batch skipping, the plan-mode barrier,
contract hints, delegation profiles across the proxy) run as agent_test
in the agent directory through export_test.go. The user-turn retention
test is split so each package pins its half. Test helpers used on both
sides are copied, since neither test binary may import the other. One
test more than before, from that split; none dropped.
Baseline: the moved files carry their debt unchanged under the new path,
and task.go gains the one import line the move costs (-allow-widen).
explore answers questions about the code; the parent often only needs to know
which files and lines to read. locate is a read-only built-in subagent for
that: it issues all its searches (grep patterns, globs, code_index) in one
message so they run concurrently, confirms candidates in a second round, and
returns only "path:start-end — reason" lines, or "no match" with the patterns
tried. Its tool is capped at four rounds.
The cap is a per-call value, not a profile field (profile_boundary_test
forbids that): SubagentRunOptions.MaxSteps, set by the locate tool and taken
as the tighter of it and the default budget. It is also host-owned: a child
whose cap came from its entry point runs under WithRunStepLimit, so the grace
round it gets at the limit ends with its answer. Without that, the first real
run paused at the cap and handed the parent an error instead of the list,
because a subagent's budget was treated as the user's own limit. explore and
the other subagents keep their default budget and behaviour.
No model is pinned: a built-in naming deepseek-flash would fail for every
user on another provider. subagent_models.locate selects a cheaper model.
locate is added wherever the built-in subagent tools are listed: the
recursion exclusions (so the depth cap holds), the capability gate, named
profiles, evidence meta tools, hook mapping to Claude's Agent tool and doctor.
The Claude Agent tables moved from hook.go to hook_claude_agent.go to keep
hook.go within its size budget.
Measured once each on this repository with the default model, two questions:
locate vs explore, 20.3s vs 23.2s and 27.2s vs 27.9s wall, 18% and 16% fewer
prompt tokens, 8% and 1% lower cost. The gain is modest at n=1 on the same
model; what it changes is the answer's shape and a hard round cap.
task.go carried three concerns. Two of them are the agent's own and now
live in core files:
- child_registry.go: shaping a child's tool surface. This covers the
registry filters, foreground-only bash and the restricted
use_capability proxy.
- child_run.go: running an agent as a child. This covers
RunSubAgentWithSession, NewPlannerAgent, the read-only runners,
EvidenceSummary and the review-report nudges.
task.go keeps the TaskTool. delegationProfile moves into execute_one.go
and decorateExecutionReceipt into tool_receipts.go, beside their only
callers. reviewGrantFor goes to the TaskTool that alone uses it. The
agent hands its mutation observer to a spawning tool through an
interface instead of asserting *TaskTool.
After this, nothing in the executor loop names a delegation type, which
is what lets delegation leave the package next.
Baseline: child_registry.go and child_run.go carry the essay debt that
left task.go, and task.go's budget drops by the same amount
(-allow-widen); totals are unchanged.
The client advertised 2024-11-05, four revisions back, and never read the
revision the server answered with, so it could not tell which protocol a
session ran on. It now offers 2025-11-25 and accepts any revision it
implements (2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05); a server that
chooses another is refused with ErrUnsupportedProtocolVersion, as the spec
has the client disconnect rather than guess.
Streamable HTTP now states the negotiated revision in MCP-Protocol-Version
on every request after initialize, and not on initialize itself. This has to
land with the version bump: a 2025-06-18+ server that gets no header assumes
2025-03-26 while the client believes it agreed on a newer one. The reconnect
wrapper forwards the value to the live connection; a replacement negotiates
its own during its handshake.
Also brought to the spec's MUSTs and SHOULDs:
- A 404 to a request that carried a session id is an expired session,
whatever the body says. The old check also required JSON-RPC -32001 and
the words "session not found" in the server's message, a judgement read
from an external server's prose; a 404 with no session held stays an
ordinary status (a wrong address, not an expiry).
- close() ends the server's session with a best-effort DELETE.
- A call the host abandons over Streamable HTTP sends notifications/cancelled,
as stdio and SSE already did.
- Tool results keep every content type: resource_link, embedded resources
(text inline, binary named) and audio reach the model as text markers
instead of being dropped, and a result with only structuredContent is
given as its compact JSON rather than as an empty answer.
parseToolResult moved to toolresult.go; plugin.go is 38 lines shorter.
Declared client capabilities are unchanged (roots only), which is what keeps
every newer feature opt-in. Elicitation, sampling, tasks and the stateless
2026-07-28 revision are left for later.
Security review (internal/ext/plugin is sensitive): the header value comes
only from the client's own list of revisions; DELETE goes to the configured
URL with the configured headers and session id; the new content types carry
the same server-supplied, untrusted text as before, under the existing body
limit. plugin, boot and builtin tests pass on a clean worktree; boot's fakes
still answer 2024-11-05 and connect. The coverage gate does not flag
internal/ext/plugin; golangci-lint reports 0 issues for it.
Write path sets, write grants and the sub-agent scheduler move from agent
to internal/runtime/writeclaim. They depend on nothing else in agent,
and both the executor loop and sub-agent delegation use them, so they
are a primitive beneath both rather than part of either.
The executor's path-effect check shares the claims' containment rule,
exported as PathWithin. Tests that waited on the scheduler's queue
through its mutex now ask SubagentScheduler.Queued. The tests that
exercise an agent binding tool calls against claims and grants stay in
agent.
Baseline: scheduler.go and write_claims.go carry their essay and orphan
debt unchanged under the new path, and agent/task.go gains the one
import line the move costs (-allow-widen).
Coordinator, which wraps an executor agent with a planner, moves to
internal/runtime/coordinator. Nothing in the agent's own loop referenced
it; it was a client living inside the package it drives, reaching into
the executor's fields.
It now drives the executor through exported agent methods, collected in
agent/coordination.go: ModelRef, ProviderName, ToolSchemas,
MarkExecutorHandoff, WithTurnPreferences and MaxStepsPauseOf. Helpers it
shared with the loop are exported by rename: UserImages,
NormalizePlannerDecision, WithRunStepLimit, IsToolLoopPause,
TrackPublishedHostStream, PlanningPhase, PlanContract, SummaryTagOpen
and SummaryTagClose. Runner and PlannerPlanApprover stay in agent, since
control consumes them as the agent's contract.
Coordinator tests moved with it, and so did the agent tests that were
really coordinator tests. The one test of an agent message moved back.
The planner-sink audit check is its own test now. Test helpers used on
both sides are copied, since neither test binary may import the other.
Baseline: coordinator.go and coordinator_test.go carry their essay and
orphan debt unchanged under the new path (-allow-widen); totals are
unchanged.
On --hair the rule sat at about 1.1:1 against the light transcript card and
read as texture. It is the transcript's one structural divider, so it takes
--border, the step the cards inside a turn are drawn with. The space above it
is wider than below, so it groups with the turn it opens rather than floating
between two.
Trajectories keep derived events, never what was sent to or received from
the provider, so no run could be played back and nothing caught a kernel
change that altered what the model is asked. The cache-first prefix is meant
to stay byte-stable, and only a live run and its bill showed when it moved.
-tape-record DIR records every exchange at the meter, one tape per task and
trial. -tape-replay DIR answers each run from its tape instead of the
provider, and the report's Replay line names the first request whose body
differs from the recorded one: a message by index and role, the tool list,
or a field. The diverging request is kept beside the recorded one.
Two sources of noise reach the model and had to be held still for a replay
to match: the random workdir path, which is in the workspace block, and the
seeded files' times, which appear in any listing the agent takes. Taped runs
use a fixed workdir per task and trial and pin those times, including the
tape root's, since it is ".." in the listing.
Verified with a real run of bugfix-slug-trailing: recorded in 15.3s for
about USD 0.0014, replayed in 6.0s with no provider traffic, all 6 requests
matched, same tokens and cache hits. The control arm holds: replaying with
-ablate all is reported as "diverged at request 1, tools".
The transcript was cut into fixed 48-card blocks and a hairline was drawn
between blocks, so the only divider in a long session landed wherever the
48th card happened to be, often in the middle of a turn. Worse, transcriptRows
runs per block, so a turn cut in two left its trailing work with no sentence
to sit under.
Blocks now gather whole turns until they reach 48 cards and close at the next
turn, and each turn opens with a rule of its own. Steered and queued lines do
not open a turn. The cutting moves to blocks.ts to keep Transcript.tsx under
its size ceiling.
-attempts retries until a pass, so it measures whether the agent can solve a
task at all (Pass@<=N). Nothing measured whether it solves it every time,
which is what a user relying on it meets. -trials N runs every task N times,
keeps every outcome, and adds a Reliability line: pass^N (tasks that passed
every trial), pass@N (any trial), mean pass@1 over all trials, and the flaky
tasks by name. A trial the token budget skips is not counted as a failure,
and the line says how many tasks ran every trial. -trials and -attempts
count repeats differently and are refused together. A run without -trials
reports exactly as before.
Verified with a real run: bugfix-slug-trailing, two trials, both passed,
reported as pass^2 100% (1/1), for about USD 0.001.
The guardian decides whether the user authorised a risky action, and its
evidence carried the agent's own prose and every tool result. Both are where
injected text arrives: a fetched page, a file or an MCP server can write "the
user pre-approved this", and the agent can be talked into repeating it. The
policy already called the transcript untrusted; the reviewer still read the
claim.
The transcript now holds user turns and every tool call with its arguments,
and nothing else. This also fixes calls issued beside prose: an assistant
message with text and tool calls kept only the text, so the reviewer missed
calls the agent had actually made. The policy says what is withheld and that
authorization is read from the user's messages alone.
The new test fails on the previous extraction ("tool output or agent prose
reached the guardian") and passes on this one. guardian, control, builtin and
boot tests pass on a clean worktree.
build now reads as load, wireTools, controller, freeze. The state one
stage settles for the next lives on a builder rather than in 150 locals
of one function, and each stage fits the function-size and complexity
limits.
Sidecar ownership keeps one owner: builder.pendingMgr holds the
preflighted manager until freeze hands it to the runtime set, and the
deferred retire closes it on any earlier failure, as before.
boot.go now carries no complexity or function-size debt; its file-size
budget drops from 898 to 167.
The tokens-per-second, cache, context and cost row sat in its own bordered,
blurred, shadowed box below the composer, a second card under the first.
It is now a plain row of text in the same place.
A Markdown file read in the workbench linked to its neighbours and showed
images stored beside it, but both were left as written: a relative link
opened the kernel's own address in a new window and a relative image never
loaded.
References now resolve against the document's folder, and a leading slash
against the workspace root. A link to a workspace file opens it in the
workbench, rendered if it is Markdown; an anchor alone does nothing rather
than reloading the window; links with a scheme keep the routing every other
link has. A reply in the transcript is unchanged.
Images load from GET /workspace/image, confined to the workspace through
os.OpenRoot, typed by extension rather than sniffed, capped at 10 MiB, and
served under "Content-Security-Policy: default-src 'none'; sandbox" with
nosniff, so an SVG opened at that address cannot run script on the kernel's
origin. Errors reuse the workspace codes the frontend already words.
Verified in Chrome against a real kernel: a README link opened docs/DESIGN.md
rendered, its img/logo.png loaded through the new route, and its
../README.md link came back.
build was one 1138-line function that knew every subsystem. Each phase
now lives in its own file and build calls them in order:
- config_migrations.go: the pre-load migrations and their notices
- extension_stage.go: sidecar preflight, the UI hub's controller handoff,
sidecar provider merging, and which assembly failures are fatal
- model_selection.go: the executor model and preset
- tool_environment.go: write/read roots, the bash sandbox spec, search
- mcp_registration.go: host-session and configured MCP servers, LSP
- tool_wiring.go: hooks, session tools, skill and slash tools, the
use_capability surface
- delegation.go: the task tool and skill sub-agent options
- roles.go: planner, guardian, recovery reviewer, semantic router
Behaviour is unchanged. build drops from 1138 to 440 lines; boot.go's
complexity budget from 125 to 9. Comments that narrated how the
assembly got here are replaced by the constraint that holds.
A .md, .markdown or .mdx file in the workbench now has Read, Edit and Diff
modes and opens in Read: a document is read far more often than edited, and
its source was the only view. Reading renders the editor's draft with the
transcript's own Markdown component, so tables, highlighted code, math and
emoji look as they do in a reply, and an unsaved edit shows before it is
saved. Other files keep File and Diff.
The workbench test opens README.md, so it now asserts the rendered view and
the draft reaching it, then switches to Edit before typing.
Every settings route is a runtime's, and the sheet rendered only when a pane
had a port, so with every pane closed the wallet, account and settings
buttons set the request and drew nothing. Opening a conversation then
satisfied the condition and the sheet appeared late, unasked.
Asking for settings with no pane now opens a session in the current folder,
as the empty state's own "new session" button does, and the sheet follows
it. With no folder at all there is nowhere to open one, and the error bar
says so instead of the click doing nothing. The request waits for the first
read of the pane list, so a click during startup does not open a second
session beside one that already exists.
A self-signed or company-issued certificate made a page fail with no way
through, while the external browser offered one. The failure now carries
the certificate - issued to, issued by, expiry, fingerprint - and the panel
offers "Proceed anyway (this run only)".
What is trusted is one host and one certificate fingerprint, kept in the
window's main process memory: a restart forgets it, and the same host
presenting another certificate is refused again. Trusting is reachable only
from the window's own page over IPC, so neither the kernel nor the agent
driving the browser can trust a certificate; a certificate error is never
passed by default, since the agent browses through the same views.
Verified in a hidden window against a local HTTPS server: the first visit
fails with ERR_CERT_AUTHORITY_INVALID and the certificate's details, trusting
loads the page, trusting with no refused certificate is declined, and the
same host serving another certificate is refused again.
configPackages arrived with the config inversion untested inside pluginpkg,
which took the package below its coverage floor. The test drives an
installed v2 package through it: an enabled package's command and prompt
roots and MCP servers reach configuration, and a disabled one contributes
nothing.
POST /browser/open waited for the main frame's load event before answering,
so a page whose subresource never finishes - an intranet front end that
references a CDN or analytics host the network cannot reach - held the
request for the whole 30s navigation timeout. The page was on screen within
milliseconds, but the panel did not switch to it, and a clicked link then
fell back to the external browser as if the open had failed.
Session.Visit returns once the navigation commits, bounded by the same
timeout; Controller.BrowserOpen, which only the person's POST /browser/open
reaches, uses it. The agent's browser_open tool still calls Open and still
waits for load, because it reads what loaded. Visit runs the same checkURL
as Open, so nothing becomes reachable that was not.
Measured through the real host, relay and views in a hidden window, against
a page whose script never answers: 30202 ms -> 113 ms. A new blank tab stays
at about 100-170 ms, whether or not another page is still loading.
- guardian moves from safety to runtime: it runs an agent to review a
turn, so it is a turn-engine client, not a subsystem.
- jobs moves from runtime to tools: it imports nothing above the
subsystem layer, and builtin's bash and background-job tools own it.
- recovery.Metrics becomes hostaudit.RecoveryMetrics. The telemetry sink
reads a counter record and no longer imports the recovery gate, which
depends on the agent.
- RedactMessage and RedactMessages move from base/secrets to
contract/provider, built on secrets.Redact, so base imports nothing
from contract.
layer-order reaches 0: every kernel import now points at its own layer or
below. Baseline carried with -allow-widen for one line: recovery/gate.go
gains the hostaudit import.
Three moves close the edges from the contract layer into runtime/evidence:
- The audit records the host emits beside a run (ReadinessAudit,
OutcomeSample, DelegationAudit) move to contract/hostaudit, so event and
trajectory carry them without the ledger that computes them.
ClassifyEvidenceOrigin, which reads delegation text, stays in evidence as
a function over the record.
- taskcontract and plancontract move from contract to runtime: only the
agent and completion consume them, and both are built from evidence
receipts rather than declaring data anything lower reads.
- evidence moves from runtime to safety. It imports nothing above the
subsystem layer, and builtin tools and the trajectory recorder, both
subsystems, depend on it.
layer-order drops from 18 to 5.
The title bar's browser button opened an iframe even in a shell that draws
views, so any site sending X-Frame-Options or frame-ancestors, which many
intranet front ends do, could not load there however the address was typed.
In a shell that draws views it now opens a view, as the workbench's + did.
Typed addresses:
- Only "scheme://" names a scheme, so "intranet:8080" and "localhost:3000"
are a host and a port rather than a refused scheme.
- A host that cannot be public (a loopback, private, link-local or shared
address, localhost, or a name with no dot) is loaded over http directly;
any other bare host tries https and falls back to http.
- A refused address says so under the address bar instead of doing nothing.
Failures: a typed load is judged by loadURL's own answer, since a refused
port never starts a navigation and so raises no did-fail-load; other loads
report through did-fail-load. The panel puts the view away and shows the
Chromium error with a plain reason, a retry, and an external-browser button.
Logins: a page or proxy asking for Basic, NTLM or similar authentication
gets a prompt in Studio instead of Electron's default cancel. What is typed
goes to the window's main process only; the kernel and the model never see
it.
Verified in a hidden Electron window against a local server: a page sending
X-Frame-Options loads, a closed port reports ERR_CONNECTION_REFUSED, port 1
reports ERR_UNSAFE_PORT, a Basic-auth page prompts and loads after the
answer, and a dotted host falls back from https to http.
Session persistence moves from internal/runtime/agent to
internal/state/sessionstore: the Session type, its checkpoint and event
log, listing sidecars, branches, leases, recovery copies, migration, and
the compaction and subagent records saved beside a transcript. The agent
keeps the loop and imports the store; history, historycatalog, migration,
guardian, instanceid and sessiontool now reach the store instead of the
agent, which drops layer-order from 26 to 18.
Tests that exercise only store internals moved with it; the rest stay in
agent and qualify the moved names. DigestSessionMessages and
CompactionStateSchemaV1 are exported for the agent tests that build
legacy fixtures.
Baseline: carried with -allow-widen. The moved files bring their essay,
file-size, complexity and orphan debt unchanged under new paths, and
boot.go, acp/service.go, serve/serve.go, agent/task.go and
control/controller.go each gain the one import line the split costs.
The title-bar toggle drew one fixed half-disc and read the stored setting
rather than the scheme on screen. Under "follow the system" with a dark
system, the first click wrote "dark" and nothing changed. It now reads the
resolved scheme: a sun while light, a moon while dark, and one click always
flips what is shown.
The wordmark's tracking goes from -.065em to -.025em, and the gap between
"reasonix" and "STUDIO" from 1px to 4px.
The card inherited scrollbar-width and scrollbar-color, and while either is
set Chromium ignores every ::-webkit-scrollbar rule. So Electron drew the
Windows scrollbar: its arrow buttons sat on the card's rounded corners and
its track ran through them, and the rules meant to hide the buttons and inset
the track never applied.
The card now sets both standard properties back to auto and is drawn by the
webkit pair: no buttons, the track inset by the 20px corner radius, a 4px
thumb inset 4px from the border, faint at rest, stronger while the pointer is
over the card and when the thumb is held.
Probing an address and listing a provider's models sat in contract/config as
methods on ProviderEntry, so describing a provider and calling one over the
network were the same package, and configuration imported model/openai to do
it. They move to model/catalog: ProbeEndpoint and its reasons, the model-list
URL candidates, and FetchModels / FetchModelsVia / FetchModelListingVia, now
functions taking the entry. A probe sets its one-off key through
WithAPIKeyForProbe instead of the private field.
Configuration now imports nothing above contract. The responses-mode fold test
goes back to config, whose migration it tests; the wizard's model-listing
helpers leave cli.go and setup_manager.go for files of their own.
layer-order debt: 29 -> 26.
Configuration asked model/openai whether a base URL was DeepSeek, Zhipu,
MiniMax and the rest, reaching up a layer for pure host arithmetic that every
protocol package shares. The predicates and DeepSeek's declared image-reading
models move to contract/provider beside IsMiMoEndpoint, named IsXEndpoint;
openai keeps its own names as one-line forwards for its wire code, and loses
IsTokenRhythm, which only its test still called.
layer-order debt: 32 -> 29.
model/billing held two things: what a call costs (fixed-point money, the
official rate tables, quotes and the ledger) and asking a provider for its
wallet balance over HTTP. Configuration, the event stream and its wire form
needed only the first, and reached up a layer to get it.
The data and arithmetic move to contract/pricing; model/billing keeps the
wallet query (balance, cache, the response shapes) and reads pricing like
everyone else. Names and behaviour are unchanged; callers say pricing.X for
what moved. MigrateLegacyUsage's orphan finding travels with its file
(-allow-widen for the carried entry).
layer-order debt: 40 -> 32.
Making a theme meant knowing that packs are read from <state>/themes/<id>,
which nothing in the window said. The theme section now has two controls:
- Import theme… takes a .zip, or a pack folder's theme.json and images
selected together. The page sends bytes, never a path, so it works from a
browser tab and against a kernel on another machine. The kernel keeps only
the manifest and the two images a pack may carry, validates the manifest
with the reader every pack goes through, and swaps the directory in whole,
so a failed import leaves the previous version of a pack intact. A pack
that is not one refuses as theme.not_a_pack; a disk that would not take it
as theme.install_failed. After an import, "Use now" activates it, since a
pack with a picture lands in the folded list.
- Open themes folder creates the directory if needed and shows it in the
system file manager.
Ids are the manifest's, then the folder or archive name; the dot entries and
the plugin: namespace are refused, and List skips dot-named staging dirs.
contract/config imported eight packages from the layer above it. This takes
five of them out, each by moving the piece to where it belongs rather than
hiding the import:
- The installed plugin packages merge into configuration through data config
declares (InstalledPackage, PackageMCPServer); pluginpkg converts a parsed
package into it and names itself the source at init. How a package merges
with what the person wrote, conflicts included, stays in config; how a
package is parsed never enters it. That also breaks the loop that kept
command dirs typed by the command package: config names CommandDir and
command.Root is an alias of it.
- A permission rule's syntax is the form config stores it in, so Rule and its
parser move to contract/permrule; permission keeps both names.
- mcpdiag has no dependencies and moves to contract. The directory move itself
landed in 03e0cb894, swept in from a shared index; this completes it.
- The workspace path fingerprint is a pure function of a path and moves to
base/workspaceid, with the same result.
- The system-one migration writes the hosted address it was written for.
computer_act and computer_read now have one description on every platform,
naming the difference (Meta+s or control+s; paste needs the front on macOS, on
Windows typing brings it forward), so the generated tool contract, the schema
budget and the cached prefix no longer depend on the host OS.
layer-order debt: 47 -> 40.
Project and conversation titles were both 11px, but a project name is
usually Latin lowercase and a conversation title usually Chinese, whose
glyphs fill the em box; at one size the child read larger than its parent.
The project name is now 12px, between the machine's 13px and the
conversation's 11px, with the conversation title's tracking.
The card was painted on .flow, which scrolls with its content, so its bottom
edge ran under the composer and was cut flat there. It was also centred in
the scroller's width less its scrollbar while the composer was centred in the
full width, which set the two 5px apart.
The scroller is now the card: fixed between the tab strip and the composer,
the scrollbar inside its rounded edge, and the same width formula as the
composer so their edges line up.
internal/ was 141 packages side by side, so nothing but a reader's memory
said which were foundations and which assembled the rest. Each now sits in a
layer directory, and the directory is the declaration:
base L0 foundation with no domain in it
contract L1 types and interfaces the layers above agree on
model, tools, safety, ext, state, platform
L2 subsystems, peers of one another
runtime L3 the turn engine
session L4 the controller
assembly L5 the composition root
frontend L6 cli, serve, acp and the host adapters
Only paths change: package names, code and behaviour do not. Imports,
repolint and coverage baselines, sensitive paths, CI package lists and docs
follow the new paths; release notes keep the paths they were written with,
and sample paths inside tests and fixtures keep theirs.
repolint gains layer-order: a package may import its own layer and those
below it, read from where it lives. The 47 imports that point upward today
are recorded as that rule's opening debt (-allow-widen, a new rule's first
baseline) and can only go down. Most of it is config, which still reaches
into eight subsystems and is the next thing to take apart; the rest is
event, history and a few tools reaching into runtime for types that belong
in contract.
Only the project holding the focused pane dropped the node's 12px inset, so
the project a fresh window opens into sat one step left of its siblings until
focus moved. Remote projects already sit at the uninset position; local ones
now match.
The shell cleared look.wallpaper before applying the user's appearance, left
over from when packs were kept off the shell; packs came back and the
wallpaper stayed stripped, so an upload saved and never showed.
The glyph was centred on a guessed origin 8 units right of its bounding box;
it now sits on the box's centre.
The tray had one 16px bitmap, which a scaled display enlarges. It now ships
@1.25x/@1.5x/@2x/@3x variants that nativeImage picks by scale, rendered
without the tile's margin so the slot is filled. The .ico gains 20 and 40.
A runtime could only replace a tool by name, and that slot was never routed
to it, so a plugin that wanted to add one had to ship an MCP server. A
runtime now declares tools in its manifest, under runtime.tools with the
tools capability: a name, a description and an object input schema, known
before the process starts and shown in the install plan and the package
list. At initialize the runtime names the declared tools it serves; one the
manifest does not describe fails the handshake as capability_not_declared.
The model sees each as ext__<plugin>__<tool> and reaches it through the
capability catalog the way it reaches MCP tools, so installing or reloading
a plugin never moves the cached prompt prefix, and every call passes the
permission check under that name. A call is the new host request
extension/tool/call, bounded at five minutes; isError carries a failure the
tool reports about its own work, apart from a call that could not run.
A runtime that failed to start contributes no tools.
The Go SDK serves them from Options.Tools and names them in the handshake.
An effect test drives a call through the real assembly to a fake runtime and
back to the model, and fails with the registration removed.
Both were parsed by pluginpkg and went nowhere, while the docs said they
worked. A package's prompts are now discovered with its commands and invoked
the same way, /<plugin>:<name>, which is what the manifest documented. Its
themes are Studio theme packs: an entry names a pack's theme.json, and the
theme library lists it as plugin:<plugin>:<directory>, read in place from the
plugin root with its own images, never copied into the user's library, and
gone when the plugin is disabled. The retired desktop's .reasonix-theme
archives are not a format Studio reads, and the docs now say so.
The plugin docs are brought in line with the code: the Studio installer takes
a repository address or a folder (a .zip installs through the CLI), the routes
Studio drives replace a list of Wails methods that no longer exist, and the
protocol documents the panel and view surface kinds it already carries.
A spark button beside attach (Ctrl+Shift+E) asks the kernel to rewrite what
is in the box. The rewrite is offered in a card above the draft — adopt, try
again, or discard — and the draft is only replaced when it is adopted; a card
over a draft that has changed since says adopting will overwrite it.
The rewrite is Controller.RefinePrompt, so every frontend can drive it, served
at POST /prompt/refine. It is one bounded no-tool call on the entry the
session resolved, with reasoning off, reading the draft, the workspace name
and the last four turns so a reference like "that bug" can resolve. It keeps
the draft's language, paths, code and scope, and adds nothing the person did
not ask for. Usage is billed as its own source, prompt-refine. Each reason no
rewrite is made carries its own code: empty, too_long, no_model, timeout,
no_answer, failed.
Assembling the goal evaluator moves out of boot.go beside the refiner, and
the history accessors out of controller.go, so neither file grows.
The key action named only the keys a handful of shortcuts needed, so a
control+b, an F5 or a digit had nowhere to go but a refusal. Both helpers now
press every letter, digit, F1 to F12, insert and the punctuation keys, with
the same modifiers as before; the refusals of keys the shell answers stay.
A C++ helper speaks the macOS helper's protocol unchanged, so the kernel,
permissions and tools drive both alike. It reads applications through UI
Automation, captures a window with PrintWindow and encodes it with WIC,
injects input with SendInput, and draws the agent's cursor as a click-through
layered window, with Escape stopping a run while that cursor shows.
An application is named by its package family name when it is packaged and
by its lowercase exe name otherwise; the helper also reports the exe, and a
refusal under either name holds. Console windows report whichever program
owns the console, so no name can refuse them: the helper excludes them by
window class. A process with more privilege than Studio is refused as
computer.elevated rather than failing as a broken application.
Windows delivers keys only to the foreground, so typing, keys and paste bring
the application forward first, and the approval card says so
(computer_front). Pointer and point clicks check the window under the point
again at the instant of input, a hold ends when the application leaves the
front, and a drag always releases its button.
build:host builds the helper with MSVC found through vswhere; CI fails
without it, a dev build only loses the feature. Live tests drive a pure-Go
Win32 probe through the real helper.
When a turn had to wait for another session writing the same workspace,
the notice said "another session is writing this workspace" and carried,
as its detail, the English sentence "workspace write lease is busy;
read-only work remains concurrent". With several windows open the
person had nothing to go on: which conversation to look at, or whether
there was one at all, was left to guess.
A session now names itself when it takes the lease, from the title the
sidebar shows or, before one exists, its first message; only the cached
title is read, so naming a holder never costs a model call. A waiter in
the same process reads the name off the in-process lock, and one in
another process reads a 0600 note left beside the lock file and removed
on release. The notice's detail is that name, as data rather than
prose. Paths, pids and tokens stay out of it, as the lease's State
already promised.
The controller's lease surface moves to its own file and the session
title helpers join the rest of the title code, which pays for the new
lines and lowers both files' budgets.
A remote serve resolves every model call through the provider broker on
the machine that started it, reached over an -R forward. The serve read
that broker's address and token once, at launch, so the reuse check
refused any connect that published the broker elsewhere, and the
bootstrap then stopped the running kernel and started another. Every
other process that connected to the same host did that: a second Studio
window, a restarted one, another computer on the same account. The
conversation open in the first window lost its kernel mid-turn and read
"the kernel on this host is not answering". A reconnect that could not
get its old remote port back left the kernel dialling a port that no
longer existed.
The serve now takes --provider-broker-file, one 0600 file holding the
broker's address and token, and reads it on every request, validating
the address as loopback each time. The broker client asks an Endpoint per
request, so providers resolved before a move follow it. A kernel launched
this way records broker_file; reuse rewrites the file under the serve
lock instead of replacing the kernel, and a link that comes back on a
different port re-points every kernel on it, one reconnect at a time.
Kernels launched the old way are still replaced as before, and the flag
probe upgrades a binary that lacks the new flag.
Address and token share one file written by a single rename, so no read
pairs one connect's token with another's port, where a squatter on a
released port would receive both. The remote state directory is made
0700 and an atomic write narrows its temporary file before writing, not
after. The broker client no longer follows redirects.
The most recent connect's broker serves every conversation on that
kernel. That is no more than the SSH account could already do, since it
used to relaunch the kernel onto the new broker anyway; a window naming
which machine it resolves through is left for later.
2026-09-23 07:18:51 -07:00
4852 changed files with 315892 additions and 195272 deletions
Write-Output "::error title=studio-signing.unexpected-signer::STUDIO_SIGNING_SUBJECT is '$($env:EXPECTED_SUBJECT)' but $file is signed by '$($certificate.Subject)'"
$failed = $true
}
}
if ($failed) { exit 1 }
'Certum payload and installer signing, signer, trust and timestamps passed.' >> $env:GITHUB_STEP_SUMMARY
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A coding agent you can leave running.</h3>
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
<br/>
## Versions
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
- HTTP `serve` protections for the unauthenticated local server, including
localhost binding assumptions, JSON-only state-changing requests, and CORS
restrictions.
- HTTP `serve` protections, including the default per-launch token, the launch
token every state-changing request requires when authentication is opted
out, localhost binding assumptions, JSON-only state-changing requests, and
CORS restrictions.
- Desktop and bot session isolation, including per-workspace session metadata
and configured bot allowlists.
- Updater, install, and release verification paths.
@@ -67,10 +68,10 @@ The following are normally treated as trusted local/operator-controlled inputs
unless another bug lets an untrusted actor supply them:
- CLI arguments and text typed directly by the local user.
-Project configuration files intentionally loaded from the current workspace.
-The user's own configuration under the Reasonix home directory.
- Explicit `@path` references supplied by the local user to attach local files.
- MCP servers, language servers, hooks, and slash commands installed or enabled
by the local user.
- MCP servers, language servers, hooks, and slash commands installed, enabled,
or approved by the local user.
- Provider base URLs and model names configured by the local user.
The following can be security issues when reachable by an untrusted actor or
@@ -89,6 +90,56 @@ when they bypass the intended boundary:
- Trusting unverified update artifacts, plugin definitions, or downloaded
binaries.
## Project Configuration
Files that arrive with a workspace are untrusted input: `reasonix.toml`,
`.reasonix/settings.json`, `.mcp.json` and the project `.env`. Cloning a
repository is not a decision to trust its author, so these files may narrow
what the user configured and never widen it.
| Setting | What a workspace file may do |
| --- | --- |
| `sandbox.bash` | Move `off` to `enforce`, never back |
| `sandbox.network` | Move `true` to `false`, never back |
| `sandbox.forbid_read`, `sandbox.denied_domains` | Add to the user's lists |
| `sandbox.allow_write`, `sandbox.workspace_root` | Apply only when they resolve inside the workspace, symlinks followed; `${VAR}` in any sandbox path expands from the process environment only |
| `sandbox.host_authorities`, a user's `sandbox.allowed_domains` | Nothing |
| `permissions.deny`, `permissions.ask` | Add to the user's rules |
| `[[providers]]`, `tools.system_one` endpoints | Declare them; they stay out of use until approved, and model settings naming them fall back to the user's |
An "always allow" answer, or extra write access, granted in a workspace is
stored under the Reasonix home, not in the workspace.
Programs a workspace names for the host to run stay off until the user
approves them for that workspace:
- Hooks, language server commands, `tools.search.rg_path`,
`tools.shell.path`, `[browser]` and `tools.system_one.laya.python` are
approved with `reasonix trust`, or for hooks by saving them from the hooks
editor.
- An approval is stored under the Reasonix home with a digest of the
declaration and of the workspace files it names; any change needs approval
again. Approvals and grants belong to one workspace folder.
- Hooks and language servers are checked against their approval again before
each start, and refused if a file they name changed, appeared or now
resolves elsewhere.
- A single program path (`tools.shell.path`, `tools.search.rg_path`,
`[browser]`, `laya.python`) may not point into the workspace, an
`allow_write` root, a temporary directory or a toolchain cache the bash jail
leaves writable, and may not climb with `..`; its approval covers the
program file's content.
-`tools.system_one.laya.local` is read from the user's configuration only.
- Without an interactive frontend they stay off.
- MCP servers a workspace declares stay off until the user enables them for
that workspace.
A project value that is not applied is reported as a load warning with the
reason. A way for workspace files to widen any of the above without the
user's approval is a security issue.
## `@` File References
Reasonix supports `@path` references so users can include local files and images
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (tasks that passed every trial), `pass@N` (any trial), mean `pass@1` over all trials, and the flaky tasks by name. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
| `-tape-record DIR` | — | Suite mode, with `-meter`: record every provider exchange at the meter, one tape per task and trial (`DIR/<task>/trial-N/0001.request.json`, `.response.json`, `.response.body`). Taped runs use a fixed workdir path and pin the seeded files' times, because both reach the model through the workspace block and file listings. |
| `-tape-replay DIR` | — | Suite mode, with `-meter`: answer each run from its tape instead of the provider, and add a **Replay** line naming the first request whose body differs from the recorded one, down to the message (`request 2, message 4 (tool)`), the tool list, or a field. The diverging request is kept as `NNNN.replayed.json` beside the recorded one. A kernel change that should not alter what the model is asked replays with every request matched. |
| `-bin` | `reasonix` | Path to the reasonix binary. |
| `-profile` | `baseline` | Tool-surface/runtime tier: `baseline` \| `economy` \| `balanced` \| `delivery`. All but `baseline` append `--profile <tier>` to the agent invocation; `baseline` passes no flag (byte-identical legacy control, behaviorally `balanced`). Economy starts with the core tool set and pays `connect_tool_source` rounds plus prefix resets to grow it — the report's Tool surface line prices that trade. |
| `-json` | *(none)* | Write the JSON report here (optional). |
| `-trajectories` | *(none)* | Suite mode: write one `<task-id>.trajectory.jsonl` per task into this directory (the agent's full event stream with timestamps — see `reasonix run --trajectory`). The report gains a time-attribution line (tools vs. model) and each JSON result a `trajectory` digest. |
| `-force-planner` | `false` | Suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate. Use for the "with planner" arm of an A/B; results carry `plan_forced` so arms are only comparable with equal forcing. |
| `-anchor` | `blind` | Suite mode: which hypothesis the agent holds before it looks at anything — `blind` (none, the control) \| `correct` \| `wrong`. The seeded arms prefix each prompt with the task's authored seed and **skip** tasks that have none, so an unseeded control run never lands in a seeded denominator. Results carry `anchor`. See [Anchor resistance](#anchor-resistance). |
| `-cache` | `cold` | Suite mode: `cold` runs each task as a fresh session (the fair cross-agent comparison arm); `warm` primes the provider prefix cache with a one-step run in the same workdir first, measuring the long-lived-session steady state. Never mix arms in one report — compare them with `-mode compare cold.json warm.json`. |
| `-policy` | *(none)* | Suite mode: experiment arm. `memory-off` hides the memory store, which is the MemoryBench counterfactual arm; the empty default runs the baseline. |
| `-budget` | `800000` | Abort once total tokens cross this (`0` = no cap). Remaining tasks are reported as skipped. |
| `-meter` | *(off)* | Suite mode: route the benchmarked provider through the neutral measuring proxy, using this `config.toml` as the source. Spend is then counted at the request boundary instead of trusted from the harness. See [Neutral metering](#neutral-metering). |
| `-faults` | *(none)* | Suite mode: inject provider failures through the meter — absolute indices (`3:429`) and/or a cadence that scales with the run (`every:5:500`). Requires `-meter`. See [Fault recovery](#fault-recovery). |
@@ -538,33 +542,32 @@ writer.
## context-maintenance-e2e
This harness measures what happens when a long session goes idle past the
provider's cache TTL and then resumes: it A/B-compares cold-restart miss tokens
with and without pruning, and checks that the agent re-reads a file behind a
prune placeholder instead of hallucinating.
A cost-capped smoke for content-driven maintenance, in three legs over one state
directory: seed a session just below the compaction trigger, load it back and
check nothing is re-derived, then grow it past the trigger and check exactly one
summary lands.
It is hardcoded to the `deepseek-v4-flash` model at `https://api.deepseek.com`
and requires the `DEEPSEEK_API_KEY` environment variable.
Seed and resume take no provider; `continue` samples and needs
`DEEPSEEK_API_KEY`. The model is hardcoded to `deepseek-v4-flash` at
`https://api.deepseek.com`.
```sh
export DEEPSEEK_API_KEY=...
# Seed below the trigger: no checkpoint installed, no summary started
go run ./benchmarks/context-maintenance-e2e -offline seed
# Seed both arms (pruned + control) with a large session and warm the cache
go run ./benchmarks/context-maintenance-e2e seed
# Wait past the provider's cache TTL, then resume: prune the "pruned" arm and
# compare cold-restart miss tokens
# Reload: the projection version must not move and no summary may run
go run ./benchmarks/context-maintenance-e2e resume
# Run the comprehension trials (agent must re-read a pruned file and answer
# from it); exits non-zero unless every trial passes
go run ./benchmarks/context-maintenance-e2e comprehension
# Grow past the trigger: one summary, version +1, then no further summary
export DEEPSEEK_API_KEY=...
go run ./benchmarks/context-maintenance-e2e continue
```
| Flag | Default | Purpose |
| --- | --- | --- |
| `-dir` | `benchmarks/context-maintenance-e2e/run` | State directory for `seed`/`resume` (sessions + `meta.json`, `resume-<ts>.json`). |
| `-trials` | `5` | Number of comprehension trials. |
| `-dir` | `benchmarks/context-maintenance-e2e/run` | State directory shared by the three legs (session + `meta.json`). |
| `-max-usd` | `0.50` | Hard cost cap for the live legs. |
| `-offline` | off | Skip the live provider; `seed` only. |
Loaded 100 of 4852 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.