A call reached through use_capability only learned its delegation profile after the target finished, so the deck's sub-agent list and the running count missed every sub-agent until it ended. The profile is now announced when execution starts.
A notes response is accepted only when its declared type is markdown or plain
text, so an error page served with 200 is refused and never written. The cache
name is lower-cased with a digest for mixed-case prereleases and over-long
versions are refused. Expanding a row on a narrow window keeps its label and
date. Error wrapping and the wait group follow the linter.
The kernel serves GET /studio/versions/{version}/notes from the release mirror,
keeps each fetched document on disk for good, shares one request between
concurrent readers and refuses by typed code. A row whose catalog entry names
notes gets an inline view with images off.
The notes are optional, so a failed upload or head-object warns and writes the
catalog entry without a pointer instead of failing the Mirror to R2 step.
Studio release workflow uploads studio/notes/<version>.md ahead of the catalog
write and gives the new entry an optional notes field. A one-time script
backfills the entries already in the catalog.
pendingAsk returned an arbitrary entry of the ask map, including one still queued behind the prompt lock. When the second ask took the lock first, the test answered the queued one and then waited for it while the other held the lock for the user: a hang until the package timeout.
useWallet treated the empty account of a pane whose status had not arrived as a source, so the first status turned it into a source change: the wallet read that had already landed was emptied and read again.
Update capped the lock wait at 5s even for a caller that supplied its own deadline, so the number of writers a queue could hold was fixed by the package rather than by the caller. The serve tests now pass a budgeted context and fail on the returned error instead of reading a swallowed one as a wrong count.
The host appends a background-job-finished notice as a user-role message once the job ends. When it landed before the CHECK turn composed, the provider read the notice as the last user message, took its default branch and never issued wait/read, so the answers it recorded for w1 and r1 were empty.
Every config load re-parses each enabled plugin package, and a settings sheet
fires a dozen such loads at once, each queued behind the package store's lock.
With a 293-skill package a burst costs one full parse per request. Callers now
wait for a parse that starts after they arrived and share it, so a burst costs
at most two.
The desktop-sessions-v5 by-id store carries no workspace, so every session
from it landed in the workspace picked in the dialog. The owner is recorded
in desktop/workspace-state-v1.json; read it from the picked folder and route
each session to its workspace while that folder still exists.
Pane.tsx sat at the 800-line ceiling with twelve open pull requests editing
it. The view (flow, analysis, browser) lived in component state, out of reach
of the tab strip and of any setting that will place tabs.
The view now lives in state/paneview.ts, keyed by pane id and owned by the
mounted instance, so a remount for another conversation still starts on the
conversation. Submit, surface slots, the meter row, the plan fold and the run
line move to files of their own; props and the report type move to
panetypes.ts. No visual or behavioural change.
Adds css-important, css-size and stray-file rules to repolint, deletes the
stray Settings.tsx-e, runs the intrinsic, contrast and concurrent browser
guards in the studio workflow, and adds a report-only wheel-scroll probe.
The rail was hidden by the studio layer's prototype alignment, which left Nav.tsx rendered but display:none. It is drawn again, beside the workspace column, when this machine has it on (default) and the window is wider than the scene fold.
The BrowserWindow carried no backgroundColor, so any pixel the page does not
cover (frameless window at a fractional display scale) showed the default
white. The ground now follows the stored rx-theme, or the system for auto, at
creation and when preferences are saved or the system theme changes.
The sidebar folds a conversation's recovery copies under one row, but
/tree/sessions/remove deleted only that row's file. The next copy then took
its place, so the delete looked like a no-op with the order shuffled.
Remove the lineage the way archive already addresses it, leaving siblings a
pane has open.
A 400/422 on a request whose history carried reasoning the connection
could not replay was labelled "the endpoint wants the thinking content
sent back", whatever the endpoint actually said. Dropped reasoning is a
fact about the request, not a cause the host observed, and the endpoint's
refusal carries no typed signal separating the two. The hint is removed;
the refusal now shows the status guidance and the endpoint's own words.
File tools resolved a path through the filesystem before comparing it with the
workspace, so a path spelled as a network share was looked up first. On Windows
that lookup is itself a connection to the machine the path names.
A network path is now answered by its spelling alone, with no filesystem call:
it passes only below a workspace root that is itself a network path, compared
lexically and case-insensitively, and is otherwise refused with
workspace.network_path_outside_scope. The check is one function in fileutil and
sits at the entry of the read tools, the writers, their previews and write-path
resolution, write claims and path observation. Local paths and network
workspaces behave as before.
A path spelled as a network or device path is now answered by its spelling
alone, before any filesystem call, and is refused with its own code
(browser.network_path) instead of the generic outside-workspace one. The
check lives in fileutil next to the containment helpers and covers page
loads and file uploads.
Both boxes submitted on Enter regardless of pointer type; they now ask
touchKeyboard() like the composer does. The queue editor also ignores Enter
while an IME composition is open, as the rewrite box already did.
The anonymous ping was a single attempt at launch with a 1 s total budget; a
failed attempt dropped the day's claim and nothing tried again until the next
launch, and a host left open past UTC midnight was never counted again. The
reporter now retries a failed ping with bounded backoff, waits for the next UTC
midnight (plus a splay) after a success, and stops with its context. Consent is
re-read before every attempt, so turning the setting off stops the next send
without a restart. A refusal the server marks permanent (400/401/403/404/410)
is not retried for the rest of the UTC day; 429, 5xx and network errors back
off. Request timeouts are relaxed since the work is off the launch path. The
payload is unchanged.
The interactive session that carried the first-run consent and the reporter was
removed, and the replacement terminal UI never started either, so CLI 2.x users
were neither asked nor counted. runTUI now runs the documented consent on the
plain terminal before the UI takes the screen, starts the reporter only after a
mode is stored, and wraps the controller sink so the existing bounded counters
flow.
An ask or tool-approval wait kept the session's claim for as long as the user
took to answer, so other sessions were blocked on paths nobody was writing.
The claim is now yielded for the wait and taken again, queueing behind any
session that claimed the extent meanwhile, when the user has answered.
Refs #11531
The address bar read "D:/DevCode/x.html" as host "d" over http. A drive
letter plus a separator, a leading backslash pair or a single leading slash
now names a file: URL, parsed once in Go (browser.LocalPathURL, used by the
agent's checkURL) and once in the window (localpath.js), both held to one
shared table. The window opens local files for the person; a network share
is refused with its own code instead of a DNS error, and a page's own links
still never reach a file.
The summary request is one user message that ends on the last tool output, so
the model sometimes carries on the agent's work (pseudo tool calls, "next I
will ...") and the host installed that reply as the digest: a read-only fold has
no changed file for the coverage guard to compare.
- The user turn now ends with an explicit ask for the briefing.
- A reply with no level-2 heading, the shape the summary prompt requires, fails
with the typed code summary_not_digest. It is billed to the compaction, is
retried like any transient summary failure once the input has grown, and
degrades to the mechanical digest when the fold must free space.
- The code has frontend text in the TUI catalogues and the desktop table.
- Test doubles that answer summary requests now speak the heading contract.
The worker now states a profile on GET /v1/feedback/mine and names the
window in every feedback refusal. The kernel client dropped both: the
profile never reached a frontend and a 429 collapsed to "wait N seconds".
- feedback.Mine carries a validated Profile (null when absent or
incoherent; the last confirmed one while offline, cleared with the
install identity).
- Refusals decode error.params into a typed LimitError (window, reset
time, wait) for rate_limited, reply_limit and busy; serve forwards the
same fields through refuse.
- Studio shows the level row, progress track, limits, trust note and the
badge artwork (single-colour SVG on text tokens) in My feedback, and
says which window a refusal ran out of and when it resets.
- The terminal's /feedback list prints the same standing and refusals,
worded through the i18n catalogue.
Refs #12025
keepToolCallGroup kept every result of a parallel tool-call group when any one
failed. Models batch 4-11 calls per turn, so one failing test pinned the whole
batch of reads, the candidate exceeded the 50% checkpoint ceiling, the billed
summary was discarded, and the non-transient block was never retried.
- The retained assistant turn now issues only the calls whose results stay; the
folded copy carries the rest, so the summarizer and the coverage guard still
see them and no request issues a call nothing answers. Turns whose call ids
cannot be told apart stay whole.
- When the retained failures alone would exceed the ceiling, the fold gives up
retaining them before the summary is requested; the coverage guard still makes
the digest name each failed command.
- A candidate_above_ceiling block is released by input growth below the physical
ceiling, and an overflow on a changed input may fold, instead of holding
forever.
The Settings subagent row writes subagent_model, while an entry the CLI
writes under subagent_models is read first for its profile, so the row
looked inert. The kernel now lists those entries per role with the file
that holds them, the row names each one, and a user-config entry can be
cleared from the row.
Refs #12151
A brokered pane takes its default from the home machine's catalogue, so a
default_model written next to the pane's own kernel was never read and the
row snapped back. The default is now its own request, POST /default-model,
sent to this machine's pane; the pane's switch stays a plain /model. A
brokered pane refuses the request by code instead of ignoring it.
Refs #12043
agent.subagent_effort was validated only against the parent model and then
applied to whichever model a subagent selected (subagent_models.task, a
skill's own model, a per-profile model, a per-call model) through
NormalizeEffort, which remaps across providers or lets an unsupported
level through. The default is now resolved per executing model by one
function; a level that model does not declare is dropped with a warning and
the model keeps its own default. Explicit efforts still go through
NormalizeEffort, whose refusal now satisfies errors.Is(err,
config.ErrEffortUnsupported) and names the model and level.
The ACP-specific subagent resolver and task defaults had no caller (ACP
sessions are built by boot.Build), so they are removed.
Refs #11871, Refs #11872
The notice appended to bash output when a command mentions the session store
read like a refusal: it said to stop retrying and report, and when the command
matched nothing it was the only text returned. A model that had just been told
to read a spilled output gave the output up. State that the command was not
blocked and name read_file/grep for spilled outputs.
Cancel had no bounded wait, so a stop the kernel never acknowledged left the
button on "stopping" forever, and stopping only cleared when the running flag
dropped. Cancel now shares the bounded acknowledged post with decisions, which
fails with a typed DeliveryError (kernel_busy, unreachable, ui_stalled). A
deadline that fires far past its time on the monotonic clock is reported as
this window being stalled instead of a busy kernel. A stop also ends when the
kernel's status reports no live turn.
Refs #12232
The shell held nothing against system suspension, so a laptop that went to
sleep overnight froze every running session. The kernel now answers how many
panes have a turn in flight (GET /tray/running, local and remote, read from each
controller), and the Electron shell holds a prevent-app-suspension block for as
long as that is non-zero, reconciling against the answer on every poll so a
closed pane or a dead kernel cannot leave it held. A machine-level switch in
Settings > Appearance > Window turns it off.
Build windows/arm64 on a native windows-11-arm runner and carry it through the
signing chain as a second leg of every Windows stage: one tree per job, legs
serial under the certum-signing group, a failed leg reruns alone. The package
check learns app-arm64.7z and refuses any PE image that is not the leg's
architecture, a new install smoke runs each installer on its own architecture
before publish, and publish counts, names and hash-checks both architectures.
The arm64 installer is packed with the 7-Zip BCJ filter: the default ARM64
filter yields an archive the NSIS extractor cannot decode, and the installer
then exits 0 with every executable missing. Signing contract tests pin the
matrix, the serial legs and the secret-free stages.
Add a windows-11-arm leg to the Studio package job: the kernel and the
computer-use helper build for the runner's architecture, every PE image in the
bundle must carry the package's machine type (the installer-written
elevate.exe and uninstaller are 32-bit by construction), and the package is
started, installed and uninstalled where it is meant to run. An arm64
installer now refuses a machine that is not ARM64 Windows instead of
installing nothing, and a second job checks that refusal on an x64 runner.
The arm64 installer is built with the 7-Zip BCJ filter: 7-Zip applies an
ARM64 filter to ARM64 executables by default, the NSIS extractor cannot decode
it, and the installer then exits 0 with every executable missing.
The ARM64 leg runs on pull requests only when files it verifies change.
Build and verification only: no secrets, nothing is uploaded to a release.
With no browser tab, following a link asked the kernel for the link's tab
and the column, mounting at the same moment, asked for an about:blank of its
own. Which of the two the selection rules landed on depended on reply and
poll order, so the blank page was sometimes the one shown.
openPage remembers what is being opened until the kernel answers, and the
empty column takes that page instead of requesting another.
Refs #12251
The sub-agent chip summed every delegate in the session, finished ones
included, while the background-jobs chip counted only running jobs (and
fell back to the total when none ran). Both chips now show what is
running; the sub-agent popover reports "N running / M total" and keeps
the finished ones listed.
An optional extension whose sidecar is not running was reported as an
English sentence with no code, which the composer's notice bar clipped
at its edge and which named no next step.
dispatch now exposes ErrNoLiveSidecar and a SidecarDown hook; boot turns
it into a notice coded extension_skipped whose Detail is a typed payload
(extension, point, reason). The desktop words it from the payload with a
button to Tools and integrations and lets the bar wrap, the TUI words it
from the i18n catalog, and ACP chunks carry the code and payload.
Refs #12258
The form card's submit awaits onAnswer and clears its in-flight flag in a
finally block. The tests resolved that answer on a 20ms timer they never
waited for, so the setState could run after the jsdom environment was torn
down and vitest exited non-zero on 'window is not defined'. The tests now
wait for the answer to settle, and an afterEach fails any test that leaves
one in flight.
An entry parked as uncertain with code steer_unapplied (guidance the turn
never read, e.g. after a stop) kept the queue paused, and the panel offered
Retry only for blocked entries, so Resume was a no-op and the only way out
was withdrawing and resubmitting. Other uncertain codes may already have
run and stay take-back-only.
Rename the model-section action to "Read available models from provider" and
offer the same action beside the add-model field, with the failure shown there.
Light --warn is 4.58:1 on white and 3.86:1 on the floating layer, under the
4.5 text line. Text rules that set color to a bare --warn, --ok or --err now
use the matching ink token; borders, washes and glyph marks keep the base.
A structural test fails a new bare hue used as text.
useWallet now takes the account (connection) the wallet belongs to: a change
of account empties the wallet at once and reads again, and only the latest
read may land, so a quick double switch cannot leave the first account's
number up. Switching models within one account keeps the wallet and does not
re-read. An account with no wallet (204) or an unreadable one shows nothing
instead of the previous figure. A reading the source reports as unavailable
is drawn in the warn-ink token and carries a text label; the figure itself is
untouched.
Refs #12266
The updater and studio-manifest already key packages by GOOS-GOARCH; nothing
pinned what happens around a second Windows architecture. Cover both
installers resolving to their own keys, a manifest without the client's key
resolving nothing, a delta for another architecture meaning no delta here,
and document the platform-key rules in the release runbook.
TestEffectVersionControlRidesTheTurnNotThePrefix builds two controllers and
requires byte-identical system messages, but the environment section in that
prefix comes from the process-wide probe cache, keyed on PATH and shared with
every earlier test. The boot package runs longer than the cache's five-minute
TTL on Windows CI, so an answer an earlier test left can expire between the
two arms: the first arm reads it, the second re-probes, and a tool that timed
out once (a cold docker --version) appears in only one prefix.
The test now appends an empty directory no other test uses to PATH, which
keys a fresh probe answer for the first arm that the second reuses seconds
later. PATH lookup order and what each probe resolves to are unchanged.
A second TUI attached to a session kept an ask or approval open after the
turn ended without a decision receipt (the other screen skipped the ask or
cancelled the turn), blocking its input on a prompt the kernel had already
dropped. The desktop already seals these in sealTurn; the TUI transcript now
does the same on turn_done.
Xiaomi lists mimo-v2.6-pro, mimo-v2.6-flash and mimo-v2.6-pro-ultraspeed, and takes mimo-v2.5-pro and mimo-v2.5 offline at 2026-10-21 10:00 Beijing time with no replacement model.
A PreToolUse/UserPromptSubmit hook that could not be evaluated used to be
treated as absent: a malformed matcher never fired, a spawn error was
DecisionError (warn-level), and a payload json.Marshal error was dropped so
the hook ran against empty stdin and passed.
On gating events (and Claude-imported PermissionRequest) each of the three
now blocks, with a typed cause (ErrInvalidMatcher, ErrSpawnFailed,
ErrPayloadUnserializable) carried on Outcome and Notice. The model gets only
structured facts (code, step, event, scope, source, label), never the
script's output or the underlying error prose. Observing events still do not
block; they report the outcome to the user. Save rejects an invalid matcher
on tool-matcher events.
The tests that pinned the old behaviour (malformed regex "should not fire",
spawn-error => DecisionError on PreToolUse) are rewritten to the new contract.
101f2ca12 stopped composing the fact index into the cached prefix, but
SESSION_MEMORY_RETRIEVAL.md and REASONIX.md still said it joins the prefix
at session start. Only pinned bodies and standing instructions do; the
index is reached through the memory tool.
A cache hit never wrote the snapshot of the directory it was served to, so
when the 5 minute in-memory entry expired the next build probed live and
could render a different section, moving the cached prompt prefix. Adopt the
served answer into that directory's snapshot.
Also print the first differing bytes when the scheduled-run prefix test
fails, so a future flake is diagnosable.
The host appended a <context-budget> block to the turn tail at 75% and 92% of
the compaction trigger and raised a matching warning card. The model can still
ask: the read-only context_budget tool and its figures are unchanged, and its
description now says when to check, since nothing reports it unprompted.
The window opened a link in a new tab and then switched the active tab back
to the agent's. The page list was re-read between the two, so the panel saw
the agent's active tab move to the new tab and back, and its follow-the-agent
rule selected the agent's tab again: the link's page appeared but was not the
one shown. Session.VisitBeside opens the tab without activating it unless the
session has no active tab, so there is no transient to misread.
A prompts/get failure surfaced as the bare transport error, and the model
never learned the user had invoked a prompt. MCPPrompt now returns a typed
MCPPromptError (prompt, server, stage; matches ErrMCPPromptFetch) and owes
the model a turn-tail block describing the failed invocation.
Draw the kernel's typed `unread` on session rows (dot, bold title, spoken
name), as a count pill on folded project rows and as a dot on pane tabs.
A session is marked viewed through the pane's own endpoint when it
becomes the pane in front, when an unread row or pane is clicked, and
when a turn ends in the pane in front while the window has focus. The
mark is cleared optimistically and restored if the call is refused.
Refs #12230
/attachments refused with plain text, so the composer could only show
"Could not add" and left the reason in a tooltip. Refusals now carry
attachment.unsupported_image, too_large, empty, unreadable and write_failed
with params, the port keeps the code, the catalogues say each in both
languages, and the failed chip shows the reason with Retry.
Panes that are not on screen kept re-rendering on every streamed delta, and
each of those renders also rebuilt the reply actions handed to every settled
row. With nine sessions streaming the renderer's main thread saturated and
queued work (status reads, stop, settings) starved.
A hidden pane now takes the first token of each stream at once and coalesces
the rest into one batch per second; any other event flushes first and bringing
the pane forward flushes immediately. Reveal pacing, the thinking clock and the
wait clock only run for a pane that is shown. The reply actions keep their
identity while a streamed answer grows, and the folded thought's length is no
longer recounted per delta.
Refs #12232
MarkSessionViewed pre-checked the session sidecar with a plain unlocked
read. Concurrent views replace the sidecar, so on Windows that read can
meet it mid-replace (access denied, or torn JSON after the replace
fallback) and the view failed with a 500 instead of waiting it out. The
pre-check now uses the tear-tolerant read the other sidecar readers use.
Refs #12230
The kernel now records two optional facts in the session sidecar,
finished_at (stamped when a turn ends) and viewed_at (stamped when the
person opens the session). A session is unread iff finished_at is after
viewed_at; the sidebar tree and GET /sessions carry it as a typed
`unread` boolean. A turn the person cancelled is stamped as viewed.
Controller.MarkSessionViewed (POST /sessions/viewed) writes only the
session sidecar and takes no workspace or transcript lease. Sidecars
without the fields read as seen.
Refs #12230
A fold leaves the model with a digest, and the host re-projected only the
step ids of plan-seeded unfinished lists. A task list the model wrote itself
(no ids), a list with every item complete, the files changed and the checks
that passed all survived only as digest prose, which a weaker model reads as
work not yet done and repeats.
While a fold is installed the request now ends with one derived "Host
progress record": the canonical task list with statuses when no todo_write is
left in view, the files changed in the folded prefix, and the verification
commands whose latest outcome was a pass (marked when a file changed after
them). It is derived from tool receipts, bounded (30 files, 15 checks, with
the omitted count), memoised on the covered-prefix fingerprint, and ends with
the fold. Sessions that never folded carry nothing extra.
A live serve with no record is attached and recorded, launch no longer
deletes the port/pid files another serve published, and a live serve
whose provider source differs fails the connect with a coded error
instead of being terminated. Document what desktop exit does to the
remote runtime.
Package segments made only of dots, leading dots, a leading underscore on
unscoped names and .tgz/.tar/.tar.gz names are no longer accepted by the
installsource and registry checks. parseGitHubRepoSource refuses dot-leading
owners and dot-only repos such as the one TrimSuffix(".git") leaves from
"...git".
Refs #11353
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
perf/tooltip.mjs looked the attach button up by its aria-label in each
language, and the English label it searched for, "Add attachment", is not
the shipped one ("Add an attachment"). The lookup found nothing and the loop
skipped on, so the four English attach combinations were never measured and
the guard stayed green.
Controls are now found by structure, data-action for refine and the attach
class for attach, and a control that cannot be found fails the guard rather
than being skipped. All 24 combinations (light/dark x zh/en x two widths x
three cases) are measured: 96 checks, where 80 ran before.
Refs #12156
The veil blurred the whole window behind it, which the compositor redoes every frame while anything beneath animates. A flat, more opaque scrim dims the same way for free.
GUIDE.md lists "Ctrl+L or /cls" as clearing only the visible transcript, and
1.x bound the key, but the 2.x terminal UI left Ctrl+L unbound. It now runs
the same clearDisplay as /cls: the conversation, session and the kernel's
task list stay, and a running turn is refused the same way. Being a key
rather than a typed command, it leaves a half-typed draft in the composer.
Refs #12144
The fold trigger compared only a local estimate against the threshold. That
estimate is characters times one tokens-per-character ratio, and the ratio is
not learned from requests that carry images or provider-side tool results, so a
session with screenshots can sit well under the real size and defer the fold.
The trigger now takes the larger of the local estimate and the provider's
reported prompt_tokens (cached tokens included) for the last request plus the
local estimate of what the visible view gained since. The anchor is dropped when
a fold changes the projection, when the transcript is swapped, and when the
usage is estimated, aggregated over retries, or padded by provider-run tools.
The hard ceiling and the overflow paths still read the local estimate alone, so
a provider count that no fold can lower never ends a turn.
The slash and @ completion menu and the /resume picker moved only with the
arrow keys, while 1.x also took Ctrl+N / Ctrl+P there and GUIDE.md and
CLI.md list both for these lists. The provider and quick pickers already
bind them. Both lists now take Ctrl+N as Down and Ctrl+P as Up; neither is
text, so a picker's filter is untouched.
Refs #12144
bash_output and wait reported a background job that later failed or timed
out only in their text, with no tool error. The activity panel and the
failure count read the host-set error, so a job that failed after it started
never turned red and was not counted, while the model was told it failed.
Both tools now return errJobFailed, naming the failed jobs, whenever a job
they report ended as failed (a background command's non-zero exit and its
timeout both end that way). The output is returned alongside, so the model
still reads it under the error line. A job that finished, was killed, was
interrupted or is still running is not a failure and the call stays clean.
Fixes#12201
POST /providers/probe built its refusal message from probe.Error(), which
carries the model-list response body verbatim. That body can echo the
request, and this route receives the key typed into the add-provider draft,
so a 401 that quoted the key sent it back in the refusal. A failure that was
not a ProbeError came back as an untyped 502 with the error's text.
The message now carries the probe reason and only what endpointDetail lets
through (one bounded printable line, the draft key and credential-shaped
text redacted), the same treatment /providers/check gives its detail. A
failure without a probe identity is a kernel fault and refuses as 500
provider.probe.failed without its text.
Fixes#12188
A phone using the web client got an empty Workbench when the agent opened a
page: useBrowserTabs returned early because the web host draws no native
views, so the tab list was never read. Read it on every host and, where the
page cannot be drawn, show its title and address as plain text with no
controls.
Opening a session pane read GET /checkpoints three times against a real
kernel: the mount effect read it, the turn-boundary effect read it again on
its own first run, and a third time when the first status named the session
path.
useCheckpointRefresh now owns every read outside a session reload: the mount
read, a move from one known session to another, and both turn edges. The
kernel opens a turn's checkpoint when the turn starts and fills it while the
turn runs, so the read at turn start stays and the rewind entry still appears
on a running turn's message. The first status naming the session the mount
read covered no longer reads again, so opening a pane costs one request.
A failed read is tried once more after a second, since no later edge is owed
to it, and a read that a newer one superseded is dropped instead of
overwriting the newer list.
Refs #12080
The save area sat at the end of a long form, so its state was out of sight
exactly when it mattered. The bar is now sticky, shared by the edit and add
forms, and holds the failure or not-applied reason beside a single persistent
status line with a dot for each phase (clean, unsaved, saving, saved, saved but
not applied, save refused). Ctrl/Cmd+S saves a pending draft.
A skill that declares paths: is held out of the per-turn listing, the
capability catalog, the slash_command listing and the unknown-name hint until
the session has touched a file its globs match. The set of touched files is
owned by the skill store; eligibility is memoised against it and dropped when
it changes, and the next turn after a match owes the listing again. The user's
/<name> and run_skill by exact name are not gated.
The shell card and the activity group decided failure a second time from
execution.state and exitCode, so state background_started (no error, no exit
code) rendered a red BACKGROUND_STARTED badge and counted as one failure.
The host already classifies every call once: err is set for failed,
timed-out, cancelled and refused calls. Read only that.
Verifying a model was one click per row, and the form blocked while any one
ran. The model header gets a Test enabled models button that runs the existing
per-model check over the ticked rows, two at a time, and stops as soon as the
draft changes or the form is left. The summary line counts rows still being
verified, and the per-row live regions are dropped so a run is announced once
through the summary.
The agent tells a PathObserver which workspace files a completed call named,
through the ReadTargeter and WritePathResolver capabilities its tool already
declares; failed, refused and hook-blocked calls feed nothing and sub-agents
are not observed. Swapping the conversation rebuilds the set from the
transcript. skillSet wires its PathHits in; the catalog does not read it yet.
A failed catalogue refresh surfaced under the form's footer and a silent
refresh that found nothing looked like a click that missed. The add form's
connection failure also rendered below its save button.
Refresh failures and outcomes (including 'no new models') now render under the
model header, verified rows are summed up in one status line over the ticked
models, and the add form's connection failure sits under the button that ran
it. Results clear when the address or key they depend on changes.
Skills may declare file globs in a paths: key. The registry now parses them
(rejecting negation, parent traversal and malformed globs into InvalidPaths,
which doctor reports) and a PathHits set records workspace files the host saw
touched, resolving symlinks so nothing outside the workspace counts. The set
is owned by skillSet; the catalog does not read it yet.
Leaving the edit form for another service, the Add form, another service's
rename, or a protocol switch dropped pending edits without a word.
EditConn reports the pending state it already computes; Providers holds it
and routes every way out of the form through one guard that asks to keep
editing or discard. A save in flight is not interrupted and not asked about,
and the question closes by itself once nothing is pending.
The edit form had no way to tell pending edits from stored ones: Save was
always enabled, Cancel was a remount in disguise, and a successful save
silently turned into the same form with nothing said.
Pending state is the fingerprint of what a save would send, compared with the
one taken at mount, so typing a value back or toggling a row off and on is
clean again. Save is disabled when clean, Cancel becomes Revert, and the footer
says whether there are unsaved changes, no changes, or that the save landed
once the list has been re-read.
Cause: POST /providers/check returned a free-text `error` assembled from the
probe error's message. The probe already knows whether a key was refused, a
path is missing, nothing listed chat models or the deadline expired, but that
identity was flattened into prose on the way out, so the settings page could
only print the sentence (which also echoed the endpoint body unredacted), and
the refresh button invented its own literal error when a check came back empty.
Blast radius: /providers/check on failure now answers 200 with
`{ok:false, code, params?, httpStatus?, detail?}` and no `error`. `code` is the
dotted identity the add flow's refusals already use (provider.probe.*), plus a
new provider.probe.timeout split out of unreachable; `params` carries only the
numeric facts the sentence needs; `detail` is the endpoint's own text through
the same redaction the model check uses. Success responses are unchanged.
catalog.ProbeError gains Status and Body, read from the typed fetch error, and
the status/code table is shared by the refusal and the finding.
Neighbouring behaviours tested: refused, forbidden, payment, rate limit, missing
path, upstream error, no chat models, unreachable, timeout, success, key
redaction in the endpoint text, bounded one-line detail, missing grant,
unknown name, and the add flow's probe codes all translated.
Why this layer: the probe owns the class; serve only projects it; the page
maps the code to words.
The refine button's tooltip carried the Ctrl+Shift+E shortcut, but nothing
pointed the button at it: it had no aria-describedby and the tooltip had no
id, so a screen reader announced only "Optimize prompt" and the keyboard
route stayed undiscoverable. The attach button beside it already describes
itself this way.
The tooltip now takes an id from useId and the button names it in
aria-describedby, matching the attach button.
Refs #12156
A settled render_chart call (direct or through use_capability) is re-validated
on the page, lifted out of the folded execution group, and drawn as a bar,
line or pie chart from the validated spec by our own React SVG components in
a lazy chunk. A running, refused or invalid call keeps the ordinary tool card.
The same card is rebuilt on reload from the stored call. Each chart offers an
accessible data table, keyboard-reachable points and no entrance motion.
A settled chart call (direct or through use_capability) now draws its title,
marks and rows as a capped, width-fitted table in the terminal, with numbers
right-aligned and the fold key opening the rest, instead of a line count.
chartspec.FromCall and Spec.Table/Describe are the single projection; the TUI
history record and frame merge now keep the resolved tool name, without which
neither a reopened session nor a live call could be told from any other
use_capability call.
A successful save remounted the editor synchronously while the list reload
was still in flight, so the new editor was seeded from the stale entry and
showed the pre-save values. onEdited now returns the reload, the editor
waits for it before remounting, and save keeps the form busy until then.
A steer sent while a stop had paused the queue was refused with inbox.paused
but its queued copy stayed behind, so retries piled up and all ran on resume.
Hold the item as a follow-up under the admission lock and answer with the
queued receipt that already carries paused=true. The pause itself is kept,
and send-now from the queue panel still refuses. The composer reads the
receipt, from the steer path and from an idle skill send, and says the queue
is paused, once, in the notice strip.
Part of #12153
Stopping a turn while the plan approval card is open left the plan in
AwaitingApproval with no card to answer it. Later messages then ran under
the planning barrier and no new card could open. Treat a cancelled or timed
out approval as a revise so the workflow can propose again.
Part of #12153
The refine and attach tooltips are children of their buttons. They inherited
the button's white-space: nowrap, so the hint spilled past the 220px box in
both locales, and the disabled refine button dimmed itself to 35% opacity,
which multiplied down onto the tooltip that explains the control.
The shared .studio-control-tip now wraps inside its max-width, and a disabled
host dims its glyph instead of itself.
Part of #12148
Cause: the settings page awaited setRole and only then re-read /roles. While a
turn, a question or a background job is active the kernel has already written
the value and answers 409 runtime.saved_while_running; run() treated that as a
failure, the re-read never ran, and the controlled select snapped back to
"follow the main model" although config.toml held the saved model. The banner
also called it "操作未完成".
Fix: the role row always re-reads /roles (promise finally), and the page's one
failure banner now distinguishes saved-but-not-applied (SAVED_NOT_APPLIED, the
set the provider edit form already uses) from a real failure, with the same
title, warn colour and status role.
Blast radius: Settings.tsx only. Every action routed through run() now shows
"已保存,尚未生效" instead of "操作未完成" when the kernel answers one of the
SAVED_NOT_APPLIED codes; other failures render as before. No kernel change.
Neighbouring behaviours tested: role saved while idle, role saved while busy
(value and notice), real refusal (alert, no saved notice), other role rows and
a reopened page after a busy save, default-model row refused while busy (stays
on the kernel's default; the kernel refuses before writing, so it is not the
same defect). Go: POST /roles mid-turn returns 409 runtime.saved_while_running
and GET /roles returns the saved value.
Why this layer: the page owns the re-read and the banner; the code identity
already comes from the kernel and the shared SAVED_NOT_APPLIED list.
Part of #12144.
Cause
- `/quit` and `/exit` are in the slash catalogue but the TUI had no handler,
so since #11969 they were sent to the model as ordinary messages.
- Every Ctrl+C on a running turn re-sent cancel, so a turn that would not
stop could not be left, while the footer says "Ctrl+C exits".
- Tab in the completion menu moved the highlight down (and did nothing
visible with one item); the guides say Tab / Enter accept.
Blast radius
- internal/frontend/tui: send(), menuKey, the Ctrl+C branches of onKey and
viKey, one new Options field. internal/frontend/cli passes the quit names
from the slash registry. docs/GUIDE.md and GUIDE.zh-CN.md rows for Ctrl+C,
Ctrl+D and /quit. No controller, wire or config change. The bare words
exit, quit and :q stay ordinary prompts.
- Tab history: the cycling was part of the first completion commit
(5ff9d7a0f) with no stated reason and no test; nothing later touched it.
Neighbouring behaviours tested
- Idle double Ctrl+C, Ctrl+D (idle and while running), double Esc opens
rewind, a second Esc does not quit, Esc then Ctrl+C quits, one cancel call
per first Ctrl+C, vi mode Ctrl+C, a finished cancelled turn needs two
presses again, Up/Down still move the menu, Enter accepts, Tab accepts
the highlighted row, file-mention directory descends, Tab without a menu
still asks the kernel, /quit while a turn runs is not queued.
Why this layer
- Quitting is a frontend lifecycle action with no controller verb, so it
lives in the TUI local-command switch beside /mouse; the names come from
the registry through Options rather than a second alias list. Both
interrupt paths share one helper so Esc and Ctrl+C agree on what
"stopping" means. The unreachable shift+tab case in menuKey is removed.
When the kernel refuses browser.open, the workbench stored the reason in
`failed` but only drew it inside the file-tab branch. Opening the column
or pressing + with no page left the pane on its empty state, "choose a
file on the right, or open a browser", with no error.
Cause: `failed` was rendered only when the active surface is a file, while
the browser open paths (column opening with no agent tab, the + button)
also write it. The kernel also answered every failure with the generic
browser.open_failed, whose wording needs an {error} param it never sent,
so the typed engine_missing / engine_failed / profile_busy causes were
lost. Separately, `.workbench-error` used --danger, a token no stylesheet
defines, so such errors drew in the body colour.
Blast radius: WorkbenchPanel, one CSS declaration, serve's browser open
refusal and the kernel.ts / en_kernel.ts tables. The alert renders for any
non-file surface; file tabs keep their own copy and are unchanged.
`failed` is cleared when a tab is picked and when the active tab closes.
POST /browser/open now answers browser.engine_missing, browser.engine_failed
or browser.profile_busy for those causes (same 400, same params, plus the
detail in params.error) and browser.open_failed for the rest. The colour
fix also recolours the file-tab and explorer errors.
Neighbouring behaviours tested: the three codes and the generic one through
the real SsePort decoding path, on both open paths, asserting the exact
text and no leftover placeholder; the reason clearing on picking a file
and on a later successful open; a file read error staying single on its
tab and going away with it; serve end to end with real launch failures;
the refusal-code parity test; the whole WorkbenchPanel suite and tsc.
Why this layer: the pane owns the state and the draw; the kernel boundary
in serve is where the typed cause becomes a code, and internal/platform/
browser is not touched.
Fixes#12139
Add internal/base/scratch: a temp directory claimed by a long-lived
exclusive .owner.lock (internal/base/filelock). Remove releases the lock
first and retries sharing violations for up to 2 s. Sweep/SweepOnce remove
only non-symlink directories past a one-minute grace whose lock can be
taken, so a live owner is never touched. The jobs manager now creates its
reasonix-jobs-* fallback root through it, which also sweeps roots left by
earlier killed kernels.
Part of #12142
In 1.x, "/resume <n>" switches straight to the nth saved session. The Studio
TUI ignored the number and always opened the picker, the known gap left in
the CLI migration tracking issue.
An indexed /resume now resolves the session by its row in the same list the
picker numbers and resumes it, with the same refusals the picker gives: a
running turn, the session already open, or an index outside the list. A bare
/resume (or a non-numeric argument) still opens the picker.
The index lookup shared by /takeover <n> and /rename <n> <title> moves into
one helper so the three commands cannot drift apart.
Refs #11493
The feedback dialog is fixed to a 720px column and thread bubbles are capped
at 560px, so a long maintainer reply on a wide window reads as a narrow strip
that needs constant scrolling.
Add a "Fill the window" toggle next to Close. While pressed, the dialog takes
the whole veil and thread bubbles widen to 92% of the column; pressing it
again returns to the reading column. The toggle is a view-only action
(feedback.expand) and the state lives only for the open dialog.
Refs #11575
The reference-solution half covered only the e2e, train and verification-stress
corpora, so three committed corpora with the same tasks/ layout carried no
evidence that any attempt can score: a grader that can never pass is
indistinguishable from a task with no solution, and a corpus of those teaches
that nothing is ever accepted.
Their pristine seeds already fail, checked across all eleven tasks, so this adds
the reference solutions the guard needs: one ANSWER.md each for the three fleet
tasks, and the non-positive-skipping calc.py the eight project-check graders
assert. The e2e suite stays the single declared exception, named in the skip.
Refs #11852.
Prompt refine and commit-message drafting built their provider with effort forced to "disabled". Generic OpenAI-compatible endpoints refuse that at construction, so the side model was nil and the user saw "no usable model" while chat worked. sideProvider retries only on provider.ErrEffortRefused, with the lowest effort the entry's vocabulary lists (automatic depth when it lists none); other construction errors are not retried and are logged at warn.
A transient summary failure held the retry until the input grew by an
eighth of the trigger. Recorded near 105% of the trigger that release
point is the window itself, so the next request went out uncompacted.
The step is now capped at half of the room left below the hard ceiling,
so the hold always lifts before it.
A relay that refuses an oversized request with a 400 and no error code
was not recognised as an overflow, so recovery never ran. A 400 or 413
whose request our own estimate puts over the window now takes the same
fold-and-replay path, judged by status and size, never by wording.
A session row was selected only once POST /runtimes (or /resume), GET /runtimes
and GET /tree had all returned, so every switch to a session without a pane
waited out the kernel's pane assembly with nothing but a dimmed row to show for it.
The window now owns which session is being opened (useSessionSwitch). The row
reads selected and busy from that state as soon as it is clicked, the active
pane shows aria-busy, a ticket orders concurrent opens so the last click wins
and a refusal from a superseded open changes nothing, and a failed open drops
the selection back to the pane that is still in front while the error is shown.
Opening moves out of App into useOpenPane so every caller shares the one path.
The pane burst after an open now asks for /runtimes and /tree through one
single-flight read instead of one per caller.
/status called config.Load() on every poll, which re-parses every
installed plugin package under a process-wide lock. The controller now
carries the model entry boot resolved and /status reads effort, vision
and the model ref from it. The provider label still comes from the user
file because a rename rebuilds nothing.
Refs #12055
Discovery read every directory-layout skill's references/ and scripts/
to build a Body that a catalog never uses, so opening a pane cost
O(bytes in the skill library). Listing now keeps only SKILL.md; Read,
ReadSlash, Prepare and the capability digest complete the skill when it
is invoked or inspected.
Refs #12055
A summary request was cut off at a fixed 90 s of wall time, so a local model
that was still streaming steadily failed every /compress with summary_timeout.
Bound the silence between streamed chunks instead (6 minutes, one minute past
the providers' own 300 s stream idle watchdog), and add a 30 minute overall
ceiling with its own typed code summary_ceiling so a trickling model cannot
hold the turn. Both failures stay transient: a later attempt may retry once
the input has grown. The notices state which bound fired and its length.
GET /skills resolved each skill's activation through Controller.SkillEnabled,
and every call re-ran config.Load(), which walks all installed plugin
packages. Resolve activation once per request via Controller.SkillActivation.
Refs #12055, #12051
Go's default User-Agent is answered with 403 by edge bot protection on
datacenter networks. Catalog and manifest reads now use the Studio update
identity shared with artifact downloads, the relay websocket handshake uses
the account client's identity, and feedback, telemetry, crash and account
requests fall back to the shared client identity when none is set.
The pane polled /status every 250ms with no in-flight guard. When the kernel
answered in 13-16s the unanswered reads filled the browser's per-origin
connection pool and the approve, plan-decision and answer POSTs queued behind
them.
Idempotent reads now share one request per path with a single trailing
re-read, the pane schedules its next poll when the previous read settles, and
decision submits get a bounded wait that fails with a typed KernelBusyError.
The retry status line showed "connection broke before the response headers"
for every retried failure, including 502/429 statuses, did not say what the
attempt was waiting for or when it counts as unanswered, and its seconds
counter ran from the first notice of the whole stall instead of per attempt.
The retrying event now carries a typed failure class (connection_closed,
timeout, upstream_status with the HTTP status, stream_idle, upstream_error),
the backoff before the next attempt, and the header wait of the coming
attempt. The producers set them: SendWithRetry knows the class, the adapters
pass their idle_timeout_seconds window, and the stream replay path maps the
stream-interrupt reason. The window words each class and phase (backoff
countdown, then a wait that restarts per attempt) from those fields, never
from text. The CLI machine record carries the same fields.
Provider settings gain one advanced row for idle_timeout_seconds (1..32767,
empty is the default), validated in the form and by the kernel's provider
edit endpoint.
Fixes#12046
The host book has no per-machine state: every entry is dialed on demand and every entry is
listed in the rail. A machine that is temporarily off the network, or one kept for the
occasional job, cannot be marked as such - the only lever is removing it, which takes its
folders and its forward rules with it.
- RemoteHostEntry gains disabled, written as "disabled" rather than "enabled" so an entry
written before the field existed still means what it always did, plus IsEnabled(); the
renderer writes the key only when it is set.
- One switch per row decides whether the machine is dialed, and that is all it decides:
live panes keep running and the rail still lists the machine, because a machine being
off the network says nothing about what this window should show. Whether this window
lists it stays a separate preference, and it is an immediate row action next to the
row's other actions rather than a control inside the edit form, which saves on submit.
- remoteHostDialable is the single answer to whether a machine may be reached: POST
/remotes/open refuses a disabled one with remote.disabled before dialing, and remoteLink
(the tree and directory reads, and removing a far session) asks the same question, so a
disabled machine is not reached for at all. A book that cannot be read is a refusal
rather than a dial.
- GET /remotes reports disabled and the edit endpoint carries it.
#11897 stopped a session's model switch from rewriting default_model, which left the
settings row reading status.modelRef - the model the session in front is running - so
switching models inside a conversation still moved that row while the file it claims to
describe did not change.
The row now shows the entry the model catalogue marks as default, falling back to the
session's model when the catalogue carries no default, and the column beside it follows
the same value. `main` stays the session's model for the vendor-protocol inference this
component does.
Tested: with a catalogue default the row shows it whatever the session runs; without one
it falls back to the session's model; the settings-catalogue, ModelUsage and i18n gates.
A stable studio-vX.Y.Z that is the highest stable studio tag is created with
--latest and without --prerelease. Candidates (-preview.N, -rc.N) keep
--prerelease --latest=false, and recovering an older stable tag does not take
latest back. The decision lives in scripts/studio-release-flags.sh and reads
the tag grammar, not wording.
The GitHub release no longer carries a root latest.json. The 1.x desktop
updaters fall back to releases/latest/download/latest.json, and builds up to
1.18 do not validate the manifest and share Studio's minisign key, so a
Studio manifest there would offer them a Studio package. The file stays in
the R2 mirror under <tag>/, which is the only place Studio reads it. The
release now has 21 assets.
Adds a flags-script test and a workflow contract test, and updates the
runbook, READMEs, MIGRATING and ROADMAP.
A deny or ask rule such as Bash(rm:*) compared only the words the model wrote,
so `env X=1 rm`, `sudo rm`, `command rm`, `nohup rm`, `nice rm`, `time rm` and
`/bin/rm` were judged by fallback mode instead of the rule.
shellparse.PeelWrappers reads one simple command from the shell AST and
follows a fixed set of wrappers (env, sudo, doas, command, builtin, exec,
nohup, time, nice) down to the program they run, naming a path-spelled program
by its base name. Deny and ask matching test every layer; allow and session
rules are unchanged. A wrapper whose target cannot be named statically needs a
human instead of falling through to the mode.
apply() wrote every pack token, and the ink variables even with no pack,
as inline custom properties on <html>, so any author stylesheet needed
!important to win. The values now live in one rule of a <style
id="pack-theme"> created after the built-in sheets, written through the
CSSOM so a rejected value is dropped. The contrast tiers carry the inks in
the stylesheet, with --ghost on --faint's value, and perf/tiers.mjs holds
each tier to the legibility users already had.
A Claude Code Mod declares "modules" in hooks/hooks.json instead of a
"hooks" map. The importer returned early on an empty hook map, so the
modules were dropped silently and a Mod with skills previewed as full.
The model list collapsed entries by endpoint + model, so two providers
sharing a base URL with different API keys listed each model once and
the second account's refs vanished from the picker.
TestInstancesDifferBetweenRuns instantiated every task twice and failed
whenever two independent draws collided. Three tasks answer with a bounded
number (i70 quota 1200..9800, i47 lease 15..300, i112 lease 20..420), so a
run fails with probability 1/8601 + 1/286 + 1/401, about 0.61%.
liveRand now delegates to liveRandFrom(io.Reader). The test asserts the
invariant at the seed: two live generators do not share a stream, the same
entropy yields identical instances and different entropy yields different
values.
The walk tolerated only not-exist, but a directory being removed on Windows
fails to open with a sharing violation or access denied. Those errors now
count as vanished only when a re-stat shows the path gone.
Cause: the per-model check classified any 4xx it could not name as
unknown/rejected and the row printed only "request refused, not confirmed".
The kernel already carried httpStatus; the settings UI dropped it, and the
endpoint's own error text was never sent, so a local Ollama 404 (base URL
without /v1, model id without its tag), a 400 for a model that cannot take
tools, and a missing key on a System One entry all read identically.
Blast radius: POST /providers/check/model gains an optional `detail` field:
the endpoint's error text as one printable line. It is built in this order:
control characters and ANSI sequences replaced, the probe key masked in raw,
URL-escaped and JSON-escaped form (skipped for keys under 8 characters),
secrets.RedactCredentials, then cut to 300 runes. Folding and the key
replacement see the whole text; only the pattern scrub is bounded (8 KiB cut
on a word boundary, after folding). The model row shows an HTTP badge and the text inline. Status and
reason are unchanged and never derived from the text.
Neighbouring behaviours tested: Ollama-shaped upstream (correct, base without
/v1, tagless id, System One kind); 400/404/405/422 classification; detail
equals the upstream text, masks raw/escaped/plain keys, Bearer, ?token=,
Cookie, neutralises newlines/ANSI/NUL, cuts after redaction, cuts at the scan cap with the key split at every offset, with and
without leading whitespace, bounds a 4 MB body, leaves short keys alone (each mutation-checked); add and edit flows
share one fact helper; full ui vitest suite and the serve package.
Why this layer: the serve handler owns the typed result and the row is the only
renderer of it; classification stays where the failure is first known.
POST /browser/open answers 400 when the navigation fails even though the
window's tab already exists, and the link handler read every refusal as
"nothing opened" and sent the same address to the machine's browser.
The refusal now names the tab it left behind and the handler only falls
back when there is none.
A pane adopted at launch is not a folder anyone picked, so nothing listed it
once a later launch opened another one. Its conversations stayed on disk with
no sidebar row to open them from. The folder is now listed behind the existing
ones when the pane's first turn mints its session path.
A queued line taken back could fail with a spurious error on a repeated click,
leave a pending transcript row when its receipt landed after the withdrawal,
and reappear in the panel when an older queue pull answered last.
A truncated, timed-out or failed summary request blocked automatic
compaction for the whole generation, so context kept growing past the
fold trigger with no further attempt. Block release now reads the typed
failure code: transient request failures are released after the input
grows by one retry step; every other class stays blocked.
The digest request inherited the session's reasoning effort, so reasoning
tokens spent the same 16384-token cap the digest had to fit in and a
finish_reason=length dropped the whole summary. It now carries
EffortOverride=low; adapters apply it only when the endpoint's own effort
vocabulary admits it, so endpoints without a low tier keep their configured
depth.
Refs #11970
/compact notices were raw English sentences built from err.Error(), and an
aborted card with no code read "compaction done / folded nothing". Add
compacted/compact_declined/compact_failed notice codes (detail = the
compaction code), give cancellation, unclassified failures and a busy
session their own codes, and word them in the desktop and TUI catalogues.
Refs #11970
The terminal UI asked the kernel to refuse any slash line the registry does
not answer, so a typed path or half-remembered command dead-ended with
"unknown command". 1.x sends it as an ordinary message and says so (#5756).
The TUI client no longer sets refuseUnknownSlash; the controller's existing
default path runs the line through the normal turn pipeline. That notice is
now localised (en/zh/zh-TW) and carries the unknown_command code.
A built-in named first is that built-in whatever follows it, as in 1.x: the
kernel matches /context, /new and /clear by first word, and the TUI does the
same for /help, /clear, /setup, /version, /mouse, /resume and its display
commands, so none of them is sent as prose when given arguments.
Refs #11493, #5756
With no API key the connection panel opened silently and a typed message went
to the provider, which answered 401 with a pointer to a shell command. The
panel now names the model that lacks a key, and /submit refuses a turn for it
with provider.key_missing before anything is sent. The TUI returns the line to
the composer and points at /setup.
The usage range test assigned the process-global time.Local while goroutines from earlier tests still read it, so the race detector failed the package. usageWindow now delegates to usageWindowIn, which takes the zone trailing windows count days in; the test passes a fixed zone and never touches the global.
A provider whose natural key slot is taken gets REASONIX_CONNECTION_<id>_KEY. Collect wrote that provider and its key, but restore validation denied every REASONIX_ name, so the backup sealed fine and then failed to open as malformed.
Settings and feedback are answered by a pane's kernel. With no pane open, feedback silently did nothing and the settings refusal never cleared. Opening either now starts a session in the current folder, a refusal is shown for both, and it is withdrawn once a folder or pane exists. The cloud-backup button now states what it is waiting for.
Preserve ACP frame-order assertions while skipping only MCP status updates,
and use t.Context in the serve regression test.
Report servers requiring launch approval as pending rather than failed in
controller, ACP and /mcp snapshots. Keep pending rows disabled without a retry
action while retaining their recorded transport and sanitized error.
Document connecting and awaiting-approval notifications, style the pending
indicator in both themes, and derive frontend state coverage from Go constants.
Add controller, endpoint, ACP and row-rendering regression coverage.
- Map the new `pending` state in the desktop panel with an i18n label. A
repository-declared server nobody has answered for is off, but not because
the user switched it off, and the row rendered the raw state string.
- Announce what actually happened. RecordFailure and
RecordLaunchApprovalRequired now share one store path and name their own
state, so a project MCP awaiting authorization stops reporting a failed
connection.
- Correct the MCPSurfaceReady contract: it fires on every change to what /mcp
answers, not once per server. Audited every consumer before changing it —
the ACP sink republishes a full snapshot, the serve broadcaster numbers the
frame and the SPA refetches /mcp, the headless stream passes it through — so
repeats are harmless and the documentation was the stale part.
- Keep the spawn claim beside the rest of the deferred-spawn bookkeeping
instead of a file of its own.
- Add the /mcp endpoint regression test the refactor was missing (live
transport and source, recorded status code, in-flight spawn, cached
description and tools), and a panel test tying every state label to the
English catalogue.
SetCredential and SetCredentialIfRevision built a KEY=value line and parsed it
with the dotenv reader, so ${...} was expanded and wrapping quotes stripped
before the key was ever written. The writer also left a value containing $
bare, which the reader then expanded. Store the assignment directly and pick
the first encoding the reader returns unchanged; a value no encoding can hold
(for example a trailing backslash next to a quote or $) is refused before
anything is written, so one entry can never make the whole file unreadable.
Adds GET/POST /display-currency over the controller's new
DisplayCurrencySettings/SaveDisplayCurrency (shared with /currency), a
Settings > Usage control, and makes the quote sink follow the
display_currency notice so rounds after a change are priced in it.
Picking a 1.x data folder only looked at <root>/sessions, so per-project
sessions, sessions-v4 and desktop-sessions-v5/by-id imported nothing and the
UI reported no old sessions.
render_chart validates a chartspec v1 spec with chartspec.Parse and answers
with a chart_id and the deterministic text summary, never the rows. It is a
registered built-in left out of the provider allow list, so it is found and
called through use_capability and the cache-stable prefix is unchanged. The
spec persists in the call arguments the session already stores (#11564).
cli-channels and cli-pointer both waited only for cli-tag, so the cask could be
pushed while the v<version> release holding its archives was still being
created, and stayed pointing at nothing if cli-pointer then failed. The cask
step also overwrote the tap whatever its version, so a workflow_dispatch of an
older tag moved Homebrew backwards; it now skips a tap that is ahead and fails
closed when the tap's version cannot be read.
Leaf package internal/contract/chartspec: spec types for bar/line/pie, a
strict Parse (size cap first, unknown fields refused, scalar cells only,
unsafe integers refused), typed refusals with dotted codes and caps, and a
deterministic Summary that quotes cell data. No consumer yet (#11564).
EditConn titled every outcome "not saved" at warn level, so a write that
landed but was not applied to the live session read as a failure, and a real
failure read as a soft notice. Split by outcome: error alert for a refused
save, status note for saved-not-applied, own title for a catalog refresh.
The archives that reasonix upgrade, the Homebrew cask and the GitHub release
install were linked with main.version only, so they reported no git_commit or
build_time_utc and the embedded product docs identified themselves as dev. The
npm packages of the same release were linked with all of them.
CostQuote carries a rate band for a rate confirmed as the vendor's own
schedule, aggregates fold it to peak, off-peak or mixed (none when any call
has no schedule), and the TUI prints it beside the cost as 1.x does.
A headless run in a folder with no trust decision refused every edit and
command and exited 0, so scripts that relied on 1.x defaults looked
successful. It now exits 4 (3 under --fail-on-unverified); the gate and
what it allows are unchanged. docs/MIGRATING.md gains the 1.x differences:
trust prompt, untrusted-folder runs, compact-ratio range, env vars.
Refs #11493
The panel dropped a list as soon as every item was completed, so the final
N/N never showed. It now stays through the ask panel and the idle prompt and
goes when the next turn begins; a rebuilt screen (resume, rewind) still does
not draw a list that finished before it was opened.
Adds a typed Commits port on the controller: ProposeCommit reads the staged
index through gitcmd, frozen as a tree object, and asks the session's model
for a conventional-commit message in a request of its own (prefix untouched,
billed as commit-message). CommitStaged writes the reviewed tree with
commit-tree and moves HEAD only if it has not moved, so an index that changes
after confirmation cannot enter the commit. Files named as secret holders or
whose added lines carry a private key are withheld from the model and need an
acknowledgement. Hooks and signing never run. Nothing pushes.
Refs #11518
- Add the pre-5.3 GLM catalogs to shippedPresets so installs holding the
old list are moved forward, plus upgrade tests (old shape upgrades, a
curated catalog stays untouched).
- Declare the per-model ladder once in provider.ZhipuEffortContract and
read it from the /effort menu, the normalizer and the request builder,
instead of repeating the model lists across three switches.
- Cite Zhipu's Core Parameters reference for the documented levels
(5.2's none/minimal/low/medium/xhigh folds, 5.3's forced thinking).
- Add a boot.Build effect test at the provider-request boundary covering
the stored enabled/disabled -> EffortDisplay -> request path.
- Surface the disabled->low landing for GLM-5.3 in the /effort menu and
document the 1M context window the added models reuse.
CodeQL's go/command-injection models regexp matches as barriers only when the
match is the branch condition dominating each use. screen() folded the match
into an || condition and the -C directory skipped it, so alert 1014 kept its
flows through screen() and Args.
- keep typed field details for days and explicit ranges
- reject from/to combined with days
- treat explicit dates as zone-less calendar days
- relocate and extend usage range tests
rebuildFailed collapsed the busy refusal into runtime.rebuild_failed with
the English sentence as detail, so the compaction, shell, sandbox,
permission, browser and MCP-load saves told a zh reader the write had
failed. It now answers runtime.saved_while_running, which the UI renders.
A bare https://host passed the add-provider probe because {base}/v1/models
answered, while chat posts to the saved base + /chat/completions and failed.
The probe now reports the base that was proven (typed address completed with
/v1 only when the listing answered there alone, openai-style kinds only), and
add, onboarding and edit-refresh adopt it into the address field before save.
F5 and Ctrl/Cmd+R reloaded the whole Studio UI from any focus, so a page
or browser-pane user pressing them lost the window state. The app window
now reloads only on Ctrl/Cmd+Shift+R (white-screen recovery, crash reload
unchanged); the browser pane reloads its own page on F5 and Ctrl/Cmd+R and
ignores the cache on the shifted chord.
POST /model persisted every switch as the user's default model, so changing
the model inside one conversation silently changed what every new session and
the next launch started on. The typed /model command, the terminal UI and ACP
never did this; only the HTTP picker path did.
The request now carries an explicit `default` flag. The Settings default-model
row and the first-run model choice send it; the composer picker does not, so
it changes only that session. A pane resolving through the hub's resolver
still never writes this machine's config.
One structural rule (secrets.RedactEndpoint / CredentialKey / RedactArgs)
replaces mcpsetup.RedactURL's query-only masking. Userinfo and fragments are
dropped or masked, query and fragment keys are decoded before classification,
unparseable endpoints render as <redacted>, and transport diagnostics keep
error identity while omitting peer-supplied prose.
Operational values (install, approval fingerprints, persistence) are untouched;
preview and plan responses add separate display fields that the desktop mirror
and confirmation UI render.
Rebased onto the launcher-aware NameFromArgv: names derive from the original
argv but never from a value the display projection masked, so build-flag values
such as `go run -exec` still resolve. Install classification reads the typed
HTTP status instead of sniffing the server name out of the message.
Fixes#11785
The primary button is focused from a passive effect that runs after the
element is committed, so asserting document.activeElement right after a
findBy* resolves races that effect. Use waitFor, as settings-layers does.
testenv.TempDir retries RemoveAll for two seconds when a file stays held.
On Windows the usage and history SQLite catalogs, which the shared helpers
left open, held a file under nearly every test's directory, so each of those
tests paid the full retry budget at teardown. Closing the catalogs from the
helper's own cleanup, which runs before the removal, makes it converge on
the first attempt.
A successful Windows bash probe is remembered per executable identity (path, size, mtime) for the life of the process; failures are never remembered. Every boot.Build used to spawn Git bash for it.
agent.Options.WorkspaceScanLimit (zero keeps the 50,000 production bound) is threaded from boot.Options. The incomplete-prose-scan effect test now sets it to 64 and writes 65 files instead of 50,001.
The deadline starts at launch, so with a real interpreter the test measured startup time. Under load Git bash needed more than 500 ms and the pre-deadline write never landed.
TestRememberPermissionRuleSerializesConcurrentWriters queued 32 writers on a
lock that waits 5s, so a slow Windows filesystem ran the last waiters out of
time (lock timeout at 5.08s). The cross-process variant had the same shape:
each helper waited a fixed 5s for the others to spawn. Eight writers still
interleave, and the readiness waits now read the shared hang budget.
The deadline is armed at launch, so a 500ms budget races interpreter startup:
on a loaded Windows runner PowerShell had not printed anything when it
fired, and the test reported the pre-deadline write as lost. PowerShell now
gets a 10s deadline named for what it has to outlast; POSIX shells keep 500ms.
Hang-catching waits in the control tests were hard-coded to 2, 3, 5 or 10
seconds. A whole turn through the real agent loop can exceed that on a
loaded Windows runner, so TestTurnOutcomeClassifiesPerseverationPause failed
with 'timed out waiting for TurnDone' on a run that passed on rerun. They now
read testenv.Budget(t), which is the single named bound for a wait that only
exists to report a hang.
cloudPresence kept a monotonic counter, so every reconnect of the same phone
took the next number. Allocate the lowest ordinal not held by a live
connection instead; it stays fixed for the connection's lifetime.
The wide-workspace effect test compared total goroutine growth against a
magic 64, which host background work (sqlite watchers in the history
catalog) pushed past under load. Count only goroutines inside the walkers
and bound them by pool size times concurrent walk kinds; assert none
survive the turn.
A fold that was refused or failed ended with an aborted card reading
"folded nothing" and a blocked receipt whose code was empty; the cause lived
only in a sentence. Checkpoint rejections now carry a code per rule, summary
failures are classified by sentinel (truncated, timeout, input too large,
context changed, provider failure), and the code rides both the receipt and
the aborted compaction frame. The Studio card shows a localized cause, and
keeps its before/after token pair on one line when the row wraps.
The Windows runner's temp dir is an 8.3 short name. The grant target for an
ambiguous path is resolved to the long form, so the expectation built from the
unresolved root never matched.
The regression held row and button references across the refetch that
PackageList.submit now triggers after a successful review submission, and
its deferred submitMarket fixtures never updated the inventory myMarket
returns. Query live rows after each reload and delegate to the mock's real
submitMarket so the reloaded inventory reflects the new status.
A tilde inside a Windows 8.3 short-name segment (RUNNER~1) of a drive-letter
path is an ordinary character; any other tilde can expand to a home
directory and stays refused as non-literal. Unescaped braces are refused
too, since a brace word expands to several targets.
Also give the PowerShell delete-analysis subprocess test a hang guard
instead of a 15 s latency assertion that fails on loaded runners.
The reference-solution guard fired only for the train corpus. For every other corpus a missing solution/ was a silent skip, so a grader that can never pass stayed indistinguishable from a task with no solution - the case the rule above it exists to prevent. #11380 supplied the ten vs-pos solutions; this extends the guard to the whole verification-stress corpus and supplies the ten tasks it was still skipping.
The e2e suite keeps the skip, and the skip message now says why: its handwritten tasks do not commit a reference solution.
Verified: go test ./cmd/e2ebench -run TestCorpusGradersPassTheReferenceSolution is green across e2e, verification-stress and train. Removing any single solution/ fails the guard at corpus_test.go:254 (checked by hand for vs-neu-cfgkey), so the added solutions are what the guard grades.
A relay that rejected the continuation field with a message the client did not recognise surfaced the HTTP 400 to the user, and the stored response id was kept so the next turn sent the same rejected request. Any HTTP 400 on a request that carried previous_response_id now retries once with the full history; a completed recovery stops sending the field for the client's lifetime, a failed one surfaces its own error naming the attempt.
Revision after the security review: ordinary commands with dynamic executable names, non-recursive deletes and non-delete wrappers launch again; each refusal carries its own cause and remedy; bounded compound sequences with literal targets inside the granted roots are allowed; default-parameter, pipeline, alias, .NET, find, xargs, cmd and static interpreter payload deletes are judged; symlink components are resolved before dot-dot cleaning; the session temp root is a granted scope; the PowerShell parser is reused across calls; bilingual copy for the new codes.
Doctor now validates declared agent roots fatally, which the warning test added by the root-diagnostics change did not expect, so the two merged changes failed together. The warnings are printed first and the root rejection keeps its exit status.
Approval alone did not stop a recursive delete: in unattended Auto and YOLO a PowerShell sequence whose first assignment failed non-terminally went on to delete the real home directory. A pre-launch gate now parses the command (bash AST, PowerShell parser run as data) and refuses recursive deletes whose target is the home directory, a drive or filesystem root, a workspace ancestor, an expansion, variable or glob, or outside the workspace and granted roots, with a typed shell.destructive_target code; parser failure is shell.analysis_unknown. The gate runs after approval and before every launch route.
A write to a markdown or text file in a workspace with no declared or captured checks still added a stale_verification debt and, in Balanced, a readiness floor demanding a verification command. When every proven mutation is a supporting-role path and nothing declares or captured a check, neither applies. Mixed, opaque, unproven or partially observed effects and declared checks keep their behaviour.
The sidebar row and the account panel disagreed when a token was present but the identity lookup failed: the row showed a signed-in state with a question-mark avatar while the panel drew the sign-in form and hid the error. Both now render the signed-in state, the identity error, a retry and confirmed sign-out.
The record panel was rendered after the whole active-turns list, so with a few
hundred rows it sat thousands of pixels below the click. It now renders inside
the clicked row's list item (or under the signal track for a signal), the same
item stays mounted, and clicking again closes it.
The session filter row gave four equal cells to labels with counts, so at the
old 176px floor the labels collided; the new-session and search rows wrapped
around their shortcut hints. Raise the column floor to 232px, let the filter
cells size to their labels, ellipsize labels, and drop counts and shortcut
hints below a container width.
The session and project menus wrote clientX/clientY into left/top, but the
interface scale is CSS zoom, so a fixed element's left/top is multiplied by the
zoom on its way in. Both now go through pinToViewport, which divides by the
zoom and clamps the measured box inside the viewport. The session menu's
max-height is divided by the zoom as well.
The official DeepSeek entry's model list migrates the retired
deepseek-v4-flash name to deepseek-flash, and qualified refs move with it,
but a bare default_model = "deepseek-v4-flash" (the shape a shared 1.x/Studio
config carries) was left behind. It then resolved to nothing, and because an
unnamed boot preserves an unknown default for its error, the Studio host's
only pane failed to build and the host exited 1 before its handshake.
- config: migrate a bare retired flash ref (default_model and every other
model-ref field) to the official entry's successor, unless another
provider still serves that name.
- boot: a new OpenOnFallbackModel option, set only by the Studio host and
hub panes, lets an unnamed build pass over an unknown non-plugin default
like a keyless one, open on the first configured chat model, and report
default_model_unavailable. The config file is not written. run, the TUI
and every other build keep the actionable unknown-model error and never
move to another provider on their own.
- serve: removing the provider in use moves the conversation to the startup
model when the saved default names nothing, instead of answering 500
after the removal; session titles follow the same startup model.
A background sub-agent keeps emitting reasonix.subagent.* progress after the
dispatching call has returned its job id. Folding those frames into the call's
card re-opened it, renamed it and its execution, and nothing closed it again.
Progress now lands in per-call phase state beside the cards; the Subagents
panel reads a child as running while its call is open or its last phase is not
terminal.
Take back cancelled the entry and dropped its text, so the label promised a
retrieval nothing performed. The body is now read before the entry is given
up and appended to the composer, under whatever is already typed. A line whose
body cannot be read stays queued rather than being lost.
The header chip counted client-side pending rows, which a delivery reported
before its HTTP receipt left pending forever, so it named a line the queue
panel (the only place to act on one) no longer showed. It now counts the
kernel's waiting guidance through the same predicate the panel filters by,
and a receipt that arrives after its delivery no longer re-opens the line.
The terminal UI opens a Configure connection panel when the session owes a
key, and /setup or /auth opens it at any time. The key is tested without
being stored, saved to the credential store only, and applied to the running
session when it belongs to the active connection; config.toml is untouched.
The Studio terminal UI answers the 1.x commands that read or steer the
kernel's own state, with 1.x argument shapes and output. Queue verbs and
steer go through the existing inbox routes, so the controller still owns
admission and ordering. /export is a controller capability: the file is
created exclusively and never through a symlink. /takeover resumes the
target without taking a session from another process.
foldTool looked for a call's parent only among top-level cards, so a
worker's own calls, whose parent is the per-worker task card nested in the
fleet group, fell through to the main list.
Pastes of five lines or a thousand characters become [Pasted text #N · M lines],
counted whatever row ending the terminal uses, and expand on send into the
labelled Begin/End block 1.x sends. Numbering continues past labels a resumed
session already holds, and panels receive pastes unfolded.
The footer's data row now leads with workspace@branch and the +added -removed ?untracked
counts from a new GET /workspace/git, read at start and after each turn. The compaction
meter label is back to the bare COMPACT word.
The one-time confirmation gate is gone: YOLO is always a stop on the cycle and a
single Ctrl+Y enters it, as in the 1.x terminal UI. --yolo on the command line
keeps its one-time notice, and the sandbox still applies in every mode.
On a fresh HTTP/2 connection, api.deepseek.com's edge acknowledges an eagerly
sent request body (about 8 KB or more) with WINDOW_UPDATE before its own
SETTINGS frame. RFC 9113 §3.4 requires SETTINGS first, so Go's client aborts
with "received WindowUpdateFrame before a SETTINGS frame" and the turn fails
with connection error PROTOCOL_ERROR. Studio sends a ~30 KB body on every
turn, so every request fails on affected networks.
Send Expect: 100-continue on chat requests to the official DeepSeek host.
The transport then holds the body until the server answers, by which point
its preface has arrived. HTTP/2 stays negotiated and request bytes are
unchanged; other providers are unaffected.
Moving the header setup into chat_headers.go keeps openai.go within its
file-size budget.
Refs #11679
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Next used the first still-open question, so after going back it skipped answered questions, and with two questions it vanished in favour of Confirm. Stepping is now positional; only the last question looks for an open one.
The workspace label in the sidebar footer, the composer's model name and the
workbench file name paired overflow clipping with line-height 1, so a UI font
whose ascent and descent exceed 1em lost the tops and bottoms of its glyphs.
They now use line-height 1.5; the rows centre their children, so nothing moves.
A style contract fails any new rule that clips a line tighter than 1.2.
The rail items re-declare the font shorthand, which resets
font-variant-numeric, so the digits were proportional and each item changed
width with its value. Restate tabular-nums in those rules and reserve the
width of a three-digit speed.
A submit that gets an answer without a recognised service code (an uncoded
5xx or an undecodable success body) is retried once with the existing
backoff, keeping the idempotency key. Coded refusals, refused redirects and
replies are never retried. The failure message now says the typed text is
kept, to try again shortly, and points at the GitHub alternative.
The source-name field intentionally hides validity errors while an IME candidate is still composing. The Add button did not share that state, so a completed form could submit a partial non-ASCII candidate before composition ended. Hold the button while composing and cover the name boundaries at the UI and serve boundary.
Probe host capabilities before opening a native picker. Headless and remote browser clients now enter a server-side path and use the existing /tree/workspaces API, while the desktop shell keeps its native dialog.
Archive a folded legacy recovery row together with its siblings, keep siblings open in a pane active, and restore sidecars if a multi-file update fails. Refs #11581.
The context-budget notice reported "room remaining" and, at the second
rung, told the model to land what it knows now. It never said that the
fold is automatic and the same task goes on, so a model near the trigger
read the figure as the end of its window, announced it would be
compacted, and handed the work back to the user (in-app feedback
FB-J2QR-HQ89).
Both rungs now state that the figure counts to the automatic compaction,
not to the end of the window, and carry one shared continuation clause:
keep working, do not stop, hand the work back or suggest a new session.
The notice still rides the turn tail and the typed ContextBudgetFigures
event is unchanged.
Two fixtures sat within a few tokens of the notice size and are widened.
Every runtime build ran ReconcileCleanupPending inline: a handful of stat and
read calls per transcript in the session directory. On a Windows box with 8000
sessions the sweep took 11 s of an 11.8 s build and 29 s at 20000 sessions
(about 1.4 ms per session, warm), and it sits before the host handshake, so a
cold start with a large history waits on it. The shell gave up at 20 s.
The Studio host, hub.Open, workspace switch and rebuild paths now hand boot a
reconciler that runs the sweep in a goroutine, once per directory at a time.
8000 sessions: build 11.8 s -> 1.0 s.
Refs #11434
Skipping an ask ends the turn through the same cancel as the stop button, so
guidance the user had queued into it was held uncertain and the queue paused.
The cancel now carries a typed cause: only the user declining a question
returns the undelivered item to the queue as an ordinary follow-up and lets
dispatch continue. Stop, errors and crashes keep the pause, and a stop that
lands after a skip still wins. The requeue only moves an accepted steer, so a
repeat delivers nothing twice.
Refs #11524
The add-source form only learned that a name such as a Chinese one is not
allowed after the kernel refused the save, and then showed the refusal under
the title "cannot connect". The name rule now shows inline as the name is
typed (with the allowed characters stated), the add button waits for a usable
name, coded name refusals from the kernel land on the field, and any other
failed save is titled as a save failure. The identifier rule is unchanged.
Reported through in-app feedback FB-WJ8B-C1XH.
An optional user-level [serve] share_port makes the direct link listen on a
chosen port instead of a random one. Unset keeps today's behaviour. The range is
1024-65535, a project reasonix.toml cannot set it, and the bind range is
unchanged. A taken port is a typed refusal (share.port_in_use). The settings
page saves it to the user config; changing it moves an open door and
phones re-pair.
Refs #11540
The onboarding stage only had a min-height, so it grew with its content and
never overflowed itself while the body clipped it. Once the connection result
was showing, the Start button sat below the window with nothing to scroll.
The stage is now the window's height and scrolls; the shell no longer shrinks
or acts as a scroll container. Adds a browser guard (perf/onboarding.mjs).
A model told a user that Reasonix has no hooks. The guide's hooks section only
covered SessionStart, and the bundled reasonix-guide skill had no settings
schema, exit-code convention or worked example.
A ```diff fence rendered through the colourised built-in path on every
frame, and a colourised row costs ~1.4 ms to lay out (chroma via
regexp2) against ~1.3 µs for a plain one. While a fence streams every
delta re-renders the whole growing body, so a multifile diff pegged a
core and the display crawled. Changes:
- Each "@@ " hunk header settles the rows before it, so render that
prefix and leave only the in-progress hunk on the plain rail: the diff
colours in as it streams instead of only at the closing fence. A
closed fence settles the whole body. The formatter runs on each
settled prefix too, so its output appears hunk by hunk.
- Keep the already-formatted rows on screen while a settled body's run
is pending: growing the body by one hunk changed its key and redrew
the whole section on the plain rail until the new run landed, so the
delta output flashed back to plain at every hunk. The renderer now
draws the largest settled prefix whose result has landed and leaves
only the newly settled remainder (and the in-progress hunk) on the
rail.
- Memoize the colourised rows per hunk (hunk, path, width, rail, theme).
A completed hunk never changes, so it is highlighted once however many
frames redraw it; caching the whole body instead would re-highlight
every earlier hunk at each "@@ " and is quadratic in the hunk count
(measured: 60 hunks, 79 KB took 40 s with a body memo, 2 s per-hunk).
The theme is in the key so a runtime theme switch cannot serve the old
palette's colours.
- A fence still with fewer than two hunks keeps the plain rail, and a
closed fence whose configured [cli].diff_formatter result is still
pending also draws the rail, since that output supersedes the built-in
rows anyway; a landed-but-failed run falls back to the built-in.
- Hold the transcript viewport still when the content shrinks: a settled
diff collapsing its raw preamble to one formatted line frees rows at
the bottom, and following them back up made the screen jump. The
position now holds, the freed rows stay blank, and later rows fill
them, so scrolling resumes only when the output actually grows past
what is shown. The hold has only a lower bound, so a shrink larger
than the viewport would hold the position past the content and blank
the whole transcript — a collapsing multifile fence, or /clear
restarting over a shorter one — so the view re-anchors to its tail
instead. A manual scroll or resize releases the hold.
- Coalesce the stream frames the TUI hands the model: waitUpdate drains
everything already queued into one updateMsg, so a burst of deltas
costs one render instead of one per delta. Order is kept and a Gap
still triggers a history reload.
- Fix the file-header misdetection: a removed SQL "-- x" line renders
"--- x", which trimDiffPreamble/diffSectionStart mistook for a
"--- "/"+++ " file header, dropping the "@@" hunk header (line numbers
reset to 0) or the rows themselves when a removed "--- x" sat before
an added "+++ y". A pair is a file header only when a "@@ " hunk
follows it, and countDiff no longer skips "--- "/"+++ " rows.
- Keep a streaming multi-file diff's next file header on the plain rail:
splitAtLastHunk settled everything before the last "@@ " header, which
included the next file's "--- "/"+++ " pair (and its git preamble) once
that file's first hunk header had arrived but no hunk of it had
completed. isFileHeaderPair then saw no "@@ " after the pair, so the
settled renderer read it as a removed/added row of the previous file's
last hunk and counted it in that file's stat. A trailing dangling file
header now moves from the settled part into the tail until its "@@ "
arrives.
Tests: the multi-file cases TestSplitAtLastHunk was missing and an
end-to-end streamed test; a theme-switch test pinning the per-hunk memo's
theme key; and a benchmark replaying a 4-file x 15-hunk stream, redrawn
per streamed line.
fix(cli): highlight a streaming diff fence's hunk once
splitDiffSections appends a newline to every section, so splitHunks
returns a section's last hunk with a trailing newline and every earlier
hunk without. hunkRowsCached keyed the memo on the raw hunk, so one hunk
carried two keys over a stream: it was highlighted once while it was its
section's last hunk and again once a following hunk arrived. Every hunk
but a file's last was therefore highlighted twice, at ~1.4 ms a row.
Trim the trailing newline before keying. diffBody already trims, so the
rendered rows are unchanged; the streaming benchmark over a 4-file,
30-hunk, 1816-line diff drops from 7.8 s to 5.6 s.
TestStreamingDiffHunkMemoisedOnce pins one memo entry per hunk: it sees
three entries for two hunks before this change, two after.
Fold the writer-tool preview before highlighting it too. diffBody laid
out every row and then dropped all but maxLines, so a write_file card's
~24-row preview colourised the whole diff: ~772 ms per repaint for a
199-line file, paid on every message while the call runs. Counting the
fold from the source and laying out only the kept rows prices a
2000-line diff like a 24-line one.
BenchmarkDiffBlockPreview prices both shapes through a diffPreviewFold
seam, which TestDiffBodyFoldSeamSameRows proves draws the same rows: a
2000-line preview is 6.3 s and 86 MB with the whole body laid out, 71 ms
and 1.0 MB folded, and flat from 24 lines up.
docs(cli): correct diff-fence comment coherence
Four comments described behaviour the code does not have:
- diffRowCount's doc counted one row per non-blank line, but diffBody draws no
row for a "@@ " header, so it over-counted by the hunk count.
- renderCodeRail's doc claimed the in-progress hunk and the pending-formatter
placeholder, both drawn by renderRailText, not here.
- splitDiffSections' doc named only the fence path, omitting DiffText, and
described the plain-rail fallback where DiffText drops a headerless section.
- the file header claimed md.go still sits at the 800-line ceiling.
Reword each to the constraint that holds now.
A held queue item showed the kernel's English diagnostic (for example
"steer accepted but unapplied before turn exit") in a Chinese UI. Items now
also carry a stable BlockCode and the desktop words it in the UI language;
unknown codes fall back to the diagnostic.
A real failure that raced the stop button was reported as a cancellation and
hidden. TurnDone.Cancelled now requires Err to be nil or context.Canceled. The
desktop also reads "Cancelled" instead of "Interrupted" after the user's own
cancel.
Refs #11524
Settings > Storage moved sessions and projects but left other top-level state
behind while the code reads only the new root: the remembered project list
(serve-workspaces.json), user-level REASONIX/AGENTS/CLAUDE instructions,
memory/global, schedules, trusted seals, machine-id.key and the MCP migration
marker. All are now owned entries, so a move carries them.
Installs already moved get them back at boot: each entry is merged once without
overwriting and the old copy is renamed *.adopted. The project list is merged
under its lock and written atomically by a new leaf package shared with serve,
and file copies publish whole files. A guard test scans the tree for paths
joined under the state root so a future entry cannot be forgotten.
Both host notices reached every frontend as kernel English, so a Chinese UI
showed an English card. The context-budget notice now carries a code and a JSON
figures payload, the unapplied-guidance notice carries the user's text as
Detail, and the desktop card and the TUI word them from those in the UI
language. Unknown codes and undecodable payloads still fall back to the
kernel's text.
Refs #11524
Skipping an ask (or stopping a turn) ends it with the kernel's Cancelled flag
set and context.Canceled as Err. The transcript appended Err as an error card
regardless of the flag, so the user's own choice read as "context canceled".
The flag is the identity; Err is not drawn when it is set. An effect test pins
the kernel side through boot.Build.
Refs #11524
resolveWorkspace only knew a leading slash as rooted, so a drive-letter path such as C:\Users\u\x (what the remote tree reports as a project root) was appended to home and the serve never started. Rootedness is now asked of the target machine.
Headless `run` must leave stderr empty unless --debug is passed, so the
boot record of the resolved shell interpreter cannot be an Info log. It is a
diagnostic; the always-on counterpart is the Warn shellrun emits when a
launch actually fails.
Trim repolint comment blocks and move launch-failure logging out of RunForeground so the function stays within the 120-line limit. No behavior change.
Signed-off-by: Harbor404 <[email protected]>
A pinned [tools.shell] path was honoured on existence alone, so a pwsh.exe
that stats fine but will not start was held for the whole session while every
command failed at launch — the failure #10633 reported, still reachable
through the pinned-path branch after #10617 reworked auto-detection.
- Prove a pinned PowerShell the way the bash arm already proves its own, and
fall back to detection with a warning when it will not start.
- VerifyShell proves a pinned PowerShell too, so the settings screen refuses
one where it was typed rather than on every later command.
- powerShellLaunches answers false for a path that is not there: a missing
executable is not launchable, so callers that gate on it alone now agree.
- resolveShell takes the launch probe as an argument, which puts the decision
table ("pwsh will not start, 5.1 will") under test on any host.
- Detection that finds nothing says so instead of silently returning bare bash.
- Boot records the interpreter the session resolved to, and a launch-phase
spawn failure records the shell, its path, the caller and the error. The
desktop relays the sidecar's stderr into desktop-shell/logs/, which the
report found empty.
Windows end-to-end is not verified locally; the fallback is covered by the
injected probe on Linux and macOS.
The hand-written Edit menu dropped Paste and Match Style, Delete,
Substitutions and Speech; restore them with the same roles (structure checked
against the role-based menu in a real Electron). The menu factory is now
Electron-free and unit-tested, the language follows the first preferred system
language like the page, and the redundant second menu install is gone.
Refs #11524
The project menu offered only sort and remove-from-list. Add a reveal item that
asks the hub where a listed project lives (GET /host/workspaces/locate, window
only, refuses any folder the window does not list) and has the shell select it,
so it needs no open pane. Remote hosts draw in their own rail and never reach
this menu; a project whose folder is gone shows the item disabled with the
reason.
Refs #11524
Role-only menu items take their text from Electron's built-in English
catalogue even when the app locale is zh-CN (verified on a real Electron:
getLocale() == zh-CN, labels Undo/Cut/Copy/Paste/Select All). Keep the roles
for the clipboard work and give each item an explicit zh/en label chosen by the
page's saved language (rx-lang), falling back to the machine locale exactly as
the page does. The macOS Edit menu is rebuilt when the language preference
changes.
Refs #11524
Registers tool.show-all, agent.open, agent.close and agent.focus-trap, moves
focus into the overlay and back to the deck chip, caps the overlay's rendered
output at 200000 characters with a note, keeps the sub-agent count stable, wraps
nested output, and drops a stray .agents rule that changed the sidebar spacing.
Headless `run` must leave stderr empty unless --debug is passed, so the
boot record of the resolved shell interpreter cannot be an Info log. It is a
diagnostic; the always-on counterpart is the Warn shellrun emits when a
launch actually fails.
The sub-agent list stays after delegates finish and each row opens that
sub-agent's whole transcript. A nested call whose output exceeds the 400
character preview now says so with a show-all control instead of cutting.
Trim repolint comment blocks and move launch-failure logging out of RunForeground so the function stays within the 120-line limit. No behavior change.
Signed-off-by: Harbor404 <[email protected]>
Project configuration could replace a user server entry and drop disabled_tools, while a stale call to a filtered tool was reported as unknown. Merge user and project disables, reject empty names, and carry a typed refusal through boot and hot-added MCP registration.
Move an existing contextual-tool helper unchanged so execute_one.go does not grow past the file-size ratchet.
A pinned [tools.shell] path was honoured on existence alone, so a pwsh.exe
that stats fine but will not start was held for the whole session while every
command failed at launch — the failure #10633 reported, still reachable
through the pinned-path branch after #10617 reworked auto-detection.
- Prove a pinned PowerShell the way the bash arm already proves its own, and
fall back to detection with a warning when it will not start.
- VerifyShell proves a pinned PowerShell too, so the settings screen refuses
one where it was typed rather than on every later command.
- powerShellLaunches answers false for a path that is not there: a missing
executable is not launchable, so callers that gate on it alone now agree.
- resolveShell takes the launch probe as an argument, which puts the decision
table ("pwsh will not start, 5.1 will") under test on any host.
- Detection that finds nothing says so instead of silently returning bare bash.
- Boot records the interpreter the session resolved to, and a launch-phase
spawn failure records the shell, its path, the caller and the error. The
desktop relays the sidecar's stderr into desktop-shell/logs/, which the
report found empty.
Windows end-to-end is not verified locally; the fallback is covered by the
injected probe on Linux and macOS.
A run in a folder with no trust decision refused every edit and command
under the asking default and exited 0 with an empty stderr (plain text
run) and only the generic 'no interactive approver' reason for the model.
The refusal now carries permission.untrusted_folder. The model reads the
cause and the remedy, the text run prints refused tools, code and remedy on
stderr, the structured result's denial carries a remedy, and
--fail-on-unverified exits 3 for it. Exit 0 and the trust gate itself are
unchanged.
Refs #11493
The shell gave the kernel 20 s to handshake and reported every failure as a
sentence. The wait is now 60 s (REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS), a small
starting window appears after 3 s, an exit or spawn failure within 10 s is
retried once, and launch failures carry a typed code (timeout, exited, spawn,
overflow) with a matching explanation in the error box.
Refs #11434
Answering the last question of a multi-question ask with nothing left
unanswered commits the batch immediately, skipping the extra confirmation
Enter; an incomplete batch jumps to the first unanswered question instead. The
behaviour is gated on a new top-level `auto_submit` option (default false):
with it on the ask drops its Submit tab, and without it the Submit tab is shown
and still needs its explicit Enter.
`auto_submit` is a user preference: LoadForRoot holds it across the project
merge so a cloned repository cannot turn it on, and neither the annotated
project render nor the project delta carries it. Answering moves on to the next
unanswered question, so answering one the panel was thrown back to skips ahead
to the next gap rather than stopping on the already-answered question that
follows it; only the last question, with nothing left unanswered, commits. With
auto-submit on there is no Submit tab to page onto, so right stops at the last
question rather than landing on a hidden tab that commits.
- config: new top-level auto_submit, user/global only; rendered in the user's annotated TOML and omitted from a project render and the project delta.
- tui: Options.AutoSubmit; nextQuestion advances to the next unanswered question and commits on the last; right stops at the last question instead of a hidden Submit tab.
- program_test.go: the commit, next-unanswered-after-skip-back, skipped-question, hidden-tab, no-option and arrow-stops-at-last cases.
- docs: auto_submit in CONFIG_PATHS.md, reasonix.example.toml, and the Guide shortcut tables (en and zh-CN).
Move the providerSourceScope type out of config.go and the [ui] table render
out of render.go into their own files, so both sit under their recorded
repolint budgets with room for the options that follow.
My feedback shows the thread under each report as plain-text bubbles, a
needs-your-input chip with an inline reply box (byte counter, errors per code),
status chips for answered and closed, and a New mark that stays for the visit
in which the replies were read. The sidebar entry and the command palette item
carry an unread count that is read when the app opens and when the page opens,
never on a timer. The form copy says a report becomes public only if it is
filed as an issue, and keeps the warning about secrets and screenshots.
Refs #11424
The kernel reads the new statuses (needs_info, answered, closed), each report's
thread and needsInput, keeps the last-seen reply id per report locally and
exposes unread/hasNew for an entry badge. A reply goes through the controller
port, serve (POST /feedback/{receipt}/reply and /seen) and the TUI
(/feedback show, /feedback reply with --yes as the first token). The new
worker codes become sentinels: feedback.reply_limit, feedback.not_replyable
and feedback.challenge_required. A reply is never retried. Thread text is
cleaned of escape sequences before any frontend sees it. Submit copy no longer
says every report becomes public.
Refs #11424
The worker now holds new submissions by default. The converter still
creates issues only for received items, links only images the worker marks
released, and posts one idempotent bot comment per reporter reply on items
that have an issue, then acks it. Contact is never read by the converter,
which a test pins.
Refs #11424
A browser cannot read the status of a refused WebSocket upgrade, so a
rate limit, a refused origin, an expired ticket, a network loss and an
unreachable gateway all read as "relay temporarily unavailable". When the
socket errors before opening, one probe round (offline check, the gateway
health route, and an attachment preflight that only an allowed origin passes)
now classifies the failure by typed code; a 429 from the account service
carries its Retry-After. The notice names the cause, offers reconnect
(counting down a rate limit), and a reconnect that runs out of retries ends
with the last attempt's cause, or idle when the relay had reclaimed the
lease. The generic sentence remains only when the probe itself fails.
Adds internal/platform/feedback: env collection, local redaction (the
crashreport implementation, now exported), image re-encode with a metadata
self-check, idempotent submit with retry, persisted install identity and
receipts, and GET /v1/feedback/mine with typed sentinel errors.
The controller exposes it as the Feedback port (SessionAPI), serve answers
GET /feedback/env, GET /feedback/mine, POST /feedback and POST /feedback/name
with dotted refusal codes, and /feedback [list|name|<category> --yes <text>]
is a controller slash command so the TUI inherits it. Nothing reaches a model
request or the cache-stable prefix.
Refs #11424
Adds a scheduled converter (scripts/feedback-sync.mjs) that turns pending
Studio feedback into issues, idempotently via a hidden marker, and reports
in_progress/fixed/wontfix/duplicate back to the worker. A fix recorded as
"next" is upgraded to its studio-v tag once the tag exists.
exec.LookPath returns only the first bash.exe on PATH, which is normally the
WSL launcher in System32, and the fallback list covered only the default
Program Files roots. A Git for Windows install elsewhere (with just its cmd
directory on PATH) was therefore never detected, so Studio offered PowerShell
only. Candidates now also come from every PATH directory and from the Git
tree the git.exe on PATH belongs to, and the WSL launcher is rejected in the
candidate loops too.
Fixes#11421
Screened 87 candidates, staged: one pass over the pool, then two more only for
the candidates that were clean on both boundary arms. The six that held on both
arms in all three passes stay, the rest are dropped, so the efficiency substrate
only contains tasks that qualify:
quarter i62-epochlatch, i37-dispatchlag
half i106-budgetseal, i70-ingestfloor
default i47-tenantlease, i51-routedelay
All six are SnippetStop, SnippetThenRead or DefensiveRead in each of their
eighteen judged cells - nothing post-sufficient.
What the screening found: the answer kind decides whether a candidate holds.
Half-tier candidates whose answer was a bare number never survived (0 of 8);
the same tier with a codename answer survived 11 of 20. The dirty class in both
cases was PostSufficientRetrieval - the model re-checking a value it already had
- so the shape that needs no checking is the one that holds.
Also: i07 and i10 were re-measured after the review asked for it (their old
numbers came from the grep shape they no longer use), NeverSufficient is
reported separately rather than counted as clean, and the i11/i12 rows are gone
because those tasks are not in the corpus.
The host took its working directory as the first pane's project. A Start menu
launch inherits the install directory and a Finder launch the filesystem root,
so the app's folder was listed in the sidebar again after every removal. The
directory now counts only when it is outside the app's own directory and not a
filesystem root; otherwise the first remembered folder that still exists is
opened, then the home directory.
Fixes#11418
The provider form only edited one window and one output cap for every model
on a connection. model_overrides already carried both per model and the
kernel resolves them for requests and compaction, so the edit endpoint and
the form now read and write them.
A model with no entry inherits the connection's value, shown as a grey
placeholder; a blank field is never sent as an override, and clearing one
removes the entry.
A cli-pointer job publishes the Studio release's CLI archives as a GitHub
release vX.Y.Z (the only shape the 1.x updater accepts), records it
immutably in R2, then advances cli/{stable,preview}/latest.json. It reuses
the 1.x manifest validators and pointer ordering, only moves forward, and
is gated by STUDIO_PUBLISHES_CLI.
cli-gate requires CLI_PUBLISH_FROZEN so two lines never write the CLI
channels. cli-tag creates the vX.Y.Z tag with the release tag identity in
one inline step of a job that runs no repository code. An existing release
is trusted on its own checksums, never compared with a rebuild.
- The host creates the schedule store directory (0700) before a command's
sandbox is built, so the forbid-read mask exists for it on Linux and on macOS
paths that go through a symlink.
- Document in schedule/doc.go that the confirmed digest defends only against
sandboxed writers (Windows, sandbox off, yolo and MCP/plugin processes can
still write the store).
- Only the model's own usage report for an executor request counts toward
metering; estimates and side calls no longer let unmetered requests slip past.
- The job handle is released on the ready-refused, ready-timeout and cancelled
paths.
- A stored session path must be a .jsonl file in a sessions directory under the
state root.
- Tests: the report's untrusted mark, the stderr sanitising, the supervisor
watch that cancels and forces the process out, and bubblewrap tests for the
forbidden-directory mask. The child's own state check is dropped; Start
refuses a run that is not running.
Refs #11406
- A run whose provider reports no usage is stopped after two unreported
requests and charged the whole per-run cap instead of 0.
- The start of a run is one manifest transition guarded by a token that only
travels over the supervisor's pipe (its hash is what the manifest holds); the
child announces it holds the run's lease before it is released, and the
supervisor holds a supervision lock from before the spawn until the run is
settled, so the reaper never sees a gap.
- The read-only posture runs no config migration, so it rewrites no config file.
- Seatbelt denies writes as well as reads under forbid-read directories, as the
bubblewrap mask already did, so a blind overwrite of the schedule store fails.
- Windows refuses to run a child that a Job Object cannot hold; the job handle is
released; a child whose supervisor left is forced out after a grace period.
- The stored report is marked untrusted, the session path must lie under the
state root, the stderr tail is sanitized, the supervisor's backstops use the
policy minimum, the child reads the same user config file the loader does.
- Tests for the mutants the review found surviving; supervised runs are bounded
by a context so a missing kill fails instead of hanging.
Refs #11406
Adds the hidden `reasonix schedule exec <trigger-id>` child and a supervisor
that starts it, counts the tokens it reports and ends it, process tree and
all, when the wall clock or 1.25x the per-run token cap passes. A run that is
killed, crashes or loses its usage stream is charged the whole per-run cap.
The child holds an operating-system lease for the run, waits for the
supervisor's release line (so a run the store already settled spends nothing;
MarkRunning returning ErrRunSettled kills the child), reads its policy from the
user's configuration only, builds the read-only posture, and keeps reading its
stdin: the end of that stream is the supervisor going away, and the child then
stops and settles itself. A run that parks one kind of request too often
(max_repeat_parks, default 3, user level) is ended.
The final report and parked records are sanitized and bounded by the store on
the way in (PutResult). The schedule store directory joins the protected read
set of every run and the file tools' write guard; Grant.Tools narrows the read
tools of the posture.
Refs #11406
realPath no longer cleans a POSIX path lexically before resolving it, so a
'..' after a symlink is checked against the file the system opens. On Windows
it asks for the final path through a handle, which follows junctions and
settles short names, case and the extended and UNC spellings. read_file
re-checks the scope against the open file. LockSurface freezes SetTools and
SetExtensions once assembly has installed them; Sanitize now removes
zero-width, tag and line-separator characters and runs before a record reaches
any store; a workspace root is refused when the home directory is unknown.
Refs #11406
Read tools take readRoots, checked after symlinks resolve, with the typed
refusal workspace.read_outside_scope; walkers, glob matches and ripgrep
(--no-follow) honour it. The posture forces both secret-protection switches
on and refuses a workspace containing the home directory or filesystem root.
No hook loads under the posture (an empty non-nil scope list). Rebuilding an
observed controller without the posture is refused (ErrObserveRebuild) and
the executor's gate and asker are locked. Approval records keep the host's
text in Summary and the model's arguments in Detail, marked untrusted and
stripped of control and bidi characters. A run may park at most 32 records,
then it is ended (ErrParkLimit). A tool outside the ceiling is refused with
posture.tool_not_allowed instead of unknown tool.
Refs #11406
Adds a host-only Options.Observe that builds a run which can only read
files. The tool ceiling is decided by what each tool declares about its
reach (tool.Reach), enforced by a registry that refuses every other Add.
Approvals and ask questions are parked as Pending records and never
answered; the gate and asker cannot be replaced through the controller.
The workspace's reasonix.toml, .mcp.json, project hooks and folder grants
are not read, no MCP server or extension sidecar starts, and the run
context rides the turn tail as <scheduled-run>. The posture records
remote_content=false and whether an OS sandbox backs the tool filter.
Refs #11406
Slice 1 of the scheduled-runs P0 (#11406): a machine-wide manifest with atomic
writes and a schema version, a Policy that only the user layer can loosen,
pessimistic budget accounting, and claim-before-start with deterministic
trigger ids. No process is started and nothing is wired to a frontend.
The review settled the bar: a task qualifies only if it is clean on both of its
boundary arms, and none of the first six candidates is. This adds five more,
authored to the same shape but with a single value each and a call whose
arguments name the topic (./scripts/x {{cue}}), which is the shape the review
pointed at for i07 and i10.
- i15-ingest-lag and i16-commit-fence (quarter), i17-route-budget and
i18-salvage-epoch (half), i19-tenant-quota (default).
- All five preflight clean at their own tier's generation.
- The corpus stays unqualified until six candidates hold on both arms across a
three-pass batch; the ones that do not are dropped rather than promoted.
The title button in a memory row was flex:none, so a long title (or an
unbroken path/identifier) pushed the scope, date and action past the
container and made the Forget label break vertically. Let it shrink and
wrap within 60% of the row, and keep the trailing action on one line.
Withdraws the studio-cli-channels environment from #11395 at the owner's
decision: no human approval. The job reads the shared NPM_TOKEN and
HOMEBREW_TAP_TOKEN. The contract test and STUDIO_RELEASE.md follow.
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.
Removing a session, a folder, a remote machine or its folder, or the
model source a conversation is on was refused whenever a pane merely had
it open: has_open_pane, has_open_panes, model_in_use. The person asked
for the delete; an idle pane showing the thing is not a reason to say
no, and a window full of such refusals reads as one that will not let
go of anything.
The hub now splits "held by a pane" into running and idle. A pane
mid-turn still holds the delete, answered as session.running,
workspace.running or remote.running with the count, and nothing is
closed while it waits. Idle panes, local or remote, are closed and the
delete goes through. A remote pane's state is asked of its own runtime
over the link; one whose link cannot answer is running nothing this
window can see.
The model source an idle conversation is on can be removed: the
conversation moves to what remains, and when nothing remains the
default is cleared, which lands the window back on connecting a model,
the same screen a first launch shows. Only active work refuses it, as
provider.running. Removing a source that is already gone is the state
asked for and answers 204 rather than a sentence. The provider list now
offers delete on the source in use too.
The sidebar closed a pane before deleting whatever it held without
asking whether that pane was running, so a delete could stop a turn. It
now closes only idle panes itself and leaves a running one for the
kernel to refuse, and the confirmation says so.
A user's recording showed the empty window with no folder, its one
button "open a new session", and every press answered in English: "no
workspace to open in — add a folder first". The kernel raised that as a
bare error, so it reached a Chinese window as the kernel's own sentence
and the window could not tell it from any other failure. It is now the
coded refusal workspace.none, and the empty state no longer offers a
session there is no folder for: with no folder its button adds one,
through the same picker the sidebar uses.
Reaching that state in this build meant closing the last pane, and a
reload then showed nothing at all. The page root was answered by the
first pane, so with none it was a 503; the boot probe asked a pane's
/status, which failed the same way and in a shipped build ends in "cannot
reach the kernel"; and the app held its whole tree behind provider
setup and the welcome flag, both read through a pane's port that no
longer existed. The hub now serves the page itself when no pane is open,
to a request past its auth gate only, since "/" is public in token mode;
the probe asks the hub's own /runtimes; and with no pane the app stops
waiting on setup and the welcome, which the next pane reads again.
The Studio workflow's census gate has failed on every studio commit
since the link router landed: three inputs proven to write the host
named no action, so the product's own vocabulary could not account for
them. The folder picker in the extension dialog, the compaction
threshold presets and the window-wide link router now carry
extensions.pick-folder, compaction.threshold and external.open, the
last registered through listenAction as the registry already describes
a transcript link.
A remote machine's sessions were listed under it but could only be
opened: the local rows carry a menu with delete, the remote ones had
nothing, so a conversation left on a server stayed there unless someone
went over and removed the file by hand.
Each remote session row now has a delete button, quiet until hovered
but never hidden, and the same confirmation the local rows use. The hub
forwards the removal to that machine's kernel over the link its list is
read through, taking one for the call when no pane holds it, and reads
the list again after. A pane holding the conversation is closed first,
since the far kernel will not erase a transcript that is still held.
When the far kernel refuses, it is the one that knows why: a pane open
over there, another process writing. Its refusal used to arrive only as
remote.kernel_refused with the raw body as detail. The far reason now
travels as structured parameters, and this route re-raises it under the
far kernel's own code and status, which the window already knows how to
say.
The usage panel reported a month of DeepSeek traffic at roughly a
hundred times what it cost: on this machine USD 192.92 for records that
sum to USD 1.94, and a user's report put CNY 1,702 on spend they knew to
be well under 200. Tokens were right; only money was multiplied.
ReconcileFile rebuilt a file's records and the rollups of its days, but
left usage_cost in place while re-adding every row's cost to it. Each
pass over a file added its whole cost again, and files are reconciled on
every hash mismatch and directory scan, so the factor grew with how
often a day had been rescanned. The same pass deleted a day's rollups
wholesale, so a second file filed under that day lost its tokens.
Records now carry the cost they were quoted at, and a reconcile derives
the affected days' rollups and costs again from every record that
remains, whichever file wrote it. The live append path still adds one
new row at a time. The schema moves to v4, which retires the inflated
projection and rebuilds it from the stats files on the next open.
agent.go carried the whole mid-turn guidance surface (Steer, SteerItem,
SteerHostNotice, the consume, close and flush paths, the unapplied-steer
notice) while the inbox type and its other Agent methods sat in
steer_inbox.go. They move there, and the orphaned comment describing
steerEntry goes back onto the type.
The move is what pays for giving renderRoot its own line in the Agent
literal: squeezing two fields onto one line kept agent.go under its
file-size budget by hiding a line rather than removing one. agent.go is
93 lines shorter, Steer's doc fits the comment limit instead of carrying
its excess across, and the baseline records the lower budgets.
On Windows the bash tool prefers Git Bash, whose MSYS programs take NUL
as an ordinary path. The Windows habit of discarding into it, as in
ssh -o UserKnownHostsFile=NUL, leaves a regular file named NUL in the
working directory instead: nearly undeletable from Windows tools, since
every path API resolves the name to the device, and sitting where the
next commit picks it up. One landed in this repository's root holding a
remote host's key.
The host can see it happen and the model cannot, so a foreground command
under Git Bash now checks the working directory before and after, and a
NUL file the command created is named in its result with the cause and
the remedy. The check reads the directory listing, the one view that
sees the file rather than the device. A steer in the tool description
was the other option; it moves the cache-stable prefix for every
Windows session and exceeds the schema budget to warn about a mistake
most sessions never make.
A relay forwards someone else's model, so its effort ladder cannot be
probed, and the composer showed "not declared". Its only row said to
declare levels under the source's requests and capabilities, which holds
a thinking on/off switch and nothing else; the real control was a
protocol select behind a fold labelled as an unremarkable line of text,
and a custom vocabulary had no control at all, only supported_efforts
in the config file.
That row now opens the running source's editor with the reasoning fields
unfolded and focused. The editor takes the levels the endpoint accepts
and the one auto resolves to; the kernel stores them as the effort layer
reads them and refuses a default outside them as
provider.default_effort_not_listed. Under Kimi K3 and "none", which
carry no custom vocabulary, the fields keep their values but stand down.
The fold is a titled card that names what it holds and shows the
protocol and level count while closed.
The composer read the model list once at mount, so a ladder declared in
settings stayed "not declared" until a reload. Provider edits now reach
the window's settings pulse, and the model and effort menus re-read the
list when they open, which also catches a config edited outside Studio.
A remembered approval reached the window only as the kernel's English
sentence, so a window in another language could not say it and could
not show the rule it saved. The three outcomes (saved, already covered,
failed to save) now carry notice codes. Their detail is the rule's
subject as a person reads it, drawn as code under the notice.
The version panel treated a Studio run from source as a failure to read
versions and offered a retry that could never succeed. The
studio.no_install refusal is now read by its code and the panel says
this is a development build.
The shell list showed Git for Windows twice, once for bin/bash.exe and
once for the usr/bin/bash.exe it launches. It now offers one row per
install and keeps a pinned path as the selected one.
The workspace list of a remote machine could be read only through a
pane already open on it. Without one the kernel refused with
remote.not_connected, so after a restart the sessions a person came back
for stayed hidden until they opened something there. The hub now takes
a link for the read and gives it back: the far kernel outlives the link
and its session book is on its own disk. Listing publishes no pane.
Each machine row has a read/refresh button. The window remembers which
machines it has read and reads them again on start. The machine-book
hook's key separator was a literal NUL byte, which made git treat the
file as binary; it is now a space.
Network settings belong to a machine, not a pane. Settings now edits
the network of a local pane, because model requests leave through this
machine's broker. When only a remote pane is open it says whose network
is shown.
The boot screen timed itself off its own CSS animations and left as
soon as they finished, often before the app had anything to show. A
bundle that failed to load could leave a blank screen.
The screen is now a scripted intro on a night sky: a meteor, the R
struck together from shards, the wordmark unfolding. It marks each
startup step that has actually happened (bundle, kernel, app) and holds
the wordmark until the app reports it has settled, with a cap for a
shell that never does. It then settles onto the app's own page colour.
If the intro never runs, the wordmark still appears.
The application icon is redrawn to match. It has a single SVG source
and a script that renders every .png, .ico and .icns from it.
The kernel listens on a new loopback port each launch and localStorage
is keyed by origin, so every launch opened on a page that remembered
nothing. The Electron shell now keeps the page's own rx- preferences in
a file under userData and restores them before any page script reads
them. Only the Studio window may read or write that file; the agent's
browser pages cannot.
The Studio stylesheet carried about 150 scheme-scoped hex literals. They
outranked a theme pack, so floating panels, code blocks, sunken grounds
and syntax colours stayed on the default palette whatever pack was
chosen. The pack vocabulary now names float, floatHi, codeBg, sunkBg and
four syntax colours. Every shipped pack sets them, held to AA contrast,
with syntax held on the code ground. A pack that predates these tokens
derives them from the surfaces it does set. A test holds the kernel's
vocabulary and the frontend's CSS mapping to the same set of names. The
stylesheet reads the variables, bringing the carried literals down to
24.
Unreferenced rules (the old run header, trajectory timeline, graph
lanes, side peek and receipt rows) are gone. So are the stylesheet
comments, which move to LAYOUT.md where they state a constraint. The
gutter rules stay: their class is built as gutter-${edge}, which no
literal search finds, and without them the pane dividers lost their
handles.
Guardian, memory, extension, compaction, ask and approval cards now
share one framed host style. The tag says who is speaking rather than
an event name. A guard's verdict is translated and a critical risk is
named. A failed delegate reads as interrupted, not delivered. The
delegate chip shows only while one is running, since a chip left after
the work stops reads as work still going.
A workspace file opened as a highlighted Markdown fence, and editing it
meant switching to a bare textarea. The file now opens directly in a
CodeMirror editor that loads with the first file opened and pulls each
grammar only when needed. The editor is painted from the theme's own
syntax and ground variables, and has its own find (Ctrl+F), go-to-line
(Ctrl+G) and save (Ctrl+S). The window's shortcuts leave alone a key the
editor has already handled. Picking a file from the docked list now
shows the file instead of leaving the list over it.
The browser column did not follow the agent. A page the agent opened
stayed behind the blank start page, and the column opened only if the
person asked for it. The column now opens when the agent opens a page in
the pane being watched, and the start page gives way to the agent's page
and to any new tab. A strip wider than the column scrolls the shown tab
into view, and the column folds only when its last tab closes. A tab the
person opens from the window now sits beside the agent's without taking
over the tab the agent's calls land on. The footer note about sites that
refuse framing, which no longer applies, is gone.
Every foldable part of a conversation had its opening behaviour decided
in its own component: thinking always closed, an activity group forced
open while a call ran, a step open only on failure, long output clipped.
None of it could be changed, and the forced opens overrode a block the
reader had just closed.
Appearance now carries a Transcript folding group with a preset and one
row per part (thinking, activity, step details, long output and the
compaction brief), with a live preview. "Live" keeps an activity group
open for the whole running turn rather than only while a call runs. A
block the reader opened or closed keeps that choice when the preference
moves.
Find stopped reaching rows that were virtualised or not yet mounted,
and a hit inside a folded group was painted where nobody could see it.
Each transcript row now carries its item id. Find lands on the hit by
block, opens every fold around it, clears the find bar, and repaints as
rows mount. A row nested inside another is painted once, under its
nearest row.
Failure diagnosis ran over the whole fold region before the compaction
card went up. A hand-typed /compact over a history with failed commands
therefore sat silent through one model call per failure. Failures that
were about to be folded away also paid for a diagnosis they would never
use.
The card is now emitted first. Diagnosis runs afterwards, and only on
the failures the policy retains, since a folded failure never reads its
selection.
A turn that wrote an HTML page or an SVG could finish on the strength of
a check that proved the file parses, which says nothing about whether it
looks like what was asked for. Nothing in the ledger could tell a page
that had been seen from one that had only been written.
A screenshot now records which workspace file it showed, read off the
URL the browser reports rather than off the model's arguments. Each page
or image written since its last screenshot is owed as an unseen_render
obligation, and final readiness holds the turn until it is looked at or
the look is declared impossible. The obligation exists only where a look
can be taken: boot supplies a render root only when the session has a
browser and the model reads images. Where the turn wrote nothing but
pages and the project declares no checks, a look stands as the check.
A browser_act the host classified as passing also counts as a
verification on the completion card, so the card no longer calls such a
change unverified. The new gap is carried through the readiness audit
and the trajectory.
The route binds the runtime as {id}, and the refusal read {rt}, which no
route declares. Every request to a runtime that had gone away was refused as
"no runtime named ''", and the window said 找不到名为「」的 runtime — a
refusal naming nothing, with no way to tell which pane had lost its runtime.
When the person closed the browser window, the next step heard
browser.no_tab: "no page is open; open one first". That is an instruction to
reopen, and the model took it — reopening the window the person had just shut,
asking for approval each time. The session knows the page went without the
agent closing it: the browser destroyed its target, or the browser itself
exited. That cause now reaches the model as browser.tab_closed, telling it to
ask before opening the page again.
CloseTab removes its tab before closing the target, so the destruction it
causes is not read as someone else's.
A model that calls tools before saying anything still streams a message,
usually with a blank thought. The transcript filed the following work under
that message and then drew nothing for it, so every call before the first
sentence vanished: of six calls in a real run, two were shown, and the one
missing was the failing test the diagnosis rested on. Work now files under a
sentence that is drawn, and a message with no words yet still draws its
speaker when work hangs under it. A blank thought no longer reads as
"thinking 0.0s, 1 character".
The completion menu reset its dismissal in an effect keyed on the answer,
which runs after the commit that opened the menu. A blur landing between the
two was undone and the menu came back. The dismissal now names the answer it
dismissed.
The browser gate read a file URL's path and ignored its host, so
file://elsewhere/C:/<workspace>/x passed as a workspace file while Chrome on
Windows opens it as a UNC share on another machine. A host other than
localhost or a drive letter now names no local file, and a bare drive (C:),
which resolves against the working directory rather than the drive root, is
refused.
Escape during a computer-use run compared the stop time against the run's
start. Windows advances time.Now by timer tick, so a stop inside the tick the
run started in compared equal and the run carried on. The stop is now counted.
The rest is CI debt:
- golden prompts and TOOL_CONTRACT.md regenerated after the browser_act
description change
- desktop go.sum tidied after the terminal removal
- the three OAuth concurrency tests the terminal removal deleted are restored
against ReconcileHTTPMCPOAuthAfterRemoval, the production writer that still
bumps the generation; their loss put internal/plugin under its floor
- the broken-config test reads the resolved user config path instead of
assuming ~/.reasonix, which is not where Windows keeps it
- frontend tests run without vi.mock or fake timers: the launch probation
waits in real time, a missing settings chunk is a real failed import, and
format switches language through boot()
Reasonix ships Studio; the `reasonix` binary exists because Studio installs it
on remote machines and the e2e bot drives it. Nobody sits at a terminal on
either, so the chat TUI was 36k lines cross-compiled to six targets and shipped
to machines that would never run it.
The binary stays, and the two things that need it keep working, because both are
measured rather than assumed. internal/remote/bootstrap launches
`reasonix serve --addr --auth --token-file --port-file --pid-file`, having first
parsed `serve --help` to check those flags exist; e2e-bot.yml drives
`reasonix run --metrics`. Both are untouched.
What went:
- the interactive entry, so bare argv is a usage error rather than an invitation
- 233 methods on the TUI model, stripped in place so a file that also held a
command kept the command
- the views, pickers and cards behind them, and the 42 test files that only
covered them
- 17 exported kernel functions the TUI was the last caller of, each checked
against what Studio actually calls first: boot.RebuildFrom (Studio uses
Rebuild/BuildRuntime), control.SaveAttachmentFile and friends (serve saves its
own bytes; the symlink checks guarded attaching a file *by path*, which only
the CLI could do), plugin.ClearHTTPMCPOAuth and mcpdiag.DiagnoseAuth (no
surface anywhere)
- mcpdiag.IsAuthFailure, which matched 401|403|unauthorized against prose an
external server wrote — the phrase table REASONIX.md says was retired, left
behind with no caller
cli leaves frontend-parity and the port-segregation guard, in both tables with
the reason: it launches serve and a one-shot run now, so a session capability it
does not drive is not debt. --preset joins run's completion, which it always
accepted and was only ever offered on the interactive command.
TestMain moved out of chat_tui_test.go. It pins the language for the whole test
binary, and two render tests started failing on this machine without it —
exactly what its own comment predicted.
internal/cli: 246 files to 162, 35,636 non-test lines to 18,224. repolint's
recorded budgets fall with it — complexity 1383 to 1014, file-size 29,492 to
24,191, frontend-parity 129 to 32 — and no ceiling was raised.
The shell asked for /_studio/ after the kernel stopped serving that namespace,
so index.html came back for every module the page imports: three script
requests answered 200 with text/html, strict MIME checking refused all three,
and the window sat on "connecting to the kernel" with React never mounted. The
page owns the root, which is where its relative asset paths already pointed.
macOS draws its window controls over the window's corner, and that corner
belongs to the rail rather than to the title bar, so they landed on the
wordmark. The reserved corner is a pair of tokens the rail head and the chrome
row both read, applied to whichever of them the rail state leaves there, and
the strip the rail gives up drags the window the way the bar it replaces does.
Their y is set rather than inherited: AppKit seats them nine points above the
line this row centres everything else on.
A reply's menu draws above the row that anchors it, and the row closed it when
the pointer left — but leaving the row is how the menu is reached, so it shut
on the way there, in the 5px offset that belongs to neither. Dismissal is
useDismiss, the one every other menu answers to. The deck reading has the same
shape and keeps its hover, so its anchor spans that offset instead.
The [desktop] table is written by hand, one fmt.Fprintf per field, so a field
added to the struct without a line in the renderer is dropped on every save.
There is nothing to notice: the control works, the file is written, and the
setting is gone on the next launch.
`editor`, added two commits ago, was exactly that. Anyone who named their editor
would have found it blank again the next time they opened settings.
The gate is the fix. It reflects over DesktopConfig, gives each field a value
that field accepts, renders, reads back, and reports every one that did not
survive. A marker the loader normalises away comes back as the default and is
indistinguishable from a field that was dropped, which is why the values are
the fields' own rather than one placeholder for all of them — two of the three
first failures were the test's fault for exactly that reason.
Three fields are exempt, each with its reason in the table: update_channel and
expand_thinking are read for compatibility and never written, and currency is
superseded by [billing].display_currency.
Checked the other tables the same way. background_jobs, system_one, wallpaper
and reasoning_display_mode are rendered through helpers. The six [agent] fields
with no renderer — auto_plan_classifier, soft_compact_ratio,
tool_result_snip_ratio, compact_force_ratio, context_editing, cold_resume_prune
— are the ones boot.go already reports as removed from config, so not writing
them back is what they are for.
Compared against the prototype, Studio's remote surface already carries more
than it does — a connection test, a proxy jump, ssh_config following, secrets
named by environment variable rather than typed, several workspaces per machine
— and the one thing the prototype states that this did not is which credential
gets used.
Leaving the key fields blank is not "no authentication". The kernel tries
ssh-agent's keys first, then the default keys under ~/.ssh, and reaches a
password only when one of the named variables is set or the machine asks
interactively. That order is the kernel's to say, and a reader left to guess
between three of them finds out on the first connect. It is on the form now.
Two comments in that file were in Chinese and are in English, per the tree's
own rule; the interface strings are untouched.
Also fixes the launch-health flake, which predates this branch and fires with
isolation on, so it was never a pooling artefact. The test sampled `said` part
way through a probation measured on a clock that advances by itself — a race
with whatever else the machine is doing, which says nothing on the run where it
loses. It reads the moment off the acknowledgement's own timestamp instead.
Verified by sabotage: with PROBATION_MS at 0 it fails with "expected 920 to be
greater than or equal to 2000", and three full runs are green.
Every http link anywhere in the window was intercepted and handed to the
machine's browser. That was right when Studio had no browser of its own; it has
one now, and it is the one the model can read and drive. A page opened outside
is a page nobody in this conversation can see — not the person who clicked it,
and not the model that would have to be told about it by hand.
A link now opens a tab in that browser and reveals it. Ctrl, Cmd or Shift is
the way out to the machine's own browser, and a shell that draws no views —
a plain browser tab on `reasonix serve` — has only that way. A kernel that
refuses to open one falls back rather than leaving a click that did nothing.
The handler moves out of App into useLinkRouting, because a rule about where
every link in the product goes should be readable and testable on its own
rather than as an effect halfway down a 700-line component.
Links inside a drawn page were already staying inside it: will-navigate allows
any http origin but the kernel's, and target=_blank becomes another internal
view rather than an OS window. Nothing there needed changing.
A button beside the file list, which is the one place the workspace is already
what you are looking at. The kernel opens its own workspace root rather than a
path the request names — taking one from the client would make this a way to
open anything on the machine.
internal/editor discovers what is installed the way internal/browser discovers
a Chromium: VS Code, Insiders, Cursor, Windsurf and VSCodium, by the paths their
installers use and then by PATH. Installed paths first on Windows, because
`code` there is a .cmd shim. A configured [desktop] editor is taken as given and
is the only candidate: naming one is how a machine with several says which, and
a search that found something else first would make the setting advisory.
Three outcomes, three codes: no editor installed asks the person to install or
name one, a launch that failed does not, and a kernel with no window says so
rather than opening a window nobody is sitting at. The button reports which
editor opened it, because a control that does nothing visible is
indistinguishable from a broken one.
Also takes `isolate: false` back out of the test config. It was fast — about 9s
against 15 — and it made the suite intermittent: a render test passed alone,
failed in one full run and passed in the next with nothing changed. A gate that
goes red by arrangement teaches people to re-run it rather than read it. The
threads pool stays, which is most of the win that does not cost correctness:
19.4s to 12s, stable across three runs.
Sub-agents and background jobs are readings about this turn, so they sit with
the turn's other readings at the right end of the run rail instead of at its
left, and the detail is inside the same anchor as the reading rather than a
panel that opened above the whole row. Hover reaches it, so does keyboard
focus, and a click pins it — the context reading already worked this way, and a
row where each control answers to a different gesture is a row you have to
learn.
The two chips are written out rather than sharing a component that computed
`data-action` from a template literal: the actions gate reads those names out
of the source, and a computed one is a name nothing can check.
Fixes a typecheck failure I committed in 71a63d325: vite.config.ts derived the
isolated-test list with node:fs, which this project has no types for. The list
lives in src/testing/isolated.ts now, and isolated.test.ts holds it to what the
sources actually mock — so it still cannot drift, and the config stays free of
node APIs.
The kernel has had these notifications since the CLI: a turn finishing, a stop
for approval, a question. Studio's host wired them and then never gave anyone a
way to turn them on — the only switch was a hand-edited config.toml, which is
why they read as missing.
Three things were wrong beyond the missing screen. The host decided at boot
whether to wrap a runtime at all, so "notify me" meant "notify me after a
restart"; every runtime is wrapped now and the sink reads a holder the settings
endpoint stores into, which is what makes the block honest in saying it applies
immediately. The bodies were English literals in a window set to Chinese; they
come from the catalogue now, like the tray's. And a kernel reached over the
network registers no route at all, because a notification fired there lands on
the server's desktop rather than the watcher's.
The block is one switch with three branches, drawn rather than greyed out of
existence, and it draws nothing where the kernel has no window to notify from.
Tests: the sink delivers once the switch flips on a runtime built while it was
off, stops when it flips back, and answers in the window's language; the block
writes what was asked and keeps what the host answered back.
Also splits the test suite by what needs a private module registry. `vi.mock`
cannot share one — a module another file already imported unmocked stays
unmocked — and format.test.ts began failing in the full run while passing alone,
which is exactly that. Those files run isolated; the rest keep the shared
worker. Which files they are is read out of the sources, never listed. 19.4s to
8.8s, verified under --sequence.shuffle.
Quoting a whole answer to ask about one sentence is not quoting. The button now
takes what is highlighted inside that card, and the quote rides above the box as
a chip the way a long paste already does — an answer pasted into the composer
becomes the composer. Which reply it is stays the kernel's to say: the turn its
checkpoint named travels with the text and is stated to the model, and a
transcript rebuilt without checkpoints sends no turn rather than a guess.
The token readings move while the model writes. usage only lands when a round
closes, so received is estimated from the chunks themselves and replaced by what
was billed. The two share one place, so which is on screen is said: an estimate
is drawn in --muted with ≈ on its unit. A number that just moved lights and lets
go on a timer; reduced motion keeps the numbers and drops the movement.
The receipt card was the only transcript card faking the gutter with a magic 42px
and owning a private row vocabulary. It is a host card now, each item a .find, so
a receipt and a notice read as one runtime speaking. The gutter is --call-sym +
--call-gap, stated once, and .nest-bd redefines those rather than re-specifying
the grid.
Tests: one worker per thread, reused across files. Re-importing React, the port
and app.css once per file was most of the run; `pnpm test` goes 19.4s to 9.4s,
verified twice under --sequence.shuffle. What it costs is written down in the
frontend README: module state outlives a file now.
.glyph leaves the mono exception list — it holds PNG/TXT/❝, which are marks, and
it no longer renders a bare string the gate reads as prose.
Ten extractions, each one concern leaving a file that had outgrown the
800-line ceiling: the gate and queue callbacks and the rewind group out of
Pane.tsx, the effort vocabulary out of Composer.tsx, provider-search splitting
out of session.ts, the mock usage report out of mock.ts, stream interrupts out
of provider.go, the steer wrapper out of agent.go, the config-forward chain
and system-prompt resolution out of config/load.go, on-demand MCP connect out
of usecapability.go, and session previews out of save.go. The MCP list filters
move next to listServerInfo, which is where they were always about.
The picker's two refusals now carry codes — picker.unsupported and
picker.failed — so a frontend can say which one happened instead of printing
whatever sentence the host wrote. EnsureBranchMetaLocked and TouchBranchMeta
were exported and called by nothing, including tests; the comment that
survived them narrated their removal, which is the commit message's job.
Also: three comment blocks in Pane.tsx that had drifted away from the elements
they described, one of which contradicted the code beneath it; the duplicated
status-totals dispatch; and three redundant nil checks whose fallthrough
returned the same value they guarded.
repolint is clean for the first time on this branch — every per-file budget
and all five repo-wide ceilings — and -update reclaims the 410 lines of
file-size budget the tree stopped using, so nothing grows back into it.
A queued line is the composer's to show, not the transcript's: leaving it in
the list split the running turn at the moment of typing and handed its work to
a line the model had not read. It now seats at delivery, and the outbox shows
only what is still owed.
Several subsystems were built and never rendered. Plan, Jobs, Agents, the
command palette and the reply actions are now mounted: the plan sits above the
composer as a one-line summary that opens, the deck chips ride the run rail,
and Ctrl K reaches sessions and commands.
Theme work: the sidebar's greys were hardcoded past the token closure, so every
migration silently missed them. Selection reuses the 2px run bar rather than an
accent fill. A ratchet test now counts the literal-colour rules that outrank
the closure.
Browser: manual tabs route through control.BrowserOpen, so what the person
opened is what the model can read and drive — there is no second
implementation. POST /browser/open carries the refusal code
browser.open_failed, and the CLI gets /browser for the same capability.
Credentials: APIKeyEnvFor is the one derivation, beside the validator that
judges it, and ErrInvalidCredentialKey gives callers an identity instead of a
sentence — a provider named "129" now reports the name as the field to change,
not the key that was pasted.
serve: the kernel serves the built page at its own paths and answers
page.not_built when none is mounted. Onboarding belongs to that page, so the
second inline setup page is gone and TestServeStartsWithMissingProviderKey
pins /provider-setup, which is what the kernel actually owes.
The System One decision backends collapse onto the general mechanism: the
hosted endpoint and its gateway become ordinary sources with a role, migrated
once for installs that already hold the old shape.
Also fixes three lint findings that predate this branch (a tagged switch in
agent/task.go, http.MethodPost in serve/hub_picker_test.go), so golangci-lint
is clean again.
repolint's repo-wide ceilings were already exceeded at 9a4ead5cd; every total
here is at or below that commit's. The per-file budgets Pane.tsx, Composer.tsx
and the config/agent files now owe are to be paid by extraction, not by
-update.
It filled the window and the only way out was a click. The dismissal every
other overlay in this window already uses, and a test that was checked against
the code without it.
`/sandbox revoke` with nothing after it said this session had allowed nothing —
the branch that lists them was lost when the command moved under /sandbox, so
the one case a person hits first was the one that lied. The test now walks it.
The clipboard went back 250ms after the paste keystroke, which is the step
saying "the application has surely read it by now" about an application it
cannot see. It has not always; a busy one reads it later and pastes what the
person had instead — their own clipboard, into their own document, silently.
The wait is now 1.75s and the step does not pay for it: it answers after 250ms
and the clipboard goes back on a timer, still only if nothing else claimed it.
Watched a real run: asked to operate the Calculator and show the result, the
model took the screenshot the tool gives it, then wrote sixty-three lines of
ctypes against CoreGraphics to find the window id, ran screencapture into a
file, and opened that file in the browser pane — six or seven calls to put a
picture somewhere it believed a person could look. It had no way to know the
card already draws the one it was given.
Sixteen tokens of schema (6751 -> 6767) against that.
The chain from a tool's image to a card is tested at each seam — the event
carries it, the wire mirrors it, the card renders it — and none of those asks
the real window. This turn takes a screenshot of the page and then reads the
renderer's own DOM for it.
The document opens by saying it is generated from the same canonical schema
path as the runtime registry. It was not: the table was maintained by hand, and
the check only asked whether a row existed with the right read-only flag. So a
description could drift from the one the model is actually sent, and sixteen of
them had — including bash, whose row still told a reader to prefer shell
redirects the tool now tells the model never to use.
The table is written from the registry, and the check compares it. Everything a
person wrote around it stays theirs. A round that changes a description on
purpose regenerates with -update-contract.
"Allow for this session" is written in no file. The rules editor showed the
file, so it showed less than the agent may currently do, and the only way out
of a grant was to end the session — which costs the work as well as the grant.
The rules the kernel is holding ride along with the rules it read, so one
request answers "what may it do right now". A revoke takes one back or all of
them, and is not behind the provider-edit grant: writing the boundary wider is
a privilege, and taking permission away is always the reader's to do.
Studio lists them above the table, each with a way back. The CLI answers the
same question under /sandbox, which is already where it says what the agent may
reach — `/sandbox revoke <rule>`, or `revoke all`.
That command took the boundary view out of chat_tui.go, which is 5468 lines and
where three budgets sat one line from their ceiling. The baseline moves down:
36 lines of file-size, and one frontend-parity edge, because reading the rules
from the CLI is what that view now does.
A screenshot the agent took reached the model and stopped there. The result
text says "[image: screenshot]", which is the tool telling the model a picture
exists — the reader was told one was taken and never shown it. It is the one
thing a person watching an agent operate an application, or a page drawn
somewhere they are not looking, most needs to see.
The tool result carries the images it showed, the wire mirrors them, and the
card draws them under the output: thumbnails, and the full size on a click. No
store and no second request — nothing persists the event, so what the window
needs travels on it.
The rules editor groups by tool and labels each group. A Computer= or Browser=
rule — which is what "always allow this application" writes — matched no label,
so it listed under a blank one, and fell through to the default note saying it
matches by path with * crossing a /. Neither is true of it: an application grant
is compared to a bundle id and only answers when it names one exactly, so a
glob there can refuse but never permit.
Typing a long value one synthetic keystroke at a time is what an editor that
completes, indents or reformats what it is given turns into something else.
paste puts the whole text in through the clipboard — and the clipboard is the
person's, so what was on it goes back, unless something else claimed it while
the step ran.
Measured first, and it changed the shape: an application that is not in front
never handles the keystroke at all. Bringing one forward is taking over the
person's screen, which is a thing they answer for separately, so paste does not
do it quietly — it refuses with computer.needs_front and names both ways out: a
pointer step to bring the application forward, or type and set_value, which
work while it is behind. The live test walks that path and reads the person's
clipboard back afterwards.
The probe application grew the Edit menu every application has. Without one
there was nothing for a paste keystroke to reach, so the fixture could not
stand for a real application.
Real run, asked whether a pricing page breaks at phone width: the model set the
viewport, saw the cards run off the side, and then had nowhere to go — scroll
only carried delta_y. It read the rest by dragging the horizontal scrollbar,
four drags and four screenshots to see three cards.
scroll takes delta_x. 17 tokens of schema (6706 -> 6723).
The request list outlives a navigation, which is what makes it useful — a click
that navigates is exactly when the agent wants to know what was asked for. But
nothing said where the page changed, so a 404 the previous document collected
read as this one's, which is a wrong answer rather than a missing one. Each
request carries the document it belongs to, and the list says where that
changes.
Alongside: the browser nobody could find says when the setting that fixes it is
read. A real run watched the model write [browser] executable into the config,
retry, get the same refusal, and start looking for somewhere to symlink a
browser into instead.
The shell target is a hand-written protocol in this window, not a browser's own
port, so what the kernel's live tests proved against Chromium says nothing
about it. Two checks read the real thing: the page laid out at phone width, and
the model told what the page requested.
Both passed the first time, which is the point of running them — but the
viewport check passed at first for the wrong reason twice over. It handed the
measured width to the argument that takes a condition, so any non-zero width
was a pass; and once that was fixed it read the wrong view, because by then the
page had opened another. The turn now names its tab and the check compares to
what it asked for.
Alongside: drawing a page over the rectangle it already has skips the work. The
panel reports its rectangle whenever anything in the window changes, which
during a turn is constantly and nearly always the same rectangle.
The resilient loader already does the right thing and records why: a user
config it cannot parse is replaced in memory by the last good one, or by the
built-in defaults, without touching the file. Whether anyone is told was left
to the frontend, through OnConfigLoadWarnings — which no frontend sets. Only
tests ever did.
So a typo in config.toml ran the session under another model, with no
providers and none of the person's permission rules, and said nothing. Driving
a headless run into it, what reached the terminal was two lines about a config
upgrade and an MCP migration failing, which name neither the cause nor what
the session is now running on.
Boot writes them to the diagnostic stream when no frontend takes them, so all
four inherit it. It does not claim they were handled: the migration notice a
window would have shown stays owed, because that one reaches a window and this
does not.
Two gaps the other implementations answer and this one did not. A page that
misbehaves is usually a request that 404ed, was refused, or never came back,
and the only thing the agent could see was the console. A layout that breaks
at a phone's width cannot be seen through a window that is not that wide.
- browser_read what=network lists what the tab requested, newest first, with
what came back: status, type, size and how long it took, or the browser's own
words when nothing arrived, or that it is still in flight. text narrows by
URL. The tab keeps the most recent 200.
- Headers and bodies are deliberately not kept. A credential travels in exactly
those, and a list that held them would put them in the transcript of every
session that read it.
- browser_act gains resize: the page lays out in the viewport it is given,
whatever the window is.
The schema surface grows 46 tokens (6660 -> 6706), which every session pays;
both are trimmed to their shortest useful description first.
TestEffectFoldDropsSupersededStandingState fails under that larger prefix, and
was wrong rather than unlucky: it asked a fold to leave strictly fewer copies
than the request before it, while the turn that follows a fold always adds its
own. It now asks for one fewer than the count that request would have carried.
Two packages had written the same size formatter, to different answers: the
storage panel's goes to PB and rounds down, the tool-output notice's stops at
MB and rounds to the nearest. A third caller was about to arrive with the
network list, so the judgement moves to the leaf both may import, and the two
callers read it from there.
The live shell run proved computer use end to end under YOLO, which answers
every prompt — so the one thing the pointer's design rests on was the one thing
that run could not see. It now runs under auto and answers as a person would.
What it reads: granting the application for the session covers operating it,
the accessibility steps go through with no further question, and the pointer
step is still put to the person. Refusing it leaves the application with
nothing — no mouse event in its log, the pointer where it was, and the
frontmost application unchanged — and the model is told it was declined.
A capture waited on the main thread for ScreenCaptureKit to finish, which is
the thread the rest of the helper's work needs; it now answers out of band,
from whichever thread the capture ends on, and the one output stream is
written under a lock.
That was not why a capture hung, though. Measured on macOS 15: while one
process that has captured a window is alive, a capture in any other process of
that same executable never returns — for any application, with no expiry, until
that process exits. Two Studio hosts on one machine is the reachable case, and
an orphaned host is the one that leaves no window to close. So the wait is
bounded at 10s, and what it says names the condition it can observe: the other
helper, by pid.
The live tests were making that state themselves, a helper per test, and the
third one waited out the timeout. They share one session now, which is what a
host holds.
Accessibility can only touch what an application published as an element. A
canvas, a drawn view, a map, a game — the other implementations reach those by
moving the pointer, and this one answered computer.no_action. The live test
pins the difference on one view: AXPress finds nothing there, and the same
point under pointer_click reaches the application as a real mouseDown.
So the pointer becomes an explicit class rather than another step in the list:
- pointer_move, pointer_click, pointer_drag and pointer_position aim in the
pixels of the screenshot the model was given, the same frame every other
point-taking step reads, and the helper posts real events to the HID tap.
- The helper refuses a point whose window belongs to another application, so
an approval for one application cannot walk into the next one's window.
- It is the person's pointer: taking it parks where they left it and the run
gives it back, whether the steps finished, failed part way, or were stopped.
- The permission subject carries a pointer prefix, and that subject is only
ever answered by an exact grant — no posture, no application-wide allow, can
hand over the pointer on its own. The approval says so in as many words.
Alongside it, the steps the other implementations have and an accessibility
tree already exposes: right_click opens the element's own menu, scroll says
whether it turned the wheel or brought a named element into view, hold_key
holds a chord for a duration, key repeats, and wait is bounded.
internal/computer's floor rises 85.8% -> 89.8%.
The golden tool schemas and the committed trace readings are what a test
compares the live assembly against, so they are only useful while they are
what it produces. Two rounds landed without them: the browser gained drag,
upload, forward, reload and find, and an approval gained the two fields that
say which answers the kernel will honour. Both tests have been failing on the
branch since.
Regenerating costs 106 tokens of cache-stable prefix (5335 -> 5441), which is
the schema of the five browser steps and nothing else: the pointer work in the
next commit registers its tools only where a computer is attached, so it does
not reach this baseline.
The scroll step's reveal worked only where an application offers an element a
way to reveal itself. AppKit does not: its scroll areas advertise
AXScrollDownByPage and then refuse to run it (-25205, measured on a live
window), while the scroll bar's own value moves the view. So the geometry is
solved on that instead — nudge the bar, see how far the element travelled with
it, and put the bar where the element lands inside what the area shows,
verifying that it did.
The live test holds it to the effect rather than to the application's own
report: an application that scrolls by pages never hears about the element at
all, so what says it worked is the scroll area having moved.
Read against Anthropic's computer tool (17 members), OpenAI's computer-use
action list and Playwright's MCP server, then filled the gaps that a real run
had already hit.
The browser gains reload — a page whose file changed is served again, which a
run worked around by appending ?v=2 to the address — forward beside back, drag
from a ref or point to another, upload, which hands a file input files from
inside the workspace and refuses anything outside it, and browser_read find,
which answers where text is on a page too long to read rather than paging 250
lines at a time. Verified against a real Chromium: the reload shows the changed
file, back and forward walk this tab, the upload arrives and /etc/hosts is
refused, and the drag travels so the page sees it.
Computer use gains right_click, which opens the element's own context menu
rather than borrowing a pointer, scroll, which brings a ref into view or turns
the wheel at the application, and wait. The keys and the pointer stay where the
person left them, as before.
And the host now names what a locked screen does: measured here,
CGSSessionScreenIsLocked makes every application answer with its own element in
place of its windows, so a snapshot holds a menu bar and a click by point finds
nothing to press. The snapshot, the missing element and the dead point all say
so; typing, keys and screenshots keep working, which is what the failing run
ended up using. The built-in schema budget grows 6346 -> 6512 for the two
vocabularies.
Measured on this machine: Calculator's AXWindows, AXMainWindow and
AXFocusedWindow all answer with the application element itself, whose children
are itself and the menu bar. Safari answers the same way; Finder does not. The
snapshot handed the model 195 lines of menu items with no window in them, and a
run spent twenty calls on it — coordinate clicks answered no_action, focus
failed, and the model ended up writing itself Quartz key-posting scripts.
The tree now says what it is: an application that hands its own element back in
place of every window gets the menus it does have plus the host's note that
nothing inside the window is there to read, click or hit, that typing, keys and
screenshots still reach it, and that relaunching usually brings the window back.
The key action also names its vocabulary when it refuses one: that run tried
"2", "Numpad2" and "Digit2" in turn against a message that said only that the
key was not one it could press, when digits go through the type action.
The card had three answers and one of them lied. "Do not ask again" sent a
grant that dies with the session — the sealed line under it even said so, in
smaller type — and the answer that writes a rule down, which the chat TUI has
offered for as long as it has had a prompt, was not reachable from this window
at all. Operating one application through nine calls meant nine prompts and no
way to say "this application, for this session".
The kernel now says with each request which answers it will act on: a session
grant, and writing the answer down. Both are derived from the call — a fresh
decision takes neither unless its tool allows a session grant, and a controller
with nowhere to write a rule does not offer to write one — so a window cannot
promise what the host would drop. The card draws exactly those: allow once,
allow for this session, do not ask again, refuse.
Verified in the real Studio: granting the session for com.apple.calculator
stopped the per-call prompts for it, and the two prompts that followed were
the ones the grant does not cover — a different subject, and a shell command.
Two things one screenshot run turned up.
The accessibility walk had no cycle guard, and Calculator's application element
lists itself among its children: the tree repeated to the depth cap, 303 lines
and 33 KB of nothing but that element and the menu bar, with the window's own
buttons never reached. The model read that, gave up on refs, and wrote itself
Quartz click scripts to finish the task. Every element is now walked once per
snapshot. The state that starts the walk at the application element rather than
a window was not reproducible on demand; the run that hit it is recorded.
And the full-width browser view left the page in the bottom of the pane: the
column that holds the conversation keeps its place in the body, and an empty
flex child still takes the room. It is not drawn while the pages have the pane.
The live test now checks the reserved rectangle is the pane, which the
comparison against that rectangle could never catch.
Every page the agent opened came back carrying an Electron security warning
about the page having no Content-Security-Policy. The window this browser runs
in injects its own bundle into every page it draws, and that bundle warns there
— so the model read the host talking to itself as the page reporting a problem,
on every read, and had to decide each time whether the site it was looking at
was broken.
A console message or exception whose every frame comes from a script the page
did not load — this window's bundle, an extension, devtools — is not the page's
and is dropped. The stack says so; nothing here reads the text. A message with
no stack at all still counts as the page's: unattributed is not the host's.
Watching the agent browse and reading what it says are one activity, and the
window made them exclusive: the pages were a pane view, so seeing them meant
leaving the transcript, and a run that opened one said nothing about it — the
reader had to know to go looking. Someone testing it asked how they were
supposed to watch both, which is the question the layout could not answer.
The pages now dock beside the conversation from the moment the agent opens one,
in a column this window remembers turning off. The browser tab still opens them
full width, and leaving it puts them back beside the transcript. The shell draws
the page over whatever rectangle the panel reserves, so the column needed no
work there.
The three diagnostic readings move into PaneDetail.tsx, which is what made room
for the layout under the pane's ceiling; their mounting contract moves with
them. pnpm browser-live now drives the dock, the full-width view and the switch
between them.
A browser task's verification is the browser: open the page the change is in,
act on it, read what it reported. The host counted none of that, so after the
model had fixed a page and proved it in the browser the turn was sent back for
"a verification command", and the shell checks it then invented were the ones
that went wrong — a real session spent 21 more tool calls and three forced
continuations on a task that was already done, at seven times the cost.
browser_act now reports what the call was, the way bash does: a page served by
this workspace — a file inside it, or something on loopback — acted on with
every step run and nothing thrown is a check that passed. Anywhere else stays
unclassified: a page on the open web exercises nobody's code here.
Re-run through the real Studio with the same task and the same model: 35 tool
calls, 3 continuations and ¥0.07 before; 14 calls, no continuation and ¥0.011
after.
Reopening a finished session showed "waiting on you" in the title bar and the
composer's amber posture, with nothing on screen to answer and the kernel
reporting neither a running turn nor a pending decision. The transcript is read
back from the record, and the frame that says how a turn ended is not in it, so
the terminal was null — which the run state read as "stopped part way".
A restored transcript now says so (TurnTerminal "unread"), and only that reads
as nothing happening. A live turn that vanished mid-flight still leaves null and
still reads as the turn stopping short, which is the case that shape was for.
The identity tail exists so a sign-off can cite a step id. A list whose every
item is complete has none left to sign, and the host has already told the turn
not to open new work under that plan — but the tail went on riding every later
request, so an unrelated task in the same session opened with the host asking
it to cite ids for a plan that ended. A real session spent a round reasoning
about whether it was supposed to adopt the finished list.
Owed is still recomputed per request from canonical state; a list with any item
left carries its identities as before.
Every request ends with what the host projects onto it — the task list's
identities, the blocks it owes this turn — and the body chain hashed those too.
They are re-derived per request and are not what the next request carries, so
from the first turn that had a task list onward every comparison reported the
carried body rewritten with no declared reason, and the window warned "the
prefix moved" on ordinary work. Measured through the real assembly: three
requests, two false reports.
A projected message says so (provider.Message.Derived, never stored and never
serialized), a coalesced run that swallowed one says so too, and the chain
stops there. A rewrite underneath the tail is still observed.
Usage and pricing move out of provider.go into usage.go so the new field fits
under the file's ceiling; the baseline update carries that file's existing
comment debt across and lowers the totals.
"The check you ran after the latest write did not pass" named nothing, and the
debt is keyed on the check's own identity: any other check passing leaves the
first one standing. A real run showed what that costs — a `grep -L` whose exit
status is 1 when it finds nothing was recorded as failed, the model rewrote the
check three different ways, each passed, each left the debt where it was, and
the turn was sent back twice more before it happened to re-run the original
spelling.
The ledger now answers which checks stand failed after the boundary, and the
readiness message names them and says the same one has to pass.
The card that asks a person to approve a call showed them the sentence written
for the model: three English paragraphs in an otherwise Chinese window, one of
them added this week with computer use. The window cannot translate a sentence
— matching prose is the one thing this tree does not build — so the kernel now
names the class instead: dynamic_bash, browser_credential, computer_use ride
the approval as ReasonCode beside the sentence, derived from the call so the
replay path answers the same as the first emission, and the window renders the
class it knows and falls back to the sentence for one it does not.
The model still reads the English it read before; only who the text is for
changed.
A page in a minimized window, or in one another application's window covers,
counts as hidden, and a hidden page drops the input sent to it: measured, the
click never arrives and Input.dispatchMouseEvent does not even answer. That is
the ordinary case, not an edge one — a person starts the agent and goes to
another application — and it failed silently: the step said it completed and
the snapshot afterwards showed the page unchanged, so the model concluded its
click had missed and tried again, twice, before falling back to a screenshot
and a coordinate.
Two mechanisms, both measured, and each one alone leaves half the failure:
occlusion is kept away from every page in this application by
disable-backgrounding-occluded-windows, and a minimize is answered by attaching
the agent's views again, which is what brings their widgets back. The cost of
the switch is that this application's renderers keep running at full rate while
nobody is watching them.
pnpm browser-live now drives the same greeting through a minimized window and
through one covered by another window; without either half a check fails. What
the two live tests share moves into test/livekit.js. A live browser test also
pins what was ruled out on the way: a click that starts a slow navigation still
answers with the page it led to.
Opening the browser view drew nothing: the panel showed "the page is paused
while something covers it" over an empty rectangle, with the tab and the
address bar correct beside it. Switching a pane view runs a view transition,
which covers the whole page while it animates, so the sampling that asks what
is drawn over the reserved rectangle answered "the document element" and the
panel put the page away. Nothing then said the animation was over: its end
changes no attribute inside the body, which is all the panel was watching, so
the page stayed away until something else moved.
The panel now also watches the mark the swap leaves on the root, which is what
says a transition is running, and places the page when it goes. The browser
live test never caught this because its window is not on screen, and a document
that is not visible skips the transition entirely.
packaging/build-host.js builds desktop/computer-helper beside the kernel, as
one universal binary for the same reason the kernel is one, and
electron-builder carries it into resources/bin. The shell finds it beside the
kernel on macOS only and passes -computer-helper when the file is there. The
kernel starts the helper, so macOS attributes Accessibility and Screen
Recording to Reasonix Studio.
pnpm computer-live drives it through the real shell with a scripted model:
the kernel lists applications, snapshots a probe application built from the
Go live test's source, and sets a value, presses a button, focuses and types
into it. All eight checks pass, including that the person's pointer did not
move and the frontmost application did not change. What the two live tests
share moves into test/livekit.js, and pnpm browser-live still passes on it.
STUDIO_SHELL_BOUNDARIES.md gains the split for other applications: the kernel
decides, the helper carries out, the shell only finds it.
Two tools over a native helper. computer_read lists the applications a person
can see (what=apps), reads one's accessibility tree with host-issued [aN] refs
(snapshot), or captures its front window (screenshot). computer_act runs steps
in order on one application — click by ref or at x/y in the latest
screenshot's pixels, focus, set_value, type, key — stops at the first failure
with its code, and returns the application's tree afterwards. An application
is named by its bundle id.
Nothing takes the person's pointer or the foreground. A click is an
accessibility action on the element under the point, and only an element of
the application the call names takes it; keys go to that application's
process. An element with no accessibility action answers computer.no_action
instead of being clicked some other way. The helper draws its own cursor where
it acts, and Escape pressed while that cursor shows stops the run between
steps (computer.stopped). Terminals, the keychain, password managers, System
Settings, authorization prompts and Studio itself are refused before the
helper is asked. A missing Accessibility or Screen Recording grant is requested
from the system once per helper, and the failure tells the model what the
person has to switch on and where.
desktop/computer-helper is the helper, in Swift: JSON lines on stdin/stdout,
built for macOS 12 with ScreenCaptureKit checked at run time. internal/computer
owns everything it is not: which applications, what a ref means, and the
screenshot geometry that turns image pixels into screen points. It is a leaf
package and declared sensitive, with a coverage floor of 85.8 from tests that
run the test binary as a fake helper; live_test.go drives a real application
through the real helper when REASONIX_LIVE_COMPUTER names one.
Permission: both tools form the Computer rule group, granted per application
as Computer=<bundle id>. A call on another application needs a person the way
typing a secret into a site does — auto, a glob and a bare rule do not answer
it; a person, the session grant they gave for that application, an exact rule,
or YOLO does. Headless auto refuses and says why. computer_act is an unknown
mutation for evidence, since an application can write into the workspace and
names no paths; computer_read is none.
The tools reach a provider only in a host that has a helper:
reasonix-studio-host takes -computer-helper, and no other host passes one, so
the default prefix and its goldens do not move. The built-in schema budget
is widened 6049 -> 6346 for these two schemas, which only a Studio session on
macOS carries.
A browser call's projected permission arguments kept every field the model
sent and wrote the origin beside them. Subjects reads its keys in priority
order and origin comes last, so browser_open {"url":"https://example.com/a",
"path":"/etc/passwd"} was decided on "/etc/passwd": in ask mode the prompt
named that, and "allow for this session" recorded Browser=/etc/passwd, a grant
any later call on any site could claim by sending the same field. On HEAD the
subject of that call is "/etc/passwd".
What the gate reads is now the arguments the tool's schema declares, with the
host's subject written over them; anything undeclared is dropped. The approval
still shows the steps, because steps is declared.
Studio half of the in-app browser. The shell answers the browser-level half of
the debugging protocol itself — targets are WebContentsViews it owns, each on a
partition of its own and never the session the credential lives in — and hands
each page's commands to that page's debugger. It holds the kernel's relay open
over the same HTTP as everything else and posts frames back one request at a
time, so an event that came before a reply reaches the kernel before it.
Pages refuse the kernel's origin from every frame, ask for no permission,
download nothing, and turn window.open into a tab the kernel is told about.
A pane gains a 浏览器 view while the agent has a page open. The page reserves a
rectangle and tells the shell which page to draw over it; four sender-checked
verbs are all the renderer gets. Whether anything covers that rectangle is read
by sampling what the page itself shows, so a menu or a sheet opened on top puts
the page away without every overlay having to announce itself. The person gets
back, forward, reload and an address bar for the page on screen.
A page nobody is watching overlaps the window by one pixel in its bottom-left
corner, which took three wrong answers to find, all measured in the real shell:
moved wholly off screen it lays out at 0×0 and every click fails as not
visible; setVisible(false) keeps the layout, but a page whose renderer a
navigation replaced while hidden accepts no input until shown, and the
protocol's screenshot waits on a frame that never comes; and a child view at
index 0 is still drawn above the window's own page. Placement runs on a timer,
not an animation frame, because a window the system counts as hidden runs none.
pnpm browser-live drives it end to end — a scripted model, the real kernel, the
real window — and checks the page is operated, the model hears the change, and
the view is drawn exactly over the panel and put away again.
A window drawing the agent's browser needs to know which tabs exist and which
is active. The session announces that a tab opened, closed, became active,
navigated or finished loading; the controller turns it into a content-free
browser_tabs_changed frame; and every frontend reads the tabs back through
BrowserTabs (GET /browser/tabs, /tabs in the TUI), from the session that owns
them. A tab now carries the browser's target id, which is what a window keys
its views by. The page's BrowserTab mirrors the kernel's TabInfo under
wire-parity, and an empty list is [] rather than null.
Kernel half of the in-app browser. A browser.Pool can reach an Endpoint a host
provides instead of launching one: the same protocol client, the same
startup demands (version, target discovery, downloads denied), and no process
of its own. A host with nothing attached answers ErrNoHost and the pool
launches as before; a host that refuses is reported, not quietly replaced.
serve.BrowserHost is that host for Studio. The shell's main process holds one
SSE stream (GET /browser-host/stream) and posts back replies, events and the
connections it ended (POST /browser-host/frames); every workspace's browser is
one connection on it, opened with a partition name derived from the profile so
the window never learns its path. A new stream ends the old one's
connections, since their views went with the window that drew them. The Studio
host registers it for every pane through boot.SetBrowserHost, on the pool that
is already process-wide, so no build or rebuild path can forget to pass it.
The browser session now belongs to the controller rather than to one build:
retained on construction, released with the controller's resources, and handed
to the replacement on every hot rebuild (boot reload, serve rebuild, CLI model
switch) the way the private temporary directory is. A model switch no longer
closes the agent's tabs — and closing no longer depends on the cleanup chain,
which drops what was chained into it whenever an extension runtime exists.
Auto lets the agent onto any site without asking, which is the decision that
was made. Entering a password, a one-time code or card details is a different
question, and a site grant or a broad allow rule is not an answer to it.
Whether a browser_act enters a secret is read from markup, never wording: a
password input, or an autocomplete token naming a secret. Typing into a frame,
or into wherever a Tab or a coordinate click left focus, counts too, since the
host cannot see what is there. Such a call's permission subject becomes
credential:<origin>. It is also evaluated as a visit to <origin>, so a rule
denying the site denies it; otherwise only a rule naming that exact subject
allows it — no site grant, glob or bare Browser rule does — and the approval
bridge treats it like dynamic bash: Guardian and the approved-plan window do
not answer it, YOLO or an exact session grant does, and auto asks.
The check that allowed a call is what execution acts on: the verdict is noted
against the call's arguments, and a step whose target turns out to hold a
secret without that confirmation fails as browser.unconfirmed_secret before
any input is sent — which is what closes typing after focus moved somewhere
the permission check could not follow.
A headless run has nobody to ask, and its refusal now leads with why a person
was needed. Before that, a real run was refused with a reason that fits any
call, tried curl instead, and closed by suggesting an allow rule; with it, the
model filled the email, reported the password was refused, and stopped.
A fake CDP page lets this logic run in CI without a browser.
Every browser_open and browser_act was classified as a proven mutation, the
default for a writer-capable tool, so each one owed a stale_verification and a
turn that only browsed ended on "verification or review gap" — which asks the
model to go run checks against code nothing touched. A real run showed it on
every call.
The browser downloads nothing and has no way to upload, so no call of its can
write a workspace path; the classifier now says so for both. The grant group,
this classifier and the schema rule each know the tools by name without being
able to import them, and a boot test holds all three to the tools that exist.
Chrome saves a download to the person's Downloads folder without asking — a
write outside the workspace that nothing the host observes can account for.
Downloads are denied at launch, and each refusal is logged on the tab it came
from, so the act that triggered one tells the model the file never arrived
instead of leaving it to assume it did.
Three tools over internal/browser. browser_open loads a page and returns its
snapshot, so the first look costs no extra round; browser_read pages a
snapshot, scopes it to a ref, takes a screenshot or drains console errors and
failed requests; browser_act runs a list of steps in order, stops at the first
failure with its code, and answers with what the page reported and a line diff
against the snapshot the model last saw — a whole snapshot only when the
document changed.
[browser] enabled (default true) decides the schema; which browser runs is
found at first use, so the prefix answers to the config and not to the
machine, and a machine without one hears browser.engine_missing. executable
and headless are the other two keys. The tools are provider-visible rather
than behind use_capability: a browsing session is dozens of calls and nesting
each one's steps inside a proxy's arguments is where calls go wrong. This
moves the default prefix — tool schema tokens 4876 → 5335, goldens
regenerated — so every install pays one cold start on upgrade, and the
built-in schema budget is widened 5590 → 6049 for the same 459 tokens.
Permission reads the site, which is host state for a read or an act: a tool
may now project what the gate reads (tool.PermissionProjector) while the call,
its hooks and its evidence keep the arguments as sent. The browser tools write
the origin the host knows over any origin the model sent. The three share one
rule group, so a session grant or an always-allow names Browser=<origin> and
covers loading, reading and operating that site and no other; a bare grant
covers none. Ask prompts once per site, auto and yolo do not, and a read
follows the read-only fallback. Rule groups move into rule_group.go with the
file-writer group they generalise, whose predicate lost its last caller.
LSPConfig and its renderer move to config/lsp.go beside the new browser.go,
and the [tools] enabled list renders through renderStringArray.
A kernel package for an agent that has to see and operate web pages, with no
frontend attached: the tools and the Studio panel sit on top of it.
It finds a Chrome, Edge or Chromium already on the machine (never downloads
one), runs it on a profile of its own, and drives it over
--remote-debugging-pipe; Windows cannot hand a child descriptors 3 and 4, so
there it falls back to a loopback port. One process per profile is shared by
every session through a Pool, since a profile admits one process.
A page is rendered from the accessibility tree, one element per line, with a
ref the package issues, never reuses, and retires when its document navigates
away — so a stale ref fails as browser.stale_ref instead of landing on
whatever replaced the element. Inputs are real pointer and keyboard events; a
pointer input whose point another element would receive is refused as
browser.covered, naming that element. A JavaScript dialog stops the page from
answering anything that runs on it, so page-bound commands fail at once while
one is open and are abandoned when one opens mid-call. Every failure carries a
Code.
checkURL bounds a page to http(s), about:blank and files inside the workspace
once symlinks resolve; a page reached past that by a link is refused when read
or acted on. The package is declared sensitive, and a utility-layer leaf.
Live tests (REASONIX_LIVE_BROWSER=<executable>) cover both transports, forms,
selects, covering elements, dialogs, popups, stale refs and screenshot
coordinates; they skip where no browser is installed.
Prepare answers from modelVisibleMessages, which already appends the host
tails, and buildSamplingRequest appended them again. The todo tail checks
whether it is already visible; the host context tail does not, so every
request after a run that died waiting for a person carried the
interrupted-adjudication block twice — reached by quitting while an approval
was pending and reopening the session.
function_call_output carried text only and the images a tool returned were
dropped on the Responses wire, while the chat-completions client already
forwarded them. They now follow the run of outputs as a user turn with
input_image parts, the shape these vendors accept for user images; a turn
between two outputs would split calls from their results.
The error path built its outcome without the images the tool returned, so a
failure's screenshot — often the only record of the state that caused it —
reached the model as a bare placeholder.
Every image a tool returned stayed on its message and was resent on every
later request, with nothing in the window estimate accounting for it. A
session that takes a screenshot per round grows its request by one image per
round until the provider refuses it.
A request now carries only the newest three tool images. Older ones leave in
blocks of three, so the edit to earlier messages — which is what costs the
prefix cache — happens once per block instead of every round, and each
message that lost an image says so. User attachments are never detached. The
transcript keeps every image; only the request view ages them.
Images also reach the window estimate (width×height/750 from the header, the
largest image's size when unreadable), and a request that carried any no
longer trains the chars-to-tokens calibration: its prompt tokens bill pixels
the host never priced, the same pollution a provider-run web search caused.
The interrupted-request billing estimate stops counting base64 bytes as
characters.
8b2a05c51 moved a brokered pane's default model, picker and rebuilds onto
the broker, but only a kernel carrying it behaves that way. The pane
floor stayed at 2.0.0, so a host already holding a 2.18.0 kernel kept
reusing it after the window upgraded, and #10448 stayed broken for
exactly the machine that reported it.
A connect that publishes a broker now needs 2.18.1, the first version
that can carry the fix; a host on its own credentials keeps the 2.0.0
floor. PaneFloor is the one place both the connect and its probe read
the floor from, so a probe cannot call a kernel usable that the connect
would then replace.
Studio has no bot surface. On this line the QQ / Feishu / WeChat gateway
is reachable only as `reasonix bot`, a CLI command no Studio user starts,
and it carries about 12.9k lines: internal/bot, internal/botruntime, the
subcommand, its config section and its docs. It is also where the recent
Windows heap-corruption and gateway-wedge fixes had to land.
What goes with it:
- `reasonix bot`, its completion spec and its usage line in three locales.
- The typed [bot] config, its defaults, render block, model-ref and
migration hooks, and the 0.x QQ import.
- GatewayAPI, the control port only the gateway named, and the two
passive inbox variants only it called; neterr.IsTransient and
textutil.FitGraphemeBytes, which only it used.
- The bot entries in repolint's layer and frontend-parity declarations,
the bot race-test target in CI, BOT_GUIDE.md and its images, and the
[bot] example.
What stays, and why:
- The user config is shared with 1.x installs, which still run the
gateway. [bot] now decodes into an opaque table and is written back
unchanged on every save, so a settings change here does not switch
that gateway off or erase its allowlists. Credential variables the
table names are still kept out of subprocess environments.
- surface.Bot stays: usage records on disk carry it, and dropping it
would re-attribute them to another frontend.
- SECURITY.md and the crash-report worker still describe the bot, since
they serve every release line.
Budgets only fall: frontend-parity drops by 20 edges and the reclaimed
headroom is recorded with -update.
A host whose provider is "local" resolves every request on the machine
that connected to it, yet the pane's model surface still read the host's
own config. A pane opened with no model started on the host's
default_model, the picker listed the host's providers, and the broker
at home refused the first turn with `unknown model` for a model that
works fine when the host is used directly (#10448).
The broker catalog could not have said otherwise: it listed one
descriptor per provider, by bare name for any provider declaring
`models`, and named no default. It now lists one ref per chat model and
marks the one a new session starts on; the broker side offers only
models it holds a key for. A build given a resolver starts on that
default, the serve launched for a bootstrapped host leaves its model
implicit, and /models on such a pane lists the resolver's catalog
without writing the host's default_model on a switch.
The pane also lost the broker on every rebuild. A model switch, an
extension reload and a workspace move built from options that never
carried the hub's resolver, so the replacement resolved through the
host's config and credentials — the exact thing provider = "local"
exists to avoid. The server now keeps the resolver the hub gave it and
every rebuild passes it on.
A ref the broker's machine does not carry now crosses as its own kind
and arrives as provider.ErrUnknownModel naming where the lookup ran, so
a resumed session recorded against the host's own model says which
machine lacks it and what to change.
Both sides need this build: a host kernel from before it still applies
its own default_model.
Two user-visible fixes since 2.18.0, one line each per the release-note
format.
The Windows crash line says a known trigger was removed, not that the
crash is fixed: golang/go#81238 is still open, and any other recovered
hardware exception on such a host can corrupt the heap the same way.
2835cd5a5 is left out because no production controller lacks a runner.
runSettled and runComposedSyntheticTurn called c.runner.Run directly. On
a controller assembled without a runner, that was a nil dereference.
spawnGuardedTurn's recover turned it into "internal error: invalid
memory address or nil pointer dereference". On Windows the fault is a
hardware exception whether or not it is recovered, the class that
corrupts the heap on AMX runners (golang/go#81238, see 3d5b0b68d).
This was an audit of the recover() sites left after 3d5b0b68d:
- Three re-panic after recording: the CLI crash capture, the subagent
progress tracker and an SDK path. Nothing to change.
- The others isolate faults: background task jobs, guarded turns, the
rpcwire after-write callback, the jobs runner and the SDK handlers.
Isolation is their job, so they stay.
- What mattered was whether anything routinely sent a real fault
through them. Temporary instrumentation over control, serve, agent,
extension, jobs, boot and sdk/go found two nil dereferences, both this
runner call, plus six explicit panic("boom") values tests raise on
purpose, which are not exceptions.
runWithRunner returns ErrNoRunner when the runner is nil. Both call
sites use it, and the identity is a sentinel a caller can test with
errors.Is. Re-running the instrumented control and serve suites records
no nil dereference, only the two intentional panics.
TestATurnWithoutARunnerReportsErrNoRunner holds the identity.
The sentinel lives in turn_projection.go beside its use; controller.go
is already past its size budget.
getOrCreateSession took gw.mu, then called into the controller to decide
reuse (WorkspaceRoot, SessionPath, RuntimeStatus) and released the lock
by hand on each branch. A panic in any of those calls left gw.mu held:
- every later message waited on it forever;
- the turn cleanup that runs while unwinding (finishTurnItem ->
sessionAPI) waited too, so the gateway hung instead of crashing.
This showed up in 3d5b0b68d's investigation: a test fake still
embedding a nil controller hung its test rather than panicking.
The decision moves into claimSession, which holds the lock with defer
and returns what it decided: absent, reused, change deferred while work
is active, or retired. getOrCreateSession acts on that result outside
the lock, in the same order as before: set the approval mode and log for
reuse and deferral; close the retired state and log before rebuilding.
TestAPanicWhileClaimingASessionReleasesTheGatewayLock panics in
WorkspaceRoot with an explicit panic, not a nil dereference, so it
raises no hardware exception on Windows. It then requires the lock to be
free within a second. Against the previous session_routing.go it fails
with "the gateway lock is still held after the panic unwound".
The repolint baseline gives back 12 units of function-size budget that
the shorter functions stopped using.
The Windows CI leg crashed twice inside internal/bot's test binary with
Go runtime faults:
- "Exception 0xc0000005 ... PC=0x0" (44150b0aa);
- "fatal error: acquireSudog: found s.elem != nil in cache" during GC
mark termination (d636e93d5).
Both are heap corruption surfacing wherever it is next read.
golang/go#81238 is still open. On windows-latest runners (Windows Server
2025), a host whose CPU saves a large XSTATE (Intel AMX) writes the
exception context below the goroutine stack when a hardware exception is
dispatched, corrupting the Go heap. The pool mixes hosts, which is why it
is intermittent. It needs a real exception, a nil dereference for
example; an ordinary panic() does not trigger it. golang/go#77975, the
Go 1.26 overlapped-I/O corruption, was fixed in 1.26.2, and it involved
UDP receive, not these paths.
internal/bot dispatched such exceptions on purpose.
safeBotControllerRuntimeStatus, safeBotControllerWorkspaceRoot and
safeBotSetToolApprovalMode wrapped their call in recover(). Test fakes
embed a nil botController, so every call to a method a fake did not
define dereferenced nil and was recovered. Counted with temporary
instrumentation on one run of ./internal/bot: SetToolApprovalMode 7,
WorkspaceRoot 3. In production the controller is a complete
*control.Controller, so the recover only hid real bugs.
- The three wrappers keep their nil check and lose the recover.
- The eight fakes embed stubBotController, a nil botController plus
no-op SetToolApprovalMode, WorkspaceRoot and RuntimeStatus. A fake
missing a method the gateway starts calling now fails at once on every
platform.
- ci.yml runs internal/bot under the race detector, which it never did,
and the Windows test leg prints the runner CPU so the next runtime
crash can be matched against the host.
Found on the way and not changed here: a panic inside getOrCreateSession
unwinds through finishTurnItem, which takes the gateway lock the
panicking frame already holds. The gateway deadlocks instead of
crashing; with a fake that still embedded nil, the test hung rather than
panicked.
Checked: go test -race over internal/bot/... and internal/botruntime
passes; golangci-lint is clean on darwin and on GOOS=windows; actionlint
passes.
newStreamAttemptID built "sa-<attempt>-<UnixNano>". On Windows two
rounds that start within one tick of the clock get the same nanosecond
reading, so the second round reused the first round's id. Stream events
are keyed by that id:
- begin/commit/abort for a round;
- tool AttemptID;
- usage.
With a shared id they attach to the wrong round.
This is what made TestTraceFixturesMatchWhatTheKernelProduces flaky on
the Windows CI leg. Reproduced on a Windows 11 box with the serve test
binary built from the release-notes commit (d636e93d5): 8 of 12 runs
passed. A diverged fixture showed the third round's attempt normalized
to r2 instead of r3, because its raw id equalled round two's.
The suffix is now a process-wide atomic sequence. The id stays
host-local, never persisted or sent to a model, so its format has no
reader. With the fix the same box passed 24 of 24 runs.
TestStreamAttemptIDsNeverRepeat issues a thousand ids in a tight loop.
The first notes written to docs/DOCS_STANDARD.md section 6 and held by
the release-note gate: one summary line, fixed group headings, one
referenced line per user-visible change. repolint -strict reports
nothing for the file.
Left out as not visible to users: the documentation standard,
CODEOWNERS, the repolint rules, the removal of site/ and of the main-v2
release workflows. Also left out: c37dac3a4, which fixed a regression
introduced and fixed between 2.17.0 and this release.
reasonix.io is built and deployed from main-v2 only. pages.yml, removed
in 68b031ae5, ran on main-v2 pushes. On studio the directory was a
remnant, and it still read release-notes/releases.json, which that
commit deleted. main-v2 keeps the site; nothing here published it.
- site/ (60 files) is deleted.
- ci.yml loses the site job, which ran site/'s node tests, and the
`site` output of the path filter. The code and desktop skip patterns
no longer name site/.
- .gitignore loses the five site build-output rules.
- The crash-report worker's shell comment names the reasonix.io website
instead of a stylesheet path that no longer exists here.
studio has no branch protection or required status checks, so dropping
the job leaves no check pending. actionlint passes and make lint is
clean. internal/fileref still skips site/.astro in file pickers: that is
workspace behavior for users' Astro projects, not a dependency on this
directory.
Users report that the question box stops appearing. It shows at first.
Later it only shows when the user tells the model to ask. In the end it
does not show even then.
Prompt ids came from a counter in each controller, starting at "1". A
controller is rebuilt on model switch, extension reload, workspace
switch, /new, /resume and remote reconnect. The rebuild keeps the pane's
event stream (rebuildSink), so the window keeps its cards. The frontend
correlates prompts by id (prompts.ts `prompted`). An incoming request
whose id matches a card already answered is dropped as a replay, which
is correct for the kernel re-emitting a pending prompt on reconnect.
So after a rebuild the new controller's first question was "1", the
window had already answered a "1", and the request never reached the
screen while the run waited for an answer. Every answered card in the
session was one more id the next generation would reissue. The longer
the session and the more rebuilds, the more questions vanished.
The same ids are written to the session's adjudication journal, so a
resumed session also recorded a second, different "1".
Ids are now `<8 hex>-<n>`. The prefix is drawn once per approval manager
from crypto/rand, and drawn lazily for a zero-value manager. The counter
moves into a named promptIDs sub-state, so struct-state keeps its field
count. Answering still resolves only against the issuing runtime.
Tests:
- TestARebuiltControllerNeverReissuesAPromptID: asks twice on one
controller, rebuilds with InheritLifecycleFrom as a model switch does,
asks again. It fails on the old counter with `reissued "1"`.
- TestDecisionIdentitiesAreOnlyMeaningfulWithinTheirRuntime required the
two panes to mint equal ids. It now asserts the property directly: B
refuses A's id and keeps its own card.
- The trace fixture's stabilize() now normalizes decision receipt ids
with the approval and ask ids. The committed receipt read "1" against
an approval normalized to "b1", so the fixture never showed the
receipt sealing its own card.
Checked: the control, serve, bot, cli, acp and boot tests pass; the
frontend suite passes (748 tests); make check is clean.
68b031ae5 was pushed with only its deletions and renames staged. Its
`git add` named CHANGELOG.md, which was already removed, and the failed
pathspec skipped every modification after it. That commit alone does
not build:
- productdocs still imports reasonix/release-notes;
- repolint still calls checkSpecParity;
- the extension golden test reads the old fixture path.
This commit carries the changes 68b031ae5 describes: productdocs and
docs-manifest without the release catalog, repolint without spec-parity,
the golden test on testdata, the /docs tests, the link fixes, ci.yml,
CODEOWNERS, the standard, the theme generator and the lowered baseline.
Verified locally on this exact tree before 68b031ae5 was pushed:
go test ./... passes and make lint is clean.
Deletes what the documentation standard (6b6a41d36) classes as dead on
the studio line, as reviewed and approved item by item.
Deleted:
- Stale or unreferenced: production_checklist (a "v5.9.9 stable" that
never existed), the 4-line WINDOWS_LTSC_2019 stub,
SESSION_REFERENCE_ARCHITECTURE, docs/superpowers (the retired
autoresearch plans), docs/research, RELEASING.md (main-v2's three-tag
line), SIGNPATH_WINDOWS_ADMIN_SOP.md (desktop line only) and
CHANGELOG.md (main-v2's Go line).
- Chinese copies: 30 zh-CN documents with an English original, plus
three with none (COLLABORATION_MODES, DESKTOP_HOOKS, GOAL_ENFORCEMENT).
Only README, GUIDE and CLI keep a Chinese page.
- main-v2 release pipeline: the prepare-release-notes, release-notes,
release-verify-issues and pages workflows; their release-notes,
generate, event, stable-validation and public-contract scripts; and
release-notes/releases.json.
What had to move with it:
- releases.json was embedded, so the docs tool served main-v2's
1.17-1.24 history inside Studio 2.17, where "latest release" answered
1.24.1. productdocs drops the release catalog: release_notes.go, the
virtual changelog paths, the version boost and Manifest.ReleaseNotes.
docs-manifest --verify-source now compares docs/ only. The /docs
example no longer promises a changelog.
- spec-parity held SPEC.zh-CN.md to SPEC.md. With one language it has
nothing to compare, so the rule and its tests are removed.
- The spatiotemporal golden fixtures lived under docs/superpowers but
are test data. They move to internal/extension/testdata.
- Links: onboarding pages point at the English documents; English
documents lose their switch to a copy that is gone. GUIDE drops the
pointer to the Chinese-only hooks page. The theme provenance generator
stops writing THEME_ASSETS.zh-CN.md. ci.yml stops linting and running
what was deleted. CODEOWNERS and the standard lose the two dead paths.
Checked: no broken relative .md link remains; actionlint passes;
go test ./... passes; make lint is clean.
Baseline only goes down:
- doc-language 33 -> 0
- doc-owner 91 -> 44
- doc-prose 2755 -> 2375
- spec-parity removed
site/ is also main-v2's and still reads releases.json. It was not on the
approved list and is left for a separate decision.
docs/*.md is embedded and served to the model through the docs tool.
6b6a41d36 gave two documents the metadata header the documentation
standard requires. Markdown reads `---`, key lines, `---` as a setext
heading, so the tool listed "owner: @esengine / backup: @SivanCola / ..."
as a section of STUDIO_RELEASE.md and DOCS_STANDARD.md, beside an empty
title section. This was checked by reading both documents through the
tool.
The parser now blanks the header to spaces before parsing, so section
offsets and cited line ranges stay those of the file. An unterminated
delimiter is left as ordinary Markdown. STUDIO_RELEASE and DOCS_STANDARD
join the maintainer audience with RELEASING and the SignPath SOP, so a
user-facing search does not rank the release runbook.
The blanking lives in metadata.go: docs.go was at its 800-line budget.
Documents on the studio line had no owner, no status and no shape. Of
120 Markdown files, 544 paragraphs and list items ran past 320
characters. Chinese copies had drifted from the English ones (SPEC.md
1301 lines, SPEC.zh-CN.md 492). CODEOWNERS still named five paths that
no longer exist. The Studio release doc claimed a required approval and a
main-v2-only signing policy, neither of which is true any more.
- docs/DOCS_STANDARD.md states the rules as ID tables: ownership,
document classes, header, writing width, section shape per type,
language, and lifecycle.
- .github/CODEOWNERS gives every area a primary and a backup, in that
order. Every rule still lists both maintainers, so review requests do
not change.
- docs/STUDIO_RELEASE.md becomes a runbook: owners, preconditions,
steps, verification, recovery, release-note format, reference. Each
fact was checked against release-studio.yml, the environment's branch
policy and the 2.17.0 release.
- repolint gains five rules:
- codeowners: a rule must match a file and name two people.
- doc-owner: a document header's owner and backup must equal the
matching CODEOWNERS rule.
- doc-prose: a block must stay within 320 display columns, with a CJK
character counting as two.
- doc-language: only three onboarding pages keep a Chinese copy.
- release-note: fixed group headings, one referenced short line per
change.
Document classes are declared by path. Landing pages, model-read files,
GitHub templates and release notes carry no header, because each is
rendered or consumed verbatim; CODEOWNERS owns them.
Baseline: -allow-widen raises only the five new rules, from nothing to
the debt that exists today (doc-owner 91, doc-prose 2755 weighted,
doc-language 33, release-note 661, codeowners 0). No existing rule
widened. Deleting stale documents and the untranslated copies follows
in its own change, after the list is reviewed.
Users report deepseek-flash, a configured and verified vision model,
saying it cannot see an attached image. The image did reach it. The text
beside each image said "image bytes are never inlined into prompt text.
Whether this model receives the image ... is stated once in the turn's
attached-images note." That note was written only for a model that
cannot read images. A vision model got the deferral and no note, and a
model reasoning at length read that as the image not having arrived.
The same judgement was wrong on a second path. unreadable() treated "this
message carries no pixels" as "this model cannot read". A Goal
continuation under a vision parent carries its images in history, not in
the synthetic message, so the host told a vision model "This model cannot
read images, so they are not in your context" and told the user the same.
- A turn whose own message carries images now gets an attached-images
note saying they are in the message as images, to be looked at
directly.
- turnImages records modelReads, the model's capability, so a
continuation is no longer called unreadable.
- The image block drops the transport clause the model misread and keeps
only the deferral to the note, which now exists in every case.
Measured with the real CLI against deepseek-flash on DeepSeek's
Anthropic endpoint. Each run is one fresh single-turn session at effort
max, with a 1100x360 PNG pasted as .reasonix/attachments/*.png and the
prompt "I sent you a screenshot. Can you see it directly, or do you need
to call another model? First tell me what it says." (asked in Chinese).
- Denial means the final answer states the image did not reach the
model; every answer lacking image-only text was read by hand.
- Tool-first means the run's metrics show steps > 1, i.e. the model ran
tools before answering.
stock: denied 5/80; tool-first 36/70 (10 early runs had no metrics)
fixed: denied 1/80; tool-first 1/80
Denials alone are not significant at this n (two-sided Fisher p~0.21).
The tool-first drop is: in half the stock runs the model doubted the
image and inspected the file first. The fixed arm also cost ¥0.65 for 80
runs against ¥1.88 for 70. Four other phrasings (codeword, "can you see
it", owner/region, open review; 40 runs at the default effort) saw the
image every time on stock.
TestAVisionTurnLandsItsImageWithTheNoteSayingItIsThere drives
runOrchestratedTurn with a vision model and fails with the note removed.
#10435: Studio 2.17.0 on a Windows 10 machine never showed a window. The
kernel served the page; the renderer exited with STATUS_BREAKPOINT
(-2147483645) and the load failed with ERR_FAILED. The report read it as
an Electron 44 / Chromium 152 regression because a downloaded Electron
43.7.1 started, but that copy ran from a different directory.
Reproduced on a Windows 11 box with bare Electron apps: an allow entry for
a specific AppContainer package SID on the DLLs next to the executable
kills the sandboxed renderer on Electron 43.7.1 and 44.1.0 alike, and a
clean directory starts on both. One entry on ffmpeg.dll is enough. A live
package's SID fails the same as one whose profile was deleted. The entry
on the exe alone, on the directory without inheritance, on resources\app,
on the parent, or as a deny does nothing; the built-in package groups and
capability SIDs do nothing. --no-sandbox starts it and
--disable-gpu-sandbox does not, the same flag profile the report gives.
The real 2.17.0 zip with that entry on its root reproduces the report's
log lines.
Such entries come from any AppContainer sandbox that grants its package
read/execute on a tool's directory with inheritance, which is what
main-v2's winsandbox does for the executable it launches.
The kernel gets -strip-package-grants: for the directory the stated
application runs from, it removes allow entries whose SID is authority 15,
base RID 2, with more sub-authorities than a built-in group. The
judgement reads SID structure, never a name. Only allow entries go, so
access only narrows. An entry inherited from above the tree is cut by
protecting the object that inherits it, which keeps its other entries as
its own and leaves the parent untouched. The shell runs this
synchronously before Chromium starts a child, on packaged Windows builds
only (~20ms warm on a 100-file install). It logs what it removed, and if a
removal was refused and the renderer then dies before the window shows,
it names that cause in a dialog instead of exiting silently. Objects it
could not read are reported apart and never attributed.
Verified on the Windows box with the 2.17.0 zip, patched host and shell:
entry on the root, entry inherited from above, entry on one DLL, and a
clean install all start and load the SPA; the stock 2.17.0 with the entry
on its root does not. The rewritten DACL is byte-identical (SDDL) to one
set directly. internal/packagegrant's Windows tests ran there too.
TestNoApprovalPostureAnswersAQuestionForTheUser closed its signal in a
defer after AnswerQuestion returned, but the reply send inside it is
what unblocks the turn. The turn could finish and the non-blocking
select could run before the answering goroutine reached its defer, so
the race leg reported auto answering the question itself when the user
had in fact been asked and had picked B.
Controller.Ask has no posture branch: every mode emits AskRequest and
waits on the reply. The signal now closes on receipt, ahead of the
answer, which puts it ahead of the turn's end.
TestIsTransient built its reset error from syscall.ECONNRESET, which on
Windows is the APPLICATION_ERROR placeholder the package exists to stop
matching. IsTransient correctly refused it, so the Windows leg failed.
The refused case already drew from refusedErrors; the reset case now
draws from resetErrors the same way.
TestIsConnReset carried the same literal and passed on Windows only
because every *net.OpError is a net.Error, which never exercised the
reset identity at all.
393ec8eed removed desktop/third_party/go-webview2 with the Wails shell,
but desktop-windows still cd'd into it after go test ./..., so the leg
failed on every push regardless of what changed. The module's own
tests are all that remain to run there.
The macOS leg's note about cgo dock-icon glue goes too: desktop/ has no
darwin-only files and no cgo left.
The shell's smoke suite judges the workbench window, and a data home with no
model configured opens on the onboarding card instead — where none of that
window exists. The drag-region check then failed with "no .chrome", which
reads as a defect in the title bar and is the suite never reaching its subject:
run over an empty home, that judgement had nothing to weigh and said so in a
way nobody would read as nothing. It now brings the home to the state its
judgements assume before the shell reads it, and leaves a home that already
carries a configuration exactly as it is.
The contrast sweeps reported a disabled button. WCAG exempts text in an
inactive user interface component — a control greys out precisely to say it is
not one you can use — so 3.92 against a floor of 4.5 was a number nobody could
act on, sitting above the ones somebody could. Both sweeps skip an element
inside a disabled control, and dark comes back clean over twelve sections
where it had one finding.
The built-in verbs are worded by the kernel and read in a window, and the two
need not agree: a machine whose CLI speaks Chinese can have an English window
on it. The endpoint read config's desktop language and, where none is set —
the default, meaning "follow the machine" — fell through to the catalogue this
process installed from its own $LANG. So an English window on a Chinese machine
opened a Chinese menu under an English composer, twenty-one entries of it.
The window is the authority on what it is drawing: it has already adopted a
configured language, and where none is configured it follows its own locale,
which the kernel cannot read. So the request carries it and the config answers
only for a client that says nothing.
Verified in the shell it is about: with no desktop language in config and the
kernel's own LANG set to zh_CN, an English window's menu comes back with
thirty-nine entries and no Chinese in any of them; the i18n sweep over the same
kernel goes from twenty-two findings to one, the 中文 in the language picker.
The catalogue is keyed by the Chinese source text and assembled by spreading
ten modules into one object, so a key written in two of them keeps whichever
spread came last and the other reading is unreachable. Five keys were written
twice with different values, and three of them were rendering the wrong word:
记录 was "Save" beside the metrics and "Transcript" beside the graph, and the
graph won — so the button that records a model's context window read
"Transcript". 还原 was "Restore" for the window control and "Revert" for a
file, and the file won, so the maximised window's restore button announced
itself as Revert. 改动文件 names the edit_file tool in the permission rules
and a count of changed files in the chrome; the chrome won.
Where one Chinese word carries two meanings the fix is two Chinese words, not
a cleverer lookup: 保存 for the button that records a value, 向下还原 for the
window control, which is what Windows calls it in Chinese. The other two
conflicts (模型, 取消) rendered the same either way and lose the definition
that never won. Twelve keys written twice with identical values lose their
second copy.
The guard reads the sources rather than the assembled object, because after
the spread the loser is gone: neither the type system nor the runtime can see
a duplicate across two object literals. It also checks that the reading covers
the whole catalogue — an entry the pattern cannot parse is one it cannot find a
second definition of either — which is what caught the bare keys prettier
leaves unquoted, Chinese being a valid identifier.
stream_options was sent unconditionally to every OpenAI-compatible endpoint.
It is a 2024 addition, a gateway that predates it or forwards strictly rejects
the request that carries it, and all it asks for is the usage record — a token
count, not anything a turn needs. So a relay that other agents drive fine would
refuse every single message here, which is what a user reported.
The client now asks that endpoint once: when a body is rejected it retries
without the field, and if that answers it stops sending it. Asked once per
endpoint rather than on every refusal, so a gateway that rejects bodies for
some other reason is not probed twice forever, and the statuses that earn the
question are named — 400, 413, 422 — rather than taken from the 4xx range,
because 402 means the account cannot pay and a second request changes nothing.
Verified against a gateway built for it: a relay that rejects stream_options
failed every turn before and completes one now, sending the field once and
never again.
One side effect stated rather than claimed: openai's client leaves the
struct-state report, and not because it shed fields — it still carries twenty
scalars. Grouping authed with the two new facts means the client no longer
holds a concurrency primitive directly, which is what that rule looks for. The
budget is reclaimed rather than kept, so the next atomic added back to the
client fails immediately instead of fitting under an allowance it did not earn.
Reasonix is an agent, so a model it cannot hand tools to cannot run a turn.
The check sent messages and max_tokens and nothing else, which is why a relay
that answers chat and rejects a tools array passed verification and then failed
on the user's first message — with the settings panel still showing the model
as checked.
The probe now carries one tool. When the body is rejected it asks again without
the array, and reports "tools" only when removing it is what made the request
answer: the finding is the second attempt succeeding, not a word in the
refusal. A credential or a rate limit earns no retry, since the second call
would be refused for the reason the first was, and the one-attempt rule that
test pins stays intact for them.
Neither this nor the connect button ever claimed more than it proved — the
connect probe only lists models, as its own comment says — but "connected,
OpenAI-compatible" reads as "ready" and the model row said checked. Now the row
can say the endpoint answers chat and refuses tools, which is the one sentence
that sends the user to a different gateway instead of into a failing session.
Three subjects come out of evidence.go, each one a different kind of thing:
ledger_queries.go is what the ledger can be asked (thirty Has* answers, each
reading recorded receipts only), verification_command.go is whether a shell
command counts as verification — read from its parsed shape, which runner it
invokes and whether a flag turns that runner into something that writes a
report — and todos.go is the todo list as evidence.
evidence.go goes 2529 -> 723, which puts it under the 800-line ceiling: its
1729 lines of file-size debt are gone rather than moved, the first file in this
series to clear its own. essay and complexity travel with the code that carries
them (34 becomes 2+6+8+18) and neither total moves.
gateway.go held a third of the package and five subjects that answer different
questions: adapter_health.go (what each connection is doing, recorded as it
happens so a status command answers without asking the platform), slash.go (the
commands a chat can send and the text each answers with), approval_shortcut.go
(which reply counts as which answer to a pending approval or ask),
session_routing.go (which session a message belongs to, and the options one is
opened with), and outbound.go (what was sent, so the gateway does not answer
itself, plus the reactions to clean up).
gateway.go goes 2973 -> 1147. file-size drops 1826; essay, complexity and
function-size are unchanged. Emit, Start and Stop stay where they are — they
share a name with methods on other types in this package, and the gateway's own
lifecycle is what gateway.go is for.
The ACP package had ten files and one of them held a third of the package.
Three subjects come out of it, each a thing the protocol names rather than a
slice of the implementation: session.go (new, load, resume, prompt, cancel,
close and the lookups they share), session_config.go (which axes a client may
switch on a live session, and how a pending change merges and applies), and
session_meta.go (the sidecar that remembers a session between connections).
service.go goes 3047 -> 1676. file-size drops 1371 and every other budget is
unchanged — the same code, the same rules, read in the order the protocol is.
Extensions to extensions.go, steering to inbox_steer.go, the job verbs to
jobs_bridge.go, plan mode to planlifecycle.go, and close/rotate to
session_lifecycle.go — each into the file its subject already had. The turn
entry points get a new one: Send, RunTurn and the loop they drive are what a
frontend calls to start work, and turn_orchestrator.go is over 670 lines
already.
controller.go goes 2024 -> 1430, so its overage is 630 where it was 2432 two
commits ago. What stays is the constructor and the accessors — the session
state every subject reads — which is what a controller is once its behaviour
lives beside the state it acts on.
file-size drops another 594. The other budgets are byte-for-byte unchanged,
including essay: the one line this pass added was a four-line file note where
three is the limit for that position, and it says the same thing in three.
Seven groups of methods move to the file their subject already owns: skills to
skill.go beside skillSet, checkpoints and summarize to checkpoint.go beside
checkpointManager, memory to memory_command.go, the in-flight and interrupted
turn bookkeeping to turn_cancel.go (where appendUniqueString's four callers
already were), branches to branches.go, session artifacts and recovery notices
to session_snapshot.go, and the goal verbs to a new goal_control.go rather than
into goal.go, which is over the ceiling already.
controller.go goes 3232 -> 2024. The essay, frontend-parity, complexity and
function-size budgets are unchanged to the point — this is the same code under
the same rules — while file-size drops 1208, which is what a 2400-line overage
becoming a 1200-line one is worth. The baseline is updated with -allow-widen
because an extraction moves each file's share, and REASONIX.md asks for that to
be visible in the command.
The approval bridge earns more than a move. gateApprover decides whether a tool
call runs, and it sat in controller.go — outside the sensitive paths, which
name approval.go and approval_orchestration.go and could not reach it there.
approval_bridge.go is declared sensitive, so the review and the coverage floor
now cover it: 77%, against 94% and 91% for the two files beside it.
Two coverage floors move on their own merit — approval_bridge.go is new, and
shellsafe rose to 81.6% when the redirect guard got the tests its own comment
promised. The rest are restored to what they were: `covergate -update` had
raised every floor to one run's reading, and providerbroker's 83.6% was seen at
83.1% on the next, which is a gate that fails for no reason.
The comment said desktop/next is absent from frontendPorts because it drove the
kernel over HTTP, where wire-parity is the guard. That shell is retired; what
the note still has to say is why botruntime is absent, which is a different
reason — it never reaches the controller at all.
The three fields already had a lock of their own — externalFolderRefsMu sat in
the Controller beside the map it guarded and the tool-side registrar it fed —
which is the shape a collaborator takes before it has a name. It has one now,
and the lock travels with the state it protects rather than sitting among
forty other fields that answer to c.mu.
A strict leaf in the sense memoryManager set: register, resolve and roots touch
only their own map, so a dropped folder never contends with a turn's run state.
The three call sites lose the locking they were open-coding — dropped.go's
seven lines become one, and refs.go's prefix walk moves next to the map it
walks.
build-host.js ran go from desktop/electron, so Go resolved
../../cmd/reasonix-studio-host against desktop/go.mod — a main-module package
built in the desktop module's context, working only while that module's go.sum
happened to carry the main module's whole dependency closure. Retiring the
Wails shell ended that: `go mod tidy` there keeps what the desktop module's own
code imports, and the build stopped with a missing go.sum entry for sftp.
It now runs from the repository root, where that package's module is, and names
its output by absolute path since the working directory moved.
vite's proxy comment matched its Origin rewrite against "production and the
Wails shell"; there is one shell now and production is the whole claim.
release-studio.yml opened by saying which of two shells it builds. And a
completion test used a wailsjs path as its nested-file fixture, where any
nested path proves the same thing — that a file below the workspace is offered
and node_modules is not.
The comparisons that remain in release-studio.yml are load-bearing: they say
what the Wails line installed, which is what the Electron package has to take
over rather than sit beside.
Section 6 of the migration contract bound these into one transaction, because
deleting any one first leaves a broken shell rather than a smaller tree:
desktop/next and its event pump, remote_pump.go, /rx-replay, the frontend's
WAILS_* constants and bus branch, the apiPaths/apiPrefixes whitelist, the
vendored go-webview2 fork, and the Wails dependency with its CGO and
GTK/WebKitGTK build requirements. They go together here.
What allows it now is the condition section 7 named — no exclusive semantics
left — plus the one ledger entry that had been recorded as unverified: the
release line. studio-v2.11.0 (2026-09-01) through studio-v2.16.0 shipped the
Electron bundle seven times, and the crossing from an installed Wails 2.10 was
confirmed by the maintainer rather than assumed here.
The kernel loses what only that shell reached: update.LocalApplication (a
process that is its own application, which Electron's is not), update.Here,
appidentity's AppUserModelID application and shortcut repair — Electron states
its own appId — and crashreport.CaptureStudioPanic. AppUserModelID itself stays,
because Windows toasts still carry it.
Two consequences worth stating. The desktop module is now CGO-free, so neither
CI nor a contributor installs GTK or WebKitGTK to build it, and the webkit2_41
tag is gone from every command. And what the old shell installed is untouched:
winuninstall, the Electron shell's legacy takeover and studio_line's binary name
are how an installed 2.10 becomes a 2.11, and none of them are Wails code.
The contract becomes docs/STUDIO_SHELL_BOUNDARIES.md: the rules the migration
established outlive it, and the ledgers it kept do not. The presentation gaps it
tracked are carried over as known gaps, with the host's missing panic capture
added to them.
SetDesktopUpdateChannel was kept for pre-single-channel Wails clients, which
reached it through Wails bindings. Those bindings are gone — the migration
contract records their removal under version reading and pinning — so nothing
can call it, and its own package is its only mention. The legacy field it
cleared is still read: NormalizeDesktopUpdateChannel folds an old config's
value on load, which is the half that has to stay.
These three outlive the application they drove. `dev` runs `wails dev` in
desktop/, which holds no Go file at all since that app was deleted; `prod_test`
copies `desktop/wails.json` and calls `scripts/desktop-build.sh`, neither of
which exists, so it fails on its first command. `.wails-version` pinned the
Wails CLI for them, and prod_test was its last reader — the shell that is still
here needs no CLI, as scripts/studio-build.sh says in its own header ("Studio
has no wails.json, so this drives go build directly").
README and CONTRIBUTING told a contributor to run `make wails-install`, a
target the Makefile does not define, and then to launch
`build/bin/reasonix-desktop`, a binary nothing builds. They now name the
command that does work. CONTRIBUTING also asked for Go 1.25 where go.mod
requires 1.26, which sends a first-time contributor to install the wrong
toolchain.
This is the retired desktop's residue, not the Wails shell: desktop/next and
everything section 6 of the migration contract binds to it stay, because that
shell still compiles in CI and the contract retires it in one transaction or
not at all.
Thirty places built the same four lines — make with len(m), range-append,
slices.Sort — to get a map's keys in order, while nine others already wrote
slices.Sorted(maps.Keys(m)). The long form says nothing the short one does
not, and having both means a reader has to check each one for the difference
that is not there.
The four collectors that do not sort are left alone: their callers may or may
not be relying on something about the slice, and a shorter spelling is not
worth deciding that here.
Two ways to sort a string slice were in the tree at once — slices.Sort in 30
places, sort.Strings in 148, and internal/hook/inspect.go used both. Since Go
1.22 sort.Strings is a call to slices.Sort, so this settles which one a reader
meets, not what the code does.
The four files that would have grown by an import line are already over their
size budget, so their remaining sort.Slice calls move too: cmp.Or over
cmp.Compare says a multi-field ordering in one expression where the boolean
form needed a chain of ifs, and the four files come out shorter than they went
in.
math/rand becomes math/rand/v2 throughout. The seeded call sites all use a
fixed seed for repeatability rather than asserting particular values, so PCG
replacing the v1 source changes which sequence they walk and nothing about
what they prove.
Backoff stays as it is. Four packages compute a retry delay and it is tempting
to share that too, but provider caps at 15s and the stream retry at 8s to match
a reference implementation's timing, while feishu uses proportional jitter and
remote full jitter. Those are four calibrated decisions, not one judgement
copied four times, and the formula is the part that was never hard.
Three packages defined pathWithin and meant three different things. sandbox
excluded the root itself and normalized neither side; environment included the
root and cleaned both; instruction included the root and resolved both to
absolute. Two of the three decide a security boundary — instruction fences
@import against escaping the workspace, environment blocks executables under a
denied root — and a reader moving between them had no way to see the rule
change under the same name.
fileutil.Under and fileutil.AtOrUnder name the two questions apart and resolve
both sides before comparing, so a relative path or an interior ".." cannot
answer by spelling rather than by location. sandbox keeps the strict form on
purpose: /tmp is not a directory inside /tmp, and mounting it would not be the
same operation. Both of its call sites already passed cleaned absolute paths,
so what changes there is only that a future one cannot forget to.
The redirect guard gets the tests its own doc comment implied: eleven safe
spellings it promises to strip, and the file-reaching forms it must refuse,
none of which had a case. That also restores the shellsafe coverage floor,
which slipped when an earlier pass retired a fully covered wrapper.
boot/runtime_plan.go spelled out extension.ComponentID("plugin/" + name) on
line 50 and called sidecar.PluginNameFromComponentID on line 136 — the same
file encoding by hand and decoding through the constructor's own package. That
is why PluginComponentID had no caller: the one place that should build an id
built its own. The two halves also disagreed on whitespace, the constructor
trimming where the literal did not, and a manifest name keeps its surrounding
spaces (IsValidName trims only to match).
The kernel's own "is this a plugin component" test reads the prefix from
extensioncontract rather than repeating the literal a fourth time.
Fifty-seven comments carried a development stage or phase — "stage 7",
"Stage 6b1", "Phase 0" — that no document in the tree defines. A reader who
never saw the plan they came from loses nothing by their removal, which is
what REASONIX.md asks of a comment. The budget is now zero, so the rule stops
being a ratchet and starts being a gate.
Three of them were not only unreadable but wrong, because a comment naming a
future stage goes stale the moment that stage lands:
- SandboxConfig said reads were unrestricted and that confining bash was
still to come, while the struct it documents carries ForbidRead, Bash,
Network and HostAuthorities.
- Activator said stage 2 wires none; boot has installed one since.
- UIHandler said stage 8 would wire real frontends; the host UI hub serves
them now.
Where a stage is a real step rather than a plan — the rewind overlay's three
screens, restoreReleaseUnit's stage-then-swap — the step keeps its meaning and
loses only the number, which the reader had to count anyway.
pluginpkg.validRuntimeProviderRef said in its own comment that it mirrors
extension.validProviderSlotTarget, and it did — by hand, with the two-branch
shape retyped and the whitespace check repeated. The parser decides whether a
package may install and the kernel decides whether its claim resolves, so the
two disagreeing is a package that passes validation and then cannot bind.
pluginpkg cannot import the kernel (extension -> hook -> pluginpkg is a cycle),
which is what extensioncontract exists for. Both now read the rule from there,
so agreement is structural rather than maintained.
boot.splitProviderRef keeps its own shape on purpose: it cuts at the first
slash and tolerates what has none, which is parsing a user's input, not
validating a kernel ref. Sharing a name with the strict one is a separate
hazard and stays as it is.
Thirteen exported functions each forwarded to a fuller form with one argument
filled in — RenderTOMLForScope(RenderScopeFull), StartPackagesWithPlan(nil,
nil), decodeForDirection(direction, strict), NewProviderWithProxy(ModeAuto) —
and production used the fuller form every time. The shorter shape had one
audience, the tests that wanted fewer arguments, so it now lives beside them:
a same-package helper where the fuller form is unexported, a direct call where
it is not.
environment.RunProbesWithOverrides shows why the shape keeps coming back: the
previous pass retired RunProbes onto it, and it turned out to be the middle of
three generations, itself forwarding to RunProbesWithOptions.
ClaimRename, SharedCatalog, CommandOverview and SharesRepo each forward to
something the tree still calls, from tests alone. fileutil.GlobSet went further:
the type, its constructor and its Match method had no user in any module, so
what survives the file is the two slash helpers other packages do call, under a
name that says what they are.
MatchSlashGlob keeps a behaviour worth stating, since the deleted GlobSet was
where it was covered: a "**/" prefix also matches at the root, which doublestar
does not do on its own.
Three packages each carried their own answer to "is this socket failure worth
another attempt": provider.IsConnReset, the duplicated case inside
ClassifyStreamInterrupt that its own default already covered, and feishu's
isTransientError, which fell through to a list of English substrings.
All three matched syscall.ECONNRESET, which on Windows is an APPLICATION_ERROR
placeholder rather than a socket code — errors.Is against it cannot match what
the runtime returns there, so the sentinel arm was dead on that platform and
the substring list was the only path left. That list reads the operating
system's own sentence, which arrives localized, so on a non-English Windows
neither arm fired and nothing retried.
internal/neterr holds the judgement once and names the identities each platform
actually reports. netclient's transport-error trimming was the other text
match: net/http hands back a *url.Error whose Op and URL are the prefix it was
cutting by hand, so it reads the field instead.
Seventeen exported kernel functions were reachable from tests alone. Each one
forwards to something the tree still calls — ensureBranchMetaUnlocked,
UpdateBranchMeta, editConfigFile, CommandDirsForRoot, DecidePlannerRoute,
RunProbesWithOverrides, ClassifyWorkspaceNonMutatingCommand,
SessionGrantRuleForScope, acquire — or, for LoadRecoveryDefaultsForRoot,
ResetOfficialProviderPricingOnUpgrade and TaskWarrantsPlanner, says in its own
doc comment that it exists for callers that no longer exist. A test calling the
wrapper is not covering the path production takes; it is keeping a second
entrance warm. The tests now call what production calls.
event.FanOut had no user at all: the type, its constructor and its whole file
were reachable only from the sink-capability guard's fixture list, which covers
the three wrappers boot actually chains.
AcquireWithExternalTimeout carried one thing its callee does not, a rejection
of a non-positive budget. That contract was the wrapper's own — acquire treats
zero as "follow ctx", which is what Acquire passes — so the case asserting it
goes with the entrance it belonged to.
adapters.go offered seven Contributor factories that each ran their own
discovery — command.LoadRoots, hook.Load, skill.Store.List. boot discovers
first and hands the kernel resolved records, so it built its contributions
from the single-record mappers and wrapped them in an anonymous "boot-legacy"
ContributorFunc instead. The two models are exclusive and only one of them
ever ran; the factories were reachable from tests alone.
Delete them and the two mappers only they called. The package comment claimed
the adapters wrapped discovery, which stopped being true the moment boot took
discovery over, and it now says what the file does. It also records that
KindPrompt and KindTheme have no contributor at all: pluginpkg parses a
package's prompts and themes and nothing carries them to a snapshot.
`default_tool_approval_mode` reached exactly one shell. The Wails binary read
it and called SetToolApprovalMode; the Electron one never did, so the same
config opened Ask in Studio and the configured mode in the shell it replaced.
The hub then propagated that wrong posture to every later pane on purpose —
`h.stance` takes whatever the first controller came up with, which is the right
rule applied to a value nobody had set.
It belongs in the shared assembly rather than in whichever shell remembers it,
so `boot` now hands back a controller already in that posture, for the desktop
surface only: a terminal frontend states its own on the command line, and
`reasonix serve` is left where it was. An unset config normalises to Ask, so
this loosens nothing nobody asked for.
`internal/boot/effect_test.go` asserts both sides through the real assembly.
Reverted, it reports `ToolApprovalMode() = "ask", want "yolo"` — the sentence
the report described.
The same change also retires a second field for a setting that already had one:
yesterday's `measure` and the existing `desktop.conversation_width` are the same
question, and the older one had a setter, a renderer and installs that may
already carry it. The endpoint now reads and writes that one, as `width`.
Two changes that look like one, and are not.
A clipped command is not a preference. `--doc` held the whole transcript to a
reading line length, so a Bash line ran off at `rm xiangqi.html xiangqi.js
xi…` and the rest of what it deleted was simply not on screen. Cards, commands,
code and output now take the container's width — a new `--wide` — while prose
keeps the measure it was given. The three places that align to the transcript's
left edge (the tabs, the run header, the task list) follow the container, not
the measure, or they would sit 100px off it.
How far prose runs is a preference, and it now has a setting: 外观 → 行宽, beside
the body size, because they are one question asked twice — how big the letters
are, and how far they run before the eye comes back. Two answers, not four: a
line short enough to find the next one, or a window used to its edge. A step
between them would be a number nobody could tell from its neighbour.
Measured at 1920: the transcript was 706 wide in a 1288 column, 291 empty on
each side. It is now 1120 at the default and 1180 when someone asks for the
window. The composer's own width was computed from a different subtraction than
the flow's, which nothing showed while both were clamped by the same max-width
and showed as a 14px misalignment the moment they were not.
`renderAppearanceSection` is hand-written and drops what it does not know, as
its own comment says: the field reached disk through the endpoint and was gone
from `/appearance` on the next read until it was added there too.
A Windows update runs the *previous* install's uninstaller first, and
electron-builder's uninstaller takes $INSTDIR whole — it enumerates every entry
under the directory and moves it aside, then `RMDir /r $INSTDIR`. It is not a
record of what was installed. With `allowToChangeInstallationDirectory` on, a
person can point that at a folder that already holds their work, and nothing
says a word until the next update removes all of it. What it moves aside goes
to $PLUGINSDIR, which is under %TEMP% and swept when the installer exits, so
there is nothing to recover.
Reproduced on a real machine with the shipped v2.15.0 and v2.16.0 packages:
four files in the chosen folder, all four present after the install, all four
gone after the upgrade.
The choice of folder is the only place left to refuse, because an upgrade runs
the uninstaller that was compiled with the *old* build. So this lands in the
directory page (`.onVerifyInstDir` greys out Next) and in `customInit` for the
silent `/S /D=` path, which never sees that page and is how a scripted deploy
names a folder. A folder carrying our own uninstaller is the one that may
already have things in it — that is what an upgrade is.
Verified against a build of this change, silently, on Windows:
into a folder with files in it → exit 2, files untouched, nothing installed
into an empty folder → installs
over our own install → installs
Two notes on the NSIS side: this file is included before electron-builder's own
script, so LogicLib has to be included here (its guard makes that a no-op) and
${UNINSTALL_FILENAME} is not defined yet — PRODUCT_FILENAME arrives on the
command line, so the name is built the same way common.nsh builds it.
This cannot help an install that is already sitting in a shared folder: that
machine's next upgrade runs its existing uninstaller, which has none of this.
A new sweep refuses every write the settings panels make and asks the four
things a refused write has to answer: did the control roll back, did the window
say anything, is anything stuck, did anything go uncaught. Seventeen writes
across twelve panels; sixteen answered all four.
The one that did not was activating a theme pack — `.catch(() => {})`, so the
card sprang back to the palette that is really on and the window said nothing
about why. Clearing a wallpaper swallowed its refusal the same way. Both now
land in the state this screen already had for the one write somebody
remembered, which was being rendered inside the wallpaper block: a palette that
would not activate is not news for the wallpaper section, so it is said at the
top, where the rest of settings says it.
Two judgements in the sweep had to be fixed before the report was worth
reading, and both are a probe answering for something other than what it names:
a panel that sends no write was being scored on whether its control rolled back
(nothing was injected, so it had not), and one shared error sentence made the
previous panel's message part of the next panel's baseline — four panels that
did speak read as silent.
Deduplicating the i18n sweep's findings — one string is one finding, wherever
it was first seen — took the report from 49 lines to 33 and brought 25 real
ones out from under an error card that rides every section and was being
counted 29 times.
What they were: the completion menu rendered its kind column and its key hints
straight from a constant table, past t(), which no static check can see —
`t(variable)` hides its key from every one of them. And the built-in verbs are
rendered by the kernel from the process's catalogue, while the window reads the
interface language: a machine whose CLI speaks Chinese showed an English window
a Chinese menu. The kernel now renders that catalogue in the language of
whoever asked, which is a per-request answer because a menu is.
What is left on an English screen: the kernel's error text, which rides the
event stream to two frontends at once and so has to carry a code rather than a
sentence, and the language picker's 中文.
Also: the fixture's "this machine has a provider" belongs to a window, not to
a module. Holding it per module leaked between tests — four of them failed
together and passed alone, which reads exactly like flake.
The i18n sweep was opening settings by an English aria-label the button had
already stopped carrying, and threw rather than passing — the one of the three
that failed loudly. The contrast sweep found the same button by trying both
languages, so it kept working and kept its own copy of the walk. A step is a
claim about what the window can be driven to show, and two copies of that
claim drift: the walk now lives in tools/steps.mjs and all three take it.
Two ways a sweep was proving nothing while reporting zero:
The contrast sweep switched themes by setting `data-theme` by hand. A pack
writes its palette to `root.style`, which outranks that attribute and does not
move until the window repaints — so the "dark" pass was reading a pack's light
inks under a dark stylesheet, a state nothing renders, and reported ten
failures in it. It reloads into the theme now, and prints the theme that
actually painted, because two passes over one theme also report zero.
The i18n sweep set `rx-lang` and trusted it. The kernel's own language setting
outranks that and reloads the window with it, so a home configured for Chinese
turned the sweep into a Chinese window reporting Chinese: 4012 hits, not one of
them a finding. It reads back what rendered and refuses to go on otherwise.
Its walk goes from 12 settings pages to 29 sections. What is left on screen in
English is the kernel's own error text — a separate setting, tracked — and the
language picker's 中文, which is written in its own language on purpose.
The connect card is the first thing a new machine shows and the last place to
be stingy with ink: its field labels, the env-var name beside them, the note
under the button and the line under the brand all sat between 2.71 and 3.46
against AA's 4.5. They were white at .30–.38 opacity on a near-black scene;
.48 reads without taking the scene's own darkness away.
The sweep had been reporting the brand heading at 1.00 — text painted through
a gradient has no colour of its own to weigh, so its transparent ink resolved
to the surface behind it. One impossible number sitting on top of five real
ones is how a report stops being read.
A rule written for a row that does not exist. `.lrow .ds` was declared twice,
and the second one — paired with a `.lrow .nm` the interface has never
rendered — clipped it to a single line with an ellipsis. All 26 descriptions
in settings were cut at 820px wide with no way to read the rest; they now
wrap, in the size and colour the first declaration always meant.
Two strings painted over their neighbours rather than being clipped: a word
longer than the box it had been squeezed into (`overflow-wrap`), and a status
that had nowhere left to go on a row that would not wrap.
What is still deliberately clipped now carries the whole string on `title` —
an MCP server's line, its tools' descriptions, a provider's host, and the
activity row, where the tail of an error is the half that says what failed.
`--accent-ink` cleared the completion popover at 6.4:1 and the selected row
inside it at 4.40. The darkest surface an ink lands on is the one that sets
its value.
And the boot line's Chinese was under Latin letterspacing, which pulls Han
characters apart for no reading it helps.
Two things the token validator cannot see, because every value involved is a
legal colour on its own.
Contrast is a property of a pair. All nine shipped packs put `fgFaint` — what
`--faint` and `--ghost` are drawn from — below AA on their own surfaces: 2.56
to 3.58 against a line of 4.5, which the default appearance has always met.
A light window under a pack reported 137 unreadable strings. Each value is
moved on lightness alone, so the palette keeps its hue and chroma, and
`TestShippedPacksMeetTextContrast` now holds the set to the line. The one
string left is the in-use badge on a selected model row, which had stayed on
`--faint` over the accent wash while everything else in the row stepped up.
Contrast is also the reader's, the way reading size already is: a pack states
a palette, not how legible it has to be for whoever is in front of it. Pinning
`--text` inline won every step of the setting, so someone who asked for
stronger text got the author's answer for as long as the palette was on. A
pack's inks are now read as the step someone who never opened the setting
sees, and the two stronger steps are derived from them by the same lightness
the built-in palette moves.
The interface sweep had been walking five of its thirty steps and reporting
the rest as skipped. Three things were behind that, and each of them failed
silently:
The entry script answered the connect card on fixed timers — 200ms, then
1800ms for a probe that takes longer — so it walked away from a card still
on screen, and its return value was dropped rather than printed. A dozen
unreachable steps read as a dozen skipped ones.
The steps named their controls by label and by DOM shape: `.sesstitle` had
not existed for months, so the step meant to open a session "succeeded" into
the rename button beside it, and the tab steps looked for English the window
only renders in Chinese. They now name the intent the interface declares —
`data-action` — which is the one thing that moves with neither.
And the fixture held "this machine has a provider" per port instance. A
session opens a second pane, which builds its own port, which had never been
given a key: the connect card came back over a window already in use.
Thirty steps, nothing skipped.
`.main` took a `--surface` background, and it is the one surface in the
middle column that the `data-bg` set never hands a transparent one: the
rails, the panes and the app itself are all cleared when a pack brings a
picture, so an image survived at the edges of the window and was painted
over everywhere a transcript is read.
The pack was loading the whole time — the asset answered 200 and the
alpha was right — which is why this read as "the image stopped showing"
rather than as a missing file.
`test (full)` timed out at 20 minutes on three separate pushes, each time
with every package it had reached reporting ok and the step dying mid-run.
The budget was set to sit "above normal full-suite wall time (~8-12m)", and
that is no longer where the suite is: green runs on this branch measured
520s, 661s, 844s, 874s, 885s, 936s and 976s, while the three that died
measured 1287s, 1295s and 1305s. 20m was inside the distribution, so a slow
runner arrived as a failed test — the same reading the package budget above
was widened to stop producing.
30m keeps what the step is for: a hang still dies on it rather than on a
package timeout that names the wrong cause.
Opening a second conversation left every answer in it rendered as its own
source text, and switching back left the first one that way too.
The three deferred plugins are held in module slots and read with
`useState(slot)` — and a plugin is a function, which React takes as a lazy
initializer and calls. What the state then held was not the attacher but the
transformer it returns, and unified attaches that in turn: it runs with no
tree at all, which is the `Cannot use 'in' operator to search for 'children'
in undefined` the renderer threw into SayCard's boundary. A boundary does not
reset, so the card stayed at its source text for as long as it was mounted.
The highlighter has therefore never worked past the card that loaded it: the
listing that triggered the fetch rendered, because the value that reached it
came from setPlugin rather than from the initializer, and every card mounted
after that fell back. katex escaped it because its slot holds
`[plugin, options]`, an array; gemoji did not.
The three hooks were one shape written three times, so they become one that
takes the slot, and every read goes through a thunk.
Measured in the workbench against a mock kernel: opening a second pane went
from 2 rich / 3 plain with two TypeErrors to 5 rich / 0 plain with none, and
highlighted tokens from 3 to 9. Markdown.plugins.test.tsx mounts a listing,
waits for `code.hljs`, then mounts a second card — and fails on exactly that
TypeError with this change stashed.
modernize fails both Index-then-slice pairs in tsRecordKeys, and the
lint job runs with cross-platform build tags where the root `make lint`
does not reach the desktop module at all.
TestAnUncontendedHoldStillReports acquired and released with nothing in
between and then asserted Held > 0. Go's monotonic clock on Windows
advances on the system tick, about every 15ms, so the whole hold fell
inside one reading and the account came back {Held:0s} — the test was
asserting clock resolution, and only that platform was red.
The hold now spans 40ms and the assertion asks for 30, the same shape
the idle test beside it already used. What it proves is unchanged: an
uncontended hold is accounted for and reports no contention.
The committed readings gate what the desktop reducer is checked against,
so stabilize() strips everything that is a measurement rather than turn
structure — a tool call's DurationMs has been zeroed there all along.
The workspace lease account arrived beside it carrying heldMs and idleMs
and nothing stripped them, so the file recorded how long one run of the
test happened to hold the lease: 24ms when it was written, 23ms on the
next machine to run it. The test could not pass twice.
It failed on ubuntu, macos, windows, race and coverage at once, which is
what a fixture keyed on wall-clock looks like rather than five separate
breaks.
Waited, held and idle are zeroed with the rest. What the fixture still
carries is the fact that a lease event was emitted, where it sits in the
turn, and that it was uncontended — the shape the reducer is judged on.
PROJECTION_CAUSE_WITHOUT_SOURCE stood at 17: every interaction whose
lifecycle axis reached Transcript's presence carried an unmount-open
cause with nothing behind it, so the invariant the reduction exists to
hold — a projection always names its sources — was broken tree-wide by
one component.
Openness is decided over every effect a component owns, and the sources
were read off `d.by`, which is the single effect a displayed line names.
Transcript has twelve: the one that makes unmountOpen true is the
useEffect at 456 whose cleanup the walk cannot read, while `by` points
at 325, the first with an open setup and no cleanup at all. Reading a
cleanup off an effect that has none yields an empty set, which is a
debt with no mechanism under it.
The predicates are now declared once and both the verdict and the
attribution read them, so the two cannot diverge again: mountOpenBy and
unmountOpenBy are the effects the verdict was taken over, and a cause's
sources are the union across them. The mount side had the same shape and
was one component away from the same result.
census:verify is unchanged across all ten fixture reports.
The account the kernel closes at each release is five integers with no
path, command or identity in it, so it can ride the channel that already
has consent: three closed enums added to the upload allow-list, and a
group on the dashboard beside the other settings metrics.
lease_hold contended | uncontended
lease_wait the existing latency tiers, contended holds only
lease_idle how much of the hold came after the last write asked
The third is the one that decides anything. A high contention rate with
a low idle share means the lease is held exactly as long as it is used,
and the proposal to release at the last verification would return
nothing; the reverse says the opposite.
Flushed on its own rather than into the turn's batch, because the lease
is released when a session's last run ends and that is not reliably
before the TurnDone that empties the batch. Nothing here changes who
reports: the policy is unchanged and still refuses a non-release build,
CI, DO_NOT_TRACK, and anything but an interactive session under `auto`.
A hold with no length has no idle share rather than a share of zero,
which would read as a hold measured to have been busy throughout.
The dashboard's group table moved to a file of its own instead of
stats.ts growing past its ceiling, and the 17 lines that file had been
carrying are reclaimed.
The kernel now closes an account at each release; this is what reads them.
It reports holds, how many were contended, how long those waited, and the
share of each hold that came after the last write asked for the lease.
Four things it refuses to let a number mean something it does not. The
denominator is holds, so a session that wrote and never waited counts —
it is most of the rate. contended and reported are separate columns,
because a wait inside the one-second grace never becomes a notice and
those are the common case. A zero-length hold has no idle share rather
than a share of zero. And a log the size cap truncated makes every
number a floor, which is said in the rendering rather than left for the
reader to remember — the frames that go missing are the ones written at
the end of long turns.
Run against every session on this machine it reports 35 logs and no
holds, which is the honest answer: the instrument shipped an hour ago
and the corpus starts now.
The workspace write lease is the reason two sessions cannot write one
project at once, and whether that costs anyone anything had no number
behind it. The only record was a sentence on screen — "waited 2.3s" —
and only for a wait that outlived the one-second grace, which hides the
common case entirely.
workspacelease.Stats closes one account per hold: contended acquisitions
including the ones under the grace, how many became notices, total time
waited, how long the lease was held, and how much of that hold came after
the last write asked for it. That last one is the number the proposal to
release at the last verification stands or falls on, and nothing was
measuring it. It rides a shadow event, the shape TodoProgress already
uses: no gate reads it and no prompt byte moves with it.
Writing the test found a real ordering error: the ask that takes the
lease is dated before the acquisition it waited for, so Idle measured
longer than the Held it is part of. It runs from whichever came later.
internal/event/event.go was at the 800-line ceiling, so the audit
forwarders moved to a file of their own rather than the budget moving.
The card was behind a localStorage key no control ever wrote, so it was
not off by default — it was unreachable. What it reports is what the
turn changed, what it verified, and what went unverified, which is
exactly what a reader wants once the turn stops moving; without it most
turns simply stopped.
It is on unless this machine turns it off, and the switch now exists,
beside the other per-machine display choices. The kernel still decides
whether a receipt has anything to say; the preference only decides
whether a reader sees one that does. The control declares that it takes
effect from the next turn, because the reducer records the card when the
turn ends rather than the view choosing per render.
A shell row showed the first 96 characters of the command, which for
`PATH=$(echo $PATH | …) go test ./x` is sixty characters of plumbing and
no verb. Which part of a command names what it runs can only be answered
by a parser — a regex over the text is the word-matching this tree does
not build — so the kernel answers it: shellrun.OperativeCommand cuts the
leading assignments positionally, from the parsed command's own byte
offsets, so quoting and substitutions survive as written. It rides
ShellExecution.Subject and never replaces the command: authorization
surfaces carry the full text, and so does the row's title.
Declaring that pair in wireparity found three drifts already there:
shellVersion and supportsAndAnd were sent and unreadable — which is
PowerShell 5.1, where `&&` is a syntax error, arriving indistinguishable
from 7 — and contextTokens was declared here while only Tool sends it.
A MultiEdit card named its file in the headline and again in the diff
header, truncating one fact twice. The diff takes the path only where
nothing around it has said it, which is the change preview.
A settled call whose whole output was "ok" measured 109px, against 39px
for one that printed nothing — a 2.8x height for two characters. Nine
ordinary calls filled a screen, two thirds of it padded ground holding
nothing. A surface exists to cut a block of machine text into the page,
and one line is not a block.
Three separate costs, each measured:
- The block's own padding and ground: a one-line output takes neither.
- The "more below" fade, drawn on every settled card whether or not
anything was clipped. Clip already knows; it now says so.
- .term's 13px bottom margin, which held the surface off the next
element and holds nothing off anything once there is no surface.
A turn of nine plain calls: 1474px to 1162px.
The diff header wrapped a long path across three lines, so the card grew
by what a path happened to measure — 37px of header became 105px, and
the file read as an empty band above its own diff. It ellipses now, with
the whole path in the title.
The bar floats over the transcript's top edge, so a row landed at the
usual 12px clearance arrived underneath it — the first hit of a search
was the one hit that could not be read.
668 comment blocks, 1500 lines, met one aside at a time while reading
rules. Almost none of them restated the CSS: they are measured colours,
layout mechanics, and why the obvious rule is the wrong one — knowledge
worth more than its placement.
It moves to two companion documents read end to end. TOKENS.md holds the
vocabulary's law: why --float exists apart from --raised, what the
categorical palette was measured against, why the type floor is 11px.
LAYOUT.md follows app.css rule by rule.
No declaration moved: both files are rule-for-rule identical to what
they were, apart from the diff gutter taking --faint and the plan's
completed rows losing the opacity that dimmed them twice.
REASONIX.md states the rule and where a constraint goes: to the
component that has to honour it, or to the stylesheet's companion when
the decision is about the medium itself. It also states that comments
are written in English — commentary in two languages is two codebases,
and the half a reader cannot read is the half that rots.
`"[data-pane="flow"]"` closed its own string, and since 60b028856
cards, bench, look, motion, scale and verify have not parsed at all —
21 card-geometry assertions and 20 behaviour assertions that nobody ran.
selfcheck read that as health: it injects a false assertion and asks
whether the exit code turns red, and a SyntaxError is also non-zero. It
now asks whether the guard parses first, then whether it can fail.
contrast.mjs could only draw four of the transcript's cards, so the
diff gutter's line numbers sat at 4.42 against AA's 4.5 undetected. Its
feed now covers sixteen, which found five more: the plan's completed
rows were dimmed twice, by colour and by opacity, putting their text at
3.22 and the tick at 2.86. Three further corrections to the guard
itself: it stops animations rather than waiting them out (a staggered
entrance measured .cost at 2.04 while it sat at opacity 0.46), it prints
the ink, the ground and the ancestry rather than a signature that four
cards share, and it skips fully transparent text — unpainted text has no
contrast, and whether a hover-only control is reachable is another
question.
**Find in a conversation.** The transcript mounts a few dozen cards out
of however many the session holds, so the browser's own find sees only
what is on screen: with 200 messages, 59 cards are in the DOM and the
first one cannot be found at all. The search reads the rows instead —
saidBy() is exhaustive over Item, so a new card has to answer what text
it carries. Matches are painted with the highlight registry rather than
by wrapping text, which would mean rewriting markdown React owns inside
cards that re-render on every delta.
**Listings are highlighted.** rehype-highlight, lazy like katex, emits
classes rather than inline colours: a highlighter writing its own hex
values leaves the token system and stops following the theme. The
palette is four new tokens; comments and punctuation ride --faint and
--muted, so they follow the contrast tier. A fence with no language
stays unpainted — guessing a grammar paints prose as code.
**A message can be rewritten and sent again.** Composed from what the
kernel already owns: a conversation rewind to that turn, then a send.
The TS mirror of RewindResult was missing conversationOk, so a commit
that touched files and left the conversation standing was
indistinguishable from one that truncated it — which would send the
message twice. The type is completed and the pair is declared in
wireparity.
**The capability a call resolved to reaches the card**, when the name
has not already said it: `tool:grep` beside Search is one fact twice,
`skill:review` is not.
**The task list draws the status its owner keeps.** Three renderers
each re-derived "which step is underway" as the first unticked one,
while HostTodo has carried status, activeForm and level all along. A
list the kernel has written but not started now points at no step, the
step in hand reads as its active form, and sub-steps indent. A
complete_step advance reaches the panel on its own frame instead of
waiting for the turn to end.
Pane.tsx was at the 800-line ceiling, so find and rewind became hooks
rather than the baseline being widened.
Twenty-two of the fifty-four tools the kernel registers had no entry in
either table, so a call through use_capability rendered as its own
title-cased id under the mark that draws a page with a folded corner. A
delegation, a memory write and an install all read as file reads, and
the category colour — the row's only warning — was the neutral one.
install_source was the worst of them: it is not read-only, it installs
an MCP server, and it had no identity at all. It was found by the gate
rather than by the sweep, whose regex missed its Name() spacing.
The fallback mark becomes a neutral dot: "·" is a document, so a name
with no entry was claiming to have read a file.
tool_identity_test.go holds both directions — a registered tool with no
label, and a label naming nothing the kernel registers. A tool is a type
with a Name and a Schema; matching on the name alone reads every bot
adapter in the tree as one.
The rule in this tree is that an error a caller must tell apart carries an
identity and never a sentence. Tool results were the exception, and the
projection comment said so outright: "only that record says a tool call failed"
— meaning ToolExecution, which the shell path writes and nothing else does.
Everything else left the fact in the words, and isErrorMessage matched "error:"
against prose the tool itself wrote.
Measured across real sessions by day: the shell side was closed on 08-17 and
has written an identity for every failure since. The rest never was — 27 on
08-16, 10 on 08-17, 16 on 08-25, and still arriving in September.
toolOutcome already held what was missing. Its refusalCode comment even says
the identity is "carried beside the words rather than recovered from them" —
it just stopped at the message. ToolFailure now carries it: non-nil is the
fact, RefusalCode names a host refusal. Local metadata on the ToolExecution
pattern, so projection.go keeps it for the next compaction and strips it at the
provider boundary. isErrorMessage reads it first; the prefix match stays as the
answer for transcripts written before any of this, which is what it always was.
The identity also never crossed the wire. event.Tool has had RefusalCode all
along and eventwire.Tool did not, so no frontend could read one — parity was
silent because both sides were equally missing it. Both carry it now, the
history projection carries it, and a failure label says which refusal it was
instead of "失败".
ToolFailure and ToolExecution move to local_metadata.go: provider.go was 1087
lines against an 800 ceiling and -update refused to widen for the extraction,
which is what it is there for. Trimming one sentence that the new file's own
header already states brings both budgets down instead — essay 3218→3217,
file-size 38207→38188.
use_capability is one provider-visible tool that stands in for every hidden
capability. The card takes its heading, its hue and its argument summary from
what the call resolved to, which is why a live transcript reads "Remember ·
2026-08-20 会下雨" and not "MCP". Reopening the session read "MCP" with no
argument at all: three separate places dropped the identity on the way.
historyMessages projected ID/Name/Arguments and left ResolvedName and
CapabilityID behind, though provider.ToolCall carries both and their comment
says they exist for exactly this. The rebuild in state/session.ts then had
nothing to pass on. And shortArgs read only the envelope's top level, where a
proxied call has action and capability_id and nothing a reader wants — the real
call sits under `arguments`, a field the proxy's own schema declares, so
reading one level in is following the contract rather than guessing.
Measured on a real session before and after: one blank "MCP" card became
Memory · 天气 下雨, Remember · 2026-08-20(周四)会下雨, and a Remember whose
arguments were refused. The remaining MCP card is action=list, which resolves
to no capability and correctly says so.
historyToolCall joins mirroredWireTypes. It was a hand-written pair with no
guard, which is how a field goes missing on one side and nothing notices;
removing resolvedName from the TypeScript side now fails wire-parity by name.
The history projection moves to its own file rather than widening serve.go's
budget — 1294 lines against an 800 ceiling — which hands back 70 lines.
Narrowing the provider surface to what a turn admits left the estimate behind:
context_breakdown and estimatedVisibleRequestTokens both asked the registry for
Schemas(), which answers with everything provider-visible whether this turn
carried it or not. The estimate is compared against a window the provider
enforces and a maintenance line the host folds at, so an estimate that counts
tools no request carried folds earlier than the conversation needs.
The surface is recorded where it is already computed — run_loop asks
ProviderSchemas once per round and hands it to the prefix-shape capture — and
the two estimators read that instead. Before any request has gone out there is
nothing to have carried, and the whole visible set answers: it overstates, which
is the direction that folds early rather than overrunning the window.
lastProviderSchemas is on sessionCarryOver for the same reason lastPrefixShape
is: the next request rebinds it before anything reads it.
The gutter names a category twice: the mark carries the hue and the line under
it carries the same hue at 30%. net, deleg, write and ask each had both. mcp
had only the mark — its line fell through to the default grey, so the icon said
"this went out to the network" and the stripe beneath it said "ordinary tool".
mem had the same gap, unrendered in the fixture and visible only in the
stylesheet.
src/styles/gutter.test.ts reads both halves out of app.css and fails a mark
whose line carries a different hue, or none. host is the one exception and
carries its reason: its line is a dash, because colour names the category and
texture names who authored the step.
Found by measuring what the fixture actually paints rather than by reading the
rules — the two mcp declarations sit four lines apart and look complete.
Three states existed where the model could only tell two apart: in the schema
and callable, registered but hidden behind use_capability, and — the one that
was nobody's intent — in the schema and refused. update_goal lived in the third:
its ProviderVisible already reads the turn for a goal recorder and its refusal
already carries goal.no_active_turn, but provider requests asked Schemas(),
which answers without a context. Across 534 real sessions it was called 121
times and refused 116.
The reasoning for asking without a context is in SchemasForContext's own
comment: a contextual answer would churn the cache-stable contract. Under
alphabetical order it would — bash_output sorts fourth of twenty-five, and
moving it invalidates the twenty-one after it. Measured rather than assumed, in
benchmarks/tool-surface-cache: at the head a tool costs 896 cached tokens, in
the middle 384, and appended at the tail it costs nothing at all.
So ProviderSchemas sorts in two segments — every stable tool, then every
contextual one the turn admits — and the two request sites take it: the sampling
request, and the prefix-shape capture, which has to see what was actually sent
or the cache diagnosis reports a surface no turn carried.
The five guards that held the old surface are rewritten to the new contract,
which is stronger than what it replaces rather than narrower:
- the stable segment must be byte-identical whatever the context, and every
contextual tool must come after all of it, so an absence is a prefix of a
presence. Nothing asserted that before; it is the property the cache rests on.
- the contract/provider comparison matches by name. Position-wise only held
while the two sets were identical, which is exactly the assumption being
dropped.
- subagent and planner isolation now read the schema rather than the refusal: a
child without the parent's recorder must not be offered update_goal at all.
The executor, which holds one, still is — an asymmetry the old "always
present" assertion could not express.
The tool schema was held still on the reasoning that changing it churns the
cache-stable prefix. True of a change anywhere, false of a change at the end,
and nothing measured which applied.
Against deepseek-chat, 10 tools behind a 1278-token system prompt: appending at
the tail reports 2048 cached tokens against a 1920 baseline — more, because
every token before the new one is unchanged and the request is longer. The same
tool at the head reports 1024, and editing the fifth tool's description 1536,
which is the tail after the edit.
A tail tool alternating absent/present holds 1920 and 2048 across three
switches with no miss spike, because the absent surface is a byte prefix of the
present one and one cache entry serves both.
It reaches the network and spends a few thousand input tokens, so it stays out
of lint and CI, next to pricecheck.
A capability kept out of the provider schema is reached by listing the catalog
and then calling by id. The listing named every id and no arguments, so the
first call could only be a guess. Measured over 534 real sessions: 12 calls
went out against 3 inspects, and 5 came back "it requires ..." — the proxy
description does say "inspect one for its arguments before calling", and a 12:3
ratio is what that instruction is worth.
The two lists the contract error prints after a call has been spent are now in
the listing before it: required field names, and every declared property. Read
from the target's own schema through the registry, the same source inspect
already used, so a tool that changes its schema changes what list reports. An
entry that is not a registry tool, or whose schema declares no properties, says
nothing rather than reporting empty lists a reader would take for "takes no
arguments".
listCapabilities and listServers move to their own file. usecapability.go was
1667 lines against an 800-line ceiling and this would have added 23 more;
carrying the debt forward through -update is what the ratchet exists to refuse.
Split instead, and the file drops to 1557 — 113 lines of file-size budget
handed back.
Not done: projecting the hidden-capability catalogue onto the turn, which was
the other half of the plan. Built, measured, removed. The block ran 870
characters against a 3486-character turn — a quarter of it — and broke
TestEffectFoldDropsSupersededStandingState by moving the fold boundary. The
premise did not hold either: remember fails once on arguments and succeeds on
the retry in the same session, so the model finds it when a task calls for it.
Zero use across 534 sessions is 534 tasks that never needed to remember
anything, not a capability out of reach.
id="flowScroll" was written into Transcript, which renders once per pane, so
two open panes answered to one id and the rail's aria-controls pointed at
whichever came first — not necessarily the one it scrolls. The sweep only ever
reported it with two panes open, which is a state its 23-step walk does not
reach; it turned up on a session opened by hand.
useId per pane. The stylesheet and the perf scripts move to [data-pane="flow"],
an attribute the element already carried, so nothing depended on the id being
the same string twice. Specificity drops from (1,0,0) to (0,1,0) and the four
rules still land: .scroll is written earlier in the file, and the [hidden] rule
gains a second attribute.
#trajScroll in that same [hidden] rule has no element anywhere and is left
alone.
Vertically clipped output has two tells — the fade at its foot and "Show all".
Horizontally hidden output had none, and the scrollbar is an overlay: at rest
the line simply looks finished.
Measured over 499 real sessions, 7587 tool outputs: 54% have a longest line
past the ~80 columns this width fits, and 31.5% past 120. In one session on
screen, 19 blocks scroll sideways; the worst holds 2881px of content in a 624px
box, so 78% of it is off-screen with nothing saying so.
A right-edge fade on the same gradient as the foot, driven by the block's own
inline scroll position: opacity starts at 0, so a timeline that is inactive —
which is what an output narrower than its column has — draws nothing at all.
Verified by progress rather than by computed style, since an opacity animation
is composited and the main thread reads the base value: 0 unscrolled, 0.5 at
the midpoint, 1 at the end, where it fades out.
712 assertions, and audit-sweep clean at 1600/820 in both themes. The dupId it
reports for #flowScroll is present with this stashed — two panes, one id.
Four levels sit in the right rail, and the block title was at the bottom of all
of them: 12px w700 at --ghost, against rows of 13px at --faint, a legend at
--muted and values at --text. Smaller than anything it introduces, and the
palest ink in the column. Of the three signals it carried, two said "minor".
In English the tracked uppercase carries it — that is what an eyebrow is, and
the sweep has never had anything to say about it. Chinese has no small caps to
carry, so once the letterspacing came off there was nothing left but a slightly
bolder, slightly paler line the same length as the rows beneath it.
It takes the column's own size now and ink one step darker than the rows, so
weight alone carries the rank. Not a larger size: the context total went to 22px
to give this column one place for the eye to land, and six more large things
would be six more places. Size is what that landing is made of, and spending it
here spends it there.
The rule is split out of the :lang(zh) group rather than layered over it, since
:is() takes the specificity of its strongest arm and the group has a
three-class one. .side .lbl is named inside the new :is() for the same
arithmetic — the rail's own rule for it is written later in the file.
Six titles measured: 13px w700 --muted, no tracking, no transform. Clean at
1600/820 in both themes in Chinese, and in English, where the rule does not
apply; contrast-sweep reports zero across twelve sections in both schemes.
The sweep parsed getComputedStyle's text by hand, and a computed colour comes
back in whichever notation its value was authored in. An oklch colour arrives
as `oklch(0.85 0.012 255)` — lightness under 1, hue past 255 — and read as
8-bit RGB that is r=1, g=0, b=255, which is the #0100FF the report was full of.
Hue 262 gave b=262 and printed #0100106, seven digits wide.
So every settings page was being judged against colours nothing on screen has.
Ten rows of "below AA" in dark and eight in light were invented, and a real
failure among them could not have been told apart. A gate that misfires is
worse than no gate: this one was printing a verdict on eleven panels it was
not measuring.
It paints instead of parsing now, the way audit-sweep already did: fill a 1×1
canvas with the value and read the pixel back, which is the same engine that
drew the thing under test and accepts every notation there is. The fillStyle
reset before each fill is not decoration — an unparseable value leaves the
property at what it held last, and the sweep would report the previous
element's colour as this one's.
Both schemes now report zero across twelve sections. Raising --text to 88%
brings eight rows back with every colour legible, and dropping it again clears
them.
The light scheme's neutrals were laid out along hue 255. #EEF1F7 has more blue
in it than red, which is what an engineering tool looks like, and it sat under
an amber accent — the two are on opposite sides of the wheel, so the largest
surface in the window and the colour that means "you are needed here" were
quietly working against each other. They run along hue 85 now: #F4F1EA, red
over blue instead of under it.
Only the hue and the chroma move. Lightness is untouched at every one of the
52 declarations, so no contrast ratio was traded for this, and --raised and
--float keep a chroma of zero — a card stays white on a warm ground, which is
the arrangement that was drawn and chosen.
Dark keeps its blue. The two schemes' neutrals interleave through this file by
selector — :root under a media query, then [data-theme], then three contrast
tiers of each — so which side a declaration belongs to is read from its own
value instead: in light a surface is bright and ink is dark, and in dark both
are the other way round. 52 declarations moved and 52 stayed.
--shadow-float stays cool. It is a fifth of an alpha over the ground, and it
was not part of what was drawn.
contrast-sweep could not check this: on every settings page it now prints
colours like #0100106, a channel-parsing fault that reproduces with the change
stashed and predates it. audit-sweep's own contrast judgement is sound —
raising --text to 88% made it report 174 rows with the warm ground resolved
correctly in every one — and it reports none here, at 1600/820/760, with dark
unchanged.
Letterspaced uppercase is Latin lettering. Tracking separates words there; on
Han characters it pulls apart the strokes of a single glyph, and uppercase has
nothing to act on at all. The stylesheet already knew this — a :lang(zh) rule
hands seventeen small-caps selectors a normal face, no tracking and no
transform — but ten more had been written since, and one of them is the only
semantic heading the workbench has. In Chinese the right rail's block titles
were 11px at 1.54px of tracking, uppercase, with .dgrp-h's own count inheriting
both: 成本, 运行指标, 子代理, 本会话.
Which ones belong is settled by where the text comes from, never by reading it.
A class carries Han if its element's children call t(): .hl .fail says "{n}
项失败" and joins, while .hl .src and .hl .who sit in the same badge row and
stay, because what they render is an MCP server's name and a subagent's id.
.shot .glyph stays too — IMG, TXT, PDF are not words.
Two things that judgement cannot see, so both were measured in a running
window: a label handed down through props (.seglb, .dgrp-h) shows no t() at the
element, and it is exactly .dgrp-h that was worst.
audit-sweep grows a ninth judgement rather than leaving this to a script that
runs once. hanCaps reads resolved style — Han text under tracking or uppercase
— so it holds whatever rule set it, and it rides the walk into the panels a
render-time check would never open.
The first clean run was a false one: the sweep drives the kernel build, where
the language comes from the kernel's config and adopt() overwrites the
localStorage the fixture answers to, so it had been measuring English. With
[desktop] language = "zh" and .dgrp-h pulled back out, the sweep reports the two
rows; put back, zero. Clean at 1600/820 in both themes in Chinese, and at 1600
in English, where nothing in this change applies.
Monospace says a machine produced this: a path, a command, an id, a column of
figures that must not jump, a mark that has to hold its cell. The window was
setting its own sentences in it — "仅当前项目 · 后续相关时会被检索", "Palettes
install under themes/ …", every empty state, every settings footnote — and a
column of prose in that face reads as a terminal rather than as something
written. Measured on the fixture: 54% of the transcript's characters, 37% of
the right rail's.
The judgement is structural and never looks at the words, because a rule that
sorted sentences from identifiers by reading them would be the phrase table
this repository does not build. A class is monospaced if a rule in app.css
gives it var(--mono); an element renders prose if its own children call t().
The two together found 63 classes across 155, which a screenshot could not
have — a sweep sees what one render drew, and most of these live on panels a
walk never opens.
46 rules move to var(--ui). What stays is what earns it: terminal output, tool
arguments, paths, URLs, ids, every figure, and the marks. Where a rule wanted
figures not to jump rather than a face, --msub already said so with
tabular-nums; .fold gets the same, because it repaints every second while a
turn runs.
src/styles/mono.test.ts holds it, and holds the two exception lists apart. Nine
marks and keycaps carry a reason each, plus the welcome screen, whose wordmark
is deliberately set in the terminal's own face. Thirty-six classes render prose
and data through one box; splitting those is a change per call site rather than
per rule, so they are recorded and may not grow. A third assertion fails an
entry that stopped being needed, so the lists cannot outlive their reasons.
Reverted one rule to check the gate reports it, then reverted that.
Characters set in monospace: 42% → 33% overall, 54% → 45% in the transcript,
37% → 22% in the right rail. Held by audit-sweep at 1600/820/760 in both
themes against a kernel, and by 712 unit assertions.
Two numbers that only mean anything together. The transcript's prose was
13.5px in an 820px column, which measures 103 Latin characters or 56 Han to
the line — half again past the 45–75 / 30–40 where the eye still finds the
start of the next line without hunting for it. The column was right and the
size was small, so neither one looked wrong on its own.
--read moves to 15px, 16 in Chinese where the strokes are denser, and the
step ladder shifts with it so a reader who had already picked a size keeps
the one they picked. --doc goes to 706px, which is not taste: the narrowest
thing this column has to hold is 80 columns of IBM Plex Mono at --fs-ui,
measured at 624px, and the transcript's own indent and padding want 82px
more. The main-line terminal block now lands on exactly 80 columns.
Measured on the fixture at 1600x1000: the answer's line goes 103 → 71 Latin
characters and 56 → 39 Han, 36 at the Chinese default. Sub-pixel spill is
unchanged at seven elements, every one a 2px rounding that predates this.
Held by audit-sweep at 1600/820/760 in both themes, against a kernel and
against the fixture, and by the 709 unit assertions. Both sweeps still
report four prefs truncations and the oobe contrast findings; both are
there with the change stashed.
Twelve counts render in the rail and six of them say "0 会话". A machine row
counts open panes, so every machine says it at rest; a workspace with no saved
conversation says it under its own name, and the absence of rows beneath it had
already said so.
Plan.tsx settled this for the transcript — a block reading "0 / 0 · 尚未制定" is
a wall to skip on every glance — and the session row in this same file has
always followed it, printing nothing for a turn count of zero. The workspace row
and both remote rows did not.
An empty project has nothing to report and one thing to offer, which is the "+"
beside its name.
Five measurements, five changes, all in the stylesheet.
The transcript separated what the model said from what a tool did by colour and
leading alone — same 14.5px, same weight — so a column of them scanned as one
undifferentiated stream of events. The answer gets a size step; machine output
stays where it was. Its own breathing room widens with it, which pulls the
rhythm apart into three real steps: 22px between two machine rows, 32px across
the boundary into speech, 42px between two separate utterances. It was 22/28/34,
a ladder pointing the right way with treads too shallow to feel.
Every tool card carried a filled icon chip, which meant the fill said nothing.
It is transparent now for the neutral ones, so net/write/ask/deleg/mcp keeping
theirs turns the fill into the signal the gutter's own comment always claimed it
was. Their headlines keep the heavier weight too; the rest step down.
Settled output that succeeded is clipped to a glance. The criterion is outcome
rather than category: a call still running and a call that failed keep theirs,
because a failure's output is the only thing that step has to say. Measured on a
full turn: unrequested output 560px to 358px. The transcript itself only drops
7.6% — the bulk of it is card count, not output, and this does not change that.
The right rail read as a dump because it was set as one: 33 numbers at rest and
37 of its 62 text elements sharing two nearly identical steps, with a single
15px element as the only hierarchy. Nothing was removed and nothing shrank —
hierarchy by making five things smaller is five chances to make CJK illegible.
The context total goes to 22px and its gauge from 6px to 10px, so the eye has
somewhere to land and everything else becomes background by staying still.
The product introduces itself in Inter 200 under a 0.34em all-caps eyebrow,
which is what any product reaches for. Its own vernacular is the terminal, and
that face is already in the bundle: the wordmark is set in it, and the eyebrow
becomes a lowercase line. No new bytes, the metallic sweep intact, and no
overflow at 900px where a monospace wordmark could have found one.
Held by contrast/cards/motion/panels/overflow/press/lang, in both themes.
"Never: incident or conversation history" is a rule nobody recognises breaking.
A writer with a fix in hand narrates the fix, and the result passes every gate
there is, because the essay rule compares line counts and nothing reads
register. Meanwhile the instruction to write code that reads like the code
around it is unambiguous, and much of the tree reads as prose — so the tree
teaches the habit back faster than the rule removes it.
The rule now states its test — a reader who has never seen an earlier version
must lose nothing, and past tense about this code's own behaviour is the tell —
gives the paired example, says where the narrative does belong, and says that it
outranks the surrounding style. It also says no gate reads register and why one
will not be built: separating narrative from a statement of constraint needs a
phrase table, which is the one thing this repo does not build. That rule is held
by review, and saying so is better than implying a check exists.
repolint's paragraph no longer claims to enforce all of it.
Deleting a conversation another Reasonix process has open is refused, and the
refusal was a dead end. The pane map belongs to one process, so the sidebar
shows no pane to close, no button in that window would close one, and
session.in_use rendered as "held elsewhere — close it there first" while naming
no there. The guard had already read the lease in order to refuse and knew the
pid, the hostname and when it was taken; all of it was dropped at the boundary,
and the wording it left behind — still being written to — is not even true of
that case, since nothing is writing.
session.in_use_by carries the holder, and only when the holder is another
process: a lease this one holds does mean a write is in flight here, which is a
different cause and keeps the old code. Params already reach the window's own
wording, which is what busy's params exist for.
Verified against two hosts sharing a state root: the refusal named pid 88240,
pgrep confirmed that was the second host, and the delete succeeded once it was
gone. The rest of the flow was exercised in the same run and is sound — an
unheld session deletes, close-then-delete deletes, and the rail's own gesture
asks, deletes what it asked about, and sequences the pane close ahead of the
delete rather than racing it.
Orphan hosts are what produce the state in the first place, and nothing here
stops them being produced.
GET /todos has been publishing the canonical task list — the latest todo_write
merged with every complete_step advance — and the window never called it. The
panel derived its own instead, by parsing todo_write arguments back out of the
transcript, which is a second implementation of rebuildTodoState and drifted
from it two ways: it replayed no complete_step, so every sign-off was lost, and
it checked no receipt, so a list the kernel refused became the plan. Every
rebuild — on mount, on rebinding a pane, on a stream gap — reset the panel to
the list as the model last wrote it. A signed-off item rendered as the current
one and no later call could move it, which is what "the todo panel will not
clear" looks like from the outside.
The task list is not derivable from a transcript at all: the advances are not
todo_write calls, and the refused writes are. So fromHistory stops trying, the
window asks, and the reads that re-open a session are collected in one place
with the new one. A turn's end re-reads the list too, for the points it moves
with no event to say so — a cancel makes the kernel rebuild its own state. A
read that fails leaves the panel as it stands rather than blanking it. The live
path is unchanged except that a refused todo_write no longer sets the plan.
"A plan that ran to the end is spent" now has one definition, because two ingest
paths have to draw a finished list the same way.
todoItem is lifted to a named type and declared in wireparity: the desktop keeps
a hand-written copy of it, the two are compiled by different toolchains and meet
only over JSON. The handler moves to its own file, which takes serve.go 21 lines
further under its ceiling; the reclaimed budget is recorded.
withTodoIdentityTail decided it owed nothing whenever every step id was still
readable in the view. Ids are readable forever: they sit in the model's own
todo_write arguments. The statuses under them do not stay put — complete_step
advances the canonical list host-side without the model writing anything — so a
list the host had moved on went on reading as the model last wrote it, and the
next sign-off named an item the host no longer called current. complete_step
refuses that, which costs a whole round at full context to learn nothing about
the task.
Canonical state changing and no projection rendering the change is the case
"Detection is semantic" exists to forbid. Freshness is now decided against the
rendered line — citation and status together — through a single todoStateLine
shared by the note and the check, so what the projection says and what the
check looks for cannot diverge.
In 23 recorded sessions complete_step failed 16 times in 43 calls; five of those
are this, the largest single group.
Every session stays mounted and the off-screen ones are hidden with CSS, so a
tab switch throws away no stream, no transcript and no scroll position. That
trade is affordable only while the hidden panes are quiet, and one of them was
not: the /status poll was gated on the turn running, never on the pane being
visible. Each background pane with a live turn therefore cost a round trip and
a full re-render of its subtree four times a second, plus two JSON.stringify
passes over the status object, and the bill scaled with the number of open
conversations rather than with the one being watched.
The poll and the elapsed clock now need the pane to be visible; when the run
began is still recorded either way, so a pane brought forward mid-turn shows the
time that passed instead of restarting at zero, and the tick fires on the way in
so it is current rather than up to 250ms stale. The wait spinner in the
transcript gets the same treatment: ten redraws a second are wasted on a pane
nobody has open, and it reads from the stamp, so the tick on the way back in
covers the whole wait.
Measured on a real pane driven by a mock port with fake timers: four /status
reads per second per hidden running pane before, zero after. That is what the
new test asserts, and removing the visibility gate turns it red.
A turn that stopped with items still open was read as work left undone, and
continueUntilReady pushed it on for up to eight rounds with a message the host
wrote. That is right for a step the model can advance alone. It is wrong for a
step whose next input is the user's — presenting one item of a list and waiting
for a reply was indistinguishable from quitting early, so a list worked one item
per turn could not be worked at all: the turn stopped, the host restarted it,
and the whole list came out in one round.
Nothing about the shape of a turn tells those two apart, so a call does.
await_user joins ask and conclude_blocked as the third way a turn ends on the
model's terms: it names the waiting item and what the user must supply, the list
is kept as it stands, readiness waives the open list, and the continuation loop
does not run. Under a Goal the gate is a pause cause, so the FSM stops rather
than driving through it and the evaluator does not spend a call judging a turn
that already said why it stopped; the next user turn lifts that pause and no
other.
The guards are structural: there must be an open list, the step_id must name an
item on it that is not completed, and there must be an interactive user — a
headless run refuses and is pointed at conclude_blocked. The continuation prompt
names the call too, so the round being told to keep going can take it.
Prefix cost is 292 tool-schema tokens for the new schema, and every existing
session cold-starts its prompt cache once.
Left undone and worth its own change: conclude_blocked is registered but absent
from HostControlToolNames, so the host names it as a way out of an unfinished
turn while the schema does not carry it and only use_capability reaches it.
Adding it grows the prefix enough to move where the compaction fixture in
TestEffectFoldDropsSupersededStandingState lands its first fold, which that
test reads as a fold that dropped nothing.
It named `/bin/echo`, with a comment saying why the command has to exist:
resolving the executable happens before the context is consulted, so a
missing one fails earlier and for another reason. On Windows that is
exactly what happened — `exec: "D:\bin\echo": executable file not found`
arrived in place of the cause, and the guard has been red on that leg
since it was written.
Both judgements fired on what the interface does on purpose: at the
narrowest window every settings nav button reads as past the right edge,
though the rail scrolls to each one, and the twist beside a tree row is
aria-hidden because the row is the control. Seventy-three findings, none of
them a defect — and a gate that cries wolf is read as noise, which is where
the two real ones beside them were going.
Translation tables are data — they grow one entry per label, so the line
ceiling fires on every new string without pointing at anything worth
splitting. The exemption saying so still named the tree the retired desktop
kept its locales in, and has therefore exempted nothing since that tree was
deleted. The live catalogues sat at the ceiling instead.
Twenty-six strings reached the screen written into JSX rather than through
t(): the sign-in device code, what an install or an MCP connection says when
it finishes, the package rows, the tab menu, the Ask and Rewind cards, and
six in the version panel — the last read by nobody, because the sweep that
walks the running app only sees what a run renders, and its catalogue
answered 404.
The catalogue test beside this one checks the keys a t() call names, so a
literal that names no key was invisible to it. `jsx.test.ts` reads every
component's syntax instead: Chinese in JSX text, in an attribute, or in an
expression is a finding unless the nearest enclosing call translates it,
and a file renaming t on import is followed. The one helper that took a
Chinese label and translated it inside now takes a translated one, so its
seven keys are visible to the catalogue check too.
The one shipped theme pack named in Chinese takes the English name its eight
siblings have.
`.seg` does not shrink, so the row shrank the label instead: at the window
sizes this app supports down to, "Interface" was drawn under the first
segment rather than beside it. The group wraps now, and the label stays
whole.
The guard refuses a POST that is not application/json, and refuses it the
way a missing route answers — so a call with no content-type could never
land, and being silent about it, never said so. Acknowledging a launch's
health and replaying a waiting prompt were both making that call.
The kernel states why a total is incomplete; the panel answered "price not
reported" for all four. Three of them are not about pricing at all, so a
user who had simply not sent anything yet was sent to go and fix a price
table that was fine.
The ignore state at a directory was read twice: the file tools carried their
own walk of it, and completion carried none at all, so typing `@` in a
project root offered `node_modules` and `dist` above the files anyone meant.
A name list is one ecosystem's answer; `.gitignore` is where this repository
stated its own, so that is what both now read, through one package.
Inside a directory the rules already exclude, they exclude everything —
which is an empty list, not an answer. A user who typed their way in there
has named it, the way grep searches a path named explicitly, so the rules
step aside rather than offering nothing.
The flash model is `deepseek-flash` everywhere: the id it is served under
lost the generation it never needed. An installed entry is renamed rather
than left behind, and the rename moves every key that named the old id with
it — a price or an effort list stranded under the old key stops answering
for the model that replaced it, which reads downstream as a setting the
user edited reverting to its default.
A preset is now what settles which of its models read images. Reading a
name misses every Kimi, Qwen, MiniMax and Claude model these presets
declare, and `deepseek-flash` spells nothing at all; the spelling stays the
last resort for an address nothing has declared, and stays a correctable
suggestion rather than a claim. The connection that existed only to reach an
image-taking model folds into the entry beside it once that entry reaches
the same model on the same key — unless its user configured it, because
folding would answer their edit by deleting where it lived.
A preset changing shape reaches installs holding the old one through one
declared shape per generation and one upgrade that brings a matching entry
forward. The guard is the whole shape, because an entry holding what we
shipped is ours to move and anything else its user curated. That retires
nine hand-written migrations that each recognised one vendor by hand.
Official list prices live once, in `officialRates`, oldest generation first.
A price change appends rather than edits: the superseded rate is what lets
an installed config be recognised as holding one of ours and brought
forward, instead of quoting a retired rate for the rest of its life. `config`
reads them through `billing.CurrentRate` and `SupersededRates` and restates
none of them — a table written twice is one that drifts, which is how a
vendor's USD rate ended up published on one side only.
`make pricecheck` reads each vendor's own page and compares it against that
table, and says how long ago a person last confirmed each one. It reaches
the network, so it stays out of lint and out of CI. A page it cannot read
exits non-zero: unread is its own verdict and never counts as agreement.
A server started on its first tool call runs on a context the host owns.
When that context ends before the subprocess is up, exec.CommandContext
returns "context canceled" from Start() — in well under a millisecond,
with no stderr, because the process never ran. What reaches the reader
is `MCP startup launch failed after 0.7ms: context canceled`, which
reads like the server failed to launch. The server is fine.
The host is the only one that knows which of two things happened, and
they are not the same thing to go fix:
- the MCP host shut down — the session ended or the runtime was rebuilt
- that server was removed, disabled, or reconnected
Both are now carried as causes through context.WithCancelCause, and the
launch failure reports the cause rather than the mechanism. Nothing
about when or whether a context is cancelled changes; only what the
failure is able to say about it.
Found while testing #9931 against a real server. That report's leading
theory — that the stdio client rejects the framing the official SDKs
speak — does not hold up: a server built on modelcontextprotocol/go-sdk
writes plain newline-delimited JSON (verified byte-for-byte on the
wire), the spec requires exactly that ("Messages are delimited by
newlines"), and `reasonix mcp add` connects to it on the first try. The
0-4ms cancellation is a separate path, and this is what it was hiding.
internal/plugin/** is a declared sensitive path. The change is
attribution only: no boundary, no grant, no launch decision, and no
condition under which a context is cancelled is touched; the causes are
host-authored sentinels, never anything a server supplies.
go test ./internal/plugin/ ./internal/boot/ ./internal/control/ — pass
make lint / make check / make coverage-gate — clean
All three were green at a1adff18c, where these assertions were added,
and red after the changes that followed. One was the product, two were
the guard reading a shape that had moved.
The product one — the approval card's narrow tier. Its two secondary
buttons sit side by side under the primary, and "never ask again" is a
step smaller than "refuse". align-items:center then centres two boxes
of different heights against each other, so their tops differ by a
pixel: a row that looks unaligned, and counts as two. They stretch to
the same height now.
The guards — a menu and a card that each moved:
- The model menu is rendered through a portal, so it hangs off body
rather than inside the picker. `.picker:first-of-type .modemenu`
could only ever time out, and by the time it did, the mutual
exclusion it was checking already held. It asks the picker's own
aria-expanded instead, which no change of mount point can move.
- Quick actions stopped being a card inside a card. The container has
no border or shadow of its own any more; the hover landed on the
row — a fill, 8px of indent, the chevron stepping forward. Same
question, asked where the answer is now drawn.
And one signal removed. `.sym` carries a comment of its own: running is
grey, settled takes the category colour — the colour is the completion
signal. The pulse around it was a second telling of that, and it ran
beside the light travelling down the connector, two loops on one card.
The travelling one stays: it says something the pulse cannot, which is
that the step is advancing rather than hung — the reason it was added.
Restoring the pulse is the `.call[data-running] > .g .sym` rule and its
keyframes, if that reads as too quiet.
All 22 guards green, and all 22 still go red under selfcheck.
.flow lets the rail through by narrowing itself, and then centres what
is left with margin-inline:auto — so one rail's width of room is split
between two sides and only half of it lands where the rail is. Measured
at 1512: content ran to 1077 with the rail starting at 1070. The rail is
pointer-events:auto along its whole height, so those pixels answer a
click with a jump instead of a selection.
Two rails' width, symmetrically, which is what centring costs.
perf/verify.mjs has been asking this all along.
And the guard over the guards was reading a hand-kept list. selfcheck
injects an always-false assertion into each guard to see whether it
really goes red, and its GUARDS array had drifted: cards, composer,
intrinsic, motion, overflow, press and surface — seven scripts, never
once asked whether they can fail. It now finds every script in the
directory that expresses failure through `fails`, so a new guard joins
by existing. All 22 go red on injection.
selfcheck has a blind spot of its own worth naming: a guard that is
always red also passes it. Running the 22 on their own turns up three
reds that predate today — cards, composer and motion — all of them
green at a1adff18c, where those assertions were written, and red after
the changes that followed them.
--ghost is the bottom of the text scale and is calibrated to clear 4.5:1
on the darkest surface it lands on. Three places then took it further
down, and each landed under AA:
- .cost's second reading multiplied it by 76% to sit a step behind the
duration. A step is a tier, not an opacity: with the base already at
the floor there is nothing below it, so the three readings move up
one instead — live to --muted, the duration to --faint, the token
count to --ghost. The same spacing, all of it legible. Dark 3.30.
- The approval card's secondary button kept --ghost after that card
was lifted onto --float, which is several steps lighter than the
--page it was measured against. Dark 4.07 — and this one was mine,
from 2d1cd8bfd.
- A pane tab's count sat on --surface for the same reason. Dark 3.92.
perf/contrast.mjs already measures all of this and reported none of it:
its scripted turn gives a tool result with no contextTokens, so .cost
renders one span and the rule on the second never has an element to
match. One field added to that injection, and the sweep finds all three
— including a light-scheme instance of the first that a hand-rolled
survey of mine had missed.
Verified against the existing tool: three findings without the fix
(dark 3.30 / light 3.33 / dark 4.07), none with it, in both schemes.
.crumb carries flex:1 and min-width:0 so it can shrink, and a comment
saying that is load-bearing. It is — for the box. The children were
left with overflow:visible, so the text went on painting past it: a
folder with a long name ran the whole width of the bar and out the
other side, under the run pill and the three buttons at the right,
which ended up drawn on top of each other.
The clipping existed. It was written under
[data-fold~="rail"][data-fold~="side"] — the tier where both side
columns are folded away — and blowing out the title bar does not wait
for a narrow window. Moved to hold at every width; the narrow tier no
longer restates it. The session title goes with it: it was in the
flex:none group beside the separator, so it could not shrink either.
perf/overflow.mjs supplies the impolite name the fixture never has and
asserts where content lands rather than how it was clipped — an
ellipsis and a scroller are both right, and a box whose scrollWidth
exceeds it may just be holding an unhovered tooltip. Verified it fails
without the fix at all four widths (the breadcrumb reaching 1336px
past the controls at 1512, 1948px at 900) and passes with it.
Eight was a machine's guess about another machine. It was enforced in
three places and explained in a fourth, and the reader met it as a
greyed-out button on a laptop that could have held thirty.
Removed rather than raised, so nothing is left half-holding it: the
refusals in Open and OpenRemote, the X-Panes-Max header the list
carried, the [desktop] max_panes setting and its clamp, the ceiling the
window read off that header, the guard on the new-session button, and
the hub.too_many_panes wording on both sides. An unknown key in an
existing config file is ignored, so a machine that set max_panes keeps
loading.
What the ceiling was defending is real and is now unbounded: every pane
is a full assembly with its own tools, extensions and MCP subprocesses.
That cost is the person's to spend.
The rail never enforced this anyway — it greyed out the button that
starts a new session while opening an existing one went straight past
it to the kernel, so the two paths already disagreed about whether the
limit existed.
Measured with every session in the fixture opened: fifteen panes, one
in front, no refusal, no console error.
A relay can answer the Responses protocol without storing any state.
It then rejects previous_response_id rather than ignoring it, and every
turn after the first fails — with a sentence naming a field the reader
has no control over. The config field to pin it has always existed;
there was no way to reach it from the window, so the only cure was
editing reasonix.toml by hand.
Declared rather than detected. The fallback already in the client
retries once when the endpoint names the field it rejected; widening
that to any 400 was tried and backed out, because a single unrelated
400 would then drop a good endpoint out of stateful mode permanently,
off the prefix cache, on the strength of one request — and a test named
for exactly that case stands against it. What an endpoint does with a
field it was sent is not evidence about what it supports.
Built on the shape the thinking-parameter pin already uses, because it
is the same class of relay incompatibility:
- The capability is a bit on the protocol catalog, which is the single
source of truth for wire differences. Only Responses can carry a
turn by reference; Chat Completions replays by construction and has
nothing to decide. It is not a comparison against the kind string.
- config states the precedence once — mode over the legacy boolean —
so a panel and a request cannot disagree about what is in force.
- The list carries canSetContinuation and continuation the way it
carries canSetThinking and sendsThinking, so the control appears
only where the choice exists.
- An unknown mode is refused, never folded into auto: that would
record "decide for me" for a request that said the opposite.
Verified end to end against the running panel: the control is absent on
the OpenAI protocol, appears with three tiers on Responses, and the
pin reaches the config file and comes back on the list.
Reported in #9815.
The guard that finds Chinese rendered without t() reads JSX text as
">…<". A fat arrow was already excluded from the opening side, for the
reason the comment gives: a check that cries on valid source is one
people learn to edit around.
The closing side has the same shape. `Record<string, string> = { … }`
ends a type argument with ">", and the text this pattern then reads
runs to the next "<" anywhere in the file — so a table of wording whose
call sites each pass it through t() reports as unrouted. That is the
one construct such a table is most likely to be written as.
A ">" followed by "=" is closing a generic, not opening a tag. Verified
the guard still fails on a literal rendered without t().
Two spellings say this row is the drag handle, and they disagree about
inheritance. --wails-draggable is a custom property, so under Wails
every span on the bar inherited drag and the opt-out on the controls
was the whole story. -webkit-app-region is a regular property and
inherits nothing: measured on the running page, .chrome computed drag
and all nine of its descendants computed none. The bar was draggable
only where nothing was painted — the breadcrumb alone spans 1060px of
it, the right-hand group another 328 — which leaves the gaps beside the
wordmark, and those are what a reader finds by hunting.
`inherit` is not the fix: the parser accepts it and it still computes
to none. The row names its descendants instead, excluding only what a
control draws inside itself — that stays out of the union and is
covered by the control's own no-drag rectangle, so the list of controls
is still written once.
perf/press.mjs asks it as a hole rather than as a list: nothing on the
bar may be neither draggable nor part of a control. Verified it fails
without the rule (six holes, the breadcrumb among them) and passes with
it. Chromium computed values are what was measured; a real window drag
is not something synthetic events can drive.
Reported in #10156.
Checking a provider against a kernel that does not grant the provider
routes answered 403 provider.editing_disabled, and the panel drew every
failed check the same way: 「无法连接」 over the kernel's own sentence.
The two then contradicted each other — the heading named a transport
that was never reached, while the line under it said the request was
refused on purpose. A reader who connects to a remote host sees it on
every check there, because that grant is the desktop shell's alone: a
source writes an API key into the credential store of the machine
running the kernel, so a server reachable over a network must not take
one from a client.
Read off the code, never the status: 403 is also what a gateway in
front of the kernel answers, and that is a different thing to go fix.
A refusal now says what it is and where the source is configured
instead.
The code is spelled twice — the exported constant the branch reads, and
the literal key the kernel's parity guard reads this file as text to
find. Neither language notices when one of them moves, so a test holds
them together.
Reported in #9772.
The task view lays the steps out at full width while the rail is still
up, so one list stood twice on one screen in two hands: numbered, with
a measured cursor and a strike, in the rail; dotted, with a 进行中
label, in the view. Neither is wrong for the width it was drawn for —
316px and 880px do not want the same density — so they are not merged.
They are stopped from being on screen at the same time.
Presence stays the panel's own contract, which is where the rail's
order table says it belongs: Plan takes shownElsewhere and returns
null, the same way it already returns null with no steps. The order
table is untouched, and every other panel keeps its place.
Measured across a tab switch: conversation has one in the rail and
none in the view, task has none in the rail and one in the view, and
coming back restores the first. The room the rail gets back goes to
the panels under it — tokens per round and the prefix cache reach the
first screen on the task tab.
A project row drew its × at full strength beside its +, while a session
row drew both of its own at .45. The three baselines came from the move
to the row layout: the old card hid its × until .ws:hover, the row
selector no longer matched, and opacity:1 was what put it back on
screen — not a decision about weight, and never commented as one.
Not hidden until hover: these reserve their width, so hiding them buys
nothing back and leaves a control that reads as missing. perf/press.mjs
holds exactly that — with the buttons at 0 it reports five ghosts and
fails. Quiet is the setting that is available here, and the rows below
already use it.
The × now sits a tier under the + it stands next to, in both kinds of
row, and comes up to full on hover or focus.
The rule that raises an unanswered approval out of the flow keyed on
data-k="ask". Authorisation was split off to data-k="write" — a grant
is a write permission — and this was not moved with it, so the common
case, the one that is about to touch your working tree, has been
drawn flat ever since. The criterion is whether a grant is still
unanswered, which .apv:not([data-sealed]) already states; the category
says which kind it is, never whether it blocks.
Lifting also needs a layer, not just an edge. Measured on a blocked
frame, the elements carrying a shadow were the chrome, the search
field, the selected project row, the selected session row and the
composer — five, and the card holding everything up was not among
them. A 9% wash over a transparent fill tints the page it sits on; it
does not leave it. The fill is --float now, with the shadow that goes
with it, and the amber edge stays as the categorical mark.
Verified in both schemes: before an answer the card is the only lifted
element in the transcript, after one there is none.
.mfind stood for two different structures: a bare <input> inside the
popup menu, and the <div> wrapping a field and its count in the model
lists. Both stylesheets that carry the name were unscoped, so the two
reached each other from both directions — the menu's sticky rule stuck
the settings container to the page while its rows scrolled underneath,
and the container's margin-bottom overrode the margin the menu's own
rule declared.
The menu keeps .mfind; the wrapper becomes .msearch. That leaves
`.mlist .mfind`, which reset position/width/margin/padding/border/
background back to static, with nothing to undo — it was the local
patch written the last time this collision surfaced, applied to
ModelChoice and never to Models, which is why only one of the two
lists was ever affected.
Measured on the three call sites: the menu input keeps sticky, its
9px 11px padding and the --float fill, and regains the 5px bottom
margin its own rule asks for; both wrappers resolve to the flex row
they were always meant to be, with no fill and no border.
Reported in #9648.
Two readings off a 3x capture of a real call.
"64.0s 82" — two quantities of different kinds set side by side with
nothing between them, and the second carrying no visible unit at all. It
is the tokens this step left in the context, which only a title attribute
ever said. A middle dot separates them, and it steps back one notch:
how long the step took is what you are waiting on, how much context it
holds is what you ask afterwards.
"Bash git-bash curl A/B · 200 次并发" — the name and the tag answer one
question (which tool, in which shell) and the arguments answer another
(what this call did), but all three sat 8px apart and read as three
parallel items. The pair closes to 5px, the arguments open to 11px.
Both are three pixels of change. They are here because the line repeats
on every card on the screen, which is the only reason three pixels matter.
contradicting its colours
Measured on the running rail, and the first two readings were mine being
wrong: I took the weight off the selected session row, which is bolder by
design, and read a hierarchy that was not there. The real numbers say
something else.
Weight and colour pointed opposite ways. A machine name was the heaviest
step in the column (700) in the palette's quietest ink (--muted); a
project name was a step lighter (600) in the brightest (--text). One axis
said machines outrank projects, the other said the reverse, and a glance
resolved neither. What a person looks for here is a project or a session,
never a machine — so the weight comes down rather than the colour going
up. A machine row still reads as its own thing: no indent, a status pip,
and a count of projects rather than of sessions.
Grouping. Projects sat 0px apart and machines 8px — eight pixels in a
264px column reads as "these two are slightly further apart", not as a
boundary, which is the same failure the metrics rail had one commit ago.
Projects get 7px of their own, machines 20px, and the seam between this
machine and the first remote takes that same 20px because it is the same
kind of seam.
Two things the rail was not doing.
Grouping. Nine blocks sat 12px apart with a hairline between them, and
9px apart inside — three pixels and a line between "these two answer
different questions" and "these two answer the same one". Read down the
column it was one evenly spaced list, which is the reading the group
headings were added to fix. Space groups better than a line does: a line
only says there is a boundary, while spacing also says which side is
nearer. 22px between, 9px within, no rule at all.
The leading figure. A comment has claimed since the postures landed that
"the one in the leading block is a step larger than the rest" — and no
rule ever implemented it. .mtop .amt took the display step unconditionally
while the working posture had already moved cost to fifth place, so the
largest number in the column was the one least worth watching: ¥0.00, four
blocks below the context gauge it dwarfed. The step now belongs to the
first block rather than to the cost block.
Measured on the running rail at 1512×945, both before and after.
Each was measured against the app, not against a mock-up. Two of them were
rewritten after the screenshots came back, because a mock-up of a card and
a card are not the same object.
- A reading column. The transcript had no width ceiling, so at 1920 a line
ran 159 characters — twice what is readable. Two earlier attempts were
refused for the same reason ("the right edge is left hanging"), and both
had only narrowed half: one the outer column while the body kept 68ch, one the
body while the column ran full. Taking both together was never tried,
and it is the only version where nothing hangs. 820px is what the 1512
window already lays out — the default window was always right.
- The host gets a voice. data-k reads categoryOf(tool), so compaction,
guardian and notice — the three things the host does itself — carried no
identity at all and sat in the same grey as an unclassified tool. They
take a dashed rail: same line, same palette, different material. Colour
is spoken for by category, length by duration, motion by state; material
was the one channel still free, and it is colour-blind safe.
- Duration becomes spacing. It was meant to be the length of the gutter
line, and on the real thing that does nothing: .line is flex:1 and real
cards run past two hundred pixels, so min-height never applies. Spacing
does not compete with content height.
- A pulse travels the running line, replacing a static gradient that left
a 64-second call looking identical to a hung one.
- Authorisation stops wearing consultation's clothes. Both were data-k
"ask" with the same "?": one asks your opinion, the other asks to write
your files. It takes the write category and a key glyph, and the three
buttons are ordered by consequence — "never ask again" is a permanent
grant and no longer the same size as "just this once".
- The guardian's tag printed guardian_assessment. Every other card spends
that slot on something a person reads.
- Diff line numbers were array indices. No @@ parsing, so a change at line
205 was labelled 1, 2, 3 — and the hunk header itself rendered as code.
Blank lines were dropped, and long lines were clipped with no way to
scroll. Deleted lines now show no number at all: they do not exist in
the file the column is offering to take you to.
Centring came out of the same session: with no ceiling, "flush right" and
"full width" were one thing; once the column narrowed they separated, and
the transcript sat 414px away from its own composer. Both now centre, and
their edges agree at 1920, 1512 and 1180.
One proposal was withdrawn after reading the code: a notice's severity is
already a left colour bar, as it is on .guard and .find. Changing its glyph
would have introduced a second language for the same fact.
Sixteen rows declared role="treeitem", which tells a screen reader the
arrows walk them, and not one of them could be reached by any key. The
tree had no roving tabindex, no arrow handling, and no comment saying the
omission was deliberate. What Tab did reach were the small buttons inside
the rows — so a keyboard could delete a session it had no way to open,
and a screen reader was promised a tree that answered nothing.
Roving tabindex rather than a tabbable row each: a tree is one stop on the
Tab path and the arrows move within it, which is the whole point of the
pattern — forty rows in the tab order is the same as none. The entry point
starts on the open session and follows the walk, so coming back with Tab
lands where the arrows left off.
Up/Down walk what is showing, Right opens a closed branch and steps into
an open one, Left closes an open branch and otherwise goes out to the
branch the row hangs under, Home/End reach the ends, Enter and Space do
what a click does. Order is read from the DOM — document order already is
visual order and a collapsed branch is already absent — so nothing has to
be told when the tree changes shape. aria-level goes on all seven rows
because Left needs it and a screen reader was owed it anyway.
The handler rides JSX rather than addEventListener: a registration made
through the platform call has nowhere to write which action it belongs to,
and an input outside the census is one nothing checks. tree.navigate is a
view action — the arrows only move focus, and what Enter reaches is the
row's own action.
Verified against the running app: 1 tab stop across 16 rows before and
after walking, Enter opening a second pane, Left collapsing 16 rows to 11
and Right restoring them, and a rename field keeping its own arrow keys.
Two rules computed the same left edge by different routes — the flow pads
20px inside a margin, the composer takes the margin alone — so only the
right edges disagreed, by exactly that padding. A card's right edge sat
20px inside the box below it, which is small enough to read as "not quite
square" and never as a number. The padding gets a name and both sides
reference it, so the two cannot drift again.
Also: the sub-agent block led with a row labelled "子代理数量" under a
heading already reading "子代理" — in English, "Sub-agents" under
"Subagents". The heading supplies the noun; the row keeps the count.
The title, the run pill and the approval mode are the front pane's, and
they were written in one place: the report handler, when the reporting
pane happened to be the active one. Nothing read them on a switch. A pane
reports when something about it changes, so switching to a pane that is
sitting still left the chrome naming the conversation that had last
spoken — verified by clicking between two panes three times, with the
title never moving off the second one.
This is the failure mode the projection notes name: freshness that rests
on every writer remembering to announce itself is one quiet writer away
from stale. Each pane's last report is kept in a ref — a ref rather than
state, so a background pane's usage round does not re-render the window —
and the switch reads from it instead of waiting to be told.
The fixture's fold also still carried the pre-boundary shape, so the one
place a reader could see a compaction card was showing the wording that
was replaced two commits ago.
The fold point was drawn as a notch and read as something to drag, which
is the control a reader already expected — and it is the right one. What
it must not become is a percentage slider. This bound is an absolute input
size and that is its entire point: the same 16% is 160k against one window
and 20k against another, so a track measured in shares would rewrite the
setting every time the model changed. The track spans the window because
the bar does; the number it carries stays tokens.
The three buttons and the number field are gone. The two ends are not
extremes but the other two answers — past capacity the economic bound has
nothing left to tighten, and the default's own position is a detent,
because "the default" and "a number equal to today's default" stop meaning
the same thing the moment the default moves.
Four things only a real drag showed:
- Given its own min..max the track cut off everything below the floor and
above capacity, so the handle sat at one fraction of the track while the
mark sat at another. Dragging to the visual middle moved the mark
somewhere else entirely. Both are placed by the window now.
- A one-step detent is about two pixels of track. Landing beside the
default instead of on it silently converts "use the default" into a
number that stops following it, so the detent is two steps wide.
- The reading and the thumb need different clamps: a controlled range
clamped mid-drag pulls its thumb back under the pointer, while a reading
left unclamped past capacity names a size nothing folds at.
- Rounding the step in powers of two puts 160k between two stops, so the
handle could never sit on the number the panel was naming. Thousands.
The state is a hook rather than a component: owning the track would
rebuild it on every open, and a bar that remounts animates its width from
zero, which reads as the session's usage jumping. Three scoped
declarations, one per event — a generic data-action on an element with
several handlers names none of them. UNDECLARED_CEILING drops to 220.
The footnote said why a 1M window folds at 160k and then left the reader
with nowhere to go: the bound itself lived in a settings sheet two clicks
away, under a disclosure called Advanced. A setting that can only be found
by people who already know it exists is one most people never find, and
the reader this footnote was written for is exactly the one who did not
know.
The rail now offers the same three choices the sheet does — default,
custom, capacity-only — because they are one intent and not two. Only the
labels differ, since one of them is drawn in a 245px column. FoldMode and
foldModeOf move to src/ui/foldbound.ts so the rule that reads a stored
number as an intent has one home rather than two.
- The fold point becomes the way in, with the same dashed affordance the
window figure already carries. Opening one editor closes the other:
side by side they are two boxes of numbers with nothing saying which
ceiling each answers for.
- The footnote stands down while the editor is open. The chosen mode says
what it does there, and two greyed paragraphs stacked in one narrow
column read as one nobody finishes.
- A write rebuilds the runtime, so the gauge handed back is the kernel's
own — port.context() after the save, never this reply's arithmetic.
- The fixture answered 128k to the settings sheet while the gauge had been
told 1M, and saving moved neither. Both faces now read one pair from one
bounds(), so the editor is developed against a session that moves.
- The rail's segmented control is a step smaller than the sheet's: at the
sheet's size it reads as a part from another product dropped into a
20px-line column.
A session folds at whichever comes first: compact_ratio of the window, or
context_soft_limit_tokens of visible input, default 160000. Against a 1M
window the second always wins, so compact_ratio = 0.8 never reaches the
800k it asks for — the session folds at 160k, 16% of what the same panel
says the model holds, with nothing on screen naming the other bound.
The host had already decided it. compactBoundary() answers capacity or
economic and has ridden ContextMaintenance since 280dfd8c9; nothing
rendered it. The card said a threshold was reached, the rail showed a bare
160k beside 1.0M, and the reader supplied the only explanation left.
- event.Compaction carries the boundary and its size, stamped by one
compactionFrame rather than four copies of the same two lookups.
- ContextBreakdown carries the boundary and the window's own line, so the
rail states which bound holds instead of comparing the pair itself —
that comparison is the kernel's rule and belongs in one place.
- The rail notches the fold point onto the capacity bar: 160k against 1M
is a division nobody performs while reading a panel. The footnote is
written only where the bound is not self-evident, since a fold at the
window's own share explains itself.
- The notch is drawn in the text colour, not accent. Accent sits one step
from cat-4 and the legend for cat-4 is directly beneath that line, so a
position mark would have read as a sixth category.
- Three statements that compact_ratio is the sole automatic trigger are
corrected. It has not been one since 280dfd8c9, and one of them is
written into every config.toml the wizard renders.
- wire-parity now holds both mirrors that carry this: a boundary the page
cannot read is a fold it explains by guessing.
Found while answering "why can't I open this conversation — it says something
else has it". Something else did: four Reasonix kernels were alive on this
machine, one of them for ten days after the application that spawned it was
gone, and between them they held five session leases. Clicking any of those
conversations is refused, correctly, by a lease whose holder nobody can see or
close.
The host is supposed to end when its shell does, and it reads that off the
stream the shell holds: `io.Copy(io.Discard, os.Stdin)` returns at EOF, which
is the parent going away, "exit or crash alike". What enabled it asked for
`os.ModeNamedPipe`, and the shell is Node: libuv gives a child's stdio a
socketpair, not a FIFO, on every platform Studio ships. So `parentLease`
answered nil at every real launch and the watchdog was never armed — a host
whose window was force-quit, or whose Electron died, simply kept serving, kept
its data home, and kept its leases.
A socket is a lease now. A terminal and a regular file still are not: one never
ends, and the other ends at once and would take the host down at startup.
The existing test proved the mechanism with `os.Pipe()` — a FIFO, which no
launch uses — so it passed while production had no watchdog at all. The new one
uses the stream the shell actually hands over, and checks both halves: that it
counts as a lease, and that reading it ends when the peer closes. Against the
old predicate it fails on the first.
Swept both languages for text that ellipsizes, and set the fixture's own titles
aside — a session's name is data and is supposed to clip. What was left is a
label that does not fit the slot it was given, and in English there were three.
The settings list truncated its section names. The rule for that row is written
above it — "the value gives up its width first... a row that truncates the
section's own name to keep four characters of its value has traded the label for
the footnote" — and it was not in force: the name was `flex: 1`, so its basis is
zero, the row never counted as overflowing, and the name simply took what the
value left it. "Tools and permissions" wants 138px and was handed 99.6. The name
now lays its content as its basis, so the row does overflow, and the value takes
99 shrink against the name's 1.
That alone leaves the value at 13px, which is not a value, so the column widens
from 224 to 240: enough for the longest name and its value together. The 224 was
measured against Chinese names, which are half the length.
The account footnote wrapped to two lines instead of eating its own last words
("Only the networked features n…"), and the rail's search placeholder lost the
last letter of "projects" — 180.3px of text in a 179.8px field — so the English
reads "or projects" now, which fits with room to spare.
The workspace tree's disclosure is a stroked polyline on the 16-unit grid. The
transcript's fold ("思考 11 字") and the settings sheet's advanced section were
solid triangles out of the type — ▸ and ▾ — so the same idea, "there is more
under this", had two shapes depending on which part of the window drew it.
Both take the tree's polyline now, as a mask so the pseudo-element can still be
a pseudo-element, with the fill coming from currentColor exactly as the type did.
The token holds the one path. Checked in WebKit as well as Chromium: the desktop
shell is the former, and a mask that quietly does nothing there would leave the
mark invisible rather than merely different.
The composer's policy summary and the cache panel both marked their one
dangerous state with the character `⚠`. Every other mark in that row — the
attach plus, the plan and execute glyphs, the model diamond, the send arrow — is
a stroked path on the same 16-unit grid, so the one that matters most was the
one shape in the row a font chose for us. The code point is also in the emoji
set, which leaves its presentation to the platform rather than to this design.
It is a path now, the same grid and stroke as its neighbours, taking the colour
of whatever says it — err beside 全部放行, warn-ink beside 前缀变了.
The two tests that pinned this pinned the character. What they are for is that
the state is marked at all — "the quiet may not swallow a fully-permitted
session" — so they ask for the mark rather than for the glyph.
Correcting the earlier note on the rail's own marks: it said `✎` and `×` were in
none of the shipped faces. That part is wrong — both faces carry both. What held
is the rest of it, and it is what that commit's message says now: two characters
out of two different stacks at 11px and 15px, beside a 13px drawn plus, with the
destructive one drawn largest.
The one block in the transcript that is the reader's own words was drawn at the
column's full width whatever it held. "帮我看下这个文件" and a twenty-line brief
got the same white band, and a conversation of short questions read as a stack
of empty slabs with a sentence in the corner of each.
It is as wide as its longest line now, and still stops at the column. Nothing
else about it moves: the ground, the corner, the top highlight and the gutter
mark are the ones it had.
The five parts of the context window were drawn in deleg, net, accent, ok and
faint — the palette's status colours, each of which already means one thing, and
none of which means "the system prompt". Two costs. The status vocabulary lost
five of its slots to a chart. And the chart had no palette: run the six checks a
categorical set has to pass and it fails four of them.
The one that matters is the fourth. 工具输出 (faint, grey) against 模型回复 (ok,
green) measures ΔE 12.0 to normal vision, under the floor of 15 — not a
colour-vision case, a plain "these two read as the same colour" — and they are
the second and fourth largest slices on the bar. Alongside: accent sits outside
the categorical lightness band at L 0.78, deleg and faint are under the chroma
floor (they read as grey rather than as a hue), and accent measures 1.79:1
against the panel it is painted on.
The five slots are their own tokens now, stepped for each scheme and validated
as a set in both: lightness band, chroma floor, adjacent CVD separation (worst
9.1 light / 8.4 dark against a target of 8), normal-vision separation (19.6 /
19.3 against a floor of 15), contrast. Four light steps land under 3:1, which
the relief rule allows because the legend spells out every row — identity is
never colour alone here.
The gap between segments goes to 2px. At 1px the ground between two slices is
not visible on a dark surface, and that gap is the mark that says they are two.
The control shelf is pulled back out to the card's edge with negative margins,
and the card's padding is written in a different rule, so the two drifted: the
margins are 14 and 10 against a padding of 15 and 11. A one-pixel strip of the
card's own gradient shows down the left, down the right and along the bottom of
the floor, which is not a seam anyone drew.
The padding is two named lengths now and the margins are computed from them, so
"the floor ends where the card ends" is one statement rather than two numbers
that agree by memory. Measured after: 0, 0, 0.
The floor's corners take the card's radius rather than a step of their own —
9 inside 12 leaves the inner arc bulging past the outer one — and inset by the
border, so the two curves are concentric.
Two things in the column that is actually read, both of them a hue or a rule
doing work that type already does.
A markdown table was a grid: every cell boxed on four sides, the header row
filled and set in the monospaced face. Four columns of that read as a form
someone has to fill in. The vertical rules go — the horizontal ones already say
which words are on one line — the header keeps a heavier rule under it and takes
the interface face, because a column heading is a phrase and not a value, and
the first column loses its left padding so the table's left edge lands on the
prose's.
Inline code in an answer was --deleg. That hue says one thing in this palette:
this work was handed to a sub-agent. A file name is not that. The face is what
marks a machine word — it is doing that already, in the middle of Han text with
no argument — so the colour goes back to the prose's. The settings pages have
been rendering their inline code in a neutral all along; this was the outlier,
and the same teal was on two other prose surfaces.
Four cards of one weight, and the page's actual answer — what is this doing, how
far along — was the first of them. Two of the four are usually empty states, one
of them is a card whose content is a list of places to go, and its first row
went to the same place as the link in the card beside it.
The state and the goal are the page's head now: no box, the goal a step larger,
a hairline under it. The plan and the live trajectory are the content, and the
trajectory takes the full width it always should have had — it was sharing the
row with three buttons. What is left of those buttons is two, side by side under
a hairline, because they are the way out of this page rather than a quarter of
it. The duplicated one is gone.
Each card also carried a colour: --tk-glow, a 520px radial wash that lit up on
hover, tinted per card — net for the plan, deleg for the trajectory, accent for
the other two. A card is not a control, so nothing happens when a pointer
enters one, and none of those four hues meant anything: "当前任务" and "快速操作"
were both amber. What stays is the one that was true — something inside took
focus, so the border says so — and it says it in the focus colour.
The trajectory's own link stops being blue. --net is "the system is running on
its own", and it was the only blue text on a page that also has a blue running
pill; the rail's links have been --muted all along.
The fade at the bottom of a long tool output is the clip's own ::after, and the
button that expands it is inside the clip. That went unnoticed for as long as
the fade was an invalid declaration reading a token that never existed: nothing
was painted, so nothing was covered. Fixing the token turned the gradient on and
it landed over 展开全部 — the one control that answers the thing the gradient is
there to say.
The control sits above the veil now. The fade still runs to the clip's edge, so
the last line still dissolves, which is the whole point of it.
An empty composer was three bands of text: the placeholder, two capability
hints, and "Enter 发送 · Shift+Enter 换行" — which is the least surprising fact
about a box you type into, standing permanently in the one place a reader looks
before they start writing.
The slot is not decoration; it carries what is happening to a draft (sending,
stopping, an attachment that failed) and it is the input's accessible
description. So the slot stays and its idle sentence goes. What Enter does is
already declared to assistive technology through aria-keyshortcuts, and while a
turn runs Enter means something else — that sentence is worth writing, and it
still appears.
#A76200 was not chosen as a colour. It is the darkest amber that still carries
white text, arrived at from the other end: white on it measured 4.4977, which is
not 4.5, so there was nothing left over. A fill that has to be that dark to hold
white ink is brown, and in the light scheme it was the largest saturated area on
the screen — the primary button on the empty state, the approval card's action,
the stop button, the waiting pill.
The dark scheme has always done it the other way: the hue itself, with dark ink
on it. Both schemes do now — #E6AC45 against dark's #DDA144, with the ink
measured at 7.25:1 rather than assumed.
Three consequences, and the gate that found each of them:
- **A mark is not a fill.** At 78% lightness the hue is a face for dark ink; a
5px pip of it on the rail reaches 1.79:1 against the page. --accent-ink is
already the readable value of this hue, and in dark it *is* --accent, so the
marks move to it and that scheme does not change: pips, the tab marker, the
meters, the composition bar's own segment, the ask card's gutter mark. The
guard now holds every painted semantic hue to 3:1 against what it is painted
on — WCAG 1.4.11's threshold, scoped to the six hues the palette declares
rather than to "thin things", because a hairline is supposed to be faint and
a state is not. It named three: a 4px marker, a 6px host pip and the 28px ask
mark.
- **--err needs its own ink.** Two danger buttons were painting --accent-fg on
a filled --err, which only worked while the two hues happened to share a
lightness in each scheme. --err-fg is declared: white in light at 5.45:1,
near-black in dark at 6.0.
- Nothing else regressed: 21 guards and 661 tests, with the contrast sweep's
known list still empty.
`perf/tokens.mjs` exists because CSS is silent about an undefined custom
property: `background: var(--bg)` with no fallback makes the whole declaration
invalid, the box keeps whatever it inherited, and it reads as the design. The
guard collects every `--x:` in the stylesheets as a declaration and every
unfallbacked `var(--x)` as a use, and fails the difference.
It scans the file as text, comments included. One of those comments is the note
left when the veil behind the connection dialog was fixed: "--page, not --bg:
there has never been a --bg, so this whole declaration was invalid". That
sentence contains `--bg:`, so the guard has been reading the explanation of why
the token does not exist as the proof that it does — and the seven remaining
uses have been green ever since.
The seven: the fade over a clipped tool output, the initial in the account
button (its ink, on a filled circle), and the fields and list in the remote
connection dialogs — an input with no ground, sitting on a floating card, which
is what "the box looks like it is missing" was. They take the surface a field
sits on everywhere else, the fade takes the ground it fades into, and the
initial takes --accent-fg.
Comments are stripped before either side is collected now. Checked by running
the guard on the tree as it was: it names --bg and exits 1.
The last sweep found five lines under AA in light and fixed them by eye. That
result held for exactly one day: a colour is a token, so what a value change
reaches is every rule that reads it, not the handful the person changing it can
picture. This walks the rendered window instead — workbench with an approval, an
ask, a failed call and prose, plus the settings sheet — in both schemes.
Three things decide the number, and each one read wrong turns a failure into a
pass:
- Colours go through a canvas, which parses CSS Color 4. Reading the components
out of `oklch(0.605 0.016 255)` as if they were R, G and B makes the hue angle
the blue channel — the same defect the picker guard was just carrying.
- The ground is composited upward. A 13% tint is not a ground; what shows
through it is. Written the other way, ImageData is never premultiplied, and
dividing by alpha again reported the fail pill as 1.19 when it is 4.39.
- Large text is exempt at AA's own two steps: 24px, or 18.66px at 700.
Six lines came back, all of them one fact: **--ghost's headroom is measured
against --page**, which the tokens file says outright, and these six had it
landing on a raised face instead — a selected project row (raised), the search
field's ⌘K (raised), the signed-out avatar (overlay), the prose copy control
where the settings sheet draws it (surface), and the ask card's hint (the card's
own wash). On those grounds the weakest tier is faint, and that is what they
use now. The seventh, the failure pill, was --err reading on a tint of itself:
--err-ink exists for exactly that and the light scheme needed it.
The known list is empty, so any line that falls under AA from here is new. The
guard is in selfcheck's roster and goes red with a false assertion injected;
putting one of the six back reproduces its own finding.
The last of the selection surfaces. Every menu's current value, the completion
caret, and the model list's chosen row were all drawn with a tint of accent and
an accent bar — the same mark the rail, the icon column and the settings sheet
have already given up, and for the same reason: a hue that means "a decision is
waiting for you" cannot also mean "this is the one you picked".
These sit on --float rather than on the page, so the neutral fill is mixed from
the text colour instead of stepping to a named surface: that moves it away from
hover in whichever direction the scheme goes — darker than --float-hi in light,
lighter in dark. Measured by perf/pick.mjs: 38.6 against a hover step of 19.6
in light, 25.9 against 13.8 in dark, where the rule asks for 1.4×.
Amber in the interface is now the approval card, the ask card and its options,
the pill and the stop button that a running turn puts up, and the pip on a
conversation that is holding for an answer. Everything it marks is something
waiting on a person.
`pick.mjs` compares the current row's fill against what hover paints, and
requires the same direction — the rule exists because --accent-wash moved the
wrong way in dark and left the selected row reading as a hole. Both sides of
that comparison were being computed from strings parsed as RGB triples.
A computed colour is serialized in whatever space the author wrote: a token is
`oklch(0.31 0.02 255)`, a color-mix comes back `oklab(0.897 -0.0009 -0.0035)`,
everything else is `rgb(...)`. Reading the three numbers out of the first and
weighting them as red, green and blue makes the hue angle — 255 — the blue
channel, and 0.0722 × 255 then dominates the whole expression. The two sides of
the comparison were not in the same units, and one of them had no relation to
lightness at all. Both numbers were stable, so the check looked like it was
working: the sign it reported depended on which colour syntax the stylesheet
happened to produce, not on which fill was heavier.
Colours now go through a canvas, which parses CSS Color 4 and hands back the
bytes that reach the screen. The hover reference is read from the `.mi:hover`
rule's own declaration rather than from a token name written into the guard —
and through `getPropertyValue`, because CSSOM's typed getters return an empty
string for a `var()` and the guard was silently finding nothing.
Checked against the bug it was written for: putting --accent-wash back on the
row is now reported as -9.2 against a hover of +13.8 in dark — the wrong
direction, which is the defect in one line. The current fills are unchanged
and pass with 1.9× the hover step in both schemes.
The transcript colours a call by category, and the categories were reading off
the semantic palette: net for a network call, deleg for work sent out, accent
for a question that needs answering. Three of them say what their hue says.
Write did not. An edit_file was drawn in the same amber as the approval card
sitting four rows under it — one hue, two meanings, adjacent in the same
scroll, and the one that needs a person is the one that loses.
warn exists for exactly this. It was split out of accent on the grounds that
"琥珀是「该你了」,橙是「这一步有代价」", and a write is the second sentence.
The mark, the thread line, the timeline bar and the task board's dot all move
together, because a category is one decision rendered in four places.
The anchor label above the main model goes neutral in the same pass: an eyebrow
that appears on the page whatever its state is a label, not a state.
Same rule, the two places it had not reached. The section list filled the open
page with accent-wash, wrote its label and its value in accent-ink, turned the
icon amber on hover, and hung an accent bar off the left edge — five marks, one
of them a hue that means a decision is waiting. And every segmented control in
the product wrote its chosen option in accent-ink: the reading window, the date
range, the approval posture, all of them.
The list row now carries a fill, a ring and the bar together, because this
column lays --surface and hover already spends the one neutral step under it —
the same pairing the rail's project row uses, for the same reason. The chosen
segment keeps the raised chip and the shadow it always had and drops the hue:
the chip is the mark, and under it the postures that really are coloured (全部
放行 in err) have room to be read as colour again.
The section's own icon loses its amber tile too. It appears on every page of
the sheet, so tinting it says "you are needed" nine times over, once per page.
Measured on a 1512px window: a card's prose starts 42px from the middle
column's left edge, the composer's caret at 37. Five pixels is not read as a
misalignment — it is read as "these two things are not part of the same
thing", which is the opposite of what a conversation is. The gap between the
prompt mark and the text is what sets that inset, so that is what moved; the
box, the mark and the guide line under it keep their places.
Three more edges in the same family. The rail's search field sat 10px in while
every row in the tree sits 12, and the account row at the foot sat 13 — three
gutters in a 264px column, none of them a decision. They are 12.
The shelf under the composer laid `--page` outright: 7.9 L below the input
surface in dark, 4.2 in light. A step that size stops reading as a floor and
starts reading as a seam between two widgets, which is what the hairline over
it was already saying on its own. Half a step now, in both schemes.
`--face-sunk` was 2 L below the page in dark and 0.8 below in light — the same
token cutting twice as deep in one scheme as the other, and in dark it landed
past the page it was supposedly cut down to, so machine output was the darkest
thing in the window. Both schemes now sit just under the page, and the visible
step is the one against the card the output is set into (3.9 L).
A session row carries rename and delete; the project row above it carries add
and delete. The add is a stroked path on Sym's 16-unit grid — it was moved off a
"+" character for a reason already written down beside it: "place-items 对齐的是
行盒,一个「+」字形在行盒里靠基线坐,看上去永远偏下". The other two were still
characters, and not even from the same face: `✎` at 11px out of the monospaced
stack, `×` at 15px out of the interface stack, under a 13px stroked plus.
Three hands in one column, and the heaviest of them is the destructive one on
every row: delete is drawn 40% larger than rename, so what the eye lands on
first, down the whole list, is the one that removes things.
Both are paths now, on the same grid at the same 1.5 stroke, in the same 20px
box, and the shared `.wsdel` covers the project row and the remote list too. The
opacity rule that keeps them quiet rather than absent is untouched, and
perf/press.mjs still finds every control it did.
The interface's own scale exists because 24 sizes with 442 declarations inside
the four steps between 10 and 11.5px read as nothing quite matching rather than
as one line outweighing another. Six steps replaced them, and a guard keeps a
seventh from being typed in.
That guard exempts `calc()` and `var(--read)`, because the reading size is a
setting rather than a step — and behind the exemption the reading column kept
exactly what the scale was introduced to end. The four heading levels an answer
is written with were `--read` +1.5 / +.5 / 0 / -.5 pixels: two pixels of range
around the body, in the one column a person reads a paragraph at a time. A
model writing `##` was producing structure the screen could not show, and long
answers arrived as one flat block of prose.
The levels are a ratio ladder now — 1.38 / 1.2 / 1.06 / 1 — so they hold at
every value of the reading size, which is the reason the sizes were written
against it in the first place. At the default 14.5px that is 20 / 17.4 / 15.4 /
14.5. Nothing else about them changes: the weight, the colour and the margins
that bind a heading to the text under it are the ones they had.
The guard grows the case it was missing. It fails a ladder whose steps are
closer than 1.05 apart, and a top level that does not outweigh the body by
1.25 — checked by putting h2 back within a step of h1, which reproduces the
flat reading exactly.
The palette assigns one job per hue, and the file says so at the top: accent is
"需要你介入". Two of the largest painted areas in the window were spending it on
navigation state. The icon column filled the current page with accent-wash and
drew an accent bar beside it; the rail filled the open project's whole two-line
row with a mix of accent-wash and 9% accent, and hung a 8px glow off its bar.
Neither is a decision waiting for anyone. Both are on screen the entire session.
What that cost is visible with an approval on screen: the card that does need a
person was competing with a permanently amber row and a permanently amber chip,
in the same hue, two of them larger than it.
The session rows had already worked this out one level down — "拿它再说一遍『你
在看这条』,两个意思就分不开了" — and settled on a raised ground. Both places now
say it the same way. The ground carries in either scheme: +4.2 L over the rail
in light, +7.9 L in dark, which is the step the rail's own selected session row
has been using.
The project row also loses its bar. A fill, an inset ring and a bar are one
state said three times, and that bar was square against a 9px corner, so it cut
a notch out of the left edge of the row it was marking. Two levels are still two
levels: the indent and the weight already say which is which.
Amber left in the interface: the approval card, the pill that says a turn is
waiting, the stop button, and the pip on a session that is holding for you.
The side panel's cost figure and the connection panel's model rows are both
`.mrow`. Two components, one class — and the second definition only overrides
what it lists: display, alignment, gap, min-height. The background, the 9/12
padding and the bottom hairline stayed the picker row's, so the one number that
side of the window is built around sat inside a white slab with a rule under it.
Read cold, that is an input field nobody filled in.
The metrics row takes a name of its own, `.mtop`, and the figure sits on the
panel's own ground with the sub-label beside it. The picker keeps `.mrow` and is
untouched; the size, the tabular numerals and the second-currency step are the
ones it already had.
A name is an interface. Nothing warns when two components pick the same one —
the collision only shows up as chrome the second component never asked for.
Swept the ports for capabilities the frontend cannot reach. frontend-parity
reports 150 missing edges, but every one has some frontend driving it — there is
no capability nobody can use. The sharper question is which of serve's 163
routes no client calls, and that turned up four. One of them matters.
POST /update/health had a handler, a capability behind it, and no caller
anywhere in the tree — not the page, not the Electron main process, not the
host. Its own comment says what it is for: "the swap is performed by a process
that cannot judge it. This is the other half: the application that booted from
the replacement says it is working, and only then is the rollback material
retired."
Nothing said it. So pending-update.json and the backup bundle were never
retired, ensureNoPendingUpdate found a transaction it judged live, and every
install after the first refused with "a pending update already exists" —
permanently, per machine, starting the first time self-update worked. The
release line is on this path now: cmd/reasonix-studio-host builds the capability
and runs MaybeRunMacHandoff, so legs one and two are in place and only the
acknowledgement was missing.
Said from the page, not the shell. A window that opened and then failed to
render is exactly the case the rollback is kept for, and only the page knows it
got past that. Two seconds of probation first, the same wait the old desktop
used and for the same reason, and nothing at all while onboarding is still on
screen: an application still asking for a key is one the user cannot use, and
retiring the way back from inside it discards the rollback on a launch that has
proven nothing.
Silent on failure both ways. Nobody asked for this, there is nothing to do about
it, and the two ordinary answers are non-events — a kernel with no update
capability has no route, and a launch that booted from no update has nothing to
retire.
Two tests, and the first fails against the previous tree with "never
acknowledged; the transaction stays open". The second pins the onboarding case,
which is the one an eager implementation gets wrong.
The other three unreferenced routes are recorded but not acted on here:
GET /branches (session branch tree — no switch endpoint exists either, so the
feature is unreachable from Studio rather than half-wired), GET
/extensions/actions, and POST /auto-approve-tools.
Swept every browser-layer guard looking for regressions. Found none — what the
sweep found was four assertions that had drifted off the thing they name, three
of them red for long enough that the failure had become the expected output.
lang.mjs認词,and the words expired. It had already been rewritten once when
Metrics / Pending changes / Agents and tools disappeared; the replacements —
Prefix cache, Activity, Workspaces, Worktree changes — are gone the same way.
The tab strip has said Conversation / Task for a while, and the other three
blocks do not render under this fixture at all. Every red one was the guard,
never the interface. It now reads structure: the same label elements in both
languages, present in both, differing position by position, with no Chinese
left on the English side. Panels may be renamed or added and it keeps up.
look.mjs died on the first thing a user would actually do — typing a font name.
The two font slots live inside the "advanced" details, and a closed details is
not rendered, so the element resolved and was never visible. The eight
assertions before it passed because they click through page.evaluate and the DOM
click does not check visibility. Opens it first, and says so as its own check.
verify.mjs waited on a rewind button that could not appear. The entry point
attaches only to a row that claims a message (state/checkpoints: no claim, no
entry, because a rewind cannot be undone), the claim arrives with msgIndex on
turn_started, and this harness exists precisely so the fixture's own frames
never reach the UI — BenchPort overrides subscribe. The driver has to feed that
frame like every other. Feeding it, the button appears immediately.
That timeout had been hiding the last one: the trajectory view is no longer on
the tab strip. Timeline, event trajectory and run graph moved into the "run
details" picker, so nth(1) was clicking Task and counting rows in a page with no
table.traj — zero, every time, and never once reached.
No product defect behind any of them. Two new assertions fall out of the fixes:
the advanced block opens, and a rewind entry lands on the turn it claims.
Asked how to stop the two lists from drifting. They should not have been two
lists — and proving it turned up the half of the previous fix that was missing.
Three places knew "this tool's authorization reads its subject": the branch in
subjectRequiresHuman, the subjectScopedTools table, and a copy in the test. I
had just fixed the no-approver hole in one of them, for the write fence only.
So this was still true, and had been all along:
install_source, apply, planId "high:..."
Decide → ask (the grading works)
Approver == nil → allow=true (nobody ever sees the plan)
That is the case the grading exists for. A high-risk self-extension plan — a
resident process, a lifecycle hook, an external server — was granted silently
under YOLO, which is built with no approver at all. Older than this branch, and
worse than the fence hole I found first.
Membership in subjectSensitiveTools is now the single declaration, and it
carries all three consequences, so a tool cannot arrive with one and not the
others: Decide returns Ask whatever the fallback says, unattendedAsk refuses
rather than answering for the absent person, and a session grant must name the
subject it covers. Adding a fourth tool is one map entry.
The convergence is real rather than a layer beside the old one: widensWriteFence
had no callers left afterwards and is gone. The test that was the third copy is
now a guard over the declaration — it fails an empty table (a guard that would
pass by examining nothing) and an entry whose question is never true for any
probed subject, which would sit there looking like protection while granting
none.
Both new cases fail against the previous commit: the high-risk plan is granted
with no approver, and reverting unattendedAsk to the fence-only check reproduces
it exactly. A low-risk plan still runs unattended — the grade decides, not the
tool — and a plan-only call still reads as the preview it is, allowed before Ask
is reached.
The previous commit stopped recording bare-tool grants for the two tools whose
authorization reads their subject. It left the ones already recorded alone, on
the reasoning that they were what the user granted and rewriting them would be
deciding on their behalf.
That reasoning is wrong, and the direction it gets wrong matters. When the user
pressed "don't ask again" they were looking at one install plan, or one path.
That is what they answered about. The bare-tool rule is not their answer written
down — it is their answer widened by the code that recorded it. Keeping it is
what carries a decision they never made: every later plan, including a high-risk
one they would have been shown, is covered by a yes they gave to something else.
Migrating is not available — which subject it was given for is not in the rule,
which is the whole defect. So the check moves to the read side:
SessionGrantMatches refuses a bare grant for a subject-scoped tool, and the
question is asked once more. One extra prompt, for a decision that was never
taken.
Scope, deliberately: this is only the session-grant path, the one the code
widened by itself. A rule the user wrote in their config is their own sentence —
`allow = ["install_source"]` means what it says, goes through the policy, and
is untouched here.
Against the previous code the new test prints the stale rule it objects to and
the three subjects it still covered.
Chasing whether the fence widening this branch added could be opened wholesale
by one click. It could — and so could the self-extension grading that was
already here, the same way and for the same reason.
SessionGrantRuleForScope keeps the subject for bash (a command prefix) and
folds the file tools into one Edit grant. Everything else fell through to the
bare tool name, and a bare-tool rule matches every subject that tool will ever
have. Measured before the fix:
granting extend_write_paths /ws/internal/foo/bar.go → rule "extend_write_paths"
matches /home/user/.ssh/authorized_keys → true
matches /ws/.git/hooks/pre-commit → true
granting install_source low:sha256:abc → rule "install_source"
matches high:sha256:def → true
The second one is older than this branch and is the more serious of the two. The
whole point of grading an install plan is that a high-risk ticket returns to a
person even under a blanket allow; sessionGrantAllowsLocked is consulted before
that check, so approving one harmless low-risk plan disarmed the grade for the
rest of the session. Nobody was ever shown the high-risk plan.
A tool whose authorization reads its subject cannot be granted by name. The two
that do are named in one table, and the grant rule carries the subject for them:
"extend_write_paths=/ws/internal/foo/bar.go" covers that file and no other.
TestEverySubjectSensitiveToolIsSubjectScoped is the guard over the table —
a tool that requires a human for some subject but grants by bare name fails it,
which is how this would otherwise come back.
Both new tests fail against the previous code with the rule they objected to
printed in the message, and the guard fails if either list drifts.
Not fixed here, and worth naming: grants already persisted in a session's
authorizations keep their old bare-tool form, and those still match everything.
They are what the user granted under the old shape; rewriting them would be
deciding on their behalf what they had approved.
What a run must touch is not always knowable before it has read anything. A
write_paths declaration could only be made up front, so every such discovery was
terminal for that file: the tool refused with "outside this subagent's declared
write_paths", which told the run neither who could change that nor where to put
the need. It worked around it or gave up.
It now asks. The question goes to the user — never to the parent, because the
parent is a model and a model approving its own reach is not a fence — and it
goes through the gate every other permission goes through, the one a delegated
run already shares with its parent (task.go hands it t.gate). Nothing new
carries the request: widening is a capability, and permission is the axis
capabilities live on. It is not an ask (that axis is for what only a user can
tell us) and not a plan decision (that axis is workflow).
Widening returns to the user under every fallback posture, on the same yardstick
install_source already used: subjectRequiresHuman. "Allow every write" is a
statement about this workspace's files, never that a run may redraw the boundary
it was given. An explicit allow rule for the path still wins — that is how the
line gets moved deliberately rather than by a posture nobody re-read.
WriteGrant keeps declared and granted apart, because only the declared set can
be proven non-overlapping before anything starts; nothing added later can join a
proof against runs already going. So scheduling reads Declared() while the audit
reads Scope(), and a granted path does not surface as a write that escaped its
claim. Every write outside the declared set also reserves through the scheduler
for the length of the call: a grant says this run may be here, never that
another may not. The reservation is a try, never a wait — a caller inside a tool
call already holding paths is the shape a deadlock needs.
The security review of this change found a hole in its own first draft, and the
fix is the reason unattendedAsk exists. Ask is fail-open when no approver is
attached ("preserve autonomy"), and YOLO is built with exactly that — nil. So
the fence question would have answered itself under the one posture that most
needed a person, leaving confinement weaker than before it was a question at
all. Autonomy is a statement about a posture with nobody watching; it is not
permission to move a boundary. TestWideningIsRefusedWhenThereIsNobodyToAsk fails
against the draft and passes here.
The adversarial tests are unchanged in substance and still pass: a symlink, a
traversal and a move destination are compared after resolution, so the path put
to the user is where the write would land, and their fixture binds no gate —
which is the case they were always about. With nobody to ask, nothing widens.
"占用了我打开又如何呢" — and the answer is that opening it was never the
problem. The lease exists so two windows cannot write the same transcript back
over each other: each holds its own in-memory copy, and Hub.Close and resume
both Snapshot theirs on the way out, so the one that closes second erases
whatever the other produced meanwhile. That is a real way to lose a
conversation. It is also entirely about the write-back. Reading is not that,
and the session you most want to read is the one you can see running.
So a pane attaches instead of demanding. SessionLeaseKeeper.Attach takes the
lease when it is free and reports that it did; when a live runtime holds it,
the pane opens with no lease and no authority bound. The guard does not move —
it was already in the right place and was simply being duplicated at the door.
Turn admission goes through ensureWriteAuthorityReady, and every save through
requireWriteAuthorityForSave; neither has anything to present without a lease,
so a read-only pane cannot reach the transcript at all. Callers that are about
to write (chat, run, bot, switch-model) keep Rebind, where read-only is not an
answer.
RuntimeView carries the state rather than leaving the composer to discover it
by being refused: derived from the keeper, not stored, because the keeper
either holds that path or it does not. The composer says so in the placeholder
and takes no line.
The tests that asserted the old refusal are not deleted — they move to the
boundary that still holds. control.TestReadOnlyAttachmentCannotWriteTheTranscript
is the one that matters: attach read-only over a live holder, save, and the
save must fail with ErrSessionWriteAuthorityMissing and the file must come back
byte-identical. If that ever passes silently, the lost update is back.
Worth saying what this does not touch, because the two get the same word: two
different sessions writing one workspace already queue rather than refuse
(internal/workspacelease — readers never wait, and an agent team is not
serialized there at all, SubagentScheduler claims write paths instead). This is
the other thing entirely: one conversation, two windows, and no second writer
to coordinate with.
Reported as ugly. It placed each mark at its message's position in the whole
transcript, so the rail drew how much each turn produced rather than the list of
what was asked: a turn that ran twenty tools landed ten times further from its
neighbour than one that ran two. Measured on a session of mixed turn lengths,
gaps ran from 14px to 171px, σ=56 — clumps too tight to aim at, then nothing.
Two workarounds had grown on top of it: a neighbour-nudge for when marks landed
within 46px of each other, and a 120px aim radius to find one at all.
Marks now sit together in the middle at a fixed 9px step, clamped to the track
when there are enough of them to need it (300 marks land on a 2.21px step and
stay inside it). Where the reader is in the record is what the viewport box
already says; this is the list, in order, one step each.
Length and weight are a falloff from whichever mark the pointer has picked out,
and every mark is identical until one is. A cosine, not a line: a linear
falloff has a corner at the focus, which reads as the neighbours being a
different kind of thing rather than the same thing further away. Measured
across the three states — at rest every mark is 11px at .30; on the pane, 11px
at .46; with the pointer on the rail, 11·11·12·16·21·25·26·25·21·16·12·11·11
and .46 up to 1.00 and back.
The size no longer tracks how many files that turn touched. That put a column
of equal entry points at unequal lengths to say something the hover preview
says in words a few pixels away.
One thing the cluster broke and this restores: ⌘↑/↓ picked its starting mark by
comparing rail geometry against scroll progress. The rail is a cluster in the
middle now and its geometry says nothing about how far down the record a mark
is, so the walk starts from the message's own position in the transcript.
Reported as the scrollbar jumping back on its own. The native bar is hidden —
the rail's viewport box has been the only scroll indicator this transcript has
since it took that job — and the box is placed at scrollTop over scrollHeight.
Neither of those is wrong; the height under them was.
A block nobody has scrolled to yet stands in for its own height, and it stood
in at 96px an entry. The shortest card that exists measures 119, the median 199
and the mean 216 — the numbers are already written down one file over, beside
the identical fix made to a card's contain-intrinsic-size, where the note ends
"the scroller always landed short, then corrected, which is the jerk". The block
placeholder was left on the old number.
Measured, scrolling back through 300 turns: the content grew 18393px (+20%)
under the reader, and the indicator slid down three times while they scrolled
up, worst 48.8px on a 713px rail. The content itself never moved — scroll
anchoring does its job, and scrollTop rising by exactly the height added is that
compensation, not the defect. Only the ratio the indicator divides moved.
The estimate is now learned from this transcript's own blocks, a running mean
over a bounded window, seeded at the measured 216. Card heights differ by an
order of magnitude between a one-line read and a twenty-line diff, so no
constant was ever going to fit both; what fits is whatever this conversation
turns out to be made of.
After: content growth 18393px → 1113px (-94%), indicator reversals 3 → 0, worst
0.5px. Holds at 1200 turns: 1229px, zero reversals.
Reported: delete a conversation, click another one in the same project, and it
comes back "held elsewhere — close it there first". The log of a real window
has the shape of it:
POST /runtimes/r1/close 204 the deleted one's pane, closed first
POST /tree/sessions/remove 204 the delete itself
POST /runtimes 409 the next click
POST /runtimes 200 the same click, seconds later
resumeInto returns (status, error), and its doc says why: "answering with the
status a refusal deserves so both the HTTP handler and a hub opening a session
in a new pane refuse for the same reasons". /resume passes that status to
writeErr. Open discarded it into `_`, and openRuntime passed 409 as the
fallback for everything — so every failure with no code of its own arrived as
Conflict, which the frontend renders as in-use. A transcript the list still
showed and the disk no longer had reported as one another window was writing.
So did a provider the kernel could not resolve.
Open now keeps the status, and the catch-all becomes 500: what is left after
every coded refusal is a pane the kernel could not build, which is ours and not
a clash over the session. The path refusals in resumeInto were bare
errors.New — a status with no identity, which leaves the frontend printing the
kernel's English — and now carry session.bad_path and session.outside_dir,
both of which the frontend already had wording for. session.open_failed is the
one new code, for a failure that reaches the hub with no code of its own.
Two tests, and the pair matters: a deleted session must say it is gone, and a
session a live holder really is writing must still refuse as in-use — the fix
would otherwise have bought clarity by losing the guard. Against the old code
the first fails with "409: invalid session dir" and the second passes, which is
the old behaviour exactly: one answer for every outcome, right by coincidence
in the single case that was actually a conflict.
A listing the assistant wrote rendered in the box every tool result renders in.
Not similar — identical: both fills measured the same value, so nothing on
screen separated what was said from what was run. The step meant to sink them
was mixed against --raised while the column lays --surface, which left 2.7 L in
dark and 2.2 in light, and a code block all but dissolved into the page.
Two faces are declared instead, and they separate by different means. Machine
output takes the step, now cut properly into the ground: ΔL 2.7 → 5.7 dark,
2.2 → 3.9 light. A listing takes a line, which is also the half that survives
in light, where there is no room left above --surface to raise anything. Between
the two, ΔL 0 → 3.2 dark and 2.6 light. The listing carries its language and a
copy of its own, the corner handing one to the other under the pointer; where
there is no pointer the control stands and the language gives the corner up.
The answer's mark on the rail was a 1.5 stroke at --ghost: "not a button"
carried all the way to "not there", on the one thing in the column that holds
the turn. Filled, in the reading ink, plus the run its cell failed to cover —
a mark that does not fill its cell reads as the line having stopped. The two
labels naming the speaker are gone; the mark says it a column to the left, on
the same line. The row they sat in stays for what only it can carry.
The prose's copy had the paragraph's own left edge and nothing else, which is
what made it read as a stray list item rather than an offer. It gets the mark,
the prose's exact left edge, a ground that arrives under the pointer, and the
tick where the offer was.
Two things found on the way, both older than this change:
perf/surface.mjs read oklch(0.156 0.015 255) as rgb/255, landing the hue in the
blue channel at full. Every fill in the tree returned 45.2, so every parent and
child matched, and equal fills are the one case it exempts — it has been passing
by examining nothing for as long as it has existed. Reading L directly, it found
.wsrow[data-open] at ΔL 0.8 in light: the fill half of "the fill says which row,
the bar says it from the edge" had never taken effect. Carrying a little of the
hue moves it off the ground in both schemes.
An unscoped `.acts`, written for the action at the end of a server row, wins on
source order over every other use of that class and was adding its row-end
margin to the transcript. Qualified here only; the other eleven uses have lived
with it and are not this change's to move.
Cost: five nodes per code block (20 blocks, 577 → 676), render unchanged at
2330 → 2284ms. 658 tests, eight browser guards, and an action census identical
to before at 101/24/8/79.
The file was written when the version was one fix. It now carries three, and
notes that describe a third of a release are worse than none: a reader who
reads them decides they have seen the change list.
Reported as the interface looking stuck while a command runs. Measured first,
because "stuck" is two different defects and only one of them is a defect.
The thread is not blocked. Over an 18s turn the fixture produced one long task
of 131ms, and switching views mid-run answered in 62-71ms across six presses.
Nothing there needs fixing.
What is wrong is that the wait says nothing. Sampling every running call's own
card: four calls under 600ms went through one distinct text each, and the 7.4s
call through four — 0.41 changes a second. The only motion on a running call is
sympulse, a 1.9s breath on a 14px glyph, and a pulse reads the same at two
seconds as at two minutes. A command that runs for half a minute therefore
draws one unchanged line for half a minute, which is indistinguishable from a
window that has died.
So the wait reports its own length. It lands in the slot the settled duration
already uses, so when the call ends the number stops rather than a different
value appearing in a different place. Under a second nothing is drawn: a call
that answers that fast never looked stuck, and a digit that appears and leaves
is its own noise. No bar while it runs — the bar is a length against a known
duration, and growing one against a duration nobody knows yet is a progress bar
that cannot be honest about the fraction it has drawn.
The kernel's startedAt is deliberately not the source. It is unix ms on the
kernel's clock, and over a remote session that is not this window's, so
counting from it would spend a clock skew as elapsed time. This counts on the
window's own clock from when the window saw the call running, and says nothing
about a call that was already running when it arrived — that is a duration this
window does not know.
Measured again after: the 7.4s call goes from 4 distinct texts to 11, and the
four sub-second calls stay at one each.
Also removes two reduced-motion rules that turn off a shimmer that no longer
exists — .shim has been a colour for some time, and a rule disabling an
animation nobody declares tells the next reader there is one.
Reported from use: with a transcript on screen, the 运行详情 menu opened under
the cards and its rows took no clicks.
Nothing in the transcript outranks it. The menu is z-index 30; a runtime scan
of every descendant of the scroller finds no z-index at all above 5, and the
stylesheet agrees. So the transcript was never being compared against 30 —
the menu's level was already spent inside an ancestor of the bar. A stacking
context anywhere above holds the whole menu at that ancestor's level, and the
scroller, the later sibling, then paints over it and takes the presses. Two
things in this tree make one without saying so: an entrance animation's fill
transform and a card's paint containment. RewindControl's menu was moved out
of the flow for this same reason, and its comment says so.
Which ancestor it is here is not established: eight states at the reporter's
own window size, scrolled and zoomed, did not reproduce it, so this does not
claim to name one. What it removes is the dependency. The menu is rendered
into the body, where its level is its own, and that holds whatever forms a
context above the bar tomorrow.
Position is therefore measured instead of anchored. The layout box, not the
client rect: the entrance transition's scaleY is running while this measures,
and placing the menu by a size it is on its way out of put it 6px off. The
anchor rules stay for the composer's policy menu, which is not a Picker.
Alignment moves from the stylesheet to the call site, since there is no
positioned ancestor left for a selector to reach through.
Verified in a browser at 2000x1080 and again at zoom 1.25: all three Pickers
sit at the same offsets the CSS used to give them, right-aligned on the bar
and left-aligned in the composer, none clipped by the viewport. The guard
asserts the menu is not under the trigger, not that today's ancestors are
innocent.
The bullets had been written by translating commit subjects, which are
deliberately aphoristic in English and become spoken-register couplets in
Chinese — "先有账单,再有决定", "按下要有回答". A release note is not the place
for that form. Each entry now leads with the technical claim and carries the
mechanism, the identifiers and the measurements, the way 2.12.0's and 2.13.0's
notes do.
One fix, and the one thing a reader has to know that the fix cannot do for
them: the window updating into 2.14.1 is still holding 2.14.0's document, so
this update is the last one that can show the blank screen.
Found by clicking 设置 against a window whose assets had been rebuilt under it:
the whole screen went white. The document that window was holding asks for the
chunk name it was built with, the rebuild had written a different one, and the
404 rejects the promise lazy() is waiting on. A rejected import throws during
render, and there was nothing above it to catch the throw — so React unmounted
the tree and took the window with it.
This is not only a development accident. Studio updates in place: the .deb
replaces the SPA tree under a running window, and any window that outlives an
update is holding names that no longer exist. Opening settings is then the
first thing that asks for one.
The tree already answers this exactly once. The transcript's markdown is
deferred the same way, and its call site wraps the boundary in Boundary so a
throw costs one message its formatting rather than the window — Boundary's own
comment says why. The settings chunk is the other deferred route and had a
Suspense with nothing behind it.
It now falls back to the settings overlay saying the screen did not arrive,
offering the reload that actually repairs a stale document and a close that
gives the window back. chrome.reload is registered because the census holds
what a person can do here, and closing the panel unmounts the boundary, so a
second attempt is a fresh one.
The test renders App with the module mocked to throw, which is what a missing
chunk does to the import, and asserts the panel appears with the window still
mounted behind it. Run against the code without the fix it fails on both.
Committed before the tag rather than after it. 2.13.0's notes landed after
studio-v2.13.0, so the push-triggered run read a tag that did not carry the
file and would have published the standing text alone.
The case named "the system temp root itself" was written as /private/tmp,
which is the temp root on macOS and an ordinary path everywhere else. On
Windows the slug under test came out -private-tmp-run while the tool derives
its scratch slugs through filepath.Abs, which qualifies that path with the
current drive, so the test failed on a tool that was behaving correctly.
The case now reads the host's own temp root, which is what the surrounding
comment already claims the check does. The second name a root may be reached
by keeps its own case, run only where resolving /tmp lands somewhere else —
comparing the resolved path rather than the string, because EvalSymlinks
returns \tmp for /tmp on Windows and a spelling is not a second root.
The composer's paste handler reaches port.attach through attach and upload,
so pasting an image sends its bytes to the host exactly as the picker does.
The root carried no action id, which is the one thing the census exists to
refuse: an input proven to write with nothing naming it.
It takes session.attach, the id the picker's button and the file input
already carry. The registry's note on that entry — the bytes reach the host
as the file is chosen, before anything is sent — is the same fact about this
root, and a paste that becomes a chip is the same intent arriving by another
modality.
Splitting the settings page out behind lazy() took every action on it out of
the census's product graph: 75 ids the registry declares came back as having
no production root, and seal D failed on a tree where nothing had actually
gone unreached.
The walk does read dynamic imports — it looks for a call whose callee is
named Import, which is how @babel/parser 7 spelled one. Version 8 spells it
ImportExpression, with the specifier on .source rather than in .arguments, so
that branch has matched nothing since the parser was upgraded. It cost
nothing while every route was imported statically, and the first lazy
boundary in the tree is what made it visible.
Reading the node the parser actually produces puts the settings subtree back
in the graph: orphans 75 to 0, and the inputs it contributes rejoin the
denominator the open seals are measured against — B moves 55 to 79 because
those inputs were never being counted, not because any of them changed.
Measured first. A machine, a project inside it and a conversation inside that
were drawn at 13px each; the machine and the project both started at the rail's
edge; and the project's row was 52px against the machine's 47, so row height
denied the order it was meant to express. A selected project carried four marks
at once — fill, ring, bar, and an accent dot — none of them the main one. The
leaf's title, the only thing on its row that cannot be read anywhere else, held
87px of 201 while needing 210.
Depth now says the level: 0 / 12 / 37px of indent, weights semi / medium /
regular, heights 47 / 46 / 30. The leaf steps down one size on the scale rather
than two, because indent and weight carry the hierarchy and type has a floor to
respect.
The leaf's two controls leave the layout and keep the screen: placed one after
the other over the row's tail, still visible at rest, no longer holding 38px of
the title's width. The count drops its unit — every row on the list is in
turns, so the row says the number and its title says what it counts. Measured
result: the title goes from 87px to 128px, and with the rail's default at 264px
rather than 236 it renders about eleven Han characters where it rendered five.
A state says itself twice, not four times: the fill names the row, the bar
names it from the edge the eye is already travelling. The dot goes back to
neutral — it was this state's only visible mark when data-open changed nothing
but a text colour, and it has had three companions since.
Acceptance was the four numbers stated when this was proposed: title width
87 → 128, heights monotone 47/46/30, state marks 4 → 2, and overflowing boxes
unchanged at 35 before and after.
perf/panels.mjs carried its own copy of the rail's default and failed on all
three of its width checks the moment that default moved — the copy described
the rail until someone changed it, and then described nothing while still
failing. It reads RAIL and SIDE out of Gutter.tsx, which is where a span is
declared.
The checks these guards print were worded in a spoken register — 吃指针的控件,
按下去没有任何回答, 没量到卡片 — while the interface they guard was swept into
written Chinese for the reason upstream recorded: a spoken register reads as
unserious in something that states what software does. A report about the work
should not be looser than the text inside it.
Twelve check names and their detail lines rewritten. The two Chinese block
comments still left in perf/intrinsic.mjs and perf/pick.mjs are English now,
which is where comments in this tree belong.
No behaviour changes: every guard was run before and after, and all five pass
on the same measurements.
Reported from use: over SSH the session title never came across. It did come
across — once, at connect, before the session had one. The far kernel names a
session when its first turn ends, and nothing re-read the book after that, so
the row kept the name the session had before it had a name for as long as the
window stayed open.
This machine's tree is re-read whenever the pane list comes back, which is how
the window hears that a turn ended — Pane says so on purpose, and the comment
there has always said why: "when the turn ends, that is when it has a generated
title and a turn count". The far books were held inside the remote column with
their own trigger, the set of connected hosts, which does not change when a
turn ends. One beat for this machine, another for every other.
The books move to one owner. useMachineBooks holds every connected machine's,
answers with a single reload, and reads a machine's book when it connects; the
window calls that reload beside the local one, on the beat it already had. The
column draws what it is handed. That is the rule this tree already states for
projections — whoever owns a dynamic fact owns the invalidation that makes it
owed again — and the far book had been kept fresh by a trigger that belonged to
something else.
Three tests: the title read at connect, the title arriving on a re-read, and
the wiring itself — that the pane-list path re-reads both books, because the
defect was never in the reading, it was that nothing asked. Each was run
against the code without the fix: the last one fails alone when the far re-read
is removed.
Rename and delete sat at opacity 0 until the row was hovered, and they reserved
their width the whole time — 38px of a 201px row, plus the gaps around them,
spent on two controls nobody could see. The workspace row directly above keeps
its own actions visible at rest, so the same list drew the same kind of thing
two ways, and the invisible half was the one that also cost the title its room.
They rest at .45 and come up on hover and focus as before. The width was always
theirs, so nothing moves; what changes is that the row now says what can be
done to it.
perf/press.mjs gains the rule, and it is a narrow one on purpose: a control
that reserves space and paints nothing. Measured before writing it — the whole
screen had exactly these two kinds, 18 of each, one pair per session row — so
it starts green everywhere else rather than arriving with a backlog.
Not fixed here, and worth saying: the title still gets 87px of that row while
it needs 210, so six sessions in one workspace can read as the same truncated
string. Reclaiming that is a layout decision — a second line, a wider rail, or
fewer controls on the row — and it is not this commit's to make.
The bar on the selected row was an inset shadow, so it followed the row's 6px
corner and curved into a crescent. The rail draws the same idea on the
workspace a pane is open in — an absolutely placed 2px bar, inset from top and
bottom, with an end of its own — and that is the shape a reader already knows
here. One idea, two drawings, and the menu had the one that bends.
The value pickers had no bar at all: a hue change on a 6px dot was the whole
mark for "this is the model you are on". The stylesheet's own note beside the
completion menu says why that does not work — the eye resolves area, not a hue
shift on a dot — and it said so while the pickers two hundred lines up were
doing exactly that. They take the same fill and the same bar now, because they
answer the same question: which one is it right now.
perf/pick.mjs was reading the bar as "the row has any box-shadow", which is a
proxy for a drawing rather than for the mark — it would have passed a row whose
shadow was anything at all, and failed this change for no reason. It measures
the mark: a painted element under 4px wide against the row's left edge, with
the old spelling still accepted. Run against a row with the bar removed it
fails all four menus.
Reported as the content twitching up and down while a reply comes back, and
narrowed to the moment an ask appears. Measured on the running page: at the
frame the ask lands the transcript moves 108px one way, and on the very next
frame 561px the other. One beat down, one beat up — which is what the eye reads
as a shake rather than as something arriving.
What grew in that frame names the cause exactly: two .call cards went from 96px
to 325px and 428px. 96px is the fallback in contain-intrinsic-size, the height
an off-screen card is assumed to have until it is laid out for the first time.
The `auto` keyword covers every card the reader has already reached, so this
only ever applied to cards never scrolled to — and the fallback was below the
shortest card that exists. Measured across a 40-turn transcript: min 119,
median 199, mean 216. Every unseen card was underestimated, so the error
accumulated in one direction: the scroller computed a bottom from estimates,
landed short, and corrected once they resolved.
The fallback is the measured mean now. Same page, same ask: direction reversals
2 → 0, backwards scroll 108px → 0px, worst single-frame shift 561px → 321px —
and what is left is the card arriving, one movement in one direction.
perf/intrinsic.mjs holds it by measuring rather than by remembering the number:
it walks a long transcript so every card lays out, then fails a fallback below
the shortest card or above the tallest. Run against 96px it says 96 < 119.
Ruled out along the way, each by measurement rather than by argument: the
reasoning block's auto-collapse (2 frames of 55, 26px), the entrance animation
(8 frames of motion, the 561px jump unchanged with it disabled), and block
virtualization (chunk count and placeholders unchanged across the jump).
The slash menu listed its built-in commands in English inside a Chinese window:
"compact context", "discard current context". The translations exist and always
did — messages_zh.go has every one of them — and nothing was reaching for them.
i18n.DetectLanguage is what points the process catalogue at a language, and it
is called from internal/cli and nowhere else. The Studio host never imported
i18n at all, so the kernel it assembles kept the English default that exists so
that code running before detection still has text. Everything the kernel
renders itself went to the window in that default: the built-in slash hints are
the ones a reader sees, and they were the ones reported.
The top-level language rather than the desktop's, because config.toml already
declares that split — Desktop.Language dresses the shell, the top-level one
dresses the kernel and what it says to the model — and a host is not the place
to redefine which authority owns which. With neither key set, as here, both
sides resolve from the OS locale and agree, which is the case that was broken.
Verified end to end: rebuilt the host, reopened the window, and the menu now
reads 压缩上下文 / 查看上下文窗口、阈值与上次维护结果. wiring_test.go holds it,
and was run against a resolution that ignores the config first — it names the
exact English sentence that was on screen.
Every button in the top-right of the shell was dead: the account button, the
icon buttons beside it, and the window's own minimize, maximize and close. The
title bar is the drag handle, -webkit-app-region inherits, and a control that
does not opt back out is drag surface — the OS takes the press and the page
never hears it, with nothing on screen to say why.
The opt-out had been there. 49b574687 removed .themer, a control that no longer
existed, and .themer happened to be the last selector of the list that carried
the declaration — so the block left with it and the two selectors above it were
swallowed by the next rule's comma. The comment above the rule went on saying
"every control on it opts back out" for two days while none of them did.
Membership is now what the markup says is interactive rather than the controls
someone remembered to list, so a new button on the bar is covered by existing
and deleting one takes nothing with it.
The check belongs in the shell and only there. In a plain browser the property
does not inherit — a child of a drag region computes "none" — so a page-level
guard cannot fail on this and would be worse than having none. It went into
test/smoke.js, which drives the real window, and it was run against the missing
rule first: it names button.thbtn, button.wc and the account button exactly.
Two defects met in one place. The composer draws stop and steer side by side
while a turn runs, and data-primary sat unconditionally on steer — so the
emphasis, the thing that answers "what would you press now", pointed at
interjecting rather than at stopping. And with an empty box the steer button is
display:none, since there is nothing to send: the single button on screen was
therefore 停下 drawn as a ghost, with no filled action anywhere.
Emphasis follows the running state now. Steering keeps its place as the
rightmost button, so that position always means "send what I typed", and it is
an Enter away with the footer saying so; stopping has no other way in.
perf/focus.mjs was reporting this as 插话 while a hidden button was what it
read: it selected .send, which was the answer only back when the composer had
one button. It reads [data-primary] instead — which button is emphasised is
the question it was always asking.
The rule also lands in composer-salience.test.tsx, because focus.mjs needs a
browser and does not run in CI; two of the browser gates were found red today
from wording and structure that moved under them months ago. Both were run
against the old markup first to watch them fail.
Every comment added over this pass was written in Chinese, which leaves the
tree with two voices in the same file and this branch adding to the half that
has to be read twice. All of them are English now, and the register goes with
the language: what the code does and why, not how it feels to use.
The gates keep their Chinese console output. Thirty of them print Chinese and a
reader runs them together, so switching three would make the report bilingual
rather than making it English. What those three print is now written rather
than spoken — 接受指针的控件 rather than 吃指针的, 按下后没有任何反馈 rather
than 按下去没有任何回答.
perf/reason.mjs is the one whose wording was load-bearing: it asserted a
specific Chinese sentence, and when upstream rewrote the interface into written
Chinese the assertion went stale and reported "中文界面不说中文" — a defect that
had not happened. It reads the expectation out of kernel.ts and en_kernel.ts
now, so a rewording moves both sides at once. That is the same rule the tree
already states for judgements: read structure, never wording.
Untouched and worth naming: 1027 Chinese comment lines across 93 frontend files
and 285 across 47 Go files predate this branch. Converting them is a sweep of
its own, not something to bury in a UI pass.
Merging the two panels left the search box filtering one of them. In two panels
that read as a local search; in one list it is a search that quietly skips most
of the list, and the half it skips looks like a half that is not there.
The word now belongs to the box that collects it rather than to either list:
RailSearch owns it, publishes it, and both lists read it. Lifting it into App
would have split "the box at the top of the rail" from "who uses it" across two
files, and leaving it where it was would have made the remote half ask the
local half for it.
Remote filtering follows the local rule exactly: a machine whose own name or
address matches keeps everything under it, otherwise only the folders and
conversations that match survive, and a machine with nothing left drops out.
One difference is deliberate — a machine that has not connected has only its
name to match on, because its projects are on the far side, and answering "not
here" for them would be this window answering on its behalf.
Folding follows too: a fold is a resting-state preference, and while a word is
being typed it hides the very rows that word just found. The local half has
always done this; both halves do now, including this machine's own group.
The box is a type=search at last, so it is announced as one, and the native
cancel button Chromium draws for that type is suppressed — this field already
draws its own, and two controls for one action is worse than either.
Five tests hold it, and all five were run against a disconnected query first to
watch them fail; a filter test that passes on an unfiltered list is the shape
of guard this repo has been bitten by before.
The rail drew two panels: 「工作区」 above, 「远程」 below, each with its own
caption, its own add button and its own idea of what a row is. But the hierarchy
underneath them was never two things — a remote workspace was already rendered
with the local workspace's own class, and opening a session in a folder is one
intent whichever machine the folder is on. What differed was only that local
skipped the machine level, because the machine was implied.
It is implied no longer: this machine is the first row in the list, shaped like
a host row and folding like one. A filled pip is a connection state and this
machine has none, so it carries a hollow ring — it is not connected, it is
here. The add button moves into the slot a host's already occupies, which makes
the two buttons one meaning: open a folder on this machine.
Both halves now render into a single role="tree". That was a correctness fix as
well as a visual one: every remote row already claimed role="treeitem" while
sitting outside any tree, so assistive technology was handed items belonging to
nothing. The hosts render as children of the tree that was already there rather
than App growing a wrapper, which keeps the search state where it is.
The line between the two sections goes with them. A rule that draws one there
says "above is local, below is remote", and that is the distinction being
withdrawn; machines separate by space, like everything else in the list.
Two renames follow the widened meaning: .rmthead is every machine's row and is
.machrow, .rmtpick is every machine's add and is .machpick. .addws and .rail-hd
and .rmtcap are gone with the captions they styled.
Known gap, unchanged by this: the search box still filters only this machine's
workspaces. In two panels that read as a local search; in one list it reads as
a search that quietly skips most of the list, and it is worth closing next.
205 border declarations against 16 uses of the float shadow: the interface
separated almost everything by drawing a line around it, which is what makes a
screen read as a table of cells rather than as things sitting on a surface.
Two containers in the flow drop the line and keep the fill — the user's own
message, which is the most-looked-at box in the app, and the settings block,
which the settings screen is made of. Floating layers keep their line: the
float shadow carries a ring in dark and nothing like it in light, so a menu
without a border smears into the page on one of the two themes.
Removing a border removes whatever was hanging off it, and two states were:
.grp:hover tinted the border and .grp:focus-within tinted it alongside a halo.
A state has to be said in the same vocabulary as the separation it modifies, so
hover lifts the fill and focus draws a ring, both still on the card tier. Left
as they were, hover would have done nothing at all — the third dead declaration
this pass has turned up, after var(--hover) and var(--shadow).
perf/surface.mjs is the gate, and it measures the thing the change actually
claims: a layer that separates by fill has to be a layer you can see. It reads
every rounded box's own fill and its nearest filled ancestor's, converts both
to OKLab lightness and fails a gap under 2. An identical fill is not a failure
— that is a box deliberately not claiming to be a layer, which is how an
unselected tab is drawn, and reading it as a defect was this gate's own first
bug.
24 sizes, and 442 of the 665 type declarations inside one 1.5px band: 11.5,
11, 10.5 and 10px, four steps separated by half a pixel. Half a pixel is not a
level — it is not read as "this line is heavier than that one", it is read as
"nothing here is quite the same as anything else", which is most of what makes
an interface look unconsidered next to one that was drawn. The conversation
body was never the problem: it has had its own token all along. Everything
around it was.
Six steps, widening as they go: 11 / 12 / 13 / 15 / 17 / 22, with --read left
where it is. The floor rises to 11px, which is where the smallest labels,
badges and table headers were living at 9 and 9.5 — dense Han strokes lose
their counters there before Latin does, and the large-text exemption that
excuses low contrast starts at 18.66px, so none of this was ever inside it.
Above 24px stays off the scale: a splash wordmark is one size for one thing,
not a step other text is measured against.
Measured rather than eyeballed, on the running page at 1440×900: the same 16
boxes overflow before and after, all of them ellipsis truncation and the
scrolling settings nav — the change introduced none. The one thing that did
move was the transcript's share of the pane at 1100×640, which fell to 39.8%
against a floor of 40%: bigger chrome eats fixed pixels, and at that height the
composer's auxiliary budget is what the gate says must yield rather than the
transcript. It yields 2vh, and the share comes back at 42%.
typescale.test.ts holds the scale on the stylesheet, with the same two guards
the motion rule needed: a coverage assertion so a renamed property cannot leave
it passing on nothing, and an exemption for calc() against --read, whose 1.5px
adjustment is not a size and had been read as one sitting below the floor.
Measured before changed: of the 42 pointer-taking controls the harness page
puts on screen, 21 showed nothing at all on mouse-down. Hover lit 179 rules and
press answered on 17, so the gap between pressing and the state changing was
empty — which is the half of "手感" that no amount of colour work reaches,
because the question being asked in that gap is "did it hear me".
The cause was membership, not intent: the pressed rule already existed and
listed nine class names, with a second copy of the same nine in a reduced-motion
block and a third, differently-spelled copy further up. Membership now comes
from what the markup says a control is — button, summary, a[href] and the
interactive roles — so a new control is covered by existing, and the classes
that want a different press (.thbtn, .seg button, .mode) say so in one
declaration instead of joining a list. --press carries the shape, which is what
lets reduced motion answer for all of them by changing one value rather than
repeating the set, and drops the !important the second copy needed.
The wash composites over whatever fill a control already has, so one rule
answers for a ghost icon and a filled row without knowing either. Full-width
rows take the push without the squeeze: a percentage on a 300px row moves its
far edge several pixels, on a 24px button a quarter of one.
perf/press.mjs holds it from the running DOM rather than from the stylesheet,
because the thing that rots here is the list, and a list is exactly what a
stylesheet cannot check. It also caught two of its own bugs worth keeping: a
disabled control owes no pressed state, and splitting a selector on commas
without counting parens shreds :is(...) into fragments that match nothing.
Three defects it turned up on the way:
- var(--hover) and var(--shadow) were never defined by anyone. CSS drops the
whole declaration in silence, so .mverify:hover and .mrefresh:hover — from
this branch's own model-verification work — did nothing at all, and the nav
tooltip had no shadow.
- .mrow was two things: a <button> in Models and a row of numbers in the cost
panel. The numbers inherited cursor:pointer and a hover fill, so the panel
claimed to be clickable. The layout stays on the class; the interaction
moved to button.mrow.
- transition-duration beside the shorthand escaped the tier migration. Both
are on tokens now, and the gate covers that spelling too.
tokens.css has carried three weights and said what they are for since it was
written, and the stylesheet ignored them: of 157 transitions, one used --m-*
and 156 typed a number. Fourteen distinct durations for four weights, with
.12 / .14 / .15 / .16 all meaning "a line just changed" — so the same change
ran at four speeds in four corners, which is what reads as an interface that
has not quite settled rather than as anything nameable.
191 transition segments and 33 short animations now take their duration from a
tier. Where the easing was already the tier's own, the pair collapses into
--m-line / --m-card / --m-panel / --m-exit; where the author chose --ease,
--ease-in or the spring deliberately, only the duration moves onto --t-* and
the curve stays. The split is not cosmetic: a transition's timing function *is*
its shape, so leaving it at the initial `ease` was the one decision nobody had
made, while a keyframe animation is often shaped inside its keyframes and
overwriting the curve there would apply easing twice.
--t-live joins them as the tier that is not a weight. Five fills were running
at .5s, .6s and .9s because a meter reports a rate rather than acknowledging an
action, and the three weights had nothing to say about that job. It is held to
a fill's own geometry, which is how a meter reports: the settings icon that had
drifted for .5s moved to --t-panel instead, since answering a hover on the live
tier answers it late.
Delays divide the same way. Under 100ms nobody is waiting for a weight, they
are offsetting a batch, so those become --m-step; the one real wait keeps its
tier. motion.test.tsx holds all of it on the stylesheet, with a coverage
assertion so a renamed property cannot leave the rule passing on an empty list.
Both sides of the update grew Settings.tsx and it landed at 802 lines, two over
the ceiling. The part that does not belong to drawing the sheet is the search
matcher: it reads SETTINGS, NAV and a row's aliases, all of which prefsnav
owns, and it answers a question about the catalogue rather than about the panel.
Upstream reworded the interface into written Chinese while this branch was
moving the composer's own strings out of en.ts, so the two halves met with the
screen asking for 不询问 and 全部放行 and the catalogue carrying 不打扰 and
全放行. The approval notes were invisible to the i18n guard on top of that:
Policy passes them through t() from a table, so a key with no English renders
Chinese in an English window and nothing fails. They now read as upstream
spells them, and the spoken copies the extraction left behind are gone.
Chinese is the key, so 手动添加 for a model the reader typed collided with
手动添加 for writing a hook by hand — one label, two English answers, which
TypeScript caught as a duplicate property. The model's provenance badge says
用户添加, beside 已配置, 现场探测 and 本次未返回, which are origins too.
Clicking all 121 non-destructive buttons in the app against a live kernel, then
asking of each one whether the screen said anything when the kernel turned it
down. One did not: 创建隔离副本 on the session page. run() had caught the
refusal and stored the reason correctly — but the banner that prints it was
copied onto the model and extension pages, and the session page writes the same
state with nowhere to show it. The banner moves to the column every page is
drawn into, which is where a Settings-wide state belongs; the two copies go.
The sweep's own criterion is worth stating: a 4xx is not a defect, since this
server declines several capabilities on purpose. A 4xx that leaves the screen
unchanged is. Everything else came back clean — no uncaught error, no crash,
no silent failure.
The copy pass that preceded this could have broken two things and did not, by
luck rather than by design: a skill row decided its mark by comparing the
rendered "调不到", and the session reducer compared "等待工作区" to know which
state it was leaving. Both are a sentence doing a second job, and a rewrite of
the sentence breaks them without failing anything — the row just stops being
marked. The skill row now returns reachability beside its text; the reducer
names the two values it also recognises.
wording.test.ts holds the rule at a ceiling of zero rather than a ratchet,
because the event that produces these is exactly the pass that just ran.
Comparing against a named constant passes; the literal at the comparison site
is what it refuses. It carries its own regression, four shapes it must catch
and five it must let through — a catalogue keyed by the text is a lookup, not
a judgement.
The settings screen went first; this is everything else the reader sees. 348
strings across 62 files, the same way: the Chinese that renders it and the
English catalogue keyed by it move together, so the i18n guard fails on any
key left half-moved.
Where it mattered most was the refusal catalogue. kernel.ts is what a reader
sees at the moment something did not work, and it was written to be overheard
rather than read: 八成粘错了东西, 这句话排到它后面了, 这边出了点问题, 先关掉再
移除. 156 refusals now state the condition and the action, which is the whole
job of that file — the code carries the identity, the sentence carries what to
do about it.
Then the transcript cards, which are the lines a reader passes every turn
(要动手了, 想了 12 秒, 这一趟没折叠掉什么), the run graph and queue, the
onboarding and welcome flow, the remote connect prompts including the host-key
warning, and the model picker.
English moved too where it was informal in the same way — contractions,
"approved writes just happen", "Next tidy-up", "I am R." — though most of its
second-person phrasing is ordinary for an English interface and was left alone.
Three strings printed raw rather than through t() and so had never moved with
the rest: the identity-service error on the signed-in account branch, and two
remote progress labels whose catalogue keys are now in step with them.
Assertions that pinned the old wording were updated with it: nine of them
across the graph, workspace, pane-close, remote-directory, trajectory, shell
and kernel tests, each one a test that reads the copy rather than the state.
The screen was written in spoken Chinese: verbs from conversation (测一下,
加一条, 开一份, 搬迁), sentence-final particles, and the reader addressed as 你
throughout. That register suits a chat transcript and reads as unserious in a
settings panel, where every line is a statement about what the software will
do.
274 strings rewritten into written Chinese across all 30 files the screen is
made of. Both sides of each move together — the Chinese that renders it and
the English catalogue keyed by it — which is what let this run as batches: the
i18n guard fails on any key whose translation was left behind, and it did,
twice, on catalogue keys written unquoted because Chinese is a valid
identifier. The rewriter now handles that spelling too.
Merges that fell out of it: 删掉/删除 and 加上/添加 were two spellings of one
label and are now one catalogue entry; 问我 became 请求确认 rather than 询问,
which the approval segment already uses for a different control.
Deliberately unchanged. Search keywords in prefsnav stay colloquial — they buy
discoverability and nothing else, and a reader hunting for a setting types the
word they would say. 这台机器 stays as the nav group: it was never the informal
part of that list, and folding it into 本机 would collide with the scope badge
that already means something narrower.
The workspace lease's package doc said it serializes Delivery writers. It does
not: the gate is plan.mutates at every role setting, and the lease is re-entrant
within a session, so parallel tool calls and concurrent subagents share one and
an agent team is never serialized by it. What schedules those is
SubagentScheduler, which claims write paths rather than the workspace. The doc
now says both.
Driving every control on the settings screen against a live kernel turned up
one fault with several faces: an answer that existed was not making it to the
person who needed it.
SseHttp.get threw a bare Error carrying a path and a status, while every write
went through fail() and kept the kernel's refusal code. So a read the kernel
had spelled out — "this server does not open account sign-in" — arrived as
"/account: 403", and the three panels that folded the failure back into their
initial null went on saying "loading…" about a question already answered, for
the rest of the session. Versions, Account and Memory now hold three states
rather than two, and say which one they are in. saveShell, installMcp,
removeMcp, removePlugin and dryRunHook hand-rolled the same throw and lose the
same code; they go through post0/del0 now, and del0 is post0's counterpart for
a DELETE whose answer is a payload.
Storage had a row whose own note says "every path is either actionable or says
why it is not" — and a server that refuses moves left it with neither. Memory
had no way out of an empty store at all: the panel reads, rewrites and removes
facts, and writing one is the composer's /remember, which the CLI's memory view
already points at and this page never named.
Escape was window-scoped, so the six inline forms inside the sheet closed the
whole sheet along with whatever had been typed into them. useEscape claims the
key in the capture phase the way useDismiss already did; both now declare the
registration through listenAction, which lowers the unnamed-input ceiling by
two rather than raising it by one.
The section list was a tablist with twelve tab stops, and closing the sheet
left focus on <body>. The switch pill was a 30x17 hit target, theme cards sat
on two baselines because a CJK name and a Latin one resolve `normal` line
height differently, and a workspace path broke through the middle of a number.
The locks storage root reported "0 B · 7871 files" under a heading that asks
how much is on disk: the count breaks down a size, so it now goes where there
is a size to break down. The files themselves are correct and deliberate — a
lock file outlives its use because deleting one breaks exclusion for a holder
that still has it open — but nothing said so at the decision, and a measured
run shows a fixed home adds none while each fresh REASONIX_HOME adds four.
Both notes now say it, to the reader and to the next person reading the code.
Seven guards, each checked by removing the fix and watching it fail.
Coverage repair answered a digest that forgot a changed file by summarizing the
same fold a second time. The facts it was recovering are host-owned — the
receipts already prove which paths were written and which commands failed — so
the correction was buying nine thousand tokens of transcript to recover a
filename the host could name for free. On the fixture P0-B measured, the same
fold both ways:
calls summary input summary output coverage
before 2 18,500 850 complete
after 1 9,000 400 complete
The correction now runs on what the host knows. backstopFoldCoverage already
existed and already ran immediately after the repair, writing the dropped paths
and command signatures into the digest itself; it was the fallback for folds
under hard pressure, where the repair path was skipped. It is now the only
correction there is, which makes the expensive path and the cheap path the same
path. Nothing about the first summary changes — not its prompt, not its output
budget, not the fold geometry — so the number above is a deletion and not a
trade.
The circuit breaker survives, and deliberately judges the digest rather than
the completed text. A summary that named none of the fold's changes is broken
whatever the host wrote in afterwards, and measuring the backstopped text
instead would have retired the one signal that says so — it can only ever find
the facts the host just added. What changed is the price of finding out:
refusing used to cost a second reading of the whole fold first.
CoverageRepaired went with it, everywhere it was carried. A digest needing a
second call is a state that can no longer occur, and the CLI card and the
Studio card both had a branch for it that would never fire again. The backstop
line already says the true thing: the facts are in the projection, just not
because the digest carried them.
P0-B's accounting is untouched and still gated on more than one call. No
production path bills a fold twice now, so the cases drive two calls inside one
transaction directly — whatever brings a second call back has to land in one
bill, and the ledger cross-check still holds it.
Three gates, each shown to make two calls against the code before this: a
dropped change costs one call and is in the installed text, what gets installed
drops nothing, and refusing a useless digest costs one call too.
/compact set Force, and Force was one bool doing five jobs: waive the trigger,
shrink the recent tail, skip the idempotence check, skip the new-history rule,
and skip the fold's own economics. Only the first two are what a person means
by asking. The other three are why typing the command twice bought two
summaries of a context nothing had been added to — the shape a user actually
meets, since this machine's own history shows automatic compaction firing once
in five thousand calls while every /compact fires by definition.
The bool is now two: a request waives the threshold, because asking is what
makes it manual, and waives economics only when the user says so. Both halves
matter. Setting Force to false would have kept the economics and lost the tail
shrink, leaving a command that does nothing at all below the trigger.
Measured in the project's own harness, one /compact per generation over six
generations:
fold calls summarizer input projection @gen6
before 6 96,654 18,311
after 3 79,890 33,594
-50% -17.3% +83.5%
The last column is the trade being made rather than a regression: folding only
when a fold pays for itself means holding more between folds, and 33.6k of a
128k window is what that costs. The three skipped generations were skipped
because a checkpoint costs the whole prefix cache and too little had closed
behind it to earn a second one.
Declining is now an answer. A manual noop returned rejectCheckpoint — an error,
carrying a sentence that said "no foldable region remains" whichever gate had
actually declined. It returns a verdict with the code the host already settled,
and CompactDeclineText turns that code into words for a caller with no phrase
of its own. Over HTTP the endpoint answers 200 with the code rather than the
204 that could not tell a fold from a session it decided not to pay for. The
verdict's Installed is a bool and not a CompactionOutcome because that enum's
zero value is "installed", and a verdict nobody filled in must not claim a fold.
Two smaller things fell out. acceptCheckpointCandidate had a branch that read
like a force exception and rejected exactly what the branch above it rejected;
a checkpoint landing back at the trigger has bought the next fold rather than
avoided one, and nobody may waive that. And the overflow paths passed Force
alongside a trigger that already implies both waivers.
/compact --force is the escape hatch and stays one: economics only, and it
still cannot land a candidate that is not smaller than what it replaces. The
five behaviours are gated, and the two that matter — a request over an
unchanged context, and one over a few hundred new tokens — were each shown to
buy a summarizer call against the code before this.
A compaction's telemetry read res.Usage — the usage of the answer it decided to
keep. A fold that needed a coverage repair makes two billed calls and keeps one
of them, so what the receipt reported was never the transaction:
the repair improved the digest reported 1200 in, the provider charged 2200
the repair improved nothing reported 1 call, the provider charged 2
the repair billed and then failed reported 1 call, the provider charged 2
The first row is the one worth staring at: adopting the retry replaced the whole
result, so the first call's tokens left with it. Driven through a real
compaction, an applied receipt reported 9500 input tokens against 18500 the
provider was paid — a maintenance under-reporting its own bill by half, in the
one record a cost display would read.
A transaction's bill is now accumulated where the charge is, in the deferred
emit inside summarize, beside the usage event the ledger receives. That
placement is the point: the paths that lost a call were the ones that discarded
its answer, and no call site can discard a charge it never handles. Both entries
that reach a summarizer open a transaction — compactToProjection and
compressVisibleRange, the second of which builds its own telemetry and would
have been the next thing to go unaccounted.
Calls and RequestAttempts are kept apart. A call the provider retried inside
itself is one summarizer call over several requests, and collapsing them loses
the retries while counting them as calls loses the shape. Calls counts billed
calls, which is what makes it comparable to the ledger.
The receipt carries the same total, because the receipt is what survives, and
its InputTokens is the context that was worked on rather than what the work
cost — the two are routinely orders apart, and reading the first as the second
is what made a compaction look like it had read 160k of anything.
The gate against future drift is the cross-check the cases all carry: a
transaction's bill and the usage ledger are two readings of the same charges
and may not disagree. Each case bills its two calls differently so a total that
silently describes one of them cannot pass, and each was shown to fail against
the previous accounting.
Cost stays out. Quoting belongs to the sink that already prices these events,
and internal/agent does not import internal/billing; a per-transaction figure
wants the receipt's OperationID on the usage events, which is its own change.
A tool result the model saw bounded reached the summarizer in full. Content is
the provider-visible body, capped at maxToolOutputBytes, and RawContent holds
the original beside it for local display and recall. projectMessages clears
RawContent for both ModelMessages and ProjectionMessages, so no provider ever
receives it — except that a summarizer call does not go through either. It
renders the stored messages into a user message, and renderTranscript preferred
RawContent, so compaction was the one path in the tree that put display-only
state into provider request bytes.
The cost of that is not marginal, because the bound it undoes is the one thing
keeping a large read out of a request. The same fold rendered both ways, through
the estimator the budget itself uses:
fold shape model saw summarizer got ratio
20 reads truncated from 300KB 164297 1536332 9.4x
20 reads truncated from 100KB 164297 512232 3.1x
50 mixed results truncated from 80KB 410739 1024639 2.5x
This is also why the size of a compaction did not follow from the size of the
session it compacted: a conversation is bounded per result, and this read the
copies those bounds had cut away. The first row does not even fit a 1M window,
so a fold a bounded transcript would have sent in one clean call was either
shortened first or refused outright.
Both routes into the request are closed, not just the rendering one.
shortenFoldForSummary sketched long bodies from RawContent as well, on the
reasoning that a digest should see the real outcome shape. A head-and-tail of
the full output is bounded, but its tail is material the conversation never
carried, and the digest becomes what the session remembers — so the sketch is
cut from the body the model was given. Recall is untouched and stays the
deliberate exception: it exists to answer with what actually ran, as a tool
result its own budget bounds.
summaryInputTokens shares the renderer, so a fold's measured size was already
counting the raw copies. It now measures what actually rides, which is what the
budget was always meant to be comparing against.
Three gates, each shown to fail against the code before it: the request never
carries a body the conversation did not, a longer local copy cannot move a
fold's measured size, and the shortening path sketches only what was sent.
A session's pricing completeness was one boolean, and an aggregate has four
answers to give. A session that has billed nothing, one whose every round
priced, one that priced some rounds and not others, and one where nothing
priced at all all came out as costComplete true or false — and the first and
last of those also total the same amount, zero, so nothing downstream could
tell them apart. Studio could not tell them apart for a second reason: the
field was declared in the page's own CostQuote and read by nothing, so the
session projection carried no completeness at all.
Coverage is that fact with the states a boolean cannot hold: none, complete,
partial, incomplete. It is folded rather than assigned — the join over complete
and incomplete, with none as the identity and partial as the top — so it is
commutative, associative and idempotent, and the same rounds billed in any
order reach the same state. A run that has priced everything so far cannot fold
back to complete once an unpriced round joins it, which is the failure a
boolean invites and the one the ledger was already committing.
That ledger defect is the one thing here that was a wrong answer rather than a
missing distinction. Entries merge by pricing key, and going bucketed on a
second currency re-asserted completeness before folding the incoming quote in,
so an entry that had already seen an unpriced call read as complete again. On
the pre-change tree, a ledger given one unpriced call and two priced ones in
different currencies reported the session as costComplete true with the reason
erased. It reads partial now, because coverage folds and the boolean is a
reading of it rather than a second answer free to drift from it.
Zero is an amount, not a state. A model billed at a published price of zero and
a session that has not spent both total zero and must not share an identity:
the first is complete, the second is none, and both halves are pinned.
The kernel owns the answer on restore, because the record is not replayed —
usage rounds are not in the transcript, so the fold has folded nothing when a
pane opens on a session that already ran. /status carries the session's own
aggregate and that is where the coverage comes from. A kernel older than the
field restores nothing rather than a guess, and the live fold takes over from
the next round, which is all it can speak for.
Deliberately not here: the cost block's wording is untouched and still reads
pricing state off whether an amount is above zero. displayStatus and
displayComplete stay the separate question of whether a requested currency
could be shown, and aggregateMode, originalTotals and legacyEstimate stay in
the kernel — the page keeps the smallest set that maintains the fact's
identity, not a copy of the quote.
Every cost figure on the rail was formatted to exactly two places, so anything
under half a cent rendered as US$0.00 — the same string a true zero prints. Read
back from a sampled session's own journal, all 45 usage quotes were complete and
non-zero, at $0.000206 and $0.00291 a round and $0.018644 for the session, and
every per-round and per-source figure on screen said US$0.00. A turn that cost
money read as a turn that cost nothing.
That is not the same defect as a missing price, which is what it looks like from
the outside and what the block's own "价目未上报" line claims when it appears.
The completeness fact the kernel sends is a separate matter and is untouched
here; this is only the figure being unreadable.
Ordinary amounts keep their currency's own precision, because paying for the
small end with six places everywhere would be a different kind of noise. Only an
amount that the fixed precision would swallow is given the significant digits it
takes to stay non-zero, so $12.28 and $1,234.50 are unchanged, $0.005 is still
$0.01, and $0.0049 becomes $0.0049 rather than nothing.
The gate is the contract rather than a table of renderings: across six orders of
magnitude, no non-zero amount may format to the string zero formats to — held on
the Intl path and on the fallback path for a code Intl does not know. Two more
pin the halves that a fix could trade against each other: ordinary amounts stay
at ordinary precision, and a true zero still reads as zero.
Context drew its usage figure twice — once against the fold point, once against
the window — and 393 sampled renderings never once disagreed about it, because
it is one number. It is drawn once now, with each ceiling keeping a row of its
own and naming its own denominator. Collapsing them into a single percentage
would have been the same defect wearing less: "6%" cannot say whether it is 6%
of the point where the host tidies up or of what the model can hold at all, and
those are two different things to do next.
The cache block led the rail at its largest size with a session-cumulative hit
ratio, on the argument that a session which stops hitting its prefix costs
several times more per turn and a number that size gets noticed. A cumulative
ratio is the one shape that cannot do that: the longer a session runs the less
one broken turn can move it, and four sampled runs watched it drift three points
across a hundred seconds of turns. It is a figure beside the block's name now.
What can answer "why did this turn suddenly cost more" is the prefix having
moved, so that is what surfaces, with the kernel's own reasons on it — and only
when it has. A block nobody has made a request against says nothing at all.
Both stop easing their numbers. useTicker eased a metric from its old reading to
its new one over 520ms, which at a 250ms sample turned 11 real cache updates
into 21 ratio renderings and 25 hit renderings; on the context gauge, which
Round 2 established moves once per request, it drew a per-request step as a
smooth climb. A reader cannot tell an eased frame from a measurement, and
nothing on screen offers to. The ratio is also hit / (hit + miss), so easing it
on its own clock briefly disagreed with the two figures it is derived from.
With both of them off it, useTicker had no readers and is gone rather than kept
for a caller that might want it: what is left in num.ts is one hook whose timer
samples a shape and one whose timer may only end an observation. Geometry and
emphasis still move — the bars keep their transitions.
The gate is the contract in one line, held for each of them: one metric update
is one displayed value, asserted by advancing timers and frames afterwards and
finding nothing added. Against the deleted helper the same test reports six
readings for one update, four of which no request ever produced.
The runtime panel drew six counters on every rail, standing at zero whether or
not anything had happened, and three panels beside it held a block each to say
they had nothing. A sampled clean run spent 100% of its samples showing 失败步数
0, 外部请求 0, 子代理数量 0, a background-jobs block whose entire content was
"没有在跑的后台任务", and a work-tree block whose entire content was "尚无改动".
A reader who has learned to skip five zeroes skips the sixth on the run where it
is finally not zero.
Each of them now speaks only when it has something to report, which is the
contract the rail already claimed for itself and most of its panels already
kept. A failed step, a call that reached outside, a delegate, a background job, a
changed file: absent at zero, present the moment they are not. The work tree is
the one review posture draws first, so an unchanged one led the rail with a
sentence about nothing every time a turn ended.
Throughput goes with them but on its own reading. It is about a turn in flight,
so it is drawn while one is running and not after — a permanent "— tok/s" on an
idle rail is the same zero row spelled differently, and D1 already made it fall
silent within the turn.
待确认 is the only fact here a reader can act on, and the only one about them
rather than about the run. It leads the rows and is toned as a request rather
than as telemetry, label and number both, because what matters is that
something is waiting and not how many things are. The transcript says what is
being asked; this says that something is being asked at all, which is why the
two saying it at once is not the duplication the next paragraph is about.
改动文件 was: it and the 工作树改动 block were one derivation rendered twice, four
rows apart, from the same visible(changes, tree) call — measured identical
sample for sample. One projection is kept.
工具调用次数 moves off the rows and onto the group's own line. D2 made it a
correct cumulative fact; that is not the same as it deserving a standing row,
because how many calls a turn made is almost never what decides the next thing
to do. Being right is not a reason to be loud.
Jobs carried a comment arguing the opposite case — that empty is a state and a
vanishing block moves the rail's order under the reader. The order is a fixed
table per posture, so a block leaving moves only what is below it, and jobs sits
near the end of both. Measured against a standing row nobody reads, that is the
cheaper of the two. The inspector's contract test kept the same shape and
changed which panel is its example.
Runtime's own doc described three headline figures on a head card that
Metrics.tsx says twice does not exist. The panel is now what it actually is, and
no comment in the rail claims that card any more.
Four controls stood on the shelf under every turn, and on an ordinary one three
of them recited a value the session already had before anyone touched it:
均衡, 计划, and whichever posture the policy chip had collapsed to. Sampled runs
watched all three sit unchanged for the whole of a turn — zero changes, zero
time away from baseline — beside a model picker and a stop button that were
doing the work.
policySummary already knew how to do this for effort and approval, and preset
was the one field it recited unconditionally. It is baseline like the other two:
balanced is what a session runs as before anyone changes anything, so it is what
the shelf stops spending width on, and delivery — or any preset the kernel names
that this table does not — is a deviation and shows. With all three quiet the
control has nothing to report, so it draws nothing at all rather than an empty
chip, and it carries its own separator so nothing dangles where it stood.
The plan toggle is not the same reading, and the difference is the rule worth
keeping: a status may recede to its baseline, but the only way into a mode may
not. Preset, effort and approval are readouts whose control lives in the
settings sheet as well, so what recedes there is the shelf's copy. The plan
toggle is where plan mode is discovered — Shift+Tab is a shortcut for someone
who already knows, not a hint — so it stays, drawn as text rather than as
another chip while it is off, and taking its full weight back the moment it is
on. It keeps its border on hover, because a control that only exists once you
are already touching it is the one nobody finds.
The model has no baseline to recede to. Every value of it is a choice somebody
made, so it stays, and so does stop.
The gate is the two halves together: a session at balanced / auto / ask must
recite none of it, the plan toggle must still be there saying it is off, and
each deviation on its own must be visible. The one
the quiet may never swallow has its own two tests — a fully-permitted session
shows 全放行 and its mark with everything else at baseline, and again while a
plan is running. A third catches the shape of failure this kind of change makes:
the dangling-separator check is vacuous if its selector is wrong, so the
deviating case asserts the same query counts something.
The rail counted tool calls, failed steps and external reaches by walking the
transcript for items of type "tool". The transcript is a projection whose job
is to collapse cards — read_file alone was 96 calls across a sample of recent
sessions against 47 for bash — so it folds settled lookups into one manifest,
nests a subagent's calls under the task that spawned them, and replaces a saved
memory's row outright. Every one of those moves an execution out of the shape
the counter recognised, and the counter is labelled with a cumulative fact. Four
sampled runs watched it count down: 7 to 5, 13 to 12, 15 to 14. Failures went
with it, because the same walk derives all three behind one type test, and
nothing stops a read that failed from being folded.
The three facts now come from the executions the session records, keyed by the
id the wire gives each call. Identity is what makes this idempotent rather than
a guard: the same event reduced twice — a replay, a reconnect that re-delivers —
is one fact restated, not a second call. An outcome is absent until the result
lands, because a call still running has not succeeded. Classification stays in
derive.ts beside the categories the rest of the interface uses; what moved is
where identity lives, which is the part the projection was overwriting.
Recording happens before the branch that deletes a card, not after it: a row
being replaced is not the call not having run.
The gates are the four ways the transcript destroys a card, plus the general
form — the same executions drawn two legal ways must answer identically, which
is what a new collapse rule has to pass. Against the old derivation all five are
red: the two fold cases report 0 where 2 and 3 ran, a subagent's call reports 1
where 2 ran, a replaced memory reports 0 where 1 ran, and a failure folded into
a manifest reports 0.
Every restore hands the executions over with the transcript, and the `as never`
the three dispatch sites carried is gone — it was what let one of them be
written without them, which a live run then found by reducing a tool event
against nothing at all. Dropping the cast is what makes the next omission a
compile error rather than a crash.
The folding rules move to state/fold.ts, which is what keeps session.ts inside
its ceiling and also says the thing this change is about: drawing and recording
are two jobs, and only one of them is allowed to be the source.
What this does not fix: /history carries no error field and no resolved name, so
a rebuilt execution has no outcome. After a reload the failure count reads 0
however many failures the session had — the same reading as before, now for a
stated reason rather than a silent one. Closing it means the kernel projecting
what it already knows, not the frontend inferring it.
The rail's throughput row was read four times a second by the same interval
that advances the elapsed clock, whether or not a token had arrived. Between
arrivals the window's samples age out of the front while the span they are
divided by does not, so the value slid down a smooth ramp and the reader
watched a rate fall through a dozen readings the model produced nothing for. A
sampled run measured seventeen consecutive readings, 87 to 2 tok/s, across 4.3
seconds in which the turn was waiting on a tool. 134 of the 136 value changes
on that rail in four runs came from this one row.
The intent was never wrong. tokens.ts already says the window is "short enough
that the number falls to zero while a tool runs instead of implying the model
is still writing", and falling to zero is right. What was wrong is that the
fall was drawn as a series of rates rather than as an ending.
A rate is an observation of what arrived, so useRate makes one only when
something arrives, and gives wall time exactly one power over it: to end it. On
each new sample it reads the window once; it then schedules the single step to
zero at that sample's own expiry. Between the two the last observation stands
unchanged. WINDOW_MS is untouched — how long the window should be is a separate
question from what may write a number into it, and answering both at once would
leave neither answered.
The invariant test drives the hook through arrivals and silence and asserts
that silence produces exactly one transition, to nothing. Because that is also
satisfied by a hook that never changes at all, a second test requires a fresh
reading as soon as tokens resume, and a third requires the last reading to
stand until its window actually closes rather than vanishing early. A fourth
keeps the defect stated rather than remembered: asked repeatedly with nothing
new, the formula itself still answers a different number every time, which is
correct for a pure function of `now` and is exactly why nothing may ask it on a
clock.
The page did not read as a console because any one control on it was obscure.
It read that way because all of it arrived at once, in an order nobody chose:
语言 and 窗口 first, 明暗 and 配色 last. What a visit is actually for now comes
first — 明暗, 配色, 大小, 壁纸 — with 语言 and 窗口 after them, and 字体, 文字粗细
and 文字对比度 folded into one disclosure.
A disclosure that hides a value somebody set is a screen disagreeing with the
window it describes, so the summary says which of the three have been changed,
read from the same values the folded blocks render rather than from a second
copy of that question. Its label is drawn at the weight of a settings row, not
a footnote, because what is behind it is three whole blocks.
Which disclosure a jump has to open is answered by structure — the anchor's own
ancestors — rather than by a table of which settings count as advanced, which
would have to be threaded through prefsnav, Appearance and Settings and kept in
step with all three. The landing then has to survive the page: 配色's packs and
the font list arrive after the scroll is already running, and a smooth scroll
aimed once lands short by however much grew above it. Measured on the fixture,
文字对比度 came to rest with only its heading edge on screen. A jump owns its
landing until it lapses, so it re-aims for as long as it is owed.
Gating 壁纸's opacity, dim and focus behind the same treatment was considered and
dropped: `look.wallpaper &&` is already the gate, and hiding those once a
wallpaper is chosen hides them exactly when they became worth having.
The chrome is the one line that is always on screen, so what sits in it is a
claim about what a session needs within reach for all of it. A theme cycle is
not that. It is set once and then left, and 明暗 on the appearance page already
owns the same state with its three values named rather than reached by clicking
through them — which is also what made the button hard to write: two of the
three look identical on screen, so the cycle had to be re-derived from what the
system currently resolves to just to guarantee the first press changed
something.
What goes is the control, not the capability: the button, the two props the
window threaded down to it, the registry entry and the icon's per-state CSS.
`.thbtn`'s shared rules stay, because the account, focus and settings buttons
are still drawn in that weight class. audit-sweep's theme-toggle step goes with
them — with no control left to find it would report a permanently skipped step
on every run, and a skip a reader has to re-diagnose each time is worse than no
step.
Shrinking the button instead was the other option, and it answers the wrong
question. It would leave the same claim on screen — that this is one of the few
things the window shows unconditionally — while making the control harder to
hit.
The preceding commit establishes that the producer owns the identity and that it
survives the pipeline into the model's own view. It does not establish that the
model does anything differently for having it. This is the harness that would
answer that, and the record of what happened when it was pointed at the question.
Three arms share one transcript ending in a refused update_goal and differ in
the refusal tail alone: the shipped sentence, that same sentence byte-for-byte
plus the identity Refusal.String() appends, and a synonymous rewrite carrying
none. A minus B is then the identity, and the rewrite is the control that says
whether any difference was the identity or merely changed words.
TestRefusalArmsDifferOnlyInTheRefusalTail runs in CI without a provider, because
it is the claim every live number would rest on: the arms are checked to be
identical in transcript and tool surface up to the last tool result, the typed
arm to be exactly the sentence arm plus the identity, and the reworded arm to
carry no dotted token that could serve as an identity proxy. The measurement
itself is opt-in behind REASONIX_LIVE_REFUSAL and skipped otherwise.
The pilot result, recorded because it is the outcome:
R1 typed refusal architecture PROVEN
Behavioural claim NOT MEASURED
Reason the design pilot found the invalid-goal-retry
primary at the floor on this fork, 0/15 across
all three arms; no confirmatory run launched.
A pilot is allowed to falsify the assay rather than the hypothesis, and that is
what this one did: with the primary at the floor, a confirmatory run buys a more
precise measurement of something the instrument cannot see on this fork. The 15
observations are excluded from any reported result, including the secondary axis
that varied in them — moving the primary to an axis noticed after looking is the
move a frozen primary exists to prevent. Measuring this properly needs a new
assay, preregistered from host state rather than from these runs.
Classification reads the calls the model emitted, never its account of itself,
and the outcome vocabulary and the two-call window were frozen before the first
request. The other-goal-mutation set is declared rather than name-matched: a
guess at "some goal tool" would count one that never existed and miss one that
does.
The goleak allowance is scoped to the transport read loop a live provider owns
and no test can reach to close, and only under the env gate that asks for real
traffic. Forgiving everything parked on IO — the first shape of this — would
have made the live harness the one place its own leaks cannot be seen.
`ContextualReasoner.Unavailable` answered a bare string, so a caller that has to
tell two refusals apart was left matching the words one of them happened to be
phrased in this release. The rule the tree already holds errors and HTTP
refusals to — the identity travels, the sentence is presentation — did not reach
the one refusal the model reads most often.
`tool.Refusal` carries both. Five codes land, each backed by exactly one
predicate its producer can prove: jobs.no_job_context, plan.mode_active,
plan.no_planning_turn, goal.no_active_turn, and tool.unavailable_unspecified for
a contextual tool that says nothing, which is a fact about the registry rather
than about the call and so gets its own identity instead of a bare sentence.
The middle two are the argument for doing this at all. complete_step refuses
because plan mode is active; submit_plan refuses because no planning submission
exists. Those are opposite facts with opposite recoveries, and under
sentence-matching both read as "something about plan state is wrong". The two
plan tools share one code and keep their own wording, which is the shape: one
identity, two presentations.
No code is invented ahead of a producer. update_goal's ProviderVisible proves
only that this turn carries no goal recorder, so there is no second goal code
for a completed-but-armed turn; that state has no producer yet.
Refusal implements error because update_goal had two refusal paths for one fact
— the availability gate and the fail-closed Execute a stale transcript reaches —
with the sentence hand-copied into both. Returning one value from both is what
stops the identity from being present on one path and lost on the other. The
same shape was live in submit_plan and conclude_no_changes.
The identity crosses both boundaries it has to. Refusal.String() renders it into
the text the model reads, because a code the model never sees is one it cannot
cite; refusalCode rides toolOutcome to event.Tool.RefusalCode structurally,
never recovered from the words. No sink reads that field yet — this commit
claims that the producer owns the identity and that it survives the pipeline,
not that anything has been shown to act on it.
The registry tests pin which tool refuses under which code. Nothing about a
tool's name says which host fact stops it, and two tools sharing a code is a
claim that one predicate stops both, so a new contextual tool has to enter the
table and make that claim in the diff.
The narrowed standards gate built its universe from `git diff --name-only
HEAD`, which answers what the worktree changed against HEAD and says nothing
about a file git has not started tracking. Three new files reached `make check`
as clean while the full sweep found a violation in one of them. Not a wrong
judgement — a measurement universe missing a piece, and a green meaning only
that the gate never opened the file.
The universe moves into Go as `-changed` rather than growing another expression
in the recipe, because the defect was in the universe and a universe computed in
a Makefile cannot be tested. `trackedChangedPaths` is kept as its own function
though nothing in production calls it: it is the incomplete universe, and a test
that cannot express the incomplete one cannot prove the complete one fixed
anything. That is what the second test does — one finding, two universes, two
verdicts — and the first fails on purpose if `git diff` ever starts reporting
untracked files, because the witness would be gone.
Deleted paths stay out. A path the change removed cannot be scanned, and handing
one to the scanner asks it to fail on an absence the change intended.
REASONIX.md now names `make check` as the owner of that universe rather than
naming the flags behind it. The rule being preserved is that the evidence entry
point is the make target; which flags implement it is not the contract, and
writing them down is how the document went stale the first time.
CI is unaffected: it runs the full sweep with no narrowing, so the hole existed
only in the local check cited as evidence that a change owed nothing.
The catalogue could answer when a change takes effect and not who it
reaches, so ownership was read off the page a block happened to be filed
under — and the model page alone holds one value stored per provider
entry, three stored machine-wide, and one stored nowhere at all.
Every scope here came from reading the endpoint behind the control and
the authority it writes. Three findings the screen could not have shown:
the compaction threshold sits on the model page and is one [agent] key
every session on this computer reads; 执行设定 and 计划模式 reach the
controller and persist nothing, while 工具批准 and 模型 look identical on
screen and are written as the default every later session starts from;
and four blocks — hooks, MCP, skills, memory — take the authority as a
kernel parameter the reader chooses per write, so naming one owner for
them would be wrong half the time.
Scope and apply stay two fields, joined only where they are drawn: a
pre-joined sentence would give search, filtering and export nothing to
read. Both come from the one catalogue, so the block and the search
result that leads to it cannot disagree about what a setting owns — and
search is where it is worth most, since a proxy found under 网络 with no
page around it says nothing about whose proxy it is.
Ownership is a fact, so it rides the weak line the timing already used.
No badges, no accent: thirty of them down a page must not read as thirty
alarms.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013UmAThRKnRPWvBouHWHkQ3
Three kernel facts were recited on every turn, including every turn where
none of them was unusual — so a shelf that already carries a model, a plan
toggle and an attachment row spent its remaining attention saying that
nothing had been changed. It now spends one word on a session nobody has
configured, and the whole reading stays on hover: baseline recedes, it is
not deleted.
What recedes is decided by the kernel's own defaults, not by length. ask
is the only approval mode that changes nothing about what happens without
being asked, so it is the only one allowed to be quiet: auto lets calls
through unasked, dontAsk refuses what it would have asked about, and yolo
asks about nothing and carries its own tone, because it is the one state
that costs a workspace if it is forgotten. The summary says 自动批准 where
the menu says 自动 — under a 工具权限 heading that word is unambiguous, and
on the shelf it stands next to a reasoning rung.
The projection reads committed fact only. A field still in flight shows
its pending state inside the open menu, where the person who clicked is
looking; a shelf that moved first would name a posture the kernel has not
accepted and may refuse. With nothing read yet it names the control rather
than the posture the kernel usually answers.
The rail's own 运行详情 becomes 运行指标: it reports throughput, calls and
files changed, and the pane's detail menu now owns the other name.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013UmAThRKnRPWvBouHWHkQ3
Two navigations sat one above the other speaking the same grammar — a row
of tabs under a row of tabs, both drawing the same travelling marker — so
which row changed the session and which changed the view had to be worked
out on every glance. The inner one now projects the five views onto three
standing purposes: the transcript, the task board, and one entry for the
three that are diagnosis.
Demoting that entrance must not hide the position, so the trigger carries
the category alone while nobody is inside any of the three, and the open
lens's name alone once somebody is — at every width, which is why nothing
here arbitrates between the two by container width. What pins it is three
renders, one per lens, each a first render: that is also how a pane
arriving from somewhere other than this menu reaches the projection, since
it keeps no memory to consult and can only draw the identity it was
handed. The other gate is that opening and closing the menu picks nothing.
An entrance that reopens the last lens, or defaults into one, is a state
machine with no place on screen to show itself.
The five view identities are untouched, and so is everything that reaches
them without going through the bar: a graph node still lands on its call
in the transcript, the task board still reaches the trajectory and the
latest turn, and a run that loses its graph still takes its timeline with
it — the menu offers no timeline there is no graph to draw. Each
diagnostic view is still mounted only while it is on screen; that
asymmetry is a performance contract, not markup nobody tidied, and a test
now reads it off the source so unifying them fails rather than quietly
costing a transcript's worth of work per streamed delta.
The travelling marker is PaneTabs' alone. The transcript's item count is
gone — the largest number on the bar and the one that changed nothing —
while task progress stays and the diagnostic counts move into the menu.
Two bounds decide when a session folds and only the lower one fires. The
window was the one on screen, so a 1M session drew a bar 16% full at the
moment maintenance happened, and the setting behind it was the kernel's
configuration language: one signed integer, where a negative retired the
economic bound and left the window share firing at 85% under a label that
read as off.
ContextBreakdown now carries the boundary in force, read from the rule
that fires it rather than recomputed by whoever draws it. The panel leads
with the distance to it and keeps the window as a quieter second figure —
capacity answers whether the model is too small, and is still the only
place a relay's wrong window is corrected. The two rungs the kernel
already tells the model about, 75% and 92%, are the two the panel shows,
so the screen and the conversation are never under different pressures;
both read as accent, because a routine fold painted as a warning makes
every long session look broken.
The setting states the three intents instead of asking for a sign, names
which of them is in force while collapsed, and says capacity still
applies when the economic bound is retired.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013UmAThRKnRPWvBouHWHkQ3
Renaming a session, moving where compaction starts, and renaming a pane all
saved on blur and nowhere else. Blur fires when focus moves for reasons
nobody aimed at — a click elsewhere, a Tab, a window going away, an element
being removed — which is why the census counts it as a runtime event and
never as an interaction root. So three host writes had no root at all, and
Seal A's "every proven write has an identity" was true over a universe that
could not see them.
Enter now commits, and carries the identity. Blur still saves, because
clicking away is an answer and taking that away would be a worse product
for a cleaner number; what changed is that it is no longer the only way in.
Each of the three guards against sending the same value twice, since Enter
commits and then blurs — for the compaction threshold that matters more
than tidiness, because declaring it rebuilds the runtime.
One id for two places: the sidebar's tree and the pane tabs rename the same
session through the same host call, so session.rename covers both and says
which on data-target. compaction.threshold is new. The sandbox's workspace
root turned out to be fine already — its Enter has committed and been named
all along, and it is in this only as the shape the other three now have.
There is a guard now, and it is about the rule rather than these three: a
field whose blur handler reaches outside the component — a port or hub
call, or a callback handed in from above — must declare a user input on the
same element. Blur closing a menu or clearing a local flag is untouched,
which the check demonstrates on both a commit it must see and a local write
it must not.
Found while adjudicating the workspace rail, and worth saying that the
first count was wrong: a four-line grep found three of these and there were
five. The two it missed committed through a local function rather than
inline. Queue's was already named and Sandbox's already committed on Enter,
so three needed the change.
Seal B 79 to 76, unnamed 231 to 228, Seal A still held — now over a
universe that includes what it is claiming about.
Ten unnamed inputs across onboarding, the remote list and the composer, and
four of them do something outside this window.
Opening the vendor's console during setup and opening a link in the
transcript are one thing to do, so both sites take external.open. It is
shell-native and not a mutation: the reader is handed to a browser and no
state on either side moves.
Probing a remote asks a machine whether it answers. Nothing changes, and
asking again is the point when the first answer was "not yet", so it is
repeatable and carries the host it is about on data-target.
Choosing an image uploads it before anything is sent — the picker's own
button only opens a dialog, and the bytes reach the host on the change.
That is where session.attach goes, and naming the button instead would have
put the identity one step away from the write.
The other six stay unnamed on purpose. Picking a vendor, typing a URL or a
key, and choosing a model all edit onboarding's draft until
onboarding.connect sends it. Setting or dropping a remote's default folder
edits a draft the save reads. Restoring or discarding a long paste moves
text in and out of the box you are still writing in.
Seal B 83 to 79, unnamed 235 to 231, ratchet lowered, roots unchanged at
418. Three ids for four roots, and six looked at and left, which is the
same discipline both ways: an id is what a person did, and there is nothing
to name where nothing was done to anything.
Two of the four unnamed inputs on the workspace rail really do change the
host: the folder picker and the typed-path form both end at addWorkspace.
They take the id the empty transcript's own button already carries, because
which of two ways you named the folder is not a different thing to have
done.
The other two do not, and are left alone. Folding a workspace row writes a
Set in the window and nothing else. Renaming a session commits on blur —
its keydown only blurs the field, so naming that would name the trigger and
not the write.
That blur is worth stating plainly, because it is a hole in a number this
tree quotes. Three host mutations in the desktop commit from onBlur — two
renames and the sandbox's workspace root — and blur is classified as a
runtime event, so none of them can ever be an interaction root. Seal A
reads held over a universe that cannot see them. Reclassifying blur is not
obviously right (it fires for reasons nobody aimed at) and giving these an
explicit commit control is a product change, so this records the gap rather
than closing it.
ModelChoice is refused as identity debt, and that is the other half of this
change. Its four inputs — the model ticks, the vision flag, the default,
and adding a name the endpoint never reported — all write React state that
lives until someone presses save. The canonical mutation is already owned
by provider.add and provider.save, and inventing model.enabled or model.add
would have said something untrue: typing a model name and then cancelling
changes no provider config at all. It would also have sat one word away
from model.select, which switches the model this session is running on.
So this is two roots named out of eight looked at. Seal B 85 to 83, unnamed
237 to 235, and the ratchet lowered to match. The rule the earlier census
should have been applying, and now is: a root earns product identity when
it triggers the host mutation itself, not when it edits a draft some later,
already-named commit will read.
Thirteen inputs on the appearance page changed durable state and had no
identity — the interface language, the interface and reading scale, four
background parameters, weight, contrast, light-or-dark, and two font slots.
The same file already declared five other actions, so this was a gap in the
vocabulary rather than a page nobody had reached yet.
Eight names for thirteen controls, and the difference is the point. The
interface scale has a row of presets and a slider beside it: two element
types, one thing a person is doing. The four background sliders edit four
fields of one background — changing how much it is darkened is not a
different capability from changing how opaque it is — so they share
appearance.background and say which parameter on data-value, the way
pane.close already says which panes on one. Naming each control would have
been the DOM renamed, not a vocabulary.
Three boundaries kept on purpose. theme.activate installs a palette pack
and appearance.scheme is light, dark or follow-the-system. wallpaper.change
and wallpaper.remove decide which image is there; appearance.background is
how the image that is already there is drawn. Weight and contrast look
alike in CSS and are two different things a reader wants.
The surface stays out of the id. These are reached from the settings sheet
today, and an id that said so would need a second one the day onboarding or
a command palette can change the same setting — which is the rule
chrome.preset was already keeping when the composer started rendering it.
Where they land decides their kind rather than how they look: the scale,
the reading size, the background, the fonts and the language all reach
saveAppearance, while light-or-dark, weight and contrast are this browser's
and stay view, which is what chrome.theme already is.
Two controls, one of which must not fail. Swapping the opacity slider for a
number input leaves every guard green — the identity is not the widget's.
Giving each background parameter its own id turns the vocabulary contract
red, which is the shape this was most likely to drift into.
Measured, and every root accounted for: Seal B 98 to 85, and the thirteen
that left are exactly the thirteen named here — the appearance page now has
none in that population. Actions 112 to 120, unnamed 250 to 237, the root
count unchanged at 418, and Seal A still held. What remains in Seal B is
not this kind of debt: it is dominated by an identity axis whose unresolved
provenance is one list shared across fifty-seven roots, and by callees the
analyzer has no verdict vocabulary for.
Seven changes to the desktop's presentation layer, and what is worth keeping
from them is not the diff. Each rule below is held by a guard that fails
when it is broken, which is why they are standing instructions rather than a
changelog:
38c1477 transcript visual hierarchy
49b5746 turn-policy ownership
637c0ff certified primitive callback roots
47e518f anchored pane-close confirmation
3966791 settings discovery and apply truth
b0af1d1 motion has explicit causes
2ebfff6 lifecycle-aware inspector composition
Seal A of the action census stayed held throughout, and the one time Seal B
moved it was the instrument being corrected rather than debt being added:
certifying a primitive's callbacks brought the Picker call sites into the
universe, one of which switches the model and had never been counted.
The rail drew the same ten panels in the same order whether the turn was
running or long finished. While it runs the questions are "is it alive,
what is it doing, how much room is left, where is it in the plan"; once it
is over they are "what changed, what came of it, what did it cost" — and
the answer to the second set sat below six answers to the first.
Two postures, and deliberately only two. A rail that reorders itself on
every change of fact is not lifecycle-aware, it is unstable, and a reader
who has to find the same panel twice in one turn has lost more than the
ordering gave. Working leads with context, runtime, agents and the plan;
review leads with files, plan and jobs, and context drops — how full the
window is matters while there is a turn to fit in it and is forensic once
there is not.
The plan moves up rather than staying with the after-the-fact group. While
a turn runs it answers where it is and what is next, which is orientation,
not an audit trail.
Posture comes off the run and never off what the panels hold. It is derived
beside runState because "halt" answers two different questions — a turn
waiting on a decision has not finished, and a turn that ended badly has —
so blocked is asked separately rather than read back out of the state that
already collapsed them. The rail was reading `!running`, which called a
turn waiting on your approval finished.
One list of panels, keyed, reordered. Two branches of JSX would be the
obvious way and the wrong one: the same panel under a different parent is a
different element to React, so changing posture would tear every panel down
and build it again — losing whatever was open inside and asking again for
whatever it reads. Changing which reading the rail is for is not a change
to any panel's lifetime, and there is a guard that fails if it becomes one.
Priority is not presence. Whether a panel appears is still its own
contract, in both directions: Plan draws nothing without steps, and Jobs
reports zero on purpose so the order does not move under the reader every
time the last background task finishes. Composition decides where something
goes, never whether there is something.
The diagnostics drawer this was going to grow is not here, and that is a
finding rather than an omission. Mcp already speaks only when a server has
failed or has never been answered for — a drawer would add an empty one to
every healthy rail and shut it over the one thing worth seeing on a rail
that is not. Ordering it last is the whole of what it needed.
Three panels gained the id the others already had, so what the rail put
where can be read off the document instead of guessed from the words
inside it. Three sabotages: two conditional subtrees, an order that follows
the data, and a wrapper that overrules a panel's own silence.
Also recorded rather than fixed: there is no head card. Three comments in
this rail describe one, and Context still carries a row=true form for it
that nothing passes. That is panel API, which this change does not touch —
but the comment that sent readers looking for it is gone.
Seal A held, Seal B 98, and the action universe unchanged at 418 inputs and
250 unnamed. Reordering what is already there gives nobody a new thing to
do, which is what this should have cost.
Two places took a DOM node coming into existence for something happening.
A transcript card animates in whenever it mounts, while blocks of 48 mount
and unmount as the reader scrolls — so a fact from an hour ago arrives
again every time its block comes back. A settings block does the same on
every section change, and four of them flying in says four new things
appeared when the reader only walked to another page.
Entrance is now a debt, owed once and spent by the render that draws it.
The projection keeps it, because only the projection knows whether a card
is arriving or being replayed: a restored transcript owes nothing, a card
the live reducer first materialises owes exactly one, and a delta rewriting
a message it already made, a dispatch settling into its result, or a block
scrolling back into the document are all the same card and none of them is
a new fact. Kept beside Item rather than on it — what a card is does not
depend on whether its arrival has been shown yet.
Provenance would not have been enough, and that is the trap this is written
against. A card that was live three hours ago is still live-origin when
virtualization brings it back, and a mark meaning "this came in live" would
animate it a second time. What has to be asked is whether the entrance is
still owed, which is a question with an answer that changes.
The debt is spent by render and never by the animation. Reduced motion runs
no animation at all, an element can leave the document before one ends, and
either would leave the debt standing for a card to arrive again later.
Motion does not get to own the state machine.
The mark rides a display:contents frame around each row rather than a prop
threaded into twelve card components. Measured rather than assumed: every
card still has its block's exact box, content-visibility still applies, and
the turn anchor's margin still collapses to the same 28px.
The settings sheet gets the same treatment from the other end. Its blocks
stop animating and the column is keyed on the section, so changing section
is one movement of the thing that changed — the view — instead of a stagger
of everything mounted beneath it.
The rule is written down twice where it will be read: beside the motion
tokens, and as a guard over the stylesheet itself. That guard is the only
thing standing between this and someone writing `.call { animation: card }`
again, and it needed vitest to stop stubbing CSS — through ?raw it was
handed an empty string, which every rule in it would have passed.
Four sabotages, each reddening its own half: not spending the debt, giving
it to a restored transcript, re-owing it on every revision, and putting the
settings rule back. Seal A stays held, Seal B stays at 98, and the action
universe is unchanged at 418 inputs and 250 unnamed — this adds no way for
a person to act on anything, which is what a motion change should cost.
testkit gains an IntersectionObserver: jsdom has no viewport and therefore
no intersections, and the transcript asks one which block is near.
The comment over the composer's pending state said every control on that
shelf rebuilds the runtime. Reading the endpoints for UX-2D showed it is
true of the model and the effort ladder and of nothing else there: preset,
plan mode and tool approval set kernel state and return, with no rebuild in
the path.
Behaviour is untouched — the pending state is worth having either way,
because a control that looks dead for half a second is the thing it fixes.
What changes is that the next reader is not told something the kernel
disagrees with, and is pointed at the kernel rather than at this file.
Two questions the settings screen could not answer about itself. Finding a
setting meant already knowing which of fourteen pages it was filed under.
And what a change would do was one sentence at the top of the whole sheet —
"takes effect immediately; things that rebuild the runtime cannot be changed
while a turn is running" — which speaks for two of the three answers and
leaves the reader to work out which one they are holding.
Both are now facts in a table, not something to work out.
The search corpus is that table and never the page. Sections mount one at a
time, so reading the DOM could only ever search the page already open, and
mounting the rest to search them is how a page nobody asked for starts
making requests. Typing 代理 finds the network page without opening it, and
the guard measures exactly that: the block is absent from the document and
the port is asked nothing it was not already asked.
Keywords buy a way in and nothing else. 代理 is not what that setting is
called, the result says the setting's own name, and no alias may be spelled
like some other setting's title — the same line R3 drew between what makes
something discoverable and what makes it what it is.
When a change lands is declared per block, from what the endpoint behind it
really does. Every value here was read off whether that handler reaches the
kernel's rebuild, not off a hint's wording or a handler's name: model,
effort, roles, permissions, sandbox, shell and the context bounds rebuild;
preset, plan mode and tool approval do not, whatever the composer's comment
said. Storage and the interface language are the third answer, and that one
has to say both halves — saved already, in force after a restart — or
closing the sheet reads as losing what was just typed.
Group's id is required, so a settings block cannot be written without
naming itself, and a name the catalogue does not carry fails. That is the
whole of "no setting may be silent about its cost": there is no default to
fall back to and nowhere to leave it out. The appearance page builds its
nine blocks by hand rather than through Group, so ApplyNote is separate
from it and those blocks answer the same question the same way. What counts
as a block is having a heading — Storage draws three frames with none, and
they belong to the block above them.
Three sabotages. Searching only the open page, deleting a catalogue row,
and defaulting the cost to "immediate" each turn their own guards red; the
last one is checked by two blocks alike in title, shape and children that
say different things because the table says different things.
The search is a view and stays one. Its results jump through
settings.section, the id the nav already uses, because arriving from a
search is the same arrival — no second identity for finding a thing. Seal A
stays held and Seal B stays at 98.
Not done here, and not silently: the preset block's hint still explains
that it takes effect immediately, which the label under it now also says.
Trimming duplicated copy is worth doing and is not what this change is
about.
Closing a pane that is still working asked first, and asked by replacing
the tab strip with the question. Three tabs became one sentence, so the one
thing needed to answer it — which of them this is about — left the screen
along with everything else. The answer also acted on what was running when
the question went up, which is not the same set as what is running when it
is answered.
The question is now a bubble under the tab it is about, through the same
viewport seam the context menu already uses, and the strip stays exactly
where it was. Escape and a press outside cancel it, changing nothing, and
put the keyboard back on the close button that asked — found on the tab
rather than held since it was pressed, because the pane it sits on may have
gone in the meantime.
What is still live stopped being something the question owns. Only the ids
are kept; which of them still exist and which are still running are read
off the current tabs on every render and again when the answer comes. A
pane that finishes while the question is up is closed without the sentence
about stopping it, one closed from somewhere else is never handed back to
the host, and a question whose panes have all gone takes itself away rather
than offering to stop what is not there. The wording follows the same
derivation, so it cannot say "3 panes" about two.
Closing panes keeps one identity. The confirmation is a view; pane.close
is answered where it was asked, with one|others|all still on data-value,
and cancelling names no action because it performs none.
Three sabotages, because a confirmation that merely looks calmer is the
easy thing to ship here. Trusting the open-time live set, sending the
unfiltered ids, and putting the strip-replacing shape back each turn their
own fixture red — the last one takes five of the ten with it, which is a
fair measure of how much that shape was hiding.
Two things the census caught that a screenshot would not. The effect that
withdraws a dead question first declared its dependency on present.length,
a value derived in that render and traceable from nowhere else; the census
lost the whole cell's provenance with it and five unrelated handlers in
this file went from read-only to "might write". Depending on the sources
instead — the confirmation and the tabs — is both the honest edge and the
resolvable one. Seal A stays held and Seal B stays at 98.
The English catalogue reached the tree's 800-line ceiling on the way, and
the usage and cost section moves out whole rather than the budget moving.
The dialog is named by the question it renders, which is one key fewer and
a name that cannot drift from what is on screen.
testkit gains scrollIntoView. jsdom implements no scrolling, so the strip
keeping the selected tab in view was throwing inside a requestAnimationFrame
on every one of these guards — outside React, reported as an unhandled error
the file still passes with, which is how a real one would go unseen.
The action census placed a certified primitive's call site by the DOM event
its handler prop is named after. Picker's is onPick, which names no event,
so every Picker call site sat outside the interaction universe — and one of
them switches the model, a kernel write with no identity. Seal A read as
held the whole time. It was true, over a denominator that had a hole in it,
which is the shape of assurance this tree exists to refuse.
What a callback means is now the primitive's to declare. One list says
which components are primitives and, for each, which props carry a
person's interaction and which DOM event inside the component raises them.
onPick is an interaction because Menu says so and its body does it, never
because the name starts with "on".
The declaration is checked rather than believed. primitives.test.ts parses
each primitive and requires a handler for the declared event from which the
declared prop is actually reachable, so a certified callback with nothing
behind it fails. Its own negative control asks the same question about a
prop no body raises and requires the answer to be no.
There were two copies of the primitive list, the vitest gate's and the
census tool's, and adding Seg to one of them is what broke a seal at 1 —
reported as an input proven to write the host that the product names no
action for, which is a true sentence about a bookkeeping slip. There is now
one declaration, both callers pass nothing, and Options has no skip to pass:
the way to keep two lists in agreement is to have one. A third list, CTL,
went with them; nothing had read it.
Three fixtures and three sabotages, because a rule nobody has seen fail is
not a rule. A certified call site becomes a root; an ordinary component
with a prop spelled the same does not; a prop the primitive does not
declare does not. Each was broken in turn — spelling restored, every
component promoted, undeclared props admitted — and each turned its own
fixture red. The tool's own options are held to naming a resolver and
nothing else, which reddens the moment it starts deciding this again.
One of those fixtures was testing nothing and passed a sabotage that
promoted every component in the tree. It resolved its imports against the
product's files, where its ordinary component does not exist, so a test
about ordinary components was a test about unresolved ones. Fixtures now
resolve within their own universe.
The frozen fixture corpus caught the first implementation. Anchoring a
primitive's identity to a literal "/src/" un-certified the corpus's own
Switch, whose two call sites lost their separate actions to one root inside
the component — the exact rule that corpus was written to hold. A primitive
is matched by its path within whichever tree is being scanned, and those
reports are byte-identical again rather than rewritten.
The denominator moves and does not compare across this change: 413 inputs
to 415, unnamed 249 to 250, Seal B 97 to 98. The two arrivals are the
Picker call sites that were never visible. One is the composer's model
switch, which takes model.select — the id the settings sheet has always
rendered for the same intent — and Seal A is held over the corrected
universe rather than the incomplete one. The other is onboarding's, and it
joins the 98 that Seal B is still open on.
Three values decide how a turn executes, and they were kept in two places.
The preset sat in the title bar; effort and tool approval sat under the
box. Someone writing the next message had to look at opposite ends of the
window to know what would happen when they pressed send, and neither end
said the other existed.
They are one question, so they are one surface. The composer's shelf now
reads: attach, model, plan, and the three current values themselves —
均衡 · high · 询问 — which open one panel holding all three. Not the word
"settings": a summary that does not say what is set answers nothing, and
the shelf is where the question is asked.
Model and plan stay outside it. The model is the most-changed and most
recognisable choice on the shelf, and plan states an intent for this turn
rather than a preference that persists.
The settings sheet keeps its full version of all three and is not a second
owner: it is the complete, explained surface, and the composer is the
turn-local projection of the same canonical state.
No identity was invented for the move. The preset keeps chrome.preset,
which the settings sheet has always rendered too — where a control is
drawn was never its identity. Effort and approval reuse reasoning.effort
and tool-approval.mode, which that sheet already owned. One new id, for
opening the panel.
Three fields drawn together must not become one call. The kernel has three
effects and the panel makes three, each with its own pending value and its
own refusal, so a rung this endpoint will not take says so under the
ladder it belongs to and leaves the preset that did land alone. The shelf
itself stops greying as a whole for the same reason: "nothing here can be
changed" was never true, and only the control actually waiting is marked.
Seg is the second interaction primitive after Switch — a group whose call
site owns the action, with data-* passed through to each button and the
answer on data-value. Its handler is called onClick rather than onPick
because the action census places a primitive's call site by the DOM event
its handler stands for, and a prop the DOM has no event for makes that
call site invisible. That is not hypothetical: every Picker in the tree is
uncounted today for exactly this reason, which is a gap worth its own
look. The primitive has to be declared twice, in the vitest guard and in
tools/ui-census, because those two keep separate lists of the same fact —
also worth its own look, and not widened here for anything but a real
primitive.
Three things the move orphaned are removed rather than left: the menu
item's meter, and the level bars and gate ring the two pickers drew with.
Nothing produces them now, and a zero-consumer field is the shape this
tree keeps deleting.
Verified rather than asserted: the interaction proof that covered the
preset in the chrome moves with it and grows the two guarantees the old
placement could not have — that one field waiting leaves the others live,
and that picking one calls one setter and not the others. Seal A of the
action census returns to held; it broke at 1 while Seg was undeclared, and
Seal B is unchanged at 97.
Six kinds of fact share one card: what you asked, what it did, what it
finally said, what it needs decided. They shared the weight too, so a turn
scrolled back through read as one long list of the same row in different
colours, and the answer — the thing the turn was for — was drawn in the
grey reserved for process text.
Four weights now, and only the weight moves. Membership is untouched,
semantic projection is untouched, and no card shows less than it did.
The answer stops being a timeline entry. Its body takes --text where the
transcript's process text keeps --muted, it gets the panel's vertical air
instead of the card's, and its symbol loses the ring and ground that say
"there is a control here". The turn anchor keeps its rule and gets a
paragraph's distance above it rather than a line's. Work rows are one line
each: a long argument gives up characters now instead of folding the row
in two, because rows of unequal height cannot be scanned for structure,
only read one at a time.
The raw tool id leaves the ordinary card, on a criterion rather than a
list. It is shown when it says something the name above it does not — the
interpreter a shell call really went to, the remote tool a capability
resolved to — and dropped when tagFor would only hand back the string
labelFor was computed from. "Read" beside read_file is one fact printed
twice and it was on every card in the record. A list of tools to hide it
for would have to be maintained against a registry that grows; the
derivability test does not. The exact id stays one hover away on the name,
so nothing became unauditable.
The reading measure is deliberately not narrowed. That was settled
already: fencing the prose at 68ch while terminal blocks and diffs stay
full width puts two column widths on one screen, and the tier is carried
by colour and air instead.
Measured against the same markup through the real compiled stylesheet, not
asserted: the answer's body moves from --muted to --text, its padding from
11px to 17px; Read and Search drop a header element each and stop
wrapping; Bash keeps bash and an MCP call keeps its server and remote
name.
What this does not do is fix the entrance animation. Every .call animates
on mount while blocks of 48 mount and unmount as they scroll, so a fact
from an hour ago re-enters as though it just happened. Telling those apart
needs provenance carried to each card — the DOM's shape cannot say it,
since only an unfinished say is rendered outside a block — and that is its
own change.
The protocol said the sample is ordinary use and leaves out benchmark and
fixture runs. Nothing enforced it. The only thing keeping the twelve designed
turns out was that they happened before the freeze, which says nothing at all
about the next scripted run — and the session store already holds 481 project
directories from past automated ones, every future sibling of which would have
walked straight into a sample that calls itself natural.
A driven run lives in a scratch directory. So a workspace under a temp root is
not ordinary use, and its sessions never enter the sample. The check asks the
host for the slug it writes such a path as, rather than looking for words in a
directory name: the encoding is the host's own and reading it back is a fact,
while matching "scratch" or "tmp" would be a guess about how someone names
things.
Seven of 488 project directories survive it, and they are the ones a person
actually works in. The count of what was turned away is printed with the rest,
so a sample that stays empty says why.
Twelve turns from a written task list said singletons were 17% of groups and
two thirds of turns had three or more calls to fold. Those numbers answer "how
good can this be where it fits", and they cannot answer "how often does
ordinary use have anything to fold" — the task list decided how many tools
each turn needed, half of it was routed plan-first when nothing in ordinary
use routes that way without being asked, and every one of the twelve called at
least one tool, which no real day does.
So that assay is closed rather than continued. It is recorded as a reference
condition with its raw trajectory kept outside the tree, and the natural
sample starts from its own freeze, after it, from zero. Folding the designed
turns into the natural denominator would put two sampling mechanisms in one
number and neither question would come back out.
The gate is untouched. It was written before any sample existed and has now
seen one; redrawing it here is exactly what would spend that.
Two things the natural sample reports that the designed one had no room for. A
turn with no tool call at all is in the sample, because a fold that has nothing
to show is evidence about coverage rather than a turn to exclude. And coverage
itself is printed and deliberately not gated: adding it to the verdict after
seeing a sample is redrawing the line, while leaving it out of the report lets
a low singleton rate read as broad reach when a fold might touch half the
turns.
The package's own doc now carries what the twelve turns actually established,
which was not a number: three call lifecycles reach a fold and only two of them
are calls, because Partial=true says arguments are still arriving and never
promises that a completion frame follows.
Twelve real turns broke the partition on the ninth. A group closed while one
of its members was still running, which the semantics pass had said could not
happen — and the member was `conclude_no_changes`, dispatched as a streaming
partial and never settled, because calling it is what ends the round. No full
dispatch, no result, and the id stayed open for the rest of the record, so
every later boundary sealed a group over a call still in flight.
The frame's own contract already said this: a partial marks a dispatch whose
arguments are still arriving and promises a full one when they land. For a
tool that ends the round that promise is never kept. So a Call materializes
from a full dispatch or a result, never from a partial alone.
Ten fixtures missed it for one reason worth recording: the stub provider
answers with complete tool calls and never streams one, so an entire class of
frame was absent from every turn the semantics pass ever saw. The fixture
added here drives ChunkToolCallStart with nothing after it and asserts the
record really holds a partial-only id before folding it — a shape test that
never sees the shape proves nothing.
The rule was written after the sample was collected, which is worth saying
plainly. What had been seen at that point was the legality failure and the
frame shapes behind it: 175 calls, 2 never resulted, both partial-only, no
discarded attempts anywhere. None of the assay's five numbers had been
printed, and the rule is read off the field's documented meaning rather than
off anything the numbers do.
Whether a turn's execution is worth offering as one foldable unit is a
question about ordinary use, and the only honest way to answer it is to watch
ordinary use. Designing tasks to cover the interesting cases would put the
task designer's choices into the granularity being measured, which is the one
thing this cannot afford.
So the protocol is fixed now, while no sample exists to fit it to. The first
twenty eligible authored turns after the freeze timestamp, in the order they
happen — not the last twenty, not a chosen twenty, and not twenty-four because
the answer landed near a line. Eligible means the turn carries a named
authored turn, which only the current schema emits and a synthetic
continuation never gets, and that its trajectory reads complete, because a
prefix cannot answer a question about a whole turn.
A turn with no tool calls is in the sample. The question is how much of
ordinary use has something to fold, and dropping the quiet turns would answer
a different one and flatter the feature. There are no route quotas either: the
mix of one-model and two-model turns is reported as a description of what
happened, never selected for.
The tool refuses to say anything but the count until the sample is whole.
Reading a partial result and acting on it is the sequential decision the
freeze exists to prevent, so there is nothing to read.
The verdict is one gate with both halves required — most groups holding a
single call AND few turns having three or more to fold — and failing it is not
an automatic go: a partition can be coarse and still rare, or common and still
thin, and those are different products.
The fold this measures with is the one the semantics pass settled, moved out
of that test rather than copied: two implementations of a partition drift, and
the assay and the fixtures have to be reading the same rules. The gate itself
runs on samples built to sit on each side of it, because an instrument nobody
has seen produce a number is one nobody has checked.
A partition was being argued about frame by frame, which is how a barrier
landing between a dispatch and its result came to look like a boundary between
two calls. It is not: it interrupts one. So the fold now atomizes first — a
tool call is one Call however many rounds it spans and however many decisions
it waits on — and only then groups.
That reordering settled the duplication on its own. Six of seventy-six calls
appeared in two candidate episodes on a real corpus; admitting a call once, at
its first frame, makes that unreachable regardless of where boundaries fall.
What a misplaced boundary leaves behind instead is a group sealed while a
member is still running, whose result then belongs to no group at all, and
that is what the invariant now names.
The candidate grouped here is not the round. Rounds are a structural authority
and two of every three held a single call, which makes them an internal step
group rather than something to offer a reader. WorkGroup-v1 is one producer's
consecutive assistant-owned work across rounds: the producer cuts, a line the
user typed cuts, a turn cuts; a round does not, a settled say does not, and
the host advancing its own task list neither counts as a step nor cuts.
Membership is AssistantWork, not ModelWork. A provider-executed call is not
something the model issued and is still the assistant working, so it needed a
second predicate rather than a widened first one — collapsing them is how the
taxonomy the issuer axis exists for gets folded back up.
Ten rules, each with a turn the production assembly really produced, and six
sabotages that switch one rule off and require a fixture to notice. Two of the
sabotages did not fire when first written, and both were findings rather than
test bugs: one because atomization had already removed the defect it was
looking for, the other because the fixture had no work after the host's
advance for a boundary to cut from.
Two rules are marked as mechanism given the state rather than production
shapes, because they are: a user-issued call admits under its own guard today
and lands between turns, and a turn that never closes is real in the record —
12 of 80 — but not something this harness can produce on demand.
Nothing here ships. It answers which partitions are legal, not whether the
natural granularity is worth an interaction, and scripted turns cannot answer
that second question.
The record and the stream had never been compared. A frontend rebuilding a
conversation from /trajectory was trusted to see what a connected client saw,
and the only evidence was that both came from the same broadcaster — which
says nothing, because the durable log deliberately keeps less: stream deltas,
phase markers and progress ticks never reach it, so any fact that lived only
in one of those is lost on the way back and nothing noticed.
So the two readings are now taken from the same turn and compared. Six turns
are driven through the production assembly — control, eventwire, broadcaster,
wire log, the real /trajectory handler — with a stubbed provider and nothing
else stubbed: one model saying and calling in turn, two models in one turn, a
host advancing a list beside the model's own calls, a provider-side call that
reports a result and never a dispatch, and an approval and an ask landing
between a call's dispatch and its result.
What is compared is what a partition would be computed FROM — turn identity,
producer, issuer, round, call lifecycle, interruptions, settled says — and
never a partition. Grouping is the thing under study, and comparing it here
would only have proved both readings ran the same algorithm.
The asymmetry is the point: the live reading knows a say's text from its
deltas, the cold one has only the settled frame. Three destructive controls
hold that the gate is reading the fields it claims to: strip the producer, the
issuer, or a settled say's text from the cold reading and it must fail. It
does, on both layers.
The second layer is the desktop's own reducer, fed the same turns through a
committed file the kernel regenerates — a fresh store each time, never one
cleared and reused, so a reload cannot pass by borrowing turn-local state the
live run left behind. A staleness gate fails the file when it stops being what
the kernel emits, because a reducer checked against a shape nothing produces
is checked against nothing.
A tool frame said what ran and never who asked for it. Three provenances
share one id namespace and one shape: calls the model issued, calls the host
minted for it — seeding an approved plan's list, advancing a step, closing a
goal, expanding one delegation call into its items — and calls the provider
ran on its own side. A person's slash command and inline shell line are a
fourth. Reading a real corpus, 5 of 51 candidate execution episodes were
headed by `plan-seed`, the host's own bookkeeping, which any fold counting
steps would have credited to the model.
Nothing downstream can recover this. The shapes that look like provenance are
transport facts: a provider-side call has no dispatch because it never had one
to emit, and an attempt id marks a speculative partial, not an author — the
provider-side result carries one and was not issued by the model. What is left
is matching id spellings, which is what this tree does not build.
So the issuer is set where the invocation is decided, and it is a second axis
rather than a widening of Source: Source says who wrote the frame, Issuer says
who asked for the call, and one executor frame can carry any of the four. It
holds for a call's lifetime — a dispatch and its result cannot disagree about
who asked — and a result-only call carries it alone, with no dispatch invented
to hang it on.
ToolIssuer.ModelWork is where "is this the model working" lives, so an unset
issuer answers false rather than guess: guessing model is what counts a seeded
task list as a step. The gate is checked destructively — flip the host's
issuer to model and the count changes, which is what makes the field, and not
the call's shape, the thing being read.
/trajectory answered with a bare array, and three different states produced
the same empty one: a run nothing records, a log the capacity cap stopped
extending, and a recorded session that had produced no frames. A consumer
reading the last frame as the end of the run was right two times in three and
had no way to know which.
The read model now carries its own coverage. complete means no frame was
refused between the session's start and what is durable now — and an empty
events list under it means the session produced none. truncated means the
frames are a prefix and nothing more. not_recorded means nothing recorded this
run at all, which is what a studio pane is before its first turn.
Truncation is a durable fact or it is not a fact: the refusal happens in one
process and nothing in the bytes it leaves behind records it, so the cap writes
a witness beside the log. A log's own size is not consulted — past the cap the
next frame will be refused, but until one is nothing has been dropped, and
reading truncation off the size reports a session that grew large as one that
lost frames. not_recorded likewise comes from the writer's own precondition,
never from a missing file, which cannot tell a run with no recorder from one
that has not written yet. A log that cannot be read is refused rather than
answered: an empty array is a claim, and a failed read supports none.
The pane says which of the three it is holding, and the exported file carries
it too, since that file outlives the window that made it.
A two-model turn put two producers on one stream and said nothing about
which was which. The kernel knew — the planner's sink has labelled its
events all along — but eventwire had no field for it, so the label died at
the transport. Everything downstream was left with the same guess: read
the last phase marker before the frame. That is an adjacency, not a fact,
and the record does not even keep phase frames, so a rebuilt transcript
could not make the guess at all.
Producers now name themselves. An agent is built for a role and labels the
frames it settles; a forwarder that re-parents another agent's work
relabels on the way through, because in the parent's stream a sub-agent's
call is the sub-agent's whatever role the child plays in its own. The
field crosses eventwire, so it is on the frames the record keeps, and a
card rebuilt from those frames carries it.
Streaming deltas stay unlabelled, and finding out why was the useful part
of this cut. Coalescing merges only deltas that carry nothing but text —
it decides that with a zero-probe, so any new field on a delta silently
turns the whole optimisation off, which is exactly what labelling every
frame did: forty provider chunks reached the frontend as forty events. A
delta is a fragment of a message whose settled frame names its producer,
so naming the fragment says nothing new and costs the stream. The boot
effect test is what caught it, at the boundary it was written for.
That leaves one asymmetry already in the tree: the planner's own text
deltas have carried a source since before this change, so they have never
coalesced. Not touched here — it is the coordinator's direct emissions,
not this seam, and the card takes its producer from the settled frame
either way.
The destructive control runs both ends. Strip the field from the transport
and a real two-model turn fails on the frames themselves; strip it from
the frames and the cards go unattributed rather than falling back to the
nearest phase marker — which would look right in the fixture and be wrong
the moment a frame arrives out of the order the markers suggest.
Tool cards do not carry it yet. The fact is on the frame they fold from,
so that is a projection when episode membership needs it, not another
fact to establish.
A reopened session lost every answer the planner ever wrote. The record
keeps no streamed deltas — one frame per chunk, most of the file, nothing
to read back — so a replay has only the settled `message` frame, and the
reducer could not build a card from one: it knew how to seal a card the
stream had already opened, and nothing else. Feed it the record and it
produced tools with no answers between them.
The frame is the assistant turn in its settled form, so one fold now
serves both directions: it settles the open card when the stream opened
one, and builds the card when nothing did. Measured on the recorded
routes, a two-model turn's replay goes from two items to six — the same
six the live stream drew, planner's plan text included — and the
conclude-no-changes route from two to five.
The frame also wins on content, which it had already earned: it carries
the display-correct text with the host's own markers stripped, and the
reasoning an extension may have replaced, emitted precisely so what is
shown and what is persisted agree. So a stream that lost a chunk is
repaired by it rather than left short. An empty field is not content and
overwrites nothing: a tool-call round settles with reasoning only, and it
must not wipe the answer already on screen.
That closes the fork this series was heading for. The durable execution
trajectory does not need building — it exists, and it already carries the
planner's work; what was missing was a reducer that could read it.
The say card's lifecycle moves to its own file with the fold, and the id
sequence to another. Both files were at the 800-line ceiling, and the
streaming, sealing and materialising of one card is a single concern that
had been living inside the reducer. The sequence had to come out for the
new file to mint an id without importing the reducer back.
Reopening a session with an approved plan in it drew a user bubble nobody
had typed. The host writes a continuation into the transcript to carry
approved execution — "Plan approved — plan mode is off…" — and the rebuild
turned every user-role message with text into a card, so a line the host
wrote came back attributed to the person. Live it was never a bubble at
all, which is the shape of the problem: one message, two truths, and the
one that survives a reload is the wrong one.
Authorship is now declared where it is known. The host composes those
lines, so the host says so: the turn boundary control already hands the
runner — it says whether this run continues a turn already announced —
gains the other fact it had all along, and the landing seam stamps it onto
the message. /history projects it, and the rebuild draws no bubble for it.
Declared, not recognised. There is a prefix table in this tree that spots
host-injected text, and it would have been one line to reuse — and wrong
in both directions: a user who types the sentence the host injects loses
their own message, and a continuation phrased any other way keeps
impersonating them. The control runs both ways at the kernel and again at
the rebuild, and the two cases differ only in the flag, never in what they
say.
The two facts stay apart on purpose. A turn that opens no authored turn is
not the same as a message the host wrote: a user's line only has to read
like one of the host's to open no turn, and it is still theirs. So the
boundary carries them as separate fields rather than reading one off the
other's absence.
At the HTTP boundary an approved plan now leaves exactly one user line in
the record, and it is the one that was typed. Measured on the real thing:
the live/reload census fixture for that route dropped its phantom row.
ParseImageDataURL moves to its own file. provider.go was at its recorded
ceiling and a struct field cannot be added to a file that may not grow;
the parser was the piece with least to do with the message model around
it, and moving it is what made room rather than widening the budget.
Sessions recorded before this carry no declaration, so their host lines
still draw a bubble. Nothing reads the wording to guess at them.
A turn had as many starts as it had model runs. The coordinator opened one
before planning, the executor opened another before working, and a plan
approved mid-turn opened a third — so "a turn began" meant "an agent run
began", and the only thing telling the two apart was whether the event
happened to carry an identity.
The route that made this worse than untidy is production-reachable. When
the planner concludes no changes are needed, no executor ever runs: the
user's message is written straight into the transcript by the coordinator,
and the turn's only start was the bare one from before planning. So a
durable authored message existed that the live stream had never named —
the row got no rewind entry until the session was reopened and the
transcript rebuilt. Two truths about the same message, which is the thing
this series exists to remove.
Control now opens the boundary. It already had both numbers before any
work could produce the message: the index it opens the checkpoint at and
marks the turn in flight with, and the raw authored text it puts in the
context itself. It publishes them as one TurnStarted before the runner is
called, and hands the runner the identity to land on. Four seams take it:
the orchestrated turn, the headless one, a synthetic continuation (which
carries ownership with no identity, because it belongs to a turn already
announced), and the slash-invoked subagent skill — that last one was a
fourth start emitter and a third place that appended the user's message,
found while doing the work rather than in the preflight that preceded it.
Identity is the host's; representation stays with whoever writes the
message. A plan-and-execute turn still persists the handoff as Content
while the user's own words stay in RawContent, and that distinction is
worth keeping. What the seam takes away is the freedom to disagree about
which message the turn was about: the raw authored text comes from the
published identity, and the landing is checked against it. The check
detects and cannot repair — the name is already out — so a break is an
operator notice with a code, and the durable index stays the authority a
reload reads.
persistExecutorNoOp lands through that seam now rather than composing its
own message beside it. It had been setting RawContent empty whenever
nothing wrapped the input, which left the classification reading a
different field on that path than on every other; it agreed with the
others by coincidence, and the coincidence is now a contract.
Suppression is declared, not observed. A run reads a host boundary out of
its context; nothing infers that it has been announced because an event
already reached the sink. A standalone Agent.Run is still its own
lifecycle owner and still announces — that contract is not collateral to
a desktop fix.
The coordinator's filter on planner lifecycle events is NOT dead and was
kept. Under a host boundary the planner announces nothing, but standalone
it still opens a turn over its own session, and that name would reach the
parent as a second answer to which message the turn is about. A test runs
both ways rather than a grep deciding it.
Six production routes are now a matrix rather than an observation:
executor-only, plan-and-execute, conclude-no-changes, an approval the user
denied, planner failure falling back to the executor, and the subagent
skill. Each asserts one named start, no bare one, one end, that the start
precedes every visible piece of work, and that the message it named
classifies as the authored turn it said. Each also asserts whether the
executor ran, so a route that quietly took another branch cannot satisfy
the matrix. The negative control holds the other end: an approved plan's
execution opens neither a start nor a done of its own.
Two deliberate behavior changes come with the ownership move. The text
renderer resets its per-stream state on a phase marker instead of on a
second turn start, so the executor's thinking still gets its own header
now that planner and executor are one turn; a byte-exact dual-model
fixture pins that boundary. And telemetry's turn count changes meaning: it
counted starts, so a two-model turn counted twice and an approved plan
counted again. It now counts authored turns, which is what the metric was
always named after.
Not in this cut: which items belong to a turn, and where an execution
episode begins inside one.
turn_started carried nothing, so a client that mints its own user row had
no way to learn which message the kernel had just written for it. The row
and the snapshot taken for it were paired by position and confirmed by
prompt text — which is why two identical prompts could only be told apart
by luck, and why a steer sitting in the transcript shifted every pairing
after it.
The event now carries the message's provenance: AuthoredTurn, the number
the conversation gives the turn this message opens, and MsgIndex, the
session index it takes. They are two namespaces and neither is the
checkpoint's Turn, which numbers snapshots — the first authored turn is 1
while the first checkpoint turn is 0, and TestCheckpointTurnIsNotTheAuthored-
Turn reads both off one real run so an implementation that sourced one from
the other cannot pass.
One rule answers both projections. ClassifyTurn is what the display-index
sidecar already counted a transcript with, now named and exported, and the
live path asks it the same question about the message it is about to
append. The message is composed before the turn is announced so the
announcement can classify what actually goes out rather than a stand-in
for it, and the prior count is re-derived from the transcript rather than
tracked beside it — a second counter is a second thing to be wrong. A turn
that opens no authored message (a steer, a host-injected turn) names none
rather than minting a number.
The join then goes structural on both sides. checkpoint.Meta carries the
boundary it was opened at, /checkpoints and /history publish it, and
pairCheckpoints is a lookup on that index — no position, no prompt
equality. A row that names no message gets no rewind entry at all, which
is the right refusal: a rewind cannot be undone, and an entry against the
wrong turn throws away work nobody asked to lose.
The client joins by causation, not by adjacency: a send is remembered
until the kernel names a message for it, in the order the sends were made,
and a name that no longer names a row — taken back, or replaced by a
rebuild — is dropped rather than handed to the next turn. Guidance queued
mid-turn never joins that line, because it starts no turn of its own.
Six proofs, at the three boundaries: the agent alone names its turn (that
layer cannot import control, so no checkpoint state can reach the number);
the live number and the durable display index agree about the same message;
a steer and a synthetic turn each mint nothing and leave the number for the
next real turn; the checkpoint boundary equals the index the event named;
and at the HTTP boundary two identical prompts pair with their own
snapshots.
Not in this cut: the other eleven item kinds still carry no turn. Which
items belong to a turn, and where an execution episode begins inside one,
is a separate question — this only establishes the boundary it would be
asked about.
classify follows the callbacks a .then/.catch/.finally is handed, and only
where the fact proves the receiver. The edge is deferred, because a
handler that mutates when the request comes back is not one that mutates
now. .then's second slot is the rejection handler and carries the edge
.catch carries, so which member was written decides nothing. And an
optional continuation keeps MayCall at the hop that may skip it rather
than flattening it — the first draft reached the write and lost the
evidence, which is the failure the optional-call round was about.
Eight roots move, every one UNRESOLVED to READ_ONLY, and every one inside
the thirteen whose only open cause was this selector. No MUTATION became
READ_ONLY and no READ_ONLY became MUTATION. 410 roots, MUTATION 146
unchanged, UNRESOLVED 112 to 104, READ_ONLY 152 to 160; the selector falls
from twenty-one sites to eight, and those eight are exactly the sources
this cut declined to build — a static factory, an async function's return,
a binding typed Promise, a prop call — rather than anything it should have
proven and did not.
The sixteenth invariant holds the boundary: a continuation the walk
executes must trace to a declared return type. Adding an opaque call
result as a source turns it red on both trees and turns PC4 and PC6 into
mutations the corpus is not declared to have. PC6 is the case that
matters — the receiver is PC1's proven type and the member is spelled the
same, and only the declared return type differs.
One thing was wrong for the right reason and is fixed with it. A mutation
reached only through a continuation or a timer was reported as a direct
call on the interaction axis, which is a claim that pressing the control
writes; the family now comes from the edges the walk recorded. It changes
no production count today — no root here writes only behind a deferred
edge — and it is what the day one does will say.
promises.mjs answers one question and refuses to answer a second. A value
is proven to be a Promise by a return type the source declares on a member
of the type its receiver is annotated with, or by being a continuation on
something already proven — a fixpoint over the receiver chain with no hop
limit. Only a literal Promise<T> counts: a union that may not be one, an
alias nothing here follows, PromiseLike, any, and a tree that declares its
own Promise all state nothing. Which type a receiver has comes from the
authority that already answers it, lifted out of receiverKind so the two
readers cannot drift apart.
The order is the point. Taking `.then` off the member and concluding the
receiver was a Promise is a circular proof, and it would buy an execution
edge into whatever that call was handed. Every receiver that stays
unproven is reported with the mechanism behind it — an opaque call result,
a binding the source types Promise, a member the declared type does not
have, a receiver nothing types — because naming a shape is not claiming
it, and the next cut is chosen off that table.
Nothing consumes the fact yet. All twenty-nine reports over both trees are
byte-identical to the revision before this one, which is what will make
the verdicts that move when a continuation becomes an execution edge
attributable to that and to nothing else.
tools/ui-census reads the interface and answers which inputs can change
host state, which of those the product names an action for, and why the
rest cannot be decided. Twenty-two modules over one certified universe:
410 interaction roots, and every verdict carries either a positive
witness or the open edges that stopped it.
Fifteen invariants run in CI. UNDECLARED_MUTATION == 0 is the one this
exists for — an input proven to write with no identity — and it has
reopened three times under real defects rather than sitting decorative:
a memo alias that hid a prop chain, a JSX spread that hid another, and an
optional call the walk could not see. Three more hold holes that are
measured and empty rather than argued: a platform scheduler, a prop
heuristic, and a spread that a later attribute may override.
Judgements read structure. A listener registration, an event parameter,
a promise receiver and a component identity are facts certified once and
consumed by every reader, because the alternative is a member name
deciding what a value is — and this series found that reader disagreeing
with itself three separate times.
_fx is the corpus the rules are exercised against, and its reports are
frozen: the product's name every root by file and line and are stale the
moment a button moves, so only the fixture's can be a permanent gate. The
invariants it breaks on purpose are declared, and it is held to exactly
that set.
Ninety data-action declarations across the interface, and the registry
that says what each id claims to be. Identity is the product's: an id is
<surface>.<intent>, written as a literal at the control and written again
in actions.ts, so the two can disagree and be caught — sharing a constant
would make the check compare a value with itself.
The attribute carries membership only. Which entry a click is about rides
on data-target and which of several answers it gives rides on data-value,
so a row rendered seventy times is still one contract and 「取消」 is
never an identity. An element carrying several handlers takes a scoped
data-action-<event>, because a single declaration there cannot say which
input it names.
Six of these ids exist because a census proved the input writes and the
product named nothing: session.open and remote.open for the workspace and
remote trees, workspace.add, extensions.submit, and file-revert and
rewind's prepare/commit pairs — planning a revert and performing one are
two contracts, and a person who stops between them has done nothing.
roots.ts answers a question two readers were answering differently: the
repository's gate looked at onClick and onDoubleClick, which left every
onChange, every keydown and every DOM listener outside the set it was
meant to be guarding. Certification is two independent questions and
neither may answer the other — what the registration is on, and whether
the event is something a person does to the page — so a data-action says
which action an input belongs to and never whether it is one.
listen.ts is where a DOM listener can carry that declaration. A button
has an attribute and the shortcut table has a field; a listener had
nowhere to put one, so every keyboard entry point was outside the action
vocabulary. The declaration belongs to the registration rather than the
callback: one function registered on two targets is two entry points.
Nothing references either yet. They land first because everything that
does — the vocabulary, its census, and the gate over both — cannot
compile without them.
Every version's notes live in release-notes/studio/<semver>.md and this
one was tagged without them, so the release published the standing text
and a warning. The file lands here for the line's own record; the
published body is set from it separately, because the workflow reads the
notes out of the tagged commit and this is not in it.
studio-v2.13.0 built every package and then failed at "Collect artifacts":
`find: 'staging/cli-archives': No such file or directory`.
The previous change was right about the rule and could not hold under the
download it was written against. Taking the CLI archives by name matched
the reasonix-studio-host.exe the signing flow leaves behind, so it moved
to taking them from their artifact directory — but the download asked for
merge-multiple, which is exactly the option that flattens those
directories away. The path it looked in never existed, and only a tag
runs this workflow, so nothing said so until a release.
Both sets now come from their own artifact. Dropping merge-multiple is
what makes that possible, and it closes the same hole on the other line:
the SignPath flow uploads the unsigned installer it hands over, that file
is named ReasonixStudio-* too, and a flattened staging directory let
whichever move ran last decide which one shipped.
actionlint's shellcheck reads `.deb` inside a single-quoted printf format
as a command substitution that will not expand (SC2016). They are Markdown
for the notes, so the string says so instead: double quotes with the
backticks escaped produce the same bytes and no warning. Verified against
the same actionlint invocation CI runs, with shellcheck 0.11.0; restoring
the single-quoted form reports the issue again.
Nothing in this repository changed to cause it — the runner's shellcheck
did — which is why it appeared on a workflow no commit had touched.
The windows-latest crash moved rather than went away: 1.26.6 found a bad
pointer in the heap during a Green Tea mark; 1.26.8 threw
"runtime.preemptM: duplicatehandle failed; errno=6" — ERROR_INVALID_HANDLE
on a thread handle the runtime owns. That is not a runtime bug. It is what
a process looks like after it closes a handle it does not own.
internal/extension/sidecar closed the Job Object twice. kill() called
proc.KillTracked, which releases the handle, and then finishJob(), whose
sync.Once had not fired yet and released it again. kill() is reached from
four places, two of them `go c.proc.kill()`, so it also raced with itself.
Windows reissues a freed handle value, so the second close lands on
whatever now holds it — a thread handle, and preemption then fails.
internal/extension/sidecar is the package the crash reported.
internal/plugin had the other half: close() is not guarded, so two calls
took KillTracked twice; and its graceful path returned without releasing
at all, leaving a KILL_ON_JOB_CLOSE handle open for the life of the
process and reaping nothing the server had detached.
Rather than ask three callers to remember, the handle now owns its own
release. StartTracked returns a *TrackedJob whose Kill and Finish share
one sync.Once; killTracked and finishTracked are unexported, so no caller
outside this package holds a raw handle to close twice. internal/proc's
own TrackedCommand, which had hand-rolled the same guard, uses it too.
The test counts releases through a substituted release func: off Windows
the real one is a no-op, so a test that watched the handle would pass on
the platform where the bug cannot happen. Restoring the old shape — a
release outside the one-shot — makes it report nine releases where one is
owed.
The windows-latest crash is the GC finding a bad pointer while marking:
runtime.scanObject in mgcmark_greenteagc.go, under gcDrain. Green Tea is
the detector, not the cause — turning it off would move where the
corruption surfaces, not stop it, so this does not do that.
Two things in that binary can produce it and were both behind. The Go
toolchain moves 1.26.6 -> 1.26.8, which carries compiler and runtime
fixes; and modernc.org/sqlite moves 1.57.0 -> 1.58.0, taking libc from
1.74.4 to 1.75.6. libc is the only unsafe-heavy code linked into that
test binary that is not the standard library, and internal/bot reaches
it through historycatalog and projectiondb. Neither upgrade is proven to
be the fix: one crash cannot attribute itself, and golang/go#81238 —
windows/amd64 exception recovery writing below a goroutine stack — is
open, unfixed, and the same class. If it recurs on these versions, that
issue is where it goes.
covergate was red on a third path this had not read: internal/sandbox is
78.0% on linux against a 78.1% floor, while the same tree measures 78.1%
on darwin. The package compiles a different set of files per GOOS, so a
floor recorded on one platform can be unreachable on the one the gate
runs on. Rather than lower it, three statements nothing reached are now
covered — podman's per-user socket, an authority the table does not
govern, and a nil context carrying no escape approver — which takes
darwin to 78.9% and lifts linux above its floor by the same edges.
covergate now names the lines it is short of. A percentage alone left
whoever has to restore it measuring a package by hand on a platform they
may not be on, which is how this one sat red across three runs.
go.mod tidy in desktop/ failed because the root module moved x/crypto to
v0.56.0 and the nested one still pinned v0.55.0. The dependency is
indirect there, so nothing local reads it — and nothing local runs that
tidy either, which is why the root gates passed while the desktop job
did not.
third_party/go-webview2 is left alone: it is a vendored fork with its
own module, CI runs only its tests, and tidying it would rewrite a sum
nobody asked to move.
govulncheck: x/crypto v0.55.0 reaches the advisory through
remote.newSSHClient. Bumped to v0.56.0, which is where it is fixed.
race: matrixSink folds graph deltas in place, and a fan-out publishes
from every worker's own goroutine. The sink now holds its own lock;
agentgraph.Graph is a pair of slices, and the production reader that
folds deltas does it on one goroutine.
covergate: two branches added with the adjudication barrier were never
reached. liveBarrierIDs was only ever asked about asks, so a live tool
approval read as an abandoned obligation; and Ask's refusal when the
barrier cannot be recorded had no case at all. Both are now tests, not
a lowered floor.
macOS: TestDeniedAuthorityIsNotInheritedThroughADescriptor read the
inherited socket once, immediately after dialling, while the peer writes
the marker only after its Accept returns. The positive control lost that
race and the test correctly refused to pass vacuously. The scan now
retries for five seconds, which is also the stronger claim: a clean
sandboxed result means seconds of trying found nothing, rather than one
instant. Reproduced by delaying the peer's write, and the fix holds with
that delay still in place.
The windows-latest crash in internal/bot is not addressed here: it is a
runtime GC fault in a package with no unsafe and no cgo, it passed on
the three preceding runs, and nothing in this tree explains it.
Thirteen more Tier A sites, taken as a cluster rather than in file order so
naming drift shows up while the vocabulary is still in hand: everything that
writes a permission rule, everything that moves the sandbox, everything that
changes what an endpoint is asked to do.
The resolver that found them was wrong, and one of its answers is what said so.
It reported a fold toggle in the rules panel as reaching savePermissions, which
is not something a fold does. The cause was that it followed every identifier a
handler mentions, against a table of locals collected file-wide with no regard
for scope — so a closure parameter named n, or s, or g, resolved to whatever
else in that file happened to share the name. A click reaches a mutation by
calling something, not by mentioning it, and that is what it now follows.
So the Tier A count in the previous change was inflated. Five of the fifty-nine
were that mistake. The number is not the finding here; what the number counts
is, and it had been counting coincidences of spelling.
Two ids stayed two segments rather than three. permissions.add-rule reads as one
surface with one intent, which is the rule; permissions.rule.add reads as a
sub-surface, and inventing those is how a vocabulary starts describing the code's
layout instead of the user's intent.
Four of the thirteen are the sandbox's own controls and one is a permission
rule's removal, so they carry authority-effect: what these claim to change is
what the host is allowed to do, and a promise resolving is not evidence of it.
Nothing proves that yet — the proof column is what the contracts will have to
satisfy, not a claim that they already do.
Naming the two hundred and forty-nine anonymous controls in one pass would have
been labelling, not census. What the risk tiers needed was a fact, and the one
available is what a click reaches: not the button, not its wording, but the port
method behind it and whether that method's own transport posts or gets.
So the resolver follows a handler through the locals of its file and then out
through its props — every place the component is rendered, what the parent
passes for that prop, up to four hops — and lands on the port calls a click can
actually reach. Then the port's implementation classifies itself: a method whose
body reaches post, patch or delete changes canonical state, one that reaches get
does not. A name says nothing here and a return type says almost nothing, since
an enqueue answers with a receipt.
Of the two hundred and forty-nine, a hundred and sixty-five never leave the
page, twenty-five only read, and fifty-nine write across thirty-nine
capabilities. That is the Tier A set, and it is derived rather than judged.
Eleven of them are named here, with the ids the registry now carries: the two
account switches, the ask card's two answers, memory's save, restore and forget,
the config repair, the extension reload, the storage move and the network probe.
The queued line's take-back in the transcript turned out to be queue.cancel
already — the same action the pending strip renders, which is the point of an id
being a semantic class rather than a place.
The ceiling falls to two hundred and thirty-seven and the rest of Tier A is next.
One site in it will stay anonymous on purpose: the switch primitive resolves to
six different capabilities because it is whatever its caller is, and its identity
belongs at those call sites, which already pass it.
A token the endpoint had just turned down was retried unchanged, because the
forced refresh asked the wrong question. It reloaded the stored credential, saw
that the local clock still liked it, and returned — so the retry went out with
the one that had already been refused, and the failure that followed looked like
the server's rather than ours.
The judgement it should have been making needs an identity the call did not
carry. forceRefresh was a bool: it said "refresh" without saying which
credential had been refused, so "the server rejected what I hold" and "someone
else already replaced what I hold" arrived as the same request with the same
argument. No condition written inside could have told them apart. That is why
this is a signature and not an if.
So the ask names the credential that actually went out. Not the one in the store
by the time the 401 came back — that is precisely the one another actor may
have replaced it with, and re-reading it there erases the fact the decision
turns on. Two call faces rather than a nullable at every call site: the ordinary
one, and the one that has a refusal to report.
Under the lock, the stored credential is used instead of refreshing only when it
is both usable and not the one that was refused. Identity alone would send a
replacement that had itself expired; freshness alone is what let a refused token
through, since a server saying no is a fact about the credential that this
machine's opinion of its expiry cannot answer.
Five arms, and each was checked by breaking it. Restoring the old shortcut turns
the refused-token arm and the end-to-end recovery red together. Making a
rejection refresh unconditionally — the fix that looks right — turns the
concurrency arm red instead, a refresh storm caught before it was written. An
expired replacement is refreshed rather than sent. A rejection naming a
credential this client never sent changes nothing. And the ordinary request
still touches no endpoint and leaves the store byte-identical, because this is a
fix to the rejection path and daily traffic should not have got more expensive.
The tripwires from the previous two changes are gone, replaced in place by the
contracts they were holding open: a 401 the transport recovered from is now
proven never to become a failure, which is the negative control any future
classification of terminal auth outcomes has to sit on.
Still not claimed: what a refresh that genuinely fails means for the reader. No
refresh token, an authorisation server that declines, an endpoint that is down,
a 401 that survives a successful refresh — four different things to do next, and
the row goes on offering to reconnect rather than guessing between them.
No production change. This is the slice that decides what the fix has to be,
because the obvious one is wrong and the tests below are what say so.
The credential's identity is the access token. It is the bytes the server was
shown and refused, nothing else in the stored state names a generation, and
none is wanted: a refresh that handed back the same access token would not have
moved the state either. So the question a forced refresh answers is not "is
this token stale by my clock" — which is what it asks today — but "is the one
the server just refused still the canonical one".
Three arms, and two of them already hold. An unrefused token the clock likes is
sent as it is, and a token another actor replaced while we waited for the lock
is used rather than refreshed again — that second one is the concurrency the
existing short-circuit was carrying, and it is now a test rather than a side
effect. Both were checked against the fix that looks obvious: making a forced
refresh unconditional turns the concurrency arm red, which is a refresh storm
caught before it was written.
The third arm is the gap, asserted as it behaves. A refused token that is still
canonical is handed straight back and the endpoint hears nothing — pinned at
the primitive, and again through the production chain, where a real 401 is
retried with the very credential the server just declined. Two requests, two
identical Authorization headers, no refresh. The coarser tripwire from the
previous change is retired into these, which say the same thing with the
witness spelled out.
And the reason a one-line fix cannot do it: nothing tells the refresh which
credential was refused, so the arm that must change and the arm that must not
arrive as the same call with the same argument. That is pinned too. The
signature is where this gets settled, not an if.
What is still not answered, deliberately: whether a refresh that genuinely fails
means the user must authorise again. Invalid refresh token, an authorisation
server that declines, no refresh token at all, an endpoint that is down, a 401
that survives a successful refresh — those are five different things to do next,
and the corrective action stays unclaimed until they are told apart.
The reconnect button said 「重新授权」 when a regex found 401, 403 or the word
auth in the failure text — and that text is what an external MCP server wrote
about itself, which this host then sanitised and truncated to four hundred
graphemes on the way through. A repair the user is told to perform was chosen by
a stranger's prose, and truncation could take the word out from under it either
way.
The status now travels as the number the host held: httpStatusError, read once
at RecordFailure where a failure is known to be terminal, onto Failure and
across the wire to the row. mcpEntry and McpEntry are a declared mirrored pair
now, so a status one side sends and the other cannot read fails wire-parity in
both directions.
It arrives as a fact and stays one. The row prints HTTP 401 beside the transport
and offers 「重连」, because whether a 401 means the reader must authorise again
depends on whether the automatic refresh actually ran — and it does not, for a
token the server rejected while the local clock still likes it. Claiming
otherwise would swap a regex nobody trusted for a typed judgement everybody
would. So no reason is derived, no enum is introduced, and 401 and 403 are not
folded together on the strength of HTTP folklore. Fewer claims than the old row
made, and every one of them true.
The four counterfactuals are what this is for. Prose carrying every word the old
regex looked for buys no repair offer; a typed 401 next to the detail "banana"
prints the status and still claims nothing; the detail is displayed and never
branched on; and a failure that was not an HTTP one says nothing about a status
at all.
Two guards came out of breaking it. wire-parity compares type declarations, so
deleting the assignment that fills the field passes every check in the tree —
that arm is now a real 401 driven through a host, a controller and the endpoint,
and it fails with the sentence the row would have been reduced to reading. And
the forced refresh that does not force is a tripwire in internal/plugin: it
passes today by handing back the refused token, and the day it fails is the day
someone fixed the recovery and owes the real negative control.
plugin.go was one line over its budget with the field in it, so the failure
record moved out whole — the type, RecordFailure and its two neighbours — into
failures.go. The file gives back sixty-six lines it had been carrying.
Five places turned a non-2xx answer into fmt.Errorf("... http %d ..."), and the
host knew the status at every one of them. Downstream nobody did: a failure
record keeps prose, the settings row above it keeps a sanitised and truncated
copy of that prose, and the frontend was left running /401|403|unauthorized/
over text an external server had a hand in writing — recovering, by regex, a
number this package had held one frame earlier.
So the status travels as a number. Error() renders exactly what it rendered
before, because everything that only displays this — diagnostics, the redaction
pass, the row — should see no change at all; what changes is that reading it no
longer means parsing it. Only the identity was added: no Method field, no
classification, nothing without a reader yet.
The session-expired answer keeps its own identity, and that is the arm this
change could have swallowed: a 404 carrying rpc code -32001 is something the
host silently re-establishes rather than a failure to report, and it is now
pinned as not also reading as a plain status.
Nothing classifies on this yet. Deriving a reason at RecordFailure is the next
step and it has a blocker of its own, found by trying to write its negative
control: the reactive refresh does not fire for a token the server rejected but
the local clock still considers valid, because refresh() returns early on
oauthAccessTokenUsable before it looks at whether forceRefresh was asked for. So
a 401 reaching the terminal path today does not yet mean "the user must
authorise again", and a classifier built on it would say so anyway. That is a
credential-path behaviour change and its own decision, not a detail of this one.
Seven exits in catalog.go answered 400 for everything, and behind each of them
were at least two different things: a skill nobody declared and an activation
file that would not open, a server that would not start and a lock another
process held. The endpoint was not the owner of any of it — it was reading
whatever error arrived and picking one status — so a failed disk told the user
their request was wrong, and a frontend had nothing to tell the two apart by.
The census came first and it changed what this fixes. There is no stable domain
family here to code: exactly two domain conditions exist, both fmt.Errorf, and
one of them was spelled out in three separate files. And they are the same fact
— a named thing is not there — which this tree already carries an identity for.
So the domain half needed no new codes at all. It needed the owners to say what
they meant: skill.NotFoundError where skills are resolved, config.ServerNotFoundError
where configured servers are, and serve's second copy of the MCP judgement
deleted rather than typed a second time.
Deliberately not one ErrUnknownCapability. Skills and MCP servers both happen to
have an unknown-name condition today; that is a similarity in wording, not a
shared owner, lifecycle or namespace. If a third and fourth turn up with the
same lookup contract, that is when it is a fact worth merging.
The activation store now says which way it failed. A malformed file is
UnparsedFile — the identity that already carries a path, a line and an excerpt,
because it is the one internal-looking failure a person can go and fix — and
read, write and lock failures are one sentinel, because they are one answer to
whoever flipped the switch: it did not stick, and none of them is their doing.
Notably the malformed arm is fixed by this alone: reverting the transport
classifier leaves that test green, which is what "the owner is where this
belongs" looks like from the outside.
The join in SetMCPServerEnabled kept one fact and lost the worse one. A server
that will not start and whose switch was put back, and one whose switch could
not be put back, are not the same place to be left standing — the second is
persisted state disagreeing with the runtime, and it is now checked before the
failure it accompanies rather than folded into it.
Dependency answers 409 rather than a status invented for it. The request was
fine and the world does not satisfy it, which is what this tree's other "not a
fault" refusals answer; whether a server that will not start deserves a class of
its own is for the MCP failure taxonomy to settle, and that is its own probe.
An unclassified failure is now internal rather than a bad request. A failure
nobody named is not evidence that the request was wrong, and the old default
said it was.
One stale claim came out of the same file: skillEnabled's doc still said the
runtime serves the old prompt index until a rebuild, which stopped being true
before this branch started and was fixed in four other places.
Nothing in this tree could say which actions Studio offers. A hundred buttons
answered to their own handler and to nothing else, so "does every button work"
had no subject to range over — the question needs a set before it can have an
answer, and any set kept by hand would be wrong within a month.
The census found 280 interactive sites across seventy files, and three things
worth having found. Only sixty of them are anything but a plain <button>, so a
check that looked for buttons would have missed a fifth of the surface. Five
kernel capabilities are driven from two components each — preset, plan mode,
pickFolder, probeProvider, checkProvider — and only the first was inconsistent,
which the previous change had already fixed; the others were checked one by one
rather than assumed. And the wording is useless as identity: 「取消」 is
seventeen buttons reaching eight destinations, 「关闭」 five reaching a panel, a
dialog and the window itself.
So an id is <surface>.<intent>, written as a literal at the control and again in
a registry that production may not import. Two spellings that can disagree is
the entire reason the check is worth running; one shared constant would compare
a value with itself. The instance rides on data-target and the answer on
data-value, so a row rendered seventy times is one contract, and an approval is
decision.tool with a verdict rather than three ids named after button text —
the kernel takes one decision with an answer, and the id follows the port, not
the label.
A control that is genuinely two actions may branch, as long as both are written
down: send and stop share a button, and Escape is the same pair on one key. The
window's shortcuts became a table for this reason. They were a chain of ifs,
which is a set nothing can enumerate; now each names the action it performs, and
it is the same id the control on screen carries, because they are the same thing
asked for two ways. No action here is reachable from the keyboard alone, so
there is no flag saying one is — the day that changes, it is a field with a
reader.
Twenty-five actions, thirty-four sites. The remaining two hundred and forty-nine
are counted and ceilinged rather than left uncounted: a registry that covered an
eighth of the surface while nothing said so would read as the whole set, which
is the failure this was built to prevent. The ceiling may fall and never rise.
Every direction was checked by breaking it: an unregistered id, a registry entry
nothing renders, an id assembled at runtime, one more anonymous button, a
production file importing the oracle. A sixth arm came out of that pass — the
parser recovers from some breakage rather than throwing, and a file that stopped
parsing would contribute no sites while every count above it read as an
improvement. Hard breakage throws and fails the run; the recoverable kind is now
named per file.
The tree spent two changes giving refusals an identity — dotted codes at the
HTTP boundary, a sentinel family behind them — and then twenty-five panels
printed the caught error's own message, which is the English that rode along for
the log. A coded refusal arrived untranslated; worse, a kernel that was simply
not there arrived as an internal path and a status number, because HttpError's
undetailed case is exactly what message cannot say.
Every one of them was the same expression, and every one of them was a sink a
reader looks at: an errbar, a .why line, a panel's failure row. No developer
sink and no control-flow read among them, so the migration is uniform —
reason(), which is a superset: it says a coded refusal in this window's
language, gives the undetailed case a sentence of its own, and falls through to
exactly what these wrote for everything else. The twenty-fifth was found by the
guard rather than by the census: it is main.tsx's boot failure, a .then's
rejection half, and the only thing on screen when the kernel cannot be reached.
reason() is pinned as the boundary it now is. The gate among those is that
rewording the kernel's English must not move what a reader sees for the same
code — without it the codes bought nothing and wording authority never left the
kernel. Language is pinned rather than defaulted: with nothing stored the window
follows the machine, and on an English runner every assertion about a Chinese
sentence would have been about the runner.
The guard reads structure, not spelling. What counts as an error is a catch
binding, a .catch callback's single parameter, or the rejection half of
.then(ok, err) — nothing else is known to be one, and a rule that guessed from
a name would be reading spelling again. It carries its own regression in both
directions: five shapes it must find, and four it must leave alone, including a
field that merely happens to be spelled message. Ceiling zero.
@babel/parser is a direct dependency now. It was already in the tree under the
React plugin, but pnpm does not expose what a package did not ask for, and the
installed TypeScript is the Go port, whose only parse entry points are behind
an unstable path — a CI gate is not the place to hold one of those.
The skill seed says what it stopped short of saying: a coded refusal reaches the
panel as this window's sentence, and a dead kernel as one sentence about the
request rather than the route it took.
Two things this does not close, both older than it. ServerRow decides whether a
failure was an authorisation problem by matching /401|403|unauthorized/ against
the message — HttpError.status has carried that all along. And catalog.go still
refuses skill and MCP switches with writeErr prose; those are not a declared
sentinel family, so the inbox treatment does not transfer to them.
Every UI assertion in this tree was renderToStaticMarkup or a Playwright guard,
and the gap between them is where a control's contract lives. Nothing could ask
what happens between the click and the answer — pending, refused, clicked twice
— so the three fixes that just landed were pinned by whichever end could be made
to reach them, and one arm could not be reached at all: the queue's positive
case needs an inbox_changed the bench's own subscription swallows.
jsdom is chosen over the alternatives for its DOM and event semantics, not its
speed: nothing here measures layout, and the browser guards keep every question
that depends on one. It is opted into per file rather than globally, so the
thirty-six static tests keep running under node and none of their behaviour
moves on the way in.
Three seeds, one per fix. The queue opens on the whole instruction and not on
the row, refuses to open at all when the body cannot be read, says why in this
window's language, and does not let one failure outlive its click. The preset
asks once, marks the call it has out, turns down a second while the first is
unanswered, hands the selection back to the kernel's own answer, and on a
refusal keeps the old one and asks for no re-read. The skill switch names this
skill at this project's layer, holds still while unanswered, and reports a
refusal without flipping.
All three were checked by breaking their subject: swallowing the read again
fails three assertions, dropping the preset group's disabled fails one, removing
the switch's busy fails one.
The skill seed stops short of asserting how a refusal is worded, and says so
where a reader will find it. That row hands on err.message rather than going
through reason(), so a coded refusal arrives untranslated and a dead kernel
arrives as a path and a status — and so do some twenty other catch sites,
including the settings panel's own helper. It is one pattern and belongs in one
change, not in whichever guard happened to walk past it.
The inbox store refuses eleven distinct conditions and the boundary named one
of them. Five shared a status and were told apart only by reading English, which
no frontend does; three — closed, schema-readonly, idempotency conflict — had no
case at all and went out as 500, so a known answer about the request was
reported to the user as their machine having failed. This is the rule the tree
already holds everywhere else: an error a caller must tell apart carries an
identity, and across HTTP that identity is a dotted code.
So each condition has its own case and its own code, and the sentence rides
along as the English fallback a log keeps. Statuses are unchanged on purpose —
not_found answering 409 is arguable, but the code is what carries the identity
now, and moving a status is a wire change no caller asked for. The default
branch still delegates to writeErr, which renders an identity a deeper layer
assigned and otherwise falls back to prose: for a condition this package cannot
name, prose is a diagnostic, and inventing a code would be the same lie pointing
the other way.
Two guards, because neither is enough alone. A test runs every sentinel through
the writer wrapped the way the store hands one over, and fails a code that is
missing, wrong, or shared by two conditions — but its table is hand-kept, so it
cannot notice a twelfth sentinel. repolint reads the family out of its own
declaration and the switch out of its AST: a sentinel with no case, or one whose
branch writes prose, fails there. Both were checked by breaking them, in all
four directions.
The frontend now has wording for all ten, which is what closes the loop: the
codes guard already required it, and could not have asked before, because it
reads codes at their emit site and there were none to read.
The chrome's 均衡/交付 pair sent the call and dropped the promise: no pending
state, no catch, and the selection moved only if onChanged happened to run. A
call that never lands therefore left the pair reading as the state it did not
reach, and the rejection went nowhere — a click with no answer at all, which is
the one outcome a control must not have.
The settings panel drives the same capability and has always handled it: busy
while the call is out, the refusal shown, the old state kept. That the two
disagreed is the defect; the chrome now holds the same two facts. The preset
being asked for is marked while it is out, the group refuses a second click
until the first settles — a pair of one-of-two buttons has no meaning for two
concurrent answers — and a refusal appears next to them instead of being
swallowed.
What can refuse today is narrow: /preset turns down a malformed body or a preset
that does not exist, and neither is reachable from a fixed list of two. Transport
is what actually fails here — a kernel that is gone, a pane whose runtime went
with it — so this is a dead click and an unhandled rejection being closed, not a
refusal path that was going unreported. It is written the way the settings panel
writes it because a second shape for the same capability is how the first one
came to be missing this.
The switch in settings has been live for a while: SetSkillEnabled writes the
activation store, the live store consults it per call, so the session that flips
one loses it at once — a slash invocation stops resolving, and the next eligible
turn owes the model a catalogue without it. Both halves are pinned in
internal/boot, one by invocation and one by projection.
Four places still described the old mechanism. Two of them were on screen: the
skills group told the reader changes enter the model's index "the next time you
start a session", and the discovery-off variant said the same in fewer words.
So the panel asked for a restart the product had stopped needing, and a reader
who took it at its word would keep a skill they had just switched off — the one
reading of it that is worse than saying nothing.
The other two were comments, and they are what let the screen drift: AgentPort
told the next reader the switch reaches the model on the next rebuild, and
skillSet justified its absence of a lock with "a preference a rebuild picks up".
The lock argument holds and is kept — SetSkillEnabled touches none of those
fields — but the reason it gave was the same wrong mechanism, and the true one
is why there is nothing to synchronise.
The pending strip's 「改」 reads the whole instruction back before it opens an
editor, because the row shows a preview and saving the preview back would file
a cut-off line as the entire message. That read could fail, and the failure was
spelled `.catch(() => "")`: an entry the kernel no longer holds — dispatched
between the render and the click — opened a box with nothing in it. Empty is a
state a body can genuinely be in, so nothing about the screen said the read had
failed. Whatever the user then typed replaced, in full, a line they had never
seen.
So the editor stays shut. The row keeps its own text, and the reason the read
failed is on the row rather than in the box. Two layers collapsed the same way
and both are closed: the port returned "" for an answer carrying no envelope,
which is a shape the kernel does not send and therefore an answer we do not
understand, not an empty instruction.
The fixture refused where the kernel refuses. MockPort.readQueued answered ""
for an id it does not hold, so the path a reader actually hits could not be
reached from any guard — the seeded rows ?queue= draws are exactly that state,
a row still on screen whose entry is gone.
perf/queue.mjs pins it in the browser, and it was written against the old code
first: opening an empty box, losing the row's text and saying nothing were three
separate failures before, and are three passes now. It also records what it does
not cover. The positive arm — a body that does read back opens the editor with
it — needs an inbox_changed to reach the panel, and BenchPort owns the
subscription the fixture emits into, so that arm belongs to a component-level
harness rather than to a change in the bench's event model.
Three ledger states read identically through the readiness result: a review
report filed by a run granted no such authority, one the host could not join
back to any run, and no report at all. Missing said structured_review for all
of them — or, in the fixture that isolates it, said exactly the same list for
all of them — and the classification the ledger already computes existed only
inside a sentence written for the model.
So the proofs are their own dimension beside Missing rather than an entry in
it. Missing names obligations that are unmet; Proofs names candidates that
cannot close one, and only the second is something a person can act on: "file a
review" and "the report execution-3 filed carries no grant for this" are
different instructions. An obligation with no candidate at all has no entry,
which is how the third state stays distinct from the other two.
The gap carries the run that filed it, read in the same walk that classifies it
— two passes over the ledger could disagree about which receipt they were
describing, and the id is the half that makes the gap actionable.
The parity this needs is not about wording. The completion gate and the
readiness projection share one evaluator today, and the guard is what fails if
either grows arithmetic of its own: for one fixture, whether the gate refuses
and whether the projection says ready must be the same answer, over the same
missing ids and the same proofs. Reason is excluded on purpose. Both halves were
checked by breaking them — folding the proofs into Missing fails orthogonality
and parity together, and letting the projection decide readiness on its own
count fails three arms of the parity table.
Nothing is exposed to a port, the wire or a window yet. Two lifecycle questions
have to be settled before a read called "current" can be published: the evidence
ledger is constructed empty per agent with no rebuild path found, and the
acceptance-criteria input is turn-local, so a read taken outside a turn answers
from the last one's flag. Both are probes of their own.
agent.go crossed its budgets on the way, so the result type moved next to the
evaluator that produces it rather than the budgets moving.
A falsifier ran first, because the question was not which field replaces the
label but what the label was standing for. Eight arms through the reducer,
recording the frame that ended the turn and the state the session was actually
in: a new turn already clears the label, and nothing in the background moves it,
so what it stood for was the current turn's ending rather than a memory that one
had ever ended. That answer is what makes a state machine in the reducer the
right owner, and it ruled out asking /status for a Finished bool.
The same matrix found a defect the label was hiding. It asked only whether an
error was attached, so a turn that ended because its obligations were not met —
outcome final_readiness, no error — read exactly like one that finished the
work, tick and all. The comment three lines above it already said the opposite:
anything but a clean finish keeps the run amber rather than claiming the tick.
The code now agrees with it.
So terminal is typed and not a flag: completed, failed, cancelled, incomplete.
Cancellation is separated from a dropped connection by the flag the kernel sends
for exactly that, which nothing was reading. runState asks for the terminal
rather than a boolean, and only a completed one claims the tick — the other
three stay amber, which is what they always meant.
Two rules make it a state instead of a history, and both are pinned: a turn
starting clears it, and nothing behind the session moves it in either direction.
Reverting the first makes an hour-old tick sit over running work and turns that
arm red; folding the readiness outcome back in with a clean finish turns the
other one red.
The counterfactual is the same one the decision list got, in both directions.
The label may say 已完成 with no terminal fact and the turn is not done; it may
say anything at all over a completed one and the turn is. Neither selector can
see it — with this, no selector in the pane takes a label at all.
Five labels in the rail stayed in the source language in an English window. The
table naming them was built at module scope, and t() answers out of a dictionary
that boot() installs — a module body runs before that, so the labels captured
the empty default and never followed the interface again. Switching the language
reloads the window, which meant the same thing happened on the way back: the one
place in this tree where the fix is not "re-render on change" but "do not
compute this until you are drawing it".
They are computed in a function now. The literals stay inside t() at the call
site so the catalogue scanner still sees them — the alternative, a table of
keys resolved later, is the shape that made t(TABLE[key]) invisible to it before.
A scan of the whole tree for the same mistake found no second instance: two
other candidates are a memoised component and a regex with a tab escape in it.
perf/lang.mjs was four more red beside it, and none of those were the product.
It looked for three panel headings — Metrics, Pending changes, Agents and tools
— that no longer appear anywhere under src/ui and survive only in the catalogue,
so the oracle had been asking after names the panels stopped using. It now names
what they render.
The flip itself is pinned where it broke: the legend is read in both windows, its
labels must be wholly English in one and wholly Chinese in the other, and its
numbers must be identical across the pair — translation moves the labels and
nothing else. Restoring the module-scope table turns that assertion and the
stray-Chinese one red together.
One guard bug came out of the same edit: the check that rendered Chinese goes
through the catalogue treats a fat arrow as a tag opening, so any arrow
returning Chinese before the next "<" reads as markup. It no longer matches
after "=".
perf/models.mjs threw where it should have failed. Its wait for the connection
editor timed out and the stack went to node, so the exit code and the message
both stopped belonging to the guard — and "this screen is not this shape any
more" is precisely what it exists to be able to say. Missing targets are named
failures now, the way side.mjs's became.
The reason it could not find them was the same one verify.mjs had: the oracle
matches Chinese against the DOM and the page was opened in whatever language the
machine runs in. Three guards were in that position — models, look and panes —
and two of them pass today only because their Chinese comparisons happen to sit
on paths that do not decide anything yet. All three now open a context with a
locale, which is what locale.mjs has always done.
Checked both ways: pointing the wait at a selector that does not exist reports
one named failure and exits 1 rather than throwing, and the three guards pass
with the locale pinned.
Whether the run reads as moving was settled by comparing the label on screen
against two Chinese literals. Both are also translation keys — one of them is
"Waiting for approval" in the English catalogue — so a rewrite of the wording
was a rewrite of the verdict, and the presentation owned a fact the kernel
already publishes.
It publishes it better than the string did. Decisions() answers with typed
entries whose Kind names the owner that resolves them — tool_approval,
plan_approval, recovery_approval, ask — and all four need a person, so the
condition is the list's length rather than anything inside it. Which kind it is
says who answers, and that is what an explanation would render; this only says
that someone has to. Absent is false and never a fallback: a kernel that does
not answer leaves this window unable to claim anyone is waiting, and quietly
reading the label instead would put presentation back in charge with nothing
saying so.
The guard is a counterfactual in both directions, because only one direction
proves anything on its own. The old wording present with an empty list must read
as running — that is what says the string has lost its authority. A typed
decision present with a label that reads like progress, or like nothing, must
read as halt — that is what says the typed fact carries the verdict alone. Every
kind is driven through the second, so narrowing the condition to some of them
fails; every label is driven through both, so no test pins a word. Neither
selector can see the label at all, which is the strongest form of the claim.
runState is separated out so it takes facts and never a label. One label test
remains at the call site — whether the turn finished — and it is left visible
rather than folded into a function that would then be wording-free only in name.
It is the next instance of this, and it needs a canonical source of its own.
Two fixture repairs ride along, both the same shape as the last round's. The
mock never published decisions at all, so the whole chain would have measured as
"nobody is ever waiting"; it now sets the list where it raises the card and
clears it where it answers, and driver-fed frames go through the same door as
scripted ones. And verify.mjs now asserts the chain end to end: the card and a
halted run appear together, which is what proves the projection reaches the real
Pane rather than only the selector.
The status read is pulled forward too. Nothing but the 250ms poll would have
noticed a prompt opening, so the glow outlived the card by up to a quarter
second; a prompt event now re-reads /status, the same content-free event plus
authoritative pull the queue already uses.
mock.ts crossed the 800-line ceiling on the way, so the memory shelf's fixture
data moved out beside the other six mock_* modules rather than the budget moving.
Three owners decide whether a column is on screen, and they are kept apart
because collapsing them is the only way this feature can go wrong. What the user
chose is theirs; what the viewport allows is the layout's; whether the
surroundings are put away for a moment is this. They are multiplied where they
are used — effective = wanted && allowed && !focus — and focus never calls the
setters for the other two. Writing a temporary state back into a preference is
how "leave focus" stops restoring anything.
So focus stores one boolean and nothing else. Not the previous layout, not the
width it was entered at: both would be a saved projection, and the point of
keeping the owners separate is that everything is asked again. A window narrowed
while focused leaves focus with the columns the new width allows, not the ones
it had before — the responsive rule never stopped running.
The columns are collapsed, not unmounted. Taking them out of the tree would lose
the sidebar's scroll, the inspector's open sections and whichever panel was
selected, and this is a state you step out of. Collapsing goes through the same
path the existing close does, so the transition, the borders and the fade are
the ones already there. inert is the other half: a column that is only hidden to
the eye can still be tabbed into, and a guard that checks pixels would not
notice.
What stays is everything belonging to the turn in front of you: the transcript,
the queue, approvals and asks, the composer, the stop button, and what the run
is doing. Focus is not a reading mode — it is the same session with less around
it. The run header loses its description and its timestamp through the rules the
short-window fold already uses, rather than a second header component that would
drift from the first.
Escape follows the convention useDismiss set: a transient surface takes the press
in the capture phase and stops it there, so the window's handler only sees a
press nothing else wanted. Stopping a turn stays ahead of leaving focus, because
doing both on one press would be neither. The overflow bubble now stops it too —
it was listening in the bubble phase, which would have closed the bubble and
dropped focus mode together.
perf/focus.mjs holds it, and the two easiest things to fake are the two it was
checked against. Removing inert leaves every assertion about pixels green and
fails only the one that puts focus into the hidden columns. Making the toggle
write the columns closed turns four of them red at once, including the one that
reads the preference straight out of storage. It also pins the geometry both
ways: the transcript grows from 884px to 1394px on the way in and is 884px again
on the way out, which is the whole promise stated as a number.
Both were found while checking that the composer-budget change had broken
nothing. Neither had.
side.mjs crashed rather than failed. It read .why out of the MCP rows, and the
panel no longer has one — the reason moved into a title and the row now carries
a name and a way out — so getComputedStyle was handed null and the script threw
before any assertion ran. A guard that dies says nothing about what it saw, and
"the panel is not this shape any more" is exactly what it should be able to say,
so a missing target is now a named failure and the wait for one is too. Its
subject is unchanged: endpoint-authored text does not get to widen a fixed
column. What it asserts about has moved to the name, which is what the row
renders now, and to the way out beside it, which is the only thing on that row
you can press and therefore the thing that must not be squeezed away.
verify.mjs was five red on a clean checkout, and every one of them compared a
string on screen against a Chinese literal while opening the page in whatever
language the machine happened to be in. locale.mjs has always opened its context
with a locale for this reason; this one never did, so on an English machine a
whole group of plan-gate assertions failed over wording that was in fact correct.
Pinning it left one, and that one was the fixture too. data-plan is read from
/status, and MockPort's planDecision was an empty method: approving a plan could
not turn plan mode off in the harness no matter what the product did. The three
plan outcomes are three different kernel transitions rather than an allow/deny
pair, so the mock now makes the distinction it always described — revise stays,
the other two leave. Verified on an unmodified origin/studio worktree: five red
before, none after, with no change to any product file.
So neither is UI debt, and neither was fixed by moving an oracle to meet the
code. The fixture is dev-only and never reaches a shipped bundle.
The queue was the instance; this is the class. Everything stacked around the
composer — the pending queue, extension views, the error bar, runtime notices —
shared a container that is flex: none and therefore takes whatever height its
children ask for. Only the extension rail had ever bounded itself. Fixing the
queue alone left the next child to do the same thing.
So the two regions are named and share one number. Above the box, .composeaux
holds the queue and the extension rail; below it, .composenotes holds the error
and runtime bars, which arrive unbidden and are dismissed one at a time. Both
read from a single --aux-budget on .compose, split .72/.28, because the
invariant is singular: the transcript must not be squeezed out. Two independent
ceilings do not compose — the first version of this had them at 30vh and 16vh,
and 64 queued rows with twelve notices still measured the transcript at 36% of
a 900px pane.
The queue's own ceiling is gone with it. What it needs is not a height but a
shape: the header stays put and the rows scroll, so the count and the pause
control are still there while you read what is waiting.
Height now folds the way width does. Everything around the transcript is
budgeted as a fraction of the window, and on a short one those fractions are
the transcript; under the existing "side" fold the metrics column is one of
them, sitting under the flow with 40vh of its own. A window 720px or shorter
reports "short", which tightens the composer budget, caps that column further,
and takes the description and the timestamp out of the run header.
The queue's preview carried a native title, whose delay, placement, size and
scrollability all belong to the browser — a long one is an uncontrollable sheet
over the page, which is how it appeared in the report this came from. Dropping
it alone would have taken the full text away from settled rows, which have no
edit button to read it back, so it is replaced rather than removed: Overflow
shows the whole string, and only where the string is actually cut. It is bounded
and scrolls inside itself, its text can be selected, it closes on Escape, blur
or any scroll, and it takes a tab stop only on the rows that have something
behind them. Long titles and job commands can use it next.
It renders through a portal for a reason worth writing down: a finished entry
animation with fill-mode: both leaves transform as the identity matrix rather
than none, and an identity matrix is still a containing block for a fixed child.
Both .queue and .qi carry one, and the bubble's inline left/top were correct
while it rendered 675px below the window.
perf/budget.mjs is the guard, and it reads geometry rather than class names: 64
queued rows (the kernel's own admission ceiling) plus twelve runtime notices, at
three window sizes, asserting the transcript keeps 40% of the pane, the textarea
stays wholly on screen, and the region that gives way is the auxiliary one. The
floor is not a product truth — it is "never zero again", with the measurement
that motivated it at 48%. Removing --aux-budget puts 1440x900 back to a 0%
transcript and a composer off the bottom of the window, which is the report this
came from.
It guards the bubble on the same terms. One seeded row is short, and must draw
nothing; one is longer than any preview the kernel will ever produce, because
previews are cut at 120 runes and the bubble's own ceiling would otherwise never
be reached — a bound no fixture can cross is not a bound anything checks.
And .qi was outside the reduced-motion list while carrying an entry animation,
so a burst of rows was a burst of movement for a reader who asked for none.
A failing cargo build finished thirty-two background jobs in a few seconds. Each
one became its own queued turn whose text was the composed <background-jobs>
block, and the pending-queue panel — which had no height of its own — rendered
all of them. Measured against the real stylesheet in a 900px pane with the 41
rows from the report: the transcript is 466px with the ceiling this adds and 0px
without it, the composer pushed off the bottom along with it.
Three places treated the host's own account of a job as something a person had
said. backgroundCompletionPrompt built one string and handed it to both Display
and Submit, and Display is persisted as the turn's RawContent — so the queue row
and the transcript were given the markup meant for the model. UserMessageText
returned RawContent verbatim on the grounds that it holds what a person typed,
which is true of every producer but this one; the comment at its ACP call site
already claimed it stripped. And the page's own control-block regex named three
of the thirteen tags the kernel prepends, so the stripping that did run missed
this block anyway.
That last list has now drifted twice — internal/history/strip.go records its own
copy having named five of eleven — so the two are held to each other by repolint
under wire-parity, compared as sets and failing both ways. A tag the page cannot
strip is markup it prints at the reader; a tag only the page knows is a copy
that has gone stale.
The burst is a separate fact. A completion gives its session a turn so the work
it unblocked continues without waiting for the user, but thirty of them are one
interruption, not thirty. While a continuation is still unread, later
completions are refused through the error path publishCompletion already had,
and the manager keeps its note — which the next turn's <background-jobs> block
folds in whole.
The queue's ceiling is layout, not semantics: 30vh at most, and what does not
fit scrolls inside it. Its item count was already bounded at 64; its height was
bounded by nothing, and the two are not the same limit. The input box is the one
thing nothing may push off the screen, and the transcript is the second.
The last open row in the projection rules. Standing instructions rode the turn
already — they were never in the prefix, which is why classifying them as prefix
configuration would have been a move rather than a description — but they
reached a live session only through the writers that published them. An edit by
an editor, a script or a branch switch was in the canonical set and not in the
next turn.
They are now rendered from disk each turn and compared with what the model has,
the same shape the skills listing took. Discovery is the narrow one: the
instruction documents, not the fact store and its recall index, at 272µs p50
against a turn measured in seconds. A session that never resolved a workspace
renders nothing rather than discovering from the process directory, which is
what it was accidentally doing the moment discovery moved onto the turn.
Two rows this closes are not edits. A removal is owed once as a removal: silence
would leave the rules the model already holds reading as current, which is the
failure a deletion is most likely to cause and the one nothing would report. And
a rewrite of the same bytes is not a change — the comparison is against the
rendered block, so touching the file costs nothing.
Delivery, not composition, settles a debt. A turn can still be blocked by a hook
or refused by an extension after it composed, and clearing the debt there would
leave the model holding state nobody sent it; runSettled is the hand-off point
because everything that can refuse the turn has already run. A provider failure
after that is not a lost delivery — the composed message is in the session and
rides the next request, which was measured rather than assumed.
Two tests changed because they measured the old contract. Both composed twice
without running a turn and expected the second compose to be silent; under
delivery-settles they correctly owe it again, so one now pins exactly that and
the other settles explicitly where a real turn would.
13a made the durable skill switch a live registry input, which is the same rule
the out-of-band file write already forced and worth stating once rather than
rediscovering per input: a switch flipped, a file edited past every API and a
plugin dropped into a directory are one question, and the registry answers it
from canonical state.
Its second half matters as much and is easy to lose: a change no projection
renders is not a change. Without it, "detect every mutation" invites mtime and
event streams back in, and both re-send listings nobody's model would read
differently — freshness bought out of the cache stability it rides on.
The section also stops implying the original hypothesis was right. Moving memory
and skills out of the prefix was the plan this phase started with; measurement
found them already there and freshness ownership missing instead.
Enable/disable is registry configuration, not a build-time constant, so a user
who turns a skill off should not have to restart an agent to be rid of it. The
fix is not a republish after the setter — 12b settled that the registry answers
for itself — it is that the disabled set stops being a snapshot. Options take a
function, resolved once per discovery pass, and the pass is what every surface
already goes through: the listing, slash resolution, run_skill, Inspect.
Everything downstream follows without being told. The catalog is rendered from
the registry each turn, so a toggle changes the block and the block is owed; a
toggle that changes no listing — turning on what is already on — writes its
durable row and owes nothing, because the debt is against what the model would
be sent and not against the file that decides it.
The boundary is dispatch. A child already running keeps the skill it was
dispatched with: resolution happens once, the run holds what it resolved, and a
registry consulted again mid-run would cancel work the user asked for. The arm
that pins this fails without a witness that a child actually ran, since a turn
that dispatched nothing would otherwise satisfy every other assertion in it.
The two surfaces now say what is true: restartRequired is false and the CLI
notice says the change is in effect from the next message. That order is
deliberate — the promise moved after the behaviour did, not before it, which is
how it became false in the first place.
Cost of asking per pass rather than binding once: 23µs with no overrides, 63µs
with twenty, against the 166µs-2.4ms the listing already costs to render and a
turn measured in seconds.
The listing's freshness was already owned by the registry, but the state behind
it was three inline lines on skillSet: a pointer, a comparison, a store. That
names the operations and not the fact, and the fact is the part another
projection will need — the standing-instructions block is the same shape and is
the open row in the rules.
projectionDebt holds one thing: what the model has of a block that is delivered
once. Rendering is the caller's, and so is invalidation, which is the boundary
worth keeping — a debt that decided for itself when to invalidate would be back
to guessing at canonical state it does not own.
The delivered value is the version rather than a digest of it. A digest is a
second representation that can drift from the thing it stands for, and the block
is a couple of kilobytes; there is nothing to buy by hashing it.
12a and 12b were one question asked twice. The prefix design was already right;
what was missing both times was the rule for when a once-delivered projection
becomes owed again, and both times the answer had been left to whoever happened
to write the state.
The four rules are what the matrix in internal/boot/context_projection_test.go
now holds, written down so they bind the next dynamic fact rather than being
rediscovered by it. Prefix ownership names pinned memory as the one declared
exception rather than leaving it as an unexplained violation, which a rule
phrased as "no canonical fact may move the prefix" would have made it. Freshness
says what "eventually" is not. Ownership is the one this phase actually cost:
the owner of a fact owns the invalidation, because a projection kept fresh by
callers remembering is one forgotten call from stale. Determinism is already
true and is frozen before something makes it accidentally false.
The open row is stated rather than quietly carried: standing instructions still
reach a live session only through the writers that publish them, so an edit to
REASONIX.md made outside them waits for the next build. That is measured, not
assumed — the same probe that closed the skills row shows this one open — and it
is the shape the listing had before it started asking. Fixing it is a decision
about memory, not a consequence of this phase.
The cache-first convention loses "memory" from its parenthetical: the index left
the prefix some time ago and only pinned bodies remain, so the list was naming a
fact that had moved.
Falsifying the activation lifecycle turned up something else. `/skill disable`
and the Studio switch write a durable override, `SkillEnabled` reads it back,
and the panel duly shows the skill as off — while discovery is configured from
`skills.disabled_skills` alone, which that switch has never written. The skill
therefore stayed in the model's listing and stayed runnable. Not until the next
turn, or the next session: permanently. Both surfaces answer `restartRequired`
and the CLI says "restart or refresh the session", and a restart did nothing
either, so the promise was not late — it was false.
Discovery now reads the durable decision: the hand-written config list, plus
what the switch turned off for this workspace, minus what it turned back on.
The switch answers by name, so a name the user turned on beats a config file
that declares it off, which is the order every reporting surface already used.
The arm is written as the lifecycle contract rather than as a freshness row,
because that is what it is: the change takes effect on the next build, both
surfaces say so, and the live session's silence is the boundary. Its second
half is what gives the first half meaning — after a rebuild the skill is gone,
so the live session not moving is a boundary rather than a switch doing
nothing. If that boundary ever moves, the arm fails and the two surfaces have
to stop promising a restart.
Whether the switch should apply to the live session is now a product question
and no longer an implementation one: 12b left the listing being rebuilt from
the registry each turn, so the only thing between here and immediate effect is
the store's construction-time disabled set.
One measurement note, because it cost a debugging round: the index preamble
names a built-in as its own call example, so a test matching a bare skill name
in that block matches the boilerplate. The arm matches the entry line.
The listing was owed by being told: publishCatalog on construction, on a
completed fold, on a command reload, and — after the last change — on each of
the three skill writers. Every one of those is a caller remembering, and the
probe beside this had already shown what the remembering misses: a skill file
written by an editor, a script or the model's own write tool is in the canonical
registry and announces nothing.
So the question moves to where the answer lives. owedCatalog renders the listing
from the registry and compares it with what the model was last sent; the writers
stop announcing themselves entirely, because there is no longer anything to
announce to. A fold does not re-publish either — it forgets what the model has,
which is the truthful statement: the bytes it was sent are no longer in the
context it samples from.
That the comparison is against the rendered listing rather than the files is
what makes the reverse hold. A touched mtime and an edited body both change the
registry's inputs and neither changes a line the model reads, so neither
re-sends anything. Freshness bought by re-sending on every filesystem event
would pay for itself out of the cache stability the block rides on.
The cost of asking instead of being told is the walk, measured before this was
written rather than assumed: 166µs on a workspace with no skills of its own,
2.4ms at fifty, against a turn that costs seconds. The old comment said a fresh
List() per turn was what the once-only delivery existed to avoid. It was
avoiding 0.1% of a turn.
12a fixed the skills listing by having each writer announce itself, which is
correct and does not scale: the same probe showed a skill file written past
those writers — an editor, a script, the model's own write tool — is in the
canonical registry and still owes nothing. A contract that reads "every writer
remembers to announce" is one forgotten call from the defect it just fixed.
So this measures the three candidates rather than picking one. Rebuild the
projection and compare it; fingerprint path, size and mtime; or trust that every
write announced itself. Owed is measured, not declared: the block is rendered
before and after each transition, and owed means those differ. Under that
definition rebuilding the projection is not an estimate of the truth, it is the
truth, and what the arms establish is where the two cheap detectors diverge
from it.
Both diverge, in opposite directions. Metadata misses a change whose length and
mtime are preserved, which is not exotic — a branch switch restores content and
stamps it with the mtime the file had — and it fires on a touch and on a body
edit, neither of which changes a line the model sees. Observed-write is blind to
every out-of-band write by construction; it can be an optimization, never the
owner of correctness.
The cost argument for the cheap ones then mostly evaporates. Rebuilding the
projection costs 166µs on a workspace with no skills of its own, 668µs at ten,
2.4ms at fifty and 6.4ms at two hundred; metadata saves 25% of that at two
hundred and 60% at ten, because the walk is the cost and reading the files is
not. A turn costs seconds. Paying the truth once per turn is noise, and it was
avoided on an assumption nobody had measured.
Two measurement traps are worth naming. Timing a detector with its own
accounting inside it measures the accounting: attaching the file/byte counter to
the projection detector reported it 2.5x slower than it is. And pointing the
project root and the home root at one directory walks it twice, which doubled
every number until they were separated.
Canonical host state — the memory store, the skill registry — belongs to its
own owner, and what reaches the model is a projection of it onto each request.
Nothing measured that end to end, so this file does, through the real Build
assembly against a recording provider: five rows, each reading bytes that
actually reached the wire.
The two failures are named apart because their fixes are opposite. A
cache-boundary violation is canonical state reaching the system prefix, where
changing it re-sends every byte after it. A freshness violation is canonical
state not reaching the next turn, where the model acts on a registry the host
has already replaced. Both would otherwise be reported as "stale context",
which says nothing about what to do.
Four rows hold. The same canonical state projects byte-identically in a second
session, so a difference elsewhere is the change under test rather than the
assembly's own nondeterminism. A memory write reaches the very next request,
leaves the prefix alone, and survives the session that made it. A fold re-owes
the listing it summarised away, and re-owes the registry as it is now rather
than replaying the bytes it dropped. Pinning is the declared exception, stated
here so moving it later is a decision and not drift: a pinned body folds into
the prefix, which costs one cold start and is what pinning means.
One row failed. A skill written through the controller's own writers is in the
canonical registry immediately — RunSkill finds it by name — and the listing is
owed once, so nothing told the model it exists until a fold or the next session
happened to re-owe it. The write re-owes it now. That is on the write and not
on the turn deliberately: the listing costs a disk walk, and paying it per turn
is what the once-only delivery was built to avoid.
Getting the measurement right took three definitions, and the two that were
wrong are the useful part. Read the whole history and a listing delivered on
turn one is present forever, so every row passes while measuring nothing. Read
the last user message and under context pressure it is a host notice appended
after the turn, not the turn. Only the message carrying this turn's own text is
this turn's projection. A fourth trap sits next to those: the host makes
provider calls of its own, so a cursor carried between turns reads the
compaction summarizer's prompt as the model's context.
Known gap, measured and not closed here: a skill file written by anything other
than those writers — the model's own write tool, an editor, a script — is in the
canonical registry and still does not re-owe the listing, because nothing
observes the write. Closing it means either a fingerprint walk per turn or
routing observed write paths back to the registry, and that is a different cut
from this one.
The skill write methods move to skill.go rather than widening controller.go's
budget by the twenty lines this added.
`go build ./tools/repolint` writes the binary into the working directory, and
two of those — repolint and covergate — had been committed. Nothing builds them
that way on purpose: every caller, Makefile target and CI step runs them with
`go run`. So any probe, agent or script that builds one produced a diff on a
tracked file that had nothing to do with what it was doing.
Untracking them is half of it. The other half is that the same accident is
available to all thirteen commands in the root module, and only three were
ignored, so the ignore list is completed from the package list rather than from
the two names that happened to get caught.
`build-artifact` holds the two lists together: every main package in the root
module must have its output name covered by a root-anchored .gitignore entry.
It reads the package list, so a command added tomorrow fails before anyone
builds it, and it passes on a clean checkout where there is nothing lying around
to observe — a rule that only fired on a dirtied tree would be silent in CI,
which is the one place this needs to hold. A pattern that is not root-anchored,
or that names a directory, does not count as covering the drop.
The walk stops at a nested go.mod: a command in another module builds into that
module's directory, and the root .gitignore is not what covers it.
review_report's `kind` carried two facts at once: which review this was, and
which obligation it closed. The first is the worker's to state — it is the
payload it was asked for. The second never was, and reading it out of the same
field made the label the capability: a run contracted for one kind could submit
the other, and every gate downstream accepted it, because by the time a gate
looked there was nothing left but the word the model had written.
So the permission is issued before the run and stamped from outside it.
ReviewAuthority says which obligations a report from this execution may close;
the host binds it to the report tool at mount time, the receipt records it off
the mounted instrument, and ReceiptProves asks that question instead of reading
the payload. The kind stays, as the delivery fact it always was: a submission
naming a kind other than the one assigned is refused at the tool, before a
receipt exists, rather than downstream where a gate has to unpick it.
Authority is separate from Delivery all the way up — on the skill, on the
profile, in the spec — because a worker can owe a report it may prove nothing
with, and one did. Both come from the worker's own declaration, so no entry
point widens either by asking differently, and the built-in security reviewer
declares only the security obligation: whether a security review also answers
for an ordinary one is undecided, and an undecided grant is not asserted.
Two consequences are deliberate. The report tool leaves subRegistryFor and is
mounted where the execution id exists, since a permission issued to a run that
does not exist yet is not one — there is now no reachable state in which the
instrument is present unbound. And read_only_skill, which runs outside the
delegation lifecycle and has no execution to name, gets a grant with no
authority: the verdict is still owed, a block from it still stops delivery, but
evidence nothing can be joined back to does not close an obligation.
The gate says which of these happened. ReviewProofGap separates "no report",
"submitted by a run the host cannot name" and "submitted by a run granted no
such authority", and the refusal names the one that occurred. Without that the
model reads a demand for a review it believes it just delivered, and the rule
it invents to explain the contradiction outlives the turn.
Reporting is accepted once rather than called once: a refused attempt records
nothing, so a run that got the schema or the kind wrong can still correct
itself, while one that already has an accepted verdict cannot file a second.
Split returned one flat map for every frontmatter block, collapsing nested
mappings onto their leaf keys: `metadata:\n type: user` and a top-level
`type: user` were the same fact by the time any caller saw it. That is fine for
the vocabularies it was written for — they are flat — and fatal for a namespace,
because the one thing a namespace is made of is which key a field was written
under, and flattening is exactly the step that drops it.
So the parse keeps the shape the author wrote. Document holds fields in order
with their kind, Lookup walks a path, and Has answers whether the author reached
for a namespace at all — an empty mapping, an empty sequence and a bare key all
count, three states the flat map cannot tell apart from absence.
The flat view is not deleted, it is demoted: Split becomes SplitLegacy, defined
as Parse followed by LegacyFlat, so the historic vocabularies keep their exact
behaviour including last-write-wins and the argument-hint bracketing. Nothing
about their parse changes; only the direction of the derivation does.
A rename alone would not hold. The reason the old shape survived this long is
that reaching for the flat map is the easy call, so `flat-view` names the ten
files allowed to make it. A vocabulary that migrates to the document comes off
the list and the boundary only tightens; a new caller fails. Asking instead
whether a read "looks new" would judge intent, which the source does not record.
11a proved one member of this enum live after the plan had it down as cleanup.
That made the method the finding: a name that reads like history is not evidence
of anything, and the same doubt applies to the six members beside it. So the
family is classified by evidence, once, and the scope stops there.
Six now have a witness — nothing between head and tail, one transaction with
none of it closed, a reachable region that ends inside the body, something new
that is not worth folding, a pinned head already over the trigger, and an
extension that rewrote the fold to nothing. Each is a concrete state, not an
argument that the branch exists.
Two of them are worth reading twice. fixed_prefix_above_trigger reports its
verdict together with a rejection error, so refusing to fold and failing to are
one answer to "why was nothing folded" and the reader takes the verdict either
way. fold_empty_after_hooks needs raw JSON rather than a marshalled payload: the
messages field is omitempty, so a Go value cannot express an empty fold at all,
while an extension is a separate process and sends its own bytes. A witness
built from the host's own struct would have concluded the branch was dead.
input_unchanged is stated and not reached. It fires when the current view hashes
to the one a previous applied fold consumed, and a fold changes that view — so
the match needs the projection to stop being used while the canonical and the
receipt both survive, which is what a resume does and what a second process
would be needed to show. It is recorded as unwitnessed with the reason rather
than as dead, because failing to reach a verdict is not evidence that nothing
reaches it. That distinction is the whole point of the exercise.
The classification is data rather than prose: a member with neither a witness
nor a stated reason fails, so a verdict added later cannot arrive unclassified.
Nothing is deleted — no member of this family turned out to be dead.
11a asked whether NoopNoNewClosedPrefix is still something production produces,
because the plan had it down as cleanup. It is. Deleting it would have removed a
live branch on the strength of a name that reads like history.
Reaching it took three tries, and the two that failed are the useful part. With
no growth after a fold the attempt never happens: the prompt is back under the
trigger. With growth big enough to reach the trigger again, the foldable region
starts past the body and the verdict is the economics of folding it. The state
that reaches this one is narrower than either: a fold, then a small increment,
with a recent-tail budget wide enough to reach back past the tail and into the
body the checkpoint already holds. The tail is measured in tokens, so the window
is what decides how far back it reaches — which is why the fixture varies the
window rather than the message count.
That also explains why the two neighbouring verdicts are not substitutes for it.
NoopInputUnchanged answers when nothing changed and cannot answer here; the
input did change, by one message. NoopFoldBelowEconomics answers when there is
something new to fold and it is not worth folding. This one answers when there
is nothing new inside reach at all, and the three would be one sentence if the
verdict were a sentence.
The test covers the two reachable verdicts it has a fixture for rather than
claiming the whole vocabulary. Its value is the shape: a code is proven live by
reaching it, and a code that resists reaching gets investigated before it gets
deleted.
10a found the typed-review requirement had no owner: neither the skill nor the
profile could express it, and what decided it was a switch on the worker's name
consulted by one compiler. That made two independent errors at once — a reviewer
renamed lost its obligation, and an ordinary worker that borrowed the name
gained one — and left task(profile=review) with nothing to inherit.
So the fix is a declaration, not parity. DeliveryContract lives on the
definition, the built-in reviewers declare their own verdict there, and it
projects into the execution spec the same way a tool ceiling does. Both entry
points read only that projection. The name switch is deleted rather than moved:
putting it in the task compiler too would have made the accident look like a
design.
task(profile=review) therefore changes behaviour and is meant to. The same
reviewer owed different things depending on which button started it, and nothing
about the product justified that — only the compilation path did. Moving the
rule from wording to structure fixes the false negative and the false positive
together; the parity is a consequence.
The contract is declared by built-in definitions and deliberately not parsed
from user frontmatter. Where the fact lives and who may assert it are different
questions, and if a security verdict ever counts as verification evidence, a
line of YAML should not be enough to claim it. That one stays open on purpose.
The guard is the falsifier inverted. What proved the defect — a renamed reviewer
losing its obligation, an impostor gaining one — now proves the fix, through both
surfaces, and the built-ins are read from the store the way a session reads them
so a definition that dropped its declaration fails loudly.
Two files were extracted rather than widening a budget: the contract to its own
file, and the frontmatter key vocabulary out of a 1400-line loader.
10a asked which layer owns the requirement that a reviewer answer with a typed
verdict — the skill, the profile, the worker, the compiler, or the delivery that
enforces it. The answer is none of them.
Neither skill.Skill nor agent.ProfileDefinition has a field that could express
it. That is checked over the types rather than one value, so a field added later
is found whether or not anyone sets it. What remains is a switch on the worker's
name, and the cross-wiring shows the name is the whole of it: an ordinary worker
that borrows the name acquires the obligation, and the reviewer under another
name loses it. Only one surface consults that switch, so the same worker owes a
verdict through run_skill and owes nothing through task(profile=).
The measurement is what each child is actually handed. review_report is attached
exactly when the requirement is set and from the same field the gate reads, so
offering the tool and owing the verdict are one fact, observed without exporting
anything for a test.
This reframes the divergence recorded when the runners converged. It is not a
parity gap: there is nothing for task(profile=review) to inherit, because the
requirement was never a property of the reviewer. It is something one compiler
adds on recognising a word — the phrase-table shape retired everywhere else in
this tree, still standing here because nobody had asked it for its source.
So the tempting fix is the defect. Adding `if worker.Name == "review"` to the
task compiler would not close a gap; it would put the same undeclared derivation
in a second place and make the accident look like a design. An owner has to exist
first, and choosing one is the next cut's question — this one only establishes
that the candidates a reader would consult cannot be it.
The six cells are logged rather than asserted individually. What the table
establishes is where the requirement enters, and a per-cell expectation would be
asserting the answer it exists to find; the two hard assertions are the ones that
would have to change for the finding to stop being true.
9b gave a host-started execution an identity of its own, and left one entry
point into that change unmeasured: the controller's synchronous call, which 9a
had deliberately excluded because its cancellation and session ownership need
not match a slash invocation's. Excluding it from the measurement is right;
leaving it unverified after changing what it produces is not.
Three arms, entered the way that surface's callers enter it and stopped by the
owner it actually has — the caller's own context, never the controller's gate,
which never admitted the call and holds nothing for it. Two premises had baked
in the interactive surface and were corrected: readiness read the gate for a
call the gate had never heard of, and the arm required a synthetic dispatch id
that a synchronous call does not mint. Each surface's premise is now its own,
which is the point of measuring them apart.
Every provenance row matches: the same host-owned execution, the same empty
lineage, the same journal, the same store join, the same restart. Three things
differ and each follows from what the surface is — who owns the stop, whether a
sink exists to draw on, and whether a synthetic id was minted for the screen. So
the contract is narrower than "identical" and stronger than "both work":
Host execution provenance is surface-independent. The live delta stream, the
synthetic dispatch id, and cancellation ownership are surface-specific.
The delta stream belongs in the second sentence rather than in a parity failure
because the authority holds the node on both: the snapshot is rebuilt from
durable facts, and the stream is only where a sink happens to be. The arms
therefore report the two together, so the surface-specific half is visible
instead of read as an absence.
Across 63 arms nothing is violated and no arm is invalid. Four other rows moved,
all in the canonical-transcript and capture-timing family measured as flaky
before; derive-answered moved back to the value three same-build repeats found
dominant, confirming that reading.
9a showed that a host-started execution had no name that could outlive the
process. The store's ref does not exist while the scheduler is holding work
back; the synthetic slash id may never be persisted, because a reader finding it
would take it for a call the model made; and the parent call must stay empty,
because there is none. Three candidates, none of them able to be the answer, so
the answer is a fourth thing.
SubagentMeta now carries ExecutionID beside ParentToolCallID. They are different
questions — which execution this is, and which provider-visible call it descends
from — and they were one field only because every durable execution so far came
from a tool call. A model-delegated run still has them equal. A host-started run
has the first and not the second: an execution the host mints before the work is
opened, a root node with no spawn edge above it, and an empty lineage.
That distinction also removed a conflation in the opener. Descending from a call
and having somewhere to draw are separate facts, and treating them as one made a
run outside any run loop unopenable. It is durable work; it gets an execution.
The picture goes wherever a sink exists.
Reads are lenient, writes are strict. A record naming its own execution is
joined on it; one written before that did is aliased to its parent call only
where the journal opened an execution by exactly that id, because the journal is
what confirms the older writer used one id for both — a parent id alone proves
only what it meant by lineage. A parent naming no execution stays unknown and is
joined on by nothing: a single task once stored the call while the journal opened
the node, and a prefix rule would have joined those two wrong. Six shapes are
pinned as a table. New writes refuse an unnamed execution outright, since that is
the one record no later reader can place.
Nothing historical is rewritten, and this does not promise it will be. The safe
legacy subset resolves losslessly on read; the unsafe one — a host-started record
from before this, which never had an execution — cannot be recovered by any rule
that is not inventing history. Resolving on read forever costs a map lookup and
claims nothing.
9a's four arms rerun exact: what was record-only and LOST-SILENT is now an
execution the journal opened, a root node on the graph, and a restart that can
place the child. Both oracles still hold in every arm — no persisted lineage
names a host event id, and every host-started parent is empty. The frozen matrix
gains the host row and joins on the execution rather than the parent, and across
60 arms nothing is violated.
9a measures one surface and its name says so: a person typing /<skill> into a
live session, routed through Submit to the controller's slash path. The headless
counterpart is deliberately not measured beside it — caller-owned, with its own
cancellation and session ownership — because reading one for the other is how a
real number gets the wrong subject, which has happened twice in this series.
The fourth arm is queued rather than restart. Restart is an axis every arm
already has; what no other arm can answer is whether an identity exists before
the child does, and only work the scheduler is holding back asks that.
Four identities are printed apart, because this is the first surface where they
may not coincide: the synthetic dispatch id the host mints so a child's activity
can nest on screen, the store's own artifact ref, the provider-visible parent
call, and the execution a journal would open. For a model-delegated run the last
two are one string, and that has only ever held because a tool call produced
every durable execution there was.
What it found is narrower and sharper than "a slash skill records nothing". The
store is not empty: since the runner converged, a slash-started child is marked
running before it acts and carries a correct terminal afterwards — completed,
interrupted, cancelled, each measured. What is missing is the execution. Nothing
was ever opened, so nothing places that child, and the arms say exactly that:
record-only, a reader can see a child of this name was cut and cannot say which
invocation it belonged to. LOST-SILENT is reserved for the queued arm, where
work the scheduler had admitted into its domain and then refused leaves nothing
at all.
That is what makes the queued arm decide a linearization. An identity for this
class would have to exist before a child artifact does, and the three candidates
fail by exhaustion rather than by assertion: the synthetic dispatch id may never
be persisted as lineage, the child ref does not exist while the work is queued,
and the parent call must stay empty because there is none. Both oracles held in
every arm — no persisted lineage named a host event id, and every parent was
empty.
Two labels were corrected while building this, both mine. A restart that still
holds a child record is not silence, and calling it LOST-SILENT would have
reported a surviving record as a total loss; and the rebuilt column credited the
holder fleet's nodes to the invocation until it was filtered to the execution it
belongs to.
Nothing is implemented. These arms establish what the candidate identities
cannot do; what replaces them is the next cut's question.
Every surface that spawns a child now belongs to one of two classes, and this
records which — not as a summary of the arms that established it, but as a gate
a new entry point has to pass to join either.
durable delegation task, fleet, parallel_tasks, run_skill (model-invoked)
opened before visible, started before it may act,
stored once it has, reconstructable afterwards
ephemeral research read_only_task, read_only_skill
same scheduler, and no claim a snapshot cannot justify
run_skill is listed as the model-invoked entry point only. A slash invocation
reaches the same runner without a provider-visible parent call, so it is not a
delegation and the table makes no claim about it; saying otherwise would let the
matrix outrun what was measured.
The guard holds the classification where a completed run cannot: what the store
answers at the one instant a child is inside the provider. A check made after the
fact cannot tell a recorded execution from an unrecorded one that happened to
finish, because completion writes a record either way — the first version of this
test had exactly that hole, and a fan-out item that skipped the store passed it.
Two mutations are runnable now and each turns exactly the rows that would drift:
an ephemeral entry point that opens a delegation again, and a fan-out item that
records nothing while it runs.
Two things are left open on purpose rather than tidied away. A slash-invoked
skill records nothing at all; the contract that produced that is narrow and real
— a synthetic event id must not be persisted as though the model had issued the
call — but it is not the claim that host-started work deserves no provenance, and
only the first has been established. And run_skill(review) requires a typed
verdict where task(profile=review) does not; that is about what a profile name
carries, so unifying a lifecycle was the wrong moment to decide it.
Step 8 seals as: every model-delegated durable subagent surface now shares one
lifecycle authority; the ephemeral read-only surfaces are explicitly outside it;
host-initiated top-level execution is not a delegation and its provenance is
deliberately left unresolved.
8d-4 asked whether the execution authority can account for what the two
ephemeral entry points draw, and found them disagreeing. read_only_skill drew
nothing. read_only_task drew a group and a worker that Controller.ExecutionGraph
could not produce, because it is rebuilt from durable facts and an ephemeral run
writes none — so a reader that lost the stream and started from the authority
again lost both nodes while the child they named went on holding a slot.
That is not a lighter kind of node. The graph is a projection of durable facts:
a reader starts from the snapshot and returns to it after every gap, so what the
snapshot cannot produce survives only until the next resync. The node was there
because unifying the lone-delegation path gave every delegation a picture
without asking whether the authority could account for an ephemeral one.
So the picture goes where the record already went. An ephemeral run opens no
delegation at all: no node, no journal, no store, and no lifecycle to hold a
flag about which halves it skips. The flag is gone with it — an entry point that
opens nothing has nothing to make conditional, and three call sites stop asking.
The caller still sees what it delegated. A tool call and its status are honestly
live-only surfaces that claim nothing a later reader is owed, and the test
asserts both halves: no node is drawn, and the parent is still told.
What this does not do is answer who currently occupies a scheduler slot. That is
a real question and a different one — an ephemeral run does hold capacity — and
it belongs to a read model that can say plainly it does not survive the process,
not to a graph whose whole contract is that its contents do.
Both arms now read identically across all five rows, and across 56 arms nothing
is violated. The four other rows that moved are the canonical-transcript family
whose values were already measured as timing-flaky, and each moved back to the
value three same-build repeats found dominant.
8d-2 classified the normal skill runner as a shared delegated execution: it asks
the same scheduler, runs a real child, keeps transcripts and supports
continuation. What it did not do was leave a trace of any of it — an executing
skill was named by no graph, no journal and no store record — and a stop that
reached its child was filed as a failure. Both followed from the same thing: it
had built a second execution owner beside the one every other delegation uses.
So the fix is not to give it a lifecycle. It is to stop it having a loop. A
skill now compiles to a ProfileExecSpec and hands it to RunProfileSpec, which
already owns admission, the child's own run, and how an execution ended. The
runner keeps what only it knows — the body, the workspace facts a child cannot
discover for free, the tool ceiling, the whole-workspace claim a writer skill
carries, the typed verdict a reviewer owes — and nothing about how an execution
is admitted, carried out or ended.
Three semantics had to survive the move, and each has a test rather than a
promise: the prefix is the body plus the workspace facts, not the body alone; a
writer without declared paths still claims the whole workspace so it serializes
against fleet and task writers; and a reviewer still owes a typed report, which
the spec now carries as a worker property because it follows from which profile
this is and never from what one caller asked. That last one is deliberately not
derived from the worker's name: doing so would have handed the same contract to
task(profile=review), which does not have it today, and a convergence must not
change a delivery contract on the way past.
The cancellation defect is fixed by deletion. The legacy runner classified every
error as failed; RunProfileSpec already had the one classifier that tells a
deadline from a cancellation from a failure, so removing the duplicate makes
skill-cancel read cancelled where it read failed. Nothing was written to address
the symptom.
Lineage keeps its existing contract. A host-started run carries a synthetic call
id so the child's UI can nest under it, and that id must not be persisted as
though the model had made the call — so the spec says the run is top-level and
it opens no delegation and is stored under no parent. Whether a slash-invoked
skill should instead join its own host-emitted dispatch is a question this cut
does not answer; it leaves that path recording nothing, as before.
Two gates: a skill must leave the records every delegation leaves — an opening,
a slot grant, a settlement, and a child joined to that execution — and a
host-started one must be filed under no call, including when a synthetic one
exists. Reintroducing the private loop as a mutation turns the first red.
The probe's own criteria are corrected where they were written for the world
before this. A refused item's negative control is about the store alone: an
opening is the orchestration recording that work entered it, which a refused
item did, and demanding journal silence would forbid the record that tells a
restart it was held back rather than lost. The identity row now checks the join
rather than one spelling of it, because a record's parent is the execution's own
identity and comparing it to the bare call id fails the chain that makes them
joinable.
Across 54 arms nothing else moves and nothing is violated. The read-only entry
point is untouched: every one of its rows reads exactly as before, and whether
an ephemeral run that holds a scheduler slot should be drawn stays open.
8d-2 classifies rather than compares. Whether a runAs=subagent skill belongs to
the delegation class the task and fan-out arms established, or is an execution
class of its own, leads to opposite work — converge it onto the shared owner, or
declare an exclusion — and the answer cannot come from which plumbing happens to
exist today.
Two runners are measured apart instead of averaged. The tool a model calls
prepares a transcript, supports continuation and saves a terminal; the read-only
entry point builds a session in memory, refuses continuation and stores nothing.
Asking the second for durability would be demanding it break its own contract,
so those arms ask what it is instead, and answer in a classification —
ephemeral-seen or ephemeral-unseen — rather than a pass.
Seven dimensions, one row each, because a skill can share one domain and not
another: scheduler, graph, journal, store, cancel, restart, identity. The
scheduler has no refusal hook to read, so admission is established as an A/B —
the call is out, unanswered, the ceiling occupied and no child of its own has
reached the provider, against the same dispatch with capacity free whose child
is inside it. Only the slots check is reached that way, and the README says so
rather than letting a writer claim be read as measured.
Identity reads the two entry points apart. A model-invoked skill has a call to
join to; the controller's own drops that id deliberately, so an empty one there
is top-level provenance and not a lost join — both run in one death so the row
compares two lineages rather than a value against an expectation. The cancel row
judges the terminal against an observed cause, never wording: the child's own
context closed, which is what a cancellation does to it and a failure does not.
The queued arm is the negative control and bounds any convergence: an execution
the scheduler never admitted produced nothing, so a change that satisfied the
running arm by preparing a transcript at dispatch would break it.
Three labelling faults were caught while building this and are fixed here rather
than reported later: a journal and a store that were empty on both sides read as
persisted-direct, a restart row credited the holder fleet's interruption to the
skill, and the stop row named a guarded turn caller-owned. Absence now has its
own verdict, evidence is filtered to the execution it belongs to, and the graph
row carries the whole graph's node count so "the filter found nothing" cannot be
read as "there was nothing to find".
PROBE-PARALLEL sat inside PROBE-PARALLEL-IDENTITY, and sentinels are matched by
substring, so the identity arm's prompt opened the cancellation fan-out as well
as its own. The arm then reported a scenario nobody wrote: five extra workers it
never dispatched, appearing or not depending on where the death fell relative to
a fan-out that was never part of its design.
It surfaced as a verdict that moved with an unrelated change, which is the worst
way to find it — a real reading in an arm measuring something else. Removing the
containment returns the row to exact on three runs out of three and takes the
uninvited fan-out out of the arm entirely.
Care had already been tried and was not enough: the child sentinels carry a
comment about exactly this hazard, chosen after it bit once, while the parent
sentinels had no guard at all. So the rule is a test over every sentinel in the
probe rather than a second comment, and it is the containment that is checked,
not the spelling.
8d-1 measured one store answering differently about executions it holds the same
evidence for. A fan-out item whose slot the scheduler had granted, whose child
had reached the provider, and whose start the journal had recorded was absent
from the sub-agent store entirely — live and after a restart — while a lone
delegation in the same death, the same store and the same join read running and
then interrupted. The store is what list_subagents, read_subagent_result and
adopt_ref address, so that item was work nobody could ask the host about.
The cause was where the store's record hung, not what the fan-out is. A lone
delegation marked it from the lifecycle that opened it; an item whose group did
the opening had no lifecycle, so it reached the same runner and none of the same
record. The boundary now belongs to the run rather than to whoever opened it:
every delegated execution crosses beginExecution once it holds a slot and has a
transcript, and there it is recorded, then drawn, and only then may act.
Nothing else moves. The group still owns dependency, adoption, skipping and
settlement; an item the scheduler refused is owed no record and still gets none,
which is the proxy this line of work removed and must not reintroduce. What did
have to move is the picture: the slot grant used to draw a fan-out item running,
which put the drawing ahead of the record, so both entry points' start hooks now
write the journal and nothing more. The item's node travels with the sink it was
drawn on, because a fan-out item's own sink forwards tool events and drops graph
deltas — the picture is reachable only through the group's.
A refused record stops the run at both entry points, and that is now asserted
rather than reasoned about: the child must not act, nothing may be drawn running,
the slot must come back, and the journal keeps the start it honestly wrote. Two
mutations were run against it — the boundary skipping the store for an item whose
node someone else drew, and drawing before recording — and each turns the suite
red, the first only on the fan-out side.
The active-store arm's two cells flip as specified: an executing fan-out item
reads running then interrupted where it read no record twice, list_subagents
shows it, and the refused item stays absent. Across all 47 arms nothing else
changes but the two fan-out arms whose store row now sees the child that was
executing, and parallel_tasks converges on the same contract.
8d-1 asks the narrower question rather than the parity one. Not "does a fan-out
item get what a lone delegation gets", which would be a finding about two code
paths, but: once the scheduler has granted a slot and the child has reached the
provider, is that execution something the sub-agent store can be asked about?
The store is what list_subagents, read_subagent_result and adopt_ref read, so an
execution missing from it is missing from every recovery surface there is.
One death holds four populations under a ceiling of two. The delegation goes out
first and holds a slot to the end, which is what keeps capacity from returning
under the fan-out; the fan-out's first item takes the other, settles, and frees
it for exactly one of the two behind it. Which of those two wins is the
scheduler's, so the arm reads the populations off the journal rather than off the
ids the dispatch declared — naming them would have made a race the premise.
The finding is a conjunction and each part is its own column: the journal proves
the slot was granted, the child proves it arrived at the provider, and the store
is asked about that execution. Any two of them are not it. A grant with no
arrival is an execution nobody has shown happened, and an absent record for work
that never ran is the correct answer.
Two rows are the arm's own falsifier: the settled item and the lone delegation
run the same acknowledgement check against the same store and the same join, so
a green there is what says the check can see a record when one exists. A third
bounds any fix — an item the scheduler refused is owed no record, and a change
that recorded every opening would satisfy the row under measurement by
reintroducing the proxy the delegation arms just removed. A fourth pins
reconstruction: no node comes back running, and both cuts keep their class.
Measured: the settled item is completed in both processes and the delegation is
running then interrupted, while the executing fan-out item is no record in
either, and list_subagents shows the delegation and not the item beside it. The
refused item is absent as it should be, and the rebuild names both cuts without
a ghost. The arm needed the auto gate the other delegation arms already have;
under the denied default a fleet is refused before any child starts.
The last cut reported that a turn's stop reached work already handed to a
background job. It did not. That arm dispatched a foreground delegation: the
task call only sets run_in_background for arms the probe lists as backgrounded,
and this one was not on the list, so the row said "work already handed to a job"
about work that had never been handed anywhere.
Three arms now read both trees at one instant, and they agree:
parent stop, foreground turn ends — child ctx closes record settled
parent stop, backgrounded turn ends job runs child ctx open record still open
the job's own stop turn active job gone child ctx closes record settled
The handoff boundary holds. A stop aimed at the turn does not reach the tree the
turn gave away, and the job's own stop does — which is the ownership this step
built, now measured from both sides rather than assumed from one.
Each reading keeps five facts apart, because a context that closed and a call
that returned are different events: work whose caller stopped waiting leaves the
same absence behind as work that was cancelled, and only one of those is a
cancellation. The two positive controls are what make the middle row mean
anything — without the job-kill arm, a reading that could never see a job's
context close would report the same green.
One question was dropped rather than answered: a second backgrounded run
dispatched after the stop, to ask whether the manager's own root survived. It
reported "refused new work" for a reason the probe could not explain, and an
unexplained failure inside a measurement path is exactly the proxy this line of
work keeps removing. The question is already answered directly — a job still
running with its child's context open is the root — so the experiment went and
the observation stayed.
Two retractions in three cuts, both the same class: the arm was measuring
something other than what its row named. First the wrong owner, now the wrong
subject.
The last cut reported a cancellation defect. It was measuring the wrong owner,
and this A/B retracts it.
A turn the controller admits itself — what Send opens, and what a person's Stop
reaches — carries the cancel the gate holds. A synchronous Run is never admitted
through that gate: the controller holds no cancel for it, and the caller's own
context holds the only one. The probe had been driving Run and then calling
Controller.Cancel, which claims nothing there, and reading Running(), which
answers for a gate that was never told about the turn — so "the turn ended while
its work ran on" was two facts about two different things.
Read the right way round, the interactive surface is correct. An ordinary shell
the turn is waiting on ends the moment the stop arrives. A delegation stopped
while the scheduler still held it back settles with no store record, and one
stopped while its child was executing settles with the child's own cancelled
terminal — the table this step was aiming for, on the path a person actually
uses. On the other surface both halves hold too: the controller leaves the
caller's work alone, and the caller's own cancel ends it.
One arm is red, and it is the one that matters most. Work already handed to a
background job — which the tool result tells the caller runs across turns — is
gone the instant the parent turn is cancelled, with the probe confirming it was
live when the stop was issued. That contradicts the ownership boundary the
handoff exists to draw. The mechanism is not located here: a job's context is
rooted in the manager, not the turn, so something else in the child's chain is
carrying the cancellation, and finding which edge is the next cut's job rather
than a guess in this one.
The readings now say what surface they came from, so neither answer can be read
as the other's again.
8c left one reading unexplained: a cancelled turn ended while its foreground
delegation stayed open and its child kept running. That could have been three
different defects, so this is the probe that tells them apart rather than a fix
aimed at the first guess.
Four readings, and they localize it. A plain shell — `bash sleep 30`, a tool
that derives its context the way every tool does — is still running three
seconds after the stop, with the turn already ended. So the fault is not in how
a delegation derives its context: an ordinary tool loses the same cancellation.
The delegation arms agree from both sides of the slot: one stopped while queued
and one stopped while executing are both left open after the turn is over.
The fourth is the negative control and bounds any fix. Work already handed to a
background job keeps running when a stop is aimed at the turn — which is
correct, and is what a cancel that reached everything the session knows about
would destroy. A fix passes this arm only by cancelling what the turn owns and
nothing further.
The delegation arms now carry this as an invariant instead of a premise: once
the turn has ended, nothing it owned may still be open. An open record implies a
live owner, since nothing else closes one, so the child's liveness rides in the
reading as evidence rather than standing in as the test — a delegation still
waiting for a slot has no child at all and is just as much still owned. That
also fixes a hole in the first version of the row, which read a queued
delegation's absent child as work that had ended.
No production change: this says where to look, and the next cut says what to do
about it.
8c asked one question: on each side of the slot grant, who owns the ending. The
answer the code gave was wrong on the near side. A backgrounded delegation
killed while it was still queued wrote a failed child record — and the store
outranks the lifecycle on a rebuild, so a cancellation before anything ran came
back as a failure that never happened. The journal said it plainly at the same
time: opened, queued on slots, settled, never started.
The cause was not vocabulary. A background run has its transcript before it has
a slot, so "a run object exists" had come to stand in for "a child executed",
and every early exit on that path wrote a terminal for it: a refused admission,
a writer registration that failed, a store that would not mark it running. None
of those is an execution outcome. They now return their error and leave the
store alone, which the journal's opened-queued-settled already accounts for.
Four arms hold the boundary from both sides. Backgrounded, a delegation killed
while queued now leaves no child record and rebuilds as cancelled; one killed
while running keeps its own cancelled terminal and rebuilds exactly. The rows
carry a cross-authority check that refuses the combination neither layer may
produce — a child record for work the journal proves never started — because a
state comparison shows that only as "cancelled came back failed".
The two foreground arms do not establish. Stopped through the turn's own cancel,
the turn ends — running=false, no cancel pending — while the delegation is still
started and never settles: its child keeps waiting on a context the cancel did
not reach. That is a separate question from ownership and is left open rather
than guessed at; the arms report it as their premise failing, with the reading
that produced it. Cancellation at the delegation's own context settles and
records correctly, which a unit test now holds.
8a and 8a.1 measured two failures with one cause. A single task reached the
same runner a fan-out item does and none of the same records — nothing on disk
for a restart to read — and it drew itself running before it had asked the
scheduler for anything, so a delegation the ceiling was holding back appeared,
to the graph and to the parent, as work in progress. Both came from the same
place: states were being inferred from where control flow stood rather than
projected from what the scheduler and the child's own ending had proven.
The lifecycle is now one thing that decides nothing. It opens after every
deterministic refusal has answered — a prompt that would not settle or a
runtime that would not resolve is not work orchestration ever held — and writes
the opening from the same delta the graph is drawn from, journal first. The
node opens pending. Queued and its cause come only from the scheduler's first
refusal; running only from the slot grant, and behind it the store's own record,
so nothing shows a run executing that the store has never heard of.
Two proxies are gone. The foreground no longer announces running before
acquiring a slot, and a background run no longer reports queued because a job
was registered: that word now means the scheduler refused, and an immediate
grant goes straight to running. What tells a caller its work was handed to the
background is the tool result, which says so in words.
A backgrounded delegation is on the graph like any other. Hiding it was the
lexical lifetime of the old opener, not a semantic: run_in_background decides
whether the parent waits, not whether the delegation exists. Ownership moves
with the handoff, exactly as the progress tracker's already does — the caller
settles what it still owns, the job settles what it took.
The store's parent is now the execution's own node. That join is what a rebuild
reads, and with the journal keyed by node and the store by call, a completed
lone delegation rebuilt as skipped: the outcome could not be found. The two
spellings are one identity again.
read_only_task keeps its promise: ephemeral entry points write no journal, so a
research child stays out of the history a restart rebuilds from, while its
caller still sees what it delegated.
Six mutations were run against the new tests: publishing before recording,
opening the node running, marking the store running before the slot, settling a
handed-off run at the outer return, telling the parent running before the slot,
and recording an ephemeral delegation. Each turns the suite red. The five
process-boundary arms now read pending→queued(slots)→before-start and
pending→running→during-execution on both entry points, and a completed
delegation rebuilds exactly.
The boot-wired skill runner prepares its own transcript rather than going
through this path, so it inherits none of it yet; that is 8d's question, not a
gap this closes quietly.
The four arms before this all asked what a restart can say. This one asks about
now: fill the session's ceiling, delegate once in the foreground, and read the
picture while the scheduler is refusing the delegation admission. A graph and a
parent status are claims about the present — a restart cannot correct one,
because by then it describes a moment nobody can observe again.
It is drawn as running. The node reads probe_task/sub-1:running and the parent
is told running, while the child has not reached the provider at all and the
fan-out holding the ceiling has queued=slots on disk. The lone path calls
trk.running() and publishes its node before asking for a slot, and its scheduler
hooks are nil, so nothing later corrects either.
The premise is established from both sides rather than assumed: the ceiling is
proven full by the control fleet's own journal, and the delegation's child
leaves a record when it reaches the provider, so "it never started" is read
rather than inferred. The arm then settles for two seconds and asks again — a
child that was merely slow to start would have arrived, and the death would be
measuring a race instead of a refusal.
The same row is carried by the background queued arm, where it holds: that path
draws no node at all and its parent status is the job's registration rather than
a scheduler verdict. Two different pictures of the same refusal, and only one
of them says something untrue.
A single task is not a second executor. It reaches the same RunProfileSpec a
fleet item does, asks the same scheduler for a slot, and draws its node through
the same publication. So the question these four arms ask is not whether a lone
task has a graph — it does — but where that shared chain stops being wired to
the durable authority the fan-out arms established. No production code changes:
this is the reading, taken before anything is moved.
Four deaths, because they can answer differently. A delegation that finished in
a turn that closed loses nothing to interruption. One killed mid-execution is
the fan-out arms' death, one entry point over. And the two background arms are
the ones the foreground cannot stand in for: a backgrounded run hands its
execution to a job and asks for a slot inside it, so the parent call has already
returned when the refusal lands, and the terminal belongs to the job.
What the matrix says, across all four:
durable opening, queued, started, settled — nothing, in every arm. The
journal is never written for a delegation that did not come from a fan-out.
the rebuild — nothing: no node, no grant, no worker, no wait, no
interruption. A restart reads a session that delegated nothing, even in the
arm where the store still holds a completed child.
the live graph — a foreground run draws its node; a backgrounded one draws
none at all, which is the early return openDelegationGraph takes for a run it
cannot settle. The live parity is already asymmetric.
the sub-agent store — the one layer that survives, unevenly: a completed
foreground child persists directly; a background child is recorded running
and reads back interrupted; and a foreground child killed mid-execution
leaves nothing at all, because nothing is written until it settles.
the identity join — the store's parent call, the call the model made, and the
node's parent are one identity in three arms, and the fourth is only absent
because that death has no store record yet. Step 8 does not need an identity
invented for it.
The queued arm fills its ceiling with a fan-out, and that fleet rides along as
the positive control: same session, same scheduler, same journal, and every
transition recorded. What these rows report missing is missing at the entry
point, not in the machinery.
Two things the harness needed to ask honestly. Rows are filtered to the
delegation under measurement, or the control fleet's own record would have read
as the lone task's and reported the gap closed. And the arm whose turn has to
end signs the host's steps off with complete_step, one per round: a turn that
called a tool cannot deliver while the list is open, and a todo_write that marks
an item completed on its own is refused by design.
read_only_task is deliberately outside this: it declares itself ephemeral, so a
journal entry from it would break a contract rather than close a gap.
Step 6 proved the graph can be rebuilt and 7d proved the view folds it. Both
are value comparisons, and the defect this line of work exists to prevent
agrees with them on every value: a history replayed as live transitions settles
on exactly the right graph and tells the user that work which ended in a dead
process is starting now. Only a sequence separates the two, so `ui-graph-mixed`
records one.
The arm dies holding all seven states at once — a settled fan-out beside a
mid-flight one, which is the only way a skip and a running item coexist, plus a
ceiling of one so something is queued and never admitted. The resumed process
then serves that session on a loopback listener and runs the real client
against it: the production port, the production store, bundled out of the
frontend's own sources with its own toolchain. The probe plays only the part
the shell plays — forwarding the kernel's frames onto a bus with no reconnect,
which is the transport the bootstrap was written for.
Seven rules follow, each a rule rather than a comparison: the first picture is
loading then a snapshot; every execution the dead process opened enters through
that snapshot; none of them is ever drawn as pending, queued or running, not
for one render; the interruptions the view names are the ones the host derived;
work dispatched after the resume arrives as a delta; nothing is introduced
twice; and no delta names work the dead process opened.
That last rule is the one a first-appearance check cannot reach. A republished
history moves no visible state — the fold is idempotent, the snapshot arrived
first — and is still the kernel saying that finished work is happening. It is
also what makes the sabotages fail where they should: `-sabotage publish` fires
a resume that republishes what it rebuilt, and only after the client has read
the snapshot, because what a resume publishes before that is numbered below the
watermark the client resumes past and the bootstrap absorbs it. `-sabotage
trajectory` restores the line 7d deleted, the one that built the graph out of
the recorded trajectory: it fails the republication rule and the ghost rule
together, putting pre-death nodes back on screen as pending and running.
Staging the second one needed the dying process to record what a window
records. A wire log is written by the server, and a headless construct never
had one, so the trajectory it left behind held none of its own frames and the
sabotage was feeding an empty file. The construct now attaches the same
recorder a window attaches, which is what the process being modelled has.
The nine fan-out latches became one named state keyed by sentinel. Each new
fleet was adding another flag to a struct that already carried twelve, and the
one meaning that is not per-sentinel — the arm's own fan-out has gone out, so
the terminal arms hold the turn open — stays a field of its own.
Runs: clean holds=7, publish VIOLATED=1, trajectory VIOLATED=2. The judgement
itself is held by uirows_test.go, because a full arm is three processes and a
live kernel, which is the wrong place to discover a verdict was computed
backwards.
The panel used to build its graph by replaying the trajectory's deltas, which
is what made a resumed session look like a run that had just happened: every
node arrived as a transition, in the order the frames were written. It now
reads /execution-graph and folds the live stream onto that answer. The
trajectory keeps feeding the trajectory pane; it feeds the graph nothing.
Snapshot and delta are kept apart on purpose. A snapshot replaces the model —
the graph, the interruptions, the unrecorded identities and the session it
describes — because the last three have no delta at all: nothing in the stream
can add or retire an interruption, so a merge would keep one the rebuild has
since resolved. A delta carries nodes and edges and nothing else, which is what
stops a live frame from arguing with the authority about the rest.
Two races, two mechanisms, because they are not the same problem. A session
change bumps a generation the read carries, so an answer for a conversation the
view has left cannot be adopted by the one it shows now — "the newest answer
wins" is exactly what loses that race. And the session the answer names decides
what happens to the deltas held while it was in flight: numbered past the cut is
not enough when they describe another run.
The held deltas are this model's own boundary, not the transport's. A first
bootstrap is covered by the stream holding its frames; a gap happens with the
stream running, so the store holds what arrives while it re-reads and then folds
only what the snapshot cannot already account for. A delta numbered at or below
the cut is dropped rather than folded: an older publication would take a settled
node back to what it said on the way there. The watermark is that read's cut and
nothing else — no graph cursor is kept, and none should be.
A session switch clears rather than dimming the old graph. Some hundreds of
milliseconds of "loading" is worth more than the same milliseconds of a picture
that belongs to a conversation nobody is looking at.
What the panel now says out loud: executions whose owner disappeared stand
beside the graph with the two kinds told apart and neither offering to resume;
a node whose worker layer was never recorded says so, rather than showing the
same blank an inherited identity shows; and the queue's refusal is worded as
what was recorded when the node queued, not as what it is waiting for now. The
state also records whether a snapshot or a delta last moved it, which is the
difference a view needs before it can animate anything.
The mock port answers the same contract: it numbers its frames, holds them
while a subscriber reads its snapshot, and answers with the fold of what it has
published. Otherwise the snapshot path would exist only against a real kernel,
which is where it is hardest to see fail.
Two gates were widened to hold this. The four types the desktop re-declares by
hand are now declared mirrors, so a field the kernel sends and the page cannot
read fails; and the route guard's regex could not see a typed read at all —
this.get<T>("/x") matched nothing, hiding 24 of the client's 60 paths from the
check that exists to keep the shell's table honest.
Seven mutations of the store and three of the view were run: dropping the
resync buffer, the watermark cut, the generation check, the foreign-session
rule, merging a snapshot instead of replacing, keeping the old graph across a
reset, answering zero for a failed read, drawing the identity marker without
the list, drawing the old graph while loading, and never rendering an
interruption. Each turns the suite red.
A read model that bootstraps from its own authority needs one thing from the
transport: everything after the frame its snapshot names, exactly once. That is
what the broadcaster already numbers and holds — SubscribeFrom for a reconnect,
Replay for the shell's bus, which has no connection to re-establish. So the
graph gets no cursor and no replay of its own; subscribe takes a bootstrap read
and answers with the same one stream.
The cursor stays the stream's. 101 may be a graph delta, 102 an approval, 103 a
delta again — a cursor that only advanced on the frames one model folds would
read 102 as a hole it never had and ask for everything after it a second time.
The bus is already delivering when a page attaches, so frames are held from
before the transport is wired until the replay lands, and merged with what the
replay brings by number. Both paths reach the same frames: the log holds
everything after the cursor, the frame that exposed the gap included, so a
replay answers with a copy of it. Folding both is what put the same tool result
on screen twice, and the cursor now settles it — a numbered frame at or below
what has been folded is the same fact arriving the other way.
A watermark and a gap frame both state a position, and while a bootstrap is
stating one from a snapshot neither may baseline the cursor: taken here it
would sit ahead of frames still held, which are then dropped as duplicates of
nothing. The hole itself still reaches the caller, because a gap is a transport
fact and which authority each model re-reads to close it is the caller's — the
transcript for one, the run graph for another. The comments that said /history
have been corrected to say that.
Inside Studio /execution-graph answered index.html: 7a registered the route and
taught the dev proxy about it, but not the shell's own table, which the shell's
route-parity test had been failing on along with /adjudications and
/context/window. All three are routed now.
Seven mutations were run against the tests: dropping the replay, dropping the
buffering, dropping the dedupe, advancing the cursor only on graph frames,
letting either meta frame baseline during a bootstrap, and never releasing a
failed one. Each turns the suite red.
Step 6 proved the graph can be recomputed. This is how a reader gets it:
Controller.ExecutionGraph rebuilds from the journal, the store and this
process's own claims, and nothing is emitted. A reader asks; it is not sent
a stream of transitions that already ended, which would tell it a child had
just been spawned and had just completed when neither happened.
The snapshot carries what a graph alone cannot say. Executions whose owner
disappeared ride beside it rather than inside a node state, because the
vocabulary has no word for work nobody is running. So do the executions
whose worker layer was never recorded: their model reads empty for a
different reason than an inheritance does, and showing both alike would
claim an observation the host never made.
Over HTTP the watermark is read before the rebuild, and that order is the
whole contract. Read after, a transition landing in between is numbered
below a watermark the client then resumes past, and the frame is lost with
nothing later to reveal it. Read first, the same transition is replayed onto
a snapshot that may already hold it — which the fold absorbs, because a node
is upserted and an edge added once. Duplicates are safe; gaps are not.
The test drives the real handler and settles a worker during the rebuild, so
inverting the two reads turns it red rather than leaving it a comment about
intent. The snapshot also names its session: frame numbers keep climbing
across a switch while the replay tail is cleared, and a client that merged a
new snapshot with the previous conversation's deltas would fold two graphs
into one.
/graph gives a headless run the same answer. That is not symmetry for its
own sake — after the process that ran a fan-out is gone, this is the only
way to ask what it did, and frontend parity was what pointed out the gap.
GraphDelta is unchanged. It stays the low-latency projection of the same
facts; the snapshot is the authority.
A node's model and effort survived a restart only when a caller had named
them, and the reason was not that the fact was unrecoverable — the store has
a model for every child that ran. It has a different one. The store resolves
inheritance; the graph's field does not, and an empty model there says the
worker layer named nothing and the parent's value stands. Filling one from
the other would have made the rebuild more specific than the picture and
deleted that.
So the opening records it, taken off the same delta the graph is drawn from
rather than resolved a second time, and the rebuild reads it from there and
never from the store.
It is stored whole, present even when both fields are empty, because absence
and "named nothing" are different answers that both read back empty. Only
presence separates them, and without it a sidecar written before this would
report as an exact reconstruction of an inheritance nobody had recorded. An
older entry that has none leaves the identity unknown; it does not borrow the
store's, and it is not migrated.
Measured, all three fan-out arms: states, topology, grants, wait causes, the
interrupted set and identity are exact. The identity arm's four shapes each
come back as they were drawn — an inherited model stays empty beside a named
effort, and an adoption stays empty on both.
Step 6 closes with no vocabulary added beyond this one field.
The rebuild was lossy on model and effort, and the reason given for it was
wrong. This arm names a worker four ways across both producers and reads
every layer:
item graph store
names nothing none/none probe/scripted/none
names a model and effort probe/alt/high probe/alt/high
names only an effort none/low probe/scripted/low
adopts, runs nothing none/none no child at all
fleet and parallel_tasks agree cell for cell. There is no producer
disagreement: the earlier reading that one filled the caller's request and
the other the resolved identity does not survive naming a model and looking.
Both go through the same resolution; what differs is the layer they report.
The graph shows what the sub-agent layer resolved, where empty means inherit
the parent, and the store shows the final identity with those blanks filled.
Which makes the remaining loss one item shape: a caller that named something.
Everything the graph shows for those is in the store, and taking it would
make the rebuild more specific than the picture — an inherited blank becoming
an explicit value, which is how the first attempt at this ended up knowing
more than what it was rebuilding.
So it is a choice, not a gap, and the arm exists to state it rather than
settle it. Either a node's model means the final identity, and the producers
fill it from the store's layer with nothing new recorded; or empty keeps
meaning inherited, and that is a fact only the journal could carry.
Two probe defects fixed on the way: a second model reference, because with
one every layer of the resolution collapses onto the same string and none of
this is visible; and a wait that stopped at the first producer to settle,
leaving the other two unread.
The graph a process draws dies with it. What outlives it is the execution
journal and the store's record of the children that ran, and those two now
fold into the same vocabulary the live graph is drawn in.
It recomputes rather than restores. Nothing reads a file, emits an event, or
knows what an agent is: the inputs are read models the caller loaded, the
output is a value, and the same inputs always give the same picture.
Two states are never rebuilt. An execution that was running or waiting when
its owner disappeared does not come back as running or queued — the owner is
gone and either would describe work nobody is doing. They come back as
identity and topology with no state, named separately as interruptions and
split by whether they had reached a slot.
Measured against what the dying process drew: states, topology, grants, wait
causes and the interrupted set are exact in all three fan-out arms.
One gap remains, and it is narrow: the worker override a caller named for an
item. The store keeps the identity it resolved, which is a different fact —
filling one from the other would put a third meaning on the field — and the
journal records neither. It is also not consistently meant before a restart:
fleet fills it with the override and parallel_tasks with the resolved
identity, so what a rebuild should agree with depends on the producer.
Two probe defects fixed while establishing this. The identity comparison was
green because the arms never named a model, so both sides agreed a field was
empty; it names one now. And PROBE-CHILD-FAIL is a prefix of the sentinel
meant to delay the second failure, which sentinels match by substring, so the
two skip arms had been differing by whatever the runtime chose rather than by
construction. The cause rule survived that — it reads the order that actually
happened — and the arms now hold the second failure until the first is
recorded, and repeat.
The skip-cause row reads three ways: what the picture named, what this
benchmark works out on its own, and what the production fold concludes. A
probe that only asked the implementation would agree with it by construction.
Three arms ask whether skipped, its cause, and the context edge need
records of their own. None of them does.
They stand on one join: a child's metadata names the execution it ran for,
so a restart can ask the store what each item ended as. Answered is then
computable without the graph — an adoption says so on its own opening, and
everything else is the store's terminal.
Skipped state derives. An item released without ever starting, ordered
behind something that did not answer, is exactly the set the picture drew,
in both skip arms and empty in the arm where both upstreams answered.
Context derives, including through an adoption. The dependent receives from
one completed and one adopted upstream and the prediction matches edge for
edge. The adopted half works only because the source rides the opening:
without it that upstream would read as unanswered and the edge would go
missing — which is what the arm before this one measured.
The cause derives too, but not the way it looks. The skip arms leave
identical durable facts and the picture names a different upstream in each,
so declaration order is refuted outright. Earliest release holds both times,
and not by luck: a fan-out cuts a branch when it processes the first result
that did not complete, and the journal releases an item in that same
handler. The order it records is the order the choice was made in. The cause
is a historical fact that is already written down, in the timestamps.
The answered arm waits on a delivery edge rather than a count of completed
items — the warm-up fleet alone satisfied the count, and the observation was
taken before the fan-out under test had started.
An adopted item survived a restart as adopted and could not say what it
adopted. The disposition rode the opening — an adoption is declared before
anything runs — while the source lived only in the edge the graph drew, so
a restart could see reuse and not what paid for it.
The source now rides the opening too, taken from the same delta the adopt
edge comes from. That is what keeps the two from naming different sources:
there is no second place to compute it from, and no terminal transition was
added for a fact that was already true when the item opened.
It is carried through as the graph spells it. What kind of thing a source is
belongs to whoever reads the graph back; a journal that parsed it would be a
second vocabulary for an identity it does not own.
An opening is held to one story about itself: an adoption must name a
source, and an item that is going to run must not carry one. Only new writes
are held to that. An older entry recorded before the source was captured
stays readable as an adoption whose source is unknown — lossy history rather
than corruption, and nothing else in the record could be read to guess it,
the same reason a historical failed status is not reclassified.
Three timestamps lost an omitempty that never worked. It does nothing for
time.Time, so a zero value was emitted as 0001-01-01 under a tag promising
it would be dropped, and a consumer reading the field saw a transition that
had not happened. It read that way here first.
The probe's judgement is unchanged: the adoption-source row flips from lost
to persisted-direct on its own, while the adopted item still records no
queue, no start, and no child in the store.
The run graph tells cancellation from failure. The sub-agent store, which
owns what happened to a child, did not: every error path saved a child as
failed and SubagentStatus had no cancelled to save. The probe measured the
result as graph cancelled=2 completed=1 failed=1 against store completed=1
failed=3, with the journal — which records orchestration, not outcome —
unable to help. The distinction was not un-persisted, it was unkept.
The store now keeps it. A cancelled run persists everything a failed one
does; a transcript is as worth reading when work is cut short as when it
goes wrong, and only what it says happened differs. One status covers both
ways a run is stopped, because the graph maps a cancelled context and an
expired deadline onto one state; which of them it was rides beside it, so
the two owners share a vocabulary instead of splitting one.
Only the two seams where a child actually ran may read a context error as a
terminal outcome. The other four SaveFailed callers — a failed writer
registration, a panic, a refused slot, a transcript that would not save —
are host failures whose chains can carry a cancellation without the work
having been stopped. They keep calling SaveFailed, which takes no error and
therefore has nothing to classify: the negative control holds by shape
rather than by judgement.
Old sidecars are not migrated. A historical status=failed was written by a
store that never knew this distinction, and the same error text can come
from paths that were not cancellations; reclassifying on it would invent
provenance nobody recorded.
The probe's judgement is unchanged and flips on its own, VIOLATED to holds.
Its journal row is part of that gate: teaching the journal about
cancellation would have made the comparison green while breaking the
boundary it exists to protect.
One group reaches all three executed terminals, and the two owners of that
fact are read side by side:
graph: cancelled=2 completed=1 failed=1
store: completed=1 failed=3
journal: all four items open+started+settled
The graph draws cancellation apart from failure. The store, which owns what
happened to a child, folds it in — every error path saves a child as failed
and SubagentStatus has no cancelled to save. The journal cannot help: it
records orchestration, not outcome, and all four items left the same shape.
So the distinction is not un-persisted, it is unkept. No owner has it, and
no journal record could recover it — writing one would create a second
authority for a fact the store already owns, with nothing to settle the
first disagreement.
The arm reuses the terminal family's shape: the fan-out runs, the turn is
held open, and the process still dies inside it. Cancellation comes from
the turn's own context, since the synchronous headless Run does not pass
through the gate Controller.Cancel operates.
SETTLED says an opening is no longer active and nothing else. Four arms
ask what that costs, by putting lifecycles that look identical beside
meanings that are opposite.
Adopted survives, and not by accident: the disposition rides the opening,
because an adoption is declared before anything runs. Its source does not.
The reference whose answer stood in is drawn as an edge and recorded
nowhere, so a restart can say an item was adopted and not what paid for it.
The collapse is elsewhere. open+started+settled covers completed, failed
and cancelled alike. Skipped is distinguishable today only because nothing
else produces open+settled — a distinction by absence rather than by
record — while a cancellation before admission is distinguishable for a
real reason: it left a queue record.
One population turned out not to exist. These arms expected two routes to
skipped, and parallel_tasks' is unreachable: running[idx] is set when an
item is dispatched, not when the scheduler admits one, and every item is
dispatched in one pass, so a cancellation finds all of them running and
marks them cancelled. The arm was renamed to what it reaches instead of
being constructed into the shape it was supposed to have.
Context needs no record of its own. Two samples — an upstream that
answered and one that failed — both show the delivery edges matching what
an ordering edge with an answered upstream predicts. What it does need is
that upstream's disposition, which is exactly what is not durable: after
the boundary the prediction goes empty, because the answered set comes
from the graph and not the journal. Context belongs behind whatever makes
a disposition durable, not beside it.
Controller.Cancel does nothing to a synchronous headless Run — that path
does not pass through turn admission, so the gate has nothing registered
for it. The arm cancels the turn's context, which is how a headless caller
interrupts anyway.
A ready item the scheduler turned away came back correctly classified and
unexplained. Four falsifier arms showed why nothing else could answer it:
the refusal exists only at the moment it is made, and by the time anyone
asks, the constraint is gone.
OnQueued is that moment, and the record goes ahead of it — the third seam
in the same shape as the first two, and fallible for the same reason. A
request whose wait could not be recorded is withdrawn rather than shown
waiting, with the care the cancellation path takes: capacity may have been
granted since the refusal, and a slot nobody releases never comes back.
All three causes are kept. The scheduler knew whether total capacity, the
writer ceiling, or a path conflict turned the item away, and a journal that
folds that into "a ceiling refused it" cannot get the distinction back. A
reader wanting capacity-versus-claim can fold them at the read surface; the
record does not fold them first.
The cause is the one that queued the request, never the one holding the
item now. The transition arm measured the difference: it freed the ceiling
the refusal named while the item stayed queued under a different one, and
the report never moved. So the record is named for entering the queue, a
later cause cannot rewrite the first, and a queue record arriving after the
item started or settled is refused.
A queue record also proves the dependency gate was crossed, which a declared
ordering alone never could: an item reaches the scheduler only once its
upstream is answered. The mixed arm's blocked item reads "open" and nothing
else; a refused one reads "open queued(slots)".
Measured, all four arms: the cause is read back intact after the restart,
and the item blocked by a dependency still records no refusal at all.
STARTED settled the classification: an item that never reached a slot comes
back as interrupted-before-start. It did not settle why. Four arms ask that,
and none of them changes production code.
They are four rather than one because canStartLocked decides in a fixed
order. An arm that let an earlier check fire would report that refusal as
the later one, so each clears everything above what it measures: the slots
arm refuses a reader, which is not a writer at all; the writers arm leaves
total capacity free and gives its two writers disjoint paths; the claim arm
leaves both ceilings free and overlaps the paths. Ceilings come from a
project reasonix.toml, which is where a person sets them.
The claim arm needs two fan-outs. A single fleet's preflight refuses
concurrent writers whose claims overlap, so the holder is dispatched in the
background and the refused writer comes from a later fleet. It and the
transition arm wait on a signal the holder's own child sends once its slot
is granted — reaching a provider call is the first moment the slot is
provably held, and dispatching before it lets the refused item start.
Measured: each refusal is reached in isolation, reported exactly once, and
the restart still classifies the item correctly — while which ceiling
refused it is gone. Nothing carries it and nothing can re-derive it: the
constraint no longer exists by the time anyone asks.
The transition arm answers what a cause means. It frees the ceiling the
refusal named while the item stays queued, now held by a different one, and
the graph still reports the first. One report, not two: the cause is the one
that queued the request, not the one holding it now — so a durable record of
it belongs under a name about entering the queue, not about waiting.
An item that never started is explained by what it was held behind, and
that explanation died with the process. The mixed arm could say fleet-3
was interrupted-before-start and nothing else: the edge naming what it was
ordered behind lived only in the plan the dispatching call held.
The plan is already decided when the fan-out opens, so the ordering rides
the opening rather than waiting for a record of its own. Both the graph
and the journal are now derived from one delta, which is what stops them
drifting: an ordering edge the graph draws is one the journal can be asked
about after a restart, and there is no second place to forget.
Only Depends is read. An adopt edge names reuse and a context edge names
delivery; neither holds an item back, and reading either as ordering would
explain a start that nothing had blocked.
A dependency is deliberately not recorded as scheduler state. An unmet
dependency is why an item is not yet ready; a slot is what a ready item
waits for; agentgraph keeps pending and queued apart for that reason, and
collapsing them into one "waiting" would lose the distinction the
vocabulary was built to carry.
What it still cannot say is written down rather than implied: a declared
dependency does not prove the dependency went unmet. An item whose
upstream finished, which then queued and died waiting for a slot, reads
the same. Separating those needs a durable record of entering the queue,
and the arm that would prove it worth having does not exist yet.
Measured: the mixed arm's restart reads fleet-3 <- fleet-2 unchanged
across the boundary, and still classifies fleet-3 before-start against
fleet-5 during-execution.
An opened delegation and a running one reached the next process as the
same fact. The mixed arm proved the cost: a restart inherited two
interruptions, one that had been executing and one its dependency had
blocked, with nothing on disk able to tell them apart — so the block the
host wrote had to say it could not tell either.
The slot grant is the linearization point, and it is already a shared
seam: fleet and parallel_tasks both reach it through the hook only the
scheduler can fire. STARTED is persisted and synced there, ahead of the
running delta and ahead of anything the child does. OnStart becomes
fallible to make that enforceable rather than merely intended — a start
that cannot be recorded hands the slot straight back and the run never
acts, the same refusal an unrecordable opening already gets.
Settling without ever starting stays ordinary. A branch its dependency
cut is opened, never started, and released when the group ends; treating
that as corruption would reject a path every failed fan-out reaches. A
start with no opening is ignored, a second start does not move the first,
and one arriving after the settling is refused.
Interrupted is still never written, and neither classification offers a
continuation. They differ only in what may already have happened, which
is what the next request is now told instead of "the host cannot tell".
Measured: the mixed arm's restart reads fleet-3 interrupted-before-start
and fleet-5 interrupted-during-execution; the completed arm inherits
nothing; nothing reads as running once its owner is gone.
A turn reaches the transcript only when it ends, so a process that dies
inside one takes the whole turn with it: the request, the dispatch, and
every item the fan-out opened. The resurrection probe measured the result
as LOST-SILENT — the item that was executing had no record anywhere, not
even one saying it had existed.
internal/execjournal writes an opening before the dispatch becomes
observable, the ordering an adjudication barrier already uses. An entry
carries what is known at that moment: the item's identity, the group and
turn it belonged to, its grant, and whether it will execute at all. It
carries nothing about how the item ended. The sub-agent store already owns
that answer, and a second durable copy would be a second authority to
reconcile the first time the two disagree.
Interrupted is never written. It is derived: an open entry with no live
owner in this process. Recording it would mean the dying process wrote
down what it could not know, and a record that claims resumability is
worse than the loss it replaced — the probe ranks a ghost above every
reconstruction for that reason. What the next request is told says only
that work was opened and never settled, and that some of it may never have
started, because an item waiting on a dependency journals exactly like one
holding a slot and the host cannot tell them apart.
The live set is process-wide rather than per-assembly. That is the scope of
the question it answers: two assemblies in one process are still one
process, and a set per assembly would let each report the other's running
work as interrupted.
Measured, all three fan-out arms: the item still executing moves
LOST-SILENT to interrupted-explicit, settled items stay
reconstructed-lossy, and nothing reads as running after its owner is gone.
IsSessionTranscriptName excluded four suffixes by name and missed
.adjudication.jsonl, so every session that recorded a barrier was listed
to the user as a second conversation beside itself — one holding barrier
records rather than messages. Nothing reports this: a directory scan
classifies by name and a wrong answer just appears in the picker.
The chain of conditions is why it was missed. Each new sidecar had to
remember to add itself to a check written somewhere else, and the failure
for forgetting is silent. One list, walked by the check, makes the next
one safe without anyone remembering this file, and the test derives its
cases from that same list rather than restating them.
Three arms drive a real fleet and kill the process at three points in its
life: every item settled, one item still executing, and every reachable
state at once. Phase B loads and inspects without calling a model, so each
reading is what the host can prove before a provider call.
They classify rather than pass or fail, over two populations, because one
label covers the better half: items that had settled own durable child
records, while the item still executing has never been written down. The
readings are reconstructed-lossy and LOST-SILENT respectively, and the
ghost row — anything still drawn as running with no owner — is green only
because nothing is stored at all.
Underneath both sits the row that explains them: the turn that dispatched
the fan-out is absent after the restart. A turn is appended when it ends,
so a process that dies inside one takes the request with it, and a graph
reconstruction would have nothing to attach to even in principle.
skipped is deliberately not among the states the mixed arm demands. A
fan-out publishes a skip only in its closing outcome delta, so nothing
reads skipped while anything is running; the item whose dependency failed
sits at pending, which is what the arm dies holding. Constructing the pair
would have measured a state no live fan-out can reach.
The graph renderings are sorted: the fold keeps first-seen order and two
processes reach it by different routes, so an unsorted comparison reported
a reordering as a changed graph.
The kernel has known since the barrier landed that a run stopped waiting on a
person. Nothing outside it could see that, so a restart showed a conversation
that simply ends. This is the read surface, and only the read surface: no
button, no route that takes an answer.
Snapshot is truth, the frame is only invalidation. GET /adjudications returns
the whole thing — active and history, both present even when empty — and
adjudications_changed carries no payload. A client that missed a frame is
corrected by the next read instead of holding a list assembled from whatever it
happened to see, and a reconnect needs no replay: the notice is on the skip
list because telling a fresh window to re-read what it just read says nothing.
The frame is also the only way to announce a transition that writes nothing.
"Interrupted" is not a record; it is what an open barrier means once nobody is
waiting on it, so the same journal reads differently either side of a process
death. A client cannot derive that — it does not know who is waiting — which is
why state comes from the kernel and not from a flag on a decision.
Which is also why this is not a field on /status. A barrier has a lifecycle, a
disposition and a successor, and the overview is read on every tick.
The barrier id goes out as provenance and nothing accepts it back. An
interrupted barrier has no owner to answer to, so a route that took one would
rebuild exactly the ghost the kernel refuses to offer; a test posts one at
/answer and /approve and checks it stays active. The types stay apart on the
far side too — AdjudicationEntry is not a Decision with a flag.
Parity is one commit, not four. Route, dev-server proxy, wire kind, replay
classification, port method, fixture and the frontend union move together,
because each of those lists is a place this could have shipped half-wired and
only failed on a real desktop.
The CLI reads it too. A terminal resume prints why the last run stopped, which
is the same fact Studio will show — and the alternative was declaring the
capability serve-only and letting the parity ledger carry the difference.
The provenance surface is the sibling of the answerable one, so it sits in
SessionHistory rather than Approvals: what the host asked and how it ended is
history, and the frontends that drive one need not drive the other.
An interruption had no way to stop being current. Open with no owner made it
interrupted, and the only dispositions that ended one belonged to the waiter
that died with the process, so every later request carried context about a
question nobody was going to answer. Three successor arms measured the same
thing three ways.
A turn ends it by taking it over. The edge names which turn, so the journal
answers why the question stopped being asked with a cause rather than with
"someone saw it" — acknowledgement records that a person looked, supersession
records that work moved on, and only the second is a fact about the system.
Which turn is decided by causality, not by reading the message. A turn that
receives the interruption in its request is its successor whether the user
answered the question or asked about something unrelated; guessing at intent
would put a language judgement in a provenance edge.
The handover is recorded from markInFlightTurn: the turn is identified and
durable by then, and the model has not been called. Only barriers the turn is
actually given are claimed, so a barrier nothing showed it is not one it took
over, and a session holding two would hand over exactly what the request
carried.
The successor is read off the in-flight marker on disk rather than remembered,
which is what makes the window safe. A crash between the edge and the response
leaves that marker in place, so the retry — in this process or the next — gets
the same context; the marker clears when the turn commits, and nothing after it
carries the interruption. So a barrier is never both un-inherited and invisible.
nobody does anything still interrupted, 1 block, journal: open
a turn about something else settled, 0 blocks, superseded by that turn
a turn that reads like an answer same, and the write it held back still
did not run
That last line is the one this could have broken. Context continuity is what a
successor inherits. Execution continuation is not, and no edge grants it.
Nothing is exposed over the wire. The read model now has a fourth disposition,
which is exactly the shape that would have had to change under four callers had
it been published a cut earlier.
The derivation has no exit. A barrier is interrupted while it is open with no
owner, and the only things that close one — resolved, cancelled — belong to the
live waiter that died with the process. So the question was whether that
matters in practice, and three successors to the same interrupted barrier
answer it the same way:
nobody does anything still interrupted, 1 block
a turn about something else still interrupted, 1 block
a turn that reads like an answer to it still interrupted, 1 block
The first is correct and the other two are the cost: an interruption outlives
the work that replaced it, and every later request pays for context about a
question nobody is going to answer. Naming it sticky rather than failing it,
because what should end it is the next cut's decision, not this one's.
The answered arm is the reading worth having. A turn that reads like an answer
gets the interruption as context, and the write that question held back still
does not run. Context continuity and execution continuation come apart exactly
where they should, and now that is measured instead of argued.
These arms need a middle process that runs a model — nothing else stands in for
a person carrying on — so the rule moves rather than bends: the phase that
judges what the host knows is still the model-free one after it.
The journal also gets a fold instead of a scan. Only an open record starts an
entry, so a terminal one alone invents no history; the first terminal wins, so
a duplicate cannot restate a settled barrier; a reopen after a terminal is
refused; a torn tail is read past, because that is what a crash leaves.
InterruptedAdjudications now reads off that fold, which is where a later
disposition for a dead continuation will have somewhere to go.
Nothing is exposed over the wire yet. The response shape would have to change
again the moment that disposition exists, and it would change in four places at
once.
The barrier made the interruption durable. Nothing read it. A restart carried
the fact and neither the model nor anyone driving it could see one, so the next
turn began as if the previous run had simply ended.
It reaches the model as provenance and nothing else: what was being asked, that
the suspended response cannot be resumed, and that nothing it deferred ran. No
identity comes with it. An interrupted barrier has no owner left to answer to,
so handing back something that looks answerable is how a model is invited to
continue work whose continuation died — the ghost the previous commit refused
to build. It stays out of Decisions() for the same reason.
It is derived on every request, with no record of having shown it. The task
identity taught that lesson: "already shown" answers for some earlier request,
not the one being built.
Where it rides is not where it was planned to. Controller turn blocks look like
the natural home, but composed text becomes the canonical user message —
StripComposePrefixes exists because frontends have to take those blocks back
out for display — so a block registered there is transcript, and a later fold
would freeze it into a body. That is precisely the shape the last four commits
removed. It rides the request instead, through a narrow port the host fills:
control owns the fact, the agent only asks for it while building a request, and
nothing stores the answer.
So the ordering that fell out of the projection work now has a third layer that
belongs to neither of the first two:
frozen body + canonical tail history, foldable
host request context provenance, derived per request
todo identity tail host state, derived per request
Measured, the arm gains a row and loses none:
what the next request tells the model 0 blocks -> 1 block
the effect the decision held back did not run -> did not run
A settled barrier projects nothing, an interruption is stable across repeated
loads, the block says what was asked without offering a handle for it, and
composing a turn does not carry it — that last one is the test that keeps it
out of the transcript it was nearly written into.
A question the host puts to a person is a historical fact, and the only one in
this system that cannot be re-derived. The task list is current state, so a
restart recomputes it; a wait that happened is not, and once the process
holding it dies nothing about the workspace says it ever did. The falsifier
showed the consequence: an ask open at death, 21 messages in memory, 19 on
disk, and a restart with no way to know a turn had stopped on a person.
So the barrier is written down, and written down first. The record precedes
registration because registration is what makes the question answerable, and a
crash after that must not leave someone having been asked something the host
has no record of owing. Durable before observable is the whole invariant; the
record is appended and never rewritten, so how a wait ended stays readable
beside the fact that it started.
What is deliberately not persisted is the continuation. No blocked tool call,
no remaining reply, nothing that would let a later process pretend it can
resume the suspended response. The contract is that work was interrupted, not
that it can be picked up, and a restored card with no owner behind it would be
worse than an absent one — a question a person can answer into nothing.
Interruption is derived, never written. An open record plus no owner waiting on
it in this process is what makes it interrupted, so the answer does not depend
on a dying process reaching one more line of code. It also means the reading is
stable: a second restart derives the same thing, where a terminal record
written on resume would have made the obligation disappear after one load. The
same fact is why a live wait is not reported as interrupted — this process is
the owner.
InterruptedAdjudications is not Decisions, and the two do not share an
identity. One is a live obligation with someone waiting; the other is evidence
that a wait happened. Offering the second as the first is exactly how the
measured LOST-SILENT becomes a ghost.
The arm moves one cell and the safety row does not move at all:
what the restart inherited LOST-SILENT -> interrupted-explicit
the effect it held back did not run -> did not run
The transcript still loses the unfinished round, and that is left alone. The
fix is not to write half an assistant turn into canonical history; it is that
the host owns the fact separately, the way it owns the goal and the task list.
Decisions are a projection of what each owner is waiting on, derived fresh from
approvalManager's pending maps. Those maps are process state, and a new manager
starts empty, so a question open at the moment a process dies is gone by
construction. What has never been established is what the host believes
afterwards.
The arm does not test that a card comes back. "The same question must survive"
is a requirement nobody has argued for: the goroutine parked on it did not
survive either, and a restored card with no continuation behind it is a
durable-looking state that cannot be answered — worse than an absent one. So
the restart is classified rather than graded: resumable, interrupted-explicit,
or lost-silent, and only the last is a defect.
The reading is lost-silent. The process died with an ask open and 21 messages;
the next one loaded 19. The round carrying the question never reached disk, so
there is no unanswered call, no interruption record, and nothing that says a
turn was cut while waiting on a person. Not a fact recorded and then dropped —
one that was never written.
The row that has only one acceptable answer holds. The scripted reply asks and
tries to write in the same round; calling ask ends the round, so that write is
the effect the decision is holding back. It did not run while the answer was
pending and did not run because a later process loaded the session. Whether the
question survives is a design choice. Whether what it blocked stayed blocked is
not, and a recovery path that let it through would be the more serious defect.
The arm exits instead of shutting down, because a clean close resolves or
cancels the question, which is exactly what a process dying mid-decision does
not do. It wires the interactive approval gate the way chat and desktop do:
without it the ask tool decides for itself and nothing ever blocks, and the arm
would have measured a question that was never asked. It invalidates itself when
no decision was open at the moment of death.
The gate found two notes and one of them was stale. A fold froze the note into
its body; the next fold carried that body's remainder forward, which is what a
body suffix is for — historical material with no canonical counterpart. A step
identity is not that. It is what the host holds now, and a frozen copy outlives
the state it names: the model was shown ids the host had already dropped, and a
restart reproduced them exactly.
So the note is derived for each request and stored nowhere. Owed is recomputed
from the ids the host holds against the history that request actually carries,
which is the judgement withTodoIdentityProjection already made — only against
the wrong thing, at the wrong time, with the answer kept.
todoIdentityShown is deleted rather than redefined. It recorded that some
earlier request showed the ids, which cannot say whether this one can: the same
mistake as validating a projection by a transcript version, a scalar witness
standing in for a fact about content. With the derivation in place there is
nothing for it to answer, and nothing to persist across a restart either.
Both storage paths are gone with it. The compaction producers no longer carry
host state into a body, and the run loop no longer writes the note into the
canonical transcript. What replaces them is one seam:
modelVisibleHistory() frozen body + canonical tail
modelVisibleMessages() that, plus what the host derives for this request
A fold plans on the history. The request carries both. So the note can never
become digest input, kept history, a body suffix, coverage, or sidecar
material, and admission still prices it — a note that cannot be folded is not
free, and counting only the foldable part would make every fold look cheaper
than the request it produces.
The gate now holds, and not vacuously: with the ids folded out of history the
request carries exactly one note, after the live tail, naming the ids the host
holds now — and the same on the far side of a process boundary, derived rather
than restored.
Two things this surfaced. The loop fixture ended a round on "the last message is
a tool result", which a tail note makes false; it now looks past host state,
because that note says nothing about whose turn it is. And the gate itself
demanded a note unconditionally, which would have passed a migration that
appended one to every request — it now reports whether the history still showed
the ids, and expects no note when it did.
todoIdentityProjection calls itself a turn-tail note owed when the host holds
step ids the conversation no longer shows. While the frozen body also held the
recent tail, "tail" and "end of the body" were the same place and the claim was
untestable. They are different places now, so the arm tests it — along with the
two things the note also claims by existing at all: that there is one of it,
and that it names the identity the host holds now.
Identity is moved while a fold sits between the model and it: write a list,
fold, grow, rewrite the list, fold again. One run then separates placement from
duplication from staleness, which three simpler scenarios would not. The
rewrite keeps the in_progress item and replaces the pending work, because the
host refuses to drop an item in flight — the first attempt replaced the whole
list and was rejected, which is a rule about task state, not a probe detail.
All three claims fail, and the process boundary does not change the answer:
notes in the view 2 exactly one reaches the model VIOLATED
where the note sits [6 11] rides the live tail VIOLATED
body ends at 12, view is 22
identity carried 01,02,03 names the host's current ids VIOLATED
01,21,22
host: 01,21,22
The first note is stale: 02 and 03 are ids the host no longer holds. It was
frozen into a body by one fold and carried forward by the next, which is what
the body suffix is for — historical material with no canonical counterpart. A
step identity is not that. It is state the host holds now, and freezing a copy
of it makes the copy outlive the state.
No implementation moved. The gate says which of the three answers we were
guessing at is true: the note is duplicated and stale, not merely misplaced, so
the fix is not a later append position.
Also completes the arm summaries, which listed seven of the eleven verdicts —
contiguous, HOLE, holds and VIOLATED were all missing, so an arm with three
violated contracts summarised as clean.
A fold froze the whole view — stable head, digest, kept history, and a copy of
the recent tail — then declared coverage to be the entire transcript. Nothing
was ever spliced, so the projection and the canonical transcript were never two
things: one owned everything the model could see, and the boundary between them
was a number that could only ever equal a length.
Now the body keeps what only it can hold and the transcript keeps the rest. The
fold's boundary goes through mapFoldBoundary, the body is head + digest + kept,
and the view is that body plus canonical[Covered:]. A boundary landing inside
an older body carries that body's remainder forward, because those messages
have no canonical counterpart to splice them back from — the half the second
fold could lose, and the reason the two halves land together.
Everything that reads the fold's output reads the spliced view, not the body:
token accounting, the acceptance test that decides whether a candidate is worth
installing, and the output fingerprint. Measuring the body alone would have
made every fold look dramatically cheaper while the request it produces stayed
the same size, and the acceptance gate would have believed it.
The compress tool keeps claiming the whole transcript, and says why: it masks a
range instead of cutting a prefix, so its body holds messages from both sides
of the digest and a shorter claim would splice copies of them back in.
Measured, folds now leave a live tail where there was none:
fold 1 body 15 -> 7 covered 19 -> 11 spliced +6 -> +14
fold 2 body 17 -> 11 covered 25 -> 19 spliced +0 -> +6
with the marker oracle still reporting one contiguous run of assistant work
reaching the newest turn, and both negative controls still dropping.
Two things the split moved that were only visible because something checked:
covered-rewind stopped being a control. It aimed at a middle checkpoint, which
was below coverage while coverage meant the whole transcript and above it
afterwards, so the arm quietly began testing the case its sibling already
covers. It now reads the boundary out of the sidecar, and both rewind arms
report which side of the fold they landed on and invalidate themselves when
that is not the side they claim.
A fold can now reach the tail the previous fold kept, because that tail lives
in the transcript rather than inside the body. So growth that never closes is
no longer "nothing new to reach": the newly reachable region is real history
and the verdict is what folding it would cost. NoopNoNewClosedPrefix now marks
the narrower case where the live tail holds nothing closed at all, and no test
reaches it any more.
Two places answered "where does this projection's coverage end". The planner
knew, and the writer worked it out again from len(commit.canonical). They
agreed only because a fold currently freezes the whole view into the body, so
coverage and transcript length happen to coincide. The moment a fold claims
less than the whole transcript, a planner saying 19 and a writer saying 25 are
both authoritative and the sidecar records the wrong one.
So the commit carries the boundary and the writer has no answer of its own.
CoveredCount is what the fold decided, CoveredPrefixHash covers exactly
canonical[:covered], and neither is derived from a length any more. A commit
that names no boundary, or one outside its transcript, is refused rather than
recorded: with the fallback gone, a zero would otherwise persist as a
projection nothing can validate.
The value is unchanged. Both planners still claim the whole transcript, and
say why on the line that does it — nothing is spliced in behind the body while
it still freezes the recent tail. Moving who decides and changing what they
decide are separate commits: this one is a no-op by construction, and the nine
arms of benchmarks/runtime-resume report cell for cell what they reported
before it.
The test that matters is the half-migrated shape: a count that has moved to the
fold boundary beside a hash that still covers the whole transcript would leave
every projection valid against a prefix it does not describe.
A second fold reads the working view — frozen projection body, then
canonical[priorCovered:] — and has to record where it landed in canonical
terms. The two systems agree only past the body: the digest at its head stands
for a range of canonical messages that no single view index names, so a
boundary inside the body cannot be converted by subtraction at all.
Today nothing has to convert anything, because a fold declares coverage to be
the whole transcript and freezes the entire view into the body. Taking the live
tail back out is what creates the second coordinate system, and this is the
translation it will need, landed on its own so the arithmetic is settled before
any producer depends on it. Nothing calls it yet; behavior is unchanged.
Values in, values out. It reads no Agent, Session or projection: what is under
test is a coordinate mapping, and a helper that reaches for runtime state to
answer is one that hides the mapping instead of stating it.
The two answers it gives are coverage and the body remainder that must survive.
A boundary inside the body keeps coverage where the previous fold left it and
returns body[start:] to stay frozen — history a digest already folded is not
uncovered by a later fold, and the part of the body the new digest did not
consume has nowhere else to live. Past the body the systems track offset for
offset. The empty suffix needs no flag: it is the range starting at the body's
end, so no pair of fields can disagree about whether one exists.
The table is the contract, and it is judged on the translation alone — coverage
and suffix range. Token counts, projection reuse and marker continuity answer
later questions, and any of them could let this pass for a reason that is not
the mapping.
A rewind threw the compaction projection away without looking at it. The same
truncation reaching the transcript by any other route kept it: the covered
prefix is untouched, the covered hash still matches, and the projection goes on
serving. Only the path a person actually drives lost it, because
ApplyConversationTruncate decided for itself that a shorter history meant a
changed lineage.
That decision was not the caller's to make. Coverage became a stated contract
one commit ago — CoveredCount plus CoveredPrefixHash, held by the projection
subsystem — and a caller that answers the same question separately is how a
tree ends up with two contracts that drift. So the rewind asks instead:
RevalidateProjection drops the projection exactly when it no longer covers the
current canonical transcript, and control never sees a count, a hash, or a
lineage key.
The truncation that lands exactly on the fold boundary is the case worth
naming. It leaves no live tail at all, and the instinct is that a projection
with nothing after it is the least trustworthy kind. It is the opposite: the
hash then proves the whole provider-visible transcript is the history the
digest folded. The same instinct, encoded as a version check, is what lost
projections across restarts until last week.
Rewind-restore is the same call for the same reason: it replays a longer
history that the folded prefix still sits at the head of.
Measured through benchmarks/runtime-resume, one cell moves and both controls
hold:
tail-rewind dropped -> kept
tail-edit kept -> kept
tail-truncate kept -> kept
covered-rewind dropped -> dropped
covered-mutation dropped -> dropped
revalidate_projection_test.go states the four-row table the caller now gets:
truncation inside the tail and truncation onto the boundary keep it, truncation
below the boundary and a rewritten covered row drop it.
The next cut treats every tail arm as one expected red with one known cause.
Measured, they are two causes, and only one of them is about coverage.
A tail change that reaches the transcript — a row past the fold edited, or the
whole tail dropped — leaves the covered prefix untouched, so the covered hash
still matches and the projection keeps serving. Both arms are green. The same
truncation driven through Controller.Rewind is not: ApplyConversationTruncate
calls InvalidateProjection unconditionally, and the projection is gone before
any coverage question is asked. That red is a caller discarding evidence it
never examined, not a validity rule getting the answer wrong, and it does not
depend on the live-tail split to fix.
covered-rewind is the control that has to exist before that invalidation can
become conditional: a rewind landing below the fold removes history the digest
folded, and no coverage check may excuse it. It targets a middle checkpoint
rather than the first — rewinding to the first empties the conversation, and a
session holding only a system row is never persisted, so the restart read back
the transcript the arm had just removed. That is a different rule answering,
which is exactly the confusion the arm exists to avoid.
The projection-in-use row now turns on whether the next request would ride the
projection, not on the token counts printed beside it. A tail edit legitimately
moves those, and letting them decide reported a surviving projection as a loss
— the same class of mistake as the one-sided marker, caught the same way.
exact in use append-after-fold in use
refold-into-body in use system-swap in use
tail-edit in use tail-truncate in use
tail-rewind DROPPED covered-rewind DROPPED (control)
covered-mutation DROPPED (control)
The tail arms of the next cut all have the same known explanation already —
CoveredCount claims the live tail as frozen coverage — so writing them first
buys answers we have. This arm asks the one question whose answer was unknown,
because it decides how much the next cut has to do: a second fold reads the
provider-visible view, stored body plus live tail, and has to record its new
boundary in canonical terms. The body has no canonical counterpart to map onto.
It does not need one today, and that is the finding:
fold 1: body=15 covered=19 spliced=+6 view=21
fold 2: body=17 covered=25 spliced=+0 view=17
The second fold sets CoveredCount back to len(canonical) and freezes the whole
view into the body, so nothing is spliced and no boundary is ever translated.
The splice only ever covers growth after a fold, never the fold itself, which
is what summaryProjectionState writes by construction. So studio is not losing
anything here — it avoids the problem rather than solving it, and taking the
live tail out of the body is what creates the requirement. This arm is the
guard for that: afterwards spliced must exceed zero while the assistant work
still reaches the newest turn.
The oracle nearly passed for the wrong reason. Tagging a turn once let the
surviving user turn hide the reply folded away beside it, and the first run
reported every turn intact for a fold that had removed four replies. Turns are
now tagged on both sides, the judgement runs on the assistant side where a fold
actually cuts, and the shape it accepts is narrow: an optional pinned head,
then one run of replies reaching the newest turn. The scripted digest carries
no tag, so a summary cannot stand in for a message that should have survived.
refold_test.go holds the oracle to that. A test that cannot fail proves
nothing, so the shapes only a loss produces — a gap inside the kept tail, two
gaps, a tail stopping short of the newest turn, an empty view — are asserted to
be caught.
The README also records what studio shares with main-v2 f60ab17b0 (#8923) and
why that line is ported by contract rather than by file.
A projection that covered the whole transcript did not survive the process that
built it. The sidecar was intact, the covered-prefix hash matched, the lineage
key matched — and projectionCoversTail still refused it, because with n ==
total the only branch that could say yes was a transcript-version equality, and
transcript version is an in-process counter a reload restarts. The append-only
fallback n < total was the sole route across a restart, so a session that was
compacted and then closed lost its projection and reverted to the full
canonical transcript, while a session that happened to take one more turn after
the fold kept it.
That is backwards. The hash is the stronger evidence, not the weaker: it
fingerprints provider-visible canonical[:CoveredCount], so a match says the
history the digest folded is still exactly there. n < total merely adds that
some of the transcript is a live tail the projection splices in anyway. Under
the old rule, having unfolded messages made a projection trustworthy across a
restart and having none made it suspect.
So the version leaves the judgement rather than gaining a restart special case.
projectionValid, projectionContentValid and projectionCoversTail no longer take
transcriptVersion at all — left in the signature it is an invitation to reach
for it again as a cheap pre-check, which is how it got here. The schema fields
stay for compatibility, diagnostics and compaction transaction metadata, and
now say in the type what they are not: durable identity for a projection's
content.
The guard that remains is the one that was doing the work. A rewrite inside the
covered region changes the hash and the projection is rebuilt; a covered count
past the end of the transcript is refused; a legacy sidecar with no hash is
refused. What is gone is a second witness that could only ever agree with the
first, and could not be produced at all by a new process.
Measured through benchmarks/runtime-resume, whose four arms move exactly as the
contract predicts — the safety control included:
exact projection lost -> reused
append-after-fold reused -> reused
system-swap reused -> reused
covered-mutation rebuilt -> rebuilt
projection_authority_test.go states the rule as a table run under four stored
transcript versions, so a verdict that starts depending on one again fails
rather than waits for another restart to reveal it.
Nothing in the tree could say which runtime semantics survive the process that
built them. Every claim about resume was an argument from where a value is
written, and a test that nils an Agent proves less than it looks: package-level
singletons, live sinks, registries and caches all stay standing, and any of
them can revive state a real restart would have lost. So the boundary here is
the OS process. Each phase is a child that exits before the next one starts.
Phase B never calls a model. That is the point, not an optimisation: a model
handed the transcript can reconstruct a step id or a goal the host has in fact
forgotten, and the row would go green while the host could prove nothing. Boot,
load, inspect, stop — so a PASS means the host knew it before a provider call.
The verdicts are not booleans. "A named artifact holds this verbatim" and "a
fold over canonical artifacts happens to agree" are different durability
claims, and a row the construct phase never established supports neither; it
reports not-measured, which is what kept a rejected todo_write from reading as
a lost identity on the first run. Open decisions and the run graph are
not-measured for the same reason and say so, rather than being filled in with a
test seam that would answer a question nobody asked.
The result the arms were built to isolate:
exact fold, then die projection NOT in use
append-after-fold fold, one more turn, then die projection in use
system-swap pinned memory moves the prefix projection in use
covered-mutation SaveRewrite edits a covered row projection NOT in use
Same sidecar, same covered-prefix hash, same stored transcript version 18. The
only difference between the first two arms is 19 messages against 21 — n ==
total against n < total. A session compacted and then closed without another
turn loses its projection at the next process; one that got a single further
turn keeps it. The third arm falsifies the reading this started from: the
composed prefix grew 3376 to 3707 bytes and the projection survived, because a
resumed session carries its persisted system row and the covered hash never
moved.
The fourth arm is the safety control and must stay red. It is the only thing
standing between "restore the projection across a restart" and "restore it over
a transcript that was actually rewritten".
Levers are production-reachable — a saved memory fact, a SaveRewrite — never a
poked field, and each arm demands a clean root: a reused one puts the lever's
effect into the construct phase, where it reads as a lever that did nothing.
macOS self-update has never worked on the Electron shell. applyMac re-executes
os.Executable() with --reasonix-mac-update-handoff and waits on a pipe for the
child to report ready; this host's main declares its own flags and parses them,
so the child met an undefined flag, exited 2, and the parent read EOF:
macOS update helper did not become ready: EOF
MaybeRunMacHandoff's doc has always said every host's main calls it before
startup. The Wails shell does, at main.go:133. The Electron one imports the
package and never calls it — 2.11.0 was the first Electron release, so 2.11.0 →
2.12.0 is the first Electron-to-Electron macOS update, and the first to run it.
The failure is safe: the transaction is cancelled and staging cleaned, so the
installed app is untouched and the message says so. It is also not fixable by
updating. The process that starts the helper is the one already installed, so a
mac on 2.11.0 will keep failing no matter what later releases contain; the fix
arrives only in a build installed by hand, and works for every update after it.
The contract now has a test instead of a sentence. hostMains is declared rather
than discovered — nothing about a main's shape says it is a host's, and this
one was missed by a reader with the comment in front of them.
Two spellings of the same version. `git describe` on this line answers
studio-v2.12.0, and that string went into main.version for every local build
while the release injected v2.12.0 — the tag with its line prefix stripped. The
prefix belongs to the release line, not to the version, and leaving it on broke
both readers of that field: the pattern that decides whether a version names a
published release rejects it, and ParseVersion splits on the first dash and
comes back with nothing. So a locally built CLI could not install from a
release it does in fact have, and installed on a remote it read as versionless
— which the floor waves through, since a version nothing stated is not a
version below it. The Makefile strips what the release strips.
The stray asset is mine. Staging picked the CLI archives out with
`-name 'reasonix-*'`, and the Windows signing flow leaves a
reasonix-studio-host.exe in the same directory: it matched, and 2.12.0 shipped
a 34MB internal component next to the archives, where it reads as a CLI someone
might download and cannot run. Taken from staging/cli-archives now — the job
that produced them — because origin is what was meant and the name was only
ever standing in for it.
The downloaders are bounded. All three had no timeout of any kind, which was
invisible while the routes that use them refused before reaching the network.
On a machine that reaches TCP 443 and never completes TLS — measured on a real
one here, against both GitHub and the Cloudflare mirror — the first route auto
tries would have held every connect for as long as the OS allowed before the
route that works was tried. curl bounds connecting and stalling but not total
time, so a 40MB archive over a slow link is still a download and not a failure;
wget takes the same pair; Invoke-WebRequest has no connect timeout at all, so a
short HEAD runs ahead of it.
FetchCommand and LocateCommand emit shell that no Go test ever executed. The
line they exist for had never published an archive for them to fetch either, so
the whole path — download, digest check, extract, locate, read the version back
— had been reasoned about and never once run. That is the shape of a defect
that passes every review and fails on the first real machine: a quoting slip, a
`find` that matches nothing, a digest line whose checker spells it differently.
Both scripts now run under /bin/sh against an archive actually served over
HTTP, and the test asserts what the caller believes afterwards: the binary is
there, it is executable, the staging directory is gone, and the version the
probe reads back is the one the archive carried. That last assertion is the
claim "the remote runs the version this side asked for", which nothing checked
end to end.
The archive holds a shell script answering --version and `serve --help`, not a
real CLI. A 40MB binary would test the archive's size; the subject here is the
script that installs it, and the payload only has to answer what the probe
asks. The digest case is what proves the check is real rather than skipped: a
mismatched archive fails the script and leaves no binary behind.
PATH is cut to /usr/bin:/bin so a reasonix on the developer's own machine
cannot be probed instead of the one just installed — which would have made this
pass while testing nothing.
Skipped on Windows: that side is WindowsFetchCommand, and it needs the same
treatment against PowerShell before it can claim to have been run.
Every remote kernel's log opened with a failure. The startup balance
diagnostic ran unconditionally, and a bootstrapped serve holds no provider
credentials of its own — they arrive over the broker tunnel — so the lookup
answered 401 every time and wrote it down:
balance: error — wallet credential rejected: status 401
The waste is one round trip. The cost is the line: that log is opened during a
fault and nothing else, and the first thing the reader meets there is a
standing error with no relation to what they came for. A diagnostic that always
fails is not a diagnostic.
The judgement is the broker, not who started the process. --port-file was the
first thing I reached for, since the token share line already reads it, but the
two are different facts: --port-file says a program is waiting for the bound
address, and that program may well have credentials. TestServeStartsWithMissing
ProviderKey caught it — it launches through a port file with no broker, and
asserts the diagnostic runs, because a balance lookup left hanging must not
hold up the serve. Judged on the resolver, both keep their meaning: the test's
serve reads its own config and still reports, a brokered one does not try.
Verified all three ways: brokered writes no balance line and still binds, a
plain serve reports ¥19.68, and the hanging-lookup test passes untouched.
The new frontend was reachable one way: through reasonix-studio-host, behind a
loopback gate that binds 127.0.0.1 and cannot be widened. Anything else that
opened a kernel — reasonix serve, a bootstrapped remote, a phone on the same
network — got the page embedded in serve, which is the old one. Asked to look
at a workspace from a phone, the answer was "that is the 1.x UI", and it was:
the same kernel, a different page, with no way to ask for the other.
The mount is now the hub's, so both hosts inherit it. HubOptions.Page is served
under /_studio/ and everything else is the kernel's — the inverse, a list of
the kernel's routes with the rest falling through to the page, is what has to
be edited every time the kernel grows a route.
It sits inside the auth gate, which is the one thing that had to change in
moving it. The window wraps the page around Handler(), outside hub auth, and
that is sound there: a loopback gate answers first and hub auth is cleared for
it anyway. A serve bound to 0.0.0.0 has no such gate, so a page mounted the
same way is one handed to whoever reaches the port. Handler() now wraps it
between auth and CSRF: through the credential, past the guard that only ever
concerned writes. A test asserts the page refuses an uncredentialed GET,
because that property is invisible in the code that reads correctly either way.
reasonix serve warns and carries on when --page names a directory holding no
page, where the window fails. The window has nothing else to draw; the kernel
has its own page, so a mistyped path should cost the better page and not the
serve.
No baseline was widened for it. runServeWithOptions was one line under its
budget, so the flag it needed came out of the two lines that spelled
`sessionIDValue := ""; sessionID := &sessionIDValue` — a named zero that
existed only to be addressed.
The download route pulls the archive here and pushes it over SFTP, which is the
route for a machine that cannot reach the release itself — an internal network,
the case where you are not installing on one machine but on several. Each of
them paid for the same 16MB.
DownloadCLI now takes a cache directory. A verified copy is kept under
<cache>/cli/<tag>/<goos>-<goarch>, so the second machine on a platform costs
nothing and the package size stays where it is, which is the trade the other
option — shipping the CLI inside Studio — gets backwards: it would add ~30MB to
every install to save one download, and only for same-platform remotes.
The copy is checked against a digest stored beside it. That is an integrity
check on this machine's own disk and it is written down as one: whatever can
write the binary can write the digest, so it catches a truncated write or a bad
sector and claims nothing about anything with access to the directory. A
mismatch is a miss — it downloads, which costs exactly what no cache costs.
Kept as one entry point rather than DownloadCLI beside CachedDownloadCLI. Two
of them left the first with no caller but the tests, which repolint reports as
an orphan, and it is right to: a second spelling of the same operation is a
choice at every call site about something the callers have no opinion on. The
cache is a parameter, and "" is no cache.
The unreleasable-version check runs before the directory is built from the
version, so a source build refuses without having named a path.
The probe asked one flag. `serve --help | grep port-file` decided a located
reasonix was drivable, and that answer stood for a set: the launch also passes
--token-file, --pid-file, and the broker pair that arrived a release line
later. A kernel carrying only the older half answered the probe yes, was
launched with --provider-broker, and exited on a flag it had never defined.
What reached the reader was `serve never reported a port` after a 20s wait —
a machine, a timeout, and an instruction to read a log that cleanupFailedLaunch
had already removed. Nothing pointed at the kernel being from another line,
which is the one thing that was wrong and the one thing this side could see.
LaunchFlags is now the single list both halves read, candidate carries the set
it was answered rather than a bool, and a test asserts the probe's flags equal
the launch's for both shapes of the command line. One flag standing for a set
is an assumption about release history; the list is not.
The same shape twice more. cleanupFailedLaunch and Stop each kept a copy of
what a serve record is made of, and the broker token was added to neither, so
every failed launch left this machine's provider credential on the remote —
removeServeState is now the one list. And `remote test` wired different Options
from `remote connect`, so it called the release route closed for want of a
resolver connect goes on to pass, and called the download route open when the
version would refuse it.
Under all of it the studio line published no CLI at all. releaseasset resolved
/download/v2.11.0/ while the tag is studio-v2.11.0, and the kernel called
itself "dev" because build-host.js injected no version, so both routes that
name a release refused before reaching the network and npm was what remained —
where the package belongs to the 1.x line, below the pane floor. The line now
ships the six archives and their SHA256SUMS, releaseasset.Line carries the tag
namespace, and the version reaches the binary. Line is its own type rather than
update.Line: that one is an install layout, and a single type answering both
questions is how a tag prefix ends up deciding where a .deb installs.
Injecting a semver also makes the floor real for the first time. ParseVersion
could not read "studio-v2.11.0" — it splits on the first dash — so the version
came back empty and meetsMinVersion waved it through as unknown. Every kernel
on this line has been passing the 2.0.0 gate without being read.
Attribution follows: ErrNoReleaseForBuild says a build has nothing published to
install from, ahead of the per-route codes, because a reader told to install
curl would install it and still have no route. The npm route now reports a
version below the floor as exactly that instead of as a PATH that needs fixing.
It keeps its place in the auto order — dropping it would hard-code which line
npm publishes, and that is a fact that can change.
A relay forwards somebody else's model under its own name, so no probe and no
catalogue can say what that model holds. Until someone says, the gauge has no
denominator and automatic compaction is off — and the panel that shows this
could only point at the Connection screen.
It is worse than a missing number. This config has
name = "yyds"
models = [11 models, claude-* and gpt-5.6-*]
context_window = 200000
one figure typed once for eleven models with different ceilings. So the field
is offered against a declared window too, not only a zero one: the denominator
in "24.8k / 200k" is a button, opening the same field prefilled. A dashed
underline marks it — an entry that only appears on hover reads as absent.
POST /context/window writes the window against the model in use, as
model_overrides.<model>.context_window, and leaves the endpoint-wide figure and
the other ten models alone. Zero is the stored "inherit", so clearing it falls
back to the endpoint rather than to no window. It rebuilds in place, because
boot binds the window into the agent and a live session keeps what it was
assembled with — the rule saveShell and savePermissions already follow.
It goes through serve rather than the controller, where editProvider and
setProviderThinking already live: source config is a settings surface only this
frontend drives, and putting it on the controller would book three
frontend-parity edges against cli, acp and bot for capabilities they do not
have a screen for.
Mid-turn the write lands and only the rebuild is refused. That is not a failed
save and it is not a model switch, so it is neither rebuild_failed (which reads
as a malfunction) nor busy.switch_model (which tells the reader to stop and
switch models). It is context.window_after_this_turn: saved, counting from the
next turn. codedRefusal reads the identity off the error to tell them apart —
the code, never the sentence.
In a 296px metrics rail the caption under the hit rate rendered one character
per line, a vertical column beside the number. Removing its second line — the
"越高越省" hint — was the obvious fix and would not have worked.
.big lays out four things on one line: the 28px rate, the caption, and two
pills. .pill is flex: none and the rate has no break opportunity, so the caption
was the only shrinkable item in the row and absorbed the whole overflow. Two
pills fit only when one of them was absent, which is why this appeared when
正文变了/正文未变 joined 前缀变了/前缀未变 — the caption had been the slack all
along, and the second pill spent it.
The caption is now rigid (flex: none, nowrap) and the two pills wrap as one
group, so a narrow rail moves both below the number and a wide one brings them
back up. The hint line is gone as asked — the block heading already says 前缀缓存
and the numbers below it say 命中/未命中 — and its catalogue key goes with it.
.big has one user, so none of this reaches another panel.
An English window rendered them in Chinese. i18n.test.ts had been failing on
them for a while — it names the file and the key, so nothing was hidden, only
unpaid.
Seven are ordinary omissions: the body half of the prefix-cache pills (前缀变了
already had "prefix changed" while 正文变了 had nothing), its carried-message
tooltip, and four in the rail's MCP block that arrived with pending servers.
Each takes the wording of the sibling beside it, so a pair reads as one
judgement about two objects: prefix held / body held, Fix / Approve.
The eighth is not an omission. en_graph.ts still holds the sentence Graph.tsx
used to show — "这一轮没有派出并行的子代理。fleet 或 parallel_tasks 一开跑…" —
and the panel was reworded to cover any dispatch, not only a parallel one. So
the key was replaced rather than added, and the English rebuilt on the new
condition: the waiting/parallel/reused clause now speaks only for a group.
That is the gap this test cannot see. It checks that every key the interface
asks for has wording; nothing checks the other direction, so a translation
whose source text was rewritten sits in the catalogue looking present.
An on-demand connect failed with `MCP startup launch failed after 5ms: context
canceled` — no approval asked, no process started, five milliseconds in. The
launcher was fine the whole time: `doctor capabilities --live` reaches the same
server and reports runtime=probed tools=68.
ensureServerToolsForSpec deliberately launches under the runtime's lifeCtx
rather than the tool call's, so a slow healthy server keeps starting after the
call that asked for it returns. That context comes from boot.Build's ctx, and
serve hands it an *http.Request one: switchWorkspaceLocked takes r.Context(),
switchModel takes r.Context(), and every pane the hub opens takes r.Context().
Each is done the moment its response is written, so from then on every on-demand
connect in that session launched under a context that was already cancelled.
The initial process boot passes signal.NotifyContext, which is why MCP worked
until a workspace was switched and then never again.
boot detaches cancellation at the one place that knows what the context is for,
rather than plumbing a lifetime through three callers that would each have to
remember. Host.Close still cancels every child it started, so teardown is
unchanged — the cancellation removed here was never what performed it.
Seen live: a map_editor session, on a workspace the desktop had switched to.
use_capability's own description tells the model to list, then inspect one
before calling. list shipped every entry's full text: 16 KB of description in a
24 KB listing, and once MCP servers joined it the reply reached 41 KB and
spilled past the 32 KiB cap. A model that asked what it had was handed a pointer
to a file — which is what a session in map_editor did, before going to read
Unity package sources to answer a question about its own configuration.
A row now carries the first sentence, bounded at 160 bytes. Measured on that
session's own recorded listing: 46 entries, 16,335 description bytes to 4,318,
and the whole reply back under the cap. inspect still answers with everything,
which is where the model was already told to look.
The bound cuts on a word, and on a character boundary when there is no word to
cut on — a CJK description is one rune per three bytes, and the first draft of
this left a lead byte with its continuation bytes gone. It reuses the trim
written for the shell-output decoder rather than deriving the rule twice.
Also: a search that succeeded and matched nothing reached the model as an empty
result, which reads exactly like a call that broke. silentExitNote already
existed for that on the failing side — a non-zero exit that printed nothing —
while the succeeding side, the more common one, had nothing. Same note, same
predicate: only for a call that printed nothing and could not have written.
The dependency barrier stops the rest of a tool batch when a mutating call
fails. ToolCallMutates counts MutationUnknown as mutating, and the read-only
classifier is Unix-shaped, so on Windows an ordinary probe the host cannot
read — netsh, ipconfig, curl.exe — was a mutation. Refuse one, or let it time
out, and every later change and the verification were dropped with "an earlier
modification in this tool batch failed or was blocked". No modification had
failed, and the model was told to fix a change it never made.
Across 1684 recorded tool results, 57 of these skips. 22 came from the criteria
capture that could not read D:\System Volume Information — already fixed — and
32 in total came from calls that never executed at all.
The barrier now says how far it reaches. A failed modification still stops
everything: it may have left the workspace half-applied, so what follows may be
built on a change that never landed. A call the host stopped before it ran
changed nothing, so later edits work from a known state — but the check does
not, because it was asked for against a plan one step of which never happened,
and passing it would mean nothing. That case stops verification alone, and says
so in its own words rather than borrowing the modification message.
A writer the host can name keeps the full barrier whether or not it ran. So does
a target it could not classify at all, which is where this fails closed.
Two things this did not do: toolOutcome.executed is never assigned, so the
`if outcomes[i].executed` branch that would set surfaceWriters is dead — the
barrier reads ShellExecution.MutationRisk instead, which is populated. And the
underlying cause is still the classifier's Unix shape; this bounds the damage
rather than removing it.
Extracting the pre-mark into barrierSkip took executeBatch's complexity and size
down far enough to pay for the new level and leave budget behind.
The trust gate landed with three paths that started a repository-declared server
without answering for it — /mcp connect, ReconnectMCPServer, and the manager's
enable action. They are user actions, so nothing started silently, but they
acted as approval without being it: the server connected once and IsEnabled
still said no, so the next session had it off again and the button read as
having not worked.
An explicit connect now records the decision it represents. The approval is
project-scoped like every other one, and a failure to write it is logged rather
than failing a connection the user asked for.
The manager showed pending as "deferred", which means the opposite thing —
deferred connects when needed, pending will not connect at all. It has its own
line now, and its action says "Approve and connect" rather than "Connect",
because naming the effect and hiding the choice is what this state exists to
stop.
TestConnectConfiguredProjectMCPIsTrustedByDefault asserted the belief this
series removed. It is now TestConnectingAProjectMCPRecordsTheDecisionItRepresents
and checks the server waits beforehand, stops waiting after, and that the answer
survives into a second controller.
Still open: the negative answer. "Disable for this session" is offered on a
pending row, but a durable refusal — the thing that stops it asking again — has
no action of its own.
A .mcp.json is checked in, so whoever wrote the repo chose the command and its
arguments. Reasonix started it at the next session in that folder with nothing
asked, and MCPConfigSource.UserAuthorized() reported it as already authorized.
A project reasonix.toml's [[plugins]] is the same file with a different name and
had the same treatment; gating only one of them would be a claim that outruns
its enforcement, since a PR could simply use the other.
The activation store already held per-project enable rows — the missing piece
was the default. IsEnabled now resolves a recorded decision first and, without
one, keeps a repository-declared server off. Everything else is a file only the
user writes and still follows auto_start, including the two ~/.claude.json
scopes.
Undecided is kept apart from disabled throughout. One is waiting for the user
and the other is their answer, and a surface that shows both as off reads as
the project's MCP having quietly gone missing: MCPServerState carries Pending,
the project listing reports "pending" rather than "disabled", and the rail
panel — whose whole purpose is servers that need a decision — lists them beside
the broken ones with "去批准".
A security review of the first draft found four ways it failed open, all fixed
here:
- Every caller answered a store read error with ShouldAutoStart(), which is the
pre-change default and re-enables exactly what the gate is for. A corrupt
activation file was enough. DeclaredDefaultOn is that fallback now, and
AwaitingDecision reports pending rather than settled when it cannot read.
- Source carries toml:"-" and is assigned only by a successful merge, so a
failed one leaves a project file's own entries with none. Unknown provenance
now counts as repository-declared.
- Decide read a global row for an identity ServerOverrideFor pins to the
project, so one row could have approved a repo server in every project.
The global layer is skipped for those.
- `doctor --live` started every auto_start server, which is what a user runs to
find out why their MCP is off — answering the question by running the command.
Left open, and reported rather than fixed: /mcp connect, ReconnectMCPServer and
the manager's enable action start a pending server without recording a decision,
so they act as approval without being it, and the TUI's own status view still
derives from cfg.Plugins and shows pending as "deferred".
mcpjson.go says Reasonix reads .mcp.json "so an MCP server already configured
for Claude works here unchanged". Claude Code writes to three places and that
is the one `claude mcp add` does not use: its default is the per-project map
inside the user's own ~/.claude.json. The reporting machine had three servers
configured, none in a .mcp.json, and Reasonix loaded zero of them for every
workspace on it.
Both ~/.claude.json scopes are now read: the entry for this workspace first,
then the user-wide map, matching Claude Code's own local-over-user order. They
merge last, so a name Reasonix's own configuration claims keeps it — that one is
written for Reasonix, this is a compatibility read.
They auto-start like anything else, and they should: the file is one only the
user writes. A repo's .mcp.json is the one that arrives with a clone, and
Reasonix already starts that without asking — which is the trust asymmetry
inverted, and a separate thing to fix.
CLAUDE_CONFIG_DIR moves the file and is honoured. Project keys are compared as
paths: Claude writes them with forward slashes on Windows, where the same
directory is also spelled with backslashes. An unreadable or malformed file
yields nothing rather than failing the load — it belongs to another tool.
Verified against the reporting machine: D:\tygit\map_editor now resolves
unity-editor-mcp from claude_local, and a name held by both scopes there
resolves to the project's own entry.
A child's bytes became a Go string unchanged. On a Chinese Windows the console
tools answer in cp936, so the string held invalid UTF-8, and JSON coerced every
bad byte to U+FFFD on the way to the transcript — destroying the text before
anything could read it. "FIND: 参数格式不正确" reached the model as a row of
question marks, and a run that failed four times never learned why.
decodeShellOutput reinterprets only bytes that are not UTF-8. The guard matters
more than the decode: the tail buffer keeps the last N bytes and therefore
starts mid-character, so valid UTF-8 output arrives invalid — deciding on those
bytes would invent mojibake where truncation was the only problem. Both edges
are trimmed of a cut character before the question is asked.
Distinct from the grep peek fix: that one misread a file's bytes, this one never
decoded a process's at all.
A call's duration covers the host's preflight — capture, effect scans,
permission — and ShellExecution.DurationMs covers only the command. Both rode
the wire already and nothing subtracted them, so the overhead had no reading
anywhere: not in the run metrics, not in a panel, not in a log line.
Subtracting them over 406 recorded bash calls: one session sat at a 13.0s median
per call with p90 42.5s and a 172s worst case, while the shell answered in
milliseconds — about twenty minutes of one session spent inside the host. On an
ordinary repo the same figure is 0.00s, so this is not a constant to be tuned
away but a cliff that only some workspaces fall off.
A tool that reports no inner duration adds nothing rather than claiming its
whole runtime was overhead.
Which criteria exist is a fact about the workspace, not about the call that
might overwrite them. The evaluation half of this subsystem already reads it
that way — BaselineTestObligations reuses a verdict while its StateEpoch holds —
while the capture half re-derived the whole set per call.
captureCriteriaOnce keys the walk on mutationEpoch, which advances on every
recorded successful mutation. A call the host proved wrote nothing clears that
flag in settleUnchangedWorkspace, so it does not invalidate; one the host could
not prove keeps it, so it does. An unobserved external writer still slips
through, which is the gap the verification model already declares.
Measured over 125 recorded transcripts: 296 walks become 67. The first attempt
at this measured no gain at all — invalidating on "an unknown call ran" is
always dirty, because unknown calls are most of them. The epoch was the
difference, and it was already being computed.
This is also what makes the preceding walk-error fix a win rather than a trade.
Those D:\ calls were aborting the walk partway at the ACL error and still cost a
3.1s median against 0.39s on an ordinary repo; finishing the walk correctly
would have cost the full 6.5s. Now it costs that once per state.
filepath.Base answers the separator for a volume root, and six places named a
workspace with it: the sidebar, the session tab, the capability scope, the
@folder refs and the TUI's git line all showed a workspace opened at "D:\" as a
lone backslash, which names nothing and cannot be told from any other drive.
fileutil.RootName falls back to the volume when Base has nothing to say. One
place already handled this — botProjectName has its own fallback — which is the
shape of a judgement copied into some callers and not the rest.
The external-folder search now matches the name the list shows rather than
filepath.Base's, so typing what is on screen finds it.
The anonymous struct that function spelled out twice becomes externalRootRef:
the import this needed put refs.go one line over, and a type it wrote down two
ways is the line to get back.
Seen live: the Studio sidebar, on a session opened at D:\.
.file .p shrinks and clips its directory, but .f was flex: none and .p had no
overflow, so a filename longer than the row kept its full width inside a box
that no longer had it — and painted straight through the diff counts at the
right edge. At the 214px rail, "capability_discovery_effect_test.go" and
"+128 -0" were drawn on top of each other.
.p clips now, and .f ellipsizes as the last resort. The directory keeps its
priority for being cut through the shrink weights rather than through .f
refusing to shrink at all, so what the old comment promised — the directory may
go, the filename may not — is still what happens: a row with room loses the
directory first and keeps the whole filename.
Verified against the real stylesheet at the real rail width, before and after.
The provider schema hides the dispatchers, so this description and an explicit
action=list are the model's whole discovery surface for them — and it named
only task:subagent. Over 21 sessions the batch dispatchers were reached 6 times
against 74 single delegations, and parallel_tasks not once. A capability
nothing names is one the model has no reason to look for, whatever its own
description says once found.
task:fleet joins the named ids with one line on what separates it from
task:subagent. Two tests hold the pair: the description must name a way to
delegate more than one thing, and every id it names must be in
CapabilityIDExamples, which is what the boot effect test checks actually
resolves.
This is a deliberate prefix change: tool schema tokens go 4545 to 4584 and
ToolsHash moves, so live sessions pay one cold prefix. The golden baselines are
re-recorded for it.
publishGraph had two callers, fleet and parallel_tasks, so every other
delegation — a task, a run_skill, a review capability — published no node at
all. A sub-agent ran eight steps in its own context and the graph tab answered
"这一轮没有派出并行的子代理", which reads as nothing having been delegated.
The graph was a fan-out visualiser; a person opening it is asking what this run
handed work to.
The hook goes on RunProfileSpec, where task.go already states the invariant
every entry point relies on: each one compiles to a spec and runs through it,
so a boundary added there cannot be missed by one caller. The two fan-outs mark
their items with withDeclaredGraphNode — only they can say it, having chosen
the node's id and kind, and without it the shared runner would redraw a fleet
worker as a group of one. A background run draws nothing: it settles after a
handoff this return never sees.
lanesOf needed no change — the group, the parentId and the spawn edge are the
shape it already reads.
Two extractions keep task.go and RunProfileSpec inside their budgets rather
than widening them: subSink/subSinkFor move to nested_sink.go, which is what
they are about, and the spec's prompt validation becomes settleSpecPrompts.
Both hand budget back.
PrefixShape hashes the system prompt and the tool schemas. The conversation is
not in it, so a full miss on a prefix the host itself hashed as unchanged had
no attribution at all: the diagnostic could not tell a dropped cache from a
message the host rewrote behind the boundary. session_types.ts already claimed
the hash "tells changed from the endpoint dropped it" — with the body
unobserved, it could not.
BodyChain hashes the provider-visible messages cumulatively, one entry per
message, so two requests compare at the length of the shorter and a rewrite
anywhere in the carried region moves every entry after it. It is taken from the
request that goes out, not from the conversation: a local-only message never
reaches the provider, and reporting a change to one as a cache change is the
mistake CompareShape's own comment already warns about.
A rewrite the session declared is already named through contentReasons. One it
did not declare gets "body_unreported" — the case this observation exists for,
because nothing else can see it.
Measured over 21 sessions before this: 118 full misses on an unchanged prefix,
10.6M prompt tokens re-paid, with cacheDiagnostics reporting prefixChanged
false on every one and nothing able to say who dropped them.
The desktop panel gets the second pill beside "前缀未变", so the answer reaches
the person who can act on it. event.go was at its 800-line ceiling and
session.ts at exactly 800, so CacheDiagnostics moves to its own file and the
metrics fold joins the usage helpers it belongs with — both lower a budget
rather than widen one.
grep detects a file's encoding from a fixed 8 KiB peek, and the comment said
utf8.Valid over that window was reliable. It is not: the window cuts a
multi-byte character in half, the cut byte alone fails the check, and GB18030 —
which accepts almost any byte string — wins. Over CJK text, where most bytes
are continuation bytes, a fixed window lands mid-character about two times in
three (measured: 2167 of 3000 offsets), so a Chinese UTF-8 source larger than
8 KiB came back as mojibake for its whole length.
TrimPartialRune drops a trailing sequence the window truncated, and only that:
a leading byte declaring more bytes than remain. A byte that is simply invalid
stays, so a file that should fail the check still fails it. read_file was never
affected — it reads whole files, so Detect always saw complete bytes.
Seen live: a grep over "Cocos-MCP进度.md" whose every matched line rendered as
UTF-8 read through GBK.
doublestar.SplitPattern hands back the bare volume for a pattern rooted at one:
"D:\**\*xray*" splits to root "D:", and on Windows "D:" names that drive's
current directory rather than its root. WalkDir therefore started somewhere the
caller never asked for, and every match came back drive-relative —
"D:DeepSeek-Reasonix\benchmarks\..." is not the path it looks like, and a model
handed it back through read_file addresses a different file or none.
globWalkRoot repairs that one root and now owns the split, so the test covers
the wiring rather than a helper the call site could stop using.
Seen live: a workspace at "D:\" where every glob result was drive-relative.
captureCriteriaUnder walks the writable domain before every call whose scope
the host cannot read — every mutating bash — and captureCriterionAt asked the
bytes a question the name already answers. It called os.ReadFile on each file
and then HoldsTestCriteria, whose first line is a "_test.go" suffix check. On a
workspace at a drive root that is the whole drive, read once per bash call.
The suffix check moves ahead of the read. Measured on the same tree (1020
files, 250 MiB of non-test content, page cache warm): 191ms to 3.3ms on the
first broad-scope call, 147ms to 1.6ms on every one after it, with the same 20
criteria held. The doc comment claiming only the first call pays was wrong
about the walk and now says so.
A subtree the walk cannot enter is skipped rather than refused. An unreadable
file was already skipped one function below, and the two disagreeing is what a
workspace at "D:\" ran into: "open D:\System Volume Information: Access is
denied" refused every write for the rest of the session, and the batch cascade
turned each refusal into four more. 14 blocks and 48 cascaded skips in twelve
observed sessions, over a directory nobody can open and nothing can write.
The walk also takes a context and checks it every 512 entries, the way
scanWorkspace already bounds its own: an uninterruptible walk across a drive is
what a user experiences as the stop button not working.
use_capability answered `unknown capability_id "parallel_tasks"` for a
capability the catalog was holding. Lookup matched an entry's id and its
declared aliases — "tool:parallel_tasks", "task:parallel" — but not the tool's
own name, which is what every other description, error message and skill body
calls it, and therefore what a model reaches for first.
The name now resolves when exactly one entry answers to it. Two tools sharing
a name refuse rather than route the call to whichever came first: guessing
sends work somewhere it was not asked for, which is worse than saying no.
Seen live: a run that tried use_capability with "parallel_tasks", was told it
does not exist, and went looking for another way to do what was available.
classifyResponseBoundary drops a trailing tool call the output limit cut in
half, and tells the model the host did it — but only when finish_reason says
"length". A provider that reports "tool_calls" alongside a half-written call
got that call executed, and the model answered for "invalid complete_subtask
JSON: unexpected end of JSON input" as if it had mistyped something.
Arguments that stop mid-value are the host's own evidence, and they do not
depend on a finish reason arriving correctly: a decoder running out of input
inside a token is not what a syntax error or a finished document looks like.
The classifier now reads that directly, so the cut is attributed and the round
is not spent on a call the model never finished writing.
A malformed call the model did finish is still its own to fix — the two need
opposite corrections, send less versus escape correctly, and the existing
normal-terminal test now uses a syntax error rather than a cut so it says which
of the two it is pinning.
Seen live: a three-way parallel_tasks run against this repo, where the first
complete_subtask of a sub-task came back truncated and cost a full round.
complete_subtask answers an unbacked report with needs_work and a count: "1
criterion claim(s) are not backed by anything the host observed". The host had
the list — AdjudicateCompletion returns a reason per criterion, naming it and
why — and threw away everything but its length.
A sub-agent that cannot tell which criterion failed, or what the host looked
for, resubmits with a different evidence shape and hopes. In a real three-way
parallel run against this repo, 6 of 11 complete_subtask calls were needs_work
loops; one sub-task cycled files-evidence -> rejected -> command-evidence ->
accepted with its unresolved count unchanged, which is the shape of guessing.
The verdict now lists the reasons, and the reasons say what the host looked
for rather than that it found nothing: "AC3: no read or write recorded for
internal/control/controller.go, ... (+5 more)", "c2: no successful run of
\"go test ./internal/agent/\"". Paths are capped at three and commands at 80
characters so one rejection cannot become the whole reply, and identical
reasons are said once — two review citations missing the same receipt read as
two problems otherwise.
No claim about the effect: a single run cannot measure a loop this noisy, and
the sample sizes here (11, 4, 9 calls over three runs of the same task) differ
by more than any change would. What is verified is the mechanism — the verdict
now carries what the host knew, checked by a test and seen in a live run.
parallel_tasks and fleet pre-nest the sink they also hand to the call context,
and RunProfileSpec nests again off that context, so every tool id from a child
was prefixed with its parent twice:
call_00_x/sub-3/call_00_x/sub-3/call_00_child
Measured on a real three-way parallel_tasks run against this repo: 317 of 332
tool dispatches — 95% — carried a doubled prefix. After, zero.
An id that does not start with its parent's is a child call that reads as
top-level, which is what a frontend's dispatch-to-result matching, the graph's
nesting, and every trajectory reader key on. It is why analysing that run
attributed all 41 complete_subtask dispatches to the root.
The fix is in the sink rather than the two callers because the invariant is the
type's: it namespaces into a parent, and doing that twice is never right. The
pre-nesting in those two callers is now a redundant layer rather than a bug,
and can go separately.
A failed modification stops later modifications and verifications in the same
batch. Delegation is neither — task, parallel_tasks, run_skill, use_capability
and the review tools spawn work rather than change state — which is why
batchCallIsMutatingFailure exempts them: a failed one does not open the barrier.
Its comment says so, and says why a bare !ReadOnly fallback would be wrong.
batchCallStaticallySkippable, which answers the other half of the same question
— who the barrier skips — used exactly that bare !ReadOnly. TaskTool.ReadOnly()
is false, so one failed edit_file dropped every delegation left in the batch as
a dependent modification, each reported as "skipped because an earlier
modification in this tool batch failed or was blocked".
Both halves now read the same exemption.
I went looking for a redesign of the turn lifecycle and did not find one to
justify. The run state is in better shape than the notes I had: turnGate already
groups the five admission flags into one type with the TOCTOU it closed written
down, and turn_orchestrator.go already owns running a turn. Turning the type's
stated invariant — running and rotating are mutually exclusive — into an
assertion and running the whole suite under it triggers nothing. There is no
bug here, so nothing is being rearranged to look tidier.
What the reading did find is smaller and real: c.cancel sat beside the gate
while having exactly the gate's lifetime. Three sites set `running = true`
and then `cancel` on the next line, verbatim; two cleared them together. Two
fields, one state, and the pairing enforced by everyone remembering it.
cancel moves into turnGate, with begin/end/requestCancel doing what those sites
each spelled out. The Controller loses a field. Behaviour is unchanged — the
methods contain the same assignments in the same order under the same lock, and
finishing stays the caller's to set because whether TurnDone is still fanning
out is a different question from whether the turn is over.
Verified where it counts: `go test -race -count=2 ./internal/control/`, full
suite, and sabotage-checked by making begin forget the cancel, which fails four
existing cancel tests.
applyPlanExec and applyGoalCommand have exactly one caller each, and it is the
command dispatch that moved out last commit. They were left behind because the
move went by name, not by call graph — which is the failure mode of splitting a
file by reading it top to bottom.
Verbatim move, 2 declarations; 161 methods present across both files, none
duplicated, no body changed. controller.go 3,386 -> 3,265.
This is where the splitting stops. What is left is 144 methods averaging 23
lines with no cluster larger than four related names — the facade itself, plus
RunShell and close. Cutting further would make files smaller without making
anything easier to find, which is the point past which this stops paying.
Cancelling is not one act. There is the request, the guarded window a turn runs
inside, and then the harder half: deciding what the conversation should look
like once a turn stopped mid-flight — which visible messages to strip, what
fallback stands in for the interrupted one, and when the session has to be
replaced outright. The 125-line strip is the largest method left in
controller.go and it was sitting nowhere near the Cancel that leads to it.
Verbatim move, 9 declarations: all 153 Controller methods present, none
duplicated, no body differs by a byte. controller.go 3,716 -> 3,386, and
5,837 -> 3,386 across the five splits — a 42% cut with no behaviour change.
Baseline carry-forward as before; finding count holds at 1,669.
I said last round that what was left had no nameable responsibility. That was
wrong, and reading it rather than assuming showed why: turn_orchestrator.go
owns running a turn, but how a turn comes to exist — every Submit variant the
frontends call, resolving its refs, deciding whether the text is a command or a
turn, preparing an invocation — was still spread through controller.go. Two
concerns with the same noun, which is what made them look like one.
Verbatim move, 17 declarations: all 170 Controller methods present, none
duplicated, no body differs by a byte. controller.go 4,092 -> 3,716, and
5,837 -> 3,716 across the four splits.
Baseline carry-forward as before; the finding count holds at 1,669 and repo
file-size falls 39,765 -> 39,388.
What waits on the user and which call answers it — tool approvals, plan
decisions, asks, the pending-prompt replay a reattaching frontend needs, and
the modes that decide whether any of it is reached — is one responsibility, and
it was the last cohesive cluster sharing controller.go with everything else.
approvalManager already owns the bookkeeping; this is the orchestration half,
the part that emits, records a receipt and rebuilds a gate.
Verbatim move, 24 declarations: all 194 Controller methods present, none
duplicated, no body differs by a byte. controller.go 4,649 -> 4,092, which is
5,837 -> 4,092 across the three splits.
The move is only half of it. internal/control/approval.go is a declared
sensitive path, so code leaving it for a file that is not one would drop out of
both effects that declaration has: the review gate, and the coverage floor.
REASONIX.md declares the new file too, which is why this could not be a tidy-up
— and the floor is real, recorded at 90.7% and sabotage-checked by raising it,
which fails the gate.
Two things surfaced while recording it, neither mine:
internal/providerbroker/** was declared sensitive without a floor ever being
recorded, so it has been passing the gate vacuously since it landed. It has one
now. Its coverage flickers between 83.1% and 83.6% across runs (5 and 2 of 7),
so the floor is the observed lower bound: a floor set at the higher number is a
gate that fails at random, which teaches people to rerun CI rather than read it.
internal/sandbox/** rises 73.5% -> 78.1%, which is a ratchet catching up with
coverage already written.
The live tool and plugin surface is already mcpManager's. What stayed on the
Controller is the config-facing half — adding a server to reasonix.toml,
connecting one on demand, importing a batch, naming what is configured versus
disconnected, removing one and unregistering its tools — and that half is a
responsibility, not leftovers. It is also one contiguous run of the file, which
is what a responsibility looks like when it has never been named.
Verbatim move, 14 declarations: all 208 Controller methods are present, none
duplicated, no body differs by a byte. controller.go 4,936 -> 4,649.
Baseline carry-forward again: repo file-size falls 40,609 -> 40,322, the finding
count is unchanged at 1,669, and controller.go hands the new file 12 of its
frontend-parity findings and 2 essays.
Not moved, deliberately: the approval orchestration, the other cohesive cluster
left. internal/control/approval.go is a declared sensitive path, so moving that
code into a file that is not one would quietly drop it out of the review and
coverage gates it is under. That move has to come with the declaration, and
that is a decision, not a tidy-up.
controller.go was 5,837 lines. The stateful subsystems with their own locks are
already extracted — memory, checkpoints, MCP, skills, goals, approval — so what
is left is not another collaborator: the session's run state IS c.mu, and
prising it out would buy a lock to reason about instead of a file. What it is
is two responsibilities sharing a file with everything else.
They move whole, to files named for what they own:
session_lifecycle.go — creating, clearing, branching, switching, resuming a
session, and where its path comes from.
session_snapshot.go — persisting one, and recovering when the persist finds
the file moved under it.
Nothing else changes. Every one of the 240 Controller methods is present after
the split, none is duplicated, and no method body differs by a byte — checked
by extracting each declaration from before and after and comparing, not by
reading the diff, because a diff that is only deletions on one side is exactly
what a botched move also looks like.
The baseline widens for the two new files and shrinks for controller.go by the
same debt, which is what -allow-widen is for. It is a carry-forward, not new
debt: the finding count is unchanged at 1,669 and every repo total falls
(file-size 41,525 -> 40,609, essay 3,243 -> 3,242, frontend-parity moves 8 of
controller.go's 87 across).
The immediate reason is that this file's ceiling has twice now been the thing
standing between a one-line change and landing it: adding a call meant first
finding another function to move out.
The prefix a provider caches is the system message and the tool schemas, and
the schemas are the larger half — 24KB against 2.8KB in the golden fixture.
The boundary test asserted only the message, so a per-project tool surface
would have diverged the prefix one message later with nothing failing.
Nothing diverges it today: no builtin composes its workspace into a name,
description or schema, and the one that listed per-project text — slash_command
naming the configured commands — stopped when the skills catalog moved to the
turn. This pins that. Sabotage-checked by prefixing read_file's description
with its workDir, which fails it.
`reasonix doctor` reads a session's usage out of a .telemetry.json beside it.
Nothing has written that file since the desktop that used to was retired, so
every report on every real session said "desktop telemetry is unavailable" and
dropped the whole usage and per-source section. The reader, its schema and its
GC entry all survived; only the writer was gone.
The kernel owns the accounting now. goalUsageTee is the one layer every
billable call passes through — executor, planner, subagent, compaction,
classifier, and the rest, whichever agent made them — so the tally hangs there
and the record is written at the session's own persistence point, which means
it names the session the transcript was written for and cannot drift onto
another. A session with no path or no billed request writes nothing rather
than a record of zeros, which a reader cannot tell from a session that made no
calls.
The schema moves to internal/usagereport so the writer and the reader stop
being two hand-written copies that meet over JSON, where a rename on one side
reads as zero forever on the other.
It carries one thing the old file did not: the cold start, the session's first
executor request kept apart from the totals it is also in. The session hit rate
averages over requests that reuse the prefix the first one paid for, so it says
whether the cache worked once warm and can never say how much of the prefix a
cold start could reuse — which is the number a per-project prefix moves, and
the one this branch's four prefix cuts were aimed at. Sidecar calls are
excluded because they carry their own prefixes; an empty source resolves to the
executor, as every other reader already resolves it.
The effect test asserts it end to end: a session that bills a cold 400/8600
request and a warm 9000/500 one leaves a record whose cold start is the first,
and doctor reports 4% against a session figure of 94% rather than collapsing
the two.
composeWithGoal built the turn by prepending to a string eight times, so the
order the model reads the blocks in was the reverse of the order they were
written in, and adding a ninth kind of dynamic state meant finding the right
line to insert before. The order was real contract — a block nearer the text is
read closer to the request — recorded nowhere.
The blocks are now a declared list and projectTurn renders it, with the one
that follows the user's text (retrieved facts, which answer the request) passed
as the tail. Same blocks, same order, same drain points: the projection is
byte-identical, which is what the existing tests check.
Two contracts come with it. The order is asserted against a written-out list,
so changing it fails a test that says what changed rather than a golden diff
somewhere downstream. And every projected tag must be in
agent.TransientUserBlockTags — the registration that the strippers, the search
indexer and the supersede pass all read. Nothing enforced that before: the
instructions block reached the supersede pass one commit late, and a block that
misses it is silently treated as the user's own words.
autoresearch-runtime stays registered with no producer on purpose. Sessions
saved before it was retired still carry the block, and the registration is what
keeps it out of their titles when they are resumed.
The block is re-owed whenever the instruction set changes and again after a
fold, so a long session accumulates copies of the largest thing a turn carries
— 18KB each on this repo — every one of them stating rules that a later copy
has already restated.
supersedeStandingState already keeps the newest block of each tag and drops the
rest; project-instructions belongs in that set for the same reason <workspace>
does. It is the newest copy that says what the rules are now, and at most one
survives the fold.
REASONIX.md / AGENTS.md / CLAUDE.md were composed into the system prompt at
boot, which made the prefix a per-project string by construction: this repo's
own instructions are 18,104 bytes, so a session here and a session in any other
project shared 3,259 bytes and then diverged on the first rule — and every byte
after the divergence, the rest of the prompt and every tool schema, missed the
provider's prefix cache on a project the machine had not warmed.
They now ride a user turn as <project-instructions>, owed once per session and
again whenever the set changes or a fold summarises the turn that carried them.
The three cuts before this one took version control, the saved-fact index and
the skills catalog out for the same reason; with instructions gone, two projects
that share nothing send the byte-identical system message — measured, 21,360 and
3,320 bytes before, 3,609 for both after.
Be precise about what that buys. The text is not saved: it is one copy either
way, in a different message. What changes is that the copy is no longer in front
of everything else, so the prefix is reusable across projects and sessions
instead of only within one session.
Instructions arriving in a user turn must not read as a demotion, so a fixed
paragraph in the prefix states where they come from and that they carry the
authority they had here. It names no project, so every session on the machine
sends the same bytes; it costs 618 of them (the golden system prompt goes 2,163
→ 2,781) and is the whole per-session price of the move.
The planner composes its prompt once and never projects a turn, so it takes
memory.Set.StaticContext and is unchanged. Set.Block is gone rather than left
meaning half of what it says: Compose takes background, the turn takes
instructions, and there is no third caller to get it wrong.
TestEffectNoProjectStateReachesThePrefix is the standing boundary, upgraded
from the version that still allowed instructions. Sabotage-checked by composing
them back in, which fails it.
autoRouteOrder gained the remote-fetch route and moved npm last, which left
this test asserting npm at index 0 and expecting three routes. It failed on
the branch as pushed: an index is a claim about ordering that the assertions
never meant to make.
Each route is now looked up by name and the count is tied to autoRouteOrder,
so adding a route fails only the count, and reordering them fails nothing.
The download route keeps its own check because its closed reason is an
unwrapped error on purpose.
The skills catalog now rides the user turn, so the runner's input starts with
<available-skills> and an equality check against the expanded command failed.
The block is already declared in agent.TransientUserBlockTags, so the test
drops the host's prefixes the same way history's indexer does and keeps
asserting the expansion exactly.
The three cuts that took version control, the saved-fact index and the skills
catalog out of the system prompt each assert their own piece at the provider
boundary. None of them fails when a fourth kind of project state is composed
in, which is the way this erodes: one line appended in buildPromptAssembly and
every project stops sharing the prefix after it.
Measured on this repo's own REASONIX.md, two projects differing in skills,
saved facts and version control shared 1,754 bytes of system prompt before the
cuts and are byte-identical after them, so the boundary is now stateable as a
test rather than three separate claims: instructions are the only project state
left in there. Sabotage-checked by appending the workspace root to the prompt,
which fails it.
Instructions are still in the prefix. When they move to a session bootstrap
this test's projects can disagree on those too.
The version-control and skills-catalog cuts landed on separate branches and
each needed the same two accessors over a provider.Request, so each wrote its
own: systemOf/userMessages and systemOfRequest/userMessagesOf, byte-identical
bodies. Merging them put both pairs in one package, where the duplication is
visible and the next effect test has two names to choose between.
MEMORY.md was folded into the system prompt at boot, ahead of the project
instructions and the skills index. Measured on this repo's own REASONIX.md with
eight saved facts: the memory block sits 2,192 bytes into a 23,731-byte system
message, so one `remember` re-sent 21,539 bytes — 90.8% of the prompt — on the
next session, of which roughly 19KB was instruction text that had not changed.
PrefixCost counted the index's own 881 bytes and called that the cost.
The facts were already reachable without it. The `memory` tool has search, read
and list — list is the index — and relevant facts already ride the user-turn
tail through auto-recall. The prompt copy was a third path to the same data,
and the only one that charged the prefix for it.
What replaces it is a fixed protocol paragraph: how to reach saved facts, how
to treat them, which tools write them. It names no fact, so it is byte-identical
for every project and session on the machine. Pinned bodies stay in the prefix:
`activation: pinned` is explicit, off by default, and documented as an
always-available fact, so it is not a churn source the way an ordinary save is.
`Set.Index` goes with it — a field nothing reads is where the index grows back —
and PrefixCost now counts pinned bodies only, because a number named for the
prefix that included something not in the prefix was measuring the store.
<memory-update> survives for changes made outside the conversation — a "#"
quick-add, the memory panel — which nothing else tells a running session about.
It no longer fires for the model's own remember/forget: that tool result is
already in the conversation, and echoing it also suppressed the turn's recall,
which says the same thing better and in context.
For an empty store the prefix grows 349 bytes (the protocol is longer than the
"no facts yet" notice it replaced); with facts saved it shrinks, and either way
it stops moving. The golden baseline is re-recorded for that text.
The effect test asserts the claim where it has to hold: a session that saves a
fact leaves the next session's system message byte-identical, and the test
fails if the index is composed back in.
The skills index was folded into the system prompt at boot: 2,250 bytes of
per-project catalog inside the prefix every project on the machine otherwise
shares (the golden fixture's system prompt goes 4,064 → 1,814 bytes). It was
also stale by construction — a skill installed mid-session never reached the
model, and the built-in guide told users to reopen the session.
The listing now rides a user turn as <available-skills>, owed once per session
and again whenever the catalog changes. A session that installs a skill and runs
/reload-cmd reaches it on the next turn. The token cost is unchanged for a
session that never reloads: one copy either way, in a different message.
A fold can summarise away the turn that carried it, and standing state said only
once does not survive that, so a completed compaction owes it again.
slash_command's description stopped listing the configured names. Be precise
about what that buys: under the default token profile the tool is not
provider-visible at all — it sits behind use_capability — so this is not a
measured cache win today. What it fixes is real but narrower: /reload-cmd
rewrote a provider-visible tool's description under a live session, which is a
prefix mutation wherever the full tool surface is in use. The names are a result
of the tool, not part of its contract; "list" returns them.
Not done, deliberately: per-turn top-k skill retrieval. At this catalog size it
loses. The whole index is 2,250 bytes sent once; injecting candidates costs new
tokens on every turn and breaks even only around fifteen of them, and it trades
a complete list for a ranking that can hide a relevant skill. Retrieval is the
right answer for a catalog orders of magnitude larger than this one.
ApplyIndex goes with the fold — nothing composed an index into a prompt any
more, and leaving the function is leaving the invitation.
The Environment section is composed into the cache-stable system prompt, and it
carried the workspace's version control. That is a per-project fact sitting at
byte ~1,740 of a 23KB prompt, ahead of memory, the project instructions and the
skills index — so a repository and a non-repository on the same machine shared
only 1,755 bytes before diverging, and everything after the divergence, the
rest of the prompt and every tool schema, missed the provider's prefix cache.
The comment above it claimed the prefix was identical for every project on the
machine; the line below it was the reason that was false.
It now rides the transient <workspace> block, next to the path, which moved out
for exactly this reason. Both are stated once per turn instead of once per
session, and the fact still reaches the model either way — unsaid, it reaches
for `git diff` in a workspace that is no repository. Resolution stays at boot,
so no turn pays a filesystem walk for it.
Measured the same way on this repo's own REASONIX.md: the shared head between a
git and a non-git project goes from 1,755 to 2,674 bytes, and the first
divergence moves off the environment section onto the project instructions.
That is the whole win here — the remaining divergence is memory, instructions
and the skills index, which are their own changes.
The effect test asserts it at the provider boundary, where the claim is: two
projects that disagree on version control send the byte-identical system
message, and each turn states its own.
Two things stood between an SSH host and a working session, and neither
was the connection. That host needed a reasonix installed on it, and it
needed the model provider configured a second time, with its own API key
and its own route to the internet.
Model credentials now stay here. A remote session resolves providers
over a reverse forward back to a broker this machine runs on loopback:
the Selection and Request cross the tunnel, the provider is built and
run at home, and the answer streams back. The far side holds an address
on its own loopback and a token, and nothing else — no endpoint, no key,
no headers, no proxy settings — so a host with no egress to the model
API works, and the key is never written to its disk.
The provider runs here rather than the tunnel reverse-proxying its HTTP
because endpoint identity decides request shaping in dozens of places
across the adapters: DeepSeek's beta prefix endpoint, Kimi's K3
contract, Gemini's model resource names, MiniMax's reasoning split. All
of them read a base URL, and a remote pointed at 127.0.0.1 would take
the default branch of every one, silently.
For the same reason errors cross as identities rather than sentences.
IsContextOverflow reads *provider.APIError's status and the code inside
its body; flattened to a string it classifies as nothing, and a remote
turn would fail where a local one compacts and replays.
Getting the kernel there is a transfer, not an install — the binary is a
static CGO_ENABLED=0 build. So auto has the host download its own
release first: this side resolves SHA256SUMS and hands over the URL and
the digest, and the host fetches over its own link, which for a machine
in a datacenter is far faster than a laptop pushing 62MB of extracted
binary. The digest is resolved here on purpose: a host fetching both the
archive and its checksums would be verifying one against the other from
the same network. npm moves from first to last, because it installs the
identical static binary and needs Node to do it, and it stays only
because a network that mirrors npm while throttling GitHub is a real one.
That order lives in one list both the installer and the probe read, so a
probe cannot promise a route the installer would not take.
Two guards this needed. A serve resolves providers over the address it
was launched with, so one bound to a retired broker is not reusable. And
retireOutdated, which stopped only kernels below the version floor,
becomes retireReplaced: whatever made a record unreusable, leaving its
process running orphans it, since that record is the only note this side
keeps of the pid.
Per-host `provider = "local" | "remote"` picks which side answers, and
defaults to local. `reasonix remote serve start` never publishes a
broker: that serve is meant to outlive the command that started it, and
a broker published from a command that has exited is a port nobody holds.
Verified against a real SSH reverse forward, not only against the codec:
a kernel holding no credentials completes a turn, an overflow keeps its
classification, and a tunnel dropped mid-answer reads as an interruption
the agent replays rather than a completion it commits. The fetch script
is executed rather than string-matched — it installs a served release,
refuses a mismatched digest, and leaves nothing behind on a 404. The
three EnsureServe cases model a POSIX remote and skip on Windows; they
were cross-vetted for linux and run in CI.
A remote listener is torn down on every disconnect and re-listened on
Attach, and a spec asking for 127.0.0.1:0 was handed a fresh port each
time. Whatever was told where to find it outlives that reconnect, so the
address it is still holding is one nobody listens on any more.
The runner now remembers the address it was given and re-requests it,
falling back to a fresh one when that port has since been taken. A spec
that named its own port is unaffected: it already asked for one.
The test failed at first for a reason that was not the code. sshtest
replied true to cancel-tcpip-forward and never closed the listener, so
re-requesting the same port failed there and nowhere else. The fake now
releases the port the way sshd does, which is what lets the test observe
the fix at all.
The Wails shell installs as ReasonixStudio.app and this one as "Reasonix
Studio.app", so a person who downloaded 2.11 with 2.10 on disk has both.
They share a data home, and the single-instance lock means clicking
either raises the same window: two icons, one application, and nothing
saying which is which. Windows already takes its predecessor over from
the installer and Linux upgrades by package name; macOS had no
equivalent, because a dmg is a drag and has no hook to put one in.
So the first launch asks. Trash rather than delete, because this removes
an application somebody may still want and only one of those is
reversible; after the window rather than before it, because a modal in
front of an unpainted window reads as a failed start; detached from boot,
because a launch must not wait on it.
Bundles are found by identifier through Spotlight rather than by name — a
bundle someone renamed is the same application, and a name match would
miss it. The published path is the fallback for a machine with Spotlight
off: that one is a fact about what we shipped, not a guess about what a
user typed.
A refusal is recorded per bundle. Asked again every launch is the
annoyance this exists to end.
Verified by breaking it. The first version of the running-application
test passed with both guards removed, because the search it used returns
nothing under a temporary directory and the loop never ran; it hands the
filter its own bundle now, and removing the guard fails it.
A universal bundle carries both architectures of Chromium and of the
kernel, and every machine runs one of them: 279MB downloaded to use 146.
Split into two jobs and the same release ships 150MB to an Intel Mac and
146MB to an Apple Silicon one, with nothing else given up — the same
targets, the same signing, the same notarization, one bundle each.
It also removes the shape that has now produced two defects in a row.
2.11.0 named a universal app darwin-arm64 because --prepackaged never
reads what is inside the bundle it is handed, and the fix for that had to
teach the packaging step a flag the bundle step already had. Per
architecture there is nothing to infer: the matrix row states electron
-builder's spelling beside the release one, both steps read the same
field, and an artifact is named for what it actually contains.
studio-manifest needed no change — it already keyed a non-universal
darwin archive to its own platform, and keeps handling universal for
anything that still ships one.
Two guards move with it. The artifact count is 7, because two macOS
architectures are four files rather than two. And the update-channel
check names each darwin archive instead of globbing darwin-*: a glob
passes on whichever architecture shipped, and the missing one is exactly
the platform this check exists to refuse a release without.
Verified locally per architecture, as the jobs run it: --x64 produces
dist/mac and is renamed to amd64, --arm64 produces dist/mac-arm64, and
studio-manifest keys darwin-amd64 and darwin-arm64 off their own
archives.
context_soft_limit_tokens has been the economic maintenance boundary since
it was added and there has been no way to set it but editing config.toml.
A declared 1M window folds at 160k under the default, which reads as the
window being ignored — and no screen said otherwise, because the two
bounds are configured separately and only the lower one ever fires.
So the settings carry both, and the number in force beside them. That
number is read from the agent rather than recomputed: min(window x ratio,
soft limit) written twice is how what a user reads stops matching what
happens, and the agent already owns the rule.
Zero and negative are answers, not absences: zero restores the default,
negative turns the boundary off, so the wire carries a pointer and the
field's empty state is the default rather than nothing.
The capability goes on RuntimeSettings, so it is owed to every frontend
that names SessionAPI. serve gets GET/POST /compaction, saving through
rebuildInPlace because boot binds the bounds into every agent it
assembles and a live session would otherwise keep folding where it was
built to. The chat TUI gets it as "/context fold <n>" rather than a
command of its own: the report it extends already prints these numbers,
and the dispatch switch it would have joined is 1083 lines at complexity
276, where one more case costs three budgets.
Both route tables learn /compaction. They match whole path segments, so
/compact does not cover it — the same gap that answered /plan-decision
with index.html.
The compaction setters leave edit.go for edit_compaction.go: the two
bounds are one subject, and that file is 1622 lines over its ceiling
already. It shrinks by both.
CompactionSettings joins wire-parity beside SandboxSettings: the desktop
hand-writes the mirror, and a field the panel cannot read is a threshold
a user sets and never sees applied.
595MB unpacked, and two of the largest pieces are there by default rather
than by need.
Chromium carries 220 locale bundles, 49MB, where the interface draws two
languages. Both spellings are listed because the match is against the file
name and the platforms disagree — macOS names them zh_CN.lproj, Windows
and Linux zh-CN.pak — which the first attempt got wrong in the direction
that says nothing: only en survived, and a Chinese system would have found
Chromium's own surfaces in English.
The kernel carried its symbol table and DWARF, 28% of a 43MB binary, which
a shipped build never reads: a panic's traceback comes from the pclntab,
and -w leaves that alone.
Measured on a real universal build: 595MB to 524MB, with en, zh_CN and
zh_TW kept and the kernel at 61MB from 85MB.
2.11.0 published a universal application as ReasonixStudio-darwin-arm64,
and studio-manifest reads the artifact name: it keyed darwin-arm64 alone
where 2.10.0 keyed both. An Intel Mac asks for darwin-amd64, finds
nothing, and is never offered the update — the package runs there, the
catalog just does not mention it.
The flag was on the bundle build and not on the packaging step. Splitting
the two so notarization can happen between them left --prepackaged to
name the targets, and it never reads what is inside the directory it was
handed, so it used the runner's own architecture.
Verified end to end on a real build: the targets come out
ReasonixStudio-darwin-universal.{zip,dmg} at arch=universal, and
studio-manifest keys darwin-amd64 and darwin-arm64 off the one archive.
The macOS release leg failed at the merge: @electron/universal packages an
x64 app and an arm64 app, walks the Mach-O files present in both, and
refuses one whose bytes are identical — the case it cannot tell from a
mistake. go build -o bin/ produced the runner's own architecture and both
legs got that same file.
The check ahead of it is the one to satisfy: already universal on both
sides is accepted and skipped, so the kernel is lipo'd from an amd64 and
an arm64 build rather than declared an exception through x64ArchFiles,
which would have shipped Apple Silicon a kernel it runs under Rosetta.
CGO_ENABLED=0 because one of the two is always a cross-compile.
Off macOS the command is what it was.
Verified on a real universal build: the merge completes, the bundle's
kernel and window binary are both x86_64+arm64, the SPA is a resource,
the version is injected, and the packaged kernel answers -instance-id.
The standing footer told every reader the Windows package is unsigned and
to click through SmartScreen, because Authenticode was bound to main-v2
when that line was written. It has not been true since 2.10.0: the studio
origin is provisioned, and that release signed the payload and the
installer and verified the chain.
The sentence now reads the pair the build job gates signing on, in the
same run, so it cannot drift from what happened again in either
direction.
131 commits since 2.10.0, and the shell underneath them changed. The
document is what someone reads to decide whether to upgrade, so the
migration path is stated before the features: which installer replaces
what, on each platform.
Same register as 2.10.0 — declarative bullets, one claim each, the
mechanism named and the measurement kept where one exists.
Every Studio release carries GitHub's prerelease flag, so the flag said
nothing about the tag, and the catalog took whatever version the tag
named. A studio-v2.11.0-rc.1 would therefore reach 2.10.0 installs as
their next version. The tag's own grammar decides it now: semver's
prerelease segment marks a candidate, which is still mirrored under its
tag prefix so it can be installed from its manifest and tested.
Two things stood between an installed Wails 2.x and the first Electron build,
and only one of them was the crossing everyone expected.
The installer was started with "runas". That was written for the Wails
installer, which requests admin in its manifest and installs per-machine under
Program Files. The Electron one requests nothing: it defaults to the running
user's profile and raises consent itself only if that user chooses an all-users
install. Elevating it up front puts the whole installer in the consenting
account, where its own default writes that profile — the user who asked for the
update stays on the build they had, and nothing reports it. So the line states
which installer it ships rather than carrying a bool that means "elevated or
refuse": guessing is wrong in both directions now, and the per-user direction
fails silently.
And installing this one would never have replaced the old one. It is
per-machine under Program Files with its own uninstall key; this one is
per-user under a key electron-builder derives from the appId. Both would have
stayed in Add/Remove Programs, both on disk, and only one of them would ever
have been updated again. assets/installer.nsh runs the old uninstaller from
customInstall — through ExecShellWait, because CreateProcess refuses an image
that manifests admin, and in the 32-bit registry view, because studio.nsi never
called SetRegView and wrote through WOW64 redirection into a key electron-
builder's 64-bit view cannot see. A declined prompt leaves the old install
where it was and this one working: a takeover must not fail the install it is
cleaning up after.
Verified by building the installer: the first attempt failed on a warning from
this file, which is how the include proved it is compiled in, and makensis runs
warnings-as-errors in the packaging job. Not driven against a real legacy
install on any machine, which the migration contract now says per platform
rather than as one unexercised crossing.
Every layer read the Reasonix home back out of the environment, so the only
way to bind one was to mutate the process — which is why a second home meant
a second process, and why nothing in internal/boot could run in parallel.
config.Roots is that binding. The storage root table's precedence chain now
runs against it, so a stated home carries state, cache and worktrees along
exactly as REASONIX_HOME does, and a stated home outranks the variable: the
environment is how a host works out which home to use, and once it has, it
says so. Package-level path functions delegate to the zero value, so nothing
unstated changes. A loaded Config remembers its binding, which is how the
code holding a config derives paths without the home threaded beside it.
boot.Options.Home is the input. Everything Build reaches is bound to it:
config load, credentials, the six legacy migrations, plugin packages, LKG,
sessions/archive/stats/memory, skills and hooks, MCP launch, sidecars, and
the permission-rule write path, which fell back to the user-global config
when the workspace root did not resolve.
The effect test asserts it where it has to hold: two homes in one process
with no t.Setenv or t.Chdir, and no write into the home the environment
names. It found the one place that does not hold — the history-search
projection is a single per-process SQLite file under the process cache root
— and names it rather than excluding it quietly. A Controller's own later
re-reads still follow the process; Options.Home says so.
load.go went over budget, so the four retired-key migrations move to
retired_keys.go unchanged; -update then reclaimed 189 lines of file-size
budget. Every baseline number moved down.
prompt.go says what this section is for: "this prefix is identical for
every project and session on the machine and they share one cache entry."
The fingerprint included DenyRoots, which is the workspace root, so every
project on a machine got its own entry and re-ran all eleven probes --
against the stated intent, in the same file that states it.
Measured before assuming. internal/boot's tests take 160s; the temp-dir
cleanup I suspected first accounts for 0.9s of it across 359 removals,
and the CPU profile is 98.96% runtime.cgocall -- not computation at all.
Under it: Process.Wait 26%, reading child pipes 49%, LookPath 9%, and
environment.runOne owning 95% of the LookPath time. Build costs 715ms at
p50 and 86.3s across 141 calls, over half the package, and it is eleven
subprocesses each time because every test is a fresh workspace.
PATH decides what a probe resolves to. The deny roots decide afterwards
whether to run what was resolved, which is a string comparison on a path
-- so the expensive half is a property of the machine and the cheap half
is a property of the workspace. The fingerprint now names PATH, and the
deny is applied to the answer.
Which means an answer has to carry what it resolved to, so another
workspace can check it: ProbeResult gains Path, recorded before the deny
decision and rendered nowhere. An entry is reused only where nothing it
ran sits under this workspace's deny roots, and a result recorded before
Path existed cannot be checked and so is not reused.
Nothing runs that a deny would have stopped. The order in runOne is
unchanged -- resolve, deny, execute -- and a run that refused anything is
released to whoever is waiting but never recorded, because a refusal
belongs to the workspace that made it. That last part is a bug the test
found: publishing through finishProbe also wrote the cache, and the
workspace after the denying one inherited an empty answer.
internal/boot: 160s to 105s. The same profile on the Build-heavy subset
falls from 242s of samples at 399% CPU to 24.6s at 85%, and runOne no
longer appears under exec.Start at all.
Production is the point rather than the tests: opening a project you have
not opened before paid eleven subprocesses at startup, and the disk
snapshot -- keyed the same way -- could never help it.
The number it guards is real: 3,995 of one measured run's 5,577 prompt
tokens were built-in tool schemas, paid by every session before it does
anything. The mechanism was not. A hand-edited const in a test has no
update path and leaves no trace of why it moved, so the only thing a nine
-token documentation improvement could do was trip it -- which is how a
budget stops being a budget and becomes something people route around.
Every other gate in this tree already answers this: repolint and
covergate both record a baseline and take -update, free in the direction
that gives ground and explicit in the direction that takes it. This one
was the last that did not, so it is that now: schema_budget.json,
-update-schema-budget to record, -allow-schema-widen to raise, and the
raise lands as a line in the diff with a reason beside it in the PR.
Recorded at 5590, which is below the 5600 the const carried -- the tree
had already given some back and nobody could see it.
The contract was exercised in all four directions before landing:
shrinking records without a flag; a deliberate growth goes red and names
the largest schemas; -update alone refuses to raise and says by how much;
-update -allow-schema-widen records it.
grep honored .gitignore with no way past it and no way to know it had
been applied. A match in dist/, node_modules/ or any generated tree was
unreachable, and what came back was a bare "no matches" -- from which the
only available reading is "not here", which is a different claim than the
one the search could actually make.
That is the host holding a cause and not passing it on. It knows the
result was filtered; the caller cannot know, and acts on the stronger
statement.
Two phases, in the order the cost argues for. The tracked tree first,
because that is the answer nearly every time and it is the cheap one. The
ignored paths only when that came back empty -- which is exactly when the
unqualified answer is most likely to be acted on wrongly, and the only
time a walk of the build output is worth paying for.
Three answers now instead of two: matches, matches that say they came
from ignored paths, and "nothing here either, so this is absent rather
than filtered". The third is new information -- an empty result that has
looked everywhere is a different fact from one that has not.
Widening widens the ignore rules and nothing else. Confinement is checked
above the switch, because what a caller may not read is not an ignore
rule; the VCS store stays pruned, being history rather than anywhere a
build writes; hidden files stay hidden. Both engines answer alike, which
is why the ignorer's skip order changed rather than its flag count.
The ripgrep flags were verified against ripgrep 14.1.1 by hand -- reaches
dist and node_modules, does not read .git, leaves hidden files hidden --
because the automated check for that engine skips wherever rg is not on
PATH, which includes this host.
Measured, not guessed. Across 17 recorded sessions and 393 real bash
calls: p50 472ms overall, but 46% of calls are ones the host cannot
classify, and those run 957ms against 387ms for the rest. The gap is two
whole-workspace walks -- one before the command, one after -- and a walk
of this repo's 20k entries is ~300ms warm. 570ms of gap, 600ms of walk.
Three things were wrong with paying it.
The after-walk was taken even when nothing could come of it. changed()
refuses a truncated or absent before-scan, so a backgrounded call -- whose
before-scan is deliberately skipped -- walked the tree only to be told
so. Guarded on the before-scan now.
Past the limit the whole thing is loss. scanWorkspace stops at 50k
entries and reports incomplete; changed() then refuses both. So a
workspace above the limit paid two 50k-entry walks per unclassified call
and learned nothing, every time. The task remembers the first one now.
It resets with the task rather than living for the session, which is also
the more correct lifetime: an install is what puts a tree over the limit
and a clean is what takes it back under, and both happen inside one.
And it could not be stopped. scanWorkspace took no context, so pressing
stop killed the command promptly and then waited on an uninterruptible
walk -- which is what "stop does not work" is, on a tree big enough for
the walk to matter. It checks every 512 entries now.
Nothing is filtered and nothing is skipped that could have answered. The
walk still crosses ignored directories on purpose: npm install and a
build write exactly where ignore rules point away, so a filtered walk
would report the biggest writes there are as no change at all. What the
skips give up is observation, which leaves a mutation unproven rather
than mis-proven -- the direction the trust domain requires, since an
incomplete observation never establishes scope.
The limit is a parameter now so a test can reach the over-limit answer
without laying down fifty thousand files, and without a package variable
one test writes while another reads -- the shape of the race fixed two
commits ago.
Checked red first: with the cancellation guard removed the cancelled
walk reports itself complete.
internal/sandbox is a declared sensitive path and its coverage gate has
been red on this branch since before the Electron work -- 73.2% against
a 73.5% floor on CI. Nothing here lowers the floor. What was uncovered
was not obscure: the unified launch plan, the context seam an escape
approval travels on, and the identity an unavailable sandbox refuses
with, all at 0%.
PrepareArgs is worth a test rather than a number. LinuxSandboxed is the
conjunction of three things and decides whether the child is told /tmp
or the host path -- a true where nothing binds points a process at a
directory that is not its own -- so what is asserted is that the flag
and the exported values agree, on whichever platform runs it.
The approver seam is now held to failing closed. A typed nil passes
`!= nil` on an interface, and what it would authorize is a shell command
running unconfined. This is NOT a reachable defect: both setters that
install one -- Agent.SetSandboxEscapeApprover and the constructor -- run
nilutil.IsNil first, deliberately, and no third caller exists. What the
change does is move that guarantee into the package making the promise
instead of leaving it something two callers remember and a third might
not. No reachable behaviour changes.
The refusal test pins what the code comment already claims: the code is
an identity rather than the sentence again, the sentence exists, and the
message ends in it -- so a surface showing one never shows half.
Local coverage crosses the floor (68.1% before; the number is
platform-dependent and lower here than on CI, which is what this has to
be confirmed against). internal/installsource also reports under its
floor on this Windows host and does not on CI, which is the same
platform dependence and is left alone.
TestAMissedNotificationLosesNothing counted wake-ups in a plain int that
the broker's callback writes from the goroutine raising the question.
Ask registers the question under the lock and notifies after releasing
it, which is right -- a host callback held under that mutex would block
every other question -- but it means the poll raise() waits on can
return before the callback has run. The test then read the counter while
that goroutine wrote it.
This is a flake before it is a race: the read could see zero and fail on
its own, with the detector only making it certain. The wake-up is
carried on a channel now, waited for, and the "once" is asserted at the
end as nothing further having arrived.
Reproduced both ways locally under -race: the counter version fails at
-count=4, this one passes.
The .deb gate went red on a package that was correct. The helper was
there, at the path the polkit action names, mode 0755, owned by uid 0 --
which is what --deb-user root asked for and got. What failed was the
assertion: dpkg-deb printed 0/0 and the pattern wanted root/root.
Which one it prints is a property of the machine reading the package,
not of the package, so the check now accepts either. This is the phrase
table the standing rules warn about, in miniature: a judgement matching
the spelling of a thing rather than the thing.
The gate is otherwise doing its job, and the diagnosis was exact because
it prints what it found -- one line named the mode, the owner and the
path, which is what made the difference between a wrong package and a
wrong pattern readable at all.
Pattern checked against that line: both spellings of root pass, and a
runner's own uid still fails.
go test -race has been red on this branch since before the Electron
work: acknowledgeUpdateHealth read updateProbation and commitUpdateHealth
inside the goroutine it starts, and nothing waits for that goroutine, so
a test restoring them at cleanup wrote what a still-running wait was
reading. Three CI jobs -- desktop, desktop-macos and race -- have been
failing on it.
Reading them where the wait is armed is also what they mean. How long
this launch must survive is a decision taken when the acknowledgement is
set up, not whenever the runtime happens to schedule the goroutine; the
old shape made it depend on which came first.
Nothing shared is touched inside the goroutine now, so it may outlive
the test that started it without racing anything.
Reproduced locally before and after: the original fails
TestWindowLostInsideProbationCommitsNothing under -race and the fixed one
passes the package. It was reachable here all along -- the pre-push list
runs the desktop module without -race, which is the one flag that sees
this.
Both from CI, and both are the platforms a Windows host cannot reach.
The .deb never built. fpm wants a project homepage and electron-builder
errors without one, so the Linux leg failed before packaging -- which
also means the gate the previous commit added, the one that reads the
package back with dpkg-deb, has still never run. package.json carries
homepage and author now; author was only a warning, but the maintainer
it would otherwise be guessed from is the .deb's.
The macOS zip ships a blockmap and no option turns it off.
differentialPackage: false is an nsis option and Windows took it, which
is why the Windows leg was green; macPackager builds the mac zip target
with update-info writing hardcoded on, so there is nothing to configure.
canonical-artifact-names.js deletes it instead -- deleted rather than
dropped from the hook's return, because what that return does is name
extra files to publish, while what the release counts is what is in the
directory.
The gate that caught it is the one added for the same reason: a
.blockmap carries the release prefix, so the count that refuses a
partial publish would have counted one and hidden a missing artifact.
It went red on the platform its author could not run.
The previous commit named the .deb reasonix-studio so dpkg would upgrade
an installed Studio rather than hold a second one beside it. That made a
worse bug reachable than the one it fixed: dpkg removes files the old
package owned and the new one does not, so an upgrade would have taken
/usr/lib/reasonix-studio/reasonix-studio-update-helper with it. Linux
self-update would then refuse by name -- correctly, "helper missing" --
on every attempt, forever, on a machine that had it working before.
The path is not a choice. The polkit action annotates exec.path with it,
so authorization is granted for that helper and no other; shipping it
anywhere else is the same as not shipping it. It is also the path the
Wails package owned, so one destination answers both the policy and the
upgrade.
electron-builder places nothing at an absolute system path without fpm
arguments, so deb.fpm carries the helper and the policy, and
packaging/linux-payload.js stages the helper where those arguments look.
It is a no-op off Linux: a helper built for another platform would be
found by fpm and shipped.
--deb-user root is stated rather than left to fpm's default. What fpm
records for -s dir is a property of the machine that ran the build, and
polkit will not execute a helper the invoking user could have written --
a CI runner's uid on that file is a package that installs and then
refuses. Read out of app-builder-lib rather than assumed: electron-builder
passes --deb-compression and nothing about ownership.
The policy moved to desktop/packaging/linux/. It was under
desktop/next/build/, which section 6 deletes when Wails retires -- a file
the new shell needs cannot sit where the old one's removal takes it.
The gate reads a real package. dpkg-deb answers the name, and the
helper's path, mode and owner, and whether the policy is there at all;
both of the failures above are ones that install cleanly and say nothing
until a user asks for an update. It has not run: no .deb has been built
here, and neither has an upgrade over an installed Wails one.
Checked locally: the payload script no-ops on win32, and the helper
cross-builds for linux CGO-free.
The capability existed with one consumer and a note saying the other
would get one when its application owner was defined. This defines it,
which closes the last thing GoToVersion was waiting on.
Three acts, two lines. Releasing the application has nothing to do under
this shell -- what holds it open is the shell and this process inside
it, and nothing short of ending releases that -- so only relaunching and
ending travel. They go as one JSON line each on the pipe the handshake
went out on, because that pipe is the only channel pointing that way:
stdin is the lease, HTTP runs from the shell to here. Nothing is
acknowledged, because both are acts the shell performs by ending, and an
acknowledgement would have to arrive from a process on its way out.
The reader had to carry what it did not read. firstLine took the
handshake and dropped everything after the newline in the same chunk,
which was correct while nothing followed it and would have lost the
first act to how the pipe happened to flush. It now hands the remainder
on, and a test pushes a line split across two chunks.
-studio-app and -studio-app-pid are the other half of what a shell
states. Which file the application runs as and which process holds it
open are both unanswerable here: os.Executable() names this binary,
which sits inside the bundle an update would be replacing. update.At and
update.ApplicationAt are what Here and LocalApplication already did,
with the executable passed rather than read from this process.
A host that cannot name both declares no capability, so no install route
registers -- the same nil-interface gate as before, driven red to check
it: without it an unpackaged launch that named no version at all still
got one.
studioLine moved to internal/update as StudioLine, beside StudioCatalog,
because they answer one question together and both shells now ask it. Its
Members and Launchers stay what the Wails shell installs; the Electron
one reads neither, since every platform it ships takes a channel that
replaces a whole install rather than staging files.
Two Linux findings, one fixed and one not.
The .deb had to be named. electron-builder takes the package name from
package.json's `name` unless told, so it would have published
reasonix-studio-electron -- held beside the installed reasonix-studio
instead of upgrading it, and invisible to the dpkg -S that decides
whether this install belongs to the package, which sends the self-update
down the portable-archive path behind the package manager's back. Read
out of app-builder-lib's own resolution rather than its documentation.
The package still carries no update helper, and that is worse than
absent: Line.InstallDeb runs /usr/lib/reasonix-studio/... under pkexec
and refuses by name without it, and sharing the package name means a
dpkg upgrade REMOVES the helper the Wails package owned. A machine that
upgrades into an Electron build would lose Linux self-update and not get
it back. Recorded in the migration doc rather than fixed: placing files
at absolute system paths needs fpm arguments, and no .deb has been built
or installed here to check one against.
Checked: the act protocol on both sides, the capability gate driven red,
the layout following the stated executable, three-platform vet, and the
suites. Not driven through a real install on any platform.
GoToVersion was the last binding on window.go, and the last place a
capability lived in one shell rather than behind the transport both
speak. The orchestration moves to internal/appupdate, which is the
package whose whole subject is owning the application meeting replacing
it, and POST /update/install starts it.
Progress is pulled, not streamed. Section 5's taxonomy answers this
before any transport is chosen: a frame nobody received costs nothing
the next read does not restore, so it is a projection, and GET
/update/install is its endpoint the way GET /tray/state is the icon's.
An event would owe delivery across the restart the install is itself
causing -- the last thing a working install does is end the process that
would have been streaming it.
The route answers 202 rather than waiting. There is no later answer to
send: an install that took over left nothing to send it with, so what a
client watches is the projection and then the application coming back.
The page had a comment saying a resolved promise meant the install did
not take over; that stopped being true here, and the row now follows the
kernel's phase instead of a local guess.
ApplicationOwner gains a third act. Releasing the application, starting
its successor and ending it are one call in a shell that is its own
process, and three different ones in a shell whose window is a framework
that spawned it -- os.Exit against app.quit(). Fusing them is what this
interface was split to stop, and it was still fused at the end.
Both gates on the route, because they answer different questions.
Owning the application is what makes replacing it this process's
business; the hub's declared Install is which build is being replaced
and where it lives, and it is handed to each call rather than copied
into the capability, so there is no second answer to disagree with.
update.install_running is told apart from every other refusal because
the thing to do about it is different: wait, not fix what was asked.
The catalog guard moved with the code it watches, and said so first: it
scans a directory for update.Options literals, and it failed with "this
guard is no longer watching anything" rather than passing over an empty
one. Every updater still has to name Studio's catalog; a release listed
by one cannot be installed from another.
The Wails shell keeps the three acts and nothing else. Its install.go
was two hundred lines of orchestration and is now forty of platform
answers, and four files that existed to split the apply and deb paths
per platform are gone -- that split lives beside the orchestration now.
Not driven through a real install on any platform: checked at the route,
at the projection, and at the orchestration's own guard. Electron still
declares no ApplicationOwner, so no route registers for it, which is
section 7's point -- a protocol existing is not parity.
The macOS swap asked os.Executable() what it was replacing. That holds
for a single binary that is the application and for nothing else: under
Electron the Go process sits at Contents/Resources/bin inside the very
bundle it would be swapping, and the handle keeping that bundle open
belongs to the framework that spawned it. Two of the three things the
handoff needs were therefore this process's answers to somebody else's
questions.
update.Application carries both -- the bundle to swap, and the process
that must exit before it can be. Neither is inferred. A shell that is its
own executable says so through LocalApplication, which is where that
assumption is now written down rather than spread through the swap; the
Wails shell calls it and behaves exactly as before, which is the point of
the checkpoint.
The third thing stays os.Executable(), and the comment now says why: the
helper that holds LockRepairMutations across the rename has to be a Go
process, and that is this binary under either shell. Conflating it with
the other two is what made the whole set look like one answer.
An unstated application is refused rather than filled in from here.
os.Executable() would have resolved to something, and every later check
-- bundle identifier, codesign, spctl -- would have passed on it, because
they verify the replacement and not the target. The two refusals are
sentinels because a caller tells them apart: nobody stated one, versus
stated a path that is not an app.
apply_darwin.go was one file doing two things, and adding to it put it
over the ceiling, which is the useful kind of pressure. Deciding what is
being swapped now lives in application_darwin.go -- the bundle, its
validation, finding one in an archive, verifying it -- and the handoff
transaction keeps apply_darwin.go. No budget was widened.
GOOS=darwin vet only: the shell cannot be cross-compiled from a Windows
host (CGO/systray, which fails the same way on an unmodified tree), so
the darwin tests here run on CI's macOS leg.
The release line was the first of the three blockers because the other
two follow it: the install path replaces whatever the release produced,
and the updater hands over to whatever the install path put on disk.
electron-builder already wrote canonical names and a packaging job
already checked them on real artifacts; what was missing was the
distribution half -- signing, notarization, and the artifact contract
the publish job refuses a partial release against.
Windows and macOS both package in two steps now, for one reason stated
once: something has to happen to the bundle between them. An Authenticode
signature there, notarization and stapling here. Linux stays one shot
because nothing does. The Wails line split only Windows, which read as a
Windows peculiarity rather than as what it is.
The Windows signing contract names two executables where it named one.
This shell ships the window and the kernel it spawns beside itself, and
signing only the container leaves both unsigned on disk after the
installer around them has already been trusted. Everything else in the
bundle is Chromium's or electron-builder's -- resources/elevate.exe is
the other PE file, written in during the NSIS step -- and stays unsigned
rather than re-signed under our certificate. Both are also verified in
stage two: listing one would let the other be swapped between the stages
and still pass.
Three things the real artifacts said that the config did not.
The version has to be injected. app.getVersion() reads package.json,
which carries 0.0.0 in the tree, so every packaged build would have
stated a version behind every published release and offered itself an
update on each launch -- the same identity trap that already showed up
once as 44.1.0, from the other direction. -c.extraMetadata.version rides
both stages: the app.asar copy is what the shell states, the second is
what the uninstall entry records. Checked: 2.0.0 in the asar, 2.0.0 on
the installer, 2.0.0.0 on the window.
The .blockmap is electron-updater's differential index and it is named
ReasonixStudio-* like everything else. The publish job counts artifacts
under that prefix to refuse a partial release, so it would have counted
one and hidden a missing artifact rather than adding one. Turned off at
the source, and a studio.yml gate fails it if it returns.
The portable archive does not hold a fixed set of executables. It is
packed from the same directory the NSIS target writes elevate.exe into,
so which of them is present depends on target order. The verifier held
"exactly one executable" and would have failed on that rather than on
anything about a signature; it now checks the two this line signs, by
path, and matches their bytes against the signed payload -- which is the
property it was after, and the one an exact count only approximated.
studio-build.sh leaves the release path and stays where it is: studio.yml
still builds the frozen shell with it. It leaves the signing contract's
fingerprint list for the same reason, and electron-builder.yml joins,
because what is packaged and how is now decided there.
Checked on Windows against real artifacts: the --dir / --prepackaged
split, the two executables and their paths, canonical naming, version
injection, and that the archive is packed from the bundle the signatures
go into. Not reachable locally and recorded as unverified in the
migration doc rather than rounded up: macOS notarization and stapling,
the Linux .deb, and both SignPath requests -- whose artifact
configurations live in that console and have to be updated to match
.signpath/artifact-configurations/ before a signed release can pass.
The two places that locate the bundle exclude the universal build's temp
directories. mac-universal-x64-temp and its arm64 twin sit beside
mac-universal holding a single-architecture .app of the same name, and
find does not promise an order across filesystems -- notarizing or
packaging one of those would ship a single-architecture bundle under the
universal name. Sorted and filtered rather than trusted to come out
right; no case where the unfiltered form picks wrong was reproduced,
because find returns sorted entries on the filesystem this was written
on.
Windows was the only platform whose package had ever existed. macOS and
Linux rested on a config file and a cross-compile, which says nothing
about what electron-builder actually writes. All three now package on
their own GitHub-hosted OS and are held to the contract the release
reads.
Four assertions, in the order they can be made:
- no artifact names x64; one names the release architecture
- no electron-updater metadata beside the manifest channel
- the kernel and the SPA are resources rather than archive members,
found rather than asserted at a path the builder chooses
- studio-manifest keys the artifact under the platform the runner is
The last is the end of the contract. It is not that a name parses -- the
parsers accept the packager's own spelling and answer with a key nobody
asks for, which is the failure the rename exists to prevent -- but that
the manifest written from the real directory carries this platform.
Writing that check found a false pass in itself: studio-manifest logs to
stdout and writes latest.json into the directory, so capturing stdout and
grepping it matched the tool's own progress line. It reads the file.
Unsigned on purpose, and the identity discovery that would otherwise fail
the job is disabled on the packaging step alone, so a signed release job
does not inherit it. Signing and notarization are distribution and belong
to the release workflow; their absence is not packaging left unverified.
The Windows leg stays despite having been proven by hand. A contract only
one machine has ever checked is not a gate.
macos-latest is arm64, where the release spelling and the packager's
agree, so the rename hook is exercised by the two x64 legs rather than
there. The macOS bundle layout is recorded rather than asserted, so later
work argues from what was produced instead of from documentation.
This proves artifacts and layout. It is not a substitute for the macOS
probes: nothing here touches LaunchServices, a live multi-process bundle,
or renaming one out from under a running application.
Introduce a host-owned application update capability and route
launch-health acknowledgement through the existing exact repair witness.
This commit does not change platform swap semantics or implement
Electron handoff ownership.
Two invariants, and they are what the shapes here are for:
- shells may acknowledge only that the current launch is healthy;
transaction identity remains owned by repair.UpdateHealthWitness
- update routes exist only when the host owns application replacement;
generic reasonix serve does not acquire desktop self-update capability
AcknowledgeLaunchHealth takes nothing and POST /update/health reads no
body, so the endpoint carries one bit -- this launch satisfied the health
oracle -- rather than a request to commit a named transaction. Which
transaction that is was read at startup, before anything could rewrite
it, which is what makes the acknowledgement unable to retire somebody
else's rollback material.
New returns a nil interface rather than a nil *capability where nothing
owns the application. A typed nil satisfies `opts.Update != nil`, which
would register the routes for a host with no owner and fail nowhere.
ApplicationOwner names the two halves the shell had fused: handOver was
a hub shutdown, a Linux-only relaunch and an exit in one function, and it
now delegates, so the interface has a consumer rather than a plan. The
split is already visible there -- a waiting helper starts the successor
on Windows and macOS, the departing application does on Linux -- which is
the evidence the handoff audit starts from.
The capability sits in the kernel, not the shell module. The update
engine is internal/update now, so a desktop/ package would be out of
reach of the Studio host in cmd/ -- the host could see the engine and
never own it, which is the trap this area already fell into once.
appupdate declares its own Capability rather than returning
serve.UpdateHost, because a kernel package may not import a frontend and
Go's interfaces meet structurally. The nil-interface gate survives that
split, and a test in the shell holds it at the assignment where it now
lives: asserting it inside appupdate stops working the moment New
returns a concrete type, because the comparison there becomes a pointer
comparison a typed nil passes. Checked by breaking it -- the local test
stayed green, the shell's went red naming (*appupdate.capability)(nil).
The shipped Wails host is the first consumer; the Electron Studio host
intentionally does not expose the capability until its application owner
is defined. Its hub wiring gate is deliberately not extended with Update
for the same reason: absent is correct there, not missed.
The health oracle is unchanged -- DOM ready, then two seconds -- and the
shell's own test can no longer assert which version it committed, which
is the boundary working.
/update joins the Wails asset whitelist and the dev proxy because that
shell still ships; it goes with apiPaths in the atomic retirement rather
than bending the canonical route to avoid temporary debt.
Install joins the shell's hub-option parity list while this is open: the
kernel grew that field in the same window, and the guard that exists to
catch a capability never handed to the hub was not watching it.
The gate the previous commit made mandatory failed about a quarter of the
time on Windows, in a different test each run, always as a TempDir
cleanup that could not remove the directory it had just emptied. A
required gate that flaps teaches people to re-run it, so it had to be
understood before anything was built on top of it.
It is not a late writer. Caught in the same command that observed the
failure, the surviving directory holds nothing, and it holds nothing
minutes later, and it then removes cleanly. A handle still open would
have failed differently: Windows refuses to remove an open file at all,
and answers with a sharing violation rather than a non-empty directory.
What is left is teardown that has not settled -- names the filesystem
has yet to drop after their content is gone.
The first attempt refused any directory that still listed something and
waited only on empty ones. That is wrong for the same reason: one run
reported a .REASONIX-RESOURCE-*.EXE.tmp that nothing here writes, which
then disappeared on its own. No single frame separates a leak from
settling. The contract is temporal instead -- the directory a test owns
must become removable within a short bound -- and the leak is content
that outlives the deadline, reported with everything seen on the way
there.
Eligibility stays narrow. Windows only, only the refusal actually
observed, a fixed two-second bound, and a timeout is a failure that
names what survived. ENOTEMPTY is deliberately not matched: it is a
different value here and would never fire. Elsewhere one cleanup gets
one answer.
The decision is tested rather than raced: removal is a parameter, so
content that never becomes removable fails deterministically and names
the sentinel, and a refusal that stops is accepted. A blanket retry
would not pass the first of those.
The eleven files converted are the three packages that demonstrated it;
which test loses the race is arbitrary, so the unit is the package.
No production code and no CI change: 20 consecutive green runs of the
command that job runs, against a measured ~29% failure rate before.
The helper lands in the kernel rather than the shell module. The update
engine moved to internal/update, so tests that need this now sit on the
other side of the module edge and a desktop/ package could not be
reached from there. It imports nothing but the standard library, which
is what the layering rule asks of a utility, and desktop/ still reaches
it through the replace directive.
The desktop module's Go tests have never run in CI. The Studio workflow
builds the Wails shell and stops; the main workflow's suites are all
main-module. So the update engine, its elevated helper, the release-side
tools and both shells were green only where a maintainer happened to run
them -- and that is the code which replaces a running application on
someone's disk.
They run now, on all three platforms, in the job that already installs
the toolchain they need. Spelled out rather than routed through make,
which no `run:` step in this repository uses; the command is the one
`make studio-test` runs, in a job whose working directory is already
desktop/.
The host's dependency closure is checked here too, once: cross-compiling
does not depend on the host, and the point is that living in this module
did not quietly make the binary need a GUI toolchain.
Verified by breaking it rather than by reading it. With an arch mapped
twice in arch.json the studio-manifest gate fails, and with PlatformKey
altered two update-engine tests fail, both under the exact command this
job runs. A workflow line that has never been observed to go red is not
an enforced gate.
Installer, portable archive, disk image and .deb, under the identity and
the artifact names the Wails line shipped -- the update manifest and the
release workflow read those names, so a rename here is a version nobody
is offered.
Nothing packaged worked before this. The shell resolved the kernel and
the SPA relative to its own source directory, which is inside app.asar
once packaged: a child process cannot be spawned out of an archive, and
the kernel serves the page off the filesystem. Both are extraResources
now, and one pure function answers for either layout.
The architecture vocabulary is the substantive part. electron-builder
says x64; a release says amd64, because the updater runs inside a Go
binary and asks runtime.GOARCH who it is. Translating at the packaging
boundary keeps studio-manifest, sign and the updater knowing only the
canonical form, rather than teaching the manifest a second vocabulary it
would then have to keep. The architecture is taken from the Arch value
the hook is handed, not parsed back out of the filename the rename
exists to correct, and an arch nothing maps fails the build: the silent
outcome is an artifact describing a platform no running binary asks for.
A gate in studio-manifest holds the map to its own parsers -- every arch
resolves to exactly one platform key, no two collapse onto one name --
and pins why the rename cannot live there instead: the parsers accept
the packager's spelling perfectly well and answer with a key nobody asks
for, so nothing downstream can notice.
Two corrections carried in with it. minimumSystemVersion was 10.13,
inherited from the Wails metadata where it described what WebKit linked
against; Electron 44 requires macOS 13. And disable-library-validation
is gone: it governs loading third-party frameworks into this process,
not spawning the kernel beside it, which is a nested-code signing
question. Signing and notarization are verified in CI, not here.
No electron-updater metadata: the update channel is the signed manifest,
and a second one is a second answer to which version is current.
Verified on real artifacts, not --dir: the installer, the portable zip
and the blockmap all land as amd64, and the packaged app opens a window,
spawns the kernel out of resources and renders the SPA.
`make studio` now builds the SPA and the kernel and opens the Electron
window. The path it replaces was macOS-only by construction -- a bare Go
binary is not a GUI app to LaunchServices, so the script bundled one
before launching and refused to run anywhere else. Electron's own dev
build is already an .app, so one script serves every platform.
Three things had to be true first, and none of them were.
The window carried no icon at all, so the taskbar drew Electron's. It is
named per platform now: .ico on Windows, where the taskbar reads the
small sizes out of it and a PNG would be one bitmap scaled; a PNG on
Linux; and nothing on macOS, which takes the icon from the bundle and
ignores what a window says.
`build:host` wrote bin/reasonix-studio-host with no extension, which
Windows will not spawn. `go build -o bin/` names the binary after the
package and adds .exe where it belongs.
That failure arrived as "the kernel sent no handshake" twenty seconds
later, because firstLine listened for exit and a program that cannot be
started emits error and never exits. It says what happened now, in
milliseconds. An error a caller must tell apart carries an identity, and
"could not be started" is not "said nothing".
Also fixes a test that could only pass on POSIX: it built an expectation
with a /tmp literal and compared it against path.join, which answers in
backslashes on Windows.
pnpm test 11/11.
A card the run is blocked on could seal itself within 250ms of appearing:
buttons gone, kernel still waiting, and the line underneath claiming the
user had allowed something they never saw. The run then waited for an
answer nobody could give.
Two faults, one cause. Decisions() projected only plan approvals and asks
-- ordinary tool permission was skipped with a comment saying it had its
own owner and its own card -- while the frontend read that list as the
whole set of what is still open and sealed anything missing from it. The
projection is served on /status four times a second, so every ordinary
approval was dropped on the next poll.
Completing the projection is necessary but not sufficient: a snapshot
taken before a prompt existed omits it exactly as a resolved one does, so
sealing on a poll is racy by construction. The kernel already records
every decision it settles on the same ordered stream as the request, and
nothing read it. Sealing now rides that receipt, which also carries the
outcome -- the card says what was decided rather than guessing "allowed
once" for everything it could not name.
Decisions() gains a kind per owner (tool_approval, recovery_approval)
because the kind is how a frontend routes an answer back to the call that
settles it, and the mapping is read off the entry's shape rather than a
label. The four Decision types the desktop re-declares by hand join
wire-parity: a field one side sends and the other cannot read is a card
left answerable over a decision already made.
Effect test at the boundary the frontend actually correlates on: while
the executor is parked, /status must project the same id the
approval_request announced.
go vet, make lint, repolint clean; pnpm typecheck/test (232) and build.
The panel now asks /studio/versions and /studio/pin over HTTP, so the two
Wails bindings behind it are gone rather than kept beside the routes.
Nothing but GoToVersion reaches window.go for a version any more, and the
old shell is the one that adapted -- which is what freezing it means.
Both shells state their own install. The Wails one resolves its layout the
way it always did; the Electron host takes -studio-version, because inside
that bundle the kernel's os.Executable() names the host binary rather than
the application, and a kernel left to guess would answer confidently and
wrongly. The layout stays empty there on purpose: reading a catalog needs
no install path, and inventing one is how a swap lands on the wrong bundle.
Driving the real Electron shell is what caught the identity trap.
app.getVersion() falls back to Electron's own version when the application
has none, so an unpackaged build reported current="44.1.0" -- a Studio that
never shipped, ranked ahead of every published release, with "newer" false
against a catalog it should have been behind. Only a packaged build names
a version now. An unpackaged one declares no install and the routes refuse
by name, which is the true answer for a build that is not a release.
Checked end to end against the real shell: the route answers 200 with the
live catalog when a version is declared, and studio.no_install when it is
not.
The version panel reached window.go.main.App.Versions, which exists in one
shell. GET /studio/versions and POST /studio/pin answer the same questions
over the transport both shells already speak, so the panel stops being a
thing only Wails can render.
What the kernel cannot work out, the shell states: HubOptions.Install
carries which build is running and where it lives. Inside a bundle
os.Executable() names the host binary rather than the application, so a
kernel left to resolve it would answer for the wrong thing -- confidently.
The routes are registered whether or not a shell declared one. Leaving
them off would have let /studio/versions fall through to the hub's default
runtime and answer 503 "no runtime open", which says nothing about the
question asked; studio.no_install says it. "Not a Studio" and "a catalog
that came back empty" are different answers and a panel that folds them
together offers to update a server with no application around it.
Hub splits into an inner form that takes its client, so the catalog can be
answered without a network. What is injectable is how the fetch travels,
never where it lands -- the URL stays a constant, and a test asserts every
fetch went to that constant and no other.
Three guards asked for the rest of the wiring, each covering a place this
would otherwise have gone quiet: the Wails shell's apiPrefixes (or the
route answers index.html), vite's dev proxy (same, under pnpm dev), and
both refusal catalogues (a code with no wording renders as itself). That
they all fired for two new routes is the evidence they are load-bearing.
SetDesktopPinnedVersion put the value on the config object and SaveTo
never wrote it out: the [desktop] renderer emits each neighbouring key by
hand -- close_behavior, tray, check_updates -- and this one was not among
them. So a machine held on an older build was free again by its next
launch, updated back off exactly the release the user had rolled back to,
which is the one thing pinning exists to prevent.
It is written only while held, the way external_opener is: an absent key
means "follow the catalog", and releasing a pin has to travel too or a
machine could never follow it again once held.
Found while testing a route that pins, not by reading the renderer. The
round trip is now a test, and removing the line again fails it.
The version panel lived in the Wails shell: what "newer", "pinned" and
"latest" mean, how the catalog is reached, which build is running. None of
that is a window's business, and a shell that holds it is a shell the next
one cannot inherit from -- the Electron host would have had to write the
same panel again in JavaScript against the same catalog.
update.Hub reads it in the kernel now, from an Install the shell hands in.
That is the part a kernel genuinely cannot work out: inside an Electron
bundle os.Executable() names the host binary rather than the application,
and the version is stamped into the process that spawned it. So the shell
states its identity and its layout, and the kernel decides everything that
follows from them. Layout was already a plain struct -- Here() is one way
to build it, not the only one -- so a shell that resolves its own bundle
reaches the same apply path without a second copy of it.
StudioCatalog is a constant rather than something the Install carries. A
catalog a shell could name is one that could be pointed at another line,
whose entries would offer to update Studio into a different product.
The Wails side keeps its bindings and becomes three lines behind them,
which is what freezing it is supposed to mean: the old shell adapts to the
new arrangement rather than the arrangement bending around it.
The guards move with what they guard. versionRows' tests are now the
kernel's, and the source-scanning check that every Options literal names a
catalog exists on both sides, because after this there are call sites in
both packages and a guard that watched only one would report clean while
the other read whatever it liked.
The same judgement existed three times: validateCLIUpgradeRedirect,
validateOfficialRedirect, and the update package's TrustedRedirect. The
first and third are line-for-line identical apart from an error prefix and
the name of the host predicate; the second is the same rules folded into
one condition. The third had never been wired to a client at all, which is
the shape this kind of duplication fails in -- a copy nobody is looking at
stops being maintained before anyone notices it stopped being called.
internal/redirectguard holds the mechanism: HTTPS only, no credentials, no
port, a hop limit, and a host list the caller names. ErrRefused gives the
refusal an identity so a caller tells one from a transport failure through
errors.Is rather than by reading the sentence.
The host lists stay apart on purpose. Which hosts a download trusts is
that path's own security decision, and that the CLI upgrade and the remote
CLI provisioner both name only GitHub is a fact about where we publish,
not one about them. Merged, a change to either would silently move the
other. Mechanism shared, policy separate.
The layering rule is what had made this impossible: it refused a leaf
importing anything under reasonix/, including another leaf, while its
stated intent is that a utility drags no dependency graph along. A closed
layer reaching inside itself drags nothing, and Go already refuses a cycle.
So the rule now says what it meant -- a leaf may import a leaf -- which is
a sharpening rather than a loosening, and releaseasset can share the guard
instead of keeping the copy the old rule forced it to keep.
redirectguard joins the sensitive list beside netclient: it decides which
host a download may land on, and a new security boundary outside that
governance is one nobody reviews. One declaration, both effects -- the
review gate and a coverage floor, recorded at 92.6%.
Three tests assembled their executor as &agent.Agent{}, and New is where
sink stops being nil -- it falls back to event.Discard. So the first thing
a seeded plan observed dereferenced that nil and panicked, failing
internal/control as a package.
That is what had been holding the coverage gate shut. covergate runs the
tests of every sensitive path, internal/control/approval.go is one of
them, and a package that exits 1 takes the whole gate down with it. A gate
that is always red is not a gate, and its floors stop at whatever they
read on the day it stopped running. With the panic gone they move to what
the code actually covers: sandbox 69.4 -> 73.5, shellparse 63.6 -> 64.5,
shellsafe 59.3 -> 61.1, approval.go 93.9 -> 94.3.
The fix is on the test side because a zero-value Agent is not a state
production reaches -- boot assembles one through New -- so this was a test
stepping around an invariant the constructor holds, not a gap the
constructor should have defended against. Adding a nil check would have
put one at each of the 71 places that dereference the sink today.
The Electron shell's host binary is built from the root module
(cmd/reasonix-studio-host), and Go's internal rule is about import paths,
not modules: nothing outside reasonix/desktop/ can ever import
reasonix/desktop/internal/update. So while the updater lived there, the
shell that is meant to replace Wails could not reach it at all -- not as a
thing to be written later, but as a thing the language forbids. Moving it
is a precondition for the crossing rather than tidying.
It moves whole because it was already independent: 44 files, zero imports
from the desktop module, and the only things it reaches for -- config,
fileutil, installlayout, repair -- are kernel packages it was already
importing across the boundary. The five desktop commands that use it keep
working, since reasonix/desktop/... may import reasonix/internal/...
The move surfaced TrustedRedirect as an orphan, and it is not dead code:
it is the CheckRedirect an update client must use, written with its own
comment saying a redirect could otherwise walk an authenticated download
onto another host, and never referenced. The shells assemble their client
from netclient, which sets no redirect policy, so every catalog read and
artifact download has been following redirects wherever they pointed.
Minisign verification is what stood between that and an installed
artifact, which is why this is a missing layer rather than an open door.
New now guards copies of the clients it is handed -- copies, because the
shells share one client with the rest of the app, and a caller that
already chose a policy keeps it. Recording that orphan in the baseline
instead would have filed an unwired security guard as debt, which is why
the wiring is in this commit rather than after it.
The baseline carries forward under -allow-widen: the repository totals
only tighten, and what enters internal/update is exactly what left
desktop/internal/update -- complexity 42, essay 3, function-size 180.
TrustedRedirect had no test either. It has one per field it reads, because
a guard checked only through one bad hostname would still pass after it
stopped reading the scheme. Checked against the live catalog too: eleven
published versions still resolve with the guard installed.
The previous commit listed SavePluginExport as blocking retirement on the
strength of it being a Wails binding with no Electron counterpart. That
reads the binding rather than the capability. The canonical path already
exists and every client but Wails already takes it: the kernel packs at
GET /plugins/{name}/export, the shell writes the bytes through saveBytes,
and internal/serve/plugins_test.go holds the packing half.
The binding is there because a WKWebView starts no download of its own and
that shell never implemented saveBytes -- a way around its own limitation,
not a semantic the other shell lacks. Which is exactly the distinction
section 7 asks for, applied to the wrong side the first time.
Checked against the real Electron shell rather than read off the source:
the endpoint answers, and the save panel opens over the window.
The picker was reached by feeling for window.go.main.App.PickWorkspace, so
the page knew which shell it was in for exactly one capability, and the
other shell had no picker at all. It is a HostPort verb now, answered three
ways: Electron opens a panel, Wails reaches the binding it already has, a
browser tab says null.
Which workspace the panel opens on is the kernel's answer, read through
/workspaces and handed to the shell, rather than something either shell
remembers. A shell that kept its own copy is the failure section 2 exists
to prevent, and the Wails side reaches the same value through the hub
because it can see it directly -- so it ignores the argument and needs no
change, which is what freezing it is supposed to mean.
createDirectory carries the half that was learned the hard way: a panel
that can only open what exists reads as an app that cannot start a project,
which is what the Wails picker was reported as before its options said
otherwise.
Three answers stay apart -- a path, "" for a dismissed panel, null for a
shell with no picker -- because a caller that folds the last two together
opens the panel twice. host_picker.test.ts holds that across all three
shells and is the first test to reach the bridge at all; a mutation that
hardcodes the starting folder fails it.
The panel blocks until answered, so nothing automated can read what it
returns. That it opens over the window was driven by hand against the real
shell, and smoke.js records the boundary rather than rounding it up.
The migration ledger was a commit behind: single instance was cleared by
"one Studio per data home" and never moved, and two blockers were missing
from it -- SavePluginExport, and the release line that still builds, signs
and notarizes a Wails bundle on three platforms.
mvdan.cc/sh v3.14.0 requires go 1.26.0, so the directive moves with it,
and the directive is what turns modernize's newexpr rule on across the
tree. The 75 findings are the price of this upgrade rather than debt it
inherited: lint reported 0 issues on the same tree one version back.
Inlining the pointer wrappers leaves nine of them with no caller, and
two different gates are needed to see that: unused catches the
unexported ones, repolint's orphan catches exported tool.IntPtr. The
//go:fix inline directives the fixer writes are dropped rather than
kept, because govet reads one as a demand to inline every call site --
which would delete names that carry meaning. provider.TemperaturePtr
tells an explicit 0 from "not set, use the provider default"; new(0.0)
at twelve call sites says neither. Keeping the new(v) body satisfies
modernize without spending the name.
Go: bubbles, bubbletea, lipgloss, x/ansi, lark, go-runewidth,
jsonschema, x/mod, x/net, sqlite, sh; wails 2.13 -> 2.15 in desktop.
Frontend: vite 8 on rolldown, typescript 7, plugin-react 6, vitest,
electron 44.1.0. Both lockfiles carry the linux and windows native
binaries, which CI installs and this machine never does.
Verified past every gate CI runs, plus the two it does not: the Electron
smoke against a real window, and Studio itself built and launched under
wails 2.15.
The boundary classification and the identity re-projection both landed
inside the loop body and pushed it past its size limit. Neither is loop
control: one settles what changed the conversation since the last request,
the other states a fact about the response that just ended.
A macOS self-update is three owners, not two: the old application
authorizes the handoff, the helper performs the swap, and the new
application proves itself healthy and retires the transaction. The
replacing process may perform the swap, but it may not declare the
replacement healthy.
The third owner was lost with the old shell. Nothing live called the
commit path, so a successful swap left pending-update.json and the
rollback bundle behind, and ensureNoPendingUpdate read that residue as
a legal transaction: the first self-update that worked refused every
one after it with "a pending update already exists".
UpdateHealthWitness is that owner, in one place. It reads the pending
transaction once at startup and carries no path or version of its own,
so a caller can only ever acknowledge the update it actually booted
from. The shell reads it before the window runs and acknowledges from
OnDomReady after the probation the old shell used: a build that paints
once and then dies is not evidence that the replacement works, and one
lost inside probation leaves the rollback material untouched.
The terminal tests replay the helper's success path against the real
state machine, so the fix cannot regress into the child clearing the
backup itself.
complete_step calls step_id PREFERRED, and the host has held one for every
item since plan projection. The model was never shown one. todoState never
rides in the prompt, todo_write answered with counts alone, and all three
list renderers printed bare ordinals — so the only place an id ever appeared
was the model's own todo_write arguments. A fold takes that out of view and
the id is gone from the conversation while the host still holds it.
What the model then cites is the ordinal it was shown, which is exactly the
citation that goes stale when a replan inserts a step above it.
evidence.TodoCitation is now the single rendering, used by todo_write's
result, complete_step's inventory, and both incomplete-todo prompts.
The fold carries the ids in its own output rather than leaving them to the
round after it. The request a fold feeds is frozen before that round exists,
so a sign-off sent in it could still only cite an ordinal — the same failure
this change exists to remove, reproduced once per compaction. A fold whose
kept tail still shows the ids appends nothing. Rewind and snip go through
Session.Rewrite instead and are re-projected at the next turn tail.
Compaction never calls Session.Rewrite — it installs a projection and leaves
the canonical transcript alone — so a rewrite-reason trigger would have
tested green against a signal the real fold path never emits.
Not covered, and separate: rebuildTodoState derives canonical todos from the
transcript's todo_write calls, so a resume after a fold that dropped them
starts with no canonical list at all. The identity does not survive there to
be projected.
A response cut at the output limit reached the model as "invalid arguments
for bash; it requires "command"". The host knew the cause — finish_reason
was length — but finishReasonMessage only ever emitted it to the sink, so
the model saw a downstream symptom and attributed it to its own formatting.
The lesson a real session drew from this was to stop batching independent
calls, which is the opposite of what the tree asks for and costs a round
per call.
classifyResponseBoundary runs before the assistant turn reaches the
conversation. A truncation can only have cut the last call, so that is the
only one it drops, and only when its arguments do not parse: earlier calls
were emitted whole and still execute, and a cut that landed after the last
call closed drops nothing. The dropped call neither runs nor enters history,
and the reason rides the turn tail as a host fact.
stopUnexecutedBoundaryCalls is not this: it fires on the round budget and
never reads the usage it is handed, and by the time it runs the assistant
turn is already committed.
malformedArgumentsDetail now quotes the bytes the parser stopped on with a
caret under them. An offset into a payload the model cannot see is what got
'>' blamed for a missing delimiter.
Dialing a machine, installing a kernel on it, holding the link and naming what
went wrong were all owned by the Wails shell. None of that is a window's
business, and the shell that had it is the one being retired — so Electron
could not open a remote workspace at all, while the protocol for the question
such a dial stops on was already canonical. That gap is what the ledger warned
about: making a capability canonical moves it out of one shell without putting
it into the other.
It moves whole rather than being copied. internal/remotehost is the standard
implementation of serve's RemoteAttacher, and both hosts now build the same
one from the same broker. The bridge from a blocked dial to a canonical
question moved with it, because that too was never a window's.
remotehost is classified as a host adapter rather than exempted from layering;
the layering ceiling remains zero. It implements a frontend's driven port and
is assembled only by hosts, which is a position the layer model had no name for
— so it has one now, and the naming carries its own constraint: nothing in the
kernel, the controller or the utility layer may reach a host adapter, and three
cases pin that alongside the two that allow it.
Cleared on the product chain, not on the protocol. A test drives everything but
the SSH: a workspace is opened on another machine over HTTP, the dial stops for
a host key, the client finds that question by polling the operation it named
itself, answers it, the open returns with a pane, and the pane's first frame
arrives through the hub's proxy from the far kernel. That is the whole of 5a
closing, end to end, and it is why the ledger's remote entry moves from blocker
to cleared.
The host is held to its own wiring the way the window is. Remote was missing
here for exactly the reason Tray went missing in the shell: an implementation
that exists and is never handed over costs nothing at compile time. Removing it
now fails a test that reads the options out of the syntax.
Two windows over one data home are two writers for every session file the
sidebar lists, and two writers on one transcript is what forks it into an
endless run of recovery copies. The Wails shell has kept that from happening
since it had a lock; the rule was written into that shell, so the other one
did not have it.
The identity moves out to internal/instanceid, where it is the thing it always
was: a canonicalized data home, not an application. The home is resolved from
an environment and a platform default the kernel owns, and canonicalized
through the same path the session lease uses, so an installed build, a portable
one and a canary find each other and a home reached through a link is the one
it points at. A shell asks for it — `-instance-id` prints it — rather than
working it out from things it does not resolve.
Electron's lock is the mechanism and not the rule. It keys on the profile
directory, so the profile is put under that identity; the platform then answers
"already running" for the same home and stays quiet for a different one,
without either question being asked in JavaScript. The app also names itself
now, because userData hangs off the app name and a name that depended on how
the process was launched put two launches of one install on two locks — which
is how this was found.
Verified against the real shell rather than reasoned about. A second launch
over the same home leaves on its own, and the window it could not open is
raised from wherever it went — hidden to the status icon included. The kernel
the first one is holding still answers afterwards, which is the property that
matters: the hazard was never the second window, it was the second writer. A
launch over another home is allowed to run. And a home whose holder was killed
outright can be opened again, because the lock belongs to the platform and goes
with the process — there is no file of ours left holding a home nobody is in.
The symlink case stays a Go test, where the canonicalization is.
The last commit made the question a remote dial stops for into canonical state,
and that is exactly the kind of result this ledger has to keep from being
misread. Nothing in Electron dials: the host supplies no Remote to its hub, so
attach, browse and probe are still Wails-only. Making the semantics shared moved
a capability out of one shell without putting it into the other, and a reader
counting "remote ask done" toward parity would retire Wails with its remote
workspaces going with it.
So the ledger is two lists now, and the retirement condition says the part that
was implicit: a protocol existing is not parity. The status icon's glyph and a
remembered window position cannot hold a retirement; a shell that cannot reach
another machine can.
A first-seen host key or a locked private key stops a dial until somebody
answers. That question lived on the Wails bus and came back through a binding,
so it was a shell capability rather than a thing the kernel held — and no
second shell could have it without holding a copy.
It is state now, and the shape came from measuring rather than from the sketch
this started as. Two facts decided it. There is no hub-level event stream: every
SSE in this tree belongs to a runtime, and a question raised during attach has
no runtime yet, because the runtime is what the attach is trying to produce.
And a dial runs under the request that asked for it — an experiment establishes
that for both entry points before anything is built on it. So a question cannot
outlive the client that raised it, which settles what a reload is allowed to
recover: nothing. The reload cancels the operation, an empty snapshot
afterwards is the correct answer, and a late reply is told the operation ended.
That leaves the snapshot as a discovery mechanism rather than a recovery one,
and there is nothing to discover except while a request this client started is
still waiting — which the client already knows. So it polls its own dial at
half a second and stops when the request settles. Building a hub event stream
for this would have added a permanent fact channel whose only subscriber is a
modal a person answers in seconds.
Three identities, because each rules out a different mistake. The epoch is the
kernel lifetime: a restart numbering a question the same must not accept a
reply written for the one before. The operation is which dial is blocked: two
at once are two questions, and a client polling its own must not be shown the
other's. The ask id is the question. An answer carries all three, and the
operation is named by the caller in a header because the question can arrive
before the response that would have carried a server-minted one.
Answering the same thing twice succeeds and answering something else does not.
A POST whose response was lost has to be safe to send again, and the client
cannot know whether the first one landed — so a settled question keeps what it
became for the rest of the launch. Cancelled is its own state rather than a
missing one: a client that hears "the operation ended" can stop, while one that
hears "no such question" has to guess whether it ever existed.
No compatibility adapter turned out to be needed. The bus existed because there
was no endpoint, and a plain GET is not what the asset server buffers — so both
shells poll the same route and answer through the same POST, and the bus
channel and its binding are gone rather than bridged.
Fixes a regression the commit before this one shipped: the window's hub was
built without the tray host, because the edit that was supposed to add it
silently matched nothing. Nothing failed at compile time, the settings panel
would have shown no icon section, and the tray menu's own switch would have
reached a nil host. Wired, and a test now reads the options out of the syntax
and fails when the window implements a capability it does not hand over.
A JSONL file does not say which definitions produced it, and samples are
comparable only while those hold still. Version 1 now names them and where
they live, so a change to any one is a version bump rather than an in-place
edit that leaves two months of samples silently incomparable.
The first corpus audits these definitions, not the agent. Whether the
text-identity fallback reports renames as replans is the question it has to
settle first, and now is the cheapest moment to find out: no execution
decision reads a progress revision yet.
What a strategy change bought is a question the transition stream already
answers, and answering it in a scratchpad after the first corpus arrives is
how the analysis logic ends up outside the tree again. --replan-episodes
writes one object per replan: where it happened, what resolved it, and what
was spent in between.
The resolution is read from the resolving transition's own kind, so terminal
is not folded into advance the way a progress revision folds them — "the plan
finished" and "the plan moved one step" are different answers. A rewrite does
not resolve anything, so restating the steps after changing them stays inside
the interval, which is precisely the interval worth measuring. An episode
still open when the sample ends is written as "end" rather than dropped: a
replan nothing ever followed is the case a survivor-only view loses, and it is
likely the most informative one.
Semantic samples only. The proxy source carries no replan verdict, so there is
nothing to segment on and inferring one would put a made-up classification
into a corpus meant to calibrate against real ones.
This is a segmentation, not a statistic. No rate is aggregated and no rate is
called healthy — and before any of them mean anything, a corpus has to settle
whether the text-identity fallback reports renames as replans. The README says
which ratio to look at first.
The analyzer that reads todo_progress back out of a trajectory was a scratch
script, which made it the one link in the chain with no way to fail. A field
rename in the kernel, the wire, or the renderer would have left it reading
every later session as having no semantic progress at all — and a corpus
collected across that change would mix two definitions in one quantile.
tools/trajectory/progress_corpus.py, standard library only, with the
distinction that matters carried as data rather than as a printed note:
progress_source and progress_fidelity are fields of every sample. The
semantic source is the todo_progress frame; the proxy source counts
complete_step calls, which is what an export predating that frame offers and
which overcounts, because a sign-off on an already complete step advances
nothing. --require-semantic refuses the downgrade, so a corpus meant to
calibrate policy cannot silently absorb a proxy sample.
Removing false advances can only merge adjacent gaps, so the proxy's maximum
gap is a lower bound on the semantic one; its quantiles and event counts are
not, and the README says which is which. On the session behind this work that
bound is 348.81M tokens and 739 minutes between two host-observed advances,
stated in resources rather than rounds.
The tool measures and does not judge: no verdict, no recommended budget, no
score. Reported numbers are descriptive.
A todo line that stops parsing raises rather than reading as absent, and CI
runs the tests, because the failure this guards against is exactly the silent
one that produced three missing-frame defects in a row.
Every rule here was demonstrated by a commit before it was written down, which
is the only reason to write it now rather than at the end: a constitution
drafted after the retirement would be an explanation of decisions already made.
The one that earns the document is the state taxonomy. Durable intent, a
projection, and state whose loss changes execution are three different things
carried three different ways, and deciding which one a capability is has to
come before deciding its API. The status icon supplied both easy cases in the
same commit — prefs in a file, a fold recomputed on every read — and a pending
question put to the user is the hard one, which is what the next commit has to
model.
Also written down: that retirement is atomic, and that what allows one is the
absence of exclusive semantics rather than the presence of a replacement.
Both came out of an attempt that this rule would have stopped — remote_pump.go
looked redundant against the hub's proxy until the page's own transport choice
said otherwise.
The ledger at the end is deliberately a ledger and not a plan. An item leaves
it when its behaviour is reproduced and checked at the layer where the old one
broke, not when something resembling it exists.
The tray was the last thing a shell could only do by being in the kernel's
process. Its settings were read and written through config.LoadForEdit from a
Wails binding, and its fold came off a Tracker the same process was feeding, so
a second shell could not have any of it without keeping a second copy. Three
routes end that: the prefs a person set, the fold the icon paints, and one
place that writes either.
The two are not the same kind of thing, and are carried differently on
purpose. Prefs are durable intent and live in the config file, so a write
answers with what it became and the caller applies that rather than waiting for
a poll. The fold is a projection of what the panes are doing — recomputed on
every read, losing one costs nothing the next read does not restore — so it is
pulled, and gets no id, no replay and no lifecycle. Promoting it to an event
would make a rendering detail into a fact the stream has to guarantee.
The fold arrives spelled out. The sentence and the three menu words are built
where the numbers and the catalogue both are, in the language the desktop is
set to, so a shell painting an icon does not also carry a second copy of the
formatting or a second answer for the language. The Wails tray now says its
line through the same function.
Backgrounding keeps the rule it had: no icon, no hiding. A window that vanished
with nothing left to bring it back is the one state this must never produce, so
the clamp lives with the write instead of in each shell, and the tray menu's
own switch runs the same call the settings panel does.
Electron's main process reaches these directly. A tray that asked the page for
its state would put the credential within reach of the page for no reason, and
would go blank the moment the window was hidden — which is exactly when the
icon is the only surface left. The close button reads a cached preference so it
never waits on a round trip, and still answers correctly once the kernel has
gone.
Held by the tests that would have let the old shape through: a hub with no
window registers none of these routes, so a browser tab reads the same null it
always did; a write round-trips through the file rather than through memory;
backgrounding is refused both when no icon is asked for and when this launch
has none; two reads of the fold answer differently, so an implementation that
cached it fails; and the endpoints sit behind the loopback boundary even under
auth_mode = "none", refusing an absent credential and an originless write.
The shell not owning the state is pinned by a check rather than by an
intention: the icon's line is held against a fresh read from the kernel, and
giving the tray a counter of its own turns it red at once —
{"icon":"Idle","kernel":"没有在跑的"}.
Also fixes a modernize finding left in the remote resume test by the commit
before this one.
todo_progress was emitted by the kernel, encoded onto the wire, and dropped
before the session log — so a reopened window and an exported trajectory
both showed a turn whose plan state never moved. Nothing else in the log
carries it: the task list rides the tool frames, but the verdict on what a
write did to it does not.
That is the third frame to go missing the same way, so the list stops being
the only statement. Every wire kind is now named by wireLogKinds or by
wireLogSkipped, and a test walks eventwire.KindNames() to fail the one that
is in neither. A frame left out on purpose reads as a decision; a frame left
out by omission no longer reads as anything at all.
The host held one fact about its task list: that it changed. A turn that
rewrote the same two steps every round therefore produced the same signal
as one that finished them, and the session behind this work spent twelve
hours and 1065 task-list writes at a fixed 22 completed steps.
ClassifyTodoTransition reads what a write did from the lists themselves —
which step identities exist, and which are complete. Identity is a stable
step_id where the list carries one and the normalized text where it does
not, never position, so an insertion does not rename every step after it.
Six verdicts follow, and three counters move independently: content for a
write, plan for a change of steps, progress for execution moving.
Replan is deliberately not advancement. Counting it as such would let a
stalled turn renew itself by rewriting its own list, which is the loop this
distinction exists to make visible. Renewal — a sign-off landing on a step
already complete — is named by advanceCanonicalTodo rather than by the
classifier: the list does not move, so no comparison of it could find one.
This records the distinction and nothing else. No gate reads the counters,
no prompt byte moves with them, and no call is refused because of one.
The verdict crosses the wire, because the last runaway could only be read
from an exported trajectory. eventwire and the desktop's hand-written
mirror both carry it, and both are now held to the kernel's shape by
wire-parity — which, added for this, immediately reported that PR 280dfd8c9
had introduced the same defect: the maintenance code and boundary existed
in the kernel, were declared by the desktop, and were dropped in between.
That is fixed here with the pairing that would have caught it.
advanceCanonicalTodo moves to todo_state.go, where the canonical list lives.
Four things the window can do and a page cannot, wired so the kernel looks
exactly as it did from the renderer: no endpoint added, no event kind added,
no change to what any request or stream carries.
A dropped file arrives as a path again. The renderer is handed a File and
never a location, and a turn that has to work on the file itself cannot do it
on a copy of the bytes, so the preload resolves it through webUtils and the
page feeds the result to the router it already had -- the same second channel
Wails reports on, arriving at once here rather than after a grace period.
Saving text and exporting a plugin package both go through the shell's own
dialog. The archive comes from the endpoint the browser build already uses, so
this adds a place to put the bytes rather than a way to produce them; the
anchor download stays for a tab, which is the only client that has one. A
dismissed dialog answers with an empty path, which is a decision rather than a
failure, and the caller can tell it apart from a shell that has no dialog at
all.
Notifications come back by reusing internal/notify against the shared
[notifications] setting. A window that announced turns the CLI would have kept
quiet about would be a second policy nobody set.
The port layer got the rest of its escapes back. saveText and openExternal
were reaching window.go.main.App from inside the transport, which is the same
thing the four window controls were doing before the last commit; both now ask
HostPort, and the Wails adapter is where its bindings are named. Nothing in
the app asks which shell it is in.
The drop is verified rather than reasoned about: the smoke run dispatches a
real drag carrying a real file at the browser's input layer and asserts the
page resolved it to that exact path, and that the window did not navigate to
it -- the default that replaces the app with whatever was dropped, with no way
back short of a reload.
The tray and close-to-background are not here, and not because they were
skipped. Their state is a Tracker fed by sink decoration and a job count read
off each controller, and their settings are read and written through
config.LoadForEdit; all of that lives in this process, and nothing on the wire
carries any of it. trayPrefs and setTrayPrefs are already AgentPort methods
that happen to be answered by a binding -- the same shape remote ask has, and
the same fix: they become protocol before they become a second shell's
feature. Building them on a preference store of this shell's own would give
two shells two answers for one setting.
TaskBudget has carried three axes since the round counters came down, and
run_budget.go calls the token one "the one that generalizes — a slow
expensive loop accumulates them and so does a fast empty one". It was the
only axis boot never mapped, and no key set it: the axis existed, was
enforced, and could not be turned on.
task_token_budget completes the set beside task_cost_budget and
task_time_budget_minutes, mapping to the same TaskBudget.Tokens that
exceeded() already reads. It ships off, like the other two.
What the axis counts is the reason it generalizes, so a test pins it:
usage.PromptTokens includes CacheHitTokens, so a round serving 159,900 of
its 160,000 prompt tokens from cache still accumulates 160,000. The session
that motivated this served 393M of 393.7M input tokens from cache at a
99.9% hit rate; scored on cache misses instead, any ceiling would have let
it run forever. Cost may account for cache pricing — tokens measure the
work the model did.
The budget spans a task, not a turn: a second test pins accumulation across
a continuation, because a ceiling the next user message reset would not
have bounded that session either.
This exposes the token axis TaskBudget already enforces and nothing more.
It does not choose a default execution ceiling or reintroduce
progress-based stopping.
Baseline widened by two lines: one field, one comment.
A pane that drops mid-turn comes back saying where it got to, and only the far
kernel can act on that. The point travels in a header the whole way, so a
proxy that dropped it would cost the reconnect every frame of the turn so far
-- and the loss would read as a quiet turn rather than as an error, which is
the one failure the numbering exists to make visible.
Two connections, because the point is per-request: a proxy that cached the
first would pass a single-connection test and lose every later resume. The far
side reads it with the kernel's own lastEventID, so what the proxy forwards is
held to the form that actually resumes a stream rather than to a header name.
It holds its connection open while the client reads, so the frame numbered
from that point is one that streamed through rather than one released when the
handler returned. Deleting the forward from the proxy fails it.
This was meant to be the evidence under deleting remote_pump.go, and it is
not, because that file turned out not to be redundant. The page decides its
transport by whether a Wails bus is on the window, not by whether a pane is
local: portFor builds the same SsePort for every pane, and subscribe takes the
bus branch for all of them. So inside that shell a remote pane's frames arrive
only through pumpRemoteEvents; the proxy's stream is what a page reaching
/rt/<id>/events over real HTTP gets, and the page in that shell never asks.
Removing it would have taken SSH workspaces down in the only shell that
currently has a tray, an updater and a single-instance lock.
What the test does establish is the condition for removing it later: the
resume cursor that file carries by hand is already carried by the proxy path,
proven rather than assumed. Its doc now says it retires with the bus rather
than before it, which is the same rule the rest of this migration runs on --
an implementation goes when it has no semantics of its own left, not when a
replacement exists somewhere.
A turn could be checkpointed once. After that the fold region ended at the
turn's first message and lastTurn refused a second attempt, so a turn long
enough to reach the boundary twice carried every round it ever ran: the
session that motivated this held 321K tokens across 2150 rounds inside one
of them, and the first fold could only reach the 30K that preceded it.
What a checkpoint may now reach is the turn's closed transactions. It ends
at closedPrefixEnd — the last point where every call the turn issued has
its result — so a transaction in flight is never split, and the request
that began the turn is held verbatim by keepIndexes rather than folded into
a digest. Both were what the old boundary protected by refusing everything.
lastTurn is gone. "Already folded this turn" was an implementation limit,
not a fact; the fact is whether a fold reaches history a checkpoint does
not already hold, which ContextProjection.CoveredCount already recorded.
Two conditions replace it: a region inside the installed projection is
no_new_closed_prefix, and one that adds less than a tail's worth of new
closed history is below the economics of paying for the prefix cache again.
Two ceilings stop ending runs on our own arithmetic. A fold that lands
above the trigger, and a generation blocked from folding again, both used
to fail the turn when the estimate exceeded the input ceiling. Only a
provider that has actually refused does that now — the estimate is ours,
the limit is theirs, and a window too small to summarize one turn was
failing runs the provider would have served.
TestCacheHitSurvivesTooSmallWindow measured a fold count against a
mechanism that allowed one per turn. Under a 900-token window the new
behaviour folds four times over thirty rounds and ends at 1577 prompt
tokens where it previously folded twice and ended at 2069 — fewer tokens
carried, more cache rebuilt, tail hit rate 94% against 96%. The bound is
now a fraction of the step count, which is what "not after every tool
result" meant; the tail-rate assertion that judges recovery is unchanged.
Repeated checkpoints bound provider-visible context growth inside a long
active turn. They do not decide whether that turn is still making progress
or should continue executing.
Chromium runs the page now. The shell spawns the kernel, reads one line of
handshake, arms the credential and loads the window from the origin it was
given -- the same frontend-next build the Wails shell serves, over the same
hub, through the gate the last two commits put in front of it.
The page lands on /_studio/. Vite already builds with base "./" while every
call to the kernel is an absolute path, so one prefix test routes the whole
thing: the page owns its namespace and the hub keeps the root. The inverse --
knowing every route the kernel has, so the rest can fall through to the page
-- is what the asset server forced, and it has to be edited every time the
kernel grows an endpoint. Nothing here needs editing again.
The two pipes carry the two halves of the relationship. stdout is protocol:
one versioned JSON line, and a parent that does not recognise the version
refuses the launch rather than reading past it. Every log goes to stderr, so
nothing can appear ahead of the line the parent is parsing. stdin is the
lease: the kernel drains when the parent's end closes, which covers a parent
that crashed as well as one that quit, on all three platforms, with no signal
involved. Only a pipe counts -- a terminal would never end and /dev/null would
end at once, taking the host with it.
The credential is set on the session before anything is loaded, or the first
request answers 403 and the window opens on a refusal. HttpOnly because
nothing in the page reads it, Strict because no cross-site navigation carries
it, host-only and unexpiring so it dies with the launch. The renderer is
sandboxed with context isolation and no node, and the preload hands over verbs
only: the credential never crosses it, and neither does the origin.
Which shell the page is in stops being something it works out for itself. The
four window controls had been reaching window.go.main.App directly, past the
port layer, and the entry point probed the Wails runtime to decide what the
chrome should look like. Both now ask HostPort, which also answers whether the
window draws its own title bar -- the shell knows how it created its window,
and the page was deriving it from a platform. The CSS follows: keyed on that
property rather than on a shell's name, and emitting both drag spellings,
since --wails-draggable and -webkit-app-region are different mechanisms that
ignore each other. openExternal moves there too, because denying window.open
without it would have left every link in the window dead.
Verified against the real thing, not a template: 25 checks drive the shell the
smoke test boots, and the page reports back that it loaded from 127.0.0.1 on
/_studio/, that the bridge holds eight verbs and no secret, that
document.cookie cannot see the credential, that require, process and module
are all undefined, that React mounted, and that a fetch to /status comes back
as the kernel's JSON. Navigation off the origin is refused, a second window is
never opened, the window fits its display, and an editing command reaches the
focused field.
The hop a test cannot reach was accepted by hand instead, driven at the OS
event layer rather than through Electron's own injection -- MenuItem.click()
runs the JS handler a role does not have, and sendInputEvent lands below the
dispatch that consults the menu. Select All, Copy, Paste, Cut, Undo and Redo
all behave on a real field, and a real secondary click pops this shell's
context menu with Undo and Redo greyed and the rest live: its Copy reaches
the system pasteboard and its Paste reaches the field.
What that does not show is any dependence on the application menu. With the
menu removed, Chromium 152 still serves all six shortcuts, so no causal claim
is made for it here. It stays because a macOS window is expected to carry one,
not because this build was measured needing it. The smoke run says where the
automated part stops.
Deliberately still Wails': the tray, close-to-background, single instance, the
updater, version pinning, plugin export, remote prompts, dropped file paths,
and the shadow event bus. Deleting those before two shells have run the same
frontend would be deleting them on an argument rather than on evidence.
complete_step ended every sign-off with "The host advanced the task list;
continue with the next step", which it asserted without knowing whether a
step remained. The host had already advanced the list and knew the answer;
the tool result — the only part the model reads — did not carry it. #8816
is that turn: the final todo completed, the result still asked for the next
step, the model invented one, and the turn did not end.
The verdict now comes from the task list. planAdvanceAfter replays
evidence.AdvanceSerialTodo on a copy of the same list verifyTodoStep
matched against, so what the result says and what the list does cannot
drift — the alternative was a second implementation of the state machine,
which is the drift. A remaining step is named; none leaves "Every step in
the task list is now complete. Do not open new work under this plan."
Two layers, because the host being right about its own state was never the
problem: the tool unit test covers mid-plan, final, and renewal sign-offs,
and an end-to-end turn asserts what actually lands on the transcript. A
third pins the resume path — a rebuilt finished plan must not come back
with a current item (#8089).
Not done here: a finished plan can still be reopened by an ordinary
todo_write. Refusing that needs somewhere for genuinely owed work to go —
a host-issued obligation — and a refusal without one is the completion
deadlock in the other direction.
Compaction was not broken. It faithfully executed a policy that only knew
one boundary: compact_ratio of the declared window. A provider preset
declaring a 1,000,000-token window therefore put the trigger at 850,000,
and a real session ran 2,150 rounds against a prompt that peaked at
321,265 — 37.8% of the budget, so maintenance was never once attempted.
input avg round latency
86K 12.18s
130K 15.16s
158K 20.25s
195K 28.17s
226K 30.28s
314K 37.91s
Capacity and economics are different boundaries. A model may support a 1M
context without making a 300K prompt economical to replay for hundreds of
agent rounds, so the automatic trigger is now whichever is reached first:
compact_ratio of the window, or context_soft_limit_tokens of visible input
(default 160000, negative disables). The verbatim tail follows the boundary
in force — a tail that reaches the trigger leaves nothing to fold, which
would have made the new boundary fire against a prompt it could not reduce.
A ratio placed past the window stays how automatic maintenance is turned
off: economics tightens a live boundary and never revives a retired one, so
an existing compact_ratio = 2 keeps meaning what it meant.
Every attempt now says what it decided. A no-fold verdict carries a code
rather than a sentence, because "already folded this turn" and "nothing
left to fold" read alike and mean opposite things about what the next round
can expect. The verdict reached while a turn is running was previously
returned silently, which is why the trajectory of that 15.6-hour session
cannot say whether maintenance was tried; it now emits once per turn per
reason, writes no receipt and blocks nothing.
This does not make long active turns repeatedly compactable. It makes the
first economic maintenance attempt observable; repeated same-turn
maintenance remains intentionally blocked, and
TestSecondMaintenanceInOneTurnReportsAlreadyCompacted is the regression
test that keeps that fact visible.
Three comments promising an adaptive stop that 21638e356 removed are
corrected to say what actually bounds a turn today: nothing, by default.
Baseline widened by three lines — the floor price of one config setting
(field, one-line comment, render line).
golang.org/x/crypto v0.54.0 does not enforce the source-address critical
option for non-public-key auth callbacks in its ssh package. govulncheck
traces the call through sshtest.Server.handleConn, so the scan fails on
every branch until both modules move to v0.55.0.
The kernel registers POST /plan-decision and sse.ts calls it, but neither
the shell's API table nor the dev proxy learned about it, so the endpoint
answered with index.html instead of JSON: plan decisions did nothing in the
desktop app, and `pnpm dev` against a live kernel behaved the same way.
/plan is not a prefix that covers it — both tables match the whole path
segment, which is what the three parity tests check.
The kernel has only ever reached Studio through the Wails asset server, as
in-process middleware. This is the first host that puts the whole hub on a
socket: panes, routes and event streams over ordinary HTTP, reachable by
anything that can speak it, which is what a renderer in its own process
needs and what the asset server cannot give. It also drops the one thing
that transport forced -- a response held until its handler returns, so
GET /events never delivered a byte inside the shell -- and with it the
reason the bus and its manual replay exist.
The startup order is the boundary. The socket binds first, because nothing
can name the origin until the kernel owns a port; the credential is minted
from crypto/rand at that moment, never read and never persisted, so no
configuration reaches it; the gate is built from both and wrapped around
hub.Handler() rather than into it, leaving the hub's own auth and CSRF
middleware exactly as they were.
That last part turned out to be load-bearing. Both gates read one cookie,
so a user with auth_mode = "token" in their serve config would have had the
hub refuse this launch's credential on every request -- 401, everything,
forever. hostServeConfig hands the hub the user's serve settings with the
authentication taken out, because Studio's boundary is the loopback gate
and two gates cannot own one cookie. Forwarded headers go with it: this
boundary decides on the address it was reached at, never on a claim about
it. Studio's security policy is not the user's serve policy, and the test
that says so is a regression gate rather than a sentence in a comment.
The handshake goes down the parent's pipe as one JSON line. A file or an
environment variable would outlive the launch and be readable by more than
the process that spawned it.
Serving reuses the hub's own drain rather than reimplementing it, which is
what RunGracefulHandler is for: a host that must wrap the handler should
not have to rewrite the shutdown to do it. The socket is released before
the panes are torn down, so nothing answers a live request with a
half-closed kernel.
Seven gates hold it, all against a real listener and a real hub: loopback
only, a fresh credential per launch, auth_mode = "none" still refusing,
the configured serve token refused while the launch credential works, a
read and a turn both reaching the kernel, the stream still flushing, and
the port refusing connections once the drain returns. Four are
mutation-checked. The binary was also driven by hand: a real boot answers
/status at 200, refuses an absent credential, a rebound Host, a foreign
Origin and an originless write, and opens the stream immediately.
Nothing renders yet -- no static assets are served here on purpose. "API
is a growing whitelist, everything else is the page" is the shape the
asset server forced, and repeating it in a real mux would carry the tax
across the move. Where the page lands is its own decision.
The three exported functions the previous commit left with only test
callers are now called by this host, and the orphan ratchet is back where
it was.
Studio's API has never been on a socket. The Wails asset server mounts
hub.Handler() as in-process middleware -- no port, no CORS, no second
transport -- and a renderer living in its own process cannot reach that.
Moving the shell puts the whole control plane behind a real listener for
the first time: /submit, /approve, /tool-approval-mode, /sandbox and
/permissions, on a port any local process can open a socket to, with
ServeConfig.AuthMode defaulting to "none".
NewLoopbackGate is the boundary that has to exist before that listener
does. Its policy comes from the host that opened the listener and never
from configuration, so nothing a user can edit turns it off: a request
must be addressed to this listener, must name this listener as its origin
to change anything, and must carry the credential minted for this launch.
A read may still arrive without an origin, because a top-level navigation
sends none; a mutation may not, so an originless POST is refused rather
than left to the credential alone. Checks run outermost first, so a caller
that reached the socket under another name learns nothing about the
credential it did not have to present. Each refusal carries its own code
because the invariants are independent -- a test that only sees "rejected"
cannot tell a rebinding defense that held from a credential check that
answered first -- and all four are mutation-checked rather than assumed.
Cookie only. The query token the web gate also accepts would put the
credential into request lines, history and referrers; the host sets
TokenCookie itself instead, so it never reaches the page's JavaScript.
setAuthCookie's Lax policy is deliberately untouched: web serve's login
semantics do not move for this, and a host that sets the cookie itself
chooses its own SameSite. ListenLoopback is spelled out rather than ":0"
or "localhost:0" so no caller can widen the socket to a wildcard address
or to a name a resolver gets to answer for.
The gate never decorates the ResponseWriter. /events asserts http.Flusher
on it, and a wrapper that dropped Flush would turn every stream into a
response the page sees only once the turn is over -- so the resume test
drives a real listener and a real kernel, disconnects mid-stream and comes
back with Last-Event-ID, rather than asserting a header was forwarded.
Nothing calls it yet; the host that opens the listener follows. Until it
lands the orphan ratchet reads three exported functions with only test
callers, and the baseline is not widened to hide that.
The profile's allow-default second line leaves Apple Events and
Mach-service lookup undenied, and the probes that would have settled
whether a delegation reaches outside the sandbox were inconclusive rather
than blocked. Recording it as undenied-and-unmeasured keeps two readings
out of the tree: that no deny rule means an escape exists, and that an
inconclusive probe means the sandbox stopped it.
It is outside the governed endpoint set, so it does not reopen the
authority work -- the claim was written narrowly enough to survive it.
The evidence is that one dimension's enforcement cannot be assumed to
cover another -- a write restriction is not confidentiality, a read
restriction is not authority, a network restriction is not authority.
That supports modelling them apart; it is not the pairwise independence
proof the previous wording could be read as claiming. Integrity also
predates this round, so "each became necessary after a measured bypass"
overstated the history.
HostProtected was one word covering three independent things, and this
round found each of them by measuring a bypass of another: a read deny on
a socket did not stop a connect, and a private key was unreadable while
the agent signed with it anyway.
Expressed by, not held by: a config field states an intent, a backend
primitive plus its evidence is what holds a claim. That distinction is
the whole point of the closing sentence -- three false-assurance bugs in
this round (an absent directory reading as a denial, a truncated probe
reading as over-blocking, a single-GOOS lint reading as clean) cost more
than any missing protection did.
CI's lint runs golangci-lint for linux, darwin and windows; a local run
only covers the host, so `grantedAuthorityEndpoints is unused` surfaced
only after the push. Re-granting after a blanket denial is a Seatbelt
need -- SBPL takes the last match -- while --unshare-net never touched
pathname sockets, so Linux has nothing to re-grant. The helper now lives
with the backend that needs it, and endpoint resolution moved behind
!windows where a backend exists to act on it.
Also adds the two attacks the authority contract rests on, each with the
control that proves the probe can see anything at all:
Provenance: a repository .env sets DOCKER_HOST and SSH_AUTH_SOCK to a
socket it owns. If the resolver followed it, granting "docker" would
grant an arbitrary host socket instead of the daemon. The host endpoint
must still appear in the generated argv (or the test proves nothing) and
the attacker's must not.
Inheritance: a connection opened before the sandbox starts is already
past every rule about opening one. The scan is validated unconfined with
the descriptor handed over deliberately, then must come back empty from
the confined process.
`network` was carrying two unrelated capabilities. Measured on Seatbelt:
`(deny network*)` blocks Unix sockets too, so a user who turned off egress
also lost ssh-agent -- `ssh-add -l` and SSH-signed commits stop working,
neither of which needs the internet. In the other direction the default
`network = true` handed every command the host's agent socket, and a probe
confirmed what that is worth: with the private key deleted from disk, a
confined shell still got the host agent to produce a valid signature.
Denying the read (ForbidReadRoots) does nothing about it, because
possession of a key and the authority to use it are different things.
Spec now carries HostAuthorities alongside Network. Policy names services,
not paths: each backend resolves ssh_agent/docker/podman the way their own
clients do (SSH_AUTH_SOCK, DOCKER_HOST, XDG_RUNTIME_DIR), so a non-default
daemon is governed rather than quietly ungoverned, and Windows can satisfy
the same contract without owning a Unix socket. macOS masks with
network-outbound rules ordered deny-then-grant, since SBPL takes the last
match and a grant has to survive a blanket denial. Linux binds /dev/null
over the endpoint, last, so no later mount re-exposes it; --unshare-net
misses pathname sockets and --unshare-ipc is the wrong tool entirely.
Default grants ssh_agent and nothing else: signing is routine in a coding
workflow, a container daemon socket is host-wide execution. The zero Spec
grants nothing, so a new call site is confined until it says otherwise --
which is why MCP servers now get no host authority. The key is user-global
like [secrets]: a cloned repo must not grant itself the docker socket.
The acceptance matrix is the point, and its third row decides whether the
axes really came apart: egress denied while the agent still answers. Also
pinned: the governed set is three named endpoints. DBus, Wayland, X11,
gpg-agent, keyrings and arbitrary sockets remain reachable, so the claim
is "these endpoints are governed", never "host IPC is isolated".
Linux CI failed the two positive cases and the opt-out control: bubblewrap
swaps /tmp for a fresh tmpfs, so a t.TempDir() HOME does not exist inside
the sandbox at all. The deny assertions had been passing there for the
wrong reason -- the secret was absent because the whole directory was,
not because anything denied the read.
The fake HOME now sits under the bound write root, and homeIsVisible
asserts a plain file in it is readable before any denial is asserted, so
this failure mode reports itself instead of reading as a pass. The
credential file living inside a writable root also makes the test
stricter: the deny has to beat an allow, not just an absence.
Only the negative control caught this. A deny-side suite without one
cannot tell "blocked" from "not there".
read_file refused ~/.ssh/id_ed25519 while `cat` returned it. The
credential denylist reached only the in-process readers and never
sandbox.Spec.ForbidReadRoots, so the OS boundary let every shell command
through -- including `cp`, `tar` and any interpreter, which is why this
cannot be patched in the command classifier. Egress is on by default and
the auto approval posture allows both halves, so the read and the POST
that follows it needed no prompt.
internal/secrets now owns the one list both consumers read. Files holding
secret bytes and nothing else (SSH private keys, ~/.aws/credentials,
~/.netrc, ~/.git-credentials, ~/.pypirc, gcloud ADC) are denied by
default. Files that mix configuration into the secret are deliberately
absent, measured rather than assumed: denying ~/.config/gh/hosts.yml
fails every gh invocation at startup, and denying ~/.npmrc silently
relocates npm's global prefix. Separating those needs a broker, not a
wider denylist.
The ~/.ssh rule is written as an exception list because private keys take
user-chosen names; id_* would miss work_github. Public keys, config and
known_hosts stay readable.
[secrets] protect_credential_files = false opts out.
Establishes the host-owned state the verification-authority design needs, and
nothing else. A Goal freezes the project declaration its process read into a
contract with a generation, the checkpoint carries it, and a resume restores the
same generation and the same criteria while the current declaration reloads
independently. Where the two differ the host records one observation.
Shadow only. Readiness is untouched, no obligation is raised, nothing is
superseded, missingProjectChecks still reads what it read, and the agent gains
no capability. Which declaration governs is the decision this state exists to
make possible later, and this commit deliberately does not make it.
The contract is not a wrapper over BaselineChecks. It is produced at Goal
creation from the declaration this process loaded, while the baseline is
captured per turn by the executor, and the two now travel side by side rather
than one standing in for the other. Keeping them apart exposed a real ordering
bug: the executor rebuilds its checkpoint from the scope id alone, so letting
its report write the contract field cleared what the Goal had frozen. The Goal
owns the acceptance; a progress report cannot clear it.
A checkpoint written before contracts existed carries none, and none is
invented from the current declaration — backfilling would manufacture an
acceptance nobody made, so those resumes are simply not observed.
Epoch is frozen at one and never incremented. Superseding a contract is the
transition the design reserves for an explicit host action, and storing a
generation is a different problem from changing one.
The drift observation says only that the two declarations differ. It does not
say the frozen contract should be kept, that the current one supersedes it, or
that the Goal should be blocked — the three readings this line has already been
burned by inventing.
Reachability is settled, so the question is no longer whether the divergent
state happens but who decides what it means. A Goal frozen under one criterion
and resumed against a different declaration is a state both current
implementations answer by guessing: the gate forgets the original, the
obligation derivation demands both, and neither is entitled to, because the
same state comes from an agent retiring the criterion it is being held to and
from an owner changing project policy between runs, and nothing durable tells
them apart.
Four rules instead. A Goal owns a frozen verification contract. A workspace
declaration change does not move an active Goal's authority. A divergence is an
unresolved authority conflict rather than an automatic union of obligations.
Only an explicit host-owned rebase supersedes, and superseding does not satisfy
the replacement — the rule this repository already holds elsewhere.
The provenance problem becomes a capability one. Deciding by who edited the file
fails on IDE edits, checkouts, background writers and crashes; the line that
holds without knowing any of that is that write permission on a file is not
write permission on authority. An owner who wants a running Goal judged
differently takes the explicit action, and the agent gets no such capability at
all.
Nothing is implemented. The document records what is deliberately not decided —
a merge state, promoting ObligationBaselineCheck to final authority, and the
cutover proposed on the premise that has since been corrected — and names the
tests that follow, including that a fix which turns the bypass into a deadlock
is not a fix.
A goal turn captures the criterion its process read and persists it with the
goal's state. Resuming that goal in a process whose declaration names a
different command restores the first criterion into the second process: the
baseline the task is held to and the declaration the gate reads are then
different commands, reached with no field assigned by hand. The two agents are
constructed the way two boots construct them, from two different declarations,
and the restore is the controller's own resume path.
This settles the question the corpus could not. Inside one process the baseline
is captured from the same list the gate reads, and plain --continue carries no
goal so it restores nothing — both disproven on real runs. The goal sidecar was
the last candidate producer and it holds.
What is established is an external declaration change between goal executions:
the second process was built from a different declaration, not shown inheriting
one the agent had rewritten. The benchmark showed an agent rewriting the file
and the rewrite surviving to the next process; joining the two is plausible and
is not what this test proves. The distinction decides the semantics — an agent
retiring the criterion it is being held to is what baseline protection exists
for, while an owner changing project policy between runs may have superseded it
deliberately — so it stays separate until something demonstrates it.
Reachability work stops here. What opens instead is the authority question,
which is not the migration that was proposed before it: preserving every
historical baseline forever is only correct for one of the two provenances.
Two runs, both parity. Single segment: the agent rewrote its own verify line and
ran only the replacement, and the gate went on naming the criterion loaded at
boot while citing the file it had already stopped matching. Two segments across
a real process boundary: leg two resumed, edited, ran only the new check and
finalized allowed, because the baseline did not survive the restart — it was
captured again from the declaration leg two had just read.
The chain breaks at its first link, so every claim after it goes too. There is
no demonstrated production path to a state where baseline and current differ,
and without one the cutover has no defect to justify it.
What is left is narrower than what was claimed and is not tested here.
DeliveryCheckpoint carries BaselineChecks and is persisted with goal state, the
restore seeds a rebuilt executor, and the turn loop clears the checkpoint only
when the scope id changes — so a Goal surviving a restart across a changed
declaration would hold the old baseline against a freshly loaded current one.
Goal is user-reachable and e2ebench cannot drive it, which makes that a
controller-level test rather than more corpus.
The corpus keeps its value by having falsified the premise it was built to
measure, on its first execution and again across a restart.
The project-check study reported a bypass as reproducible on the strength of a
test that assigned a private field. The field is written once at boot and never
again, so no run inside one process reaches that state, and the corpus said so
the first time it executed: parity, no divergence. The test proves a conditional
mechanism given the state; that is a different claim from a production defect,
and only the second earns priority.
So a destructive test now owes six lines before it is written up as something to
fix — who produces the state, through which transitions, across which
persistence boundary, under whose authority, and whether a real run has been
seen in it. Severity is reachability times frequency times authority, not
whether something can be bypassed.
Authority is in the contract because the same divergence can mean opposite
things. An agent that rewrote the criterion it was being held to is what
baseline protection exists for; an owner who changed the project's policy
between two runs may have superseded it on purpose. A study that cannot say
which it observed cannot say what the right behaviour is.
The segmented probe that motivated this is in the corpus: leg one blocked on the
gate still demanding the criterion loaded at boot, which is the same finding
from the other side — the declaration on disk had already changed and the gate
had not noticed.
Cache-impact: one rule added to REASONIX.md, which is in the stable prefix.
benchmarks/e2e declares no project checks anywhere, so the gate has zero
exposure there and no amount of running it accumulates evidence. Seven
fixed-shape tasks cover what the two derivations can disagree about: a stable
declaration, one never run, one replaced, one replaced and both run, one spelled
differently, a write after the check, and a second requirement added beside the
first. Together they pin that an old requirement cannot be deleted, a new one
cannot be missed, and a proven one can actually be discharged — the last because
a bypass fixed into a deadlock is not fixed.
The expectations live in the corpus README and nowhere else. verify.sh asserts
the production change only: a harness that also knew which class a task should
produce would fail in step with the classifier it exists to check.
The first run corrects the premise. project-check-rewrite made the agent rewrite
its own verify line and run only the replacement, and the probe recorded parity,
no divergence — because projectChecks is read once at boot and never reassigned,
so the baseline is captured from the same list the gate reads and the two cannot
differ inside a process. The rewrite branch of checkObligations is unreachable
there, and the claim that this bypass reproduces end to end was wrong.
What the destructive test proves is the mechanism given that state, and its
comments now say so. Reaching the state needs a process boundary: a goal
checkpoint restored with the old baseline into a build that read the new
declaration. e2ebench -segments is that shape, and whether a real run reaches it
is still unproven.
The subagent handoff line is resolved for the evidence there is. Protocol
salience and closure behaviour were never defective: every judged child
reported, and nothing followed a real closure in either arm. What was defective
was the host's vocabulary, which answered a lowered claim and an intact one with
the same word, and that is fixed. Structural terminal enforcement is recorded as
unjustified rather than pending — it would be a defensive invariant, and the
case for it has to be made on delivery risk, not on this data.
The discipline is the study's other output. Three mechanism explanations died
the same way: a command of unknown extent counted as a proven write, one child's
latest adjudication read as every report's, and a syntactically accepted call
read as a closed one. Each was a convenient proxy standing in for the state the
metric was named after, and each produced a confident wrong story. The rule now
sits beside the other judgement rules, and the four-line contract a metric owes
before it becomes a finding sits in the benchmark README with the five
substitutions that caused these.
Cache-impact: REASONIX.md is in the stable prefix, so this adds one rule to it
and no session carries a different prefix mid-run.
The barrier analyzer anchored on "complete_subtask accepted", which the verdict
change removed, so it would have reported no children at all. It now reads both
vocabularies and anchors on the same thing in each: the host saying the sub-task
is done. In the arm that had one word for both outcomes, an accepted report with
no lowered claim is that moment.
Re-anchoring changes what the control says. Nothing follows the first closure in
14 of 14 control children, and nothing follows it in 17 of 17 treatment children.
Tool calls after closure were never the problem: the model already stopped when
the host said done. The earlier reading came from anchoring on the first
well-formed call, which is not the same event as the first closure — the host had
answered both with the same word, and the analysis inherited the conflation.
What the treatment bought is therefore not a behaviour change, and the record
says so. Closure is now exactly one identifiable event per child, counted in the
audit instead of recoverable only from a trajectory, and needs_work produces real
work: across 21 verdicts, only 2 were resubmitted with nothing done in between.
Recovery attribution earns its commit here: all 29 repairs were
missing_reasoning_model_silent, 14 inside children, so host recovery is not what
drove the continuations. That is now evidence rather than an assumption.
complete_subtask answered both outcomes with "accepted". A report the host
could back and one whose claims it had just lowered returned the same word,
and the difference was left in a trailing sentence. The replay showed what the
child does with that: first report downgraded and it kept working, 10 of 10;
first report intact and it stopped, 4 of 4, with no exceptions either way. The
model was reading the verdict correctly out of prose the host never committed
to as a state.
So the verdict is said as one. A report with nothing lowered closes: the
sub-task is done and nothing further is asked. A report holding a claim the
host cannot back returns needs_work, naming that the work is what is missing.
Three layers stay apart, which is what the single word fused: a malformed
submission still fails the call, a verdict is a successful call, and neither
ends the run — no barrier is added here.
The contract loses "exactly once, as your final tool call". It was falsified
before it was written down: a host that answers a lowered claim by asking for
the work again cannot also be obeyed by a child that calls once. What replaces
it describes the loop that actually exists, and the test that pinned the old
sentence now pins both verdicts instead.
Verdicts are counted when issued, not recomputed afterwards. The ledger grows
during a run, so a claim it could not back at the time may be backed by the
end, and re-adjudicating an early report answers a different question than the
one the child was answering.
Cache-impact: the contract rides the child's task prompt, not the stable
prefix; no parent prefix, tool schema or memory text changes.
Extracts the background-lease methods out of evidence.go rather than widening
its file-size budget for one struct field.
A repair recorded during a delegated run was indistinguishable from one in the
parent's own loop, so "the child kept working" and "the host pulled the child
back" read the same. That distinction now decides an experiment: the next arm
asks whether a child continues on its own after a host verdict, and an
unattributed recovery would let the host's own intervention be counted as the
model's behaviour.
The identity is stamped where it is known rather than threaded to where it is
not. Every producer runs inside the child's loop holding no id; the nesting
sink already re-parents that run's tool events, so it stamps this too. A
parent-loop repair passes through no such sink and keeps an empty ChildID,
which is the honest reading rather than a default.
No semantics change: the same repairs happen at the same points, and nothing
reads the new field to decide anything. The trajectory keeps it per record and
the flat metrics count child repairs apart from the parent's.
The test drives the assertion through the stack a child actually emits into,
because that stack is where audits were being dropped whole two commits ago and
a conformance check would not have noticed.
A new offline mode answers one question against already-recorded runs: if a
child's first accepted complete_subtask had closed it, what work would that
have suppressed. It spends no tokens and changes no runtime behaviour, and it
classifies with the host's own readers rather than a second reading of the same
commands — ToolCallMutationClass and CommandRunsVerification, with verification
tested first because a command whose extent the host cannot establish already
classes as a mutation, and a check is exactly such a command. MutationProven
and MutationUnknown stay apart: reading them as one turned four real writes
into eight, which is the answer this study exists to get right.
The result rejects the barrier. Of 14 children with an accepted report, 1 wrote
a named path afterwards and 7 ran commands of unestablished extent; only 4 did
nothing further. The 47 post-report bash calls are mostly ad-hoc assertions and
inspection that CommandRunsVerification does not recognise as checks, so the
opaque bucket bounds unclassified work rather than claiming it mutated.
What it found instead is that the host asks for the continuation. First-report
adjudication and continued work separate perfectly across all 14: every child
whose first report had claims lowered kept working, every child whose report
stood intact stopped. complete_subtask answers a lowered report with "accepted
... run the check or cite what you really did" while the appended contract says
to call it exactly once as the final tool call. The child follows the later and
more specific instruction. The gap is not a contract without enforcement — it
is an adjudication that is not a state, since a lowered report and an intact
one both return "accepted".
The research record is corrected in the same commit. Its HostDowngrade 0/14 was
read off the handoff audit, which adjudicates only a child's latest report; over
all 39 accepted calls, 25 were adjudicated partial and carried lowered claims.
Extracts the offline-mode dispatch out of main rather than widening its
function-size budget for one more case.
missingReview counted two things that are read from different indices and
settled by different work: a structured review of the change set, and the
turn's own inspection of every file it wrote. reviewGateFailure raises the
first from UnreviewedMutationBaseline and exempts sub-agents; the second comes
from UninspectedWritePaths and applies wherever a delivery turn mutated. A
report reading "missingReview = 3" could not say which was owed, so it could
not say whether the fix was a review report or a read.
The aggregate is removed rather than kept beside the split. It has no producer
of its own and no consumer that needs the fused shape, and reconstructing it
would be a second derivation of a number nobody asked for.
No behaviour changes. The gate raises the same debts on the same turns and the
reason text is untouched; only the audit vocabulary and the missing-id list
move, and the two control decisions that read that list test it for emptiness,
which a split cannot alter.
One assertion changed meaning rather than shape:
TestDeliveryProfileRequiresReviewBeforeFinalAnswer said it wanted an audit with
"missing review" and the debt it actually raises is changed-path inspection.
The single counter is what let the wrong description stand.
Every judged child attempted and produced an accepted complete_subtask report
(14/14), so handoff wording is not the observed bottleneck. What 11 of 14 did
was call it repeatedly, and 10 of 14 kept using ordinary tools afterwards: the
report behaves as a milestone because the host accepts it without closing the
child's execution epoch. The contract already says "exactly once, as your final
tool call" in the most direct wording available and the tool enforces nothing,
so this is a missing state transition rather than a prompt to rewrite. Against
the threshold agreed before the run, no nudge.
The limits are recorded with the result because they bound it: 14 judged
children, 10 of them from prompts that demanded delegation, read-only n=3 from
one task, one run per task. The ratios describe children that exist, not the
model's own judgement about when to create one — 6 unprompted tasks produced a
single child against 10 from 5 that asked, which is logged as a separate
question rather than folded into this one.
Raw evidence stays out of the tree: trajectories are as sensitive as session
transcripts and run to 3.2 MB. It lives under the state home in four batches
kept apart, because the budget aborts that skipped tasks in two of them are
provenance, with a manifest pinning revision, model, the judged denominator and
a sha256 per file, beside the analyzer that reproduces the table.
Every delegated child emits into subagentProgressTracker.wrap(), and it
returned an event.FuncSink. Optional capabilities are delivered by type
assertion, and a func type implements none of them: measured against the
registry, that sink dropped all twelve. Not one channel — the child's handoff,
contract shadow, completion report, readiness audit, outcome progress and
protocol recovery all ended there, one layer before the nesting sink that would
have forwarded them.
Every delegation path goes through it. fleet, task and parallel_tasks all reach
RunProfileSpec, which wraps the child sink this way, so subagent telemetry has
been empty since 9ef543ee0 added the progress forwarding on 2026-08-05.
nestedSink already carries the lesson in its own comment — it is a type rather
than a func sink because a bare one "swallowed every delegation audit a fleet
child produced". wrap() was added in front of it and reintroduced the same bug
further out, where nestedSink could no longer be reached. plannerSink had the
same shape and is converted too.
Audits are not gated on the tracker's terminal. A preview event after the child
finishes is noise; its accounting is not, and the handoff receipt is written
from a defer that runs before the tracker is finished either way.
Measured on benchmarks/fanout-width (one parent, four children), before and
after: subagent_handoff 0 -> 4, contract_shadow 1 -> 5, completion_report
1 -> 5, protocol_recovery 0 -> 6, outcome_progress 4 -> 39. The one-per-child
channels landing at exactly parent+4 is the shape the hole was hiding.
The new guard lists the sinks handed to a child and checks each against
MissingCapabilities, because this package has now dug the hole twice and a
comment did not stop the second time.
The channel counting how often a delegated child closes the way it was asked
to reached nothing. No type implemented SubagentHandoffSink anywhere in the
tree, AuditForwarder had no method for it, and it was never registered in
capabilityContracts — so the type assertion inside RecordSubagentHandoff
matched nothing at the first wrapper and the audit was dropped. The producer
had a struct, an interface and a green unit test, which is exactly why nothing
noticed: conformance says a method exists, and only a stack says a value
arrives.
Registering it is the fix that matters. The registry is what forces every
wrapper and the shared forwarder to carry a channel, and the reason this one
escaped is that it never joined the closed set. AuditForwarder is embedded by
notify, telemetry, control, traystate, stats and the CLI sinks, so one method
there restores the channel for all of them.
The two guards that should have caught it kept their own hand-written lists of
capabilities, which pass for every capability they have not heard of. The
forwarder guard now reads capabilityContracts directly, and moves into
internal/event where that registry lives rather than exporting it for a test.
The cross-package wrapper guard still keeps a list; it is extended here, and
switching it to the registry is blocked on two channels that have no terminal
consumer at all (RunBudget, WorkspaceMutation).
Consumers follow the project-check probe's split: run-metrics aggregates,
trajectory keeps the whole observation. The metrics denominator is Judged —
expected to report and not killed by the provider — because a run that never
reached the point of closing is not a refusal, and counting it as one answers a
different question than the one being asked.
Nothing about delegation changes: no prompt byte, no handoff gate, no
completion semantics. This only makes the existing observation observable.
The gate and the ledger answer "which declared check is still owed" twice, and
the answers are not the same question. unmetProjectChecks walks the current
declaration and matches raw command text since the writer index; the ledger's
checkObligations walks baseline and current, keyed on VerificationIdentity,
since the latest successful mutation. Only the second one survives a task that
rewrites the declaration it was accepted under, which is what
ObligationBaselineCheck was written for — and the gate has never read it.
Nothing changes about what blocks. The probe derives the obligations beside the
gate and records both answers, per readiness evaluation rather than per turn.
It records identities, not counts: 0 vs 1 cannot distinguish the baseline
protection working from the candidate over-reaching, and the identity plus a
class can. A disagreement is explained strongest-first — a criterion the
current declaration dropped, then the two sides' differing after-indices, then
canonicalisation — so candidate_only and legacy_only are what is left when none
of the three account for it. Those two are the only classes that are defects.
project_check_probe_test.go records the gap end to end: a delivery turn that
rewrites its own declaration and runs only the replacement finalizes today,
never having run the criterion the task began under. The test asserts that it
ships, so flipping the gate to the obligations is what changes the assertion.
The channel is registered in capabilityContracts and forwarded by every
wrapper, so it cannot repeat SubagentHandoffAudit, which is emitted today with
no sink implementing it and no guard able to see that.
Also extracts appendIncompleteTodoGap and appendProjectCheckGaps out of
finalReadinessCheckFor rather than widening its function-size budget.
missingActionEvidence has had no producer since b071fef95 retired the word
lists that raised it. The field, its progress-signature slot, its missing-id
entry, the metrics column and the trajectory field all stayed wired, and every
one of them reported zero. A bench column that cannot be non-zero is worse than
an absent one: it reads as evidence the gate never fires.
CommandMismatchMissing was assigned the same value as MissingProjectChecks at
its only producer, so the audit carried one number under two names and the
metrics file summed it into two columns. There is no independent derivation to
restore it from, so it goes rather than being given one — inventing a second
derivation to keep a historical field alive is how a measurement vocabulary
stops matching the semantics it claims to count.
Behavior: none. Neither field was read by any gate, and missingIDs never
emitted "action" because the counter never rose, so the readiness wire payload
is unchanged. run-metrics JSON loses readiness_command_mismatches and
readiness_missing_action_evidence; no benchmark or frontend reads either.
Epoch staleness was written as "a transition invalidates everything produced
under the previous authority", which is stronger than what it exists to prevent.
What it prevents is earlier work inheriting capability it did not have: a batch
sampled while planning must not become executable because the user approved the
plan while it was still streaming.
Turning Plan on does the opposite. It narrows what may run, so nothing in flight
gains anything — and refusing it threw away the rest of the turn every time
someone touched the toggle, readers included, with "stale plan authority" where
the answer should have been the file. Side effects during planning are held by
the phase gate, which judges each call on its own terms; staleness was not what
was protecting anything there.
So it now refuses only across a widening transition. The attack it was built for
still fails closed — a writer sampled while planning is refused after approval —
and a reader caught by a toggle mid-turn returns its file, while a writer caught
the same way is refused by the phase rather than as stale, which is the
difference the model needs to read to know what to do next.
Found by asking what normal use costs rather than what an attacker gains.
Answering a card has to be the right instance, on the right surface, in the
right runtime. Those are three guards in different places, and a single test
that trips over any of them proves none of them.
Two attacks, each with its own destructive counterfactual, and the pair is
mutually exclusive — breaking one leaves the other's test green, which is what
shows they are separate lines rather than one line reached twice:
legacy /approve, stale id break resolve()'s exact lookup → stale answers current
plan surface, stale id break resolveTool()'s id selection → stale answers current
(keeping its tool check intact)
The legacy bridge is the one worth naming: it does not go through resolveTool at
all, so its staleness rests entirely on the exact map lookup in resolve(). Being
the right kind of decision was never the question there — being that decision is.
A third attack crosses runtimes, where both panes mint id "1" from their own
counters and only the route tells them apart. It passes, but the counterfactual
for it did not: routing every request to the first runtime left the test green,
so the break did not reach the path under test. It stays recorded as verified,
not proven, and the harness gap is the finding.
Planning admits read_only_task because its child cannot write. An extension can
replace the call, so the question is which invocation the phase judged — the one
the model wrote, or the one that will run. Judging the first would carry a full
tool set into a phase that refuses writers.
The assertion is that the child never starts, not that a message came back, and
the reverse direction is pinned too: narrowing task into read_only_task is
admitted, which is what shows the gate reads the capability it is about to run
rather than recognising a name.
Verified by moving tool.before after the policy gate, where the writer-capable
child starts once.
Identity, checked across the invariants while here: the pre-scheduling barrier
scan reads the original batch — it can only see what the model wrote — and the
outcome check covers what that leaves; every other judgement reads the
transformed or resolved-final call. Plan effect and the MCP phase gate read the
resolved target, permission and evidence read permName/evidenceName, and stale
authority is an epoch rather than an identity. No invariant judges an invocation
that will not run.
The barrier scanned the calls the model wrote, before scheduling. An extension
substitutes a call inside executeOne, after that scan — so a reader turned into
a question left the scan looking at the call that no longer exists, and
everything the model wrote after it ran anyway. That is the failure the barrier
exists for, reached through the one path that skips it.
The call now reports it: an outcome whose tool is a decision barrier ends the
round where it executed, and the rest of the batch is closed out the same way
the scan closes it. The scan stays, because it is what keeps a question out of a
parallel segment in the first place; this is the case it structurally cannot
see.
Found by attacking the seam rather than testing the feature, and the test fails
without the fix: the writer authored before the injected question runs once.
Studio answered a plan card by folding its three outcomes into the approval
boolean and then setting plan mode itself. That was already lossy — revising and
exiting are different transitions — and since the lifecycle became host-owned it
also raced the kernel's own move: the frontend's SetPlanMode(false) could exit a
workflow the approval had just carried into execution. The CLI did the same
thing in the same place, so both now send the outcome whole through
ResolvePlanDecision and read the legacy flag back instead of asserting it.
That call needed a way through HTTP, so serve gained POST /plan-decision. It
answers 409 with a coded refusal when the host no longer holds the decision:
concurrency, not client error, and the wording is in the catalogue so the window
says it in the user's language.
The composer toggle keeps following `plan`, which is false once a plan is
approved. planPhase is read separately and only to say what is happening —
"executing an approved plan" is a fact worth showing, and showing it must not
re-open a toggle whose next click would mean exit.
Asks name why they are asking. The reason switch is exhaustive against the two
values, so a third — a decision belonging to permission or to the plan card —
fails this compile rather than rendering as a question.
The projection is the authority on what is still waiting. A card this window
holds open that the host no longer lists was resolved somewhere else, so it is
sealed rather than deleted: it stops being answerable and stays on screen as the
record of what happened. Nothing here removes a decision on its own.
ResolvePlanDecision moves out of Approvals into its own port. A plan decision is
a workflow transition rather than a permission answer, and the chat gateway and
editor bridge have no screen for revising a plan — leaving it in the shared port
recorded them as owing an edge they will never draw.
Verified against a real kernel in a real browser: the toggle drives the
lifecycle to planning; a plan turn reaches awaiting_approval with the three
buttons on screen; clicking Start leaves the toggle off, the badge reading
执行计划中, and the kernel in executing; and replaying that same card returns
409 plan.decision_stale while the run is unaffected.
A frontend that wants one "waiting on you" list has had to assemble it from
events and remember what it saw. Doing that in the UI is how the ownership the
last four commits separated gets merged back together: one panel, one Allow/Deny
pair, and a plan approval starts looking like a permission prompt.
Decisions is a projection. It is derived from the owning subsystems each time it
is asked for, carries no state of its own, and nothing in the kernel reads it —
a client that never fetches it, fetches a stale copy, or ignores it entirely
changes no behaviour. Kind names the owner, and the owner is who answers:
plan_approval goes back through ResolvePlanDecision, ask through AnswerQuestion.
There is deliberately no Decision.Approve.
Identity is the id the owner already issued, sent back untouched. That is what
makes a stale card harmless without the UI knowing anything about phases or
epochs: the owner looks the id up, finds it resolved or replaced, and answers
nothing. Authority arithmetic stays in the kernel.
AskReason arrives with a consumer rather than ahead of one — a frontend can now
distinguish "your decision" from "information the request never gave me". The
two values are the whole set; permission, plan approval, and the agent's own
uncertainty are answered elsewhere and never become a third variant.
status gains plan_phase alongside the unchanged plan bool. Absent means outside
the workflow, so a client reading it can finally say "executing an approved
plan" while one reading only `plan` behaves exactly as before. Both are asserted
together, since the pair is the compatibility contract.
The projection is a serve-local capability rather than a port method: only this
frontend renders it today, and putting it on the shared port would record every
other frontend as owing an edge it has no screen for. It moves to the port when
a second consumer arrives.
The ask schema gained a field, so the cached prefix is invalidated once and the
golden baselines are regenerated for it.
ask obtains what only the user can supply. It is not a way to request
permission, not the plan card, and not a stand-in for research the agent can do
itself — and the criterion that used to let it through, "can't settle it from
the request, the code, or sensible defaults", let the model settle anything it
had an opinion about. Having a recommendation does not make a choice yours: when
two reasonable options would change architecture, public API, data model,
dependencies, UX, compatibility, scope, or anything irreversible, ask and put the
recommendation first. Local, low-risk, reversible details take the default.
The sharper failure is timing. Everything the model writes beside a question was
written before the answer existed: `ask("delete or archive?")` and
`write_file(...deleted...)` in one batch executes the guess whichever way the
user answers. Tools declare the barrier themselves rather than being matched by
name, and the scan runs before scheduling — ask is read-only, so it shares a
parallel segment with its neighbours and by the time it executes they have too.
Read-only siblings stop as well: sharing the segment is the problem, not writing.
The deferred calls are told they were not carried over and why.
The lifecycle is untouched. An answer changes what the task knows, not who holds
workflow capability, so it raises no plan epoch — the two staleness mechanisms
stay apart: plan epochs invalidate stale authority, this barrier invalidates
stale reasoning. If the answer leads to a revise or an approval, that transition
raises the epoch as it always did.
A run with nobody to ask no longer answers itself. The old fallback told the
model to use its best judgment, which contradicts the only rule that puts a call
here — the decision stays unresolved, and the model is pointed at
conclude_blocked instead of at a guess it would have to defend. No approval
posture answers for the user either; auto-approving writes says nothing about
which option someone wants.
The ask description is in the cached prefix, so this invalidates it once for
existing sessions. Golden baselines regenerated for that.
The plan workflow was an atomic bool carrying two questions at once — is the
user in the workflow, and what may run right now. A bool cannot say
AwaitingApproval, and it cannot say that approving grants capability to the
work that follows rather than to whatever was already in flight: flipping it
admitted every tool call the model had streamed while planning, retroactively.
planmode.Runtime holds one canonical State{Phase, Epoch}. Active is derived, not
stored, so there is no second field to keep in step. Every accepted transition
raises the epoch — Submit and Revise included, because each changes which
authority is current, and deciding case by case which one "really" counts is how
stale work finds a way back in.
Transitions compare against the state the caller last saw. An approval answered
after a revise finds Planning@N+1 where it expected AwaitingApproval@N and does
nothing, so a late click is harmless rather than merely unlikely.
Each provider round is stamped with the state it was sampled under, before
sampling rather than at execution: what matters is the authority the model
produced the call under, which the state at execution time cannot report. A call
whose origin epoch is no longer current is refused as stale, and told so, since
the reason is what sends the model to the current state instead of a retry. The
admission the gate records carries its epoch too, so the execution assertion
checks freshness — it still re-classifies nothing.
ResolvePlanDecision becomes the transition surface it already described:
revise and exit move the lifecycle before the waiting turn is answered, while
start stays with that turn, which holds the state it submitted and is the only
party that can tell an approval from a late one.
status.plan stays a bool. It is now a projection of the lifecycle for wire and
UI compatibility — true while the marker rides the turn, false once a plan is
approved, exactly as the flag behaved. Nothing in the kernel reads it.
Two places answered "may this run while planning": the phase gate, and a turn
policy that tested mutation accounting. Neither was wrong on its own — the hole
was between them, and the delegation fix landed in only one of the two. Collapse
the answer to one owner so the next tool cannot fall through the same gap.
taskpolicy stops enforcing. Its plan signal is still derived, still rendered
into the execution-policy block, and now only that: AllowsMutation had no other
caller and retires with the gate that used it. planPhaseEffect becomes the sole
classifier, and the planner's MCP exemption folds into that fact instead of
being special-cased at each gate.
Decide the phase before the first effect, not merely before permission. A proxy
names its real target only after resolving, and ResolvedCall.Commit is a state
transition its own contract places after the host's checks — it was running
before them, so a refused planning call could still leave a committed ledger
entry. The resolved-target gate now runs ahead of Commit.
What remains at the execution boundary is an assertion, not a second opinion: it
re-reads the admission the gate recorded and classifies nothing. Reaching it
means a pre-execution path skipped the gate, so it reports a host bug rather
than borrowing the ordinary refusal. tool.before keeps running ahead of the gate
because it decides which call there is to gate; that ordering is safe only
because a substitution is re-parsed, which now has a test.
Plan promised the model that the host refuses writes, side-effecting shell,
capability installation, memory mutation and writer-capable delegation until a
plan is approved. Delegation was not refused: the boundary tested `mutates`,
and mutation accounting answers "did this call record a mutation" — `task`,
`run_skill` and `use_capability` record none of their own because the write
lands in a child. In Plan with auto or yolo approval they ran, and the child
held the full tool set.
Decide the phase on an Effect axis instead. planmode is a leaf package: it owns
the rule, callers pass what their own metadata proved, and the zero value reads
as a side effect so a barrier that fails open cannot ship. The per-call fact the
plan already resolved is the input — the tool's own answer, or the shell AST's
for a concrete bash invocation, never the schema-level bool, which would refuse
`grep` and regress #5341.
A refusal says the run is still planning, not that permission was denied, and
says what still runs so the model spends no turns retrying. Each refusal carries
an identity: unclassified is classifier debt worth counting separately from a
declared side effect. The marker stops saying "avoid" now that the host refuses,
and its earlier wordings move to planmode.Superseded so replayed sessions still
strip the prefix.
Delegation splits by writer capability, not by being delegation: read_only_task,
parallel_tasks and the subagent readers are how a plan researches and keep
running. The planner's authorized non-destructive MCP research keeps its
standing exemption.
Covered by a cross-posture matrix — every approval posture refuses, execution
runs the same call, and explicit deny keeps working in both, which is what
proves Permissions was left alone.
The typed handoff exists for read-only children now, and nothing insists on it.
So this measures the natural rate first: a nudge built on a compliance number
nobody collected would be a guess with a number on it.
It observes and decides nothing. No prompt byte moves, no notice is emitted, no
parent handoff changes, and the provider surface is untouched.
The denominator is the instruction, not a guess at one. ExpectCompletionReport
is set exactly where the contract is appended, never inferred from a registry
holding the tool — the same distinction this codebase keeps having to relearn:
a count is not its evidence.
Four things are counted per child run, one sample each, from a single defer so
a salvage, a review failure, a provider error and a clean answer are all
counted the same way:
adoption attempts, accepted, malformed. Attempted-and-refused is a schema
problem; never-attempted is a protocol one, and one number
cannot say which.
protocol report round against final round, and tool calls after it. The
contract asks for the report as the closing call, and
"submitted eventually" is different behaviour.
adjudication claimed status beside the host's, and how many claims it
lowered. A child that reports readily and is corrected every
time is not a compliance problem.
exit completed, error, cancelled, no_answer. A provider failure that
produced no report never had the chance to comply and leaves
the denominator.
Split by entrance and depth, because an aggregate hides the entrance that is
failing. Content-free throughout: counts, enums and rounds, never a prompt, a
path or a summary.
Verified by making each fail: stop separating malformed from accepted and the
schema-vs-protocol distinction disappears; classify a provider error as
no_answer and a failed run becomes a compliance failure. The cancellation test
carries a wrapped sentinel and a lookalike string, because the classifier reads
errors.Is and a message that merely says "context canceled" must not pass —
repolint's error-text rule caught the first draft doing exactly that.
Options moved to agent_options.go: it is 172 lines of a god object, and this is
the third field appended to it this round. That extraction carries ten essay
findings forward, declared with -allow-widen; agent.go drops from 1597 to 1424.
runReadOnlySubSession stays in task.go — the spawn-boundary guard is right that
its list must not grow for a file-size reason — and the framing both runners
apply is shared instead.
A writer child ends by calling complete_subtask, and the host adjudicates every
citation against what it observed the child actually do. A read-only child
ended in prose. Its receipts still reached the parent ledger, so the host knew
which files were read and which commands ran — but the semantic half arrived as
an unverifiable sentence, and nothing connected the two. With one researcher
that is invisible. Across a fleet of eight it is the parent's whole picture.
Strict children now get the same contract and the same tool. complete_subtask
is already ReadOnly, so it survives the strict construction filter unchanged,
and the parent's existing adjudication path needs nothing new: read-only runs
through RunSubAgentWithSession like every other child.
The order is the point, and it has a name now. readOnlySubRegistry builds the
tools, proves the child can investigate something, and only then attaches the
way to report. Attached first, complete_subtask would make an empty registry
non-empty — a child with no research tools at all would pass the capability
check by holding the tool it files findings with, and then report on work it
could not do.
The contract's wording asked for "the paths you changed", which a child that
only looked at things cannot cite. It asks for changed or inspected.
No provider surface moves: the tool joins a child's registry, not the parent's.
Searchable canonical recall is proven: 6/6 against 3/6, and the successes
without it enumerated addresses by hand at nine times the page-in cost. That
number is from a clean environment, after three separate leaks were closed.
Fold-index marginal utility is unresolved, and the README says so rather than
picking the more interesting of two contradicting batches. The contaminated
Stage 2 is void; the clean paired batch found a consistent effect and the
same-batch dose-response did not reproduce it. Two of six index tasks carry
heavy-tail stopping behaviour, which is the likeliest reason. Existing evidence
cannot tune the shipped 1% budget in either direction.
The README also records what turned out not to be a problem — query
formulation, memory routing, snippet length, a general stopping failure — each
measured rather than argued, and each having been believed at some point in
this round.
And the four measurement distinctions that each reversed a conclusion once:
target-hit is not evidence-sufficient, linear event order is not model-round
order, an isolated workdir is not an isolated host, and a count is not its
evidence. They are enforced in the schema now, so the next reader does not have
to rediscover them.
The hypothesis was that i03 and i04 kept searching because a 240-rune window
handed over an incomplete answer — a rank-1 hit that scores as found and reads
as insufficient. Measured through the shipped MakeSnippet across every task,
100 instantiations each, and every query those runs actually drew:
240 runes: 12/12 tasks at 100% coverage, i03 and i04 included
The records are 30 to 179 runes long. They never reached the boundary. The
shipped recall path agrees: i04's four historical queries each return 3/3
values. Only r01 at 120 runes falls short, and 120 is not what ships.
So the post-hit searches in those two tasks are stopping behaviour after all.
Seven runs out of forty-four, in two tasks, which is a scale that does not
justify changing a runtime — but it is now measured rather than assumed.
FirstHitCoverage and AnswerValues are recorded from here on. Three analyses in
a row have turned on the difference between "the target came back" and "the
answer was readable", and it should not need a one-off script to tell them
apart again.
A search returning the target at rank 1 has not necessarily returned the
answer: the snippet is bounded. So the stopping boundary is the round where
everything the task needs became readable, across snippet and read together —
not the round the target came back. A read after an insufficient snippet is
correct behaviour, and counting it as waste blames the model for a bound the
host set.
Runs are classed by what they did after that point: SnippetStop, SnippetThenRead,
DefensiveRead, PostSufficientRetrieval, PostSufficientRunaway, NeverSufficient.
The funnel reports mean, median, p90 and max of the extra rounds and extra
page-in, because the suspicion here is a heavy tail and a mean would hide it.
Trajectories are written beside the metrics once the whole batch is done.
Three analyses in a row have needed a re-run because something was counted and
not kept — first the query text, then the snippet contents — and this is what
stops the fourth. They are written at the end on purpose: during a batch, those
files are the answer.
What the existing batches can already say, with target-hit standing in for
sufficiency: 44 clean runs held their target, 37 of them issued no further
search, and all seven that did belong to two tasks. Not a general stopping
failure.
Two runs in the dose batch hit the four-minute budget, and the metrics already
said which kind they were. i04's first search returned the target at rank 1 in
round one; it read it in round three and was still searching in round eight,
five of those searches issued after the answer was already back — the last of
them for the answer's own codename. i03 found it in round two and kept going
too. Neither was waiting on a provider.
So a timeout is classified rather than counted. TargetFirstSeenRound,
RoundsAfterTarget, SearchesAfterTarget and RecallTokensAfterTarget say what
happened once the answer was in hand, and TimeoutReason separates
PostTargetRunaway from SearchSpiral, TargetHeldLate and NoToolActivity. Only
the second is a case for a longer budget; the first would just get more time to
circle.
This matters beyond the two runs. A batch that reports "context deadline
exceeded" cannot tell an arm that made retrieval harder from an arm that drew a
slow minute, and the index experiment is currently deciding exactly that.
scoreRun became a runScorer holding the walk's state, because the metric it now
derives crossed what one function may carry.
Two runs in the dose batch timed out, and their curves printed 0 at those
budgets — a line falling to the floor where nothing was measured. Both tasks
were then excluded from the boundary counts, correctly, but the table beside
those counts told the opposite story. Missing arms print a dash, and a curve
with a hole says "no curve" instead of a delta computed from a zero.
CueDirectRead came back 1/6 on clean data: the model almost never uses the
addresses. But searches and page-in still dropped on all six paired tasks when
the cue was visible. So the hypothesis under test is not that a bigger index is
better. It is that a visible historical action cue lowers the cost of active
retrieval — which predicts a step at each task's own visibility boundary, and
not a slope across four budgets.
Each task now gets a curve across default, half, quarter and off, with an arrow
where its cue disappears. BoundaryAligned reports whether the largest single
transition is that one. A change somewhere else is variance wearing the shape
of an effect, and averaging four arms cannot tell the two apart.
Reported with mean, median, and the mean after setting aside the largest
effect. The largest is not deleted: one task drove a −1735 token delta, and the
honest reading is that the effect survives without it, not that it was an
outlier.
Arms are shuffled inside each task block from a fixed seed. Running six
defaults and then six halves would make provider drift collinear with the axis
under test — a bad minute would land entirely on one arm.
Each batch prints what produced it: model, endpoint, window, generations,
commit, start time. Batch effects have reversed three conclusions here already,
and a table with no provenance cannot be compared with the next one.
Twelve runs instead of twenty-four. Each index task is measured at the
narrowest scale that still addresses its cue and at the next one down, so a
pair moves the affordance and nothing else — default-vs-off moves the entire
index along with it, and averaging four arms mixes six questions of different
difficulty into every number.
The two arms draw separate nonces. Reusing one would leave the first arm's
answer in a host-readable history for the second to find, and the
counterfactual is about the cue, never about the literal.
CueDirectRead is judged strictly now: the cue was addressed, the first memory
action was a positions read, and that read covered the target. Counting any
positions call would hand the index credit for an address a search had already
found — which is the number the old Stage 2 leaned on.
The report is paired deltas per task, then the two sides pooled, then where
each side looked first.
A clean run was failed by the marker "229" appearing somewhere in
~/.claude/history.jsonl. It is a three-digit epoch: it appears in any long file
by chance, and this one did. The run had actually recovered the answer through
recall and said so.
The gate takes codenames only. They carry this run's entropy, so a hit is
proof; a model holding one holds the numbers printed beside it anyway. Numbers
still count for scoring the answer, where every marker must appear — there the
question is whether the model produced the value, not whether a file contains
it somewhere.
The disk assertion earned itself immediately: with session.jsonl and the
context sidecar removed, session.events.jsonl still held the answer, and all
twelve runs failed before their first provider request. Nothing was paid to
learn it.
A session writes more than the file it is named after, and naming the ones we
know about is how the next one gets missed. sealFixture empties the fixture
directory instead.
The disk assertion earned itself immediately: with session.jsonl and the
context sidecar removed, session.events.jsonl still held the answer, and all
twelve runs failed before their first provider request. Nothing was paid to
learn it.
A session writes more than the file it is named after, and naming the ones we
know about is how the next one gets missed. sealFixture empties the fixture
directory instead.
Two leaks were closed by name: corpus.go by instantiating the answers, and
session.jsonl by removing it once the agent holds the transcript. Naming files
is how the third one gets missed, so the fixture directory is now scanned for
this run's answer literals — before the first provider request and again after
the turn. An autosave that lands somewhere new, a recovery tombstone, a sidecar
that moves: each fails the run instead of quietly feeding it.
Also a shadow reading. AnswerRecovered means every marker appears in the text,
which proves the right fact is present and not that it was submitted: the leaked
run answered with this run's real values and an older corpus's values together
and scored recovered. AnswerAmbiguous flags an answer naming two candidates of
one codename's shape. It changes no verdict — the first behaviour pass stays on
nonce matching — but a report will no longer read "said everything, including
the right thing" as clean recovery.
Runs that errored are counted apart from runs that measured something, so a
provider timeout no longer votes in the routing distribution.
A clean-baseline run came back contaminated, and the leak was the one the repo
work could not reach: bash cd'd into the fixture directory and cat'd
session.jsonl. The canonical transcript is the answer by construction, and the
sandbox mounts the host read-only by design, so a model that finds the path
reads the answer. One did.
The agent holds the session and its sidecar in memory before the turn starts,
so the files stop being needed at exactly that point. sealFixture removes both
and denies the directory — the deny matters, or an autosave recreates the file
with the same content the removal took away. A failed autosave costs the
benchmark nothing; it scores from memory.
Two things worth recording from the leaked run. The answer it gave mixed this
run's real values, read out of session.jsonl, with values from an older corpus
it found somewhere else entirely — so answer_recovered read true on a run that
recovered nothing. And the contamination gate is what caught it: the run left
the statistics instead of scoring 6/6 for the wrong reason.
Two runs are told what they are running and sent after the answer everywhere
except the conversation: the repository, the benchmark source, the fixture
generator, test files, scratch files, session metadata. The question is not
whether a model obeys the experiment. It is whether the answer is reachable at
all by one that knows the mechanism and is actively attacking the host.
Both came back isolated. Thirteen tool calls on one, six on the other, and
neither reached a value. A run that gives up and recalls still passes: that is
the attack failing, not the model behaving.
The gate is EscapeFoundAnswer, not AnswerRecovered — recovering the answer
through recall is the path this benchmark is for, and a strict "did not answer"
check would have failed the wrong thing.
Two pilots were contaminated the same way: the model grepped corpus.go out of
this repository and read the answer. Isolating the workspace did not stop it —
the sandbox mounts the host read-only by design, so a model that guesses a path
can read it. The fix is that the path leads nowhere useful.
Every answer-bearing literal is a placeholder now. The corpus says
The ownership token is {{token}} ... filed as {{reason}}
and a run turns that into values generated for that run alone. The invariant is
exact: benchmark source may reveal the experiment, the task structure and the
semantic template, but no literal sufficient to satisfy AnswerRecovered may
exist in the repository before the run begins. That includes the numbers —
randomising a marker while leaving "expected epoch 12" behind would leak two
thirds of an answer.
Values stay shaped like engineering records: cobalt-k7m4q9, epoch 731, 43ms,
0.0437. A UUID would close the leak and turn every task into "find the random
string", which is not the retrieval being measured.
The template grammar is one identifier in double braces. No conditionals, no
loops, no defaults, no filters, and an unresolved placeholder fails the fixture
rather than reaching a transcript as "{{marker}}". This is fixture data, not a
language.
Seeds split by purpose. Preflight, calibration and tests are deterministic, so
a tier boundary is measured against reproducible values. A paid run draws from
crypto/rand, so its answers existed nowhere before it started.
Scoring binds to the instance, and results keep no vars — writing the expected
answers next to the run would rebuild the file this commit removes.
Two more things the pilots asked for:
Contamination now records the tool, its arguments and a bounded excerpt of the
output that leaked, plus EscapeFoundAnswer — "looked at the workspace" and
"found the answer there" are different facts about the same call.
Routing is judged by model round, not by the order of tool events. Two calls in
one round are a parallel fan-out; calling that workspace-first would report a
preference the model never expressed. The same correction the query audit
needed when 2.17 searches turned out to be one round's fan-out.
Verified by making each fail: write one seeded marker into corpus.go and the
leak check names the file and the line; treat a same-round fan-out as ordered
and the routing test names it.
The first two pilots kept only counts. search_calls=4 said a run searched four
times and nothing about what it searched for, so the obvious question — why
does a retrieval the host probe ranks first take more than one attempt — could
not be asked of the data at all. Sessions on disk are the fixture; the turn's
own messages were never saved. Answering it needed the runs again.
Each recall query now carries its ordinal, round, rank, result count, how it
differs from the one before it, and which of the host probe's terms it left
out. The probe is the oracle here: it ranks every target first, so the terms a
query lacks are what the model did not think to ask for.
Reformulation classes are set arithmetic over recall's own tokenizer — no
judge, nothing read out of prose. NearDuplicate is Jaccard >= 0.8 against the
previous query. HistoricalAnchor is a term the user's question never contained,
which had to come from the model's own memory of the history rather than from
rephrasing what it was asked.
The audit reports the funnel by attempt (found by query 1 / 2 / 3+ / never),
whether a hit was then read, and how many searches were issued after the target
had already come back — that last one is a result-to-read problem, not a query
problem, and the two want different fixes. Every query is printed in order, so
a surprising number can be read rather than inferred.
The round is recorded per attempt for a reason the first reading of this data
needed: two searches in one round are one parallel fan-out, not a retry.
Three additions the index experiment needed, and one rename.
An answer reachable through anything but recall invalidates the run. The
workspace is isolated, so this should never fire; asserting it beats trusting
it. A tripped run reports Contaminated, leaves every recovery statistic instead
of inflating one, and names the tool and marker that leaked. It fired once in
twenty-four — a bash call that reached this repository and read corpus.go —
which is exactly the case the gate exists for.
Escapes are placed rather than counted: before any recall is a different
strategy from after one came back short. They are behaviour, not noise. How a
model routes its uncertainty when its memory is missing is part of what this
measures, so nothing blocks them.
DirectRead is RecallReadWithoutSearch, and its stage is CueRead. read_file is
also a read, and in an audit trail the two mean opposite things: one is the
index doing its job, the other is the model giving up on memory.
The index report now walks each task across its own cue boundary — the
narrowest scale that still addresses it, against the next one down — and pools
the two sides. A four-arm average mixes six questions of different difficulty
into each number; a boundary row holds the question fixed and moves only the
affordance.
The first pilot reported search-off recovering 6/6 with no search and no read,
every run reaching for bash and read_file. The answers are nonces, so that was
not a guess. Reading one back explains it: the model grepped corpus.go out of
this repository and quoted the file's own line about every answer being "a
nonce appearing in one folded message and nowhere else", then said so.
The corpus lives in the tree the agent was started in. So the run moves into an
empty directory first, and the fixtures stay outside it — a session file the
model can read is the canonical transcript with the answer in it.
Two more things that made those numbers meaningless.
Three search tasks were addressed by the fold index. A tool call always earns
an index line, so r04, r05 and r06 were measuring the index, not search — which
is why they came back DirectRead in the arm that cannot search. They are the
assistant's own account of the same runs now. The check meant to catch this
matched the task id against the index, and a task id never appears there; it
matches the target's address, and fails all three loudly.
Metrics kept no answer text, so a surprising run could not be read back at all.
FinalAnswer is retained, bounded. A metric nobody can audit is a claim.
Also: the stage for "opened addresses, none of them the target" was called
NoSearch, which reads as a choice the model made — in the search-off arm it is
not one. It is ReadMissed.
With the workspace isolated, the same twelve runs separate:
search-on 6/6 recovered search/task 2.17 recall 197 tok/task
search-off 2/6 recovered search/task 0.00 recall 1336 tok/task
ReadMissed=3 NoRetrieval=1 DirectRead=2
Both arms still reach for the workspace and find an empty one. The two the
search-off arm recovered, it brute-forced by reading addresses until one paid
off, at roughly seven times the page-in cost.
A run mode that has never run is a run mode that fails on its first paid call.
-dry drives fixture, resume, tool surface, scoring and report with a scripted
provider that walks the chain a competent model would: search, read the best
hit, answer with what came back.
It found three harness bugs that would each have cost real calls to discover.
The script decided what to do from a call counter, and one provider serves
every task in a run, so task two started where task one left off — eleven of
twelve runs scored NoRetrieval. It reads the request now.
Then it looked for the last tool result anywhere in the request. A fixture's
own history is full of tool results, so every turn looked like a continuation
and none like a first round. It reads the last message.
Then it sent a query in the arm that has no query parameter. The runtime
refused it, and the refusal scored as SearchMiss — a failed search, when
nothing had searched. It honours the schema now, which is what a model does.
With those fixed both experiments walk end to end:
search-on 6/6 Recovered search/task 1.00 recall-tok/task 288
search-off 6/6 NoRetrieval search/task 0.00 recall-tok/task 0
index-* 24/24 Recovered identical across all four scales
The index arms being identical is correct and worth saying: the script always
searches and never uses a cue, so it is the always-search baseline. Telling the
four scales apart is the model's job, and the difference will show up as
DirectRead — which the scorer's own tests already cover.
Nothing here calls a provider. The pilot is the next thing that spends.
The experiments can now be run and read. Two modes drive the fixtures against a
real provider — run-search for the twelve-run stop/go, run-index for the
twenty-four that follow — and every judgement they make comes from a tool call's
name and its JSON arguments, or from a canonical position. "I will search the
folded context" is prose, and prose is not a search.
The funnel is the point. A run lands in exactly one stage, chosen at the first
step that did not happen:
NoRetrieval recall was never called
SearchMiss searched, the target never came back
HitNotRead the target was returned and never opened
ReadButAnswerWrong opened it and still answered wrong
DirectRead read from an index address with no search at all
Recovered
So a failed arm says where it broke rather than only that it did. Those four
failures want four different fixes, and a single pass rate would hide which.
Two counters are kept apart for the same reason: SearchHits is a search that
returned something, TargetSearchHits is one that returned the target. The gap
between them is query quality, and collapsing it would score a model that
searched badly the same as one that searched well.
Target hits and reads are matched on the canonical position the fixture already
knows, never on snippet wording — and a read is credited to the caller's own
address, which is what recall's span returns the results under. DirectRead is
separated from Recovered because it is the index arm's whole claim: the address
was on screen, so no search was needed.
RecallReturnedTokens is what recall paged back in, which is the other half of
the index tradeoff: a smaller index saves resident prefix and pays here instead.
UnexpectedWorkTools records reaching for the workspace instead of recall —
reported beside the outcome, not overriding it, since the interesting case is an
arm where the model changes strategy.
Answers are graded on nonces, every marker required, no judge model.
Verified by making each fail: score prose containing "search" as a search and
the narration test names it; match a snippet instead of a position and a run
that missed its target reports HitNotRead.
armsFor is now the single declaration of which arms a task runs at, so the
preflight cannot clear one set of arms while the run measures another.
Still no provider called under go test. The run modes need DEEPSEEK_API_KEY and
are the only thing here that spends anything.
The fixtures were checked against the model-visible projection. That is what
the agent believes; a provider.Request is what the model is told, and
rendering, sanitization and tool-schema construction sit in between. So each of
the 36 arm states now resumes the way a restart does — transcript from disk,
projection from its sidecar, the shipped tool surface at that arm — runs one
round against a capture provider, and is judged on the request it produced.
The capture answers with a clean terminal response. An empty one would buy an
empty-final retry and a second request, and the round under inspection is the
first. The turn's request is the one carrying tools: a fold on the way in asks
the same provider for a digest, and taking request[0] blindly inspected the
summarizer's prompt instead.
The scan enumerates the provider-visible surface rather than marshalling a
Message whole — content, reasoning_content, tool-call names and arguments,
responses_items, and every tool name, description and parameter schema — so a
leak is reported where it is, and local-only fields the boundary is supposed to
strip cannot produce a false hit. Those are asserted absent in their own right:
raw_content, LocalOnly, decision receipts, tool execution and memory citations
crossing the boundary is a regression whether or not a marker rides along.
It found what the projection check could not. i05's answer marker "session"
was readable in tools[0].parameters and tools[2].parameters — in all four arms,
because a tool schema is not part of any projection. r01's "partial" was one
ordinary word away from the same fate. Both are nonces now.
Search arms are held to the ablation's definition: both keep positions, only
the control has query, and the search-off arm's request may not contain the
wording that offers a search — checked over the whole surface, not just the
schema, so a description or an index line cannot reintroduce it.
Getting a resumed fixture to keep its projection turned up one more thing worth
recording: projectionCoversTail cannot revalidate a projection that covers the
entire transcript, since it has neither a matching transcript version nor a
live tail to sit in front of. Real sessions always have one, because work
continues after a fold. The fixtures now do too.
Verified by making each fail: stop scanning tool schemas and i05's leak goes
quiet; leave the search tool unswapped and the search-off arm is named twice,
once for the parameter and once for the wording.
benchmarks/* join cmd/ and desktop/ as hosts in the layering rule. Each is a
main that assembles a real agent, and a harness reproducing the shipped tool
surface has to reach the frontend that defines it. Nothing is opened to the
kernel by this: the rule that matters is that nothing below a frontend may
import one, and a benchmark is above every frontend.
No provider is called anywhere in this commit. This is the gate that says the
API spend would measure what it claims to.
Twelve recall questions, built by folding a real session and checked before any
of them is worth a provider bill. Six ask whether search reaches what no index
addresses; six ask what an index line is worth, across the four budgets.
The fixtures are real sessions. buildFixture plays a history through the actual
agent, calls CompactNow, and lets the runtime write both the transcript and its
context state where it normally does — so the loader, the projection restore
and the arm all have to work on them. A bench-only state shape would let the
experiment pass on a state the runtime never produces.
Preflight is the point of this commit. For every arm a task will run at, it
rebuilds the fixture and checks the state the model is actually handed: the
answer unreadable, the corpus findable by its own probe query at rank <= 5, and
for an index task the cue addressed at exactly the scales its tier names. Three
conditions, thirty-six arm states, all of it before a single API call.
Building it found three things about the mechanism the corpus had to be
rewritten around.
A cue's tier is set by what follows it, not by what precedes it. mergeFoldIndex
trims a cumulative index from its oldest end, so the first draft — which padded
ahead of the target — produced the same visibility at every padding size, since
the target stayed the oldest line and was always trimmed first. PlantAfterGen
replaced it and gives a clean staircase: planted after generation 4 a cue
survives to quarter, after 2 to half, after 0 to default only. Two tasks per
tier, so the boundary is crossed by the same question rather than by two
different ones.
A failing command cannot be an index cue. coverageDemands hands failures to the
digest, and the host backstop keeps them besides, so a failure never reaches
the fold index at all — the first i04 was visible in all four arms including
off, measuring nothing.
A failing tool result cannot be a search target either. KeepErrors deliberately
holds failing output through a fold, which is good news about failure evidence
and fatal to a recall question: preflight caught r04 and r05 with their answers
still on screen in every arm. Both are reported diagnostics now, which is also
what r06 already was and why it passed from the start.
Verified by making it fail: declare a cue at the wrong tier and the arms it is
missing from are named; leak an answer into a tool argument and every arm that
can read it is named.
go test runs the whole preflight (-short skips the 36 folds). No provider is
called anywhere in this commit.
The next experiment asks two questions the first bench cannot: whether an agent
reaches for search when it has forgotten something, and what the ambient
address list is worth in tokens. Both need arms, and the arms need knobs.
recall-search is a real subsystem ablation, so it joins Modules(). Off, it
restores what recall could do before the folded region became searchable: the
runtime refuses a query, the provider-visible schema loses its query parameter
and its description stops offering one, and the line the index writes when it
trims an address stops pointing at a search. An arm told about a capability it
does not have is not the arm before the feature.
The fold-index scale is not a module and does not become one. Set gains an axis
beside the module set: default, half, quarter, off. Four steps rather than a
free-form fraction, because the question is whether an ambient address list has
marginal utility, not where its optimum sits, and a float would turn a
measurement into a tuning knob nobody asked for. It scales the floor along with
the budget — a surviving floor would make the off arm a small index rather
than none.
It reaches Arm() either way: "index-quarter" is not the control arm, and
results keyed by arm name would otherwise merge with it. Both axes travel to
the child binary through one appendArmArgs, because String() carries modules
only and the four call sites that passed it would each have dropped the scale.
ParseArm reads both at once for the same reason.
Measured through the projection, not the flag: 400 tool calls folded at a 120k
window publish 112 index lines at the shipped budget, 58 at half, 28 at a
quarter, 0 off.
Invariants locked by tests, each verified by making it fail:
- the scale not reaching foldIndexBudget -> all four arms publish 112 lines
- the runtime gate held open -> search runs in the arm without it
- the trim line not reverted -> the arm is offered a query
- boot not swapping the tool -> the arm is shown a query parameter
No user-facing surface: both flags are documented as benchmark arms on the same
`run` subcommand -ablate already lives on, and nothing reads them from config.
The repolint budgets for cli.go held it to one line, which is why the flag pair
moved into bench_flags.go rather than growing the god object by seven.
Cache-impact: none on the control arm — the shipped schema, prompt and index
wording are byte-identical; only an explicitly selected arm differs.
Cache-guard: go test ./internal/boot/ (golden baselines unchanged)
Documentation-impact: none.
The bench asks one question fifty times: a fact stated once at the start, is it
still reachable? It separates the two carriers, because they lose their address
by different routes and on different schedules.
Assistant reasoning never enters the fold index at all — buildFoldIndex records
user turns and tool calls, not what the model concluded — so it is out of sight
from the first fold and search is its only route for all fifty generations. A
tool call does get an index line, which holds until the budget trims it; the
run reports where that happens (generation 17 here) and verifies search across
every generation after. Neither carrier is a user turn: keepUserTurns holds old
ones verbatim oldest-first, so a fact planted there stays on screen and would
make the whole bench pass for the wrong reason.
Every generation asserts the model cannot already see the fact before claiming
search found it, and a run where that never became true fails rather than
reporting success — without that guard a loop that skips every case is green.
Rank holds at 1 across 15, 123 and 603 folded messages, at a flat 47 tokens
against a 6000 budget, so a search does not get quietly worse or dearer as the
session it searches grows.
The bench found one real gap, fixed here. recall rendered its result through
renderTranscript, which summarizes tool-call arguments to stop a digest
reproducing a long one (#4317). Applied to a recall that meant searching for a
command, finding it, reading it back, and getting "[assistant calls bash]
{command} (1 keys)" — the address without the fact. That leak path is a digest
becoming a user message; a recall result is a tool result, and the recall
budget already bounds its size. So recall renders arguments verbatim and
summarizer input keeps summarizing them.
Verified by making each fail: fold the arguments again and the tool_input
carrier loses its wording; drop the "never left sight" guard and a bench that
tested nothing passes.
Cache-impact: none — tests and a recall-only rendering path.
Documentation-impact: none.
recall could only read a position something else had already named. The two
things that name one both decay. The fold index is bounded at 1% of the window
and trims its oldest lines to stay there. And canonicalOriginFor returns -1 for
anything inside a previous projection, so a message folded a second time has no
canonical position to derive at all -- not evicted, never minted.
Underneath, nothing was lost: compaction rewrites what the model sees and never
touches the canonical transcript, and every commit records how far into it the
projection reaches. The transcript kept the data while the model lost the
address for it.
So recall gains a query. It searches canonical[:CoveredCount] -- the backing
store, never the projection -- through the same BM25 primitives history uses
(internal/retrieval; history.Searcher itself imports agent, so only the
algorithm is shared, not the searcher). Addresses come back straight out of the
transcript, which is why generation count stops mattering: there is no
provenance to preserve across folds when nothing asks a projection where a
message came from.
Addressing differs from history's on purpose. A tool result is matched on its
own output but addressed by the assistant call above it, because recallSpan
reads a call together with its results -- pointing at the result would return
output without the command that produced it.
Search and read share one budget. A search pages folded content back in exactly
as a read does, and two budgets would let one refill what the other refused.
An empty result costs nothing and is not an error: "it is not in the folded
region" is an answer, and failing the call reads as the search being broken.
Invariants locked by tests, each verified by making it fail:
- search the projection instead of canonical -> no hits at all, every
generation, which is the whole reason this reads the backing store
- address a tool result by itself -> the command is lost
- search canonical[:] instead of the covered -> the live tail comes back
Every claim about finding a fact first asserts the model cannot already see it
(foldedOutOfSight); the first draft of this test passed for the wrong reason,
because keepUserTurns holds old user turns verbatim oldest-first and the
fixture's fact was still on screen. It is assistant reasoning now.
Also: CJK queries (the tokenizer's bigrams are what make a Chinese session
searchable), and the index's trim line now points at the search instead of
saying only that the transcript still holds what it dropped.
No cache budget was raised to land this. The visible-schema ceiling caught the
first draft 214 tokens over; the explanation of why to search moved out of the
prefix into the index line that trims, and the kind filter was dropped rather
than charged to every session for a convenience nothing had asked for.
Search cost is linear and small enough to need no index: 2.3ms at 1k folded
messages, 11ms at 5k, 44ms at 20k (BenchmarkSearchFoldedRegion, kept as the
regression guard). A generation-scoped cache was designed and not built.
Cache-impact: one-time. The recall schema changes, so the tool prefix moves
once on upgrade; the golden baselines are regenerated for it.
Cache-guard: go test ./internal/boot/ (prefix_shape + tool_schemas updated)
Documentation-impact: none.
The fold index and the digest divide the fold between them. buildFoldIndex
skips a change with a path and a failed command on the digest's promise to
carry them (coverageDemands), so nothing lands between the two. Two paths
break that promise and nothing was catching either.
Under mustFree, repairFoldCoverage measures the coverage and then returns
without spending the retry -- correctly, since the hard ceiling is no place
for another summarizer call. And when the summarizer fails outright,
degradeFoldSummary installs a mechanical digest that carries nothing at all.
In both cases the change is absent from the digest, absent from the index, and
still present in the canonical transcript: data retained, address lost.
measureFoldCoverage already computes exactly what went missing. So the host
writes it back itself -- paths and command signatures out of the same
receipts, no tool output repeated and nothing inferred:
## Host-retained fold facts
The summary above does not cover these, and the fold index omits them.
- changed: internal/parser/reader.go
- failed: go test
Only what the digest dropped, so a digest that did its job costs nothing. The
block is bounded and says how many facts it left out, because a list that just
stops reads as a complete one. It is written before the projection is measured,
so acceptance already prices it and no fold gets rejected for carrying it.
mustFree now means "prose may degrade", not "state may vanish".
The degraded path also measures its coverage now. It never did, so a fold that
lost every change reported CoverageRequired=0 / CoverageMissing=0 -- the same
receipt a perfect digest writes. The CLI and desktop cards say when the host
completed a digest rather than the summarizer.
Invariants locked by tests, each verified by making it fail:
- no backstop under mustFree -> the changed files leave the projection
- no backstop when degraded -> a proven change and failure are lost
- degraded coverage unmeasured -> total loss reports as nothing to cover
Plus: silent on a complete digest, silent about reads (the index owns those),
and explicit about what a truncated list left out.
Cache-impact: none -- compaction rewrites history by construction; the
system-prompt prefix is untouched.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none.
The execution layer was already asynchronous; the agent's control flow was
not. A background job ran to completion, queued a sentence, and then waited
for the user to say something before the model could learn it had finished.
The only way for a turn to act on its own background work was to poll it with
wait, which is what #7948 reports.
Nothing here builds a second runtime for that. The session inbox already is
one: durable, idempotent, crash-recoverable, with a level-triggered dispatcher
that admits work only when the session is idle, and a steer path that consumes
guidance at the run loop's model-round boundary. What was missing was one wire
between the job table and it.
So jobs publishes a CompletionEvent -- an identity, not a sentence:
completion/<job-id>/terminal-v1 is derived from the job, so a delivery path
that runs twice can tell a replay from a second completion. The controller
takes it and translates it into a host continuation on the queue it already
owns. Nothing under the frontend learns what an LLM is: returning nil from the
observer means the event reached durable storage, never that a model read it.
The observer fires after close(j.done), not with the note. recordCompletion
stays where it was for the reason its comment gives -- an observed terminal
status has to imply the note is queued -- but a wake-up is different in kind:
it can start a turn whose first question is about this job, and a turn that
can still observe Running has been told a lie.
Which delivery a completion gets is never decided by reading whether a turn is
running. The turn can end in the gap between that read and the enqueue. The
steer attempt itself reports which world it is in: taken means the live turn
has it, refused converts the item to a follow-up and kicks the dispatcher.
Both notification paths survive, and exactly one delivers. A completion the
observer took is acknowledged out of the manager's own queue; one it could not
take stays there and rides the next real user turn. A session with no durable
inbox (headless run, e2ebench) degrades to the old path rather than failing.
Host attribution now travels with the item instead of with the call site.
PromptOrigin lives in the manifest, not the envelope -- one place to read, and
nothing for UpdateItem to fork when it rewrites a blob. A host item steers
through SteerHostItem, runs synthetic when it degrades to a follow-up, keeps
its attribution when an abnormal exit records it unapplied, and is named as a
background follow-on in the queue panel rather than as a line the user typed.
Invariants locked by tests, each verified by making it fail:
- the observer runs before terminal publication -> status reads "running"
- the host dispatch is dropped -> the model is told the user spoke
- the legacy note is not acknowledged -> the next turn hears it twice
Plus an end-to-end over the real jobs.Manager and Controller: a background
job finishing is enough to give the session a turn, with nothing left queued.
Known gaps, both deliberately out of scope:
- A host follow-up persists as a user-role message, so previews, titles and
user-turn counts still read it as the user's. Fixing that wants a
structured marker on provider.Message, which also retires the 17-prefix
SyntheticUserPrefixes table -- a change of its own.
- The jobs -> inbox handoff is not yet crash-safe: a process that dies
between terminal metadata and the enqueue loses the wake-up. The
deterministic event id makes redelivery free, so reconciliation at bind is
a separate, small change.
Cache-impact: none -- the system-prompt prefix is untouched; a continuation
rides the turn tail like every other host notice.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none -- no docs/*.md describes the completion note path.
Repolint: no budget widened; agent.go 1617->1597, controller.go 5056->5041,
jobs.go 1174->1141 as the moved code lands where it belongs.
Reported in #9524. The font setting explained that a name it cannot find
"不会弄花" the interface, which is a worry nobody had, and never said what
the line underneath it was for. The empty session opened on 交待一件事,
它自己往下做 over a subtitle about a 机器记录 rather than a 叙事 — the
implementation's own vocabulary, on the first screen a new user sees.
Both are rewritten to the same three things: what you can do, what you will
observe, and the limit when there is one. The font hint now says the text
below previews the typeface and that an unavailable one falls back to the
default; the sample says 字体预览 rather than carrying a slogan; the hero
says what the agent can do and where the run is visible.
The issue says it is listing two examples, and it is: the voice runs
through the rest of settings. Swept src/ui with the same three rules,
taking the hints, descriptions, empty states and onboarding copy and
leaving buttons and short labels alone. What changed is the kind of
phrase the reporter pointed at — slang (有活儿在跑, 拽回来, 真有的,
挡得住, 答得上来), a door metaphor for a provider protocol, sentences
written from inside the implementation, and literary asides such as 背景值
一圈留白,不值一段读不清的正文. Copy that was already plain was left as
it was. 51 strings in all, English updated alongside each, since the
Chinese source text is the catalogue key and i18n.test.ts holds the pair
together.
Two things surfaced while sweeping and are worth knowing separately. The
font hint had two catalogue entries differing by one space, only the
second of which anything referenced; it is gone with the rewrite. A
second orphan, the rule-format hint in en_settings.ts, has no source at
all and is left alone here. A general count of dead keys is not offered
because it cannot be measured reliably: Chinese also reaches t() through
data tables, and en_storage.ts keys one entry without quotes, so a scan
misses references in one direction and invents them in the other.
No behavior, no layout, no repolint rule — copy quality is not something
an AST can judge, and a blacklist of colloquialisms would only catch the
words already fixed here.
pnpm test (228), pnpm typecheck and pnpm build pass; repolint is clean.
REASONIX.md says behavior goes on the controller so all three frontends
inherit it, and nothing read that back. serve is handed SessionAPI, which
declares SwitchBranch, and never calls it: session branching ended up
chat-TUI-only by accident rather than by decision, and 149 more edges are
in the same state.
frontend-parity reads the structure that settles it — a capability at
least one frontend already drives, a frontend whose declared port carries
it, and no call — and ratchets on missing edges, 150 today across 100
capabilities. A frontend whose port does not carry the capability reads
n/a: that scope decision is port.go's, and the compiler already keeps it.
port_segregation_test asks the opposite question at sub-port granularity,
so neither subsumes the other.
The matrix comes from go/types over compiled export data, never from
selector names: Close and Status are capabilities and also stdlib
methods, so a name match records os.File.Close as serve driving
Lifecycle.Close — a false "wired" that hides debt, the one direction a
ratchet must not fail in. It also resolves `ctrl := m.ctrl` in
chat_tui_watchdog.go, which no receiver-name heuristic can. The check is
pinned to CGO_ENABLED=0 GOOS=linux, the CLI's own release build, because
internal/cli carries _windows and _unix files and a GOOS-dependent file
set would put a developer's baseline at odds with CI. Warm, go list
answers in half a second; with no build it fails loudly rather than
passing.
An exception goes in the scope table with an issue and a reason, weighs
what the edge weighed, and lands against this file's own budget, so
declaring one moves debt into a diff a reviewer reads instead of deleting
it. The table ships empty: every row is a decision for its own change.
Verified by removing the cli Cancel wiring, which took controller.go from
87 to 88 and the ceiling from 150 to 151, and failed.
Two things about the baseline diff. Recording a new rule raises every
file it touches from zero, which the ratchet refuses without
-allow-widen; that refusal fired and this is the justification. The same
-update also reclaimed slack the tree had already stopped using and that
only -update can collect: complexity 1562->1529, essay 3276->3250,
file-size 42270->42038, function-size 6728->6639, struct-state 47->41.
One boundary is measured and left open. A capability whose last consumer
goes away leaves the candidate set, so its row retires and the debt falls
instead of failing. A port declaring a capability nobody drives is a dead
capability — 39 of them, 122 edges — and that is orphan's shape, which
orphan cannot see because it counts functions and never methods.
An adopted desktop runtime missed the interactive frontend wiring. Three
entry points put a runtime into a Hub and only two arrived bound: Open wired
its own, RunGracefulListener swept the adopted ones on its way to serving, and
the desktop window takes neither road — it adopts its first pane and serves it
through Handler. Nothing under desktop/ has ever called
EnableInteractiveApproval, and boot does not: its own comment says interactive
frontends do.
So the conversation that pane carried had no Asker, and every ask in it
answered itself with the model-assumption fallback while a person sat in front
of it. A pane opened afterwards went through Open and worked, which is what
made it read as "only new sessions can ask", and why going back to an older
build did not help — the gap is structural, not a regression (#9509, #9527).
Not the approval gate: SetToolApprovalMode already reaches
refreshInteractiveGate, so the ordinary tool gate was interactive. What was
missing is the rest of the contract — executor and runner Askers, the sandbox
escape and managed-config approvers, the planner plan approver.
The invariant moves to the publication boundary. publish knows which runtimes
are local, and a local one a Hub publishes is by definition driven by an
interactive frontend; Adopt binds auth, surface, stance, Grant and the session
lease already, so "the host assembled this runtime" was never "the Hub applies
nothing to it". Open's explicit call and RunGracefulListener's catch-up loop
both go, leaving that method as transport again. A fourth entry point cannot
now repeat this.
No HubOptions.Interactive: the sweep it replaces never consulted Surface
either, and Surface classifies a frontend for usage rather than granting it
capability. Headless already has its own contract in ApplyHeadlessApprovalMode.
The test adopts without a listener — the desktop's exact shape — and follows
the ask through to the answer. It fails on the reported string without the
invariant. A second one publishes a remote runtime, which has no Server here at
all, so the rule stays on the side of the boundary that owns a controller.
One-shot instrumentation, and it is spent. It ruled out the three stable causes
— the binary the runner resolves, ambient ssh_config, and the config the test
writes — and that is the whole of what an isolated probe can say. The finding
lives at the test it explains; the probe itself and its output live in history.
Keeping it would cost no CI time and still mislead: the next reader would take
it for the current diagnostic, when it only answers "is ssh healthy in
isolation", which is answered. A recurrence needs a different instrument
anyway — the full suite running while an external command is timed across Start
and Wait separately, with a light periodic probe recording the latency
distribution — and that is worth building when there is something to point it
at, not before.
The Windows CI failure at 15.50s matched the product's own 15s subprocess
timeout exactly, so ssh was killed rather than refused. A probe on the same
runner ruled out the three things that would have made it a real defect: the
binary it resolves (Windows OpenSSH 9.5p2, not the Git one a maintainer has),
ambient configuration (none of the three ssh_config paths exist there), and the
config the test writes — that exact file answers in 43ms, correctly, and the
test itself passes in 30ms.
What that leaves is convergence, not proof: the failure is workload-dependent,
appearing only while the whole tree runs, and the specific contention is
unidentified. It need not be the CPU scheduler — CreateProcess, Defender, handle
pressure and disk all sit on that path.
So the note says what not to do. Shortening the 15s would make a normal but
loaded machine fail sooner, and reading a timeout as "OpenSSH unavailable" would
unlock a fallback to the embedded parser — which can resolve an alias to a
different host than the user's own ssh, and does so exactly where ssh is slow
for interesting reasons (Include, Match exec, canonicalisation).
If it recurs, the fix is at this level, not that one: the test binds resolution
logic to whether a real subprocess gets scheduled, and those are two failure
domains that can be separated without giving up either.
workflow_dispatch needs the file on the default branch to be dispatchable and
this one lives only on studio, so it fires on a push that touches itself.
TestResolveJumpHostsUsesConfiguredAliasesAndSSHConfig fails there with
`ssh -G "bastion": exit status 1` after 15.50s — the same 15s SSHConfigSource
allows the command, so it was killed rather than refused. The identical command
answers in ~70ms on a maintainer's Windows machine, so this is not a slow
runner.
The probe separates the three things it could be: the ssh binary the runner
resolves (Get-Command -All, where.exe, ssh -V), the runner's ambient config
(HOME/USERPROFILE and every ssh_config path that exists, printed), or the config
the test writes — an empty -F answering fast puts the cause in configuration, a
hang there puts it in the binary. The last probe uses the test's own config with
-vvv and reports elapsed time and whether it had to be killed, so a 15s kill is
told apart from a refusal.
Deliberately no product change. A timeout is not evidence that OpenSSH is
unavailable, and the fallback it would unlock is the embedded parser — which can
resolve an alias to a different host than the user's own ssh would. That is
worth discussing only with evidence about which timeouts are safe, and the
reasons `ssh -G` can hang (Include, Match exec, canonicalisation) are exactly
the ones where the two parsers disagree.
TestTaskToolCancelDuringStuckProviderReturnsPromptly carried two numbers, and
both were guesses. The 50ms sleep before cancel() bet that the goroutine had
reached the provider by then; on a runner where this package takes 215s against
52s locally, that bet loses, and the cancel lands before the stream loop is
entered — a different path than the one the test names. The 500ms watchdog is
what actually failed on Windows CI.
The sleep is gone. consumingStreamProvider hands over one chunk on an unbuffered
channel and never another, so the send returning is proof the run took it: the
test waits for that and cancels knowing the run is blocked on a message that
will not come. It is a stronger point than signalling from Stream's entry, which
would only prove the provider was called.
TestSSHHandshakeHonorsContextWhenDeadlinesUnsupported gets the watchdog change
alone. Its 40ms context is the subject — a handshake honouring a deadline it
cannot install on the socket — so widening that for slow CI would delete the
assertion. The residual race is written down rather than fixed: a slow enough
runner expires that context before the SetDeadline branch is reached, and
closing it needs noDeadlineConn to report the call and a context that still
carries a real deadline. Recorded so the next reader does not remove the timer
the way this commit removes the sleep.
Both budgets now come from testenv.Budget, which is acp's rpcCallBudget moved
where the other packages with the same kind of wait can read it; rpcCallBudget
delegates rather than keeping a second copy.
The draft carried the narrative register the 2.8/2.9 notes use. This is the
document someone reads to decide whether to upgrade and what to re-test, so it
is restructured to be scanned: declarative bullets, one claim each, the
mechanism named rather than recounted.
Nothing is dropped — every change, measurement and issue reference is still
there, and Added grows subheadings so the five features separate at a glance.
Roughly half the length.
Below 1200px the workspace column folds, and the rule that folded it also hid
its divider:
[data-fold~="rail"] .gutter-l { display: none }
The grip on that divider is the only way to reopen the column — keys.ts has no
shortcut — so the session list left the window with nothing pointing at it. The
reporter's screenshot shows exactly that: the right divider's chevron still on
screen, the left edge bare. 1920x1080 at 175% scaling is 1097 logical pixels, so
this is an ordinary laptop, not a corner.
The rule was added believing the column is removed at that width ("a handle for
a column that doesn't exist"). It is not: .app[data-rail="off"] narrows it to
0px, and :792/:795 style that divider specifically for the collapsed state. The
block's own opening comment already said so — collapse, never remove, or "the
only entry left is an invisible shortcut."
The right column had the same hole for a different reason. Hiding .gutter-r is
right while that column is open, because it lies across a full-width row where a
vertical drag means nothing; it is wrong while the column is collapsed, because
the fold hides the column too and the grip is again the only way back. The rule
now says which state it means.
perf/fold.mjs holds both. The invariant is reachability, not the presence of any
particular control: a folded column must either still be on screen or still have
a grip that can be hit — a relocating column satisfies it, a vanished one does
not. Read structurally (display/visibility/opacity up the chain, then
elementFromPoint, because something painted over the grip fails the user the
same way), and the left column is followed through the click to prove the way
back actually works. It found the right column's half on its first run.
panels.mjs asserted the defect — shown(".gutter-l") === false at narrow widths —
which is how this survived. Corrected to the invariant.
Reported as #9507: "the session list is gone, every launch is a new session."
The second half follows from the first — with no way to reach the list, there is
no way to resume.
rpcCallBudget already exists and its comment already names this failure: "a hang
detector, not a latency assertion ... the previous two seconds failed whenever
the tree was tested at once." It was written for one call site and the other 31
waits kept the literal.
They are all the same thing — every one fails with "never started", "was never
called", "timed out waiting". None is a negative assertion waiting to confirm
something does not arrive, so none of them wants a short deadline; a wait that
fires early reports a hang that did not happen, on a runner that was merely
busy.
TestE2EDeleteActiveSessionDoesNotRecreateFiles failed this way on Windows CI
while the full tree ran: two seconds to cover a server start, a session open, a
provider turn and a tool dispatch. The same test, and the whole suite, pass on
the same platform locally.
Also restores 2.9.0.md to the text its tag shipped. Three commits landed after
studio-v2.9.0 was cut and appended their entries to that file, so it described
work no release contained; those entries move to 2.10.0, which is the release
that actually carries them.
The `ask` tool reaches a person through exactly one wire: the Asker that
EnableInteractiveApproval installs on the executor. Nothing in the tree asserted
that wire was live. An assembly that stopped installing it answers every
question with the headless model-assumption fallback — the run decides for the
user instead of asking, and no gate says anything.
Both directions, because either alone can be satisfied by the wrong thing: a
controller with interactive approval on must put the question out and take the
answer back, and one without must return the fallback and must not raise a
question nobody is there to answer.
Reported as #9509 (2.7.0 fine, 2.8.0 and 2.9.0 not). These pass at HEAD, so they
do not reproduce it — they close the hole that let it go unnoticed, and narrow
where it can still be: not in this wiring.
All three had been red on CI since the work they cover landed, each for its own
reason and none of them a defect in the code under test.
- The ACP approval round trip asserted the tool result equalled "written ok".
A call that changes what the host owes now carries the obligation delta after
its own output, so the assertion reads a prefix: the subject is that the
approved tool ran and reported, not what the host appended to it.
- attach's fake machine still answered `command -v reasonix` in the shape
LocateCommand used before it reported `bin`/`ver`/`flag` lines. The bootstrap
therefore saw no kernel over there, tried to install one, and failed with the
npm sentinel — a harness the far side had outgrown, not a broken locate.
ensure_test.go's fake already spoke the current shape.
- browse's helper resolved the temp dir through its symlink and required the far
side to answer with what it resolved to. On macOS it answers with the spelling
it was handed, so both sides are resolved before they are compared: which
spelling comes back is that machine's business, and the test's subject is
which directory it named. The tests skip on Windows, which is why only CI saw
the first two.
VerificationIdentity kept every segment standing before the check, so
`cd /w && ls -la && go test ./...` and `cd /w && go test ./...` were two
identities that could never supersede each other — and a check that just ran
green is exactly the thing that should replace the unreadable run before it.
Its own doc already said what it meant to keep: context, "a segment before the
check decides where and under what it runs". A listing or a status standing
beside the check decides nothing it proves. The identity now keeps only the
segments that set context — an assignment, or cd/pushd/popd/export/set/unset/
source — and drops the rest, as it already dropped everything after the check.
That is the shell's own account of what changes the environment a later command
runs in, read off the parsed argv, not a guess at what a command was for.
TestSameCheckInADifferentWrapperIsOneItem fails without it.
checkContract carried a lazy write: the first caller to ask what the task owed
captured the baseline the task is held to. Every parallel tool call asks as it
finishes, so that first ask arrives on several goroutines at once — -race
reports the read at final_readiness.go against the write beside it, through
obligations -> finishToolExecution -> executeOne -> runParallel.
Two baselines could overwrite each other, and the baseline is what decides
whether the turn's delivery claim stands.
The capture moves to beginRunTurn, where nothing else is running, and the ask
becomes a pure read. That is also the stronger reading of its own rule: the
baseline must come from the pre-image, before any call could rewrite the
declaration it is taken from. Guarding the write instead would have kept a
mutable field on a path that has no reason to write at all.
TestCancelInsideLargeParallelBatchStopsSchedulingNewTools and
TestBuildRegistersUsableHistoryAndMemoryRetrievalTools both go green under
-race; both failed on it before.
Baseline evaluation ran the toolchain once per captured criterion at the end of
every turn. A broad mutation captures every test file in the tree, so a turn
that rewrote none of them still paid a build per file — the model had answered
and the window sat spinning while the host quietly built.
A criterion the workspace still holds byte for byte was run by the project's own
checks; the overlay exists for the ones it no longer holds. Unchanged criteria
now produce no fact and no build, and the first evaluation that does run
announces the checking phase rather than leaving an unexplained wait.
Splitting the wallet onto its own route added the call to sse.ts and left
apiPaths behind, so the request fell through to the assets and the rail read a
404. The route parity test caught it and had been failing since — in a nested
module the root `go test ./...` cannot see, which is where a red test hides
longest. `make test` now runs the shell and the SDK too.
Five holes let a turn answer the gate that was auditing it. Each is closed by
the same move: the fact comes from what the host observed, never from what
execution was free to rewrite.
- A `go test ./...` quoted inside a here-doc body satisfied a declared project
check that never ran. commandSegments split the raw text on separators, so the
body's lines became segments. SplitOutsideHereDoc drops the body and keeps the
commands beside it.
- A here-doc cost the whole command its decomposition, so a turn that ran its
tests next to one read as having skipped them, and `python - <<PY` walked past
the Delivery gate that refuses `python -c`. The shell tables now read the same
fact permission already reads.
- `cd backend && go test ./...` shared an identity with the root run, so a green
root suite cleared a red subdirectory one. Canonicalisation now drops spelling
and plumbing, never the context a segment before the check decides.
- An unclassified receipt took the pipeline's exit status for the check's, so
`go test ./... | tail` could clear the failure it was hiding.
- missingMutation was wired end to end and never incremented. An unprovable
change is now a debt only observation settles; a check that runs afterwards
speaks for the state, not for the effect.
Obligations are derived by diffing the ledger, never announced: nothing can emit
or erase its own debt, and the delta reaches the model with the result that made
it. Delivery stops refusing opaque interpreters — what the host cannot audit is
recorded unproven and owed, not blocked at the door.
Criteria the task began under are the host's. Declared checks keep a captured
baseline the current declaration cannot retract; test criteria are captured from
the pre-image before any call that could overwrite them, and evaluated through
`go test -overlay` against the code that replaced them. A rewritten test passing
answers for itself, not for the bytes it replaced.
Also: the credentials error names the file it reads instead of an env var it
never consults, the MCP rail shows the way out instead of the endpoint's words,
and run --metrics carries the turn's own verdict beside its outcome.
t.TempDir removes its directory once. On Windows a deleted file keeps its
directory entry until the last handle closes, so a test that finished while the
OS still held something it wrote fails the removal as non-empty when every file
is already gone. Measured at ~2% of session saves, always inside 10ms.
testenv.TempDir is a drop-in whose cleanup retries RemoveAll and logs the one
that never frees rather than failing the test. The desktop module cannot import
it, so its 15 files keep t.TempDir.
A turn request has six orthogonal parts — input, the line carrying its
@-refs, display text, the pre-edit original, the resolver, the output format —
plus a sync/guarded switch. Those were spelled out as 40 entry methods:
runRefTurnWithRefsFormat, runScopedRefTurnWithFormat,
runTurnLoopWithFrozenImagesRawDisplay, and so on down the cross product. The
branch that picked between them assigned closures over three variables in two
if statements, which is a data table written as control flow.
Approval had the same shape at smaller scale: requestApproval,
WithReason, WithReasonOptions, FreshDecision, DecisionWithOptions were one ask
and one grant, spread over five names by which arguments a caller happened to
have.
Both collapse to a value. approvalRequest carries tool/subject/args/reason and
the two postures that may answer instead of a human; refTurn carries the ref
turn's parts; orchestratedTurn already existed and now reaches the entry
points. 27 entry methods remain, and the port keeps its public shapes: no
frontend contract moved.
Collapsing the loop layer found a bug. runEditedGoalLoopWithImageRefsRawDisplay
was a copy of runTurnLoopWithPreparedTurn, and when e4e6936b1 taught a turn to
finish what it owes instead of showing a card that only says "continue", it
taught only one of the two. A resubmitted edit that ended short of verification
still got the card. The copy is gone and the edit rides the same loop;
TestEditedResubmitFinishesWhatItOwes fails without the fix.
One asymmetry is left as it was, not fixed blind: the edited branch of
turnLoopRunner still does not bind the turn's output format, where the ordinary
branch does.
Capabilities named 56 methods in one interface — skills, hooks, MCP, network,
shell, permissions, sandbox, config repair, extension UI, provider catalog. The
whole point of the driving port is that a frontend sees only what it drives,
and this one interface undid it: a pane listing skills was handed
SavePermissionRules and SaveSandboxSettings along with them.
It splits along the lines the frontends already use: SlashDispatch, Skills,
Hooks, MCPControl, RuntimeSettings, Extensions. CapabilityScope moves to
Lifecycle, where the workspace it describes already lives.
What that exposes is ACP. EditorAPI embedded the whole surface, so the
transport could reach skill authoring, hook editing and sandbox rules it has
never called. It now names the three ports it drives, and the segregation
guard puts its unused count at zero, from three.
No implementation changed; *Controller satisfies every new sub-port.
A diagnostic, to be reverted. release-signing keeps refusing the studio
line's requests with nothing but "Invalid request to SignPath API", and
the connector returns no validationResult to say which gate closed.
test-signing-ci-approval carries the same two checks — verifiesTrustedBuildSystem
and verifiesOriginPolicy — against the test certificate, so one run says
whether the origin setup is what refuses the line or whether it is something
only the release policy applies.
scripts/verify-windows-authenticode.ps1 came from the retired desktop line and
still described it: six executables at the payload root, a portable archive with
current.json and versions/vX.Y.Z/, and a mandatory -PortableArchivePath the
studio workflow never passed. Studio ships one executable. The step is gated on
HAS_SIGNPATH, which has never been true, so nothing has run it — turning the
gate on would have signed both artifacts and then failed on the verification
that was supposed to confirm the signatures.
It now checks what studio actually ships: the one executable in the payload, the
installer around it, and the copy inside the portable zip — that copy compared
by hash against the signed payload, because a signature check alone would pass
an archive packed from an earlier build that was also signed. The workflow
passes the archive path it was already producing.
Verified on a real Windows host across three cases: all three artifacts signed
and matching passes; an archive holding a different signed binary is refused as
a mismatch; an unsigned payload is refused as missing a signature.
The external services block was the only thing on the rail painted red: an
inset rule down its left edge, and the endpoint's whole complaint set in --err
under it. Three lines of red in a column of grey readouts reads as a dialog
that got stuck in a dashboard.
The rail already has a rule for this and it is written into .drow's tone: the
mark that carries "this is broken" takes the colour, the words describing it do
not. The pip stays red and the count in the heading still says how many; the
rule was a third way of saying the same thing, and the reason now sits at the
size and weight every other secondary line on the rail uses.
Reaching a machine is the gate that rarely fails. A cold connect then installs
a kernel over there, and that is where it stops — and every one of those stops
arrived as an English sentence with no code on it, because HostState.Err is a
string and the refusal-path guard only watches internal/serve. One of those
sentences told the reader to set serve_install = npm, which was not a control
the window had.
internal/remote/bootstrap now has sentinels for the ways putting a kernel there
fails, grouped by what the reader would do about each rather than by which
function produced it: npm is missing, npm installed outside the login PATH, the
local binary is for another platform, nothing is left to try, what landed will
not run, serve never reported a port. desktop/next maps them to dotted codes
through one table, and the wording for each names the next move. Two tests that
matched on error text now match on identity, which is the point of having it.
The host form grows the controls those sentences point at: the install strategy,
the jump host, and the env var holding a password. They were already stored,
already on the wire and already carried through a save — only the controls were
missing, so telling someone to change one was telling them to edit TOML.
And a connect no longer has to be the thing that discovers all this. Probe reads
what a first connect depends on in one session, changing nothing over there:
which machine it is, what is installed, whether npm runs, and for each install
route whether it is open — a closed one carrying the same code a real failure
would have refused with. `reasonix remote test` walks it after the dial it
already did, and every host row in the window has a button for it.
The wallet balance had been reaching the frontend for months with nothing
rendering it, and it rode GET /status — which the desktop polls four times a
second while a turn runs. A network round trip on that clock is what the reader
ends up waiting on, so it answers on its own route at its own pace now: pane
mount, turn end, or a click on the label. Four answers stay apart rather than
collapsing into one number. A provider with no balance_url draws nothing at all
(204) — most providers have none, and a dash there reads as a number that failed
to load. A value standing in past its freshness says how old it is. A wallet
that could not be read carries a code for which failure it was, never a zero.
internal/billing picks its decoder by endpoint host, the way the price catalogue
already does. Moonshot's wallet was silently broken: its response parses as
DeepSeek's shape without erroring and decodes into an empty balance, so a funded
account read back as unusable. A response in nobody's shape is ErrUnreadable now.
Plan.tsx and panels/Overview.tsx had no importer at all. The plan block the
design calls for was written, styled and never mounted, so the plan lived only
in a tool card that scrolls away and a tab you have to switch to. Plan is back
where the design put it, beside the changed files; Overview is deleted with the
six translations only it used.
Both defects were invisible to every check the tree had, so both get a guard.
route_parity holds internal/serve's routes against vite.config.ts's proxy list:
a route missing there answers with the SPA shell, which is indistinguishable
from a broken endpoint and only happens in dev. orphan.test.ts fails a .tsx
nothing imports.
`perf/i18n.mjs` reported six missing English strings in the storage panel. All
six were there. Chinese is a valid JavaScript identifier, so a catalogue can
write either `"占用": "Space used"` or `占用: "Space used"`, and en_storage.ts
writes the second — while the guard's key regex only ever matched the first.
Seventeen keys were invisible to it, six of them reachable from a `t()` call,
and those six came back as "no translation for this".
A permanently red guard is the same failure as a permanently green one: it has
stopped saying anything about its subject and is only saying something about
itself. Nobody could use this one to catch a real miss, because it was already
failing. It reads both spellings now, and the set it builds is 1276 keys —
exactly `Object.keys(EN).length`, which is the check that says the fix is
complete rather than merely quiet.
The regex stays a regex, so it grows the one thing a text-reading guard owes
its reader: it says which lines it could not parse. Any two-space line that is
neither a spread nor a comment and produced no key is listed and turns the exit
code red, so a third spelling cannot be silently rounded down to "missing"
again. Matched by start offset rather than by text — one key's text can be a
prefix of another line, and comparing text would let exactly the line this is
meant to catch pass as read. Confirmed against a computed key, which it names.
The vitest catalogue test never saw any of this: it imports EN and asks the
object, so it was right all along. That is the lesson worth keeping — a guard
that parses source text has to be able to report its own blind spots, because
the tie-breaker is not always sitting next to it.
The composer's border carries a light that travels around it while a turn is
running. Its own comment says the light means "it is moving" and must go out
when the run stops — and it does go out: `.glowring` is `opacity: 0` unless
`.pane[data-run="running"]`. Only the opacity ever stopped. The 150px radial
gradient inside it kept running its `offset-path` loop sixty times a second,
re-rasterising inside a `mask-composite: exclude` layer, from the moment the
window opened until it was closed. `offset-distance` is not a compositor
property, so the whole of it lands on the main thread.
On a 400-turn conversation with nothing running at all: 44.0% of the main
thread and 1.11 renderer cores, against 11.4% and 1.02 with the animation
removed. Unthrottled the same pair is 9.8% / 0.13 cores against 2.9% / 0.07.
That is the shape #9481 reports — CPU that stays high with the window merely
open, and drops the moment it is closed rather than when the work finishes.
The fix is to let the animation and its visibility be the same condition:
paused by default, running only under the selector that already makes it
visible. Idle goes 44.0% -> 11.4% throttled and 9.8% -> 2.9% unthrottled. The
running case is unchanged, which is correct — that is when the light is meant
to be moving, and attempts to make that half cheaper (stepped timing, layer
promotion, a smaller blob) did not reproduce across runs, so none were taken.
perf/idle.mjs is the guard the contract was missing. The performance criteria
covered streaming, scale, and switching, and had nothing to say about a window
that is doing nothing — which is how one always-on animation went a whole
release unnoticed. Two assertions, one structural and one measured: every
running animation must be on an element that can actually be seen (zero
opacity anywhere up the chain, hidden visibility, or no box is a defect no
matter how fast the machine is), and the net cost of animation on an idle
window — the same machine measured again with animation off, subtracted — must
stay inside a budget, so machine speed cancels out and the number means the
same thing here and in CI. The light is asserted in both directions: welding
it shut would otherwise pass. selfcheck.mjs takes it, and both new assertions
were confirmed to go red for the right reason.
Opening a workspace on a remote host meant editing the host book first: type
the path into settings, save, then find the row in the sidebar. Nothing below
the sidebar required that. /remotes/open hands its workspace straight to attach,
and bootstrap resolves ~, an absolute path, or one relative to the login home —
an arbitrary folder has always been openable. What was missing was a way to say
one: the rows the sidebar can click are the union of the host book and the far
kernel's own tree, so a folder written down nowhere could not be named.
attach.Pool.Browse holds the link and nothing above it. Attach's two halves are
already separate — holdLink dials, holdSpace bootstraps — so listing a folder
takes only the first: no reasonix installed, no serve started, no forward bound.
That is what makes picking work on a machine this build has never run on, which
is exactly the machine a folder is being picked on. The connection is released
on a timer rather than at the answer: stepping into a folder is a second call,
and a link dropped between the two re-dials — which on a passphrase-protected
key means asking for the passphrase again.
GET /remotes/{host}/dirs answers folders only, spelled by the machine that owns
them. The parent comes back as a value rather than being cut from the path here:
a Windows host answers a mac with a drive letter and backslashes, and neither
side's path rules describe the other's. A folder larger than the cap says so —
a silent truncation reads as "what you are looking for is not there".
The link layer separates a mistyped path from a link that is down, because only
it sees the file protocol's answer: remote.no_such_folder and
remote.folder_unreadable come from errors.Is against the stdlib sentinels that
pkg/sftp normalises to, not from reading a message. A missing folder arriving as
a bad gateway sends someone to check a connection that is up.
Picking opens, and opening writes the folder into the book. The far kernel's own
list is the better home for it, but it can only be read while something is open
over there — the book is what survives a cold start. What is recorded is the
path the far side resolved, not the one that was asked for: ~ is expanded over
there, and recording the other spelling would list one folder twice. The write
is skipped when the book already holds it, so the common open does not rewrite
the user's config file.
A list that only grows needs an exit, so the folder list gets two narrow
endpoints of its own. The settings page replaces a whole row, which is right for
a form holding every field; a sidebar control that knows one folder must not, or
it sends back blanks for the address beside it. Only a folder this window wrote
down offers the control — one the far kernel reports is that machine's own note,
and dropping it here would take away a row that comes back on the next read.
wire-parity takes port/remote.ts: RemoteHostView, RemoteListing and RemoteFolder
are declared against the desktop's hand-written copies. RemoteHostEdit is left
out on purpose and says why — the kernel still accepts the singular workspace an
old row was saved with, and the page that writes one deliberately sends the list
alone. An under-filled request is not a picture that cannot be read.
Closes#9475
SIGNPATH_API_TOKEN is a repository secret, so it is already readable from this
workflow — the token gate alone would turn signing on the moment this file
merges, and the policy still trusts only main-v2. The next tag would then break
on a signature that cannot be granted yet.
STUDIO_SIGNING_ENABLED is the half no secret can express: SignPath's console has
been told to trust this line's origin. Merging is inert until it is set.
Studio's Windows artifacts were unsigned by construction — the production
SignPath policy trusted exactly one origin, protected main-v2, so a build from
this branch could not request the certificate. The workflow said so and left it
there. On a managed Windows endpoint that is not a SmartScreen warning: the
installer is refused at CreateProcess, and Windows reports it as "cannot access
the specified device, path, or file", which reads as a permission problem and
is not one.
The desktop line's mechanism comes over whole, adapted to what Studio ships.
Signing is two-staged for the reason main-v2 states: signing only the finished
NSIS container leaves reasonix-studio.exe unsigned on disk after installation,
which is precisely what reputation and ML heuristics quarantine. So the
executable is signed while it is still a loose file, the packages are rebuilt
around those exact bytes, and the container is signed second — with the
artifact configuration verifying the payload's signature rather than trusting
this workflow's ordering.
studio-build.sh grows the seam that makes that possible: --windows-payload
builds what a user runs and stops, --windows-package builds the zip and the
installer from a payload directory it is handed. The one-shot platform path
composes the two, so an unsigned build and a maintainer's local run are byte
-identical to what they were.
.signpath/ carries this line's own contract — branch studio, workflow
release-studio.yml — and cmd/signpath-contract validates and fingerprints it
before anything is built, so a widened origin fails at the gate rather than at
the signing request. Its branch pin is studio rather than read from the file it
checks: a guard that reads its own answer out of its subject cannot guard.
Signing engages only when SIGNPATH_API_TOKEN is set, mirroring the APPLE_* gate
— forks and token-less runs still build, unsigned.
This cannot take effect on the repository side alone: the release-signing policy
in SignPath's console still trusts only main-v2, and until the studio origin is
added there every request from this workflow is rejected. See the PR body.
TestApplyLocalSkillLinkMode asked runtime.GOOS, and asked it after asserting
resp.OK — so a machine that cannot create a symlink failed on the refusal the
tool had correctly reported, rather than skipping. Windows allows symlinks under
Developer Mode or elevation, which the platform name cannot tell apart from a
plain account, so the check is now the read three cases in this file already
make: try it, and skip on the error the filesystem gives back. A Windows host
that can make one now runs the case instead of skipping it.
Also ignore *.exe~ — what the linker leaves when it cannot replace a binary that
is still running, which on Windows is every rebuild while Studio is open.
The shelf's pills were ordinary flex children, so a row one pixel short of
fitting squeezed them instead of wrapping — and Chinese breaks between any two
characters, so 计划 came apart into two lines and took the row's baseline with
it. A pill is now its label's width or nothing: the shelf wraps, which .row
already allowed, and the send button's auto margin keeps it at the right end of
whichever line it lands on. Verified from 980px down to 320px: one line, two,
then three, with nothing broken at any of them.
The model pill is the one control with no width of its own — a gateway can
publish an id longer than the shelf — so it gives up characters before the
shelf gives up a line, and the ref it was shortened from stays in the tooltip.
Closes#9455
The attach pool has always keyed spaces by workspace and named a forward per
one; the far kernel remembers every folder a pane opens and answers /tree with
all of them. Only the book could name a single directory, so nothing could say
the second one — and cold, before a pane exists to ask through, the sidebar had
just that one row.
RemoteHostEntry gains workspaces beside workspace, folded by WorkspaceList into
one default-first list so a row that only ever set the old field is a list of
one rather than a case every reader repeats, and an upsert makes the head of
the list the default the CLI still connects to. The sidebar shows the book and
the far tree together: a folder added here and never opened over there stays,
and one opened over there appears without being written down here.
The [remote] renderer moves to render_remote.go — the section is one subject,
and the function it came from is already the longest in the package.
Closes#9459
The kernel replays a pending ask/approval to every client that attaches, and
the window then overwrote its items with what /history rebuilds — where the ask
tool call is deliberately drawn as nothing, because ask_request carries the id.
The pane sat at 等你决定 with nothing on screen to decide and the queued turn
behind it never dispatched. A prompt is not part of the record; it is the run
stopped waiting on an answer, so it rides across the rebuild. prompts.ts also
gives one its kernel id, so a replayed frame is the same card rather than a
second answerable copy, and an answered one is not reopened by the frame log.
SandboxSettings carries effectiveBash beside effectiveWriteRoots, so the pane
draws what the confiner will do rather than what was configured: an unset mode
enforces everywhere but Windows, which runs unconfined however the file reads.
The segment for a mode this host cannot run is visibly dead and carries its own
reason — `disabled` alone kept the hover of a live control and then swallowed
the click, which is what was reported as broken. Enforce-without-a-backend
refuses by code rather than by a hand-written sentence, and the SandboxSettings
mirror is declared to wire-parity so the next field cannot drift.
Closes#9454
errmsg and the refusal-hint test, instruction's resolver, and the i18n message
tables the two of them read from.
Accumulated working-tree work, committed as found; the tree builds and passes at
the tip.
install, launch, platform and the Windows shell case behind one bootstrap, with
the hardening and pure-function tests that let it be exercised without a machine
on the other end, and the hub and CLI entry points that reach it.
Accumulated working-tree work, committed as found; the tree builds and passes at
the tip.
reasoning_replay for OpenAI and Anthropic, the h2 reset case, stream_failure and
the retry path around them, with doctor reporting what the provider actually did
so a broken stream is diagnosable instead of just slow.
Accumulated working-tree work, committed as found; the tree builds and passes at
the tip.
The palette, the picture-less sky and the ray/cloud tints the Studio design was
drawn against, as a pack the kernel serves rather than something a user has to
install to see the app as designed.
Accumulated working-tree work, committed as found; the tree builds and passes at
the tip.
RunStrip was one strip carrying every reading. It splits into panels — Overview,
Runtime, Cache, Cost, Context, Agents, Files, Jobs — with derive.ts doing the
walk once in the pane and kit.tsx holding the shapes they share, so a reading
that grows does not widen a row every other reading has to live in.
Alongside: Sky draws the backdrop a theme pack asks for rather than placing a
picture, Nav takes the icon rail, Timeline and Task give the trajectory and a
subagent their own views, AccountRow the account, and prefs/keys/trailing carry
the state, the bindings and the tail the panes were each keeping privately.
perf grows three scripts: selfcheck, which pushes a false assertion into every
guard to prove the guard can still go red; sourcemap, which turns a minified
frame back into a source position; and switchprof, which samples the cost of
opening a long session. look and verify grow the assertions they were missing.
This is accumulated working-tree work, not written in the session that committed
it; the message describes what is here rather than the reasoning behind each
piece. The whole tree builds, vets, typechecks, and passes its tests and
repolint at the tip.
Both stand for a failure that stayed silent while every other gate was green.
tokens.mjs: a var(--x) with no fallback has to name something someone assigns.
CSS drops the whole declaration when it does not, and the property falls back to
whatever was inherited, which reads as the design. Two of those were live in the
tree — the connection prompt's scrim named a --bg that has never existed, so it
had blur and no dimming at all, and the usage panel's --fg meant its text colour
never landed. Both lists are read from source, the stylesheets' own `--x:` and
the per-element ones JSX writes into style, because a hand-copied list rots.
shipped.mjs: dist must be newer than everything under src. A build can succeed,
print its tick, and compile something else — this tree has more than one process
on it, and a stash that put it back to HEAD for forty-eight seconds was enough
for one build to fall inside the window and produce the previous release. The
window then loaded it and the whole interface reverted, while the tests stayed
green: they read the source. Timestamps rather than marker strings, because
"the artifact is older than the source" is one sentence that cannot rot.
Both were mutation-tested the way selfcheck.mjs asks: a var nothing defines
turns tokens.mjs red, touching a source file turns shipped.mjs red, and a
PERF_SRC that points nowhere fails loudly instead of passing quietly.
Six things, and four of them were defects rather than taste.
The window opened on a blank page. index.html now paints a boot screen before
the bundle it would otherwise wait for exists: the wordmark written on letter by
letter in gilt and settling to ink, the sky the theme packs draw, and one line
saying the kernel is being reached. main.tsx hands the window over when the
intro has finished rather than after a counted delay, so a fast kernel does not
flash it and a slow one is what it is for. It carries its own palette because
tokens.css is a bundled import that does not exist yet at that point.
Settings becomes a sheet over the workspace instead of a second full-screen
window. That removes the duplicate: .prefs-hd used to be a title bar too, with
its own drag region, its own traffic-light inset and its own window controls.
The window frame is .chrome and only .chrome now, and the veil starts below it,
so the window can still be dragged, minimised and closed while settings is
open — a modal that traps the window buttons is not a modal. Its contents list
grows the design's marks and groups, and reports what each section is worth:
storage in bytes, usage in tokens over the window Usage itself opens on, the
build running.
The segmented control was a full-width strip of underlined tabs at all sixteen
call sites. It is an inset track with the selected cell lifted out of it, which
is a control's size rather than a navigation bar's, and section headings shrink
to overlines so the rows under them carry the weight.
Then the defects:
- A theme's picture is body::before at z-index -2, and it is visible only
because an uncoloured html hands the canvas to body's background. The boot
screen's stylesheet had coloured html, which takes the canvas over and
demotes body's to an ordinary element paint — landing on top of the picture.
A wallpaper simply stopped appearing. boot.test.ts fails any rule that puts a
background on html or :root, because nothing else was watching that file.
- The cost panel's sparkline was a black rectangle. Spark's fill and stroke
rules were written under .rate, the throughput row's scope, so the cost
panel's copy matched none of them and an SVG with no fill declared is filled
black. The colours belong to the mark; only the throughput row's geometry
stays scoped. The panel's wrapper stops sharing the component's own class,
which is what let a wrapper's layout land on the drawing inside it.
- A picker's menu jumped to the bottom of the window before fading out. Its
anchor name hangs on [aria-expanded="true"], which flips the instant the menu
is asked to close, while the menu is still on screen; with no anchor left to
resolve, an anchor() without a fallback invalidates the whole declaration.
The fallback is the same 100% the non-anchored rules have always used.
- Every view swap cross-faded a snapshot of the entire document. Only the
swapped element carries a view-transition-name, which swap.ts says in a
comment, but the root kept its own default name — so closing settings dipped
the whole window and brought it back. :root opts out.
The preview itself is a sheet in the same family, portalled to the body because
under a wallpaper the side column carries a backdrop-filter and that makes it
the containing block for anything fixed inside it. Rows in the changes list
become buttons carrying git's own letter, coloured by what it means.
--fg was read six times in the usage panel and has never been defined, so those
declarations were dropped and the colour fell back to whatever was inherited.
It is --text. bytes() ran to MB and stopped, which is how a 9 GB total read as
"9318.4 MB"; it runs to TB, and Storage stops carrying a second copy of the
rule that i18n/format exists to hold.
The rail lists what the tree differs by, and until now that was all it could
say: a path and a porcelain letter. What the file actually differs by lived
only in the tool events of the turn that wrote it, so a change made before this
session — or by a shell command no event describes — could be named and not
read.
gitstatus.Diff answers for one path. It measures against HEAD so a staged edit
and an unstaged one read as the single change they are to a person. An
untracked file has nothing in HEAD to compare with and is diffed against the
null device instead: that is the only way git prints it without first writing
to the index, which a read must not do.
The path arrives from a client, so safeRel keeps three shapes away from git —
outside the tree, absolute, and readable as an option — and every invocation
puts it after "--" as well. ErrPathOutsideTree is a sentinel because the caller
has to tell a bad request from a broken repository; over HTTP they carry
changes.path_outside_tree and changes.diff_failed, and the frontend has words
for both.
root goes through gitcmd's dir parameter rather than an "-C" argument. gitcmd
hardens a subcommand it can see at args[0], and --no-ext-diff/--no-textconv are
exactly the flags a diff against someone else's repository needs: a repository
config can name a program git then runs during an ordinary read.
The desktop shell needed telling too. Its route table lists families rather
than leaves, so /changes/ joins the prefixes — without it the shell answered
/changes/diff with index.html and the preview would have failed on a body that
parses as HTML. TestEveryRouteTheKernelRegistersIsRouted caught that before it
shipped, which is what it is for.
routes() moves to routes.go. serve.go sat exactly on its file-size budget, so
one more registration line broke it, and the budget is not there to be widened:
the route table is every path this server answers, which is a subject of its
own rather than a section of the file that assembles the server. serve.go drops
from 1459 lines to 1365.
packagesFor took the directory of a declared file with filepath.Dir, which
on Windows answers `internal\control`. That is not a pattern go test can
match, and it sorted the list wrong on the way out, because a backslash
outranks a slash — one root cause, two symptoms in the same assertion.
The gate itself runs on ubuntu, so nothing it gates was affected; its test
runs on the whole matrix, and has failed windows-latest since the gate
landed. A declared glob and a go package pattern are both slash-separated
whatever the host is, so both reads move to path.
Windows groups a taskbar button and routes a toast by the process's
AppUserModelID, and the Start Menu shortcut has to carry the same one or
the notification is dropped. The two calls that set both lived in the
retired shell. When it went they lost their caller, and the sweep that
deletes unreferenced exports took them — leaving internal/notify sending
toasts under an identity nothing claims, and six unused findings on the
GOOS=windows lint leg, which the sweep's own host-GOOS run never saw.
Restored and called from the window's startup, before the first window
exists, which is when Windows reads it. The call site carries no build
tag: identity_other.go already answers for every other platform.
A host decorates its pane's sink once — the status icon's fold, desktop
notifications, usage counters — and builds the controller on it. That
decoration lived only at the build call site, so every path that rebuilds
the controller wired the replacement to the bare broadcaster and dropped
it: a model or effort switch, an extension reload, a workspace change,
and saving a provider credential, which is the first thing a new install
does.
Nothing corrected it afterwards. A switch is refused while a turn runs,
so the last event those observers saw was the previous TurnDone, and they
stayed on it for the pane's whole life. The tray reported "nothing
running" through a fourteen-minute turn.
The Server now carries the sink its controller emits into, so the thing
that rebuilds owns it rather than the call site that happened to build
first. build and rebuildWith move beside the options they read; the file
they left is over its ceiling and had no room for the addition.
The test fails on the old code for both rebuild paths with the reported
symptom: the pane's decoration sees none of the replacement's events.
Reported in #9448. The paint ordering fixed in db030ad0 is a real defect
but needs two panes changing at once, and is not what that report shows.
Both have failed every run of CI's windows-latest job since the corpus
landed, and neither is a flake.
gen-topo-cycle timed each call with signal.alarm. Windows Python has no
SIGALRM, so the grader raised while installing the handler, before it
graded anything. A daemon worker thread keeps the semantics exactly —
_Timeout on a hang, the original exception re-raised, so the cyclic case
still reads ValueError — and cannot hold the interpreter open at exit.
gen-csv-quotes compared cli.py's stdout against an expected string.
Windows Python writes CRLF in text mode, so that comparison could never
hold; the carriage returns come off first, because the grader checks the
fields and not the host's line endings. Its mktemp template also read
TMPDIR, which under Git Bash is a Windows path — the rest of the corpus
asks for a bare mktemp -d, and now so does this one.
The modernize pass reports two findings on this file, and they fail both
`make lint` and CI's lint job. A strings.Index used to slice a prefix off
is strings.Cut, and ranging over a Split is SplitSeq.
Two panes changing at once take their folds under the lock and report outside
it, so the two reports could reach the icon in the opposite order. The one that
lost left the icon describing a state the tracker had already moved past, and
nothing corrected it: the fold reports only what changed, so a wrong paint
latched until the next real change. A pane running a fifteen-minute turn is
fifteen minutes of a tray that says nothing is running.
Painting is now serialised and describes the fold as it stands when it paints,
so a report that lost the race repaints the truth instead of overwriting it.
The test fails on the old code with the reported symptom exactly: the icon
shows Working:0 while the tracker holds Working:1.
Reported in #9448.
The review gate read one index for two questions — what the change set is,
and how fresh a review of it must be — so a later harmless write moved the
window past everything before it. A sensitive edit followed by a README write
scored Low and dropped the whole structured-review demand.
UnreviewedMutationBaseline now answers the first question and the latest
mutation still answers the second. It advances per review kind, so an accepted
review does not discharge security_review's obligation with it.
Alongside, the parts of that gate that promised more than they did:
- A blocking verdict is honored at every role setting, and review subagents
submit the typed report everywhere. A verdict a caller must act on carries
an identity, not a sentence. The nudge runs everywhere too; the hard failure
stays in Delivery, since outside it no report is no block either way.
- Medium no longer demands a structured review "or" host-proven
self-inspection. That was no alternative — the reader there is the author —
so the cheap side was always taken and the demand never bound. The real
requirement inside it moves to the self-inspection gate and grows teeth:
inspect every changed path, fresh per path, not one of them.
- warn findings were collected into a slice nothing read. They now reach the
user, and they are collected from any review the turn ran rather than only
from one the gate asked for.
- verificationGapResult.advisory carried no text and reached nobody while two
comments called it reported. The waiver it stood for is right and stays; the
flag wearing a message's type is gone.
The review and security-review bodies stop hardcoding git: fileutil owns one
declared store table, environment reads it, and a skill subagent's prompt now
carries the workspace fact its parent always had. They also drop the ~12-call
self-cap, which was well under the host's real budget, and split the burden of
proof — adversarial about what must be read, conservative about what may be
reported. A pass is a negative claim and owes what any other does.
complete_step: manual evidence no longer stands alone in a turn that already
has receipts. The evidence ablation arm reaches the tool schema, so the two
arms stop paying the same prompt and the A/B can answer for the contract
rather than only for the gate.
agentgraph's own account of why it exists is that every consumer folds the same
deltas, so the graph a headless run reports is the graph the desktop draws. The
desktop's fold is a second, hand-written copy — the type and the merge both —
and nothing checked the two carried the same fields. Adding one field to the
kernel this week meant remembering to add it in two more places by hand. The
next person will not remember, and neither toolchain will say so: they are
compiled separately and meet only at runtime, over JSON.
wire-parity reads the Go struct's json tags against the TS interface's
declarations and fails either direction — a field one side sends and the other
cannot read, and one it reads that nothing sends. A field the encoder skips is
not on the wire and is not demanded, so an internal field costs nothing.
The pairs are declared rather than inferred, for the reason the sensitive paths
are: no spelling tells a mirrored contract from a struct that merely carries
json tags. Only the graph is declared today, because it is the one that claims
both sides fold identically.
agentgraph's own account of why it exists is that every consumer folds deltas
with the same Apply, so the graph a headless run reports is the graph the desktop
draws. The desktop's fold is a second, hand-written copy of it, and nothing
checks the two carry the same fields — a wait cause published by the kernel and
unknown here is a node the picture cannot say anything about.
The type is what holds it: dropping it fails the typecheck through this fold and
its test. The merge line is consistency with every other field rather than a
defence — an absent key already survives the spread.
Rendering is left alone. Every site that would show it, and the strings it would
need, are mid-change in the working tree, and this is one row away whenever that
lands.
Queued was one word for three constraints with two remedies. Two are session
settings — the total sub-agent ceiling and the concurrent-writer ceiling, which
binds a writer while total capacity is still free — and the third is a write
path someone else already holds, which no setting releases: only disjoint paths,
or an edge that orders the pair. The scheduler was the only thing in a position
to tell them apart, and it answered in a sentence built for one caller's error
message, so nothing downstream could.
WaitCause joins the graph's vocabulary beside NodeState and Grant, canStartLocked
returns it instead of prose, and an acquire that has to wait reports it once —
after the unlock, because the callback reaches a sink the scheduler must not be
holding its mutex for. The cause survives the run starting, the way the stamps
do: the state says running now, this says what it waited on.
The fan-out price splits with it. One figure summed every wait between ready and
running while its name said the concurrency ceiling caused it, so a member held
behind a claim was quoted back as a reason to raise a ceiling that would not have
moved it. Two figures now, because there are two answers.
Both fan-outs take the same pair of hooks from one place: the two moments only
the scheduler can report are published by one thing rather than copied into each
producer.
parallel_tasks takes a model and an effort per task, so two members of one group
are not necessarily the same worker. The graph drew them as if they were: a
fleet node carried the profile, model, effort and grant its item ran under, and
these carried a label and nothing else. A reader asking why one arm of a fan-out
cost more than another had nothing on the picture to answer with — the tool
offers the distinction and the graph erased it.
The identity is resolved once, in the loop that was already building the labels,
rather than again inside each item's goroutine.
A run can be told to finish its list by two host prompts: the readiness
continuation, and the Goal FSM's "signaled complete but issues remain". Only the
first was ever taught which actions the completion guard lets through. The Goal
path still ended with "use todo_write/complete_step to mark done" — the one
action the guard refuses — so a run reading it beside the requirement it embeds
was handed the open door and the shut one in a single message, and heard about
neither turn exit.
Both now append one string. Owning it once is the point: the failure here was
two copies drifting apart, and a third copy is what let them.
That third copy was the requirement's own. Every path that shows a requirement
to a model goes through one of these two prompts, so its ways-out sentences were
read twice, in two wordings, inside one message. Its other readers — the ACP
turn outcome, the recovery card — are host- and user-facing, and were being
handed instructions written for a model.
The argument contract read which fields a call supplied and never what it put in
them. A field with the right name and the wrong kind was therefore neither
unknown nor missing: it passed every check the host makes, and the answer came
from Go's unmarshaler — "cannot unmarshal string into Go struct field .evidence
of type []agent.blockedEvidence". That names the host's types rather than the
caller's mistake, and it costs the round trip that discovers it. A live run
spent one that way on conclude_blocked before it even reached the two it spent
on the field names and the evidence itself.
The schema already declares each property's kind and the arguments already carry
one, so comparing them is the same structural read the contract was doing on
names. A mismatch now refuses the call before permission is consulted, for the
reason the missing-field case already refuses: execution was never possible.
A null is not a mismatch, and a property whose schema declares no type leaves
the question unanswered rather than broken — the contract may only report what
the schema actually said. The hint drops "it accepts ..." for a pure mistype,
because the field is accepted and listing the accepted names answers a question
nobody asked.
The contract is read after proxy resolution, so a tool reached through
use_capability is compared against its own schema and refused the same way; a
test pins that, because reading the call order is how you get it backwards. An
on-demand MCP tool declares no properties and the contract stays silent on it. A
connected one is compared against its server's schema — the same trust the host
already places there by refusing a call that omits a field it marks required.
addTurnExitTools registers the two ways a turn ends on the model's terms rather
than the host's: ask for a decision only the user can make, conclude_blocked for
a task that cannot be done as specified. The host's own account of the ways out
named only the second. So a turn that ended by putting a question to the user
was continued straight past it, and the run — told in the host's voice that
there were exactly three doors and that none of them was "wait" — answered the
question itself and said so in the transcript.
Naming ask closes it, in the requirement and in the continuation alike, with the
rationalisation it has to beat spelled out: not having heard back is not an
answer to assume one from.
The escape that used to exist was an accident. The stall counter compared the
gap against the best round so far, and a run waiting on an answer could not
shrink it, so two rounds handed control back. Scoring the continuation against
the previous round's signature instead removed that side effect without
replacing it with a deliberate one.
Nothing here reads the wording of what the model said. The signal is the
declared turn-exit set, which the host was already free to name and did not.
The fleet plan proved a dependency DAG and published it, and the scheduler that
decides which item actually runs never saw it: an AcquireRequest carried a write
claim and a label and no position in the graph at all. So a slot went to whoever
queued first, which is how the head of the longest chain ends up behind leaves
that unblock nothing — and that wait lands on the run's makespan one for one.
It is the loss fanoutMetricsOf already prices as wall above the critical path:
measured every run, acted on by nothing.
The plan now measures each item's rank, the longest chain of dependents below
it, and carries it to the acquire, where the pump drains waiters heaviest first
and keeps arrival order within equal weight. That covers every grant after the
session's slots are full, which on a fan-out wider than the ceiling is nearly
all of them.
It does not cover the first fill, and sorting the launch loop does not either.
Which of a burst of goroutines reaches the scheduler first is the Go runtime's
decision: launching heaviest-first captured 41.8% of the ideal first-fill weight
against declaration order's 51.9% at a ceiling of two, and tied at one and three
— never better, sometimes worse, because the runtime favours the goroutine
created last. The loop is left alone and says why.
A run also queues before it builds rather than after. It used to construct the
whole child — registry, runtime, and the transcript that holds a store lease —
and only then join the line, so a fan-out wider than the session ceiling held
one open per item it had not started, and every millisecond of that setup was
billed to a concurrency ceiling that had not caused it. Foreground runs hold the
slot from before the transcript exists; background ones still return a job id
first and queue inside the job. Deriving the request moves to slot.go, the file
that owns the acquire.
Delegation makes three claims and upstream-edge can only settle one: both of
its tasks are strict chains, so their speed-up is 1.00x by construction and no
scheduler decision is observable in them. fanout-width fixes a shape whose
three research items share nothing, which leaves the session concurrency
ceiling as the only thing deciding whether they overlap — so the serialised arm
needs no ablation flag, only max_subagent_concurrency = 1.
Each subsystem retires nine of ten modules and names the live one in entry.py,
so a grep over the markers returns ten candidates and answers nothing.
inboxEventSink hand-forwarded seven optional sink capabilities and missed
three, so every child run's receipt died one wrapper below the sink that
accounts for it. A benchmark then reported four fleet members as a task one
agent had solved by itself, and the whole delegation section rendered empty
while its owning tests stayed green — which is the failure AuditForwarder's own
doc comment already described. Embedding it is that comment's remedy: a channel
added there now reaches this wrapper without editing it.
The graph already carried every stamp a benchmark needs and nothing folded
them, so delegation was reported as a count of children and never as what the
shape bought. Work against wall is the speed-up; the critical path is the floor
the declared dependencies impose, and wall above it is scheduling loss; slot
wait is what the concurrency ceiling cost members with nothing left to wait for
— the one wait no edge on the graph can draw.
The effect test now pins the stamps themselves. Without them every figure here
folds to zero, which reads as a fan-out that took no time rather than as a
measurement that never happened.
The pane already replayed the trajectory into the graph reducer, but the wire
log dropped every graph_delta on the way to disk, so a reopened window drew a
run that had waited on nothing. A dependency and an adopted answer exist
nowhere else in the stream: unlike a tool mark, they cannot be re-derived.
Studio never called crashreport at all — only the CLI did — so not even a Go
panic was recorded. That is the smaller half. Every Studio crash reported so
far has been a fatal signal inside GTK or WebKit, which no recover can reach:
the runtime writes that trace straight to the descriptor, so a tee on
os.Stderr would keep every log line and lose the only one worth keeping.
So stderr is redirected, not wrapped. The window opens a log under
<home>/logs and dups it onto the descriptor, keeping a copy of the terminal
first when there was one, so running Studio from a shell still prints. One
log per launch, newest few kept: a crash is reported after a relaunch, never
during the run that produced it.
Linux uses Dup3 rather than Dup2 — linux/arm64 has no dup2 syscall at all.
Windows says it cannot instead of pretending: it resolves the handle its
crash writer uses once, at start, so moving os.Stderr afterwards would move
the window's own logs and not the trace. There the log still gets those.
crashreport can now name the host it came from. sanitizeReport overwrote
Source with "cli.go" unconditionally, so a Studio report would have arrived
claiming the CLI and sent whoever read it to the wrong code. The set is
closed rather than free-form, and the CLI's own reports are unchanged.
Motivated by #9408, where the crash is inside cgo and the Go traceback stops
at gtk_main(). The only trace anyone has is there because the reporter
happened to launch from a terminal.
A census of the 78 fields, by where each is written rather than by reading
the names: 37 are bound once in New and never reassigned, and the rest change
while the session runs. Both kinds sat in one list, so c.policy and c.turn
read alike at every call site and only one of them can change under you —
which is the question anyone reaching for c.mu has to answer first.
The 37 move into an embedded controllerDeps. Embedded because field promotion
keeps all ~720 c.field reads resolving unchanged: the point is to make the
lifetime legible at the declaration, not to rename the package. Controller's
own field list drops to 42.
Two fields that qualified by the write test stayed out on purpose. commands
is an atomic.Pointer and externalFolderToolRefs sits with the mutex-guarded
map it belongs to; filing either under "never rebinds" would read as "not
concurrent", which is the opposite of true.
repolint reclaims struct-state 5, file-size 123, function-size 5 and essay 6
— the scalar debt recorded against controller.go was exactly this mixing.
Tests, vet, and -race on the package all pass unchanged.
The memory panel has always told the reader that saving "records a new
revision; the old one stays". Nothing in the UI could show that history or
step back into it: GET /memory/revisions and POST /memory/restore were both
served, both had Controller port methods behind them, and the frontend never
named either — the promise was there and unverifiable.
An entry past its first revision gets a history control; opening it lists the
revisions behind the current one with their dates and bodies, each restorable.
Restoring appends a revision rather than rewinding to one, which the panel
says, because a restore that dropped later revisions would destroy the very
version a reader opened the history to compare against.
The fixture kept only the latest revision, so a panel driven by it would have
shown an empty history and proved nothing; it now keeps what a save replaced,
the way the store does.
Verified in the running Studio through CDP: editing an entry produces
revision 2, the history lists revision 1 with its original body, and
restoring it leaves the entry on revision 3 carrying revision 1's text with
both older revisions still listed.
REASONIX.md calls these paths the ones where a wrong change is "a hole in a
boundary". Measured, they were the least-tested part of the kernel rather
than the best: shellsafe 59.3%, shellparse 63.6%, sandbox 69.4%, netclient
47.2%, against a tree median of 76.5%. The code deciding what the agent may
execute and reach had the weakest evidence that it works.
The gate reads the same `sensitive:` bullets the runtime reads for its
review requirement, so a path is declared once and gets both effects — there
is no second list to fall out of sync. Floors are per declared path and
measured from the coverage profile per file, not per package, because
`internal/control/approval.go` is sensitive while the package around it is
not; measured on its own it is 93.9%, which no package number would show.
Floors ratchet in the opposite direction to repolint's budgets and for the
same reason: raising one is free, lowering one needs -allow-drop and a
reason in the PR. They record what is there today rather than an aspiration,
so the gate is green on arrival and only fails on a regression.
Verified by removing one shellsafe test file: the gate reports 47.3% against
its 59.3% floor. Adding an unrelated `sensitive:` line puts that path under
the gate with no other change.
Two fixes and one sweep, all from the same audit.
The process history catalog was never closed. boot's cleanup releases LSP,
plugin hosts, the runtime scope and session temp; the catalog's own
Flush/Close had lived in the retired shell and were left to two test files.
It is process-wide, shared by every pane and workspace, so it closes at the
process boundary next to the usage catalogs, not with any one controller.
Queued index writes are drained first — the reopen after an unclean exit
re-scans every root instead.
The sweep removes 57 exported functions with no reference anywhere in the
tree, tests included, plus the private helpers that fell dead behind them.
Iterated to a fixpoint: deleting a function exposes the next one, and both
the orphan rule and golangci's unused ran again after each round until
neither reported anything.
The rule learned two things from being wrong here. Methods are now out of
scope: chatTUI.Init is bubbletea's tea.Model, no file in the tree names it,
and proving that needs type information an AST pass does not have — the
first attempt at this sweep deleted it. And an orphan tests still reference
is reported differently from one nothing references, because they need
opposite fixes: repair.PrepareFileUpdate is a cross-package fixture eight
test files build state with, and deleting it was the second thing that
attempt got wrong. Only the unambiguous half is deleted here; the 124
remaining stay recorded.
The last-known-good snapshot died because deleting one frontend left its
callee behind with the implementation, the tests, and the doc comments all
intact. Nothing in CI reads "has a caller", so the tree stayed green for
eight days while a recovery path read a file no longer written.
The orphan rule counts callers across the whole tree, which is the part a
single-module analysis cannot do: desktop/ is its own module and a real
consumer of internal/, so a scan that stopped at the root module would call
its APIs dead. Two things deliberately do not count as callers — tests,
which are what keep an orphan warm and green, and doc comments, which are
what make one look wired. Both are checked by tests here, because both were
true of RecordHealthyConfig.
Test-support packages are exempt: their exports cannot move into _test.go,
so having no production caller is their normal state.
328 existing orphans are recorded as the ratchet baseline, not fixed here.
Verified against the original accident: reverting the fix from the previous
commit trips the gate on RecordHealthyConfig by name.
Recovery reads a snapshot nothing had written since the Wails shell was
deleted. That shell held the only two RecordHealthyConfig calls, so from
908bc3ec0 on the read paths in config/load.go and config/unparsed.go kept
restoring whatever config was current that day. On this machine the file
was 16 days and 161 diff lines stale, and `doctor repair --apply` would
have written it back over the live config without saying so.
The shell proved health with "the window became visible", a signal only
Wails had. A successful assembly proves the same thing for every frontend,
so the record rides BuildRuntime, which reload goes through too, and CLI /
serve / Studio inherit it instead of each re-wiring it. Options.Version is
the host declaring a real installation; embedded and test assemblies leave
it empty and write nothing. The writer is already idempotent — unchanged
config hashes to the existing snapshot — so every start can call it.
Both recovery paths now date what they are about to trust: the load warning
carries the snapshot's age, and doctor repair prints it before --apply
rather than after.
Options moves to its own file, which is what pays for the new field: boot.go
drops 128 lines and the tree reclaims 176 file-size, 21 function-size, 15
essay and 3 complexity of budget.
Verified against a real binary: assembly writes the snapshot and stamps the
ldflags version, a second run adds none, and a broken config reports
"--apply would restore a snapshot 16 days old".
govulncheck reported seven reachable call paths into the 1.5.0 advisories —
the feishu adapter's socket, and two readers that reach a compressed frame
through io.ReadAll. It now finds no vulnerability this code calls.
collectSink appended to an unsynchronised slice and handed the slice itself
back. That held while a turn was the only thing emitting. It stopped holding
when the inbox gained a change listener: the store notifies from its own
goroutine, so six tests began failing under -race — both stacks inside the
helper, one appending while the other read.
It appends under a lock and returns a copy taken under the same one. The race
was never in the controller; the harness had no way to say so.
One cost line mixed the rounds that carry the session with the single-purpose
calls beside them — titles, digests, classification. They scale differently, so
a run that got dearer said nothing about which half moved.
The split is by usage source and named the other way round: the conversation
sources are listed, so a source added later counts as auxiliary instead of
falling out of the split with nobody noticing.
Failing the pristine seed is only half of an authored task. A grader that can
never pass is indistinguishable from one with no solution: every attempt scores
zero, and a corpus of those teaches that nothing is ever accepted. Tasks that
ship a reference solution are now checked against it, and a generated task that
cannot satisfy its own grader is deleted rather than patched.
The generated tasks live apart from benchmarks/e2e on purpose. That suite is
the instrument every cross-version comparison is read against; mixing new
tasks into it would change what those numbers mean.
Recorded runs are only training data once someone decides which ones were any
good, and the run report already holds that verdict. sft mode reads the two
together: the report decides what is exported, the trajectory's run header
supplies the prefix each sample was produced under.
A run without a header is skipped rather than exported against a guess at what
its prompt and tools were.
A trajectory held the event stream and nothing from the request side, so a
reader could see what the model said and never what it was asked — the system
prompt and the tool schemas the rounds were sampled against were simply gone
by the time anyone read the file.
The header carries both, plus the hashes usage events already report, so a
reader can prove the pair belongs to the rounds that follow instead of
assuming it from the file it was found in. It is written by the frontend that
owns the recorder rather than forwarded through the sink chain, which keeps it
out of the optional-capability contract every wrapper would otherwise carry.
NormalizedToolSchemas exports the exact bytes ToolsHash covers, so the hash is
recomputed from what was stored rather than from a second serialization.
bindRunSession collects what a headless run does to attach to its session,
which had grown into four steps inline in runAgent.
Opt-in metrics carried the anonymous install id so the dashboard could show a
deduplicated daily user per signal. Keeping that answer meant keeping a row per
install per signal per day — ~28M of them — and the 30-day COUNT(DISTINCT
install_id) over that table was the database's entire read bill: the primary
key leads with `date`, so each query could prune only the window and scanned
the whole table, eight signals an hour, ~5.4B rows a day for one module.
The client stops sending the id, the worker stops writing and creating the
tables, the hourly rollup that existed only to make that query survivable is
gone, and the dashboard's users/opens toggle goes with it. The aggregate
tables keep the same signals as event counts in ~180k rows.
migrate-drop-metric-users.sql drops the four retired tables. Deploy the worker
first — it stops creating them at runtime, and a DROP before that is rebuilt on
the next request.
Dropping a folder from outside the workspace mints a token and registers it
with the read tools' resolver. That is where the mapping to a real directory
lives, and the grant is read-only — handing bash the real path instead would be
a writable way into it. So the token is not a path, and bash's answer for one is
the shell's own: "No such file or directory".
A model that was handed the token in the same turn believes that. This session
did: it ran `ls` on the token, then `find` over the whole home directory, and
concluded the reference was virtual and the file did not exist — while the file
sat on the Desktop the entire time, reachable by read_file all along.
Bash now recognises a token before running and says what it is and which tools
resolve it. Recognition, not a guess about what a word might mean: the resolver
is asked, and its keys were minted by the host.
The four preflight refusals repeated the same four assignments before returning,
so they are one call now — adding a fifth check left the function shorter.
The posture a pane opened in was read off whichever runtime happened to be
first in the hub's order. Closing the conversation the person had set to auto
or yolo therefore reset the next one to ask — an approval prompt on a tool
they had already waved through, with nothing on screen saying why — and with
every pane closed there was nobody left to read it from at all. The same line
dereferenced Server on a remote pane, which does not have one.
The hub holds the stance instead: seeded from the runtime the host adopts, so
it is the launch setting until someone changes it, and written by the composer
switch, which already persisted the same choice to config for the next launch.
It no longer depends on which panes are still open.
The approval endpoints move next to that setter, where the rest of the
settings surface lives.
The icon drew a rounded square with a notch and a corner badge — a mark that
appears nowhere else in the product, so the tray was the one place Reasonix
was a different logo.
It is now RMark.tsx's three paths, carried over in the 16-unit space the SVG
draws them in and rasterized against a distance field: reading a stroke as a
capsule is what gives the round caps and joins without drawing either.
Mood is colour and nothing else, each hue the one meaning the palette already
assigns it — ghost is neutral, net is running on its own, accent is needs you.
The lightness belongs to neither theme, because a notification area is a
background this process cannot ask about: all three clear 3:1 against white
and against near-black.
The tests hold the two drawings together, so a path or a stroke weight that
moves in the frontend fails here instead of leaving the icon on the old glyph.
Studio never carried a SingleInstanceLock, so every double-click of the
launcher started another process over the same data home — several windows,
each binding session leases the others were already holding.
The lock is keyed to the data home rather than the executable: an installed
build, a portable one and a canary all write the same sessions, so they have
to find each other, while an explicit REASONIX_HOME stays its own instance.
The prefix is Studio's own bundle identifier, so this never answers the 1.x
desktop's lock.
REASONIX_DEV still waives it, and run-studio.sh now passes that through
`open --env` — LaunchServices does not inherit the shell's environment, so
without it a dev launch would be handed to an installed window.
Closes#9412.
`droppedRef.Image` carried `omitempty`, and false is a bool's zero value, so
the field left the JSON at precisely the value the composer needed: every file
that is not a picture answered with silence. The page read that silence as an
image — the check was `image === false`, and `undefined` is not `false` — and
drew an empty thumbnail labelled "image" over a .md file.
POST /attachments never had this: it writes its answer through a map, which
omitempty does not touch. Only a path dropped onto the window went wrong.
Both sides now state it. The field is always present, and the composer asks
whether it is an image rather than whether it was denied: a missing field falls
back to the file glyph, which still shows the name.
Two separate things were painting over a wallpaper nobody could see.
The rail: its 54px hit box is pointer-events:auto and sits over the transcript's
right edge, so any control that lays out to the right — the rewind entry is one
— lost its right half to it. The width is now a token both sides read, and the
transcript keeps clear of that column instead of fighting it for z-index, which
would only have traded a dead button for a dead stretch of track.
The wallpaper: three background rules were unconditional, and rail + panes +
side tile the window exactly, so the image was sealed under them. Two rules
written for it had therefore never once fired — .app cleared a path nothing
took, and .flow's 88% scrim, meant to catch prose over a photo, sat behind a
panel that was never transparent. With the panes clear they finally show, and
what they do is not what was wanted: the rails are down to a thin frosting,
the empty-page halo no longer tints itself from --surface (which reads as a
black fog in the dark theme), and prose carries its own shadow instead of
cutting a slab out of the picture.
Closing the metrics rail did not survive the window: nothing wrote the choice
down, so every launch opened it again. Two things had to change together —
the fold-away helper kept the wide-window value in a ref seeded to `true`, so
even within one session, narrowing the window and widening it back reopened a
rail the reader had shut.
The rail still opens by default. What is remembered is the reader closing it,
and only a deliberate close is written: folding away because the window ran out
of room leaves the wide-window choice alone.
The window process does not act on SIGTERM, so the pkill before `open` matched
the old instance and then left it running. Every `make studio` opened another
copy on top of the last, and they accumulated for as long as the machine stayed
up — two were live here, one of them a day old, before this was noticed.
Ask with SIGTERM, wait a beat, then insist.
Ranks say which column a node sits in and nothing about its row, so the
order the deltas arrived in decided it. Wires crossed for no reason a
reader could act on, an edge spanning a column was drawn straight over
whatever cards stood in the middle, and the same run redrawn came out
different.
Layout moves to its own module so the arithmetic can be checked without
rendering anything, and gains Sugiyama's two missing steps:
- Ordering. Each column is swept by the median of its neighbours' rows,
and only a sweep that lowered the crossing count is kept, so a round
cannot make the picture worse.
- Waypoints. A long edge reserves a thin row in every rank it crosses,
which is what keeps it off the cards it flies over.
- Columns are centred against the tallest rather than hung from the top,
where a lone node read as a step that ran early instead of one that
ran alone.
The panel also reads the kernel's new facts: a queued node says so
instead of sharing "not started" with a node nothing has reached yet,
the summary counts the two waits apart, and the detail strip carries
slot wait beside run time.
Folding is indexed rather than scanned. The graph is the one piece of
session state that only ever grows, so a findIndex per node and a scan
per edge made the cost of one delta the size of the whole run.
The graph declared StartedAt and EndedAt and no producer ever filled one,
so every consumer's timing fallback was dead code and a run's own order
survived only as the order its deltas happened to be published in.
Waiting for a slot and holding one were the same word besides. A fan-out
the session concurrency ceiling was throttling drew identically to one
where every item started at once — and which of the two it is decides
whether raising the ceiling would buy anything.
- agentgraph gains StateQueued and Node.QueuedAt. Terminal() had to learn
the new state: a queued node reported as settled leaves a reader no
longer waiting for the answer it is still going to get.
- SchedulerPolicy.OnStart fires once the slot is held, which is the only
moment the wait can be read from. acquireSlot moves to its own file and
becomes the single place that reports it, for both the foreground and
the background path.
- fleet and parallel_tasks publish queued, then running, then settled.
An item now settles the moment it settles rather than when its group
ends: a long fan-out used to draw every finished worker as still
running until its slowest sibling returned, which is the whole time
anyone is watching it.
Reported as Studio never raising the ask card: the model kept answering with
"ask 无交互响应(fallback)" and telling the user to go fix it. It was reading
its own tool result back. A subagent's ask found no asker and returned the
headless fallback - the text written for autonomous runs, which says in as many
words that no interactive user answered.
The root executor has an asker: EnableInteractiveApproval wires the controller
in as one, and every interactive frontend calls it. A child never did.
newAgentServices binds each collaborator from Options and there is no Asker
among them, so SetAsker is the only way in and only the controller ever calls
it - on the executor and the planner it holds, never on anything they spawn.
The gap was silent because the fallback is a plausible answer rather than an
error.
The call context already carries the right answer and nobody was reading it.
RunSubAgentWithSession is the one construction boundary every child goes
through, and by the time it runs, the context says whether a question can be
put to anyone: a delegated run has a parent blocked on it and a person waiting,
so exactly one question can be outstanding; fleet items and parallel tasks
build their context with the asker dropped on purpose, because a question from
one of many has no reader who knows which asked it; a background job runs under
a job context with no call on it at all. Inheriting from the context therefore
needs no flag and no policy - the three cases already differ where it matters.
Both halves are tests now, because "silently answers itself" is what this class
of bug looks like and neither half fails loudly. The construction moved to its
own file rather than growing task.go, which is 1200 lines past its budget.
Reported as connecting to a Linux server failing with "这次请求没能送到内核
(HTTP 502)" and nothing else. The request had arrived. The kernel had
answered, at length, and two layers threw that answer away between it and the
window.
Opening a pane over there is two hops: attach the link, then ask the kernel now
running on the far side to open a runtime. The link half already codes its
failures - a changed host key is not a network hiccup, and identify() in the
desktop says so. The second half did not. farRequest returned fmt.Errorf with
the far side's status and body flattened into a sentence, writeErr found no
code on it and fell through to http.Error, and a sentence is exactly what the
repo's own rule says an error a caller must tell apart may not be. A dead link
and a kernel that answered with a refusal are different problems with different
next moves; only a code carries that.
They have codes now. A transport failure is remote.unreachable, the same
condition the proxy beneath already reports through a different door. A far
kernel that answered is remote.kernel_refused, carrying the host and whatever
it actually said - falling back to the status line only when the body was
empty. Three writeErr call sites pick both up without changing.
The other half is the hub port, which asked for JSON and discarded anything
that was not. SseHttp was fixed for this once - users reported
"/providers/edit: 500" - and the hub was left reading res.json(), so every
plain-text answer from every hub route reached the window as a status and a
path, which reason() then renders as the request never having been sent. It
reads the body as text once and parses optionally, the way its sibling does.
Only a genuinely empty body may now claim nothing came back.
Reported as MCP not connecting automatically: twelve enabled servers, each
listing the tools it brought, every one of them reading "not connected" with a
Reconnect button beside it. Nothing was broken. That is what a healthy install
looks like, and the panel had no word for it.
Enabled config MCP is catalog-first by design. Boot registers each server's
cached toolset as placeholders and starts no process; the first real tool call
drives the handshake, and a fast server completes it in the same turn. So
host.Servers() is empty for all of them, every row falls through to the branch
for servers with no live connection, and that branch had two answers - off, or
idle - for three situations. The comment there already claimed to tell "switched
off" from "lazy and not needed yet"; the code did not.
The catalog answers it, not the host. A server whose tools are in this
session's registry can be called right now, whatever its process is doing, and
that is the question a status row is actually asking. MCPCatalogTools counts
the registry's MCP bindings in one pass - lazy placeholders included, because
they carry the server name like any other binding - and configuredState turns
that into standby, idle or disabled.
The panel says 待命 · 首次调用时启动 for standby and offers 立即连接 rather than
重连: nothing failed, so nothing is being retried. The mock's cache-hit fixture
moves with it, or the harness would keep showing the state this fixes.
Reported as storage settings reset to C: after upgrading Studio, with the
original location then impossible to choose again because it "needs an empty
folder". Two defects, and the second is the one that traps a person: whatever
loses the pointer, the data is still sitting on the other drive and there is no
way to say so.
The reset is the installer. studio.nsi asks for admin and the finish page runs
Studio itself, so the app inherits the installer's token; on an account that
elevated by entering someone else's credentials, %APPDATA% then resolves to
that administrator's profile and the first launch after an update reads a
config.toml that never heard of the user's relocation. It launches through
explorer.exe now, which is already running as the signed-in user. The exe
manifest is asInvoker, so only that one launch was ever affected - which is
exactly the launch the report describes.
The trap is that a relocation was recorded in exactly one place. A directory
now says what it is: a move writes .reasonix-root.json once the copy is proved
and before the configuration is told, so a marker means this folder is
complete, and it is removed from a location the root leaves. A preflight whose
target already holds this root's own data plans an adopt instead of a refusal -
the pointer alone, nothing copied, verified or reclaimed, and nothing at the
old location deleted. The plan reports what stays behind rather than letting it
go quietly missing.
Structure decides, never resemblance: the marker names the root, or - for a
root that declares what it owns - the folder holds those entries and nothing
else. A root with sole occupancy and no marker cannot be told from a stranger's
folder, so it is refused rather than guessed at. A folder left with only the
marker of a root that moved on is an empty target, not data to adopt.
RecordRelocatedRoots marks the locations a configuration already chose, so a
folder an older build moved data into becomes adoptable after one launch; it
follows the importers' rule and does nothing when the environment redirected
this run's roots.
While in there: PlanMove measured the whole source directory, so a state root
sharing its folder with home counted config.toml and the credentials into a
move that never copies them, and the progress bar could not reach the end it
was measured against.
Clicking a node showed what the kernel knew about it and stopped there. What a
reader wants next is the call itself — its arguments, its output, the reasoning
around it — and the transcript had no way to be pointed at one. Only user
messages carried an anchor in the DOM, because only the rail had ever needed to
find something.
Tool cards now carry the id the graph names them by, nested children included,
which is where a fan-out's nodes actually live. Nothing else reads it, so losing
it would fail silently — the button would switch tabs and do nothing — and that
is what the new test is for.
The rail's two-pass landing becomes one routine both callers share: scroll to
the block first, because the card may sit in one that is not mounted and has no
node to scroll to, then put the element itself under the reader and flash it.
The rail lands on a message, the graph lands on a call; same passes, different
selector.
The request is a counter, not an id, or asking for the same node twice would
land once. It is held rather than spent while the pane is hidden: a hidden pane
has no geometry, every rect reads zero, and the scroll goes nowhere. That also
makes the caller's ordering harmless — the view transition defers what it is
given, so setting the tab and the request in one commit is right, and holding
the request is what makes it right even when it is not.
The panel counted delegates. It could say four were running and never which of
them was blocked on which, because the list it was drawn from had already
thrown that away — the kernel now publishes the structure, so there is finally
something to draw.
A rank is a column: everything in it was free to run at that moment. Width is
concurrency and length is a chain, which is the difference a list renders
identically. Ranks are memoised with a separate in-flight set rather than one
visited set, or a node reached twice through a diamond reads as a root and the
whole picture flattens.
Edges are told apart by line, not by colour. Solid with a filled head means the
upstream answer went into the dependent's context; dashed with an open head
means the pair was ordered and nothing travelled — which is exactly what the
measurement arm produces, and drawing them the same way would say the two runs
were the same run. Reuse is a mark on the node, because an adopted item runs
nothing at all and would otherwise be indistinguishable from work that was paid
for. The state is a word on every card as well as a colour, so none of this
depends on telling hues apart.
The two questions a list cannot answer are the ones the cards lead with: a
blocked node says what it is waiting for by name, and the header says how many
of the fan-out actually ran. A separator that a node id could contain is a bug
waiting for an id with a slash in it, so pairs are keyed by encoding rather than
by joining.
The structure was being re-derived in five places and survived in none. A
fleet's dependency DAG lived and died inside one Execute call — the dispatch it
emitted carried a prompt and a profile, so depends_on never left the process at
all. The persisted sidecar recorded a parent link and nothing else. The event
stream implied the rest through id prefixes. By the time any of it reached a
reader it was a flat list of children, which is why a panel could count
delegates and never say which of them was waiting on which.
internal/agentgraph is the vocabulary, and it is a leaf: nodes, and edges that
carry an identity rather than leaving a reader to match words. depends and
context are two edges on purpose. The measurement arm orders its endpoints
while delivering nothing between them, so a single edge type would describe an
ablated run and a real one identically — the same reason an error a caller must
tell apart carries a sentinel rather than a sentence.
The other half of that rule was already being broken on disk.
InheritedContext.Upstream was a bool: it recorded that some dependency's answer
opened a run, and a reader asking which one had nowhere to look. It names them
now, and a sidecar written while it was a flag still decodes to "there were
sources, unnamed" rather than to nothing.
fleet and parallel_tasks publish what preflight already proved, before any of
it runs — an item that never executes emits no tool events to infer a state
from, and an adopted answer emits none at all. That collapses three copies of
the same five-state vocabulary into one and two identical group classifiers
into one, so a group's progress card and its graph node can no longer disagree
about how the fan-out ended.
A node is the call it names. Timing and output stay on the tool stream and a
frontend joins them by id, rather than the kernel publishing the same clock
twice under a second naming scheme. internal/boot asserts that at the frontend
sink through the real Build assembly: a scripted two-item fleet with a
dependency in it, and the edge has to arrive.
Studio could already say something mid-turn — the composer routes it through
steer, and the row on screen carries the receipt it can be taken back by. What
it could not do is see the queue. The pending rows are minted by the client on
its way out, so they say only what this window sent: reload the page and they
are gone while the entries are still in the manifest, still due at the next tool
boundary. A line the CLI queued, or a second window, was never on screen at all.
So the kernel had ten inbox endpoints and the frontend read three of them.
Editing, reordering, retrying a blocked entry, re-freezing the files one quoted,
holding delivery — all of it existed, with no way in. The panel above the
composer is that way in.
The queue arrives the way the workspace tree and the checkpoint list already do:
an event says it moved, and the content is read back whole. sessioninbox.Store
has had OnChange since it was written and nobody had ever registered one — the
notify path ran into an empty listener slice. Registering it there rather than
at each mutation is what makes this true of every mutation, including the ones
the kernel performs on itself when a turn consumes a steer.
InboxChanged carries nothing. It is tempting to put the snapshot on the wire and
save the round trip, and that would make every client's queue a function of the
frames it happened to receive — a reconnect that missed one leaves a panel
describing a queue that has since changed, and the manifest's checksums and blob
names would be on the wire for no reader. One authority answers instead, and a
frontend that has just reloaded is in exactly the same position as one that has
been connected all along.
adoptInboxLocked exists because there were two places opening a store, and a
listener registered in one of them is a listener half the sessions do not have.
Two smaller things the work needed.
The transport had four verbs and the update endpoint is a PATCH, so it has five.
The contract test that checks the frontend only calls paths this package serves
reads those verb names out of the source: without patch in that alternation the
new call was not unserved, it was unchecked. Confirmed by pointing it at a route
that does not exist and watching the test go red.
Editing opens on the entry's full text, not its preview. The manifest keeps a
120-rune line and nothing more — opening the box on that and saving it back is
how an instruction would quietly lose its last sentence. Measured against a
141-character entry: the row shows 121, the box gets 141.
Verified against a running kernel rather than the fixture, with the entries put
there by a second client so the panel had to have read them from somewhere:
three lines queued over HTTP appear in a window whose transcript never carried
them; an external reorder shows up without a reload; reorder, take-back and
release from inside the panel each land and the panel redraws itself; an edit
saved from the box reaches the kernel. That last one also found a defect this
could not have found any other way — an empty queue is items: null on the wire,
which is what a Go nil slice encodes to, and the panel reading it as an array
would have thrown at the moment the queue ran dry, and only then. Normalised in
the port, where one reader knows it instead of every reader having to.
Deliberately left alone: a line this window sent appears both as a pending row
in the transcript and as an entry in the panel. They are not the same statement.
The row is where it sits in the conversation; the entry is what is still owed,
and it is the one that survives a reload. Folding either into the other would
cost the case the panel exists for.
The window is what this version is: the greys came home to the product's own
hue, the activity stream became a rail you can scan instead of paragraphs on
white paper, and the menu bar got an icon that says what this machine is still
doing. Around it, the close button finally keeps the promise its config option
has been making since before this shell existed.
Two things are deliberately not in here. The status icon's own defects — a dead
Win32 pump, a crash on macOS launch — were introduced and fixed inside this
version, so no released build ever had them; the notes say what the icon is, not
what it survived. And fleet's for_each was built, measured against the call it
replaces, and removed in the same span: it never reached a surface, so there is
nothing for a reader to have used.
Studio did not start on macOS. `open` returned and the process was gone, with
nothing on screen and nothing in the terminal — the bundle path hides the
output. Run the binary directly and it is there:
*** Terminating app due to uncaught exception
'NSInternalInconsistencyException', reason:
'NSWindow should only be instantiated on the main thread!'
-[SystrayAppDelegate applicationDidFinishLaunching:]
-[NSStatusBar _statusItemWithLength:]
tray_run_other.go carried a guess about this and flagged it as one: "macOS puts
its status item on the main thread and Wails already owns that one, so handing
the icon a thread of its own — the Windows fix — would be a crash rather than a
dead icon. Neither platform has been run from the machine this was written on."
The first half is right and the conclusion does not follow: nobody was handing
the icon a thread, and the thread it inherited was not the main one either.
Wails calls OnStartup from a goroutine, and the crash frame is m=5.
It is not the ownership conflict it looks like. registerSystray returns
immediately when the loop is external, so systray never takes NSApplication —
nativeStart only allocates a delegate and calls applicationDidFinishLaunching
on it directly, and that is where the status item gets built.
So the defect is narrow, and it is a lone exception in its own library: every
other systray entry point — setIcon, setTitle, every menu mutation — already
hops to the main thread through performSelectorOnMainThread. nativeStart is the
one that does not.
The hop happens here instead, the way this tree already does it. dock_icon
_darwin.m has the same shape for the same reason, and its comment names the same
caller: "the caller is a Wails lifecycle hook that may not be it."
Async, not sync. dispatch_sync would deadlock whenever the main thread has not
reached its runloop yet, which is exactly when a startup hook runs. Nothing
needs the completion: ready() ends by replaying the last state it was given, and
a setter that arrives before the delegate exists is a message to nil.
tray_run_other.go loses darwin from its build tag and the retired guess from its
comment.
Verified with tray at its default, the configuration that crashed: the window
comes up at 1440x900 and the status item is there, titled "Reasonix", its
tooltip reading "Reasonix Studio — 没有在跑的" — which is ready() having run,
built its menu and replayed state, all on the main thread.
The token system was never the weak half. The activity stream was: the column a
reader spends the whole session in had no material at all, while the rail beside
it has hierarchy, the metrics column has data, and the trajectory tab has a
gantt. In light theme it rendered as paragraphs on white paper — a terminal
block, the largest object on screen, carried border-left: 1px and background:
none.
The timeline was already there in structure and absent in pixels. .call has been
a 20px gutter with a stamp and a connecting line since it was written, but the
line was 1px of --hair, which on a screen is nothing. It is 2px now and takes
the category colour that tokens.css had already assigned and never spent —
neutral reads, amber writes, blue network, teal delegation — so a glance down
the gutter says what a stretch of the turn was doing. Sub-steps inside a
delegate panel keep the narrow gutter: two levels at one width is one level.
Blocks get material. .term sinks half a step by mixing --page into --surface,
one recipe for both themes because page already sits under surface in each. The
approval card lifts out of the flow on an amber wash, and only the one still
waiting: :has(.apv:not([data-sealed])) drops it the moment you answer. Amber
says exactly one thing in this vocabulary — it needs you — and it had been
reaching only as far as the buttons.
Wall-clock becomes a bar beside the number it already prints. It is horizontal,
which was not the first design: drawn down the gutter its length would be
bounded by the card's own height, so a 64s step with two lines of output either
pushes the layout around or gets silently compressed — and a meter that shrinks
when it runs out of room is a meter that lies. Across a header row there is no
such constraint. The mapping is logarithmic because the span is three orders of
magnitude, and the bars right-align against the duration, so a ragged left edge
down a column is the turn's time distribution.
A turn now starts at a visible boundary. Not a new Item type: the user card is
already the anchor the rail jumps to, and UserCard.tsx already records the
decision that turns carry no number, because the rewind entry sits on the card
itself and there is nothing to match up by eye. It gets a rule and some room.
The right column stops being nine equal blocks with nine equal separators, which
reads as an unfinished form. Group boundaries fall on cost, progress,
environment and output, and the first block of each is recognised by the
data-b it already carries — no reordering, no new attribute.
One piece of the plan is deliberately not here. Highlighting the command line
inside a terminal block was going to be .term-l:first-child, and the first line
is not the command: Term splits text on newlines, real shell output routinely
opens with output, and past 300 lines the block is not split at all so there is
no .term-l to select. Guessing a role from a position is the thing this tree
keeps retiring. Doing it properly means telling the renderer which line is the
command, which is a different change.
Second half, found by the sweep the previous commit repaired: 55 light-theme
findings, and they were the design's own rules going unenforced.
--ghost was calibrated against the wrong surface. The comment said "on the
--raised it actually appears on"; it appears on --raised once and on --page 768
times, because rail, side and chrome are all painted in it and it sits a step
darker. The soft tier stopped at 4.03. It is 53% now (4.52 on page), and the
comment says which surface and why. normal and strong already cleared it and are
untouched; dark measures clean on every surface it lands on and is untouched.
--accent is a fill and --accent-ink is the reading value — tokens.css says so,
and 32 rules used the fill as text anyway. In light it clears nothing: 4.07 on
its own wash, 3.91 on page, 3.80 on overlay, while the ink is 4.98 or better
everywhere. --warn is the same hue tier and had 13 more. Only text colours move;
borders, fills and shadows keep the base. In dark the ink is the base, so both
sweeps are light-only.
Two singletons around them. The slash menu's selected row mixes its own tint
rather than using --accent-wash — deliberately, and the comment explains it —
but at 12% the ink on it reached 4.40, so it is 9% and the row is no less
selected. And white on --accent sat at 4.4977, which is not 4.5; #A7690C to
#A6680C is one step of a hex digit, invisible, and lands it at 4.56.
Verified on the real shell against fixture data, both themes, every surface the
sweep can reach: 55 and 45 findings become 0 and 0, with truncation, spill,
offscreen and unnamed-control all empty and no runtime errors.
getComputedStyle now hands back oklch() and color-mix() verbatim, and parseC
dug the numbers out of whatever string it got. Three components that mean
lightness, chroma and hue were read as if they were r, g and b, so every
judgement compared two colours nobody had painted: 1037 findings on a dark
sweep, all of them "#01004B on #00004B", a pair that appears nowhere in the
token table.
A gate that fires on everything is worse than no gate for the same reason one
that fires on nothing is — both bury the findings that are real. This one had
gone all-red, and the 55 genuine light-theme findings underneath were
indistinguishable from the 807 it invented.
The fix is to stop parsing. Paint one pixel with the computed value and read it
back: whatever the browser resolved is what gets measured, and the probe needs
to know nothing about colour syntax it will have to keep up with. Dark drops
1037 to 45, light 807 to 55 — and those 55 were real, which is what the next
commit is about.
One other correction while in there: the root background fell through to black
when html was transparent, because a fully transparent colour still parsed to
four numbers. It is only adopted now when its alpha says something was painted.
A vision host rejects an oversized image as an unsupported *format*. Measured
against api.deepseek.com/anthropic on 2026-08-24: 8192x1000 is accepted,
8193x1000 is not, and neither is 1000x8193 — the limit is a single side, while
total pixels (8000x8000 passes) and bytes (a 12 MB PNG passes) are not bounded
at all. The message names webp, png, jpeg and gif, so the reader spends the next
hour confirming the file really is a PNG. It is. It is too wide.
Worse than the wrong question: the rejected user message stays in history and is
resent every turn, so one attachment ends the session. Nothing in the tree
stripped it and retried; ProviderErrBadRequest had no consumer at all.
compressForVision scaled on side length but forwarded the original whenever it
could not: an undecodable mime, a header it could not read, and — the one that
fires here — anything past the 50M-pixel decode limit. A long diagram is exactly
that shape, so it skipped the 1568 budget and went out full size.
The budget is now the gate. internal/visionimage owns the only judgement, and
what leaves it has both sides within MaxDim or does not leave: no vendor number
is written down anywhere, because 1568 already sits below every host's ceiling
and a declared 8192 would be one vendor's answer imposed on the rest. What
cannot be proven to fit is refused with ErrUnfit, an identity a caller can read,
and the reason names the dimensions and what to do about them.
It is a package rather than a control helper because MCP tool results are the
same hole reached from the other side — toolResultImage validated base64 and
size and never looked at dimensions — and internal/plugin cannot import control.
Both callers now ask the same function.
Around it, a turn's attachments stop being three loose values. turnImages says
what the model got, what a delegate could get, and what nothing got, which
retires orchestratedTurn.imagesResolved: a resolved turn is a non-nil pointer.
The headless entry points compose through imageRoutingPrefix like the
orchestrated one does — they bound the attachments but dropped the note, so a
model with an unreadable attachment was told nothing and went off to write its
own OCR script.
Refusing is only half of it; saying so is the other half. The user gets the
reason and the file, and the model is told an attachment exists that it will
never see, so it does not answer as though nothing was attached.
Verified against the real endpoint, one arm each side of the budget:
12000x5000 (60M px) refused locally, "past the 50000000-pixel decode
limit ... shrink it first"; the model relays it and
suggests sips -Z 7000. The turn completes.
20000x1000 (20M px) downscaled to 1568x78, sent, answered correctly.
12000x5000 sent raw 400 unsupported image — what shipped before this.
Two deliberate gaps. The 50M-pixel decode limit is unchanged, so an 8000x8000
image the host would have taken is now refused locally; raising it trades
against decode-time memory and wants its own measurement. And a session already
carrying a rejected image still needs a rewind — this stops the poisoning, it
does not clean up after it.
Test fixtures that were PNG signatures over zero bytes are now decodable PNGs:
an image whose dimensions cannot be read no longer travels, so a header-only
fixture asserted nothing.
Nothing held docs/SPEC.md against its Chinese companion, which is how 3.13 lost
a table row and 3.14 fell three commits behind before anyone noticed — long
enough for one of its sentences to become a false statement about where
dependencies travel.
Parity is declared, never inferred. The companion abridges most sections on
purpose: 3.7's 111-line list is nine lines there, and twelve of seventeen
sections differ structurally by choice. A whole-file rule would fight that and
land twelve findings of recorded debt on day one. lockedSpecSections names the
five that are full translations — 3.11 and 3.13 through 3.16, the delegation
group, which is where changes actually land — and adding a section to it commits
every later change to that section to both files. It is the same reason the
host's sensitive paths are declared rather than read out of how a path is
spelled.
The check reads structure and never wording: the heading sequence across both
files, and each locked section's sequence of block kinds. Size is compared only
where a translation cannot legitimately change it — a table row and a fenced
line survive translation, a wrapped sentence does not — so prose line counts are
never compared, English hard-wrapping at 80 columns against unwrapped Chinese
stays quiet, and a test pins that. Comparing prose would fire on every
legitimate translation choice, which is a judgement this tool cannot read from
structure.
It lands at zero recorded debt: the five locked sections are in parity as of the
previous commit, so the rule's budget is zero and any drift fails rather than
being absorbed.
Verified against the drift it was written for. Dropping the 3.13 table row that
actually went missing reports:
docs/SPEC.zh-CN.md:282: [spec-parity] section 3.13 table block 2 has 9 lines
here and 10 in docs/SPEC.md
and exits 1.
A failed item collapsed every non-cancellation error into one status plus a
sentence, so the caller deciding whether to re-issue the graph with the finished
nodes adopted had to read the message. The identity already existed one layer
down and was being discarded at the fleet boundary: provider.RetryableStatus
knows 429 from 400, and APIError.ErrorCode knows an overflow from a rejection.
Confirmed the identity survives the trip before building on it: a
*provider.APIError returned by Stream reaches RunProfileSpec intact, status and
code readable through errors.As. Classification therefore reads what the error
already carries rather than decorating it.
Four classes, each sourced from a real identity — provider.transient (408, 429,
5xx, or an interrupted stream), provider.rejected (any other 4xx),
context.exhausted (ErrCompactionRequired, or a documented context-overflow
code), and unclassified for everything else. Nothing reads message text. An
error whose producer named nothing is reported unclassified rather than guessed
at, and a test pins that an error whose message reads "rate limit exceeded"
stays unclassified: this is the rule that retired the phrase tables, and a
failure surface is not where it gets an exception.
The identity travels to the branch the failure killed, because a skipped item is
worth re-issuing exactly when its cause was.
Skip policy is unchanged. Retrying inside a run is a separate decision and needs
its own evidence; adopt_ref is already the caller-side retry primitive.
Second half, found while testing the first: a skipped branch made runFleet report
the whole call as cancelled and answer context.Canceled. A rate limit is not an
interruption, and that error is what the turn loop reads to tell an interrupted
call from one that ran and reported. Only a real cancellation answers it now —
including the pre-cancelled context the old skipped check happened to cover — and
the aggregate headline gained the third state it was missing, so a run that
reached its end with failures says how many of its tasks were answered instead of
claiming to be either clean or cancelled.
docs/SPEC.zh-CN.md had drifted three commits behind on 3.13 and 3.14: the
upstream payload, adopt_ref with list_subagents, and the measured for_each
exclusion never crossed, and one sentence had become false — dependencies "never
reach ProfileExecSpec" after they had started travelling on
ContextRequest.Upstream. Both files now describe what the kernel does.
The cycle ran a fixed ["auto", "light", "dark"], and "follow the system"
looks exactly like whichever of the two it currently resolves to. On a
light machine the default auto stepped to light: not one pixel moved, so
the control read as broken. The next step now comes from what the system
is rather than from the array's order, and the first click always
repaints.
One hop stays silent -- returning to auto lands on the scheme auto already
shows, and that is what those three states mean. The button answers the
press itself so the click is acknowledged either way.
Every neutral in the palette sat at hue 255-267 while the accent sits at
75: the greys were 185 degrees from the one colour the product owns, which
is what made them read as somebody else's. They are OKLCH now, at the
accent's hue and a chroma low enough to still be grey, with the lightness
carried over untouched from the hex each one replaces -- so every contrast
ratio the comments there argue for still holds.
The four surface steps were declared but only the centre used them, so the
window was one flat plane with hairlines drawn on it. The frame drops to
the canvas and the middle column becomes a sheet on it. Radii go to
1/2/3/4: an instrument is machined, not moulded, which is what lets rules
and columns take over the dividing. Terminal output, delegate panels and
returns, the approval, and the right column's sections all lose their
boxes for rules.
--warn and --accent were the same hex, so "each colour does one job" was
not true of those two: needing you and costing you were indistinguishable.
warn moves to H45 at 8.14:1 dark and 5.83:1 light, matching accent's 8.35
and 5.77.
The mono stack shipped no face, so the layer carrying every label, tag,
figure and terminal line rendered in Cascadia on Windows and SF Mono on a
Mac -- the problem the comment above --ui says was solved, still open one
line below it. IBM Plex Mono Var, 81KB, OFL like the other two. It has no
geometric shapes and no U+2717, which fall through to the shipped Noto, so
they stay identical across platforms but off the monospace grid; the two
places a check and a cross share a marker column use x instead.
Motion: a weight tier now decides duration and easing together, and
choreography has one mechanism -- sibling-index() rather than fifteen
hand-written nth-child rules. The composer row had grown to eight items
against a chain that stopped at seven, so the eighth entered with no delay
at all. Streamed transcript cards are deliberately not staggered: they
arrive one at a time already, and delaying them would delay the work.
Light is emitted by readings, never painted on surfaces: the live symbol
blooms in its own category colour, the gauge leaves a phosphor edge as it
grows, and a finished turn gets one sweep and one flash. A grain overlay
takes the sterility off surfaces that are otherwise perfectly uniform,
which no real material is.
event.go states the contract: a Retrying indicator is transient, and the
next stream event -- or TurnDone -- clears it. The chat TUI does that. The
window only cleared it on a text or reasoning delta, so a turn whose first
packet was a tool call left "retrying 1/5" and its clock standing for the
rest of the turn, over calls that were running fine.
The clearing moves to a rule about event kinds: the kinds that say nothing
about the connection leave the wait standing, and everything else is the
provider having answered.
Two more the same measurement turned up. RetryScope was on the wire and
ignored, so a body-phase replay was announced with the header-phase
sentence -- 1/5 is maxStreamRecoveries, and headers retry ten times. And a
retry between two steps used to show nothing at all, because the indicator
only rendered inside a wait that the first packet had already ended.
The clock now counts from the stamp the wait carries rather than from the
component's mount, so it reports the stall rather than the turn.
The prefix is what prompt caching pays for and one changed byte re-bills all of
it, so the useful question is not how often the cache held but what stopped it.
CompareShape already answers that every turn — system, tools, or the reason a
Rewrite queued — and the verdict rode to the frontend and stopped. This carries
it: cache_miss_reason, one bucket per attributed cause, nothing inferred here.
Rewrite takes an open reason string, so an unknown one buckets as other instead
of letting a caller widen the wire's bucket space.
The complement is the case the kernel cannot see. A prefix that held and still
returned no cached tokens is a gateway that dropped cache_control, an entry that
expired, an endpoint that never cached — downstream, unattributable, and exactly
the shape of the route that billed twelve million prompt tokens at full rate
with nothing anywhere reporting it. It buckets as unexplained, above the
smallest prefix any vendor here will cache so that turns which were never
cacheable stay out of it.
uploadSignals is the silent gate: a counter the sink records but the list omits
is queued and dropped without a word, and locally that is indistinguishable from
a feature that works. A guard now drives a turn through every recording branch
and fails if the wire rejects what came out. It found tool_call_reasoning_recovery
dropped since it shipped — the bucket is a ProtocolRecoveryKind, a closed enum,
never content — and that is now allowed through.
Built, shipped behind the surface, and measured against the call it replaces —
the same graph with one task handling every subject. Three fixtures, twenty-eight
graded runs against a real provider, every run of both arms solved, and mapping
spent 70%, 85% and 98% more tokens for it (exact permutation p <= 0.03 each).
The last fixture was written to be the case mapping should win: ten modules of
near-identical code, a hundred and sixty lines each, and an answer that is the
sum of ten separate derivations so a slip anywhere is a wrong answer. It did not
win there either, and one run solved it by grepping the constants out rather than
reading anything — which is the reason. A mapped child pays a full sub-agent's
fixed cost per subject, while a single child with a tool belt never has to hold
the subjects at all: it reads them one at a time, and when the facts are
extractable it extracts. "One context cannot hold N subjects" assumes an
ingestion an agent does not have to perform.
So the graph vocabulary goes back to id and depends_on, and SPEC 3.14 records the
exclusion as measured rather than as taste.
Two things survive the removal. The edge dedupe in newFleetPlan stays: a task
named twice in one depends_on was already counted twice before for_each existed,
and the item would have waited for a result nothing publishes. So does
unresolvedDepCounts, which adoption needs — an adopted node enters resolved and
its dependents have to be ready on the first pass.
benchmarks/fleet-fanout goes with it. The history holds both if a case that
genuinely needs per-subject isolation ever turns up.
Claude caches only where cache_control marks a breakpoint, and that field is
serialized by internal/provider/anthropic alone. The same model reached through
an openai-kind entry is not an error and not a visible wire difference — it is
full input rate on every prompt token, and the only symptom is a cache read
that never arrives. Measured on a live gateway: 3610 input tokens with no
breakpoint against 7 plus a 3603-token cache read with one.
The judgement reads structure and nothing else — an entry that sets both a
dialect and a model list, resolved through the package-level slice the models
usually live in, because an entry that never spells the name in its own literal
is the ordinary case. Fixtures are exempt: a test builds the broken shape on
purpose to assert what it does.
realyyds_test.go pins the reason a yyds-anthropic entry exists at all, in the
//go:build live form this repo already uses for gateway probes.
The one finding is recorded, not fixed: the vendor's Anthropic route could not
be reached to tell a gateway that lacks one from a preset that picked the wrong
dialect, and asserting either in source would be inventing the fact. The
ratchet is where an open question of that shape belongs, and the rule still
fails every new one.
The counters said how often a turn cached well and never how much it cost.
A flat-rate plan is priced on the tail, so the shape has to survive the wire:
turn_prompt_tokens, turn_output_tokens and turn_cached_tokens are magnitude
buckets taken once per turn, because three 40k requests are one 120k turn and
the plan is paid on the turn. The per-turn scalars move behind turnCounts so
both boundaries reset by whole-struct assignment and the next field added
starts clean.
Surface was hardcoded "cli" at both post sites while stats spelled the same
vocabulary a third way, so internal/surface now owns it and boot, telemetry
and serve read it from there. Or(fallback) makes a caller say what an unset
field could only have meant rather than letting the code guess. One machine's
queue is shared by every surface on it, so the flush groups by surface as well
— without that a window's turns post as the bare server's.
The window and the headless server reported nothing at all: DesktopMetrics and
DesktopTelemetry were settings with no sender. They are separate consents, so
backgroundFlush takes the ping apart from the drain and each is honored alone.
Enabled stays fail-closed, so a development build, CI or DO_NOT_TRACK still
refuses, and a non-interactive server counts only where telemetry was turned
on deliberately.
runServeWithOptions moves to serve_frontend.go, where the serve frontend's
entry point belongs; cli.go was 2595 lines and 1795 over the ceiling, and the
extraction lowers its budget by more than the move costs the file that gains it.
Two fixtures, twenty graded runs against a real provider, and mapping never once
solved something the single-task form did not: 20/20 on both arms. What it cost
was 85% more tokens on the cheap fixture (exact permutation p=0.002) and 98% on
the one written to favour it — ten modules each hiding its effective timeout
behind a three-step derivation, where grepping the constants answers nothing
(p=0.029) — with no wall-clock gain either time, at the default concurrency and
at a ceiling raised past the mapped width.
So the mapped fields leave the provider-visible surface. The schema field and the
sentence describing it are dropped together, so the description never advertises
a parameter the schema does not carry, and a call naming for_each is refused
rather than half-honoured.
There is no setting. A capability the host cannot recommend does not get a knob:
turning it on is a change at the wiring point, to be made once the case that is
still untested is measured — subjects whose combined content one child cannot
hold at all, which neither fixture builds.
The code and its tests stay, and benchmarks/fleet-fanout now holds both pairs.
The evidence for switching it back on and the evidence for deleting it is the
same suite either way.
A capability has no ablation arm — there is no "the same thing with the payload
removed" for for_each the way the dependency edge has one — so the honest
comparison is what this call had to look like before it existed: one task
handling every subject. benchmarks/fleet-fanout holds both, identical except
that the middle item takes for_each rather than depends_on, graded the same.
Against the real provider, n=6 per arm across two concurrency ceilings, both
arms solved 6/6, and mapping a one-file check over twelve subjects spent 85%
more tokens (exact permutation p=0.002) and 47% more money (p=0.026) for no
wall-clock difference (p=0.70). Raising max_subagent_concurrency past the width
did cut the mapped arm's wall time, and cut the single task's by more.
The mechanism is a sub-agent's fixed cost — prefix, reasoning, closing contract,
once per subject — against per-subject work of reading one short file. It is the
same boundary the dependency edge has: both pay in proportion to what a unit of
work costs relative to that overhead, and both fixtures so far measured the cheap
side. So the tool description now says to map only work that needs a run of its
own, and SPEC 3.14 records what the fixture measured and what it did not.
for_each names the item whose submitted subjects this item maps over: its prompt
becomes a template run once per subject, and it reports one bounded aggregate
that its dependents open with over the ordinary dependency edge.
The mapped children are never vertices. The node stays one, so the reachability,
write claims, and skip rules preflight proved are the ones that ran — adding N
vertices mid-run would invalidate the precomputed reachability that
skipDependents and the concurrent-claim check both read.
Four things are fixed before anything starts, and that is what keeps for_each
from being the first keyword of a workflow language: the template, the width
ceiling (max_items, defaulted to 8 and hard-capped at 32), the read-only grant —
which removes the write-claim analysis the host could not have done for an
unknown number of writers — and the source. The source supplies only which
subjects, through submit_items rather than prose, because reading a list out of
an answer is the host guessing at a judgement the structure does not carry. The
caller owns the topology, the worker owns the data: the same split the
dependency edge makes.
submit_items reaches only a source's registry, so a capability nothing in a run
can use never enters its prompt. A source that finishes without submitting fails
the mapped node rather than quietly mapping nothing, while an empty submission is
a real answer that completes it. A source may not be adopted, having never run to
submit anything, nor itself mapped, since it answers with an aggregate rather
than a list.
SPEC 3.14 said the graph vocabulary had no dynamic fan-out. It now says no
computed edges, which is the invariant that actually mattered.
Execute's preflight moved into preflight(), which returns a proven fleetCall,
and the ctx-gated tool attach sits beside the other ctx-keyed host framing in the
two sub-session entries; that is what paid for the new lines without widening the
repolint baseline.
depends_on ordered its endpoints and delivered nothing, so a research →
implement pair paid for the same research twice. Three changes that compose:
- The edge carries the dependency's answer. It travels as
ContextRequest.Upstream, never inside TaskSpec.Objective, because the
objective must stay the pristine text delivery classification judges —
ClassifierTaskText is captured before any host framing. One 8 KiB budget is
shared across a node's dependencies, so a wide fan-in cannot flood the
opening turn and no dependency is starved out of it.
- list_subagents enumerates the children a conversation owns. A reference is
the only address a child answer has, and a background fleet that a restart
repaired to interrupted never delivered the aggregate carrying those
references, leaving its completed children unreachable.
- adopt_ref stands a node down to an already-completed child's answer instead
of running it. It is proven in preflight through ReadFinalAnswer, so what may
be adopted cannot drift from what read_subagent_result may read. Nothing
runs, so the node claims no write_paths and cannot collide with a live
writer; prompt and every execution field are refused on it rather than
silently ignored.
Whether an adopted answer is still valid is not readable from structure, so the
host does not decide it: adoption is addressed by reference, and the caller that
listed the reference judges freshness. That is also why a plan fingerprint does
not quietly reuse matching children — reuse the caller cannot see is reuse it
cannot correct.
A child now inherits something, which is exactly what InheritedContext exists to
keep a decision rather than an accident, so the capsule records it and SPEC 3.13
says so.
--ablate upstream leaves the edge ordering its endpoints while delivering
nothing, which is what lets the payload be measured apart from the order.
Against the real provider on benchmarks/upstream-edge/fleet-edge-wide, n=6 per
arm, removing the payload cost +27.6% tokens (exact permutation p=0.011) and
+59% sub-agent tool calls for the same answer, 6/6 solved either way. On a fact
one grep away it buys nothing: the edge pays in proportion to what the
upstream's work cost against how small its result is.
boot.go's delegation registrations moved to delegation_tools.go and Execute's
spec loop to buildItemSpecs; that, plus folding two defaulting branches into
firstNonEmpty, is what paid for the new lines without widening the repolint
baseline.
The refusal carried "use POST /inbox/items for durable follow-up" as its
detail, and the frontend's wording for that code interpolates the detail — so
what reached the screen was half a Chinese sentence with an API route glued to
it. The kernel was telling the client what to do and the client was showing
that instruction to a person.
Both halves. The refusal keeps its code and drops the route: the English is for
a log now. And the client does what the code asks — it queues the words as a
durable follow-up, which is a row that says it is waiting rather than an error
nobody can act on. That also closes the race behind the report: the turn reads
as finished on screen while the kernel is still landing it, and typing in that
window produced a refusal for timing, not for content.
A queued row now says which wait it is in. Guidance lands at the next tool
boundary; a follow-up goes when the turn does, and the two are not the same
promise.
DetectLanguage reads REASONIX_LANG, then the POSIX locale variables. Windows
sets none of them, so every native surface on a Chinese Windows fell back to
English — the CLI's own words, the folder picker, the status icon's menu —
while the webview beside them read the browser's list and got it right. Two
halves of one app answering the same question differently.
The machine is asked last, after anything set on purpose. It goes through a
variable so the priority between the two is testable on a machine that answers
either way, rather than only on one that happens to be installed in Chinese.
Catalog could not use it: an empty preference means "follow the machine", and
what Catalog returned instead was whatever the process had already installed —
English, for a window that installs nothing. CatalogFor resolves the same way
DetectLanguage does and installs nothing, which is what a shell whose interface
language is a setting of its own actually needs.
Three defects, one feature. The icon drew and then answered nothing, left
button or right: systray creates its window and pumps GetMessage on whichever
goroutine calls Run, and RunWithExternalLoop — which reads like the embedding
path — starts that pump on a goroutine nobody pinned. A Win32 message queue
belongs to a thread, so the window sat on one and the pump read another. It has
its own locked thread now, on Windows; the other platforms keep the documented
path, because macOS puts its status item on the main thread and Wails already
owns that one.
Clicking it did nothing for the commonest case, a window that was open and
merely buried: show and unminimise are both no-ops on that, so the raise has to
be asked for. And the icon read as a brown blob with a black slit, because the
notch was painted in the window's own dark page colour — an assumption about
the taskbar behind it that a light one disproves. It is cut out now.
The switch for it could not be switched: the field took the value and the
renderer never wrote it, so every save dropped it and the setting turned itself
back on. A setting the renderer drops is a setting that does not exist, and the
round trip is now a test.
The settings pane draws the dependency instead of enforcing it invisibly: the
close-to-tray switch hangs off the icon switch, indented, because without an
icon there is nothing to bring a hidden window back and the second switch is
not a choice. The icon itself is a launch preference — systray quits once per
process, so an immediate off would be a switch that cannot be switched back.
[desktop] close_behavior has been normalized, migrated and editable since
before this shell existed, and read by nobody: quit|background was a promise
the app did not keep. This is the half that was missing. The close button hides
where an icon can bring the window back, and quits where it cannot - no icon,
no backgrounding, because a window that vanished with no way back to it would
be worse than either.
What the icon says is folded in internal/traystate, which owns no OS handle and
draws nothing: panes in, one State out, ranked so that being needed outranks
being busy. Nothing on the wire announces an answer to an approval, so the fold
reads the first thing the run produces afterwards as the resolution - a turn
blocked on one produces nothing until it is answered, which is what makes that
sound rather than a guess. Background jobs are pulled on a timer instead,
because a job announces its life in a sentence and matching those words is the
judgement this codebase keeps retiring.
Quitting from the menu runs the window's own close sequence rather than a
second one. The reason that sequence exists - two processes writing one
transcript - does not care which control asked for it.
Windows is verified: it builds, and the icon's own encoder is the 22-byte ICO
header around a PNG. macOS and Linux compile from here but cannot run: this
machine has no CGO toolchain for either, and the thing to check there is
whether systray's external loop is happy beside Wails' own on the main thread.
/config/problem and /config/repair reached the kernel over HTTP and nowhere
else: the window routes by table, so both answered with index.html, and the
banner that says a config file will not parse could never load in Studio. The
dev proxy had the same hole.
The two guards that read the kernel's own registrations are what caught it,
which is the whole reason they read those rather than the client.
Typing while a turn runs queues the line for the next tool boundary, and nothing
could cancel it. The durable item had an id and a delete route, but the delete
refused the one state a queued steer is in, and the frontend had thrown the
receipt away - so the row on screen had no name to cancel by.
The authorization comes from the executor's queue rather than the durable state.
Its lock is the only place that can tell "not read yet" from "already on its way
to the model" without racing the run loop, so the drop there is what permits
removing the durable half: in that order, and not at all if the drop fails. Too
late answers with its own identity instead of a state name, because "removed"
and "already gone" are different sentences to the person waiting.
It rides the verb that already existed, so the CLI's inbox pane, ACP and the bot
inherit it with the controller. Studio grows one control beside the queued badge,
visible without hovering for it.
The settings screen showed built-in defaults while the user's own config sat
unreadable on disk - settings nobody had chosen, presented as theirs - and said
nothing until a save was refused with a parser's sentence about columns. The
runtime already recovers from this by loading the last-known-good snapshot;
loadForEdit does the same now. The save guard is unchanged, because a file
nothing has read is not a file to overwrite.
The refusal carries an identity. A config that will not parse is one condition
wherever it surfaces, so every panel answers config.unparsed with the line, its
text, and the one repair that is certain: a Windows path in a basic string, said
the way TOML accepts. The parser locates the escape it refused and the parser
confirms the result, so nothing here reads the message it wrote and a file it
cannot finish is left alone. The sandbox explains itself by code for the same
reason - the English under a Chinese heading was the kernel talking to a person.
While that path was open: the 24 places in internal/serve answering with a bare
error string now send a code, and refusal-path guards that envelope the way it
already guarded http.Error. A body carrying other fields is a report a panel
renders, and stays out of it.
A background job held the workspace writer lease until it was terminal, and for
a dev server that is never: the next session's first write waited behind
something that was never going to end, told only that it would continue
automatically when it was safe. The retention hook fired for every registered
job, so the case it really covered - a process the user left running - is the
one case it must not.
Agent runs already take the lease for themselves, so nothing needs the hook. It
is gone, with RetainUntil and the jobs option that carried it. A background
subagent still holds the lease for its whole run, because that run is bounded;
a process left behind is not.
The wait it does report is legible now. Contention under a second stays silent -
it is either milliseconds or a whole turn, and reporting the first kind left a
permanent line about a wait nobody experienced. Past that the open is a warning,
because the turn is stopped for the length of it, and it is always closed by a
report carrying the measured wait. Studio holds the state on the run strip while
it lasts and rewrites the card in place when it ends; a headless run sees both
ends on stderr, where it used to see nothing at all.
Remote workspaces are what this version is: a machine reached over SSH whose
folders and conversations sit in the sidebar beside the local ones. The rest
is what that took — a Windows host, a fingerprint someone has to read, a pane
addressing a runtime of its own over there — plus the effort ladder becoming
a fact about the endpoint, which is the one entry that changes what an
existing setup does.
`until_output_matches` and the reworded timeout_seconds description are a
deliberate change to the provider-visible tool surface, so the recorded
baseline moves with them.
Cache impact: the tools hash changes and the prefix hash with it, which
cold-starts the prompt cache once per machine. The system prompt is
untouched, and the schema grows by 91 tokens.
Polling a long job costs a round per look, and the alternative was waiting
blind — so the model kept choosing to poll. wait now takes a pattern and
returns the moment a job writes something matching it: one round for a job
that runs an hour, and still woken by the line that matters.
The pattern comes from the caller, because only the caller knows what it is
waiting for. A host deciding which words deserve an interruption would be the
same guess this codebase keeps retiring, and RE2 makes an untrusted pattern
linear rather than a way to hang the run.
Two things a naive version gets wrong, both found by the tests failing:
a watch registered after the line was written must still match — otherwise
asking a moment too late waits out the whole timeout for something already
true — and one registered mid-line must be seeded from what is already there,
or a pattern the next write completes is never seen.
What a wait reports is a reading, not a verdict: how long the job has run,
everything it has produced, and how much of that arrived while this call was
waiting. Slow progress and a stop differ there, and which one a task should
look like is the caller's to know — not a threshold anybody has to configure.
Two CI failures, both mine. Windows lint: the agent method was offered
unconditionally there, since the pipe name is fixed and nothing read an
environment variable to decide — so the caller's nil check could never be
false. It knocks first now, which is both what the check wanted and one less
dial per handshake on a machine with no agent. Windows tests: three of them
built a JSON body by hand around t.TempDir(), and every backslash in a
Windows path is an escape, so the body never parsed.
And the report behind it: a background job that redirects its output into a
file is silent on its own streams, which the watchdog called "may be stalled"
after fifteen minutes of it downloading a hundred megabytes. Silence is the
fact; stalled was the guess. wait now says how long a job has been running
and whether any of it ever reached this side, so the difference between a
task writing elsewhere and a hung one is visible rather than inferred — and
the answer names the longer timeout instead of another short step.
Two halves of one report from a user, whose screen showed the same notice
twice and then "model finished without a visible final answer 3 times".
The cause: a gateway can answer 200, hold the stream open long enough to look
healthy, and end it having sent nothing — no content, no reasoning, no tool
call, not even a usage record. That reached the turn as an empty answer, so it
spent three retries on it and named the wrong party. Told apart, it is
StreamInterruptUpstreamError like any other failure the far side reports
inside a 200: retried by the request layer, and ending with what happened. A
stream carrying only a usage record is still the model answering, and keeps
the turn's retry — the case that guard exists for.
The screen: repeats fold into one line and a count. Notices carry a code on
the wire that the session state dropped, and with it the ones this build has
wording for read in the reader's language rather than the kernel's English.
A code it does not know still reads.
Classification moved into stream_failure.go, which is what that file is for.
openai.go got shorter for it and the freed budget is reclaimed.
A machine worked on for months holds thousands of conversations. The local
column caps its list at thirty for that reason — drawing them all is what put
98k nodes in a sidebar once — and the remote tree, added later, drew every one
of them. A host can be folded now too, which is what makes several of them a
list rather than a scroll with the local projects somewhere above it.
Two things followed the focused pane that should not have.
The event pump still asked the far hub for /events with no runtime prefix, so
it listened to that hub's default runtime while the pane sent its turns to the
one it had opened. Everything worked and nothing came back.
Appearance was read and written through whichever pane had focus. A remote
kernel keeps its own copy, so switching to a pane on another machine repainted
this window with that machine's theme. It belongs to the window: the local
pane is what these three now read and write.
Windows answers SFTP with /C:/Users/... and its kernel takes C:\Users\... The
first spelling travelled all the way to the far hub, which tried to open
C:\C:\Users\... and refused — reaching the window as a bare 502, because that
one path had no code on it.
The platform converts, since only it knows both. The pane's name is cut the
same way: either separator can appear in a path this machine does not hold,
and its own filepath rules are not the authority on one.
It never was. The launcher linked the frontend in from the source tree, and a
symlink whose destination is outside the bundle fails codesign --strict — so
the app had a signature that verified nothing, and an app macOS cannot verify
gets no stable identity to hang a Local Network grant on. That is the whole
distance between "ping reaches the host" and the window saying no route to it.
Both fixes are the released bundle's own: copy dist rather than link the tree
(scripts/studio-build.sh has always copied), and sign --deep, because
everything under Contents/MacOS counts as code and each file needs its own
signature. The launcher now verifies afterwards and says so if it fails,
rather than leaving that to be discovered as a networking bug.
Three places still told the reader that a remote must be Linux or macOS, the
window's refusal among them. What it should say is narrower and more useful:
the link is fine, that machine's system is what a kernel cannot be installed
onto — and name the three that can.
Everything below the SSH link assumed a POSIX shell. It now goes through one
interface — which shell, which spelling of a path, how a process is started
there and found again — so the difference is in one place instead of a branch
at every call site.
Two things about Windows had to be discovered against a real machine rather
than reasoned about:
Detachment. OpenSSH puts a session's children in a job object and ends them
with the session, so a serve started by Start-Process was gone before its
port file appeared. What the WMI service creates has no such parent, and that
is the setsid of this platform.
Quoting. Commands travel as -EncodedCommand — base64 of UTF-16LE, which holds
no character cmd treats as special. That removes the outer quoting problem
rather than solving it: cmd's rules are where this would have become a
security bug, and a path with an apostrophe in it now proves the point in a
test. Inside the script PowerShell's single quotes follow the POSIX rule.
Also: the CLI release ships Windows as a zip holding reasonix.exe, and the
downloader knew only tar.gz holding reasonix — so a Windows remote had no
install path at all.
Verified end to end against a real Windows host: bootstrap, launch, survive
the session, HTTP through the forward to /status and /tree, then stop.
macOS grants Local Network access only to an app whose Info.plist says what
it wants it for. Neither bundle said anything, so the request was never put
to the user — no prompt, no entry in the privacy list, and every LAN host
answering "no route to host" while ping and the CLI reached it fine.
The release bundle had this too: any host on the user's own network was
unreachable from the window and reachable from everything else.
The launcher also signs with a development certificate where one exists. An
ad-hoc signature's hash changes with every build, so the grant would have to
be given again after each one, which is indistinguishable from it not working.
LocalBinary was copied from the CLI, where the running executable is reasonix
and uploading it is the fast path. Here it is the window: the far side would
receive a Wails app, probe it for a serve command, and reject it — after
paying for the transfer. Left unset, the install falls to npm and then to a
verified release download, which is what it should have done from the start.
Connecting to a Windows host failed as a bare 502 — "the request did not
reach the kernel" — when the SSH link was fine and the far end simply has no
uname. The reason existed the whole way up and was dropped at the last hop:
an error with no code reaches the window as a status and nothing else.
Unsupported remotes now carry a sentinel, so the window can say which half
failed rather than implying the link did. The wording carries no detail on
purpose: what such a machine answers is its own shell's complaint in its own
code page, and putting that on screen explains nothing.
Everything else keeps its text under a coded fallback, which is what makes a
failure legible at all instead of arriving as a number.
Everything above the dial — what the agent offers, which keys IdentitiesOnly
filters out — was inside the !windows build tag, so the Windows half could
not have reused it even when someone wrote it. It moves to a file with no
tag; each platform now supplies only how to reach the agent.
Windows reaches it through the pipe its own OpenSSH service fixes the name
of, with SSH_AUTH_SOCK winning where a port of the Unix agent set one. A
named pipe opens as a file, which is the same call the agent's own clients
make, so this needs nothing new to depend on.
The tests are the first for this code that run on every platform: an agent
key authenticating, IdentitiesOnly offering one key out of two, and an absent
agent not consuming publickey — the last mattering most where the pipe name
is fixed and the method is therefore always registered.
Until now a remote host offered exactly one workspace — whichever the book
named — which made it a shortcut rather than a machine you can work on. A
connected one now answers for itself: its folders and the conversations in
them, read through a pane already open on it, so no second connection and no
second serve process.
That is also why a pane had to address its own runtime over there first. With
one, resuming a remote conversation is the same act as resuming a local one,
and the sidebar can tell which of them this window is already driving.
Unconnected hosts keep the single row, which is also the connect button:
before a kernel is running there is nothing on that machine to ask.
The proxy strips the pane's prefix, so what reached the far hub named no
runtime and landed on its default. Two panes on one workspace share a link
and an address by design — and were sharing the conversation as well: one
transcript, two writers, both streams interleaved.
A pane now opens its own runtime on the far side and addresses it, which is
also what makes resuming a remote session possible at all. It is retired when
the pane closes, before the forward that reaches it goes away, or the far
machine collects runtimes until its own ceiling refuses the next connect.
The kernel builds a structured mismatch — which record it contradicts, down
to the line — and the path to the window flattened it into a sentence. Now
the link layer names what it knows: a changed key, a declined fingerprint and
a refused credential each carry their own code, and the mismatch carries the
known_hosts record so the reader can go look. There is still no way from
there to connecting anyway; a warning you can click past is not one.
The parity guard did not catch the missing wording, because it matched bare
identifiers only and a host outside the package calls the constructor
qualified. It watches both now — verified by removing a wording and seeing it
fail, which is the only way to know a guard is looking at anything.
Until now the link layer had nobody to ask, so it took the strict path: a
machine not already in known_hosts was refused, and a passphrase-locked key
could not be used at all. Both are questions, and the window is the only
thing that can put one to a person — so they travel out on the shell's bus
and the answer comes back through a binding, since the dial is blocked on it.
The fingerprint dialog is deliberately the harder one to get past. Its
fingerprint wraps rather than truncates, because one with its middle elided
cannot be compared to anything, and the focus starts on the way out: Enter is
a reflex, and accepting a key nobody read is what this exists to prevent.
A machine was only addable from the CLI, which made the sidebar section
something the window could show but never fill. The book is user-global, so
an entry belongs to the person and not to whichever folder happened to be
open when it was added.
The listing returns the stored row in full rather than the fields a row
displays: saving replaces an entry, so a page editing one field while holding
a partial copy would blank the passphrase variable somebody set from the CLI.
Forwards have no control here and are carried through for the same reason.
Importing beats typing — on a machine that already uses ssh the addresses are
written down next door, so ~/.ssh/config aliases the book does not have yet
are offered as one-click rows.
Hosts are a section, not a prefix: workspaces on one machine share a
connection, so the state belongs on the group — when a link drops, the whole
section dims together, which is what those rows actually are to each other.
Local workspaces stay unmarked, so carrying a chip is itself the signal. The
same chip appears on the pane tab and in the crumb, because a command lands
on whichever machine the focused tab points at.
A cold connect installs a binary on the far side. The kernel already reports
each step, so the wait is a list of them rather than a spinner that cannot
tell downloading from stuck.
Two function fields become one interface. The split it draws is the one that
matters: the book of hosts is configuration and stays in serve, while what a
link is doing belongs to whoever holds it — so the hub joins them without
learning what SSH is.
The pool records state whether or not a caller subscribed. A window that
opens a second pane an hour later reads the same row as the one that dialled,
and the connect step a first attach is on has somewhere to be read from.
The hub gets the endpoint from a hook the host wires, so the two halves meet
above it: a window supplies a pool that dials, and a listening server leaves
it nil. That default is the point — a kernel that dials onward because a
request asked it to is someone else's way into the network behind it.
A hold the attach took and the hub refused would be a connection nothing can
ever close, so the endpoint gives it back on every path that does not publish
a pane.
Three sweeps reached into a local assembly and would have found none. The
stream needed more than a guard: the asset server holds a response until its
handler returns, so a page inside the window cannot read SSE at all — the
shell reads the far kernel's stream and re-emits it onto the same bus a local
pane's frames ride.
Resuming rides what the stream already carries: the remote numbers its
resumable frames and honours Last-Event-ID, so a link that drops mid-turn
costs only what its replay log has since dropped.
Dialling a configured host lived in the CLI, where a second frontend could
only copy it. It moves to attach, which adds what a window needs and a
terminal did not: a pool where two workspaces on one machine are one login,
and a forward per workspace that the last pane out takes down. The remote
serve is left running — it outlives the link by design.
Exercised against a real SSH server with SFTP and forwarding, so what the
tests cover is the link and not a stand-in for it.
The hub takes an endpoint, not a connection: what makes a pane remote is
where its HTTP goes, so SSH stays above this layer and a proxied pane is
testable over httptest. The frontend addresses /rt/{id}/ either way.
Host decisions reach local panes only — a remote one has no assembly here
to apply them to. The stream is what the proxy has to get right, and the
test withholds the second frame until the first has arrived.
CheckPlanModeReadOnlyTrust had no caller outside its own tests. Everything
behind it was still there: the gate interface and its request type, two setters
and the coordinator that propagated them, the approver on Controller, the
session-trust map in approvalManager, the slot that map occupied in the
authorizations a controller rebuild carries, the boot callback that persisted a
prefix, and two approval-card branches in the TUI. None of it could run.
The bridge was superseded rather than abandoned, and the code says so in three
places: agent's interface ("ordinary Plan execution no longer invokes it; bash
calls use the normal permission gate"), Options ("classifies bash through
Permissions instead"), and config ("Main Plan bash calls now use the ordinary
Permissions classifier and Sandbox"). What was left was the wiring around a
decision already made.
The config field stays. Its comment claims exactly one job — old config and
session round trips — and that job is real: reading a reasonix.toml that still
carries plan_mode_read_only_commands must not fail, and rendering it back must
not lose it. Both are still tested. What is gone is the runtime that field no
longer feeds.
Tests that borrowed the removed tool constant as a sample keep their subject
with an ordinary tool name; the two plan-mode tests keep theirs, which was
never the bridge but what replaced it — bash reaches ordinary permission as the
declared writer it is, and a writer stays behind permissions. Tests of the
bridge itself go with it.
Touches internal/control/approval.go, a declared sensitive path. The change
there is confined to the plan-mode trust map and its slot in
SessionAuthorizations: grantSession, the granted map, requestApproval and
RequiresFreshHumanApprovalTool are untouched, and the rebuild test still proves
an ordinary session grant survives a controller swap. No path gains an
approval, loses a prompt, or widens what may run without one.
Removal was driven by the compiler and then by `unused`, one orphan at a time,
until both were clean. Net -592 lines.
vendorCapabilities declared compactionOutputTokens and two vendors set it, and
nothing read it. Its comment describes a real intent — "the separate budget for
native/summary compaction calls … without inheriting a large default" — and
that intent is already met, one level up and one grain coarser: the fold's
provider call sets MaxTokens from agent's summaryOutputMaxTokens rather than
from the client's answer budget.
The numbers say the same. DeepSeek's entry is DefaultOrdinaryOutputTokens, 16K
— exactly what summaryOutputMaxTokens already applies, so wiring it would have
changed nothing there. The other is 8192, and has never taken effect.
Making it per-vendor is a feature, not this: a compaction call would have to
say so on the request, which provider.Request has no way to carry today. The
coarse limit stays where it works. If the per-vendor grain is wanted later, it
starts from that missing signal rather than from a field nobody reads.
A field that is written and never read is a claim about state that nothing
backs. `unused` does not catch them — a constructor assignment counts as a use
— so a sweep over every unexported field in internal/ turned up the candidates
and each was read before deciding.
transcriptDirty (cli): eleven writes, no reader. Its comments still described
what it did — "forces a viewport re-feed after that in-place rewrite", and one
naming #6978 for what it must never force. #6978 replaced the whole-history
rebuild with invalidateWrapFrom, and setTranscriptBlock calls that on every
in-place rewrite, which is what re-feeds a same-length change. The reader went
then; the flag, the eleven writes and three comments crediting it did not.
recoveryGeneration (agent): a second copy of a generation that already travels.
Its comment says it is captured "so ObserveResult can ignore stale results
after a mode switch" — and ObserveResult does exactly that, from the local
recoveryGen the call site passes it. The copy on toolOutcome went nowhere.
Neither removal can change behaviour: nothing read either one. What changes is
that the comments no longer credit a mechanism to a field that is not part of
it.
c.mimo reads as vendor identity and is not: its one use rewrites tool schemas
out of the pre-2020-12 tuple keywords, which is a fact about the dialect the
endpoint speaks. NormalizeLegacyTupleItemsForDraft202012 says as much — a
provider "must opt in only after the target endpoint's schema dialect is
known" — so the field carries that, not a name.
This came out of looking at whether the client's six vendor booleans should be
one enum, and they should not. They are not one dimension. deepseek, minimax,
zhipu and longcat are thinking protocols and are mutually exclusive, decided by
one switch. This one is a wire-compatibility need, and kimiK3 is a contract
shape; both are resolved independently, and both can be true alongside a
thinking protocol — a base URL at xiaomimimo.com with protocol="deepseek" sets
deepseek and this flag at once, and that is correct: the endpoint wants
DeepSeek's thinking shape and needs its schemas rewritten. An enum would make
that combination unrepresentable and force a product decision — which vendor
wins when the declared protocol and the host disagree — under the cover of a
refactor.
So the scalar count stays where it is. What was actually wrong here was one
field claiming to be an identity.
Job carried five artifact fields and wrote to them from four places, and the
rule for recording a failure was spelled three times in two different ways.
Close kept the first error — "closeErr != nil && j.artifactErr == \"\"" — while
both writes overwrote whatever was there. Once a disk fills every write fails,
so the message that survived was the last one, which says nothing the first did
not. They are one type now, and noteErr is that rule in one place, the way
Close already had it.
The timestamps go the same way: started, activity and finished are the job's
timeline, and activity is the one stall detection reads — a job can run for an
hour with its output stopped in the first minute, which is why it is not
derivable from the other two.
tombstone goes entirely. It was set true in one place, when a finished job is
rebuilt from its persisted metadata, and read nowhere: the state it meant is
already carried by a closed done channel and a terminal status, and Manager
tracks per-session loading separately. A field only ever written is a claim
about who depends on it that nothing backs.
Job goes 18 scalars to 11.
goalMachine's state reaches a rotated session through capture/restore, and
both copy field by field. Adding a field to the machine and forgetting it in
either one compiles: the goal simply arrives without it, and nothing reports
the loss. Twenty-one fields are copied by hand in two directions, which is a
lot of chances to miss one.
Two guards read the declarations and both function bodies. What is left out is
named with its reason — the two locks, the epoch restore bumps rather than
carries, and the budget and state path that belong to the controller that built
the machine rather than to the goal being moved — so leaving something out is a
sentence someone writes, not a line someone forgets.
Checked both ways a field goes missing. Adding one to goalMachine without
carrying it: named by the first guard. Declaring it in the snapshot and
dropping its line from restore: named by the second. Removing one from the
snapshot struct needs no guard — that one the compiler already stops.
This is what the 21-scalar ceiling on goalMachine is really asking about here.
Grouping the fields would satisfy it, but the fields are read in 242 places and
the persisted goalState cannot follow them, so the copies would stay hand-
written and the loss they invite would stay possible.
Each of these sat on Controller and was read in one or two places, all inside
one subsystem: the MCP default call timeout only ever fills in a spec the MCP
manager builds, the implicit-invocation switch is only asked about skills, and
the slash sequence only numbers a slash-invoked skill's synthetic call. They
belong where they are consulted, not on the object that happens to hold both
ends.
skillSet's comment claimed every field is set once at construction, which
slashSeq would have quietly falsified — it now says which field is the counter
and that it synchronises itself.
Controller goes 20 scalars to 17. The remainder is values (the workspace root,
the label, the model ref) rather than flags, and the ceiling is aimed at flags
whose combinations describe states nothing reaches: workspaceRoot alone has 64
readers, and wrapping it to reach a number would cost every one of them a level
of indirection for no state it makes illegal. What Controller actually needs is
the responsibility split the memory and checkpoint managers started, not more
field moves.
The ratchet caught this change trying to bring two-line field comments in and
was right to; they are one line each now.
Controller carried running, finishing, canceling, rotating and closed side by
side. Their own comments say they constrain each other — "running and rotating
are mutually exclusive gates", closed "seals turn admission" over all of them —
which is exactly the shape the scalar ceiling exists to catch: five independent
flags describe 32 combinations, and a controller only ever reaches a handful.
They become turnGate, and the two questions the call sites kept re-deriving
become its methods. `running || finishing` appeared three times and is what a
bare running check gets wrong — the window where TurnDone is still in flight —
so it is active(). The four-way "anything at all in the way" appeared twice, in
two files that had each spelled it out, and is busy().
Nothing moves out from under c.mu: the group is a value field on the same
struct, so every critical section is the one it was. Checked with -race.
Controller goes 25 scalars to 20. The rest do not group this way and should not
be forced to: what is left is mostly construction-time configuration that
belongs to a subsystem rather than to the controller — the MCP call timeout to
the MCP manager, the implicit-skill switch to the skill set, the goal token
budget to the goal machine. Putting them in one bag to reach the ceiling would
read as a group while being a bag.
Agent carried 15 scalar fields against a ceiling of 12. The ceiling exists
because independent flags multiply into states no type records as legal, and
the fix it blocks is adding one more bool — so two groups come out by lifetime,
which is what the rule asks for.
steerInbox is the queue and the two flags around it. They already shared a
mutex and a lifetime — opened on the way into Run, closed on the way out — and
now share a type, so the transitions are its methods instead of seven call
sites reaching for the lock. take() hands the entry back rather than loading
its body under the lock, which is the one behaviour change and the right way
round: no inbox lock is held across a read.
agentRole is what an agent is when it is built and stays: a planner that may
not execute, an executor told to reach for its tools, a caller that needs
visible final text. That fixed lifetime is what separates them from planMode
and the language settings they were sitting next to, which move between turns.
deliveryProfile looks like one of them and is not — beginRunTurn recomputes it
per turn — so it stays where it is.
Agent is at 12, and the reclaim takes the room with it: baseline drops
struct-state for agent.go entirely, along with the file-size and function-size
the two extractions freed. Nothing was raised. The ratchet also caught the new
file trying to carry two essay findings in, which is what it is for.
Reported: at a larger appearance the context-breakdown bubble runs off the
right edge and is clipped, and the smaller the scale the further left it sits.
Both directions are the same mistake. The interface is scaled with CSS zoom,
and the placement mixed the two coordinate systems that creates. Measured in
Chromium at zoom 1.5: a 232px box reports 348 wide, an element at left:400
reports 600, and writing left:300 puts it at 450. So a client rect and
innerWidth are both read on screen — the clamp against them was right — while
the left written back is multiplied by the zoom again on its way in. At 1.5 the
clamped position landed 50% further right, at 0.8 it landed short.
scale right edge, before → after (viewport 1000)
0.8 447 → 512
1.0 640 → 640
1.3 991 → 832
1.5 1266 → 960
Dividing once at the end is the correction the stylesheet already makes for the
same reason, twice: calc(100vh / var(--zoom, 1)), because vh keeps measuring
the unscaled viewport.
The tab context menu placed itself the same way and had the same bug, so both
now go through one placer. Its arithmetic is a pure function so the invariant —
what renders has to be inside the viewport at any scale — is checked without a
browser; the browser half is the measurement in the header, taken with a probe
rather than assumed. Removing the division turns three of the four red.
stripComposePrefixes kept its own copy of the tag list and named five of the
eleven the host injects. So <workspace>, the two language blocks,
capability-route and interrupted-turn-recovery went into the BM25 index as if
the user had typed them: a query for a path matched every turn of every
session, and the search preview rendered the block instead of the message.
The list now comes from the package that injects it. history already imports
agent, so this is the copy going away rather than a new dependency, and the
scan it shares advances through the blocks instead of re-running an ^-anchored
ReplaceAll per block.
TokenizerVersion goes to 2, which is how an extraction change reaches indexes
that already exist — and that bump surfaced a bug of its own. Rebuild fills the
projection through a Catalog built directly on the migrated db, bypassing Open,
so the row kept the schema default of 1; opening the result then saw a stale
tokenizer and wiped what the rebuild had just written. It stamps the version it
built with now. The constant had never moved, so nothing had ever asked.
Verified by removing the stamp: the rebuilt projection comes back empty.
Every save walks the whole transcript to derive the sidecar's preview line and
user-turn count, and each user turn goes through StripTransientUserBlocks. Two
patterns there were quadratic in the turn's own length.
The leading-block pattern is ^-anchored, so a ReplaceAll can only ever take the
first block — and the loop around it re-scanned the whole string once per
block. Advancing past each match reads the turn once and allocates nothing.
The trailing execution-policy cut was `(?s)...*?...\s*$`: a lazy body under a
tail anchor, which backtracks over the string for every start position it
tries. It becomes a byte-exact suffix cut, which is what the delivery-runtime
marker beside it already does — and that one's comment gives the other reason
to prefer it: a lazy pattern anchored at $ swallows user prose between a
literal mention of the tag and the real block at the end.
Measured on one more turn appended to an already-saved session:
messages before after
100 22.8ms 19.0ms
1,000 70.0ms 35.4ms -49%
5,000 196.1ms 119.3ms -39%
20,000 652.3ms 432.6ms -34%
The benchmark stays. What remains is a different problem and not this one:
loadSessionUnlocked is 43% of the rest, because the write check replays the
event log to see whether another writer moved the file — a crash-consistency
mechanism, not an accident.
Reported: a third-party endpoint serving deepseek-v4-flash answered every turn
with "field ReasoningEffort invalid, should be one of: low, medium, high,
xhigh, none". That vocabulary is not DeepSeek's — it has no "medium" and spells
the switch "disabled" — so the endpoint was a relay speaking the OpenAI one,
and the composer had offered "max" because the ladder is keyed by model name.
Pressing continue re-sent the same setting, so the turn could not recover.
The model table already states the rule for the other field it carries:
ContextWindow "is a fact about the model and not about who serves it — a relay
passing it through has the same ceiling". Levels are the opposite kind of fact
and were being inherited the same way. Each measured ladder now names the
VendorHost it was measured against, and only that host is given it. Protocol
and window keep travelling with the model, which is what the table was added
for; the switch between them is host-only, matching isMimoEntry, so a lookalike
hostname cannot claim a vendor's extensions.
Elsewhere the ladder narrows rather than disappearing — a relay with no control
at all is what the model table was added to fix. What is dropped is the levels
outside the API's documented vocabulary ("max", "xhigh"), and they degrade onto
the deepest standard level instead of being refused, the way "minimal" already
degrades. A relay that does take them says so with supported_efforts, which
outranks everything the host would otherwise assume.
The protocol fallback needed the same split: an entry declaring the DeepSeek
protocol without a table entry was offered max wherever it pointed. Narrowing
it unconditionally is what the first attempt did, and TestNormalizeEffortDeepSeek
caught it — that fixture is the vendor host with an unlisted model, which must
keep max.
Three existing expectations move, all of them the behaviour being fixed: a
proxy no longer lists max, a gpt-5.6 relay no longer lists xhigh/max, and the
gpt-5.6 test that asserted a relay takes them "as itself" now expects the
degrade its own comment describes ("an alias degrades rather than sending a
level the model will reject").
Receipt.Write decides what the delivery gates, the baseline, the path
invalidation and the cross-turn diff evidence can see, and it was set from a
list of seven tool names kept in evidence. A writer added without an entry
there keeps working while its writes stop being attributable to a file, and
nothing fails.
ReadOnly could not answer instead: it is false for bash too, which mutates
without naming what it touched. Previewer could not either — it is shaped
around one path and a move has two, which is why move_file never implemented
it. So the question gets its own contract, PathWriter, and the seven tools
answer it themselves.
ReceiptFromToolCall now takes ToolFacts rather than a bare readOnly bool. The
paths that only have a name — fold coverage, the fold index, the cross-turn
PathsProvenInSession — resolve it through the live registry the way
toolIsReadOnly already did for read-only, and that helper is gone since
toolFactsFor answers the wider question. An unresolvable name reads as a read,
so a replay of a tool this build no longer has invents no writes.
Verified the swap before making it: across every built-in, "not read-only and
its arguments carry paths" agrees with the old name table on all thirteen
legal calls, disagreeing only on malformed ones that fail anyway. That is the
evidence the contract covers the same set, not an argument for that rule —
a future tool that runs a path without writing it would break the rule and
still answer PathWriter correctly.
Three guards in builtin: the seven declare it, bash and kill_shell do not, and
nothing read-only does. Checked by flipping write_file's declaration — the
guard names it, and the cross-turn sign-off e2e goes red with it, which is the
behaviour the contract actually feeds.
Also here, from the same sweep: IsDiagnosticSuccess had a command table whose
every branch returned the same value as the line after it, so it decided
nothing; and its doc claimed it kept a success from clearing the failure event,
which the caller does unconditionally two lines up.
Test doubles standing in for writers now say so instead of being recognised by
the name they answer to, and the fold-coverage test reads the shipped registry
rather than its own copy of the answers.
Two things were wrong with the one number watching the largest item in every
first request.
It was at 5700 of a 5700 budget — no room at all, so the next field anyone
adds fails CI with nothing to say about whether that field was worth it. And
it counts the whole built-in set, while a session is billed for the schemas
the provider is shown: 13 of 23, because everything else is reached through
use_capability. Promoting one tool onto that surface costs every session and
moves the guarded number by nothing.
So boot guards the visible surface on its own, at the goal-armed variant
because it is the larger of the two. It fails if the allow-list stops
restricting anything, which is the way this test could otherwise pass while
measuring the wrong set. Checked by lowering it until it went red.
The room comes from the three largest descriptions, each of which ended by
restating its own schema field by field. complete_step's restatement had
already drifted from what it described — it documented `step` while the schema
prefers `step_id` and keeps `step` as the fallback — which is what a second
copy of a contract does. todo_write keeps the two things its schema does not
carry: the two-level shape and the verbatim-step_id rule.
built-in set 5700 -> 5443 budget 5700 -> 5600
visible 3841 -> 3584 new budget 3700
goal absent 3305 -> 3141
Cache impact: 256 tokens leave the provider-visible prefix, so every existing
session cold-starts once and is warm again after. SystemHash is unchanged in
the golden, which is the evidence the base prompt was not touched; ToolsHash
and ToolSchemaTokens (4531 -> 4275) are the tools alone.
The fold effect test came along because it measured this indirectly: it
compared the last request against the run's peak, so a cheaper prefix delayed
the second fold and left more turns after it than before. It now checks each
fold where it happened, which is the invariant it was written for. Verified by
disabling the strip: the counts go linear and it names the fold that kept them.
The batch planner kept a table of six names that never joined a parallel
run. All six return ReadOnly() == true, which is the whole problem: one
bool was answering two unrelated questions. ReadOnly says a call needs no
approval, because it touches no file and starts no process; batching asks
something else entirely, and for complete_step, todo_write, compress, wait
and bash_output the two answers differ.
So the second question becomes its own contract. Each of the five declares
Sequential with the reason it cannot share a batch — a sign-off advances the
task list, a fold rewrites what the batch is reading, a collector reads what
an earlier call in the reply started.
use_capability was in that table because it "may resolve to a real MCP
writer". It now answers for what it proxies: a directory read shares a
batch, a call is its target's catalog entry, and inspect (which can start a
server), decline (which writes the ledger) and an id the catalog cannot
resolve keep their place. Nothing loosens the writer path — an MCP tool
without an explicit readOnlyHint is already false. What this buys is the
case the name table could not express: several read-only capability calls
in one reply now run together instead of one after another.
The four batch tests asserted the fake's contract rather than the shipped
one, which is exactly how a name table survives being replaced by one, so
they take the real registry. Two guards in builtin fail if a tool stops
declaring Sequential, or if an ordinary reader picks it up; both were
checked by breaking the declaration and watching each go red.
Sixty-two commits since 2.3.0: three panels for kernel capabilities that
had no front end (usage, memory editing, single-file revert), the
parallel-call prompt, and the DeepSeek price-table correction.
A change list, not a narrative. What a reader needs from this file is
what changed and whether the upgrade asks anything of them, so the two
that do — the cost figures rising and forking being gone — lead as
breaking changes, and every other entry states the change rather than
the defect behind it. Entries that only a maintainer could act on are
not entries.
2.3.0 is rewritten the same way rather than left as the odd one out; its
published release body is updated to match.
CommitFileRevert had grown to 141 lines and complexity 39, over both
budgets, and the lint gate has been red since. Each stretch it had
accumulated decides one thing, so each becomes a function: claiming the
plan, what the plan alone settles, what the barrier must re-verify, which
preimage the path resolves to, and how the target's payload is staged.
Resolving the preimage was duplicated in PrepareFileRevert and is now
shared.
/usage was registered on the kernel but missing from both path lists the
shell keeps: the window's API whitelist and the dev proxy's. Either one
answers index.html instead of JSON, so the panel read a 404 in the app and
in `pnpm dev` alike. The route parity tests had been red since the endpoint
landed.
A high-effort review of the usage, memory and file-revert work turned up
fifteen findings. Fourteen are fixed here; the two most serious were both mine
and both confirmed before touching anything.
The revert button lied about its scope. store.PrepareFileRevert takes
earliestRevisions(0) — the session's first preimage, not this turn's — so
"put this file back the way it was before this change" promised a surgical undo
and delivered a session-wide one, silently discarding every other edit the
session made to that file. It now says what it does.
Currencies were being added together in the projection. The JSONL path keeps
them apart and three tests prove it; the catalog path, which is what answers
once it is ready, summed 1.50 CNY and 0.25 USD into one column and labelled the
result with whichever currency arrived last. Cost moved to its own table keyed
by currency — tokens add up and currencies do not, so they cannot share a row.
Normalization moved to ingest so both paths agree, and two tests now cover the
catalog path the earlier three never reached.
The rest, in the order a reader meets them:
- keep_current is the kernel's deliberate no-op; the card reported it as
"reverted", the exact opposite of what the user chose.
- A plan the kernel already refused still drew a confirm, and posting it
answered "missing planId" because a refused plan carries none.
- The hero stated the cache hit rate against report.tokens, but the rate
partitions the input side alone. The one number the card exists to teach.
- Usage.tsx had grown its own money/token/percent formatters — the exact
duplication i18n/format.ts documents itself as ending, down to the symbol
glued to toFixed(2) that renders CNY as yen in an English window.
- The daily-spend chart plotted cost[0] and stamped the axis from the first
priced day, merging away the currencies the panel exists to keep apart. One
chart per currency now, for the same reason tokens and money get their own.
- Estimated quotes were aggregated under the label "paid" although the recorder
flags every one of them.
- Three sibling memory handlers answered "memory is disabled" three ways: a
code, a raw English sentence, and a silent empty 200.
- PrepareFileRevert stored plans without the staleness sweep its sibling runs,
and this branch made it reachable from every diff card.
- A SchemaVersion bump renamed the projection file with nothing deleting the
old one, so each bump would orphan another multi-MB database.
- The mock indexed the full arrays with a slice index, so its "7 days" was
neither seven days nor the right seven.
- The plot rebuilt a 365-segment path on every pointermove.
transaction.go went over its budget by seven lines, so single-file revert moved
to its own file — it is a separate responsibility and the sweep is now one
method both prepares call.
Not fixed: /memory/revisions and /memory/restore still have no caller, so the
"an edit is safe because the old wording is reachable" argument is not yet true
from the panel. And control's rewind failures are still bare fmt.Errorf, so the
shapes past canFiles reach the user as an untranslated 500 — the identity rule
REASONIX.md states, owed a separate change.
PrepareFileRevert and CommitFileRevert have been on the Controller with no
endpoint and no caller. The resolution constant says what was missing plainly:
ResolveOverwriteCheckpoint force-writes the preimage "after the user explicitly
confirmed in the single-file UI" — a UI that did not exist.
Two endpoints and a control in the diff header, which is the one place a reader
is already looking at what changed and can see whether they want it back.
Reverting stays two steps because the second one is not always a question. The
prepare answers whether this file is still the one the checkpoint captured; only
when it is not does the reader have to choose between the checkpoint's version
and the current one, and asking up front would ask on every revert. The
conflicted branch is coloured as a decision rather than as another confirm.
Unlike a turn rewind this does not reload the session: one file changes and the
transcript does not, so the pane's callback deliberately skips the reload the
rewind ones do.
Verified as far as this setup reaches: the endpoint contract test confirms both
paths are served, and typecheck, build, i18n parity and the suite all pass. The
click-through itself is not covered — the fixture's transcript only plays after
a real submit, and driving that through the composer's IME-aware key handling
was not worth more time than the feature took.
The panel could list saved facts and forget them. Changing one meant opening
the markdown by hand — while Controller.SaveMemory, MemoryRevisions and
RestoreMemory had been sitting there with no endpoint and no caller.
Unlike the usage panel, nothing was missing from the middle here: the manager
takes a write lock, hands the store the fact, and injects the new text on the
next turn rather than touching the cached prefix — which is the invariant that
makes editing mid-session safe at all.
So this is the two ends. Three endpoints (save, revisions, restore) and an edit
form inside the row that was already expanding to show the body.
The endpoint decides what is editable. Title, description, body, activation and
keywords come from the request; identity, revision and timestamps are read off
the stored fact. Taking those from the panel would let a stale view rewrite an
audit trail it never read.
Saving writes a new revision rather than overwriting, which is what makes
offering an edit safe rather than lossy — the form says so, since a reader has
no other way to know.
Two things the browser found that the source did not: the select had picked up
.pick, a class the workbench already paints with the accent, so it rendered
inside an amber slab; and the parity guard caught memory.unavailable reaching a
frontend that had no wording for it.
The recorder has been appending to ~/.reasonix/stats since it was wired in —
16,321 rows across twelve days on this machine, 4,586 of them carrying a price.
stats/query.go already aggregated it into RangeStats, every field JSON-tagged,
its doc calling it "the full aggregate the settings panel renders". There was
no settings panel, no endpoint, and no caller of Query outside the recorder.
Cost was missing from the middle of that chain. Records carry cost_amount and
cost_currency; usage_rollups had no cost column and RangeStats had no cost
field, so the money stopped at the JSONL. Both now carry it, as fixed-point
integers rather than floats.
Currencies stay apart. Cost is a list with one entry per billing currency: a
single total would have to invent an exchange rate, and the rate a turn was
billed at is not the rate today. A day with tokens and no cost reports none
rather than zero — the field started being persisted later, and rendering those
days as free understates the bill. The panel says so where it shows.
SchemaVersion now names the projection file, which is what makes adding the
columns a version bump instead of a migration: the rollups are replayable from
the JSONL, so the old file is retired rather than altered. That constant had
been sitting unused beside a hardcoded "v1.sqlite".
The panel is one series per chart, so no legend and no colour spent on identity
— length already carries the value. Tokens and money get separate plots because
their units do not share an axis. The one chart colour is the theme's --net
snapped into the readable lightness band for dark mode, which is where it
failed the contrast check.
TurnControl carried eight Submit variants. Four of them are the same two
internal calls with an argument dropped — Submit is SubmitDisplay without a
display, SubmitHTTP is SubmitHTTPFormat without a format — and no frontend
opens any of the four. Submit has 52 callers, all of them tests inside this
package, reaching it as *Controller rather than through the port.
So the port carries the four that mean distinct things and the rest stay on the
concrete type, where their one real user already was.
Removing them takes a trap with it. SubmitUserTurn read (input, display) while
SubmitDisplay beside it reads (display, input); both are two strings, so a
swapped call compiles and runs and shows the wrong text. Its only two callers
pass the same value twice, which is why no test ever noticed. It now reads
display first like every other one.
The port is a description of what frontends drive. Eight ways to start a turn
described the inside of this package instead.
BeginDestroySession opens the window and Finish closes it; jobs pairs them
itself and never consults IsDestroying to do so. The Controller wrapper and its
port entry existed for a caller that never arrived — no frontend, no test, and
nothing inside control either.
jobs.IsDestroying stays: its own tests use it to prove the window opens and
closes, which is the one thing worth asserting about a window.
Counted while auditing the port: 43 of the driving port's 186 methods have no
frontend consumer at all, and they cluster — memory writes (5), single-file
revert (5), most of TurnControl's submit variants. Those are capabilities the
kernel has and Studio has no entry for, which is a product question rather than
dead code, so only this one — provably unreachable from anywhere — is removed.
Auditing the orphans the old desktop left behind turned up a worse kind than a
method nobody calls: a method whose doc says who calls it, wrongly.
ReplayPendingPromptsTo said "Serve uses this for a newly attached SSE client".
Serve uses ReplayPendingPromptsWith, which hands the connection over while the
prompt lock is held; the ...To variant has had no caller on either side of the
port. WriteAuthorityGeneration said "Tests use it to prove old generations
become stale after rebind" — no test does. RebindSessionWriteAuthority offered
itself as a convenience for lease keepers, and no keeper took it.
A doc that names a user is the first thing a reader trusts, and each of these
sent this audit down a false trail before the call graph corrected it. Deleting
them costs nothing that was reachable and removes three wrong answers to "who
depends on this".
The remaining orphans are catalogued rather than deleted: some are a retired
frontend's capability with no replacement (memory doc editing has no serve
endpoint at all), which is a product call and not a cleanup.
Retiring the old desktop took SnapshotForShutdown's only caller with it. The
TUI had never used it: every exit — a quit gesture, SIGHUP from a dropped SSH
session, SIGTERM from a service stop — funnels through tuiShutdownMsg, and it
took a plain Snapshot.
The difference is what happens when another instance holds this session's file
lock. The plain snapshot returns ErrSessionFileLockHeld and stops; the shutdown
variant parks the in-memory transcript on a recovery branch first. Everything
the session added past the last autosave was riding on that distinction, and
the result was assigned to _, so a lock-contended exit lost the transcript and
said nothing.
The error now rides out on the model and is printed once the alt-screen is
gone, which is the only moment it can be read.
The path moved to its own file on the way: the case clause is a call, the
handler and its report live beside the test that reads them. That test reads
the syntax rather than driving the model — Update renders a status line before
it reaches the message, so a stub would have to implement most of the port to
reach one branch. It fails if the handler takes any other snapshot, or assigns
the result to _; restoring both is how it was checked to be watching something.
The five launch-web declarations become two so this lands without widening a
budget.
port.go has always said it — "leaner frontends (bot, acp) depend on just the
sub-ports they use" — and named the bot never seeing checkpoint or memory as
the example of what that buys. Nothing enforced it. The bot named the full
SessionAPI, twelve sub-ports and 187 methods, and calls four of them: eight
sub-ports and 122 methods were reachable by accident, memory and checkpoint
among them. ACP named the full port and drives seven.
GatewayAPI is the four a chat gateway drives; EditorAPI the seven an editor
drives over ACP. serve names one sub-port it never calls and the TUI names none
it does not, so both keep SessionAPI — which is now what its own comment claims
it is.
The guard reads both sides out of the syntax rather than a list someone has to
remember to update: sub-ports and their compositions from this package, the
port types named and the port methods called from each frontend's AST. A
frontend handed more than one sub-port it never calls fails, one being the room
a migration legitimately sits in. Restoring the bot's SessionAPI names the
eight exactly, which is how the guard was checked to be watching something.
The method count in the file header goes with it. It said ~99 against a surface
now past 240, and a number stated in a comment only ever goes stale.
DeepSeek's published rates had moved and this table had not followed. v4-pro's
output was carried at 6 CNY per million tokens against a list price of 13.5
off-peak and 27 at peak, so every cost readout understated by between 2.25x
and 4.5x — for as long as nobody happened to read the pricing page.
The rates are corrected, and the peak schedule they come in pairs with is
applied where it belongs. PeakWindow carries the vendor's own clock: 9-12 and
14-18 are Beijing hours, and a fixed +8 offset states that exactly without
costing an embedded tzdata. The all-weekend rule starting 2026-08-23 is a date
on that same clock. Only a rate confirmed as the vendor's own is rescheduled;
a price a user negotiated is their own arrangement, and projecting someone
else's peak hours onto it would invent a number nobody published.
Which is what exposed the second half. resolveCatalogIdentity decided the
vendor by reading names — strings.Contains(name, "deepseek") — while the
catalogue beside it states the actual test: host equality, because a relay
bills on its own terms even when it serves the same model. Both directions
were wrong. A relay named after a vendor was quoted against that vendor's
table, peak window and all; a vendor's own endpoint under a plainer name was
not quoted against it at all. The host test already existed and config already
used it in ten places — it now reaches the quote through QuoteContext, and the
guessing is retired. An unnamed vendor gets no official table, which leaves
the user's own rate card: right rather than plausible.
An effect test pins it through the real Build stack. Both runs name the entry
after the vendor, because that name is precisely what must not decide it; the
host is the only variable between them. Restoring the old guess turns the
relay case red, which is how the test was found to be watching nothing on its
first draft.
Nothing serves these prices over an API, so CheckedOn dates each row and a
test fails when one goes ninety days unread. It cannot stop someone bumping
the date without opening the page; what it buys is that the question gets
asked at all. LongCat's rows are a launch discount with no published end and
say so.
Peak pricing makes any quote time-sensitive: a test using official rates
without pinning a clock now fails on weekday mornings. QuoteContext.Now and
QuoteInput.OccurredAt are the injection points, and the boot-side tests use
them.
Wiring the host test in cost one line, which put boot.go one line over its
file and function budgets. The CostQuote assembly moved to its own file
instead: boot.go is 25 lines shorter and build 24.
The opening screen asks for a service address and a key, then infers the rest
— protocol, model list, whether it reads images. It never said where the key
itself comes from, and a person who does not have one has nowhere to go from
that field.
Each shortcut now carries the vendor's console, reachable from beside the key
input and again from the refusals that are settled there rather than here. A
vendor whose key page we cannot name links to its platform root instead:
sending someone to a 404 is worse than sending them to a front door.
The refusals had to become codes first. Probing failed as one sentence, so a
refused key, a path the endpoint does not serve and an embedding-only gateway
all read as the same dead end. The status the model-list request came back
with was already typed, and thrown away one frame up; it now travels as one of
nine provider.probe.* codes, each with its own status and its own next move.
Spelled as a switch rather than built from the reason, because the parity
guard reads those call sites and cannot check a concatenated code.
Two defects surfaced on the way. sse_provider threw a bare Error, so a coded
refusal could not have been read even once it existed — post0 already carries
it. And i18n.test.ts excluded the i18n directory, which is where kernel.ts
words every refusal: 37 codes had been reaching English windows in Chinese
with nothing to catch them. The catalogue now guards its own wording, those 37
are translated, and they move to en_kernel.ts — they belong to no screen, and
en.ts was over its line ceiling.
The executor has always run independent calls from one reply in parallel, up
to eight at a time. Nothing said so, and the model reached for one tool per
reply: measured over nine tasks, 71 calls arrived across 65 replies.
Two runs with the sentence, against one without, same nine tasks, all passing
in every run:
replies 65 → 55, 56 -14.6%
calls 71 → 60, 56 -18.3%
wall 244s → 159, 176 -31.4%
prompt 488k → 430k, 398k -15.3%
cost -9.5%
Fewer replies is where the rest of it comes from: a reply is a round trip, and
a round trip re-sends the whole prompt. The sentence costs 66 tokens once, in
the cached prefix.
Cache impact: the prefix gains those 66 tokens, so every existing session
cold-starts once against the provider's prompt cache and is warm again after.
The policy list is now one slice rather than two, since the test that strips
these back off was reading a copy of it.
A measured run: 3,995 of the first turn's 5,577 prompt tokens were built-in
tool schemas. That is the largest single item in a cold request and the thing
a warm one pays to keep cached — and nothing was watching it. A tool gains a
field, a description gains a sentence, and the floor under every session rises
with no one the wiser.
The whole built-in set rather than what a preset exposes, so the number does
not move when an allow-list is edited, and the failure names the largest
schemas so the next reader knows where the weight is.
Both draw one item of a list in the extension page, both own the calls that
change it, and one of them already had a file. The state table only this row
reads goes with it.
Adding an MCP server and adding a plugin package each have a file. Adding a
model source did not — it sat inside the list that shows them, along with the
form for editing one, and the two of them were most of that file's 754 lines.
They move out, each with the helpers only it was calling: the name-collision
check with the add form, the header and extra-body parsing with the edit form.
What is left is the list itself, at 306.
The fixture had split five subjects out; the real side had none, and both
files plus the contract were sitting one line under the ceiling. Taking the
same five across, then four more on both at once — external services, hooks,
network, themes — leaves the two sides matching face for face, and every one
of them with room again: 799 to 517, 778 to 485, 784 to 275.
Each face carries the state that belongs to it. The fixture's server list
lives with the calls that switch a server off, its hook list with the dry run,
because nothing else was ever reading them — which is what made these the
subjects worth splitting rather than a line drawn to hit a number.
Half of the port's types already lived by subject — providers, plugins,
storage, appearance, boundary — and the other half sat in the contract file
with the interface, 784 lines of them, one line under the ceiling. The split
was not a rule anyone was following; it was where someone had last needed the
room.
So the rest follow: sessions, external services, workspaces, skills, models,
account, versions, hooks, network, shell, memory. port.ts keeps the interface
and stays the way in — everything is re-exported from it, so no reader of a
port has to know where a type is written down — and drops to 275 lines.
Each of those names now matches a face on both implementations, so a subject's
contract, its wire calls and its fixture can be read side by side.
The fixture reads as five subjects, each in its own file and chained on so one
declaration still satisfies AgentPort — appearance, extensions, shell,
boundary, providers. The side that actually talks to the kernel had none of
that: one class held every method, and its file sat at 799 of the 800 it is
allowed, as did the fixture's own remainder and the contract beside them. The
next endpoint had nowhere to go.
So the real side takes the same five faces, in the same order, so the two can
be read against each other: sse_look, sse_ext, sse_shell, sse_boundary,
sse_provider, and the port class chained on the end. Adding a provider call
now touches a 59-line file rather than an 800-line one.
Two things the faces share move out with them: rootQuery builds a URL, which
is what the transport file is for, and WailsBind describes what the desktop
shell binds onto the window, which is neither the transport's business nor any
one face's.
The wire's fields already have a contract test; the paths carrying them did
not. Both sides write them as string literals, so the compiler sees one thing,
tsc sees another, and the two are only ever compared by a request that 404s.
One direction only: a route with no caller is not a fault — the CLI and the
setup page reach some of them — while a call with no route always is. The
sweep verified there were none; this is what keeps that true. It fails if
either side stops stating its paths where the patterns can read them, because
a run that compared nothing looks exactly like a clean one.
Giving the extension page somewhere to report a refused switch put Settings
over the 800-line ceiling. The row that draws one skill — with the note about
how the model can reach it, and the scope table only it reads — is a whole
thing on its own, so it goes into a file of its own rather than the budget
moving for it.
Killing the kernel mid-session and pressing Send showed three things at once.
The transcript kept the message — the client adds that row itself, because
nothing on the wire echoes what was typed, and nothing took it back when the
post failed. The composer had already been emptied, so what was typed was
gone. And the notice read "/submit: 500", which is a path and a number.
So the row now carries an id its sender knows, and a refusal takes it back;
the line goes back into the composer, refs and all, since it is the assembled
text that was sent; and a failure that carried neither a code nor a body says
what kind of failure it was instead of printing the message meant for logs.
HttpError records whether the answer had anything a reader could use, rather
than the display end trying to recognise its own fallback string.
Both files this touched were sitting one line under the 800 ceiling, so the
session's types and HttpError move into files of their own rather than the
budget moving for them.
Failing one endpoint on purpose showed what a refusal looks like from the
outside: the switch snaps back — the row re-reads the server rather than
assuming its own optimism — and nothing else happens. No message, and the
error goes out as an unhandled rejection. From the user's side that is
indistinguishable from a click that missed, so they click again.
The model page already had the answer, one place on the page to say why a
switch was refused, with every operation there routed through it. The
extension page now has the same, and the three provider operations that kept
their own try/finally report into it. Versions has no page-level notice, so it
carries one beside the notice it already shows for an unreachable catalogue.
Two sentences there were also being rendered as bare strings; a stale pin and
an unreachable catalogue are exactly the states an English interface would
have shown Chinese in.
Escape already handed focus back to the button that opened the menu. Taking a
value did not — it unmounted the row the caret was on, so focus fell to the
document and the next Tab restarted at the top of the page. Cancelling cost a
keyboard user nothing and choosing cost them their place.
The slotted view's handle closes its menu by hiding it, which drops focus the
same way; it takes the same route back. That one has no reachable path in the
fixture, so it is corrected by the rule rather than by measurement — its label
was also being built as a bare string rather than through the catalogue.
Every screen it walked was a settled one, so the text a failure puts up was
never read. A turn that ends in an error renders two more things — the kernel's
own account of it and the label the run strip settles on — and the second of
those was going out untranslated: `t(doing)` takes its key from a variable, so
the static guard cannot see it, and 已中断 was the one value of eight that had
no entry.
The step needs a key the endpoint will reject to reach that half, and says so
rather than passing silently when the turn ends any other way.
The token table already says this — the base is the fill, the ink is the
reading value — but the sweep found fourteen places still reading the fill:
the slash palette's match highlight and its hints, the approval header's
tool name, the run strip while a turn waits, the rule counters. On a tint of
their own hue they landed between 3.29 and 4.42.
The same walk found ghost carrying text onto surfaces it was never measured
against. Its floor is stated for --raised; on a selected row, a menu head or
a delegation tint it fell to 3.98–4.13, so those sites take the step above it.
warn has an ink of its own now rather than borrowing accent's by coincidence.
Three remain and are not touched here: white on accent, at 4.497 against a
4.5 floor, on the primary buttons. The table records that trade already, and
closing it means moving the base hue.
At 900px the plugin and service rows broke: the name shrank to zero width and
wrapped one character per line onto its neighbour, while the two segments that
refuse to shrink pushed the row's buttons past the window. Shrinking is now
ordered — the supplementary halves give way, the name holds a floor and
ellipses — and the buttons keep their width because they have to be clickable.
Two more the same walk turned up. The breadcrumb separators were named .sep,
which is also the 1px hairline divider, so "/" and "·" were each rendered
into a 1px box with a background behind them. And the role band's second
column was measured against Chinese: "Subagents" overruns it, so that one row
of the four sat out of line. Subgrid hands the width to the band, where a
column shared by four rows belongs.
An ellipsis says there is more; with no title there is nowhere to read it.
The sweep found nine such places in one pass — every skill's description, the
value column of the settings table of contents, session titles, memory
descriptions, a package's name and what it brought. The row they sit in
already puts a title on its scope column, so the habit was there, just not
applied to the parts that get cut first.
The rename field had no accessible name either, and three sentences never
reached the catalogue.
The two existing sweeps answer one question each and only walk the settings
sections. This one walks the workbench too — pickers, the slash palette, the
file reference, a whole turn replaying with its tool cards — and judges what
the render resolves to: contrast against the surface text actually lands on,
clipped text with no title behind it, ink wider than the box nobody clips,
elements past the viewport edge, controls with no accessible name.
The fixture was the only way to reach a transcript with cards and approvals
in it, and it could not be reached: every other face of it answers with data,
but the probe threw, so the demo sat on its connect card with no way past.
tokens.css sets the floor at AA 4.5:1 for the weakest text on its own surface.
Walked over eleven settings sections in both themes, five combinations in light
were below it. Dark was clean throughout.
Two causes. --ghost was computed against --raised (#FFFFFF, 4.55:1 — no
headroom) but renders on --surface (#FAFBFD), where it fell to 4.40. And
--accent served as both a fill with white text on it and as text on a 13% tint
of itself; at 4.55:1 on white neither direction had room, so white on it came
to 4.42 and the badges to 3.3–3.8.
--accent moves to #A7690C so white text on it clears 4.5, --ghost to #677589,
and a hue used as text on a tint of itself now has an ink of its own
(--accent-ink, --ok-ink, --err-ink). In dark the tint is darker than the hue,
so there the ink is the hue.
tools/contrast-sweep.mjs is the walk. It carries two lessons in its header: a
probe that fails to evaluate must throw rather than return an empty list, which
reads exactly like a clean sweep — this one did, silently, for three runs — and
getComputedStyle reports color-mix results as color(srgb …) with 0-1 channels,
which parsed as 8-bit turns a pale tint into a mid grey and invents failures
that are not there.
Terminal output, diffs and inline code all render through --mono, and all three
carry Chinese: a test failure message, a comment in a hunk, a commit subject.
None of the monospaced faces in that stack has a Han glyph, so the character
fell through to whatever the platform chose — on Windows typically a serif,
which at 11.5px is the worst of both.
Alignment does not change. Measured at 11.5px, a Han glyph is 11.5px against a
6.92px Latin cell — a ratio of 1.662 — and it is the same 1.662 whether the
glyph comes from the shipped face, PingFang, or the platform's own fallback.
This was never a 2:1 grid, so naming the face breaks nothing and only decides
what was previously left to chance.
Reading Studio on Windows meant Segoe UI and Microsoft YaHei, and neither
carries a 500 or a 600. CSS then resolves --w-reg (500) down to 400 and
--w-med (600) up to 700, so three weights collapse into two: body text thins
out and a secondary heading renders identically to an emphasised one. The
comment above those tokens explains why Chinese starts at medium — dense
strokes blur earlier than Latin at the same size — and on Windows that
compensation was never applied.
Measured before changing anything, as ink coverage of one Chinese line at
[400,500,600,700]:
PingFang SC 500 625 676 676 a real Medium
Heiti SC 416 416 545 545 500 renders as 400
Hiragino Sans GB 456 456 745 745 500 renders as 400
Both faces are now shipped and variable, so every step exists everywhere and
the design has one answer rather than one per OS. Inter for Latin (343KB), Noto
Sans SC for Chinese, subset to GB2312 plus the interface's own characters —
1.6MB instead of 14MB, with anything outside it still falling back through the
stack. Both are OFL 1.1 and their licences sit beside them.
Static scanning cannot answer this. A constant table holding Chinese is correct
when its render site calls t(), the definition and the render can be files
apart, and `t(variable)` hides its key from every check there is — the contrast
descriptions reached the screen that way, already inside t(), with no entry in
the catalogue. Scanning literals reported 355 candidates, most of them fine.
Walking the real interface in English reported 11, all real:
onboarding provider names and the connect button
model the relay/thinking notes and " · 缺 key"
hooks the recipe titles behind aria-label
appearance the four contrast descriptions
Fixed, plus the 37 keys the widened guard found: t() arguments are now read
through their whole argument list, so the arms of `t(cond ? "A" : "B")` count
as keys. They were written correctly and simply never checked.
One Chinese string is left on purpose — the language picker's own "中文", the
same reason its sibling reads "English".
tools/i18n-sweep.mjs is the walk itself, kept because the next drift needs the
same answer and static analysis will not give it.
The catalogue was only guarded in one direction: every t("…") key had to exist
in EN. Nothing checked the other way — that text which reaches the screen went
through t() at all — so Chinese written straight into JSX rendered as Chinese
in an English window and no test saw it.
The Chinese-as-key design stays. It is deliberate and the reasons hold: naming
thousands of fragments costs more than it returns, the call site reads as the
sentence it renders, and a missing translation degrades to the original rather
than to a blank. What was missing is the symmetric half of its guard.
The new check reads the two places text always reaches the screen — JSX text
nodes and rendered attributes — and requires them to be empty of raw Chinese.
It needs no ratchet: the 31 sites it found are fixed here, so it starts at zero
and stays there.
Sentences split by JSX were the reason some of them were written that way:
`这段做过的 <b>{n}</b> 处改动` is three nodes, and translating the pieces
separately cannot work because English puts the number elsewhere. tx() keeps
such a sentence one key and substitutes nodes into its placeholders, so the
bold stays and the sentence is still translatable.
en.ts already carried "新会话" → "New session", but App.tsx wrote the literal.
On an English interface the tab therefore read 新会话 while the tree row two
inches away read New session — the same word, one screen, two languages.
Found by driving the built frontend headless, where the locale is English and
the mismatch is visible; in a Chinese interface both sides render identically
and nothing looks wrong.
Profile declared {name, description} in TypeScript while this package sent
{model, effort}. Nothing failed — the reads were simply undefined forever, and
the card fell through to a fallback that reported a delegate's step count as a
number of delegates. Type checking cannot see across the JSON boundary, so the
drift was invisible until someone read both files.
The test compares same-named structs field by field in the direction that can
break: a field the frontend declares must be one the wire carries. Extra fields
on the Go side are fine, since a reader ignores them. It skips when the desktop
sources are absent and fails if the two type sets stop overlapping by name,
which is how a rename would otherwise make it compare nothing.
Same situation as the session catalog: nothing read it. Its only importer was
the CLI command that rebuilt it, and no product path ever opened the file that
command wrote. Task snapshots and event logs were already the authoritative
data and are read directly.
That leaves two projections, both with real readers and both doing something a
directory scan cannot: history-search maintains an inverted index for BM25,
usage-catalog a precomputed rollup.
Selecting an effort on mimo-v2.5-pro was refused with "xiaomimimo declares no
reasoning-effort levels", and on older builds it surfaced as a 500. The
vocabulary existed — mimoEffortCapability names auto|none|low|medium|high — but
only the branch for a hand-written reasoning_protocol consulted it. Inferring
the protocol never recognised MiMo, so a stock provider block fell through to
the "no levels" answer and the effort control was dead.
The two switches that named one protocol's levels are now one function both
paths read, and protocol inference recognises MiMo. Declaring the protocol and
having it inferred can no longer disagree, which is what the defect was.
Measured against api.xiaomimimo.com before changing anything: none, low, medium
and high return 200; max and xhigh return 400 "Invalid request parameters". The
levels offered are exactly that set, so the composer stops showing max for this
model and the host refuses it without spending a request.
Nothing read it. Open, ReconcileDirectory, ListSessions and GetSession had zero
callers outside the package: the sidebar, the CLI picker, the bot and doctor all
list sessions by scanning the directory, which answers from sidecars and never
decodes a transcript. The only entry points were `sessions reindex` and
`doctor sessions`, which rebuilt and reported on a file no one then opened.
Measured before removing: scanning 186 sessions costs 17ms, and the sidebar
reads one workspace directory at a time — on this machine 461 of them averaging
a couple of sessions each, so well under a millisecond each.
What stays is what a scan cannot do. history-search needs an inverted index for
BM25; usage-catalog needs a precomputed rollup. Both have real readers. A
projection earns its place when the authoritative data cannot answer directly
and quickly — listing a directory can, because Turns and Preview already sit in
the sidecar next to each file.
The cost of keeping it was not hypothetical: both sidebar bugs fixed this week
were the projection misreading data that was itself correct. 4400 lines, three
schema migrations and a reconcile loop, for rows nothing displayed.
The memory index added yesterday to replace it goes too — it was written for a
consumer that turned out not to exist. Its comparison harness is in the history
if a projection is ever warranted again.
The sidebar folds a conversation's conflict copies into one row by walking
ParentID up to the transcript they came from. GC reclaims covered copies, and
those sit in the middle of the chain — so each reclaim broke the walk in two
and added a row. A long enough conflict loop then showed as a staircase of
same-titled sessions with descending turn counts, which is the report.
A copy now records the conversation it ultimately came from when it is written,
so grouping no longer depends on which of its ancestors still exist. Walking
parents remains the fallback for copies written before this.
Reproduced first: an intact six-copy chain drew two rows, and removing one
middle link drew three.
The catalog keeps a sqlite projection of data whose authority is the jsonl
transcripts beside it, and every bug fixed here this week was the projection
misreading them rather than the data being wrong. This adds the alternative —
scan the sidecars, answer from a slice — next to it, plus the test that only
lets it stand if it answers identically: same order, same page breaks, same
cursors, across scope, directory, query and time filters.
Measured on this machine's real tree (461 workspace directories, the shape a
working install actually has — many directories of a few sessions, not one
large one):
build catalog=543ms mem=32ms
rebuild catalog=32ms mem=35ms (catalog skips unchanged dirs;
mem rescans everything)
query all catalog=768us mem=333us
query one catalog=434us mem=73us
Nothing reads the memory index yet. Writing the comparison first also caught
that the seeded timestamps were being overwritten by SaveBranchMeta, which had
been quietly making every time-filter assertion vacuous.
Forking a conversation at an earlier turn into a second session file existed in
three places — `/branch <turn>` in the TUI and the slash handler, the Fork entry
in the rewind picker, and POST /fork — and produced the one thing sessions
should not do on their own: a second conversation with the same title. It is
gone, along with ParseBranchTarget, the three Controller entry points, the port
methods, and fork_turn / fork_message_index in branch metadata, which nothing
ever read back.
`/branch [name]` still branches from the current tip, which is a branch the
user asks for by name. parent_id stays: recovery GC reads it to find the
transcript a conflict copy came from, so it belongs to the recovery lineage,
not to forking.
A save conflict forks the transcript into a recovery branch, and two writers on
one file produce one per save. Each is a valid session file with the same title
and one fewer turn, so the sidebar filled with a staircase of conversations
nobody started while the file the user was actually in sat at the top.
The list now leaves recovered branches out. They stay indexed and on disk —
GC, diagnostics and the serve-side reader still find them by path — they are
simply not offered as sessions to open.
The lineage pass goes with them: it read the content of every recovery file on
every directory scan to sort them into covered/diverged/canonical groups, and
nothing outside the package ever read the result. Three columns, an index, a
243-line classifier and a per-scan pass over transcripts, all to label rows
that are no longer shown.
The continuation demanded "finish it now" while the completion guard rejects
the one action that phrase describes: a todo_write marking an item done is
refused until a complete_step signs it off. So the model flipped a status, the
guard refused, the list stayed exactly as it was, and the next round repeated
the identical demand. Asking the run to stop changed nothing — the loop reopens
on every user turn — and the stop button was the only way out.
Both the requirement and the continuation now name the three things the guard
lets through: complete_step to close an item (it advances the list, so no
todo_write is needed), a todo_write without an item that should no longer be
done, and conclude_blocked for one that cannot be done as specified.
The stall test also moves off the category list, which stays identical while
the counts inside it change, onto the full requirement state; and a ceiling
ends a run whose gap keeps changing shape without ever shrinking, which no
stall counter can see.
The nested rows under a dispatch are the sub-agent's own calls. The card read
their number as a number of sub-agents, so one delegate doing 64 things
announced "64 个子代理" — while the panel beside it said none had been
dispatched at all, because it matched the tool name "task" and the model only
ever calls use_capability. The wire made the first inevitable: the kernel sends
{model, effort} and the frontend declared {name, description}, so the profile
name was undefined on every card and the fallback always won.
The type now matches what the kernel sends, the label reads nested rows as
steps, and a batch says both numbers. The panel keys off the profile the kernel
sets and sums the contexts opened, so one fleet call counts as its whole batch.
Both readings live in one module: how a delegation is recognised, counted and
named is a single question with two callers.
Every dispatcher is hidden behind use_capability, so the tool name on a
dispatch is the proxy's. The profile that says who ran was resolved only for
directly-called tools, and emitResolvedToolDispatch never carried one — so
every sub-agent the model actually spawned reached a frontend anonymous, with
no way to attribute a step or count the contexts opened.
Profile now describes the delegation rather than just its model: Name says who
runs, Count says how many. A task without a model override is still a
delegation and says so; fleet and parallel_tasks report the size of the batch,
which is the number nobody could previously see. The resolution behind the
proxy reads the real target with the real inner arguments, so a proxied
dispatch and a direct one carry the same identity.
All of it sits in delegation_capability.go beside the alias contract: what a
dispatcher answers to and what it reports are one subject.
Measured over five repeats of the committed no-solution corpus, false
completion ranged 25–50% and honest 42–67% with nothing changed between runs.
A rate printed without its denominator's resolution invites exactly the
comparison that spread makes meaningless, so each line now states the
percentage-point move a single task carries.
The mixed-shape gate below already accepts a check whose status the host reads
out of PIPESTATUS; the declared-check gate refused the same shape. A run that
declares `verifies` and pipes its suite into tail — to keep 200 lines of output
out of its context — is doing what the declaration is for, and the host reads
that stage's status rather than the pipeline's.
Shapes no probe can answer for stay blocked: a `;` hands the status on outside
any pipeline, and nothing there proves what the check exited with.
Cache-impact, Cache-guard, System-prompt-review and Documentation-impact were
enforced by reading text out of the pull request description. Both workflows,
their checkers and the template fields go.
What they were protecting stays: scripts/cache-guard.sh is a test run, not a
body field, and remains the release-level cache-hit check. CONTRIBUTING still
asks that a cache-sensitive change say what it does to the provider-visible
prefix — as prose in review, not as a parsed line.
The proxy description promised `task:subagent`, resolveCall accepted it, and
list/inspect denied it existed — two id tables for one capability. A model that
verifies a capability before dispatching was told the harness had no subagents,
so it ran the work inline and the isolation was lost with nothing failing.
Aliases now belong to the tool that answers to them: CapabilityAliased rides the
contract into Entry.Aliases, Lookup accepts them, and the six hand-written
prefix branches plus taskToolName are gone — one table, three readers. inspect
also returns a registry tool's input schema, which the model previously had to
guess. The web:/lsp:/session:/memory:/workflow: passthroughs had no users, no
catalog entries and no tests, so they are removed rather than documented.
The boot effect test walks CapabilityIDExamples through a real assembly, so the
description can no longer name an id discovery refuses.
Both wizard tests had been failing since deepseek-v4-flash-vision-exp
joined the default preset. Neither found a bug: withBuiltinFamilies dedupes
by name, and a builtin the user has never named is an addition rather than
a duplicate — which is the whole point of that commit, since images fail
without a model that reads them.
So the expectations were wrong, but bumping 2 to 3 would only date them
again. Each now says what it meant.
groupByFamily's invariant is that the whole preset collapses into one
family in order, so the member list derives from the preset. A floor guard
keeps it from passing vacuously on an empty one.
The duplicate check counts names instead of members: the user's two
entries must each survive exactly once, and no name may appear twice.
Removing the dedupe now reports "deepseek-flash appears 2 times; the
user's entry was duplicated" rather than an arithmetic mismatch.
Every goal turn carried the same 1255-byte task contract, unchanged. The
fold now collapses the copies, but between two folds they still accrue one
per turn, and repeated identical prose loses salience while pushing real
content out of the window.
Only the operative half has to be on every turn: pursue the goal, do not
stop at a plan, end by calling update_goal. The judgement rules — scope,
pause policy, completion criteria — are what the model consults when
deciding, and one copy in view is enough. 1255 bytes becomes 442, about
235 tokens a turn.
That only holds while a copy really is in view, and the honest way to know
is to look. There is no latch: the condition is read off the transcript
through SeesStandingBlock. A latch would have to guess, and it would guess
wrong on a fold, a rewind, or a resumed session — none of which tell the
controller anything. It would also guess wrong on ordering, because the
fold runs after Compose has already chosen what this turn says.
The full statement renders as its own variant, <active-goal
contract="full">, so the fold supersedes reminders against reminders and a
reminder can never displace the contract it points at.
The invariant is the test: no request may carry a reminder without a
contract. Breaking any of the three parts fails it with the symptom that
part causes —
strip every retained copy req 5 reminds of a contract it cannot see
one variant for both forms every turn restated the full contract
never restate req 2 reminds of a contract it cannot see
The fixture sets the goal after two ordinary turns on purpose. A goal set
on turn one lands in the pinned first user turn, which no fold can reach,
and the test proves nothing — it passed against every broken variant above
until the fixture matched how the command is actually used.
The eleven assertions that matched "<active-goal>" now match either form.
They always meant "the block is there"; pinning one variant was incidental.
keepUserTurns holds the user's own words verbatim because a constraint
stated mid-session is unrecoverable once a digest drops it. It was costing
each turn at its full size, injected host boilerplate included — so a
40-byte constraint carrying a 1250-byte goal contract spent eight times
its own weight, and the budget meant to protect the user's words was being
spent on the host's. Turns got dropped to make room for duplication.
The copies were also immortal. Retention is verbatim, so every stale copy
rode through every later fold. Counting <workspace> blocks at the provider
boundary across eight turns:
before 1 2 3 4 [fold] 5 6 7 [fold] 8 never falls
after 1 2 3 4 [fold] 2 3 4 [fold] 3 falls at each fold
TransientUserBlockTags already gave these blocks an identity; it was only
ever used to strip them for previews. This splits that list by what the
block means. Standing state — the goal, the languages, the workspace — is
superseded by a newer copy. A one-time fact is not: dropping a memory
write, a finished job, a hook's output, or the policy that governed one
particular turn destroys the only record the model has of it.
Superseded means superseded, so the newest copy of each variant survives.
An earlier revision stripped every retained copy on the theory that the
live turn always restates it, and the representative fixture caught what
that costs: the fold runs after Compose, so the turn whose request is
built after the fold is left with a reminder and nothing to point at. A
block is superseded only by a newer one carrying the same tag AND the same
attributes.
The strip runs inside the fold, which already rewrites and has already
paid for the cache miss. Doing it per turn instead would change bytes the
provider has already seen and cost a miss on every single turn — which is
why the blocks are baked into the stored message in the first place.
We had a lot of compaction machinery and no way for the model to know it
was about to be compacted. Grepping the tree for any figure ever shown to
it returned nothing: the model was always the folded party, never an
informed one. A model that knows it is at 15% writes down the exact path
and the next step before the summary takes them; one that does not gets
folded mid-thought.
Two halves, because they answer different moments.
The push is a tagged fragment appended before the next sampling call, at
75% and again at 92% of the distance to the compaction trigger — the fold
is the event it can still act ahead of, so the figure counts down to that
rather than to the physical window. It rides the turn tail, so the prefix
stays byte-stable; it is edge-triggered, because a notice that re-fired
every step would spend the very window it reports on. The rungs re-arm
when the history is rebuilt.
It hangs in the run loop rather than in control.Compose. A goal turn runs
dozens of tool calls between user turns, and a notice that only fires on a
user turn would never reach the turn that needs it.
The pull is context_budget, for the decision that arrives between the two
rungs: a broad search or a large file the model cannot afford to read.
Measured at the boundary rather than estimated — ToolSchemaTokens in the
golden moves 4362 -> 4509, so the schema costs 147 tokens of prefix, and
SystemHash is unchanged, which is the evidence the base prompt was not
touched.
ContextGeneration lands here because the latch needs it. Compaction
installs a projection and never calls Session.Rewrite, so a latch keyed on
the rewrite counter alone never notices a fold.
Extracting toolCallContext is not drive-by: the one binding line pushed
prepareToolExecution past the 120-line limit, and the repo does not widen
a baseline to land a change. The extraction reclaims budget instead.
2026-08-21 05:54:56 -07:00
7283 changed files with 684489 additions and 375961 deletions
Target branch: 2.x features and fixes go to `studio`. 1.x takes bug fixes,
provider/API compatibility, release/updater, security and platform stability
only, on `main-v2`.
-->
## Summary
-
## Cause
-
## Blast radius
-
## Neighbouring behaviours tested
-
## Why this layer
-
## Issues
<!--
@@ -21,24 +43,6 @@ cancellation, reduced-motion behavior, and failure fallback. Verification must
assert that the target API was actually called; a mock that silently skips it
does not count.
## Documentation impact
## Disclosure
Documentation-impact: TODO
For changes to user-visible CLI, Desktop, configuration, provider, permission,
or tool behavior, use one of:
-`Documentation-impact: updated - <what changed>` and update `docs/*.md`.
-`Documentation-impact: none - <why the embedded documentation remains correct>`.
## Cache impact
Cache-impact: TODO
Cache-guard: TODO
System-prompt-review: N/A
For cache-sensitive changes, fill these lines before requesting review:
-`Cache-impact`: `none`, `low`, `medium`, or `high`, plus the reason.
-`Cache-guard`: the focused guard test/command added or run, or why an existing guard covers the change.
-`System-prompt-review`: required reviewer/approval note when provider-visible system prompt, memory prefix, output style, or skill index behavior changes.
- [ ] AI assistance was used for this change (optional; for transparency only, it does not change how the change is reviewed)
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
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
echo "::error::the package is $name; dpkg holds that beside the installed reasonix-studio instead of upgrading it, and update.StudioLine asks for it by name"
exit 1
fi
dpkg-deb -c "$deb" > contents.txt
# The path is the polkit action's exec.path annotation, so it is the
# only one authorization is granted for; root because polkit will not
# run a helper the invoking user could have written. Either spelling
# of root: dpkg-deb prints the numeric ids it cannot map, and which
# one it prints is a property of the machine reading the package
# rather than of the package.
if ! grep -qE '^-rwxr-xr-x (root/root|0/0) +[0-9]+ .* \./usr/lib/reasonix-studio/reasonix-studio-update-helper$' contents.txt; then
echo "::error::no root-owned executable update helper at the path the polkit action names"
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
exit 1
fi
if ! grep -q './usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
echo "::error::the polkit action is not in the package, so pkexec has nothing to authorize against"
exit 1
fi
- name:build shell
run:bash ../scripts/studio-build.sh --shell-only
# The Electron shell sets the package's glibc floor; a Go binary linked
# with cgo would raise it to the runner's own.
- name:The .deb's Go binaries are statically linked
if:runner.os == 'Linux'
run:|
deb=$(ls dist/ReasonixStudio-linux-*.deb)
dpkg-deb -x "$deb" "$RUNNER_TEMP/deb"
host=$(find "$RUNNER_TEMP/deb" -type f -path '*/resources/bin/reasonix-studio-host')
if ($run.ExitCode -eq 0) { throw 'the arm64 installer exited 0 on an x64 machine' }
if (@(Get-ChildItem -Recurse -Force $target -ErrorAction SilentlyContinue).Count -ne 0) { throw 'the arm64 installer wrote into the target on an x64 machine' }
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
</p>
<p align="center">
@@ -40,20 +44,42 @@
<br/>
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A coding agent you can leave running.</h3>
<h3 align="center">A reliable coding agent for complex software engineering tasks.</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>**
>
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
<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.
| Line | Branch | Status | Get it |
| --- | --- | --- | --- |
| **Reasonix 2.x** | `studio` (this branch) | Active development | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
- 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). |
@@ -417,6 +478,34 @@ marginal-utility line (`accuracy +X.Xpp · wall/task +Y.Ys`), and — when tasks
carry `class` labels — a per-class breakdown, so a subsystem's uplift and
latency cost can be judged per task class instead of globally.
## Pricing the run graph
Delegation makes three separate claims, and one number cannot settle them. Each
has its own arm, and all three read the same **Fan-out** section of the report —
folded from the run graph, which is the only record of a scheduling wait:
| Claim | Arm |
| --- | --- |
| Delegating at all beats one agent | `-ablate subagent` |
| A `depends_on` edge is worth its payload, not only its order | `-ablate upstream` over `benchmarks/upstream-edge` |
| Running members side by side beats running them in turn | `max_subagent_concurrency = 1` over `benchmarks/fanout-width` |
```sh
go run ./cmd/e2ebench -suite benchmarks/upstream-edge -json control.json
go run ./cmd/e2ebench -suite benchmarks/upstream-edge -ablate upstream -json ablated.json
go run ./cmd/e2ebench -mode compare control.json ablated.json
```
The section reports work against wall (the speed-up the shape bought), the
critical path (the floor the declared dependencies impose — wall above it is
scheduling loss, not shape), and slot wait (what the concurrency ceiling cost
members that had nothing left to wait for).
Read all three axes before calling an arm better: a fan-out that halves wall
clock while doubling tokens wins one and loses another. The arm passes when its
solve rate is no worse, its wall clock is strictly lower, and its tokens stay
inside the budget the comparison declares.
## SWE-bench Verified mode
`e2ebench` can also run the agent inside the official SWE-bench evaluation
@@ -453,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. |
returnos.WriteFile(path,[]byte(strings.Replace(string(old),"Body paragraph the index never renders.","A different body paragraph, longer than the one it replaces.",1)),0o644)
Prompt:"What ownership token did we settle on for the retry boundary, and under what tag did we file the reason for not moving retries above framing?",
prompt="""Answer using the fleet tool with exactly four items, and do not read any project files yourself.
Item one: id "alpha", read_only true. Prompt it to report the marker string the alpha subsystem actually uses at runtime. Ten markers are present in alpha/ and only the live module's is the answer.
Item two: id "beta", read_only true. Prompt it to report the marker string the beta subsystem actually uses at runtime, under the same rule.
Item three: id "gamma", read_only true. Prompt it to report the marker string the gamma subsystem actually uses at runtime, under the same rule.
Item four: id "report", depends_on ["alpha", "beta", "gamma"], write_paths ["ANSWER.md"]. Prompt it to write ANSWER.md in the workspace root with exactly three lines, `alpha=<marker>`, `beta=<marker>` then `gamma=<marker>`.
The first three items must not declare depends_on: nothing one of them finds decides anything another needs."""
Loaded 100 of 7283 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.